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/dist/hook.js CHANGED
@@ -3,16 +3,17 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } fr
3
3
  import { join } from 'node:path';
4
4
  import { AGENTS, fromAgent, toAgent } from './agents.js';
5
5
  import { ensureDaemon } from './daemon.js';
6
- import { noteEdit, onStop } from './done.js';
6
+ import { EDIT_TOOLS, noteEdit, onStop, SHELL_TOOLS } from './done.js';
7
7
  import { toArcroomEvent } from './events.js';
8
- import { noteDir } from './code.js';
8
+ import { editClash, noteDir } from './code.js';
9
9
  import { fence } from './fence.js';
10
10
  import { redact, rememberSecrets } from './redact.js';
11
- import { ARCROOM_HOME, isOff, keyRefused } from './home.js';
11
+ import { ARCROOM_HOME, isOff, keyRefused, loadConfig } from './home.js';
12
12
  import { lookupFast } from './lookup.js';
13
13
  import { checkLoop, sessionFile } from './loop.js';
14
14
  import { pack } from './pack.js';
15
15
  import { lastRunNote, projectOf } from './session.js';
16
+ import { laptopHookActs } from './scope.js';
16
17
  import { bump, MARK, signFor, toolResult } from './signs.js';
17
18
  import { costSinceClaim, markClaim, turnUsage } from './usage.js';
18
19
  // Claude Code runs this for every hook event, with the event JSON on stdin.
@@ -43,8 +44,14 @@ export async function hook() {
43
44
  const raw = fromAgent(JSON.parse(input), agent);
44
45
  if (isOff(raw.cwd))
45
46
  return; // switched off for this repo by this person (arcroom off): nothing recorded or sent
46
- if (keyRefused())
47
- return; // the key was revoked or its account deleted: nothing runs until a new key (arcroom init)
47
+ if (process.argv.includes('--user') && !laptopHookActs(raw.cwd))
48
+ return; // laptop-wide hook: only set-up repos, never twice
49
+ if (keyRefused(loadConfig(raw.cwd))) {
50
+ // the key was revoked or its account deleted: nothing runs until a new key (arcroom init); said once, at session start
51
+ if (raw.hook_event_name === 'SessionStart')
52
+ process.stdout.write(JSON.stringify(toAgent(agent, 'SessionStart', { systemMessage: `${MARK} · stopped: the API refused this laptop's key. Run arcroom init with a new key (web app, Settings → API keys).` }, String(raw.session_id))));
53
+ return;
54
+ }
48
55
  rememberSecrets(raw.cwd); // this project's .env values are redacted wherever they show up
49
56
  const name = raw.hook_event_name;
50
57
  const session = String(raw.session_id);
@@ -58,7 +65,7 @@ export async function hook() {
58
65
  const injected = (kind, text, keepText = false) => emit('context', { kind, chars: text.length, ...(keepText ? { text: text.slice(0, 2000) } : {}) });
59
66
  // Token meter: the turn's usage goes in before its turn.end, so the report built at turn.end includes it.
60
67
  // After each tool call too, so the website shows a long turn's tokens while it runs.
61
- if (name === 'Stop' || name === 'SessionEnd' || name === 'PostToolUse') {
68
+ if (name === 'Stop' || name === 'SessionEnd' || name === 'PreToolUse') {
62
69
  const usage = turnUsage(session, raw.transcript_path);
63
70
  if (usage)
64
71
  emit('usage', usage);
@@ -149,21 +156,27 @@ export async function hook() {
149
156
  }
150
157
  if (pointer)
151
158
  given.add(pointer.memoId);
152
- // what `arcroom wrong` reports when the person says the memory just handed over was wrong
153
- const last = pointer ?? (playbook && { memoId: playbook.memoId, intent: 'playbook' });
154
- if (last)
155
- writeFileSync(join(ARCROOM_HOME, 'last-recall.json'), JSON.stringify({ memo_id: last.memoId, intent: last.intent }));
159
+ // what `arcroom wrong` reports when the person says the memory just handed over was wrong: every memory handed over
160
+ // (steps or answer, playbook, each fact and decision), so it can name the one that was wrong
161
+ const handed = [
162
+ ...(pointer ? [{ memo_id: pointer.memoId, kind: pointer.kind, text: pointer.intent }] : []),
163
+ ...(playbook ? [{ memo_id: playbook.memoId, kind: 'playbook', text: playbook.intent ?? 'playbook' }] : []),
164
+ ...(knowledge ? knowledge.items.map((i) => ({ memo_id: i.id, kind: i.kind, text: i.text })) : []),
165
+ ];
166
+ if (handed.length)
167
+ writeFileSync(join(ARCROOM_HOME, 'last-recall.json'), JSON.stringify({ memo_id: handed[0].memo_id, intent: handed[0].text, handed }));
156
168
  if (pointer || knowledge || playbook)
157
169
  writeFileSync(givenFile, JSON.stringify([...given]));
158
170
  if (pointer) {
159
171
  injected('recall', pointer.text, true);
160
172
  // the turn's outcome credits (or heals) these steps when the turn ends
173
+ const match = pointer.match ? { similar: true, match: pointer.match } : {};
161
174
  if (pointer.kind === 'steps')
162
- emit('memory.replayed', { memo_id: pointer.memoId });
175
+ emit('memory.replayed', { memo_id: pointer.memoId, ...match });
163
176
  else
164
- emit('memory.reused', { memo_id: pointer.memoId, version: pointer.version });
177
+ emit('memory.reused', { memo_id: pointer.memoId, version: pointer.version, ...match });
165
178
  bump(session, pointer.kind === 'steps' ? 'replayed' : 'reused');
166
- say(`${MARK} · ${pointer.kind === 'steps' ? 'steps from a past run' : 'a team answer'}${pointer.similar ? ' for a similar task' : ''}: ${pointer.intent.slice(0, 70)}`);
179
+ say(`${MARK} · ${pointer.kind === 'steps' ? 'steps from a past run' : 'a team answer'}${pointer.similar ? ' for a similar task' : ''}: ${pointer.intent.slice(0, 70)}${pointer.match ? ` (match: ${pointer.match})` : ''}`);
167
180
  }
168
181
  // a hit is the lookup already done: the reminder would only send the agent to find_task for it again
169
182
  // what teammates' agents wrote goes in one fenced block of data (fence.ts); ArcRoom's own reminder stays outside it
@@ -184,11 +197,22 @@ export async function hook() {
184
197
  say(`${MARK} · blocked a repeated ${String(raw.tool_name).replace(OUR_TOOLS, '')} call (same input 3 times in a row)`);
185
198
  bump(session, 'blocked');
186
199
  }
187
- else
188
- noteEdit(raw);
200
+ else {
201
+ // two agents on one file: the first edit waits once, with who is on it (the same edit again goes through)
202
+ const clash = EDIT_TOOLS.has(raw.tool_name)
203
+ ? await editClash(loadConfig(raw.cwd), session, raw.cwd, raw.tool_input?.file_path ?? raw.tool_input?.notebook_path).catch(() => undefined)
204
+ : undefined;
205
+ if (clash) {
206
+ out.hookSpecificOutput = { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: clash };
207
+ injected('deny', clash);
208
+ say(`${MARK} · another agent is editing this file: asked yours to check first`);
209
+ }
210
+ else
211
+ noteEdit(raw);
212
+ }
189
213
  }
190
214
  // a done-check the agent ran with Bash (the Stop hook asks for them; it never runs them itself)
191
- if (name === 'PostToolUse' && raw.tool_name === 'Bash')
215
+ if (name === 'PostToolUse' && SHELL_TOOLS.has(raw.tool_name))
192
216
  noteEdit(raw);
193
217
  if (name === 'PostToolUse' && String(raw.tool_name).startsWith(OUR_TOOLS)) {
194
218
  const tool = raw.tool_name.slice(OUR_TOOLS.length);
@@ -227,14 +251,16 @@ export async function hook() {
227
251
  }
228
252
  if (project) {
229
253
  // Session memory: start with what the last run in this project did. Slow or offline API = no note.
230
- const note = await lastRunNote(session, project).catch(() => undefined);
254
+ const root = git(['rev-parse', '--show-toplevel'], { cwd: raw.cwd }).stdout?.trim();
255
+ const note = await lastRunNote(session, project, branch, root).catch(() => undefined);
231
256
  if (note) {
232
257
  // the last run here may be a teammate's: its ask is their words, so it is fenced like any memory
233
258
  out.hookSpecificOutput = { hookEventName: 'SessionStart', additionalContext: fence(note) };
234
259
  injected('last_run', note, true);
235
260
  }
236
261
  // say what leaves the laptop, every session (events.ts keeps it to that)
237
- say(`${MARK} · on: team memory · sends prompts, file paths and commands (no file contents)${note ? ' · picked up where the last run here left off' : ''}`);
262
+ const ws = loadConfig(raw.cwd).workspace;
263
+ say(`${MARK} · on: team memory${ws ? ` in "${ws}"` : ''} · sends prompts, file paths and commands (no file contents)${note ? ' · picked up where the last run here left off' : ''}`);
238
264
  }
239
265
  const reply = toAgent(agent, name, out, session);
240
266
  if (Object.keys(reply).length)
package/dist/index.js CHANGED
@@ -1,16 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { codeSetup, forgetBlind, gitRoot, repoKey, writeSetup } from './blind.js';
5
- import { daemon } from './daemon.js';
4
+ import { daemon, flush, unsent } from './daemon.js';
6
5
  import { doctor, print } from './doctor.js';
7
6
  import { approveChecks, checksHash, isApproved, loadChecks, readApprovals } from './done.js';
8
- import { syncTree } from './tree.js';
7
+ import { codeSetup, repoKey, syncTree, writeSetup } from './tree.js';
8
+ import { gitRoot, projectOf } from './git.js';
9
9
  import { api, ARCROOM_HOME, loadConfig, setOff } from './home.js';
10
10
  import { hook } from './hook.js';
11
- import { init } from './init.js';
11
+ import { init, uninstall } from './init.js';
12
+ import { sentReport } from './sent.js';
12
13
  import { mcp } from './mcp.js';
13
- import { readStats, statusText } from './signs.js';
14
+ import { readStats, statusText, wrongNote } from './signs.js';
14
15
  import { sessionUsage, tokensOf } from './usage.js';
15
16
  const [cmd, ...args] = process.argv.slice(2);
16
17
  // A command someone typed that fails prints one line, not a stack trace (the hook, daemon and MCP server keep their own handling).
@@ -32,7 +33,7 @@ else if (cmd === 'doctor') {
32
33
  process.exitCode = 1;
33
34
  }
34
35
  else if (cmd === 'whoami') {
35
- const me = await api(loadConfig(), '/v1/me');
36
+ const me = await api(loadConfig(process.cwd()), '/v1/me');
36
37
  console.log(`${me.email ?? 'shared key (no person)'} in workspace "${me.workspace}" (key ${me.key_label ?? 'unlabelled'}, made ${me.key_created_at})`);
37
38
  }
38
39
  else if (cmd === 'status') {
@@ -55,31 +56,29 @@ else if (cmd === 'rule') {
55
56
  // arcroom rule [--tag <tag>] [--max-hits N] [--idle-days D] (no limits = remove the rule)
56
57
  const opt = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
57
58
  const num = (name) => (opt(name) === undefined ? null : Number(opt(name)));
58
- const res = await api(loadConfig(), '/v1/rules', { tag: opt('--tag') ?? null, max_hits: num('--max-hits'), idle_days: num('--idle-days') });
59
+ const res = await api(loadConfig(process.cwd()), '/v1/rules', { tag: opt('--tag') ?? null, max_hits: num('--max-hits'), idle_days: num('--idle-days') });
59
60
  console.log(res.rule ? `rule set: ${JSON.stringify(res.rule)}` : 'rule removed');
60
61
  }
61
62
  else if (cmd === 'wrong' || cmd === 'share') {
62
63
  // arcroom wrong [memo id] [reason]: the memory just handed over (or the one named) was wrong
63
- // arcroom share [memo id]: share your new steps with the team before their second pass
64
+ // arcroom share <memo id>: share an answer saved as "only me" with the team (recorded steps are the team's already)
64
65
  const lastFile = join(ARCROOM_HOME, 'last-recall.json');
65
66
  const named = /^[0-9a-f-]{36}$/i.test(args[0] ?? '') ? args.shift() : undefined;
66
- const last = named ? { memo_id: named, intent: named } : existsSync(lastFile) ? JSON.parse(readFileSync(lastFile, 'utf8')) : undefined;
67
- if (!last)
67
+ const last = named ? { memo_id: named, intent: named } : cmd === 'wrong' && existsSync(lastFile) ? JSON.parse(readFileSync(lastFile, 'utf8')) : undefined;
68
+ if (!last && cmd === 'share')
69
+ console.log('steps your agents record are shared with the team already; to share an answer saved as private, pass its id: arcroom share <memo id> (see the Memory page)');
70
+ else if (!last)
68
71
  console.log('no memory was handed over yet; pass its id (see the Memory page)');
72
+ else if (!named && Array.isArray(last.handed) && last.handed.length > 1)
73
+ // several memories came in that hand-over: say which, instead of guessing
74
+ console.log(`The last hand-over had ${last.handed.length} memories. Name the wrong one:\n${last.handed.map((h) => ` arcroom wrong ${h.memo_id} ${h.kind}: ${h.text}`).join('\n')}`);
69
75
  else if (cmd === 'share') {
70
- await api(loadConfig(), '/v1/memory/share', { memo_id: last.memo_id });
76
+ await api(loadConfig(process.cwd()), '/v1/memory/share', { memo_id: last.memo_id });
71
77
  console.log(`shared with the team: ${last.intent}`);
72
78
  }
73
79
  else {
74
- const r = await api(loadConfig(), '/v1/memory/wrong', { memo_id: last.memo_id, reason: args.join(' ') || null });
75
- const what = r.deleted
76
- ? 'deleted (2 people said wrong)'
77
- : r.steps_broken
78
- ? 'steps broken, the last good version takes over'
79
- : r.answer_thrown_away
80
- ? 'answer thrown away'
81
- : `noted (${r.reports} of 2 reports delete it)`;
82
- console.log(`reported wrong: ${last.intent} · ${what}`);
80
+ const r = await api(loadConfig(process.cwd()), '/v1/memory/wrong', { memo_id: last.memo_id, reason: args.join(' ') || null });
81
+ console.log(`reported wrong: ${last.intent} · ${wrongNote(r)}`);
83
82
  }
84
83
  }
85
84
  else if (cmd === 'code') {
@@ -90,23 +89,40 @@ else if (cmd === 'code') {
90
89
  console.log('not inside a git repo');
91
90
  else if (sub === 'off' || sub === 'on') {
92
91
  writeSetup(s.root, { code: sub === 'off' ? 'off' : undefined });
93
- forgetBlind(s.root); // the person here chose it: this laptop stops holding the repo to Blind
94
92
  console.log(`Code wing ${sub} for ${s.root} (commit .arcroom.json so the team gets the same setting)`);
95
93
  }
96
- else if (s.mode !== 'standard')
97
- console.log(`Code wing is ${s.mode} for this repo${s.mode === 'off' ? ': arcroom code on turns it on' : ''}`);
94
+ else if (s.mode === 'off')
95
+ console.log('Code wing is off for this repo: arcroom code on turns it on');
98
96
  else if (sub === 'sync') {
99
- const r = await syncTree(loadConfig(), s);
97
+ const r = await syncTree(loadConfig(process.cwd()), s);
100
98
  console.log(`sent ${r.nodes} folders and files (repo ${repoKey(s)})`);
101
99
  }
102
100
  else {
103
- const repos = await api(loadConfig(), '/v1/code/tree');
101
+ const repos = await api(loadConfig(process.cwd()), '/v1/code/tree');
104
102
  const me = repos.find((x) => x.repo === repoKey(s));
105
103
  console.log(me
106
104
  ? `${me.repo}: ${me.folders} folders, ${me.files} files, ${me.editing} agent(s) editing now, default branch ${me.default_branch}`
107
105
  : 'this repo has not sent its tree yet: arcroom code sync');
108
106
  }
109
107
  }
108
+ else if (cmd === 'flush') {
109
+ // arcroom flush: the last step of a CI job, so a headless run's final events are not lost when the machine goes away
110
+ const ok = await flush(process.argv[1]);
111
+ console.log(ok ? 'everything queued was sent' : `${unsent()} bytes still queued after 60 s: see arcroom doctor`);
112
+ if (!ok)
113
+ process.exitCode = 1;
114
+ }
115
+ else if (cmd === 'sent') {
116
+ // arcroom sent [YYYY-MM-DD] [--all]: what this laptop sent that day, kept 7 days
117
+ console.log(sentReport(args));
118
+ }
119
+ else if (cmd === 'uninstall') {
120
+ // arcroom uninstall [--all]: ArcRoom out of this project for every agent; --all also deletes ~/.arcroom
121
+ const changed = uninstall(process.cwd(), args.includes('--all'));
122
+ console.log(changed.length ? `Removed ArcRoom from:\n${changed.map((c) => ` ${c}`).join('\n')}` : 'Nothing of ArcRoom was set up in this project.');
123
+ if (!args.includes('--all'))
124
+ console.log('Other projects on this laptop keep it. arcroom uninstall --all also deletes ~/.arcroom (key, queue, runtime).');
125
+ }
110
126
  else if (cmd === 'off' || cmd === 'on') {
111
127
  // arcroom off | on: ArcRoom for the repo you are in, on this laptop only (nothing traced or sent while off)
112
128
  const root = gitRoot(process.cwd()) ?? process.cwd();
@@ -123,7 +139,7 @@ else if (cmd === 'checks') {
123
139
  console.log('no doneChecks in .arcroom.json here');
124
140
  else if (args[0] === 'approve' && !process.stdin.isTTY)
125
141
  // an agent's shell has no terminal: approving stays a step the person takes themselves (N14)
126
- console.log('not approved: run arcroom checks approve yourself, in a terminal (not through an agent)');
142
+ console.log(`not approved: run arcroom checks approve yourself, in a terminal (not through an agent); in CI, set ARCROOM_APPROVE_CHECKS=${code} in the job's environment`);
127
143
  else if (args[0] === 'approve') {
128
144
  const done = approveChecks(process.cwd(), args[1] ?? '');
129
145
  console.log(done ? `approved for ${process.cwd()} (after editing files, the agent is asked to run them with its Bash tool before it stops):\n${list}` : 'not approved: run arcroom checks, read the commands, then arcroom checks approve <the code it prints>');
@@ -143,9 +159,10 @@ else if (cmd === 'decide') {
143
159
  else {
144
160
  const over = opt('--over')?.split(',').map((o) => o.trim()).filter(Boolean);
145
161
  const url = opt('--source');
146
- const r = await api(loadConfig(), '/v1/memory/remember', {
162
+ const r = await api(loadConfig(process.cwd()), '/v1/memory/remember', {
147
163
  kind: 'decision',
148
164
  text,
165
+ project: projectOf(process.cwd()),
149
166
  why: opt('--why') ?? null,
150
167
  ...(over ? { options: [text, ...over] } : {}),
151
168
  ...(opt('--context') ? { context: opt('--context') } : {}),
@@ -153,8 +170,12 @@ else if (cmd === 'decide') {
153
170
  ...(url ? { source: { kind: /\/pull\//.test(url) ? 'pr' : /\/issues\//.test(url) ? 'issue' : 'other', url } } : {}),
154
171
  ...(opt('--replaces') ? { replaces: opt('--replaces') } : {}),
155
172
  });
156
- console.log(`${r.saved ? 'decision saved' : 'decision already saved; detail updated'}${r.replaced ? ', the earlier one is replaced' : ''} (${r.memo_id})`);
173
+ // refused (replaced or reported wrong before): the API's note says which memory is in force and what to pass
174
+ if (!r.saved && r.note)
175
+ console.log(`decision not saved: ${r.note}`);
176
+ else
177
+ console.log(`${r.saved ? 'decision saved' : 'decision already saved; detail updated'}${r.replaced ? ', the earlier one is replaced' : ''} (${r.memo_id})`);
157
178
  }
158
179
  }
159
180
  else
160
- console.log('usage: arcroom init [--project <id>] (asks for the key; or set ARCROOM_API_KEY) [--url <arcroom api url>] | arcroom whoami | arcroom doctor | arcroom rule [--tag t] [--max-hits n] [--idle-days d] | arcroom wrong [memo id] [reason] | arcroom share [memo id] | arcroom decide "<choice>" [--why ...] [--over "A, B"] | arcroom code [status|sync|on|off] | arcroom checks [approve] | arcroom off | arcroom on');
181
+ console.log('usage: arcroom init [--project <id>] (asks for the key; or set ARCROOM_API_KEY) [--url <arcroom api url>] | arcroom whoami | arcroom doctor | arcroom rule [--tag t] [--max-hits n] [--idle-days d] | arcroom wrong [memo id] [reason] | arcroom share [memo id] | arcroom decide "<choice>" [--why ...] [--over "A, B"] | arcroom code [status|sync|on|off] | arcroom checks [approve] | arcroom off | arcroom on | arcroom uninstall [--all] | arcroom init --everywhere | arcroom sent [YYYY-MM-DD] [--all] | arcroom flush');
package/dist/init.js CHANGED
@@ -1,26 +1,30 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
2
+ import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { dirname, isAbsolute, join, relative } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { createInterface } from 'node:readline/promises';
6
6
  import { AGENTS, CURSOR_HOOKS, opencodePlugin } from './agents.js';
7
7
  import { stopDaemon } from './daemon.js';
8
- import { onPath } from './git.js';
9
- import { apiUrl, ARCROOM_HOME, DEFAULT_URL, isLocalUrl, keyRefused } from './home.js';
8
+ import { git, onPath, projectOf } from './git.js';
9
+ import { api, apiUrl, ARCROOM_HOME, DEFAULT_URL, isLocalUrl, keyRefused } from './home.js';
10
+ import { userSettings } from './scope.js';
10
11
  const EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'SessionEnd'];
11
12
  const TOOL_EVENTS = new Set(['PreToolUse', 'PostToolUse']);
12
13
  const MARK = '.arcroom/runtime'; // identifies our hook entries in a settings file
13
14
  // node by its full path, never the word "node": an agent starting a hook inside a repo must not run a node.exe planted there
14
15
  const NODE = process.execPath;
15
16
  const nodeCmd = JSON.stringify(NODE);
17
+ // After a tool, only a shell run (a done-check passing) and ArcRoom's own tools matter, so Claude Code launches the hook
18
+ // once per tool call instead of twice: each launch is a window flash on Windows (Git Bash, no console of its own).
19
+ export const AFTER_TOOLS = 'Bash|PowerShell|mcp__arcroom__.*';
16
20
  /** Puts ArcRoom's hook on every event: replaces an earlier ArcRoom entry, keeps everyone else's hooks. */
17
- export function withArcroomHooks(settings, command, anyTool = '*') {
21
+ export function withArcroomHooks(settings, command, anyTool = '*', afterTool = anyTool) {
18
22
  const hooks = { ...settings.hooks };
19
23
  for (const event of EVENTS) {
20
24
  const others = (hooks[event] ?? []).filter((g) => !g.hooks.some((h) => h.command.includes(MARK)));
21
25
  const ours = { hooks: [{ type: 'command', command }] };
22
26
  if (TOOL_EVENTS.has(event))
23
- ours.matcher = anyTool;
27
+ ours.matcher = event === 'PostToolUse' ? afterTool : anyTool;
24
28
  hooks[event] = [...others, ours];
25
29
  }
26
30
  return { ...settings, hooks };
@@ -38,6 +42,28 @@ export function withCodexMcp(toml, entry, node = NODE) {
38
42
  const ours = `[mcp_servers.arcroom]\ncommand = ${JSON.stringify(node)}\nargs = [${JSON.stringify(entry)}, "mcp"]\n`;
39
43
  return (others ? `${others}\n\n` : '') + ours;
40
44
  }
45
+ /**
46
+ * Keeps the files init wrote out of commits, in this clone only (.git/info/exclude, never committed): they hold this
47
+ * laptop's paths, so a `git add -A` would break a teammate's setup. A file the repo already tracks is left alone.
48
+ */
49
+ export function keepOutOfGit(files, cwd) {
50
+ const root = git(['rev-parse', '--show-toplevel'], { cwd }).stdout?.trim();
51
+ const path = git(['rev-parse', '--git-path', 'info/exclude'], { cwd }).stdout?.trim();
52
+ if (!root || !path)
53
+ return [];
54
+ const exclude = isAbsolute(path) ? path : join(cwd, path);
55
+ const have = existsSync(exclude) ? readFileSync(exclude, 'utf8').split(/\r?\n/) : [];
56
+ // git answers with the real path; a symlinked folder (macOS /var) or a short name (Windows RUNNER~1) would read as outside
57
+ const real = (p) => (existsSync(p) ? realpathSync.native(p) : p);
58
+ const add = files
59
+ .map((f) => '/' + relative(real(root), real(f)).replaceAll('\\', '/'))
60
+ .filter((rel) => !rel.startsWith('/..') && !have.includes(rel) && git(['ls-files', '--error-unmatch', rel.slice(1)], { cwd: root }).status !== 0);
61
+ if (!add.length)
62
+ return [];
63
+ mkdirSync(dirname(exclude), { recursive: true });
64
+ appendFileSync(exclude, (have.length && have[have.length - 1] !== '' ? '\n' : '') + add.join('\n') + '\n');
65
+ return add;
66
+ }
41
67
  const readJson = (file) => (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {});
42
68
  const writeJson = (file, value) => {
43
69
  mkdirSync(dirname(file), { recursive: true });
@@ -58,6 +84,26 @@ export function initChoice(o) {
58
84
  ];
59
85
  return { key, url, notes };
60
86
  }
87
+ /**
88
+ * The config after init saves `key` in `repo`. A key for another workspace than the laptop's working default becomes this
89
+ * repo's own, and every other repo keeps sending where it did (a freelancer's two clients); otherwise it is the default.
90
+ */
91
+ export function keysAfterInit(saved, repo, key, url, workspace) {
92
+ const other = !!saved.apiKey && saved.apiKey !== key && !keyRefused({ apiKey: saved.apiKey, url }) && !!saved.workspace && !!workspace && saved.workspace !== workspace;
93
+ const repos = { ...(saved.repos ?? {}) };
94
+ if (other)
95
+ repos[repo] = { apiKey: key, workspace };
96
+ else
97
+ delete repos[repo];
98
+ const next = other ? { ...saved, url, repos } : { ...saved, apiKey: key, url, ...(workspace ? { workspace } : {}), repos };
99
+ if (!Object.keys(next.repos).length)
100
+ delete next.repos;
101
+ if (other)
102
+ console.log(`This repo sends to "${workspace}"; other repos on this laptop keep sending to "${saved.workspace}".`);
103
+ else if (saved.workspace && workspace && saved.workspace !== workspace)
104
+ console.error(`Note: this laptop's default is now "${workspace}" (was "${saved.workspace}").`);
105
+ return next;
106
+ }
61
107
  export async function init(args) {
62
108
  const arg = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
63
109
  const agent = (arg('--agent') ?? 'claude');
@@ -67,7 +113,10 @@ export async function init(args) {
67
113
  }
68
114
  const configFile = join(ARCROOM_HOME, 'config.json');
69
115
  const saved = existsSync(configFile) ? JSON.parse(readFileSync(configFile, 'utf8')) : {}; // re-running init reuses saved values
70
- const choice = initChoice({ argKey: arg('--key'), envKey: process.env.ARCROOM_API_KEY, argUrl: arg('--url'), saved });
116
+ const repo = projectOf(process.cwd());
117
+ const mine = saved.repos?.[repo]; // this repo's own key, when it has one
118
+ const forRepo = mine ? { ...saved, apiKey: mine.apiKey, workspace: mine.workspace } : saved;
119
+ const choice = initChoice({ argKey: arg('--key'), envKey: process.env.ARCROOM_API_KEY, argUrl: arg('--url'), saved: forRepo });
71
120
  let key = choice.key;
72
121
  const url = choice.url;
73
122
  if (arg('--key'))
@@ -90,9 +139,24 @@ export async function init(args) {
90
139
  console.error('That is not an ArcRoom key (it starts with ark_).');
91
140
  process.exit(1);
92
141
  }
93
- // 1. Remember the key for this machine.
142
+ // 1. Check the key with the API, then remember it for this machine: a typo or a revoked key must not end in "ArcRoom is on".
143
+ let workspace = forRepo.apiKey === key ? forRepo.workspace : undefined;
144
+ try {
145
+ const me = await api({ apiKey: key, url }, '/v1/me');
146
+ workspace = me.workspace;
147
+ console.log(`Connected to workspace "${me.workspace}"${me.email ? ` as ${me.email}` : ''}.`);
148
+ }
149
+ catch (e) {
150
+ if (/invalid api key/.test(e.message)) {
151
+ console.error('The API refused this key. Make a new one in the web app (Settings → API keys) and run arcroom init again.');
152
+ process.exit(1);
153
+ }
154
+ console.error(`Could not check the key (${e.message}); saved it anyway. Run arcroom doctor once you are online.`);
155
+ }
94
156
  mkdirSync(ARCROOM_HOME, { recursive: true, mode: 0o700 });
95
- writeFileSync(configFile, JSON.stringify({ apiKey: key, url }, null, 2), { mode: 0o600 });
157
+ const cfg = keysAfterInit(saved, repo, key, url, workspace);
158
+ cfg.setUp = [...new Set([...(cfg.setUp ?? []), repo])]; // laptop-wide hooks (--everywhere) act only in repos set up here
159
+ writeFileSync(configFile, JSON.stringify(cfg, null, 2), { mode: 0o600 });
96
160
  stopDaemon(); // the next hook event starts the updated one
97
161
  // 2. Copy the hook runtime to a stable place (npx's own copy is temporary).
98
162
  const runtime = join(ARCROOM_HOME, 'runtime');
@@ -113,7 +177,7 @@ export async function init(args) {
113
177
  }
114
178
  if (agent !== 'claude')
115
179
  return initOther(agent, entry, project);
116
- const settings = withArcroomHooks(current, `${nodeCmd} "${entry}" hook${project ? ` --project ${project}` : ''}`);
180
+ const settings = withArcroomHooks(current, `${nodeCmd} "${entry}" hook${project ? ` --project ${project}` : ''}`, '*', AFTER_TOOLS);
117
181
  // never pre-approve a server from the shared .mcp.json by name: a pull request could change what that name runs
118
182
  const approved = (current.enabledMcpjsonServers ?? []).filter((n) => n !== 'arcroom');
119
183
  if (approved.length)
@@ -121,9 +185,13 @@ export async function init(args) {
121
185
  else
122
186
  delete settings.enabledMcpjsonServers;
123
187
  // Live badge at the bottom of Claude Code ("ArcRoom · live · 2 reused"), unless the project has its own status line.
188
+ // It runs a command on every redraw, a window flash each time on Windows, so there it is only on with --status.
124
189
  const theirs = current.statusLine;
125
- if (!theirs || theirs.command?.includes(MARK))
190
+ const status = process.platform !== 'win32' || args.includes('--status');
191
+ if (status && (!theirs || theirs.command?.includes(MARK)))
126
192
  settings.statusLine = { type: 'command', command: `${nodeCmd} "${entry}" status`, padding: 0 };
193
+ else if (!status && theirs?.command?.includes(MARK))
194
+ delete settings.statusLine;
127
195
  mkdirSync(dirname(file), { recursive: true });
128
196
  writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
129
197
  // 4. Register the MCP server (find_task / claim_task / complete_task) for this project: in Claude Code's local scope
@@ -142,17 +210,28 @@ export async function init(args) {
142
210
  mcpConfig.mcpServers = { ...mcpConfig.mcpServers, arcroom: { command: NODE, args: [entry, 'mcp'] } };
143
211
  writeFileSync(mcpFile, JSON.stringify(mcpConfig, null, 2) + '\n');
144
212
  }
213
+ const kept = keepOutOfGit([file, ...(local ? [] : [mcpFile])], process.cwd());
214
+ // 5. --everywhere: the same hook and tools for every folder on this laptop (Claude Code's user settings and user scope),
215
+ // acting only in repos set up here, so other clones and worktrees of this repo are covered too.
216
+ const everywhere = args.includes('--everywhere');
217
+ if (everywhere) {
218
+ const user = userSettings();
219
+ const mine = existsSync(user) ? JSON.parse(readFileSync(user, 'utf8')) : {};
220
+ writeJson(user, withArcroomHooks(mine, `${nodeCmd} "${entry}" hook --user`, '*', AFTER_TOOLS));
221
+ claudeCli(['mcp', 'remove', '--scope', 'user', 'arcroom']);
222
+ claudeCli(['mcp', 'add', '--scope', 'user', 'arcroom', '--', NODE, entry, 'mcp', '--user']);
223
+ }
145
224
  console.log(`ArcRoom is on for this project.
146
225
  key ${join(ARCROOM_HOME, 'config.json')}
147
226
  hooks ${file}
148
- tools ${local ? 'Claude Code local scope (claude mcp list)' : `${mcpFile} (approve "arcroom" when Claude Code asks)`}
149
- Start a new Claude Code session here to begin capturing runs.`);
227
+ tools ${local ? 'Claude Code local scope (claude mcp list)' : `${mcpFile} (approve "arcroom" when Claude Code asks)`}${kept.length ? `\n git kept out of commits in this clone: ${kept.join(', ')}` : ''}${everywhere ? `\n laptop ${userSettings()}: every clone and worktree of a repo set up here` : ''}
228
+ Start a new Claude Code session here to begin capturing runs. Check it any time: arcroom doctor`);
150
229
  }
151
- /** `claude mcp add --scope local arcroom`: false when the claude command is not on PATH or the add failed. */
152
- function addLocalMcp(entry) {
230
+ /** Runs the claude command; null when it is not on PATH. */
231
+ function claudeCli(args) {
153
232
  const claude = onPath(process.platform === 'win32' ? ['claude.exe', 'claude.cmd'] : ['claude']);
154
233
  if (!claude)
155
- return false;
234
+ return null;
156
235
  const run = (args) => claude.endsWith('.cmd') // an npm install on Windows: a batch file runs through cmd, every argument quoted
157
236
  ? spawnSync(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', `"${[claude, ...args].map((a) => `"${a}"`).join(' ')}"`], {
158
237
  windowsVerbatimArguments: true,
@@ -160,8 +239,106 @@ function addLocalMcp(entry) {
160
239
  encoding: 'utf8',
161
240
  })
162
241
  : spawnSync(claude, args, { encoding: 'utf8', windowsHide: true });
163
- run(['mcp', 'remove', '--scope', 'local', 'arcroom']); // none yet is fine
164
- return run(['mcp', 'add', '--scope', 'local', 'arcroom', '--', NODE, entry, 'mcp']).status === 0;
242
+ return run(args).status;
243
+ }
244
+ /** `claude mcp add --scope local arcroom`: false when the claude command is not on PATH or the add failed. */
245
+ function addLocalMcp(entry) {
246
+ if (claudeCli(['mcp', 'remove', '--scope', 'local', 'arcroom']) === null)
247
+ return false; // none yet is fine
248
+ return claudeCli(['mcp', 'add', '--scope', 'local', 'arcroom', '--', NODE, entry, 'mcp']) === 0;
249
+ }
250
+ /** A hooks map without ArcRoom's entries (Claude Code and Codex group them; Cursor lists commands); empty events go. */
251
+ function withoutOurHooks(hooks) {
252
+ if (!hooks)
253
+ return hooks;
254
+ const ours = (h) => String(h?.command ?? '').includes(MARK) || (h?.hooks ?? []).some((x) => String(x?.command ?? '').includes(MARK));
255
+ const next = Object.fromEntries(Object.entries(hooks).map(([event, list]) => [event, (list ?? []).filter((h) => !ours(h))]).filter(([, list]) => list.length));
256
+ return Object.keys(next).length ? next : undefined;
257
+ }
258
+ /**
259
+ * `arcroom uninstall [--all]`: takes out of this project what init put in for every agent (hooks, status line, MCP entries,
260
+ * the OpenCode plugin), keeping everyone else's settings, and stops the uploader. --all also removes ~/.arcroom (the key,
261
+ * the queue, the runtime). Returns what it changed.
262
+ */
263
+ export function uninstall(cwd, all = false, removeLocalMcp = () => claudeCli(['mcp', 'remove', '--scope', 'local', 'arcroom']), removeUserMcp = () => claudeCli(['mcp', 'remove', '--scope', 'user', 'arcroom'])) {
264
+ const changed = [];
265
+ const edit = (file, change) => {
266
+ if (!existsSync(file))
267
+ return;
268
+ const before = JSON.parse(readFileSync(file, 'utf8'));
269
+ const after = change(structuredClone(before));
270
+ if (JSON.stringify(after) === JSON.stringify(before))
271
+ return;
272
+ if (!Object.keys(after).length)
273
+ rmSync(file);
274
+ else
275
+ writeFileSync(file, JSON.stringify(after, null, 2) + '\n');
276
+ changed.push(file);
277
+ };
278
+ const ourServer = (s) => JSON.stringify(s ?? '').replaceAll('\\\\', '/').includes(MARK);
279
+ const hooksOf = (v) => {
280
+ const hooks = withoutOurHooks(v.hooks);
281
+ if (hooks)
282
+ v.hooks = hooks;
283
+ else
284
+ delete v.hooks;
285
+ if (String(v.statusLine?.command ?? '').includes(MARK))
286
+ delete v.statusLine;
287
+ return v;
288
+ };
289
+ edit(join(cwd, '.claude', 'settings.local.json'), hooksOf);
290
+ edit(join(cwd, '.codex', 'hooks.json'), hooksOf);
291
+ edit(join(cwd, '.cursor', 'hooks.json'), (v) => {
292
+ const hooks = withoutOurHooks(v.hooks);
293
+ if (hooks)
294
+ v.hooks = hooks;
295
+ else
296
+ delete v.hooks;
297
+ return Object.keys(v).length === 1 && v.version !== undefined ? {} : v;
298
+ });
299
+ for (const file of [join(cwd, '.mcp.json'), join(cwd, '.cursor', 'mcp.json')])
300
+ edit(file, (v) => {
301
+ if (ourServer(v.mcpServers?.arcroom))
302
+ delete v.mcpServers.arcroom;
303
+ if (v.mcpServers && !Object.keys(v.mcpServers).length)
304
+ delete v.mcpServers;
305
+ return v;
306
+ });
307
+ edit(join(cwd, 'opencode.json'), (v) => {
308
+ if (ourServer(v.mcp?.arcroom))
309
+ delete v.mcp.arcroom;
310
+ if (v.mcp && !Object.keys(v.mcp).length)
311
+ delete v.mcp;
312
+ return Object.keys(v).length === 1 && v.$schema ? {} : v;
313
+ });
314
+ const toml = join(cwd, '.codex', 'config.toml');
315
+ if (existsSync(toml)) {
316
+ const before = readFileSync(toml, 'utf8');
317
+ const after = before.replace(/\n?\[mcp_servers\.arcroom\][\s\S]*?(?=\n\[|$)/, '').trimEnd();
318
+ if (after !== before.trimEnd()) {
319
+ if (after)
320
+ writeFileSync(toml, after + '\n');
321
+ else
322
+ rmSync(toml);
323
+ changed.push(toml);
324
+ }
325
+ }
326
+ const plugin = join(cwd, '.opencode', 'plugins', 'arcroom.js');
327
+ if (existsSync(plugin))
328
+ rmSync(plugin), changed.push(plugin);
329
+ if (removeLocalMcp() === 0)
330
+ changed.push('Claude Code local MCP entry "arcroom"');
331
+ if (all) {
332
+ edit(userSettings(), hooksOf);
333
+ if (removeUserMcp() === 0)
334
+ changed.push('Claude Code user MCP entry "arcroom"');
335
+ }
336
+ stopDaemon();
337
+ if (all && existsSync(ARCROOM_HOME)) {
338
+ rmSync(ARCROOM_HOME, { recursive: true, force: true });
339
+ changed.push(ARCROOM_HOME);
340
+ }
341
+ return changed;
165
342
  }
166
343
  /** Steps 3 and 4 for Codex, Cursor and OpenCode: their own hook and MCP files, in this project. */
167
344
  function initOther(agent, entry, project) {
@@ -202,8 +379,10 @@ function initOther(agent, entry, project) {
202
379
  cursor: 'Restart Cursor. Cursor adds context only after a tool call, so team memory reaches the agent on its first tool use.',
203
380
  opencode: "Restart OpenCode. Experimental: per-prompt recall rides OpenCode's experimental system-prompt hook, so it may change with OpenCode.",
204
381
  };
382
+ const kept = keepOutOfGit(written, cwd);
205
383
  console.log(`ArcRoom is on for ${agent} in this project.
206
384
  key ${join(ARCROOM_HOME, 'config.json')}
207
- ${written.map((f) => ` wrote ${f}`).join('\n')}
208
- ${next[agent]}`);
385
+ ${written.map((f) => ` wrote ${f}`).join('\n')}${kept.length ? `\n git kept out of commits in this clone: ${kept.join(', ')}` : ''}
386
+ ${next[agent]}
387
+ Check it any time: arcroom doctor`);
209
388
  }
package/dist/lookup.js CHANGED
@@ -36,7 +36,7 @@ const CODE_MS = 1200; // the code map is the extra: it never holds recall up
36
36
  export async function lookup(prompt, cwd, session, given) {
37
37
  const [r, code] = await Promise.all([
38
38
  recall(prompt, cwd, projectOf(cwd), given, RECALL_MS).catch(() => undefined),
39
- codeContext(loadConfig(), cwd, session, prompt, CODE_MS).catch(() => undefined),
39
+ codeContext(loadConfig(cwd), cwd, session, prompt, CODE_MS).catch(() => undefined),
40
40
  ]);
41
41
  if (!r)
42
42
  markDown();