@nanhara/hara 0.124.4 → 0.125.1

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 CHANGED
@@ -5,6 +5,32 @@ All notable changes to `@nanhara/hara`.
5
5
  > Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
6
6
  > **patch** (last) number bumps for **optimizations/fixes of existing features**.
7
7
 
8
+ ## 0.125.1 — 2026-07-18 — installed plugin commands inside agent tasks
9
+
10
+ - **Commands contributed by an installed Hara plugin are now available to Hara's own tool subprocesses.**
11
+ Hara appends the user-approved `~/.hara/bin` directory after the inherited `PATH`, so a plugin command
12
+ such as `hara-video` can be called during an agent task without shadowing a project or system executable.
13
+ Interactive shells remain user-controlled and still receive the existing one-time PATH hint.
14
+ - This closes the failure mode where the Video Skill was loaded but the agent saw `hara-video: command not
15
+ found`, hand-built an ungoverned replacement project, and then bypassed the plugin's verification command.
16
+ - Upgrade with `npm i -g @nanhara/hara@0.125.1`.
17
+
18
+ ## 0.125.0 — 2026-07-18 — Claude-compatible specialist orchestration
19
+
20
+ - **Personal Claude Code subagents now work in Hara without copying prompts.** Hara discovers
21
+ `~/.claude/agents/*.md` as portable global roles in addition to project `.claude/agents`, translates common
22
+ Claude tool names, inherits the active Hara model for Claude aliases/provider-specific ids, and preserves
23
+ native/project Hara precedence on id collisions. Workflow-only prompts and prompts that require a private
24
+ Claude notification/skill dependency stay explicit-only rather than being selected automatically.
25
+ - **Ordinary Hara conversations can now discover the right specialist.** The main agent receives a bounded,
26
+ guarded name/description catalog and loads only the selected role body. Delegation guidance requires a
27
+ minimal self-contained brief, distinct non-overlapping work, and parent-side synthesis; fan-out specialists
28
+ remain read-only so role prompts cannot bypass the edit/command approval boundary.
29
+ - **Plans route by responsibility instead of role name alone.** The atom planner receives bounded role
30
+ descriptions and read-only status, excludes `disable-model-invocation` roles from automatic selection, and
31
+ removes unknown role ids instead of silently executing under a generic persona. Explicit role execution
32
+ remains available for host-coupled prompts.
33
+
8
34
  ## 0.124.4 — 2026-07-18 — deadline-aware task checkpoints
9
35
 
10
36
  - **The 80% run-budget warning now reaches the Agent, not only the user.** Before the next model turn,
package/README.md CHANGED
@@ -162,6 +162,7 @@ hara # interactive REPL (offers to create AGENTS.md on fir
162
162
  hara init # analyze the project & (re)generate AGENTS.md
163
163
  hara doctor # check your setup (auth / model / node / assets / roles)
164
164
  hara roles init # scaffold role-agents (implementer / reviewer / docs)
165
+ hara roles # list Hara roles + compatible personal/project Claude Code agents
165
166
  hara org "review src/ for bugs" # dispatch a task to the role that owns it (or --role <id>)
166
167
  hara projects add shop /absolute/path/to/shop # register an agent home
167
168
  hara agents # list global + registered project agents
@@ -312,6 +313,18 @@ to a clean start tree; a review that doesn't pass leaves the work uncommitted).
312
313
  **`agent`** tool spawns **parallel read-only sub-agents** for fan-out — analyze / review / search
313
314
  several things at once (each can take a `role`), bounded to 8 concurrent (`HARA_MAX_CONCURRENCY`).
314
315
 
316
+ Claude Code role collections work in place: Hara discovers both `~/.claude/agents/*.md` and project
317
+ `.claude/agents/*.md`, translates common Claude tool names (`Read`, `Edit`, `Bash`, `WebSearch`, …), and
318
+ treats Claude aliases and Claude-only model ids as “inherit the current Hara model” (a role cannot silently
319
+ switch the active provider). Precedence is plugin < managed org < personal Claude < personal Hara < project
320
+ Claude < project Hara. Ordinary Hara turns receive only a compact,
321
+ guarded catalog of role names and descriptions; a role's full prompt is loaded only after that role is
322
+ selected. This lets the main agent ask an architect, debugger, tester, or reviewer for bounded independent
323
+ analysis without copying every prompt into every request. Claude prompts that declare themselves workflow-only
324
+ or require a private notification server / Claude-only local skill are automatically kept explicit-only; they
325
+ remain available through `--role` / `agent(role)` but cannot be picked by automatic routing. Set
326
+ `disable-model-invocation: true` to make the same boundary explicit for any other host-coupled role.
327
+
315
328
  Register project homes with `hara projects add <name> <absolute-path>`, then `hara agents` becomes a global
316
329
  address book across `~/.hara/roles` and each registered project's roles. A qualified address such as
317
330
  `shop:reviewer` is unambiguous; both `hara org --role shop:reviewer "<task>"` and one-shot `hara -p "<task>"
@@ -326,7 +339,10 @@ command, so verification is **objective** (e.g. `npm test`, `tsc --noEmit`) rath
326
339
  self-assessment. Plan state is the SSOT at `.hara/org/plan.json` (inspectable; execution stops on the
327
340
  first failed verification — fix it and **`hara plan resume`** continues, skipping the atoms already done).
328
341
  With **`hara plan --parallel`**, independent atoms (the same dependency wave) run **concurrently** — the org
329
- works the independent parts at once, not one step at a time.
342
+ works the independent parts at once, not one step at a time. The planner sees each automatically-invocable
343
+ role's bounded description and read-only status—not only its id—so it can assign the right specialist and
344
+ will discard a hallucinated, removed, or explicit-only role instead of silently running the atom as a generic
345
+ agent.
330
346
 
331
347
  ### What it can do
332
348
 
@@ -20,6 +20,7 @@ import { resolve as resolvePath } from "node:path";
20
20
  import { redactSensitiveText } from "../security/secrets.js";
21
21
  import { redactToolSubprocessOutput } from "../security/subprocess-env.js";
22
22
  import { prepareHistoryForModel } from "./context-budget.js";
23
+ import { rolesDigest } from "../org/roles.js";
23
24
  /** File tools whose `path` input marks the file as "recently worked with" (post-compaction restore). */
24
25
  const FILE_TOUCH_TOOLS = new Set(["read_file", "edit_file", "write_file"]);
25
26
  /** Stall watchdog ceiling: a model attempt that streams NOTHING for this long is treated as a dead /
@@ -88,7 +89,12 @@ are disabled without an interactive approval channel unless the user launched wi
88
89
  HARA_ALLOW_TRUSTED_EXTENSIONS=1.
89
90
  For broad,
90
91
  open-ended exploration (more than ~3 searches), spawn \`agent\` sub-agents — several in one response for
91
- independent questions (role "explore") — each returns conclusions, not dumps. Messages the user sends
92
+ independent questions (role "explore") — each returns conclusions, not dumps. When specialist roles are
93
+ listed below, delegate only a bounded question that materially benefits from that expertise; give each role
94
+ the minimum self-contained context, relevant paths, constraints, and expected output. Do not dump the whole
95
+ conversation, spawn overlapping roles, or delegate a simple lookup. Reconcile conflicting specialist advice
96
+ yourself before acting. Role-based \`agent\` calls stay read-only; the main agent owns approved edits, while
97
+ \`hara org\` / \`hara plan\` provide write-capable role execution behind their normal gates. Messages the user sends
92
98
  mid-task arrive marked as interjections — triage them (refine current / queue as todo / urgent-switch)
93
99
  instead of blindly folding everything into the current task; the todo list is your task queue. For a multi-step task, call \`todo_write\` to plan a short checklist and keep it updated as
94
100
  you go (one item in_progress at a time) — skip it for trivial one-step tasks. You have a persistent
@@ -139,12 +145,14 @@ const CONTINUATION_SYSTEM = "# Existing-session continuity\n" +
139
145
  function composeSystem(cwd, projectContext, override, memory, continuationSession = false, executionContext) {
140
146
  const head = override ? `${override}\n\nWorking directory: ${cwd}` : HARA_SYSTEM(cwd);
141
147
  const skills = skillsDigest(cwd);
148
+ const roles = override ? "" : rolesDigest(cwd);
142
149
  return (head +
143
150
  gatewayNote() +
144
151
  (continuationSession ? `\n\n${CONTINUATION_SYSTEM}` : "") +
145
152
  (executionContext ? `\n\n${executionContext}` : "") +
146
153
  (projectContext ? `\n\n# Project context (AGENTS.md)\n${projectContext}` : "") +
147
154
  (memory ? `\n\n# Memory (durable — facts/decisions/prefs you've saved; use memory_search/get for more)\n${memory}` : "") +
155
+ (roles ? `\n\n# Specialist roles (metadata only — use \`agent\` with a role id for bounded read-only expertise)\n${roles}` : "") +
148
156
  (skills ? `\n\n# Skills (capabilities you can load — call the \`skill\` tool with the id for full instructions before using one)\n${skills}` : ""));
149
157
  }
150
158
  const RUN_STOPPED = Symbol("agent-run-stopped");
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ import { readFileSync, existsSync, realpathSync, statSync, writeFileSync, rmSync
13
13
  import { homedir, tmpdir } from "node:os";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { randomUUID } from "node:crypto";
16
- import { dirname, join, relative, resolve } from "node:path";
16
+ import { delimiter, dirname, join, relative, resolve } from "node:path";
17
17
  import { loadConfig, configPath, readRawConfig, writeConfigValue, setModelVisionOverride, providerEnvKey, providerDefaultBaseURL, CONFIG_KEYS, APPROVAL_MODES, SANDBOX_MODES, REASONING_EFFORTS, } from "./config.js";
18
18
  import { runAgent } from "./agent/loop.js";
19
19
  import { formatAgentDuration, parseAgentRunTimeoutMs, MIN_AGENT_RUN_TIMEOUT_MS, MAX_AGENT_RUN_TIMEOUT_MS, MAX_AGENT_MAX_ROUNDS, } from "./agent/limits.js";
@@ -503,20 +503,25 @@ async function runOrg(task, o) {
503
503
  }
504
504
  }
505
505
  else {
506
- const kw = routeByKeywords(task, roles);
506
+ const routableRoles = roles.filter((candidate) => candidate.modelInvocable !== false);
507
+ if (!routableRoles.length) {
508
+ out(c.yellow("No automatically routable roles — choose an explicit role with --role <id>.\n"));
509
+ return { status: "error", error: "no roles allow automatic invocation" };
510
+ }
511
+ const kw = routeByKeywords(task, routableRoles);
507
512
  if (kw) {
508
513
  role = kw.role;
509
514
  }
510
515
  else {
511
516
  const r = await boundedProviderTurn(o.baseProvider, {
512
517
  system: "You are a task dispatcher. Reply with only a role id.",
513
- history: [{ role: "user", content: buildDispatchPrompt(task, roles) }],
518
+ history: [{ role: "user", content: buildDispatchPrompt(task, routableRoles) }],
514
519
  tools: [],
515
520
  onText: () => { },
516
521
  }, { timeoutMs: 20_000, label: "role dispatch" });
517
522
  if (r.stop === "error")
518
- out(c.yellow(`(role dispatch unavailable — using ${roles[0].id})\n`));
519
- role = parseRoleId(r.text, roles) ?? roles[0];
523
+ out(c.yellow(`(role dispatch unavailable — using ${routableRoles[0].id})\n`));
524
+ role = parseRoleId(r.text, routableRoles) ?? routableRoles[0];
520
525
  }
521
526
  }
522
527
  out(c.dim(`→ ${role.id} owns this task\n`));
@@ -626,6 +631,13 @@ async function executeAtom(atom, plan, done, roles, o) {
626
631
  atom.status = "running";
627
632
  await savePlan(o.cwd, plan);
628
633
  const role = atom.role ? roles.find((r) => r.id === atom.role) : undefined;
634
+ if (atom.role && !role) {
635
+ atom.status = "failed";
636
+ atom.note = `planned role '${atom.role}' is no longer available`;
637
+ await savePlan(o.cwd, plan);
638
+ out(c.red(` ✗ ${atom.id} ${atom.note}\n`));
639
+ return false;
640
+ }
629
641
  const __atomModel = effectiveRoleModel(role?.model, o.cfg.model);
630
642
  const roleProvider = __atomModel ? ((await buildProvider({ ...o.cfg, model: __atomModel })) ?? o.baseProvider) : o.baseProvider;
631
643
  const toolFilter = roleToolFilter(role);
@@ -1039,6 +1051,14 @@ async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stat
1039
1051
  return "(sub-agent produced no output)";
1040
1052
  }
1041
1053
  /** Check the hara setup and print a health summary (provider/auth/model/node/assets/roles). */
1054
+ function roleMeta(role) {
1055
+ return [
1056
+ role.source,
1057
+ role.readOnly ? "read-only" : "",
1058
+ role.modelInvocable === false ? "explicit-only" : "",
1059
+ ...(role.compatibilityWarnings ?? []),
1060
+ ].filter(Boolean).join(" · ");
1061
+ }
1042
1062
  function runDoctor(cfg) {
1043
1063
  const ok = (b) => (b ? c.green("✓") : c.red("✗"));
1044
1064
  const dot = c.dim("·");
@@ -1057,7 +1077,7 @@ function runDoctor(cfg) {
1057
1077
  `${ok(authed)} auth ${authed ? c.dim("configured") : c.yellow("missing — " + authHint(cfg))}`,
1058
1078
  `${ok(existsSync(configPath()))} config ${c.dim(configPath())}`,
1059
1079
  `${dot} code-assets ${existsSync(ad) ? c.dim(ad) : c.dim("none — run: hara recall --init")}`,
1060
- `${dot} roles ${roles.length ? c.dim(roles.map((r) => r.id).join(", ")) : c.dim("none — run: hara roles init")}`,
1080
+ `${dot} roles ${roles.length ? c.dim(`${roles.length} (${roles.slice(0, 8).map((r) => r.id).join(", ")}${roles.length > 8 ? ", …" : ""})`) : c.dim("none — run: hara roles init")}`,
1061
1081
  `${dot} skills ${(() => { const n = loadSkillIndex(cfg.cwd).length; return n ? c.dim(`${n} (${loadSkillIndex(cfg.cwd).map((s) => s.id).slice(0, 6).join(", ")})`) : c.dim("none — run: hara skills init"); })()}`,
1062
1082
  `${dot} memory ${existsSync(join(homedir(), ".hara", "memory")) ? c.dim("~/.hara/memory + project") : c.dim("none yet (created on first write)")} ${c.dim("· evolve")} ${c.bold(cfg.evolve)} ${c.dim("· capture")} ${c.bold(cfg.assetCapture)}`,
1063
1083
  `${dot} search ${c.dim("lexical (always on)")}${cfg.embedProvider === "off" ? c.dim(" · semantic off (hara config set embedProvider ollama|qwen)") : c.dim(" · semantic ") + c.bold(cfg.embedProvider) + (() => { const idx = ["repo", "assets", "memory"].filter((n) => indexExists(n, cfg.cwd)); return c.dim(" · indexed: ") + (idx.length ? c.green(idx.join(", ")) : c.yellow("none — run: hara index --all")); })()}`,
@@ -1286,7 +1306,7 @@ program
1286
1306
  .action(() => {
1287
1307
  const idx = buildAgentsIndex();
1288
1308
  if (!idx.length) {
1289
- out(c.dim("(no agents — add roles to ~/.hara/roles, or register projects: hara projects add <name> <path>)\n"));
1309
+ out(c.dim("(no agents — add roles to ~/.hara/roles or ~/.claude/agents, or register projects: hara projects add <name> <path>)\n"));
1290
1310
  return;
1291
1311
  }
1292
1312
  for (const e of idx) {
@@ -2445,7 +2465,7 @@ memoryCmd
2445
2465
  if (stats.input || stats.output)
2446
2466
  out(statusLine(cfg.model, stats.input, stats.output) + "\n");
2447
2467
  });
2448
- const rolesCmd = program.command("roles").description("manage org roles (.hara/roles)");
2468
+ const rolesCmd = program.command("roles").description("list/manage Hara roles and compatible Claude Code agents");
2449
2469
  rolesCmd
2450
2470
  .command("init")
2451
2471
  .description("scaffold example roles")
@@ -2462,7 +2482,8 @@ rolesCmd.action(() => {
2462
2482
  return;
2463
2483
  }
2464
2484
  for (const r of roles) {
2465
- out(`${c.bold(r.id)}${r.model ? c.dim(` (${r.model})`) : ""} ${c.dim("owns: " + r.owns.join(", "))}\n ${r.description}\n`);
2485
+ const meta = roleMeta(r);
2486
+ out(`${c.bold(r.id)}${r.model ? c.dim(` (${r.model})`) : ""}${meta ? c.dim(` [${meta}]`) : ""} ${c.dim("owns: " + r.owns.join(", "))}\n ${r.description}\n`);
2466
2487
  }
2467
2488
  });
2468
2489
  program
@@ -2634,11 +2655,13 @@ pluginCmd
2634
2655
  m.mcpServers ? `${Object.keys(m.mcpServers).length} mcp server(s)` : "",
2635
2656
  ].filter(Boolean);
2636
2657
  out(c.green(`Installed ${p.name}@${p.version}${parts.length ? c.dim(" — " + parts.join(", ")) : ""}\n`));
2637
- // A plugin can ship CLI commands (manifest `bin`); they're linked into ~/.hara/bin. Tell the user to PATH it.
2658
+ // Hara's model-controlled subprocesses append this directory automatically. The user's independent
2659
+ // interactive shell still needs its own PATH entry when they want to invoke the command directly.
2638
2660
  const bins = Object.keys(m.bin ?? {});
2639
2661
  if (bins.length) {
2640
- const onPath = (process.env.PATH ?? "").split(":").includes(haraBinDir());
2662
+ const onPath = (process.env.PATH ?? "").split(delimiter).includes(haraBinDir());
2641
2663
  out(c.green(`Linked command(s): ${bins.join(", ")} → ${c.dim(haraBinDir())}\n`));
2664
+ out(c.dim(" available to tool commands started inside Hara automatically\n"));
2642
2665
  if (!onPath)
2643
2666
  out(c.yellow(` add to PATH once: echo 'export PATH="$HOME/.hara/bin:$PATH"' >> ~/.zshrc && source ~/.zshrc\n`));
2644
2667
  }
@@ -3668,7 +3691,7 @@ program.action(async (opts) => {
3668
3691
  if (!rs.length)
3669
3692
  return void out(c.dim("No roles. Run `hara roles init`.\n"));
3670
3693
  for (const r of rs)
3671
- out(` ${r.id} ${c.dim("owns: " + r.owns.join(", "))}\n`);
3694
+ out(` ${r.id} ${c.dim(`[${roleMeta(r)}] owns: ${r.owns.join(", ")}`)}\n`);
3672
3695
  },
3673
3696
  },
3674
3697
  {
@@ -4358,7 +4381,7 @@ program.action(async (opts) => {
4358
4381
  return void h.sink.notice(applyVision(arg));
4359
4382
  if (nm === "roles") {
4360
4383
  const rs = loadRoles(cwd);
4361
- return void h.sink.notice(rs.length ? rs.map((r) => ` ${r.id} — owns: ${r.owns.join(", ")}`).join("\n") : "No roles. Run `hara roles init`.");
4384
+ return void h.sink.notice(rs.length ? rs.map((r) => ` ${r.id} [${roleMeta(r)}] — owns: ${r.owns.join(", ")}`).join("\n") : "No roles. Run `hara roles init`.");
4362
4385
  }
4363
4386
  if (nm === "skills") {
4364
4387
  const ss = loadSkillIndex(cwd);
@@ -5,6 +5,7 @@
5
5
  import { existsSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { boundedProviderTurn } from "../providers/bounded-turn.js";
8
+ import { roleCatalog } from "./roles.js";
8
9
  import { runShell } from "../sandbox.js";
9
10
  import { readModelContextFileSync, readVerifiedRegularFileSnapshot } from "../fs-read.js";
10
11
  import { atomicWriteText, bindAtomicWritePath } from "../fs-write.js";
@@ -17,7 +18,13 @@ Return ONLY a JSON object, no prose:
17
18
  Rules: short ids (a1,a2,…); deps reference earlier ids only; typically 3-8 atoms; each atom small and verifiable. Prefer a concrete 'check' command (e.g. "npm test", "tsc --noEmit", "test -f src/x.ts") so a step is verified objectively; omit 'check' if none fits.`;
18
19
  /** Ask the model to decompose `task` into an atomized, sequenced plan. */
19
20
  export async function decompose(provider, task, roles, opts = {}) {
20
- const roleHint = roles.length ? `\nAvailable roles for the optional "role" field: ${roles.map((r) => r.id).join(", ")}.` : "";
21
+ const eligibleRoles = roles.filter((role) => role.modelInvocable !== false);
22
+ const catalog = roleCatalog(eligibleRoles);
23
+ const roleHint = catalog
24
+ ? `\nAvailable roles for the optional "role" field:\n${catalog}\n` +
25
+ "Assign a role only when its specialization materially fits the atom. A read-only role may analyze, " +
26
+ "research, or verify, but must not own an atom that changes files or executes deployment."
27
+ : "";
21
28
  const r = await boundedProviderTurn(provider, {
22
29
  system: PLAN_SYSTEM + roleHint,
23
30
  history: [{ role: "user", content: `Task: ${task}\n\nReturn the JSON plan.` }],
@@ -26,7 +33,13 @@ export async function decompose(provider, task, roles, opts = {}) {
26
33
  }, { timeoutMs: opts.timeoutMs ?? 60_000, signal: opts.signal, label: "plan decomposition" });
27
34
  if (r.stop === "error")
28
35
  return { task, atoms: [], createdAt: new Date().toISOString() };
29
- return { task, atoms: parsePlan(r.text), createdAt: new Date().toISOString() };
36
+ const roleIds = new Set(eligibleRoles.map((role) => role.id));
37
+ const atoms = parsePlan(r.text);
38
+ for (const atom of atoms) {
39
+ if (atom.role && !roleIds.has(atom.role))
40
+ atom.role = undefined;
41
+ }
42
+ return { task, atoms, createdAt: new Date().toISOString() };
30
43
  }
31
44
  /** Extract + normalize atoms from the model's (possibly fenced/noisy) JSON reply. */
32
45
  export function parsePlan(text) {
@@ -280,7 +280,9 @@ function sameRoleDefinition(a, b) {
280
280
  sameList(a.rejects, b.rejects) &&
281
281
  sameList(a.allowTools, b.allowTools) &&
282
282
  sameList(a.denyTools, b.denyTools) &&
283
- a.readOnly === b.readOnly);
283
+ a.readOnly === b.readOnly &&
284
+ a.modelInvocable === b.modelInvocable &&
285
+ sameList(a.compatibilityWarnings, b.compatibilityWarnings));
284
286
  }
285
287
  /** Build the global index: inherited globals are listed once; any project override gets its qualified home. */
286
288
  export function buildAgentsIndex() {
package/dist/org/roles.js CHANGED
@@ -1,12 +1,16 @@
1
- // Org roles — markdown agent definitions in <project>/.hara/roles/*.md.
2
- // Frontmatter: name, description, owns[], rejects[], model?, allowTools[], denyTools[], readOnly?. Body = persona/system.
1
+ // Org roles — markdown agent definitions from Hara and Claude Code.
2
+ // Frontmatter: name, description, owns[], rejects[], model?, allowTools[]/tools, denyTools[],
3
+ // readOnly?, disable-model-invocation?. Body = persona/system, loaded only for the selected role.
3
4
  import { writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
4
5
  import { join } from "node:path";
5
6
  import { homedir } from "node:os";
6
7
  import { findProjectRoot } from "../context/agents-md.js";
7
8
  import { pluginRoleDirs } from "../plugins/plugins.js";
8
9
  import { readModelContextFileSync } from "../fs-read.js";
10
+ import { scanMemory } from "../memory/guard.js";
9
11
  const MAX_ROLE_BYTES = 512 * 1024;
12
+ const ROLE_DIGEST_CAP = 16_000;
13
+ const ROLE_DESCRIPTION_CAP = 180;
10
14
  export function rolesDir(cwd) {
11
15
  return join(findProjectRoot(cwd), ".hara", "roles");
12
16
  }
@@ -14,6 +18,11 @@ export function rolesDir(cwd) {
14
18
  export function globalRolesDir() {
15
19
  return join(homedir(), ".hara", "roles");
16
20
  }
21
+ /** Claude Code's personal subagents are portable role prompts. Read them in place so users do not need
22
+ * to copy or fork the prompt collection into Hara. Native ~/.hara/roles overrides an id collision. */
23
+ export function globalClaudeAgentsDir() {
24
+ return join(homedir(), ".claude", "agents");
25
+ }
17
26
  /** Org-pushed roles (B-end): the digital-employee bundle synced from hara-control's `/v1/roles` into
18
27
  * `~/.hara/org-roles/*.md` (see org-fleet/enroll.ts syncOrgRoles). A managed baseline — above
19
28
  * third-party plugins, but a dev's own global/project roles still win. */
@@ -57,13 +66,16 @@ export function claudeTools(v) {
57
66
  return raw.map((t) => CLAUDE_TOOL_MAP[t.toLowerCase().replace(/[^a-z]/g, "")] ?? t);
58
67
  }
59
68
  function parseFrontmatter(text) {
60
- const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(text);
69
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
61
70
  if (!m)
62
71
  return { fm: {}, body: text.trim() };
63
72
  const fm = {};
64
- for (const raw of m[1].split("\n")) {
65
- const line = raw.trim();
66
- const kv = /^([A-Za-z0-9_]+)\s*:\s*(.*)$/.exec(line);
73
+ for (const raw of m[1].split(/\r?\n/)) {
74
+ // This is intentionally a small top-level parser, not YAML. Do not trim before matching: nested
75
+ // metadata such as `persona:\n name: Vera` must never overwrite the role's top-level `name`.
76
+ if (/^\s/.test(raw))
77
+ continue;
78
+ const kv = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(raw);
67
79
  if (!kv)
68
80
  continue;
69
81
  const key = kv[1];
@@ -106,39 +118,80 @@ export function roleToolFilter(role) {
106
118
  return role.allowTools || role.denyTools ? declared : undefined;
107
119
  }
108
120
  export function loadRoles(cwd) {
109
- // lowest→highest precedence: plugins < org(B-end push) < global < .claude/agents < .hara/roles (project wins)
110
- return [...rolesFromDirs([...pluginRoleDirs(), orgRolesDir(), globalRolesDir(), claudeAgentsDir(cwd), rolesDir(cwd)]).values()];
121
+ // lowest→highest precedence: plugins < org(B-end push) < personal Claude < personal Hara
122
+ // < project Claude < project Hara. A user's native Hara definition intentionally wins an id collision.
123
+ return [...rolesFromDirs([
124
+ ...pluginRoleDirs().map((dir) => ({ dir, source: "plugin" })),
125
+ { dir: orgRolesDir(), source: "org" },
126
+ { dir: globalClaudeAgentsDir(), source: "claude-global" },
127
+ { dir: globalRolesDir(), source: "global" },
128
+ { dir: claudeAgentsDir(cwd), source: "claude-project" },
129
+ { dir: rolesDir(cwd), source: "project" },
130
+ ]).values()];
111
131
  }
112
- /** The project-independent layers only (plugins + org-pushed + ~/.hara/roles) what the global agent
113
- * index lists as "runs anywhere". Excludes cwd-derived layers by construction. */
132
+ /** The project-independent layers only what the global agent index lists as "runs anywhere".
133
+ * Personal Claude Code agents participate directly; no copy/import step is required. */
114
134
  export function loadGlobalRoles() {
115
- return [...rolesFromDirs([...pluginRoleDirs(), orgRolesDir(), globalRolesDir()]).values()];
135
+ return [...rolesFromDirs([
136
+ ...pluginRoleDirs().map((dir) => ({ dir, source: "plugin" })),
137
+ { dir: orgRolesDir(), source: "org" },
138
+ { dir: globalClaudeAgentsDir(), source: "claude-global" },
139
+ { dir: globalRolesDir(), source: "global" },
140
+ ]).values()];
141
+ }
142
+ const isTrue = (value) => value === true || String(value).toLowerCase() === "true";
143
+ function claudeCompatibilityWarnings(description, body) {
144
+ const warnings = [];
145
+ if (/\bcalled by\b.*\b(?:only|workflows? only)\b/i.test(description))
146
+ warnings.push("workflow-only");
147
+ if (/\b(?:must be used|mandatory before|always use)\b/i.test(description)) {
148
+ warnings.push("mandatory auto-invocation directive");
149
+ }
150
+ if (/localhost:\d+\/notify|YOUR_VOICE_ID(?:_HERE)?|voice notification/i.test(body)) {
151
+ warnings.push("local notification dependency");
152
+ }
153
+ if (/(?:~|\/Users\/[^/\s]+)\/\.claude\/skills\//i.test(body))
154
+ warnings.push("Claude-only skill dependency");
155
+ return [...new Set(warnings)];
116
156
  }
117
157
  function rolesFromDirs(dirs) {
118
158
  const byId = new Map();
119
- for (const dir of dirs) {
159
+ for (const { dir, source } of dirs) {
120
160
  if (!existsSync(dir))
121
161
  continue;
122
162
  for (const f of readdirSync(dir)) {
123
163
  if (!f.endsWith(".md") || f === "README.md")
124
164
  continue;
125
165
  try {
126
- const { fm, body } = parseFrontmatter(readModelContextFileSync(join(dir, f), MAX_ROLE_BYTES));
166
+ const file = join(dir, f);
167
+ const { fm, body } = parseFrontmatter(readModelContextFileSync(file, MAX_ROLE_BYTES));
127
168
  const id = fm.name || f.replace(/\.md$/, "");
128
169
  const explicitReadOnly = /^(true|false)$/i.test(String(fm.readOnly ?? ""))
129
170
  ? String(fm.readOnly).toLowerCase() === "true"
130
171
  : undefined;
172
+ const claudeSource = source === "claude-global" || source === "claude-project";
173
+ const compatibilityWarnings = claudeSource
174
+ ? claudeCompatibilityWarnings(String(fm.description ?? ""), body)
175
+ : [];
176
+ const rawModel = fm.model ? String(fm.model) : "";
177
+ const foreignClaudeModel = claudeSource && /^claude(?:[-_.]|$)/i.test(rawModel);
131
178
  byId.set(id, {
132
179
  id,
133
180
  description: fm.description || "",
134
181
  owns: Array.isArray(fm.owns) ? fm.owns : [],
135
182
  rejects: Array.isArray(fm.rejects) ? fm.rejects : [],
136
- // Claude-Code model ALIASES (sonnet/opus/haiku/inherit) aren't hara model ids treat as
137
- // "inherit the session model" rather than passing a string no provider resolves.
138
- model: fm.model && !/^(sonnet|opus|haiku|inherit)$/i.test(String(fm.model)) ? fm.model : undefined,
183
+ // Claude aliases and Claude-provider ids cannot safely switch Hara's active providerinherit
184
+ // the session model instead of passing a foreign id to (for example) a Qwen/OpenAI endpoint.
185
+ model: rawModel && !/^(sonnet|opus|haiku|inherit)$/i.test(rawModel) && !foreignClaudeModel
186
+ ? rawModel
187
+ : undefined,
139
188
  allowTools: Array.isArray(fm.allowTools) ? fm.allowTools : claudeTools(fm.tools),
140
189
  denyTools: Array.isArray(fm.denyTools) ? fm.denyTools : undefined,
141
190
  readOnly: explicitReadOnly ?? (id.toLowerCase() === "reviewer" ? true : undefined),
191
+ modelInvocable: !isTrue(fm["disable-model-invocation"]) && compatibilityWarnings.length === 0,
192
+ compatibilityWarnings,
193
+ source,
194
+ file,
142
195
  system: body,
143
196
  });
144
197
  }
@@ -149,6 +202,65 @@ function rolesFromDirs(dirs) {
149
202
  }
150
203
  return byId;
151
204
  }
205
+ function compactRoleDescription(role) {
206
+ const description = role.description.replace(/\s+/g, " ").trim();
207
+ if (!description || !scanMemory(description).ok)
208
+ return "";
209
+ return description.length > ROLE_DESCRIPTION_CAP
210
+ ? description.slice(0, ROLE_DESCRIPTION_CAP - 1).trimEnd() + "…"
211
+ : description;
212
+ }
213
+ /** Compact metadata catalog for dispatch/planning. Role bodies remain progressive: only the selected role's
214
+ * persona is injected into its run. Descriptions are bounded and guarded because plugin roles can be
215
+ * untrusted. */
216
+ export function roleCatalog(roles, cap = ROLE_DIGEST_CAP) {
217
+ const lines = [];
218
+ const sourceRank = {
219
+ project: 0,
220
+ "claude-project": 1,
221
+ global: 2,
222
+ "claude-global": 3,
223
+ org: 4,
224
+ plugin: 5,
225
+ };
226
+ const ordered = [...roles].sort((a, b) => {
227
+ const source = (sourceRank[a.source ?? "plugin"] ?? 9) - (sourceRank[b.source ?? "plugin"] ?? 9);
228
+ if (source)
229
+ return source;
230
+ const ownership = Number(b.owns.length > 0) - Number(a.owns.length > 0);
231
+ if (ownership)
232
+ return ownership;
233
+ return a.id.localeCompare(b.id);
234
+ });
235
+ for (const role of ordered) {
236
+ if (role.modelInvocable === false)
237
+ continue;
238
+ const description = compactRoleDescription(role);
239
+ if (!description)
240
+ continue;
241
+ const flags = [role.readOnly ? "read-only" : "", role.source?.startsWith("claude-") ? "Claude-compatible" : ""]
242
+ .filter(Boolean)
243
+ .join(", ");
244
+ lines.push(`- ${role.id}${flags ? ` [${flags}]` : ""}: ${description}`);
245
+ }
246
+ let digest = lines.join("\n");
247
+ if (digest.length > cap)
248
+ digest = digest.slice(0, cap) + "\n…";
249
+ return digest;
250
+ }
251
+ let roleDigestCache = new Map();
252
+ /** Frozen-per-session specialist index for the ordinary Hara agent. This is the missing Claude-style
253
+ * discovery layer: the main agent sees role metadata, then loads only the chosen persona through agent/org. */
254
+ export function rolesDigest(cwd) {
255
+ if (roleDigestCache.has(cwd))
256
+ return roleDigestCache.get(cwd);
257
+ const digest = roleCatalog(loadRoles(cwd));
258
+ roleDigestCache.set(cwd, digest);
259
+ return digest;
260
+ }
261
+ export function invalidateRolesCache() {
262
+ roleDigestCache.clear();
263
+ }
152
264
  export function hasRoles(cwd) {
153
265
  return loadRoles(cwd).length > 0;
154
266
  }
@@ -193,6 +305,7 @@ Each \`*.md\` here is a role-agent. Frontmatter:
193
305
  - \`model\` — optional model override
194
306
  - \`allowTools\` / \`denyTools\` — restrict the role's tools
195
307
  - \`readOnly\` — enforce read/search-only tools (defaults on for a role named \`reviewer\`)
308
+ - \`disable-model-invocation\` — hide the role from automatic routing while keeping explicit \`--role\` use
196
309
 
197
310
  Run \`hara org "<task>"\` to dispatch a task to the owning role, or \`hara org --role <id> "<task>"\`.
198
311
  `,
@@ -208,5 +321,7 @@ export function scaffoldRoles(cwd) {
208
321
  written.push(name);
209
322
  }
210
323
  }
324
+ if (written.length)
325
+ invalidateRolesCache();
211
326
  return written;
212
327
  }
@@ -1,3 +1,5 @@
1
+ // Dispatcher — route a task to the role that OWNs it (keyword match first, LLM fallback).
2
+ import { roleCatalog } from "./roles.js";
1
3
  /** Deterministic routing by `owns`/`rejects` keywords. null if no clear owner. */
2
4
  export function routeByKeywords(task, roles) {
3
5
  const t = task.toLowerCase();
@@ -13,7 +15,7 @@ export function routeByKeywords(task, roles) {
13
15
  }
14
16
  /** Prompt for the LLM dispatcher fallback. */
15
17
  export function buildDispatchPrompt(task, roles) {
16
- const list = roles.map((r) => `- ${r.id}: ${r.description}`).join("\n");
18
+ const list = roleCatalog(roles);
17
19
  return `You are the dispatcher in an engineering org. Pick the single best role to own this task.
18
20
 
19
21
  Roles:
@@ -3,7 +3,10 @@
3
3
  // A user can explicitly pass selected names with HARA_SUBPROCESS_ENV_ALLOW=NAME,OTHER before launching Hara.
4
4
  import { redactSensitiveText } from "./secrets.js";
5
5
  import { spawn } from "node:child_process";
6
+ import { existsSync } from "node:fs";
6
7
  import { platform } from "node:os";
8
+ import { delimiter, join } from "node:path";
9
+ import { normalizePortableWindowsHome } from "../runtime.js";
7
10
  const SECRET_NAME = /(?:^|_)(?:API_?KEY|KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIALS?|COOKIE|JWT|AUTH)(?:_|$)/i;
8
11
  const SECRET_EXACT = new Set([
9
12
  "DATABASE_URL",
@@ -27,6 +30,32 @@ function explicitAllow(env) {
27
30
  .filter((name) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
28
31
  .map((name) => name.toUpperCase()));
29
32
  }
33
+ function environmentValue(env, name) {
34
+ const key = Object.keys(env).find((candidate) => candidate.toUpperCase() === name);
35
+ return key ? env[key] : undefined;
36
+ }
37
+ /** Installed plugin commands are user-approved Hara extensions. Make them reachable by model-controlled
38
+ * subprocesses without allowing them to shadow an existing system/project command: ~/.hara/bin is appended,
39
+ * never prepended. The interactive shell still owns its own PATH configuration. */
40
+ function appendHaraPluginBin(env) {
41
+ const explicitHome = environmentValue(env, "HOME");
42
+ const fallbackHome = environmentValue(env, "USERPROFILE");
43
+ // Match Hara's portable-home contract: an explicit Git Bash/MSYS HOME wins on Windows too.
44
+ const home = platform() === "win32" && explicitHome
45
+ ? normalizePortableWindowsHome(explicitHome)
46
+ : explicitHome ?? fallbackHome;
47
+ if (!home)
48
+ return;
49
+ const pluginBin = join(home, ".hara", "bin");
50
+ if (!existsSync(pluginBin))
51
+ return;
52
+ const pathKey = Object.keys(env).find((candidate) => candidate.toUpperCase() === "PATH") ?? "PATH";
53
+ const current = env[pathKey] ?? "";
54
+ const comparable = (value) => platform() === "win32" ? value.toLowerCase() : value;
55
+ if (current.split(delimiter).some((entry) => comparable(entry) === comparable(pluginBin)))
56
+ return;
57
+ env[pathKey] = current ? `${current}${delimiter}${pluginBin}` : pluginBin;
58
+ }
30
59
  /** Build an own copy so callers never mutate process.env. Explicit overrides (for example an MCP server's
31
60
  * configured env) are intentional and win after the inherited environment has been scrubbed. */
32
61
  export function toolSubprocessEnv(source = process.env, overrides = {}) {
@@ -48,6 +77,7 @@ export function toolSubprocessEnv(source = process.env, overrides = {}) {
48
77
  else
49
78
  out[name] = value;
50
79
  }
80
+ appendHaraPluginBin(out);
51
81
  return out;
52
82
  }
53
83
  /** Redact both recognizable credential syntax and the exact values of secret-named environment variables.
@@ -14,6 +14,8 @@ export const EXPLORE_SYSTEM = "You are a fast, READ-ONLY codebase explorer. Navi
14
14
  registerTool({
15
15
  name: "agent",
16
16
  description: "Delegate an independent sub-task to a fresh READ-ONLY sub-agent and get its conclusions. " +
17
+ "Specialist role ids and descriptions appear under `# Specialist roles` in the system context; pass one " +
18
+ "only when its distinct expertise helps, and give it a minimal self-contained brief instead of conversation dumps. " +
17
19
  "WHEN TO USE: open-ended exploration ('how does X work across the codebase', 'find everything that touches Y') " +
18
20
  "that would take more than ~3 searches — pass role \"explore\" for a fast search specialist; and spawning " +
19
21
  "SEVERAL agents in ONE response for independent questions (they run in parallel). " +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.124.4",
3
+ "version": "0.125.1",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"