@nanhara/hara 0.0.2 → 0.48.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 +582 -0
- package/CLA.md +1 -1
- package/README.md +207 -10
- package/dist/activity.js +30 -0
- package/dist/agent/loop.js +184 -0
- package/dist/config.js +114 -0
- package/dist/context/agents-md.js +64 -0
- package/dist/context/mentions.js +90 -0
- package/dist/diff.js +103 -0
- package/dist/fs-walk.js +103 -0
- package/dist/fuzzy.js +62 -0
- package/dist/images.js +146 -0
- package/dist/index.js +1589 -0
- package/dist/mcp/client.js +54 -0
- package/dist/md.js +52 -0
- package/dist/memory/guard.js +51 -0
- package/dist/memory/store.js +93 -0
- package/dist/org/planner.js +174 -0
- package/dist/org/roles.js +140 -0
- package/dist/org/router.js +39 -0
- package/dist/plugins/plugins.js +124 -0
- package/dist/providers/anthropic.js +83 -0
- package/dist/providers/openai.js +125 -0
- package/dist/providers/qwen-oauth.js +139 -0
- package/dist/providers/types.js +2 -0
- package/dist/recall.js +76 -0
- package/dist/sandbox.js +78 -0
- package/dist/search/embed.js +42 -0
- package/dist/search/hybrid.js +38 -0
- package/dist/search/semindex.js +192 -0
- package/dist/session/store.js +109 -0
- package/dist/skills/skills.js +141 -0
- package/dist/statusbar.js +69 -0
- package/dist/tools/agent.js +26 -0
- package/dist/tools/apply-core.js +63 -0
- package/dist/tools/builtin.js +106 -0
- package/dist/tools/codebase.js +102 -0
- package/dist/tools/computer.js +376 -0
- package/dist/tools/edit.js +62 -0
- package/dist/tools/memory.js +147 -0
- package/dist/tools/patch.js +123 -0
- package/dist/tools/registry.js +18 -0
- package/dist/tools/search.js +176 -0
- package/dist/tools/skill.js +30 -0
- package/dist/tools/web.js +73 -0
- package/dist/tui/App.js +200 -0
- package/dist/tui/InputBox.js +208 -0
- package/dist/tui/run.js +10 -0
- package/dist/tui/theme.js +11 -0
- package/dist/ui.js +17 -0
- package/dist/undo.js +40 -0
- package/dist/vision.js +130 -0
- package/package.json +34 -9
- package/plugins/browser/.hara-plugin/plugin.json +9 -0
- package/plugins/browser/skills/web/SKILL.md +27 -0
- package/plugins/chrome/.hara-plugin/plugin.json +9 -0
- package/plugins/chrome/skills/chrome/SKILL.md +26 -0
- package/LICENSE-MIT +0 -21
- package/bin/hara.mjs +0 -25
- /package/{LICENSE-APACHE → LICENSE} +0 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// MCP client — connect to configured MCP servers (stdio) and register their tools into hara.
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
4
|
+
import { registerTool } from "../tools/registry.js";
|
|
5
|
+
const clients = [];
|
|
6
|
+
/** Connect each server, register its tools as `mcp__<server>__<tool>`. Returns #tools registered. */
|
|
7
|
+
export async function connectMcpServers(servers, log) {
|
|
8
|
+
let count = 0;
|
|
9
|
+
for (const [name, cfg] of Object.entries(servers)) {
|
|
10
|
+
try {
|
|
11
|
+
const transport = new StdioClientTransport({
|
|
12
|
+
command: cfg.command,
|
|
13
|
+
args: cfg.args ?? [],
|
|
14
|
+
env: { ...process.env, ...(cfg.env ?? {}) },
|
|
15
|
+
});
|
|
16
|
+
const client = new Client({ name: "hara", version: "0.4.0" }, { capabilities: {} });
|
|
17
|
+
await client.connect(transport);
|
|
18
|
+
clients.push(client);
|
|
19
|
+
const { tools } = await client.listTools();
|
|
20
|
+
for (const t of tools) {
|
|
21
|
+
const schema = t.inputSchema ?? { type: "object", properties: {} };
|
|
22
|
+
registerTool({
|
|
23
|
+
name: `mcp__${name}__${t.name}`,
|
|
24
|
+
description: t.description ?? `${name}/${t.name}`,
|
|
25
|
+
input_schema: schema,
|
|
26
|
+
kind: "exec",
|
|
27
|
+
async run(input) {
|
|
28
|
+
const res = await client.callTool({ name: t.name, arguments: input ?? {} });
|
|
29
|
+
const blocks = Array.isArray(res?.content) ? res.content : [];
|
|
30
|
+
const text = blocks.map((b) => (b?.type === "text" ? b.text : JSON.stringify(b))).join("\n");
|
|
31
|
+
return text || "(no output)";
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
count++;
|
|
35
|
+
}
|
|
36
|
+
log(`mcp: ${name} → ${tools.length} tool(s)`);
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
log(`mcp: ${name} failed (${e?.message ?? e})`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return count;
|
|
43
|
+
}
|
|
44
|
+
export async function closeMcp() {
|
|
45
|
+
for (const cl of clients) {
|
|
46
|
+
try {
|
|
47
|
+
await cl.close();
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* ignore */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
clients.length = 0;
|
|
54
|
+
}
|
package/dist/md.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Tiny streaming Markdown renderer for assistant output. Line-buffered (style on complete lines,
|
|
2
|
+
// codex-style) so headers/bold/inline-code/bullets render in a terminal instead of showing raw
|
|
3
|
+
// `**`/`##`/backticks. Code fences are passed through verbatim (copy-paste accurate). Color via the
|
|
4
|
+
// shared `c` helper, so in a non-TTY it degrades to the structural transform only.
|
|
5
|
+
import { c } from "./ui.js";
|
|
6
|
+
const inline = (s) => s.replace(/\*\*([^*]+)\*\*/g, (_, x) => c.bold(x)).replace(/`([^`]+)`/g, (_, x) => c.cyan(x));
|
|
7
|
+
/** Style one complete line given the running fence state. */
|
|
8
|
+
export function styleLine(line, state) {
|
|
9
|
+
if (line.trimStart().startsWith("```")) {
|
|
10
|
+
state.inFence = !state.inFence;
|
|
11
|
+
return c.dim(line);
|
|
12
|
+
}
|
|
13
|
+
if (state.inFence)
|
|
14
|
+
return line; // inside a code block — leave verbatim
|
|
15
|
+
const h = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
16
|
+
if (h)
|
|
17
|
+
return c.bold(inline(h[2]));
|
|
18
|
+
const b = /^(\s*)[-*]\s+(.*)$/.exec(line);
|
|
19
|
+
if (b)
|
|
20
|
+
return `${b[1]}${c.cyan("•")} ${inline(b[2])}`;
|
|
21
|
+
return inline(line);
|
|
22
|
+
}
|
|
23
|
+
/** Whole-block render (non-streaming) — used in tests. */
|
|
24
|
+
export function renderMarkdown(text) {
|
|
25
|
+
const state = { inFence: false };
|
|
26
|
+
return text
|
|
27
|
+
.split("\n")
|
|
28
|
+
.map((l) => styleLine(l, state))
|
|
29
|
+
.join("\n");
|
|
30
|
+
}
|
|
31
|
+
/** Streaming sink: feed deltas via push(), flush the tail with end(). Styles complete lines. */
|
|
32
|
+
export function makeRenderer(write) {
|
|
33
|
+
const state = { inFence: false };
|
|
34
|
+
let buf = "";
|
|
35
|
+
return {
|
|
36
|
+
push(d) {
|
|
37
|
+
buf += d;
|
|
38
|
+
let nl;
|
|
39
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
40
|
+
const line = buf.slice(0, nl);
|
|
41
|
+
buf = buf.slice(nl + 1);
|
|
42
|
+
write(styleLine(line, state) + "\n");
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
end() {
|
|
46
|
+
if (buf) {
|
|
47
|
+
write(styleLine(buf, state));
|
|
48
|
+
buf = "";
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Lexical guard for agent-written content (memory + skills). No embeddings — small pattern lists.
|
|
2
|
+
// Two policies by direction (the asset pipeline's "redact on the way in, block on the way out"):
|
|
3
|
+
// • scanMemory() — BLOCK: used at LOAD/inject time (skill bodies, memory) — poisoned content must not
|
|
4
|
+
// come back into the prompt. Checks secrets AND injection phrases.
|
|
5
|
+
// • redactSecrets()/scrubLocal() — REDACT: used at CAPTURE time (skill_create) — strip secrets +
|
|
6
|
+
// local identifiers so a reusable snippet is safe to persist (and later share).
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
// Secret-shaped tokens — redactable to a typed placeholder on capture; still blocked on load.
|
|
9
|
+
const SECRETS = [
|
|
10
|
+
[/\bsk-[a-zA-Z0-9_-]{16,}\b/, "sk-key"],
|
|
11
|
+
[/\bAKIA[0-9A-Z]{16}\b/, "aws-key"],
|
|
12
|
+
[/\bghp_[A-Za-z0-9]{20,}\b/, "github-token"],
|
|
13
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/, "private-key"],
|
|
14
|
+
];
|
|
15
|
+
// Prompt-injection phrases + exfil URLs — block-only (can't meaningfully "redact" an instruction).
|
|
16
|
+
const INJECTION = [
|
|
17
|
+
[/ignore (all |your )?(previous|prior|above) (instructions|prompts?)/i, "prompt-injection phrase"],
|
|
18
|
+
[/disregard (your |the )?(system prompt|instructions|rules|guidelines)/i, "prompt-injection phrase"],
|
|
19
|
+
[/\bfile:\/\/\/?\S+/i, "file:// URL"],
|
|
20
|
+
];
|
|
21
|
+
const ALL = [...SECRETS, ...INJECTION];
|
|
22
|
+
/** Scan agent-written text; ok=false (with labels) when something looks unsafe to persist/inject. */
|
|
23
|
+
export function scanMemory(text) {
|
|
24
|
+
const hits = [...new Set(ALL.filter(([re]) => re.test(text)).map(([, label]) => label))];
|
|
25
|
+
return { ok: hits.length === 0, hits };
|
|
26
|
+
}
|
|
27
|
+
/** Replace secret-shaped tokens with typed placeholders (capture path). Injection phrases are left for
|
|
28
|
+
* scanMemory to block — they aren't redactable. */
|
|
29
|
+
export function redactSecrets(text) {
|
|
30
|
+
const redactions = [];
|
|
31
|
+
let out = text;
|
|
32
|
+
for (const [re, label] of SECRETS) {
|
|
33
|
+
const g = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
|
|
34
|
+
out = out.replace(g, () => {
|
|
35
|
+
redactions.push(label);
|
|
36
|
+
return `<REDACTED:${label}>`;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return { text: out, redactions };
|
|
40
|
+
}
|
|
41
|
+
/** Deterministically generalize local identifiers so a captured snippet isn't tied to this machine:
|
|
42
|
+
* the project path → <project>, the home dir → ~, and email addresses → <email>. Light + reversible. */
|
|
43
|
+
export function scrubLocal(text, cwd) {
|
|
44
|
+
let out = text;
|
|
45
|
+
if (cwd)
|
|
46
|
+
out = out.split(cwd).join("<project>");
|
|
47
|
+
const home = homedir();
|
|
48
|
+
if (home)
|
|
49
|
+
out = out.split(home).join("~");
|
|
50
|
+
return out.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, "<email>");
|
|
51
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Agent-curated memory — durable facts/decisions/prefs hara records and recalls across sessions.
|
|
2
|
+
// File-backed Markdown (git-versionable, human-readable), two scopes: global ~/.hara/memory and
|
|
3
|
+
// project <root>/.hara/memory. Lexical search reuses recall.ts; no embeddings (local-first).
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join, dirname } from "node:path";
|
|
6
|
+
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync } from "node:fs";
|
|
7
|
+
import { findProjectRoot } from "../context/agents-md.js";
|
|
8
|
+
const DIGEST_CAP = 4000; // chars of MEMORY/USER injected at session start (logs reached via search)
|
|
9
|
+
export function memoryDir(scope, cwd) {
|
|
10
|
+
if (scope === "global")
|
|
11
|
+
return process.env.HARA_MEMORY || join(homedir(), ".hara", "memory");
|
|
12
|
+
return join(findProjectRoot(cwd), ".hara", "memory");
|
|
13
|
+
}
|
|
14
|
+
/** Dirs to search for memory (project first, then global). */
|
|
15
|
+
export function memoryRoots(cwd) {
|
|
16
|
+
return [memoryDir("project", cwd), memoryDir("global", cwd)];
|
|
17
|
+
}
|
|
18
|
+
function today() {
|
|
19
|
+
const d = new Date();
|
|
20
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
21
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
22
|
+
}
|
|
23
|
+
function targetFile(scope, target, cwd) {
|
|
24
|
+
const dir = memoryDir(scope, cwd);
|
|
25
|
+
if (target === "user")
|
|
26
|
+
return join(dir, "USER.md");
|
|
27
|
+
if (target === "log")
|
|
28
|
+
return join(dir, "log", `${today()}.md`);
|
|
29
|
+
return join(dir, "MEMORY.md");
|
|
30
|
+
}
|
|
31
|
+
export function appendMemory(scope, target, content, cwd) {
|
|
32
|
+
const f = targetFile(scope, target, cwd);
|
|
33
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
34
|
+
appendFileSync(f, (existsSync(f) ? "\n" : "") + content.trim() + "\n", "utf8");
|
|
35
|
+
return f;
|
|
36
|
+
}
|
|
37
|
+
export function replaceMemory(scope, target, content, cwd) {
|
|
38
|
+
const f = targetFile(scope, target, cwd);
|
|
39
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
40
|
+
writeFileSync(f, content.trim() + "\n", "utf8");
|
|
41
|
+
return f;
|
|
42
|
+
}
|
|
43
|
+
export function forgetMemory(scope, target, match, cwd) {
|
|
44
|
+
const f = targetFile(scope, target, cwd);
|
|
45
|
+
if (!existsSync(f) || !match)
|
|
46
|
+
return 0;
|
|
47
|
+
const lines = readFileSync(f, "utf8").split("\n");
|
|
48
|
+
const kept = lines.filter((l) => !l.includes(match));
|
|
49
|
+
writeFileSync(f, kept.join("\n"), "utf8");
|
|
50
|
+
return lines.length - kept.length;
|
|
51
|
+
}
|
|
52
|
+
/** Capped MEMORY + USER digest (project + global) for frozen-snapshot injection at session start. */
|
|
53
|
+
export function memoryDigest(cwd) {
|
|
54
|
+
const sources = [
|
|
55
|
+
["project", "memory", "project MEMORY"],
|
|
56
|
+
["global", "memory", "global MEMORY"],
|
|
57
|
+
["global", "user", "USER preferences"],
|
|
58
|
+
];
|
|
59
|
+
const parts = [];
|
|
60
|
+
for (const [scope, target, label] of sources) {
|
|
61
|
+
const f = targetFile(scope, target, cwd);
|
|
62
|
+
if (!existsSync(f))
|
|
63
|
+
continue;
|
|
64
|
+
try {
|
|
65
|
+
const t = readFileSync(f, "utf8").trim();
|
|
66
|
+
if (t)
|
|
67
|
+
parts.push(`## ${label}\n${t}`);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
/* skip unreadable */
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const out = parts.join("\n\n");
|
|
74
|
+
return out.length > DIGEST_CAP ? out.slice(0, DIGEST_CAP) + "\n…[memory truncated — use memory_search]" : out;
|
|
75
|
+
}
|
|
76
|
+
/** Create memory dirs + seed files (global + project). Returns files written. */
|
|
77
|
+
export function scaffoldMemory(cwd) {
|
|
78
|
+
const written = [];
|
|
79
|
+
for (const scope of ["global", "project"]) {
|
|
80
|
+
mkdirSync(join(memoryDir(scope, cwd), "log"), { recursive: true });
|
|
81
|
+
const mem = join(memoryDir(scope, cwd), "MEMORY.md");
|
|
82
|
+
if (!existsSync(mem)) {
|
|
83
|
+
writeFileSync(mem, `# hara ${scope} memory\n\nDurable facts & decisions hara records and recalls across sessions. Git-versionable — edit freely.\n`, "utf8");
|
|
84
|
+
written.push(mem);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const user = join(memoryDir("global", cwd), "USER.md");
|
|
88
|
+
if (!existsSync(user)) {
|
|
89
|
+
writeFileSync(user, "# User preferences\n\nHow you like hara to work — voice, conventions, do/don't.\n", "utf8");
|
|
90
|
+
written.push(user);
|
|
91
|
+
}
|
|
92
|
+
return written;
|
|
93
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Atomization planner — the execution methodology made real:
|
|
2
|
+
// FRAME the task → ATOMIZE into smallest verifiable steps → SEQUENCE as a DAG →
|
|
3
|
+
// execute each atom (optionally routed to a role) → VERIFY gate. State is the SSOT
|
|
4
|
+
// at .hara/org/plan.json. This is hara's differentiator: not one agent, an org that plans.
|
|
5
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { runShell } from "../sandbox.js";
|
|
8
|
+
const PLAN_SYSTEM = `You are hara's planner. Decompose a coding task using this method:
|
|
9
|
+
1) FRAME the goal in one sentence.
|
|
10
|
+
2) ATOMIZE into the smallest independently-verifiable steps.
|
|
11
|
+
3) SEQUENCE them with dependencies (a step lists the ids it depends on).
|
|
12
|
+
Return ONLY a JSON object, no prose:
|
|
13
|
+
{"atoms":[{"id":"a1","title":"imperative step","detail":"how/where","deps":[],"verify":"observable done-criteria","check":"shell command exiting 0 iff done (optional)","role":"<roleId or omit>"}]}
|
|
14
|
+
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.`;
|
|
15
|
+
/** Ask the model to decompose `task` into an atomized, sequenced plan. */
|
|
16
|
+
export async function decompose(provider, task, roles) {
|
|
17
|
+
const roleHint = roles.length ? `\nAvailable roles for the optional "role" field: ${roles.map((r) => r.id).join(", ")}.` : "";
|
|
18
|
+
const r = await provider.turn({
|
|
19
|
+
system: PLAN_SYSTEM + roleHint,
|
|
20
|
+
history: [{ role: "user", content: `Task: ${task}\n\nReturn the JSON plan.` }],
|
|
21
|
+
tools: [],
|
|
22
|
+
onText: () => { },
|
|
23
|
+
});
|
|
24
|
+
return { task, atoms: parsePlan(r.text), createdAt: new Date().toISOString() };
|
|
25
|
+
}
|
|
26
|
+
/** Extract + normalize atoms from the model's (possibly fenced/noisy) JSON reply. */
|
|
27
|
+
export function parsePlan(text) {
|
|
28
|
+
const json = extractJson(text);
|
|
29
|
+
if (!json)
|
|
30
|
+
return [];
|
|
31
|
+
let raw;
|
|
32
|
+
try {
|
|
33
|
+
raw = JSON.parse(json);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
const list = Array.isArray(raw) ? raw : Array.isArray(raw?.atoms) ? raw.atoms : [];
|
|
39
|
+
const atoms = [];
|
|
40
|
+
list.forEach((a, i) => {
|
|
41
|
+
if (!a || typeof a.title !== "string")
|
|
42
|
+
return;
|
|
43
|
+
atoms.push({
|
|
44
|
+
id: typeof a.id === "string" && a.id ? a.id : `a${i + 1}`,
|
|
45
|
+
title: a.title.trim(),
|
|
46
|
+
detail: typeof a.detail === "string" ? a.detail : undefined,
|
|
47
|
+
deps: Array.isArray(a.deps) ? a.deps.filter((d) => typeof d === "string") : [],
|
|
48
|
+
verify: typeof a.verify === "string" ? a.verify : undefined,
|
|
49
|
+
check: typeof a.check === "string" && a.check ? a.check : undefined,
|
|
50
|
+
role: typeof a.role === "string" && a.role ? a.role : undefined,
|
|
51
|
+
status: "pending",
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
return atoms;
|
|
55
|
+
}
|
|
56
|
+
function extractJson(text) {
|
|
57
|
+
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
58
|
+
if (fenced)
|
|
59
|
+
return fenced[1].trim();
|
|
60
|
+
const start = text.indexOf("{");
|
|
61
|
+
const end = text.lastIndexOf("}");
|
|
62
|
+
if (start >= 0 && end > start)
|
|
63
|
+
return text.slice(start, end + 1);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
/** Topological order (Kahn). Unknown deps are ignored; returns an error on a cycle. */
|
|
67
|
+
export function topoOrder(atoms) {
|
|
68
|
+
const byId = new Map(atoms.map((a) => [a.id, a]));
|
|
69
|
+
const indeg = new Map(atoms.map((a) => [a.id, 0]));
|
|
70
|
+
const adj = new Map(atoms.map((a) => [a.id, []]));
|
|
71
|
+
for (const a of atoms) {
|
|
72
|
+
for (const d of a.deps) {
|
|
73
|
+
if (!byId.has(d))
|
|
74
|
+
continue; // ignore dangling deps
|
|
75
|
+
indeg.set(a.id, (indeg.get(a.id) ?? 0) + 1);
|
|
76
|
+
adj.get(d).push(a.id);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const q = atoms.filter((a) => (indeg.get(a.id) ?? 0) === 0).map((a) => a.id);
|
|
80
|
+
const order = [];
|
|
81
|
+
while (q.length) {
|
|
82
|
+
const id = q.shift();
|
|
83
|
+
order.push(byId.get(id));
|
|
84
|
+
for (const nx of adj.get(id)) {
|
|
85
|
+
indeg.set(nx, indeg.get(nx) - 1);
|
|
86
|
+
if (indeg.get(nx) === 0)
|
|
87
|
+
q.push(nx);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (order.length !== atoms.length)
|
|
91
|
+
return { error: "plan has a dependency cycle — cannot sequence" };
|
|
92
|
+
return { ok: order };
|
|
93
|
+
}
|
|
94
|
+
/** Group atoms into dependency "waves": every atom in a wave depends only on atoms in EARLIER waves, so a
|
|
95
|
+
* wave's atoms are mutually independent and may run concurrently. Preserves atom order; errors on a cycle. */
|
|
96
|
+
export function topoWaves(atoms) {
|
|
97
|
+
const byId = new Map(atoms.map((a) => [a.id, a]));
|
|
98
|
+
const remaining = new Map(atoms.map((a) => [a.id, a]));
|
|
99
|
+
const done = new Set();
|
|
100
|
+
const waves = [];
|
|
101
|
+
while (remaining.size) {
|
|
102
|
+
const wave = [...remaining.values()].filter((a) => a.deps.every((d) => !byId.has(d) || done.has(d)));
|
|
103
|
+
if (!wave.length)
|
|
104
|
+
return { error: "plan has a dependency cycle — cannot sequence" };
|
|
105
|
+
for (const a of wave)
|
|
106
|
+
remaining.delete(a.id);
|
|
107
|
+
for (const a of wave)
|
|
108
|
+
done.add(a.id);
|
|
109
|
+
waves.push(wave);
|
|
110
|
+
}
|
|
111
|
+
return { ok: waves };
|
|
112
|
+
}
|
|
113
|
+
/** Prompt to execute a single atom in the context of the overall plan. */
|
|
114
|
+
export function atomPrompt(atom, plan, done) {
|
|
115
|
+
const priors = done.length ? `Already completed: ${done.map((a) => a.title).join("; ")}.\n` : "";
|
|
116
|
+
return (`You are executing ONE step of a larger plan — do only this step.\n` +
|
|
117
|
+
`Overall task: ${plan.task}\n` +
|
|
118
|
+
`${priors}` +
|
|
119
|
+
`This step (${atom.id}): ${atom.title}\n` +
|
|
120
|
+
(atom.detail ? `Details: ${atom.detail}\n` : "") +
|
|
121
|
+
`Done when: ${atom.verify ?? "the step is complete"}\n` +
|
|
122
|
+
`Use tools as needed. Finish with a one-line result.`);
|
|
123
|
+
}
|
|
124
|
+
/** Soft verification gate: ask the model whether the atom met its done-criteria. */
|
|
125
|
+
export async function verify(provider, atom, transcriptTail) {
|
|
126
|
+
const r = await provider.turn({
|
|
127
|
+
system: "You verify whether a coding step met its done-criteria. " +
|
|
128
|
+
"Reply EXACTLY 'DONE' if met, or 'NEEDSWORK: <short reason>' if not. No other text.",
|
|
129
|
+
history: [
|
|
130
|
+
{
|
|
131
|
+
role: "user",
|
|
132
|
+
content: `Step: ${atom.title}\nDone-criteria: ${atom.verify ?? "step complete"}\n\nWhat the agent did:\n${transcriptTail.slice(0, 4000)}\n\nVerdict:`,
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
tools: [],
|
|
136
|
+
onText: () => { },
|
|
137
|
+
});
|
|
138
|
+
const t = r.text.trim();
|
|
139
|
+
if (/^done\b/i.test(t))
|
|
140
|
+
return { ok: true, reason: "verified" };
|
|
141
|
+
return { ok: false, reason: t.replace(/^needswork:?\s*/i, "").slice(0, 200) || "did not meet criteria" };
|
|
142
|
+
}
|
|
143
|
+
/** Objective gate: run the atom's `check` shell command; exit 0 = pass. */
|
|
144
|
+
export async function runCheck(cmd, cwd, sandbox) {
|
|
145
|
+
try {
|
|
146
|
+
const { stdout } = await runShell(cmd, cwd, sandbox, { timeout: 120_000, maxBuffer: 1_000_000 });
|
|
147
|
+
return { ok: true, reason: (stdout.trim().split("\n").pop() || "ok").slice(0, 200) };
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
const out = (e?.stderr || e?.stdout || e?.message || "").toString().trim();
|
|
151
|
+
return { ok: false, reason: (out.split("\n").pop() || `exit ${e?.code ?? "?"}`).slice(0, 200) };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function planDir(cwd) {
|
|
155
|
+
const d = join(cwd, ".hara", "org");
|
|
156
|
+
mkdirSync(d, { recursive: true });
|
|
157
|
+
return d;
|
|
158
|
+
}
|
|
159
|
+
const planFile = (cwd) => join(planDir(cwd), "plan.json");
|
|
160
|
+
/** SSOT: persist plan state so it's inspectable / resumable. */
|
|
161
|
+
export function savePlan(cwd, plan) {
|
|
162
|
+
writeFileSync(planFile(cwd), JSON.stringify(plan, null, 2), "utf8");
|
|
163
|
+
}
|
|
164
|
+
export function loadPlan(cwd) {
|
|
165
|
+
const p = planFile(cwd);
|
|
166
|
+
if (!existsSync(p))
|
|
167
|
+
return null;
|
|
168
|
+
try {
|
|
169
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Org roles — markdown agent definitions in <project>/.hara/roles/*.md.
|
|
2
|
+
// Frontmatter: name, description, owns[], rejects[], model?, allowTools[], denyTools[]. Body = persona/system.
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { findProjectRoot } from "../context/agents-md.js";
|
|
7
|
+
import { pluginRoleDirs } from "../plugins/plugins.js";
|
|
8
|
+
export function rolesDir(cwd) {
|
|
9
|
+
return join(findProjectRoot(cwd), ".hara", "roles");
|
|
10
|
+
}
|
|
11
|
+
/** Global roles — reusable personas across all projects. */
|
|
12
|
+
export function globalRolesDir() {
|
|
13
|
+
return join(homedir(), ".hara", "roles");
|
|
14
|
+
}
|
|
15
|
+
/** Claude-Code subagents (`.claude/agents/*.md`) — consumed for ecosystem interop (project scope). */
|
|
16
|
+
export function claudeAgentsDir(cwd) {
|
|
17
|
+
return join(findProjectRoot(cwd), ".claude", "agents");
|
|
18
|
+
}
|
|
19
|
+
/** Accept Claude-Code `tools:` (comma string or list) as an alias for hara's allowTools. */
|
|
20
|
+
function claudeTools(v) {
|
|
21
|
+
if (Array.isArray(v))
|
|
22
|
+
return v;
|
|
23
|
+
if (typeof v === "string" && v.trim())
|
|
24
|
+
return v.split(",").map((s) => s.trim()).filter(Boolean);
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
function parseFrontmatter(text) {
|
|
28
|
+
const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(text);
|
|
29
|
+
if (!m)
|
|
30
|
+
return { fm: {}, body: text.trim() };
|
|
31
|
+
const fm = {};
|
|
32
|
+
for (const raw of m[1].split("\n")) {
|
|
33
|
+
const line = raw.trim();
|
|
34
|
+
const kv = /^([A-Za-z0-9_]+)\s*:\s*(.*)$/.exec(line);
|
|
35
|
+
if (!kv)
|
|
36
|
+
continue;
|
|
37
|
+
const key = kv[1];
|
|
38
|
+
const val = kv[2].trim();
|
|
39
|
+
if (val.startsWith("[") && val.endsWith("]")) {
|
|
40
|
+
fm[key] = val
|
|
41
|
+
.slice(1, -1)
|
|
42
|
+
.split(",")
|
|
43
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
fm[key] = val.replace(/^["']|["']$/g, "");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { fm, body: m[2].trim() };
|
|
51
|
+
}
|
|
52
|
+
export function loadRoles(cwd) {
|
|
53
|
+
const byId = new Map();
|
|
54
|
+
// lowest→highest precedence: plugins < global < .claude/agents < .hara/roles (project wins, same as memory/config)
|
|
55
|
+
for (const dir of [...pluginRoleDirs(), globalRolesDir(), claudeAgentsDir(cwd), rolesDir(cwd)]) {
|
|
56
|
+
if (!existsSync(dir))
|
|
57
|
+
continue;
|
|
58
|
+
for (const f of readdirSync(dir)) {
|
|
59
|
+
if (!f.endsWith(".md") || f === "README.md")
|
|
60
|
+
continue;
|
|
61
|
+
try {
|
|
62
|
+
const { fm, body } = parseFrontmatter(readFileSync(join(dir, f), "utf8"));
|
|
63
|
+
const id = fm.name || f.replace(/\.md$/, "");
|
|
64
|
+
byId.set(id, {
|
|
65
|
+
id,
|
|
66
|
+
description: fm.description || "",
|
|
67
|
+
owns: Array.isArray(fm.owns) ? fm.owns : [],
|
|
68
|
+
rejects: Array.isArray(fm.rejects) ? fm.rejects : [],
|
|
69
|
+
model: fm.model || undefined,
|
|
70
|
+
allowTools: Array.isArray(fm.allowTools) ? fm.allowTools : claudeTools(fm.tools),
|
|
71
|
+
denyTools: Array.isArray(fm.denyTools) ? fm.denyTools : undefined,
|
|
72
|
+
system: body,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
/* skip bad role file */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return [...byId.values()];
|
|
81
|
+
}
|
|
82
|
+
export function hasRoles(cwd) {
|
|
83
|
+
return loadRoles(cwd).length > 0;
|
|
84
|
+
}
|
|
85
|
+
const SCAFFOLD = {
|
|
86
|
+
"implementer.md": `---
|
|
87
|
+
name: implementer
|
|
88
|
+
description: Implements features, fixes bugs, and refactors code.
|
|
89
|
+
owns: [implement, add, feature, fix, bug, refactor, build, create, write, change]
|
|
90
|
+
model:
|
|
91
|
+
---
|
|
92
|
+
You are the **implementer** on an engineering team. You write and change code to satisfy the task.
|
|
93
|
+
Make small, verifiable edits (prefer edit_file over rewriting). Run tests/build when relevant.
|
|
94
|
+
End with a one-line summary of what changed.
|
|
95
|
+
`,
|
|
96
|
+
"reviewer.md": `---
|
|
97
|
+
name: reviewer
|
|
98
|
+
description: Reviews code for bugs, correctness, security, and style. Does not modify code.
|
|
99
|
+
owns: [review, audit, check, correctness, security, vulnerability, lint, quality]
|
|
100
|
+
allowTools: [read_file, bash]
|
|
101
|
+
---
|
|
102
|
+
You are the **reviewer**. Read the relevant code and report concrete issues (bug / correctness /
|
|
103
|
+
security / style) with file:line and a suggested fix. Do NOT edit files — you have read-only tools.
|
|
104
|
+
Be specific; skip nitpicks unless asked.
|
|
105
|
+
`,
|
|
106
|
+
"docs.md": `---
|
|
107
|
+
name: docs
|
|
108
|
+
description: Writes and updates documentation, READMEs, and code comments.
|
|
109
|
+
owns: [doc, docs, document, readme, comment, explain, guide, changelog]
|
|
110
|
+
---
|
|
111
|
+
You are the **docs** writer. Produce clear, concise documentation grounded in the actual code.
|
|
112
|
+
Update or create the relevant files with write_file/edit_file. Match the project's existing tone.
|
|
113
|
+
`,
|
|
114
|
+
"README.md": `# Org roles
|
|
115
|
+
|
|
116
|
+
Each \`*.md\` here is a role-agent. Frontmatter:
|
|
117
|
+
|
|
118
|
+
- \`name\` — role id
|
|
119
|
+
- \`description\` — what it owns (used by the dispatcher)
|
|
120
|
+
- \`owns\` — keywords that route a task here (OWN)
|
|
121
|
+
- \`rejects\` — keywords that exclude this role (REJECT)
|
|
122
|
+
- \`model\` — optional model override
|
|
123
|
+
- \`allowTools\` / \`denyTools\` — restrict the role's tools
|
|
124
|
+
|
|
125
|
+
Run \`hara org "<task>"\` to dispatch a task to the owning role, or \`hara org --role <id> "<task>"\`.
|
|
126
|
+
`,
|
|
127
|
+
};
|
|
128
|
+
export function scaffoldRoles(cwd) {
|
|
129
|
+
const dir = rolesDir(cwd);
|
|
130
|
+
mkdirSync(dir, { recursive: true });
|
|
131
|
+
const written = [];
|
|
132
|
+
for (const [name, content] of Object.entries(SCAFFOLD)) {
|
|
133
|
+
const p = join(dir, name);
|
|
134
|
+
if (!existsSync(p)) {
|
|
135
|
+
writeFileSync(p, content, "utf8");
|
|
136
|
+
written.push(name);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return written;
|
|
140
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Deterministic routing by `owns`/`rejects` keywords. null if no clear owner. */
|
|
2
|
+
export function routeByKeywords(task, roles) {
|
|
3
|
+
const t = task.toLowerCase();
|
|
4
|
+
let best = null;
|
|
5
|
+
for (const r of roles) {
|
|
6
|
+
if (r.rejects.some((k) => k && t.includes(k.toLowerCase())))
|
|
7
|
+
continue;
|
|
8
|
+
const score = r.owns.filter((k) => k && t.includes(k.toLowerCase())).length;
|
|
9
|
+
if (score > 0 && (!best || score > best.score))
|
|
10
|
+
best = { role: r, score };
|
|
11
|
+
}
|
|
12
|
+
return best;
|
|
13
|
+
}
|
|
14
|
+
/** Prompt for the LLM dispatcher fallback. */
|
|
15
|
+
export function buildDispatchPrompt(task, roles) {
|
|
16
|
+
const list = roles.map((r) => `- ${r.id}: ${r.description}`).join("\n");
|
|
17
|
+
return `You are the dispatcher in an engineering org. Pick the single best role to own this task.
|
|
18
|
+
|
|
19
|
+
Roles:
|
|
20
|
+
${list}
|
|
21
|
+
|
|
22
|
+
Task: ${task}
|
|
23
|
+
|
|
24
|
+
Reply with ONLY the role id, nothing else.`;
|
|
25
|
+
}
|
|
26
|
+
/** Resolve a role id from free-form dispatcher output. */
|
|
27
|
+
export function parseRoleId(text, roles) {
|
|
28
|
+
const t = text.toLowerCase();
|
|
29
|
+
// prefer an exact whole-token id match, else substring
|
|
30
|
+
for (const r of roles) {
|
|
31
|
+
const re = new RegExp(`\\b${r.id.toLowerCase().replace(/[^a-z0-9]/g, "\\$&")}\\b`);
|
|
32
|
+
if (re.test(t))
|
|
33
|
+
return r;
|
|
34
|
+
}
|
|
35
|
+
for (const r of roles)
|
|
36
|
+
if (t.includes(r.id.toLowerCase()))
|
|
37
|
+
return r;
|
|
38
|
+
return null;
|
|
39
|
+
}
|