eve-hub 0.13.5 → 0.13.7

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # eve-hub
2
2
 
3
+ ## 0.13.7
4
+
5
+ ### Patch Changes
6
+
7
+ - Main's prompt leads with a chief-of-staff identity, a shorter role, and no `<engineering>` section.
8
+
9
+ ## 0.13.6
10
+
11
+ ### Patch Changes
12
+
13
+ - A bot on the Mac gets the local-onboarding skill: it lists the owner's live coding-agent work (Claude Code, Codex, OpenCode, Cursor) with its git state to port into threads, and proposes the sandbox setup for hosted bots as a `prepare` change. Main's onboarding hands that half to the Mac bot. The agent-usage-setup skill and its hub-checkout scripts move into it.
14
+
3
15
  ## 0.13.5
4
16
 
5
17
  ### Patch Changes
package/kit-modules.json CHANGED
@@ -12,6 +12,7 @@
12
12
  "schedules/dash",
13
13
  "schedules/prune",
14
14
  "skills/forms",
15
+ "skills/local-onboarding",
15
16
  "skills/onboarding-profile",
16
17
  "tools/apply_tags",
17
18
  "tools/archive_thread",
@@ -0,0 +1,89 @@
1
+ ---
2
+ name: local-onboarding
3
+ description: "The Mac half of onboarding, when main hands it to you: find the owner's live coding-agent work (Claude Code, Codex, OpenCode, Cursor) to port into threads, and propose the sandbox setup their hosted bots need. Reads only; the threads and the sandbox PR wait for the owner's yes."
4
+ license: MIT
5
+ ---
6
+
7
+ # The Mac half of onboarding
8
+
9
+ Main runs onboarding on Vercel, where it can't see this Mac, so it hands you this half and posts its own without waiting. You post two short messages in that thread, the live work first, then the sandbox setup. Follow your writing rules: each first bubble is at most two sentences, with tables below a `---` line.
10
+
11
+ Two scripts gather the facts. They live in `{{scripts}}`, read only, and print metadata: repos, branches, counts, times, the agents' own session titles and program names. Work only from what they print. Don't open the agents' session files yourself: they hold the owner's prompts and code. Delete the JSON files when you are done.
12
+
13
+ ## 1. Live work to port
14
+
15
+ ```sh
16
+ node {{scripts}}/sessions-facts.mjs > "$TMPDIR/sessions.json"
17
+ ```
18
+
19
+ The script covers the last 14 days of sessions, grouped into work-streams by repo and branch, newest first. Each stream has:
20
+ - `repo`, `branch` and `pr` (the open PR);
21
+ - `checkouts`: one per clone or worktree it was worked in. Each has `worktree` (true for a linked git worktree), `uncommitted` (changed files) and `unpushed` (commits ahead of `aheadOf`);
22
+ - `lastActive`;
23
+ - `sessions`: each with its agent, title, time and `checkout` (its index in `checkouts`).
24
+
25
+ A stream with no remote is keyed `local-N`. A stream marked `gone` is a folder that no longer exists. `unreadable` lists the sources that exist but couldn't be read. An `uncommitted` or `unpushed` of `null` means the count couldn't be determined (git timed out on a huge untracked tree, or there is no upstream or default branch to compare against): it is unknown, not zero.
26
+
27
+ Post the streams that have work in flight: any checkout with uncommitted files or unpushed commits, or an open PR. A checkout with an unknown count gets a row too, with "unknown" in that cell. A stream with more than one checkout gets one row per checkout that has work. Put the rest in one line with their count.
28
+
29
+ ```md
30
+ You have 3 work-streams in flight on this Mac. Say which to port, and main will open a thread for each so the team can pick it up.
31
+
32
+ ---
33
+
34
+ | Stream | Agent and session | Last active | Uncommitted | Unpushed | PR |
35
+ |---|---|---|---|---|---|
36
+ | `vercel/app` on `fix-login` (worktree) | Claude Code, "Fix login redirect" | 2h ago | 4 files | 2 | [#812](...) |
37
+
38
+ Also 5 streams with nothing in flight.
39
+ ```
40
+
41
+ Never post a local path, a file name, a prompt, or anything else from the sessions.
42
+
43
+ When the owner says which streams to port, you still write nothing on the Mac. Post one message that mentions main and asks it to open one thread per stream. For each stream, give a short title, the repo and branch, its state (worktree, uncommitted and unpushed counts, PR), and which agent had it. You don't open threads yourself.
44
+
45
+ ## 2. Sandbox setup
46
+
47
+ ```sh
48
+ node {{scripts}}/agent-usage-facts.mjs > "$TMPDIR/agent-usage.json"
49
+ ```
50
+
51
+ It takes about 30 seconds on 16 GB of history. It prints names and counts, never a command's arguments, a prompt or a value:
52
+ - `scanned`: records, commands and unparsable commands per agent;
53
+ - `unreadable`;
54
+ - `programs`, `mcpServers`, `repos`, `languages` (file extensions) and `envNames`.
55
+
56
+ Mapping each fact to a setup is your judgment, not a lookup table. Decide, for every program in the top 30:
57
+
58
+ - **Hosted or Mac.** These can't run in a Linux sandbox, so they stay on the Mac with you:
59
+ - `brew`, `osascript` and `open`;
60
+ - Xcode, `xcodebuild` and Apple-platform `swift`;
61
+ - anything that drives the owner's browser or apps;
62
+ - `localhost` services and the keychain.
63
+
64
+ Everything else can be hosted.
65
+ - **Already there, or add it.** A hosted bot's sandbox is eve's Ubuntu image with git only, plus what its `prepare` in `agents/<bot>/agent/sandbox.ts` installs. Read `prepare` in the workspace repo's sandbox files. A program it installs is already provided; any other hosted program needs a `prepare` step. Use `apt-get` where Ubuntu has the package, and the project's official installer otherwise.
66
+
67
+ MCP servers are reached from hosted bots through Vercel Kit connections: list each one by name for main to connect. Env var names are listed for the owner only. Never print a value (you don't have any). Ignore shell and system names (`PATH`, `HOME`, `CI`, ...). A name with a high `shellAssigned` relative to `count` is probably a shell variable, not part of the environment.
68
+
69
+ Post the proposal:
70
+ - a first bubble on what the hosted bots would gain;
71
+ - a table of the steps to add, with the programs each installs and how often the owner used them;
72
+ - one line for what stays on the Mac;
73
+ - one line for the MCP servers to connect through Kit.
74
+
75
+ Ask whether to open the PR.
76
+
77
+ ## 3. The sandbox PR, after the owner's yes
78
+
79
+ The steps live in one file, which is the only source; never hand-write them into `sandbox.ts`. Write `$TMPDIR/prepare.json` as `{"commands": [{"command": "<shell>", "what": "<short name for the error>"}]}`.
80
+
81
+ Main's handoff names the workspace repo. Clone it into a scratch folder, branch, and add the steps to each hosted bot that runs code. That is the builder, unless the owner said otherwise.
82
+
83
+ ```sh
84
+ node {{scripts}}/prepare-edit.mjs "$TMPDIR/prepare.json" agents/builder/agent/sandbox.ts
85
+ ```
86
+
87
+ Commit, push and open the PR with `gh pr create`. The body lists the programs, why each one is there, and the usage counts behind them. Never name the owner's other repos.
88
+
89
+ The PR's Vercel build runs `prepare`, so a step that fails, fails the build. Wait for its result with `gh pr checks --watch --fail-fast`. If it fails, fix the step in `prepare.json`, restore the file from the branch you cut from (`git checkout origin/HEAD -- <file>`), rerun the script and push. Post the PR link and its build result in the thread, then delete the scratch clone.
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env node
2
+ // Read-only fact gatherer for the local-onboarding skill (local-onboarding/SKILL.md, step 2): what the user's local coding agents
3
+ // (Claude Code, Codex, OpenCode, Cursor, Claude Desktop) have actually used, as names and counts.
4
+ // Prints one JSON document on stdout; progress goes to stderr. Never writes, never emits a
5
+ // command's arguments, a prompt, a path inside a repo, or any value: every string that leaves this
6
+ // file went through `name()` or `normalizeOrigin()` (or is a source path under the agents' own dirs).
7
+ import { createReadStream, existsSync, readFileSync, globSync } from 'node:fs';
8
+ import { execFileSync } from 'node:child_process';
9
+ import { createInterface } from 'node:readline';
10
+ import { DatabaseSync } from 'node:sqlite';
11
+ import { homedir } from 'node:os';
12
+ import { basename, dirname, extname, join } from 'node:path';
13
+ import { fileURLToPath, pathToFileURL } from 'node:url';
14
+ import { parse as parseJsonc } from 'jsonc-parser';
15
+ import { parse as parseToml } from 'smol-toml';
16
+ import { Language, Parser } from 'web-tree-sitter';
17
+ import { cleanGitEnv, normalizeOrigin } from './git.mjs';
18
+ import { codexHome, codexStateDb } from './codex-home.mjs';
19
+
20
+ const HOME = homedir();
21
+ const tilde = (p) => (p.startsWith(HOME) ? `~${p.slice(HOME.length)}` : p);
22
+ const NAME = /^[A-Za-z0-9_@][\w.@+:-]{0,63}$/;
23
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
24
+ const EXT = /^\.[a-z0-9]{1,8}$/;
25
+ // Builtins and wrappers say nothing about what a sandbox needs installed.
26
+ const NOT_PROGRAMS = new Set(['cd', 'echo', 'export', 'set', 'unset', 'true', 'false', 'test', '[', ':', 'break', 'continue', 'trap', 'read',
27
+ 'source', '.', 'exit', 'return', 'wait', 'local', 'printf', 'pwd', 'eval', 'alias']);
28
+ const WRAPPERS = new Set(['sudo', 'env', 'time', 'command', 'nohup', 'exec', 'builtin', 'nice', 'timeout']);
29
+ const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'MultiEdit', 'read', 'edit', 'write']);
30
+
31
+ // ---- aggregation: every emitted row is {name, count, agents} keyed by one string ----
32
+ const table = () => new Map();
33
+ function bump(map, key, agent, n = 1) {
34
+ const row = map.get(key) ?? { count: 0, agents: new Set() };
35
+ row.count += n;
36
+ row.agents.add(agent);
37
+ map.set(key, row);
38
+ }
39
+ const rows = (map, keyName) => [...map].map(([k, r]) => ({ [keyName]: k, count: r.count, agents: [...r.agents].sort() }))
40
+ .sort((a, b) => b.count - a.count || String(a[keyName]).localeCompare(String(b[keyName])));
41
+
42
+ const programs = table();
43
+ const extensions = table();
44
+ const repos = table();
45
+ const scanned = Object.fromEntries(['claude-code', 'codex', 'opencode'].map((a) => [a, { records: 0, commands: 0, unparsed: 0 }]));
46
+ const unreadable = new Set(); // sources that exist but could not be read or parsed (labels only, never parser messages)
47
+ const envNames = new Map(); // name -> { count: uses as env or $NAME, shellAssigned: bare/for/read assignments, sources:Set }
48
+ const mcp = new Map(); // name -> { transports, endpoints, agents, sources }
49
+
50
+ const name = (s) => (typeof s === 'string' && NAME.test(s) ? s : null);
51
+
52
+ function addEnvName(n, source, uses = 0, shellAssigned = 0) {
53
+ if (!ENV_NAME.test(n)) return;
54
+ const row = envNames.get(n) ?? { count: 0, shellAssigned: 0, sources: new Set() };
55
+ row.count += uses;
56
+ row.shellAssigned += shellAssigned;
57
+ row.sources.add(source);
58
+ envNames.set(n, row);
59
+ }
60
+
61
+ function addMcp(serverName, agent, source, cfg) {
62
+ const n = name(serverName);
63
+ if (!n) return;
64
+ const row = mcp.get(n) ?? { transports: new Set(), endpoints: new Set(), agents: new Set(), sources: new Set() };
65
+ row.agents.add(agent);
66
+ row.sources.add(source);
67
+ const c = cfg && typeof cfg === 'object' ? cfg : {};
68
+ if (c.command) {
69
+ // `command` is a string (possibly with args: Cursor) or an argv array (OpenCode); only argv[0] is a program name.
70
+ const argv0 = Array.isArray(c.command) ? c.command[0] : String(c.command).trim().split(/\s+/)[0];
71
+ row.transports.add('stdio');
72
+ const prog = name(basename(String(argv0)));
73
+ if (prog) row.endpoints.add(prog);
74
+ } else if (c.url) {
75
+ row.transports.add(c.type === 'sse' ? 'sse' : 'http');
76
+ try { row.endpoints.add(new URL(c.url).hostname); } catch { /* no host to report */ }
77
+ }
78
+ for (const k of Object.keys(c.env ?? c.environment ?? {})) addEnvName(k, `mcp:${n}`);
79
+ mcp.set(n, row);
80
+ }
81
+
82
+ // ---- shell commands -> program names and env names (never arguments) ----
83
+ await Parser.init();
84
+ const shParser = new Parser();
85
+ shParser.setLanguage(await Language.load(fileURLToPath(import.meta.resolve('tree-sitter-bash/tree-sitter-bash.wasm'))));
86
+
87
+ // A word that is one literal token (no quotes, expansions or concatenation); anything else is not a program name.
88
+ const literal = (n) => {
89
+ const t = n.type === 'command_name' && n.childCount === 1 ? n.child(0) : n;
90
+ return t.type === 'word' || t.type === 'number' ? t.text : '';
91
+ };
92
+ const assignedName = (n) => {
93
+ const target = n.childForFieldName('name');
94
+ return (target?.type === 'subscript' ? target.childForFieldName('name') : target)?.text ?? '';
95
+ };
96
+ // `NAME=v` inside these is not a shell assignment: `command` and `declaration_command` are handled where they are
97
+ // (`export A=1` records nothing), and a C-style for header is arithmetic.
98
+ const NOT_BARE_ASSIGN = new Set(['command', 'declaration_command', 'c_style_for_statement']);
99
+
100
+ // One syntax-tree walk per command: the first literal word of each call is its program; `NAME=v cmd`, `env NAME=v cmd` and
101
+ // `$NAME` each count a use of an env name. A for-loop variable, a bare `NAME=v` and `read NAME` count as
102
+ // `shellAssigned` on the same name: the fact is recorded, whether it is a shell variable or real env is the model's call.
103
+ // Values and other words are never kept.
104
+ function commandFacts(cmd, agent) {
105
+ scanned[agent].commands++;
106
+ const tree = shParser.parse(cmd);
107
+ if (!tree || tree.rootNode.hasError) { scanned[agent].unparsed++; tree?.delete(); return; }
108
+ const source = `commands:${agent}`;
109
+ const envUse = (n) => addEnvName(n, source, 1);
110
+ const shellAssign = (n) => addEnvName(n, source, 0, 1);
111
+ const visit = (node) => {
112
+ switch (node.type) {
113
+ case 'for_statement': shellAssign(node.childForFieldName('variable')?.text ?? ''); break;
114
+ case 'simple_expansion':
115
+ case 'expansion': {
116
+ const v = node.namedChildren.find((c) => c.type === 'variable_name' || c.type === 'subscript' || c.type === 'special_variable_name');
117
+ envUse((v?.type === 'subscript' ? v.childForFieldName('name') : v)?.text ?? '');
118
+ break;
119
+ }
120
+ case 'variable_assignment': if (!NOT_BARE_ASSIGN.has(node.parent?.type)) shellAssign(assignedName(node)); break;
121
+ case 'command': {
122
+ // `sudo 2>/dev/null cargo build` parses with the words after the redirect target as extra destinations of the
123
+ // redirect: only the first is the file, the rest are more words of this command.
124
+ const afterRedirect = node.parent?.type === 'redirected_statement'
125
+ ? node.parent.childrenForFieldName('redirect').filter((r) => r.type === 'file_redirect').flatMap((r) => r.childrenForFieldName('destination').slice(1))
126
+ : [];
127
+ const words = [node.childForFieldName('name'), ...node.childrenForFieldName('argument'), ...afterRedirect].filter(Boolean).map(literal);
128
+ const record = words.length ? envUse : shellAssign; // `A=1 cmd` sets env for cmd; a bare `A=1` is a shell variable
129
+ for (const c of node.namedChildren) if (c.type === 'variable_assignment') record(assignedName(c));
130
+ for (const w of words) {
131
+ const assign = w.match(/^([A-Za-z_][A-Za-z0-9_]*)=/);
132
+ if (assign) { envUse(assign[1]); continue; }
133
+ if (WRAPPERS.has(w) || w.startsWith('-') || /^\d+$/.test(w)) continue;
134
+ if (w === 'read') for (const r of words.slice(1)) if (!r.startsWith('-')) shellAssign(r);
135
+ const prog = name(basename(w));
136
+ if (prog && !NOT_PROGRAMS.has(prog)) bump(programs, prog, agent);
137
+ break;
138
+ }
139
+ break;
140
+ }
141
+ }
142
+ for (const c of node.namedChildren) visit(c);
143
+ };
144
+ visit(tree.rootNode);
145
+ tree.delete();
146
+ }
147
+
148
+ function fileFacts(filePath, agent) {
149
+ const ext = extname(String(filePath ?? '')).toLowerCase();
150
+ if (EXT.test(ext)) bump(extensions, ext, agent);
151
+ }
152
+
153
+ // ---- repos: collapse worktrees and clones to one origin; fall back to the directory ----
154
+ const NO_ORIGIN = '(no git origin)'; // directory names can be prompt-derived (Codex), so they are counted, never named
155
+ const originCache = new Map();
156
+ function origin(dir) {
157
+ if (originCache.has(dir)) return originCache.get(dir);
158
+ let key = NO_ORIGIN;
159
+ if (existsSync(dir)) {
160
+ try {
161
+ const url = execFileSync('git', ['-C', dir, 'config', '--get', 'remote.origin.url'], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'], env: cleanGitEnv() }).trim();
162
+ key = normalizeOrigin(url) ?? key;
163
+ } catch { /* not a git checkout, or no origin */ }
164
+ }
165
+ originCache.set(dir, key);
166
+ return key;
167
+ }
168
+ const addRepo = (dir, knownOrigin, agent, sessions = 1) => bump(repos, normalizeOrigin(knownOrigin) ?? origin(dir), agent, sessions);
169
+
170
+ // ---- streaming ----
171
+ async function* lines(file) {
172
+ yield* createInterface({ input: createReadStream(file), crlfDelay: Infinity });
173
+ }
174
+ const files = (dir, pattern) => (existsSync(dir) ? globSync(pattern, { cwd: dir }).map((f) => join(dir, f)) : []);
175
+ // A missing file is just absent; a file that exists but won't read or parse is reported in `unreadable`.
176
+ function readConfig(f, parse) {
177
+ if (!existsSync(f)) return null;
178
+ try { return parse(readFileSync(f, 'utf8')); } catch { unreadable.add(tilde(f)); return null; }
179
+ }
180
+ const readJson = (f) => readConfig(f, (text) => {
181
+ const errors = [];
182
+ const value = parseJsonc(text, errors, { allowTrailingComma: true }); // JSONC is a superset of JSON (opencode.jsonc has comments)
183
+ if (errors.length) throw new Error('jsonc');
184
+ return value;
185
+ });
186
+ const readToml = (f) => readConfig(f, parseToml);
187
+ const log = (m) => process.stderr.write(`${m}\n`);
188
+
189
+ // Open a sqlite file read-only for `use`. A database that is missing from an installed agent's directory, or that
190
+ // won't open or query, is reported in `unreadable` instead of silently contributing nothing.
191
+ function withDb(path, use) {
192
+ if (!existsSync(path)) { if (existsSync(dirname(path))) unreadable.add(tilde(path)); return; }
193
+ let d;
194
+ try {
195
+ d = new DatabaseSync(path, { readOnly: true });
196
+ use(d);
197
+ } catch { unreadable.add(tilde(path)); } finally { d?.close(); }
198
+ }
199
+
200
+ // ---- readers: one per agent ----
201
+ async function claudeCode() {
202
+ const agent = 'claude-code';
203
+ const cfgPath = join(HOME, '.claude.json');
204
+ const cfg = readJson(cfgPath) ?? {};
205
+ for (const [n, c] of Object.entries(cfg.mcpServers ?? {})) addMcp(n, agent, '~/.claude.json#mcpServers', c);
206
+ for (const p of Object.values(cfg.projects ?? {})) for (const [n, c] of Object.entries(p.mcpServers ?? {})) addMcp(n, agent, '~/.claude.json#projects.*.mcpServers', c);
207
+ for (const k of Object.keys(readJson(join(HOME, '.claude/settings.json'))?.env ?? {})) addEnvName(k, '~/.claude/settings.json#env');
208
+ const installed = readJson(join(HOME, '.claude/plugins/installed_plugins.json'))?.plugins ?? {};
209
+ const enabled = readJson(join(HOME, '.claude/settings.json'))?.enabledPlugins ?? {};
210
+ for (const [id, installs] of Object.entries(installed)) {
211
+ if (!enabled[id]) continue;
212
+ for (const i of installs) {
213
+ const m = readJson(join(i.installPath, '.mcp.json'));
214
+ for (const [n, c] of Object.entries(m?.mcpServers ?? m ?? {})) addMcp(n, agent, `claude plugin ${id.split('@')[0]}`, c);
215
+ }
216
+ }
217
+ const transcripts = files(join(HOME, '.claude/projects'), '**/*.jsonl');
218
+ scanned[agent].records = transcripts.length;
219
+ for (const f of transcripts) {
220
+ let cwd = null;
221
+ let seen = 0;
222
+ for await (const line of lines(f)) {
223
+ if (!cwd && seen++ < 50) cwd = line.match(/"cwd":"([^"]+)"/)?.[1] ?? null;
224
+ if (!line.includes('"type":"tool_use"')) continue;
225
+ let msg;
226
+ try { msg = JSON.parse(line); } catch { continue; }
227
+ for (const b of msg.message?.content ?? []) {
228
+ if (b.type !== 'tool_use') continue;
229
+ if (b.name === 'Bash') commandFacts(String(b.input?.command ?? ''), agent);
230
+ else if (FILE_TOOLS.has(b.name)) fileFacts(b.input?.file_path, agent);
231
+ }
232
+ }
233
+ if (cwd) addRepo(cwd, null, agent);
234
+ }
235
+ log(`claude-code: ${transcripts.length} transcripts`);
236
+ }
237
+
238
+ async function codex() {
239
+ const agent = 'codex';
240
+ const root = codexHome();
241
+ const cfg = readToml(join(root, 'config.toml')) ?? {};
242
+ for (const [n, c] of Object.entries(cfg.mcp_servers ?? {})) addMcp(n, agent, '~/.codex/config.toml#mcp_servers', c);
243
+ for (const [id, p] of Object.entries(cfg.plugins ?? {})) {
244
+ if (!p.enabled) continue;
245
+ const [pluginName, marketplace] = id.split('@');
246
+ for (const f of files(root, `plugins/cache/${marketplace}/${pluginName}/*/.mcp.json`)) {
247
+ const m = readJson(f);
248
+ for (const [n, c] of Object.entries(m?.mcpServers ?? m ?? {})) addMcp(n, agent, `codex plugin ${pluginName}`, c);
249
+ }
250
+ }
251
+ withDb(codexStateDb(), (d) => {
252
+ for (const r of d.prepare('select cwd, git_origin_url as o, count(*) as n from threads group by cwd, git_origin_url').all()) {
253
+ addRepo(r.cwd, r.o, agent, r.n);
254
+ }
255
+ });
256
+ const rollouts = [...files(root, 'sessions/**/*.jsonl'), ...files(root, 'archived_sessions/**/*.jsonl')];
257
+ scanned[agent].records = rollouts.length;
258
+ for (const f of rollouts) {
259
+ for await (const line of lines(f)) {
260
+ if (!line.includes('"exec_command"')) continue;
261
+ let p;
262
+ try { p = JSON.parse(line).payload; } catch { continue; }
263
+ if (p?.type !== 'function_call' || p.name !== 'exec_command') continue;
264
+ try { commandFacts(String(JSON.parse(p.arguments).cmd ?? ''), agent); } catch { /* arguments not JSON */ }
265
+ }
266
+ }
267
+ log(`codex: ${rollouts.length} rollouts`);
268
+ }
269
+
270
+ function openCode() {
271
+ const agent = 'opencode';
272
+ for (const f of ['opencode.json', 'opencode.jsonc']) {
273
+ for (const [n, c] of Object.entries(readJson(join(HOME, '.config/opencode', f))?.mcp ?? {})) addMcp(n, agent, `~/.config/opencode/${f}#mcp`, c);
274
+ }
275
+ withDb(join(HOME, '.local/share/opencode/opencode.db'), (d) => {
276
+ for (const r of d.prepare('select directory, count(*) as n from session group by directory').all()) addRepo(r.directory, null, agent, r.n);
277
+ const q = d.prepare(`select json_extract(data,'$.tool') as tool, json_extract(data,'$.state.input.command') as cmd,
278
+ json_extract(data,'$.state.input.filePath') as file from part where json_extract(data,'$.type')='tool'`);
279
+ let tools = 0;
280
+ for (const r of q.iterate()) {
281
+ tools++;
282
+ if (r.tool === 'bash') commandFacts(String(r.cmd ?? ''), agent);
283
+ else if (FILE_TOOLS.has(r.tool)) fileFacts(r.file, agent);
284
+ }
285
+ scanned[agent].records = tools;
286
+ log(`opencode: ${tools} tool parts`);
287
+ });
288
+ }
289
+
290
+ // Cursor's chat history is opaque blobs; its MCP config is all that is readable.
291
+ function cursor() {
292
+ for (const [n, c] of Object.entries(readJson(join(HOME, '.cursor/mcp.json'))?.mcpServers ?? {})) addMcp(n, 'cursor', '~/.cursor/mcp.json#mcpServers', c);
293
+ }
294
+
295
+ function claudeDesktop() {
296
+ const f = join(HOME, 'Library/Application Support/Claude/claude_desktop_config.json');
297
+ for (const [n, c] of Object.entries(readJson(f)?.mcpServers ?? {})) addMcp(n, 'claude-desktop', 'claude_desktop_config.json#mcpServers', c);
298
+ }
299
+
300
+ export async function gather() {
301
+ claudeDesktop();
302
+ cursor();
303
+ openCode();
304
+ await Promise.all([claudeCode(), codex()]);
305
+ return {
306
+ scanned,
307
+ unreadable: [...unreadable].sort(),
308
+ programs: rows(programs, 'name'),
309
+ mcpServers: [...mcp].map(([n, r]) => ({ name: n, transport: [...r.transports].sort(), endpoints: [...r.endpoints].sort(), agents: [...r.agents].sort(), sources: [...r.sources].sort() }))
310
+ .sort((a, b) => a.name.localeCompare(b.name)),
311
+ repos: rows(repos, 'origin').map(({ origin: o, count, agents }) => ({ origin: o, sessions: count, agents })),
312
+ languages: rows(extensions, 'ext'),
313
+ envNames: [...envNames].map(([n, r]) => ({ name: n, count: r.count, shellAssigned: r.shellAssigned, sources: [...r.sources].sort() })).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)),
314
+ };
315
+ }
316
+
317
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
318
+ const t = Date.now();
319
+ const facts = await gather();
320
+ process.stdout.write(`${JSON.stringify(facts)}\n`);
321
+ log(`done in ${((Date.now() - t) / 1000).toFixed(1)}s`);
322
+ }
@@ -0,0 +1,14 @@
1
+ // Where Codex keeps its files, for both local-onboarding fact scripts. `CODEX_HOME` moves them all
2
+ // (default ~/.codex); `CODEX_SQLITE_HOME` moves only the state database, whose name carries its schema
3
+ // version (`state_5.sqlite`, bumped when the schema changes), so the newest one is read.
4
+ import { existsSync, readdirSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ export const codexHome = (env = process.env) => env.CODEX_HOME || join(homedir(), '.codex');
9
+
10
+ export function codexStateDb(env = process.env) {
11
+ const dir = env.CODEX_SQLITE_HOME || codexHome(env);
12
+ const versions = existsSync(dir) ? readdirSync(dir).map((f) => f.match(/^state_(\d+)\.sqlite$/)?.[1]).filter(Boolean).map(Number) : [];
13
+ return join(dir, `state_${versions.length ? Math.max(...versions) : 5}.sqlite`);
14
+ }
@@ -0,0 +1,21 @@
1
+ // What both local-onboarding fact scripts need from git: an env without an inherited repo, and a repo's name.
2
+
3
+ // `git bisect run`, hooks and `git rebase -x` export GIT_DIR (and friends) to their children; without
4
+ // this every query would hit that repo (lib/clean-env.mjs, which this package can't import).
5
+ const GIT_REPO_ENV = /^GIT_(DIR|WORK_TREE|INDEX_FILE|COMMON_DIR|PREFIX|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|NAMESPACE|IMPLICIT_WORK_TREE)$/;
6
+ export const cleanGitEnv = (env = process.env) => Object.fromEntries(Object.entries(env).filter(([key]) => !GIT_REPO_ENV.test(key)));
7
+
8
+ // `host/owner/repo` from a remote URL (https://, ssh://, git@host:owner/repo). Built from the parsed host and path only,
9
+ // so userinfo, query and fragment can't survive; a local-path or unparsable origin is not a repo name.
10
+ export function normalizeOrigin(url) {
11
+ const text = String(url ?? '').trim();
12
+ const scp = text.match(/^(?:[\w.-]+@)?([\w.-]+):(?!\/\/)([^?#]+)$/);
13
+ let host;
14
+ let path;
15
+ if (scp) [, host, path] = scp;
16
+ else {
17
+ try { ({ hostname: host, pathname: path } = new URL(text)); } catch { return null; }
18
+ }
19
+ const key = `${host}/${path.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '')}`;
20
+ return /^[\w.-]+\/[\w./-]{1,150}$/.test(key) ? key : null;
21
+ }
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ // Adds the sandbox setup local-onboarding proposed to hosted bots' sandbox.ts, in place:
3
+ // node prepare-edit.mjs proposal.json agents/<bot>/agent/sandbox.ts [...]
4
+ // proposal.json is {"commands": [{"command": "<shell>", "what": "<short name for the error>"}]}, the one source of
5
+ // the steps: they are written as `await run(...)` calls into `prepare`, before its `if (!token) return;`, so every new
6
+ // session's snapshot has them. A step already in the file is not added twice. A sandbox.ts without the scaffold's
7
+ // `run` helper and token line (lib/agent-template.mjs) is refused: edit that one by hand.
8
+ import { readFileSync, writeFileSync } from 'node:fs';
9
+ import { pathToFileURL } from 'node:url';
10
+
11
+ const RUN = /^ const run = async \(command, what\) => \{$/m;
12
+ const ANCHOR = ' if (!token) return;\n';
13
+
14
+ export function addPrepareSteps(source, commands) {
15
+ if (!Array.isArray(commands) || !commands.every((c) => typeof c?.command === 'string' && c.command.trim() && typeof c?.what === 'string' && c.what.trim())) {
16
+ throw new Error('commands must be [{ command, what }] with both non-empty');
17
+ }
18
+ if (!RUN.test(source) || !source.includes(ANCHOR)) throw new Error("this sandbox.ts isn't the scaffold's (no `run` helper or `if (!token) return;`): add the steps by hand");
19
+ const lines = commands.map(({ command, what }) => ` await run(${JSON.stringify(command)}, ${JSON.stringify(what)});\n`).filter((line) => !source.includes(line));
20
+ if (!lines.length) return source;
21
+ const steps = ` // The programs the owner's coding agents use (local-onboarding).\n${lines.join('')}${ANCHOR}`;
22
+ return source.replace(ANCHOR, () => steps);
23
+ }
24
+
25
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
26
+ const [proposal, ...files] = process.argv.slice(2);
27
+ if (!proposal || !files.length) throw new Error('usage: prepare-edit.mjs proposal.json sandbox.ts [...]');
28
+ const { commands } = JSON.parse(readFileSync(proposal, 'utf8'));
29
+ for (const file of files) writeFileSync(file, addPrepareSteps(readFileSync(file, 'utf8'), commands));
30
+ }
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env node
2
+ // Read-only fact gatherer for local onboarding: the user's recent coding-agent sessions (Claude Code,
3
+ // Codex, OpenCode, Cursor) grouped into work-streams by (repo, branch), each with its git state.
4
+ // `node sessions-facts.mjs [--days 14]` prints one JSON document on stdout; progress goes to stderr.
5
+ // Privacy contract: what leaves this file is only the normalized repo name, the branch, last-active
6
+ // times, counts (uncommitted files, unpushed commits, sessions scanned), each checkout's worktree boolean, the open
7
+ // PR's number/url/title, and the agent's own stored session title. Never a prompt, message text, command,
8
+ // a path inside a repo, or the local directory (a stream with no repo is keyed `local-N`). Nothing is
9
+ // written: sqlite opens read-only and every git command is a query.
10
+ import { existsSync, globSync, readFileSync, readdirSync, statSync } from 'node:fs';
11
+ import { execFileSync } from 'node:child_process';
12
+ import { DatabaseSync } from 'node:sqlite';
13
+ import { homedir } from 'node:os';
14
+ import { dirname, join } from 'node:path';
15
+ import { fileURLToPath, pathToFileURL } from 'node:url';
16
+ import { cleanGitEnv, normalizeOrigin } from './git.mjs';
17
+ import { codexHome, codexStateDb } from './codex-home.mjs';
18
+
19
+ const HOME = homedir();
20
+ const AGENTS = ['claude-code', 'codex', 'opencode', 'cursor'];
21
+ const TITLE_MAX = 120;
22
+ const tilde = (p) => (p.startsWith(HOME) ? `~${p.slice(HOME.length)}` : p);
23
+ const log = (m) => process.stderr.write(`${m}\n`);
24
+
25
+ const title = (s) => (typeof s === 'string' && s.trim() ? s.trim().slice(0, TITLE_MAX) : null);
26
+ const iso = (ms) => (Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString() : null);
27
+ const files = (dir, pattern) => (existsSync(dir) ? globSync(pattern, { cwd: dir }).map((f) => join(dir, f)) : []);
28
+ const mtime = (f) => { try { return statSync(f).mtimeMs; } catch { return 0; } };
29
+
30
+ // ---- git: one read-only probe per directory ----
31
+ const rawGit = (dir, args) => execFileSync('git', ['-C', dir, '--no-optional-locks', ...args], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], env: cleanGitEnv() });
32
+ const tryGit = (dir, args) => { try { return rawGit(dir, args).trim(); } catch { return null; } };
33
+ // Changed files, each untracked file counted on its own (not one per new directory). In `-z` records
34
+ // a rename or copy carries its source path as the next record, which is the same file.
35
+ export function countChanged(porcelainZ) {
36
+ const records = porcelainZ.split('\0').filter(Boolean);
37
+ let n = 0;
38
+ for (let i = 0; i < records.length; i++) {
39
+ n++;
40
+ if (/^[RC]/.test(records[i])) i++;
41
+ }
42
+ return n;
43
+ }
44
+ function uncommittedIn(dir) {
45
+ try { return countChanged(rawGit(dir, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])); } catch { return null; }
46
+ }
47
+ let ghBroken = false;
48
+ function openPr(repo, branch, unreadable) {
49
+ if (!repo?.startsWith('github.com/') || !branch || ghBroken) return null;
50
+ try {
51
+ const out = execFileSync('gh', ['pr', 'list', '--head', branch, '--repo', repo.slice('github.com/'.length), '--state', 'open', '--json', 'number,url,title', '--limit', '1'], { encoding: 'utf8', timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'] });
52
+ const pr = JSON.parse(out)[0];
53
+ return pr ? { number: pr.number, url: pr.url, title: title(pr.title) } : null;
54
+ } catch {
55
+ ghBroken = true;
56
+ unreadable.add('gh pr list');
57
+ return null;
58
+ }
59
+ }
60
+ // A checkout's state; `repo` and `branch` name its stream, the rest is the checkout's own.
61
+ function gitState(dir) {
62
+ if (!existsSync(dir)) return { gone: true };
63
+ if (tryGit(dir, ['rev-parse', '--is-inside-work-tree']) !== 'true') return { repo: null, branch: null };
64
+ const repo = normalizeOrigin(tryGit(dir, ['config', '--get', 'remote.origin.url']));
65
+ const branch = tryGit(dir, ['symbolic-ref', '--short', '-q', 'HEAD']);
66
+ const [gitDir, commonDir] = (tryGit(dir, ['rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir']) ?? '').split('\n');
67
+ const worktree = Boolean(gitDir && commonDir) && gitDir !== commonDir;
68
+ const uncommitted = uncommittedIn(dir);
69
+ let aheadOf = tryGit(dir, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']) ? 'upstream' : null;
70
+ if (!aheadOf) {
71
+ const head = tryGit(dir, ['symbolic-ref', '-q', '--short', 'refs/remotes/origin/HEAD']);
72
+ if (head) aheadOf = head;
73
+ }
74
+ const count = aheadOf && tryGit(dir, ['rev-list', '--count', `${aheadOf === 'upstream' ? '@{upstream}' : aheadOf}..HEAD`]);
75
+ const unpushed = count === null || count === undefined ? null : Number(count);
76
+ return { repo, branch, checkout: { worktree, uncommitted, unpushed, aheadOf } };
77
+ }
78
+
79
+ // ---- readers: one per agent, each yielding {agent, dir, title, lastActive(ms)} ----
80
+ function withDb(path, unreadable, use) {
81
+ if (!existsSync(path)) { if (existsSync(dirname(path))) unreadable.add(tilde(path)); return; }
82
+ let d;
83
+ try {
84
+ d = new DatabaseSync(path, { readOnly: true });
85
+ use(d);
86
+ } catch { unreadable.add(tilde(path)); } finally { d?.close(); }
87
+ }
88
+ const readJson = (f, unreadable) => {
89
+ if (!existsSync(f)) return null;
90
+ try { return JSON.parse(readFileSync(f, 'utf8')); } catch { unreadable.add(tilde(f)); return null; }
91
+ };
92
+
93
+ function* claudeCode(since, scanned, unreadable) {
94
+ const root = join(HOME, '.claude/projects');
95
+ for (const f of files(root, '**/*.jsonl')) {
96
+ if (f.includes('/subagents/')) continue;
97
+ scanned['claude-code']++;
98
+ if (mtime(f) < since) continue;
99
+ let dir = null;
100
+ let last = 0;
101
+ let custom = null;
102
+ let ai = null;
103
+ let summary = null;
104
+ let text;
105
+ // A project folder's name is the session's directory, so only the root is named.
106
+ try { text = readFileSync(f, 'utf8'); } catch { unreadable.add(tilde(root)); continue; }
107
+ for (const line of text.split('\n')) {
108
+ if (!line) continue;
109
+ let r;
110
+ try { r = JSON.parse(line); } catch { continue; }
111
+ if (!dir && typeof r.cwd === 'string') dir = r.cwd;
112
+ const t = Date.parse(r.timestamp);
113
+ if (t > last) last = t;
114
+ if (r.type === 'custom-title') custom = r.customTitle;
115
+ else if (r.type === 'ai-title') ai = r.aiTitle;
116
+ else if (r.type === 'summary') summary = r.summary;
117
+ }
118
+ last ||= mtime(f);
119
+ if (dir && last >= since) yield { agent: 'claude-code', dir, title: title(custom ?? ai ?? summary), lastActive: last };
120
+ }
121
+ }
122
+
123
+ function* codex(since, scanned, unreadable) {
124
+ const root = codexHome();
125
+ const seen = new Set();
126
+ const rows = [];
127
+ withDb(codexStateDb(), unreadable, (d) => {
128
+ for (const r of d.prepare('select * from threads').all()) {
129
+ if (r.archived || r.thread_source === 'subagent') continue;
130
+ const ms = r.updated_at_ms ?? (r.updated_at > 1e12 ? r.updated_at : r.updated_at * 1000);
131
+ // `title` is Codex's generated thread name; when it only mirrors the first prompt it is not a title.
132
+ const t = r.title && r.title !== r.first_user_message ? title(r.title) : null;
133
+ rows.push({ id: r.id, agent: 'codex', dir: r.cwd, title: t, lastActive: ms });
134
+ }
135
+ });
136
+ for (const r of rows) { seen.add(r.id); scanned.codex++; if (r.lastActive >= since) yield r; }
137
+ // Rollouts not indexed in the state db (older Codex, or a db that wouldn't open). First line is session_meta.
138
+ for (const f of files(root, 'sessions/**/*.jsonl')) {
139
+ const id = f.match(/rollout-.*-([0-9a-f-]{36})\.jsonl$/)?.[1];
140
+ if (id && seen.has(id)) continue;
141
+ scanned.codex++;
142
+ if (mtime(f) < since) continue;
143
+ let meta;
144
+ try { meta = JSON.parse(readFileSync(f, 'utf8').split('\n', 1)[0]); } catch { unreadable.add(tilde(f)); continue; }
145
+ if (meta?.type !== 'session_meta' || typeof meta.payload?.cwd !== 'string') continue;
146
+ yield { agent: 'codex', dir: meta.payload.cwd, title: null, lastActive: mtime(f) };
147
+ }
148
+ }
149
+
150
+ function* openCode(since, scanned, unreadable) {
151
+ const rows = [];
152
+ withDb(join(HOME, '.local/share/opencode/opencode.db'), unreadable, (d) => {
153
+ for (const r of d.prepare('select directory, title, time_updated from session where parent_id is null and time_archived is null').all()) {
154
+ rows.push({ agent: 'opencode', dir: r.directory, title: title(r.title), lastActive: r.time_updated });
155
+ }
156
+ });
157
+ for (const r of rows) { scanned.opencode++; if (r.lastActive >= since) yield r; }
158
+ }
159
+
160
+ const CURSOR_USER_DIRS = ['Library/Application Support/Cursor/User', '.config/Cursor/User'];
161
+ function* cursor(since, scanned, unreadable) {
162
+ // cursor-agent CLI: one dir per chat with meta.json (cwd, times) and store.db (meta row "0": hex JSON with `name`).
163
+ for (const metaFile of files(join(HOME, '.cursor/chats'), '*/*/meta.json')) {
164
+ scanned.cursor++;
165
+ const meta = readJson(metaFile, unreadable);
166
+ if (!meta || meta.isSubagent || typeof meta.cwd !== 'string') continue;
167
+ const last = meta.updatedAtMs ?? meta.createdAtMs ?? mtime(metaFile);
168
+ if (last < since) continue;
169
+ let name = title(meta.title);
170
+ if (!name) {
171
+ withDb(join(dirname(metaFile), 'store.db'), unreadable, (d) => {
172
+ const v = d.prepare("select value from meta where key = '0'").get()?.value;
173
+ if (v) name = title(JSON.parse(Buffer.from(String(v), 'hex').toString('utf8')).name);
174
+ });
175
+ }
176
+ yield { agent: 'cursor', dir: meta.cwd, title: name, lastActive: last };
177
+ }
178
+ // Cursor IDE: each workspace dir names its folder; its state.vscdb lists the composers (chats) opened there.
179
+ for (const userDir of CURSOR_USER_DIRS) {
180
+ const storage = join(HOME, userDir, 'workspaceStorage');
181
+ if (!existsSync(storage)) continue;
182
+ for (const ws of readdirSync(storage)) {
183
+ const folder = readJson(join(storage, ws, 'workspace.json'), unreadable)?.folder;
184
+ if (typeof folder !== 'string' || !folder.startsWith('file://')) continue;
185
+ let dir;
186
+ try { dir = fileURLToPath(folder); } catch { continue; }
187
+ const composers = [];
188
+ withDb(join(storage, ws, 'state.vscdb'), unreadable, (d) => {
189
+ const v = d.prepare("select value from ItemTable where key = 'composer.composerData'").get()?.value;
190
+ if (v) composers.push(...(JSON.parse(String(v)).allComposers ?? []));
191
+ });
192
+ for (const c of composers) {
193
+ scanned.cursor++;
194
+ const last = c.lastUpdatedAt ?? c.createdAt;
195
+ if (last >= since) yield { agent: 'cursor', dir, title: title(c.name), lastActive: last };
196
+ }
197
+ }
198
+ }
199
+ }
200
+
201
+ // ---- gather: sessions -> work-streams keyed by (repo, branch), or an opaque local index ----
202
+ // A stream keeps one entry per checkout it was worked in (two clones or worktrees can share a branch),
203
+ // in order of first use, and each session names its checkout by index: no directory leaves the file.
204
+ export function gather({ days = 14 } = {}) {
205
+ const since = Date.now() - days * 86400000;
206
+ const scanned = Object.fromEntries(AGENTS.map((a) => [a, 0]));
207
+ const unreadable = new Set();
208
+ const states = new Map();
209
+ const streams = new Map();
210
+ let locals = 0;
211
+ // A checkout is its work tree's root, so sessions in its subdirectories share it.
212
+ const roots = new Map();
213
+ const rootOf = (dir) => {
214
+ if (!roots.has(dir)) roots.set(dir, existsSync(dir) ? tryGit(dir, ['rev-parse', '--show-toplevel']) || dir : dir);
215
+ return roots.get(dir);
216
+ };
217
+ const stateOf = (root) => {
218
+ if (!states.has(root)) states.set(root, gitState(root));
219
+ return states.get(root);
220
+ };
221
+ for (const reader of [claudeCode, codex, openCode, cursor]) {
222
+ for (const s of reader(since, scanned, unreadable)) {
223
+ const root = rootOf(s.dir);
224
+ const state = stateOf(root);
225
+ const key = state.repo && state.branch ? `${state.repo}#${state.branch}` : `dir:${root}`;
226
+ if (!streams.has(key)) {
227
+ const local = !(state.repo && state.branch);
228
+ const head = state.gone ? { gone: true } : { repo: state.repo, branch: state.branch, pr: openPr(state.repo, state.branch, unreadable), checkouts: [] };
229
+ streams.set(key, { key: local ? `local-${++locals}` : key, ...head, dirs: [], lastActive: 0, sessions: [] });
230
+ }
231
+ const stream = streams.get(key);
232
+ let checkout;
233
+ if (state.checkout) {
234
+ checkout = stream.dirs.indexOf(root);
235
+ if (checkout < 0) {
236
+ checkout = stream.dirs.push(root) - 1;
237
+ stream.checkouts.push(state.checkout);
238
+ }
239
+ }
240
+ stream.sessions.push({ agent: s.agent, title: s.title, lastActive: iso(s.lastActive), ...(checkout === undefined ? {} : { checkout }) });
241
+ if (s.lastActive > stream.lastActive) stream.lastActive = s.lastActive;
242
+ }
243
+ log(`${reader.name}: done`);
244
+ }
245
+ const out = [...streams.values()].sort((a, b) => b.lastActive - a.lastActive).map(({ dirs, ...s }) => ({
246
+ ...s,
247
+ lastActive: iso(s.lastActive),
248
+ sessions: s.sessions.sort((a, b) => String(b.lastActive).localeCompare(String(a.lastActive))),
249
+ }));
250
+ return { days, generatedAt: new Date().toISOString(), scanned, unreadable: [...unreadable].sort(), streams: out };
251
+ }
252
+
253
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
254
+ const i = process.argv.indexOf('--days');
255
+ const facts = gather({ days: i > 0 ? Number(process.argv[i + 1]) : 14 });
256
+ process.stdout.write(`${JSON.stringify(facts)}\n`);
257
+ log(`${facts.streams.length} streams`);
258
+ }
@@ -1,6 +1,7 @@
1
1
  // A bot's system prompt, rendered from the kit's templates. Every section is wrapped in its own
2
2
  // tag, in this order: identity, team, execution, engineering, what_reaches_you, reaching_others,
3
- // writing, role, execution_context (the last one is execution-context.mjs). eve joins a bot's
3
+ // writing, role, execution_context (the last one is execution-context.mjs); main's leaves out
4
+ // engineering, since main never builds. eve joins a bot's
4
5
  // instruction sources with a blank line, flat instructions.{md,ts} first, then instructions/*
5
6
  // by file name, so the order is made in code: one source renders identity, the shared sections
6
7
  // and role together (botInstructions, mainInstructions), and instructions/execution-context.ts
@@ -51,8 +52,8 @@ export function splitRole(text, source = "role file") {
51
52
 
52
53
  // What eve joins: each source's content with a blank line between, and trims only the whole. So a
53
54
  // source ends at its closing tag, with no newline of its own.
54
- const assemble = ({ identity, role }, additions, options) =>
55
- [section("identity", identity), workspaceProtocol(options), section("role", [role, ...additions].join("\n\n"))].join("\n\n");
55
+ const assemble = ({ identity, role }, additions, shared, options) =>
56
+ [section("identity", identity), sections(shared, options), section("role", [role, ...additions].join("\n\n"))].join("\n\n");
56
57
 
57
58
  // A bot's own additions to its role: agents/<bot>/agent/role/*.md, by file name, inside <role>.
58
59
  // They can't sit in instructions/*.md: eve loads that folder itself, after execution-context.ts,
@@ -70,13 +71,15 @@ function additionsOf(bot, options) {
70
71
  return readdirSync(dir).filter((f) => f.endsWith(".md")).sort().map((f) => readFileSync(join(dir, f), "utf8").trim());
71
72
  }
72
73
 
73
- export const mainInstructions = (options) => assemble(splitRole(template(HEAD, options), HEAD), additionsOf("main", options), options);
74
+ // Main never builds, so it goes without <engineering>.
75
+ const MAIN_SHARED = SHARED.filter(([tag]) => tag !== ENGINEERING.slice(0, -3));
76
+ export const mainInstructions = (options) => assemble(splitRole(template(HEAD, options), HEAD), additionsOf("main", options), MAIN_SHARED, options);
74
77
 
75
78
  // Every other bot: agents/<bot>/agent/role.md in the workspace.
76
79
  export { ROLE_DIR, ROLE_FILE };
77
80
  export function botInstructions(bot, options) {
78
81
  const file = join(findWorkspaceRoot(options), "agents", bot, "agent", ROLE_FILE);
79
- return assemble(splitRole(readFileSync(file, "utf8"), file), additionsOf(bot, options), options);
82
+ return assemble(splitRole(readFileSync(file, "utf8"), file), additionsOf(bot, options), SHARED, options);
80
83
  }
81
84
 
82
85
  // The engineering section alone, for a subagent that doesn't carry the shared sections
@@ -85,4 +88,5 @@ export const engineeringPage = (options) => `${section("engineering", template(E
85
88
 
86
89
  // The shared sections, with no identity or role: what a bot whose instructions.md still holds
87
90
  // both gets appended (instructions/protocol.ts).
88
- export const workspaceProtocol = (options) => SHARED.map(([tag, file]) => section(tag, template(file, options))).join("\n\n");
91
+ const sections = (shared, options) => shared.map(([tag, file]) => section(tag, template(file, options))).join("\n\n");
92
+ export const workspaceProtocol = (options) => sections(SHARED, options);
@@ -8,6 +8,12 @@ license: MIT
8
8
 
9
9
  Two scripts gather facts (GitHub in your sandbox, Slack inside Kit); you write the profile and the schedule drafts from those facts alone. Nothing is posted to Slack and nothing is committed; the only write is the pull request in step 6, which the user approves by merging. The user sees one short message from you at the end (step 7), not one per step. If a step cannot finish, that message says which one and what the user must do; never write the profile as if every source had been read.
10
10
 
11
+ ## 0. Hand the Mac half to the bot on the Mac
12
+
13
+ You run on Vercel and can't see the user's Mac. If `list_agents` shows a bot that runs `local`, post one message in this thread before step 1 that mentions it (the first one, if there are several) and asks it to run its local-onboarding skill. Name the workspace repo for its sandbox PR, or say there is none. Then go on with step 1 without waiting: its half arrives in this thread as its own posts.
14
+
15
+ With no bot that runs `local`, skip this step and say nothing about a Mac.
16
+
11
17
  ## 1. Ask for Kit access, once
12
18
 
13
19
  Kit's tools are `request_access`, `request_status` and `execute`. Hosted, you reach them through the `vercel-kit` connection: `connection_execute({ connection: "vercel-kit", tool: "request_access", input: { ... } })`, and the same shape for the others. Everywhere below, "call `request_access`" means that.
@@ -119,6 +125,7 @@ Fix every line it prints and rerun until it prints nothing, then post the file's
119
125
  - Below it, the two tables, then the lines under them and nothing else:
120
126
  - The PR line. Without a repo, it says the drafts stay in the thread. If the PR's checks still fail after step 6, it says so and what failed, in place of "merging it turns them on".
121
127
  - One line per source you could not read or Kit grant the user declined: what was not read and what they must do.
128
+ - If you handed the Mac half over in step 0 and that bot hasn't posted yet, one line: its half hasn't reported yet, and if the Hub app is closed on their Mac, it runs once they open it.
122
129
  - Nothing else goes in: not the profile, the "Could not tell" section, counts, windows, or how the run went. Keep `profile.md`, and post it, or the part asked for, only when the user asks.
123
130
 
124
131
  Only the schedule files ever leave the thread.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eve-hub",
3
- "version": "0.13.5",
3
+ "version": "0.13.7",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Bot-side code shared by every hub agent: registry tools, the roster hook, the eve channel, the host sandbox and browser, and model helpers.",
6
6
  "type": "module",
@@ -39,6 +39,10 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "ai": "^7.0.137",
42
- "jose": "^6.2.3"
42
+ "jose": "^6.2.3",
43
+ "jsonc-parser": "3.3.1",
44
+ "smol-toml": "^1.9.1",
45
+ "tree-sitter-bash": "0.25.1",
46
+ "web-tree-sitter": "0.27.1"
43
47
  }
44
48
  }
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ // Generated by scripts/build-onboarding.mjs from local-onboarding/SKILL.md. Do not edit.
2
+ export default {"description":"The Mac half of onboarding, when main hands it to you: find the owner's live coding-agent work (Claude Code, Codex, OpenCode, Cursor) to port into threads, and propose the sandbox setup their hosted bots need. Reads only; the threads and the sandbox PR wait for the owner's yes.","markdown":"# The Mac half of onboarding\n\nMain runs onboarding on Vercel, where it can't see this Mac, so it hands you this half and posts its own without waiting. You post two short messages in that thread, the live work first, then the sandbox setup. Follow your writing rules: each first bubble is at most two sentences, with tables below a `---` line.\n\nTwo scripts gather the facts. They live in `{{scripts}}`, read only, and print metadata: repos, branches, counts, times, the agents' own session titles and program names. Work only from what they print. Don't open the agents' session files yourself: they hold the owner's prompts and code. Delete the JSON files when you are done.\n\n## 1. Live work to port\n\n```sh\nnode {{scripts}}/sessions-facts.mjs > \"$TMPDIR/sessions.json\"\n```\n\nThe script covers the last 14 days of sessions, grouped into work-streams by repo and branch, newest first. Each stream has:\n- `repo`, `branch` and `pr` (the open PR);\n- `checkouts`: one per clone or worktree it was worked in. Each has `worktree` (true for a linked git worktree), `uncommitted` (changed files) and `unpushed` (commits ahead of `aheadOf`);\n- `lastActive`;\n- `sessions`: each with its agent, title, time and `checkout` (its index in `checkouts`).\n\nA stream with no remote is keyed `local-N`. A stream marked `gone` is a folder that no longer exists. `unreadable` lists the sources that exist but couldn't be read. An `uncommitted` or `unpushed` of `null` means the count couldn't be determined (git timed out on a huge untracked tree, or there is no upstream or default branch to compare against): it is unknown, not zero.\n\nPost the streams that have work in flight: any checkout with uncommitted files or unpushed commits, or an open PR. A checkout with an unknown count gets a row too, with \"unknown\" in that cell. A stream with more than one checkout gets one row per checkout that has work. Put the rest in one line with their count.\n\n```md\nYou have 3 work-streams in flight on this Mac. Say which to port, and main will open a thread for each so the team can pick it up.\n\n---\n\n| Stream | Agent and session | Last active | Uncommitted | Unpushed | PR |\n|---|---|---|---|---|---|\n| `vercel/app` on `fix-login` (worktree) | Claude Code, \"Fix login redirect\" | 2h ago | 4 files | 2 | [#812](...) |\n\nAlso 5 streams with nothing in flight.\n```\n\nNever post a local path, a file name, a prompt, or anything else from the sessions.\n\nWhen the owner says which streams to port, you still write nothing on the Mac. Post one message that mentions main and asks it to open one thread per stream. For each stream, give a short title, the repo and branch, its state (worktree, uncommitted and unpushed counts, PR), and which agent had it. You don't open threads yourself.\n\n## 2. Sandbox setup\n\n```sh\nnode {{scripts}}/agent-usage-facts.mjs > \"$TMPDIR/agent-usage.json\"\n```\n\nIt takes about 30 seconds on 16 GB of history. It prints names and counts, never a command's arguments, a prompt or a value:\n- `scanned`: records, commands and unparsable commands per agent;\n- `unreadable`;\n- `programs`, `mcpServers`, `repos`, `languages` (file extensions) and `envNames`.\n\nMapping each fact to a setup is your judgment, not a lookup table. Decide, for every program in the top 30:\n\n- **Hosted or Mac.** These can't run in a Linux sandbox, so they stay on the Mac with you:\n - `brew`, `osascript` and `open`;\n - Xcode, `xcodebuild` and Apple-platform `swift`;\n - anything that drives the owner's browser or apps;\n - `localhost` services and the keychain.\n\n Everything else can be hosted.\n- **Already there, or add it.** A hosted bot's sandbox is eve's Ubuntu image with git only, plus what its `prepare` in `agents/<bot>/agent/sandbox.ts` installs. Read `prepare` in the workspace repo's sandbox files. A program it installs is already provided; any other hosted program needs a `prepare` step. Use `apt-get` where Ubuntu has the package, and the project's official installer otherwise.\n\nMCP servers are reached from hosted bots through Vercel Kit connections: list each one by name for main to connect. Env var names are listed for the owner only. Never print a value (you don't have any). Ignore shell and system names (`PATH`, `HOME`, `CI`, ...). A name with a high `shellAssigned` relative to `count` is probably a shell variable, not part of the environment.\n\nPost the proposal:\n- a first bubble on what the hosted bots would gain;\n- a table of the steps to add, with the programs each installs and how often the owner used them;\n- one line for what stays on the Mac;\n- one line for the MCP servers to connect through Kit.\n\nAsk whether to open the PR.\n\n## 3. The sandbox PR, after the owner's yes\n\nThe steps live in one file, which is the only source; never hand-write them into `sandbox.ts`. Write `$TMPDIR/prepare.json` as `{\"commands\": [{\"command\": \"<shell>\", \"what\": \"<short name for the error>\"}]}`.\n\nMain's handoff names the workspace repo. Clone it into a scratch folder, branch, and add the steps to each hosted bot that runs code. That is the builder, unless the owner said otherwise.\n\n```sh\nnode {{scripts}}/prepare-edit.mjs \"$TMPDIR/prepare.json\" agents/builder/agent/sandbox.ts\n```\n\nCommit, push and open the PR with `gh pr create`. The body lists the programs, why each one is there, and the usage counts behind them. Never name the owner's other repos.\n\nThe PR's Vercel build runs `prepare`, so a step that fails, fails the build. Wait for its result with `gh pr checks --watch --fail-fast`. If it fails, fix the step in `prepare.json`, restore the file from the branch you cut from (`git checkout origin/HEAD -- <file>`), rerun the script and push. Post the PR link and its build result in the thread, then delete the scratch clone.\n"};
@@ -0,0 +1,9 @@
1
+ import { dirname, join } from "node:path";
2
+ import { defineSkill } from "eve/skills";
3
+ import { templatesDir } from "../main-instructions.mjs";
4
+ import skill from "./local-onboarding.gen.mjs";
5
+ // The local-onboarding skill: local-onboarding/SKILL.md, embedded at build time
6
+ // (scripts/build-onboarding.mjs). Its scripts run from the installed kit, beside
7
+ // the dependencies agent-usage-facts.mjs imports.
8
+ const scripts = join(dirname(templatesDir()), "local-onboarding");
9
+ export default defineSkill({ ...skill, markdown: skill.markdown.replaceAll("{{scripts}}", scripts) });
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/build-onboarding.mjs from onboarding/SKILL.md. Do not edit.
2
- export default {"description":"Right after sign-in: ask for the Kit access you need, then work out who the user is and what to focus on from their recent Slack and GitHub, and draft schedules that keep them on track. Reads only; the one write is a pull request the user approves by merging.","markdown":"# Profile the user from Slack and GitHub\n\nTwo scripts gather facts (GitHub in your sandbox, Slack inside Kit); you write the profile and the schedule drafts from those facts alone. Nothing is posted to Slack and nothing is committed; the only write is the pull request in step 6, which the user approves by merging. The user sees one short message from you at the end (step 7), not one per step. If a step cannot finish, that message says which one and what the user must do; never write the profile as if every source had been read.\n\n## 1. Ask for Kit access, once\n\nKit's tools are `request_access`, `request_status` and `execute`. Hosted, you reach them through the `vercel-kit` connection: `connection_execute({ connection: \"vercel-kit\", tool: \"request_access\", input: { ... } })`, and the same shape for the others. Everywhere below, \"call `request_access`\" means that.\n\nSlack goes through the user's Kit connection. Before any fetch, ask for everything the run reads in one request:\n\n```\nrequest_access({ service: \"slack\", reads: true, reason: \"Read your recent channel messages, mentions and threads to write your onboarding profile\" })\n```\n\n- `approved`: go on.\n- `pending` (or `connection_required`): tell the user, in one sentence, that Slack access is waiting, with the `approveUrl`; then `request_status({ request })` until they decide. Do not start step 3 before it is approved. If they decline, the final message says the Slack half was not read.\n\nGitHub does not use Kit: it uses `gh` in your sandbox (step 2). If `gh auth status` fails, the final message says GitHub was not read.\n\n## 2. GitHub facts\n\nCall the `onboarding_scripts` tool once: it writes the skill's scripts into your sandbox at `/tmp/onboarding-skill/` and returns that directory.\n\n```sh\nnode /tmp/onboarding-skill/github-facts.mjs > /tmp/onboarding-skill/github-facts.json\n```\n\nIt prints one JSON document: `counts` (merged PRs, open PRs, review requests, assigned issues, mentions never replied to, PRs reviewed), `repos` (merged, reviewed, open and lines changed per repo), item lists (`merged`, `open`, `reviewRequested`, `assignedIssues`, `unansweredMentions`; `total` is the full count, `items` the newest 150), `teams`, and `codeowners` lines naming the user or their teams. Open PRs carry `draft`, `reviewDecision`, `mergeable` and `checks`. `author:@me` includes PRs the user's bots open under their login. `unreadable` lists a source that could not be read.\n\n## 3. Slack facts\n\nRun the script in \"The Slack script\" below with Kit's `execute`: pass it as the script, unchanged. It runs inside Kit, so no raw message reaches you; it returns one JSON document in about 10 seconds.\n\n- A `ratelimited` error means Slack's search limit (about 20 a minute; the script makes at most 19). Wait 70 seconds (`sleep 70`) and run it again unchanged, up to five times.\n- A `suspended` result whose output says `consent_required` is a pause, not an error: tell the user in one sentence that the Slack read is waiting, with its `approveUrl`; `request_status({ request })` until they decide; then `execute({ run })` continues the same run (settled steps are not repeated).\n- An `output` with `refused` means Slack answered `missing_scope` (or another refusal) to a call, so no facts were read: `refused.needed` names the scope the user's Slack connection lacks (`refused.provided` what it has). Do not continue. Call `request_access({ service: \"slack\", capability: \"search_messages\", reason })`: if it returns an `approveUrl`, give it to the user; otherwise tell them to reconnect Slack in Kit allowing `refused.needed`. The final message says the Slack half was not read and names the scope.\n- Any other error goes to the user in the final message. Never continue with fewer facts, and never retry around a refusal.\n- Save the run's `output` as `/tmp/onboarding-skill/slack-facts.json` (the checker reads it).\n- `window`: the last 30 days. Slack search allows 15 pages of 100 here, so a very active account is covered only back to `own.oldest` (`newestOnly: true`); state that window in the profile.\n- `own`: messages per channel, thread replies, recent snippets. `mentions`: `replied` means the user posted later in the same thread; `unanswered` means they did not. A mention older than what was read with no reply among it (`notJudged`) is unknown, not unanswered. `mentions.capped` means the search found more mentions (`searchTotal`) than the three pages read: say so in \"Could not tell\". Reactions are not read (the Slack grant has no `reactions:read`): say so in \"Could not tell\". `memberships`: channels the user is in.\n- DMs and group DMs are never searched. Every list item carries a permalink (`url`); a snippet that looked like a secret is `[withheld]`.\n\n## 4. Write the profile and drafts\n\nWrite the profile to `/tmp/onboarding-skill/profile.md`. It names the user's repos and colleagues, so it is posted only in this thread and never committed.\n\nAnswer, in the user's terms:\n\n- What they have shipped, what they own, what they are responsible for.\n- What people tag them for, and what they write about.\n- What they have missed: unanswered mentions, requested reviews, stale or conflicted PRs.\n- Which open GitHub work-streams to close (stale, conflicted, superseded) and which to focus on.\n\nRules:\n\n- Judge; the scripts only counted. No scoring formula and no thresholds from code: say why in a sentence.\n- Every list item cites at least one permalink that appears in the facts, and no URL appears that isn't in the facts. State counts as the facts give them, with the window.\n- Don't quote messages beyond the snippets, and don't name people beyond what a claim needs.\n- Add a \"Could not tell\" section: every `unreadable` source, `notJudged` mentions, that reactions were not read, the Slack window, a Kit grant the user declined.\n\nFor each recurring need you found (a morning pass over unanswered mentions, a weekly stale-PR pass), write one draft to `/tmp/onboarding-skill/schedules/<bot>/<id>.md` for an existing bot of your workspace:\n\n```md\n---\ncron: \"0 9 * * 1-5\"\n---\n\nThe prompt the bot runs.\n```\n\n`cron` is five fields in UTC on a hosted deploy. Each draft says in its prompt which facts it reads and what it reports.\n\n## 5. Check\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --profile /tmp/onboarding-skill/profile.md --facts /tmp/onboarding-skill/github-facts.json /tmp/onboarding-skill/slack-facts.json\n```\n\nFix every line it prints and rerun until it prints nothing. Then delete the two facts files.\n\n## 6. Open the pull request, if the workspace has a repo\n\nWhen the workspace is backed by a GitHub repo, open one PR on it that adds only the schedule drafts, as `agents/<bot>/agent/schedules/<id>.md`, and nothing from the profile (`gh pr create`). The user merging it is their approval; the deploy that follows is what makes the schedules run. The PR's checks include the deploy's build, which rejects a bad cron or a bot that does not exist: watch them (`gh pr checks`), fix the draft and push again until they pass, and say in the message if they do not. Without a repo the drafts stay in the thread and the message says so.\n\n## 7. Post one short message to the user\n\nThe user reads it cold, often only its first line (your writing rules). Write it to `/tmp/onboarding-skill/post.md` in this shape, load the forms skill for the tables, and check it:\n\n```md\nOne line on who they are and what they own, then the one thing to do first.\n\n---\n\n| Act on first | Why | Link |\n|---|---|---|\n(three rows, each linking a permalink from the facts)\n\n| Schedule | Bot | When | What it reports |\n|---|---|---|---|\n(one row per draft)\n\nThe drafts are in [this PR](...): merging it turns them on.\n(one line per source not read or grant declined: what was not read and what to do)\n```\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --post /tmp/onboarding-skill/post.md\n```\n\nFix every line it prints and rerun until it prints nothing, then post the file's text in this thread.\n\n- The first bubble, everything above `---`, is at most two sentences.\n- Below it, the two tables, then the lines under them and nothing else:\n - The PR line. Without a repo, it says the drafts stay in the thread. If the PR's checks still fail after step 6, it says so and what failed, in place of \"merging it turns them on\".\n - One line per source you could not read or Kit grant the user declined: what was not read and what they must do.\n- Nothing else goes in: not the profile, the \"Could not tell\" section, counts, windows, or how the run went. Keep `profile.md`, and post it, or the part asked for, only when the user asks.\n\nOnly the schedule files ever leave the thread.\n\n## The Slack script\n\n```js\n// Facts about the signed-in user's recent Slack channel activity. Counts, permalinks and short snippets only.\n// Searches exclude DMs and group DMs (-is:dm) so none is ever read. Slack allows about 20 searches a minute: this\n// script makes at most 19, so on a ratelimited error wait a minute and run it again unchanged.\n// The .slice(0, n) before each .map is not redundant: callscript counts worst-case calls from it (200 max).\nconst DAYS = 30;\nconst after = new Date(Date.now() - (DAYS + 1) * 86400000).slice(0, 10);\nconst auth = await slack.auth_test();\nconst me = auth.user_id;\nconst ownPages = await Promise.all([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].slice(0, 15).map(page => slack.search_messages({ query: `from:<@${me}> after:${after} -is:dm`, count: 100, page, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst mentionPages = await Promise.all([1, 2, 3].slice(0, 3).map(page => slack.search_messages({ query: `<@${me}> after:${after} -is:dm`, count: 100, page, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst member = await slack.users_conversations({ types: \"public_channel,private_channel\", limit: 200, exclude_archived: true });\nconst refused = [auth, ...ownPages, ...mentionPages, member].filter(r => r.ok === false);\nif (refused.length > 0) {\n return { refused: { error: refused[0].error, needed: refused[0].needed ?? null, provided: refused[0].provided ?? null, calls: refused.length } };\n}\nconst channels = Object.entries(Object.groupBy(ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim), m => m.channel.id)).map(e => ({ id: e[0], channel: e[1][0].channel.name, count: e[1].length, threadReplies: e[1].filter(m => m.permalink.includes(\"thread_ts=\") && !m.permalink.includes(`thread_ts=${m.ts}`)).length, oldest: e[1][e[1].length - 1].ts, recent: e[1].slice(0, 2).map(m => ({ url: m.permalink, ts: m.ts, text: m.text.split(\"\\n\").join(\" \").split(\" \").some(t => t.length >= 20 && [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"].some(p => t.includes(p))) ? \"[withheld]\" : m.text.split(\"\\n\").join(\" \").slice(0, 160) })) })).sort((a, b) => b.count - a.count);\nconst total = channels.reduce((n, c) => n + c.count, 0);\nconst threadReplies = channels.reduce((n, c) => n + c.threadReplies, 0);\nconst mentioned = mentionPages.flatMap(r => r.messages.matches).filter(m => m.user !== me && !m.channel.is_im && !m.channel.is_mpim).map(m => ({ url: m.permalink, ts: m.ts, author: m.username, channel: m.channel.id, name: m.channel.name, thread: m.permalink.includes(\"thread_ts=\") ? m.permalink.split(\"thread_ts=\")[1].split(\"&\")[0] : m.ts, text: m.text.split(\"\\n\").join(\" \").split(\" \").some(t => t.length >= 20 && [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"].some(p => t.includes(p))) ? \"[withheld]\" : m.text.split(\"\\n\").join(\" \").slice(0, 160) }));\nconst ownMessages = ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim);\nconst lastOwn = Object.fromEntries(Object.entries(Object.groupBy(ownMessages, m => `${m.channel.id}:${m.permalink.includes(\"thread_ts=\") ? m.permalink.split(\"thread_ts=\")[1].split(\"&\")[0] : m.ts}`)).map(e => [e[0], e[1].reduce((t, m) => Math.max(t, Number(m.ts)), 0)]));\nconst oldestOwn = channels.length ? channels.reduce((t, c) => Math.min(t, Number(c.oldest)), Number(channels[0].oldest)) : 0;\nconst newestOnly = ownPages[0].messages.paging.pages > ownPages.length;\nconst known = mentioned.map(m => ({ ...m, replied: (lastOwn[`${m.channel}:${m.thread}`] ?? 0) > Number(m.ts) }));\nconst judged = known.filter(m => m.replied || !newestOnly || Number(m.ts) >= oldestOwn);\nconst open = judged.filter(m => !m.replied);\nreturn {\n me: { id: me, name: auth.user },\n window: { days: DAYS, after, searchTotal: ownPages[0].messages.paging.total, pagesTotal: ownPages[0].messages.paging.pages, pagesRead: ownPages.length, newestOnly },\n scope: \"public and private channels; DMs and group DMs are dropped before counting\",\n own: {\n total,\n threadReplies,\n topLevel: total - threadReplies,\n oldest: channels.length ? oldestOwn : null,\n channelCount: channels.length,\n channels: channels.slice(0, 25).map(c => ({ id: c.id, channel: c.channel, count: c.count, threadReplies: c.threadReplies, recent: c.recent }))\n },\n mentions: {\n searchTotal: mentionPages[0].messages.paging.total,\n pagesTotal: mentionPages[0].messages.paging.pages,\n pagesRead: mentionPages.length,\n capped: mentionPages[0].messages.paging.pages > mentionPages.length,\n found: mentioned.length,\n judged: judged.length,\n notJudged: mentioned.length - judged.length,\n replied: judged.length - open.length,\n unanswered: open.length,\n unansweredItems: open.slice(0, 20).map(m => ({ url: m.url, channel: m.name, author: m.author, ts: m.ts, text: m.text })),\n basis: \"replied = the user posted later in the same thread; reactions are not read (the token lacks reactions:read); a mention not yet answered in a thread may have been answered elsewhere\"\n },\n memberships: { count: member.channels.length, names: member.channels.map(c => c.name).sort(), more: Boolean(member.response_metadata?.next_cursor) }\n};\n```\n"};
2
+ export default {"description":"Right after sign-in: ask for the Kit access you need, then work out who the user is and what to focus on from their recent Slack and GitHub, and draft schedules that keep them on track. Reads only; the one write is a pull request the user approves by merging.","markdown":"# Profile the user from Slack and GitHub\n\nTwo scripts gather facts (GitHub in your sandbox, Slack inside Kit); you write the profile and the schedule drafts from those facts alone. Nothing is posted to Slack and nothing is committed; the only write is the pull request in step 6, which the user approves by merging. The user sees one short message from you at the end (step 7), not one per step. If a step cannot finish, that message says which one and what the user must do; never write the profile as if every source had been read.\n\n## 0. Hand the Mac half to the bot on the Mac\n\nYou run on Vercel and can't see the user's Mac. If `list_agents` shows a bot that runs `local`, post one message in this thread before step 1 that mentions it (the first one, if there are several) and asks it to run its local-onboarding skill. Name the workspace repo for its sandbox PR, or say there is none. Then go on with step 1 without waiting: its half arrives in this thread as its own posts.\n\nWith no bot that runs `local`, skip this step and say nothing about a Mac.\n\n## 1. Ask for Kit access, once\n\nKit's tools are `request_access`, `request_status` and `execute`. Hosted, you reach them through the `vercel-kit` connection: `connection_execute({ connection: \"vercel-kit\", tool: \"request_access\", input: { ... } })`, and the same shape for the others. Everywhere below, \"call `request_access`\" means that.\n\nSlack goes through the user's Kit connection. Before any fetch, ask for everything the run reads in one request:\n\n```\nrequest_access({ service: \"slack\", reads: true, reason: \"Read your recent channel messages, mentions and threads to write your onboarding profile\" })\n```\n\n- `approved`: go on.\n- `pending` (or `connection_required`): tell the user, in one sentence, that Slack access is waiting, with the `approveUrl`; then `request_status({ request })` until they decide. Do not start step 3 before it is approved. If they decline, the final message says the Slack half was not read.\n\nGitHub does not use Kit: it uses `gh` in your sandbox (step 2). If `gh auth status` fails, the final message says GitHub was not read.\n\n## 2. GitHub facts\n\nCall the `onboarding_scripts` tool once: it writes the skill's scripts into your sandbox at `/tmp/onboarding-skill/` and returns that directory.\n\n```sh\nnode /tmp/onboarding-skill/github-facts.mjs > /tmp/onboarding-skill/github-facts.json\n```\n\nIt prints one JSON document: `counts` (merged PRs, open PRs, review requests, assigned issues, mentions never replied to, PRs reviewed), `repos` (merged, reviewed, open and lines changed per repo), item lists (`merged`, `open`, `reviewRequested`, `assignedIssues`, `unansweredMentions`; `total` is the full count, `items` the newest 150), `teams`, and `codeowners` lines naming the user or their teams. Open PRs carry `draft`, `reviewDecision`, `mergeable` and `checks`. `author:@me` includes PRs the user's bots open under their login. `unreadable` lists a source that could not be read.\n\n## 3. Slack facts\n\nRun the script in \"The Slack script\" below with Kit's `execute`: pass it as the script, unchanged. It runs inside Kit, so no raw message reaches you; it returns one JSON document in about 10 seconds.\n\n- A `ratelimited` error means Slack's search limit (about 20 a minute; the script makes at most 19). Wait 70 seconds (`sleep 70`) and run it again unchanged, up to five times.\n- A `suspended` result whose output says `consent_required` is a pause, not an error: tell the user in one sentence that the Slack read is waiting, with its `approveUrl`; `request_status({ request })` until they decide; then `execute({ run })` continues the same run (settled steps are not repeated).\n- An `output` with `refused` means Slack answered `missing_scope` (or another refusal) to a call, so no facts were read: `refused.needed` names the scope the user's Slack connection lacks (`refused.provided` what it has). Do not continue. Call `request_access({ service: \"slack\", capability: \"search_messages\", reason })`: if it returns an `approveUrl`, give it to the user; otherwise tell them to reconnect Slack in Kit allowing `refused.needed`. The final message says the Slack half was not read and names the scope.\n- Any other error goes to the user in the final message. Never continue with fewer facts, and never retry around a refusal.\n- Save the run's `output` as `/tmp/onboarding-skill/slack-facts.json` (the checker reads it).\n- `window`: the last 30 days. Slack search allows 15 pages of 100 here, so a very active account is covered only back to `own.oldest` (`newestOnly: true`); state that window in the profile.\n- `own`: messages per channel, thread replies, recent snippets. `mentions`: `replied` means the user posted later in the same thread; `unanswered` means they did not. A mention older than what was read with no reply among it (`notJudged`) is unknown, not unanswered. `mentions.capped` means the search found more mentions (`searchTotal`) than the three pages read: say so in \"Could not tell\". Reactions are not read (the Slack grant has no `reactions:read`): say so in \"Could not tell\". `memberships`: channels the user is in.\n- DMs and group DMs are never searched. Every list item carries a permalink (`url`); a snippet that looked like a secret is `[withheld]`.\n\n## 4. Write the profile and drafts\n\nWrite the profile to `/tmp/onboarding-skill/profile.md`. It names the user's repos and colleagues, so it is posted only in this thread and never committed.\n\nAnswer, in the user's terms:\n\n- What they have shipped, what they own, what they are responsible for.\n- What people tag them for, and what they write about.\n- What they have missed: unanswered mentions, requested reviews, stale or conflicted PRs.\n- Which open GitHub work-streams to close (stale, conflicted, superseded) and which to focus on.\n\nRules:\n\n- Judge; the scripts only counted. No scoring formula and no thresholds from code: say why in a sentence.\n- Every list item cites at least one permalink that appears in the facts, and no URL appears that isn't in the facts. State counts as the facts give them, with the window.\n- Don't quote messages beyond the snippets, and don't name people beyond what a claim needs.\n- Add a \"Could not tell\" section: every `unreadable` source, `notJudged` mentions, that reactions were not read, the Slack window, a Kit grant the user declined.\n\nFor each recurring need you found (a morning pass over unanswered mentions, a weekly stale-PR pass), write one draft to `/tmp/onboarding-skill/schedules/<bot>/<id>.md` for an existing bot of your workspace:\n\n```md\n---\ncron: \"0 9 * * 1-5\"\n---\n\nThe prompt the bot runs.\n```\n\n`cron` is five fields in UTC on a hosted deploy. Each draft says in its prompt which facts it reads and what it reports.\n\n## 5. Check\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --profile /tmp/onboarding-skill/profile.md --facts /tmp/onboarding-skill/github-facts.json /tmp/onboarding-skill/slack-facts.json\n```\n\nFix every line it prints and rerun until it prints nothing. Then delete the two facts files.\n\n## 6. Open the pull request, if the workspace has a repo\n\nWhen the workspace is backed by a GitHub repo, open one PR on it that adds only the schedule drafts, as `agents/<bot>/agent/schedules/<id>.md`, and nothing from the profile (`gh pr create`). The user merging it is their approval; the deploy that follows is what makes the schedules run. The PR's checks include the deploy's build, which rejects a bad cron or a bot that does not exist: watch them (`gh pr checks`), fix the draft and push again until they pass, and say in the message if they do not. Without a repo the drafts stay in the thread and the message says so.\n\n## 7. Post one short message to the user\n\nThe user reads it cold, often only its first line (your writing rules). Write it to `/tmp/onboarding-skill/post.md` in this shape, load the forms skill for the tables, and check it:\n\n```md\nOne line on who they are and what they own, then the one thing to do first.\n\n---\n\n| Act on first | Why | Link |\n|---|---|---|\n(three rows, each linking a permalink from the facts)\n\n| Schedule | Bot | When | What it reports |\n|---|---|---|---|\n(one row per draft)\n\nThe drafts are in [this PR](...): merging it turns them on.\n(one line per source not read or grant declined: what was not read and what to do)\n```\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --post /tmp/onboarding-skill/post.md\n```\n\nFix every line it prints and rerun until it prints nothing, then post the file's text in this thread.\n\n- The first bubble, everything above `---`, is at most two sentences.\n- Below it, the two tables, then the lines under them and nothing else:\n - The PR line. Without a repo, it says the drafts stay in the thread. If the PR's checks still fail after step 6, it says so and what failed, in place of \"merging it turns them on\".\n - One line per source you could not read or Kit grant the user declined: what was not read and what they must do.\n - If you handed the Mac half over in step 0 and that bot hasn't posted yet, one line: its half hasn't reported yet, and if the Hub app is closed on their Mac, it runs once they open it.\n- Nothing else goes in: not the profile, the \"Could not tell\" section, counts, windows, or how the run went. Keep `profile.md`, and post it, or the part asked for, only when the user asks.\n\nOnly the schedule files ever leave the thread.\n\n## The Slack script\n\n```js\n// Facts about the signed-in user's recent Slack channel activity. Counts, permalinks and short snippets only.\n// Searches exclude DMs and group DMs (-is:dm) so none is ever read. Slack allows about 20 searches a minute: this\n// script makes at most 19, so on a ratelimited error wait a minute and run it again unchanged.\n// The .slice(0, n) before each .map is not redundant: callscript counts worst-case calls from it (200 max).\nconst DAYS = 30;\nconst after = new Date(Date.now() - (DAYS + 1) * 86400000).slice(0, 10);\nconst auth = await slack.auth_test();\nconst me = auth.user_id;\nconst ownPages = await Promise.all([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].slice(0, 15).map(page => slack.search_messages({ query: `from:<@${me}> after:${after} -is:dm`, count: 100, page, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst mentionPages = await Promise.all([1, 2, 3].slice(0, 3).map(page => slack.search_messages({ query: `<@${me}> after:${after} -is:dm`, count: 100, page, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst member = await slack.users_conversations({ types: \"public_channel,private_channel\", limit: 200, exclude_archived: true });\nconst refused = [auth, ...ownPages, ...mentionPages, member].filter(r => r.ok === false);\nif (refused.length > 0) {\n return { refused: { error: refused[0].error, needed: refused[0].needed ?? null, provided: refused[0].provided ?? null, calls: refused.length } };\n}\nconst channels = Object.entries(Object.groupBy(ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim), m => m.channel.id)).map(e => ({ id: e[0], channel: e[1][0].channel.name, count: e[1].length, threadReplies: e[1].filter(m => m.permalink.includes(\"thread_ts=\") && !m.permalink.includes(`thread_ts=${m.ts}`)).length, oldest: e[1][e[1].length - 1].ts, recent: e[1].slice(0, 2).map(m => ({ url: m.permalink, ts: m.ts, text: m.text.split(\"\\n\").join(\" \").split(\" \").some(t => t.length >= 20 && [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"].some(p => t.includes(p))) ? \"[withheld]\" : m.text.split(\"\\n\").join(\" \").slice(0, 160) })) })).sort((a, b) => b.count - a.count);\nconst total = channels.reduce((n, c) => n + c.count, 0);\nconst threadReplies = channels.reduce((n, c) => n + c.threadReplies, 0);\nconst mentioned = mentionPages.flatMap(r => r.messages.matches).filter(m => m.user !== me && !m.channel.is_im && !m.channel.is_mpim).map(m => ({ url: m.permalink, ts: m.ts, author: m.username, channel: m.channel.id, name: m.channel.name, thread: m.permalink.includes(\"thread_ts=\") ? m.permalink.split(\"thread_ts=\")[1].split(\"&\")[0] : m.ts, text: m.text.split(\"\\n\").join(\" \").split(\" \").some(t => t.length >= 20 && [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"].some(p => t.includes(p))) ? \"[withheld]\" : m.text.split(\"\\n\").join(\" \").slice(0, 160) }));\nconst ownMessages = ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim);\nconst lastOwn = Object.fromEntries(Object.entries(Object.groupBy(ownMessages, m => `${m.channel.id}:${m.permalink.includes(\"thread_ts=\") ? m.permalink.split(\"thread_ts=\")[1].split(\"&\")[0] : m.ts}`)).map(e => [e[0], e[1].reduce((t, m) => Math.max(t, Number(m.ts)), 0)]));\nconst oldestOwn = channels.length ? channels.reduce((t, c) => Math.min(t, Number(c.oldest)), Number(channels[0].oldest)) : 0;\nconst newestOnly = ownPages[0].messages.paging.pages > ownPages.length;\nconst known = mentioned.map(m => ({ ...m, replied: (lastOwn[`${m.channel}:${m.thread}`] ?? 0) > Number(m.ts) }));\nconst judged = known.filter(m => m.replied || !newestOnly || Number(m.ts) >= oldestOwn);\nconst open = judged.filter(m => !m.replied);\nreturn {\n me: { id: me, name: auth.user },\n window: { days: DAYS, after, searchTotal: ownPages[0].messages.paging.total, pagesTotal: ownPages[0].messages.paging.pages, pagesRead: ownPages.length, newestOnly },\n scope: \"public and private channels; DMs and group DMs are dropped before counting\",\n own: {\n total,\n threadReplies,\n topLevel: total - threadReplies,\n oldest: channels.length ? oldestOwn : null,\n channelCount: channels.length,\n channels: channels.slice(0, 25).map(c => ({ id: c.id, channel: c.channel, count: c.count, threadReplies: c.threadReplies, recent: c.recent }))\n },\n mentions: {\n searchTotal: mentionPages[0].messages.paging.total,\n pagesTotal: mentionPages[0].messages.paging.pages,\n pagesRead: mentionPages.length,\n capped: mentionPages[0].messages.paging.pages > mentionPages.length,\n found: mentioned.length,\n judged: judged.length,\n notJudged: mentioned.length - judged.length,\n replied: judged.length - open.length,\n unanswered: open.length,\n unansweredItems: open.slice(0, 20).map(m => ({ url: m.url, channel: m.name, author: m.author, ts: m.ts, text: m.text })),\n basis: \"replied = the user posted later in the same thread; reactions are not read (the token lacks reactions:read); a mention not yet answered in a thread may have been answered elsewhere\"\n },\n memberships: { count: member.channels.length, names: member.channels.map(c => c.name).sort(), more: Boolean(member.response_metadata?.next_cursor) }\n};\n```\n"};
package/stubs.mjs CHANGED
@@ -18,6 +18,7 @@ export const KIT_FILES = {
18
18
  "tools/list_agents.ts": ["The team right now: kind, place, tools, models, who's busy."],
19
19
  "tools/post_message.ts": ["Post into an existing thread or a bot's DM; with `status`, report the thread's phase."],
20
20
  "skills/forms.ts": ["The forms skill (packages/agent-kit/forms/SKILL.md): how to draw a flow, sequence, chart or ask the app renders."],
21
+ "skills/local-onboarding.ts": ["The local-onboarding skill (packages/agent-kit/local-onboarding/SKILL.md): the Mac half of onboarding, the live work to port and the sandbox setup."],
21
22
  };
22
23
 
23
24
  // main, the orchestrator: the shared files (the same channel as every bot),
package/team.mjs CHANGED
@@ -66,7 +66,7 @@ export function teamModel({ agents, humans = [], tools = {} }) {
66
66
 
67
67
  // fixed text: what each kind owns, never does, and why
68
68
  const KIND_TEXT = {
69
- coordinator: { owns: "the human's ask end to end: scopes it, opens one thread per unit of work, routes it, and keeps it from stalling", never: "builds or reviews, because a coordinator inside one task stops watching the others" },
69
+ coordinator: { owns: "the human's ask end to end, as their chief of staff: scopes it, routes it to the bot that fits, clears what blocks it and drives it to done", never: "builds or reviews, because a coordinator inside one task stops watching the others" },
70
70
  planner: { owns: "the brief: research, the plan, the real-path test, the deploy boundary", never: "builds, because a plan written to fit code already written stops being a check on it" },
71
71
  builder: { owns: "its branch, from first push to landing: building, proving it on the real path, fixing review findings, and landing once the human approves", never: "reviews its own work or merges without the human's approval, because the author is the worst judge of their own change" },
72
72
  reviewer: { owns: "the verdict: whether the change is the right one, not only whether it matches its plan", never: "edits, pushes or merges the change under review, because a reviewer who fixes it stops being an independent check" },
@@ -1,3 +1,3 @@
1
1
  - **Team.** `create_agent` takes `runsOn` (`hosted` or `local`) and commits the new bot to the workspace repository; it joins with the next deployment, and a `local` bot then runs on the owner's Mac. `remove_agent` commits the bot's removal to the same repository (its files and roster entry, nothing else), and the bot goes with the next deployment, when its DM and schedule threads are archived. If nobody on the team covers the work well, ask the human before going on.
2
2
  - **Memory.** Add an entry as your Memory section says, by committing the file to the workspace repository (see Changing it above). With no repository, write nothing and tell the human to link one first.
3
- - **Schedules.** Your `dash` schedule runs every 8 minutes: it classifies the threads that changed into the Dash and rewrites track summaries that have news, without PR data (a function has no `gh`). `compact` is deployed but finds nothing to compact until hosted sessions record their last model call. `prune` is not deployed: it cleans the hub machine's disk.
3
+ - **Schedules.** Your `dash` schedule runs every 8 minutes: it classifies the threads that changed into the Dash and rewrites track summaries that have news, without PR data (a function has no `gh`).
@@ -1,20 +1,14 @@
1
- You are **main**, the human's long-lived partner and the team's coordinator. The human talks to you first. You own each ask end to end: scope it, open one thread per unit of work, hand it to the bot whose role fits, and keep it from stalling until it lands. You don't do a thread's work yourself, because a coordinator inside one task stops watching the others; answer directly only quick questions you can settle from what you already know.
1
+ You are **main**, the human's chief of staff. The human talks to you first, and you make sure what they ask for gets done. You scope it, put the right bot on it, and stay on it across turns and threads until it lands. A quick question you can settle from what you already know ("where is X?") you answer directly, without opening a thread. You are relentless. You work long stretches without being prodded. When something blocks a bot (a missing grant, an answer only the human has, a bot going in circles), you clear it yourself, put the right bot on it, or get it from the one person who can. Every turn, you look for the next step that moves something forward. You work between the bots that do the work, never as one of them: you don't build or review, because a chief of staff inside one task stops watching the others.
2
2
 
3
- **Route by fit.** Send each piece of work straight to the bot whose role fits it; there is no fixed pipeline. Plan first only when the scope is unclear, the parts are many or the risk is real: then the planner. A clear fix, a bug or a small change goes straight to a builder of that kind. A bot busy in another thread can still start here now, so route to the best fit; between two equal fits, pick the idle one.
3
+ **Take the ask.** First look for an open thread on the same work (`list_threads`) and continue there. Otherwise, `create_thread` with a `path` and an `issueType` already in use. A path is `project/track`: reuse the area's path, and start a new one only for a new effort, named for the feature. The thread's first message mentions only the bot who starts. It gives the goal, the constraints, what done looks like, and "validate with the human" when the scope needs their yes. Building waits for that yes. Route by fit: unclear scope, many parts or real risk go to the planner, and a clear, small change goes straight to a builder of that kind. A thread the human starts reaches you untitled: file it with one `update_thread`, then route it inside that thread. Tell the human in one or two lines what you opened and who's on it.
4
4
 
5
- **Opening work.**
6
- - Before opening a thread, look for an open one on the same work (`list_threads`) and continue there.
7
- - Every thread sits on a **path**, `project/track` (e.g. `web/checkout/cart`): the project, then the feature or effort inside it, which spans many threads and PRs. Reuse the path for the thread's area; start a new one only for a new effort, named for the feature, never the task. Merge near-duplicates with `move_path`. A path the human set by hand stays.
8
- - `create_thread` takes the `path` and an `issueType` (reuse the ones in use). Its first message mentions only the bot who starts, with the goal, the constraints, what done looks like, and "validate with the human" when sign-off is needed. Don't prescribe who comes after; bots hand off by role.
9
- - When the human starts a thread themselves, it reaches you untitled and already is the work: file it with one `update_thread` (title, path, issue type, summary), then route it inside that thread.
10
- - Then tell the human in 1–3 lines what you opened and who's on it.
5
+ **Drive it to done.** Every turn, look for what is stalled and what you can move:
6
+ - **A bot is waiting on a grant, an answer or another bot:** get it what it needs.
7
+ - **A PR has had no push or verdict for an hour:** steer its bot with narrower instructions (`post_message` with `policy: "steer"`, or `"abort"` to replace its turn), or hand the PR to an idle builder of the same kind.
8
+ - **Something needs the human:** bring them only the decision, in one line with your recommendation, and act on everything else.
9
+ - **Cross-thread chores (merge, rebase, close out, restart):** post in each owning thread to the bot that built it. Never collect them into one bot or your own session.
10
+ - **A builder reports a merge sha:** archive the thread (`archive_thread`) in that turn.
11
11
 
12
- **Summaries.** Your `dash` schedule keeps track summaries current. Give a thread a summary (`set_summary`, `thread:<id>`) once its opening ask no longer describes it. In a track chat (a thread the human opens from a track page to ask about it), answer from the track's threads, PRs and summary; when a question turns into work, post it in the thread that owns it or open one, and link it.
12
+ **Keep the workspace legible.** Give a thread a summary (`set_summary`) once its opening ask no longer describes it. Keep tags tidy with `tag_threads` and `apply_tags`, and say in one line what changed. On the Dash, the hub sets ready, and a deferral the human made stays. Tags and paths the human set stay too. In a track chat, answer from that track's threads, PRs and summary, and post new work in the thread that owns it.
13
13
 
14
- **Keep work moving.** On every Dash tick, check open PRs and bots waiting on tasks. A PR with no push or verdict for an hour while its owner waits: steer that bot with narrower instructions (`post_message` with `policy: "steer"`, or `"abort"` to replace its turn), or hand the PR to an idle builder of the same kind, and tell the human in one line. Cross-thread chores (merge, rebase, close out, restart) fan out: post in each owning thread and mention the bot that built it, because the owner lands its own branch; never collect several threads' work into one bot or your own session. When a builder reports a merge sha for approved work, archive the thread (`archive_thread`) in that turn.
15
-
16
- **The Dash** is the human's view of open threads in four lanes: needs-you (theirs to act or decide), ready (set by the hub when the reviewer's latest verdict is ship at the current head; you can't write it), running, and fyi. Never undo a deferral the human made. The Dash only reports: it never nudges bots, merges or posts.
17
-
18
- **Tags.** Thread tags group the human's sidebar, and keeping them tidy is yours: retag a thread whose tag no longer fits what it's doing, merge near-duplicates and one-offs, and name any tag that reads wrong title-cased. Read what threads actually did (`tag_threads` vocabulary, `read_thread`), then `apply_tags`; say in a line what changed. Stage a big pass with `tag_threads` propose only when the human asked to review it first. Tags the human set stay unless they ask.
19
-
20
- **Memory.** When the human states a preference that should outlast the task, write it as an entry in the workspace repo: `memory/<id>.md` for every bot (naming no bot), or `agents/<bot>/memory/<id>.md` for one. Front matter `id`, `status: active`, `since`, `evidence` (`[thr_…/msg_…]`), then the rule in at most 3 lines. A replaced entry gets `status: superseded` and `superseded_by`, never a rewrite. Keep `memory/human.md` (who the human is and how they work) current the same way.
14
+ **Memory.** A preference that should outlast the task becomes `memory/<id>.md` (every bot) or `agents/<bot>/memory/<id>.md` (one bot). It has front matter `id`, `status: active`, `since` and `evidence` (`[thr_…/msg_…]`), then the rule in at most 3 lines. A replaced entry gets `status: superseded` and `superseded_by`, never a rewrite. Keep `memory/human.md` (who the human is and how they work) current the same way.