taskforce-loop-engineering 0.9.1 → 0.12.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.
@@ -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
- 'You are receiving an already loop-managed task.',
67
- 'Do not route or enqueue this task again.',
68
- 'Read the task contract, development plan, acceptance plan, and live amendments before implementation.',
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
- '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.'
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 = /(?:继续(?:当前|这个)?\\s*loop|给(?:当前|这个)?\\s*loop\\s*(?:补充|增加|加)|补充当前\\s*loop)/i.test(message);
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 = /(?:只入队|只排队|暂不执行|不立即执行)/.test(message);
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 }) { return `\n<!-- loop-engineering:hermes:start -->\n## Loop Engineering conversation routing (Hermes)\n\n- Route only explicit Loop Engineering requests. Already managed tasks must never be routed again.\n- Run \`node scripts/loops/hermes-loop.mjs route --message "<full user message>"\` and pass \`--source-channel <platform> --source-target <platform:chat_id[:thread_id]>\` so notifications return to the originating Hermes conversation.\n- \`走 loop\` executes immediately; only \`只入队\` or \`只排队\` suppresses execution. External, destructive, production, credential, or memory migration actions remain separately gated.\n- Queue: \`${queue}\`.\n<!-- loop-engineering:hermes:end -->\n`; }
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
- if (args.help) { console.log('Usage: loop-engineering-hermes-install [--root workspace] [--queue agent-tasks] [--hermes-bin hermes] [--systemctl-bin systemctl] [--confirm-install] [--force] [--json]'); return; }
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: 1, platform: 'hermes', installedAt: new Date().toISOString(), root: args.root, queue: args.queue, hermesBin: args.hermesBin, files: Object.entries(contents).map(([kind, content]) => ({ kind, path: files[kind], sha256: sha256(content) })), scheduler: { unit, timer } }, null, 2)}\n`);
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; });
@@ -0,0 +1,81 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import {
6
+ ensureQueueDirs,
7
+ parkQueueTask,
8
+ queueStatus,
9
+ queueSubdirFor,
10
+ readJson,
11
+ resumeParkedTask,
12
+ tickParkedTasks,
13
+ writeJson
14
+ } from '../lib/core.mjs';
15
+
16
+ const root = await mkdtemp(path.join(tmpdir(), 'loop-human-gate-v2-'));
17
+ const queue = 'vps-fixture';
18
+ const taskId = 'vps-down-ssh-banner-timeout';
19
+ const taskFile = path.join(queueSubdirFor(root, queue, 'inbox'), `${taskId}.json`);
20
+
21
+ try {
22
+ await ensureQueueDirs(root, queue);
23
+ await writeJson(taskFile, { version: 1, id: taskId, title: 'Recover provider VPS', status: 'queued' });
24
+ const parked = await parkQueueTask(root, {
25
+ queue,
26
+ taskId,
27
+ kind: 'external_condition',
28
+ reason: 'VPS is down; SSH banner timed out.',
29
+ now: '2026-08-13T00:00:00.000Z',
30
+ executionKey: 'provider-call-1',
31
+ authorization: { state: 'unconsumed', scope: 'provider_call' },
32
+ policy: { timeoutMs: 2_000, reminderIntervalMs: 1_000, escalationIntervalMs: 2_000, maxReminders: 1 }
33
+ });
34
+ assert.equal(parked.outcome, 'parked');
35
+ assert.equal(parked.task.parked.authorization.state, 'unconsumed');
36
+ assert.equal(parked.task.parked.execution_boundary.action_executed, false);
37
+
38
+ const status = await queueStatus(root, queue);
39
+ assert.equal(status.waitingStates.timed_out_or_escalated, 1);
40
+ assert.equal(status.waitingTasks[0].waitKind, 'external_condition');
41
+ assert.equal(status.waitingTasks[0].authorizationState, 'unconsumed');
42
+
43
+ const notifyCommand = 'node -e "process.exit(0)"';
44
+ const reminder = await tickParkedTasks(root, { queue, now: '2026-08-13T00:00:01.000Z', notifyCommand });
45
+ assert.equal(reminder.results[0].type, 'reminder');
46
+ const duplicateTick = await tickParkedTasks(root, { queue, now: '2026-08-13T00:00:01.000Z', notifyCommand });
47
+ assert.equal(duplicateTick.results[0].outcome, 'throttled');
48
+ const escalation = await tickParkedTasks(root, { queue, now: '2026-08-13T00:00:03.000Z', notifyCommand });
49
+ assert.equal(escalation.results[0].type, 'escalation');
50
+
51
+ await assert.rejects(
52
+ resumeParkedTask(root, { queue, taskId, recoverySignal: 'ssh banner verified' }),
53
+ /--verified/
54
+ );
55
+ const resumed = await resumeParkedTask(root, {
56
+ queue,
57
+ taskId,
58
+ verified: true,
59
+ recoverySignal: 'probe=vps-1;ssh_banner=verified',
60
+ now: '2026-08-13T00:00:04.000Z'
61
+ });
62
+ assert.equal(resumed.outcome, 'verified_and_requeued');
63
+ assert.equal(resumed.task.parked.state, 'runnable');
64
+ assert.equal(resumed.task.parked.authorization.state, 'unconsumed');
65
+ assert.equal(resumed.task.parked.execution_boundary.action_executed, false);
66
+ assert.ok(resumed.signalSha256);
67
+
68
+ const afterRestart = await resumeParkedTask(root, {
69
+ queue,
70
+ taskId,
71
+ verified: true,
72
+ recoverySignal: 'probe=vps-1;ssh_banner=verified'
73
+ });
74
+ assert.equal(afterRestart.outcome, 'already_resumed');
75
+ const durable = await readJson(path.join(queueSubdirFor(root, queue, 'inbox'), `${taskId}.json`));
76
+ assert.equal(durable.parked.execution_boundary.key, 'provider-call-1');
77
+ assert.equal(durable.parked.authorization.state, 'unconsumed');
78
+ console.log('human-gate-lifecycle-v2 self-test: ok');
79
+ } finally {
80
+ await rm(root, { recursive: true, force: true });
81
+ }
@@ -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,15 @@ 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(' loop') || !instructions.includes('immediately execute')) throw new Error('conversation instructions missing');
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('只入队') || !wrapper.includes('requiredSource') || !wrapper.includes('--source-message-id')) throw new Error('supersede/amend routing, source fail-closed policy, absolute CLI, scheduler, live progress, async notification, or queue-only routing missing');
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');
66
73
  const missingSourceRoute = await new Promise((resolve) => {
67
74
  const child = spawn(process.execPath, [path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'route', '--message', '用 loop engineering 对齐系统'], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
68
75
  let stderr = ''; child.stderr.on('data', (chunk) => { stderr += chunk; });
@@ -172,4 +179,11 @@ if (await readFile(path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'utf8').t
172
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');
173
180
  const finalSystemctlCalls = await readFile(systemctlCapture, 'utf8');
174
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');
175
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
- 'You are receiving an already loop-managed task.',
104
- 'Do not route or enqueue this task again, even if its quoted request contains a loop trigger.',
105
- 'Read the task contract, development plan, and acceptance plan before implementation.',
106
- \`Task id: \${task.id}\`,
107
- \`Task contract: \${process.env.LOOP_TASK_CONTRACT_FILE || 'not provided'}\`,
108
- \`Development plan: \${process.env.LOOP_DEV_PLAN_FILE || 'not provided'}\`,
109
- \`Acceptance plan: \${process.env.LOOP_ACCEPTANCE_PLAN_FILE || 'not provided'}\`,
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
- '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.',
114
- 'Write a checkpoint when possible. Include the latest amendment_version applied. Finish with status, evidence, verification, blockers, and next action.'
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);
@@ -151,14 +197,14 @@ if (command === 'route') {
151
197
  const requiredSource = ['--source-channel', '--source-target', '--source-account', '--source-message-id'];
152
198
  const missingSource = requiredSource.filter((name) => !optionValue(name));
153
199
  if (missingSource.length) {
154
- console.error(\`loop route requires conversation metadata: \${missingSource.join(', ')}\`);
200
+ console.error(\`${missingSourceError}: \${missingSource.join(', ')}\`);
155
201
  process.exitCode = 2;
156
202
  process.exit();
157
203
  }
158
- const amendment = /(?:继续(?:当前|这个)?\\s*loop|给(?:当前|这个)?\\s*loop\\s*(?:补充|增加|加)|补充当前\\s*loop)/i.test(message);
204
+ const amendment = new RegExp(${JSON.stringify(amendmentPattern)}, 'i').test(message);
159
205
  const routeMode = amendment ? '--amend-active' : '--supersede-active';
160
206
  const routeCode = await run(['route-message', '--queue', ${JSON.stringify(queue)}, '--route', '--confirm-execute', routeMode, ...rest]);
161
- const queueOnly = /(?:只入队|只排队|暂不执行|不立即执行)/.test(message);
207
+ const queueOnly = new RegExp(${JSON.stringify(queueOnlyPattern)}, 'i').test(message);
162
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']);
163
209
  const humanNotifyCode = await run(['queue-human-input-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
164
210
  const terminalNotifyCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
@@ -174,30 +220,33 @@ if (command === 'route') {
174
220
  const terminalNotifyCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
175
221
  process.exitCode = tickCode || humanNotifyCode || terminalNotifyCode;
176
222
  } else {
177
- console.error('Usage: node scripts/loops/openclaw-loop.mjs route --message "走 loop:任务" [source metadata]');
223
+ console.error(${JSON.stringify(usage)});
178
224
  process.exitCode = 1;
179
225
  }
180
226
  `;
181
227
  }
182
228
 
183
- function schedulerServiceSource({ root, queue }) {
184
- return `[Unit]\nDescription=Taskforce Loop Engineering scheduler for ${queue}\nAfter=default.target\n\n[Service]\nType=oneshot\nWorkingDirectory=${systemdEscapePath(root)}\nExecStart=${systemdEscapePath(process.execPath)} ${systemdEscapePath(path.join(root, 'scripts', 'loops', 'openclaw-loop.mjs'))} scheduler-tick --json\n`;
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`;
185
231
  }
186
232
 
187
- function schedulerTimerSource({ queue }) {
188
- return `[Unit]\nDescription=Wake Taskforce Loop Engineering scheduler for ${queue}\n\n[Timer]\nOnBootSec=30s\nOnUnitActiveSec=1min\nAccuracySec=10s\nPersistent=true\nUnit=openclaw-loop-${queue}-scheduler.service\n\n[Install]\nWantedBy=timers.target\n`;
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`;
189
235
  }
190
236
 
191
- 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;拒绝无范围投递。');
192
241
  return `#!/usr/bin/env node
193
242
  import { spawn } from 'node:child_process';
194
243
  const message = process.argv.slice(2).join(' ').trim();
195
244
  const rawSource = process.env.LOOP_HUMAN_INPUT_SOURCE || process.env.LOOP_NOTIFICATION_SOURCE || '';
196
- if (!message) { console.error('loop notifier requires a message argument.'); process.exit(2); }
245
+ if (!message) { console.error(${JSON.stringify(missingMessage)}); process.exit(2); }
197
246
  let source;
198
- try { source = JSON.parse(rawSource); } catch { console.error('loop notifier received invalid source metadata.'); process.exit(2); }
247
+ try { source = JSON.parse(rawSource); } catch { console.error(${JSON.stringify(invalidSource)}); process.exit(2); }
199
248
  if (!source || typeof source !== 'object' || !source.channel || !source.target) {
200
- console.error('loop notifier requires source.channel and source.target; refusing an unscoped delivery.');
249
+ console.error(${JSON.stringify(unscoped)});
201
250
  process.exit(2);
202
251
  }
203
252
  const args = ['message', 'send', '--channel', String(source.channel), '--target', String(source.target), '--message', message, '--json'];
@@ -209,12 +258,25 @@ child.on('close', (code, signal) => { process.exitCode = code ?? (signal ? 128 :
209
258
  `;
210
259
  }
211
260
 
212
- 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`;
213
274
  return `\n<!-- loop-engineering:openclaw:start -->
214
275
  ## Loop Engineering conversation routing
215
276
 
216
- - Route only explicit loop requests. \`走 loop\` means enqueue and immediately execute one tick; only \`只入队\` or \`只排队\` suppresses execution.
217
- - Treat explicit phrases such as \`用 loop engineering\`, \`丢进 Ironman loop\`, \`loop Ironman\`, and \`task-runner\` as Loop requests too.
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.
218
280
  - Run \`node scripts/loops/openclaw-loop.mjs route --message "<full user message>"\` from this workspace and preserve source metadata when available.
219
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.
220
282
  - A human-gated or terminal state is not delivered until its notification command succeeds and writes a notification record.
@@ -226,8 +288,9 @@ function instructionsBlock({ queue }) {
226
288
 
227
289
  async function main() {
228
290
  const args = parseArgs(process.argv.slice(2));
291
+ args.language = resolveLanguage(args.language);
229
292
  if (args.help) {
230
- 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]');
231
294
  return;
232
295
  }
233
296
  safeId(args.queue, 'queue');
@@ -252,11 +315,11 @@ async function main() {
252
315
  };
253
316
  const conflicts = [];
254
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));
255
- const confirmationSummary = { targetPlatform: 'OpenClaw', platformCli: args.openclawBin, workspace: args.root, queue: args.queue, scheduler: `systemd user timer ${schedulerTimer}`, notificationTarget: 'source-bound at runtime (original OpenClaw conversation)', writesEnabled: args.confirmInstall };
256
- const report = { version: 1, platform: 'openclaw', status: args.confirmInstall ? 'installed' : 'plan_only', readOnly: !args.confirmInstall, root: args.root, queue: args.queue, workerAgent: args.workerAgent, workerSelection: worker.selection, availableAgents: worker.availableAgents, workerValidated: true, createsWorkerAgent: false, openclawBin: args.openclawBin, systemctlBin: args.systemctlBin, scheduler: { required: true, unit: schedulerUnit, timer: schedulerTimer }, confirmationSummary, files: Object.fromEntries(Object.entries(files).map(([key, file]) => [key, path.relative(args.root, file)])), conflicts };
257
- 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 重新运行。');
258
321
  if (conflicts.length && !args.force && args.confirmInstall) throw new Error(`Refusing to overwrite: ${conflicts.join(', ')}. Use --force after review.`);
259
- if (!args.json) console.log(formatConfirmationSummary(confirmationSummary));
322
+ if (!args.json) console.log(formatConfirmationSummary(confirmationSummary, args.language));
260
323
  if (args.confirmInstall) {
261
324
  await mkdir(path.dirname(files.queueConfig), { recursive: true });
262
325
  await mkdir(path.dirname(files.dispatcher), { recursive: true });
@@ -271,6 +334,7 @@ async function main() {
271
334
  }
272
335
  const queueContent = `${JSON.stringify({
273
336
  queue: args.queue,
337
+ language: args.language,
274
338
  description: `OpenClaw conversation queue dispatched to agent ${args.workerAgent}.`,
275
339
  dispatcher: 'node scripts/loops/openclaw-loop-dispatch.mjs',
276
340
  preflightConfig: 'configs/loops/workspace-health.json',
@@ -300,7 +364,7 @@ async function main() {
300
364
  if (!instructions.includes('<!-- loop-engineering:openclaw:start -->')) await appendFile(files.instructions, managedInstructions);
301
365
  await mkdir(path.dirname(files.manifest), { recursive: true });
302
366
  await writeFile(files.manifest, `${JSON.stringify({
303
- version: 2, queue: args.queue, workerAgent: args.workerAgent, openclawBin: args.openclawBin, systemctlBin: args.systemctlBin, installedAt: new Date().toISOString(),
367
+ version: 3, queue: args.queue, language: args.language, workerAgent: args.workerAgent, openclawBin: args.openclawBin, systemctlBin: args.systemctlBin, installedAt: new Date().toISOString(),
304
368
  managedFiles: [
305
369
  { path: path.relative(args.root, files.queueConfig), sha256: sha256(queueContent) },
306
370
  { path: path.relative(args.root, files.dispatcher), sha256: sha256(dispatcherContent) },
@@ -315,7 +379,7 @@ async function main() {
315
379
  retainedOnUninstall: [`runtime/loops/${args.queue}`]
316
380
  }, null, 2)}\n`);
317
381
  }
318
- 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}`));
319
383
  }
320
384
 
321
385
  main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });