eve-hub 0.15.1 → 0.16.0

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.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Move the kit to eve 0.88.0 (v27 session events). The host sandbox takes eve's `signal` for aborts, bot models are `defineDynamic`, session limits use `sessionUsage`, and memory recall gives each entry one message under its own id on `turn.started`. Needs eve 0.88.0 or later.
8
+
9
+ ## 0.15.2
10
+
11
+ ### Patch Changes
12
+
13
+ - A wake-up posts at every firing. The hub no longer skips one because the bot had a turn in the thread since setting it, so a turn that only reacted can't cancel a promised check. Only deleting it, or `until` passing, stops it. The tool description and the shared instructions say so.
14
+
3
15
  ## 0.15.1
4
16
 
5
17
  ### Patch Changes
package/bash-run.mjs CHANGED
@@ -47,6 +47,7 @@ const asList = (code) => (code == null ? [] : Array.isArray(code) ? code : [code
47
47
  // that thread's own worktree, not hostRoot. The description is fixed when the
48
48
  // tool is defined, so it can't name the path; it says how to find it and keeps
49
49
  // the bot out of the shared checkouts.
50
+ /** @param {{ hostRoot?: string, code?: any, sessionWorktrees?: boolean }} [options] */
50
51
  export function bashDescription({ hostRoot, code = workspaceCode(), sessionWorktrees = process.env.HUB_SESSION_WORKTREES === "1" } = {}) {
51
52
  const dirs = asList(code);
52
53
  if (sessionWorktrees) {
@@ -238,6 +239,11 @@ export function selfStopRefusal(command, env = process.env) {
238
239
  return verb ? `[${selfStopMessage(verb, env.HUB_AGENT)}]` : null;
239
240
  }
240
241
 
242
+ /**
243
+ * @param {any} sandbox
244
+ * @param {{ command: string, timeoutSeconds?: number }} input
245
+ * @param {{ hostRoot?: string, code?: any, graceMs?: number, env?: NodeJS.ProcessEnv }} [options]
246
+ */
241
247
  export async function runBash(sandbox, { command, timeoutSeconds }, { hostRoot, code = workspaceCode(), graceMs = KILL_GRACE_MS, env = process.env } = {}) {
242
248
  const selfStop = selfStopRefusal(command, env);
243
249
  if (selfStop) return { exitCode: 1, stdout: "", stderr: selfStop, truncated: false };
@@ -1,2 +1,2 @@
1
- declare const _default: import("eve").DynamicSentinel<import("eve/connections").DynamicConnectionResult>;
1
+ declare const _default: import("eve").DynamicSentinel<import("eve/connections").DynamicConnectionResult, import("eve").DynamicContext>;
2
2
  export default _default;
@@ -32,21 +32,18 @@ async function refreshProjectToken() {
32
32
  }
33
33
  }
34
34
  // One connector per Hub app; Connect resolves credentials for the active user.
35
- // Resolve each turn so unconfigured apps run without Kit and connector changes
36
- // update the instance identity used when resuming an approval.
37
- export default defineDynamic({
38
- events: {
39
- "turn.started": async () => {
40
- const connector = process.env.KIT_CONNECTOR?.trim();
41
- if (!connector)
42
- return null;
43
- await refreshProjectToken();
44
- return defineMcpClientConnection({
45
- url: "https://kit.vercel.sh/api/mcp",
46
- instanceKey: connector,
47
- description: "The requesting user's connected services and saved skills through Vercel Kit. Search Kit's tools, then execute within the user's approved permissions.",
48
- auth: connect(connector),
49
- });
50
- },
51
- },
35
+ // eve resolves this before each model call, so unconfigured apps run without
36
+ // Kit and connector changes update the instance identity used when resuming
37
+ // an approval.
38
+ export default defineDynamic(async () => {
39
+ const connector = process.env.KIT_CONNECTOR?.trim();
40
+ if (!connector)
41
+ return null;
42
+ await refreshProjectToken();
43
+ return defineMcpClientConnection({
44
+ url: "https://kit.vercel.sh/api/mcp",
45
+ instanceKey: connector,
46
+ description: "The requesting user's connected services and saved skills through Vercel Kit. Search Kit's tools, then execute within the user's approved permissions.",
47
+ auth: connect(connector),
48
+ });
52
49
  });
@@ -59,7 +59,7 @@ export function hostedContext({ bot, kit, removed, deployment: d }, options) {
59
59
  : "- **Changing it:** there is no repository to commit to. The hub replaces these files on its next deploy, so an edit made only in this deployment does not last.",
60
60
  d.githubToken
61
61
  ? "- **GitHub:** `git` and `gh` in your shell are already signed in and set to sign commits as the owner; do not set your own author or identity, or GitHub rejects the push."
62
- : "- **GitHub:** your shell has no GitHub credential: `git` cannot authenticate, so a private repository won't clone and nothing can be pushed. When a `vercel-kit` connection is listed, reach GitHub through it (connection_search, then connection_execute); otherwise tell the human this deployment has no GitHub access.",
62
+ : "- **GitHub:** your shell has no GitHub credential: `git` cannot authenticate, so a private repository won't clone and nothing can be pushed. When a `vercel-kit` connection is listed, reach GitHub through it (eve__search, then eve__tool); otherwise tell the human this deployment has no GitHub access.",
63
63
  "- **Memory:** the standing decisions you recall are the `memory/` and `agents/<bot>/memory/` files, built into each deployment. An entry takes effect when the deployment that contains it is live, not on your next turn; with no repository it cannot last.",
64
64
  "- **Limits:** there is no `hub` CLI, you cannot restart yourself or any bot, and there are no host paths: your shell is a Vercel Sandbox with its own filesystem, not the owner's machine.",
65
65
  `- **Kit tools this build removed:** ${removed.length ? list(removed) : "none"}. They need the hub's machine, or change the roster, which a deployment does by commit.`,
package/host-sandbox.js CHANGED
@@ -657,7 +657,7 @@ export function hostSession(baseRoot, children, sessionId, { worktree, rootSessi
657
657
  return join(homedir(), path.slice(home.length));
658
658
  return path;
659
659
  };
660
- const spawn = async ({ command, workingDirectory, env, abortSignal }) => {
660
+ const spawn = async ({ command, workingDirectory, env, signal: abortSignal }) => {
661
661
  await ready();
662
662
  const cwd = hostPath(workingDirectory);
663
663
  // Paused first: nothing of the user's runs until the ticket is granted.
package/memory/bot.d.ts CHANGED
@@ -1,6 +1,4 @@
1
- declare const recall: ({ messages }: {
2
- messages: readonly unknown[];
3
- }) => {
1
+ declare const recall: () => {
4
2
  messages: any;
5
3
  };
6
4
  declare const _default: import("eve/memory").DefinedMemory<{
@@ -10,7 +8,6 @@ declare const _default: import("eve/memory").DefinedMemory<{
10
8
  readonly provider: {
11
9
  readonly recall: {
12
10
  "turn.started": typeof recall;
13
- "compaction.completed": typeof recall;
14
11
  };
15
12
  };
16
13
  }>;
package/memory/bot.js CHANGED
@@ -8,9 +8,9 @@ import { findWorkspaceRoot } from "../workspace.mjs";
8
8
  // renamed bot keeps its memory. Scope is that directory's name; with no bot
9
9
  // directory the slot is off.
10
10
  const botDir = () => findBotDir();
11
- const recall = ({ messages }) => {
11
+ const recall = () => {
12
12
  const dir = botDir();
13
- return { messages: dir ? loadBotMemoryRecall(findWorkspaceRoot(), dir, messages) : [] };
13
+ return { messages: dir ? loadBotMemoryRecall(findWorkspaceRoot(), dir) : [] };
14
14
  };
15
15
  export default defineMemory({
16
16
  description: "Standing decisions for this bot only, from its agents/<bot>/memory/ directory.",
@@ -20,6 +20,6 @@ export default defineMemory({
20
20
  return dir ? dir.split(/[\\/]/).at(-1) : null;
21
21
  },
22
22
  provider: defineMemoryProvider({
23
- recall: { "turn.started": recall, "compaction.completed": recall },
23
+ recall: { "turn.started": recall },
24
24
  }),
25
25
  });
package/memory/team.d.ts CHANGED
@@ -1,6 +1,4 @@
1
- declare const recall: ({ messages }: {
2
- messages: readonly unknown[];
3
- }) => {
1
+ declare const recall: () => {
4
2
  messages: {
5
3
  id: string;
6
4
  content: string;
@@ -13,7 +11,6 @@ declare const _default: import("eve/memory").DefinedMemory<{
13
11
  readonly provider: {
14
12
  readonly recall: {
15
13
  "turn.started": typeof recall;
16
- "compaction.completed": typeof recall;
17
14
  };
18
15
  };
19
16
  }>;
package/memory/team.js CHANGED
@@ -6,15 +6,15 @@ import { findWorkspaceRoot, readWorkspaceFile } from "../workspace.mjs";
6
6
  // The team architecture (team.mjs) as an eve memory slot: how work flows between roles, what each
7
7
  // role owns, where bots run and this bot's own place, built from the workspace's hub.workspace.json
8
8
  // (a hosted bot's is embedded by its Vercel build, prepare.mjs). Recall runs at the start of every
9
- // turn and after compaction, and adds one message only when the text differs from what this
10
- // session was last given. The bot is the agents/<bot> directory this process runs from (findBotDir).
9
+ // turn; eve replaces the session's copy only when the text changed. The bot is the agents/<bot>
10
+ // directory this process runs from (findBotDir).
11
11
  const warned = new Set();
12
- const recall = ({ messages }) => {
12
+ const recall = () => {
13
13
  try {
14
14
  const dir = findBotDir();
15
15
  if (!dir)
16
16
  return { messages: [] };
17
- return teamRecall(messages, architectureText(readWorkspaceFile(findWorkspaceRoot()).data, basename(dir)));
17
+ return teamRecall(architectureText(readWorkspaceFile(findWorkspaceRoot()).data, basename(dir)));
18
18
  }
19
19
  catch (error) {
20
20
  const note = error.message;
@@ -30,6 +30,6 @@ export default defineMemory({
30
30
  namespace: "hub-team-memory",
31
31
  scope: "workspace",
32
32
  provider: defineMemoryProvider({
33
- recall: { "turn.started": recall, "compaction.completed": recall },
33
+ recall: { "turn.started": recall },
34
34
  }),
35
35
  });
@@ -1,6 +1,4 @@
1
- declare const recall: ({ messages }: {
2
- messages: readonly unknown[];
3
- }) => {
1
+ declare const recall: () => {
4
2
  messages: any;
5
3
  };
6
4
  declare const _default: import("eve/memory").DefinedMemory<{
@@ -10,7 +8,6 @@ declare const _default: import("eve/memory").DefinedMemory<{
10
8
  readonly provider: {
11
9
  readonly recall: {
12
10
  "turn.started": typeof recall;
13
- "compaction.completed": typeof recall;
14
11
  };
15
12
  };
16
13
  }>;
@@ -2,22 +2,21 @@ import { defineMemory, defineMemoryProvider } from "eve/memory";
2
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
- // Recall re-reads memory/ at the start of every turn and after compaction and
6
- // returns only the entries whose text changed since this session last saw
7
- // them; eve appends those as user-role messages after the history, so the
8
- // system prompt and the cached prefix stay put when an entry changes.
5
+ // Recall re-reads memory/ at the start of every turn and returns every entry
6
+ // under its id; eve keeps recalled messages outside the history and replaces
7
+ // one only when its text changed.
9
8
  // One constant scope and namespace. The entries are memory/ (every bot) plus
10
9
  // the folders for the domains this bot lists in hub.workspace.json.
11
- const recall = ({ messages }) => {
10
+ const recall = () => {
12
11
  const root = findWorkspaceRoot();
13
12
  const dir = findBotDir();
14
- return { messages: loadMemoryRecall(root, messages, { domains: dir ? botDomains(root, dir) : [] }) };
13
+ return { messages: loadMemoryRecall(root, { domains: dir ? botDomains(root, dir) : [] }) };
15
14
  };
16
15
  export default defineMemory({
17
16
  description: "The human's standing decisions for this workspace, from its memory/ directory.",
18
17
  namespace: "hub-workspace-memory",
19
18
  scope: "workspace",
20
19
  provider: defineMemoryProvider({
21
- recall: { "turn.started": recall, "compaction.completed": recall },
20
+ recall: { "turn.started": recall },
22
21
  }),
23
22
  });
package/memory.mjs CHANGED
@@ -27,7 +27,6 @@
27
27
  // The front matter is a small YAML subset: `key: value` or `key: [a, b]`,
28
28
  // with `# comments`. An evidence item is `thr_…/msg_…`, or `msg_…` for
29
29
  // another message in the thread cited just before it.
30
- import { createHash } from "node:crypto";
31
30
  import { existsSync, readFileSync, readdirSync } from "node:fs";
32
31
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
33
32
  import { isWorkspace, readWorkspaceFile } from "./workspace.mjs";
@@ -155,31 +154,18 @@ export function activeEntries(entries) {
155
154
  return entries.filter((e) => e.status === "active");
156
155
  }
157
156
 
158
- // What a bot's memory slot (memory/workspace.ts) recalls, given the session's
159
- // history: one message per entry whose current text differs from the last
160
- // text this session saw for it, and nothing for the rest. eve appends each as
161
- // a user-role `memory.load` message after the history, so the history before
162
- // it (and its cached prefix) never changes.
163
- //
164
- // Every message gets a fresh id, `<id>#<n>@<hash>` with n one more than the
165
- // versions of that entry already in the history. eve treats an id it has seen
166
- // with the same text as a no-op, so reusing a content-only id would swallow a
167
- // reverted edit (A, B, then A again), and reusing a bare id would make eve
168
- // hide the earlier message in place, rewriting the prefix. The id is a pure
169
- // function of the history and the files, so a replayed recall gives the same
170
- // result.
171
- //
172
- // eve can't retract a recalled message, so a superseded or deleted entry this
173
- // session has seen gets a retirement note; one it never saw gets nothing. A
174
- // file that can't be read isn't treated as deleted, so a bad edit never
175
- // retires a rule (it's reported, and the workspace's tests reject it).
176
- const hash = (text) => createHash("sha256").update(text).digest("hex").slice(0, 8);
177
- // Each layer's messages start with its own prefix, so a slot reading the
178
- // shared history only counts its own: the workspace's (memory/) and the
179
- // bot's own (agents/<bot>/memory/).
157
+ // What a bot's memory slot (memory/workspace.ts) recalls: every entry, each
158
+ // as one message whose id is the entry's id. eve keeps recalled messages
159
+ // outside the history and replaces an earlier recall with the same id, so an
160
+ // unchanged entry is a no-op and an edited one replaces what the session saw.
161
+ // A superseded or retired entry is recalled as a retirement note under its
162
+ // id, which replaces its old rule. An entry whose file is deleted is simply
163
+ // not recalled: sessions that saw it keep its last text, so retire an entry
164
+ // rather than deleting it.
165
+ // Each layer's messages start with its own prefix: the workspace's (memory/)
166
+ // and the bot's own (agents/<bot>/memory/).
180
167
  export const SHARED = "Standing decision";
181
168
  export const OWN = "Your standing decision";
182
- const recalledPattern = (prefix) => new RegExp(`^${prefix} ([a-z][a-z0-9-]{1,60})(?::| is retired)`);
183
169
 
184
170
  const retired = (id, by, prefix) => `${prefix} ${id} is retired${by ? `; ${by} replaces it` : ""}. Ignore any earlier text under ${id}.`;
185
171
 
@@ -188,37 +174,8 @@ export function entryText(e, prefix = SHARED) {
188
174
  return e.status === "active" ? `${prefix} ${e.id}: ${e.rule} (${e.evidence.join(", ")})` : retired(e.id, e.supersededBy, prefix);
189
175
  }
190
176
 
191
- const textOf = (m) => (typeof m.content === "string" ? m.content : Array.isArray(m.content) ? m.content.map((p) => (p?.type === "text" ? p.text : "")).join("") : "");
192
-
193
- // Per entry id, every text this session has recalled for it, oldest first:
194
- // the `memory.load` messages in the (projected) history.
195
- export function recalledVersions(history = [], prefix = SHARED) {
196
- const RECALLED = recalledPattern(prefix);
197
- const seen = new Map();
198
- for (const m of history) {
199
- if (m?.role !== "user" || m.kind !== "memory.load") continue;
200
- const text = textOf(m);
201
- const id = RECALLED.exec(text)?.[1];
202
- if (!id) continue;
203
- if (!seen.has(id)) seen.set(id, []);
204
- seen.get(id).push(text);
205
- }
206
- return seen;
207
- }
208
-
209
- export function memoryRecall(entries, history = [], { unreadable = new Set(), prefix = SHARED } = {}) {
210
- const seen = recalledVersions(history, prefix);
211
- const now = new Map();
212
- for (const e of entries) if (e.status === "active" || seen.has(e.id)) now.set(e.id, entryText(e, prefix));
213
- for (const id of seen.keys()) if (!now.has(id) && !unreadable.has(id)) now.set(id, retired(id, undefined, prefix));
214
- const messages = [];
215
- for (const id of [...now.keys()].sort()) {
216
- const text = now.get(id);
217
- const versions = seen.get(id) ?? [];
218
- if (versions.at(-1) === text) continue;
219
- messages.push({ id: `${id}#${versions.length + 1}@${hash(text)}`, content: text });
220
- }
221
- return messages;
177
+ export function memoryRecall(entries, { prefix = SHARED } = {}) {
178
+ return entries.map((e) => ({ id: e.id, content: entryText(e, prefix) }));
222
179
  }
223
180
 
224
181
  // The recall for the workspace at `root`. A file that isn't a valid entry is
@@ -232,13 +189,11 @@ const warnOnce = (msg) => {
232
189
  console.warn(`memory: ${msg}`);
233
190
  };
234
191
 
235
- const unreadableIds = (errors) => new Set(errors.map((e) => /(?:^|\/)memory\/(?:[^/:]+\/)*([^/:]+)\.md:/.exec(e)?.[1]).filter(Boolean));
236
-
237
192
  // `domains`: the bot's domains (hub.workspace.json), see the header.
238
- export function loadMemoryRecall(root, history = [], { warn = warnOnce, domains = /** @type {string[]} */ ([]) } = {}) {
193
+ export function loadMemoryRecall(root, { warn = warnOnce, domains = /** @type {string[]} */ ([]) } = {}) {
239
194
  const { entries, errors } = readMemory(root, { folders: domainFolders(domains) });
240
195
  for (const error of errors) warn(error);
241
- return memoryRecall(entries, history, { unreadable: unreadableIds(errors) });
196
+ return memoryRecall(entries);
242
197
  }
243
198
 
244
199
  // A bot's own entries, agents/<bot>/memory/<id>.md: same schema. An id can't
@@ -253,10 +208,10 @@ export function readBotMemory(root, botDir) {
253
208
  return { entries: entries.filter((e) => !shared.has(e.id)), errors };
254
209
  }
255
210
 
256
- export function loadBotMemoryRecall(root, botDir, history = [], { warn = warnOnce } = {}) {
211
+ export function loadBotMemoryRecall(root, botDir, { warn = warnOnce } = {}) {
257
212
  const { entries, errors } = readBotMemory(root, botDir);
258
213
  for (const error of errors) warn(error);
259
- return memoryRecall(entries, history, { unreadable: unreadableIds(errors), prefix: OWN });
214
+ return memoryRecall(entries, { prefix: OWN });
260
215
  }
261
216
 
262
217
  // The bot this process runs: the agents/<bot> directory of a workspace that
@@ -16,7 +16,7 @@ With no bot that runs `local`, skip this step and say nothing about a Mac.
16
16
 
17
17
  ## 1. Ask for Kit access, once
18
18
 
19
- Kit's tools are `request_access`, `request_status` and `execute`. Hosted, you reach them through the `vercel-kit` connection: `connection_execute({ connection: "vercel-kit", tool: "request_access", input: { ... } })`, and the same shape for the others. Everywhere below, "call `request_access`" means that.
19
+ Kit's tools are `request_access`, `request_status` and `execute`. Hosted, you reach them through the `vercel-kit` connection: `eve__tool({ name: "vercel-kit__request_access", input: { ... } })`, and the same shape for the others. Everywhere below, "call `request_access`" means that.
20
20
 
21
21
  Slack goes through the user's Kit connection. Before any fetch, ask for everything the run reads in one request:
22
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eve-hub",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
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",
@@ -35,7 +35,7 @@
35
35
  "peerDependencies": {
36
36
  "@vercel/connect": "^2.4.1",
37
37
  "@vercel/oidc": "4.0.0",
38
- "eve": "*",
38
+ "eve": ">=0.88.0",
39
39
  "zod": "4.5.4"
40
40
  },
41
41
  "dependencies": {
package/prepare.mjs CHANGED
@@ -90,8 +90,8 @@ function removeStub(bot, file) {
90
90
  const memoryProvider = (slot, entries, prefix, scope) => `import { defineMemory, defineMemoryProvider } from "eve/memory";
91
91
  import { memoryRecall } from "eve-hub/memory";
92
92
  const entries = ${JSON.stringify(entries)};
93
- const recall = ({ messages }) => ({ messages: memoryRecall(entries, messages, { prefix: ${JSON.stringify(prefix)} }) });
94
- export default defineMemory({ namespace: ${JSON.stringify(`hub-${slot}-memory`)}, scope: ${JSON.stringify(scope)}, provider: defineMemoryProvider({ recall: { "turn.started": recall, "compaction.completed": recall } }) });
93
+ const recall = () => ({ messages: memoryRecall(entries, { prefix: ${JSON.stringify(prefix)} }) });
94
+ export default defineMemory({ namespace: ${JSON.stringify(`hub-${slot}-memory`)}, scope: ${JSON.stringify(scope)}, provider: defineMemoryProvider({ recall: { "turn.started": recall } }) });
95
95
  `;
96
96
 
97
97
  // The team slot of a hosted bot: its architecture text, built from the whole roster (Mac bots
@@ -99,8 +99,8 @@ export default defineMemory({ namespace: ${JSON.stringify(`hub-${slot}-memory`)}
99
99
  const teamProvider = (text) => `import { defineMemory, defineMemoryProvider } from "eve/memory";
100
100
  import { teamRecall } from "eve-hub/team";
101
101
  const text = ${JSON.stringify(text)};
102
- const recall = ({ messages }) => teamRecall(messages, text);
103
- export default defineMemory({ namespace: "hub-team-memory", scope: "workspace", provider: defineMemoryProvider({ recall: { "turn.started": recall, "compaction.completed": recall } }) });
102
+ const recall = () => teamRecall(text);
103
+ export default defineMemory({ namespace: "hub-team-memory", scope: "workspace", provider: defineMemoryProvider({ recall: { "turn.started": recall } }) });
104
104
  `;
105
105
 
106
106
  // Replaces the kit's execution-context stub (the local page) with the hosted
@@ -74,6 +74,11 @@ export async function hubHeaders(init, env = process.env) {
74
74
  return registryHeaders(merged, env);
75
75
  }
76
76
 
77
+ /**
78
+ * @param {string} path
79
+ * @param {RequestInit} [init]
80
+ * @param {{ base?: string, deadlineMs?: number, attemptTimeoutMs?: number, fetchImpl?: typeof fetch }} [options]
81
+ */
77
82
  export async function registryFetch(path, init = {}, { base = registryBase(), deadlineMs = num(process.env.ROSTER_REGISTRY_RETRY_MS, 30_000), attemptTimeoutMs, fetchImpl = fetch } = {}) {
78
83
  init = { ...init, headers: await hubHeaders(init.headers) };
79
84
  const input = `${base}${path}`;
package/roster-model.mjs CHANGED
@@ -30,26 +30,12 @@ export function createRosterModel({ agents, subagentCap }) {
30
30
  return function rosterModel(name, { mock, copyMock } = {}) {
31
31
  // Resolve once up front so a bad config fails at compile, not mid-turn.
32
32
  selectModel(agents, subagentCap, name, null);
33
- // The bot's own model, for model work outside a step: eve's compaction on
34
- // request (POST /eve/v1/session/:id/compact, which main's compact
35
- // schedule asks the hub for) runs between turns, where only a session or
36
- // turn selection applies; without one it fails ("Dynamic model selection
37
- // is required"). Steps still pick per step (step > turn > session), so
38
- // copies stay clamped. Session/turn selections must be serializable: a
39
- // mock model is a live object, so mock mode has none.
40
- const own = () => {
41
- const { copy: _copy, ...selection } = selectModel(agents, subagentCap, name, null);
42
- return selection;
43
- };
44
- return defineDynamic({
45
- events: {
46
- ...(real ? { "session.started": own, "turn.started": own } : {}),
47
- "step.started": (_event, ctx) => {
48
- const { copy, ...selection } = selectModel(agents, subagentCap, name, ctx);
49
- if (real) return selection;
50
- return { model: copy && copyMock ? copyMock : mock, modelContextWindowTokens: CONTEXT_WINDOW };
51
- },
52
- },
33
+ // eve evaluates this before every model call, compaction between turns
34
+ // included, so `agent` copies (subagent channel) are clamped each time.
35
+ return defineDynamic((ctx) => {
36
+ const { copy, ...selection } = selectModel(agents, subagentCap, name, ctx);
37
+ if (real) return selection;
38
+ return { model: copy && copyMock ? copyMock : mock, modelContextWindowTokens: CONTEXT_WINDOW };
53
39
  });
54
40
  };
55
41
  }
@@ -125,8 +125,8 @@ export function classifyTargets(snap, { max = CLASSIFY_MAX, now = Date.now() } =
125
125
 
126
126
  // --- the model -------------------------------------------------------------
127
127
  // openai/gpt-6-luna-decisions on the AI Gateway, through an AI SDK
128
- // `evaluate` (experimental_evaluate's shape): the schedule passes eve's
129
- // (eve/ai); tests pass a mock.
128
+ // `evaluate` (eve/ai's `decide`): the schedule passes eve's; tests pass a
129
+ // mock.
130
130
  export const DECISION_MODEL = "openai/gpt-6-luna-decisions";
131
131
 
132
132
  // What the thread is doing for the human, as one pick. Each state is one lane
@@ -184,7 +184,7 @@ export async function decideThread(t, snap, { evaluate, model = DECISION_MODEL,
184
184
  const uq = t.paths ? pathQuestion(t.paths) : null;
185
185
  const asks = askCandidates(snap?.threads?.[t.id], t.brief ?? threadBrief(t, snap));
186
186
  const questions = { ...QUESTIONS, ...(asks.length > 1 ? { ask: askQuestion(asks) } : {}), ...(uq ? { path: uq } : {}) };
187
- const r = await evaluate({ model, state: threadState(t, snap), questions, maxRetries: 1, abortSignal: AbortSignal.timeout(timeoutMs) });
187
+ const r = await evaluate({ model, state: threadState(t, snap), questions, maxRetries: 1, signal: AbortSignal.timeout(timeoutMs) });
188
188
  const out = {};
189
189
  for (const [name, q] of Object.entries(questions)) {
190
190
  const a = r?.answers?.[name];
package/schedules/dash.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // (anthropic/claude-sonnet-5.5 through the AI SDK). The pass: runPass in
7
7
  // ./dash-pass.mjs.
8
8
  import { defineSchedule } from "eve/schedules";
9
- import * as eveAi from "eve/ai";
9
+ import { decide } from "eve/ai";
10
10
  import { generateText, Output } from "ai";
11
11
  import { createHub } from "./hub.mjs";
12
12
  import { modelClassify } from "./dash-classify.mjs";
@@ -15,14 +15,11 @@ import { PLAN_MODEL, PLAN_TIMEOUT_MS } from "./dash-plan.mjs";
15
15
  import { PlanOut } from "./dash-plan-schema.mjs";
16
16
  import { standingDecisions } from "./dash-decisions.mjs";
17
17
  import { modelSummarize } from "./track-summaries.mjs";
18
- // eve 0.80 renamed `evaluate` to `decide`. The Mac bots run 0.81; the hosted bots' eve build is a
19
- // 0.69-based branch with `evaluate` only, and a named import of the missing one fails that build.
20
- const evaluate = eveAi.decide ?? eveAi.evaluate;
21
18
  export default defineSchedule({
22
19
  cron: "*/8 * * * *",
23
20
  async run() {
24
21
  const hub = createHub();
25
22
  const plan = async (prompt) => (await generateText({ model: PLAN_MODEL, prompt, output: Output.object({ schema: PlanOut }), maxRetries: 1, abortSignal: AbortSignal.timeout(PLAN_TIMEOUT_MS) })).output;
26
- await hub.track("dash", () => dashPass(hub, { classify: modelClassify({ evaluate }), summarize: modelSummarize({ generateText }), plan, decisions: standingDecisions() }));
23
+ await hub.track("dash", () => dashPass(hub, { classify: modelClassify({ evaluate: decide }), summarize: modelSummarize({ generateText }), plan, decisions: standingDecisions() }));
27
24
  },
28
25
  });
@@ -12,6 +12,8 @@ import { said, flat, clip } from "./thread-brief.mjs";
12
12
  const MIN = 60_000;
13
13
  const minutes = (from, now) => Math.max(0, Math.floor((now - from) / MIN));
14
14
  const at = (m) => Date.parse(m?.at ?? "") || 0;
15
+ // eve 0.85 names it eve__task_wait; turns recorded before the move say task_wait.
16
+ const isWait = (x) => x.name === "eve__task_wait" || x.name === "task_wait";
15
17
  const isHuman = (m, human) => m.author === human || m.authorKind === "human";
16
18
 
17
19
  // A busy thread's quiet time is read in steps of one pass (the schedule's
@@ -49,15 +51,15 @@ export function laneFacts(t, snap, now = Date.now()) {
49
51
  function turnFacts(agent, turn, now) {
50
52
  const tools = turn?.tools ?? [];
51
53
  const swarm = Object.values(turn?.swarm ?? {});
52
- const running = [...tools].reverse().find((x) => x.status === "running" && x.name !== "task_wait" && x.name !== "researcher");
53
- const wait = tools.find((x) => x.name === "task_wait" && x.status === "running");
54
+ const running = [...tools].reverse().find((x) => x.status === "running" && !isWait(x) && x.name !== "researcher");
55
+ const wait = tools.find((x) => isWait(x) && x.status === "running");
54
56
  const counts = new Map();
55
- for (const x of tools) if (x.name !== "task_wait") counts.set(`${x.name} ${flat(x.input)}`, (counts.get(`${x.name} ${flat(x.input)}`) ?? 0) + 1);
57
+ for (const x of tools) if (!isWait(x)) counts.set(`${x.name} ${flat(x.input)}`, (counts.get(`${x.name} ${flat(x.input)}`) ?? 0) + 1);
56
58
  return {
57
59
  agent,
58
60
  turnMin: turn?.startedAt ? minutes(turn.startedAt, now) : null,
59
61
  quietMin: turn?.startedAt ? minutes(Math.max(turn.startedAt, ...tools.map((x) => x.endedAt ?? x.startedAt ?? 0)), now) : null,
60
- toolCalls: tools.filter((x) => x.name !== "task_wait").length,
62
+ toolCalls: tools.filter((x) => !isWait(x)).length,
61
63
  current: running ? { call: clip(`${running.name === "bash" ? "$" : running.name} ${flat(running.input)}`.trim(), 90), min: running.startedAt ? minutes(running.startedAt, now) : null } : null,
62
64
  waitingMin: wait?.startedAt ? minutes(wait.startedAt, now) : wait ? 0 : null,
63
65
  subagentsOut: swarm.filter((d) => d.status === "working").length,
@@ -31,13 +31,13 @@ export declare function wakeAuth(principal: Principal): {
31
31
  issuer?: string | undefined;
32
32
  subject?: string | undefined;
33
33
  } | null;
34
- /** The one call a firing makes: the hub decides whether to post, keyed on the occurrence so a retry posts once.
34
+ /** The one call a firing makes: the hub posts it, keyed on the occurrence so a retry posts once.
35
35
  * A definitive refusal (a 4xx: the session is unknown, the thread archived) is returned, not thrown: retrying it can't change it. */
36
36
  export declare function fireWake(payload: ArmedWake, occurrence: Occurrence, { fetchImpl, base }?: {
37
37
  fetchImpl?: typeof fetch;
38
38
  base?: string;
39
39
  }): Promise<{
40
- status: "posted" | "skipped";
40
+ status: "posted";
41
41
  } | {
42
42
  status: "refused";
43
43
  error: string;
package/schedules/wake.js CHANGED
@@ -10,9 +10,8 @@
10
10
  // before approval and again before writing, and refuses the write if the two differ.
11
11
  // When it was armed is the hub's: it records the schedule record the tool call returns.
12
12
  // When it fires, run() tells the hub (POST /api/wakes, hosted-hub/src/api/wakes.ts)
13
- // as the bot's deployment; the hub skips it if the bot has had a turn in the thread
14
- // since arming or its last firing, and otherwise posts a system @-mention that queues
15
- // behind any running turn. An occurrence scheduled past `until` deletes the schedule
13
+ // as the bot's deployment; the hub posts a system @-mention for every occurrence, which
14
+ // queues behind any running turn. An occurrence scheduled past `until` deletes the schedule
16
15
  // instead. run() never starts a session itself: a session eve starts here is one the
17
16
  // hub cannot see.
18
17
  import { byPrincipal, defineDynamicSchedules, schedules } from "eve/experimental/schedules";
@@ -28,7 +27,7 @@ export function wakeAuth(principal) {
28
27
  return null;
29
28
  return { principalType: principal.type, authenticator: principal.authenticator, principalId: principal.principalId, attributes: {}, ...(principal.issuer === undefined ? {} : { issuer: principal.issuer }), ...(principal.subject === undefined ? {} : { subject: principal.subject }) };
30
29
  }
31
- /** The one call a firing makes: the hub decides whether to post, keyed on the occurrence so a retry posts once.
30
+ /** The one call a firing makes: the hub posts it, keyed on the occurrence so a retry posts once.
32
31
  * A definitive refusal (a 4xx: the session is unknown, the thread archived) is returned, not thrown: retrying it can't change it. */
33
32
  export async function fireWake(payload, occurrence, { fetchImpl = fetch, base } = {}) {
34
33
  const res = await registryFetch("/api/wakes", {
@@ -52,7 +51,7 @@ export async function expireOrFire(payload, occurrence, { remove, fire }) {
52
51
  return fire(payload, occurrence);
53
52
  }
54
53
  const definition = defineDynamicSchedules({
55
- description: "A wake-up for this thread: arm it when you end a turn with your own work unfinished and nothing sure to wake you. One-shot: a delay set to the expected wait. Recurring: a cron expression. Always set `until`, the last time it may fire. The hub posts it into this thread unless you have had a turn here since it was set or last fired.",
54
+ description: "A wake-up for this thread: arm it when you end a turn with your own work unfinished and nothing sure to wake you. One-shot: a delay set to the expected wait. Recurring: a cron expression. Always set `until`, the last time it may fire. Every firing posts into this thread until you delete it or `until` passes.",
56
55
  inputSchema: WakeInput,
57
56
  scope: byPrincipal,
58
57
  tool: true,
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/build-onboarding.mjs from fanout/SKILL.md. Do not edit.
2
- export default {"description":"How to hand work to your reader, editor and verifier subagents. Load before you send any task to one, so each gets an unambiguous, non-overlapping, checkable task and you can check what comes back.","markdown":"# Fanning out work\n\nYour `reader`, `editor` and `verifier` run cheaper models than you and never see your conversation. Each one gets only the task you write, so the task has to stand on its own.\n\n## 1. The contract\n\nEvery task you send has all of these fields. If you can't fill one in, don't send the task: do it yourself or split it further.\n\n- `goal`: one sentence describing the result.\n- `owns`: the files the worker may change. It is empty for a reader and a verifier.\n- `facts`: what is already decided, so the worker doesn't reopen it.\n- `pointers`: where to look, as `file:line` or a URL. Never paste file contents.\n- `check`: the command that proves the task is done.\n- `budget`: a turn count. Allow 2 to 3 for setup, 1 to 2 for reading, 2 to 4 for implementing and 2 to 3 for verifying, 12 at most.\n- `stop`: when to give up and report instead of pushing on.\n\n```\ngoal: Make listThreads skip archived threads unless asked.\nowns: /workspace/wt-a/src/threads.ts, /workspace/wt-a/test/threads.test.ts\nfacts: The flag is `archived: \"include\"`; the default excludes them.\npointers: src/threads.ts:88 (the query), test/threads.test.ts:40\ncheck: cd /workspace/wt-a && node --test test/threads.test.ts\nbudget: 5 turns\nstop: if the query is built anywhere else, report where and stop.\n```\n\n## 2. Disjoint ownership\n\n- No two tasks running at the same time share a file in `owns`.\n- Your workers share your sandbox. Give each editor running in parallel its own worktree (`git worktree add /workspace/wt-a`) and name that directory in its task.\n- You apply their diffs to your branch one at a time.\n- A change that cuts across files several workers own runs serially, or goes to an `agent` copy (rung 2 below).\n\n## 3. What comes back\n\nEach tier replies with exactly this report, inside its turn budget:\n\n- `reader`, 8 turns:\n\n ```\n answer: <1-2 sentences>\n evidence: <file:line | command + output | URL>, one per line\n open: <what it couldn't settle, or \"none\">\n ```\n\n- `editor`, 12 turns:\n\n ```\n diff: <git diff of owns>\n check: <command> -> exit <n>, last 20 lines\n outside: <files that also need a change but aren't in owns, or \"none\">\n notes: <=5 lines\n ```\n\n- `verifier`, 10 turns:\n\n ```\n verdict: holds | fails | couldn't check\n runs: <command> -> exit <n>, the deciding output lines\n seen: <what is on screen, for a UI>\n other: <anything wrong outside the claim>\n ```\n\nReview the diff together with the module around it, not just the changed lines. Rerun every `check` yourself before you take the change.\n\nAfter a reader or verifier returns, run `git status --porcelain` in its worktree. If anything changed, discard its result and count the attempt as a failure on the ladder below: `bash` can still write files, so read-only is a prompt rule, not a guarantee.\n\n## 4. When it fails\n\n1. Retry once on the same tier with a fresh worker. Give it the original task, the failed diff and the output of the check.\n2. Then give the same task to an `agent` copy of yourself.\n3. Then take the task back and split it differently.\n\nNever try a rung a second time. If two workers given the same task return substantively different diffs, the task is under-specified: rewrite it before you send it again.\n\n## 5. The cap\n\nRun at most 3 workers in parallel.\n"};
2
+ export default {"description":"How to hand work to your reader, editor and verifier subagents. Load before you send any task to one, so each gets an unambiguous, non-overlapping, checkable task and you can check what comes back.","content":"# Fanning out work\n\nYour `reader`, `editor` and `verifier` run cheaper models than you and never see your conversation. Each one gets only the task you write, so the task has to stand on its own.\n\n## 1. The contract\n\nEvery task you send has all of these fields. If you can't fill one in, don't send the task: do it yourself or split it further.\n\n- `goal`: one sentence describing the result.\n- `owns`: the files the worker may change. It is empty for a reader and a verifier.\n- `facts`: what is already decided, so the worker doesn't reopen it.\n- `pointers`: where to look, as `file:line` or a URL. Never paste file contents.\n- `check`: the command that proves the task is done.\n- `budget`: a turn count. Allow 2 to 3 for setup, 1 to 2 for reading, 2 to 4 for implementing and 2 to 3 for verifying, 12 at most.\n- `stop`: when to give up and report instead of pushing on.\n\n```\ngoal: Make listThreads skip archived threads unless asked.\nowns: /workspace/wt-a/src/threads.ts, /workspace/wt-a/test/threads.test.ts\nfacts: The flag is `archived: \"include\"`; the default excludes them.\npointers: src/threads.ts:88 (the query), test/threads.test.ts:40\ncheck: cd /workspace/wt-a && node --test test/threads.test.ts\nbudget: 5 turns\nstop: if the query is built anywhere else, report where and stop.\n```\n\n## 2. Disjoint ownership\n\n- No two tasks running at the same time share a file in `owns`.\n- Your workers share your sandbox. Give each editor running in parallel its own worktree (`git worktree add /workspace/wt-a`) and name that directory in its task.\n- You apply their diffs to your branch one at a time.\n- A change that cuts across files several workers own runs serially, or goes to an `agent` copy (rung 2 below).\n\n## 3. What comes back\n\nEach tier replies with exactly this report, inside its turn budget:\n\n- `reader`, 8 turns:\n\n ```\n answer: <1-2 sentences>\n evidence: <file:line | command + output | URL>, one per line\n open: <what it couldn't settle, or \"none\">\n ```\n\n- `editor`, 12 turns:\n\n ```\n diff: <git diff of owns>\n check: <command> -> exit <n>, last 20 lines\n outside: <files that also need a change but aren't in owns, or \"none\">\n notes: <=5 lines\n ```\n\n- `verifier`, 10 turns:\n\n ```\n verdict: holds | fails | couldn't check\n runs: <command> -> exit <n>, the deciding output lines\n seen: <what is on screen, for a UI>\n other: <anything wrong outside the claim>\n ```\n\nReview the diff together with the module around it, not just the changed lines. Rerun every `check` yourself before you take the change.\n\nAfter a reader or verifier returns, run `git status --porcelain` in its worktree. If anything changed, discard its result and count the attempt as a failure on the ladder below: `bash` can still write files, so read-only is a prompt rule, not a guarantee.\n\n## 4. When it fails\n\n1. Retry once on the same tier with a fresh worker. Give it the original task, the failed diff and the output of the check.\n2. Then give the same task to an `agent` copy of yourself.\n3. Then take the task back and split it differently.\n\nNever try a rung a second time. If two workers given the same task return substantively different diffs, the task is under-specified: rewrite it before you send it again.\n\n## 5. The cap\n\nRun at most 3 workers in parallel.\n"};
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/build-onboarding.mjs from forms/SKILL.md. Do not edit.
2
- export default {"description":"How to show structure in a message. Load before you explain anything with parts that connect, events in order, numbers to compare, a before and after, or a choice the human must make; and before you post a table, diagram or chart.","markdown":"# Showing structure\n\n## Examples\n\nThe prompt's show-don't-tell rule says when to draw. These are past misses.\n\n<example>\nThe human asks \"how do we detect an existing workspace? is it stored in the hub db?\" Miss: one sentence and numbered steps. Instead: the one-line answer, then a flow of today's sign-in check and, beside it, the flow with a \"create new\" option. The question never says \"diagram\"; it asks for a path through parts.\n</example>\n<example>\nBuild times by step. Miss: a schedule chart, then a paragraph repeating every step's seconds. Instead: the chart's title is the claim (\"Linking is 70% of the build\"), and the prose says only what to do about it.\n</example>\n<example>\nThree candidate looks for a theme. Miss: a prose list of concepts. Instead: a rendered image of each, labeled in a few words.\n</example>\n\n## Picking the shape\n- A path through three or more parts: `flow`.\n- An order of events across two or more actors: `sequence`.\n- Numbers to compare: `chart`. Time split into phases or hops (a latency breakdown, a turn's timeline) is a `schedule` chart, one row per request and one segment per phase, never one `bar` per total. A `bar` compares one number across items.\n- A choice you need from the human: `ask`. Choosing an option sends its label back as their reply, so write labels you can act on.\n- Two or more rows of short, comparable values: a table, at most four columns, a few words per cell. A single item, or anything that needs a reason, is a sentence or bullets.\n- Anchor the picture in something the reader knows: a labeled before form, then an after form, in one message.\n- Every part of one diagram is the same kind of thing (all hosts, all processes or all functions); say which in the sentence before it. A group is the level that contains them.\n- At most one or two forms per message.\n- Never draw with characters: no boxes, arrows, timelines or tables in a code fence.\n\n## How the app shows it\n- Each top-level code fence is a card with a copy button; a table is a table card; a link is a chip showing its label; an attached image is its own tile.\n- A fenced form becomes an interactive figure the human can step through.\n\n## Form syntax\n\n- Claim first: a sentence that says what the form shows comes before it. A chart's `title` is its claim.\n- Link a word in your prose to a part with `[word](#id)`. Ids are lowercase slugs (`a-z`, `0-9`, `-`, up to 32 characters), unique in the whole message.\n- Put the explanation of a part in `note` (up to 240 characters), which the human sees when they point at or step to it. Keep labels short.\n- `hi: true` marks the one part that matters, at most one per form. Unknown keys are rejected.\n- Send a form with `post_message`, never in your end-of-turn reply. The hub checks a posted form and rejects a bad one (400) with what's wrong: fix it and post again. Nothing checks a final reply, so a bad form there shows as raw JSON.\n\nA request leaves the [app](#app), the [hub](#hub) routes it, and a [bot](#bot) answers. `flow`: nodes 2–12 (`role`, `group`, `ref` as file:line), edges 1–24 (`from`, `to`, `label` up to 16, `fail` for a failure path), optional `groups`. Steps follow node order.\n\n```flow\n{\"nodes\": [\n {\"id\": \"app\", \"label\": \"App\", \"role\": \"on your Mac\"},\n {\"id\": \"hub\", \"label\": \"Hub\", \"role\": \"threads, routing\", \"hi\": true, \"ref\": \"server.mjs:5323\", \"note\": \"Stores the message, then wakes the bot.\"},\n {\"id\": \"bot\", \"label\": \"Bot\", \"role\": \"one turn\"}],\n \"edges\": [{\"from\": \"app\", \"to\": \"hub\", \"label\": \"send\"}, {\"from\": \"hub\", \"to\": \"bot\", \"label\": \"wake\"}, {\"from\": \"bot\", \"to\": \"hub\", \"label\": \"post\"}]}\n```\n\nThe stream [drops](#drop) before the client [retries](#retry). `sequence`: actors 2–6, messages 1–16 (`from`, `to`, `label` up to 32, `fail` where it breaks, `ref` as file:line; `from` = `to` for a wait or retry inside one actor). Optional `unit` (up to 48 characters) is printed above the actors, e.g. \"each line = one process\"; optional `groups` (up to 3, `id` and `label`) with `group` on an actor draws a box around actors that share a host; grouped actors must sit next to each other. Steps follow message order.\n\n```sequence\n{\"unit\": \"each line = one process\", \"groups\": [{\"id\": \"mac\", \"label\": \"Mac\"}, {\"id\": \"hub-proj\", \"label\": \"Hub project\"}],\n \"actors\": [{\"id\": \"client\", \"label\": \"Client\", \"group\": \"mac\"}, {\"id\": \"hub\", \"label\": \"Hub\", \"group\": \"hub-proj\"}],\n \"messages\": [\n {\"id\": \"drop\", \"from\": \"hub\", \"to\": \"client\", \"label\": \"stream drops\", \"fail\": true, \"ref\": \"Client.swift:257\"},\n {\"id\": \"retry\", \"from\": \"client\", \"to\": \"hub\", \"label\": \"reconnects\", \"note\": \"Resumes from the last event id.\"}]}\n```\n\nFirst token is under 2 s everywhere but [Sydney](#syd). `chart`: `title` (the claim) and `points` 1–24, each with an id and `label`. `type` is one of four:\n- `bar`: `value`; optional `unit`, `target`.\n- `scatter`: axis titles `x`, `y`; points have numeric `x`, `y`.\n- `budget`: `value` > 0; optional `total`; `spare: true` for unused room.\n- `schedule`: `row` (up to 8 rows), `start` < `end`; optional `unit`.\n\n```chart\n{\"type\": \"bar\", \"title\": \"First token is under 2 s everywhere but Sydney\", \"unit\": \"s\", \"target\": 2,\n \"points\": [{\"id\": \"sfo\", \"label\": \"San Francisco\", \"value\": 0.9}, {\"id\": \"syd\", \"label\": \"Sydney\", \"value\": 2.6, \"hi\": true, \"note\": \"No region near it.\"}]}\n```\n\nYou need a decision, so I'm asking you to pick one. `ask`: 2–5 `options`, each with `id`, `label` (up to 40) and an optional one-line `line` (up to 160). Choosing one sends its label back as the human's reply, so write labels you can act on.\n\n```ask\n{\"options\": [\n {\"id\": \"heron\", \"label\": \"Heron Lodge\", \"line\": \"Lake house 90 minutes north. Rooms for all 14, $5,400.\"},\n {\"id\": \"pines\", \"label\": \"The Pines\", \"line\": \"In town, walkable. Sleeps 10, so 4 need a hotel.\"}]}\n```\n"};
2
+ export default {"description":"How to show structure in a message. Load before you explain anything with parts that connect, events in order, numbers to compare, a before and after, or a choice the human must make; and before you post a table, diagram or chart.","content":"# Showing structure\n\n## Examples\n\nThe prompt's show-don't-tell rule says when to draw. These are past misses.\n\n<example>\nThe human asks \"how do we detect an existing workspace? is it stored in the hub db?\" Miss: one sentence and numbered steps. Instead: the one-line answer, then a flow of today's sign-in check and, beside it, the flow with a \"create new\" option. The question never says \"diagram\"; it asks for a path through parts.\n</example>\n<example>\nBuild times by step. Miss: a schedule chart, then a paragraph repeating every step's seconds. Instead: the chart's title is the claim (\"Linking is 70% of the build\"), and the prose says only what to do about it.\n</example>\n<example>\nThree candidate looks for a theme. Miss: a prose list of concepts. Instead: a rendered image of each, labeled in a few words.\n</example>\n\n## Picking the shape\n- A path through three or more parts: `flow`.\n- An order of events across two or more actors: `sequence`.\n- Numbers to compare: `chart`. Time split into phases or hops (a latency breakdown, a turn's timeline) is a `schedule` chart, one row per request and one segment per phase, never one `bar` per total. A `bar` compares one number across items.\n- A choice you need from the human: `ask`. Choosing an option sends its label back as their reply, so write labels you can act on.\n- Two or more rows of short, comparable values: a table, at most four columns, a few words per cell. A single item, or anything that needs a reason, is a sentence or bullets.\n- Anchor the picture in something the reader knows: a labeled before form, then an after form, in one message.\n- Every part of one diagram is the same kind of thing (all hosts, all processes or all functions); say which in the sentence before it. A group is the level that contains them.\n- At most one or two forms per message.\n- Never draw with characters: no boxes, arrows, timelines or tables in a code fence.\n\n## How the app shows it\n- Each top-level code fence is a card with a copy button; a table is a table card; a link is a chip showing its label; an attached image is its own tile.\n- A fenced form becomes an interactive figure the human can step through.\n\n## Form syntax\n\n- Claim first: a sentence that says what the form shows comes before it. A chart's `title` is its claim.\n- Link a word in your prose to a part with `[word](#id)`. Ids are lowercase slugs (`a-z`, `0-9`, `-`, up to 32 characters), unique in the whole message.\n- Put the explanation of a part in `note` (up to 240 characters), which the human sees when they point at or step to it. Keep labels short.\n- `hi: true` marks the one part that matters, at most one per form. Unknown keys are rejected.\n- Send a form with `post_message`, never in your end-of-turn reply. The hub checks a posted form and rejects a bad one (400) with what's wrong: fix it and post again. Nothing checks a final reply, so a bad form there shows as raw JSON.\n\nA request leaves the [app](#app), the [hub](#hub) routes it, and a [bot](#bot) answers. `flow`: nodes 2–12 (`role`, `group`, `ref` as file:line), edges 1–24 (`from`, `to`, `label` up to 16, `fail` for a failure path), optional `groups`. Steps follow node order.\n\n```flow\n{\"nodes\": [\n {\"id\": \"app\", \"label\": \"App\", \"role\": \"on your Mac\"},\n {\"id\": \"hub\", \"label\": \"Hub\", \"role\": \"threads, routing\", \"hi\": true, \"ref\": \"server.mjs:5323\", \"note\": \"Stores the message, then wakes the bot.\"},\n {\"id\": \"bot\", \"label\": \"Bot\", \"role\": \"one turn\"}],\n \"edges\": [{\"from\": \"app\", \"to\": \"hub\", \"label\": \"send\"}, {\"from\": \"hub\", \"to\": \"bot\", \"label\": \"wake\"}, {\"from\": \"bot\", \"to\": \"hub\", \"label\": \"post\"}]}\n```\n\nThe stream [drops](#drop) before the client [retries](#retry). `sequence`: actors 2–6, messages 1–16 (`from`, `to`, `label` up to 32, `fail` where it breaks, `ref` as file:line; `from` = `to` for a wait or retry inside one actor). Optional `unit` (up to 48 characters) is printed above the actors, e.g. \"each line = one process\"; optional `groups` (up to 3, `id` and `label`) with `group` on an actor draws a box around actors that share a host; grouped actors must sit next to each other. Steps follow message order.\n\n```sequence\n{\"unit\": \"each line = one process\", \"groups\": [{\"id\": \"mac\", \"label\": \"Mac\"}, {\"id\": \"hub-proj\", \"label\": \"Hub project\"}],\n \"actors\": [{\"id\": \"client\", \"label\": \"Client\", \"group\": \"mac\"}, {\"id\": \"hub\", \"label\": \"Hub\", \"group\": \"hub-proj\"}],\n \"messages\": [\n {\"id\": \"drop\", \"from\": \"hub\", \"to\": \"client\", \"label\": \"stream drops\", \"fail\": true, \"ref\": \"Client.swift:257\"},\n {\"id\": \"retry\", \"from\": \"client\", \"to\": \"hub\", \"label\": \"reconnects\", \"note\": \"Resumes from the last event id.\"}]}\n```\n\nFirst token is under 2 s everywhere but [Sydney](#syd). `chart`: `title` (the claim) and `points` 1–24, each with an id and `label`. `type` is one of four:\n- `bar`: `value`; optional `unit`, `target`.\n- `scatter`: axis titles `x`, `y`; points have numeric `x`, `y`.\n- `budget`: `value` > 0; optional `total`; `spare: true` for unused room.\n- `schedule`: `row` (up to 8 rows), `start` < `end`; optional `unit`.\n\n```chart\n{\"type\": \"bar\", \"title\": \"First token is under 2 s everywhere but Sydney\", \"unit\": \"s\", \"target\": 2,\n \"points\": [{\"id\": \"sfo\", \"label\": \"San Francisco\", \"value\": 0.9}, {\"id\": \"syd\", \"label\": \"Sydney\", \"value\": 2.6, \"hi\": true, \"note\": \"No region near it.\"}]}\n```\n\nYou need a decision, so I'm asking you to pick one. `ask`: 2–5 `options`, each with `id`, `label` (up to 40) and an optional one-line `line` (up to 160). Choosing one sends its label back as the human's reply, so write labels you can act on.\n\n```ask\n{\"options\": [\n {\"id\": \"heron\", \"label\": \"Heron Lodge\", \"line\": \"Lake house 90 minutes north. Rooms for all 14, $5,400.\"},\n {\"id\": \"pines\", \"label\": \"The Pines\", \"line\": \"In town, walkable. Sleeps 10, so 4 need a hotel.\"}]}\n```\n"};
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/build-onboarding.mjs from local-onboarding/SKILL.md. Do not edit.
2
- export default {"description":"The Mac half of onboarding, when main hands it to you: find the owner's live coding-agent work (Claude Code, Codex, OpenCode, Cursor) to port into threads, and propose the sandbox setup their hosted bots need. Reads only; the threads and the sandbox PR wait for the owner's yes.","markdown":"# The Mac half of onboarding\n\nMain runs onboarding on Vercel, where it can't see this Mac, so it hands you this half and posts its own without waiting. You post two short messages in that thread, the live work first, then the sandbox setup. Follow your writing rules: each first bubble is at most two sentences, with tables below a `---` line.\n\nTwo scripts gather the facts. They live in `{{scripts}}`, read only, and print metadata: repos, branches, counts, times, the agents' own session titles and program names. Work only from what they print. Don't open the agents' session files yourself: they hold the owner's prompts and code. Delete the JSON files when you are done.\n\n## 1. Live work to port\n\n```sh\nnode {{scripts}}/sessions-facts.mjs > \"$TMPDIR/sessions.json\"\n```\n\nThe script covers the last 14 days of sessions, grouped into work-streams by repo and branch, newest first. Each stream has:\n- `repo`, `branch` and `pr` (the open PR);\n- `checkouts`: one per clone or worktree it was worked in. Each has `worktree` (true for a linked git worktree), `uncommitted` (changed files) and `unpushed` (commits ahead of `aheadOf`);\n- `lastActive`;\n- `sessions`: each with its agent, title, time and `checkout` (its index in `checkouts`).\n\nA stream with no remote is keyed `local-N`. A stream marked `gone` is a folder that no longer exists. `unreadable` lists the sources that exist but couldn't be read. An `uncommitted` or `unpushed` of `null` means the count couldn't be determined (git timed out on a huge untracked tree, or there is no upstream or default branch to compare against): it is unknown, not zero.\n\nPost the streams that have work in flight: any checkout with uncommitted files or unpushed commits, or an open PR. A checkout with an unknown count gets a row too, with \"unknown\" in that cell. A stream with more than one checkout gets one row per checkout that has work. Put the rest in one line with their count.\n\n```md\nYou have 3 work-streams in flight on this Mac. Say which to port, and main will open a thread for each so the team can pick it up.\n\n---\n\n| Stream | Agent and session | Last active | Uncommitted | Unpushed | PR |\n|---|---|---|---|---|---|\n| `vercel/app` on `fix-login` (worktree) | Claude Code, \"Fix login redirect\" | 2h ago | 4 files | 2 | [#812](...) |\n\nAlso 5 streams with nothing in flight.\n```\n\nNever post a local path, a file name, a prompt, or anything else from the sessions.\n\nWhen the owner says which streams to port, you still write nothing on the Mac. Post one message that mentions main and asks it to open one thread per stream. For each stream, give a short title, the repo and branch, its state (worktree, uncommitted and unpushed counts, PR), and which agent had it. You don't open threads yourself.\n\n## 2. Sandbox setup\n\n```sh\nnode {{scripts}}/agent-usage-facts.mjs > \"$TMPDIR/agent-usage.json\"\n```\n\nIt takes about 30 seconds on 16 GB of history. It prints names and counts, never a command's arguments, a prompt or a value:\n- `scanned`: records, commands and unparsable commands per agent;\n- `unreadable`;\n- `programs`, `mcpServers`, `repos`, `languages` (file extensions) and `envNames`.\n\nMapping each fact to a setup is your judgment, not a lookup table. Decide, for every program in the top 30:\n\n- **Hosted or Mac.** These can't run in a Linux sandbox, so they stay on the Mac with you:\n - `brew`, `osascript` and `open`;\n - Xcode, `xcodebuild` and Apple-platform `swift`;\n - anything that drives the owner's browser or apps;\n - `localhost` services and the keychain.\n\n Everything else can be hosted.\n- **Already there, or add it.** A hosted bot's sandbox is eve's Ubuntu image with git only, plus what its `prepare` in `agents/<bot>/agent/sandbox.ts` installs. Read `prepare` in the workspace repo's sandbox files. A program it installs is already provided; any other hosted program needs a `prepare` step. Use `apt-get` where Ubuntu has the package, and the project's official installer otherwise.\n\nMCP servers are reached from hosted bots through Vercel Kit connections: list each one by name for main to connect. Env var names are listed for the owner only. Never print a value (you don't have any). Ignore shell and system names (`PATH`, `HOME`, `CI`, ...). A name with a high `shellAssigned` relative to `count` is probably a shell variable, not part of the environment.\n\nPost the proposal:\n- a first bubble on what the hosted bots would gain;\n- a table of the steps to add, with the programs each installs and how often the owner used them;\n- one line for what stays on the Mac;\n- one line for the MCP servers to connect through Kit.\n\nAsk whether to open the PR.\n\n## 3. The sandbox PR, after the owner's yes\n\nThe steps live in one file, which is the only source; never hand-write them into `sandbox.ts`. Write `$TMPDIR/prepare.json` as `{\"commands\": [{\"command\": \"<shell>\", \"what\": \"<short name for the error>\"}]}`.\n\nMain's handoff names the workspace repo. Clone it into a scratch folder, branch, and add the steps to each hosted bot that runs code. That is the builder, unless the owner said otherwise.\n\n```sh\nnode {{scripts}}/prepare-edit.mjs \"$TMPDIR/prepare.json\" agents/builder/agent/sandbox.ts\n```\n\nCommit, push and open the PR with `gh pr create`. The body lists the programs, why each one is there, and the usage counts behind them. Never name the owner's other repos.\n\nThe PR's Vercel build runs `prepare`, so a step that fails, fails the build. Wait for its result with `gh pr checks --watch --fail-fast`. If it fails, fix the step in `prepare.json`, restore the file from the branch you cut from (`git checkout origin/HEAD -- <file>`), rerun the script and push. Post the PR link and its build result in the thread, then delete the scratch clone.\n"};
2
+ export default {"description":"The Mac half of onboarding, when main hands it to you: find the owner's live coding-agent work (Claude Code, Codex, OpenCode, Cursor) to port into threads, and propose the sandbox setup their hosted bots need. Reads only; the threads and the sandbox PR wait for the owner's yes.","content":"# The Mac half of onboarding\n\nMain runs onboarding on Vercel, where it can't see this Mac, so it hands you this half and posts its own without waiting. You post two short messages in that thread, the live work first, then the sandbox setup. Follow your writing rules: each first bubble is at most two sentences, with tables below a `---` line.\n\nTwo scripts gather the facts. They live in `{{scripts}}`, read only, and print metadata: repos, branches, counts, times, the agents' own session titles and program names. Work only from what they print. Don't open the agents' session files yourself: they hold the owner's prompts and code. Delete the JSON files when you are done.\n\n## 1. Live work to port\n\n```sh\nnode {{scripts}}/sessions-facts.mjs > \"$TMPDIR/sessions.json\"\n```\n\nThe script covers the last 14 days of sessions, grouped into work-streams by repo and branch, newest first. Each stream has:\n- `repo`, `branch` and `pr` (the open PR);\n- `checkouts`: one per clone or worktree it was worked in. Each has `worktree` (true for a linked git worktree), `uncommitted` (changed files) and `unpushed` (commits ahead of `aheadOf`);\n- `lastActive`;\n- `sessions`: each with its agent, title, time and `checkout` (its index in `checkouts`).\n\nA stream with no remote is keyed `local-N`. A stream marked `gone` is a folder that no longer exists. `unreadable` lists the sources that exist but couldn't be read. An `uncommitted` or `unpushed` of `null` means the count couldn't be determined (git timed out on a huge untracked tree, or there is no upstream or default branch to compare against): it is unknown, not zero.\n\nPost the streams that have work in flight: any checkout with uncommitted files or unpushed commits, or an open PR. A checkout with an unknown count gets a row too, with \"unknown\" in that cell. A stream with more than one checkout gets one row per checkout that has work. Put the rest in one line with their count.\n\n```md\nYou have 3 work-streams in flight on this Mac. Say which to port, and main will open a thread for each so the team can pick it up.\n\n---\n\n| Stream | Agent and session | Last active | Uncommitted | Unpushed | PR |\n|---|---|---|---|---|---|\n| `vercel/app` on `fix-login` (worktree) | Claude Code, \"Fix login redirect\" | 2h ago | 4 files | 2 | [#812](...) |\n\nAlso 5 streams with nothing in flight.\n```\n\nNever post a local path, a file name, a prompt, or anything else from the sessions.\n\nWhen the owner says which streams to port, you still write nothing on the Mac. Post one message that mentions main and asks it to open one thread per stream. For each stream, give a short title, the repo and branch, its state (worktree, uncommitted and unpushed counts, PR), and which agent had it. You don't open threads yourself.\n\n## 2. Sandbox setup\n\n```sh\nnode {{scripts}}/agent-usage-facts.mjs > \"$TMPDIR/agent-usage.json\"\n```\n\nIt takes about 30 seconds on 16 GB of history. It prints names and counts, never a command's arguments, a prompt or a value:\n- `scanned`: records, commands and unparsable commands per agent;\n- `unreadable`;\n- `programs`, `mcpServers`, `repos`, `languages` (file extensions) and `envNames`.\n\nMapping each fact to a setup is your judgment, not a lookup table. Decide, for every program in the top 30:\n\n- **Hosted or Mac.** These can't run in a Linux sandbox, so they stay on the Mac with you:\n - `brew`, `osascript` and `open`;\n - Xcode, `xcodebuild` and Apple-platform `swift`;\n - anything that drives the owner's browser or apps;\n - `localhost` services and the keychain.\n\n Everything else can be hosted.\n- **Already there, or add it.** A hosted bot's sandbox is eve's Ubuntu image with git only, plus what its `prepare` in `agents/<bot>/agent/sandbox.ts` installs. Read `prepare` in the workspace repo's sandbox files. A program it installs is already provided; any other hosted program needs a `prepare` step. Use `apt-get` where Ubuntu has the package, and the project's official installer otherwise.\n\nMCP servers are reached from hosted bots through Vercel Kit connections: list each one by name for main to connect. Env var names are listed for the owner only. Never print a value (you don't have any). Ignore shell and system names (`PATH`, `HOME`, `CI`, ...). A name with a high `shellAssigned` relative to `count` is probably a shell variable, not part of the environment.\n\nPost the proposal:\n- a first bubble on what the hosted bots would gain;\n- a table of the steps to add, with the programs each installs and how often the owner used them;\n- one line for what stays on the Mac;\n- one line for the MCP servers to connect through Kit.\n\nAsk whether to open the PR.\n\n## 3. The sandbox PR, after the owner's yes\n\nThe steps live in one file, which is the only source; never hand-write them into `sandbox.ts`. Write `$TMPDIR/prepare.json` as `{\"commands\": [{\"command\": \"<shell>\", \"what\": \"<short name for the error>\"}]}`.\n\nMain's handoff names the workspace repo. Clone it into a scratch folder, branch, and add the steps to each hosted bot that runs code. That is the builder, unless the owner said otherwise.\n\n```sh\nnode {{scripts}}/prepare-edit.mjs \"$TMPDIR/prepare.json\" agents/builder/agent/sandbox.ts\n```\n\nCommit, push and open the PR with `gh pr create`. The body lists the programs, why each one is there, and the usage counts behind them. Never name the owner's other repos.\n\nThe PR's Vercel build runs `prepare`, so a step that fails, fails the build. Wait for its result with `gh pr checks --watch --fail-fast`. If it fails, fix the step in `prepare.json`, restore the file from the branch you cut from (`git checkout origin/HEAD -- <file>`), rerun the script and push. Post the PR link and its build result in the thread, then delete the scratch clone.\n"};
@@ -6,4 +6,4 @@ import skill from "./local-onboarding.gen.mjs";
6
6
  // (scripts/build-onboarding.mjs). Its scripts run from the installed kit, beside
7
7
  // the dependencies agent-usage-facts.mjs imports.
8
8
  const scripts = join(dirname(templatesDir()), "local-onboarding");
9
- export default defineSkill({ ...skill, markdown: skill.markdown.replaceAll("{{scripts}}", scripts) });
9
+ export default defineSkill({ ...skill, content: skill.content.replaceAll("{{scripts}}", scripts) });
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/build-onboarding.mjs from onboarding/SKILL.md. Do not edit.
2
- export default {"description":"Right after sign-in: ask for the Kit access you need, then work out who the user is and what to focus on from their recent Slack and GitHub, and draft schedules that keep them on track. Reads only; the one write is a pull request the user approves by merging.","markdown":"# Profile the user from Slack and GitHub\n\nTwo scripts gather facts (GitHub in your sandbox, Slack inside Kit); you write the profile and the schedule drafts from those facts alone. Nothing is posted to Slack and nothing is committed; the only write is the pull request in step 6, which the user approves by merging. The user sees one short message from you at the end (step 7), not one per step. If a step cannot finish, that message says which one and what the user must do; never write the profile as if every source had been read.\n\n## 0. Hand the Mac half to the bot on the Mac\n\nYou run on Vercel and can't see the user's Mac. If `list_agents` shows a bot that runs `local`, post one message in this thread before step 1 that mentions it (the first one, if there are several) and asks it to run its local-onboarding skill. Name the workspace repo for its sandbox PR, or say there is none. Then go on with step 1 without waiting: its half arrives in this thread as its own posts.\n\nWith no bot that runs `local`, skip this step and say nothing about a Mac.\n\n## 1. Ask for Kit access, once\n\nKit's tools are `request_access`, `request_status` and `execute`. Hosted, you reach them through the `vercel-kit` connection: `connection_execute({ connection: \"vercel-kit\", tool: \"request_access\", input: { ... } })`, and the same shape for the others. Everywhere below, \"call `request_access`\" means that.\n\nSlack goes through the user's Kit connection. Before any fetch, ask for everything the run reads in one request:\n\n```\nrequest_access({ service: \"slack\", reads: true, reason: \"Read your recent channel messages, mentions and threads to write your onboarding profile\" })\n```\n\n- `approved`: go on.\n- `pending` (or `connection_required`): tell the user, in one sentence, that Slack access is waiting, with the `approveUrl`; then `request_status({ request })` until they decide. Do not start step 3 before it is approved. If they decline, the final message says the Slack half was not read.\n\nGitHub does not use Kit: it uses `gh` in your sandbox (step 2). If `gh auth status` fails, the final message says GitHub was not read.\n\n## 2. GitHub facts\n\nCall the `onboarding_scripts` tool once: it writes the skill's scripts into your sandbox at `/tmp/onboarding-skill/` and returns that directory.\n\n```sh\nnode /tmp/onboarding-skill/github-facts.mjs > /tmp/onboarding-skill/github-facts.json\n```\n\nIt prints one JSON document: `counts` (merged PRs, open PRs, review requests, assigned issues, mentions never replied to, PRs reviewed), `repos` (merged, reviewed, open and lines changed per repo), item lists (`merged`, `open`, `reviewRequested`, `assignedIssues`, `unansweredMentions`; `total` is the full count, `items` the newest 150), `teams`, and `codeowners` lines naming the user or their teams. Open PRs carry `draft`, `reviewDecision`, `mergeable` and `checks`. `author:@me` includes PRs the user's bots open under their login. `unreadable` lists a source that could not be read.\n\n## 3. Slack facts\n\nRun the script in \"The Slack script\" below with Kit's `execute`: pass it as the script, unchanged. It runs inside Kit, so no raw message reaches you; it returns one JSON document in about 10 seconds.\n\n- A `ratelimited` error means Slack's search limit (about 20 a minute; the script makes at most 19). Wait 70 seconds (`sleep 70`) and run it again unchanged, up to five times.\n- A `suspended` result whose output says `consent_required` is a pause, not an error: tell the user in one sentence that the Slack read is waiting, with its `approveUrl`; `request_status({ request })` until they decide; then `execute({ run })` continues the same run (settled steps are not repeated).\n- An `output` with `refused` means Slack answered `missing_scope` (or another refusal) to a call, so no facts were read: `refused.needed` names the scope the user's Slack connection lacks (`refused.provided` what it has). Do not continue. Call `request_access({ service: \"slack\", capability: \"search_messages\", reason })`: if it returns an `approveUrl`, give it to the user; otherwise tell them to reconnect Slack in Kit allowing `refused.needed`. The final message says the Slack half was not read and names the scope.\n- Any other error goes to the user in the final message. Never continue with fewer facts, and never retry around a refusal.\n- Save the run's `output` as `/tmp/onboarding-skill/slack-facts.json` (the checker reads it).\n- `window`: the last 30 days. Slack search allows 15 pages of 100 here, so a very active account is covered only back to `own.oldest` (`newestOnly: true`); state that window in the profile.\n- `own`: messages per channel, thread replies, recent snippets. `mentions`: `replied` means the user posted later in the same thread; `unanswered` means they did not. A mention older than what was read with no reply among it (`notJudged`) is unknown, not unanswered. `mentions.capped` means the search found more mentions (`searchTotal`) than the three pages read: say so in \"Could not tell\". Reactions are not read (the Slack grant has no `reactions:read`): say so in \"Could not tell\". `memberships`: channels the user is in.\n- DMs and group DMs are never searched. Every list item carries a permalink (`url`); a snippet that looked like a secret is `[withheld]`.\n\n## 4. Write the profile and drafts\n\nWrite the profile to `/tmp/onboarding-skill/profile.md`. It names the user's repos and colleagues, so it is posted only in this thread and never committed.\n\nAnswer, in the user's terms:\n\n- What they have shipped, what they own, what they are responsible for.\n- What people tag them for, and what they write about.\n- What they have missed: unanswered mentions, requested reviews, stale or conflicted PRs.\n- Which open GitHub work-streams to close (stale, conflicted, superseded) and which to focus on.\n\nRules:\n\n- Judge; the scripts only counted. No scoring formula and no thresholds from code: say why in a sentence.\n- Every list item cites at least one permalink that appears in the facts, and no URL appears that isn't in the facts. State counts as the facts give them, with the window.\n- Don't quote messages beyond the snippets, and don't name people beyond what a claim needs.\n- Add a \"Could not tell\" section: every `unreadable` source, `notJudged` mentions, that reactions were not read, the Slack window, a Kit grant the user declined.\n\nFor each recurring need you found (a morning pass over unanswered mentions, a weekly stale-PR pass), write one draft to `/tmp/onboarding-skill/schedules/<bot>/<id>.md` for an existing bot of your workspace:\n\n```md\n---\ncron: \"0 9 * * 1-5\"\n---\n\nThe prompt the bot runs.\n```\n\n`cron` is five fields in UTC on a hosted deploy. Each draft says in its prompt which facts it reads and what it reports.\n\n## 5. Check\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --profile /tmp/onboarding-skill/profile.md --facts /tmp/onboarding-skill/github-facts.json /tmp/onboarding-skill/slack-facts.json\n```\n\nFix every line it prints and rerun until it prints nothing. Then delete the two facts files.\n\n## 6. Open the pull request, if the workspace has a repo\n\nWhen the workspace is backed by a GitHub repo, open one PR on it that adds only the schedule drafts, as `agents/<bot>/agent/schedules/<id>.md`, and nothing from the profile (`gh pr create`). The user merging it is their approval; the deploy that follows is what makes the schedules run. The PR's checks include the deploy's build, which rejects a bad cron or a bot that does not exist: watch them (`gh pr checks`), fix the draft and push again until they pass, and say in the message if they do not. Without a repo the drafts stay in the thread and the message says so.\n\n## 7. Post one short message to the user\n\nThe user reads it cold, often only its first line (your writing rules). Write it to `/tmp/onboarding-skill/post.md` in this shape, load the forms skill for the tables, and check it:\n\n```md\nOne line on who they are and what they own, then the one thing to do first.\n\n---\n\n| Act on first | Why | Link |\n|---|---|---|\n(three rows, each linking a permalink from the facts)\n\n| Schedule | Bot | When | What it reports |\n|---|---|---|---|\n(one row per draft)\n\nThe drafts are in [this PR](...): merging it turns them on.\n(one line per source not read or grant declined: what was not read and what to do)\n```\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --post /tmp/onboarding-skill/post.md\n```\n\nFix every line it prints and rerun until it prints nothing, then post the file's text in this thread.\n\n- The first bubble, everything above `---`, is at most two sentences.\n- Below it, the two tables, then the lines under them and nothing else:\n - The PR line. Without a repo, it says the drafts stay in the thread. If the PR's checks still fail after step 6, it says so and what failed, in place of \"merging it turns them on\".\n - One line per source you could not read or Kit grant the user declined: what was not read and what they must do.\n - If you handed the Mac half over in step 0 and that bot hasn't posted yet, one line: its half hasn't reported yet, and if the Hub app is closed on their Mac, it runs once they open it.\n- Nothing else goes in: not the profile, the \"Could not tell\" section, counts, windows, or how the run went. Keep `profile.md`, and post it, or the part asked for, only when the user asks.\n\nOnly the schedule files ever leave the thread.\n\n## The Slack script\n\n```js\n// Facts about the signed-in user's recent Slack channel activity. Counts, permalinks and short snippets only.\n// Searches exclude DMs and group DMs (-is:dm) so none is ever read. Slack allows about 20 searches a minute: this\n// script makes at most 19, so on a ratelimited error wait a minute and run it again unchanged.\n// The .slice(0, n) before each .map is not redundant: callscript counts worst-case calls from it (200 max).\nconst DAYS = 30;\nconst after = new Date(Date.now() - (DAYS + 1) * 86400000).slice(0, 10);\nconst auth = await slack.auth_test();\nconst me = auth.user_id;\nconst ownPages = await Promise.all([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].slice(0, 15).map(page => slack.search_messages({ query: `from:<@${me}> after:${after} -is:dm`, count: 100, page, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst mentionPages = await Promise.all([1, 2, 3].slice(0, 3).map(page => slack.search_messages({ query: `<@${me}> after:${after} -is:dm`, count: 100, page, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst member = await slack.users_conversations({ types: \"public_channel,private_channel\", limit: 200, exclude_archived: true });\nconst refused = [auth, ...ownPages, ...mentionPages, member].filter(r => r.ok === false);\nif (refused.length > 0) {\n return { refused: { error: refused[0].error, needed: refused[0].needed ?? null, provided: refused[0].provided ?? null, calls: refused.length } };\n}\nconst channels = Object.entries(Object.groupBy(ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim), m => m.channel.id)).map(e => ({ id: e[0], channel: e[1][0].channel.name, count: e[1].length, threadReplies: e[1].filter(m => m.permalink.includes(\"thread_ts=\") && !m.permalink.includes(`thread_ts=${m.ts}`)).length, oldest: e[1][e[1].length - 1].ts, recent: e[1].slice(0, 2).map(m => ({ url: m.permalink, ts: m.ts, text: m.text.split(\"\\n\").join(\" \").split(\" \").some(t => t.length >= 20 && [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"].some(p => t.includes(p))) ? \"[withheld]\" : m.text.split(\"\\n\").join(\" \").slice(0, 160) })) })).sort((a, b) => b.count - a.count);\nconst total = channels.reduce((n, c) => n + c.count, 0);\nconst threadReplies = channels.reduce((n, c) => n + c.threadReplies, 0);\nconst mentioned = mentionPages.flatMap(r => r.messages.matches).filter(m => m.user !== me && !m.channel.is_im && !m.channel.is_mpim).map(m => ({ url: m.permalink, ts: m.ts, author: m.username, channel: m.channel.id, name: m.channel.name, thread: m.permalink.includes(\"thread_ts=\") ? m.permalink.split(\"thread_ts=\")[1].split(\"&\")[0] : m.ts, text: m.text.split(\"\\n\").join(\" \").split(\" \").some(t => t.length >= 20 && [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"].some(p => t.includes(p))) ? \"[withheld]\" : m.text.split(\"\\n\").join(\" \").slice(0, 160) }));\nconst ownMessages = ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim);\nconst lastOwn = Object.fromEntries(Object.entries(Object.groupBy(ownMessages, m => `${m.channel.id}:${m.permalink.includes(\"thread_ts=\") ? m.permalink.split(\"thread_ts=\")[1].split(\"&\")[0] : m.ts}`)).map(e => [e[0], e[1].reduce((t, m) => Math.max(t, Number(m.ts)), 0)]));\nconst oldestOwn = channels.length ? channels.reduce((t, c) => Math.min(t, Number(c.oldest)), Number(channels[0].oldest)) : 0;\nconst newestOnly = ownPages[0].messages.paging.pages > ownPages.length;\nconst known = mentioned.map(m => ({ ...m, replied: (lastOwn[`${m.channel}:${m.thread}`] ?? 0) > Number(m.ts) }));\nconst judged = known.filter(m => m.replied || !newestOnly || Number(m.ts) >= oldestOwn);\nconst open = judged.filter(m => !m.replied);\nreturn {\n me: { id: me, name: auth.user },\n window: { days: DAYS, after, searchTotal: ownPages[0].messages.paging.total, pagesTotal: ownPages[0].messages.paging.pages, pagesRead: ownPages.length, newestOnly },\n scope: \"public and private channels; DMs and group DMs are dropped before counting\",\n own: {\n total,\n threadReplies,\n topLevel: total - threadReplies,\n oldest: channels.length ? oldestOwn : null,\n channelCount: channels.length,\n channels: channels.slice(0, 25).map(c => ({ id: c.id, channel: c.channel, count: c.count, threadReplies: c.threadReplies, recent: c.recent }))\n },\n mentions: {\n searchTotal: mentionPages[0].messages.paging.total,\n pagesTotal: mentionPages[0].messages.paging.pages,\n pagesRead: mentionPages.length,\n capped: mentionPages[0].messages.paging.pages > mentionPages.length,\n found: mentioned.length,\n judged: judged.length,\n notJudged: mentioned.length - judged.length,\n replied: judged.length - open.length,\n unanswered: open.length,\n unansweredItems: open.slice(0, 20).map(m => ({ url: m.url, channel: m.name, author: m.author, ts: m.ts, text: m.text })),\n basis: \"replied = the user posted later in the same thread; reactions are not read (the token lacks reactions:read); a mention not yet answered in a thread may have been answered elsewhere\"\n },\n memberships: { count: member.channels.length, names: member.channels.map(c => c.name).sort(), more: Boolean(member.response_metadata?.next_cursor) }\n};\n```\n"};
2
+ export default {"description":"Right after sign-in: ask for the Kit access you need, then work out who the user is and what to focus on from their recent Slack and GitHub, and draft schedules that keep them on track. Reads only; the one write is a pull request the user approves by merging.","content":"# Profile the user from Slack and GitHub\n\nTwo scripts gather facts (GitHub in your sandbox, Slack inside Kit); you write the profile and the schedule drafts from those facts alone. Nothing is posted to Slack and nothing is committed; the only write is the pull request in step 6, which the user approves by merging. The user sees one short message from you at the end (step 7), not one per step. If a step cannot finish, that message says which one and what the user must do; never write the profile as if every source had been read.\n\n## 0. Hand the Mac half to the bot on the Mac\n\nYou run on Vercel and can't see the user's Mac. If `list_agents` shows a bot that runs `local`, post one message in this thread before step 1 that mentions it (the first one, if there are several) and asks it to run its local-onboarding skill. Name the workspace repo for its sandbox PR, or say there is none. Then go on with step 1 without waiting: its half arrives in this thread as its own posts.\n\nWith no bot that runs `local`, skip this step and say nothing about a Mac.\n\n## 1. Ask for Kit access, once\n\nKit's tools are `request_access`, `request_status` and `execute`. Hosted, you reach them through the `vercel-kit` connection: `eve__tool({ name: \"vercel-kit__request_access\", input: { ... } })`, and the same shape for the others. Everywhere below, \"call `request_access`\" means that.\n\nSlack goes through the user's Kit connection. Before any fetch, ask for everything the run reads in one request:\n\n```\nrequest_access({ service: \"slack\", reads: true, reason: \"Read your recent channel messages, mentions and threads to write your onboarding profile\" })\n```\n\n- `approved`: go on.\n- `pending` (or `connection_required`): tell the user, in one sentence, that Slack access is waiting, with the `approveUrl`; then `request_status({ request })` until they decide. Do not start step 3 before it is approved. If they decline, the final message says the Slack half was not read.\n\nGitHub does not use Kit: it uses `gh` in your sandbox (step 2). If `gh auth status` fails, the final message says GitHub was not read.\n\n## 2. GitHub facts\n\nCall the `onboarding_scripts` tool once: it writes the skill's scripts into your sandbox at `/tmp/onboarding-skill/` and returns that directory.\n\n```sh\nnode /tmp/onboarding-skill/github-facts.mjs > /tmp/onboarding-skill/github-facts.json\n```\n\nIt prints one JSON document: `counts` (merged PRs, open PRs, review requests, assigned issues, mentions never replied to, PRs reviewed), `repos` (merged, reviewed, open and lines changed per repo), item lists (`merged`, `open`, `reviewRequested`, `assignedIssues`, `unansweredMentions`; `total` is the full count, `items` the newest 150), `teams`, and `codeowners` lines naming the user or their teams. Open PRs carry `draft`, `reviewDecision`, `mergeable` and `checks`. `author:@me` includes PRs the user's bots open under their login. `unreadable` lists a source that could not be read.\n\n## 3. Slack facts\n\nRun the script in \"The Slack script\" below with Kit's `execute`: pass it as the script, unchanged. It runs inside Kit, so no raw message reaches you; it returns one JSON document in about 10 seconds.\n\n- A `ratelimited` error means Slack's search limit (about 20 a minute; the script makes at most 19). Wait 70 seconds (`sleep 70`) and run it again unchanged, up to five times.\n- A `suspended` result whose output says `consent_required` is a pause, not an error: tell the user in one sentence that the Slack read is waiting, with its `approveUrl`; `request_status({ request })` until they decide; then `execute({ run })` continues the same run (settled steps are not repeated).\n- An `output` with `refused` means Slack answered `missing_scope` (or another refusal) to a call, so no facts were read: `refused.needed` names the scope the user's Slack connection lacks (`refused.provided` what it has). Do not continue. Call `request_access({ service: \"slack\", capability: \"search_messages\", reason })`: if it returns an `approveUrl`, give it to the user; otherwise tell them to reconnect Slack in Kit allowing `refused.needed`. The final message says the Slack half was not read and names the scope.\n- Any other error goes to the user in the final message. Never continue with fewer facts, and never retry around a refusal.\n- Save the run's `output` as `/tmp/onboarding-skill/slack-facts.json` (the checker reads it).\n- `window`: the last 30 days. Slack search allows 15 pages of 100 here, so a very active account is covered only back to `own.oldest` (`newestOnly: true`); state that window in the profile.\n- `own`: messages per channel, thread replies, recent snippets. `mentions`: `replied` means the user posted later in the same thread; `unanswered` means they did not. A mention older than what was read with no reply among it (`notJudged`) is unknown, not unanswered. `mentions.capped` means the search found more mentions (`searchTotal`) than the three pages read: say so in \"Could not tell\". Reactions are not read (the Slack grant has no `reactions:read`): say so in \"Could not tell\". `memberships`: channels the user is in.\n- DMs and group DMs are never searched. Every list item carries a permalink (`url`); a snippet that looked like a secret is `[withheld]`.\n\n## 4. Write the profile and drafts\n\nWrite the profile to `/tmp/onboarding-skill/profile.md`. It names the user's repos and colleagues, so it is posted only in this thread and never committed.\n\nAnswer, in the user's terms:\n\n- What they have shipped, what they own, what they are responsible for.\n- What people tag them for, and what they write about.\n- What they have missed: unanswered mentions, requested reviews, stale or conflicted PRs.\n- Which open GitHub work-streams to close (stale, conflicted, superseded) and which to focus on.\n\nRules:\n\n- Judge; the scripts only counted. No scoring formula and no thresholds from code: say why in a sentence.\n- Every list item cites at least one permalink that appears in the facts, and no URL appears that isn't in the facts. State counts as the facts give them, with the window.\n- Don't quote messages beyond the snippets, and don't name people beyond what a claim needs.\n- Add a \"Could not tell\" section: every `unreadable` source, `notJudged` mentions, that reactions were not read, the Slack window, a Kit grant the user declined.\n\nFor each recurring need you found (a morning pass over unanswered mentions, a weekly stale-PR pass), write one draft to `/tmp/onboarding-skill/schedules/<bot>/<id>.md` for an existing bot of your workspace:\n\n```md\n---\ncron: \"0 9 * * 1-5\"\n---\n\nThe prompt the bot runs.\n```\n\n`cron` is five fields in UTC on a hosted deploy. Each draft says in its prompt which facts it reads and what it reports.\n\n## 5. Check\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --profile /tmp/onboarding-skill/profile.md --facts /tmp/onboarding-skill/github-facts.json /tmp/onboarding-skill/slack-facts.json\n```\n\nFix every line it prints and rerun until it prints nothing. Then delete the two facts files.\n\n## 6. Open the pull request, if the workspace has a repo\n\nWhen the workspace is backed by a GitHub repo, open one PR on it that adds only the schedule drafts, as `agents/<bot>/agent/schedules/<id>.md`, and nothing from the profile (`gh pr create`). The user merging it is their approval; the deploy that follows is what makes the schedules run. The PR's checks include the deploy's build, which rejects a bad cron or a bot that does not exist: watch them (`gh pr checks`), fix the draft and push again until they pass, and say in the message if they do not. Without a repo the drafts stay in the thread and the message says so.\n\n## 7. Post one short message to the user\n\nThe user reads it cold, often only its first line (your writing rules). Write it to `/tmp/onboarding-skill/post.md` in this shape, load the forms skill for the tables, and check it:\n\n```md\nOne line on who they are and what they own, then the one thing to do first.\n\n---\n\n| Act on first | Why | Link |\n|---|---|---|\n(three rows, each linking a permalink from the facts)\n\n| Schedule | Bot | When | What it reports |\n|---|---|---|---|\n(one row per draft)\n\nThe drafts are in [this PR](...): merging it turns them on.\n(one line per source not read or grant declined: what was not read and what to do)\n```\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --post /tmp/onboarding-skill/post.md\n```\n\nFix every line it prints and rerun until it prints nothing, then post the file's text in this thread.\n\n- The first bubble, everything above `---`, is at most two sentences.\n- Below it, the two tables, then the lines under them and nothing else:\n - The PR line. Without a repo, it says the drafts stay in the thread. If the PR's checks still fail after step 6, it says so and what failed, in place of \"merging it turns them on\".\n - One line per source you could not read or Kit grant the user declined: what was not read and what they must do.\n - If you handed the Mac half over in step 0 and that bot hasn't posted yet, one line: its half hasn't reported yet, and if the Hub app is closed on their Mac, it runs once they open it.\n- Nothing else goes in: not the profile, the \"Could not tell\" section, counts, windows, or how the run went. Keep `profile.md`, and post it, or the part asked for, only when the user asks.\n\nOnly the schedule files ever leave the thread.\n\n## The Slack script\n\n```js\n// Facts about the signed-in user's recent Slack channel activity. Counts, permalinks and short snippets only.\n// Searches exclude DMs and group DMs (-is:dm) so none is ever read. Slack allows about 20 searches a minute: this\n// script makes at most 19, so on a ratelimited error wait a minute and run it again unchanged.\n// The .slice(0, n) before each .map is not redundant: callscript counts worst-case calls from it (200 max).\nconst DAYS = 30;\nconst after = new Date(Date.now() - (DAYS + 1) * 86400000).slice(0, 10);\nconst auth = await slack.auth_test();\nconst me = auth.user_id;\nconst ownPages = await Promise.all([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].slice(0, 15).map(page => slack.search_messages({ query: `from:<@${me}> after:${after} -is:dm`, count: 100, page, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst mentionPages = await Promise.all([1, 2, 3].slice(0, 3).map(page => slack.search_messages({ query: `<@${me}> after:${after} -is:dm`, count: 100, page, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst member = await slack.users_conversations({ types: \"public_channel,private_channel\", limit: 200, exclude_archived: true });\nconst refused = [auth, ...ownPages, ...mentionPages, member].filter(r => r.ok === false);\nif (refused.length > 0) {\n return { refused: { error: refused[0].error, needed: refused[0].needed ?? null, provided: refused[0].provided ?? null, calls: refused.length } };\n}\nconst channels = Object.entries(Object.groupBy(ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim), m => m.channel.id)).map(e => ({ id: e[0], channel: e[1][0].channel.name, count: e[1].length, threadReplies: e[1].filter(m => m.permalink.includes(\"thread_ts=\") && !m.permalink.includes(`thread_ts=${m.ts}`)).length, oldest: e[1][e[1].length - 1].ts, recent: e[1].slice(0, 2).map(m => ({ url: m.permalink, ts: m.ts, text: m.text.split(\"\\n\").join(\" \").split(\" \").some(t => t.length >= 20 && [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"].some(p => t.includes(p))) ? \"[withheld]\" : m.text.split(\"\\n\").join(\" \").slice(0, 160) })) })).sort((a, b) => b.count - a.count);\nconst total = channels.reduce((n, c) => n + c.count, 0);\nconst threadReplies = channels.reduce((n, c) => n + c.threadReplies, 0);\nconst mentioned = mentionPages.flatMap(r => r.messages.matches).filter(m => m.user !== me && !m.channel.is_im && !m.channel.is_mpim).map(m => ({ url: m.permalink, ts: m.ts, author: m.username, channel: m.channel.id, name: m.channel.name, thread: m.permalink.includes(\"thread_ts=\") ? m.permalink.split(\"thread_ts=\")[1].split(\"&\")[0] : m.ts, text: m.text.split(\"\\n\").join(\" \").split(\" \").some(t => t.length >= 20 && [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"].some(p => t.includes(p))) ? \"[withheld]\" : m.text.split(\"\\n\").join(\" \").slice(0, 160) }));\nconst ownMessages = ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim);\nconst lastOwn = Object.fromEntries(Object.entries(Object.groupBy(ownMessages, m => `${m.channel.id}:${m.permalink.includes(\"thread_ts=\") ? m.permalink.split(\"thread_ts=\")[1].split(\"&\")[0] : m.ts}`)).map(e => [e[0], e[1].reduce((t, m) => Math.max(t, Number(m.ts)), 0)]));\nconst oldestOwn = channels.length ? channels.reduce((t, c) => Math.min(t, Number(c.oldest)), Number(channels[0].oldest)) : 0;\nconst newestOnly = ownPages[0].messages.paging.pages > ownPages.length;\nconst known = mentioned.map(m => ({ ...m, replied: (lastOwn[`${m.channel}:${m.thread}`] ?? 0) > Number(m.ts) }));\nconst judged = known.filter(m => m.replied || !newestOnly || Number(m.ts) >= oldestOwn);\nconst open = judged.filter(m => !m.replied);\nreturn {\n me: { id: me, name: auth.user },\n window: { days: DAYS, after, searchTotal: ownPages[0].messages.paging.total, pagesTotal: ownPages[0].messages.paging.pages, pagesRead: ownPages.length, newestOnly },\n scope: \"public and private channels; DMs and group DMs are dropped before counting\",\n own: {\n total,\n threadReplies,\n topLevel: total - threadReplies,\n oldest: channels.length ? oldestOwn : null,\n channelCount: channels.length,\n channels: channels.slice(0, 25).map(c => ({ id: c.id, channel: c.channel, count: c.count, threadReplies: c.threadReplies, recent: c.recent }))\n },\n mentions: {\n searchTotal: mentionPages[0].messages.paging.total,\n pagesTotal: mentionPages[0].messages.paging.pages,\n pagesRead: mentionPages.length,\n capped: mentionPages[0].messages.paging.pages > mentionPages.length,\n found: mentioned.length,\n judged: judged.length,\n notJudged: mentioned.length - judged.length,\n replied: judged.length - open.length,\n unanswered: open.length,\n unansweredItems: open.slice(0, 20).map(m => ({ url: m.url, channel: m.name, author: m.author, ts: m.ts, text: m.text })),\n basis: \"replied = the user posted later in the same thread; reactions are not read (the token lacks reactions:read); a mention not yet answered in a thread may have been answered elsewhere\"\n },\n memberships: { count: member.channels.length, names: member.channels.map(c => c.name).sort(), more: Boolean(member.response_metadata?.next_cursor) }\n};\n```\n"};
package/team.d.mts CHANGED
@@ -20,4 +20,4 @@ export const FLOW: readonly { from: string; to: string; kind?: string; text: str
20
20
  export function teamEdges(nodes: readonly TeamNode[]): TeamEdge[];
21
21
  export function teamModel(input: { agents: readonly TeamAgent[]; humans?: readonly { name: string; label?: string; color?: string }[]; tools?: Record<string, readonly string[] | null | undefined> }): TeamModel;
22
22
  export function architectureText(data: unknown, reader: string): string;
23
- export function teamRecall(history: readonly unknown[], text: string): { messages: { id: string; content: string }[] };
23
+ export function teamRecall(text: string): { messages: { id: string; content: string }[] };
package/team.mjs CHANGED
@@ -113,13 +113,9 @@ export function architectureText(data, reader) {
113
113
  return `<team_architecture>\n${architecture({ agents, reader })}\n</team_architecture>`;
114
114
  }
115
115
 
116
- const textOf = (m) => (typeof m.content === "string" ? m.content : Array.isArray(m.content) ? m.content.map((p) => (p?.type === "text" ? p.text : "")).join("") : "");
117
-
118
- // The team slot's recall, given the session's history: the architecture as one message when it
119
- // differs from the last one this session was given, nothing otherwise.
120
- export function teamRecall(history, text) {
121
- const given = history.filter((m) => m?.role === "user" && m.kind === "memory.load" && textOf(m).startsWith("<team_architecture>")).map(textOf);
122
- if (given.at(-1) === text) return { messages: [] };
123
- return { messages: [{ id: `team#${given.length + 1}@${sha(text, 8)}`, content: text }] };
116
+ // The team slot's recall: the architecture as one message under one id, so eve replaces what the
117
+ // session was given when the text changes and keeps it when it doesn't.
118
+ export function teamRecall(text) {
119
+ return { messages: [{ id: "team", content: text }] };
124
120
  }
125
121
 
@@ -21,8 +21,8 @@
21
21
  - **Waiting**
22
22
  - Never sleep or poll more than 60 seconds in one command. Check CI or a deploy once, report, and say what you're waiting on.
23
23
  - While work runs, post at least every 30 minutes: a result, or the one thing blocking you.
24
- - Ending a turn with your own work unfinished and nothing sure to wake you (CI, a deploy, a background task, stopping partway): arm a wake-up with `schedule__wake__create`, the delay set to the expected wait, the note saying what to pick up and `until` a little after it. It posts into this thread unless you've had a turn here since.
25
- - To check back repeatedly (a long CI run, a slow rollout), arm it with a cron expression, `until` the last time it may fire. It posts each time the thread has been quiet since the last one.
24
+ - Ending a turn with your own work unfinished and nothing sure to wake you (CI, a deploy, a background task, stopping partway): arm a wake-up with `schedule__wake__create`, the delay set to the expected wait, the note saying what to pick up and `until` a little after it. It posts into this thread at that time, whatever happens meanwhile; delete it if the wait is over.
25
+ - To check back repeatedly (a long CI run, a slow rollout), arm it with a cron expression, `until` the last time it may fire. It posts at every firing; delete it once you're done waiting.
26
26
  - Don't arm one while waiting on the human or on a bot you just mentioned: their reply wakes you.
27
27
  - **Ending a turn**
28
28
  - End only with results, a blocker you can't clear, or a question for the human; never "I'll do it next".