eve-hub 0.13.2 → 0.13.4
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 +12 -0
- package/README.md +1 -1
- package/execution-context.mjs +4 -1
- package/onboarding/SKILL.md +34 -3
- package/onboarding/check-report.mjs +31 -0
- package/onboarding/files.gen.mjs +1 -1
- package/package.json +1 -1
- package/prepare.mjs +10 -6
- package/skills/onboarding-profile.gen.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# eve-hub
|
|
2
2
|
|
|
3
|
+
## 0.13.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Onboarding's final post is one line about the user, then the three actions and the schedules as tables; `check-report.mjs --post` rejects a first bubble longer than two sentences.
|
|
8
|
+
|
|
9
|
+
## 0.13.3
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- A hosted bot's GitHub guidance follows one fact, the build's `GITHUB_TOKEN`. With it, the build mounts eve's git extension (its `# GitHub` section) and the execution context says `git` and `gh` are signed in. Without it, no bot mounts the git extension (a stub the workspace repo carries is removed), and the execution context's new GitHub line says the shell has no credential and points to the `vercel-kit` connection when one is listed.
|
|
14
|
+
|
|
3
15
|
## 0.13.2
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ A deployed workspace (lib/workspace-template.mjs, staged by hosted-hub/scripts/s
|
|
|
61
61
|
|
|
62
62
|
Releases go through Changesets, as in eve. A PR that changes the kit adds a changeset (`pnpm changeset`) and applies it (`pnpm changeset version`); when it merges, `.github/workflows/release.yml` runs `changeset publish` (no bot commits to main: its ruleset allows only PR merges): the kit's `prepack` stages the package into `dist/npm` (its `publishConfig.directory`), npm gets it, and the `eve-hub@<version>` GitHub release is created. npm auth is the repo's `NPM_TOKEN` secret (an npm automation token). The template pins the version this checkout carries.
|
|
63
63
|
|
|
64
|
-
The template's main gets a Vercel Sandbox and only the kit stubs that run without the hub's machine (lib/workspace-template.mjs `DEPLOYED_KIT_FILES`; eve's own bash replaces the kit's). The template's builder also mounts eve-code (`eve/extensions/code`: `apply_patch`, `grep`, the review skills, a read-only worker) and `eve/extensions/git` (`git({})`), both with no `github` option. The bot uses `gh` and `git` from bash, whose GitHub token is the sandbox firewall's (the Vercel sandbox `create_agent` writes, lib/agent-template.mjs `sandboxFile`), and no `gh` tool is contributed. A deployed workspace's eve is the one pin in `hosted-hub/bots/package.json`, which staging copies over the template's (an eve snapshot with `eve/extensions/git`; `hosted-hub/test/stage-workspace.test.mjs` checks the staged imports resolve against it). The hub and local bots stay on the hub's eve. The published tarball (`scripts/pack.mjs`) leaves out the kit__worker extension, which mounts only on the hub's machine. The hub reaches each bot's eve channel with `Authorization: Bearer $BOT_TOKEN` (`bot-token.mjs`); the kit's calls to the hub (`registry-fetch.mjs`, at `ROSTER_REGISTRY_URL`) carry `Authorization: Bearer $HUB_TOKEN` when set. The hosted hub keeps Vercel Authentication on: a deployed bot sends its project's OIDC token in `x-vercel-trusted-oidc-idp-token` too (the hub project lists each bots project in its Trusted Sources, `hosted-hub/src/onboard/trust.ts` `trustBots`, and never the bypass secret, even while its deployment still holds the env), and a bot on a Mac sends `x-vercel-protection-bypass: $HUB_PROTECTION_BYPASS` (the hub project's automation bypass secret) when it has one.
|
|
64
|
+
The template's main gets a Vercel Sandbox and only the kit stubs that run without the hub's machine (lib/workspace-template.mjs `DEPLOYED_KIT_FILES`; eve's own bash replaces the kit's). The template's builder also mounts eve-code (`eve/extensions/code`: `apply_patch`, `grep`, the review skills, a read-only worker) and `eve/extensions/git` (`git({})`), both with no `github` option. Every bot of a build with the project's `GITHUB_TOKEN` mounts `eve/extensions/git`; a build without one mounts it nowhere, and the execution context points the bot to the `vercel-kit` connection instead (packages/agent-kit/prepare.mjs, execution-context.mjs; the rendered prompts are snapshotted in hosted-hub/test/prompts). The bot uses `gh` and `git` from bash, whose GitHub token is the sandbox firewall's (the Vercel sandbox `create_agent` writes, lib/agent-template.mjs `sandboxFile`), and no `gh` tool is contributed. A deployed workspace's eve is the one pin in `hosted-hub/bots/package.json`, which staging copies over the template's (an eve snapshot with `eve/extensions/git`; `hosted-hub/test/stage-workspace.test.mjs` checks the staged imports resolve against it). The hub and local bots stay on the hub's eve. The published tarball (`scripts/pack.mjs`) leaves out the kit__worker extension, which mounts only on the hub's machine. The hub reaches each bot's eve channel with `Authorization: Bearer $BOT_TOKEN` (`bot-token.mjs`); the kit's calls to the hub (`registry-fetch.mjs`, at `ROSTER_REGISTRY_URL`) carry `Authorization: Bearer $HUB_TOKEN` when set. The hosted hub keeps Vercel Authentication on: a deployed bot sends its project's OIDC token in `x-vercel-trusted-oidc-idp-token` too (the hub project lists each bots project in its Trusted Sources, `hosted-hub/src/onboard/trust.ts` `trustBots`, and never the bypass secret, even while its deployment still holds the env), and a bot on a Mac sends `x-vercel-protection-bypass: $HUB_PROTECTION_BYPASS` (the hub project's automation bypass secret) when it has one.
|
|
65
65
|
|
|
66
66
|
## Vercel Kit connection
|
|
67
67
|
|
package/execution-context.mjs
CHANGED
|
@@ -55,8 +55,11 @@ export function hostedContext({ bot, kit, removed, deployment: d }, options) {
|
|
|
55
55
|
].filter(Boolean).join(", ") || "no Vercel facts were available to this build"}. Each bot is its own service at \`/eve/<name>\` in that one project.`,
|
|
56
56
|
`- **Source:** ${d.repo ? `built from \`${d.repo}\`${d.branch ? ` on \`${d.branch}\`` : ""}${d.sha ? ` at commit \`${d.sha}\`` : ""}` : "no git repository: the hub uploaded these files"}. You are made of files in it: \`agents/${bot}/\`, \`memory/\` and \`hub.workspace.json\`.`,
|
|
57
57
|
d.repo
|
|
58
|
-
?
|
|
58
|
+
? "- **Changing it:** commit to that repository; Vercel builds and deploys the commit."
|
|
59
59
|
: "- **Changing it:** there is no repository to commit to. The hub replaces these files on its next deploy, so an edit made only in this deployment does not last.",
|
|
60
|
+
d.githubToken
|
|
61
|
+
? "- **GitHub:** `git` and `gh` in your shell are already signed in and set to sign commits as the owner; do not set your own author or identity, or GitHub rejects the push."
|
|
62
|
+
: "- **GitHub:** your shell has no GitHub credential: `git` cannot authenticate, so a private repository won't clone and nothing can be pushed. When a `vercel-kit` connection is listed, reach GitHub through it (connection_search, then connection_execute); otherwise tell the human this deployment has no GitHub access.",
|
|
60
63
|
"- **Memory:** the standing decisions you recall are the `memory/` and `agents/<bot>/memory/` files, built into each deployment. An entry takes effect when the deployment that contains it is live, not on your next turn; with no repository it cannot last.",
|
|
61
64
|
"- **Limits:** there is no `hub` CLI, you cannot restart yourself or any bot, and there are no host paths: your shell is a Vercel Sandbox with its own filesystem, not the owner's machine.",
|
|
62
65
|
`- **Kit tools this build removed:** ${removed.length ? list(removed) : "none"}. They need the hub's machine, or change the roster, which a deployment does by commit.`,
|
package/onboarding/SKILL.md
CHANGED
|
@@ -6,7 +6,7 @@ license: MIT
|
|
|
6
6
|
|
|
7
7
|
# Profile the user from Slack and GitHub
|
|
8
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.
|
|
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 short message from you at the end (step 7), not one per step. If a step cannot finish, that message says which one and what the user must do; never write the profile as if every source had been read.
|
|
10
10
|
|
|
11
11
|
## 1. Ask for Kit access, once
|
|
12
12
|
|
|
@@ -88,9 +88,40 @@ Fix every line it prints and rerun until it prints nothing. Then delete the two
|
|
|
88
88
|
|
|
89
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
90
|
|
|
91
|
-
## 7. Post one message to the user
|
|
91
|
+
## 7. Post one short message to the user
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
The user reads it cold, often only its first line (your writing rules). Write it to `/tmp/onboarding-skill/post.md` in this shape, load the forms skill for the tables, and check it:
|
|
94
|
+
|
|
95
|
+
```md
|
|
96
|
+
One line on who they are and what they own, then the one thing to do first.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
| Act on first | Why | Link |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
(three rows, each linking a permalink from the facts)
|
|
103
|
+
|
|
104
|
+
| Schedule | Bot | When | What it reports |
|
|
105
|
+
|---|---|---|---|
|
|
106
|
+
(one row per draft)
|
|
107
|
+
|
|
108
|
+
The drafts are in [this PR](...): merging it turns them on.
|
|
109
|
+
(one line per source not read or grant declined: what was not read and what to do)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```sh
|
|
113
|
+
node /tmp/onboarding-skill/check-report.mjs --post /tmp/onboarding-skill/post.md
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Fix every line it prints and rerun until it prints nothing, then post the file's text in this thread.
|
|
117
|
+
|
|
118
|
+
- The first bubble, everything above `---`, is at most two sentences.
|
|
119
|
+
- Below it, the two tables, then the lines under them and nothing else:
|
|
120
|
+
- The PR line. Without a repo, it says the drafts stay in the thread. If the PR's checks still fail after step 6, it says so and what failed, in place of "merging it turns them on".
|
|
121
|
+
- One line per source you could not read or Kit grant the user declined: what was not read and what they must do.
|
|
122
|
+
- Nothing else goes in: not the profile, the "Could not tell" section, counts, windows, or how the run went. Keep `profile.md`, and post it, or the part asked for, only when the user asks.
|
|
123
|
+
|
|
124
|
+
Only the schedule files ever leave the thread.
|
|
94
125
|
|
|
95
126
|
## The Slack script
|
|
96
127
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Checks the model-written profile against the facts it came from:
|
|
3
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
|
+
// --post post.md the first bubble (everything above the first `---` line) is at most two sentences
|
|
4
5
|
// Exits 1 and prints one line per problem.
|
|
5
6
|
import { readFileSync } from "node:fs";
|
|
6
7
|
import { pathToFileURL } from "node:url";
|
|
@@ -29,12 +30,42 @@ export function citationProblems(profile, facts) {
|
|
|
29
30
|
return problems;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
const MAX_SENTENCES = 2;
|
|
34
|
+
|
|
35
|
+
export function firstBubble(post) {
|
|
36
|
+
const rule = post.search(/^[ \t]*---[ \t]*$/m);
|
|
37
|
+
return (rule < 0 ? post : post.slice(0, rule)).trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Each line is at least one sentence; within a line, a sentence ends at . ! or ?, plus any closing emphasis,
|
|
41
|
+
// quote or paren, before a space. Code, link targets and common abbreviations are blanked first, so their dots
|
|
42
|
+
// don't count; `etc.` before a capital still ends its sentence.
|
|
43
|
+
export function sentenceCount(text) {
|
|
44
|
+
const prose = text
|
|
45
|
+
.replace(/```[\s\S]*?```/g, " ")
|
|
46
|
+
.replace(/`[^`]*`/g, "code")
|
|
47
|
+
.replace(/\]\([^)]*\)/g, "]")
|
|
48
|
+
.replace(URL_RE, "link")
|
|
49
|
+
.replace(/\b(e\.g|i\.e|vs)\./gi, "$1")
|
|
50
|
+
.replace(/\b([Ee]tc)\.(?!\s+[A-Z])/g, "$1");
|
|
51
|
+
return prose.split("\n").flatMap((line) => line.split(/(?<=[.!?][*_~"'”’)]*)\s+/)).filter((s) => /\w/.test(s)).length;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function postProblems(post) {
|
|
55
|
+
const bubble = firstBubble(post);
|
|
56
|
+
if (!bubble) return ["the post has no first bubble"];
|
|
57
|
+
const n = sentenceCount(bubble);
|
|
58
|
+
return n > MAX_SENTENCES ? [`the first bubble has ${n} sentences; keep it to ${MAX_SENTENCES} and move the rest below a --- line`] : [];
|
|
59
|
+
}
|
|
60
|
+
|
|
32
61
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
33
62
|
const args = process.argv.slice(2);
|
|
34
63
|
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
64
|
const problems = [];
|
|
36
65
|
const [profile] = after("--profile");
|
|
37
66
|
if (profile) problems.push(...citationProblems(readFileSync(profile, "utf8"), after("--facts").map((f) => JSON.parse(readFileSync(f, "utf8")))));
|
|
67
|
+
const [post] = after("--post");
|
|
68
|
+
if (post) problems.push(...postProblems(readFileSync(post, "utf8")));
|
|
38
69
|
problems.forEach((p) => console.log(p));
|
|
39
70
|
process.exit(problems.length ? 1 : 0);
|
|
40
71
|
}
|
package/onboarding/files.gen.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
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\", \"--jq\", \".data.search\", \"-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) throw e;\n await new Promise((r) => setTimeout(r, 1000 * attempt + Math.random() * 1000));\n }\n }\n const pages = out.split(\"\\n\").filter(Boolean).map((l) => JSON.parse(l));\n const nodes = pages.flatMap((p) => p.nodes).filter((n) => n?.url);\n return { total: pages[0].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"};
|
|
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\", \"--jq\", \".data.search\", \"-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) throw e;\n await new Promise((r) => setTimeout(r, 1000 * attempt + Math.random() * 1000));\n }\n }\n const pages = out.split(\"\\n\").filter(Boolean).map((l) => JSON.parse(l));\n const nodes = pages.flatMap((p) => p.nodes).filter((n) => n?.url);\n return { total: pages[0].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// --post post.md the first bubble (everything above the first `---` line) is at most two sentences\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\nconst MAX_SENTENCES = 2;\n\nexport function firstBubble(post) {\n const rule = post.search(/^[ \\t]*---[ \\t]*$/m);\n return (rule < 0 ? post : post.slice(0, rule)).trim();\n}\n\n// Each line is at least one sentence; within a line, a sentence ends at . ! or ?, plus any closing emphasis,\n// quote or paren, before a space. Code, link targets and common abbreviations are blanked first, so their dots\n// don't count; `etc.` before a capital still ends its sentence.\nexport function sentenceCount(text) {\n const prose = text\n .replace(/```[\\s\\S]*?```/g, \" \")\n .replace(/`[^`]*`/g, \"code\")\n .replace(/\\]\\([^)]*\\)/g, \"]\")\n .replace(URL_RE, \"link\")\n .replace(/\\b(e\\.g|i\\.e|vs)\\./gi, \"$1\")\n .replace(/\\b([Ee]tc)\\.(?!\\s+[A-Z])/g, \"$1\");\n return prose.split(\"\\n\").flatMap((line) => line.split(/(?<=[.!?][*_~\"'”’)]*)\\s+/)).filter((s) => /\\w/.test(s)).length;\n}\n\nexport function postProblems(post) {\n const bubble = firstBubble(post);\n if (!bubble) return [\"the post has no first bubble\"];\n const n = sentenceCount(bubble);\n return n > MAX_SENTENCES ? [`the first bubble has ${n} sentences; keep it to ${MAX_SENTENCES} and move the rest below a --- line`] : [];\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 const [post] = after(\"--post\");\n if (post) problems.push(...postProblems(readFileSync(post, \"utf8\")));\n problems.forEach((p) => console.log(p));\n process.exit(problems.length ? 1 : 0);\n}\n"};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eve-hub",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.4",
|
|
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
|
@@ -27,10 +27,12 @@ import { botDomains, domainFolders, readBotMemory, readMemory, validateMemory }
|
|
|
27
27
|
import { agentRunsOn, readWorkspaceFile, removeWorkspaceAgent } from "./workspace.mjs";
|
|
28
28
|
import { architectureText } from "./team.mjs";
|
|
29
29
|
|
|
30
|
-
// eve's git extension, for every bot
|
|
31
|
-
// bash, whose GitHub credential is the sandbox
|
|
32
|
-
//
|
|
33
|
-
//
|
|
30
|
+
// eve's git extension, for every bot of a build with a GITHUB_TOKEN: it points
|
|
31
|
+
// the bot at `gh` and `git` in bash, whose GitHub credential is the sandbox
|
|
32
|
+
// firewall's (the Vercel sandbox lib/agent-template.mjs writes). With no
|
|
33
|
+
// `github` option it brokers no credential and says the shell has one, so a
|
|
34
|
+
// build without the token mounts none. It only guides: its `pr` skill (commit,
|
|
35
|
+
// push, draft PR) loads when a bot publishes.
|
|
34
36
|
export const gitExtensionFiles = () => ({ "extensions/git/extension.ts": `import git from "eve/extensions/git";\n\nexport default git({});\n` });
|
|
35
37
|
|
|
36
38
|
// Bots that edit code: eve-code (apply_patch, grep, the review skills and its
|
|
@@ -134,17 +136,19 @@ export function prepareDeployment(dir) {
|
|
|
134
136
|
mkdirSync(join(dir, "agents", "main", "agent", "channels"), { recursive: true });
|
|
135
137
|
writeFileSync(join(dir, "agents", "main", "agent", "channels", "manifest.ts"), MANIFEST_CHANNEL);
|
|
136
138
|
applyKit(change, dirOf);
|
|
139
|
+
const deployment = deploymentFacts();
|
|
137
140
|
for (const file of readdirSync(join(dir, "agents"), { recursive: true })) {
|
|
138
141
|
if (!file.endsWith("/agent.ts")) continue;
|
|
139
142
|
const bot = dirname(join(dir, "agents", file));
|
|
140
143
|
const name = basename(dirname(bot));
|
|
141
144
|
const code = CODE_BOTS.includes(name);
|
|
142
|
-
for (const [stub, content] of Object.entries({ ...gitExtensionFiles(), ...(code ? codeExtensionFiles(name) : {}) })) {
|
|
145
|
+
for (const [stub, content] of Object.entries({ ...(deployment.githubToken ? gitExtensionFiles() : {}), ...(code ? codeExtensionFiles(name) : {}) })) {
|
|
143
146
|
mkdirSync(dirname(join(bot, stub)), { recursive: true });
|
|
144
147
|
writeFileSync(join(bot, stub), content);
|
|
145
148
|
}
|
|
149
|
+
// A workspace repo may carry the stub from an earlier build.
|
|
150
|
+
if (!deployment.githubToken) for (const stub of Object.keys(gitExtensionFiles())) if (existsSync(join(bot, stub))) removeStub(bot, stub);
|
|
146
151
|
}
|
|
147
|
-
const deployment = deploymentFacts();
|
|
148
152
|
for (const name of readdirSync(join(dir, "agents"))) {
|
|
149
153
|
const bot = join(dir, "agents", name, "agent");
|
|
150
154
|
writeExecutionContext(bot, { bot: name, deployment });
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by scripts/build-onboarding.mjs from onboarding/SKILL.md. Do not edit.
|
|
2
|
-
export default {"description":"Right after sign-in: ask for the Kit access you need, then work out who the user is and what to focus on from their recent Slack and GitHub, and draft schedules that keep them on track. Reads only; the one write is a pull request the user approves by merging.","markdown":"# Profile the user from Slack and GitHub\n\nTwo scripts gather facts (GitHub in your sandbox, Slack inside Kit); you write the profile and the schedule drafts from those facts alone. Nothing is posted to Slack and nothing is committed; the only write is the pull request in step 6, which the user approves by merging. The user sees one 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"};
|
|
2
|
+
export default {"description":"Right after sign-in: ask for the Kit access you need, then work out who the user is and what to focus on from their recent Slack and GitHub, and draft schedules that keep them on track. Reads only; the one write is a pull request the user approves by merging.","markdown":"# Profile the user from Slack and GitHub\n\nTwo scripts gather facts (GitHub in your sandbox, Slack inside Kit); you write the profile and the schedule drafts from those facts alone. Nothing is posted to Slack and nothing is committed; the only write is the pull request in step 6, which the user approves by merging. The user sees one short message from you at the end (step 7), not one per step. If a step cannot finish, that message says which one and what the user must do; never write the profile as if every source had been read.\n\n## 1. Ask for Kit access, once\n\nKit's tools are `request_access`, `request_status` and `execute`. Hosted, you reach them through the `vercel-kit` connection: `connection_execute({ connection: \"vercel-kit\", tool: \"request_access\", input: { ... } })`, and the same shape for the others. Everywhere below, \"call `request_access`\" means that.\n\nSlack goes through the user's Kit connection. Before any fetch, ask for everything the run reads in one request:\n\n```\nrequest_access({ service: \"slack\", reads: true, reason: \"Read your recent channel messages, mentions and threads to write your onboarding profile\" })\n```\n\n- `approved`: go on.\n- `pending` (or `connection_required`): tell the user, in one sentence, that Slack access is waiting, with the `approveUrl`; then `request_status({ request })` until they decide. Do not start step 3 before it is approved. If they decline, the final message says the Slack half was not read.\n\nGitHub does not use Kit: it uses `gh` in your sandbox (step 2). If `gh auth status` fails, the final message says GitHub was not read.\n\n## 2. GitHub facts\n\nCall the `onboarding_scripts` tool once: it writes the skill's scripts into your sandbox at `/tmp/onboarding-skill/` and returns that directory.\n\n```sh\nnode /tmp/onboarding-skill/github-facts.mjs > /tmp/onboarding-skill/github-facts.json\n```\n\nIt prints one JSON document: `counts` (merged PRs, open PRs, review requests, assigned issues, mentions never replied to, PRs reviewed), `repos` (merged, reviewed, open and lines changed per repo), item lists (`merged`, `open`, `reviewRequested`, `assignedIssues`, `unansweredMentions`; `total` is the full count, `items` the newest 150), `teams`, and `codeowners` lines naming the user or their teams. Open PRs carry `draft`, `reviewDecision`, `mergeable` and `checks`. `author:@me` includes PRs the user's bots open under their login. `unreadable` lists a source that could not be read.\n\n## 3. Slack facts\n\nRun the script in \"The Slack script\" below with Kit's `execute`: pass it as the script, unchanged. It runs inside Kit, so no raw message reaches you; it returns one JSON document in about 10 seconds.\n\n- A `ratelimited` error means Slack's search limit (about 20 a minute; the script makes at most 19). Wait 70 seconds (`sleep 70`) and run it again unchanged, up to five times.\n- A `suspended` result whose output says `consent_required` is a pause, not an error: tell the user in one sentence that the Slack read is waiting, with its `approveUrl`; `request_status({ request })` until they decide; then `execute({ run })` continues the same run (settled steps are not repeated).\n- An `output` with `refused` means Slack answered `missing_scope` (or another refusal) to a call, so no facts were read: `refused.needed` names the scope the user's Slack connection lacks (`refused.provided` what it has). Do not continue. Call `request_access({ service: \"slack\", capability: \"search_messages\", reason })`: if it returns an `approveUrl`, give it to the user; otherwise tell them to reconnect Slack in Kit allowing `refused.needed`. The final message says the Slack half was not read and names the scope.\n- Any other error goes to the user in the final message. Never continue with fewer facts, and never retry around a refusal.\n- Save the run's `output` as `/tmp/onboarding-skill/slack-facts.json` (the checker reads it).\n- `window`: the last 30 days. Slack search allows 15 pages of 100 here, so a very active account is covered only back to `own.oldest` (`newestOnly: true`); state that window in the profile.\n- `own`: messages per channel, thread replies, recent snippets. `mentions`: `replied` means the user posted later in the same thread; `unanswered` means they did not. A mention older than what was read with no reply among it (`notJudged`) is unknown, not unanswered. `mentions.capped` means the search found more mentions (`searchTotal`) than the three pages read: say so in \"Could not tell\". Reactions are not read (the Slack grant has no `reactions:read`): say so in \"Could not tell\". `memberships`: channels the user is in.\n- DMs and group DMs are never searched. Every list item carries a permalink (`url`); a snippet that looked like a secret is `[withheld]`.\n\n## 4. Write the profile and drafts\n\nWrite the profile to `/tmp/onboarding-skill/profile.md`. It names the user's repos and colleagues, so it is posted only in this thread and never committed.\n\nAnswer, in the user's terms:\n\n- What they have shipped, what they own, what they are responsible for.\n- What people tag them for, and what they write about.\n- What they have missed: unanswered mentions, requested reviews, stale or conflicted PRs.\n- Which open GitHub work-streams to close (stale, conflicted, superseded) and which to focus on.\n\nRules:\n\n- Judge; the scripts only counted. No scoring formula and no thresholds from code: say why in a sentence.\n- Every list item cites at least one permalink that appears in the facts, and no URL appears that isn't in the facts. State counts as the facts give them, with the window.\n- Don't quote messages beyond the snippets, and don't name people beyond what a claim needs.\n- Add a \"Could not tell\" section: every `unreadable` source, `notJudged` mentions, that reactions were not read, the Slack window, a Kit grant the user declined.\n\nFor each recurring need you found (a morning pass over unanswered mentions, a weekly stale-PR pass), write one draft to `/tmp/onboarding-skill/schedules/<bot>/<id>.md` for an existing bot of your workspace:\n\n```md\n---\ncron: \"0 9 * * 1-5\"\n---\n\nThe prompt the bot runs.\n```\n\n`cron` is five fields in UTC on a hosted deploy. Each draft says in its prompt which facts it reads and what it reports.\n\n## 5. Check\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --profile /tmp/onboarding-skill/profile.md --facts /tmp/onboarding-skill/github-facts.json /tmp/onboarding-skill/slack-facts.json\n```\n\nFix every line it prints and rerun until it prints nothing. Then delete the two facts files.\n\n## 6. Open the pull request, if the workspace has a repo\n\nWhen the workspace is backed by a GitHub repo, open one PR on it that adds only the schedule drafts, as `agents/<bot>/agent/schedules/<id>.md`, and nothing from the profile (`gh pr create`). The user merging it is their approval; the deploy that follows is what makes the schedules run. The PR's checks include the deploy's build, which rejects a bad cron or a bot that does not exist: watch them (`gh pr checks`), fix the draft and push again until they pass, and say in the message if they do not. Without a repo the drafts stay in the thread and the message says so.\n\n## 7. Post one short message to the user\n\nThe user reads it cold, often only its first line (your writing rules). Write it to `/tmp/onboarding-skill/post.md` in this shape, load the forms skill for the tables, and check it:\n\n```md\nOne line on who they are and what they own, then the one thing to do first.\n\n---\n\n| Act on first | Why | Link |\n|---|---|---|\n(three rows, each linking a permalink from the facts)\n\n| Schedule | Bot | When | What it reports |\n|---|---|---|---|\n(one row per draft)\n\nThe drafts are in [this PR](...): merging it turns them on.\n(one line per source not read or grant declined: what was not read and what to do)\n```\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --post /tmp/onboarding-skill/post.md\n```\n\nFix every line it prints and rerun until it prints nothing, then post the file's text in this thread.\n\n- The first bubble, everything above `---`, is at most two sentences.\n- Below it, the two tables, then the lines under them and nothing else:\n - The PR line. Without a repo, it says the drafts stay in the thread. If the PR's checks still fail after step 6, it says so and what failed, in place of \"merging it turns them on\".\n - One line per source you could not read or Kit grant the user declined: what was not read and what they must do.\n- Nothing else goes in: not the profile, the \"Could not tell\" section, counts, windows, or how the run went. Keep `profile.md`, and post it, or the part asked for, only when the user asks.\n\nOnly the schedule files ever leave the thread.\n\n## The Slack script\n\n```js\n// Facts about the signed-in user's recent Slack channel activity. Counts, permalinks and short snippets only.\n// Searches exclude DMs and group DMs (-is:dm) so none is ever read. Slack allows about 20 searches a minute: this\n// script makes at most 19, so on a ratelimited error wait a minute and run it again unchanged.\n// The .slice(0, n) before each .map is not redundant: callscript counts worst-case calls from it (200 max).\nconst DAYS = 30;\nconst after = new Date(Date.now() - (DAYS + 1) * 86400000).slice(0, 10);\nconst auth = await slack.auth_test();\nconst me = auth.user_id;\nconst ownPages = await Promise.all([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].slice(0, 15).map(page => slack.search_messages({ query: `from:<@${me}> after:${after} -is:dm`, count: 100, page, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst mentionPages = await Promise.all([1, 2, 3].slice(0, 3).map(page => slack.search_messages({ query: `<@${me}> after:${after} -is:dm`, count: 100, page, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst member = await slack.users_conversations({ types: \"public_channel,private_channel\", limit: 200, exclude_archived: true });\nconst refused = [auth, ...ownPages, ...mentionPages, member].filter(r => r.ok === false);\nif (refused.length > 0) {\n return { refused: { error: refused[0].error, needed: refused[0].needed ?? null, provided: refused[0].provided ?? null, calls: refused.length } };\n}\nconst channels = Object.entries(Object.groupBy(ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim), m => m.channel.id)).map(e => ({ id: e[0], channel: e[1][0].channel.name, count: e[1].length, threadReplies: e[1].filter(m => m.permalink.includes(\"thread_ts=\") && !m.permalink.includes(`thread_ts=${m.ts}`)).length, oldest: e[1][e[1].length - 1].ts, recent: e[1].slice(0, 2).map(m => ({ url: m.permalink, ts: m.ts, text: m.text.split(\"\\n\").join(\" \").split(\" \").some(t => t.length >= 20 && [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"].some(p => t.includes(p))) ? \"[withheld]\" : m.text.split(\"\\n\").join(\" \").slice(0, 160) })) })).sort((a, b) => b.count - a.count);\nconst total = channels.reduce((n, c) => n + c.count, 0);\nconst threadReplies = channels.reduce((n, c) => n + c.threadReplies, 0);\nconst mentioned = mentionPages.flatMap(r => r.messages.matches).filter(m => m.user !== me && !m.channel.is_im && !m.channel.is_mpim).map(m => ({ url: m.permalink, ts: m.ts, author: m.username, channel: m.channel.id, name: m.channel.name, thread: m.permalink.includes(\"thread_ts=\") ? m.permalink.split(\"thread_ts=\")[1].split(\"&\")[0] : m.ts, text: m.text.split(\"\\n\").join(\" \").split(\" \").some(t => t.length >= 20 && [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"].some(p => t.includes(p))) ? \"[withheld]\" : m.text.split(\"\\n\").join(\" \").slice(0, 160) }));\nconst ownMessages = ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim);\nconst lastOwn = Object.fromEntries(Object.entries(Object.groupBy(ownMessages, m => `${m.channel.id}:${m.permalink.includes(\"thread_ts=\") ? m.permalink.split(\"thread_ts=\")[1].split(\"&\")[0] : m.ts}`)).map(e => [e[0], e[1].reduce((t, m) => Math.max(t, Number(m.ts)), 0)]));\nconst oldestOwn = channels.length ? channels.reduce((t, c) => Math.min(t, Number(c.oldest)), Number(channels[0].oldest)) : 0;\nconst newestOnly = ownPages[0].messages.paging.pages > ownPages.length;\nconst known = mentioned.map(m => ({ ...m, replied: (lastOwn[`${m.channel}:${m.thread}`] ?? 0) > Number(m.ts) }));\nconst judged = known.filter(m => m.replied || !newestOnly || Number(m.ts) >= oldestOwn);\nconst open = judged.filter(m => !m.replied);\nreturn {\n me: { id: me, name: auth.user },\n window: { days: DAYS, after, searchTotal: ownPages[0].messages.paging.total, pagesTotal: ownPages[0].messages.paging.pages, pagesRead: ownPages.length, newestOnly },\n scope: \"public and private channels; DMs and group DMs are dropped before counting\",\n own: {\n total,\n threadReplies,\n topLevel: total - threadReplies,\n oldest: channels.length ? oldestOwn : null,\n channelCount: channels.length,\n channels: channels.slice(0, 25).map(c => ({ id: c.id, channel: c.channel, count: c.count, threadReplies: c.threadReplies, recent: c.recent }))\n },\n mentions: {\n searchTotal: mentionPages[0].messages.paging.total,\n pagesTotal: mentionPages[0].messages.paging.pages,\n pagesRead: mentionPages.length,\n capped: mentionPages[0].messages.paging.pages > mentionPages.length,\n found: mentioned.length,\n judged: judged.length,\n notJudged: mentioned.length - judged.length,\n replied: judged.length - open.length,\n unanswered: open.length,\n unansweredItems: open.slice(0, 20).map(m => ({ url: m.url, channel: m.name, author: m.author, ts: m.ts, text: m.text })),\n basis: \"replied = the user posted later in the same thread; reactions are not read (the token lacks reactions:read); a mention not yet answered in a thread may have been answered elsewhere\"\n },\n memberships: { count: member.channels.length, names: member.channels.map(c => c.name).sort(), more: Boolean(member.response_metadata?.next_cursor) }\n};\n```\n"};
|