eve-hub 0.6.9 → 0.6.11

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.6.11
4
+
5
+ ### Patch Changes
6
+
7
+ - Hosted main carries the onboarding-profile skill and an `onboarding_scripts` tool: after sign-in it reads the user's Slack (inside Kit) and GitHub, posts a profile, and opens a workspace PR with suggested schedules. A refused Slack scope is reported by name instead of finishing with fewer facts.
8
+
9
+ ## 0.6.10
10
+
11
+ ### Patch Changes
12
+
13
+ - The change-explainer is gone: its skill and the `artifact_publish`, `artifact_status` and `artifact_list` tools are no longer kit files (sync and the hosted deploy remove them from every bot), and the protocol no longer tells reviewers to publish an explainer.
14
+
3
15
  ## 0.6.9
4
16
 
5
17
  ### Patch Changes
@@ -0,0 +1,97 @@
1
+ ---
2
+ name: onboarding-profile
3
+ 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."
4
+ license: MIT
5
+ ---
6
+
7
+ # Profile the user from Slack and GitHub
8
+
9
+ Two scripts gather facts (GitHub in your sandbox, Slack inside Kit); you write the profile and the schedule drafts from those facts alone. Nothing is posted to Slack and nothing is committed; the only write is the pull request in step 6, which the user approves by merging. The user sees one message from you at the end (step 7), not one per step. If a step cannot finish, that message says which one and what the user must do; never write the profile as if every source had been read.
10
+
11
+ ## 1. Ask for Kit access, once
12
+
13
+ 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.
14
+
15
+ Slack goes through the user's Kit connection. Before any fetch, ask for everything the run reads in one request:
16
+
17
+ ```
18
+ request_access({ service: "slack", reads: true, reason: "Read your recent channel messages, mentions and threads to write your onboarding profile" })
19
+ ```
20
+
21
+ - `approved`: go on.
22
+ - `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.
23
+
24
+ GitHub 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.
25
+
26
+ ## 2. GitHub facts
27
+
28
+ Call the `onboarding_scripts` tool once: it writes the skill's scripts into your sandbox at `/tmp/onboarding-skill/` and returns that directory.
29
+
30
+ ```sh
31
+ node /tmp/onboarding-skill/github-facts.mjs > /tmp/onboarding-skill/github-facts.json
32
+ ```
33
+
34
+ It 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.
35
+
36
+ ## 3. Slack facts
37
+
38
+ Run 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.
39
+
40
+ - 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.
41
+ - 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).
42
+ - 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.
43
+ - Any other error goes to the user in the final message. Never continue with fewer facts, and never retry around a refusal.
44
+ - Save the run's `output` as `/tmp/onboarding-skill/slack-facts.json` (the checker reads it).
45
+ - `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.
46
+ - `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.
47
+ - DMs and group DMs are never searched. Every list item carries a permalink (`url`); a snippet that looked like a secret is `[withheld]`.
48
+
49
+ ## 4. Write the profile and drafts
50
+
51
+ Write 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.
52
+
53
+ Answer, in the user's terms:
54
+
55
+ - What they have shipped, what they own, what they are responsible for.
56
+ - What people tag them for, and what they write about.
57
+ - What they have missed: unanswered mentions, requested reviews, stale or conflicted PRs.
58
+ - Which open GitHub work-streams to close (stale, conflicted, superseded) and which to focus on.
59
+
60
+ Rules:
61
+
62
+ - Judge; the scripts only counted. No scoring formula and no thresholds from code: say why in a sentence.
63
+ - 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.
64
+ - Don't quote messages beyond the snippets, and don't name people beyond what a claim needs.
65
+ - 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.
66
+
67
+ For 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:
68
+
69
+ ```md
70
+ ---
71
+ cron: "0 9 * * 1-5"
72
+ ---
73
+
74
+ The prompt the bot runs.
75
+ ```
76
+
77
+ `cron` is five fields in UTC on a hosted deploy. Each draft says in its prompt which facts it reads and what it reports.
78
+
79
+ ## 5. Check
80
+
81
+ ```sh
82
+ node /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
83
+ ```
84
+
85
+ Fix every line it prints and rerun until it prints nothing. Then delete the two facts files.
86
+
87
+ ## 6. Open the pull request, if the workspace has a repo
88
+
89
+ When 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.
90
+
91
+ ## 7. Post one message to the user
92
+
93
+ In this thread: the profile, the three things to act on first, the schedule drafts (bot, cadence, what each does) with the PR link, and anything you could not read with what they must do about it. The sandbox setup is not part of this skill: say that it was not proposed because no Mac runner is connected to read the laptop. Only the schedule files ever leave the thread.
94
+
95
+ ## The Slack script
96
+
97
+ <!-- script:slack-facts.callscript.js -->
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ // Checks the model-written profile against the facts it came from:
3
+ // --profile p.md --facts a.json [b.json...] every URL in the profile is in the facts, and every list item cites one
4
+ // Exits 1 and prints one line per problem.
5
+ import { readFileSync } from "node:fs";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ const URL_RE = /https?:\/\/[^\s)>\]"'`]+/g;
9
+ const trimUrl = (u) => u.replace(/[.,;:!?]+$/, "");
10
+
11
+ export function factUrls(facts) {
12
+ const urls = new Set();
13
+ const walk = (v) => {
14
+ if (typeof v === "string") { if (/^https?:\/\//.test(v)) urls.add(v); }
15
+ else if (v && typeof v === "object") Object.values(v).forEach(walk);
16
+ };
17
+ facts.forEach(walk);
18
+ return urls;
19
+ }
20
+
21
+ export function citationProblems(profile, facts) {
22
+ const known = factUrls(facts);
23
+ const problems = [];
24
+ profile.split("\n").forEach((line, i) => {
25
+ const urls = (line.match(URL_RE) ?? []).map(trimUrl);
26
+ for (const u of urls) if (!known.has(u)) problems.push(`line ${i + 1}: ${u} is not in the facts`);
27
+ if (/^\s*(?:[-*]|\d+\.)\s/.test(line) && !urls.length) problems.push(`line ${i + 1}: list item cites no permalink: ${line.trim().slice(0, 60)}`);
28
+ });
29
+ return problems;
30
+ }
31
+
32
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
33
+ const args = process.argv.slice(2);
34
+ const after = (flag) => { const i = args.indexOf(flag); if (i < 0) return []; const out = []; for (let j = i + 1; j < args.length && !args[j].startsWith("--"); j++) out.push(args[j]); return out; };
35
+ const problems = [];
36
+ const [profile] = after("--profile");
37
+ if (profile) problems.push(...citationProblems(readFileSync(profile, "utf8"), after("--facts").map((f) => JSON.parse(readFileSync(f, "utf8")))));
38
+ problems.forEach((p) => console.log(p));
39
+ process.exit(problems.length ? 1 : 0);
40
+ }
@@ -0,0 +1,2 @@
1
+ // Generated by scripts/build-onboarding.mjs. Do not edit.
2
+ export default {"github-facts.mjs":"#!/usr/bin/env node\n// Facts about the signed-in `gh` user's last 90 days on GitHub, as one JSON document on stdout.\n// Counts, titles (<=200 chars), URLs and CODEOWNERS lines only; no judgment: the model reads this and profiles.\nimport { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\nimport { pathToFileURL } from \"node:url\";\nimport { snippet } from \"./redact.mjs\";\n\nconst exec = promisify(execFile);\nconst DAYS = 90;\nconst LIST_CAP = 150;\nconst CODEOWNERS_REPOS = 10;\nconst STATEFUL = new Set([\"open\", \"reviewRequested\"]);\nconst CODEOWNERS_PATHS = [\".github/CODEOWNERS\", \"CODEOWNERS\", \"docs/CODEOWNERS\"];\n\nconst BASE = `\n __typename\n ... on PullRequest { number title url createdAt updatedAt mergedAt isDraft additions deletions changedFiles repository { nameWithOwner } author { login } @STATE@ }\n ... on Issue { number title url createdAt updatedAt repository { nameWithOwner } author { login } }`;\nconst STATE = \"reviewDecision mergeable reviewRequests { totalCount } commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }\";\nconst searchQuery = (withState) => `query($q: String!, $endCursor: String) {\n search(query: $q, type: ISSUE, first: 50, after: $endCursor) {\n issueCount\n pageInfo { hasNextPage endCursor }\n nodes {${BASE.replace(\"@STATE@\", withState ? STATE : \"\")}}\n }\n}`;\n\nexport const realRun = async (args) => (await exec(\"gh\", args, { maxBuffer: 1 << 28 })).stdout;\n\nconst ATTEMPTS = 3;\n\nasync function search(run, q, withState = false) {\n const args = [\"api\", \"graphql\", \"--paginate\", \"--slurp\", \"-f\", `query=${searchQuery(withState)}`, \"-f\", `q=${q}`];\n let out;\n for (let attempt = 1; ; attempt++) {\n try {\n out = await run(args);\n break;\n } catch (e) {\n if (attempt === ATTEMPTS || !/HTTP 50\\d/.test(e.stderr ?? \"\")) throw e;\n await new Promise((r) => setTimeout(r, 1000 * attempt + Math.random() * 1000));\n }\n }\n const pages = JSON.parse(out);\n const nodes = pages.flatMap((p) => p.data.search.nodes).filter((n) => n?.url);\n return { total: pages[0].data.search.issueCount, nodes };\n}\n\nconst item = (n) => ({\n repo: n.repository.nameWithOwner,\n number: n.number,\n type: n.__typename,\n title: snippet(n.title),\n url: n.url,\n author: n.author?.login ?? null,\n createdAt: n.createdAt,\n updatedAt: n.updatedAt,\n ...(n.__typename === \"PullRequest\"\n ? {\n mergedAt: n.mergedAt,\n draft: n.isDraft,\n additions: n.additions,\n deletions: n.deletions,\n changedFiles: n.changedFiles,\n reviewDecision: n.reviewDecision ?? null,\n mergeable: n.mergeable ?? null,\n checks: n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state ?? null,\n reviewRequests: n.reviewRequests?.totalCount ?? null,\n }\n : {}),\n});\n\nconst byRecent = (a, b) => (b.mergedAt ?? b.updatedAt).localeCompare(a.mergedAt ?? a.updatedAt);\nconst listed = ({ total, nodes }) => ({ total, listed: Math.min(nodes.length, LIST_CAP), items: nodes.map(item).sort(byRecent).slice(0, LIST_CAP) });\n\nfunction perRepo(sets) {\n const repos = {};\n for (const [key, nodes] of Object.entries(sets)) {\n for (const n of nodes) {\n const r = (repos[n.repository.nameWithOwner] ??= { repo: n.repository.nameWithOwner, merged: 0, reviewed: 0, open: 0, additions: 0, deletions: 0 });\n r[key] += 1;\n if (key === \"merged\") { r.additions += n.additions ?? 0; r.deletions += n.deletions ?? 0; }\n }\n }\n return Object.values(repos).sort((a, b) => b.merged + b.reviewed + b.open - (a.merged + a.reviewed + a.open));\n}\n\nasync function codeownersLines(run, repo, login, teams) {\n const owners = [`@${login}`, ...teams.map((t) => `@${t}`)].map((o) => o.toLowerCase());\n for (const path of CODEOWNERS_PATHS) {\n let text;\n try {\n text = await run([\"api\", `repos/${repo}/contents/${path}`, \"-H\", \"Accept: application/vnd.github.raw+json\"]);\n } catch (e) {\n if (/HTTP 404/.test(e.stderr ?? e.message)) continue;\n throw e;\n }\n return text.split(\"\\n\")\n .map((l) => l.trim())\n .filter((l) => l && !l.startsWith(\"#\") && l.toLowerCase().split(/\\s+/).some((tok) => owners.includes(tok)))\n .map((line) => ({ repo, path, line: snippet(line) }));\n }\n return [];\n}\n\nexport async function collect({ run = realRun, now = new Date() } = {}) {\n const since = new Date(now.getTime() - DAYS * 86400_000).toISOString().slice(0, 10);\n const unreadable = [];\n const attempt = async (what, fn, fallback) => {\n try {\n return await fn();\n } catch (e) {\n unreadable.push({ what, error: snippet(e.stderr || e.message) });\n return fallback;\n }\n };\n const empty = { total: 0, nodes: [] };\n const login = JSON.parse(await run([\"api\", \"user\"])).login;\n const teams = await attempt(\"user teams\", async () => (await run([\"api\", \"user/teams\", \"--paginate\", \"--jq\", '.[] | .organization.login + \"/\" + .slug'])).split(\"\\n\").filter(Boolean), []);\n const q = {\n merged: `is:pr author:@me is:merged merged:>=${since} archived:false`,\n open: \"is:pr author:@me is:open archived:false\",\n reviewRequested: \"is:pr is:open review-requested:@me archived:false\",\n assignedIssues: \"is:issue is:open assignee:@me archived:false\",\n unansweredMentions: `is:open mentions:@me -commenter:@me -author:@me updated:>=${since} archived:false`,\n reviewed: `is:pr reviewed-by:@me -author:@me merged:>=${since} archived:false`,\n };\n const got = Object.fromEntries(await Promise.all(Object.entries(q).map(async ([k, v]) => [k, await attempt(`search ${k}`, () => search(run, v, STATEFUL.has(k)), empty)])));\n const repos = perRepo({ merged: got.merged.nodes, reviewed: got.reviewed.nodes, open: got.open.nodes });\n const owned = (await Promise.all(repos.slice(0, CODEOWNERS_REPOS).map((r) => attempt(`CODEOWNERS ${r.repo}`, () => codeownersLines(run, r.repo, login, teams), [])))).flat();\n return {\n login,\n window: { since, until: now.toISOString().slice(0, 10), days: DAYS },\n counts: Object.fromEntries(Object.entries(got).map(([k, v]) => [k, v.total])),\n repos: repos.slice(0, 30),\n merged: listed(got.merged),\n open: listed(got.open),\n reviewRequested: listed(got.reviewRequested),\n assignedIssues: listed(got.assignedIssues),\n unansweredMentions: listed(got.unansweredMentions),\n teams,\n codeowners: owned,\n unreadable,\n };\n}\n\nif (import.meta.url === pathToFileURL(process.argv[1] ?? \"\").href) {\n process.stdout.write(JSON.stringify(await collect(), null, 1) + \"\\n\");\n}\n","redact.mjs":"const TOKEN = /\\b(?:xox[a-z]-[A-Za-z0-9-]{8,}|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{16,}|vck_[A-Za-z0-9]{16,}|AKIA[A-Z0-9]{16}|eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]*)/g;\n\nexport const MAX_TEXT = 200;\n\nexport function snippet(text) {\n const flat = String(text ?? \"\").replace(/\\s+/g, \" \").trim().replace(TOKEN, \"[redacted]\");\n return flat.length > MAX_TEXT ? flat.slice(0, MAX_TEXT - 1) + \"…\" : flat;\n}\n","check-report.mjs":"#!/usr/bin/env node\n// Checks the model-written profile against the facts it came from:\n// --profile p.md --facts a.json [b.json...] every URL in the profile is in the facts, and every list item cites one\n// Exits 1 and prints one line per problem.\nimport { readFileSync } from \"node:fs\";\nimport { pathToFileURL } from \"node:url\";\n\nconst URL_RE = /https?:\\/\\/[^\\s)>\\]\"'`]+/g;\nconst trimUrl = (u) => u.replace(/[.,;:!?]+$/, \"\");\n\nexport function factUrls(facts) {\n const urls = new Set();\n const walk = (v) => {\n if (typeof v === \"string\") { if (/^https?:\\/\\//.test(v)) urls.add(v); }\n else if (v && typeof v === \"object\") Object.values(v).forEach(walk);\n };\n facts.forEach(walk);\n return urls;\n}\n\nexport function citationProblems(profile, facts) {\n const known = factUrls(facts);\n const problems = [];\n profile.split(\"\\n\").forEach((line, i) => {\n const urls = (line.match(URL_RE) ?? []).map(trimUrl);\n for (const u of urls) if (!known.has(u)) problems.push(`line ${i + 1}: ${u} is not in the facts`);\n if (/^\\s*(?:[-*]|\\d+\\.)\\s/.test(line) && !urls.length) problems.push(`line ${i + 1}: list item cites no permalink: ${line.trim().slice(0, 60)}`);\n });\n return problems;\n}\n\nif (import.meta.url === pathToFileURL(process.argv[1] ?? \"\").href) {\n const args = process.argv.slice(2);\n const after = (flag) => { const i = args.indexOf(flag); if (i < 0) return []; const out = []; for (let j = i + 1; j < args.length && !args[j].startsWith(\"--\"); j++) out.push(args[j]); return out; };\n const problems = [];\n const [profile] = after(\"--profile\");\n if (profile) problems.push(...citationProblems(readFileSync(profile, \"utf8\"), after(\"--facts\").map((f) => JSON.parse(readFileSync(f, \"utf8\")))));\n problems.forEach((p) => console.log(p));\n process.exit(problems.length ? 1 : 0);\n}\n"};
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ // Facts about the signed-in `gh` user's last 90 days on GitHub, as one JSON document on stdout.
3
+ // Counts, titles (<=200 chars), URLs and CODEOWNERS lines only; no judgment: the model reads this and profiles.
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ import { pathToFileURL } from "node:url";
7
+ import { snippet } from "./redact.mjs";
8
+
9
+ const exec = promisify(execFile);
10
+ const DAYS = 90;
11
+ const LIST_CAP = 150;
12
+ const CODEOWNERS_REPOS = 10;
13
+ const STATEFUL = new Set(["open", "reviewRequested"]);
14
+ const CODEOWNERS_PATHS = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"];
15
+
16
+ const BASE = `
17
+ __typename
18
+ ... on PullRequest { number title url createdAt updatedAt mergedAt isDraft additions deletions changedFiles repository { nameWithOwner } author { login } @STATE@ }
19
+ ... on Issue { number title url createdAt updatedAt repository { nameWithOwner } author { login } }`;
20
+ const STATE = "reviewDecision mergeable reviewRequests { totalCount } commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }";
21
+ const searchQuery = (withState) => `query($q: String!, $endCursor: String) {
22
+ search(query: $q, type: ISSUE, first: 50, after: $endCursor) {
23
+ issueCount
24
+ pageInfo { hasNextPage endCursor }
25
+ nodes {${BASE.replace("@STATE@", withState ? STATE : "")}}
26
+ }
27
+ }`;
28
+
29
+ export const realRun = async (args) => (await exec("gh", args, { maxBuffer: 1 << 28 })).stdout;
30
+
31
+ const ATTEMPTS = 3;
32
+
33
+ async function search(run, q, withState = false) {
34
+ const args = ["api", "graphql", "--paginate", "--slurp", "-f", `query=${searchQuery(withState)}`, "-f", `q=${q}`];
35
+ let out;
36
+ for (let attempt = 1; ; attempt++) {
37
+ try {
38
+ out = await run(args);
39
+ break;
40
+ } catch (e) {
41
+ if (attempt === ATTEMPTS || !/HTTP 50\d/.test(e.stderr ?? "")) throw e;
42
+ await new Promise((r) => setTimeout(r, 1000 * attempt + Math.random() * 1000));
43
+ }
44
+ }
45
+ const pages = JSON.parse(out);
46
+ const nodes = pages.flatMap((p) => p.data.search.nodes).filter((n) => n?.url);
47
+ return { total: pages[0].data.search.issueCount, nodes };
48
+ }
49
+
50
+ const item = (n) => ({
51
+ repo: n.repository.nameWithOwner,
52
+ number: n.number,
53
+ type: n.__typename,
54
+ title: snippet(n.title),
55
+ url: n.url,
56
+ author: n.author?.login ?? null,
57
+ createdAt: n.createdAt,
58
+ updatedAt: n.updatedAt,
59
+ ...(n.__typename === "PullRequest"
60
+ ? {
61
+ mergedAt: n.mergedAt,
62
+ draft: n.isDraft,
63
+ additions: n.additions,
64
+ deletions: n.deletions,
65
+ changedFiles: n.changedFiles,
66
+ reviewDecision: n.reviewDecision ?? null,
67
+ mergeable: n.mergeable ?? null,
68
+ checks: n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state ?? null,
69
+ reviewRequests: n.reviewRequests?.totalCount ?? null,
70
+ }
71
+ : {}),
72
+ });
73
+
74
+ const byRecent = (a, b) => (b.mergedAt ?? b.updatedAt).localeCompare(a.mergedAt ?? a.updatedAt);
75
+ const listed = ({ total, nodes }) => ({ total, listed: Math.min(nodes.length, LIST_CAP), items: nodes.map(item).sort(byRecent).slice(0, LIST_CAP) });
76
+
77
+ function perRepo(sets) {
78
+ const repos = {};
79
+ for (const [key, nodes] of Object.entries(sets)) {
80
+ for (const n of nodes) {
81
+ const r = (repos[n.repository.nameWithOwner] ??= { repo: n.repository.nameWithOwner, merged: 0, reviewed: 0, open: 0, additions: 0, deletions: 0 });
82
+ r[key] += 1;
83
+ if (key === "merged") { r.additions += n.additions ?? 0; r.deletions += n.deletions ?? 0; }
84
+ }
85
+ }
86
+ return Object.values(repos).sort((a, b) => b.merged + b.reviewed + b.open - (a.merged + a.reviewed + a.open));
87
+ }
88
+
89
+ async function codeownersLines(run, repo, login, teams) {
90
+ const owners = [`@${login}`, ...teams.map((t) => `@${t}`)].map((o) => o.toLowerCase());
91
+ for (const path of CODEOWNERS_PATHS) {
92
+ let text;
93
+ try {
94
+ text = await run(["api", `repos/${repo}/contents/${path}`, "-H", "Accept: application/vnd.github.raw+json"]);
95
+ } catch (e) {
96
+ if (/HTTP 404/.test(e.stderr ?? e.message)) continue;
97
+ throw e;
98
+ }
99
+ return text.split("\n")
100
+ .map((l) => l.trim())
101
+ .filter((l) => l && !l.startsWith("#") && l.toLowerCase().split(/\s+/).some((tok) => owners.includes(tok)))
102
+ .map((line) => ({ repo, path, line: snippet(line) }));
103
+ }
104
+ return [];
105
+ }
106
+
107
+ export async function collect({ run = realRun, now = new Date() } = {}) {
108
+ const since = new Date(now.getTime() - DAYS * 86400_000).toISOString().slice(0, 10);
109
+ const unreadable = [];
110
+ const attempt = async (what, fn, fallback) => {
111
+ try {
112
+ return await fn();
113
+ } catch (e) {
114
+ unreadable.push({ what, error: snippet(e.stderr || e.message) });
115
+ return fallback;
116
+ }
117
+ };
118
+ const empty = { total: 0, nodes: [] };
119
+ const login = JSON.parse(await run(["api", "user"])).login;
120
+ const teams = await attempt("user teams", async () => (await run(["api", "user/teams", "--paginate", "--jq", '.[] | .organization.login + "/" + .slug'])).split("\n").filter(Boolean), []);
121
+ const q = {
122
+ merged: `is:pr author:@me is:merged merged:>=${since} archived:false`,
123
+ open: "is:pr author:@me is:open archived:false",
124
+ reviewRequested: "is:pr is:open review-requested:@me archived:false",
125
+ assignedIssues: "is:issue is:open assignee:@me archived:false",
126
+ unansweredMentions: `is:open mentions:@me -commenter:@me -author:@me updated:>=${since} archived:false`,
127
+ reviewed: `is:pr reviewed-by:@me -author:@me merged:>=${since} archived:false`,
128
+ };
129
+ const got = Object.fromEntries(await Promise.all(Object.entries(q).map(async ([k, v]) => [k, await attempt(`search ${k}`, () => search(run, v, STATEFUL.has(k)), empty)])));
130
+ const repos = perRepo({ merged: got.merged.nodes, reviewed: got.reviewed.nodes, open: got.open.nodes });
131
+ const owned = (await Promise.all(repos.slice(0, CODEOWNERS_REPOS).map((r) => attempt(`CODEOWNERS ${r.repo}`, () => codeownersLines(run, r.repo, login, teams), [])))).flat();
132
+ return {
133
+ login,
134
+ window: { since, until: now.toISOString().slice(0, 10), days: DAYS },
135
+ counts: Object.fromEntries(Object.entries(got).map(([k, v]) => [k, v.total])),
136
+ repos: repos.slice(0, 30),
137
+ merged: listed(got.merged),
138
+ open: listed(got.open),
139
+ reviewRequested: listed(got.reviewRequested),
140
+ assignedIssues: listed(got.assignedIssues),
141
+ unansweredMentions: listed(got.unansweredMentions),
142
+ teams,
143
+ codeowners: owned,
144
+ unreadable,
145
+ };
146
+ }
147
+
148
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
149
+ process.stdout.write(JSON.stringify(await collect(), null, 1) + "\n");
150
+ }
@@ -0,0 +1,8 @@
1
+ const TOKEN = /\b(?:xox[a-z]-[A-Za-z0-9-]{8,}|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{16,}|vck_[A-Za-z0-9]{16,}|AKIA[A-Z0-9]{16}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]*)/g;
2
+
3
+ export const MAX_TEXT = 200;
4
+
5
+ export function snippet(text) {
6
+ const flat = String(text ?? "").replace(/\s+/g, " ").trim().replace(TOKEN, "[redacted]");
7
+ return flat.length > MAX_TEXT ? flat.slice(0, MAX_TEXT - 1) + "…" : flat;
8
+ }
@@ -0,0 +1,53 @@
1
+ // Facts about the signed-in user's recent Slack channel activity. Counts, permalinks and short snippets only.
2
+ // Searches exclude DMs and group DMs (-is:dm) so none is ever read. Slack allows about 20 searches a minute: this
3
+ // script makes at most 19, so on a ratelimited error wait a minute and run it again unchanged.
4
+ // The .slice(0, n) before each .map is not redundant: callscript counts worst-case calls from it (200 max).
5
+ const DAYS = 30;
6
+ const after = new Date(Date.now() - (DAYS + 1) * 86400000).slice(0, 10);
7
+ const auth = await slack.auth_test();
8
+ const me = auth.user_id;
9
+ const 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" })));
10
+ const 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" })));
11
+ const member = await slack.users_conversations({ types: "public_channel,private_channel", limit: 200, exclude_archived: true });
12
+ const refused = [auth, ...ownPages, ...mentionPages, member].filter(r => r.ok === false);
13
+ if (refused.length > 0) {
14
+ return { refused: { error: refused[0].error, needed: refused[0].needed ?? null, provided: refused[0].provided ?? null, calls: refused.length } };
15
+ }
16
+ const 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);
17
+ const total = channels.reduce((n, c) => n + c.count, 0);
18
+ const threadReplies = channels.reduce((n, c) => n + c.threadReplies, 0);
19
+ const 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) }));
20
+ const ownMessages = ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim);
21
+ const 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)]));
22
+ const oldestOwn = channels.length ? channels.reduce((t, c) => Math.min(t, Number(c.oldest)), Number(channels[0].oldest)) : 0;
23
+ const newestOnly = ownPages[0].messages.paging.pages > ownPages.length;
24
+ const known = mentioned.map(m => ({ ...m, replied: (lastOwn[`${m.channel}:${m.thread}`] ?? 0) > Number(m.ts) }));
25
+ const judged = known.filter(m => m.replied || !newestOnly || Number(m.ts) >= oldestOwn);
26
+ const open = judged.filter(m => !m.replied);
27
+ return {
28
+ me: { id: me, name: auth.user },
29
+ window: { days: DAYS, after, searchTotal: ownPages[0].messages.paging.total, pagesTotal: ownPages[0].messages.paging.pages, pagesRead: ownPages.length, newestOnly },
30
+ scope: "public and private channels; DMs and group DMs are dropped before counting",
31
+ own: {
32
+ total,
33
+ threadReplies,
34
+ topLevel: total - threadReplies,
35
+ oldest: channels.length ? oldestOwn : null,
36
+ channelCount: channels.length,
37
+ channels: channels.slice(0, 25).map(c => ({ id: c.id, channel: c.channel, count: c.count, threadReplies: c.threadReplies, recent: c.recent }))
38
+ },
39
+ mentions: {
40
+ searchTotal: mentionPages[0].messages.paging.total,
41
+ pagesTotal: mentionPages[0].messages.paging.pages,
42
+ pagesRead: mentionPages.length,
43
+ capped: mentionPages[0].messages.paging.pages > mentionPages.length,
44
+ found: mentioned.length,
45
+ judged: judged.length,
46
+ notJudged: mentioned.length - judged.length,
47
+ replied: judged.length - open.length,
48
+ unanswered: open.length,
49
+ unansweredItems: open.slice(0, 20).map(m => ({ url: m.url, channel: m.name, author: m.author, ts: m.ts, text: m.text })),
50
+ 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"
51
+ },
52
+ memberships: { count: member.channels.length, names: member.channels.map(c => c.name).sort(), more: Boolean(member.response_metadata?.next_cursor) }
53
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eve-hub",
3
- "version": "0.6.9",
3
+ "version": "0.6.11",
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",
package/prepare.mjs CHANGED
@@ -19,7 +19,7 @@
19
19
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
20
20
  import { basename, dirname, join } from "node:path";
21
21
  import { GITHUB_PATH } from "./github-path.mjs";
22
- import { KIT_FILES, MAIN_KIT_FILES } from "./stubs.mjs";
22
+ import { KIT_FILES, MAIN_KIT_FILES, REMOVED_KIT_FILES } from "./stubs.mjs";
23
23
  import { deploymentFacts, hostedContext } from "./execution-context.mjs";
24
24
  import { botDomains, domainFolders, readBotMemory, readMemory, validateMemory } from "./memory.mjs";
25
25
  import { agentRunsOn, readWorkspaceFile, removeWorkspaceAgent } from "./workspace.mjs";
@@ -37,9 +37,10 @@ import { agentRunsOn, readWorkspaceFile, removeWorkspaceAgent } from "./workspac
37
37
  // roster by commit), memory (the workspace's files), and main's schedules.
38
38
  export const DEPLOYED_KIT_FILES = new Set([
39
39
  "channels/eve.ts",
40
- "skills/change-explainer.ts",
40
+ "skills/onboarding-profile.ts",
41
+ "tools/onboarding_scripts.ts",
41
42
  ...["react", "show", "update_thread", "list_threads", "read_thread", "post_message"].map((t) => `tools/${t}.ts`),
42
- ...["create_thread", "set_summary", "move_path", "archive_thread", "unarchive_thread", "list_agents", "agent_send", "tag_threads", "dash_update", "apply_tags", "artifact_publish", "artifact_status", "artifact_list"].map((t) => `tools/${t}.ts`),
43
+ ...["create_thread", "set_summary", "move_path", "archive_thread", "unarchive_thread", "list_agents", "agent_send", "tag_threads", "dash_update", "apply_tags"].map((t) => `tools/${t}.ts`),
43
44
  ]);
44
45
 
45
46
 
@@ -187,6 +188,7 @@ export function prepareDeployment(dir) {
187
188
  if (!file.endsWith("/agent.ts")) continue;
188
189
  const bot = dirname(join(dir, "agents", file));
189
190
  for (const stub of Object.keys({ ...MAIN_KIT_FILES, "memory/workspace.ts": 0, "memory/bot.ts": 0 })) if (!DEPLOYED_KIT_FILES.has(stub) && existsSync(join(bot, stub))) removeStub(bot, stub);
191
+ for (const stub of REMOVED_KIT_FILES) if (existsSync(join(bot, stub))) removeStub(bot, stub);
190
192
  // learning-review is not in MAIN_KIT_FILES, but also requires the host.
191
193
  if (existsSync(join(bot, "schedules/learning-review.ts"))) removeStub(bot, "schedules/learning-review.ts");
192
194
  const name = basename(dirname(bot));
@@ -0,0 +1,2 @@
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 message from you at the end (step 7), not one per step. If a step cannot finish, that message says which one and what the user must do; never write the profile as if every source had been read.\n\n## 1. Ask for Kit access, once\n\nKit's tools are `request_access`, `request_status` and `execute`. Hosted, you reach them through the `vercel-kit` connection: `connection_execute({ connection: \"vercel-kit\", tool: \"request_access\", input: { ... } })`, and the same shape for the others. Everywhere below, \"call `request_access`\" means that.\n\nSlack goes through the user's Kit connection. Before any fetch, ask for everything the run reads in one request:\n\n```\nrequest_access({ service: \"slack\", reads: true, reason: \"Read your recent channel messages, mentions and threads to write your onboarding profile\" })\n```\n\n- `approved`: go on.\n- `pending` (or `connection_required`): tell the user, in one sentence, that Slack access is waiting, with the `approveUrl`; then `request_status({ request })` until they decide. Do not start step 3 before it is approved. If they decline, the final message says the Slack half was not read.\n\nGitHub does not use Kit: it uses `gh` in your sandbox (step 2). If `gh auth status` fails, the final message says GitHub was not read.\n\n## 2. GitHub facts\n\nCall the `onboarding_scripts` tool once: it writes the skill's scripts into your sandbox at `/tmp/onboarding-skill/` and returns that directory.\n\n```sh\nnode /tmp/onboarding-skill/github-facts.mjs > /tmp/onboarding-skill/github-facts.json\n```\n\nIt prints one JSON document: `counts` (merged PRs, open PRs, review requests, assigned issues, mentions never replied to, PRs reviewed), `repos` (merged, reviewed, open and lines changed per repo), item lists (`merged`, `open`, `reviewRequested`, `assignedIssues`, `unansweredMentions`; `total` is the full count, `items` the newest 150), `teams`, and `codeowners` lines naming the user or their teams. Open PRs carry `draft`, `reviewDecision`, `mergeable` and `checks`. `author:@me` includes PRs the user's bots open under their login. `unreadable` lists a source that could not be read.\n\n## 3. Slack facts\n\nRun the script in \"The Slack script\" below with Kit's `execute`: pass it as the script, unchanged. It runs inside Kit, so no raw message reaches you; it returns one JSON document in about 10 seconds.\n\n- A `ratelimited` error means Slack's search limit (about 20 a minute; the script makes at most 19). Wait 70 seconds (`sleep 70`) and run it again unchanged, up to five times.\n- A `suspended` result whose output says `consent_required` is a pause, not an error: tell the user in one sentence that the Slack read is waiting, with its `approveUrl`; `request_status({ request })` until they decide; then `execute({ run })` continues the same run (settled steps are not repeated).\n- An `output` with `refused` means Slack answered `missing_scope` (or another refusal) to a call, so no facts were read: `refused.needed` names the scope the user's Slack connection lacks (`refused.provided` what it has). Do not continue. Call `request_access({ service: \"slack\", capability: \"search_messages\", reason })`: if it returns an `approveUrl`, give it to the user; otherwise tell them to reconnect Slack in Kit allowing `refused.needed`. The final message says the Slack half was not read and names the scope.\n- Any other error goes to the user in the final message. Never continue with fewer facts, and never retry around a refusal.\n- Save the run's `output` as `/tmp/onboarding-skill/slack-facts.json` (the checker reads it).\n- `window`: the last 30 days. Slack search allows 15 pages of 100 here, so a very active account is covered only back to `own.oldest` (`newestOnly: true`); state that window in the profile.\n- `own`: messages per channel, thread replies, recent snippets. `mentions`: `replied` means the user posted later in the same thread; `unanswered` means they did not. A mention older than what was read with no reply among it (`notJudged`) is unknown, not unanswered. `mentions.capped` means the search found more mentions (`searchTotal`) than the three pages read: say so in \"Could not tell\". Reactions are not read (the Slack grant has no `reactions:read`): say so in \"Could not tell\". `memberships`: channels the user is in.\n- DMs and group DMs are never searched. Every list item carries a permalink (`url`); a snippet that looked like a secret is `[withheld]`.\n\n## 4. Write the profile and drafts\n\nWrite the profile to `/tmp/onboarding-skill/profile.md`. It names the user's repos and colleagues, so it is posted only in this thread and never committed.\n\nAnswer, in the user's terms:\n\n- What they have shipped, what they own, what they are responsible for.\n- What people tag them for, and what they write about.\n- What they have missed: unanswered mentions, requested reviews, stale or conflicted PRs.\n- Which open GitHub work-streams to close (stale, conflicted, superseded) and which to focus on.\n\nRules:\n\n- Judge; the scripts only counted. No scoring formula and no thresholds from code: say why in a sentence.\n- Every list item cites at least one permalink that appears in the facts, and no URL appears that isn't in the facts. State counts as the facts give them, with the window.\n- Don't quote messages beyond the snippets, and don't name people beyond what a claim needs.\n- Add a \"Could not tell\" section: every `unreadable` source, `notJudged` mentions, that reactions were not read, the Slack window, a Kit grant the user declined.\n\nFor each recurring need you found (a morning pass over unanswered mentions, a weekly stale-PR pass), write one draft to `/tmp/onboarding-skill/schedules/<bot>/<id>.md` for an existing bot of your workspace:\n\n```md\n---\ncron: \"0 9 * * 1-5\"\n---\n\nThe prompt the bot runs.\n```\n\n`cron` is five fields in UTC on a hosted deploy. Each draft says in its prompt which facts it reads and what it reports.\n\n## 5. Check\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --profile /tmp/onboarding-skill/profile.md --facts /tmp/onboarding-skill/github-facts.json /tmp/onboarding-skill/slack-facts.json\n```\n\nFix every line it prints and rerun until it prints nothing. Then delete the two facts files.\n\n## 6. Open the pull request, if the workspace has a repo\n\nWhen the workspace is backed by a GitHub repo, open one PR on it that adds only the schedule drafts, as `agents/<bot>/agent/schedules/<id>.md`, and nothing from the profile (`gh pr create`). The user merging it is their approval; the deploy that follows is what makes the schedules run. The PR's checks include the deploy's build, which rejects a bad cron or a bot that does not exist: watch them (`gh pr checks`), fix the draft and push again until they pass, and say in the message if they do not. Without a repo the drafts stay in the thread and the message says so.\n\n## 7. Post one message to the user\n\nIn this thread: the profile, the three things to act on first, the schedule drafts (bot, cadence, what each does) with the PR link, and anything you could not read with what they must do about it. The sandbox setup is not part of this skill: say that it was not proposed because no Mac runner is connected to read the laptop. Only 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"};
@@ -0,0 +1,6 @@
1
+ import { defineSkill } from "eve/skills";
2
+ import skill from "./onboarding-profile.gen.mjs";
3
+ // The onboarding-profile skill: onboarding/SKILL.md with its Slack script
4
+ // inlined, embedded at build time (scripts/build-onboarding.mjs) so it
5
+ // loads the same on the hub's machine and in a deployed bot.
6
+ export default defineSkill(skill);
package/stubs.mjs CHANGED
@@ -1,6 +1,10 @@
1
1
  // eve finds tools, hooks, and channels by file (the tool name is the file
2
2
  // name), so each bot keeps a one-line stub per kit module. Bot file -> what it
3
3
  // is, and which kit module it re-exports (default: the same path).
4
+ // Kit files whose module is gone. Sync retires them from every bot, and the
5
+ // hosted deploy prunes them (the change-explainer: its skill and artifact tools).
6
+ export const REMOVED_KIT_FILES = Object.freeze(["tools/artifact_publish.ts", "tools/artifact_status.ts", "tools/artifact_list.ts", "skills/change-explainer.ts"]);
7
+
4
8
  export const KIT_FILES = {
5
9
  "channels/eve.ts": ["Queued eve channel that resolves the registry's human as a user.", "channels/eve-roster-user.ts"],
6
10
  "hooks/sandbox.ts": ["Migrates legacy Docker sessions to the host sandbox."],
@@ -14,10 +18,6 @@ export const KIT_FILES = {
14
18
  "tools/update_thread.ts": ["Update a thread this bot is in: title, track, domain and summary."],
15
19
  "tools/list_threads.ts": ["Every workspace thread, from any session."],
16
20
  "tools/read_thread.ts": ["Read any workspace thread."],
17
- "tools/artifact_publish.ts": ["Publish the next version of an artifact (a change explainer, ...) and show its card in the thread."],
18
- "tools/artifact_status.ts": ["Whether an artifact this bot published still describes its source."],
19
- "tools/artifact_list.ts": ["The artifacts this bot owns, or every artifact in the workspace."],
20
- "skills/change-explainer.ts": ["The change-explainer skill (packages/explainer/skills/change-explainer/SKILL.md), which artifact_publish's change explainers follow."],
21
21
  "tools/post_message.ts": ["Post into an existing thread or a bot's DM; with `status`, report the thread's phase."],
22
22
  };
23
23
 
@@ -25,6 +25,8 @@ export const KIT_FILES = {
25
25
  // plus the tools that shape the workspace.
26
26
  export const MAIN_KIT_FILES = {
27
27
  ...KIT_FILES,
28
+ "skills/onboarding-profile.ts": ["The onboarding-profile skill (packages/agent-kit/onboarding/SKILL.md): after sign-in, reads the user's Slack and GitHub and posts a profile with suggested schedules."],
29
+ "tools/onboarding_scripts.ts": ["Writes the onboarding-profile skill's sandbox scripts into the sandbox."],
28
30
  "tools/create_thread.ts": ["Open a workspace thread for one piece of work."],
29
31
  "tools/set_summary.ts": ["Write the summary of a path (project/track) or a thread."],
30
32
  "tools/move_path.ts": ["Rename or merge a path (project/track): every thread under it moves."],
@@ -30,7 +30,7 @@ When several bots work on one piece of work, coordinate through posts; don't rou
30
30
  2. When you push, tell the bots your change affects: the owner of a branch stacked on yours, and the reviewer. Use `@all` if you can't tell who. To reach a bot in another thread, use `post_message`. A branch has one owner; anyone else works on a branch stacked on top, or the owner hands it over.
31
31
  3. When the thread's phase changes (planning → working → reviewing → done; a review can send it back to working or planning), post a status: `post_message` into this thread with `status` set to the new phase, as the text at most 8 plain words on what is happening now ("Building the input-count fix", not "Plan approved by Rui; builder starting PR 1 (count only new input)": no names, no restating the phase, no parentheticals), and `metadata: { pr, sha }` when there are any (the PR URL and sha go there, never in the text). A status is a phase marker, not a report: test results, branch names and caveats go in a separate message. It wakes nobody unless you @-mention someone, so it doesn't replace your message. The workspace state shows the thread's current phase.
32
32
 
33
- A reviewer records its verdict on the same kind of post: `verdict` (`ship`, `fix-first` or `rethink`) with `metadata: { pr, sha }`, the PR as its full GitHub URL (like every status post) and the sha you reviewed; the text is the one-line what-changed. Add `decisions`, a list of `{ question, options: [{ label, pickedBy? }] }` for what the human has to pick (`[]` if nothing; a `ship` without the list is refused). Publish the change explainer with `artifact_publish` at the first ship and reuse it on later passes; the Dash row says "no explainer" without one. CI and the PR's head come from GitHub, not from your post; don't state them as facts there.
33
+ A reviewer records its verdict on the same kind of post: `verdict` (`ship`, `fix-first` or `rethink`) with `metadata: { pr, sha }`, the PR as its full GitHub URL (like every status post) and the sha you reviewed; the text is the one-line what-changed. Add `decisions`, a list of `{ question, options: [{ label, pickedBy? }] }` for what the human has to pick (`[]` if nothing; a `ship` without the list is refused). CI and the PR's head come from GitHub, not from your post; don't state them as facts there.
34
34
  4. When you need another specialty or more help, ask main, or the bot whose role fits.
35
35
 
36
36
  On a conflict, read the push posts and ask the bot that pushed.
@@ -0,0 +1,10 @@
1
+ declare const _default: import("eve/tools").ToolDefinition<Record<string, never>, {
2
+ dir: string;
3
+ files: string[];
4
+ }> & {
5
+ execute(input: Record<string, never>, ctx: import("eve/tools").ToolContext): Promise<{
6
+ dir: string;
7
+ files: string[];
8
+ }>;
9
+ };
10
+ export default _default;
@@ -0,0 +1,19 @@
1
+ // The `onboarding_scripts` tool: writes the onboarding-profile skill's sandbox
2
+ // scripts (GitHub facts, the report checker) into this session's sandbox.
3
+ import { defineTool } from "eve/tools";
4
+ import { z } from "zod";
5
+ import files from "../onboarding/files.gen.mjs";
6
+ const DIR = "/tmp/onboarding-skill";
7
+ export default defineTool({
8
+ description: `Starts a fresh ${DIR} in your sandbox, writes the onboarding-profile skill's scripts into it and returns the directory. Call it once, before the skill's GitHub step.`,
9
+ inputSchema: z.object({}),
10
+ async execute(_input, ctx) {
11
+ const sandbox = await ctx.getSandbox();
12
+ const made = await sandbox.run({ command: `rm -rf ${DIR} && mkdir -p ${DIR}` });
13
+ if (made.exitCode !== 0)
14
+ throw new Error(`mkdir failed: ${made.stderr || made.stdout}`);
15
+ for (const [name, content] of Object.entries(files))
16
+ await sandbox.writeTextFile({ path: `${DIR}/${name}`, content });
17
+ return { dir: DIR, files: Object.keys(files) };
18
+ },
19
+ });
@@ -3,7 +3,7 @@ import { z } from "zod";
3
3
  import { registryFetch } from "../registry-fetch.mjs";
4
4
  // Posts as you into a thread or DM, from whichever session you're in. With no
5
5
  // target it posts into the thread you're in, as part of the running turn: it
6
- // carries the turn's id and the images and artifact cards collected so far,
6
+ // carries the turn's id and the images collected so far,
7
7
  // and if it's your turn's last act, your end-of-turn text isn't posted. Into
8
8
  // another thread, mentions wake bots exactly as your replies do; posting into
9
9
  // a bot's DM wakes that bot; archived threads refuse it. Your session in the