@nanhara/hara 0.53.0 → 0.67.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 +224 -0
- package/README.md +30 -10
- package/SECURITY.md +54 -0
- package/dist/agent/loop.js +17 -10
- package/dist/completions.js +49 -0
- package/dist/concurrency.js +22 -0
- package/dist/config.js +18 -7
- package/dist/cron/install.js +112 -0
- package/dist/cron/runner.js +109 -0
- package/dist/cron/schedule.js +147 -0
- package/dist/cron/store.js +87 -0
- package/dist/export.js +43 -0
- package/dist/index.js +485 -13
- package/dist/mcp/server.js +56 -0
- package/dist/memory/store.js +44 -6
- package/dist/org/review-chain.js +91 -0
- package/dist/org/roles.js +11 -0
- package/dist/org-fleet/enroll.js +94 -0
- package/dist/providers/qwen-oauth.js +9 -2
- package/dist/recall.js +20 -4
- package/dist/sandbox.js +25 -3
- package/dist/search/semindex.js +9 -2
- package/dist/session/store.js +12 -2
- package/dist/tools/computer.js +9 -4
- package/dist/tools/patch.js +31 -12
- package/dist/tools/web.js +81 -8
- package/dist/tui/App.js +2 -2
- package/dist/tui/InputBox.js +37 -3
- package/dist/tui/vim.js +115 -0
- package/package.json +6 -2
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { runTui } from "./tui/run.js";
|
|
|
6
6
|
import { readClipboardImage } from "./images.js";
|
|
7
7
|
import { describeImages, locateImage, classifyVision, SCREENSHOT_SYSTEM } from "./vision.js";
|
|
8
8
|
import { setTheme } from "./tui/theme.js";
|
|
9
|
-
import { memoryDigest, memoryDir } from "./memory/store.js";
|
|
9
|
+
import { memoryDigest, memoryDir, readRecentLogs, scaffoldMemory } from "./memory/store.js";
|
|
10
10
|
import { nextMode as cycleMode } from "./tui/InputBox.js";
|
|
11
11
|
import { stdin, stdout } from "node:process";
|
|
12
12
|
import { readFileSync, existsSync, writeFileSync, rmSync } from "node:fs";
|
|
@@ -16,6 +16,16 @@ import { dirname, join } from "node:path";
|
|
|
16
16
|
import { loadConfig, configPath, readRawConfig, writeConfigValue, setModelVisionOverride, providerEnvKey, CONFIG_KEYS, APPROVAL_MODES, SANDBOX_MODES, } from "./config.js";
|
|
17
17
|
import { runAgent } from "./agent/loop.js";
|
|
18
18
|
import { notifyDone } from "./notify.js";
|
|
19
|
+
import { startMcpServer, mcpServeToolNames } from "./mcp/server.js";
|
|
20
|
+
import { completionScript } from "./completions.js";
|
|
21
|
+
import { renderSessionMarkdown } from "./export.js";
|
|
22
|
+
import { loadEnrollment, clearEnrollment, enrollDevice, heartbeat, gatewayBaseURL } from "./org-fleet/enroll.js";
|
|
23
|
+
import { mapLimit, maxParallel } from "./concurrency.js";
|
|
24
|
+
import { parseVerdict, captureChanges, reviewPrompt, fixPrompt, REVIEWER_SYSTEM, isTreeClean, stripCommitFence } from "./org/review-chain.js";
|
|
25
|
+
import { parseSchedule, describeSchedule, nextRun } from "./cron/schedule.js";
|
|
26
|
+
import { addJob, removeJob, setEnabled, resolveJob, loadJobs, recordRun, logPath } from "./cron/store.js";
|
|
27
|
+
import { runTick, runJobOnce, selfArgv } from "./cron/runner.js";
|
|
28
|
+
import { installScheduler, uninstallScheduler, isInstalled } from "./cron/install.js";
|
|
19
29
|
import { getTools } from "./tools/registry.js";
|
|
20
30
|
import { createAnthropicProvider } from "./providers/anthropic.js";
|
|
21
31
|
import { createOpenAIProvider } from "./providers/openai.js";
|
|
@@ -26,7 +36,7 @@ import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists
|
|
|
26
36
|
import { searchHybrid } from "./search/hybrid.js";
|
|
27
37
|
import { expandMentions, fileCandidates } from "./context/mentions.js";
|
|
28
38
|
import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, listSessions, latestForCwd, titleFrom, slugify, } from "./session/store.js";
|
|
29
|
-
import { loadRoles, scaffoldRoles } from "./org/roles.js";
|
|
39
|
+
import { loadRoles, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
|
|
30
40
|
import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
|
|
31
41
|
import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers, pluginHooks } from "./plugins/plugins.js";
|
|
32
42
|
import { routeByKeywords, buildDispatchPrompt, parseRoleId } from "./org/router.js";
|
|
@@ -50,7 +60,19 @@ import "./tools/codebase.js"; // register codebase_search (repo as a knowledge b
|
|
|
50
60
|
import "./tools/todo.js"; // register todo_write (inline task checklist)
|
|
51
61
|
import { computerBackends } from "./tools/computer.js"; // register the computer tool + expose the backend probe
|
|
52
62
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
53
|
-
|
|
63
|
+
// Version: from a build-time define in the compiled single-binary (no package.json on its virtual FS),
|
|
64
|
+
// else read package.json (npm install / `node dist`). The read is wrapped so the binary never hits it.
|
|
65
|
+
const pkg = {
|
|
66
|
+
version: process.env.HARA_BUILD_VERSION ??
|
|
67
|
+
((() => {
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")).version;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return "0.0.0";
|
|
73
|
+
}
|
|
74
|
+
})()),
|
|
75
|
+
};
|
|
54
76
|
const maskKey = (v) => (v ? `${v.slice(0, 7)}…${v.slice(-4)}` : "(unset)");
|
|
55
77
|
async function buildProvider(cfg) {
|
|
56
78
|
if (cfg.provider === "qwen-oauth") {
|
|
@@ -59,6 +81,12 @@ async function buildProvider(cfg) {
|
|
|
59
81
|
return null;
|
|
60
82
|
return createOpenAIProvider({ apiKey: auth.accessToken, baseURL: auth.baseURL, model: cfg.model, label: "qwen-oauth" });
|
|
61
83
|
}
|
|
84
|
+
if (cfg.provider === "hara-gateway") {
|
|
85
|
+
const e = loadEnrollment();
|
|
86
|
+
if (!e)
|
|
87
|
+
return null; // not enrolled → `hara enroll`
|
|
88
|
+
return createOpenAIProvider({ apiKey: e.deviceToken, baseURL: gatewayBaseURL(e), model: cfg.model || e.model, label: "hara-gateway" });
|
|
89
|
+
}
|
|
62
90
|
if (!cfg.apiKey)
|
|
63
91
|
return null;
|
|
64
92
|
if (cfg.provider === "anthropic") {
|
|
@@ -69,12 +97,98 @@ async function buildProvider(cfg) {
|
|
|
69
97
|
function authHint(cfg) {
|
|
70
98
|
if (cfg.provider === "qwen-oauth")
|
|
71
99
|
return `Run ${c.bold("hara login qwen")} to authenticate.`;
|
|
72
|
-
return `Set ${c.bold(providerEnvKey(cfg.provider))} (or ${c.bold("HARA_API_KEY")}), or run ${c.bold("hara
|
|
100
|
+
return `Set ${c.bold(providerEnvKey(cfg.provider))} (or ${c.bold("HARA_API_KEY")}), or run ${c.bold("hara setup")}.`;
|
|
101
|
+
}
|
|
102
|
+
const SETUP_DEFAULT_MODEL = { anthropic: "claude-opus-4-8", qwen: "qwen-plus", openai: "gpt-4o-mini", "qwen-oauth": "coder-model" };
|
|
103
|
+
/** Interactive first-run setup: pick a provider, (optional) base URL, API key, and model → ~/.hara/config.json. */
|
|
104
|
+
async function runSetup() {
|
|
105
|
+
if (!stdin.isTTY) {
|
|
106
|
+
out(c.yellow("`hara setup` is interactive — run it in a terminal, or use `hara config set <key> <value>` in scripts.\n"));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
110
|
+
try {
|
|
111
|
+
out(c.bold("hara setup") + c.dim(" — configure a provider, key, and model (Ctrl-C to cancel)\n\n"));
|
|
112
|
+
const provider = ((await rl.question(`Provider ${c.dim("anthropic / qwen / openai")} [anthropic]: `)).trim() || "anthropic").toLowerCase();
|
|
113
|
+
let baseURL = "";
|
|
114
|
+
if (provider === "qwen" || provider === "openai") {
|
|
115
|
+
baseURL = (await rl.question(`Base URL ${c.dim("(blank = default; set for an OpenAI-compatible endpoint, e.g. GLM/Kimi/DashScope)")}: `)).trim();
|
|
116
|
+
}
|
|
117
|
+
const envKey = providerEnvKey(provider);
|
|
118
|
+
const apiKey = (await rl.question(`API key ${c.dim(`(blank = use the ${envKey} env var)`)}: `)).trim();
|
|
119
|
+
const model = (await rl.question(`Model [${SETUP_DEFAULT_MODEL[provider] ?? "?"}]: `)).trim() || SETUP_DEFAULT_MODEL[provider] || "";
|
|
120
|
+
writeConfigValue("provider", provider);
|
|
121
|
+
if (baseURL)
|
|
122
|
+
writeConfigValue("baseURL", baseURL);
|
|
123
|
+
if (apiKey)
|
|
124
|
+
writeConfigValue("apiKey", apiKey);
|
|
125
|
+
if (model)
|
|
126
|
+
writeConfigValue("model", model);
|
|
127
|
+
out(c.green(`\n✓ saved to ${configPath()}\n`) + c.dim(`Check it with ${c.bold("hara doctor")}, then just run ${c.bold("hara")}.\n`));
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
rl.close();
|
|
131
|
+
}
|
|
73
132
|
}
|
|
74
133
|
async function runInit(provider, cwd, sandbox = "off") {
|
|
75
134
|
const history = [{ role: "user", content: INIT_PROMPT }];
|
|
76
135
|
await runAgent(history, { provider, ctx: { cwd, sandbox }, approval: "full-auto", confirm: async () => true });
|
|
77
136
|
}
|
|
137
|
+
/** Stage everything and commit with an AI-written message. Returns a one-line summary or "error: …".
|
|
138
|
+
* Used by `hara org --commit`; the caller guards on a clean start tree so this only captures the run's work. */
|
|
139
|
+
async function autoCommit(provider, cwd) {
|
|
140
|
+
try {
|
|
141
|
+
await runShell("git add -A", cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
/* fall through — empty diff is reported below */
|
|
145
|
+
}
|
|
146
|
+
let diff = "";
|
|
147
|
+
try {
|
|
148
|
+
diff = (await runShell("git diff --staged", cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
return `error: git diff failed (${e instanceof Error ? e.message : String(e)})`;
|
|
152
|
+
}
|
|
153
|
+
if (!diff.trim())
|
|
154
|
+
return "nothing to commit";
|
|
155
|
+
const r = await provider.turn({
|
|
156
|
+
system: COMMIT_SYSTEM,
|
|
157
|
+
history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }],
|
|
158
|
+
tools: [],
|
|
159
|
+
onText: () => { },
|
|
160
|
+
});
|
|
161
|
+
const msg = stripCommitFence(r.text);
|
|
162
|
+
if (!msg)
|
|
163
|
+
return "error: no commit message produced";
|
|
164
|
+
const tmp = join(tmpdir(), `hara-org-commit-${process.pid}.txt`);
|
|
165
|
+
writeFileSync(tmp, msg + "\n", "utf8");
|
|
166
|
+
try {
|
|
167
|
+
const res = await runShell(`git commit -F ${JSON.stringify(tmp)}`, cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
|
|
168
|
+
return (res.stdout || "").trim().split("\n")[0] || "committed";
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
return `error: git commit failed (${e instanceof Error ? e.message : String(e)})`;
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
try {
|
|
175
|
+
rmSync(tmp);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* best-effort cleanup */
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Format an autoCommit result + emit it. */
|
|
183
|
+
async function commitStep(provider, cwd) {
|
|
184
|
+
const r = await autoCommit(provider, cwd);
|
|
185
|
+
if (r.startsWith("error:"))
|
|
186
|
+
out(c.red(`✗ ${r}\n`));
|
|
187
|
+
else if (r === "nothing to commit")
|
|
188
|
+
out(c.dim("(nothing to commit)\n"));
|
|
189
|
+
else
|
|
190
|
+
out(c.green(`✓ committed · ${r.slice(0, 100)}\n`));
|
|
191
|
+
}
|
|
78
192
|
/** Dispatch a task to the owning role and run that role's agent (its persona + tool subset + model). */
|
|
79
193
|
async function runOrg(task, o) {
|
|
80
194
|
const roles = loadRoles(o.cwd);
|
|
@@ -115,7 +229,7 @@ async function runOrg(task, o) {
|
|
|
115
229
|
? (n) => !role.denyTools.includes(n)
|
|
116
230
|
: undefined;
|
|
117
231
|
const history = [{ role: "user", content: expandMentions(task, o.cwd) }];
|
|
118
|
-
|
|
232
|
+
const runImplementer = () => runAgent(history, {
|
|
119
233
|
provider: roleProvider,
|
|
120
234
|
ctx: { cwd: o.cwd, sandbox: o.sandbox },
|
|
121
235
|
approval: o.approval,
|
|
@@ -126,6 +240,61 @@ async function runOrg(task, o) {
|
|
|
126
240
|
systemOverride: role.system,
|
|
127
241
|
toolFilter,
|
|
128
242
|
});
|
|
243
|
+
const wasClean = o.commit ? isTreeClean(o.cwd) : false; // capture BEFORE the implementer edits anything
|
|
244
|
+
const doCommit = async (ok) => {
|
|
245
|
+
if (!o.commit)
|
|
246
|
+
return;
|
|
247
|
+
if (!ok)
|
|
248
|
+
return void out(c.yellow("(not committing — review didn't approve; changes left in your working tree)\n"));
|
|
249
|
+
if (!wasClean)
|
|
250
|
+
return void out(c.yellow("(not auto-committing — the tree wasn't clean before this run; commit manually)\n"));
|
|
251
|
+
await commitStep(o.baseProvider, o.cwd);
|
|
252
|
+
};
|
|
253
|
+
await runImplementer();
|
|
254
|
+
if (!o.review) {
|
|
255
|
+
await doCommit(true);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// Review chain: a reviewer role inspects the diff and APPROVES or sends it back, looping until clean.
|
|
259
|
+
const reviewer = roles.find((r) => r.id === "reviewer");
|
|
260
|
+
const revProvider = reviewer?.model && reviewer.model !== o.cfg.model ? ((await buildProvider({ ...o.cfg, model: reviewer.model })) ?? o.baseProvider) : o.baseProvider;
|
|
261
|
+
const revSystem = reviewer?.system ?? REVIEWER_SYSTEM;
|
|
262
|
+
const revTools = reviewer?.allowTools ? (n) => reviewer.allowTools.includes(n) : (n) => READONLY_TOOLS.has(n);
|
|
263
|
+
const maxRounds = Math.max(1, o.rounds ?? 3);
|
|
264
|
+
for (let round = 1; round <= maxRounds; round++) {
|
|
265
|
+
const changes = captureChanges(o.cwd);
|
|
266
|
+
if (!changes.diff && !changes.newFiles.length) {
|
|
267
|
+
out(c.dim("(no changes to review)\n"));
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
out(c.dim(`🔍 reviewer · round ${round}/${maxRounds}\n`));
|
|
271
|
+
const rHist = [{ role: "user", content: reviewPrompt(task, changes) }];
|
|
272
|
+
await runAgent(rHist, {
|
|
273
|
+
provider: revProvider,
|
|
274
|
+
ctx: { cwd: o.cwd, sandbox: o.sandbox },
|
|
275
|
+
approval: "full-auto", // reviewer is read-only via revTools, so nothing to confirm
|
|
276
|
+
confirm: o.confirm,
|
|
277
|
+
projectContext: o.projectContext,
|
|
278
|
+
memory: memoryDigest(o.cwd),
|
|
279
|
+
stats: o.stats,
|
|
280
|
+
systemOverride: revSystem,
|
|
281
|
+
toolFilter: revTools,
|
|
282
|
+
});
|
|
283
|
+
const verdict = parseVerdict(lastAssistantText(rHist));
|
|
284
|
+
if (verdict.approved) {
|
|
285
|
+
out(c.green(`✓ reviewer approved after ${round} round(s)\n`));
|
|
286
|
+
await doCommit(true);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (round === maxRounds) {
|
|
290
|
+
out(c.yellow(`⚠ stopped after ${maxRounds} round(s) — reviewer still wants changes.\n`));
|
|
291
|
+
await doCommit(false);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
out(c.yellow(`✗ changes requested — back to ${role.id} (round ${round})\n`));
|
|
295
|
+
history.push({ role: "user", content: fixPrompt(verdict.issues) });
|
|
296
|
+
await runImplementer();
|
|
297
|
+
}
|
|
129
298
|
}
|
|
130
299
|
function lastAssistantText(history) {
|
|
131
300
|
for (let i = history.length - 1; i >= 0; i--) {
|
|
@@ -190,7 +359,7 @@ async function executePlan(plan, roles, o) {
|
|
|
190
359
|
if (!todo.length)
|
|
191
360
|
continue; // whole wave already complete (resume)
|
|
192
361
|
out(c.cyan(`\n▶ wave [${todo.map((a) => a.id).join(", ")}] — ${todo.length} in parallel\n`));
|
|
193
|
-
const results = await
|
|
362
|
+
const results = await mapLimit(todo, maxParallel(), (atom) => executeAtom(atom, plan, done, roles, o)); // bounded
|
|
194
363
|
todo.forEach((atom, i) => {
|
|
195
364
|
if (results[i]) {
|
|
196
365
|
done.push(atom);
|
|
@@ -312,6 +481,11 @@ const PLAN_SYSTEM = "You are in PLAN MODE. Investigate read-only (read_file / gr
|
|
|
312
481
|
"End your message with the plan as a short numbered list.";
|
|
313
482
|
const DISTILL_SYSTEM = "The session is ending. Reflect and persist only durable, reusable learnings: memory_write for facts / " +
|
|
314
483
|
"conventions / the user's preferences, skill_create for reusable how-tos. Be selective — skip the trivial. Then reply DONE.";
|
|
484
|
+
const MEMORY_DISTILL_SYSTEM = "You consolidate an agent's short-term daily memory logs into its durable long-term memory. You're given " +
|
|
485
|
+
"the current durable memory and recent daily logs. Extract ONLY durable, reusable facts / decisions / " +
|
|
486
|
+
"conventions / user preferences from the logs that are NOT already captured, and persist each with " +
|
|
487
|
+
"memory_write (target=memory, or target=user for preferences; pick the right scope=project|global). " +
|
|
488
|
+
"Skip the ephemeral, the one-off, and anything already known. Be terse and de-duplicated. Then reply DONE.";
|
|
315
489
|
const COMPACT_SYSTEM = "Summarize the conversation so far into a concise but complete brief so the assistant can " +
|
|
316
490
|
"continue seamlessly: the user's goal, key decisions, files changed, current state, and open next steps. " +
|
|
317
491
|
"Be specific. Output only the summary.";
|
|
@@ -326,11 +500,10 @@ async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stat
|
|
|
326
500
|
const roles = loadRoles(cwd);
|
|
327
501
|
const role = roleId ? roles.find((r) => r.id === roleId) : undefined;
|
|
328
502
|
const provider = role?.model && role.model !== cfg.model ? ((await buildProvider({ ...cfg, model: role.model })) ?? baseProvider) : baseProvider;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
: (n) => READONLY_TOOLS.has(n); // default sub-agent = read-only (safe to parallelize)
|
|
503
|
+
// A sub-agent runs full-auto + UNCONFIRMED + parallel, so it is ALWAYS read-only — a role may narrow
|
|
504
|
+
// further but can never GRANT write/exec to a fan-out sub-agent (that would bypass the approval gate).
|
|
505
|
+
// Write-capable roles run in the main loop via `hara org`, behind the user's gate.
|
|
506
|
+
const toolFilter = subagentToolFilter(role, (n) => READONLY_TOOLS.has(n));
|
|
334
507
|
const subHistory = [{ role: "user", content: task }];
|
|
335
508
|
await runAgent(subHistory, {
|
|
336
509
|
provider,
|
|
@@ -377,9 +550,11 @@ function runDoctor(cfg) {
|
|
|
377
550
|
`${dot} vision · ${c.bold(cfg.model)} ${vdesc}${cfg.visionModel ? c.dim(" · describer ") + c.bold(cfg.visionModel) : vcap === "text" ? c.yellow(" · set /vision <model>") : ""}`,
|
|
378
551
|
`${dot} screen ${cfg.computerUse === "off" ? c.dim("off (hara config set computerUse read|click|full)") : c.bold(cfg.computerUse) + c.dim(` · ${computerBackends()}${cfg.computerApps.length ? " · apps: " + cfg.computerApps.join(", ") : " · no app allowlist"}`)}`,
|
|
379
552
|
`${dot} plugins ${(() => { const inst = listInstalled(); const on = enabledPlugins().length; return inst.length ? c.dim(`${on}/${inst.length} enabled: ${inst.map((p) => p.name).slice(0, 6).join(", ")}`) : c.dim("none — hara plugin add <source>"); })()}`,
|
|
380
|
-
`${dot} mcp
|
|
553
|
+
`${dot} mcp ${c.dim(`client: ${Object.keys({ ...pluginMcpServers(), ...cfg.mcpServers }).length} server(s) · serve: ${mcpServeToolNames().length} read tools via \`hara mcp\``)}`,
|
|
381
554
|
`${dot} hooks ${(() => { const ph = pluginHooks(); const pre = (cfg.hooks.PreToolUse ?? []).length + (ph.PreToolUse ?? []).length; const post = (cfg.hooks.PostToolUse ?? []).length + (ph.PostToolUse ?? []).length; return pre + post ? c.dim(`${pre} pre · ${post} post`) : c.dim("none — config.json \"hooks\""); })()}`,
|
|
382
555
|
`${dot} notify ${cfg.notify === "off" ? c.dim("off — hara config set notify bell|system") : c.bold(cfg.notify)}`,
|
|
556
|
+
`${dot} cron ${(() => { const n = loadJobs().length; return n ? `${n} job(s) · ${isInstalled() ? c.green("scheduler installed") : c.yellow("scheduler off — hara cron install")}` : c.dim("no jobs — hara cron add"); })()}`,
|
|
557
|
+
`${dot} input ${cfg.vimMode ? c.bold("vim") + c.dim(" (modal)") : c.dim("default — hara config set vimMode true for vim keys")}`,
|
|
383
558
|
];
|
|
384
559
|
return lines.join("\n");
|
|
385
560
|
}
|
|
@@ -434,8 +609,11 @@ program
|
|
|
434
609
|
});
|
|
435
610
|
program
|
|
436
611
|
.command("org <task...>")
|
|
437
|
-
.description("dispatch a task to the owning role and run it")
|
|
612
|
+
.description("dispatch a task to the owning role and run it (--review loops a reviewer until it approves)")
|
|
438
613
|
.option("--role <id>", "force a specific role")
|
|
614
|
+
.option("--review", "after implementing, loop a reviewer role until it approves (implement → review → fix)")
|
|
615
|
+
.option("--rounds <n>", "max review rounds with --review (default 3)", (v) => parseInt(v, 10))
|
|
616
|
+
.option("--commit", "commit the result with an AI message (with --review: only after approval; needs a clean start tree)")
|
|
439
617
|
.action(async (taskParts, opts2) => {
|
|
440
618
|
const cfg = loadConfig();
|
|
441
619
|
const provider = await buildProvider(cfg);
|
|
@@ -454,6 +632,9 @@ program
|
|
|
454
632
|
projectContext: loadAgentsMd(cfg.cwd) || undefined,
|
|
455
633
|
stats,
|
|
456
634
|
forceRole: opts2.role,
|
|
635
|
+
review: opts2.review,
|
|
636
|
+
rounds: opts2.rounds,
|
|
637
|
+
commit: opts2.commit,
|
|
457
638
|
});
|
|
458
639
|
if (stats.input || stats.output)
|
|
459
640
|
out(statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
@@ -555,6 +736,80 @@ program
|
|
|
555
736
|
.command("doctor")
|
|
556
737
|
.description("check your hara setup (provider / auth / model / node / assets / roles)")
|
|
557
738
|
.action(() => out(runDoctor(loadConfig()) + "\n"));
|
|
739
|
+
program
|
|
740
|
+
.command("setup")
|
|
741
|
+
.description("interactive first-run setup — pick a provider, API key, and model")
|
|
742
|
+
.action(runSetup);
|
|
743
|
+
program
|
|
744
|
+
.command("enroll [gateway-url]")
|
|
745
|
+
.description("B-end: join a fleet — trade a one-time code for a device token (routes hara through your org's gateway; no provider key on this device)")
|
|
746
|
+
.option("--code <code>", "enrollment code from your hara-control admin")
|
|
747
|
+
.option("--status", "show the current enrollment")
|
|
748
|
+
.option("--clear", "remove the enrollment (revert to your own provider config)")
|
|
749
|
+
.action(async (gatewayUrl, opts) => {
|
|
750
|
+
if (opts.status) {
|
|
751
|
+
const e = loadEnrollment();
|
|
752
|
+
return void out(e ? c.green("enrolled") + c.dim(` · ${e.gatewayUrl} · device ${e.deviceId || "?"} · model ${e.model || "(gateway default)"} · since ${e.enrolledAt}\n`) : c.dim("Not enrolled — `hara enroll <gateway-url> --code <code>`.\n"));
|
|
753
|
+
}
|
|
754
|
+
if (opts.clear)
|
|
755
|
+
return void out(clearEnrollment() ? c.green("✓ enrollment cleared — set your own provider with `hara setup`.\n") : c.dim("(not enrolled)\n"));
|
|
756
|
+
if (!gatewayUrl)
|
|
757
|
+
return void out(c.red("usage: hara enroll <gateway-url> --code <code> (or --status / --clear)\n"));
|
|
758
|
+
if (!opts.code)
|
|
759
|
+
return void out(c.red("Need --code <code> — ask your hara-control admin to issue an enrollment code.\n"));
|
|
760
|
+
try {
|
|
761
|
+
const e = await enrollDevice(gatewayUrl, opts.code);
|
|
762
|
+
writeConfigValue("provider", "hara-gateway");
|
|
763
|
+
if (e.model)
|
|
764
|
+
writeConfigValue("model", e.model);
|
|
765
|
+
out(c.green(`✓ enrolled with ${e.gatewayUrl}`) + c.dim(` · device ${e.deviceId || "?"} · model ${e.model || "(gateway default)"}\n`) + c.dim("hara routes through the gateway now — the real provider key stays server-side.\n"));
|
|
766
|
+
}
|
|
767
|
+
catch (err) {
|
|
768
|
+
out(c.red(`Enroll failed: ${err instanceof Error ? err.message : String(err)}\n`));
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
program
|
|
772
|
+
.command("export [session]")
|
|
773
|
+
.description("export a session to a Markdown transcript (default: the latest in this directory)")
|
|
774
|
+
.option("--out <file>", "write to a file instead of stdout")
|
|
775
|
+
.action((sessionArg, opts) => {
|
|
776
|
+
const data = sessionArg ? (() => { const id = resolveSessionId(sessionArg); return id ? loadSession(id) : null; })() : latestForCwd(process.cwd());
|
|
777
|
+
if (!data)
|
|
778
|
+
return void out(c.red(sessionArg ? `No session matching '${sessionArg}'.\n` : "No session for this directory — pass an id (see `hara sessions`).\n"));
|
|
779
|
+
const md = renderSessionMarkdown(data);
|
|
780
|
+
if (opts.out) {
|
|
781
|
+
writeFileSync(opts.out, md, "utf8");
|
|
782
|
+
out(c.green(`✓ wrote ${opts.out}`) + c.dim(` (${md.length} chars)\n`));
|
|
783
|
+
}
|
|
784
|
+
else {
|
|
785
|
+
out(md);
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
program
|
|
789
|
+
.command("completions <shell>")
|
|
790
|
+
.description("print a shell completion script: bash | zsh | fish (eval it in your shell rc)")
|
|
791
|
+
.action((shell) => {
|
|
792
|
+
const top = program.commands.map((cmd) => cmd.name()).filter((n) => n && n !== "completions").sort();
|
|
793
|
+
const subs = {};
|
|
794
|
+
for (const cmd of program.commands) {
|
|
795
|
+
const sub = cmd.commands.map((s) => s.name()).filter(Boolean);
|
|
796
|
+
if (sub.length)
|
|
797
|
+
subs[cmd.name()] = sub;
|
|
798
|
+
}
|
|
799
|
+
const script = completionScript(shell, { top, subs });
|
|
800
|
+
if (!script)
|
|
801
|
+
return void out(c.red(`Unsupported shell '${shell}'. Use: bash | zsh | fish\n`));
|
|
802
|
+
out(script);
|
|
803
|
+
});
|
|
804
|
+
program
|
|
805
|
+
.command("mcp")
|
|
806
|
+
.description("run hara as an MCP server (stdio) — expose its read/search tools (incl. codebase_search) to other MCP clients")
|
|
807
|
+
.action(async () => {
|
|
808
|
+
const cfg = loadConfig();
|
|
809
|
+
// stdout is the JSON-RPC transport — diagnostics MUST go to stderr only.
|
|
810
|
+
process.stderr.write(c.dim(`hara mcp · serving over stdio · cwd ${cfg.cwd}\n tools: ${mcpServeToolNames().join(", ") || "(none)"}\n (read-only by default; set HARA_MCP_TOOLS to override)\n`));
|
|
811
|
+
await startMcpServer(pkg.version, { cwd: cfg.cwd, sandbox: "read-only" });
|
|
812
|
+
});
|
|
558
813
|
program
|
|
559
814
|
.command("review")
|
|
560
815
|
.description("review your uncommitted changes (git diff) for bugs, security, and missing tests")
|
|
@@ -660,6 +915,154 @@ program
|
|
|
660
915
|
}
|
|
661
916
|
}
|
|
662
917
|
});
|
|
918
|
+
function renderCronJobs() {
|
|
919
|
+
const jobs = loadJobs();
|
|
920
|
+
const head = isInstalled() ? c.green("scheduler: installed") : c.yellow("scheduler: NOT installed — run `hara cron install`");
|
|
921
|
+
if (!jobs.length)
|
|
922
|
+
return head + "\n" + c.dim('No jobs. Add one: hara cron add "every 1h" "<task>"\n');
|
|
923
|
+
const now = Date.now();
|
|
924
|
+
const lines = jobs.map((j) => {
|
|
925
|
+
const nxt = nextRun(j, now);
|
|
926
|
+
const status = j.lastStatus ? (j.lastStatus === "ok" ? c.green("ok") : c.red("err")) : c.dim("—");
|
|
927
|
+
return `${c.bold(j.id)} ${describeSchedule(j.schedule)} ${c.dim(`· ${j.mode} · next ${nxt ? new Date(nxt).toLocaleString() : "—"} · last ${status}`)}${j.enabled ? "" : c.dim(" [disabled]")}\n ${c.dim(j.name)}`;
|
|
928
|
+
});
|
|
929
|
+
return head + "\n" + lines.join("\n") + "\n";
|
|
930
|
+
}
|
|
931
|
+
const cronCmd = program.command("cron").description("scheduled tasks — run a prompt/org task on a schedule (fired by your OS via `hara cron install`)");
|
|
932
|
+
cronCmd
|
|
933
|
+
.command("add <schedule> <task...>")
|
|
934
|
+
.description('schedule a task — schedule = cron expr ("0 9 * * *"), "every 30m", "in 2h", or an ISO timestamp')
|
|
935
|
+
.option("--name <name>", "a label for the job")
|
|
936
|
+
.option("--org", "run via `hara org` (role routing + review) instead of a plain `hara -p` prompt")
|
|
937
|
+
.action((schedule, taskParts, opts) => {
|
|
938
|
+
const task = taskParts.join(" ");
|
|
939
|
+
const sched = parseSchedule(schedule, Date.now());
|
|
940
|
+
if ("error" in sched)
|
|
941
|
+
return void out(c.red(sched.error + "\n"));
|
|
942
|
+
const job = addJob({ name: opts.name || task.slice(0, 48), schedule: sched, task, mode: opts.org ? "org" : "print", cwd: process.cwd(), createdAt: Date.now() });
|
|
943
|
+
out(c.green(`✓ scheduled ${job.id}`) + c.dim(` · ${describeSchedule(sched)} · ${job.mode} · cwd ${job.cwd}\n`));
|
|
944
|
+
if (!isInstalled())
|
|
945
|
+
out(c.yellow("⚠ scheduler not installed yet — run `hara cron install` so jobs actually fire.\n"));
|
|
946
|
+
});
|
|
947
|
+
// Resolve an id/prefix to one job, printing a clear error for none / ambiguous (never act on a guess).
|
|
948
|
+
const cronResolve = (id) => {
|
|
949
|
+
const r = resolveJob(id);
|
|
950
|
+
if (r === "ambiguous")
|
|
951
|
+
return void out(c.red(`ambiguous id "${id}" — matches multiple jobs; type more characters\n`)), null;
|
|
952
|
+
if (!r)
|
|
953
|
+
return void out(c.red(`no such job: ${id}\n`)), null;
|
|
954
|
+
return r;
|
|
955
|
+
};
|
|
956
|
+
cronCmd.command("list").alias("ls").description("list scheduled jobs").action(() => out(renderCronJobs()));
|
|
957
|
+
cronCmd
|
|
958
|
+
.command("remove <id>")
|
|
959
|
+
.alias("rm")
|
|
960
|
+
.description("delete a job (by id or unique prefix)")
|
|
961
|
+
.action((id) => {
|
|
962
|
+
const j = cronResolve(id);
|
|
963
|
+
if (j)
|
|
964
|
+
out(removeJob(j.id) ? c.green(`✓ removed ${j.id}\n`) : c.red("no such job\n"));
|
|
965
|
+
});
|
|
966
|
+
cronCmd.command("enable <id>").description("enable a job").action((id) => {
|
|
967
|
+
const j = cronResolve(id);
|
|
968
|
+
if (j) {
|
|
969
|
+
setEnabled(j.id, true);
|
|
970
|
+
out(c.green(`✓ enabled ${j.id}\n`));
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
cronCmd.command("disable <id>").description("disable a job (keeps it, stops firing)").action((id) => {
|
|
974
|
+
const j = cronResolve(id);
|
|
975
|
+
if (j) {
|
|
976
|
+
setEnabled(j.id, false);
|
|
977
|
+
out(c.green(`✓ disabled ${j.id}\n`));
|
|
978
|
+
}
|
|
979
|
+
});
|
|
980
|
+
cronCmd
|
|
981
|
+
.command("run <id>")
|
|
982
|
+
.description("run a job right now, ignoring its schedule")
|
|
983
|
+
.action(async (id) => {
|
|
984
|
+
const job = cronResolve(id);
|
|
985
|
+
if (!job)
|
|
986
|
+
return;
|
|
987
|
+
out(c.dim(`running ${job.id} (${job.name})…\n`));
|
|
988
|
+
const r = await runJobOnce(job);
|
|
989
|
+
recordRun(job.id, Date.now(), r.ok ? "ok" : "error", r.error);
|
|
990
|
+
out((r.ok ? c.green("✓ done") : c.red(`✗ ${r.error}`)) + c.dim(` · log: ${logPath(job.id)}\n`));
|
|
991
|
+
});
|
|
992
|
+
cronCmd
|
|
993
|
+
.command("tick")
|
|
994
|
+
.description("run all due jobs now (your OS scheduler calls this every minute)")
|
|
995
|
+
.action(async () => {
|
|
996
|
+
const r = await runTick(Date.now());
|
|
997
|
+
if (r.skipped)
|
|
998
|
+
return void out(c.dim(`(skipped — ${r.skipped})\n`));
|
|
999
|
+
out(c.dim(r.ran.length ? `ran ${r.ran.length} job(s): ${r.ran.join(", ")}\n` : "(no jobs due)\n"));
|
|
1000
|
+
});
|
|
1001
|
+
cronCmd
|
|
1002
|
+
.command("install")
|
|
1003
|
+
.description("register the per-minute tick with your OS scheduler (launchd on macOS, crontab on Linux)")
|
|
1004
|
+
.action(() => {
|
|
1005
|
+
const r = installScheduler(selfArgv());
|
|
1006
|
+
out((r.ok ? c.green("✓ ") : c.red("✗ ")) + r.msg + "\n");
|
|
1007
|
+
});
|
|
1008
|
+
cronCmd.command("uninstall").description("remove the OS scheduler entry").action(() => {
|
|
1009
|
+
const r = uninstallScheduler();
|
|
1010
|
+
out((r.ok ? c.green("✓ ") : c.red("✗ ")) + r.msg + "\n");
|
|
1011
|
+
});
|
|
1012
|
+
cronCmd
|
|
1013
|
+
.command("logs <id>")
|
|
1014
|
+
.description("show a job's recent run output")
|
|
1015
|
+
.action((id) => {
|
|
1016
|
+
const job = cronResolve(id);
|
|
1017
|
+
if (!job)
|
|
1018
|
+
return;
|
|
1019
|
+
const p = logPath(job.id);
|
|
1020
|
+
out(existsSync(p) ? readFileSync(p, "utf8").slice(-4000) + "\n" : c.dim("(no runs yet)\n"));
|
|
1021
|
+
});
|
|
1022
|
+
const memoryCmd = program.command("memory").description("inspect + consolidate hara's durable memory (~/.hara/memory + project .hara/memory)");
|
|
1023
|
+
memoryCmd.command("show").description("print the memory digest injected at session start").action(() => {
|
|
1024
|
+
const d = memoryDigest(process.cwd());
|
|
1025
|
+
out(d ? d + "\n" : c.dim("(memory is empty — `hara memory init`, or let the agent write via memory_write)\n"));
|
|
1026
|
+
});
|
|
1027
|
+
memoryCmd.command("init").description("scaffold the memory dirs + seed files (global + project)").action(() => {
|
|
1028
|
+
const w = scaffoldMemory(process.cwd());
|
|
1029
|
+
out(w.length ? c.green(`Scaffolded: ${w.join(", ")}\n`) : c.dim("Memory already scaffolded.\n"));
|
|
1030
|
+
});
|
|
1031
|
+
memoryCmd
|
|
1032
|
+
.command("distill")
|
|
1033
|
+
.description("consolidate recent daily logs into durable MEMORY (promote short-term → long-term)")
|
|
1034
|
+
.option("--days <n>", "days of logs to consider (default 14)", (v) => parseInt(v, 10))
|
|
1035
|
+
.option("--scope <s>", "global | project | all (default all)")
|
|
1036
|
+
.action(async (opts) => {
|
|
1037
|
+
const cfg = loadConfig();
|
|
1038
|
+
const provider = await buildProvider(cfg);
|
|
1039
|
+
if (!provider) {
|
|
1040
|
+
out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
|
|
1041
|
+
process.exit(1);
|
|
1042
|
+
}
|
|
1043
|
+
const days = opts.days && opts.days > 0 ? opts.days : 14;
|
|
1044
|
+
const scopes = opts.scope === "global" ? ["global"] : opts.scope === "project" ? ["project"] : ["project", "global"];
|
|
1045
|
+
const logs = scopes
|
|
1046
|
+
.map((s) => readRecentLogs(s, cfg.cwd, days))
|
|
1047
|
+
.filter(Boolean)
|
|
1048
|
+
.join("\n\n");
|
|
1049
|
+
if (!logs.trim())
|
|
1050
|
+
return void out(c.dim(`No daily logs in the last ${days} day(s) to distill. (The agent jots them via memory_write target=log.)\n`));
|
|
1051
|
+
out(c.dim(`Distilling ${days}-day logs → durable memory…\n`));
|
|
1052
|
+
const stats = { input: 0, output: 0, lastInput: 0 };
|
|
1053
|
+
const history = [{ role: "user", content: `Current durable memory:\n\n${memoryDigest(cfg.cwd) || "(empty)"}\n\n---\n\nRecent daily logs (last ${days} days):\n\n${logs.slice(0, 80_000)}` }];
|
|
1054
|
+
await runAgent(history, {
|
|
1055
|
+
provider,
|
|
1056
|
+
ctx: { cwd: cfg.cwd, sandbox: cfg.sandbox },
|
|
1057
|
+
approval: "full-auto",
|
|
1058
|
+
confirm: async () => true,
|
|
1059
|
+
toolFilter: (n) => n === "memory_write" || READONLY_TOOLS.has(n),
|
|
1060
|
+
systemOverride: MEMORY_DISTILL_SYSTEM,
|
|
1061
|
+
stats,
|
|
1062
|
+
});
|
|
1063
|
+
if (stats.input || stats.output)
|
|
1064
|
+
out(statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
1065
|
+
});
|
|
663
1066
|
const rolesCmd = program.command("roles").description("manage org roles (.hara/roles)");
|
|
664
1067
|
rolesCmd
|
|
665
1068
|
.command("init")
|
|
@@ -715,6 +1118,18 @@ pluginCmd
|
|
|
715
1118
|
m.mcpServers ? `${Object.keys(m.mcpServers).length} mcp server(s)` : "",
|
|
716
1119
|
].filter(Boolean);
|
|
717
1120
|
out(c.green(`Installed ${p.name}@${p.version}${parts.length ? c.dim(" — " + parts.join(", ")) : ""}\n`));
|
|
1121
|
+
// Surface the code-execution surface: a plugin's MCP servers + hooks run shell commands on every
|
|
1122
|
+
// hara launch with no prompt. Installing a plugin = trusting its author to run code; show what.
|
|
1123
|
+
const execs = [];
|
|
1124
|
+
for (const [name, s] of Object.entries(m.mcpServers ?? {}))
|
|
1125
|
+
execs.push(`mcp ${name}: ${[s.command, ...(s.args ?? [])].join(" ")}`);
|
|
1126
|
+
for (const h of [...(m.hooks?.PreToolUse ?? []), ...(m.hooks?.PostToolUse ?? [])])
|
|
1127
|
+
execs.push(`hook: ${h.command}`);
|
|
1128
|
+
if (execs.length) {
|
|
1129
|
+
out(c.yellow(`⚠ ${p.name} will run these commands on every hara launch (a plugin is code you run — review them):\n`) +
|
|
1130
|
+
execs.map((e) => c.dim(` ${e}`)).join("\n") +
|
|
1131
|
+
c.dim(`\n disable: hara plugin disable ${p.name}\n`));
|
|
1132
|
+
}
|
|
718
1133
|
}
|
|
719
1134
|
catch (e) {
|
|
720
1135
|
out(c.red(`Install failed: ${e.message}\n`));
|
|
@@ -807,10 +1222,23 @@ program.action(async (opts) => {
|
|
|
807
1222
|
cfg.model = opts.model;
|
|
808
1223
|
const provider0 = await buildProvider(cfg);
|
|
809
1224
|
if (!provider0) {
|
|
1225
|
+
// First-run friendliness: offer the setup wizard instead of just erroring (interactive TTY only).
|
|
1226
|
+
if (stdin.isTTY && !opts.print) {
|
|
1227
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
1228
|
+
const ans = (await rl.question(c.yellow(`Not authenticated for '${cfg.provider}'. Run setup now? `) + c.dim("[Y/n] "))).trim().toLowerCase();
|
|
1229
|
+
rl.close();
|
|
1230
|
+
if (ans === "" || ans === "y" || ans === "yes") {
|
|
1231
|
+
await runSetup();
|
|
1232
|
+
out(c.dim(`\nThen run ${c.bold("hara")} to start.\n`));
|
|
1233
|
+
process.exit(0);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
810
1236
|
out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
|
|
811
1237
|
process.exit(1);
|
|
812
1238
|
}
|
|
813
1239
|
let provider = provider0;
|
|
1240
|
+
if (cfg.provider === "hara-gateway")
|
|
1241
|
+
void heartbeat(); // fleet visibility — fire-and-forget, never blocks startup
|
|
814
1242
|
const cwd = cfg.cwd;
|
|
815
1243
|
let approval = opts.yes ? "full-auto" : (opts.approval || cfg.approval);
|
|
816
1244
|
let currentTurn = null; // set during a running turn so Esc can abort it
|
|
@@ -1272,6 +1700,7 @@ program.action(async (opts) => {
|
|
|
1272
1700
|
header: { version: pkg.version, model: `${cfg.provider}:${cfg.model}`, cwd, vision: visionLine, session: meta.id, tip: `/help · @file attaches · shift+tab cycles modes · esc interrupts${projectContext ? " · AGENTS.md loaded" : ""}` },
|
|
1273
1701
|
cycleApproval: (m) => cycleMode(m),
|
|
1274
1702
|
onClipboardImage: readClipboardImage,
|
|
1703
|
+
vim: cfg.vimMode,
|
|
1275
1704
|
onSubmit: async (line, h, images) => {
|
|
1276
1705
|
if (line.startsWith("/")) {
|
|
1277
1706
|
const [nm, ...rest] = line.slice(1).split(/\s+/);
|
|
@@ -1420,6 +1849,49 @@ program.action(async (opts) => {
|
|
|
1420
1849
|
h.setApproval(m);
|
|
1421
1850
|
return void h.sink.notice(`(approval → ${m})`);
|
|
1422
1851
|
}
|
|
1852
|
+
if (nm === "diff") {
|
|
1853
|
+
try {
|
|
1854
|
+
const d = (await runShell(arg === "staged" ? "git diff --staged" : "git diff HEAD", cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout.trim();
|
|
1855
|
+
if (!d)
|
|
1856
|
+
return void h.sink.notice(arg === "staged" ? "(nothing staged)" : "(no changes vs HEAD — /diff staged for the index)");
|
|
1857
|
+
return void h.sink.diff(d.length > 12_000 ? d.slice(0, 12_000) + "\n…[truncated]" : d);
|
|
1858
|
+
}
|
|
1859
|
+
catch {
|
|
1860
|
+
return void h.sink.notice("(git diff failed — is this a git repo?)");
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
if (nm === "commit") {
|
|
1864
|
+
h.sink.notice("✻ writing a commit message…");
|
|
1865
|
+
const r = await autoCommit(provider, cwd); // stages all + commits with an AI message
|
|
1866
|
+
return void h.sink.notice(r.startsWith("error:") ? `✗ ${r}` : r === "nothing to commit" ? "(nothing to commit — make or stage changes first)" : `✓ committed · ${r.slice(0, 100)}`);
|
|
1867
|
+
}
|
|
1868
|
+
if (nm === "review") {
|
|
1869
|
+
let diff = "";
|
|
1870
|
+
try {
|
|
1871
|
+
diff = (await runShell("git diff HEAD", cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
|
|
1872
|
+
}
|
|
1873
|
+
catch {
|
|
1874
|
+
/* not a git repo → empty */
|
|
1875
|
+
}
|
|
1876
|
+
if (!diff.trim())
|
|
1877
|
+
return void h.sink.notice("(nothing to review — no changes vs HEAD)");
|
|
1878
|
+
const rui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
|
|
1879
|
+
const xin = stats.input;
|
|
1880
|
+
const xout = stats.output;
|
|
1881
|
+
await runAgent([{ role: "user", content: `Review this diff:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }], {
|
|
1882
|
+
provider,
|
|
1883
|
+
ctx: { cwd, sandbox, ui: rui },
|
|
1884
|
+
approval: "full-auto", // read-only via the tool filter, so nothing prompts
|
|
1885
|
+
confirm: h.confirm,
|
|
1886
|
+
toolFilter: (n) => READONLY_TOOLS.has(n),
|
|
1887
|
+
systemOverride: REVIEW_SYSTEM,
|
|
1888
|
+
memory: buildMemory(),
|
|
1889
|
+
stats,
|
|
1890
|
+
signal: h.signal,
|
|
1891
|
+
});
|
|
1892
|
+
h.sink.usage(stats.input - xin, stats.output - xout);
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1423
1895
|
if (byName.has(nm))
|
|
1424
1896
|
return void h.sink.notice(`/${nm} isn't wired into the TUI yet — use \`hara ${nm} …\` as a subcommand, or HARA_TUI=0.`);
|
|
1425
1897
|
const near = nearest(nm, [...byName.keys()]);
|