@nanhara/hara 0.124.4 → 0.125.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 +16 -0
- package/README.md +17 -1
- package/dist/agent/loop.js +9 -1
- package/dist/index.js +31 -10
- package/dist/org/planner.js +15 -2
- package/dist/org/projects.js +3 -1
- package/dist/org/roles.js +131 -16
- package/dist/org/router.js +3 -1
- package/dist/tools/agent.js +2 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ 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.0 — 2026-07-18 — Claude-compatible specialist orchestration
|
|
9
|
+
|
|
10
|
+
- **Personal Claude Code subagents now work in Hara without copying prompts.** Hara discovers
|
|
11
|
+
`~/.claude/agents/*.md` as portable global roles in addition to project `.claude/agents`, translates common
|
|
12
|
+
Claude tool names, inherits the active Hara model for Claude aliases/provider-specific ids, and preserves
|
|
13
|
+
native/project Hara precedence on id collisions. Workflow-only prompts and prompts that require a private
|
|
14
|
+
Claude notification/skill dependency stay explicit-only rather than being selected automatically.
|
|
15
|
+
- **Ordinary Hara conversations can now discover the right specialist.** The main agent receives a bounded,
|
|
16
|
+
guarded name/description catalog and loads only the selected role body. Delegation guidance requires a
|
|
17
|
+
minimal self-contained brief, distinct non-overlapping work, and parent-side synthesis; fan-out specialists
|
|
18
|
+
remain read-only so role prompts cannot bypass the edit/command approval boundary.
|
|
19
|
+
- **Plans route by responsibility instead of role name alone.** The atom planner receives bounded role
|
|
20
|
+
descriptions and read-only status, excludes `disable-model-invocation` roles from automatic selection, and
|
|
21
|
+
removes unknown role ids instead of silently executing under a generic persona. Explicit role execution
|
|
22
|
+
remains available for host-coupled prompts.
|
|
23
|
+
|
|
8
24
|
## 0.124.4 — 2026-07-18 — deadline-aware task checkpoints
|
|
9
25
|
|
|
10
26
|
- **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
|
|
package/dist/agent/loop.js
CHANGED
|
@@ -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.
|
|
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
|
@@ -503,20 +503,25 @@ async function runOrg(task, o) {
|
|
|
503
503
|
}
|
|
504
504
|
}
|
|
505
505
|
else {
|
|
506
|
-
const
|
|
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,
|
|
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 ${
|
|
519
|
-
role = parseRoleId(r.text,
|
|
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
|
|
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
|
-
|
|
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
|
|
@@ -3668,7 +3689,7 @@ program.action(async (opts) => {
|
|
|
3668
3689
|
if (!rs.length)
|
|
3669
3690
|
return void out(c.dim("No roles. Run `hara roles init`.\n"));
|
|
3670
3691
|
for (const r of rs)
|
|
3671
|
-
out(` ${r.id} ${c.dim(
|
|
3692
|
+
out(` ${r.id} ${c.dim(`[${roleMeta(r)}] owns: ${r.owns.join(", ")}`)}\n`);
|
|
3672
3693
|
},
|
|
3673
3694
|
},
|
|
3674
3695
|
{
|
|
@@ -4358,7 +4379,7 @@ program.action(async (opts) => {
|
|
|
4358
4379
|
return void h.sink.notice(applyVision(arg));
|
|
4359
4380
|
if (nm === "roles") {
|
|
4360
4381
|
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`.");
|
|
4382
|
+
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
4383
|
}
|
|
4363
4384
|
if (nm === "skills") {
|
|
4364
4385
|
const ss = loadSkillIndex(cwd);
|
package/dist/org/planner.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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) {
|
package/dist/org/projects.js
CHANGED
|
@@ -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
|
|
2
|
-
// Frontmatter: name, description, owns[], rejects[], model?, allowTools[], denyTools[],
|
|
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(
|
|
65
|
-
|
|
66
|
-
|
|
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) <
|
|
110
|
-
|
|
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
|
|
113
|
-
*
|
|
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([
|
|
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
|
|
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-
|
|
137
|
-
//
|
|
138
|
-
model:
|
|
183
|
+
// Claude aliases and Claude-provider ids cannot safely switch Hara's active provider — inherit
|
|
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
|
}
|
package/dist/org/router.js
CHANGED
|
@@ -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
|
|
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:
|
package/dist/tools/agent.js
CHANGED
|
@@ -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). " +
|