@hmharness/cli 0.1.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/dist/main.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/main.js ADDED
@@ -0,0 +1,641 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @hmharness/cli - main (terminal frontend)
4
+ * Usage:
5
+ * hmh init create HMH_HOME skeleton + config
6
+ * hmh "do something" one-shot task (full agent loop, streaming)
7
+ * hmh interactive REPL (conversation memory kept)
8
+ * hmh resume [id-prefix] continue a past session by id prefix (or latest)
9
+ * hmh web [--port=7788] local web frontend (SSE streaming + approvals)
10
+ * hmh tui lite terminal UI (status header + slash commands)
11
+ * hmh ops [scan|brief|status] ops keeper: ecosystem radar
12
+ * hmh devices|check direct tool run, no model
13
+ * hmh tools list all registered tools (native + MCP)
14
+ * hmh mcp show configured MCP servers and their tools
15
+ * hmh evolve [--every=N] self-evolution cycle (or resident loop)
16
+ * hmh bench run the evolution bench
17
+ * hmh skills [--promote|--rollback|--unpromote <name>]
18
+ hmh skills add <git-url-or-local-dir> install skills (multi-skill packs supported)
19
+ * Flags: --yes / -y / --yolo auto-approve gated tools (Claude-Code-style alias;
20
+ * --locale=zh|en override the UI locale for this run.
21
+ */
22
+ import readline from 'node:readline/promises';
23
+ import { stdin, stdout } from 'node:process';
24
+ import { join } from 'node:path';
25
+ import { stopWebDaemon, startWebDaemon, hmhWebUp, readWebPid } from "./web-daemon.js";
26
+ import { chat, homeDir, resolveProvider, initHome, latestSession, listProviders, loadConfig, loadTranscript, mcpServerTools, Registry, runLoop, setChatRoute, setLocale, } from '@hmharness/kernel';
27
+ import { listSkills, listDrafts, promoteSkill, runBench, runEvolution, rollbackSkill, unpromoteSkill, } from '@hmharness/evolution';
28
+ import { harmonyTools } from '@hmharness/domain-harmony';
29
+ import { baseTools, buildRegistry, buildSystemPrompt, runAgentTask, strings } from '@hmharness/agent';
30
+ const DIM = (s) => `\x1b[2m${s}\x1b[0m`;
31
+ const CYAN = (s) => `\x1b[36m${s}\x1b[0m`;
32
+ const YELLOW = (s) => `\x1b[33m${s}\x1b[0m`;
33
+ const GREEN = (s) => `\x1b[32m${s}\x1b[0m`;
34
+ async function uiStrings() {
35
+ const cfg = await loadConfig();
36
+ return strings((cfg.locale ?? 'zh'));
37
+ }
38
+ async function runTask(task, taskOpts = {}) {
39
+ const cfg = await loadConfig();
40
+ const { reg, clients } = taskOpts.registry
41
+ ? { reg: taskOpts.registry, clients: taskOpts.clients ?? [] }
42
+ : await buildRegistry({ announce: false });
43
+ void clients;
44
+ // Live output state: reasoning arrives dimmed and prefixed, final text plain.
45
+ const zt = strings((cfg.locale ?? 'zh'));
46
+ let displayMode = 'none';
47
+ let streamedText = false;
48
+ const openMode = (m) => {
49
+ if (displayMode !== m) {
50
+ if (displayMode === 'reasoning')
51
+ stdout.write('\n');
52
+ if (m === 'reasoning')
53
+ stdout.write(DIM('\n' + zt.thinkingLabel));
54
+ displayMode = m;
55
+ }
56
+ };
57
+ const result = await runAgentTask({
58
+ task,
59
+ registry: reg,
60
+ cfg,
61
+ yes: taskOpts.yes,
62
+ resumeMessages: taskOpts.resumeMessages,
63
+ events: {
64
+ onLine: (l) => stdout.write(DIM(` ${l}\n`)),
65
+ onDelta: (kind, chunk) => {
66
+ if (kind === 'reasoning') {
67
+ openMode('reasoning');
68
+ stdout.write(DIM(chunk));
69
+ }
70
+ else {
71
+ openMode('text');
72
+ streamedText = true;
73
+ stdout.write(chunk);
74
+ }
75
+ },
76
+ onToolCall: (name, args) => {
77
+ if (displayMode !== 'none') {
78
+ stdout.write('\n');
79
+ displayMode = 'none';
80
+ }
81
+ stdout.write(DIM(` [tool] ${name} ${JSON.stringify(args).slice(0, 100)}\n`));
82
+ },
83
+ onToolResult: (name, output, isError) => {
84
+ if (isError)
85
+ stdout.write(DIM(` [${name} ERROR] ${output.slice(0, 160)}\n`));
86
+ },
87
+ },
88
+ });
89
+ stdout.write(streamedText ? '\n\n' : '\n' + result.text + '\n\n');
90
+ stdout.write(DIM(`(session ${result.sessionId} · ${result.turns} turns · ${result.toolUses} tool uses)\n`));
91
+ // working transcript minus the system prompt and the task line we appended
92
+ return { messages: result.messages, sessionId: result.sessionId };
93
+ }
94
+ async function repl(yes, initialHistory) {
95
+ const home = homeDir();
96
+ let cfg = await loadConfig();
97
+ let autoApprove = yes;
98
+ let t = strings((cfg.locale ?? 'zh'));
99
+ const header = () => stdout.write(CYAN('hmh') + DIM(` · ${cfg.provider.model} · ${home}\n`));
100
+ stdout.write(CYAN('hmh') + DIM(` · ${cfg.provider.model} · ${home}\n`) + DIM(`${t.replHint} · /help ${String(t.cmdHelp)}\n\n`));
101
+ const { reg, clients } = await buildRegistry();
102
+ const rl = readline.createInterface({ input: stdin, output: stdout });
103
+ // stdin EOF (piped input, closed terminal) must exit the loop - a bare
104
+ // rl.question() promise never settles after close, which would hang
105
+ const closed = new Promise((_, reject) => rl.on('close', () => reject(new Error('stdin closed'))));
106
+ // The REPL keeps conversation memory across its own lines (and any
107
+ // resumed history); each line re-injects fresh memory/skills.
108
+ let history = initialHistory ? [...initialHistory] : [];
109
+ try {
110
+ while (true) {
111
+ let line;
112
+ try {
113
+ line = await Promise.race([rl.question(CYAN('hmh> ')), closed]);
114
+ }
115
+ catch {
116
+ break;
117
+ }
118
+ line = line.trim();
119
+ if (!line)
120
+ continue;
121
+ if (line === '/exit' || line === '/quit')
122
+ break;
123
+ if (line.startsWith('/')) {
124
+ // same command set as the TUI palette, line-mode
125
+ if (line === '/yolo' || line === '/yolo on' || line === '/yolo off') {
126
+ const turnOn = line === '/yolo' ? !autoApprove : line === '/yolo on';
127
+ autoApprove = turnOn;
128
+ stdout.write((turnOn ? YELLOW(t.yoloOn) : DIM(t.yoloOff)) + '\n');
129
+ continue;
130
+ }
131
+ if (line === '/lang' || line.startsWith('/lang ')) {
132
+ const { nextLocale } = await import("./tui.js");
133
+ const target = nextLocale(cfg.locale ?? 'zh', line.slice(5));
134
+ cfg = await setLocale(target);
135
+ t = strings(target);
136
+ stdout.write(GREEN('✓') + ' ' + t.langSwitched(target) + '\n');
137
+ continue;
138
+ }
139
+ if (line === '/help' || line === '?') {
140
+ const { COMMANDS } = await import("./tui.js");
141
+ stdout.write(COMMANDS.map((c) => ' ' + c.name.padEnd(11) + ' ' + String(t[c.key])).join('\n') + '\n');
142
+ continue;
143
+ }
144
+ if (line === '/model' || line.startsWith('/model ')) {
145
+ const mArg = line.slice(7).trim();
146
+ if (!mArg) {
147
+ // line-mode REPL has no live palette: the list IS the menu,
148
+ // the hint tells how to act on it (i18n, was hardcoded zh)
149
+ const rows = listProviders(cfg).map((v) => ` ${v.purposes.includes('chat') ? GREEN('●') : DIM('○')} ${v.name} — ${v.model}${v.purposes.length ? DIM(` (${v.purposes.join('/')})`) : ''}`);
150
+ stdout.write(rows.join('\n') + '\n' + DIM(t.cmdModelHint) + '\n');
151
+ continue;
152
+ }
153
+ try {
154
+ cfg = await setChatRoute(mArg);
155
+ stdout.write(GREEN('✓') + ` chat → ${mArg} · ${resolveProvider(cfg, 'chat').model}\n`);
156
+ }
157
+ catch (err) {
158
+ stdout.write(YELLOW(`${String(err)}\n`));
159
+ }
160
+ continue;
161
+ }
162
+ if (line === '/tools') {
163
+ for (const tool of reg.list())
164
+ printTool(tool);
165
+ continue;
166
+ }
167
+ if (line === '/skills') {
168
+ const active = await listSkills(home);
169
+ const drafts = await listDrafts(home);
170
+ stdout.write(CYAN(`${t.active} (${active.length})\n`));
171
+ stdout.write(active.length ? active.map((s) => ` ${s.name} — ${s.description}`).join('\n') + '\n' : DIM(` ${t.none}\n`));
172
+ stdout.write(CYAN(`${t.drafts} (${drafts.length})\n`));
173
+ stdout.write(drafts.length ? drafts.map((s) => ` ${s.name} — ${s.description}`).join('\n') + '\n' : DIM(` ${t.none}\n`));
174
+ continue;
175
+ }
176
+ if (line === '/mcp') {
177
+ for (const [name, c] of Object.entries(cfg.mcpServers ?? {})) {
178
+ stdout.write(` ${name} — ${c.type}${c.trusted ? ' · trusted' : ' · gated'}\n`);
179
+ }
180
+ continue;
181
+ }
182
+ if (line === '/providers' || line === '/providers scan') {
183
+ // same capability as the TUI: probe local keys, optionally write
184
+ // them in (was REPL-missing -> 'unknown command' while /help
185
+ // advertised it, a scan-D gap)
186
+ const { readFile } = await import('node:fs/promises');
187
+ const { detectLocalProviders, addProviders } = await import('@hmharness/kernel');
188
+ const found = await detectLocalProviders(cfg, readFile);
189
+ if (line === '/providers') {
190
+ stdout.write(found.length
191
+ ? found.map((p) => ` ${YELLOW('+')} ${p.name} — ${p.model} (${p.envVar})`).join('\n') + '\n' + DIM(t.cmdProvidersScanHint) + '\n'
192
+ : DIM(t.cmdProvidersListed) + '\n');
193
+ }
194
+ else if (!found.length) {
195
+ stdout.write(DIM(t.cmdProvidersNone) + Object.keys(cfg.providers ?? {}).join(', ') + '\n');
196
+ }
197
+ else {
198
+ const r = await addProviders(found.map((p) => ({ name: p.name, baseUrl: p.baseUrl, model: p.model })));
199
+ cfg = r.cfg;
200
+ stdout.write(GREEN('✓') + ' ' + t.cmdProvidersAdded(r.added.length, r.added.join(', ')) + '\n');
201
+ }
202
+ continue;
203
+ }
204
+ if (line === '/clear') {
205
+ // line-mode twin of the TUI /clear: clear the conversation so the
206
+ // next task starts fresh (REPL counterpart was missing)
207
+ history = [];
208
+ stdout.write(DIM(t.cmdClearDone) + '\n');
209
+ continue;
210
+ }
211
+ if (line === '/status') {
212
+ header();
213
+ continue;
214
+ }
215
+ if (line === '/web') {
216
+ stdout.write(DIM(t.tuiWebHint + '\n'));
217
+ continue;
218
+ }
219
+ if (line === '/ops' || line === '/ops scan') {
220
+ const { harmonyOpsStatus, harmonyOpsRadarScan } = await import('@hmharness/domain-ops');
221
+ const r = line === '/ops'
222
+ ? await harmonyOpsStatus.execute({}, { cwd: process.cwd(), home })
223
+ : await harmonyOpsRadarScan.execute({}, { cwd: process.cwd(), home });
224
+ stdout.write(r.output + '\n');
225
+ continue;
226
+ }
227
+ if (line === '/bench') {
228
+ const { results, passRate } = await runBench(home, (c) => makeCaseRunner()(c, ''));
229
+ for (const r of results)
230
+ stdout.write(`${r.pass ? GREEN(t.pass) : YELLOW(t.fail)} ${r.name} — ${r.detail}\n`);
231
+ stdout.write(`pass rate: ${(passRate * 100).toFixed(0)}%\n`);
232
+ continue;
233
+ }
234
+ if (line === '/evolve') {
235
+ const report = await runEvolution({
236
+ home,
237
+ provider: resolveProvider(cfg, 'evolve'),
238
+ runCase: makeCaseRunner(),
239
+ log: (l) => stdout.write(DIM(` ${l}\n`)),
240
+ });
241
+ stdout.write(t.tuiEvolveDone(report.proposals.length, report.insightCount, report.noteCount) + '\n');
242
+ continue;
243
+ }
244
+ stdout.write(YELLOW(t.unknownCommand(line) + '\n'));
245
+ continue;
246
+ }
247
+ try {
248
+ const r = await runTask(line, { yes: autoApprove, sharedRl: rl, registry: reg, clients, resumeMessages: history });
249
+ // working transcript = [system, ...resumeMessages, user, ...new turns];
250
+ // only the NEW turns (past the replayed prefix) extend history.
251
+ history = [...history, { role: 'user', content: line }, ...r.messages.slice(history.length + 2)];
252
+ }
253
+ catch (err) {
254
+ stdout.write(`error: ${String(err)}\n`);
255
+ }
256
+ }
257
+ }
258
+ finally {
259
+ rl.close();
260
+ for (const c of clients)
261
+ c.close();
262
+ }
263
+ }
264
+ /**
265
+ * Bench case runner shared by `hmh bench` and `hmh evolve`. Tool cases run
266
+ * through the real loop (native tools only - no MCP, no approvals: gated
267
+ * tools deny safely, keeping runs deterministic and side-effect free).
268
+ */
269
+ function makeCaseRunner() {
270
+ return async (c, skillsPrompt) => {
271
+ const cfg = await loadConfig();
272
+ if (!c.tools) {
273
+ const r = await chat(resolveProvider(cfg, 'bench'), [{ role: 'user', content: c.prompt }]);
274
+ return r.message.content ?? '';
275
+ }
276
+ const reg = new Registry();
277
+ reg.registerAll(baseTools).registerAll(harmonyTools);
278
+ const system = buildSystemPrompt({
279
+ cwd: process.cwd(),
280
+ home: homeDir(),
281
+ memory: '',
282
+ skills: skillsPrompt,
283
+ insights: '',
284
+ model: cfg.provider.model,
285
+ });
286
+ const res = await runLoop({
287
+ provider: resolveProvider(cfg, 'bench'),
288
+ registry: reg,
289
+ messages: [
290
+ { role: 'system', content: system },
291
+ { role: 'user', content: c.prompt },
292
+ ],
293
+ ctx: { cwd: process.cwd(), home: homeDir() },
294
+ maxTurns: 6,
295
+ });
296
+ return res.text;
297
+ };
298
+ }
299
+ function printTool(t) {
300
+ stdout.write(` ${t.name}${t.needsApproval ? YELLOW(' [gated]') : ''} — ${t.description.split('\n')[0].slice(0, 100)}\n`);
301
+ }
302
+ async function main() {
303
+ const rawArgs = process.argv.slice(2);
304
+ // --yolo is the Claude-Code-style alias of --yes (auto-approve gates;
305
+ // the kernel's destructive-command hard-deny always stays on)
306
+ const yes = rawArgs.some((a) => a === '--yes' || a === '-y' || a === '--yolo');
307
+ // --locale=zh|en overrides the configured locale for this run (kernel's
308
+ // loadConfig honours HMH_LOCALE), so every command - task, REPL, TUI, web -
309
+ // picks it up without touching config.json
310
+ const localeArg = rawArgs.find((a) => a.startsWith('--locale=') && a.length > 9);
311
+ if (localeArg === '--locale=zh' || localeArg === '--locale=en')
312
+ process.env.HMH_LOCALE = localeArg.slice(9);
313
+ const args = rawArgs.filter((a) => a !== '--yes' && a !== '-y' && a !== '--yolo' && !a.startsWith('--locale='));
314
+ const [cmd, ...rest] = args;
315
+ const arg = rest.join(' ');
316
+ if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
317
+ stdout.write(`hmh - self-evolving agent harness for HarmonyOS development
318
+
319
+ usage:
320
+ hmh "do something" one-shot task (full agent loop, streaming)
321
+ hmh interactive REPL (conversation memory kept, /help for commands)
322
+ hmh resume [id-prefix] continue a past session by id prefix (or latest)
323
+ hmh web start|stop|status web UI as a silent background daemon (no window,
324
+ survives closing everything; log ~/.hmharness/web.log)
325
+ hmh web [--port=7788] web UI in the foreground (debugging)
326
+ hmh tui [--no-web] fullscreen terminal UI (slash palette, mouse wheel);
327
+ also starts the web UI in the background (--no-web skips)
328
+ hmh ops [scan|brief|status] ops keeper: ecosystem radar
329
+ hmh devices|check direct tool run, no model
330
+ hmh tools list all registered tools (native + MCP)
331
+ hmh mcp show configured MCP servers and their tools
332
+ hmh evolve [--every=N] self-evolution cycle (or resident loop)
333
+ hmh bench run the evolution bench
334
+ hmh skills [--promote|--rollback|--unpromote <name>]
335
+ hmh skills add <git-url-or-local-dir> install skills (multi-skill packs supported)
336
+
337
+ flags:
338
+ --yes / -y auto-approve gated tools (else they prompt; non-TTY denies)
339
+ --locale=zh|en override the UI locale for this run
340
+ --help | -h this help
341
+ `);
342
+ return;
343
+ }
344
+ if (cmd === 'init') {
345
+ const { home, created } = await initHome();
346
+ stdout.write(`home: ${home}\n${created.length ? 'created: ' + created.join(', ') : 'already initialized.'}\n`);
347
+ return;
348
+ }
349
+ if (cmd === 'devices' || cmd === 'check') {
350
+ await initHome();
351
+ const { reg } = await buildRegistry({ mcp: false, announce: false });
352
+ const tool = reg.get(cmd === 'devices' ? 'harmony_devices' : 'harmony_toolchain_check');
353
+ const r = await tool.execute({}, { cwd: process.cwd(), home: homeDir() });
354
+ stdout.write(r.output + '\n');
355
+ return;
356
+ }
357
+ if (cmd === 'tools') {
358
+ await initHome();
359
+ const { reg, clients } = await buildRegistry({ announce: false });
360
+ stdout.write(CYAN('native tools\n'));
361
+ for (const t of reg.list())
362
+ if (!t.name.startsWith('mcp_'))
363
+ printTool(t);
364
+ const mcp = reg.list().filter((t) => t.name.startsWith('mcp_'));
365
+ if (mcp.length > 0) {
366
+ stdout.write(CYAN('mcp tools\n'));
367
+ for (const t of mcp)
368
+ printTool(t);
369
+ }
370
+ for (const c of clients)
371
+ c.close();
372
+ return;
373
+ }
374
+ if (cmd === 'mcp') {
375
+ await initHome();
376
+ const cfg = await loadConfig();
377
+ const servers = Object.entries(cfg.mcpServers ?? {});
378
+ if (servers.length === 0) {
379
+ stdout.write('No MCP servers configured. Add them to HMH_HOME/config.json, e.g.\n'
380
+ + '{ "mcpServers": { "fetch": { "type": "stdio", "command": "npx", "args": ["-y", "mcp-server-fetch"] } } }\n');
381
+ return;
382
+ }
383
+ for (const [name, raw] of servers) {
384
+ try {
385
+ const sc = raw.type === 'http'
386
+ ? { type: 'http', url: raw.url ?? '', headers: raw.headers, trusted: raw.trusted }
387
+ : { type: 'stdio', command: raw.command ?? '', args: raw.args, env: raw.env, trusted: raw.trusted };
388
+ const { client, tools } = await mcpServerTools(name, sc);
389
+ stdout.write(CYAN(`${name}`) + DIM(` (${raw.type}${raw.trusted ? ', trusted' : ''}) — ${tools.length} tools\n`));
390
+ for (const t of tools)
391
+ printTool(t);
392
+ client.close();
393
+ }
394
+ catch (err) {
395
+ stdout.write(YELLOW(`${name}: unavailable (${String(err).slice(0, 160)})\n`));
396
+ }
397
+ }
398
+ return;
399
+ }
400
+ if (cmd === 'skills') {
401
+ const home = homeDir();
402
+ const flag = rest.find((a) => a.startsWith('--'));
403
+ const skillName = rest.find((a) => !a.startsWith('-') && !a.startsWith('--') && a !== 'add');
404
+ if (rest[0] === 'add' || flag === '--add') {
405
+ // install from a git URL or local dir (multi-skill packs supported)
406
+ if (!skillName) {
407
+ stdout.write(`usage: hmh skills add <git-url-or-local-dir>\n`);
408
+ return;
409
+ }
410
+ const { installSkills } = await import('@hmharness/evolution');
411
+ try {
412
+ const r = await installSkills(skillName, home);
413
+ stdout.write(r.installed.length
414
+ ? GREEN('✓') + ` installed ${r.installed.length} skill(s): ${r.installed.join(', ')}\n` + DIM(`verify: hmh skills\n`)
415
+ : DIM('no installable skills found (looked for SKILL.md at root, skills/*/, or */SKILL.md)\n'));
416
+ if (r.skipped.length)
417
+ stdout.write(DIM(`skipped (already present): ${r.skipped.join(', ')}\n`));
418
+ }
419
+ catch (err) {
420
+ stdout.write(YELLOW(`install failed: ${String(err).slice(0, 300)}\n`));
421
+ }
422
+ return;
423
+ }
424
+ if (flag === '--promote' || flag === '--rollback' || flag === '--unpromote') {
425
+ if (!skillName) {
426
+ stdout.write(`usage: hmh skills --promote|--rollback|--unpromote <name>\n`);
427
+ return;
428
+ }
429
+ if (flag === '--promote') {
430
+ const r = await promoteSkill(home, skillName);
431
+ stdout.write(`promoted ${skillName} -> active (${r.file}${r.archivedPrevious ? '; previous archived' : ''})\n`);
432
+ }
433
+ else if (flag === '--rollback') {
434
+ stdout.write((await rollbackSkill(home, skillName)) ? `rolled back ${skillName} to the previous archived version\n` : `no archived snapshot of ${skillName}\n`);
435
+ }
436
+ else {
437
+ stdout.write((await unpromoteSkill(home, skillName)) ? `moved ${skillName} back to drafts\n` : `${skillName} is not active\n`);
438
+ }
439
+ return;
440
+ }
441
+ const active = await listSkills(home);
442
+ const drafts = await listDrafts(home);
443
+ stdout.write(CYAN(`active (${active.length})\n`));
444
+ stdout.write(active.length ? active.map((s) => ` ${s.name} — ${s.description}`).join('\n') + '\n' : ' (none)\n');
445
+ stdout.write(CYAN(`drafts (${drafts.length})\n`));
446
+ stdout.write(drafts.length ? drafts.map((s) => ` ${s.name} — ${s.description}`).join('\n') + '\n' : ' (none)\n');
447
+ return;
448
+ }
449
+ if (cmd === 'bench') {
450
+ await initHome();
451
+ // `hmh bench --impact`: the canary A/B report - which experimental
452
+ // skills earned full activation on evidence, which retired, which
453
+ // need more data. The observability half of self-evolution (P0).
454
+ if (rest.includes('--impact')) {
455
+ const { impactReport } = await import('@hmharness/evolution');
456
+ const { rows, applied } = await impactReport(homeDir());
457
+ if (rows.length === 0) {
458
+ stdout.write(DIM('no canary skills under evaluation\n'));
459
+ return;
460
+ }
461
+ for (const r of rows) {
462
+ const badge = r.verdict === 'promote' ? GREEN('PROMOTE') : r.verdict === 'retire' ? YELLOW('RETIRE') : r.verdict === 'keep' ? GREEN('KEEP') : DIM('needs data');
463
+ stdout.write(`${badge.padEnd(10)} ${r.skill} — exposed ${r.exposed.sessions}s/${(r.exposed.okRate * 100).toFixed(0)}% vs control ${r.control.sessions}s/${(r.control.okRate * 100).toFixed(0)}%\n`);
464
+ }
465
+ if (applied.length)
466
+ stdout.write('\n' + applied.map((a) => CYAN('✓ ') + a).join('\n') + '\n');
467
+ else
468
+ stdout.write(DIM('\n(no verdicts strong enough to act on - honesty over noise)\n'));
469
+ return;
470
+ }
471
+ const runner = makeCaseRunner();
472
+ const { results, passRate } = await runBench(homeDir(), (c) => runner(c, ''));
473
+ for (const r of results)
474
+ stdout.write(`${r.pass ? GREEN('PASS') : YELLOW('FAIL')} ${r.name} — ${r.detail}\n`);
475
+ stdout.write(`pass rate: ${(passRate * 100).toFixed(0)}%\n`);
476
+ return;
477
+ }
478
+ if (cmd === 'evolve') {
479
+ await initHome();
480
+ const cfg = await loadConfig();
481
+ const maxN = Number(rest.find((a) => a.startsWith('--max='))?.slice(6) ?? 2);
482
+ const everyMin = Number(rest.find((a) => a.startsWith('--every='))?.slice(8) ?? 0);
483
+ const maxCycles = Number(rest.find((a) => a.startsWith('--cycles='))?.slice(9) ?? 0);
484
+ const t = await uiStrings();
485
+ const runCycle = async (n) => {
486
+ stdout.write(CYAN('evolve') + DIM(` · ${t.evolveCycle(cfg.provider.model, n)}${everyMin ? '' : ' (one-shot)'}\n`));
487
+ const report = await runEvolution({
488
+ home: homeDir(),
489
+ provider: resolveProvider(cfg, 'evolve'),
490
+ runCase: makeCaseRunner(),
491
+ maxProposals: Number.isFinite(maxN) ? Math.min(Math.max(maxN, 0), 4) : 2,
492
+ log: (l) => stdout.write(DIM(` ${l}\n`)),
493
+ });
494
+ for (const o of report.outcomes) {
495
+ const tag = o.action === 'promoted' ? GREEN(t.promoted) : o.action === 'rejected' ? YELLOW(t.rejected) : YELLOW(t.errorLabel);
496
+ stdout.write(`${tag} ${o.name} — ${o.reason}\n`);
497
+ }
498
+ if (report.memoryDistilled)
499
+ stdout.write(DIM(`memory distilled: ${report.memoryDistilled}\n`));
500
+ };
501
+ if (everyMin >= 1) {
502
+ // Scheduled mode: run a cycle, sleep, repeat. Errors don't kill the
503
+ // loop (transient gateway failures are expected); Ctrl-C exits.
504
+ const waitMs = Math.max(everyMin, 5) * 60_000;
505
+ const cap = maxCycles > 0 ? maxCycles : Infinity;
506
+ for (let n = 1; n <= cap; n++) {
507
+ try {
508
+ await runCycle(n);
509
+ }
510
+ catch (err) {
511
+ stdout.write(YELLOW(`cycle ${n} failed: ${String(err).slice(0, 160)} (continuing)\n`));
512
+ }
513
+ if (n >= cap)
514
+ break;
515
+ stdout.write(DIM(`next cycle in ${Math.max(everyMin, 5)} min (Ctrl-C to stop)\n`));
516
+ await new Promise((r) => setTimeout(r, waitMs));
517
+ }
518
+ stdout.write(DIM(`log: ${homeDir()}/evolution/log.jsonl\n`));
519
+ return;
520
+ }
521
+ await runCycle(1);
522
+ stdout.write(DIM(`log: ${homeDir()}/evolution/log.jsonl\n`));
523
+ return;
524
+ }
525
+ if (cmd === 'providers') {
526
+ await initHome();
527
+ const cfg = await loadConfig();
528
+ const { readFile } = await import('node:fs/promises');
529
+ const { detectLocalProviders, listProviders, PROVIDER_PRESETS, addProviders } = await import('@hmharness/kernel');
530
+ stdout.write(CYAN(`configured (${listProviders(cfg).length})\n`));
531
+ for (const v of listProviders(cfg)) {
532
+ stdout.write(` ${v.purposes.includes('chat') ? GREEN('●') : DIM('○')} ${v.name} — ${v.model}${v.purposes.length ? DIM(` (${v.purposes.join('/')})`) : ''}\n`);
533
+ }
534
+ const found = await detectLocalProviders(cfg, readFile);
535
+ if (!found.length) {
536
+ stdout.write(DIM('no new local providers detected (env vars / opencode config)\n'));
537
+ return;
538
+ }
539
+ stdout.write(CYAN(`detected locally (${found.length})\n`));
540
+ for (const p of found)
541
+ stdout.write(` ${YELLOW('+')} ${p.name} — ${p.model} (${p.envVar})\n`);
542
+ if (rest.includes('--scan')) {
543
+ const r = await addProviders(found.map((p) => ({ name: p.name, baseUrl: p.baseUrl, model: p.model })));
544
+ stdout.write(GREEN('✓') + ` added ${r.added.length}: ${r.added.join(', ')} — hmh /model or hmh tui "/model <name>" to use\n`);
545
+ }
546
+ else {
547
+ stdout.write(DIM('run "hmh providers --scan" to add them to config.json\n'));
548
+ }
549
+ void PROVIDER_PRESETS;
550
+ return;
551
+ }
552
+ if (cmd === 'ops') {
553
+ await initHome();
554
+ const { harmonyOpsRadarScan, harmonyOpsRadarBrief, harmonyOpsStatus } = await import('@hmharness/domain-ops');
555
+ const sub = rest[0] ?? 'status';
556
+ const ctx = { cwd: process.cwd(), home: homeDir() };
557
+ if (sub === 'scan') {
558
+ const r = await harmonyOpsRadarScan.execute({}, ctx);
559
+ stdout.write(r.output + '\n');
560
+ }
561
+ else if (sub === 'brief') {
562
+ const r = await harmonyOpsRadarBrief.execute({}, ctx);
563
+ stdout.write(r.output + '\n');
564
+ }
565
+ else {
566
+ const r = await harmonyOpsStatus.execute({}, ctx);
567
+ stdout.write(r.output + '\n');
568
+ }
569
+ return;
570
+ }
571
+ if (cmd === 'tui') {
572
+ await initHome();
573
+ // tui(yes, noWeb): inside the TTY check the TUI auto-links the web UI
574
+ const { tui } = await import("./tui.js");
575
+ await tui(yes, rest.includes('--no-web'));
576
+ return;
577
+ }
578
+ if (cmd === 'web') {
579
+ await initHome();
580
+ const port = Number(rest.find((a) => a.startsWith('--port='))?.slice(7) ?? 7788);
581
+ const t = await uiStrings();
582
+ const sub = rest.find((a) => !a.startsWith('-'));
583
+ if (sub === 'stop') {
584
+ stdout.write(stopWebDaemon() ? t.webStopped + '\n' : t.webNotRunning + '\n');
585
+ return;
586
+ }
587
+ if (sub === 'status') {
588
+ const up = await hmhWebUp(Number.isFinite(port) ? port : 7788);
589
+ stdout.write(up ? t.webRunning(readWebPid() || 0, port) + '\n' : t.webNotRunning + '\n');
590
+ return;
591
+ }
592
+ if (sub === 'start') {
593
+ const r = startWebDaemon(Number.isFinite(port) ? port : 7788);
594
+ if (r.already) {
595
+ stdout.write(t.webRunning(r.pid, port) + '\n');
596
+ }
597
+ else {
598
+ stdout.write(t.webStarted(Number.isFinite(port) ? port : 7788, join(homeDir(), 'web.log')) + '\n');
599
+ }
600
+ return;
601
+ }
602
+ // default: foreground server (handy for debugging)
603
+ const { startServer } = await import('@hmharness/web');
604
+ await startServer({ port: Number.isFinite(port) ? port : 7788, host: '127.0.0.1' });
605
+ return; // startServer keeps the process alive
606
+ }
607
+ if (cmd === 'resume') {
608
+ await initHome();
609
+ const file = await latestSession(homeDir(), arg);
610
+ if (!file) {
611
+ stdout.write(arg ? `No session matches prefix "${arg}".\n` : 'No sessions yet.\n');
612
+ return;
613
+ }
614
+ const tr = await loadTranscript(file);
615
+ if (!tr) {
616
+ stdout.write(`Could not parse ${file}\n`);
617
+ return;
618
+ }
619
+ stdout.write(DIM(`resuming ${tr.id} · ${tr.messages.length} messages · model ${tr.model}\n`));
620
+ await repl(yes, tr.messages);
621
+ return;
622
+ }
623
+ if (cmd && !cmd.startsWith('-')) {
624
+ await initHome();
625
+ const { reg, clients } = await buildRegistry({ announce: false });
626
+ try {
627
+ await runTask([cmd, ...rest].join(' '), { yes, registry: reg, clients });
628
+ }
629
+ finally {
630
+ for (const c of clients)
631
+ c.close();
632
+ }
633
+ return;
634
+ }
635
+ await initHome();
636
+ await repl(yes);
637
+ }
638
+ main().catch((err) => {
639
+ console.error(String(err));
640
+ process.exit(1);
641
+ });
@@ -0,0 +1,7 @@
1
+ export declare function buildSystemPrompt(opts: {
2
+ cwd: string;
3
+ memory: string;
4
+ skills: string;
5
+ insights: string;
6
+ model: string;
7
+ }): string;
package/dist/prompt.js ADDED
@@ -0,0 +1,14 @@
1
+ export function buildSystemPrompt(opts) {
2
+ const parts = [];
3
+ parts.push(`You are hmh, a coding agent powered by ${opts.model}, running on hmharness - a self-evolving agent framework designed for the full HarmonyOS development lifecycle. Working directory: ${opts.cwd}.`, '', 'Reply in the language the user writes in (Chinese in, Chinese out).', '', 'HarmonyOS development is your home domain: DevEco Studio toolchain, hvigor builds, ohpm packages, hdc devices, ArkTS/ArkUI, OpenHarmony and Cangjie. When a task touches it, prefer the harmony_* tools and precise toolchain knowledge.', '', 'Working style: read before writing; prefer small focused commands; verify results; state tradeoffs briefly. For risky operations (deleting, overwriting, publishing) say what will happen first.');
4
+ if (opts.memory.trim()) {
5
+ parts.push('', '## Long-term memory', opts.memory.trim());
6
+ }
7
+ if (opts.skills.trim()) {
8
+ parts.push('', '## Skill library', 'Read a skill file with read_file before applying it the first time.', opts.skills.trim());
9
+ }
10
+ if (opts.insights.trim()) {
11
+ parts.push('', '## Recent session outcomes (what worked / what failed)', opts.insights.trim());
12
+ }
13
+ return parts.join('\n');
14
+ }