@nanhara/hara 0.122.0 → 0.122.2
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 +62 -0
- package/README.md +24 -11
- package/SECURITY.md +48 -9
- package/dist/agent/loop.js +33 -10
- package/dist/agent/touched.js +4 -0
- package/dist/checkpoints.js +103 -17
- package/dist/cli.js +16 -0
- package/dist/config.js +141 -12
- 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/runner.js +372 -37
- package/dist/cron/store.js +11 -3
- package/dist/exec/jobs.js +88 -20
- package/dist/fs-read.js +318 -3
- package/dist/fs-walk.js +8 -2
- package/dist/fs-write.js +197 -11
- package/dist/gateway/discord.js +2 -4
- package/dist/gateway/feishu.js +4 -6
- package/dist/gateway/flows-pending.js +38 -31
- package/dist/gateway/matrix.js +3 -5
- package/dist/gateway/mattermost.js +2 -4
- package/dist/gateway/outbound-files.js +379 -0
- package/dist/gateway/serve.js +121 -73
- package/dist/gateway/signal.js +4 -6
- package/dist/gateway/slack.js +4 -6
- package/dist/gateway/telegram.js +3 -5
- package/dist/gateway/tmux-routes.js +11 -3
- package/dist/gateway/wecom.js +7 -8
- package/dist/gateway/weixin.js +22 -12
- package/dist/hooks.js +12 -6
- package/dist/index.js +142 -61
- package/dist/mcp/client.js +164 -12
- package/dist/memory/store.js +68 -22
- package/dist/org/planner.js +36 -10
- package/dist/org/review-chain.js +360 -24
- package/dist/org/roles.js +4 -2
- 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/sensitive-files.js +723 -0
- package/dist/security/subprocess-env.js +210 -0
- package/dist/serve/server.js +2 -1
- package/dist/skills/skills.js +16 -7
- package/dist/tools/builtin.js +53 -27
- package/dist/tools/cron.js +6 -0
- package/dist/tools/edit.js +15 -5
- package/dist/tools/external_agent.js +110 -16
- package/dist/tools/memory.js +38 -8
- package/dist/tools/patch.js +37 -17
- package/dist/tools/search.js +100 -40
- package/dist/tools/send.js +11 -5
- package/package.json +11 -10
- package/runtime-bootstrap.cjs +72 -0
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;
|
package/dist/org/review-chain.js
CHANGED
|
@@ -4,6 +4,191 @@
|
|
|
4
4
|
// (it needs runAgent + providers); these are the pure, testable pieces: verdict parsing, change capture,
|
|
5
5
|
// and the prompts. Used by `hara org --review`.
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
|
|
9
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
10
|
+
import { verifyOpenedRegularFileSync } from "../fs-read.js";
|
|
11
|
+
import { sensitiveFileReason } from "../security/sensitive-files.js";
|
|
12
|
+
import { redactToolSubprocessOutput, toolSubprocessEnv } from "../security/subprocess-env.js";
|
|
13
|
+
const MAX_CHANGED_PATHS = 4096;
|
|
14
|
+
const GIT_CAPTURE_TIMEOUT_MS = 10_000;
|
|
15
|
+
const MAX_STAGED_VERIFY_BYTES = 64 * 1024 * 1024;
|
|
16
|
+
const READ_CHUNK_BYTES = 64 * 1024;
|
|
17
|
+
function gitBytes(cwd, args) {
|
|
18
|
+
return execFileSync("git", args, {
|
|
19
|
+
cwd,
|
|
20
|
+
env: toolSubprocessEnv(),
|
|
21
|
+
encoding: "buffer",
|
|
22
|
+
maxBuffer: 50_000_000,
|
|
23
|
+
timeout: GIT_CAPTURE_TIMEOUT_MS,
|
|
24
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function nulPaths(bytes) {
|
|
28
|
+
const text = bytes.toString("utf8");
|
|
29
|
+
// A replacement character means a POSIX byte filename could not be represented safely as a JS path.
|
|
30
|
+
// Fail closed instead of accidentally diffing a path other than the one Git named.
|
|
31
|
+
if (text.includes("\uFFFD"))
|
|
32
|
+
throw new Error("git returned a filename that is not valid UTF-8");
|
|
33
|
+
return text.split("\0").filter(Boolean);
|
|
34
|
+
}
|
|
35
|
+
function pathInside(cwd, path) {
|
|
36
|
+
const rel = relative(resolve(cwd), resolve(cwd, path));
|
|
37
|
+
return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
38
|
+
}
|
|
39
|
+
function safePathLabel(path) {
|
|
40
|
+
return redactToolSubprocessOutput(path);
|
|
41
|
+
}
|
|
42
|
+
function safePathLabels(paths) {
|
|
43
|
+
return [...new Set(paths.map(safePathLabel))];
|
|
44
|
+
}
|
|
45
|
+
function classifyPaths(cwd, paths) {
|
|
46
|
+
const classified = { protected: [], missing: [] };
|
|
47
|
+
for (const path of paths) {
|
|
48
|
+
if (!pathInside(cwd, path)) {
|
|
49
|
+
classified.protected.push(path);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const absolute = join(cwd, path);
|
|
53
|
+
if (sensitiveFileReason(absolute) !== null) {
|
|
54
|
+
classified.protected.push(path);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const info = lstatSync(absolute);
|
|
59
|
+
// A tracked directory is a submodule gitlink (Git emits only its object id). Every other non-regular
|
|
60
|
+
// inode is excluded so FIFOs/devices/symlinks and hard-link aliases cannot enter model context.
|
|
61
|
+
if (!info.isDirectory() && (!info.isFile() || info.nlink > 1))
|
|
62
|
+
classified.protected.push(path);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error?.code === "ENOENT")
|
|
66
|
+
classified.missing.push(path);
|
|
67
|
+
else
|
|
68
|
+
classified.protected.push(path);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return classified;
|
|
72
|
+
}
|
|
73
|
+
function diffArgs(options, format) {
|
|
74
|
+
const args = ["diff", "--no-ext-diff", "--no-textconv", "--no-renames"];
|
|
75
|
+
if (format === "name-status")
|
|
76
|
+
args.push("--name-status", "-z");
|
|
77
|
+
if (options.staged)
|
|
78
|
+
args.push("--staged");
|
|
79
|
+
else if (options.base) {
|
|
80
|
+
if (options.base.startsWith("-") || options.base.includes("\0"))
|
|
81
|
+
throw new Error("invalid base ref");
|
|
82
|
+
args.push(options.base);
|
|
83
|
+
}
|
|
84
|
+
else
|
|
85
|
+
args.push("HEAD");
|
|
86
|
+
return args;
|
|
87
|
+
}
|
|
88
|
+
function changedPathStatuses(cwd, options) {
|
|
89
|
+
const fields = nulPaths(gitBytes(cwd, [...diffArgs(options, "name-status"), "--"]));
|
|
90
|
+
const statuses = new Map();
|
|
91
|
+
for (let i = 0; i < fields.length;) {
|
|
92
|
+
const rawStatus = fields[i++];
|
|
93
|
+
if (!/^[ACDMRTUXB][0-9]*$/u.test(rawStatus))
|
|
94
|
+
throw new Error("git returned an invalid name-status record");
|
|
95
|
+
const status = rawStatus[0];
|
|
96
|
+
// --no-renames keeps C/R out of ordinary output, but consume both paths defensively if a Git variant
|
|
97
|
+
// still reports one so a source name can never be mistaken for a status token.
|
|
98
|
+
if (status === "C" || status === "R") {
|
|
99
|
+
const source = fields[i++];
|
|
100
|
+
const destination = fields[i++];
|
|
101
|
+
if (source === undefined || destination === undefined)
|
|
102
|
+
throw new Error("git returned a truncated name-status record");
|
|
103
|
+
statuses.set(destination, status);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const path = fields[i++];
|
|
107
|
+
if (path === undefined)
|
|
108
|
+
throw new Error("git returned a truncated name-status record");
|
|
109
|
+
statuses.set(path, status);
|
|
110
|
+
}
|
|
111
|
+
return statuses;
|
|
112
|
+
}
|
|
113
|
+
function stagedIndexEntry(cwd, path) {
|
|
114
|
+
const records = nulPaths(gitBytes(cwd, ["ls-files", "--stage", "-z", "--", path]));
|
|
115
|
+
if (records.length !== 1)
|
|
116
|
+
return null;
|
|
117
|
+
const record = records[0];
|
|
118
|
+
const tab = record.indexOf("\t");
|
|
119
|
+
if (tab < 0 || record.slice(tab + 1) !== path)
|
|
120
|
+
return null;
|
|
121
|
+
const fields = record.slice(0, tab).split(" ");
|
|
122
|
+
if (fields.length !== 3 || fields[2] !== "0" || !/^[0-9a-f]+$/u.test(fields[1]))
|
|
123
|
+
return null;
|
|
124
|
+
return { mode: fields[0], oid: fields[1] };
|
|
125
|
+
}
|
|
126
|
+
function verifiedWorktreeBlobOid(cwd, path, algorithm, maxBytes) {
|
|
127
|
+
const absolute = join(cwd, path);
|
|
128
|
+
const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
|
|
129
|
+
const fd = openSync(absolute, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
|
|
130
|
+
try {
|
|
131
|
+
const before = fstatSync(fd);
|
|
132
|
+
verifyOpenedRegularFileSync(absolute, before, {
|
|
133
|
+
action: "verify staged commit content",
|
|
134
|
+
rejectHardLinks: true,
|
|
135
|
+
protectSensitive: true,
|
|
136
|
+
});
|
|
137
|
+
if (before.size > maxBytes)
|
|
138
|
+
throw new Error("staged file exceeds verification limit");
|
|
139
|
+
const chunks = [];
|
|
140
|
+
let total = 0;
|
|
141
|
+
while (total <= maxBytes) {
|
|
142
|
+
const want = Math.min(READ_CHUNK_BYTES, maxBytes + 1 - total);
|
|
143
|
+
const buffer = Buffer.allocUnsafe(want);
|
|
144
|
+
const bytesRead = readSync(fd, buffer, 0, want, total);
|
|
145
|
+
if (!bytesRead)
|
|
146
|
+
break;
|
|
147
|
+
chunks.push(buffer.subarray(0, bytesRead));
|
|
148
|
+
total += bytesRead;
|
|
149
|
+
}
|
|
150
|
+
if (total > maxBytes)
|
|
151
|
+
throw new Error("staged file exceeds verification limit");
|
|
152
|
+
const after = fstatSync(fd);
|
|
153
|
+
verifyOpenedRegularFileSync(absolute, after, {
|
|
154
|
+
action: "verify staged commit content",
|
|
155
|
+
rejectHardLinks: true,
|
|
156
|
+
protectSensitive: true,
|
|
157
|
+
});
|
|
158
|
+
if (before.dev !== after.dev
|
|
159
|
+
|| before.ino !== after.ino
|
|
160
|
+
|| before.size !== after.size
|
|
161
|
+
|| before.mtimeMs !== after.mtimeMs
|
|
162
|
+
|| before.ctimeMs !== after.ctimeMs
|
|
163
|
+
|| total !== before.size)
|
|
164
|
+
throw new Error("staged worktree file changed during verification");
|
|
165
|
+
const hash = createHash(algorithm);
|
|
166
|
+
hash.update(`blob ${total}\0`);
|
|
167
|
+
for (const chunk of chunks)
|
|
168
|
+
hash.update(chunk);
|
|
169
|
+
return hash.digest("hex");
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
closeSync(fd);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function stagedBlobMatchesWorktree(cwd, path, maxBytes) {
|
|
176
|
+
try {
|
|
177
|
+
const entry = stagedIndexEntry(cwd, path);
|
|
178
|
+
if (!entry || (entry.mode !== "100644" && entry.mode !== "100755"))
|
|
179
|
+
return false;
|
|
180
|
+
const algorithm = entry.oid.length === 40 ? "sha1" : entry.oid.length === 64 ? "sha256" : null;
|
|
181
|
+
if (!algorithm)
|
|
182
|
+
return false;
|
|
183
|
+
return verifiedWorktreeBlobOid(cwd, path, algorithm, maxBytes) === entry.oid;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function untrackedPaths(cwd) {
|
|
190
|
+
return nulPaths(gitBytes(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--"]));
|
|
191
|
+
}
|
|
7
192
|
/** Fallback reviewer persona when the project has no `reviewer` role. Read-only by intent. */
|
|
8
193
|
export const REVIEWER_SYSTEM = `You are a senior code reviewer reviewing changes made to accomplish a task.
|
|
9
194
|
Inspect them for: correctness and bugs, security, missing edge cases, and whether they actually accomplish
|
|
@@ -37,33 +222,142 @@ export function parseVerdict(text) {
|
|
|
37
222
|
const approved = !CHANGES_RE.test(after) && APPROVE_RE.test(after); // changes-signal vetoes; ambiguous = not approved
|
|
38
223
|
return { approved, issues: text.slice(0, idx).trim() };
|
|
39
224
|
}
|
|
40
|
-
/** Capture
|
|
41
|
-
*
|
|
42
|
-
export function captureChanges(cwd, cap = 100_000) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
225
|
+
/** Capture model-safe change metadata. Git patches are deliberately excluded because their old side is
|
|
226
|
+
* historical content with no trustworthy current filesystem identity. Reviewers can read current files. */
|
|
227
|
+
export function captureChanges(cwd, cap = 100_000, options = {}) {
|
|
228
|
+
try {
|
|
229
|
+
const statuses = changedPathStatuses(cwd, options);
|
|
230
|
+
const paths = [...statuses.keys()];
|
|
231
|
+
const deleted = new Set(paths.filter((path) => statuses.get(path) === "D"));
|
|
232
|
+
const includeUntracked = options.includeUntracked ?? (!options.staged && !options.base);
|
|
233
|
+
const untracked = includeUntracked ? untrackedPaths(cwd) : [];
|
|
234
|
+
if (paths.length + untracked.length > MAX_CHANGED_PATHS) {
|
|
235
|
+
return { diff: "", newFiles: [], skippedFiles: [], omittedDeletions: [], error: `too many changed paths (>${MAX_CHANGED_PATHS})` };
|
|
46
236
|
}
|
|
47
|
-
|
|
48
|
-
|
|
237
|
+
const tracked = classifyPaths(cwd, paths);
|
|
238
|
+
const untrackedClass = classifyPaths(cwd, untracked);
|
|
239
|
+
const skippedRaw = new Set([...tracked.protected, ...untrackedClass.protected]);
|
|
240
|
+
// A token-shaped untracked pathname is itself unsafe model input. Once staged, its diff header is
|
|
241
|
+
// redacted like every other subprocess output; before then, omit it rather than advertise a false path.
|
|
242
|
+
for (const path of untracked)
|
|
243
|
+
if (safePathLabel(path) !== path)
|
|
244
|
+
skippedRaw.add(path);
|
|
245
|
+
// Missing is not synonymous with deleted: a staged AD entry has an added blob in the index while the
|
|
246
|
+
// worktree path is absent. Only Git's exact D status is an omitted deletion; every other missing tracked
|
|
247
|
+
// entry is unverifiable and remains protected.
|
|
248
|
+
for (const path of tracked.missing)
|
|
249
|
+
if (!deleted.has(path))
|
|
250
|
+
skippedRaw.add(path);
|
|
251
|
+
const omittedRaw = paths.filter((path) => !skippedRaw.has(path) && deleted.has(path));
|
|
252
|
+
const safePaths = paths.filter((path) => !skippedRaw.has(path) && !deleted.has(path));
|
|
253
|
+
const missingUntracked = new Set(untrackedClass.missing);
|
|
254
|
+
const newFiles = untracked
|
|
255
|
+
.filter((path) => !skippedRaw.has(path) && !missingUntracked.has(path))
|
|
256
|
+
.slice(0, 50);
|
|
257
|
+
// Never provide a Git patch to a model: even an ordinary modification's old side can be a historical
|
|
258
|
+
// hard-link alias of a credential, and a staged index blob can diverge from today's verified worktree.
|
|
259
|
+
// Status + redacted path metadata is sufficient for reviewers to inspect the current file via read_file.
|
|
260
|
+
let diff = safePaths.map((path) => `${statuses.get(path)}\t${safePathLabel(path)}`).join("\n");
|
|
261
|
+
if (diff.length > cap)
|
|
262
|
+
diff = diff.slice(0, cap) + "\n…[diff truncated]";
|
|
263
|
+
return {
|
|
264
|
+
diff,
|
|
265
|
+
newFiles,
|
|
266
|
+
skippedFiles: safePathLabels([...skippedRaw]),
|
|
267
|
+
omittedDeletions: safePathLabels(omittedRaw),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
return {
|
|
272
|
+
diff: "",
|
|
273
|
+
newFiles: [],
|
|
274
|
+
skippedFiles: [],
|
|
275
|
+
omittedDeletions: [],
|
|
276
|
+
error: redactToolSubprocessOutput(error instanceof Error ? error.message : String(error)),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/** Protected files currently staged for commit. Commit-message generation and commit both fail closed. */
|
|
281
|
+
export function protectedStagedPaths(cwd) {
|
|
282
|
+
try {
|
|
283
|
+
const statuses = changedPathStatuses(cwd, { staged: true });
|
|
284
|
+
if (statuses.size > MAX_CHANGED_PATHS)
|
|
285
|
+
return ["(too many staged paths to verify)"];
|
|
286
|
+
const classified = classifyPaths(cwd, [...statuses.keys()]);
|
|
287
|
+
const unverifiable = new Set(classified.missing.filter((path) => statuses.get(path) !== "D"));
|
|
288
|
+
const protectedSet = new Set(classified.protected);
|
|
289
|
+
let remainingBytes = MAX_STAGED_VERIFY_BYTES;
|
|
290
|
+
for (const [path, status] of statuses) {
|
|
291
|
+
if (status === "D" || protectedSet.has(path) || unverifiable.has(path))
|
|
292
|
+
continue;
|
|
293
|
+
let size = remainingBytes + 1;
|
|
294
|
+
try {
|
|
295
|
+
size = lstatSync(join(cwd, path)).size;
|
|
296
|
+
}
|
|
297
|
+
catch { /* verification below fails closed */ }
|
|
298
|
+
if (!["A", "M", "T"].includes(status)
|
|
299
|
+
|| size > remainingBytes
|
|
300
|
+
|| !stagedBlobMatchesWorktree(cwd, path, remainingBytes)) {
|
|
301
|
+
unverifiable.add(path);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
remainingBytes -= size;
|
|
305
|
+
}
|
|
49
306
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
307
|
+
return safePathLabels([...classified.protected, ...unverifiable]);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// An unreadable/ambiguous Git index cannot be declared safe to send to a model or commit automatically.
|
|
311
|
+
return ["(unable to verify staged paths)"];
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/** Protected tracked/untracked changes that `git add -A` would stage. */
|
|
315
|
+
export function protectedWorkingTreePaths(cwd) {
|
|
316
|
+
try {
|
|
317
|
+
// Compare HEAD directly with the final worktree. Combining cached + unstaged lists misclassifies an AD
|
|
318
|
+
// entry that `git add -A` will collapse to no net change.
|
|
319
|
+
const statuses = changedPathStatuses(cwd, {});
|
|
320
|
+
const tracked = [...statuses.keys()];
|
|
321
|
+
const untracked = untrackedPaths(cwd);
|
|
322
|
+
if (tracked.length + untracked.length > MAX_CHANGED_PATHS)
|
|
323
|
+
return ["(too many changed paths to verify)"];
|
|
324
|
+
const trackedClass = classifyPaths(cwd, tracked);
|
|
325
|
+
const untrackedClass = classifyPaths(cwd, untracked);
|
|
326
|
+
const unverifiable = trackedClass.missing.filter((path) => statuses.get(path) !== "D");
|
|
327
|
+
return safePathLabels([...trackedClass.protected, ...unverifiable, ...untrackedClass.protected]);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return ["(unable to verify working-tree paths)"];
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/** Protected tracked changes that `git add -u` would stage (untracked files are intentionally excluded). */
|
|
334
|
+
export function protectedTrackedWorkingTreePaths(cwd) {
|
|
335
|
+
try {
|
|
336
|
+
const statuses = changedPathStatuses(cwd, {});
|
|
337
|
+
const tracked = [...statuses.keys()];
|
|
338
|
+
if (tracked.length > MAX_CHANGED_PATHS)
|
|
339
|
+
return ["(too many changed paths to verify)"];
|
|
340
|
+
const classified = classifyPaths(cwd, tracked);
|
|
341
|
+
const unverifiable = classified.missing.filter((path) => statuses.get(path) !== "D");
|
|
342
|
+
return safePathLabels([...classified.protected, ...unverifiable]);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return ["(unable to verify tracked working-tree paths)"];
|
|
346
|
+
}
|
|
60
347
|
}
|
|
61
348
|
/** True only if the working tree is fully clean — no uncommitted changes. The `--commit` capstone uses
|
|
62
349
|
* this as a guard: `git add -A` + commit is only safe to run when the tree was clean before the org ran,
|
|
63
350
|
* so it captures THIS run's work and never sweeps up pre-existing WIP. False for a non-git dir. */
|
|
64
351
|
export function isTreeClean(cwd) {
|
|
65
352
|
try {
|
|
66
|
-
return execFileSync("git", ["status", "--porcelain"], {
|
|
353
|
+
return execFileSync("git", ["status", "--porcelain"], {
|
|
354
|
+
cwd,
|
|
355
|
+
env: toolSubprocessEnv(),
|
|
356
|
+
encoding: "utf8",
|
|
357
|
+
maxBuffer: 50_000_000,
|
|
358
|
+
timeout: GIT_CAPTURE_TIMEOUT_MS,
|
|
359
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
360
|
+
}).trim() === "";
|
|
67
361
|
}
|
|
68
362
|
catch {
|
|
69
363
|
return false; // not a git repo / git error → treat as "not clean" so we never auto-commit blindly
|
|
@@ -73,15 +367,57 @@ export function isTreeClean(cwd) {
|
|
|
73
367
|
export function stripCommitFence(text) {
|
|
74
368
|
return text.trim().replace(/^```[a-z]*\n?/i, "").replace(/\n?```$/i, "").trim();
|
|
75
369
|
}
|
|
370
|
+
function displayPathList(paths, cap = 12_000) {
|
|
371
|
+
const shown = [];
|
|
372
|
+
let length = 0;
|
|
373
|
+
for (const path of paths) {
|
|
374
|
+
const label = JSON.stringify(safePathLabel(path));
|
|
375
|
+
if (length + label.length + (shown.length ? 2 : 0) > cap)
|
|
376
|
+
break;
|
|
377
|
+
shown.push(label);
|
|
378
|
+
length += label.length + (shown.length > 1 ? 2 : 0);
|
|
379
|
+
}
|
|
380
|
+
const remaining = paths.length - shown.length;
|
|
381
|
+
if (!shown.length)
|
|
382
|
+
return `${paths.length} path(s) (names omitted for length)`;
|
|
383
|
+
return shown.join(", ") + (remaining ? `, … and ${remaining} more` : "");
|
|
384
|
+
}
|
|
385
|
+
/** Model-safe input for commit-message generation. A deletion-only commit still has useful metadata, but
|
|
386
|
+
* never receives the unverifiable historical blob that Git's ordinary deletion diff would expose. */
|
|
387
|
+
export function commitMessageInput(changes) {
|
|
388
|
+
const omittedDeletions = changes.omittedDeletions ?? [];
|
|
389
|
+
const parts = [];
|
|
390
|
+
if (changes.diff.trim())
|
|
391
|
+
parts.push(changes.diff);
|
|
392
|
+
if (omittedDeletions.length) {
|
|
393
|
+
parts.push("Tracked deletions (historical file contents intentionally omitted for safety): " +
|
|
394
|
+
displayPathList(omittedDeletions));
|
|
395
|
+
}
|
|
396
|
+
return parts.join("\n\n");
|
|
397
|
+
}
|
|
76
398
|
/** The reviewer's input: the task + the changes to review. */
|
|
77
399
|
export function reviewPrompt(task, changes) {
|
|
400
|
+
const skippedFiles = changes.skippedFiles ?? [];
|
|
401
|
+
const omittedDeletions = changes.omittedDeletions ?? [];
|
|
78
402
|
const parts = [`Task that was implemented:\n${task}`, ""];
|
|
79
|
-
if (changes.diff)
|
|
80
|
-
parts.push("
|
|
403
|
+
if (changes.diff) {
|
|
404
|
+
parts.push("Change metadata only (status + path; historical patch contents are intentionally omitted):\n```text\n" +
|
|
405
|
+
changes.diff + "\n```");
|
|
406
|
+
}
|
|
81
407
|
if (changes.newFiles.length)
|
|
82
|
-
parts.push(`New files (use read_file to inspect): ${changes.newFiles
|
|
83
|
-
if (
|
|
84
|
-
parts.push("
|
|
408
|
+
parts.push(`New files (use read_file to inspect): ${displayPathList(changes.newFiles)}`);
|
|
409
|
+
if (skippedFiles.length) {
|
|
410
|
+
parts.push("Protected paths were omitted from review context and MUST NOT be opened by the reviewer: " +
|
|
411
|
+
displayPathList(skippedFiles));
|
|
412
|
+
}
|
|
413
|
+
if (omittedDeletions.length) {
|
|
414
|
+
parts.push("Tracked deletions were detected, but their historical contents were omitted because old filesystem " +
|
|
415
|
+
"identity cannot be verified safely: " + displayPathList(omittedDeletions));
|
|
416
|
+
}
|
|
417
|
+
if (changes.error)
|
|
418
|
+
parts.push(`(change capture failed closed: ${changes.error})`);
|
|
419
|
+
if (!changes.diff && !changes.newFiles.length && !omittedDeletions.length)
|
|
420
|
+
parts.push("(no reviewable diff was captured)");
|
|
85
421
|
parts.push("\nReview these changes against the task. Finish with your VERDICT line.");
|
|
86
422
|
return parts.join("\n");
|
|
87
423
|
}
|
package/dist/org/roles.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// Org roles — markdown agent definitions in <project>/.hara/roles/*.md.
|
|
2
2
|
// Frontmatter: name, description, owns[], rejects[], model?, allowTools[], denyTools[], readOnly?. Body = persona/system.
|
|
3
|
-
import {
|
|
3
|
+
import { writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { findProjectRoot } from "../context/agents-md.js";
|
|
7
7
|
import { pluginRoleDirs } from "../plugins/plugins.js";
|
|
8
|
+
import { readModelContextFileSync } from "../fs-read.js";
|
|
9
|
+
const MAX_ROLE_BYTES = 512 * 1024;
|
|
8
10
|
export function rolesDir(cwd) {
|
|
9
11
|
return join(findProjectRoot(cwd), ".hara", "roles");
|
|
10
12
|
}
|
|
@@ -121,7 +123,7 @@ function rolesFromDirs(dirs) {
|
|
|
121
123
|
if (!f.endsWith(".md") || f === "README.md")
|
|
122
124
|
continue;
|
|
123
125
|
try {
|
|
124
|
-
const { fm, body } = parseFrontmatter(
|
|
126
|
+
const { fm, body } = parseFrontmatter(readModelContextFileSync(join(dir, f), MAX_ROLE_BYTES));
|
|
125
127
|
const id = fm.name || f.replace(/\.md$/, "");
|
|
126
128
|
const explicitReadOnly = /^(true|false)$/i.test(String(fm.readOnly ?? ""))
|
|
127
129
|
? String(fm.readOnly).toLowerCase() === "true"
|