eve-hub 0.6.10 → 0.6.12

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.12
4
+
5
+ ### Patch Changes
6
+
7
+ - The agent-usage-setup skill (a local skill on the hub's Mac) infers a repo's sandbox setup from the user's coding-agent history: it parses shell commands with tree-sitter-bash, probes the real sandbox image, and proposes a verified prepare.
8
+
9
+ ## 0.6.11
10
+
11
+ ### Patch Changes
12
+
13
+ - 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.
14
+
3
15
  ## 0.6.10
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.10",
3
+ "version": "0.6.12",
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",
@@ -12,6 +12,7 @@
12
12
  "./channels/*": "./channels/*.js",
13
13
  "./instructions/*": "./instructions/*.js",
14
14
  "./memory/*": "./memory/*.js",
15
+ "./skills/*": "./skills/*.js",
15
16
  "./host-sandbox": "./host-sandbox.js",
16
17
  "./host-browser": "./host-browser.js",
17
18
  "./roster-model": "./roster-model.mjs",
package/prepare.mjs CHANGED
@@ -37,6 +37,8 @@ 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/onboarding-profile.ts",
41
+ "tools/onboarding_scripts.ts",
40
42
  ...["react", "show", "update_thread", "list_threads", "read_thread", "post_message"].map((t) => `tools/${t}.ts`),
41
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`),
42
44
  ]);
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ // Generated by scripts/build-onboarding.mjs from 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
@@ -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."],
@@ -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
+ });