taskforce-loop-engineering 0.9.0 → 0.10.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 +12 -0
- package/README.md +20 -7
- package/lib/core.mjs +62 -13
- package/package.json +1 -1
- package/scripts/hermes-install-self-test.mjs +2 -0
- package/scripts/hermes-install.mjs +40 -25
- package/scripts/openclaw-install-self-test.mjs +25 -5
- package/scripts/openclaw-install.mjs +113 -35
- package/scripts/openclaw-manage.mjs +1 -1
- package/scripts/route-notify-self-test.mjs +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.0 - 2026-08-11
|
|
4
|
+
|
|
5
|
+
- Add OpenClaw installer language adaptation with `--language auto|en|zh`.
|
|
6
|
+
- Auto-select Chinese for Chinese locales and English for all other or missing locales; explicit language selection takes precedence.
|
|
7
|
+
- Persist the resolved language in queue configuration and installer manifests, and localize generated conversation instructions, worker prompts, progress reports, human-input gates, and terminal notifications.
|
|
8
|
+
|
|
9
|
+
## 0.9.1 - 2026-08-11
|
|
10
|
+
|
|
11
|
+
- Recognize explicit `用 loop engineering` requests that ask to align, complete, enhance, develop, or build a system as executable Loop tasks instead of direct chat.
|
|
12
|
+
- Make the generated OpenClaw conversation wrapper fail closed when channel, target, account, or message-id routing metadata is missing, preventing unscoped human-gate and terminal tasks.
|
|
13
|
+
- Document the mandatory wrapper and notification-delivery postcondition, and add regression coverage for the original Growth OS phrasing and missing-source failure.
|
|
14
|
+
|
|
3
15
|
## 0.9.0 - 2026-08-08
|
|
4
16
|
|
|
5
17
|
- 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.
|
package/README.md
CHANGED
|
@@ -33,6 +33,11 @@ loop-engineering-openclaw-install \
|
|
|
33
33
|
--queue agent-tasks
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
+
The installer accepts `--language auto|en|zh`. `auto` is the default: Chinese
|
|
37
|
+
locales install Chinese conversation rules and notifications; all other or
|
|
38
|
+
missing locales install English. Use `--language en` or `--language zh` to
|
|
39
|
+
override locale detection explicitly.
|
|
40
|
+
|
|
36
41
|
The installer reads `openclaw agents list --json` during the plan. Without
|
|
37
42
|
`--worker-agent`, it chooses an existing `main`, or the only available agent
|
|
38
43
|
when exactly one exists. If there is no unambiguous choice, it fails with the
|
|
@@ -54,12 +59,20 @@ loop-engineering-openclaw-install \
|
|
|
54
59
|
--confirm-install
|
|
55
60
|
```
|
|
56
61
|
|
|
57
|
-
|
|
62
|
+
In an English installation, conversation requests can use forms such as:
|
|
63
|
+
|
|
64
|
+
- `Use Loop Engineering to fix this issue.`
|
|
65
|
+
- `Run this through Loop Engineering.`
|
|
66
|
+
- `Queue this only; do not run it yet.`
|
|
67
|
+
- `Continue the current loop with this amendment: ...`
|
|
68
|
+
|
|
69
|
+
Chinese installations provide equivalent Chinese conversation rules and
|
|
70
|
+
examples. The generated dispatcher uses a per-task session key of
|
|
58
71
|
`agent:<worker-agent>:loop-task-<task-id>` and explicitly marks the task as
|
|
59
72
|
already loop-managed to prevent recursive re-enqueue. Existing generated files
|
|
60
73
|
are not overwritten unless `--force` is supplied after review. The installed
|
|
61
|
-
conversation policy treats
|
|
62
|
-
|
|
74
|
+
conversation policy treats an explicit Loop Engineering request as enqueue plus
|
|
75
|
+
immediate execution; explicit queue-only wording remains the override.
|
|
63
76
|
The confirmed installer also creates and enables a managed per-queue systemd
|
|
64
77
|
user timer. It wakes the adaptive scheduler once per minute; the persisted
|
|
65
78
|
scheduler cadence still decides whether work is due. Generated queue configs
|
|
@@ -381,7 +394,7 @@ questions from explicit execution handoffs:
|
|
|
381
394
|
|
|
382
395
|
```bash
|
|
383
396
|
loop-engineering route-message \
|
|
384
|
-
--message "
|
|
397
|
+
--message "Use Loop Engineering to fix this issue" \
|
|
385
398
|
--queue agent-tasks \
|
|
386
399
|
--route \
|
|
387
400
|
--confirm-execute \
|
|
@@ -888,11 +901,11 @@ loop-engineering queue-human-decision --config configs/loops/queues/agent-tasks.
|
|
|
888
901
|
`run-queue` processes one task. `run-queue-drain` is an explicit batch/daemon
|
|
889
902
|
command that keeps claiming queued tasks serially until the inbox is empty or
|
|
890
903
|
`--max-tasks` is reached. The generated OpenClaw conversation wrapper does not
|
|
891
|
-
use drain mode: a new
|
|
904
|
+
use drain mode: a new explicit Loop Engineering request while a task is active supersedes the
|
|
892
905
|
active task, records the replacement lineage, stops the old dispatcher process
|
|
893
906
|
group, and starts the corrected task after the lock is released. Explicit
|
|
894
|
-
queue-only wording still creates ordinary queued work.
|
|
895
|
-
uses the amendment path instead: it keeps the same task and worker session,
|
|
907
|
+
queue-only wording still creates ordinary queued work. `Continue the current
|
|
908
|
+
loop with this amendment: ...` uses the amendment path instead: it keeps the same task and worker session,
|
|
896
909
|
writes `amendments/NNNN.json`, increments `amendment_version` in the task
|
|
897
910
|
contract, acceptance plan, and dev plan, and requires the worker to reread the
|
|
898
911
|
latest amendment before each checkpoint and final completion. Both commands use a lease lock so overlapping ticks do not process the same
|
package/lib/core.mjs
CHANGED
|
@@ -1520,7 +1520,7 @@ export function classifyLoopMessage(message) {
|
|
|
1520
1520
|
const mentionsLoop = /(loop engineering|loop-engineering|task-runner|队列|queue|\bloop\b)/i.test(text);
|
|
1521
1521
|
const statusIntent = mentionsLoop
|
|
1522
1522
|
&& /(查|看|检查|审计|状态|情况|进度|怎么样|为什么|失败|报错|健康|health|status|progress|audit|summar)/i.test(text);
|
|
1523
|
-
const executeIntent = /(走\s*loop|继续(?:当前|这个)?\s*loop|给(?:当前|这个)?\s*loop\s*(?:补充|增加|加)|用\s*loop.*(
|
|
1523
|
+
const executeIntent = /(走\s*loop|继续(?:当前|这个)?\s*loop|给(?:当前|这个)?\s*loop\s*(?:补充|增加|加)|用\s*loop.*(?:解决|执行|完成|处理|修复|对齐|补齐|增强|开发|构建|绕过|避开|跳过|bypass|evade)|丢进.*loop|入队|enqueue|run[- ]?queue|立刻执行|立即执行|use\s+(?:loop engineering|the\s+loop)|run\s+(?:this|it|the\s+task).*(?:through|with)\s+(?:loop engineering|the\s+loop)|continue\s+(?:the\s+)?(?:current\s+)?loop|amend\s+(?:the\s+)?(?:current\s+)?loop)/i.test(text);
|
|
1524
1524
|
const intent = executeIntent ? 'execute' : statusIntent ? 'status' : 'direct';
|
|
1525
1525
|
return {
|
|
1526
1526
|
intent,
|
|
@@ -1692,9 +1692,32 @@ export async function routeLoopMessage(root, options = {}) {
|
|
|
1692
1692
|
};
|
|
1693
1693
|
}
|
|
1694
1694
|
|
|
1695
|
-
function
|
|
1695
|
+
function normalizeLanguage(value) {
|
|
1696
|
+
return value === 'zh' ? 'zh' : 'en';
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
async function installedQueueLanguage(root, queue, fallback = 'en') {
|
|
1700
|
+
const file = path.join(root, 'configs', 'loops', 'queues', `${queue}.json`);
|
|
1701
|
+
if (!await exists(file)) return normalizeLanguage(fallback);
|
|
1702
|
+
try { return normalizeLanguage((await readJson(file)).language); } catch { return normalizeLanguage(fallback); }
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
function terminalNotificationMessage(queue, task, language = 'en') {
|
|
1696
1706
|
const needsReview = task.status === 'ready_for_human_review';
|
|
1697
1707
|
const needsHuman = ['needs_human_input', 'blocked', 'ready_for_human_review'].includes(task.status);
|
|
1708
|
+
if (language === 'zh') return [
|
|
1709
|
+
needsReview ? 'Loop 任务已准备好接受人工验收'
|
|
1710
|
+
: needsHuman ? 'Loop 任务需要人工输入'
|
|
1711
|
+
: 'Loop 任务已到达终态',
|
|
1712
|
+
`任务:${task.title}`,
|
|
1713
|
+
`队列:${queue}`,
|
|
1714
|
+
`状态:${task.status}`,
|
|
1715
|
+
...(needsReview
|
|
1716
|
+
? [`下一步:检查最终判定,并为任务 ${task.id} 记录 approve、request_changes 或 reject。`]
|
|
1717
|
+
: needsHuman
|
|
1718
|
+
? ['下一步:检查任务的最终判定和检查点,解决阻塞,然后明确继续或重新入队。']
|
|
1719
|
+
: [])
|
|
1720
|
+
].join('\n');
|
|
1698
1721
|
return [
|
|
1699
1722
|
needsReview ? 'Loop task is ready for human acceptance'
|
|
1700
1723
|
: needsHuman ? 'Loop task needs human input'
|
|
@@ -1712,6 +1735,7 @@ function terminalNotificationMessage(queue, task) {
|
|
|
1712
1735
|
|
|
1713
1736
|
export async function notifyTerminalTasks(root, options = {}) {
|
|
1714
1737
|
const queue = normalizeLoopId(options.queue);
|
|
1738
|
+
const language = await installedQueueLanguage(root, queue, options.language);
|
|
1715
1739
|
if (!options.notifyCommand && !options.dryRun) {
|
|
1716
1740
|
throw new Error('queue-terminal-notify requires --notify-command unless --dry-run is used.');
|
|
1717
1741
|
}
|
|
@@ -1739,7 +1763,7 @@ export async function notifyTerminalTasks(root, options = {}) {
|
|
|
1739
1763
|
results.push({ taskId: task.id, status: task.status, outcome: 'already_notified', ledger: path.relative(root, ledgerFile) });
|
|
1740
1764
|
continue;
|
|
1741
1765
|
}
|
|
1742
|
-
const message = terminalNotificationMessage(queue, task);
|
|
1766
|
+
const message = terminalNotificationMessage(queue, task, language);
|
|
1743
1767
|
if (options.dryRun) {
|
|
1744
1768
|
results.push({ taskId: task.id, status: task.status, outcome: 'dry_run', message, source: task.source });
|
|
1745
1769
|
continue;
|
|
@@ -1783,11 +1807,19 @@ export async function notifyTerminalTasks(root, options = {}) {
|
|
|
1783
1807
|
};
|
|
1784
1808
|
}
|
|
1785
1809
|
|
|
1786
|
-
function humanInputMessage(queue, task, checkpoint, gateId) {
|
|
1810
|
+
function humanInputMessage(queue, task, checkpoint, gateId, language = 'en') {
|
|
1787
1811
|
const blockers = Array.isArray(checkpoint.blockers) ? checkpoint.blockers : [];
|
|
1788
1812
|
const blockerText = blockers.length
|
|
1789
1813
|
? blockers.map((item) => typeof item === 'string' ? item : item?.human_action_required ?? item?.user_action ?? item?.description ?? item?.message ?? JSON.stringify(item))
|
|
1790
1814
|
: [checkpoint.next_action ?? 'Human input is required before the task can continue.'];
|
|
1815
|
+
if (language === 'zh') return [
|
|
1816
|
+
'Loop 任务正在等待你的输入',
|
|
1817
|
+
`任务:${task.title}`,
|
|
1818
|
+
`队列:${queue}`,
|
|
1819
|
+
`门禁:${gateId}`,
|
|
1820
|
+
...blockerText.map((item) => `需要:${item}`),
|
|
1821
|
+
`回复:LOOP ${gateId} <你的输入>`
|
|
1822
|
+
].join('\n');
|
|
1791
1823
|
return [
|
|
1792
1824
|
'Loop task is waiting for your input',
|
|
1793
1825
|
`task: ${task.title}`,
|
|
@@ -1811,6 +1843,7 @@ async function tasksById(root, queue) {
|
|
|
1811
1843
|
|
|
1812
1844
|
export async function notifyHumanInputRequests(root, options = {}) {
|
|
1813
1845
|
const queue = normalizeLoopId(options.queue);
|
|
1846
|
+
const language = await installedQueueLanguage(root, queue, options.language);
|
|
1814
1847
|
if (!options.notifyCommand && !options.dryRun) {
|
|
1815
1848
|
throw new Error('queue-human-input-notify requires --notify-command unless --dry-run is used.');
|
|
1816
1849
|
}
|
|
@@ -1857,7 +1890,7 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
1857
1890
|
results.push({ taskId, checkpointId, gateId, outcome: gate.status === 'resolved' ? 'resolved' : 'already_notified', ledger: path.relative(root, ledgerFile) });
|
|
1858
1891
|
continue;
|
|
1859
1892
|
}
|
|
1860
|
-
const message = humanInputMessage(queue, entry.task, checkpoint, gateId);
|
|
1893
|
+
const message = humanInputMessage(queue, entry.task, checkpoint, gateId, language);
|
|
1861
1894
|
if (options.dryRun) {
|
|
1862
1895
|
results.push({ taskId, checkpointId, gateId, outcome: 'dry_run', message, source: entry.task.source });
|
|
1863
1896
|
continue;
|
|
@@ -3458,20 +3491,32 @@ function computeQueueSchedulerInterval(previous, policy, observed) {
|
|
|
3458
3491
|
};
|
|
3459
3492
|
}
|
|
3460
3493
|
|
|
3461
|
-
function summarizeQueueCounts(status) {
|
|
3462
|
-
return
|
|
3494
|
+
function summarizeQueueCounts(status, language = 'en') {
|
|
3495
|
+
return language === 'zh'
|
|
3496
|
+
? `排队=${status.queued},执行中=${status.active},失败=${status.failed},完成=${status.done}`
|
|
3497
|
+
: `queued=${status.queued}, active=${status.active}, failed=${status.failed}, done=${status.done}`;
|
|
3463
3498
|
}
|
|
3464
3499
|
|
|
3465
3500
|
function buildQueueProgressMessage(report) {
|
|
3501
|
+
const language = normalizeLanguage(report.language);
|
|
3466
3502
|
const lines = [];
|
|
3467
3503
|
const run = report.observed.runPath ? ` run=${report.observed.runPath}` : '';
|
|
3468
3504
|
const task = report.observed.taskId ? ` task=${report.observed.taskId}` : '';
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3505
|
+
if (language === 'zh') {
|
|
3506
|
+
lines.push(`Loop 进度:${report.queue} ${report.status}${task}${run}`);
|
|
3507
|
+
lines.push(`结果:${report.outcomeGroup};下次运行 ${report.nextRunAt};间隔 ${report.currentIntervalMs}ms`);
|
|
3508
|
+
lines.push(`之前:${summarizeQueueCounts(report.statusBefore, language)}`);
|
|
3509
|
+
lines.push(`之后:${summarizeQueueCounts(report.statusAfter, language)}`);
|
|
3510
|
+
if (report.reasonSummary) lines.push(`原因:${report.reasonSummary}`);
|
|
3511
|
+
if (report.attention.length > 0) lines.push(`需要关注:${report.attention.join(', ')}`);
|
|
3512
|
+
} else {
|
|
3513
|
+
lines.push(`Loop progress: ${report.queue} ${report.status}${task}${run}`);
|
|
3514
|
+
lines.push(`Outcome: ${report.outcomeGroup}; next run ${report.nextRunAt}; interval ${report.currentIntervalMs}ms`);
|
|
3515
|
+
lines.push(`Before: ${summarizeQueueCounts(report.statusBefore)}`);
|
|
3516
|
+
lines.push(`After: ${summarizeQueueCounts(report.statusAfter)}`);
|
|
3517
|
+
if (report.reasonSummary) lines.push(`Reason: ${report.reasonSummary}`);
|
|
3518
|
+
if (report.attention.length > 0) lines.push(`Needs attention: ${report.attention.join(', ')}`);
|
|
3519
|
+
}
|
|
3475
3520
|
return lines.join('\n');
|
|
3476
3521
|
}
|
|
3477
3522
|
|
|
@@ -3566,6 +3611,7 @@ export async function queueSchedulerTick(root, options) {
|
|
|
3566
3611
|
const progressReport = {
|
|
3567
3612
|
version: 1,
|
|
3568
3613
|
queue,
|
|
3614
|
+
language: normalizeLanguage(options.language),
|
|
3569
3615
|
generatedAt: now,
|
|
3570
3616
|
status,
|
|
3571
3617
|
outcomeGroup: decision.group,
|
|
@@ -3645,6 +3691,9 @@ export async function loadQueueConfig(root, configPath) {
|
|
|
3645
3691
|
const file = path.resolve(root, safeRelativePath(configPath, 'queue config'));
|
|
3646
3692
|
const config = await readJson(file);
|
|
3647
3693
|
if (config.queue !== undefined) normalizeLoopId(config.queue);
|
|
3694
|
+
if (config.language !== undefined && !['en', 'zh'].includes(config.language)) {
|
|
3695
|
+
throw new Error('queue config language must be en or zh.');
|
|
3696
|
+
}
|
|
3648
3697
|
if (config.preflightConfig !== undefined) safeRelativePath(config.preflightConfig, 'preflight config');
|
|
3649
3698
|
if (config.timeoutMs !== undefined && !positiveInteger(config.timeoutMs)) {
|
|
3650
3699
|
throw new Error('queue config timeoutMs must be a positive integer.');
|
package/package.json
CHANGED
|
@@ -31,6 +31,8 @@ const plan = await run(process.execPath, [installer, ...base, '--json']); const
|
|
|
31
31
|
const humanPlan = await run(process.execPath, [installer, ...base]); if (humanPlan.code !== 0 || !humanPlan.stdout.includes('Installation confirmation') || !humanPlan.stdout.includes('target platform: Hermes') || !humanPlan.stdout.includes('writes enabled: no (plan only)')) throw new Error('human-readable Hermes confirmation summary missing');
|
|
32
32
|
const install = await run(process.execPath, [installer, ...base, '--confirm-install', '--json']); if (install.code !== 0 || JSON.parse(install.stdout).status !== 'installed') throw new Error(`Hermes install failed: ${install.stderr}`);
|
|
33
33
|
const queue = JSON.parse(await readFile(path.join(root, 'configs/loops/queues/hermes-tasks.json'), 'utf8')); if (queue.dispatcher !== 'node scripts/loops/hermes-loop-dispatch.mjs' || queue.scheduler?.required !== true) throw new Error('Hermes queue wiring missing');
|
|
34
|
+
const instructions = await readFile(path.join(root, 'AGENTS.md'), 'utf8'); if (!instructions.includes('Use Loop Engineering to fix this issue') || !instructions.includes('Queue this only; do not run it yet') || /走 loop|只入队|只排队/.test(instructions)) throw new Error('Hermes English conversation instructions are missing or contain Chinese routing examples');
|
|
35
|
+
const wrapper = await readFile(path.join(root, 'scripts/loops/hermes-loop.mjs'), 'utf8'); if (!wrapper.includes('queue') || !wrapper.includes('continue') || /只入队|只排队|继续当前/.test(wrapper)) throw new Error('Hermes English wrapper routing was not fully localized');
|
|
34
36
|
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
37
|
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
38
|
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');
|
|
@@ -5,13 +5,14 @@ import { spawn } from 'node:child_process';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
7
|
function parseArgs(argv) {
|
|
8
|
-
const out = { root: process.cwd(), queue: 'agent-tasks', hermesBin: 'hermes', systemctlBin: 'systemctl', json: false, confirmInstall: false, force: false };
|
|
8
|
+
const out = { root: process.cwd(), queue: 'agent-tasks', hermesBin: 'hermes', systemctlBin: 'systemctl', language: 'auto', json: false, confirmInstall: false, force: false };
|
|
9
9
|
for (let i = 0; i < argv.length; i++) {
|
|
10
10
|
const arg = argv[i];
|
|
11
11
|
if (arg === '--root') out.root = path.resolve(argv[++i]);
|
|
12
12
|
else if (arg === '--queue') out.queue = argv[++i];
|
|
13
13
|
else if (arg === '--hermes-bin') out.hermesBin = argv[++i];
|
|
14
14
|
else if (arg === '--systemctl-bin') out.systemctlBin = argv[++i];
|
|
15
|
+
else if (arg === '--language') out.language = argv[++i];
|
|
15
16
|
else if (arg === '--confirm-install') out.confirmInstall = true;
|
|
16
17
|
else if (arg === '--force') out.force = true;
|
|
17
18
|
else if (arg === '--json') out.json = true;
|
|
@@ -21,6 +22,13 @@ function parseArgs(argv) {
|
|
|
21
22
|
return out;
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
function resolveLanguage(requested = 'auto', env = process.env) {
|
|
26
|
+
if (!['auto', 'en', 'zh'].includes(requested)) throw new Error('--language must be auto, en, or zh.');
|
|
27
|
+
if (requested !== 'auto') return requested;
|
|
28
|
+
return String(env.LC_ALL || env.LC_MESSAGES || env.LANG || '').toLowerCase().startsWith('zh') ? 'zh' : 'en';
|
|
29
|
+
}
|
|
30
|
+
const text = (language, en, zh) => language === 'zh' ? zh : en;
|
|
31
|
+
|
|
24
32
|
function safeId(value, label) { if (!/^[a-zA-Z0-9._-]+$/.test(value)) throw new Error(`${label} contains unsupported characters.`); return value; }
|
|
25
33
|
function systemdEscapePath(value) {
|
|
26
34
|
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('');
|
|
@@ -44,7 +52,8 @@ function run(command, args, options = {}) {
|
|
|
44
52
|
});
|
|
45
53
|
}
|
|
46
54
|
|
|
47
|
-
function formatConfirmationSummary(summary) {
|
|
55
|
+
function formatConfirmationSummary(summary, language) {
|
|
56
|
+
if (language === 'zh') return ['安装确认', ` 目标平台:${summary.targetPlatform}`, ` 平台 CLI:${summary.platformCli}`, ` 工作区:${summary.workspace}`, ` 队列:${summary.queue}`, ` 调度器:${summary.scheduler}`, ` 通知目标:${summary.notificationTarget}`, ` 允许写入:${summary.writesEnabled ? '是' : '否(仅生成计划)'}`].join('\n');
|
|
48
57
|
return [
|
|
49
58
|
'Installation confirmation',
|
|
50
59
|
` target platform: ${summary.targetPlatform}`,
|
|
@@ -57,15 +66,15 @@ function formatConfirmationSummary(summary) {
|
|
|
57
66
|
].join('\n');
|
|
58
67
|
}
|
|
59
68
|
|
|
60
|
-
function dispatcherSource({ hermesBin }) {
|
|
69
|
+
function dispatcherSource({ hermesBin, language }) {
|
|
70
|
+
const intro = language === 'zh' ? ['你收到的是一个已经由 Loop Engineering 管理的任务。', '不要再次路由或入队。', '实施前阅读任务合同、开发计划、验收计划和实时补充要求。'] : ['You are receiving an already loop-managed task.', 'Do not route or enqueue this task again.', 'Read the task contract, development plan, acceptance plan, and live amendments before implementation.'];
|
|
71
|
+
const finish = text(language, '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.', '每次写检查点和最终完成前都重新读取实时补充要求文件。尽可能写检查点,并以状态、证据、验证、阻塞和下一步结束。');
|
|
61
72
|
return `#!/usr/bin/env node
|
|
62
73
|
import { readFile } from 'node:fs/promises';
|
|
63
74
|
import { spawn } from 'node:child_process';
|
|
64
75
|
const task = JSON.parse(await readFile(process.env.LOOP_TASK_FILE, 'utf8'));
|
|
65
76
|
const prompt = [
|
|
66
|
-
|
|
67
|
-
'Do not route or enqueue this task again.',
|
|
68
|
-
'Read the task contract, development plan, acceptance plan, and live amendments before implementation.',
|
|
77
|
+
${intro.map((line) => JSON.stringify(line)).join(',\n ')},
|
|
69
78
|
\`Task id: \${task.id}\`,
|
|
70
79
|
\`Task contract: \${process.env.LOOP_TASK_CONTRACT_FILE || 'not provided'}\`,
|
|
71
80
|
\`Development plan: \${process.env.LOOP_DEV_PLAN_FILE || 'not provided'}\`,
|
|
@@ -73,14 +82,16 @@ const prompt = [
|
|
|
73
82
|
\`Live amendments: \${process.env.LOOP_LATEST_AMENDMENT_FILE || 'not provided'}\`,
|
|
74
83
|
\`Checkpoints dir: \${process.env.LOOP_CHECKPOINTS_DIR || 'not provided'}\`,
|
|
75
84
|
'', task.body, '',
|
|
76
|
-
|
|
85
|
+
${JSON.stringify(finish)}
|
|
77
86
|
].join('\\n');
|
|
78
87
|
const child = spawn(${JSON.stringify(hermesBin)}, ['-z', prompt], { cwd: process.cwd(), env: process.env, stdio: 'inherit' });
|
|
79
88
|
child.on('close', (code, signal) => { process.exitCode = code ?? (signal ? 128 : 1); });
|
|
80
89
|
`;
|
|
81
90
|
}
|
|
82
91
|
|
|
83
|
-
function wrapperSource({ queue, loopBin }) {
|
|
92
|
+
function wrapperSource({ queue, loopBin, language }) {
|
|
93
|
+
const amendmentPattern = language === 'zh' ? '(?:继续(?:当前|这个)?\\s*loop|给(?:当前|这个)?\\s*loop\\s*(?:补充|增加|加)|补充当前\\s*loop)' : '(?:continue\\s+(?:the\\s+)?(?:current\\s+)?loop|amend\\s+(?:the\\s+)?(?:current\\s+)?loop|add\\s+(?:this\\s+)?amendment\\s+to\\s+(?:the\\s+)?(?:current\\s+)?loop)';
|
|
94
|
+
const queueOnlyPattern = language === 'zh' ? '(?:只入队|只排队|暂不执行|不立即执行)' : '(?:queue\\s+(?:this|it)\\s+only|only\\s+queue\\s+(?:this|it)|enqueue\\s+(?:this|it)\\s+only|do\\s+not\\s+(?:run|execute)\\s+(?:this|it)\\s+(?:yet|now))';
|
|
84
95
|
return `#!/usr/bin/env node
|
|
85
96
|
import { spawn } from 'node:child_process';
|
|
86
97
|
const [command, ...rest] = process.argv.slice(2);
|
|
@@ -89,9 +100,9 @@ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
89
100
|
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
101
|
if (command === 'route') {
|
|
91
102
|
const messageIndex = rest.indexOf('--message'); const message = messageIndex >= 0 ? String(rest[messageIndex + 1] || '') : '';
|
|
92
|
-
const amendment =
|
|
103
|
+
const amendment = new RegExp(${JSON.stringify(amendmentPattern)}, 'i').test(message);
|
|
93
104
|
const routeCode = await run(['route-message', '--queue', ${JSON.stringify(queue)}, '--route', '--confirm-execute', amendment ? '--amend-active' : '--supersede-active', ...rest]);
|
|
94
|
-
const queueOnly =
|
|
105
|
+
const queueOnly = new RegExp(${JSON.stringify(queueOnlyPattern)}, 'i').test(message);
|
|
95
106
|
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
107
|
const humanCode = await run(['queue-human-input-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/hermes-loop-notify.mjs']);
|
|
97
108
|
const terminalCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/hermes-loop-notify.mjs']);
|
|
@@ -106,18 +117,18 @@ if (command === 'route') {
|
|
|
106
117
|
const humanCode = await run(['queue-human-input-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/hermes-loop-notify.mjs']);
|
|
107
118
|
const terminalCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/hermes-loop-notify.mjs']);
|
|
108
119
|
process.exitCode = tickCode || humanCode || terminalCode;
|
|
109
|
-
} else { console.error('Usage: node scripts/loops/hermes-loop.mjs route|run-once|scheduler-tick'); process.exitCode = 1; }
|
|
120
|
+
} else { console.error(${JSON.stringify(text(language, 'Usage: node scripts/loops/hermes-loop.mjs route|run-once|scheduler-tick', '用法:node scripts/loops/hermes-loop.mjs route|run-once|scheduler-tick'))}); process.exitCode = 1; }
|
|
110
121
|
`;
|
|
111
122
|
}
|
|
112
123
|
|
|
113
|
-
function notifierSource({ hermesBin }) {
|
|
124
|
+
function notifierSource({ hermesBin, language }) {
|
|
114
125
|
return `#!/usr/bin/env node
|
|
115
126
|
import { spawn } from 'node:child_process';
|
|
116
127
|
const message = process.argv.slice(2).join(' ').trim();
|
|
117
128
|
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); }
|
|
129
|
+
if (!message) { console.error(${JSON.stringify(text(language, 'loop notifier requires a message', 'loop 通知器需要消息参数'))}); process.exit(2); }
|
|
130
|
+
let source; try { source = JSON.parse(rawSource); } catch { console.error(${JSON.stringify(text(language, 'loop notifier received invalid source metadata', 'loop 通知器收到无效的来源元数据'))}); process.exit(2); }
|
|
131
|
+
if (!source || typeof source !== 'object' || !source.target) { console.error(${JSON.stringify(text(language, 'Hermes loop notifier requires source.target in platform[:chat[:thread]] form', 'Hermes loop 通知器要求 source.target 使用 platform[:chat[:thread]] 格式'))}); process.exit(2); }
|
|
121
132
|
const args = ['send', '--to', String(source.target), '--quiet', message];
|
|
122
133
|
if (process.env.LOOP_NOTIFICATION_DRY_RUN === '1') { console.log(JSON.stringify({ dryRun: true, command: ${JSON.stringify(hermesBin)}, args })); }
|
|
123
134
|
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); }); }
|
|
@@ -126,11 +137,15 @@ else { const child = spawn(${JSON.stringify(hermesBin)}, args, { cwd: process.cw
|
|
|
126
137
|
|
|
127
138
|
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
139
|
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 }) {
|
|
140
|
+
function instructionsBlock({ queue, language }) {
|
|
141
|
+
if (language === 'zh') return `\n<!-- loop-engineering:hermes:start -->\n## Loop Engineering 会话路由(Hermes)\n\n- 仅路由明确的 Loop Engineering 请求。已管理的任务不能再次路由。\n- 运行 \`node scripts/loops/hermes-loop.mjs route --message "<完整用户消息>"\` 并传入来源 channel 和 target,使通知返回原始 Hermes 会话。\n- \`走 loop\` 立即执行;只有 \`只入队\` 或 \`只排队\` 才不立即执行。高风险动作仍需单独门禁。\n- 队列:\`${queue}\`。\n<!-- loop-engineering:hermes:end -->\n`;
|
|
142
|
+
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- \`Use Loop Engineering to fix this issue\` and \`Run this through Loop Engineering\` execute immediately. \`Queue this only; do not run it yet\` suppresses execution. \`Continue the current loop with this amendment: ...\` amends the active task.\n- External, destructive, production, credential, or memory migration actions remain separately gated.\n- Queue: \`${queue}\`.\n<!-- loop-engineering:hermes:end -->\n`;
|
|
143
|
+
}
|
|
130
144
|
|
|
131
145
|
async function main() {
|
|
132
146
|
const args = parseArgs(process.argv.slice(2));
|
|
133
|
-
|
|
147
|
+
args.language = resolveLanguage(args.language);
|
|
148
|
+
if (args.help) { console.log('Usage: loop-engineering-hermes-install [--root workspace] [--queue agent-tasks] [--language auto|en|zh] [--hermes-bin hermes] [--systemctl-bin systemctl] [--confirm-install] [--force] [--json]'); return; }
|
|
134
149
|
safeId(args.queue, 'queue');
|
|
135
150
|
args.hermesBin = await resolveExecutable(args.hermesBin);
|
|
136
151
|
const hermes = await run(args.hermesBin, ['--version'], { cwd: args.root });
|
|
@@ -140,22 +155,22 @@ async function main() {
|
|
|
140
155
|
const unit = `hermes-loop-${args.queue}-scheduler.service`; const timer = `hermes-loop-${args.queue}-scheduler.timer`;
|
|
141
156
|
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
157
|
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 };
|
|
158
|
+
const confirmationSummary = { targetPlatform: 'Hermes', platformCli: args.hermesBin, workspace: args.root, queue: args.queue, scheduler: `systemd user timer ${timer}`, notificationTarget: text(args.language, 'source-bound at runtime (original Hermes conversation)', '运行时绑定到原始 Hermes 会话'), writesEnabled: args.confirmInstall };
|
|
159
|
+
const report = { version: 1, platform: 'hermes', language: args.language, status: args.confirmInstall ? 'installed' : 'plan_only', readOnly: !args.confirmInstall, root: args.root, queue: args.queue, hermesBin: args.hermesBin, hermesVersion: (hermes.stdout || hermes.stderr).trim().slice(0, 200), scheduler: { required: true, unit, timer }, confirmationSummary, files: Object.fromEntries(Object.entries(files).map(([key, file]) => [key, path.relative(args.root, file)])), conflicts };
|
|
145
160
|
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));
|
|
161
|
+
if (!args.json) console.log(formatConfirmationSummary(confirmationSummary, args.language));
|
|
147
162
|
if (args.confirmInstall) {
|
|
148
163
|
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
164
|
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 }) };
|
|
165
|
+
const queueContent = `${JSON.stringify({ queue: args.queue, language: args.language, description: text(args.language, 'Hermes conversation queue.', 'Hermes 会话队列。'), 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`;
|
|
166
|
+
const contents = { queueConfig: queueContent, dispatcher: dispatcherSource(args), wrapper: wrapperSource({ queue: args.queue, loopBin, language: args.language }), notifier: notifierSource(args), schedulerService: schedulerServiceSource({ root: args.root, queue: args.queue }), schedulerTimer: schedulerTimerSource({ queue: args.queue }) };
|
|
152
167
|
await mkdir(systemdUserDir, { recursive: true });
|
|
153
168
|
for (const [kind, content] of Object.entries(contents)) await writeFile(files[kind], content);
|
|
154
169
|
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
170
|
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:
|
|
171
|
+
const marker = instructionsBlock({ queue: args.queue, language: args.language }); const current = await readFile(files.instructions, 'utf8').catch(() => ''); if (!current.includes('<!-- loop-engineering:hermes:start -->')) await appendFile(files.instructions, marker);
|
|
172
|
+
await mkdir(path.dirname(files.manifest), { recursive: true }); await writeFile(files.manifest, `${JSON.stringify({ version: 2, platform: 'hermes', language: args.language, installedAt: new Date().toISOString(), root: args.root, queue: args.queue, hermesBin: args.hermesBin, files: Object.entries(contents).map(([kind, content]) => ({ kind, path: files[kind], sha256: sha256(content) })), scheduler: { unit, timer } }, null, 2)}\n`);
|
|
158
173
|
}
|
|
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.'}`);
|
|
174
|
+
console.log(args.json ? JSON.stringify(report, null, 2) : text(args.language, `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.'}`, `Hermes Loop 安装器:${report.status}\n下一步:${args.confirmInstall ? '运行 loop-engineering-hermes-doctor,然后路由一个无害的冒烟任务。' : '检查此摘要,然后使用 --confirm-install 重新运行。'}`));
|
|
160
175
|
}
|
|
161
176
|
main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
|
|
@@ -34,9 +34,9 @@ if (process.env.SYSTEMCTL_CAPTURE) await appendFile(process.env.SYSTEMCTL_CAPTUR
|
|
|
34
34
|
`);
|
|
35
35
|
await chmod(mockSystemctl, 0o755);
|
|
36
36
|
const installer = new URL('./openclaw-install.mjs', import.meta.url).pathname;
|
|
37
|
-
function run(args) {
|
|
37
|
+
function run(args, env = process.env) {
|
|
38
38
|
return new Promise((resolve) => {
|
|
39
|
-
const child = spawn(process.execPath, [installer, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
39
|
+
const child = spawn(process.execPath, [installer, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
40
40
|
let stdout = ''; let stderr = '';
|
|
41
41
|
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
42
42
|
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
@@ -46,7 +46,13 @@ 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.platform !== 'openclaw' || planReport.workerAgent !== 'builder' || planReport.workerSelection !== 'only_available' || !planReport.workerValidated || planReport.createsWorkerAgent || planReport.confirmationSummary?.targetPlatform !== 'OpenClaw' || planReport.confirmationSummary?.writesEnabled !== false || !path.isAbsolute(planReport.confirmationSummary?.platformCli || '') || !planReport.confirmationSummary?.notificationTarget.includes('OpenClaw')) throw new Error(`plan failed: ${plan.stderr}`);
|
|
49
|
+
if (plan.code !== 0 || planReport.language !== 'en' || planReport.status !== 'plan_only' || planReport.platform !== 'openclaw' || planReport.workerAgent !== 'builder' || planReport.workerSelection !== 'only_available' || !planReport.workerValidated || planReport.createsWorkerAgent || planReport.confirmationSummary?.targetPlatform !== 'OpenClaw' || planReport.confirmationSummary?.writesEnabled !== false || !path.isAbsolute(planReport.confirmationSummary?.platformCli || '') || !planReport.confirmationSummary?.notificationTarget.includes('OpenClaw')) throw new Error(`plan failed: ${plan.stderr}`);
|
|
50
|
+
const zhPlan = await run([...installBase, '--language', 'zh']);
|
|
51
|
+
if (zhPlan.code !== 0 || !zhPlan.stdout.includes('安装确认') || !zhPlan.stdout.includes('目标平台:OpenClaw') || !zhPlan.stdout.includes('允许写入:否(仅生成计划)')) throw new Error('explicit Chinese installation summary missing');
|
|
52
|
+
const autoZhPlan = await run([...installBase, '--json'], { ...process.env, LC_ALL: 'zh_CN.UTF-8', LANG: 'C' });
|
|
53
|
+
if (autoZhPlan.code !== 0 || JSON.parse(autoZhPlan.stdout).language !== 'zh') throw new Error('Chinese locale was not auto-detected');
|
|
54
|
+
const explicitEnglishPlan = await run([...installBase, '--language', 'en', '--json'], { ...process.env, LC_ALL: 'zh_CN.UTF-8' });
|
|
55
|
+
if (explicitEnglishPlan.code !== 0 || JSON.parse(explicitEnglishPlan.stdout).language !== 'en') throw new Error('explicit English did not override locale');
|
|
50
56
|
const humanPlan = await run(installBase);
|
|
51
57
|
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');
|
|
52
58
|
const missingWorker = await run([...installBase, '--worker-agent', 'missing', '--json']);
|
|
@@ -55,14 +61,21 @@ const install = await run([...installBase, '--worker-agent', 'builder', '--confi
|
|
|
55
61
|
if (install.code !== 0 || JSON.parse(install.stdout).status !== 'installed') throw new Error(`install failed: ${install.stderr}`);
|
|
56
62
|
const queue = JSON.parse(await readFile(path.join(root, 'configs/loops/queues/test-tasks.json'), 'utf8'));
|
|
57
63
|
if (queue.dispatcher !== 'node scripts/loops/openclaw-loop-dispatch.mjs') throw new Error('dispatcher was not installed');
|
|
64
|
+
if (queue.language !== 'en') throw new Error('resolved installation language was not persisted');
|
|
58
65
|
if (queue.scheduler?.required !== true || queue.scheduler?.heartbeatMaxAgeMs !== 300000) throw new Error('required scheduler heartbeat was not installed');
|
|
59
66
|
const dispatcher = await readFile(path.join(root, 'scripts/loops/openclaw-loop-dispatch.mjs'), 'utf8');
|
|
60
67
|
if (!dispatcher.includes('already loop-managed') || !dispatcher.includes("'--agent', \"builder\"") || !dispatcher.includes('LOOP_LATEST_AMENDMENT_FILE') || !dispatcher.includes('LOOP_SESSION_GENERATION') || !dispatcher.includes('-g${sessionGeneration}')) throw new Error('worker, recursion guard, amendment polling, or session generation missing');
|
|
61
68
|
if (queue.retry?.runtimeRecoveryMaxAttempts !== 2 || queue.retry?.sessionMaxTicks !== 10) throw new Error('bounded runtime recovery policy was not installed');
|
|
62
69
|
const instructions = await readFile(path.join(root, 'AGENTS.md'), 'utf8');
|
|
63
|
-
if (!instructions.includes('
|
|
70
|
+
if (!instructions.includes('Use Loop Engineering to fix this issue') || !instructions.includes('Queue this only; do not run it yet') || /走 loop|只入队|只排队/.test(instructions)) throw new Error('English conversation instructions are missing or contain Chinese routing examples');
|
|
64
71
|
const wrapper = await readFile(path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'utf8');
|
|
65
|
-
if (!wrapper.includes('--supersede-active') || !wrapper.includes('--amend-active') || !wrapper.includes('--progress-notify-command') || !wrapper.includes('runWhenUnlocked') || wrapper.includes("run-queue-drain', '--config'") || wrapper.includes("spawn('loop-engineering'") || !wrapper.includes('queue-human-input-notify') || !wrapper.includes('queue-terminal-notify') || !wrapper.includes('queue-scheduler-tick') || !wrapper.includes('
|
|
72
|
+
if (!wrapper.includes('--supersede-active') || !wrapper.includes('--amend-active') || !wrapper.includes('--progress-notify-command') || !wrapper.includes('runWhenUnlocked') || wrapper.includes("run-queue-drain', '--config'") || wrapper.includes("spawn('loop-engineering'") || !wrapper.includes('queue-human-input-notify') || !wrapper.includes('queue-terminal-notify') || !wrapper.includes('queue-scheduler-tick') || !wrapper.includes('queue') || !wrapper.includes('continue') || /只入队|只排队|继续当前/.test(wrapper) || !wrapper.includes('requiredSource') || !wrapper.includes('--source-message-id')) throw new Error('supersede/amend routing, source fail-closed policy, localization, absolute CLI, scheduler, live progress, async notification, or queue-only routing missing');
|
|
73
|
+
const missingSourceRoute = await new Promise((resolve) => {
|
|
74
|
+
const child = spawn(process.execPath, [path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'route', '--message', '用 loop engineering 对齐系统'], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
75
|
+
let stderr = ''; child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
76
|
+
child.on('close', (code) => resolve({ code, stderr }));
|
|
77
|
+
});
|
|
78
|
+
if (missingSourceRoute.code !== 2 || !missingSourceRoute.stderr.includes('requires conversation metadata')) throw new Error('installed wrapper did not fail closed without source routing');
|
|
66
79
|
const serviceFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/openclaw-loop-test-tasks-scheduler.service');
|
|
67
80
|
const timerFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/openclaw-loop-test-tasks-scheduler.timer');
|
|
68
81
|
const service = await readFile(serviceFile, 'utf8');
|
|
@@ -166,4 +179,11 @@ if (await readFile(path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'utf8').t
|
|
|
166
179
|
if (await readFile(serviceFile, 'utf8').then(() => true).catch(() => false) || await readFile(timerFile, 'utf8').then(() => true).catch(() => false)) throw new Error('managed scheduler units survived uninstall');
|
|
167
180
|
const finalSystemctlCalls = await readFile(systemctlCapture, 'utf8');
|
|
168
181
|
if (!finalSystemctlCalls.includes('["--user","disable","--now","openclaw-loop-test-tasks-scheduler.timer"]')) throw new Error('scheduler timer was not disabled during uninstall');
|
|
182
|
+
const zhInstall = await run([...installBase, '--worker-agent', 'builder', '--language', 'zh', '--confirm-install', '--json']);
|
|
183
|
+
if (zhInstall.code !== 0 || JSON.parse(zhInstall.stdout).language !== 'zh') throw new Error(`Chinese installation failed: ${zhInstall.stderr}`);
|
|
184
|
+
const zhQueue = JSON.parse(await readFile(path.join(root, 'configs/loops/queues/test-tasks.json'), 'utf8'));
|
|
185
|
+
const zhInstructions = await readFile(path.join(root, 'AGENTS.md'), 'utf8');
|
|
186
|
+
const zhDispatcher = await readFile(path.join(root, 'scripts/loops/openclaw-loop-dispatch.mjs'), 'utf8');
|
|
187
|
+
const zhNotifier = await readFile(path.join(root, 'scripts/loops/openclaw-loop-notify.mjs'), 'utf8');
|
|
188
|
+
if (zhQueue.language !== 'zh' || !zhInstructions.includes('Loop Engineering 会话路由') || !zhDispatcher.includes('已经由 Loop Engineering 管理') || !zhNotifier.includes('通知器需要消息参数')) throw new Error('Chinese installation did not localize generated configuration and runtime files');
|
|
169
189
|
console.log('openclaw installer self-test passed');
|
|
@@ -5,7 +5,7 @@ import { spawn } from 'node:child_process';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
7
|
function parseArgs(argv) {
|
|
8
|
-
const out = { root: process.cwd(), queue: 'agent-tasks', workerAgent: null, openclawBin: 'openclaw', systemctlBin: 'systemctl', json: false, confirmInstall: false, force: false };
|
|
8
|
+
const out = { root: process.cwd(), queue: 'agent-tasks', workerAgent: null, openclawBin: 'openclaw', systemctlBin: 'systemctl', language: 'auto', json: false, confirmInstall: false, force: false };
|
|
9
9
|
for (let i = 0; i < argv.length; i++) {
|
|
10
10
|
const arg = argv[i];
|
|
11
11
|
if (arg === '--root') out.root = path.resolve(argv[++i]);
|
|
@@ -13,6 +13,7 @@ function parseArgs(argv) {
|
|
|
13
13
|
else if (arg === '--worker-agent') out.workerAgent = argv[++i];
|
|
14
14
|
else if (arg === '--openclaw-bin') out.openclawBin = argv[++i];
|
|
15
15
|
else if (arg === '--systemctl-bin') out.systemctlBin = argv[++i];
|
|
16
|
+
else if (arg === '--language') out.language = argv[++i];
|
|
16
17
|
else if (arg === '--confirm-install') out.confirmInstall = true;
|
|
17
18
|
else if (arg === '--force') out.force = true;
|
|
18
19
|
else if (arg === '--json') out.json = true;
|
|
@@ -22,6 +23,15 @@ function parseArgs(argv) {
|
|
|
22
23
|
return out;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
function resolveLanguage(requested = 'auto', env = process.env) {
|
|
27
|
+
if (!['auto', 'en', 'zh'].includes(requested)) throw new Error('--language must be auto, en, or zh.');
|
|
28
|
+
if (requested !== 'auto') return requested;
|
|
29
|
+
const locale = String(env.LC_ALL || env.LC_MESSAGES || env.LANG || '').toLowerCase();
|
|
30
|
+
return /(^|[_.-])zh(?:[_-]|\.|$)/.test(locale) || locale.startsWith('zh') ? 'zh' : 'en';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const text = (language, en, zh) => language === 'zh' ? zh : en;
|
|
34
|
+
|
|
25
35
|
function systemdEscapePath(value) {
|
|
26
36
|
return [...Buffer.from(String(value))]
|
|
27
37
|
.map((byte) => /[A-Za-z0-9/_.:-]/.test(String.fromCharCode(byte))
|
|
@@ -80,7 +90,17 @@ async function resolveExecutable(command) {
|
|
|
80
90
|
throw new Error(`Executable not found on PATH: ${command}`);
|
|
81
91
|
}
|
|
82
92
|
|
|
83
|
-
function formatConfirmationSummary(summary) {
|
|
93
|
+
function formatConfirmationSummary(summary, language) {
|
|
94
|
+
if (language === 'zh') return [
|
|
95
|
+
'安装确认',
|
|
96
|
+
` 目标平台:${summary.targetPlatform}`,
|
|
97
|
+
` 平台 CLI:${summary.platformCli}`,
|
|
98
|
+
` 工作区:${summary.workspace}`,
|
|
99
|
+
` 队列:${summary.queue}`,
|
|
100
|
+
` 调度器:${summary.scheduler}`,
|
|
101
|
+
` 通知目标:${summary.notificationTarget}`,
|
|
102
|
+
` 允许写入:${summary.writesEnabled ? '是' : '否(仅生成计划)'}`
|
|
103
|
+
].join('\n');
|
|
84
104
|
return [
|
|
85
105
|
'Installation confirmation',
|
|
86
106
|
` target platform: ${summary.targetPlatform}`,
|
|
@@ -93,25 +113,43 @@ function formatConfirmationSummary(summary) {
|
|
|
93
113
|
].join('\n');
|
|
94
114
|
}
|
|
95
115
|
|
|
96
|
-
function dispatcherSource({ workerAgent, openclawBin }) {
|
|
116
|
+
function dispatcherSource({ workerAgent, openclawBin, language }) {
|
|
117
|
+
const promptLines = language === 'zh' ? [
|
|
118
|
+
'你收到的是一个已经由 Loop Engineering 管理的任务。',
|
|
119
|
+
'不要再次路由或入队,即使引用的用户请求中包含 loop 触发词。',
|
|
120
|
+
'实施前先阅读任务合同、开发计划和验收计划。'
|
|
121
|
+
] : [
|
|
122
|
+
'You are receiving an already loop-managed task.',
|
|
123
|
+
'Do not route or enqueue this task again, even if its quoted request contains a loop trigger.',
|
|
124
|
+
'Read the task contract, development plan, and acceptance plan before implementation.'
|
|
125
|
+
];
|
|
126
|
+
const labels = language === 'zh' ? {
|
|
127
|
+
taskId: '任务 ID', taskContract: '任务合同', devPlan: '开发计划', acceptancePlan: '验收计划',
|
|
128
|
+
amendments: '实时补充要求', checkpoints: '检查点目录', missing: '未提供',
|
|
129
|
+
reread: '写入每个检查点以及最终完成前,如果实时补充要求文件存在,请重新读取。所有已记录的补充要求都是任务合同和验收标准的一部分。',
|
|
130
|
+
finish: '尽可能写入检查点,并包含最新已应用的 amendment_version。最终报告必须包含状态、证据、验证、阻塞和下一步。'
|
|
131
|
+
} : {
|
|
132
|
+
taskId: 'Task id', taskContract: 'Task contract', devPlan: 'Development plan', acceptancePlan: 'Acceptance plan',
|
|
133
|
+
amendments: 'Live amendments', checkpoints: 'Checkpoints dir', missing: 'not provided',
|
|
134
|
+
reread: '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.',
|
|
135
|
+
finish: 'Write a checkpoint when possible. Include the latest amendment_version applied. Finish with status, evidence, verification, blockers, and next action.'
|
|
136
|
+
};
|
|
97
137
|
return `#!/usr/bin/env node
|
|
98
138
|
import { readFile } from 'node:fs/promises';
|
|
99
139
|
import { spawn } from 'node:child_process';
|
|
100
140
|
const task = JSON.parse(await readFile(process.env.LOOP_TASK_FILE, 'utf8'));
|
|
101
141
|
const sessionGeneration = Number.parseInt(process.env.LOOP_SESSION_GENERATION || '0', 10) || 0;
|
|
102
142
|
const prompt = [
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
\`Live amendments: \${process.env.LOOP_LATEST_AMENDMENT_FILE || 'not provided'}\`,
|
|
111
|
-
\`Checkpoints dir: \${process.env.LOOP_CHECKPOINTS_DIR || 'not provided'}\`,
|
|
143
|
+
${promptLines.map((line) => JSON.stringify(line)).join(',\n ')},
|
|
144
|
+
\`${labels.taskId}: \${task.id}\`,
|
|
145
|
+
\`${labels.taskContract}: \${process.env.LOOP_TASK_CONTRACT_FILE || ${JSON.stringify(labels.missing)}}\`,
|
|
146
|
+
\`${labels.devPlan}: \${process.env.LOOP_DEV_PLAN_FILE || ${JSON.stringify(labels.missing)}}\`,
|
|
147
|
+
\`${labels.acceptancePlan}: \${process.env.LOOP_ACCEPTANCE_PLAN_FILE || ${JSON.stringify(labels.missing)}}\`,
|
|
148
|
+
\`${labels.amendments}: \${process.env.LOOP_LATEST_AMENDMENT_FILE || ${JSON.stringify(labels.missing)}}\`,
|
|
149
|
+
\`${labels.checkpoints}: \${process.env.LOOP_CHECKPOINTS_DIR || ${JSON.stringify(labels.missing)}}\`,
|
|
112
150
|
'', task.body, '',
|
|
113
|
-
|
|
114
|
-
|
|
151
|
+
${JSON.stringify(labels.reread)},
|
|
152
|
+
${JSON.stringify(labels.finish)}
|
|
115
153
|
].join('\\n');
|
|
116
154
|
const child = spawn(${JSON.stringify(openclawBin)}, [
|
|
117
155
|
'agent', '--agent', ${JSON.stringify(workerAgent)},
|
|
@@ -122,7 +160,15 @@ child.on('close', (code, signal) => { process.exitCode = code ?? (signal ? 128 :
|
|
|
122
160
|
`;
|
|
123
161
|
}
|
|
124
162
|
|
|
125
|
-
function wrapperSource({ queue, loopBin }) {
|
|
163
|
+
function wrapperSource({ queue, loopBin, language }) {
|
|
164
|
+
const missingSourceError = text(language, 'loop route requires conversation metadata', 'loop 路由需要会话来源元数据');
|
|
165
|
+
const usage = text(language, 'Usage: node scripts/loops/openclaw-loop.mjs route --message "Use loop: task" [source metadata]', '用法:node scripts/loops/openclaw-loop.mjs route --message "走 loop:任务" [来源元数据]');
|
|
166
|
+
const amendmentPattern = language === 'zh'
|
|
167
|
+
? '(?:继续(?:当前|这个)?\\s*loop|给(?:当前|这个)?\\s*loop\\s*(?:补充|增加|加)|补充当前\\s*loop)'
|
|
168
|
+
: '(?:continue\\s+(?:the\\s+)?(?:current\\s+)?loop|amend\\s+(?:the\\s+)?(?:current\\s+)?loop|add\\s+(?:this\\s+)?amendment\\s+to\\s+(?:the\\s+)?(?:current\\s+)?loop)';
|
|
169
|
+
const queueOnlyPattern = language === 'zh'
|
|
170
|
+
? '(?:只入队|只排队|暂不执行|不立即执行)'
|
|
171
|
+
: '(?:queue\\s+(?:this|it)\\s+only|only\\s+queue\\s+(?:this|it)|enqueue\\s+(?:this|it)\\s+only|do\\s+not\\s+(?:run|execute)\\s+(?:this|it)\\s+(?:yet|now))';
|
|
126
172
|
return `#!/usr/bin/env node
|
|
127
173
|
import { spawn } from 'node:child_process';
|
|
128
174
|
const [command, ...rest] = process.argv.slice(2);
|
|
@@ -144,10 +190,21 @@ async function runWhenUnlocked(args, waitMs = 300000) {
|
|
|
144
190
|
if (command === 'route') {
|
|
145
191
|
const messageIndex = rest.indexOf('--message');
|
|
146
192
|
const message = messageIndex >= 0 ? String(rest[messageIndex + 1] || '') : '';
|
|
147
|
-
const
|
|
193
|
+
const optionValue = (name) => {
|
|
194
|
+
const index = rest.indexOf(name);
|
|
195
|
+
return index >= 0 ? String(rest[index + 1] || '').trim() : '';
|
|
196
|
+
};
|
|
197
|
+
const requiredSource = ['--source-channel', '--source-target', '--source-account', '--source-message-id'];
|
|
198
|
+
const missingSource = requiredSource.filter((name) => !optionValue(name));
|
|
199
|
+
if (missingSource.length) {
|
|
200
|
+
console.error(\`${missingSourceError}: \${missingSource.join(', ')}\`);
|
|
201
|
+
process.exitCode = 2;
|
|
202
|
+
process.exit();
|
|
203
|
+
}
|
|
204
|
+
const amendment = new RegExp(${JSON.stringify(amendmentPattern)}, 'i').test(message);
|
|
148
205
|
const routeMode = amendment ? '--amend-active' : '--supersede-active';
|
|
149
206
|
const routeCode = await run(['route-message', '--queue', ${JSON.stringify(queue)}, '--route', '--confirm-execute', routeMode, ...rest]);
|
|
150
|
-
const queueOnly =
|
|
207
|
+
const queueOnly = new RegExp(${JSON.stringify(queueOnlyPattern)}, 'i').test(message);
|
|
151
208
|
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']);
|
|
152
209
|
const humanNotifyCode = await run(['queue-human-input-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
|
|
153
210
|
const terminalNotifyCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
|
|
@@ -163,30 +220,33 @@ if (command === 'route') {
|
|
|
163
220
|
const terminalNotifyCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
|
|
164
221
|
process.exitCode = tickCode || humanNotifyCode || terminalNotifyCode;
|
|
165
222
|
} else {
|
|
166
|
-
console.error(
|
|
223
|
+
console.error(${JSON.stringify(usage)});
|
|
167
224
|
process.exitCode = 1;
|
|
168
225
|
}
|
|
169
226
|
`;
|
|
170
227
|
}
|
|
171
228
|
|
|
172
|
-
function schedulerServiceSource({ root, queue }) {
|
|
173
|
-
return `[Unit]\nDescription
|
|
229
|
+
function schedulerServiceSource({ root, queue, language }) {
|
|
230
|
+
return `[Unit]\nDescription=${text(language, `Taskforce Loop Engineering scheduler for ${queue}`, `${queue} 的 Taskforce Loop Engineering 调度器`)}\nAfter=default.target\n\n[Service]\nType=oneshot\nWorkingDirectory=${systemdEscapePath(root)}\nExecStart=${systemdEscapePath(process.execPath)} ${systemdEscapePath(path.join(root, 'scripts', 'loops', 'openclaw-loop.mjs'))} scheduler-tick --json\n`;
|
|
174
231
|
}
|
|
175
232
|
|
|
176
|
-
function schedulerTimerSource({ queue }) {
|
|
177
|
-
return `[Unit]\nDescription
|
|
233
|
+
function schedulerTimerSource({ queue, language }) {
|
|
234
|
+
return `[Unit]\nDescription=${text(language, `Wake Taskforce Loop Engineering scheduler for ${queue}`, `唤醒 ${queue} 的 Taskforce Loop Engineering 调度器`)}\n\n[Timer]\nOnBootSec=30s\nOnUnitActiveSec=1min\nAccuracySec=10s\nPersistent=true\nUnit=openclaw-loop-${queue}-scheduler.service\n\n[Install]\nWantedBy=timers.target\n`;
|
|
178
235
|
}
|
|
179
236
|
|
|
180
|
-
function notifierSource({ openclawBin }) {
|
|
237
|
+
function notifierSource({ openclawBin, language }) {
|
|
238
|
+
const missingMessage = text(language, 'loop notifier requires a message argument.', 'loop 通知器需要消息参数。');
|
|
239
|
+
const invalidSource = text(language, 'loop notifier received invalid source metadata.', 'loop 通知器收到无效的来源元数据。');
|
|
240
|
+
const unscoped = text(language, 'loop notifier requires source.channel and source.target; refusing an unscoped delivery.', 'loop 通知器需要 source.channel 和 source.target;拒绝无范围投递。');
|
|
181
241
|
return `#!/usr/bin/env node
|
|
182
242
|
import { spawn } from 'node:child_process';
|
|
183
243
|
const message = process.argv.slice(2).join(' ').trim();
|
|
184
244
|
const rawSource = process.env.LOOP_HUMAN_INPUT_SOURCE || process.env.LOOP_NOTIFICATION_SOURCE || '';
|
|
185
|
-
if (!message) { console.error(
|
|
245
|
+
if (!message) { console.error(${JSON.stringify(missingMessage)}); process.exit(2); }
|
|
186
246
|
let source;
|
|
187
|
-
try { source = JSON.parse(rawSource); } catch { console.error(
|
|
247
|
+
try { source = JSON.parse(rawSource); } catch { console.error(${JSON.stringify(invalidSource)}); process.exit(2); }
|
|
188
248
|
if (!source || typeof source !== 'object' || !source.channel || !source.target) {
|
|
189
|
-
console.error(
|
|
249
|
+
console.error(${JSON.stringify(unscoped)});
|
|
190
250
|
process.exit(2);
|
|
191
251
|
}
|
|
192
252
|
const args = ['message', 'send', '--channel', String(source.channel), '--target', String(source.target), '--message', message, '--json'];
|
|
@@ -198,12 +258,28 @@ child.on('close', (code, signal) => { process.exitCode = code ?? (signal ? 128 :
|
|
|
198
258
|
`;
|
|
199
259
|
}
|
|
200
260
|
|
|
201
|
-
function instructionsBlock({ queue }) {
|
|
261
|
+
function instructionsBlock({ queue, language }) {
|
|
262
|
+
if (language === 'zh') return `\n<!-- loop-engineering:openclaw:start -->
|
|
263
|
+
## Loop Engineering 会话路由
|
|
264
|
+
|
|
265
|
+
- 仅路由明确要求使用 Loop 的请求。\`走 loop\` 表示入队并立即执行一次;只有 \`只入队\` 或 \`只排队\` 才不立即执行。
|
|
266
|
+
- \`用 loop engineering\`、\`丢进 Ironman loop\`、\`loop Ironman\` 和 \`task-runner\` 也属于明确的 Loop 请求。
|
|
267
|
+
- 从当前工作区运行 \`node scripts/loops/openclaw-loop.mjs route --message "<完整用户消息>"\`,并保留来源元数据。
|
|
268
|
+
- 会话来源任务必须使用标准包装器。缺少来源元数据时必须失败关闭,不能退回手工入队或直接运行队列。
|
|
269
|
+
- 人工门禁或终态只有在通知命令成功并写入通知记录后才算已送达。
|
|
270
|
+
- 已由 Loop 管理的任务必须直接执行,不能再次路由。
|
|
271
|
+
- 状态查询只读。高风险外部动作、破坏性操作、生产变更、凭据操作和记忆迁移仍需单独确认。
|
|
272
|
+
- 队列:\`${queue}\`。
|
|
273
|
+
<!-- loop-engineering:openclaw:end -->\n`;
|
|
202
274
|
return `\n<!-- loop-engineering:openclaw:start -->
|
|
203
275
|
## Loop Engineering conversation routing
|
|
204
276
|
|
|
205
|
-
- Route only explicit
|
|
277
|
+
- Route only explicit Loop Engineering requests. For example, \`Use Loop Engineering to fix this issue\` and \`Run this through Loop Engineering\` enqueue the request and immediately execute one tick.
|
|
278
|
+
- \`Queue this only; do not run it yet\` suppresses immediate execution. \`Continue the current loop with this amendment: ...\` amends the active task instead of replacing it.
|
|
279
|
+
- Treat explicit references to \`Loop Engineering\`, \`the loop\`, \`task-runner\`, or a named loop queue as Loop requests.
|
|
206
280
|
- Run \`node scripts/loops/openclaw-loop.mjs route --message "<full user message>"\` from this workspace and preserve source metadata when available.
|
|
281
|
+
- For conversation-originated work, the standard wrapper is mandatory. Missing source metadata must fail closed; never fall back to manual enqueue or direct run-queue.
|
|
282
|
+
- A human-gated or terminal state is not delivered until its notification command succeeds and writes a notification record.
|
|
207
283
|
- An already loop-managed task must be executed directly and never routed again.
|
|
208
284
|
- Status questions are read-only. High-risk external, destructive, production, credential, or memory migration actions remain separately gated.
|
|
209
285
|
- Queue: \`${queue}\`.
|
|
@@ -212,8 +288,9 @@ function instructionsBlock({ queue }) {
|
|
|
212
288
|
|
|
213
289
|
async function main() {
|
|
214
290
|
const args = parseArgs(process.argv.slice(2));
|
|
291
|
+
args.language = resolveLanguage(args.language);
|
|
215
292
|
if (args.help) {
|
|
216
|
-
console.log('Usage: loop-engineering-openclaw-install [--root workspace] [--queue agent-tasks] [--worker-agent agent-id] [--openclaw-bin openclaw] [--systemctl-bin systemctl] [--confirm-install] [--force] [--json]');
|
|
293
|
+
console.log('Usage: loop-engineering-openclaw-install [--root workspace] [--queue agent-tasks] [--worker-agent agent-id] [--language auto|en|zh] [--openclaw-bin openclaw] [--systemctl-bin systemctl] [--confirm-install] [--force] [--json]');
|
|
217
294
|
return;
|
|
218
295
|
}
|
|
219
296
|
safeId(args.queue, 'queue');
|
|
@@ -238,11 +315,11 @@ async function main() {
|
|
|
238
315
|
};
|
|
239
316
|
const conflicts = [];
|
|
240
317
|
for (const [kind, file] of Object.entries(files)) if (!['instructions', 'workspaceHealth', 'manifest'].includes(kind) && await exists(file)) conflicts.push(path.relative(args.root, file));
|
|
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 };
|
|
243
|
-
report.next = args.confirmInstall ? 'Run loop-engineering-openclaw-doctor, then route a harmless smoke task.' : 'Review this plan, then rerun with --confirm-install.';
|
|
318
|
+
const confirmationSummary = { targetPlatform: 'OpenClaw', platformCli: args.openclawBin, workspace: args.root, queue: args.queue, scheduler: `systemd user timer ${schedulerTimer}`, notificationTarget: text(args.language, 'source-bound at runtime (original OpenClaw conversation)', '运行时绑定到原始 OpenClaw 会话'), writesEnabled: args.confirmInstall };
|
|
319
|
+
const report = { version: 1, platform: 'openclaw', language: args.language, 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 };
|
|
320
|
+
report.next = args.confirmInstall ? text(args.language, 'Run loop-engineering-openclaw-doctor, then route a harmless smoke task.', '运行 loop-engineering-openclaw-doctor,然后路由一个无害的冒烟任务。') : text(args.language, 'Review this plan, then rerun with --confirm-install.', '检查此计划,然后使用 --confirm-install 重新运行。');
|
|
244
321
|
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));
|
|
322
|
+
if (!args.json) console.log(formatConfirmationSummary(confirmationSummary, args.language));
|
|
246
323
|
if (args.confirmInstall) {
|
|
247
324
|
await mkdir(path.dirname(files.queueConfig), { recursive: true });
|
|
248
325
|
await mkdir(path.dirname(files.dispatcher), { recursive: true });
|
|
@@ -257,6 +334,7 @@ async function main() {
|
|
|
257
334
|
}
|
|
258
335
|
const queueContent = `${JSON.stringify({
|
|
259
336
|
queue: args.queue,
|
|
337
|
+
language: args.language,
|
|
260
338
|
description: `OpenClaw conversation queue dispatched to agent ${args.workerAgent}.`,
|
|
261
339
|
dispatcher: 'node scripts/loops/openclaw-loop-dispatch.mjs',
|
|
262
340
|
preflightConfig: 'configs/loops/workspace-health.json',
|
|
@@ -286,7 +364,7 @@ async function main() {
|
|
|
286
364
|
if (!instructions.includes('<!-- loop-engineering:openclaw:start -->')) await appendFile(files.instructions, managedInstructions);
|
|
287
365
|
await mkdir(path.dirname(files.manifest), { recursive: true });
|
|
288
366
|
await writeFile(files.manifest, `${JSON.stringify({
|
|
289
|
-
version:
|
|
367
|
+
version: 3, queue: args.queue, language: args.language, workerAgent: args.workerAgent, openclawBin: args.openclawBin, systemctlBin: args.systemctlBin, installedAt: new Date().toISOString(),
|
|
290
368
|
managedFiles: [
|
|
291
369
|
{ path: path.relative(args.root, files.queueConfig), sha256: sha256(queueContent) },
|
|
292
370
|
{ path: path.relative(args.root, files.dispatcher), sha256: sha256(dispatcherContent) },
|
|
@@ -301,7 +379,7 @@ async function main() {
|
|
|
301
379
|
retainedOnUninstall: [`runtime/loops/${args.queue}`]
|
|
302
380
|
}, null, 2)}\n`);
|
|
303
381
|
}
|
|
304
|
-
console.log(args.json ? JSON.stringify(report, null, 2) : `OpenClaw integration: ${report.status}\nconflicts: ${report.conflicts.join(', ') || 'none'}\nnext: ${report.next}`);
|
|
382
|
+
console.log(args.json ? JSON.stringify(report, null, 2) : text(args.language, `OpenClaw integration: ${report.status}\nconflicts: ${report.conflicts.join(', ') || 'none'}\nnext: ${report.next}`, `OpenClaw 集成:${report.status}\n冲突:${report.conflicts.join(', ') || '无'}\n下一步:${report.next}`));
|
|
305
383
|
}
|
|
306
384
|
|
|
307
385
|
main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
|
|
@@ -73,7 +73,7 @@ async function main() {
|
|
|
73
73
|
if (!plan.ready) throw new Error(`Refusing upgrade because managed content changed: ${[...modified, ...(!instructionsClean ? ['AGENTS.md managed block'] : [])].join(', ')}`);
|
|
74
74
|
const installer = new URL('./openclaw-install.mjs', import.meta.url).pathname;
|
|
75
75
|
const result = await new Promise((resolve) => {
|
|
76
|
-
const child = spawn(process.execPath, [installer, '--root', args.root, '--queue', manifest.queue, '--worker-agent', manifest.workerAgent, '--openclaw-bin', manifest.openclawBin || 'openclaw', '--systemctl-bin', manifest.systemctlBin || 'systemctl', '--confirm-install', '--force', '--json'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
76
|
+
const child = spawn(process.execPath, [installer, '--root', args.root, '--queue', manifest.queue, '--worker-agent', manifest.workerAgent, '--language', manifest.language || 'en', '--openclaw-bin', manifest.openclawBin || 'openclaw', '--systemctl-bin', manifest.systemctlBin || 'systemctl', '--confirm-install', '--force', '--json'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
77
77
|
let stdout = ''; let stderr = ''; child.stdout.on('data', (c) => { stdout += c; }); child.stderr.on('data', (c) => { stderr += c; });
|
|
78
78
|
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
79
79
|
});
|
|
@@ -48,6 +48,15 @@ assert.deepEqual(classifyLoopMessage('用 loop engineering 绕过某个检查'),
|
|
|
48
48
|
enqueue: true,
|
|
49
49
|
readOnly: false
|
|
50
50
|
});
|
|
51
|
+
assert.deepEqual(classifyLoopMessage('用 loop engineering 把现有的 growth os 与文件对齐,把需要增强的功能补齐'), {
|
|
52
|
+
intent: 'execute',
|
|
53
|
+
risk: 'model_assessed',
|
|
54
|
+
enqueue: true,
|
|
55
|
+
readOnly: false
|
|
56
|
+
});
|
|
57
|
+
assert.equal(classifyLoopMessage('Use Loop Engineering to fix this issue.').intent, 'execute');
|
|
58
|
+
assert.equal(classifyLoopMessage('Run this through Loop Engineering.').intent, 'execute');
|
|
59
|
+
assert.equal(classifyLoopMessage('Continue the current loop with this amendment: add English examples.').intent, 'execute');
|
|
51
60
|
|
|
52
61
|
const routed = await routeLoopMessage(root, {
|
|
53
62
|
route: true,
|
|
@@ -374,10 +383,12 @@ await writeJson(checkpointFile, {
|
|
|
374
383
|
const failedFile = path.join(queueSubdirFor(root, queue, 'failed'), path.basename(terminalFile));
|
|
375
384
|
await writeJson(failedFile, terminalTask);
|
|
376
385
|
if (failedFile !== terminalFile) await rm(terminalFile, { force: true });
|
|
386
|
+
await writeJson(path.join(root, 'configs', 'loops', 'queues', `${queue}.json`), { queue, language: 'zh' });
|
|
377
387
|
|
|
378
388
|
const gateDryRun = await notifyHumanInputRequests(root, { queue, dryRun: true });
|
|
379
389
|
assert.equal(gateDryRun.results[0].outcome, 'dry_run');
|
|
380
390
|
assert.match(gateDryRun.results[0].message, /Provide the SMS code/);
|
|
391
|
+
assert.match(gateDryRun.results[0].message, /正在等待你的输入/);
|
|
381
392
|
const gateSent = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
382
393
|
assert.equal(gateSent.sent, 1);
|
|
383
394
|
const gateId = gateSent.results[0].gateId;
|
|
@@ -464,6 +475,7 @@ await writeJson(failedFile, { ...requeuedTask, status: 'needs_human_input' });
|
|
|
464
475
|
await rm(inboxRequeued, { force: true });
|
|
465
476
|
|
|
466
477
|
const dryRun = await notifyTerminalTasks(root, { queue, dryRun: true });
|
|
478
|
+
assert.match(dryRun.results[0].message, /Loop 任务/);
|
|
467
479
|
assert.equal(dryRun.results[0].outcome, 'dry_run');
|
|
468
480
|
const sent = await notifyTerminalTasks(root, { queue, notifyCommand: '/bin/true' });
|
|
469
481
|
assert.equal(sent.sent, 1);
|