@nanhara/hara 0.121.1 → 0.122.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 +120 -0
- package/README.md +57 -10
- package/SECURITY.md +48 -9
- package/dist/agent/loop.js +169 -31
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +24 -6
- package/dist/checkpoints.js +103 -17
- package/dist/cli.js +16 -0
- package/dist/config.js +173 -34
- package/dist/context/agents-md.js +44 -9
- package/dist/context/mentions.js +10 -4
- package/dist/context/subdir-hints.js +40 -7
- package/dist/cron/deliver.js +37 -3
- package/dist/cron/runner.js +372 -37
- package/dist/cron/store.js +11 -3
- package/dist/exec/jobs.js +88 -20
- package/dist/feedback.js +3 -2
- package/dist/fs-read.js +421 -12
- package/dist/fs-walk.js +8 -2
- package/dist/fs-write.js +433 -21
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -20
- package/dist/gateway/feishu.js +157 -58
- package/dist/gateway/flows-pending.js +727 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +81 -18
- package/dist/gateway/mattermost.js +44 -34
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/outbound-files.js +379 -0
- package/dist/gateway/serve.js +712 -169
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +31 -28
- package/dist/gateway/slack.js +28 -21
- package/dist/gateway/telegram.js +33 -21
- package/dist/gateway/tmux-routes.js +11 -3
- package/dist/gateway/wecom.js +38 -31
- package/dist/gateway/weixin.js +147 -59
- package/dist/hooks.js +41 -23
- package/dist/index.js +763 -273
- package/dist/mcp/client.js +164 -12
- package/dist/memory/store.js +68 -22
- package/dist/org/planner.js +36 -10
- package/dist/org/projects.js +347 -0
- package/dist/org/review-chain.js +360 -24
- package/dist/org/roles.js +42 -13
- package/dist/profile/profile.js +152 -27
- package/dist/recall.js +4 -2
- package/dist/runtime.js +37 -0
- package/dist/sandbox.js +142 -33
- package/dist/search/semindex.js +182 -53
- package/dist/search/zvec-store.js +121 -42
- package/dist/security/permissions.js +326 -19
- package/dist/security/private-state.js +299 -0
- package/dist/security/project-trust.js +6 -0
- package/dist/security/secrets.js +84 -9
- package/dist/security/sensitive-files.js +723 -0
- package/dist/security/subprocess-env.js +210 -0
- package/dist/serve/server.js +774 -318
- package/dist/serve/sessions.js +113 -33
- package/dist/session/store.js +298 -47
- package/dist/skills/skills.js +16 -7
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +77 -49
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/cron.js +6 -0
- package/dist/tools/edit.js +22 -9
- package/dist/tools/external_agent.js +110 -16
- package/dist/tools/memory.js +38 -8
- package/dist/tools/patch.js +253 -34
- package/dist/tools/search.js +543 -73
- package/dist/tools/send.js +11 -5
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +168 -54
- package/dist/undo.js +83 -7
- package/package.json +11 -10
- package/runtime-bootstrap.cjs +72 -0
package/dist/mcp/client.js
CHANGED
|
@@ -2,21 +2,158 @@
|
|
|
2
2
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
3
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
4
4
|
import { registerTool } from "../tools/registry.js";
|
|
5
|
+
import { redactToolSubprocessOutput, toolSubprocessEnv } from "../security/subprocess-env.js";
|
|
6
|
+
import { sensitiveStructuredInputReason } from "../security/sensitive-files.js";
|
|
5
7
|
const clients = [];
|
|
8
|
+
const DEFAULT_STARTUP_TIMEOUT_MS = 10_000;
|
|
9
|
+
const MIN_STARTUP_TIMEOUT_MS = 50;
|
|
10
|
+
const MAX_STARTUP_TIMEOUT_MS = 60_000;
|
|
11
|
+
const safeDiagnosticName = (name) => name.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 128) || "server";
|
|
12
|
+
function startupTimeout(value) {
|
|
13
|
+
if (value === undefined || !Number.isFinite(value))
|
|
14
|
+
return DEFAULT_STARTUP_TIMEOUT_MS;
|
|
15
|
+
return Math.max(MIN_STARTUP_TIMEOUT_MS, Math.min(MAX_STARTUP_TIMEOUT_MS, Math.floor(value)));
|
|
16
|
+
}
|
|
17
|
+
async function closeFailedMcp(client, transport) {
|
|
18
|
+
try {
|
|
19
|
+
if (client)
|
|
20
|
+
await client.close();
|
|
21
|
+
else if (transport)
|
|
22
|
+
await transport.close();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Client.close normally owns the transport. If it failed part-way through, make one direct best-effort
|
|
26
|
+
// attempt so an unresponsive startup cannot leave its child process running in the background.
|
|
27
|
+
try {
|
|
28
|
+
await transport?.close();
|
|
29
|
+
}
|
|
30
|
+
catch { /* preserve the original connect/list error */ }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Drain MCP stderr without letting a server leak split credentials, inject terminal control bytes, or grow
|
|
34
|
+
* an unbounded no-newline buffer. Oversized lines are dropped whole so even a partial token is never emitted. */
|
|
35
|
+
function mcpStderrRedactor(name, explicitEnv, log) {
|
|
36
|
+
const lineLimit = 16 * 1024;
|
|
37
|
+
const totalLimit = 64 * 1024;
|
|
38
|
+
const safeName = safeDiagnosticName(name);
|
|
39
|
+
let pending = "";
|
|
40
|
+
let droppingLine = false;
|
|
41
|
+
let emitted = 0;
|
|
42
|
+
let silenced = false;
|
|
43
|
+
const emitOmitted = () => {
|
|
44
|
+
if (silenced)
|
|
45
|
+
return;
|
|
46
|
+
const message = `mcp: ${safeName} stderr: [oversized diagnostic line omitted]`;
|
|
47
|
+
if (emitted + message.length > totalLimit) {
|
|
48
|
+
log(`mcp: ${safeName} stderr: [further diagnostics omitted]`);
|
|
49
|
+
silenced = true;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
emitted += message.length;
|
|
53
|
+
log(message);
|
|
54
|
+
};
|
|
55
|
+
const emit = (raw) => {
|
|
56
|
+
if (silenced)
|
|
57
|
+
return;
|
|
58
|
+
const clean = redactToolSubprocessOutput(raw, process.env, explicitEnv ?? {})
|
|
59
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "")
|
|
60
|
+
.replace(/\r?\n$/, "");
|
|
61
|
+
if (!clean)
|
|
62
|
+
return;
|
|
63
|
+
const message = `mcp: ${safeName} stderr: ${clean}`;
|
|
64
|
+
if (emitted + message.length > totalLimit) {
|
|
65
|
+
log(`mcp: ${safeName} stderr: [further diagnostics omitted]`);
|
|
66
|
+
silenced = true;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
emitted += message.length;
|
|
70
|
+
log(message);
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
push(chunk) {
|
|
74
|
+
if (silenced || !chunk)
|
|
75
|
+
return;
|
|
76
|
+
let start = 0;
|
|
77
|
+
while (start < chunk.length && !silenced) {
|
|
78
|
+
const newline = chunk.indexOf("\n", start);
|
|
79
|
+
const end = newline < 0 ? chunk.length : newline + 1;
|
|
80
|
+
const length = end - start;
|
|
81
|
+
if (droppingLine) {
|
|
82
|
+
if (newline >= 0) {
|
|
83
|
+
droppingLine = false;
|
|
84
|
+
emitOmitted();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else if (pending.length + length > lineLimit) {
|
|
88
|
+
pending = "";
|
|
89
|
+
if (newline >= 0)
|
|
90
|
+
emitOmitted();
|
|
91
|
+
else
|
|
92
|
+
droppingLine = true;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
pending += chunk.slice(start, end);
|
|
96
|
+
if (newline >= 0) {
|
|
97
|
+
emit(pending);
|
|
98
|
+
pending = "";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
start = end;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
flush() {
|
|
105
|
+
if (droppingLine)
|
|
106
|
+
emitOmitted();
|
|
107
|
+
else if (pending)
|
|
108
|
+
emit(pending);
|
|
109
|
+
pending = "";
|
|
110
|
+
droppingLine = false;
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
6
114
|
/** Connect each server, register its tools as `mcp__<server>__<tool>`. Returns #tools registered. */
|
|
7
|
-
export async function connectMcpServers(servers, log) {
|
|
115
|
+
export async function connectMcpServers(servers, log, options = {}) {
|
|
116
|
+
// Starting a configured MCP server already executes arbitrary external code, before any MCP tool call
|
|
117
|
+
// reaches the agent approval loop. Keep that side effect behind its own explicit startup grant even when
|
|
118
|
+
// a future caller forgets to apply the CLI-level policy.
|
|
119
|
+
if (!options.approved && process.env.HARA_ALLOW_TRUSTED_EXTENSIONS !== "1") {
|
|
120
|
+
if (Object.keys(servers).length) {
|
|
121
|
+
log("mcp: skipped — configured servers are trusted extensions outside Hara's file boundary; " +
|
|
122
|
+
"approve them interactively or set HARA_ALLOW_TRUSTED_EXTENSIONS=1 before launch after review");
|
|
123
|
+
}
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
const timeoutMs = startupTimeout(options.timeoutMs);
|
|
127
|
+
const requestOptions = { timeout: timeoutMs, maxTotalTimeout: timeoutMs };
|
|
8
128
|
let count = 0;
|
|
9
129
|
for (const [name, cfg] of Object.entries(servers)) {
|
|
130
|
+
const diagnosticName = safeDiagnosticName(name);
|
|
131
|
+
let flushStderr;
|
|
132
|
+
let transport;
|
|
133
|
+
let client;
|
|
134
|
+
let stage = "connect";
|
|
10
135
|
try {
|
|
11
|
-
|
|
136
|
+
transport = new StdioClientTransport({
|
|
12
137
|
command: cfg.command,
|
|
13
138
|
args: cfg.args ?? [],
|
|
14
|
-
|
|
139
|
+
// The inherited agent environment is scrubbed; server-specific cfg.env is an explicit grant.
|
|
140
|
+
env: toolSubprocessEnv(process.env, cfg.env ?? {}),
|
|
141
|
+
// The SDK default is "inherit", which would let an external server print credentials or terminal
|
|
142
|
+
// control sequences straight to Hara's stderr. Pipe it so every diagnostic crosses our redactor.
|
|
143
|
+
stderr: "pipe",
|
|
15
144
|
});
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
145
|
+
const stderrRedactor = mcpStderrRedactor(name, cfg.env, log);
|
|
146
|
+
flushStderr = () => stderrRedactor.flush();
|
|
147
|
+
// `stderr` is available before connect() when piping is requested, so early startup errors are neither
|
|
148
|
+
// lost nor inherited. Line buffering prevents a credential split across chunks from evading redaction.
|
|
149
|
+
transport.stderr?.on("data", (chunk) => stderrRedactor.push(Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk)));
|
|
150
|
+
transport.stderr?.on("end", flushStderr);
|
|
151
|
+
transport.stderr?.on("close", flushStderr);
|
|
152
|
+
client = new Client({ name: "hara", version: "0.4.0" }, { capabilities: {} });
|
|
153
|
+
await client.connect(transport, requestOptions);
|
|
154
|
+
stage = "list tools";
|
|
155
|
+
const activeClient = client;
|
|
156
|
+
const { tools } = await activeClient.listTools(undefined, requestOptions);
|
|
20
157
|
for (const t of tools) {
|
|
21
158
|
const schema = t.inputSchema ?? { type: "object", properties: {} };
|
|
22
159
|
registerTool({
|
|
@@ -24,19 +161,34 @@ export async function connectMcpServers(servers, log) {
|
|
|
24
161
|
description: t.description ?? `${name}/${t.name}`,
|
|
25
162
|
input_schema: schema,
|
|
26
163
|
kind: "exec",
|
|
27
|
-
|
|
28
|
-
|
|
164
|
+
trustBoundary: "external",
|
|
165
|
+
async run(input, ctx) {
|
|
166
|
+
if (!ctx.ask && process.env.HARA_ALLOW_TRUSTED_EXTENSIONS !== "1") {
|
|
167
|
+
return "Blocked: MCP is a trusted extension outside Hara's file boundary and is disabled in non-interactive runs. Set HARA_ALLOW_TRUSTED_EXTENSIONS=1 before launch only after reviewing the server.";
|
|
168
|
+
}
|
|
169
|
+
const protectedReason = sensitiveStructuredInputReason(input, ctx.cwd);
|
|
170
|
+
if (protectedReason)
|
|
171
|
+
return `Blocked: MCP input names protected ${protectedReason}.`;
|
|
172
|
+
const res = await activeClient.callTool({ name: t.name, arguments: input ?? {} });
|
|
29
173
|
const blocks = Array.isArray(res?.content) ? res.content : [];
|
|
30
174
|
const text = blocks.map((b) => (b?.type === "text" ? b.text : JSON.stringify(b))).join("\n");
|
|
31
|
-
return text || "(no output)";
|
|
175
|
+
return redactToolSubprocessOutput(text || "(no output)", process.env, cfg.env ?? {});
|
|
32
176
|
},
|
|
33
177
|
});
|
|
34
178
|
count++;
|
|
35
179
|
}
|
|
36
|
-
|
|
180
|
+
clients.push(activeClient);
|
|
181
|
+
log(`mcp: ${diagnosticName} → ${tools.length} tool(s)`);
|
|
37
182
|
}
|
|
38
183
|
catch (e) {
|
|
39
|
-
|
|
184
|
+
// Do this before returning/logging: a timeout is not merely a failed request; the external process
|
|
185
|
+
// must be torn down immediately and must not enter the global successful-client pool.
|
|
186
|
+
await closeFailedMcp(client, transport);
|
|
187
|
+
flushStderr?.();
|
|
188
|
+
const error = redactToolSubprocessOutput(String(e?.message ?? e), process.env, cfg.env ?? {})
|
|
189
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ")
|
|
190
|
+
.trim();
|
|
191
|
+
log(`mcp: ${diagnosticName} failed during ${stage} (${error})`);
|
|
40
192
|
}
|
|
41
193
|
}
|
|
42
194
|
return count;
|
package/dist/memory/store.js
CHANGED
|
@@ -2,14 +2,17 @@
|
|
|
2
2
|
// File-backed Markdown (git-versionable, human-readable), two scopes: global ~/.hara/memory and
|
|
3
3
|
// project <root>/.hara/memory. Lexical search reuses recall.ts; no embeddings (local-first).
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import { join
|
|
6
|
-
import {
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
7
7
|
import { findProjectRoot } from "../context/agents-md.js";
|
|
8
|
+
import { readModelContextFileSync, readVerifiedRegularFileSnapshot } from "../fs-read.js";
|
|
9
|
+
import { atomicWriteText, bindAtomicWritePath } from "../fs-write.js";
|
|
8
10
|
// Per-source budgets for the frozen-snapshot digest (chars). Each source gets its own cap so a large
|
|
9
11
|
// project MEMORY can't crowd out the (smaller but high-value) USER prefs — and each is cut at a line
|
|
10
12
|
// boundary, never mid-entry. Anything beyond these is still reachable via memory_search. (hermes-style
|
|
11
13
|
// per-file budgets; both PAI and hermes confirm lexical injection + capped snapshot beats a vector store.)
|
|
12
14
|
const SOURCE_CAP = { memory: 2000, user: 1200, log: 0 };
|
|
15
|
+
const MAX_MEMORY_SOURCE_BYTES = 256 * 1024;
|
|
13
16
|
/** Truncate at a line boundary at/under `cap` (never mid-entry), with a pointer to search for the rest. */
|
|
14
17
|
function capAtLine(text, cap) {
|
|
15
18
|
if (text.length <= cap)
|
|
@@ -40,26 +43,62 @@ function targetFile(scope, target, cwd) {
|
|
|
40
43
|
return join(dir, "log", `${today()}.md`);
|
|
41
44
|
return join(dir, "MEMORY.md");
|
|
42
45
|
}
|
|
43
|
-
|
|
46
|
+
async function inspectMemoryWrite(path, action) {
|
|
47
|
+
const boundary = bindAtomicWritePath(path, action);
|
|
48
|
+
try {
|
|
49
|
+
return {
|
|
50
|
+
boundary,
|
|
51
|
+
snapshot: await readVerifiedRegularFileSnapshot(boundary.target, MAX_MEMORY_SOURCE_BYTES, action),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error?.code === "ENOENT")
|
|
56
|
+
return { boundary, snapshot: null };
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function commitMemoryText(path, text, action) {
|
|
61
|
+
const { boundary, snapshot } = await inspectMemoryWrite(path, action);
|
|
62
|
+
await atomicWriteText(boundary.target, text, {
|
|
63
|
+
expected: snapshot?.text ?? null,
|
|
64
|
+
expectedIdentity: snapshot ?? undefined,
|
|
65
|
+
boundary,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
export async function appendMemory(scope, target, content, cwd) {
|
|
44
69
|
const f = targetFile(scope, target, cwd);
|
|
45
|
-
|
|
46
|
-
|
|
70
|
+
const { boundary, snapshot } = await inspectMemoryWrite(f, "append memory");
|
|
71
|
+
const text = (snapshot ? `${snapshot.text}\n` : "") + content.trim() + "\n";
|
|
72
|
+
await atomicWriteText(boundary.target, text, {
|
|
73
|
+
expected: snapshot?.text ?? null,
|
|
74
|
+
expectedIdentity: snapshot ?? undefined,
|
|
75
|
+
boundary,
|
|
76
|
+
});
|
|
47
77
|
return f;
|
|
48
78
|
}
|
|
49
|
-
export function replaceMemory(scope, target, content, cwd) {
|
|
79
|
+
export async function replaceMemory(scope, target, content, cwd) {
|
|
50
80
|
const f = targetFile(scope, target, cwd);
|
|
51
|
-
|
|
52
|
-
writeFileSync(f, content.trim() + "\n", "utf8");
|
|
81
|
+
await commitMemoryText(f, content.trim() + "\n", "replace memory");
|
|
53
82
|
return f;
|
|
54
83
|
}
|
|
55
|
-
export function forgetMemory(scope, target, match, cwd) {
|
|
84
|
+
export async function forgetMemory(scope, target, match, cwd) {
|
|
56
85
|
const f = targetFile(scope, target, cwd);
|
|
57
|
-
if (!
|
|
86
|
+
if (!match)
|
|
87
|
+
return 0;
|
|
88
|
+
const { boundary, snapshot } = await inspectMemoryWrite(f, "forget memory");
|
|
89
|
+
if (!snapshot)
|
|
58
90
|
return 0;
|
|
59
|
-
const lines =
|
|
91
|
+
const lines = snapshot.text.split("\n");
|
|
60
92
|
const kept = lines.filter((l) => !l.includes(match));
|
|
61
|
-
|
|
62
|
-
|
|
93
|
+
const removed = lines.length - kept.length;
|
|
94
|
+
if (!removed)
|
|
95
|
+
return 0;
|
|
96
|
+
await atomicWriteText(boundary.target, kept.join("\n"), {
|
|
97
|
+
expected: snapshot.text,
|
|
98
|
+
expectedIdentity: snapshot,
|
|
99
|
+
boundary,
|
|
100
|
+
});
|
|
101
|
+
return removed;
|
|
63
102
|
}
|
|
64
103
|
/** MEMORY + USER digest (project + global) for frozen-snapshot injection at session start. Each source is
|
|
65
104
|
* capped independently (SOURCE_CAP) at a line boundary, so every source is represented (project memory
|
|
@@ -76,7 +115,7 @@ export function memoryDigest(cwd) {
|
|
|
76
115
|
if (!existsSync(f))
|
|
77
116
|
continue;
|
|
78
117
|
try {
|
|
79
|
-
const t =
|
|
118
|
+
const t = readModelContextFileSync(f, MAX_MEMORY_SOURCE_BYTES).trim();
|
|
80
119
|
if (t)
|
|
81
120
|
parts.push(`## ${label}\n${capAtLine(t, SOURCE_CAP[target])}`);
|
|
82
121
|
}
|
|
@@ -101,7 +140,7 @@ export function readRecentLogs(scope, cwd, days) {
|
|
|
101
140
|
if (m && new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime() < cutoff)
|
|
102
141
|
continue;
|
|
103
142
|
try {
|
|
104
|
-
const t =
|
|
143
|
+
const t = readModelContextFileSync(join(dir, f), MAX_MEMORY_SOURCE_BYTES).trim();
|
|
105
144
|
if (t)
|
|
106
145
|
out.push(`### ${f}\n${t}`);
|
|
107
146
|
}
|
|
@@ -111,20 +150,27 @@ export function readRecentLogs(scope, cwd, days) {
|
|
|
111
150
|
}
|
|
112
151
|
return out.join("\n\n");
|
|
113
152
|
}
|
|
114
|
-
/**
|
|
115
|
-
export function scaffoldMemory(cwd) {
|
|
153
|
+
/** Seed the memory files (their parents are created by the atomic writer; log is created on first append). */
|
|
154
|
+
export async function scaffoldMemory(cwd) {
|
|
116
155
|
const written = [];
|
|
117
156
|
for (const scope of ["global", "project"]) {
|
|
118
|
-
mkdirSync(join(memoryDir(scope, cwd), "log"), { recursive: true });
|
|
119
157
|
const mem = join(memoryDir(scope, cwd), "MEMORY.md");
|
|
120
|
-
|
|
121
|
-
|
|
158
|
+
const { boundary, snapshot } = await inspectMemoryWrite(mem, "scaffold memory");
|
|
159
|
+
if (!snapshot) {
|
|
160
|
+
await atomicWriteText(boundary.target, `# hara ${scope} memory\n\nDurable facts & decisions hara records and recalls across sessions. Git-versionable — edit freely.\n`, {
|
|
161
|
+
expected: null,
|
|
162
|
+
boundary,
|
|
163
|
+
});
|
|
122
164
|
written.push(mem);
|
|
123
165
|
}
|
|
124
166
|
}
|
|
125
167
|
const user = join(memoryDir("global", cwd), "USER.md");
|
|
126
|
-
|
|
127
|
-
|
|
168
|
+
const { boundary, snapshot } = await inspectMemoryWrite(user, "scaffold memory");
|
|
169
|
+
if (!snapshot) {
|
|
170
|
+
await atomicWriteText(boundary.target, "# User preferences\n\nHow you like hara to work — voice, conventions, do/don't.\n", {
|
|
171
|
+
expected: null,
|
|
172
|
+
boundary,
|
|
173
|
+
});
|
|
128
174
|
written.push(user);
|
|
129
175
|
}
|
|
130
176
|
return written;
|
package/dist/org/planner.js
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
// FRAME the task → ATOMIZE into smallest verifiable steps → SEQUENCE as a DAG →
|
|
3
3
|
// execute each atom (optionally routed to a role) → VERIFY gate. State is the SSOT
|
|
4
4
|
// at .hara/org/plan.json. This is hara's differentiator: not one agent, an org that plans.
|
|
5
|
-
import {
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { runShell } from "../sandbox.js";
|
|
8
|
+
import { readModelContextFileSync, readVerifiedRegularFileSnapshot } from "../fs-read.js";
|
|
9
|
+
import { atomicWriteText, bindAtomicWritePath } from "../fs-write.js";
|
|
8
10
|
const PLAN_SYSTEM = `You are hara's planner. Decompose a coding task using this method:
|
|
9
11
|
1) FRAME the goal in one sentence.
|
|
10
12
|
2) ATOMIZE into the smallest independently-verifiable steps.
|
|
@@ -151,22 +153,46 @@ export async function runCheck(cmd, cwd, sandbox) {
|
|
|
151
153
|
return { ok: false, reason: (out.split("\n").pop() || `exit ${e?.code ?? "?"}`).slice(0, 200) };
|
|
152
154
|
}
|
|
153
155
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
mkdirSync(d, { recursive: true });
|
|
157
|
-
return d;
|
|
158
|
-
}
|
|
159
|
-
const planFile = (cwd) => join(planDir(cwd), "plan.json");
|
|
156
|
+
const planFile = (cwd) => join(cwd, ".hara", "org", "plan.json");
|
|
157
|
+
const planWriteTails = new Map();
|
|
160
158
|
/** SSOT: persist plan state so it's inspectable / resumable. */
|
|
161
|
-
export function savePlan(cwd, plan) {
|
|
162
|
-
|
|
159
|
+
export async function savePlan(cwd, plan) {
|
|
160
|
+
const p = planFile(cwd);
|
|
161
|
+
const previous = planWriteTails.get(p) ?? Promise.resolve();
|
|
162
|
+
const operation = previous.catch(() => { }).then(async () => {
|
|
163
|
+
// Serialize the live shared plan inside the per-path queue so parallel atoms cannot overwrite a newer
|
|
164
|
+
// sibling state with an older snapshot. The atomic writer binds parent identity and rejects links.
|
|
165
|
+
const text = JSON.stringify(plan, null, 2);
|
|
166
|
+
const boundary = bindAtomicWritePath(p, "save plan");
|
|
167
|
+
let snapshot = null;
|
|
168
|
+
try {
|
|
169
|
+
snapshot = await readVerifiedRegularFileSnapshot(boundary.target, undefined, "save plan");
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (error?.code !== "ENOENT")
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
await atomicWriteText(boundary.target, text, {
|
|
176
|
+
expected: snapshot?.text ?? null,
|
|
177
|
+
expectedIdentity: snapshot ?? undefined,
|
|
178
|
+
boundary,
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
planWriteTails.set(p, operation);
|
|
182
|
+
try {
|
|
183
|
+
await operation;
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
if (planWriteTails.get(p) === operation)
|
|
187
|
+
planWriteTails.delete(p);
|
|
188
|
+
}
|
|
163
189
|
}
|
|
164
190
|
export function loadPlan(cwd) {
|
|
165
191
|
const p = planFile(cwd);
|
|
166
192
|
if (!existsSync(p))
|
|
167
193
|
return null;
|
|
168
194
|
try {
|
|
169
|
-
return JSON.parse(
|
|
195
|
+
return JSON.parse(readModelContextFileSync(p, 64 * 1024 * 1024));
|
|
170
196
|
}
|
|
171
197
|
catch {
|
|
172
198
|
return null;
|