eve-hub 0.5.6 → 0.5.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,11 @@
1
1
  # eve-hub
2
2
 
3
+ ## 0.5.7
4
+
5
+ ### Patch Changes
6
+
7
+ - `post_message` takes a reviewer's `verdict` (ship, fix-first, rethink) and `decisions`; a ship without a change-explainer for the PR shows "no explainer" on the Dash.
8
+
3
9
  ## 0.5.6
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -8,8 +8,9 @@ The bot-side code every hub agent shares (milestone M3 in [`docs/architecture`](
8
8
  | `tools/create_thread.ts`, `post_message.ts`, `archive_thread.ts`, `unarchive_thread.ts`, `list_agents.ts`, `create_agent.ts`, `remove_agent.ts`, `restart_agent.ts`, `agent_send.ts`, `tag_threads.ts`, `apply_tags.ts` | The orchestrator's tools | `main`'s `agent/tools/<same name>.ts` |
9
9
  | `hooks/sandbox.ts` | Migrates legacy Docker sessions to the host sandbox (`defineHook`) | `agent/hooks/sandbox.ts` |
10
10
  | `instructions/main.ts`, `main-instructions.mjs` | main's instructions (`defineInstructions`), rendered at build time from the kit's `templates/main-instructions.md` + `workspace-protocol.md`, so no workspace carries a copy | `main`'s `agent/instructions.ts`; workspace-only additions in `agent/instructions/*.md` |
11
- | `instructions/protocol.ts` | The shared protocol alone, rendered from `templates/workspace-protocol.md` | every other bot's `agent/instructions/protocol.ts`, appended after its own `instructions.md` (who the bot is) |
12
- | `memory/workspace.ts`, `memory.mjs` | an eve memory slot (default export, constant `workspace` scope) whose provider recalls the workspace's `memory/<id>.md` entries, one message each, at every turn start and after compaction, only the ones whose text changed since the session last saw them, as new messages after the history; `validateMemory(root)` for a workspace's tests | every bot's (main too) `agent/memory/workspace.ts`: `export { default } from "eve-hub/memory/workspace"` |
11
+ | `instructions/protocol.ts` | The shared protocol alone, rendered from `templates/workspace-protocol.md` followed by `templates/engineering.md` | every other bot's `agent/instructions/protocol.ts`, appended after its own `instructions.md` (who the bot is) |
12
+ | `instructions/engineering.ts` | The engineering page alone (`templates/engineering.md`), for a subagent that doesn't carry the protocol | the subagent's `instructions/engineering.ts` |
13
+ | `memory/workspace.ts`, `memory.mjs` | an eve memory slot (default export, constant `workspace` scope) whose provider recalls the workspace's `memory/<id>.md` entries (plus the `memory/<domain>/` folders the bot lists as `domains` in its roster entry), one message each, at every turn start and after compaction, only the ones whose text changed since the session last saw them, as new messages after the history; `validateMemory(root)` for a workspace's tests | every bot's (main too) `agent/memory/workspace.ts`: `export { default } from "eve-hub/memory/workspace"` |
13
14
  | `memory/bot.ts` | the same for the bot's own `agents/<bot>/memory/<id>.md` entries; the bot is found from its directory (`findBotDir`), scope is its name | every bot's `agent/memory/bot.ts`: `export { default } from "eve-hub/memory/bot"` |
14
15
  | `channels/eve-roster-user.ts` | `eveChannel({ auth: [rosterUser, localDev()] })`: eve's default steer policy, and the registry's `x-roster-user` human as a user principal | every bot's `agent/channels/eve.ts` |
15
16
  | `channels/eve.ts` | Re-exports `eve-roster-user.ts`, for stubs written before every bot shared one channel | older `agent/channels/eve.ts` stubs |
@@ -56,11 +57,11 @@ Add a changeset (`pnpm changeset`, pick `eve-hub`) when a tool's name, schema, o
56
57
 
57
58
  ## Published package (deployed workspaces)
58
59
 
59
- A deployed workspace (`node scripts/workspace-template.mjs <dir>`, lib/workspace-template.mjs) has no hub checkout to link, so it installs the kit from npm, published as [`eve-hub`](https://www.npmjs.com/package/eve-hub): `"eve-hub": "<version>"`. `node packages/agent-kit/scripts/pack.mjs <outDir>` builds the package: every `.ts` module compiled to `.js` + `.d.ts` in place (node won't strip types under `node_modules`), the `exports` map pointed at them, and `templates/` included.
60
+ A deployed workspace (lib/workspace-template.mjs, staged by hosted-hub/scripts/stage-workspace.mjs) has no hub checkout to link, so it installs the kit from npm, published as [`eve-hub`](https://www.npmjs.com/package/eve-hub): `"eve-hub": "<version>"`. `node packages/agent-kit/scripts/pack.mjs <outDir>` builds the package: every `.ts` module compiled to `.js` + `.d.ts` in place (node won't strip types under `node_modules`), the `exports` map pointed at them, and `templates/` included.
60
61
 
61
62
  Releases go through Changesets, as in eve. A PR that changes the kit adds a changeset (`pnpm changeset`) and applies it (`pnpm changeset version`); when it merges, `.github/workflows/release.yml` runs `changeset publish` (no bot commits to main: its ruleset allows only PR merges): the kit's `prepack` stages the package into `dist/npm` (its `publishConfig.directory`), npm gets it, and the `eve-hub@<version>` GitHub release is created. npm auth is the repo's `NPM_TOKEN` secret (an npm automation token). The template pins the version this checkout carries.
62
63
 
63
- The template's main gets a Vercel Sandbox and only the kit stubs that run without the hub's machine (lib/workspace-template.mjs `DEPLOYED_KIT_FILES`; eve's own bash replaces the kit's). The published tarball (`scripts/pack.mjs`) leaves out the kit__worker extension, which mounts only on the hub's machine. The hub reaches each bot's eve channel with `Authorization: Bearer $BOT_TOKEN` (`bot-token.mjs`); the kit's calls to the hub (`registry-fetch.mjs`, at `ROSTER_REGISTRY_URL`) carry `Authorization: Bearer $HUB_TOKEN` and, since the hosted hub keeps Vercel Authentication on, `x-vercel-protection-bypass: $HUB_PROTECTION_BYPASS` (the hub project's automation bypass secret), each when set.
64
+ The template's main gets a Vercel Sandbox and only the kit stubs that run without the hub's machine (lib/workspace-template.mjs `DEPLOYED_KIT_FILES`; eve's own bash replaces the kit's). The template's builder also mounts eve-code (`eve/extensions/code`: `apply_patch`, `grep`, the review skills, a read-only worker) and `eve/extensions/git` (`git({})`), both with no `github` option. The bot uses `gh` and `git` from bash, whose GitHub token is the sandbox firewall's (`VERCEL_SANDBOX`), and no `gh` tool is contributed. A deployed workspace's eve is the one pin in `hosted-hub/bots/package.json`, which staging copies over the template's (an eve snapshot with both `eve/world-hub` and `eve/extensions/git`; `hosted-hub/test/stage-workspace.test.mjs` checks the staged imports resolve against it). The hub and local bots stay on the hub's eve. The published tarball (`scripts/pack.mjs`) leaves out the kit__worker extension, which mounts only on the hub's machine. The hub reaches each bot's eve channel with `Authorization: Bearer $BOT_TOKEN` (`bot-token.mjs`); the kit's calls to the hub (`registry-fetch.mjs`, at `ROSTER_REGISTRY_URL`) carry `Authorization: Bearer $HUB_TOKEN` and, since the hosted hub keeps Vercel Authentication on, `x-vercel-protection-bypass: $HUB_PROTECTION_BYPASS` (the hub project's automation bypass secret), each when set.
64
65
 
65
66
  ## Vercel Kit connection
66
67
 
package/fork-env.mjs CHANGED
@@ -5,13 +5,14 @@
5
5
  // the fork brief only restate this; the sandbox enforces the defaults.
6
6
  // Short-lived either way: a fork can be retired (and an in-place fork's
7
7
  // thread go back to the shared root) while its session lives on.
8
- import { registryBase, registryFetch } from "./registry-fetch.mjs";
8
+ import { registryFetch, sandboxRegistry } from "./registry-fetch.mjs";
9
9
 
10
10
  const TTL_MS = 3000;
11
11
 
12
- export function createForkEnv({ registry = registryBase(), fetchImpl = globalThis.fetch, now = Date.now } = {}) {
12
+ export function createForkEnv({ registry = sandboxRegistry(), fetchImpl = globalThis.fetch, now = Date.now } = {}) {
13
13
  const cache = new Map(); // sessionId -> { config, at }
14
14
  return async function lookup(sessionId) {
15
+ if (registry === null) return null; // no local hub: no forks
15
16
  const hit = cache.get(sessionId);
16
17
  if (hit && now() - hit.at < TTL_MS) return hit.config;
17
18
  let config = null;
package/host-browser.js CHANGED
@@ -11,7 +11,7 @@ import { spawn } from "node:child_process";
11
11
  import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
12
12
  import { homedir, tmpdir } from "node:os";
13
13
  import { join } from "node:path";
14
- import { registryFetch } from "./registry-fetch.mjs";
14
+ import { registryFetch, sandboxRegistry } from "./registry-fetch.mjs";
15
15
  export const VIEWPORT = { width: 1280, height: 800 };
16
16
  const FRAME_MS = 200; // at most 5 frames/s leave for the registry
17
17
  const IDLE_MS = 10 * 60_000; // an unused browser is closed; the next use reopens its last URL
@@ -43,6 +43,8 @@ export function findChrome() {
43
43
  return existsSync(chrome) ? { path: chrome, shell: false } : null;
44
44
  }
45
45
  function reportScreen(body) {
46
+ if (!sandboxRegistry())
47
+ return;
46
48
  void registryFetch(`/api/sandbox/screen`, {
47
49
  method: "POST",
48
50
  headers: { "content-type": "application/json" },
package/host-sandbox.d.ts CHANGED
@@ -104,11 +104,10 @@ export interface Fence {
104
104
  (what: string): Promise<void>;
105
105
  readonly sessionId: string;
106
106
  readonly root: string;
107
- readonly registry: string;
108
107
  admit(kind: TicketKind, op: TicketOp, what: string, group?: TicketGroup): Promise<Ticket>;
109
108
  }
110
109
  export declare function ownershipGuard(sessionId: string, { registry, rootSessionId, agent }?: {
111
- registry?: string;
110
+ registry?: string | null;
112
111
  rootSessionId?: string;
113
112
  agent?: string;
114
113
  }): Fence;
package/host-sandbox.js CHANGED
@@ -15,7 +15,7 @@ import { promisify } from "node:util";
15
15
  import { defineSandboxProvider } from "eve/sandbox/provider";
16
16
  import { closeBrowser } from "./host-browser.js";
17
17
  import { createForkEnv } from "./fork-env.mjs";
18
- import { registryBase, registryFetch, registryHeaders } from "./registry-fetch.mjs";
18
+ import { registryFetch, registryHeaders, sandboxRegistry } from "./registry-fetch.mjs";
19
19
  const execFileAsync = promisify(execFile);
20
20
  export const HOST_ROOT = resolve(process.env.ROSTER_HOST_ROOT ?? join(homedir(), "wrk"));
21
21
  // The provider's lifecycle, exported so tests drive start/resume directly.
@@ -145,11 +145,13 @@ export function killTree(child) {
145
145
  // Live terminal: every process's output is teed to the roster registry so the
146
146
  // UI can show each bot's sandbox as it works. Best-effort; never blocks.
147
147
  let procSeq = 0;
148
- const forkConfig = createForkEnv({ registry: registryBase() });
148
+ const forkConfig = createForkEnv({ registry: sandboxRegistry() });
149
149
  // `retries`: the exit report is the one that must land (without it the
150
150
  // registry eventually marks the proc interrupted), so it rides out a registry
151
151
  // restart of a few seconds.
152
152
  function reportProc(body, retries = 0) {
153
+ if (!sandboxRegistry())
154
+ return;
153
155
  void registryFetch(`/api/sandbox/proc`, {
154
156
  method: "POST",
155
157
  headers: { "content-type": "application/json" },
@@ -233,7 +235,8 @@ function teeProcess(child, sessionId, command, cwd, rootSessionId) {
233
235
  // with identical parameters until a definite answer or a bounded deadline,
234
236
  // after which the op is not performed (a paused launcher is killed; a "not-run"
235
237
  // release is queued for the id) and SessionFenceError is thrown.
236
- // HUB_OWNERSHIP_GUARD=off disables tickets entirely.
238
+ // HUB_OWNERSHIP_GUARD=off disables tickets entirely, as does having no local
239
+ // registry (sandboxRegistry(): a laptop bot of the hosted hub).
237
240
  //
238
241
  // LIMITS of the guarantee: (1) unfenced work while the registry is unreachable
239
242
  // is outside it, by policy, and only reported after the fact; (2) descendants
@@ -402,8 +405,10 @@ export function stopRevocationPollers() {
402
405
  p.stop();
403
406
  pollers.clear();
404
407
  }
405
- export function ownershipGuard(sessionId, { registry = registryBase(), rootSessionId, agent = process.env.HUB_AGENT } = {}) {
408
+ export function ownershipGuard(sessionId, { registry = sandboxRegistry(), rootSessionId, agent = process.env.HUB_AGENT } = {}) {
406
409
  const root = rootSessionId ?? sessionId;
410
+ if (registry === null)
411
+ return Object.assign(async () => { }, { sessionId, root, admit: async () => UNFENCED }); // no local hub: none to fence with
407
412
  let warned = false;
408
413
  const warnOnce = (message) => {
409
414
  if (!warned)
@@ -507,7 +512,7 @@ export function ownershipGuard(sessionId, { registry = registryBase(), rootSessi
507
512
  if (!ownership.owner)
508
513
  throw refusal(what, ownership);
509
514
  };
510
- return Object.assign(check, { sessionId, root, registry, admit });
515
+ return Object.assign(check, { sessionId, root, admit });
511
516
  }
512
517
  // The paused launcher. /bin/sh runs this with fd 3 a socket to us and the
513
518
  // shell-startup variables moved to HUB_LAUNCH_* (so neither sh nor anything it
@@ -0,0 +1,4 @@
1
+ declare const _default: {
2
+ content: any;
3
+ };
4
+ export default _default;
@@ -0,0 +1,6 @@
1
+ import { defineInstructions } from "eve/instructions";
2
+ import { engineeringPage } from "../main-instructions.mjs";
3
+ // The engineering page alone, for a subagent that doesn't carry the workspace
4
+ // protocol (the protocol ends with this same page). A subagent re-exports this as
5
+ // its instructions/engineering.ts.
6
+ export default defineInstructions({ content: engineeringPage() });
@@ -1,5 +1,6 @@
1
1
  // main's instructions, rendered from the kit's templates: templates/main-instructions.md,
2
- // then the shared protocol (templates/workspace-protocol.md). One source for
2
+ // then the shared protocol (templates/workspace-protocol.md, then the engineering
3
+ // page, templates/engineering.md). One source for
3
4
  // `hub workspace init` (lib/agent-template.mjs) and for every workspace's main,
4
5
  // which imports it (instructions/main.ts) instead of carrying a copy, so a
5
6
  // template edit reaches each workspace on main's next build. Every other bot
@@ -11,6 +12,7 @@ import { findWorkspaceRoot } from "./workspace.mjs";
11
12
 
12
13
  const HEAD = "main-instructions.md";
13
14
  const PROTOCOL = "workspace-protocol.md";
15
+ const ENGINEERING = "engineering.md";
14
16
  const templatesOf = (kitDir) => join(kitDir, "templates");
15
17
 
16
18
  // The templates sit next to the kit in the hub checkout. Run by node, this
@@ -27,13 +29,18 @@ export function templatesDir({ env = process.env, cwd = process.cwd() } = {}) {
27
29
  }
28
30
 
29
31
  export function mainInstructions(options) {
30
- const dir = templatesDir(options);
31
- const read = (name) => readFileSync(join(dir, name), "utf8");
32
- return `${read(HEAD).trimEnd()}\n\n${read(PROTOCOL)}`;
32
+ const head = readFileSync(join(templatesDir(options), HEAD), "utf8");
33
+ return `${head.trimEnd()}\n\n${workspaceProtocol(options)}`;
33
34
  }
34
35
 
35
- // The shared protocol alone: every bot but main appends it after its own
36
+ // The engineering page alone: the protocol ends with it, and a subagent that
37
+ // never sees the protocol includes it (instructions/engineering.ts).
38
+ export function engineeringPage(options) {
39
+ return readFileSync(join(templatesDir(options), ENGINEERING), "utf8");
40
+ }
41
+
42
+ // The shared protocol: every bot but main appends it after its own
36
43
  // instructions.md (agent/instructions/protocol.ts re-exports instructions/protocol.ts).
37
44
  export function workspaceProtocol(options) {
38
- return readFileSync(join(templatesDir(options), PROTOCOL), "utf8");
45
+ return `${readFileSync(join(templatesDir(options), PROTOCOL), "utf8")}\n${engineeringPage(options)}`;
39
46
  }
@@ -1,13 +1,18 @@
1
1
  import { defineMemory, defineMemoryProvider } from "eve/memory";
2
- import { loadMemoryRecall } from "../memory.mjs";
2
+ import { botDomains, findBotDir, loadMemoryRecall } from "../memory.mjs";
3
3
  import { findWorkspaceRoot } from "../workspace.mjs";
4
4
  // The workspace's memory/ entries as an eve memory slot (see ../memory.mjs).
5
5
  // Recall re-reads memory/ at the start of every turn and after compaction and
6
6
  // returns only the entries whose text changed since this session last saw
7
7
  // them; eve appends those as user-role messages after the history, so the
8
8
  // system prompt and the cached prefix stay put when an entry changes.
9
- // One constant scope and namespace: every bot, every session, the same entries.
10
- const recall = ({ messages }) => ({ messages: loadMemoryRecall(findWorkspaceRoot(), messages) });
9
+ // One constant scope and namespace. The entries are memory/ (every bot) plus
10
+ // the folders for the domains this bot lists in hub.workspace.json.
11
+ const recall = ({ messages }) => {
12
+ const root = findWorkspaceRoot();
13
+ const dir = findBotDir();
14
+ return { messages: loadMemoryRecall(root, messages, { domains: dir ? botDomains(root, dir) : [] }) };
15
+ };
11
16
  export default defineMemory({
12
17
  description: "The human's standing decisions for this workspace, from its memory/ directory.",
13
18
  namespace: "hub-workspace-memory",
package/memory.mjs CHANGED
@@ -5,6 +5,12 @@
5
5
  // them at the start of every turn (memory/workspace.ts, memory/bot.ts), so an
6
6
  // added, edited or superseded entry reaches the bot's next turn with no
7
7
  // rebuild or restart, appended after the history. Node built-ins only.
8
+ // A workspace may group entries into domains: folders under memory/, nestable
9
+ // (memory/ui/, memory/ui/motion/), same entry format. memory/ itself is the
10
+ // domain every bot shares. A bot lists its domains in its hub.workspace.json
11
+ // entry (`domains: ["ui/motion"]`) and recalls memory/ plus each listed
12
+ // folder and every folder above it. The kit names no domain; a workspace
13
+ // defines its own.
8
14
  // Shared entries name no bot: bots pick who acts next from the live workspace
9
15
  // state, never from a name, and a rule for one role says so in its text. A
10
16
  // rule for one bot goes in that bot's memory/ (or its instructions.md).
@@ -24,7 +30,7 @@
24
30
  import { createHash } from "node:crypto";
25
31
  import { existsSync, readFileSync, readdirSync } from "node:fs";
26
32
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
27
- import { isWorkspace } from "./workspace.mjs";
33
+ import { isWorkspace, readWorkspaceFile } from "./workspace.mjs";
28
34
 
29
35
  export const MEMORY_DIR = "memory";
30
36
  export const MAX_RULE_LINES = 3;
@@ -82,28 +88,64 @@ export function parseEntry(text, file = "entry.md") {
82
88
  return { id: f.id, status: f.status, since: f.since, evidence: f.evidence, ...(supersededBy ? { supersededBy } : {}), rule: lines.join(" ") };
83
89
  }
84
90
 
85
- // Every entry under <dir>/memory, sorted by id, and one error string per file
86
- // that isn't a valid entry. No memory/ directory is an empty memory. `label`
87
- // names the directory in errors; `knownIds` are ids a superseded_by may also
88
- // point at (a bot's entries may hand over to a shared one).
89
- export function readMemory(root, { label = MEMORY_DIR, knownIds = new Set() } = {}) {
90
- const dir = join(root, MEMORY_DIR);
91
- if (!existsSync(dir)) return { entries: [], errors: [] };
91
+ // The folders under <root>/memory a bot with these domains recalls, as paths
92
+ // relative to memory/: "" (the shared root), then each domain and its ancestors.
93
+ export function domainFolders(domains = []) {
94
+ const folders = new Set([""]);
95
+ for (const domain of domains) {
96
+ const parts = domain.split("/");
97
+ for (let i = 1; i <= parts.length; i++) folders.add(parts.slice(0, i).join("/"));
98
+ }
99
+ return [...folders].sort();
100
+ }
101
+
102
+ // Every folder under <root>/memory, "" included: what a workspace's tests validate.
103
+ export function allMemoryFolders(root) {
104
+ const folders = [""];
105
+ const walk = (rel) => {
106
+ for (const d of readdirSync(join(root, MEMORY_DIR, rel), { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
107
+ if (!d.isDirectory() || d.name.startsWith(".")) continue;
108
+ folders.push(rel ? `${rel}/${d.name}` : d.name);
109
+ walk(rel ? `${rel}/${d.name}` : d.name);
110
+ }
111
+ };
112
+ if (existsSync(join(root, MEMORY_DIR))) walk("");
113
+ return folders;
114
+ }
115
+
116
+ // Every entry in the given folders of <dir>/memory (default: the root only),
117
+ // sorted by id, and one error string per file that isn't a valid entry, per
118
+ // listed folder that doesn't exist, and per id used twice. No memory/
119
+ // directory is an empty memory. `label` names the directory in errors;
120
+ // `knownIds` are ids a superseded_by may also point at (a bot's entries may
121
+ // hand over to a shared one).
122
+ export function readMemory(root, { label = MEMORY_DIR, knownIds = new Set(), folders = [""] } = {}) {
92
123
  const entries = [];
93
124
  const errors = [];
94
- const files = readdirSync(dir).filter((name) => name.endsWith(".md") && !name.startsWith(".") && name.toLowerCase() !== "readme.md").sort();
95
- for (const name of files) {
96
- const file = `${label}/${name}`;
97
- try {
98
- const entry = parseEntry(readFileSync(join(dir, name), "utf8"), file);
99
- if (`${entry.id}.md` !== name) throw new MemoryError(`${file}: id ${entry.id} doesn't match the file name; rename it ${entry.id}.md`);
100
- entries.push(entry);
101
- } catch (error) {
102
- errors.push(error instanceof MemoryError ? error.message : `${file}: ${error.message}`);
125
+ const where = new Map();
126
+ for (const folder of folders) {
127
+ const dir = folder ? join(root, MEMORY_DIR, folder) : join(root, MEMORY_DIR);
128
+ const prefix = folder ? `${label}/${folder}` : label;
129
+ if (!existsSync(dir)) {
130
+ if (folder) errors.push(`${prefix}: domain folder doesn't exist`);
131
+ continue;
132
+ }
133
+ const files = readdirSync(dir, { withFileTypes: true }).filter((d) => d.isFile() && d.name.endsWith(".md") && !d.name.startsWith(".") && d.name.toLowerCase() !== "readme.md").map((d) => d.name).sort();
134
+ for (const name of files) {
135
+ const file = `${prefix}/${name}`;
136
+ try {
137
+ const entry = parseEntry(readFileSync(join(dir, name), "utf8"), file);
138
+ if (`${entry.id}.md` !== name) throw new MemoryError(`${file}: id ${entry.id} doesn't match the file name; rename it ${entry.id}.md`);
139
+ if (where.has(entry.id)) throw new MemoryError(`${file}: id ${entry.id} is also ${where.get(entry.id)}; an id is used once`);
140
+ where.set(entry.id, file);
141
+ entries.push(entry);
142
+ } catch (error) {
143
+ errors.push(error instanceof MemoryError ? error.message : `${file}: ${error.message}`);
144
+ }
103
145
  }
104
146
  }
105
147
  const ids = new Set(entries.map((e) => e.id));
106
- for (const e of entries) if (e.supersededBy && !ids.has(e.supersededBy) && !knownIds.has(e.supersededBy)) errors.push(`${label}/${e.id}.md: superseded_by ${e.supersededBy} has no entry`);
148
+ for (const e of entries) if (e.supersededBy && !ids.has(e.supersededBy) && !knownIds.has(e.supersededBy)) errors.push(`${where.get(e.id)}: superseded_by ${e.supersededBy} has no entry`);
107
149
  entries.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
108
150
  return { entries, errors };
109
151
  }
@@ -190,10 +232,11 @@ const warnOnce = (msg) => {
190
232
  console.warn(`memory: ${msg}`);
191
233
  };
192
234
 
193
- const unreadableIds = (errors) => new Set(errors.map((e) => /(?:^|\/)memory\/([^/:]+)\.md:/.exec(e)?.[1]).filter(Boolean));
235
+ const unreadableIds = (errors) => new Set(errors.map((e) => /(?:^|\/)memory\/(?:[^/:]+\/)*([^/:]+)\.md:/.exec(e)?.[1]).filter(Boolean));
194
236
 
195
- export function loadMemoryRecall(root, history = [], { warn = warnOnce } = {}) {
196
- const { entries, errors } = readMemory(root);
237
+ // `domains`: the bot's domains (hub.workspace.json), see the header.
238
+ export function loadMemoryRecall(root, history = [], { warn = warnOnce, domains = [] } = {}) {
239
+ const { entries, errors } = readMemory(root, { folders: domainFolders(domains) });
197
240
  for (const error of errors) warn(error);
198
241
  return memoryRecall(entries, history, { unreadable: unreadableIds(errors) });
199
242
  }
@@ -203,7 +246,7 @@ export function loadMemoryRecall(root, history = [], { warn = warnOnce } = {}) {
203
246
  // never by shadowing it), so a clash is an error and that file is skipped.
204
247
  export function readBotMemory(root, botDir) {
205
248
  const label = `${relative(root, botDir).split(sep).join("/")}/${MEMORY_DIR}`;
206
- const shared = new Set(readMemory(root).entries.map((e) => e.id));
249
+ const shared = new Set(readMemory(root, { folders: allMemoryFolders(root) }).entries.map((e) => e.id));
207
250
  const { entries, errors } = readMemory(botDir, { label, knownIds: shared });
208
251
  const clash = entries.filter((e) => shared.has(e.id));
209
252
  for (const e of clash) errors.push(`${label}/${e.id}.md: id ${e.id} is also ${MEMORY_DIR}/${e.id}.md; give it its own id and supersede the other explicitly`);
@@ -231,10 +274,21 @@ export function findBotDir({ env = process.env, cwd = process.cwd() } = {}) {
231
274
  return null;
232
275
  }
233
276
 
277
+ // The domains the bot in `botDir` lists in its hub.workspace.json entry. The
278
+ // bot is its directory name, as in findBotDir.
279
+ export function botDomains(root, botDir) {
280
+ return readWorkspaceFile(root).agents.find((a) => a.name === basename(botDir))?.domains ?? [];
281
+ }
282
+
234
283
  // For a workspace's tests: throws one MemoryError listing every bad file,
235
- // shared and per bot (agents/*/memory/). Returns the shared entries.
284
+ // shared (every folder) and per bot (agents/*/memory/), and every domain a bot
285
+ // lists that has no folder. Returns the shared entries.
236
286
  export function validateMemory(root) {
237
- const { entries, errors } = readMemory(root);
287
+ const { entries, errors } = readMemory(root, { folders: allMemoryFolders(root) });
288
+ if (isWorkspace(root)) {
289
+ const folders = new Set(allMemoryFolders(root));
290
+ for (const { name, domains = [] } of readWorkspaceFile(root).agents) for (const d of domains) if (!folders.has(d)) errors.push(`hub.workspace.json: ${name} lists domain ${d}, which has no memory/${d}/ folder`);
291
+ }
238
292
  const agents = join(root, "agents");
239
293
  if (existsSync(agents)) for (const name of readdirSync(agents).sort()) if (existsSync(join(agents, name, MEMORY_DIR))) errors.push(...readBotMemory(root, join(agents, name)).errors);
240
294
  if (errors.length) throw new MemoryError(`${errors.length} bad memory entr${errors.length === 1 ? "y" : "ies"}:\n${errors.join("\n")}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eve-hub",
3
- "version": "0.5.6",
3
+ "version": "0.5.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",
@@ -22,6 +22,13 @@
22
22
  // is a different route (a 404 on the hosted hub).
23
23
  export const registryBase = () => (process.env.ROSTER_REGISTRY_URL ?? "http://127.0.0.1:3100").replace(/\/+$/, "");
24
24
 
25
+ // The local hub's sandbox services: the live terminal (/api/sandbox/proc),
26
+ // admission tickets and ownership, fork config, the browser screen, prune.
27
+ // Only a hub on this machine has them. A laptop bot of the hosted hub has
28
+ // none (HUB_SANDBOX_SERVICES=none, set by hosted-hub/runner/hub-runner.ts):
29
+ // null, and their clients send nothing.
30
+ export const sandboxRegistry = () => (process.env.HUB_SANDBOX_SERVICES === "none" ? null : registryBase());
31
+
25
32
  // Also used by the sandbox's node:http transport, which distinguishes
26
33
  // requests that never connected from ones whose result may have been lost.
27
34
  export function registryHeaders(init) {
@@ -33,7 +33,7 @@ const open = (t) => !!t && t.kind === "thread" && !t.schedule && !String(t.id).s
33
33
  const working = (t) => Object.values(t?.status ?? {}).includes("working");
34
34
 
35
35
  // What a classification was made from: the last message, the live status
36
- // class (waiting / working / idle) and the project and domain tags. The entry
36
+ // class (waiting / working / idle) and the project and issue type tags. The entry
37
37
  // keeps it (entry.basis); a thread is classified again once it differs. Kept
38
38
  // in step with registry/dash.mjs classifyBasis (test/dash-entries.test.mjs).
39
39
  // The input version (registry/dash.mjs BASIS_VERSION): bumped when what the
@@ -46,7 +46,7 @@ export function classifyBasis(t) {
46
46
  // The newest status post (t.work, see the hub's lib/work-status.mjs), only
47
47
  // once a bot has posted one, so bases from before statuses existed stay valid.
48
48
  const reported = Object.values(t?.work?.bots ?? {}).map((b) => b?.messageId ?? "").sort().at(-1);
49
- return [lastSaid(t)?.id ?? "", live, t?.tags?.project ?? "", t?.tags?.domain ?? "", ...(reported ? [reported] : []), BASIS_VERSION].join("|");
49
+ return [lastSaid(t)?.id ?? "", live, t?.tags?.project ?? "", (t?.tags?.issueType ?? t?.tags?.domain) ?? "", ...(reported ? [reported] : []), BASIS_VERSION].join("|");
50
50
  }
51
51
 
52
52
  // Paths come from the snapshot's legacy view; all depths in use are choices.
@@ -281,6 +281,7 @@ const prOf = (n) => ({
281
281
  key: `${n.repository?.nameWithOwner ?? "?"}#${n.number}`,
282
282
  title: n.title,
283
283
  url: n.url,
284
+ head: n.headRefOid ?? null,
284
285
  draft: !!n.isDraft,
285
286
  ci: String(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state ?? "none").toLowerCase(),
286
287
  review: String(n.reviewDecision ?? "none").toLowerCase(),
@@ -321,8 +322,11 @@ export function prsFromSearch(data) {
321
322
  }
322
323
 
323
324
  // The probe's query: the human's open PRs, plus `refs` ("owner/repo#12") by name.
324
- const PR_FIELDS = "number title url isDraft state merged mergeable reviewDecision repository { nameWithOwner } commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }";
325
+ const PR_FIELDS = "number title url headRefOid isDraft state merged mergeable reviewDecision repository { nameWithOwner } commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }";
325
326
  const REF = /^([\w.-]+)\/([\w.-]+)#(\d+)$/;
327
+ // GitHub gives up on a GraphQL request after ~10 s (502/504); a chunk of 40
328
+ // names takes ~4 s.
329
+ export const REFS_PER_QUERY = 40;
326
330
  export function dashQuery(refs = []) {
327
331
  const named = refs
328
332
  .map((ref) => ref.match(REF))
@@ -11,7 +11,7 @@
11
11
  import { execFile } from "node:child_process";
12
12
  import { createHash } from "node:crypto";
13
13
  import { runOnce } from "./hub.mjs";
14
- import { dashPrs, dashQuery, dashScan, dashWatchList, failedRefs, prsFromSearch } from "./dash-rules.mjs";
14
+ import { REFS_PER_QUERY, dashPrs, dashQuery, dashScan, dashWatchList, failedRefs, prsFromSearch } from "./dash-rules.mjs";
15
15
 
16
16
  // The hub gives a schedule 60 s (registry/server.mjs
17
17
  // SCHEDULE_DISPATCH_TIMEOUT_MS): two 20 s tries and the registry calls fit.
@@ -76,9 +76,11 @@ export async function runDash(hub, { probe = (q) => withRetry(() => ghGraphql(q)
76
76
  let skip = new Set();
77
77
  let note = null;
78
78
  try {
79
- const answer = await probe(dashQuery(refs));
80
- prs = prsFromSearch(answer);
81
- skip = failedRefs(answer, refs);
79
+ const chunks = [];
80
+ for (let i = 0; i < Math.max(refs.length, 1); i += REFS_PER_QUERY) chunks.push(refs.slice(i, i + REFS_PER_QUERY));
81
+ const answers = await Promise.all(chunks.map((c) => probe(dashQuery(c))));
82
+ prs = answers.flatMap(prsFromSearch);
83
+ skip = new Set(answers.flatMap((a, i) => [...failedRefs(a, chunks[i])]));
82
84
  if (skip.size) note = `${plural(skip.size)} skipped: GitHub couldn't answer for ${[...skip].slice(0, 3).join(", ")}${skip.size > 3 ? ", …" : ""}`;
83
85
  } catch (err) {
84
86
  ok = false;
@@ -67,7 +67,7 @@ export function threadBrief(t, snap, limits = BRIEF) {
67
67
  title: t?.title ?? t?.id,
68
68
  project: t?.tags?.project ?? null,
69
69
  path: t?.tags?.path ?? null,
70
- domain: t?.tags?.domain ?? null,
70
+ issueType: t?.tags?.issueType ?? t?.tags?.domain ?? null,
71
71
  // The thread's stored summary (set_summary), when main wrote one.
72
72
  summary: t?.summary?.text ? { text: clip(flat(t.summary.text), limits.summary ?? 600), by: t.summary.by ?? null } : null,
73
73
  opening: ms.length > limits.recent ? line(ms[0], human, limits.opening) : null,
@@ -88,7 +88,7 @@ const who = (l) => `${l.author}${l.human ? " (human)" : ""}`;
88
88
  export function briefText(b) {
89
89
  return [
90
90
  `Title: ${b.title}`,
91
- `Project: ${b.project ?? "none"}${b.path && b.path !== b.project ? ` · path: ${b.path}` : ""} · domain: ${b.domain ?? "none"}`,
91
+ `Project: ${b.project ?? "none"}${b.path && b.path !== b.project ? ` · path: ${b.path}` : ""} · issue type: ${b.issueType ?? "none"}`,
92
92
  ...(b.summary ? [`Summary${b.summary.by ? ` (by ${b.summary.by})` : ""}: ${b.summary.text}`] : []),
93
93
  ...(b.opening ? [`Opening message, ${who(b.opening)}: ${b.opening.text}`] : []),
94
94
  ...(b.phase ? [`Phase the bots reported: ${b.phase}`, ...b.reports.map((r) => `- ${r.bot}: ${r.summary}${r.pr ? ` (PR ${r.pr}${r.sha ? ` @ ${r.sha}` : ""})` : ""}`)] : []),
@@ -1,10 +1,11 @@
1
1
  export type Requested = { model?: string; reasoning?: string };
2
2
  export type SubagentCap = Requested & { models?: readonly string[] };
3
3
  export type CapAgent = { name: string; subagents?: Record<string, Requested>; subagentCap?: SubagentCap };
4
- export type Clamped = { model: string; reasoning: string; clamped: string[] };
5
- export const REASONING_LEVELS: readonly string[];
4
+ export type ReasoningLevel = "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
5
+ export type Clamped = { model: string; reasoning: ReasoningLevel; clamped: string[] };
6
+ export const REASONING_LEVELS: readonly ReasoningLevel[];
6
7
  export const DEFAULT_SUBAGENT_CAP: Readonly<{ model: string; reasoning: string; models: readonly string[] }>;
7
8
  export function clampSubagent(requested?: Requested, cap?: SubagentCap): Clamped;
8
- export function capFor(agents: readonly CapAgent[], workspaceCap: SubagentCap, botName: string): SubagentCap;
9
+ export function capFor(agents: readonly CapAgent[], workspaceCap: SubagentCap, botName: string | undefined): SubagentCap;
9
10
  export function resolveSubagent(agents: readonly CapAgent[], workspaceCap: SubagentCap, botName: string, name: string): Clamped;
10
11
  export function resolveSubagents(agents: readonly CapAgent[], workspaceCap: SubagentCap, botName: string): Array<Clamped & { name: string }>;
@@ -0,0 +1,24 @@
1
+ ## Engineering
2
+
3
+ ### Premise first
4
+ - Question the premise before you plan or build. Check that the problem as stated is the real one: the symptom is what the user sees, the cause is proven, and the asked-for fix would solve it. If you aren't sure, find out before designing. Question it again whenever two fixes on one idea fail.
5
+ - Reduce the problem to what it fundamentally needs, then research before you design: prior art, the standard tool, the paper. Most problems are solved; use the proven tool. When one isn't, people have still spent serious time on it: find their partial answers, failed attempts, tradeoffs and open debates, and build on them instead of starting from zero.
6
+
7
+ ### Root cause
8
+ - Fix the cause, not the symptom. Ask why until you reach the invariant that broke and the module that owns it; fix it there. No guard, retry, catch or default that hides a failure. Grep for the same bug elsewhere.
9
+ - A bug fix comes with a test that fails before and passes after, on the user's exact case.
10
+ - Debug from evidence, not by reading code: repro, logs, traces. Keep facts apart from hypotheses; change one thing against a control.
11
+
12
+ ### Simplicity
13
+ - Subtract before you add. The best change deletes or moves code. Every new flag, option, layer, cache, retry or file needs a requirement that forces it today, not a future one.
14
+ - Deep modules: a lot behind a small interface. Don't split code just to make it short. Prefer duplication to the wrong abstraction; reuse what exists before writing a helper.
15
+
16
+ ### State and contracts
17
+ - One source of truth per fact; derive, don't mirror. A shape that crosses a process boundary has one definition, and CI fails on drift. Schema changes are expand/contract.
18
+ - Contracts are explicit: precise types, dependencies passed in, no silent fallbacks.
19
+ - Change related state in one step. No sleeps or ordering assumptions. Failure paths are explicit: timeouts set, retries jittered and budgeted, queues bounded.
20
+
21
+ ### Code and tests
22
+ - No comments by default; write one only for a non-obvious why.
23
+ - Don't copy a bad local pattern; flag it. After a patch, reread the whole module, not just the diff.
24
+ - Test real I/O end to end; fake only at the network edge. Property tests for parsers and wire formats. No coverage targets.
@@ -16,7 +16,7 @@ You are one named bot in a small workspace of long-lived bots. Work happens in t
16
16
  - You can `show` the human something: it moves their screen to a thread or DM, optionally scrolled to one message. Use it sparingly, when they need to look now.
17
17
  - You can `react` to any message by its `[msg_…]` id. React when a reply would be noise: 👍 to acknowledge, 👀 when you're picking something up that takes a while, ✅ when you've done what was asked, 🎉 for good news. Reactions are yours to choose; don't react to everything.
18
18
  - Images people post come with the message (shown as `[image: name]` in the log), and you see them. To post one yourself, call `attach_image` with a file from your sandbox or `screenshot: true` for your browser; it goes out with your next message. To look at an image file yourself (a screenshot you took, a capture), call `view_image` with its path; `read_file` can't read images and `attach_image` doesn't show them to you. To see an image posted in another thread, call `read_thread` with that message's `messageId`.
19
- - If a thread's scope has clearly changed, `update_thread` retitles it, and refreshes its track, domain and summary (everyone sees an inline "renamed this thread: old → new" line on a retitle). Do it rarely, and only for threads you're in.
19
+ - If a thread's scope has clearly changed, `update_thread` retitles it, and refreshes its track, issue type and summary (everyone sees an inline "renamed this thread: old → new" line on a retitle). Do it rarely, and only for threads you're in.
20
20
  - Your session only sees the thread it lives in. To find or read any other thread (by title, who's busy there, archived or not), use `list_threads` and `read_thread`; don't guess from memory.
21
21
  - Never `sleep` or poll for more than 60s in one bash call, except `gh pr checks <n> --watch` while landing an approved PR (set timeoutSeconds). For other CI or a deploy, check once, report, and say what you're waiting on.
22
22
  - If you have `kit__worker`, split the work by cost. Exploration (reading code, tracing call sites) goes to an `agent` copy or researcher. An edit you can spec fully (files, the change, how to verify) goes to `kit__worker`. You keep the design, the debugging, reading every diff it makes, and running the tests.
@@ -29,6 +29,8 @@ When several bots work on one piece of work, coordinate through posts; don't rou
29
29
  1. When your workspace root changes (worktree, branch or base), post `@all` with the old and new values.
30
30
  2. When you push, tell the bots your change affects: the owner of a branch stacked on yours, and the reviewer. Use `@all` if you can't tell who. To reach a bot in another thread, use `post_message`. A branch has one owner; anyone else works on a branch stacked on top, or the owner hands it over.
31
31
  3. When the thread's phase changes (planning → working → reviewing → done; a review can send it back to working or planning), post a status: `post_message` into this thread with `status` set to the new phase, a one-line summary as the text, and `metadata: { pr, sha }` when there are any. It wakes nobody unless you @-mention someone, so it doesn't replace your message. The workspace state shows the thread's current phase.
32
+
33
+ A reviewer records its verdict on the same kind of post: `verdict` (`ship`, `fix-first` or `rethink`) with `metadata: { pr, sha }`, the PR as `owner/repo#12` and the sha you reviewed; the text is the one-line what-changed. Add `decisions`, a list of `{ question, options: [{ label, pickedBy? }] }` for what the human has to pick (`[]` if nothing; a `ship` without the list is refused). Publish the change explainer with `artifact_publish` at the first ship and reuse it on later passes; the Dash row says "no explainer" without one. CI and the PR's head come from GitHub, not from your post; don't state them as facts there.
32
34
  4. When you need another specialty or more help, ask main, or the bot whose role fits.
33
35
 
34
36
  On a conflict, read the push posts and ask the bot that pushed.
@@ -81,8 +83,8 @@ renders as:
81
83
  [ npm test -- test/port.test.mjs ]
82
84
  ```
83
85
 
84
- - Avatar and name head only the first message of a run. Your bubbles are gray, left, at most 620pt wide (90% of a narrower window); the human's are dark, right.
85
- - Paragraphs, lists, quotes and inline `code` stay in one bubble of 14pt text. Headings are 16–18pt: skip them in short replies.
86
+ - Avatar and name head only the first message of a run. Your bubbles are gray, left, at most 540pt wide (90% of a narrower window); the human's are dark, right.
87
+ - Paragraphs, lists, quotes and inline `code` stay in one bubble of 14.5pt text. Headings are 16–18pt: skip them in short replies.
86
88
  - `---` with a blank line on both sides starts a new bubble. Without the blank line above, it makes the line before it a heading.
87
89
  - Each top-level code fence is a card with a language label and a copy icon; each table is a table card. Inline code has no copy icon, so anything the reader will paste goes in a fence. Cards wrap rather than scroll: keep command lines short.
88
90
  - A link is a gray chip with an icon and its label, so labels are short nouns (`#412`, `Feed.swift:1015`). A bare URL shows only its hostname.
@@ -5,15 +5,15 @@ declare const _default: import("eve/tools").ToolDefinition<{
5
5
  threadId: string;
6
6
  path?: string | undefined;
7
7
  project?: string | undefined;
8
- domain?: string | undefined;
8
+ issueType?: string | undefined;
9
9
  }[] | undefined;
10
10
  merges?: {
11
- field: "domain";
11
+ field: "issueType";
12
12
  from: string[];
13
13
  to: string;
14
14
  }[] | undefined;
15
15
  labels?: {
16
- field: "domain" | "path" | "project";
16
+ field: "issueType" | "path" | "project";
17
17
  tag: string;
18
18
  label: string | null;
19
19
  }[] | undefined;
@@ -25,15 +25,15 @@ declare const _default: import("eve/tools").ToolDefinition<{
25
25
  threadId: string;
26
26
  path?: string | undefined;
27
27
  project?: string | undefined;
28
- domain?: string | undefined;
28
+ issueType?: string | undefined;
29
29
  }[] | undefined;
30
30
  merges?: {
31
- field: "domain";
31
+ field: "issueType";
32
32
  from: string[];
33
33
  to: string;
34
34
  }[] | undefined;
35
35
  labels?: {
36
- field: "domain" | "path" | "project";
36
+ field: "issueType" | "path" | "project";
37
37
  tag: string;
38
38
  label: string | null;
39
39
  }[] | undefined;
@@ -6,20 +6,20 @@ import { z } from "zod";
6
6
  import { registryFetch } from "../registry-fetch.mjs";
7
7
  const tag = z.string().describe("Lowercase slug, e.g. 'checkout'.");
8
8
  export default defineTool({
9
- description: "Retag threads, merge / rename tags, or set their display names, workspace-wide. Pass `assignments` (tags per thread), `merges` (domains only: fold the `from` domains into `to`; later uses land on `to`; a rename is a one-tag merge. Projects and paths merge or rename with move_path), and/or `labels` (a tag's display name, the sidebar group header, e.g. payments-api -> 'Payments API'; no threads change), or the `proposalId` of a batch staged with tag_threads. Writes immediately, no approval. Tags and labels the human set by hand are kept, merges included. Read the threads first (list_threads, read_thread) so tags follow what they did, not their titles.",
9
+ description: "Retag threads, merge / rename tags, or set their display names, workspace-wide. Pass `assignments` (tags per thread), `merges` (issue types only: fold the `from` issue types into `to`; later uses land on `to`; a rename is a one-tag merge. Projects and paths merge or rename with move_path), and/or `labels` (a tag's display name, the sidebar group header, e.g. payments-api -> 'Payments API'; no threads change), or the `proposalId` of a batch staged with tag_threads. Writes immediately, no approval. Tags and labels the human set by hand are kept, merges included. Read the threads first (list_threads, read_thread) so tags follow what they did, not their titles.",
10
10
  inputSchema: z.object({
11
11
  proposalId: z.string().optional().describe("A batch staged with tag_threads propose, e.g. tgp_02ab."),
12
12
  note: z.string().optional().describe("One line on what this pass changes and why."),
13
13
  assignments: z
14
- .array(z.object({ threadId: z.string(), path: z.string().optional().describe("project/track, e.g. 'web/checkout'; sets the project too"), project: tag.optional(), domain: tag.optional() }))
14
+ .array(z.object({ threadId: z.string(), path: z.string().optional().describe("project/track, e.g. 'web/checkout'; sets the project too"), project: tag.optional(), issueType: tag.optional() }))
15
15
  .optional()
16
16
  .describe("Tags per thread."),
17
17
  merges: z
18
- .array(z.object({ field: z.enum(["domain"]), from: z.array(tag).min(1), to: tag }))
18
+ .array(z.object({ field: z.enum(["issueType"]), from: z.array(tag).min(1), to: tag }))
19
19
  .optional()
20
20
  .describe("Fold the `from` tags into `to`."),
21
21
  labels: z
22
- .array(z.object({ field: z.enum(["project", "domain", "path"]), tag, label: z.string().nullable().describe("Display name; null goes back to the title-cased tag.") }))
22
+ .array(z.object({ field: z.enum(["project", "issueType", "path"]), tag, label: z.string().nullable().describe("Display name; null goes back to the title-cased tag.") }))
23
23
  .optional()
24
24
  .describe("Display names per tag."),
25
25
  }),
@@ -4,6 +4,7 @@ declare const _default: import("eve/tools").ToolDefinition<{
4
4
  reasoning?: "high" | "low" | "medium" | "xhigh" | undefined;
5
5
  role: string;
6
6
  instructions?: string | undefined;
7
+ domains?: string[] | undefined;
7
8
  preset?: "builder" | "read-only" | undefined;
8
9
  }, {
9
10
  name: string;
@@ -16,6 +17,7 @@ declare const _default: import("eve/tools").ToolDefinition<{
16
17
  reasoning?: "high" | "low" | "medium" | "xhigh" | undefined;
17
18
  role: string;
18
19
  instructions?: string | undefined;
20
+ domains?: string[] | undefined;
19
21
  preset?: "builder" | "read-only" | undefined;
20
22
  }, ctx: import("eve/tools").ToolContext): Promise<{
21
23
  name: string;
@@ -11,6 +11,7 @@ export default defineTool({
11
11
  reasoning: z.enum(["low", "medium", "high", "xhigh"]).optional(),
12
12
  role: z.string().describe("One line: what this bot does."),
13
13
  instructions: z.string().optional().describe("Extra guidance: specialty, style, who it hands off to."),
14
+ domains: z.array(z.string().regex(/^[a-z][a-z0-9-]*(\/[a-z][a-z0-9-]*)*$/)).optional().describe("Memory domains: folders under memory/ whose standing decisions this bot also recalls, e.g. [\"ui\"]. It always recalls memory/ itself."),
14
15
  preset: z
15
16
  .enum(["builder", "read-only"])
16
17
  .optional()
@@ -2,9 +2,9 @@ declare const _default: import("eve/tools").ToolDefinition<{
2
2
  title: string;
3
3
  message: string;
4
4
  project?: string | undefined;
5
- domain?: string | undefined;
5
+ issueType?: string | undefined;
6
6
  projectLabel?: string | undefined;
7
- domainLabel?: string | undefined;
7
+ issueTypeLabel?: string | undefined;
8
8
  path?: string | undefined;
9
9
  pathLabel?: string | undefined;
10
10
  focus?: boolean | undefined;
@@ -16,9 +16,9 @@ declare const _default: import("eve/tools").ToolDefinition<{
16
16
  title: string;
17
17
  message: string;
18
18
  project?: string | undefined;
19
- domain?: string | undefined;
19
+ issueType?: string | undefined;
20
20
  projectLabel?: string | undefined;
21
- domainLabel?: string | undefined;
21
+ issueTypeLabel?: string | undefined;
22
22
  path?: string | undefined;
23
23
  pathLabel?: string | undefined;
24
24
  focus?: boolean | undefined;
@@ -2,23 +2,23 @@ import { defineTool } from "eve/tools";
2
2
  import { z } from "zod";
3
3
  import { registryFetch } from "../registry-fetch.mjs";
4
4
  export default defineTool({
5
- description: "Open a new workspace thread for one piece of work. The first message is posted as you; @-mention the bot who should start. File it under a path (project/track) and tag a domain when you can (reuse tags in use; list_threads shows each thread's tags); a thread left untagged asks its first bot to tag it.",
5
+ description: "Open a new workspace thread for one piece of work. The first message is posted as you; @-mention the bot who should start. File it under a path (project/track) and tag an issue type when you can (reuse tags in use; list_threads shows each thread's tags); a thread left untagged asks its first bot to tag it.",
6
6
  inputSchema: z.object({
7
7
  title: z.string().describe("Short thread title, e.g. 'Checkout: retry failed payments'."),
8
8
  message: z.string().describe("First message. Must @-mention who should act first."),
9
9
  project: z.string().optional().describe("Project tag: the codebase or effort, e.g. 'web', 'api'."),
10
- domain: z.string().optional().describe("Domain tag: the kind of work, e.g. 'feature', 'bugfix', 'docs'."),
10
+ issueType: z.string().optional().describe("Issue type tag: the kind of work, e.g. 'feature', 'bugfix', 'docs'."),
11
11
  projectLabel: z.string().optional().describe("Display name for a new project tag, e.g. 'Payments API'. Only names a tag that has none yet."),
12
- domainLabel: z.string().optional().describe("Display name for a new domain tag, e.g. 'Bug fixes'. Only names a tag that has none yet."),
12
+ issueTypeLabel: z.string().optional().describe("Display name for a new issue type tag, e.g. 'Bug fixes'. Only names a tag that has none yet."),
13
13
  path: z.string().optional().describe("Path: project, then the track inside it, e.g. 'web/checkout/cart' or 'api/billing'. Reuse a path in use (list_threads shows each thread's); the first segment is the project."),
14
14
  pathLabel: z.string().optional().describe("Display name for a new path's last segment, e.g. 'Cart'. Only names a path that has none yet."),
15
15
  focus: z.boolean().optional().describe("Move the human's screen to the new thread (default true)."),
16
16
  }),
17
- async execute({ title, message, project, domain, projectLabel, domainLabel, path, pathLabel, focus = true }, ctx) {
17
+ async execute({ title, message, project, issueType, projectLabel, issueTypeLabel, path, pathLabel, focus = true }, ctx) {
18
18
  const res = await registryFetch(`/api/threads`, {
19
19
  method: "POST",
20
20
  headers: { "content-type": "application/json" },
21
- body: JSON.stringify({ title, message, author: "main", sessionId: ctx.session.id, focus, project, domain, projectLabel, domainLabel, path, pathLabel }),
21
+ body: JSON.stringify({ title, message, author: "main", sessionId: ctx.session.id, focus, project, issueType, projectLabel, issueTypeLabel, path, pathLabel }),
22
22
  });
23
23
  if (!res.ok)
24
24
  throw new Error(`create_thread failed: ${res.status} ${await res.text()}`);
@@ -6,7 +6,7 @@ declare const _default: import("eve/tools").ToolDefinition<{
6
6
  busy?: string | undefined;
7
7
  project?: string | undefined;
8
8
  path?: string | undefined;
9
- domain?: string | undefined;
9
+ issueType?: string | undefined;
10
10
  limit?: number | undefined;
11
11
  }, any> & {
12
12
  execute(input: {
@@ -17,7 +17,7 @@ declare const _default: import("eve/tools").ToolDefinition<{
17
17
  busy?: string | undefined;
18
18
  project?: string | undefined;
19
19
  path?: string | undefined;
20
- domain?: string | undefined;
20
+ issueType?: string | undefined;
21
21
  limit?: number | undefined;
22
22
  }, ctx: import("eve/tools").ToolContext): Promise<any>;
23
23
  };
@@ -5,7 +5,7 @@ import { defineTool } from "eve/tools";
5
5
  import { z } from "zod";
6
6
  import { registryFetch } from "../registry-fetch.mjs";
7
7
  export default defineTool({
8
- description: "List workspace threads, workspace-wide (not just the one you're in): id, title, participants, status (active/archived), who's busy there (since when, doing what), last activity, tags (path: project/track, e.g. 'web/checkout'; project; domain), and the thread's stored summary when main wrote one. Newest activity first. `paths` holds the listed threads' paths with each one's current summary (what's up in that feature). Use it to find a thread by title before asking someone, and read_thread to read one.",
8
+ description: "List workspace threads, workspace-wide (not just the one you're in): id, title, participants, status (active/archived), who's busy there (since when, doing what), last activity, tags (path: project/track, e.g. 'web/checkout'; project; issue type), and the thread's stored summary when main wrote one. Newest activity first. `paths` holds the listed threads' paths with each one's current summary (what's up in that feature). Use it to find a thread by title before asking someone, and read_thread to read one.",
9
9
  inputSchema: z.object({
10
10
  query: z.string().optional().describe("Match titles containing this text (or an exact id)."),
11
11
  archived: z.enum(["exclude", "include", "only"]).optional().describe("Archived threads: exclude (default), include, or only."),
@@ -14,7 +14,7 @@ export default defineTool({
14
14
  busy: z.string().optional().describe("'true' for threads with someone working, 'false' for none, or a bot name."),
15
15
  project: z.string().optional().describe("Only threads with this project tag ('none' for untagged)."),
16
16
  path: z.string().optional().describe("Only threads on this path or under it, e.g. 'web' or 'web/checkout'."),
17
- domain: z.string().optional().describe("Only threads with this domain tag ('none' for untagged)."),
17
+ issueType: z.string().optional().describe("Only threads with this issue type tag ('none' for untagged)."),
18
18
  limit: z.number().int().min(1).max(500).optional().describe("Max threads (default 50)."),
19
19
  }),
20
20
  async execute(input) {
@@ -7,6 +7,14 @@ declare const _default: import("eve/tools").ToolDefinition<{
7
7
  pr?: string | undefined;
8
8
  sha?: string | undefined;
9
9
  } | undefined;
10
+ verdict?: "fix-first" | "rethink" | "ship" | undefined;
11
+ decisions?: {
12
+ question: string;
13
+ options: {
14
+ label: string;
15
+ pickedBy?: string | undefined;
16
+ }[];
17
+ }[] | undefined;
10
18
  }, {
11
19
  messageId: string;
12
20
  threadId: string;
@@ -23,6 +31,14 @@ declare const _default: import("eve/tools").ToolDefinition<{
23
31
  pr?: string | undefined;
24
32
  sha?: string | undefined;
25
33
  } | undefined;
34
+ verdict?: "fix-first" | "rethink" | "ship" | undefined;
35
+ decisions?: {
36
+ question: string;
37
+ options: {
38
+ label: string;
39
+ pickedBy?: string | undefined;
40
+ }[];
41
+ }[] | undefined;
26
42
  }, ctx: import("eve/tools").ToolContext): Promise<{
27
43
  messageId: string;
28
44
  threadId: string;
@@ -27,13 +27,18 @@ export default defineTool({
27
27
  })
28
28
  .optional()
29
29
  .describe("With status: the PR and sha it's about."),
30
+ verdict: z.enum(["ship", "fix-first", "rethink"]).optional().describe("A reviewer's verdict on metadata.pr at metadata.sha (needs both, and a status). A ship needs decisions."),
31
+ decisions: z
32
+ .array(z.object({ question: z.string(), options: z.array(z.object({ label: z.string(), pickedBy: z.string().optional().describe("The bot that picked this option.") })) }))
33
+ .optional()
34
+ .describe("With a verdict: what the human has to pick. [] when nothing; required with ship."),
30
35
  }),
31
- async execute({ threadId, agent, text, status, metadata }, ctx) {
36
+ async execute({ threadId, agent, text, status, metadata, verdict, decisions }, ctx) {
32
37
  const target = threadId ?? (agent ? `dm-${agent.replace(/^@/, "")}` : "here");
33
38
  const res = await registryFetch(`/api/threads/${encodeURIComponent(target)}/messages`, {
34
39
  method: "POST",
35
40
  headers: { "content-type": "application/json" },
36
- body: JSON.stringify({ text, sessionId: ctx.session.id, ...(status ? { status } : {}), ...(metadata ? { metadata } : {}) }),
41
+ body: JSON.stringify({ text, sessionId: ctx.session.id, ...(status ? { status } : {}), ...(metadata ? { metadata } : {}), ...(verdict ? { verdict } : {}), ...(decisions ? { decisions } : {}) }),
37
42
  });
38
43
  if (!res.ok)
39
44
  throw new Error(`post_message failed: ${res.status} ${await res.text()}`);
@@ -6,15 +6,15 @@ declare const _default: import("eve/tools").ToolDefinition<{
6
6
  threadId: string;
7
7
  path?: string | undefined;
8
8
  project?: string | undefined;
9
- domain?: string | undefined;
9
+ issueType?: string | undefined;
10
10
  }[] | undefined;
11
11
  merges?: {
12
- field: "domain";
12
+ field: "issueType";
13
13
  from: string[];
14
14
  to: string;
15
15
  }[] | undefined;
16
16
  labels?: {
17
- field: "domain" | "path" | "project";
17
+ field: "issueType" | "path" | "project";
18
18
  tag: string;
19
19
  label: string | null;
20
20
  }[] | undefined;
@@ -28,15 +28,15 @@ declare const _default: import("eve/tools").ToolDefinition<{
28
28
  threadId: string;
29
29
  path?: string | undefined;
30
30
  project?: string | undefined;
31
- domain?: string | undefined;
31
+ issueType?: string | undefined;
32
32
  }[] | undefined;
33
33
  merges?: {
34
- field: "domain";
34
+ field: "issueType";
35
35
  from: string[];
36
36
  to: string;
37
37
  }[] | undefined;
38
38
  labels?: {
39
- field: "domain" | "path" | "project";
39
+ field: "issueType" | "path" | "project";
40
40
  tag: string;
41
41
  label: string | null;
42
42
  }[] | undefined;
@@ -6,21 +6,21 @@ import { z } from "zod";
6
6
  import { registryFetch } from "../registry-fetch.mjs";
7
7
  const tag = z.string().describe("Lowercase slug, e.g. 'checkout'.");
8
8
  export default defineTool({
9
- description: "Thread tags (project, domain) workspace-wide. action 'vocabulary': the tags in use with counts, saved merge aliases, untagged threads, and pending proposals. action 'propose': stage a backfill (tags per thread) or a domain merge (fold near-duplicate or one-off domains into others; projects and paths merge with move_path) for the human to review; nothing is written, and the reply has a review table to post. action 'dismiss': drop a pending proposal. For everyday retags and merges skip this and call apply_tags directly.",
9
+ description: "Thread tags (project, issue type) workspace-wide. action 'vocabulary': the tags in use with counts, saved merge aliases, untagged threads, and pending proposals. action 'propose': stage a backfill (tags per thread) or an issue type merge (fold near-duplicate or one-off issue types into others; projects and paths merge with move_path) for the human to review; nothing is written, and the reply has a review table to post. action 'dismiss': drop a pending proposal. For everyday retags and merges skip this and call apply_tags directly.",
10
10
  inputSchema: z.object({
11
11
  action: z.enum(["vocabulary", "propose", "dismiss"]),
12
12
  kind: z.enum(["backfill", "merge", "labels"]).optional().describe("For propose."),
13
13
  note: z.string().optional().describe("For propose: one line on what this pass did."),
14
14
  assignments: z
15
- .array(z.object({ threadId: z.string(), path: z.string().optional().describe("project/track, e.g. 'web/checkout'; sets the project too"), project: tag.optional(), domain: tag.optional() }))
15
+ .array(z.object({ threadId: z.string(), path: z.string().optional().describe("project/track, e.g. 'web/checkout'; sets the project too"), project: tag.optional(), issueType: tag.optional() }))
16
16
  .optional()
17
17
  .describe("For propose: tags per thread."),
18
18
  merges: z
19
- .array(z.object({ field: z.enum(["domain"]), from: z.array(tag).min(1), to: tag }))
19
+ .array(z.object({ field: z.enum(["issueType"]), from: z.array(tag).min(1), to: tag }))
20
20
  .optional()
21
21
  .describe("For propose: fold the `from` tags into `to`; later uses of a `from` tag land on `to`."),
22
22
  labels: z
23
- .array(z.object({ field: z.enum(["project", "domain", "path"]), tag, label: z.string().nullable() }))
23
+ .array(z.object({ field: z.enum(["project", "issueType", "path"]), tag, label: z.string().nullable() }))
24
24
  .optional()
25
25
  .describe("For propose: display names per tag (the sidebar group headers)."),
26
26
  proposalId: z.string().optional().describe("For dismiss."),
@@ -2,21 +2,21 @@ declare const _default: import("eve/tools").ToolDefinition<{
2
2
  threadId: string;
3
3
  title?: string | undefined;
4
4
  path?: string | null | undefined;
5
- domain?: string | undefined;
5
+ issueType?: string | undefined;
6
6
  summary?: string | undefined;
7
7
  pathLabel?: string | undefined;
8
8
  projectLabel?: string | undefined;
9
- domainLabel?: string | undefined;
9
+ issueTypeLabel?: string | undefined;
10
10
  }, any> & {
11
11
  execute(input: {
12
12
  threadId: string;
13
13
  title?: string | undefined;
14
14
  path?: string | null | undefined;
15
- domain?: string | undefined;
15
+ issueType?: string | undefined;
16
16
  summary?: string | undefined;
17
17
  pathLabel?: string | undefined;
18
18
  projectLabel?: string | undefined;
19
- domainLabel?: string | undefined;
19
+ issueTypeLabel?: string | undefined;
20
20
  }, ctx: import("eve/tools").ToolContext): Promise<any>;
21
21
  };
22
22
  export default _default;
@@ -1,5 +1,5 @@
1
1
  // The `update_thread` tool, shared by every bot: say what a thread is, in one
2
- // call. Title, path (project/track), domain and the thread's summary. The
2
+ // call. Title, path (project/track), issue type and the thread's summary. The
3
3
  // registry keeps the rename history and posts an inline "<bot> renamed this
4
4
  // thread: old → new" marker; tags go on thread.tags (lib/tags.mjs) and the
5
5
  // summary is the one `set_summary` writes (lib/paths.mjs setSummary).
@@ -7,16 +7,16 @@ import { defineTool } from "eve/tools";
7
7
  import { z } from "zod";
8
8
  import { registryFetch } from "../registry-fetch.mjs";
9
9
  export default defineTool({
10
- description: "Update a workspace thread you're in: its title, track, domain and summary, in one call. Retitle when the work's scope has changed and the old title no longer fits: everyone sees a short 'renamed this thread: old → new' line; the history is kept. Keep titles short (at most 80 characters). File it on a path (project/track, e.g. 'web/checkout/cart': the first segment is the project, the rest the track) and tag a domain (the kind of work, e.g. 'feature', 'bugfix', 'infra'); reuse a path and domain already in use when they fit; for a new one, also give its display name (pathLabel / domainLabel, e.g. 'Payments API'). The summary is a short note on what the thread is about and where it stands; it replaces the previous one, and empty text clears it. The reply says what each tag was filed under and, for a tag no other thread uses, lists the ones in use so you can switch. Tags a human set by hand stay. DMs and archived threads can't be updated.",
10
+ description: "Update a workspace thread you're in: its title, track, issue type and summary, in one call. Retitle when the work's scope has changed and the old title no longer fits: everyone sees a short 'renamed this thread: old → new' line; the history is kept. Keep titles short (at most 80 characters). File it on a path (project/track, e.g. 'web/checkout/cart': the first segment is the project, the rest the track) and tag an issue type (the kind of work, e.g. 'feature', 'bugfix', 'infra'); reuse a path and issue type already in use when they fit; for a new one, also give its display name (pathLabel / issueTypeLabel, e.g. 'Payments API'). The summary is a short note on what the thread is about and where it stands; it replaces the previous one, and empty text clears it. The reply says what each tag was filed under and, for a tag no other thread uses, lists the ones in use so you can switch. Tags a human set by hand stay. DMs and archived threads can't be updated.",
11
11
  inputSchema: z.object({
12
12
  threadId: z.string().describe("Thread id, e.g. thr_0002 (shown in the [thread: …] header)."),
13
13
  title: z.string().optional().describe("New short title, e.g. 'Checkout: retry failed payments'. Omit to leave it."),
14
14
  path: z.string().nullable().optional().describe("Path: project, then the track inside it, e.g. 'web/checkout/cart' or 'api/billing'. Reuse a path in use (the naming prompt and list_threads show them); the first segment is the project. null: back to the project alone. A path a human set by hand stays."),
15
- domain: z.string().optional().describe("Domain tag: the kind of work, e.g. 'feature', 'bugfix', 'infra', 'docs', 'review', 'research'. Lowercase slug."),
15
+ issueType: z.string().optional().describe("Issue type tag: the kind of work, e.g. 'feature', 'bugfix', 'infra', 'docs', 'review', 'research'. Lowercase slug."),
16
16
  summary: z.string().optional().describe("The thread's whole new summary, markdown: what it's about and where it stands. Empty clears it. Omit to leave it."),
17
17
  pathLabel: z.string().optional().describe("Display name for a new path's last segment, e.g. 'Cart'. Only names a path that has none yet."),
18
18
  projectLabel: z.string().optional().describe("Display name for a new project (a path's first segment), shown as its sidebar group, e.g. 'Payments API' for payments-api. Only names a project that has none yet."),
19
- domainLabel: z.string().optional().describe("Display name for a new domain tag, e.g. 'Bug fixes' for bugfix. Only names a tag that has none yet."),
19
+ issueTypeLabel: z.string().optional().describe("Display name for a new issue type tag, e.g. 'Bug fixes' for bugfix. Only names a tag that has none yet."),
20
20
  }),
21
21
  async execute({ threadId, ...fields }, ctx) {
22
22
  const res = await registryFetch(`/api/threads/${encodeURIComponent(threadId)}/rename`, {
package/workspace.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // {
7
7
  // "agents": [{ "name": "main", "model": "anthropic/claude-opus-5.5", "role": "…",
8
- // "color"?: "#a78bfa", "subagents"?: { "researcher": { "when"?: "…" } },
8
+ // "color"?: "#a78bfa", "domains"?: ["ui/motion"], "subagents"?: { "researcher": { "when"?: "…" } },
9
9
  // "subagentCap"?: {…}, "limits"?: {…}, "port"?: 4201 }],
10
10
  // "humans"?: [{ "name": "me", "color"?: "#f472b6" }],
11
11
  // "subagentCap"?: { "model": "…", "reasoning": "low", "models": ["…"] },
@@ -37,10 +37,11 @@ import { limitsError } from "./session-limits.mjs";
37
37
 
38
38
  export const WORKSPACE_FILE = "hub.workspace.json";
39
39
  export const BASE_PORT = 4201;
40
+ export const DOMAIN = /^[a-z][a-z0-9-]*(\/[a-z][a-z0-9-]*)*$/;
40
41
  export const NAME = /^[a-z][a-z0-9-]{1,30}$/;
41
42
  export const HUMAN_NAME = /^[a-z][a-z0-9-]{0,30}$/;
42
43
  const COLOR = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
43
- const AGENT_KEYS = new Set(["name", "model", "role", "color", "subagents", "subagentCap", "limits", "port"]);
44
+ const AGENT_KEYS = new Set(["name", "model", "role", "color", "subagents", "subagentCap", "limits", "port", "runsOn", "domains"]);
44
45
 
45
46
  export class WorkspaceError extends Error {}
46
47
 
@@ -82,6 +83,10 @@ export function parseWorkspace(data, file = WORKSPACE_FILE) {
82
83
  if (ports.has(agent.port)) fail(file, `${label}.port ${agent.port} is used twice`);
83
84
  ports.add(agent.port);
84
85
  }
86
+ // Where the bot runs: on the hosted bots project (default), or on the human's Mac, started by
87
+ // the app's local runner. Fixed at definition.
88
+ if (agent.runsOn !== undefined && agent.runsOn !== "hosted" && agent.runsOn !== "local") fail(file, `${label}.runsOn must be "hosted" or "local"`);
89
+ if (agent.domains !== undefined && (!Array.isArray(agent.domains) || agent.domains.some((d) => typeof d !== "string" || !DOMAIN.test(d)))) fail(file, `${label}.domains must be an array of memory folders like "ui" or "ui/motion"`);
85
90
  const unknown = Object.keys(agent).filter((k) => !AGENT_KEYS.has(k));
86
91
  if (unknown.length) fail(file, `${label}: unknown field(s) ${unknown.join(", ")}`);
87
92
  return { ...agent };