eve-hub 0.5.4 → 0.5.6

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.5.6
4
+
5
+ ### Patch Changes
6
+
7
+ - 1418c75: Release: a PR bumps the version itself (`pnpm changeset version`); the release workflow only publishes.
8
+
9
+ ## 0.5.5
10
+
11
+ ### Patch Changes
12
+
13
+ - 2d21596: Publish the `update_thread` tool (replaces `rename_thread`). The published 0.5.4 predates it, so a workspace whose agents import `eve-hub/tools/update_thread` fails to build.
14
+
3
15
  ## 0.5.4
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -4,14 +4,14 @@ The bot-side code every hub agent shares (milestone M3 in [`docs/architecture`](
4
4
 
5
5
  | Module | What it is | Bots use it as |
6
6
  |---|---|---|
7
- | `tools/rename_thread.ts`, `list_threads.ts`, `read_thread.ts`, `react.ts`, `show.ts`, `attach_image.ts`, `browser.ts` | Registry and workspace tools (`defineTool`) | `agent/tools/<same name>.ts` |
7
+ | `tools/update_thread.ts`, `list_threads.ts`, `read_thread.ts`, `react.ts`, `show.ts`, `attach_image.ts`, `browser.ts` | Registry and workspace tools (`defineTool`) | `agent/tools/<same name>.ts` |
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
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
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"` |
13
13
  | `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
- | `channels/eve-roster-user.ts` | `eveChannel({ auth: [rosterUser, localDev()], turnPolicy: "queue" })`: queued mentions, and the registry's `x-roster-user` human as a user principal | every bot's `agent/channels/eve.ts` |
14
+ | `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
15
  | `channels/eve.ts` | Re-exports `eve-roster-user.ts`, for stubs written before every bot shared one channel | older `agent/channels/eve.ts` stubs |
16
16
  | `tools/disabled.ts` | `disableTool()`: removes an eve default tool | the read-only preset's `agent/tools/write_file.ts` |
17
17
  | `host-sandbox.ts`, `host-browser.ts` | `HostSandbox` (the host machine is the sandbox) and each session's headless Chromium | `agent/sandbox.ts` imports `HostSandbox` |
@@ -28,8 +28,8 @@ The `hubChannel` from D0 isn't here yet. Turn lifecycle and replies are owned by
28
28
  eve discovers tools, hooks, and channels by file under `agent/tools`, `agent/hooks`, and `agent/channels`. A tool's name is its file name, so the file has to exist in the bot. The stub just re-exports the kit's default:
29
29
 
30
30
  ```ts
31
- // agents/<bot>/agent/tools/rename_thread.ts
32
- export { default } from "eve-hub/tools/rename_thread";
31
+ // agents/<bot>/agent/tools/update_thread.ts
32
+ export { default } from "eve-hub/tools/update_thread";
33
33
  ```
34
34
 
35
35
  `sandbox.ts` stays a small module of its own, because each agent and subagent declares its own `environment`:
@@ -52,13 +52,13 @@ A bot's role is enforced by its files as well as its prose. `create_agent`'s `pr
52
52
  3. The stubs use the specifiers the `exports` map serves (`eve-hub/tools/x`, `eve-hub/host-sandbox`, `eve-hub/roster-model`, …).
53
53
  4. `eve` and `zod` are peer dependencies. eve's bundler resolves the kit to its realpath, so the kit's own `eve` imports come from hub's install. When the workspace's eve is linked to hub's, that's one eve (verified with eve 0.69).
54
54
 
55
- Add a changeset (`pnpm changeset`, pick `eve-hub`) when a tool's name, schema, or description, the hook's events, or the channel config changes; the release bumps `version` from it. Hub records each agent's kit version and accepts N and N−1 (D0/D12).
55
+ Add a changeset (`pnpm changeset`, pick `eve-hub`) when a tool's name, schema, or description, the hook's events, or the channel config changes; run `pnpm changeset version` in the same PR so it bumps `version` and writes CHANGELOG.md (a test fails while a changeset file is pending). Hub records each agent's kit version and accepts N and N−1 (D0/D12).
56
56
 
57
57
  ## Published package (deployed workspaces)
58
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
60
 
61
- Releases go through Changesets, as in eve. A PR that changes the kit adds a changeset (`pnpm changeset`); when it merges, `.github/workflows/release.yml` bumps `version`, writes CHANGELOG.md, commits both to main and runs `changeset publish`: 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.
61
+ 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
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
64
 
@@ -16,6 +16,7 @@ async function rosterUser(req) {
16
16
  return null;
17
17
  return { authenticator: "roster", principalId: `roster:${user}`, principalType: "user", attributes: {} };
18
18
  }
19
- // Mentions from other participants queue behind an active turn instead of
20
- // steering it; the registry cancels first when a human cuts in.
21
- export default eveChannel({ auth: [rosterUser, dev], turnPolicy: "queue" });
19
+ // No turnPolicy: eve's default steers a message into the running turn at its
20
+ // next step. The registry sends turnPolicy "queue" for a bot's message, so it
21
+ // waits for the turn to end.
22
+ export default eveChannel({ auth: [rosterUser, dev] });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eve-hub",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
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",
@@ -184,7 +184,7 @@ export function newPathNotice(threads, snap) {
184
184
  for (const t of threads) byProject.set(t.tags?.project ?? "", [...(byProject.get(t.tags?.project ?? "") ?? []), t]);
185
185
  const brief = (t) => [`- ${t.id}`, ...briefText(threadBrief(t, snap)).split("\n").map((l) => ` ${l}`)];
186
186
  return [
187
- `@main, these are every unfiled thread in each project. Group each project's work in one go from its briefs, using read_thread when needed. Put each thread on a listed path where one fits, or on a new project/feature path, with rename_thread (threadId and path only, including threads you aren't in). Merge near-duplicate paths with move_path. Never move a human-locked path.`,
187
+ `@main, these are every unfiled thread in each project. Group each project's work in one go from its briefs, using read_thread when needed. Put each thread on a listed path where one fits, or on a new project/feature path, with update_thread (threadId and path only, including threads you aren't in). Merge near-duplicate paths with move_path. Never move a human-locked path.`,
188
188
  ...[...byProject].flatMap(([p, ts]) => {
189
189
  const paths = pathsInProject(ts[0], snap);
190
190
  return [
@@ -1,85 +1,52 @@
1
1
  // main's tracks schedule (schedules/tracks.ts): main leads every track (a
2
- // path below its project, hub/native; registry: snap.tracks keyed by path)
3
- // by keeping its free-form summary current with set_summary. A main turn is
4
- // expensive, so every 10 minutes this decides, per thread, whether there is
5
- // anything to summarize before waking main:
6
- //
7
- // facts code only gathers facts, from the one snapshot read: the latest
8
- // human message, the bot messages since it, the latest bot
9
- // message, the latest bot event of any kind (registry session
10
- // lastEventAt, nested subagent runs included), who's working, and
11
- // when the thread's track (or the thread) was last summarized.
12
- // ask a thread is asked about when something happened since that
13
- // summary and its basis (newest message, summary time, who's
14
- // working) differs from the one it was last asked on. Each
15
- // goes to the model (DECISION_MODEL) in its own evaluation, all
16
- // in parallel, with its track's summary and the messages since
17
- // it: does the summary already state them (missing, stated,
18
- // chatter), and what changed. No thresholds in code. Each verdict is kept against its basis (state.seen):
19
- // the model is close to even on borderline threads, so asking
20
- // again on the same facts would wake main by chance, not news.
21
- // wake only threads whose verdict is `missing` reach main, in one post
22
- // in sch-main-tracks with the answers and facts.
23
- //
24
- // A failed or invalid evaluation posts and saves nothing: the next tick asks
25
- // again. Without `evaluate` (tests) nothing is asked and nobody is woken.
2
+ // path below its project, web/checkout; registry: snap.tracks keyed by path)
3
+ // by keeping its free-form summary current with set_summary. Every 10
4
+ // minutes this wakes main, in one post in sch-main-tracks, with each thread
5
+ // on an open track that has messages newer than the last notice that listed
6
+ // it (state.marks: thread id -> newest message that notice listed). Main
7
+ // decides in its own turn what each summary needs; code
8
+ // only gathers facts. No model call here: a cheap model couldn't tell
9
+ // whether a summary already states a thread's news (it missed merges).
26
10
  //
27
11
  // Plain JS on purpose: the schedule (TypeScript, bundled by eve) and the
28
12
  // tests (plain node) import the same module.
29
13
  import { runOnce } from "./hub.mjs";
30
- import { DECISION_MODEL } from "./dash-classify.mjs";
31
- import { clip, flat, said } from "./thread-brief.mjs";
14
+ import { said } from "./thread-brief.mjs";
32
15
 
33
16
  // A track chat is main's own conversation with the human; it doesn't move the track.
34
17
  const counted = (t) => !!t && t.kind === "thread" && !t.chatFor;
35
18
  const pathOf = (t) => t.tags?.path ?? t.trackId ?? null;
36
- const newest = (...ats) => ats.filter(Boolean).sort().at(-1) ?? null;
37
19
  const isHuman = (m, humans) => m.authorKind === "human" || humans.has(m.author);
38
20
 
39
- // The messages since the summary the model reads, newest last: news (a
40
- // merge) is often followed by acks, so the latest message alone misses it.
41
- export const RECENT = 6;
42
-
43
21
  // The facts for one thread on an open track.
44
22
  export function threadFacts(t, snap, track) {
45
23
  const humans = new Set((snap?.humans ?? []).map((h) => h.name));
46
24
  const ms = (t.messages ?? []).filter(said);
47
25
  let h = ms.length - 1;
48
26
  while (h >= 0 && !isHuman(ms[h], humans)) h--;
49
- const human = h >= 0 ? ms[h] : null;
50
- const bot = ms.findLast((m) => !isHuman(m, humans)) ?? null;
51
- const sessions = Object.values(snap?.sessions ?? {}).filter((s) => s?.threadId === t.id);
52
- const summaryAt = newest(track.summary?.at, t.summary?.at);
53
- const since = ms.filter((m) => !summaryAt || m.at > summaryAt);
27
+ const summaryAt = track.summary?.at ?? null;
54
28
  return {
55
29
  id: t.id,
56
30
  title: t.title ?? t.id,
57
31
  track: track.id,
58
32
  trackTitle: track.title ?? track.id,
59
33
  archived: !!t.archived,
60
- phase: t.work?.phase ?? null,
61
34
  working: Object.entries(t.status ?? {}).filter(([, s]) => s === "working").map(([b]) => b).sort(),
62
- human: human && { id: human.id, author: human.author, at: human.at, text: human.text },
35
+ humanAt: h >= 0 ? ms[h].at : null,
63
36
  botsSinceHuman: ms.length - 1 - h,
64
- bot: bot && { id: bot.id, author: bot.author, at: bot.at, text: bot.text },
65
- lastId: ms.at(-1)?.id ?? null,
66
37
  lastAt: ms.at(-1)?.at ?? null,
67
- eventAt: newest(...sessions.map((s) => s.lastEventAt), ...Object.values(t.busySince ?? {})),
38
+ summarized: !!track.summary?.text,
68
39
  summaryAt,
69
- sinceSummary: since.length,
70
- recent: since.slice(-RECENT).map((m) => ({ id: m.id, author: m.author, at: m.at, text: m.text })),
71
- summary: track.summary?.text ? { text: track.summary.text, by: track.summary.by ?? null } : null,
40
+ sinceSummary: ms.filter((m) => !summaryAt || m.at > summaryAt).length,
72
41
  };
73
42
  }
74
43
 
75
- // What a verdict was made on: a thread is asked again once this differs.
76
- // The bots' event time isn't in it: it moves every tick while a bot works.
77
- export const basisOf = (f) => [f.lastId, f.summaryAt ?? "", f.working.join(",")].join("|");
78
-
79
44
  // Every filed thread on an open track, as facts; and of those, the ones to
80
- // ask about: activity since the last summary, on a basis not yet asked
81
- // about (seen: { [threadId]: basis }).
82
- export function tracksFacts(snap, seen = {}) {
45
+ // wake main with: a message newer than its mark. A thread without a mark
46
+ // (the first run, or filed onto an open track since) has never been listed,
47
+ // so all of it is news. A summary's write time isn't a mark: it doesn't say
48
+ // which threads' messages the summary covered.
49
+ export function tracksFacts(snap, marks = {}) {
83
50
  const tracks = snap?.tracks ?? {};
84
51
  const all = [];
85
52
  for (const t of Object.values(snap?.threads ?? {})) {
@@ -87,10 +54,9 @@ export function tracksFacts(snap, seen = {}) {
87
54
  const track = tracks[pathOf(t)];
88
55
  if (track && track.open !== false) all.push(threadFacts(t, snap, track));
89
56
  }
90
- const since = (f) => !f.summaryAt || [f.lastAt, f.eventAt].some((at) => at && at > f.summaryAt);
91
- const ask = all.filter((f) => f.lastId && since(f) && seen[f.id] !== basisOf(f));
92
- ask.sort((a, b) => a.track.localeCompare(b.track) || String(b.lastAt).localeCompare(String(a.lastAt)));
93
- return { all, ask };
57
+ const wake = all.filter((f) => f.lastAt && f.lastAt > (marks[f.id] ?? ""));
58
+ wake.sort((a, b) => a.track.localeCompare(b.track) || String(b.lastAt).localeCompare(String(a.lastAt)));
59
+ return { all, wake };
94
60
  }
95
61
 
96
62
  const ago = (at, now) => {
@@ -99,128 +65,43 @@ const ago = (at, now) => {
99
65
  return min < 60 ? `${min}m ago` : min < 48 * 60 ? `${Math.round(min / 60)}h ago` : `${Math.round(min / 1440)}d ago`;
100
66
  };
101
67
 
102
- export const FACT_CHARS = { summary: 2000, human: 400, bot: 600, recent: 500 };
103
-
104
- // One track's summary as the model reads it, once for all its threads.
105
- export function trackText(f, now) {
106
- return `## Track ${f.track} ("${f.trackTitle}")\nTrack summary: ${f.summary ? `written ${ago(f.summaryAt, now)} by ${f.summary.by ?? "?"}:\n${clip(String(f.summary.text).trim(), FACT_CHARS.summary)}` : "none yet"}`;
107
- }
108
-
109
- // One thread's facts as the model reads them.
110
- export function factsText(f, now) {
111
- const msg = (m, n) => (m ? `${m.author}, ${ago(m.at, now)}: ${clip(flat(m.text), n)}` : "none");
112
- return [
113
- `### Thread ${f.id}: "${f.title}"${f.archived ? " (archived)" : ""}`,
114
- `Latest human message: ${msg(f.human, FACT_CHARS.human)}`,
115
- `Bot messages since the latest human message: ${f.botsSinceHuman}`,
116
- `Latest bot message: ${msg(f.bot, FACT_CHARS.bot)}`,
117
- `Messages since the track summary was written: ${f.sinceSummary}${f.sinceSummary > f.recent.length ? ` (the last ${f.recent.length} below)` : ""}`,
118
- ...f.recent.map((m) => `- ${msg(m, FACT_CHARS.recent)}`),
119
- `Latest bot activity of any kind (tool calls, subagents included): ${ago(f.eventAt, now)}`,
120
- `Bots working now: ${f.working.join(", ") || "none"}${f.phase ? ` · phase the bots reported: ${f.phase}` : ""}`,
121
- ].join("\n");
122
- }
123
-
124
- // The verdict: one factual question, does the track summary already state
125
- // what this thread's latest messages say? Only `missing` wakes main.
126
- export const VERDICT = {
127
- missing: "The thread's latest messages state where the work stands (a decision, a plan, a PR opened, pushed, reviewed or merged, work finished, a problem, a question for the human) and the track summary doesn't state it, or states an older status (e.g. the summary calls a PR a draft or awaiting review and the thread says it merged). Also when the track has no summary yet.",
128
- stated: "The track summary already states it, including the current status of every PR and decision the messages mention. A summary that names the PR but gives an older status doesn't count.",
129
- chatter: "The messages don't state anything about where the work stands: acks, progress narration, a bot handing off to another bot mid-task with no outcome yet.",
130
- };
131
- export const wakes = (d) => d.verdict === "missing";
132
- export const CHANGE = {
133
- nothing: "Nothing that matters changed.",
134
- progress: "Work moved forward; nothing settled yet.",
135
- plan: "A plan or approach was posted or changed.",
136
- needs_human: "A bot now waits on the human: a question, a decision or a sign-off.",
137
- decided: "The human decided, approved or redirected something.",
138
- pr: "A PR was opened, pushed, reviewed or its CI changed.",
139
- landed: "Work was merged, shipped or finished.",
140
- problem: "Something broke, failed or got stuck.",
141
- };
142
- // The evaluation for one thread: its track's summary, then its facts.
143
- // -> { state, questions }
144
- export function threadQuestions(f, now) {
145
- const questions = {
146
- verdict: { type: "choice", instructions: "Does the track summary already state what this thread's messages since it say, with each PR's and decision's current status?", criteria: VERDICT },
147
- change: { type: "choice", instructions: "What changed in this thread since the track summary was written?", criteria: CHANGE },
148
- };
149
- const state = [
150
- "A chat thread where AI bots work for a human. It sits on a track (a feature); main keeps a short summary per track so the human sees what's up without reading the threads. Compare the thread's messages with the track summary's text: answer from what the summary says, not from how long ago it was written. Times are relative to now.",
151
- trackText(f, now),
152
- factsText(f, now),
153
- ].join("\n\n");
154
- return { state, questions };
155
- }
156
-
157
- // Ask about each thread in `ask`, one evaluation per thread, all at once:
158
- // given every thread in one evaluation, the model answered for the wrong
159
- // thread (merges in a thread's latest message read as already stated). -> { decided: [{ ...facts,
160
- // verdict, change }], usage, ms }, or null when a call failed or an answer
161
- // is invalid.
162
- export async function decideTracks(ask, { evaluate, model = DECISION_MODEL, now = Date.now(), timeoutMs = 30_000, log = console }) {
163
- const started = Date.now();
164
- let rs;
165
- try {
166
- rs = await Promise.all(ask.map((f) => evaluate({ model, ...threadQuestions(f, now), maxRetries: 1, abortSignal: AbortSignal.timeout(timeoutMs) })));
167
- } catch (err) {
168
- log?.warn?.(`tracks: evaluation failed, tick skipped: ${err?.message ?? err}`);
169
- return null;
170
- }
171
- const ms = Date.now() - started;
172
- const pick = (r, q, criteria) => {
173
- const a = r?.answers?.[q];
174
- return a?.type === "choice" && Object.hasOwn(criteria, a.choice) ? a.choice : null;
175
- };
176
- const decided = ask.map((f, i) => ({ ...f, verdict: pick(rs[i], "verdict", VERDICT), change: pick(rs[i], "change", CHANGE) }));
177
- if (decided.some((d) => !d.verdict || !d.change)) {
178
- log?.warn?.("tracks: invalid answer, tick skipped");
179
- return null;
180
- }
181
- const usage = { inputTokens: rs.reduce((n, r) => n + (r?.usage?.inputTokens ?? 0), 0) };
182
- log?.log?.(`tracks: asked about ${ask.length} threads in ${ms} ms (${usage.inputTokens} input tokens), ${decided.filter(wakes).length} need an update`);
183
- return { decided, usage, ms };
184
- }
185
-
186
- // The post that wakes main: only the threads that need an update, by track,
187
- // each with the model's answers and its facts.
188
- export function tracksNotice(decided, now = Date.now()) {
68
+ // The post that wakes the summary writer (main, unless the workspace's
69
+ // schedule names another bot): the threads with new messages, by track.
70
+ export function tracksNotice(wake, now = Date.now(), writer = "main") {
189
71
  const lines = [
190
- "@main These threads need their track summary updated; the tracks classifier skipped the rest. Keep each track's summary current with `set_summary` (scope `path:<path>`), a short markdown note of what's up, written for the human. Read the thread (`read_thread`) where the facts below aren't enough. Reply here with one line; mention nobody.",
72
+ `@${writer} ` + "These threads have new messages since you last saw them. Read each (`read_thread`) and, where its track's summary no longer states where the work stands, rewrite it with `set_summary` (scope `path:<path>`), a short markdown note of what's up, written for the human. Leave a summary that's still right as it is. Reply here with one line; mention nobody.",
191
73
  ];
192
74
  let track = null;
193
- for (const d of decided) {
75
+ for (const d of wake) {
194
76
  if (d.track !== track) {
195
77
  track = d.track;
196
- lines.push("", `- **${d.trackTitle}** (\`${d.track}\`): ${d.summary ? `summary written ${ago(d.summaryAt, now)}` : "no summary yet"}`);
78
+ lines.push("", `- **${d.trackTitle}** (\`${d.track}\`): ${d.summarized ? `summary written ${ago(d.summaryAt, now)}` : "no summary yet"}`);
197
79
  }
198
- const human = d.human ? `human ${ago(d.human.at, now)}, ${d.botsSinceHuman} bot messages since` : `no human message, ${d.botsSinceHuman} bot messages`;
199
- lines.push(` - [${d.id}](${d.id}) ${d.title}: ${d.verdict}, changed: ${d.change}; ${human}; latest message ${ago(d.lastAt, now)}${d.working.length ? `; ${d.working.join(", ")} working` : ""}${d.archived ? " (archived)" : ""}`);
80
+ const human = d.humanAt ? `human ${ago(d.humanAt, now)}, ${d.botsSinceHuman} bot messages since` : `no human message, ${d.botsSinceHuman} bot messages`;
81
+ lines.push(` - [${d.id}](${d.id}) ${d.title}: ${d.sinceSummary} messages since the summary; ${human}; latest message ${ago(d.lastAt, now)}${d.working.length ? `; ${d.working.join(", ")} working` : ""}${d.archived ? " (archived)" : ""}`);
200
82
  }
201
83
  return lines.join("\n");
202
84
  }
203
85
 
204
- // One tick. State: { seen: { [threadId]: basis of its last verdict } }, kept
205
- // only for threads still on an open track.
206
- // -> { posts, state, decided, usage, ms }
207
- /** @param {any} hub @param {{ evaluate?: (options: any) => Promise<any>, now?: number, log?: any, model?: string }} [opts] */
208
- export async function runTracks(hub, { evaluate, now = Date.now(), log = console, model } = {}) {
209
- let tick = { decided: [], usage: null, ms: 0 };
86
+ // One tick. State: { marks }, per thread on an open track, the newest
87
+ // message main has been handed (a woken thread's is the one just listed).
88
+ // Saved only with a post, or on the first run. `writer`: the bot the notice
89
+ // tags. One that isn't on the roster gets main a notice saying so, and no
90
+ // marks move, so nothing is skipped once it's fixed.
91
+ // -> { posts, state, wake }
92
+ /** @param {any} hub @param {{ now?: number, writer?: string }} [opts] */
93
+ export async function runTracks(hub, { now = Date.now(), writer = "main" } = {}) {
94
+ let wake = [];
210
95
  const result = await runOnce(hub, "tracks", async (snap, saved) => {
211
- const prev = saved?.seen ?? {};
212
- const { all, ask } = tracksFacts(snap, prev);
213
- if (ask.length && evaluate) {
214
- const out = await decideTracks(ask, { evaluate, model, now, log });
215
- if (!out) return { posts: [], state: saved, changed: false };
216
- tick = out;
96
+ if (!(snap?.agents ?? []).some((a) => a.name === writer)) {
97
+ return { posts: [`@main The tracks schedule is set to tag \`${writer}\`, which isn't a bot in this workspace, so no track summaries are being updated. Fix the writer in agents/main/agent/schedules/tracks.ts.`], state: saved, changed: false };
217
98
  }
218
- const live = new Set(all.map((f) => f.id));
219
- const seen = Object.fromEntries(Object.entries(prev).filter(([id]) => live.has(id)));
220
- for (const d of tick.decided) seen[d.id] = basisOf(d);
221
- const wake = tick.decided.filter(wakes);
222
- const changed = JSON.stringify(Object.entries(seen).sort()) !== JSON.stringify(Object.entries(prev).sort());
223
- return { posts: wake.length ? [tracksNotice(wake, now)] : [], state: { seen }, changed };
99
+ const marks = saved?.marks ?? null;
100
+ let all;
101
+ ({ all, wake } = tracksFacts(snap, marks ?? {}));
102
+ if (marks && !wake.length) return { posts: [], state: saved, changed: false };
103
+ const state = { marks: Object.fromEntries(all.map((f) => [f.id, f.lastAt ?? ""])) };
104
+ return { posts: wake.length ? [tracksNotice(wake, now, writer)] : [], state, changed: true };
224
105
  }, { now });
225
- return { ...result, ...tick };
106
+ return { ...result, wake };
226
107
  }
@@ -1,3 +1,9 @@
1
+ export declare function tracksSchedule({ writer }?: {
2
+ writer?: string;
3
+ }): {
4
+ cron: string;
5
+ run(): Promise<void>;
6
+ };
1
7
  declare const _default: {
2
8
  cron: string;
3
9
  run(): Promise<void>;
@@ -1,16 +1,18 @@
1
- // main's tracks schedule: every 10 minutes, gathers facts on the threads filed
2
- // in open tracks and asks openai/gpt-6-luna-decisions, one `evaluate` per
3
- // thread (eve's), which of them need their track summary updated.
4
- // Only those tag main in sch-main-tracks, with the model's answers; main then
5
- // rewrites the summaries with set_summary. The pass: runTracks in
6
- // ./tracks-lead.mjs.
1
+ // main's tracks schedule: every 10 minutes, tags main in sch-main-tracks
2
+ // with the threads on open tracks that have new messages since a notice last
3
+ // listed them; main then rewrites the summaries that need it with
4
+ // set_summary. No model call. The pass: runTracks in ./tracks-lead.mjs. A
5
+ // workspace that has another bot write summaries exports
6
+ // tracksSchedule({ writer: "<bot>" }) from its agents/main/agent/schedules/tracks.ts.
7
7
  import { defineSchedule } from "eve/schedules";
8
- import { evaluate } from "eve/ai";
9
8
  import { createHub } from "./hub.mjs";
10
9
  import { runTracks } from "./tracks-lead.mjs";
11
- export default defineSchedule({
12
- cron: "*/10 * * * *",
13
- async run() {
14
- await runTracks(createHub(), { evaluate });
15
- },
16
- });
10
+ export function tracksSchedule({ writer } = {}) {
11
+ return defineSchedule({
12
+ cron: "*/10 * * * *",
13
+ async run() {
14
+ await runTracks(createHub(), { writer });
15
+ },
16
+ });
17
+ }
18
+ export default tracksSchedule();
@@ -12,9 +12,9 @@ You don't know in advance which bots exist or what they're good at, and the team
12
12
  - **Cross-thread chores (merge, rebase, close out, restart) fan out to the threads that own the work.** Post in each owning thread and @-mention the bot that built it. That bot lands its own branch, rebases onto the latest target and fast-forwards (retrying if the target moved), checks the build, and reports the sha in its own thread. Never collect several threads' work into one bot or into your own session. Track which threads have landed and report back where the human asked.
13
13
 
14
14
  ## Opening work
15
- **Paths.** A thread is one piece of work: one task, usually one PR. Every thread sits on one **path**, `project/track`, e.g. `hub/native/composer`: the first segment is the project, the rest is the feature or effort inside it ("world-hub", "AppKit feed port"), which spans many threads and PRs. Put each thread you open on the path for its area if one exists, even read broadly (`list_threads` shows each thread's path; a bug or follow-up in an area belongs to it). A new path only for work that clearly starts a new effort, named for the feature, never the task. Near-duplicates (`hub/tracks` and `hub/tracks-ui`) get merged with `move_path`. Never move a thread whose path the human set (it's locked).
15
+ **Paths.** A thread is one piece of work: one task, usually one PR. Every thread sits on one **path**, `project/track`, e.g. `web/checkout/cart`: the first segment is the project, the rest is the feature or effort inside it ("checkout redesign", "billing migration"), which spans many threads and PRs. Put each thread you open on the path for its area if one exists, even read broadly (`list_threads` shows each thread's path; a bug or follow-up in an area belongs to it). A new path only for work that clearly starts a new effort, named for the feature, never the task. Near-duplicates (`web/checkout` and `web/checkout-ui`) get merged with `move_path`. Never move a thread whose path the human set (it's locked).
16
16
 
17
- **Summaries.** You lead every track: keep each one's summary current with `set_summary` (scope `path:<path>`, e.g. `path:hub/native`), a short free-form markdown note for the human of what's up in that feature. You decide what's worth saying and when; there's no fixed format. Your `tracks` schedule tags you in `sch-main-tracks` only for threads a classifier judged to need a summary update, each with its verdict, what changed and its facts; update the summaries that need it (reading a thread when the facts aren't enough), reply there with one line and mention nobody. A thread can carry a summary too (scope `thread:<id>`: what it's about and where it stands); write one when a thread's opening ask no longer describes it. `list_threads` returns both, and the classifier files threads by them.
17
+ **Summaries.** You lead every track: keep each one's summary current with `set_summary` (scope `path:<path>`, e.g. `path:web/checkout`), a short free-form markdown note for the human of what's up in that feature. You decide what's worth saying and when; there's no fixed format. Every 10 minutes your `tracks` schedule tags the summary writer in `sch-main-tracks` with each thread that has new messages since a notice last listed it, by track, with its facts. The writer is you unless the schedule names another bot; then leave those notices to it. When it's you: read each listed thread (`read_thread`) and rewrite its track's summary where it no longer states where the work stands; leave a summary that's still right. Reply there with one line and mention nobody. A thread can carry a summary too (scope `thread:<id>`: what it's about and where it stands); write one when a thread's opening ask no longer describes it. `list_threads` returns both, and the classifier files threads by them.
18
18
 
19
19
  **Track chats.** Each path can have one track chat: an ordinary thread on that path, opened from the track page's composer, where the human asks you about the track. 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 a new thread on the path and hand it to a builder, and link that thread in your reply. A track chat isn't counted among the path's threads and doesn't trigger your `tracks` schedule.
20
20
 
@@ -22,7 +22,7 @@ Call `create_thread` per piece of work, with its `path` and a `domain` tag (reus
22
22
 
23
23
  Then reply to the human in 1–3 lines: what you opened and who's on it. In a DM, @-mentions never wake anyone; they're links for the human, so tag every bot you refer to there.
24
24
 
25
- When the human starts a thread themselves (from the new-thread view), it comes to you untitled and already is the piece of work: don't `create_thread` another. Name and tag it first with one `rename_thread` call (a few words for what's asked, plus `project` and `domain`; the delivery lists the tags in use), then route the work inside it by @-mentioning the bot who should start, or answer if it's a quick question.
25
+ When the human starts a thread themselves (from the new-thread view), it comes to you untitled and already is the piece of work: don't `create_thread` another. File it first with one `update_thread` call (a few words for what's asked as the `title`, a `path` (project/track), a `domain` and a short `summary`; the delivery lists the projects and domains in use and every track with its summary), then route the work inside it by @-mentioning the bot who should start, or answer if it's a quick question.
26
26
 
27
27
  ## Memory
28
28
  When the human states a durable preference (one that should hold beyond the current task), add it as a memory entry in the workspace repo: `memory/<id>.md` if it holds for every bot (naming no bot; a rule for one role says the role), or `agents/<bot>/memory/<id>.md` if it holds for one bot only (it may name that bot, no other). Front matter `id` (the file name, not already used in the other layer), `status: active`, `since` (today), `evidence` (`[thr_…/msg_…]`), then the rule in at most 3 lines. If it replaces an entry, set that one's `status: superseded` and `superseded_by: <new id>` rather than rewriting it. It ships in the next workspace sync PR; each bot recalls it on its next turn, as a `Standing decision <id>:` or `Your standing decision <id>:` message.
@@ -32,13 +32,13 @@ When the human states a durable preference (one that should hold beyond the curr
32
32
  - `remove_agent` removes a bot from the team and shuts it down (name). Use it when the human asks. It can't remove main or humans.
33
33
  - `show` moves the human's screen to a thread or a DM, optionally scrolled to one message. `create_thread` already takes them to the new thread.
34
34
  - `post_message` posts as you into an existing thread (`threadId`) or a bot's DM (`agent`), from any session: e.g. telling several stalled threads to retry. Its @-mention wakes that bot just like a reply (one mention, never yourself); archived threads refuse it. Every bot has it, not just you. Without a target it posts into the thread you're in, which is how you talk there.
35
- - **Thread tags** (`project`, `domain`) group the human's sidebar. There's no fixed list: the tags are whatever threads use. Keeping them tidy is your job, ongoing: retag a thread whose tag no longer fits what it's doing, merge near-duplicates and one-offs into bigger tags, rename a tag. Do it as a model pass: `tag_threads` `vocabulary` for what's in use and what's untagged, `list_threads` / `read_thread` to read what the threads actually did (not just their titles), then `apply_tags` with `assignments` (tags per thread), `merges` (domains only: `from` into `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, which heads its sidebar group: `mcpchannel` → "MCP Channel"; a tag without one shows title-cased, so name any that read wrong that way). It applies right away, no approval needed; say in a line what you changed. For a big pass the human asked to review first, stage it with `tag_threads` `propose`, post the table, and `apply_tags` its `proposalId` once they say yes. Tags and labels the human set by hand stay put, merges included; change one only when the human asks you to.
35
+ - **Thread tags** (`project`, `domain`) group the human's sidebar. There's no fixed list: the tags are whatever threads use. Keeping them tidy is your job, ongoing: retag a thread whose tag no longer fits what it's doing, merge near-duplicates and one-offs into bigger tags, rename a tag. Do it as a model pass: `tag_threads` `vocabulary` for what's in use and what's untagged, `list_threads` / `read_thread` to read what the threads actually did (not just their titles), then `apply_tags` with `assignments` (tags per thread), `merges` (domains only: `from` into `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, which heads its sidebar group: `payments-api` → "Payments API"; a tag without one shows title-cased, so name any that read wrong that way). It applies right away, no approval needed; say in a line what you changed. For a big pass the human asked to review first, stage it with `tag_threads` `propose`, post the table, and `apply_tags` its `proposalId` once they say yes. Tags and labels the human set by hand stay put, merges included; change one only when the human asks you to.
36
36
  - `archive_thread` takes a thread off the active list once the human has signed off on it or dropped it; history is kept. Archive only when the human says so or the thread's work is clearly finished, and give a short `reason`. `unarchive_thread` brings one back.
37
37
 
38
38
  ## The Dash
39
39
  The Dash is the human's view of open threads that need them, grouped by project, then by delay. Each thread can carry one **entry**: `importance` (`high` | `normal` | `low`: how much it matters given the human's role), `delay` (`blocking`: a bot can't go on until the human acts; `land`: finished work waits on a merge or sign-off and goes stale; `fyi`: nothing waits on it), an optional `reason`, and `deferred` (on the backburner: hidden, and not classified again until un-deferred). The sidebar badge counts `blocking` entries. Threads where a bot is working are hidden until they settle.
40
40
  - **Classifier.** Your `dash` schedule classifies every idle, non-deferred thread that moved on since its entry was written, every 10 minutes, and writes the entries itself. You don't rewrite the Dash on each tick.
41
41
  - **Ticks.** The same schedule also scans PRs, threads where a bot asks the human, and Dash threads that moved on, and posts in `sch-main-dash` only when something changed. On a tick, fix only what the classifier got wrong with `dash_update` (fields you leave out keep their value): raise `importance` for what matters more given the human's role, set `deferred: true` for work the human parked, `remove` an entry that no longer applies. Often nothing needs changing. Don't reply in the tick thread beyond a one-line note, and mention nobody.
42
- - **Unfiled paths.** The classifier files unfiled threads onto an existing path in their project. When none fits, one notice gives every unfiled thread in that project alongside every path in use. Read their briefs and put each on a listed path where one fits, or a new `project/feature` path, with `rename_thread` (`threadId` and `path` only, including threads you aren't in). Merge near-duplicates with `move_path`. Never move a human-locked path.
42
+ - **Unfiled paths.** The classifier files unfiled threads onto an existing path in their project. When none fits, one notice gives every unfiled thread in that project alongside every path in use. Read their briefs and put each on a listed path where one fits, or a new `project/feature` path, with `update_thread` (`threadId` and `path` only, including threads you aren't in). Merge near-duplicates with `move_path`. Never move a human-locked path.
43
43
  - **Deferred.** The human can defer or bring back a thread from the Dash themselves; don't undo their call.
44
44
  - **Report only.** The Dash never nudges bots, merges, or posts into the threads it lists.
@@ -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, `rename_thread` retitles it (everyone sees an inline "renamed this thread: old → new" line). 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, 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.
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.
@@ -44,9 +44,15 @@ Anything you post outside this workspace (GitHub comments and reviews, PR descri
44
44
 
45
45
  ## Style
46
46
 
47
- Precise and technical. Lead with the answer, then the 1–3 facts that decide it: file:line, numbers, the exact command or flag. Write for an engineer; don't simplify. Short paragraphs. Keep replies under ~180 words unless you're posting a plan or a review. No filler, no disclaimers, no "great question", no recap of the plan back to its author.
47
+ Signal first: say only the essence, what the reader must know or decide. Precise and technical, for an engineer.
48
48
 
49
- Link what you reference, so the human can click it, using the repo you actually worked in. PRs and issues get full URLs, `[#4234](https://github.com/<owner>/<repo>/pull/4234)`; commits too, ``[`684725be`](https://github.com/<owner>/<repo>/commit/684725be)``. Code on GitHub gets a permalink pinned to a commit sha, ``[`path:267`](https://github.com/<owner>/<repo>/blob/<sha>/path#L267)``, never a branch URL that drifts. Local files get their absolute path and line, ``[`docs/channels/mcp.mdx:267`](/Users/<you>/wrk/eve/docs/channels/mcp.mdx#L267)``; clicking opens the file in the human's editor. Bare `#123`, shas and paths stay plain text. Never show a raw URL, in prose or in a table cell: every link is `[label](url)`, and the label names the target (`#68`, a short sha, `file.swift:731`, a page title), never the URL itself. The one exception is when the human asks for the literal URL, e.g. to copy it.
49
+ - Delta only. Post only what's new since the last message in the thread. Never restate what another bot already posted (PR number, sha, file, verdict): link the message that posted it. If you have nothing new, react instead of posting.
50
+ - Crux first. When you need something from the reader, the first sentence is the one question, answerable in a word: "Land #422 now, or hold it until Kit works?" Otherwise the first sentence is the verdict or what changed. Then only the 1–3 facts that decide it.
51
+ - Every sentence must change what the reader does next. If losing it changes nothing, cut it. No filler, no disclaimers, no "great question", no recap of the plan back to its author, no bold labels or code chips on ordinary nouns; code formatting is for what the reader will paste or search.
52
+ - Name things at the reader's level. Say "the worker fix" or "GitHub writes", not "fix 1" or `resolveSubagent`. Identifiers belong behind the link.
53
+ - Depth is one step away, and it is shown, not written. Plans, briefs and reviews keep only the essence inline; the full body lives in a file you link, with the file:line, test or trace next to each claim. When asked to go deeper, don't add prose. Give a concrete scenario in numbered steps (who does what, then what happens: "1. agent A calls a subagent 2. it runs on the hub's model cap"), then the code, diff, trace or command output that proves it, trimmed to the lines that make the point, then a link pinned to a commit.
54
+
55
+ Link what you reference, so the human can click it, using the repo you actually worked in. PRs and issues get full URLs, `[#4234](https://github.com/<owner>/<repo>/pull/4234)`; commits too, ``[`684725be`](https://github.com/<owner>/<repo>/commit/684725be)``. Code on GitHub gets a permalink pinned to a commit sha, ``[`path:267`](https://github.com/<owner>/<repo>/blob/<sha>/path#L267)``, never a branch URL that drifts. Local files get their absolute path and line, ``[`docs/setup.md:42`](/Users/<you>/wrk/<repo>/docs/setup.md#L42)``; clicking opens the file in the human's editor. Bare `#123`, shas and paths stay plain text. Never show a raw URL, in prose or in a table cell: every link is `[label](url)`, and the label names the target (`#68`, a short sha, `file.swift:731`, a page title), never the URL itself. The one exception is when the human asks for the literal URL, e.g. to copy it.
50
56
 
51
57
  ## Client surface
52
58
 
@@ -83,5 +89,4 @@ renders as:
83
89
  - `attach_image` shows an image tile. `![]()` shows only alt text; raw HTML shows literally.
84
90
  - Nothing collapses, and the Mentions inbox previews only the first plain-text line of each message. So each message's first bubble is its point, in at most 2 sentences; detail follows after `---`.
85
91
  - Separate updates (started, found, done) are separate posts; `---` splits one message into bubbles.
86
- - Optional detail doesn't go in the message. If it's worth having, end with one line, `More: <topic> · <topic>`, and expand a topic when asked.
87
92
  - Tables only for 2+ rows of short, comparable values: at most 4 columns sharing the width equally, a few words per cell. A single item, or anything that needs a reason, goes in a sentence or bullets. Shorten shas to 8 characters; keep URLs and paths in link text, not cells.
@@ -10,7 +10,7 @@ export default defineTool({
10
10
  'Returns { result: "queued" | "steered" | "aborted" | "not busy there", ... }. On "not busy there" from steer, send with queue instead.',
11
11
  ].join("\n"),
12
12
  inputSchema: z.object({
13
- agent: z.string().describe("The bot, e.g. prolific-executor."),
13
+ agent: z.string().describe("The bot, a bot's name, as the workspace state lists it."),
14
14
  threadId: z.string().optional().describe("The thread, e.g. thr_006b. Needed for queue and steer, and for abort with input."),
15
15
  input: z.string().optional().describe("What to tell the bot, written to it. Needed for queue and steer; optional for abort."),
16
16
  policy: z.enum(["queue", "steer", "abort"]).optional().describe("queue (default), steer, or abort."),
@@ -4,14 +4,14 @@
4
4
  import { defineTool } from "eve/tools";
5
5
  import { z } from "zod";
6
6
  import { registryFetch } from "../registry-fetch.mjs";
7
- const tag = z.string().describe("Lowercase slug, e.g. 'native'.");
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. mcpchannel -> 'MCP Channel'; 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` (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.",
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. 'hub/native'; 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(), domain: tag.optional() }))
15
15
  .optional()
16
16
  .describe("Tags per thread."),
17
17
  merges: z
@@ -9,7 +9,7 @@ export default defineTool({
9
9
  description: "Publish the next version of an artifact: a versioned static site you own and keep current, such as a change explainer for a PR or stack. Builds it from `source` in your sandbox, deploys it, moves its stable URL to the new version, and shows a card in this thread (the same card updates on every later version). Takes a few minutes. Types: change-explainer (source: { fixture: <fixture dir>, repo: <git checkout>, scip?: <index.scip>, branch?: <ref> }).",
10
10
  inputSchema: z.object({
11
11
  type: z.enum(Object.keys(TYPES)).describe("Artifact type."),
12
- key: z.string().regex(/^[\w.-]{1,40}$/).describe("Stable key within the type, e.g. the fixture id `thr_05a3` or `eve-pr-4342`. Same key = same artifact, next version."),
12
+ key: z.string().regex(/^[\w.-]{1,40}$/).describe("Stable key within the type, e.g. the fixture id `thr_05a3` or `api-pr-123`. Same key = same artifact, next version."),
13
13
  source: z.record(z.string(), z.unknown()).describe("What the type builds from (see the description)."),
14
14
  }),
15
15
  async execute({ type, key, source }, ctx) {
@@ -4,14 +4,14 @@ import { registryFetch } from "../registry-fetch.mjs";
4
4
  export default defineTool({
5
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.",
6
6
  inputSchema: z.object({
7
- title: z.string().describe("Short thread title, e.g. 'DeepSWE hill-climb'."),
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
- project: z.string().optional().describe("Project tag: the codebase or effort, e.g. 'native', 'hub'."),
10
- domain: z.string().optional().describe("Domain tag: the kind of work, e.g. 'ui', 'infra', 'planning'."),
11
- projectLabel: z.string().optional().describe("Display name for a new project tag, e.g. 'MCP Channel'. Only names a tag that has none yet."),
12
- domainLabel: z.string().optional().describe("Display name for a new domain tag, e.g. 'UI'. Only names a tag that has none yet."),
13
- path: z.string().optional().describe("Path: project, then the track inside it, e.g. 'hub/native/composer' or 'eve/code-mode'. Reuse a path in use (list_threads shows each thread's); the first segment is the project."),
14
- pathLabel: z.string().optional().describe("Display name for a new path's last segment, e.g. 'Composer'. Only names a path that has none yet."),
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'."),
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."),
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
+ 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
17
  async execute({ title, message, project, domain, projectLabel, domainLabel, path, pathLabel, focus = true }, ctx) {
@@ -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. 'hub/native'; 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; 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.",
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."),
@@ -13,7 +13,7 @@ export default defineTool({
13
13
  participant: z.string().optional().describe("Only threads this bot or human is in."),
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
- path: z.string().optional().describe("Only threads on this path or under it, e.g. 'hub' or 'hub/native'."),
16
+ path: z.string().optional().describe("Only threads on this path or under it, e.g. 'web' or 'web/checkout'."),
17
17
  domain: z.string().optional().describe("Only threads with this domain tag ('none' for untagged)."),
18
18
  limit: z.number().int().min(1).max(500).optional().describe("Max threads (default 50)."),
19
19
  }),
@@ -1,15 +1,15 @@
1
1
  // The `move_path` tool (main's): rename or merge a path (project/track, e.g.
2
- // `hub/native`). Every thread on it or under it moves, keeping the part
2
+ // `web/checkout`). Every thread on it or under it moves, keeping the part
3
3
  // below; its label and summary carry over; later uses of the old path land
4
4
  // on the new one. Threads whose path a human set by hand stay (lib/paths.mjs).
5
5
  import { defineTool } from "eve/tools";
6
6
  import { z } from "zod";
7
7
  import { registryFetch } from "../registry-fetch.mjs";
8
8
  export default defineTool({
9
- description: "Rename or merge a path (project/track). Every thread on `from` or under it moves to `to`, keeping the part below (from 'native', to 'hub/native': 'native/composer' becomes 'hub/native/composer'). Use it to fold near-duplicate paths into one or to move a project under another. Threads whose path the human set by hand stay; the reply lists them.",
9
+ description: "Rename or merge a path (project/track). Every thread on `from` or under it moves to `to`, keeping the part below (from 'checkout', to 'web/checkout': 'checkout/cart' becomes 'web/checkout/cart'). Use it to fold near-duplicate paths into one or to move a project under another. Threads whose path the human set by hand stay; the reply lists them.",
10
10
  inputSchema: z.object({
11
- from: z.string().describe("The path to move, e.g. 'native' or 'hub/tracks-ui'."),
12
- to: z.string().describe("Where it goes, e.g. 'hub/native' or 'hub/tracks' (an existing path merges)."),
11
+ from: z.string().describe("The path to move, e.g. 'checkout' or 'web/checkout-ui'."),
12
+ to: z.string().describe("Where it goes, e.g. 'web/checkout' or 'web/payments' (an existing path merges)."),
13
13
  }),
14
14
  async execute({ from, to }, ctx) {
15
15
  const res = await registryFetch(`/api/paths/move`, {
@@ -16,7 +16,7 @@ import { registryFetch } from "../registry-fetch.mjs";
16
16
  export default defineTool({
17
17
  description: "Post a message as you. Leave out threadId and agent to post into the thread you're in, while you work: posting doesn't end your turn, and when a post is your turn's last act your end-of-turn text isn't posted. Or post into another workspace thread or a bot's DM. @-mentions wake bots the same way your replies do (at most one, never yourself); `@all` wakes every participant; each bot's gate decides whether it replies. Posting into a bot's DM wakes that bot. Archived threads are refused. A status post sets the thread's phase: planning, working, reviewing or done; allowed moves are start → planning or working, planning → working, working → reviewing, reviewing → working (fix first), planning (rethink) or done. Any other move is refused with the allowed next phases. The text is the one-line summary; metadata carries the PR and sha.",
18
18
  inputSchema: z.object({
19
- threadId: z.string().optional().describe("Another thread's id, e.g. thr_0002, or a DM id like dm-archer. Omit it to post into the thread you're in."),
19
+ threadId: z.string().optional().describe("Another thread's id, e.g. thr_0002, or a DM id, dm-<bot>. Omit it to post into the thread you're in."),
20
20
  agent: z.string().optional().describe("Post into this bot's DM instead of a thread."),
21
21
  text: z.string().describe("The message. @-mention the one bot who should act. On a status post: the one-line summary."),
22
22
  status: z.enum(["planning", "working", "reviewing", "done"]).optional().describe("The thread's phase after this post. Makes it a status post."),
@@ -38,7 +38,7 @@ export async function withImages(output) {
38
38
  return toolOutput.content(parts);
39
39
  }
40
40
  export default defineTool({
41
- description: "Read a workspace thread or DM by id (e.g. thr_006o, dm-archer) or exact title: its summary (participants, who's busy, archived or not) and its latest messages, oldest first. Works for any thread, not just the one you're in. Pass messageId to get just that message with its images, so you can see a screenshot posted in another thread.",
41
+ description: "Read a workspace thread or DM by id (e.g. thr_006o, or dm-<bot>) or exact title: its summary (participants, who's busy, archived or not) and its latest messages, oldest first. Works for any thread, not just the one you're in. Pass messageId to get just that message with its images, so you can see a screenshot posted in another thread.",
42
42
  inputSchema: z.object({
43
43
  threadId: z.string().describe("Thread id (thr_…, dm-<bot>) or exact title."),
44
44
  limit: z.number().int().min(1).max(200).optional().describe("How many of the latest messages (default 20)."),
@@ -4,7 +4,7 @@ import { registryFetch } from "../registry-fetch.mjs";
4
4
  export default defineTool({
5
5
  description: "Restart ONE bot's server so hub.workspace.json edits (model, reasoning, role, subagents) and code changes take effect, without touching any other bot. By default it waits until the bot is idle in every thread, then restarts; with whenIdle false it interrupts now, taking over a restart still waiting for idle. Any turn the restart cuts off is re-delivered to a fresh session afterwards, and messages sent meanwhile wait for the bot. Returns right away; check list_agents (its `restart` field) for progress. main can't restart itself (a human can: `hub restart --bot main`).",
6
6
  inputSchema: z.object({
7
- name: z.string().describe("The bot to restart, e.g. archer."),
7
+ name: z.string().describe("The bot to restart, a bot's name, as the workspace state lists it."),
8
8
  whenIdle: z.boolean().optional().describe("Wait until it's idle everywhere first (default true)."),
9
9
  timeoutMinutes: z.number().min(1).max(24 * 60).optional().describe("Give up waiting for idle after this long (default 60). Nothing is restarted on timeout."),
10
10
  }),
@@ -9,7 +9,7 @@ import { registryFetch } from "../registry-fetch.mjs";
9
9
  export default defineTool({
10
10
  description: "Write the summary of a path (project/track) or a thread: a short free-form markdown note, kept current. A path's: what's up in that feature, for the human (what's moving, what's blocked or waiting on them, what just landed). A thread's: what it's about and where it stands. You decide what's worth saying. It replaces the previous summary; empty text clears it. list_threads returns both.",
11
11
  inputSchema: z.object({
12
- scope: z.string().describe("What it summarizes: 'path:<path>' (e.g. 'path:hub/native') or 'thread:<id>' (e.g. 'thread:thr_0d00')."),
12
+ scope: z.string().describe("What it summarizes: 'path:<path>' (e.g. 'path:web/checkout') or 'thread:<id>' (e.g. 'thread:thr_0d00')."),
13
13
  text: z.string().describe("The whole new summary, markdown. Empty clears it."),
14
14
  }),
15
15
  async execute({ scope, text }, ctx) {
@@ -4,7 +4,7 @@
4
4
  import { defineTool } from "eve/tools";
5
5
  import { z } from "zod";
6
6
  import { registryFetch } from "../registry-fetch.mjs";
7
- const tag = z.string().describe("Lowercase slug, e.g. 'native'.");
7
+ const tag = z.string().describe("Lowercase slug, e.g. 'checkout'.");
8
8
  export default defineTool({
9
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.",
10
10
  inputSchema: z.object({
@@ -12,7 +12,7 @@ export default defineTool({
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. 'hub/native'; 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(), domain: tag.optional() }))
16
16
  .optional()
17
17
  .describe("For propose: tags per thread."),
18
18
  merges: z
@@ -1,22 +1,22 @@
1
1
  declare const _default: import("eve/tools").ToolDefinition<{
2
2
  threadId: string;
3
3
  title?: string | undefined;
4
- project?: string | undefined;
4
+ path?: string | null | undefined;
5
5
  domain?: string | undefined;
6
+ summary?: string | undefined;
7
+ pathLabel?: string | undefined;
6
8
  projectLabel?: string | undefined;
7
9
  domainLabel?: string | undefined;
8
- path?: string | null | undefined;
9
- pathLabel?: string | undefined;
10
10
  }, any> & {
11
11
  execute(input: {
12
12
  threadId: string;
13
13
  title?: string | undefined;
14
- project?: string | undefined;
14
+ path?: string | null | undefined;
15
15
  domain?: string | undefined;
16
+ summary?: string | undefined;
17
+ pathLabel?: string | undefined;
16
18
  projectLabel?: string | undefined;
17
19
  domainLabel?: string | undefined;
18
- path?: string | null | undefined;
19
- pathLabel?: string | undefined;
20
20
  }, ctx: import("eve/tools").ToolContext): Promise<any>;
21
21
  };
22
22
  export default _default;
@@ -0,0 +1,31 @@
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
3
+ // registry keeps the rename history and posts an inline "<bot> renamed this
4
+ // thread: old → new" marker; tags go on thread.tags (lib/tags.mjs) and the
5
+ // summary is the one `set_summary` writes (lib/paths.mjs setSummary).
6
+ import { defineTool } from "eve/tools";
7
+ import { z } from "zod";
8
+ import { registryFetch } from "../registry-fetch.mjs";
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.",
11
+ inputSchema: z.object({
12
+ threadId: z.string().describe("Thread id, e.g. thr_0002 (shown in the [thread: …] header)."),
13
+ title: z.string().optional().describe("New short title, e.g. 'Checkout: retry failed payments'. Omit to leave it."),
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."),
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
+ 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
+ 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."),
20
+ }),
21
+ async execute({ threadId, ...fields }, ctx) {
22
+ const res = await registryFetch(`/api/threads/${encodeURIComponent(threadId)}/rename`, {
23
+ method: "POST",
24
+ headers: { "content-type": "application/json" },
25
+ body: JSON.stringify({ sessionId: ctx.session.id, ...fields }),
26
+ });
27
+ if (!res.ok)
28
+ throw new Error(`update_thread failed: ${res.status} ${await res.text()}`);
29
+ return await res.json();
30
+ },
31
+ });
@@ -1,30 +0,0 @@
1
- // The `rename_thread` tool, shared by every bot: retitle a thread you're in as
2
- // its scope changes, and tag it (path, project, domain). The registry keeps the
3
- // rename history and posts an inline "<bot> renamed this thread: old → new"
4
- // marker; tags go on thread.tags (lib/tags.mjs).
5
- import { defineTool } from "eve/tools";
6
- import { z } from "zod";
7
- import { registryFetch } from "../registry-fetch.mjs";
8
- export default defineTool({
9
- description: "Rename and/or tag a workspace thread you're in. Rename 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 under a path (project/track, e.g. 'hub/native/composer': the first segment is the project, the rest the feature inside it) and tag a domain (the kind of work, e.g. 'ui', 'infra'); reuse a tag already in use when it fits; for a new one, also give its display name (pathLabel / domainLabel, e.g. 'MCP Channel'). 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 renamed.",
10
- inputSchema: z.object({
11
- threadId: z.string().describe("Thread id, e.g. thr_0002 (shown in the [thread: …] header)."),
12
- title: z.string().optional().describe("New short title, e.g. 'DeepSWE hill-climb: retry policy'. Omit to only retag."),
13
- project: z.string().optional().describe("Project tag: the codebase or effort, e.g. 'native', 'hub', 'eve'. Lowercase slug."),
14
- domain: z.string().optional().describe("Domain tag: the kind of work, e.g. 'ui', 'infra', 'agents', 'review', 'planning', 'research'. Lowercase slug."),
15
- projectLabel: z.string().optional().describe("Display name for a new project tag, shown as its sidebar group, e.g. 'MCP Channel' for mcpchannel. Only names a tag that has none yet."),
16
- domainLabel: z.string().optional().describe("Display name for a new domain tag, e.g. 'UI' for ui. Only names a tag that has none yet."),
17
- path: z.string().nullable().optional().describe("Path: project, then the track inside it, e.g. 'hub/native/composer' or 'eve/code-mode'. Reuse a path in use (list_threads shows each thread's); the first segment is the project. null: back to the project alone. A path a human set by hand stays."),
18
- pathLabel: z.string().optional().describe("Display name for a new path's last segment, e.g. 'Composer'. Only names a path that has none yet."),
19
- }),
20
- async execute({ threadId, title, project, domain, projectLabel, domainLabel, path, pathLabel }, ctx) {
21
- const res = await registryFetch(`/api/threads/${encodeURIComponent(threadId)}/rename`, {
22
- method: "POST",
23
- headers: { "content-type": "application/json" },
24
- body: JSON.stringify({ sessionId: ctx.session.id, title, project, domain, projectLabel, domainLabel, path, pathLabel }),
25
- });
26
- if (!res.ok)
27
- throw new Error(`rename_thread failed: ${res.status} ${await res.text()}`);
28
- return await res.json();
29
- },
30
- });