arcroom 0.0.3-beta → 0.0.5-beta
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/README.md +7 -3
- package/dist/code.js +34 -3
- package/dist/daemon.js +57 -17
- package/dist/doctor.js +6 -18
- package/dist/done.js +21 -9
- package/dist/events.js +7 -3
- package/dist/fresh.js +32 -8
- package/dist/git.js +22 -1
- package/dist/home.js +8 -3
- package/dist/hook.js +44 -18
- package/dist/index.js +50 -29
- package/dist/init.js +199 -20
- package/dist/lookup.js +1 -1
- package/dist/loop.js +13 -5
- package/dist/mcp.js +33 -10
- package/dist/pending.js +47 -0
- package/dist/recall.js +36 -12
- package/dist/redact.js +6 -0
- package/dist/scope.js +49 -0
- package/dist/sent.js +62 -0
- package/dist/session.js +29 -21
- package/dist/signs.js +10 -0
- package/dist/tree.js +40 -16
- package/dist/usage.js +14 -0
- package/package.json +1 -1
- package/dist/blind.js +0 -216
package/README.md
CHANGED
|
@@ -21,9 +21,9 @@ npx arcroom init --agent codex # or cursor, opencode (experimental)
|
|
|
21
21
|
|
|
22
22
|
## What leaves your laptop
|
|
23
23
|
|
|
24
|
-
Prompts (system reminders removed, secrets redacted, first 1,000 characters), file paths, search patterns, shell commands (first 1,000 characters), URLs and web queries, done-check results, git hashes of the files a turn touched, token counts, and what an agent saves on purpose with ArcRoom's tools. **Never file contents, tool output or edit diffs.**
|
|
24
|
+
Prompts (system reminders removed, secrets redacted, first 1,000 characters), file paths, search patterns, shell commands (first 1,000 characters), URLs and web queries, done-check results, git hashes of the files a turn touched, token counts, and what an agent saves on purpose with ArcRoom's tools. **Never file contents, tool output or edit diffs.** A path to a secret file (`.env`, keys), or a command that names one, leaves only as `[secret file]`.
|
|
25
25
|
|
|
26
|
-
Every
|
|
26
|
+
Every event passes through `~/.arcroom/spool.jsonl` on its way out and is sent within seconds; what was sent is kept for 7 days in `~/.arcroom/sent/`, and `arcroom sent` shows it. `arcroom off` stops ArcRoom for one repo on this laptop, including anything still queued for it; `arcroom on` starts it again.
|
|
27
27
|
|
|
28
28
|
## If the API is down
|
|
29
29
|
|
|
@@ -37,7 +37,11 @@ Hooks never block a session. Memory lookup gives up after 3 seconds, the prompt
|
|
|
37
37
|
| `arcroom whoami` | who the key belongs to |
|
|
38
38
|
| `arcroom doctor` | checks the key, the API, the hooks in this folder and the uploader, and prints the fix for each problem |
|
|
39
39
|
| `arcroom off` / `arcroom on` | stop or restart ArcRoom for this repo |
|
|
40
|
-
| `arcroom
|
|
40
|
+
| `arcroom flush` | waits until everything queued was sent: the last step of a CI job (exits 1 if it could not) |
|
|
41
|
+
| `arcroom sent [YYYY-MM-DD] [--all]` | what this laptop sent that day (kept 7 days), exactly as sent with `--all` |
|
|
42
|
+
| `arcroom init --everywhere` | also cover every other clone and worktree of the repos you set up (Claude Code user settings) |
|
|
43
|
+
| `arcroom uninstall [--all]` | take ArcRoom out of this project for every agent; `--all` also deletes `~/.arcroom` |
|
|
44
|
+
| `arcroom checks` / `arcroom checks approve <code>` | read and approve the done-checks in `.arcroom.json` (in CI, where there is no terminal: set `ARCROOM_APPROVE_CHECKS=<code>` in the job's environment) |
|
|
41
45
|
| `arcroom wrong [memo id] [reason]` | report a memory that was wrong |
|
|
42
46
|
| `arcroom decide "<choice>" --why ...` | record a team decision |
|
|
43
47
|
| `arcroom rule [--tag t] [--max-hits n] [--idle-days d]` | team rules for when memory expires |
|
package/dist/code.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { git } from './git.js';
|
|
1
|
+
import { git, repoPath } from './git.js';
|
|
2
2
|
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { codeSetup, repoKey, repoPath } from './blind.js';
|
|
5
4
|
import { api } from './home.js';
|
|
6
5
|
import { sessionFile } from './loop.js';
|
|
7
|
-
import { readTree } from './tree.js';
|
|
6
|
+
import { codeSetup, readTree, repoKey } from './tree.js';
|
|
8
7
|
// Code wing at prompt time: before the agent starts, the Room tells it what it knows about the folders this request
|
|
9
8
|
// is about: a summary still true for the folder's current hash, or "changed since its summary", who is editing what
|
|
10
9
|
// right now (on this branch or another), notes other agents left, and what teammates changed since the last prompt.
|
|
@@ -159,3 +158,35 @@ export function codeNoteBody(args, cwd) {
|
|
|
159
158
|
...(args.ttl_hours ? { ttl_hours: args.ttl_hours } : {}),
|
|
160
159
|
};
|
|
161
160
|
}
|
|
161
|
+
/** Another agent's claim on this file (its edits claim a file for 10 minutes), as the reason an edit waits; else undefined. */
|
|
162
|
+
export function clashNote(claims, rel, branch, now = new Date()) {
|
|
163
|
+
const c = (claims ?? []).find((c) => c?.k === rel);
|
|
164
|
+
if (!c)
|
|
165
|
+
return undefined;
|
|
166
|
+
const where = c.branch && c.branch !== branch ? `on ${c.branch}: a merge conflict if you both change it` : 'same branch';
|
|
167
|
+
return (`ArcRoom: ${short(c.by)}'s ${c.agent ?? 'agent'} is editing ${rel} right now (${minutes(c.since, now)} min, ${where}). ` +
|
|
168
|
+
'Check with them, or work on something else first. To edit it anyway, make this edit again.');
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Before this session's first edit of a file: another agent editing that same file right now, as the reason the edit
|
|
172
|
+
* waits once (the agent sees it; the same edit again goes through). One lookup per file per session; any failure is no clash.
|
|
173
|
+
* ponytail: someone who starts on the file after your first edit shows up at your next prompt (the code map), not here
|
|
174
|
+
*/
|
|
175
|
+
export async function editClash(cfg, session, cwd, file, timeoutMs = 1000) {
|
|
176
|
+
if (typeof file !== 'string' || !cwd || !cfg.apiKey)
|
|
177
|
+
return undefined;
|
|
178
|
+
const s = codeSetup(cwd);
|
|
179
|
+
if (!s || s.mode !== 'standard')
|
|
180
|
+
return undefined;
|
|
181
|
+
const rel = repoPath(s.root, file, cwd);
|
|
182
|
+
if (!rel || rel === '.')
|
|
183
|
+
return undefined;
|
|
184
|
+
const seenFile = sessionFile(session, '.clash');
|
|
185
|
+
const seen = existsSync(seenFile) ? JSON.parse(readFileSync(seenFile, 'utf8')) : [];
|
|
186
|
+
if (seen.includes(rel))
|
|
187
|
+
return undefined;
|
|
188
|
+
writeFileSync(seenFile, JSON.stringify([...seen, rel].slice(-500)));
|
|
189
|
+
const branch = currentBranch(s.root);
|
|
190
|
+
const look = await api(cfg, '/v1/code/look', { repo: repoKey(s), branch, nodes: [dirOf(rel)], session }, timeoutMs).catch(() => undefined);
|
|
191
|
+
return clashNote(look?.claims, rel, branch);
|
|
192
|
+
}
|
package/dist/daemon.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { appendFileSync, closeSync, existsSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { repoPath } from './git.js';
|
|
5
5
|
import { prefetchCode } from './code.js';
|
|
6
6
|
import { EDIT_TOOLS } from './done.js';
|
|
7
|
-
import { api, ARCROOM_HOME, keyRefused, loadConfig } from './home.js';
|
|
7
|
+
import { api, ARCROOM_HOME, isOff, keyRefused, loadConfig, offRepos } from './home.js';
|
|
8
8
|
import { serveLookups } from './lookup.js';
|
|
9
|
+
import { sendLater } from './pending.js';
|
|
10
|
+
import { logSent } from './sent.js';
|
|
9
11
|
import { redact, rememberSecrets } from './redact.js';
|
|
10
|
-
import { branches, syncTree } from './tree.js';
|
|
12
|
+
import { branches, codeSetup, repoKey, syncTree } from './tree.js';
|
|
11
13
|
const SPOOL = join(ARCROOM_HOME, 'spool.jsonl');
|
|
12
14
|
const SENDING = join(ARCROOM_HOME, 'spool.sending.jsonl');
|
|
13
15
|
const STATE = join(ARCROOM_HOME, 'daemon-state.json');
|
|
@@ -18,7 +20,7 @@ const BATCH = 200; // events per upload
|
|
|
18
20
|
const READ_MAX = 4 * 1024 * 1024; // bytes read per tick
|
|
19
21
|
const IDLE_MS = 3000;
|
|
20
22
|
/** Takes complete lines from the queue text: assigns each session's next seq and redacts secrets. */
|
|
21
|
-
export function toBatch(text, seq, max = BATCH, shape = (e) => e) {
|
|
23
|
+
export function toBatch(text, seq, max = BATCH, shape = (e) => e, off = offRepos()) {
|
|
22
24
|
const next = { ...seq };
|
|
23
25
|
const events = [];
|
|
24
26
|
let consumed = 0;
|
|
@@ -37,6 +39,8 @@ export function toBatch(text, seq, max = BATCH, shape = (e) => e) {
|
|
|
37
39
|
}
|
|
38
40
|
if (e?.v !== 1)
|
|
39
41
|
continue; // raw lines from the first hook version
|
|
42
|
+
if (isOff(e.cwd, off))
|
|
43
|
+
continue; // queued before `arcroom off`: never sent
|
|
40
44
|
const n = next[e.session] ?? 0;
|
|
41
45
|
next[e.session] = n + 1;
|
|
42
46
|
rememberSecrets(e.cwd); // the second pass knows each project's .env values too
|
|
@@ -68,8 +72,27 @@ export function rotate(state, spool = SPOOL, sending = SENDING) {
|
|
|
68
72
|
}
|
|
69
73
|
return state;
|
|
70
74
|
}
|
|
71
|
-
/**
|
|
72
|
-
|
|
75
|
+
/** Events grouped by the key their folder sends with (a repo can have its own key); each group is sent on its own. */
|
|
76
|
+
export function byKey(events, cfgOf) {
|
|
77
|
+
const groups = new Map();
|
|
78
|
+
for (const e of events) {
|
|
79
|
+
const cfg = cfgOf(e.cwd);
|
|
80
|
+
const g = groups.get(cfg.apiKey) ?? { cfg, events: [] };
|
|
81
|
+
g.events.push(e);
|
|
82
|
+
groups.set(cfg.apiKey, g);
|
|
83
|
+
}
|
|
84
|
+
return [...groups.values()];
|
|
85
|
+
}
|
|
86
|
+
/** Sends the next batch from the queue. The offset only moves after the server accepted it (a resent event is ignored). */
|
|
87
|
+
async function drainOnce(def) {
|
|
88
|
+
const cfgs = new Map();
|
|
89
|
+
const cfgOf = (cwd) => {
|
|
90
|
+
if (!cwd)
|
|
91
|
+
return def;
|
|
92
|
+
if (!cfgs.has(cwd))
|
|
93
|
+
cfgs.set(cwd, loadConfig(cwd));
|
|
94
|
+
return cfgs.get(cwd);
|
|
95
|
+
};
|
|
73
96
|
const saved = readJson(STATE, { offset: 0, seq: {} });
|
|
74
97
|
const state = rotate(saved);
|
|
75
98
|
if (state !== saved)
|
|
@@ -85,22 +108,23 @@ async function drainOnce(cfg) {
|
|
|
85
108
|
const { events, consumed, seq } = toBatch(text, state.seq, BATCH, withCode);
|
|
86
109
|
if (consumed === 0)
|
|
87
110
|
return 0;
|
|
88
|
-
|
|
89
|
-
const result = await api(cfg, '/v1/ingest', { events }, 30_000);
|
|
111
|
+
for (const g of byKey(events, cfgOf)) {
|
|
112
|
+
const result = await api(g.cfg, '/v1/ingest', { events: g.events }, 30_000);
|
|
90
113
|
if (result.rejected)
|
|
91
|
-
log(`server rejected ${result.rejected} of ${events.length} events`);
|
|
114
|
+
log(`server rejected ${result.rejected} of ${g.events.length} events`);
|
|
115
|
+
logSent(g.events); // what left the laptop, as sent: arcroom sent
|
|
92
116
|
}
|
|
93
117
|
writeFileSync(STATE, JSON.stringify({ offset: state.offset + Buffer.byteLength(text.slice(0, consumed)), seq }));
|
|
94
118
|
// a turn ended or a session started in a repo: send what changed in its tree (after the events, so claims land first)
|
|
95
119
|
for (const cwd of new Set(events.filter((e) => e.type === 'turn.end' || e.type === 'run.start').map((e) => e.cwd).filter(Boolean)))
|
|
96
|
-
await syncRepo(
|
|
120
|
+
await syncRepo(cfgOf(String(cwd)), String(cwd)).catch((err) => log(`tree sync: ${err}`));
|
|
97
121
|
// a session started: its first prompt's code map, fetched now (not awaited: the queue keeps moving)
|
|
98
122
|
for (const e of events)
|
|
99
123
|
if (e.type === 'run.start' && e.cwd)
|
|
100
|
-
prefetchCode(
|
|
124
|
+
prefetchCode(cfgOf(String(e.cwd)), String(e.cwd), String(e.session)).catch((err) => log(`prefetch: ${err}`));
|
|
101
125
|
return events.length;
|
|
102
126
|
}
|
|
103
|
-
// Code wing
|
|
127
|
+
// Code wing: which repo, branch and folder an edit is in, cached per folder for a minute.
|
|
104
128
|
const setups = new Map();
|
|
105
129
|
function setupFor(cwd) {
|
|
106
130
|
const hit = setups.get(cwd);
|
|
@@ -122,14 +146,12 @@ export function codeTag(e, s, branch) {
|
|
|
122
146
|
const dir = node.includes('/') ? node.slice(0, node.lastIndexOf('/')) : '.';
|
|
123
147
|
return { ...e, data: { ...e.data, code: { repo: repoKey(s), branch, node, dir, agent: e.data.agent ?? 'claude' } } };
|
|
124
148
|
}
|
|
125
|
-
/** Each event as it
|
|
149
|
+
/** Each event as it leaves this laptop: an edit tagged with where it is. */
|
|
126
150
|
export function withCode(e, setup = setupFor) {
|
|
127
|
-
if (typeof e.cwd !== 'string')
|
|
151
|
+
if (typeof e.cwd !== 'string' || e.type !== 'tool.start')
|
|
128
152
|
return e;
|
|
129
153
|
const { s, branch } = setup(e.cwd);
|
|
130
|
-
|
|
131
|
-
return blindEvent(e, s);
|
|
132
|
-
return e.type === 'tool.start' ? codeTag(e, s, branch) : e;
|
|
154
|
+
return codeTag(e, s, branch);
|
|
133
155
|
}
|
|
134
156
|
const lastSync = new Map();
|
|
135
157
|
async function syncRepo(cfg, cwd) {
|
|
@@ -163,6 +185,11 @@ export async function daemon() {
|
|
|
163
185
|
sent = await drainOnce(cfg);
|
|
164
186
|
if (sent)
|
|
165
187
|
log(`sent ${sent} events`);
|
|
188
|
+
const later = await sendLater(); // agents' saves kept while the API could not be reached
|
|
189
|
+
if (later.sent)
|
|
190
|
+
log(`sent ${later.sent} saves kept while offline`);
|
|
191
|
+
for (const d of later.dropped)
|
|
192
|
+
log(`a save kept while offline was refused: ${d}`);
|
|
166
193
|
}
|
|
167
194
|
catch (err) {
|
|
168
195
|
log(String(err)); // offline or server down: events stay queued, retried next tick
|
|
@@ -195,6 +222,19 @@ export function ensureDaemon(entry) {
|
|
|
195
222
|
return;
|
|
196
223
|
spawn(process.execPath, [entry, 'daemon'], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
197
224
|
}
|
|
225
|
+
/** Bytes in the queue the daemon has not sent yet (the moved-aside file's rest, then the new queue). */
|
|
226
|
+
export function unsent(state = readJson(STATE, { offset: 0, seq: {} }), spool = SPOOL, sending = SENDING) {
|
|
227
|
+
const size = (f) => (existsSync(f) ? statSync(f).size : 0);
|
|
228
|
+
return existsSync(sending) ? Math.max(0, size(sending) - state.offset) + size(spool) : Math.max(0, size(spool) - state.offset);
|
|
229
|
+
}
|
|
230
|
+
/** `arcroom flush`: the last step of a CI job. Waits until the daemon has sent everything queued; false after the timeout. */
|
|
231
|
+
export async function flush(entry, timeoutMs = 60_000) {
|
|
232
|
+
ensureDaemon(entry);
|
|
233
|
+
for (const until = Date.now() + timeoutMs; Date.now() < until; await new Promise((r) => setTimeout(r, 500)))
|
|
234
|
+
if (!unsent())
|
|
235
|
+
return true;
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
198
238
|
/** Called from init: stops a running daemon so the next hook starts the updated one. */
|
|
199
239
|
export function stopDaemon() {
|
|
200
240
|
if (!isRunning())
|
package/dist/doctor.js
CHANGED
|
@@ -1,26 +1,12 @@
|
|
|
1
|
-
import { existsSync,
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { isRunning } from './daemon.js';
|
|
4
4
|
import { isApproved, loadChecks, readApprovals } from './done.js';
|
|
5
5
|
import { api, apiUrl, ARCROOM_HOME, isOff, keyRefused, loadConfig } from './home.js';
|
|
6
|
-
|
|
7
|
-
const AGENT_FILES = [
|
|
8
|
-
['Claude Code', join('.claude', 'settings.local.json')],
|
|
9
|
-
['Codex', join('.codex', 'hooks.json')],
|
|
10
|
-
['Cursor', join('.cursor', 'hooks.json')],
|
|
11
|
-
['OpenCode', join('.opencode', 'plugins', 'arcroom.js')],
|
|
12
|
-
];
|
|
13
|
-
// the runtime under ~/.arcroom, or under ARCROOM_HOME when that is set
|
|
14
|
-
const runtimePath = join(ARCROOM_HOME, 'runtime').replaceAll('\\', '/');
|
|
15
|
-
const ours = (file) => {
|
|
16
|
-
if (!existsSync(file))
|
|
17
|
-
return false;
|
|
18
|
-
const text = readFileSync(file, 'utf8').replaceAll('\\\\', '/').replaceAll('\\', '/');
|
|
19
|
-
return text.includes('.arcroom/runtime') || text.includes(runtimePath);
|
|
20
|
-
};
|
|
6
|
+
import { AGENT_FILES, laptopWide, ours } from './scope.js';
|
|
21
7
|
export async function doctor(cwd = process.cwd()) {
|
|
22
8
|
const checks = [];
|
|
23
|
-
const cfg = loadConfig();
|
|
9
|
+
const cfg = loadConfig(cwd);
|
|
24
10
|
if (!cfg.apiKey)
|
|
25
11
|
return [{ ok: false, what: 'no key on this laptop', fix: 'arcroom init (key: web app, Settings → API keys)' }];
|
|
26
12
|
checks.push({ ok: true, what: `key ${cfg.apiKey.slice(0, 8)}… in ${join(ARCROOM_HOME, 'config.json')}` });
|
|
@@ -49,7 +35,9 @@ export async function doctor(cwd = process.cwd()) {
|
|
|
49
35
|
const wired = AGENT_FILES.filter(([, f]) => ours(join(cwd, f))).map(([agent]) => agent);
|
|
50
36
|
checks.push(wired.length
|
|
51
37
|
? { ok: true, what: `this folder is set up for ${wired.join(', ')}` }
|
|
52
|
-
:
|
|
38
|
+
: laptopWide(cwd)
|
|
39
|
+
? { ok: true, what: 'laptop-wide hooks cover this repo (arcroom init --everywhere)' }
|
|
40
|
+
: { ok: false, what: 'no agent in this folder runs ArcRoom hooks', fix: 'arcroom init (or arcroom init --agent codex|cursor|opencode) here' });
|
|
53
41
|
if (isOff(cwd))
|
|
54
42
|
checks.push({ ok: 'warn', what: 'ArcRoom is switched off for this repo on this laptop', fix: 'arcroom on' });
|
|
55
43
|
const spool = join(ARCROOM_HOME, 'spool.jsonl');
|
package/dist/done.js
CHANGED
|
@@ -2,8 +2,10 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
import { hashFiles } from './fresh.js';
|
|
5
|
+
import { git } from './git.js';
|
|
5
6
|
import { ARCROOM_HOME } from './home.js';
|
|
6
7
|
import { sessionFile } from './loop.js';
|
|
8
|
+
import { projectOf } from './session.js';
|
|
7
9
|
// Finish line: on Stop, if this turn edited files, the project's done-checks (.arcroom.json "doneChecks") must have passed
|
|
8
10
|
// since the last edit. The hook never runs them itself: it blocks the stop and asks the agent to run them with its own
|
|
9
11
|
// Bash tool, so the agent's permission prompt shows every run (a test file or package script the agent just edited can
|
|
@@ -12,6 +14,8 @@ import { sessionFile } from './loop.js';
|
|
|
12
14
|
// Checks are asked for only once the person approved these exact commands for this folder (`arcroom checks approve`):
|
|
13
15
|
// a cloned repo, a pull request or an agent's edit that adds or changes them asks for nothing until then.
|
|
14
16
|
export const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
17
|
+
/** The tools a done-check can run in: Bash, and PowerShell on Windows. */
|
|
18
|
+
export const SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
|
|
15
19
|
export const MAX_BLOCKS = 2;
|
|
16
20
|
const MAX_PASSED = 50;
|
|
17
21
|
const FILE_TOOLS = new Set(['Read', ...EDIT_TOOLS]);
|
|
@@ -22,15 +26,16 @@ const succeeded = (r) => typeof r === 'string'
|
|
|
22
26
|
: !(r && typeof r === 'object' && (r.interrupted || r.is_error || r.isError || [r.exit_code, r.exitCode, r.returncode].some((c) => typeof c === 'number' && c !== 0)));
|
|
23
27
|
/** True when this Bash command ran the check: as written, or last after `cd … &&` / `;`. */
|
|
24
28
|
export const ranCheck = (command, check) => {
|
|
25
|
-
const
|
|
26
|
-
|
|
29
|
+
const one = (s) => s.trim().replace(/\s+/g, ' '); // extra spaces don't make it a different command
|
|
30
|
+
const [cmd, c] = [one(command), one(check)];
|
|
31
|
+
return cmd === c || cmd.endsWith(`&& ${c}`) || cmd.endsWith(`; ${c}`);
|
|
27
32
|
};
|
|
28
33
|
/**
|
|
29
34
|
* Records an edit when this tool call changes files, every file the turn reads or writes (for their hashes), and every
|
|
30
35
|
* Bash command that succeeded since the last edit (a done-check the agent ran).
|
|
31
36
|
*/
|
|
32
37
|
export function track(state, raw) {
|
|
33
|
-
if (raw.hook_event_name === 'PostToolUse' && raw.tool_name
|
|
38
|
+
if (raw.hook_event_name === 'PostToolUse' && SHELL_TOOLS.has(raw.tool_name)) {
|
|
34
39
|
const command = String(raw.tool_input?.command ?? '').trim();
|
|
35
40
|
const passed = state.passed ?? [];
|
|
36
41
|
if (!command || command.length > 500 || passed.includes(command) || passed.length >= MAX_PASSED || !succeeded(raw.tool_response))
|
|
@@ -62,11 +67,13 @@ export function decide(pending, blocks) {
|
|
|
62
67
|
return blocks < MAX_BLOCKS ? 'block' : 'partial';
|
|
63
68
|
}
|
|
64
69
|
export function blockReason(pending, cwd) {
|
|
65
|
-
return (`ArcRoom done-checks: before you stop, run ${pending.length > 1 ? 'each of these' : 'this'} with your
|
|
70
|
+
return (`ArcRoom done-checks: before you stop, run ${pending.length > 1 ? 'each of these' : 'this'} with your shell tool (Bash or PowerShell) from ${cwd}, ` +
|
|
66
71
|
`exactly as written, and fix anything that fails:\n${pending.map((c) => `- \`${c}\``).join('\n')}`);
|
|
67
72
|
}
|
|
73
|
+
/** The repo's top folder: .arcroom.json lives there, so the checks hold in every subfolder and worktree. */
|
|
74
|
+
const rootOf = (cwd) => git(['rev-parse', '--show-toplevel'], { cwd }).stdout?.trim() || cwd;
|
|
68
75
|
export function loadChecks(cwd) {
|
|
69
|
-
const file = join(cwd, '.arcroom.json');
|
|
76
|
+
const file = join(rootOf(cwd), '.arcroom.json');
|
|
70
77
|
if (!existsSync(file))
|
|
71
78
|
return [];
|
|
72
79
|
const checks = JSON.parse(readFileSync(file, 'utf8')).doneChecks;
|
|
@@ -83,21 +90,26 @@ export function readApprovals() {
|
|
|
83
90
|
return {};
|
|
84
91
|
}
|
|
85
92
|
}
|
|
86
|
-
/**
|
|
87
|
-
|
|
93
|
+
/** Approvals are per repo, so every clone, worktree and subfolder of it on this laptop shares one. */
|
|
94
|
+
const repoOf = (cwd) => projectOf(rootOf(cwd));
|
|
95
|
+
/** True only when these exact commands were approved for this repo (or, before approvals were per repo, this folder).
|
|
96
|
+
* In CI there is no terminal to approve in: ARCROOM_APPROVE_CHECKS=<the code arcroom checks prints>, set in the job's own
|
|
97
|
+
* environment (an agent cannot set it for the hook), approves the same exact commands. */
|
|
98
|
+
export const isApproved = (cwd, checks, approvals, ci = process.env.ARCROOM_APPROVE_CHECKS) => [repoOf(cwd), resolve(cwd)].some((k) => approvals[k] === checksHash(checks)) ||
|
|
99
|
+
(!!ci && ci.length >= 8 && checksHash(checks).startsWith(ci));
|
|
88
100
|
/** `arcroom checks approve <code>`: the person approves the folder's done-checks they were shown (`code` = the first
|
|
89
101
|
* 8 characters of their hash, printed by `arcroom checks`); null when the file no longer holds those checks. */
|
|
90
102
|
export function approveChecks(cwd, code) {
|
|
91
103
|
const checks = loadChecks(cwd);
|
|
92
104
|
if (code.length < 8 || !checksHash(checks).startsWith(code))
|
|
93
105
|
return null;
|
|
94
|
-
const approvals = { ...readApprovals(), [
|
|
106
|
+
const approvals = { ...readApprovals(), [repoOf(cwd)]: checksHash(checks) };
|
|
95
107
|
mkdirSync(ARCROOM_HOME, { recursive: true, mode: 0o700 });
|
|
96
108
|
writeFileSync(APPROVED, JSON.stringify(approvals, null, 2) + '\n', { mode: 0o600 });
|
|
97
109
|
return checks;
|
|
98
110
|
}
|
|
99
111
|
const read = (file) => (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { edited: false, blocks: 0 });
|
|
100
|
-
/** PreToolUse / PostToolUse: remember what this turn edited, and the
|
|
112
|
+
/** PreToolUse / PostToolUse: remember what this turn edited, and the shell commands that passed after that. */
|
|
101
113
|
export function noteEdit(raw) {
|
|
102
114
|
const file = sessionFile(String(raw.session_id), '.done');
|
|
103
115
|
const state = read(file);
|
package/dist/events.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { hostname } from 'node:os';
|
|
2
|
-
import { redact, redactText } from './redact.js';
|
|
2
|
+
import { redact, redactText, sensitiveFile } from './redact.js';
|
|
3
3
|
const MAX = 4000; // ponytail: fixed per-field cap so one huge tool output can't bloat the queue; make configurable if reports need full outputs
|
|
4
4
|
/** Keeps small values as they are; replaces big ones with a preview and their size, redacted before the cut. */
|
|
5
5
|
export function clip(value) {
|
|
@@ -18,7 +18,9 @@ const KEEP = ['file_path', 'notebook_path', 'path', 'pattern', 'command', 'url',
|
|
|
18
18
|
const SHORT = 1000; // prompts and commands; the server reads the first 300 (prompt) and 200 (command) chars
|
|
19
19
|
// redacted before the cut, so a secret the cut splits is still found
|
|
20
20
|
const short = (v) => (typeof v === 'string' ? redactText(v).slice(0, SHORT) : v);
|
|
21
|
-
|
|
21
|
+
// a path to a secret file (.env, keys) or a command that names one leaves only as "[secret file]"
|
|
22
|
+
const kept = (k, v) => (sensitiveFile(v) ? '[secret file]' : k === 'command' ? short(v) : v);
|
|
23
|
+
const metadata = (input) => Object.fromEntries(KEEP.filter((k) => input && typeof input === 'object' && input[k] !== undefined).map((k) => [k, kept(k, input[k])]));
|
|
22
24
|
// what Claude Code adds to a prompt (<system-reminder> blocks) is not what the person typed
|
|
23
25
|
const typed = (prompt) => short(String(prompt ?? '').replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim());
|
|
24
26
|
/** Maps one Claude Code hook payload to an ArcRoom run event. */
|
|
@@ -37,7 +39,9 @@ export function toArcroomEvent(raw, at = new Date()) {
|
|
|
37
39
|
return { ...base, type: 'prompt', data: { prompt: typed(raw.prompt) } };
|
|
38
40
|
case 'PreToolUse': {
|
|
39
41
|
const ours = String(raw.tool_name).startsWith(OURS);
|
|
40
|
-
|
|
42
|
+
// a call made inside a subagent says which one, so its steps are not read as the main agent's
|
|
43
|
+
const subagent = raw.agent_id ? { subagent: String(raw.agent_type ?? raw.agent_id).slice(0, 80) } : {};
|
|
44
|
+
return { ...base, type: 'tool.start', data: { id: raw.tool_use_id, tool: raw.tool_name, input: clip(ours ? raw.tool_input : metadata(raw.tool_input)), ...subagent } };
|
|
41
45
|
}
|
|
42
46
|
case 'PostToolUse':
|
|
43
47
|
return { ...base, type: 'tool.end', data: { id: raw.tool_use_id, tool: raw.tool_name, ...(String(raw.tool_name).startsWith(OURS) ? { output: clip(raw.tool_response) } : {}) } };
|
package/dist/fresh.js
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import { git } from './git.js';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
4
|
-
import { extname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { basename, extname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
5
|
// Is a stored memory still true for this checkout? Steps keep the git blob hash of each file they touched (taken on
|
|
6
6
|
// the laptop at the end of the turn; hashes only, no content). Before handing steps over, each file is looked up here:
|
|
7
7
|
// same hash → as it was
|
|
8
8
|
// other hash → changed since: kept, flagged so the agent checks before trusting it
|
|
9
|
-
// missing → renamed if a file here has the saved hash (followed to its new path)
|
|
9
|
+
// missing → renamed if a file here has the saved hash (followed to its new path), or moved and edited if exactly
|
|
10
|
+
// one file here has its name (followed, flagged as changed); otherwise deleted (dropped)
|
|
10
11
|
// when the task read it, or created by the task when it only wrote it (still a target)
|
|
11
12
|
// More than half the files it read deleted: the steps are stale and nothing is handed over. All local, no network.
|
|
12
13
|
/** Git's blob id for a file's bytes (the same id `git hash-object` prints), so it works with or without git. */
|
|
13
14
|
export const blobHash = (bytes) => createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
|
|
14
|
-
|
|
15
|
+
/** A text file's bytes with Windows line ends as LF, so a Windows and a Mac checkout of it hash the same; a binary file
|
|
16
|
+
* (one with a NUL byte) stays as it is. Every hash saved with steps or answers is taken over these bytes. */
|
|
17
|
+
export const lf = (bytes) => (bytes.includes(0) ? bytes : Buffer.from(bytes.toString('latin1').replaceAll('\r\n', '\n'), 'latin1'));
|
|
18
|
+
const MAX_SCAN = 2000; // files hashed when git's index has no match (no git, untracked files, CRLF committed)
|
|
15
19
|
const MAX_READ = 5_000_000;
|
|
16
20
|
/**
|
|
17
21
|
* A path from stored memory (anyone with a workspace key can write it) as a file inside `cwd` that may be read, or
|
|
@@ -40,24 +44,39 @@ export function checkout(cwd) {
|
|
|
40
44
|
const hash = (p) => {
|
|
41
45
|
const file = localFile(cwd, p);
|
|
42
46
|
try {
|
|
43
|
-
return file ? blobHash(readFileSync(file)) : undefined;
|
|
47
|
+
return file ? blobHash(lf(readFileSync(file))) : undefined;
|
|
44
48
|
}
|
|
45
49
|
catch {
|
|
46
50
|
return undefined;
|
|
47
51
|
}
|
|
48
52
|
};
|
|
53
|
+
// git's index: every tracked file with its blob id, in one call and without opening a file, at any repo size
|
|
54
|
+
let index;
|
|
55
|
+
const tracked = () => (index ??= new Map((git(['ls-files', '-s', '-z'], { cwd, timeout: 5000 }).stdout ?? '').split('\0').flatMap((e) => {
|
|
56
|
+
const m = /^\d+ ([0-9a-f]{40,64}) \d\t([\s\S]+)$/.exec(e);
|
|
57
|
+
return m ? [[m[2], m[1]]] : [];
|
|
58
|
+
})));
|
|
49
59
|
let files;
|
|
50
60
|
const list = () => {
|
|
51
61
|
if (files)
|
|
52
62
|
return files;
|
|
53
|
-
const r = git(['ls-files', '--cached', '--others', '--exclude-standard'], { cwd, timeout:
|
|
63
|
+
const r = git(['ls-files', '--cached', '--others', '--exclude-standard'], { cwd, timeout: 5000 });
|
|
54
64
|
files = r.status === 0 ? (r.stdout ?? '').split('\n').filter(Boolean).slice(0, MAX_SCAN) : [];
|
|
55
65
|
return files;
|
|
56
66
|
};
|
|
57
67
|
return {
|
|
58
68
|
exists: (p) => !!localFile(cwd, p),
|
|
59
69
|
hash,
|
|
70
|
+
sameName: (like) => {
|
|
71
|
+
const name = basename(like);
|
|
72
|
+
const hits = [...tracked().keys()].filter((p) => basename(p) === name && !!localFile(cwd, p));
|
|
73
|
+
return hits.length === 1 ? hits[0] : undefined;
|
|
74
|
+
},
|
|
60
75
|
find: (want, like) => {
|
|
76
|
+
// a tracked file git already knows by this id, still the same on disk
|
|
77
|
+
for (const [p, id] of tracked())
|
|
78
|
+
if (id === want && hash(p) === want)
|
|
79
|
+
return p;
|
|
61
80
|
// same extension first (a move keeps it), then everything; only files that exist and are not huge
|
|
62
81
|
const ext = extname(like);
|
|
63
82
|
const pool = [...list().filter((f) => extname(f) === ext), ...list().filter((f) => extname(f) !== ext)];
|
|
@@ -96,9 +115,14 @@ export function freshen(steps, saved = {}, probe) {
|
|
|
96
115
|
changed.add(f);
|
|
97
116
|
continue;
|
|
98
117
|
}
|
|
99
|
-
const
|
|
100
|
-
|
|
118
|
+
const same = saved[f] && probe.find ? probe.find(saved[f], f) : undefined;
|
|
119
|
+
// ponytail: moved-and-edited is found by its name only; one renamed and edited at once still reads as deleted
|
|
120
|
+
const to = same ?? probe.sameName?.(f);
|
|
121
|
+
if (to && !named.has(to)) {
|
|
101
122
|
moved.set(f, to);
|
|
123
|
+
if (!same)
|
|
124
|
+
changed.add(to);
|
|
125
|
+
}
|
|
102
126
|
else if (read.has(f))
|
|
103
127
|
deleted.add(f);
|
|
104
128
|
else
|
|
@@ -130,7 +154,7 @@ export function hashFiles(cwd, paths) {
|
|
|
130
154
|
if (!rel || rel.startsWith('..'))
|
|
131
155
|
continue;
|
|
132
156
|
try {
|
|
133
|
-
out[rel] = blobHash(readFileSync(join(cwd, rel)));
|
|
157
|
+
out[rel] = blobHash(lf(readFileSync(join(cwd, rel))));
|
|
134
158
|
}
|
|
135
159
|
catch {
|
|
136
160
|
/* deleted during the turn: nothing to remember */
|
package/dist/git.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
|
-
import { delimiter, isAbsolute, join } from 'node:path';
|
|
3
|
+
import { delimiter, isAbsolute, join, relative } from 'node:path';
|
|
4
4
|
// Every git call ArcRoom makes runs inside a repo it did not write, often with no one watching (hooks, the daemon).
|
|
5
5
|
// Two ways such a repo could run its own code through those calls, both closed here:
|
|
6
6
|
// - Windows starts a git.exe sitting in the working folder before the one on PATH: git is looked up on PATH once,
|
|
@@ -35,3 +35,24 @@ export function git(args, opts = {}) {
|
|
|
35
35
|
const r = spawnSync(bin, [...SAFE, ...args], { encoding: 'utf8', windowsHide: true, ...opts });
|
|
36
36
|
return { status: r.status, stdout: r.stdout };
|
|
37
37
|
}
|
|
38
|
+
/** Project id shared by everyone on the repo: its git remote without protocol/credentials/.git, else the folder path. */
|
|
39
|
+
export function projectOf(cwd) {
|
|
40
|
+
const remote = git(['config', '--get', 'remote.origin.url'], { cwd }).stdout?.trim();
|
|
41
|
+
if (!remote)
|
|
42
|
+
return cwd.replaceAll('\\', '/').toLowerCase();
|
|
43
|
+
return remote
|
|
44
|
+
.replace(/^[a-z+]+:\/\/(?:[^@/]+@)?/i, '') // https://user:token@host/... -> host/...
|
|
45
|
+
.replace(/^[^@]+@([^:]+):/, '$1/') // git@host:owner/repo -> host/owner/repo
|
|
46
|
+
.replace(/\.git$/, '')
|
|
47
|
+
.toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
/** The repo's top folder, or undefined outside a git repo. */
|
|
50
|
+
export const gitRoot = (cwd) => {
|
|
51
|
+
const r = git(['rev-parse', '--show-toplevel'], { cwd, timeout: 3000 });
|
|
52
|
+
return r.status === 0 ? r.stdout?.trim() : undefined;
|
|
53
|
+
};
|
|
54
|
+
/** A path (absolute, or relative to `from`) as a repo path: "src/billing/tax.ts", or undefined outside the repo. */
|
|
55
|
+
export function repoPath(root, p, from = root) {
|
|
56
|
+
const rel = relative(root, isAbsolute(p) ? p : join(from, p)).replaceAll('\\', '/');
|
|
57
|
+
return rel.startsWith('..') || isAbsolute(rel) ? undefined : rel || '.';
|
|
58
|
+
}
|
package/dist/home.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
4
|
+
import { projectOf } from './git.js';
|
|
4
5
|
import { redact } from './redact.js';
|
|
5
6
|
/**
|
|
6
7
|
* ArcRoom's folder: ~/.arcroom, or ARCROOM_HOME (tests, a second setup). A repo can set environment variables for the
|
|
@@ -35,10 +36,14 @@ export function setOff(root, off, file = OFF_FILE) {
|
|
|
35
36
|
}
|
|
36
37
|
// ArcRoom API (the Oracle server behind Caddy). A local API: arcroom init --url http://localhost:8787
|
|
37
38
|
export const DEFAULT_URL = 'https://api.arcroomai.com';
|
|
38
|
-
/** ~/.arcroom/config.json written by `arcroom init
|
|
39
|
-
export function loadConfig() {
|
|
39
|
+
/** ~/.arcroom/config.json written by `arcroom init`; with `cwd`, the key of the repo it is in (its own, else the default). */
|
|
40
|
+
export function loadConfig(cwd) {
|
|
40
41
|
const file = join(ARCROOM_HOME, 'config.json');
|
|
41
|
-
|
|
42
|
+
const saved = { url: DEFAULT_URL, apiKey: '', ...(existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {}) };
|
|
43
|
+
if (!cwd || !saved.repos || !Object.keys(saved.repos).length)
|
|
44
|
+
return saved; // no git call unless some repo has its own key
|
|
45
|
+
const own = saved.repos[projectOf(cwd)];
|
|
46
|
+
return own ? { ...saved, apiKey: own.apiKey, workspace: own.workspace } : saved;
|
|
42
47
|
}
|
|
43
48
|
const LOCAL = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
44
49
|
/** Whether `url` points at this machine (a developer's local API). */
|