eve-hub 0.16.41 → 0.16.42
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/kit-modules.json +2 -0
- package/memory.mjs +39 -4
- package/onboarding/SKILL.md +15 -17
- package/onboarding/check-report.mjs +12 -9
- package/onboarding/files.gen.mjs +1 -1
- package/onboarding/profile.mjs +82 -0
- package/package.json +1 -1
- package/schedules/workspace-review.d.ts +16 -0
- package/schedules/workspace-review.js +24 -0
- package/skills/onboarding-profile.gen.mjs +1 -1
- package/skills/workspace-review.d.ts +2 -0
- package/skills/workspace-review.gen.mjs +2 -0
- package/skills/workspace-review.js +5 -0
- package/stubs.mjs +4 -0
- package/templates/main-instructions.md +1 -1
- package/workspace-review/SKILL.md +117 -0
package/kit-modules.json
CHANGED
|
@@ -12,11 +12,13 @@
|
|
|
12
12
|
"schedules/prune",
|
|
13
13
|
"schedules/tracker-prs",
|
|
14
14
|
"schedules/wake",
|
|
15
|
+
"schedules/workspace-review",
|
|
15
16
|
"skills/deep-dive",
|
|
16
17
|
"skills/fanout",
|
|
17
18
|
"skills/forms",
|
|
18
19
|
"skills/local-onboarding",
|
|
19
20
|
"skills/onboarding-profile",
|
|
21
|
+
"skills/workspace-review",
|
|
20
22
|
"tools/apply_tags",
|
|
21
23
|
"tools/archive_thread",
|
|
22
24
|
"tools/bash",
|
package/memory.mjs
CHANGED
|
@@ -27,10 +27,18 @@
|
|
|
27
27
|
// The front matter is a small YAML subset: `key: value` or `key: [a, b]`,
|
|
28
28
|
// with `# comments`. An evidence item is `thr_…/msg_…`, or `msg_…` for
|
|
29
29
|
// another message in the thread cited just before it.
|
|
30
|
+
//
|
|
31
|
+
// memory/human.md is not an entry: it is the human's profile, one page in the
|
|
32
|
+
// format onboarding/profile.mjs defines. Onboarding writes the first version and
|
|
33
|
+
// the workspace review rewrites it each week, each in a draft PR the human
|
|
34
|
+
// merges. validateMemory checks it and every bot recalls it after the entries.
|
|
30
35
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
31
36
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
37
|
+
import { PROFILE_FILE, profileProblems } from "./onboarding/profile.mjs";
|
|
32
38
|
import { isWorkspace, readWorkspaceFile } from "./workspace.mjs";
|
|
33
39
|
|
|
40
|
+
export { PROFILE_FILE };
|
|
41
|
+
|
|
34
42
|
export const MEMORY_DIR = "memory";
|
|
35
43
|
export const MAX_RULE_LINES = 3;
|
|
36
44
|
export const ID = /^[a-z][a-z0-9-]{1,60}$/;
|
|
@@ -87,6 +95,26 @@ export function parseEntry(text, file = "entry.md") {
|
|
|
87
95
|
return { id: f.id, status: f.status, since: f.since, evidence: f.evidence, ...(supersededBy ? { supersededBy } : {}), rule: lines.join(" ") };
|
|
88
96
|
}
|
|
89
97
|
|
|
98
|
+
// The profile from memory/human.md's text. Throws a MemoryError naming the
|
|
99
|
+
// file and every problem when it's not a valid profile.
|
|
100
|
+
export function parseProfile(text, file = `${MEMORY_DIR}/${PROFILE_FILE}`) {
|
|
101
|
+
const problems = profileProblems(text);
|
|
102
|
+
if (problems.length) throw new MemoryError(`${file}: ${problems.join("; ")}`);
|
|
103
|
+
return { text: text.trim() };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The workspace's profile, or null when memory/human.md doesn't exist, and the
|
|
107
|
+
// error when it isn't valid.
|
|
108
|
+
export function readProfile(root) {
|
|
109
|
+
const path = join(root, MEMORY_DIR, PROFILE_FILE);
|
|
110
|
+
if (!existsSync(path)) return { profile: null, errors: [] };
|
|
111
|
+
try {
|
|
112
|
+
return { profile: parseProfile(readFileSync(path, "utf8")), errors: [] };
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return { profile: null, errors: [error instanceof MemoryError ? error.message : `${MEMORY_DIR}/${PROFILE_FILE}: ${error.message}`] };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
90
118
|
// The folders under <root>/memory a bot with these domains recalls, as paths
|
|
91
119
|
// relative to memory/: "" (the shared root), then each domain and its ancestors.
|
|
92
120
|
export function domainFolders(domains = []) {
|
|
@@ -129,7 +157,7 @@ export function readMemory(root, { label = MEMORY_DIR, knownIds = new Set(), fol
|
|
|
129
157
|
if (folder) errors.push(`${prefix}: domain folder doesn't exist`);
|
|
130
158
|
continue;
|
|
131
159
|
}
|
|
132
|
-
const files = readdirSync(dir, { withFileTypes: true }).filter((d) => d.isFile() && d.name.endsWith(".md") && !d.name.startsWith(".") && d.name.toLowerCase() !== "readme.md").map((d) => d.name).sort();
|
|
160
|
+
const files = readdirSync(dir, { withFileTypes: true }).filter((d) => d.isFile() && d.name.endsWith(".md") && !d.name.startsWith(".") && d.name.toLowerCase() !== "readme.md" && (folder || label !== MEMORY_DIR || d.name !== PROFILE_FILE)).map((d) => d.name).sort();
|
|
133
161
|
for (const name of files) {
|
|
134
162
|
const file = `${prefix}/${name}`;
|
|
135
163
|
try {
|
|
@@ -190,10 +218,16 @@ const warnOnce = (msg) => {
|
|
|
190
218
|
};
|
|
191
219
|
|
|
192
220
|
// `domains`: the bot's domains (hub.workspace.json), see the header.
|
|
221
|
+
// The profile is recalled after the entries as one message. Its id has a colon,
|
|
222
|
+
// which no entry id (ID) can, so it never replaces a decision with the same id.
|
|
223
|
+
export const PROFILE_ID = "profile:human";
|
|
224
|
+
export const profileText = (profile) => `The human's profile (${MEMORY_DIR}/${PROFILE_FILE}): who they are and how they work, and what the bots do because of it.\n\n${profile.text}`;
|
|
225
|
+
|
|
193
226
|
export function loadMemoryRecall(root, { warn = warnOnce, domains = /** @type {string[]} */ ([]) } = {}) {
|
|
194
227
|
const { entries, errors } = readMemory(root, { folders: domainFolders(domains) });
|
|
195
|
-
|
|
196
|
-
|
|
228
|
+
const { profile, errors: profileErrors } = readProfile(root);
|
|
229
|
+
for (const error of [...errors, ...profileErrors]) warn(error);
|
|
230
|
+
return [...memoryRecall(entries), ...(profile ? [{ id: PROFILE_ID, content: profileText(profile) }] : [])];
|
|
197
231
|
}
|
|
198
232
|
|
|
199
233
|
// A bot's own entries, agents/<bot>/memory/<id>.md: same schema. An id can't
|
|
@@ -236,10 +270,11 @@ export function botDomains(root, botDir) {
|
|
|
236
270
|
}
|
|
237
271
|
|
|
238
272
|
// For a workspace's tests: throws one MemoryError listing every bad file,
|
|
239
|
-
// shared (every folder)
|
|
273
|
+
// shared (every folder), the profile, per bot (agents/*/memory/), and every domain a bot
|
|
240
274
|
// lists that has no folder. Returns the shared entries.
|
|
241
275
|
export function validateMemory(root) {
|
|
242
276
|
const { entries, errors } = readMemory(root, { folders: allMemoryFolders(root) });
|
|
277
|
+
errors.push(...readProfile(root).errors);
|
|
243
278
|
if (isWorkspace(root)) {
|
|
244
279
|
const folders = new Set(allMemoryFolders(root));
|
|
245
280
|
for (const { name, domains = [] } of readWorkspaceFile(root).agents) for (const d of domains) if (!folders.has(d)) errors.push(`hub.workspace.json: ${name} lists domain ${d}, which has no memory/${d}/ folder`);
|
package/onboarding/SKILL.md
CHANGED
|
@@ -10,7 +10,7 @@ The profile is built from many small reads run at the same time, not one pass. Y
|
|
|
10
10
|
|
|
11
11
|
The result is two files. `read.md` is about one page on how the user works and the team that fits them. `facts.md` is the appendix it cites: the ten questions below, answered from the facts.
|
|
12
12
|
|
|
13
|
-
Nothing is posted to Slack and nothing is committed by the run itself; its own writes are the pull request in step 6, which the user approves by merging, and a wake-up in this thread for any need that runs once (step 5c), and the first task (below) pushes only once the user says `go` in its thread. The user sees the first message about 20 seconds in, naming the first task, when there is one, that is already being worked on, one message with every Kit approve link if access is pending (step 1), up to four two-sentence findings as the slices finish, one `ask` to confirm what the facts say they would hand off, your answers to anything they ask, and at the end one message with the read, their team and the schedules, followed by one `ask` to pick team changes (step 7). If a step cannot finish, the final message says which and what the user must do; never write the read as if every source had been read.
|
|
13
|
+
Nothing is posted to Slack and nothing is committed by the run itself; its own writes are the pull request in step 6 (the schedule drafts and the first `memory/human.md`), which the user approves by merging, and a wake-up in this thread for any need that runs once (step 5c), and the first task (below) pushes only once the user says `go` in its thread. The user sees the first message about 20 seconds in, naming the first task, when there is one, that is already being worked on, one message with every Kit approve link if access is pending (step 1), up to four two-sentence findings as the slices finish, one `ask` to confirm what the facts say they would hand off, your answers to anything they ask, and at the end one message with the read, their team and the schedules, followed by one `ask` to pick team changes (step 7). If a step cannot finish, the final message says which and what the user must do; never write the read as if every source had been read.
|
|
14
14
|
|
|
15
15
|
The whole run is capped at 25 minutes. Every directory below is under `/tmp/onboarding-skill/`.
|
|
16
16
|
|
|
@@ -262,29 +262,19 @@ The prompt the bot runs.
|
|
|
262
262
|
|
|
263
263
|
A schedule file has only `cron`, so it runs every time the cron matches: `0 14 23 11 *` runs every 23 November, not once. Never call a draft one-time. A need that runs once (a pre-freeze check) is not a draft: when you post step 7, arm it with `schedule__wake__create` in this thread, `expression: { type: "single", at, timezone }`, `until` a few minutes after `at`, and the prompt as its note, and list it in the Schedule table with When "once, <date>". If a draft must still end by opening a draft PR that deletes its own file, its prompt and every line of the read and the post that names it say it recurs until that PR is merged and deployed: the schedule keeps running until the deployment without the file is live.
|
|
264
264
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
```md
|
|
268
|
-
---
|
|
269
|
-
cron: "0 8 * * 1"
|
|
270
|
-
---
|
|
271
|
-
|
|
272
|
-
Read every thread from the last 7 days and find each place the human corrected a bot: a rule restated, an answer rejected, work redone their way. For each correction that should outlast its task, write `memory/<id>.md` (or `agents/<bot>/memory/<id>.md` when it is about one bot) with front matter `id`, `status: active`, `since` and `evidence` (the thread and message ids), then the rule in at most 3 lines. When it replaces an entry, set the old one's `status: superseded` and `superseded_by`; never rewrite it. Update `memory/human.md` with what the week showed about who the human is and how they work. Open one draft PR with only those files and never merge it. Post its link in the thread with one line per entry: the rule and the correction it came from. With no corrections, post that and open no PR.
|
|
273
|
-
```
|
|
274
|
-
|
|
275
|
-
Its row in the step 7 Schedule table says why it is there in the user's own words when the facts have them: link one or two of the user's corrections (a reply that tells a person or an agent to redo it their way) in "What it reports". With none in the facts, it says it starts from the corrections they make in this workspace.
|
|
265
|
+
main also runs the kit's `workspace-review` schedule (Mondays 08:00 UTC), which every workspace has without a draft: it scores the week, keeps `memory/human.md` and the memory entries current from the user's corrections, proposes bot and schedule changes in a second draft PR, and ranks hub and kit proposals. Its row in the step 7 Schedule table says why it is there in the user's own words when the facts have them: link one or two of the user's corrections (a reply that tells a person or an agent to redo it their way) in "What it reports". With none in the facts, it says it starts from the corrections they make in this workspace.
|
|
276
266
|
|
|
277
267
|
Rules for both files:
|
|
278
268
|
|
|
279
269
|
- Every list item cites at least one URL that is in the facts, or a `fact:` reference: `fact:local/usage-<source>.json#<JSON pointer>` for a value in the Mac's files, `fact:interview/handoff` for the user's pick from the handoff `ask`. No URL or reference appears that isn't in the facts. Never cite a web URL for a local count. State counts as the facts give them, with the window.
|
|
280
|
-
- Don't quote messages beyond the snippets, and don't name people beyond what a claim needs. Both files name the user's repos and colleagues, so they are posted only in this thread and
|
|
270
|
+
- Don't quote messages beyond the snippets, and don't name people beyond what a claim needs. Both files name the user's repos and colleagues, so they are posted only in this thread. The one part committed is the profile, `## Who you are` and `## How you work` copied into `memory/human.md` in step 6, and only to a private workspace repo.
|
|
281
271
|
|
|
282
272
|
```sh
|
|
283
273
|
cd /tmp/onboarding-skill && node check-report.mjs --read read.md --profile facts.md --facts facts \
|
|
284
274
|
--critique critique.md --schedules schedules
|
|
285
275
|
```
|
|
286
276
|
|
|
287
|
-
`--read` checks citations and also fails a trait under `## How you work` that cites fewer than two different sources. `--critique` fails when `critique.md` is missing or holds no `keep`, `cut` or `rewrite` line; it checks presence only, not that the text is verbatim. `--schedules` fails a draft without a five-field `cron`, a
|
|
277
|
+
`--read` checks citations and also fails a trait under `## How you work` that cites fewer than two different sources. `--critique` fails when `critique.md` is missing or holds no `keep`, `cut` or `rewrite` line; it checks presence only, not that the text is verbatim. `--schedules` fails a draft without a five-field `cron`, a draft that says it runs once, and a date-pinned or self-deleting draft whose prompt and read lines don't say it recurs until its deletion is merged and deployed. The copy fixes every line the checker prints and reruns until it prints nothing. When it returns, run the checker yourself once more; it must print nothing. Keep `read.md`, `facts.md`, `phases.log` and `critique.md`.
|
|
288
278
|
|
|
289
279
|
### Evidence that arrives late
|
|
290
280
|
|
|
@@ -292,14 +282,22 @@ Late evidence is the user's handoff answer (the whole `## handoff` entry of `fac
|
|
|
292
282
|
|
|
293
283
|
- **Before 5c starts**, it goes to 5c with the critique's list.
|
|
294
284
|
- **Once 5c has started**, wait for the read copy to return, then continue it by its `taskId` with only the new evidence. It judges again every trait, `## Your team` row and schedule draft the evidence bears on, including one it contradicts, rewrites them in `read.md`, updates the affected answers in `facts.md`, and returns the list of what it changed. When that list adds or rewrites a trait or a team row, start a fresh `critique` copy (5b) on just those items, giving it the same new evidence (for a handoff answer, the whole `## handoff` entry), and continue the read copy with its list. Run the checker until it prints nothing.
|
|
295
|
-
- **Once step 6 has opened its PR**, a refresh that adds, changes or removes a schedule draft changes the PR the same way: write, edit or delete its `agents/<bot>/agent/schedules/<id>.md` on the PR's branch, push, and watch its checks (`gh pr checks`) until they pass, as in step 6.
|
|
285
|
+
- **Once step 6 has opened its PR**, a refresh that adds, changes or removes a schedule draft changes the PR the same way: write, edit or delete its `agents/<bot>/agent/schedules/<id>.md` on the PR's branch, push, and watch its checks (`gh pr checks`) until they pass, as in step 6. Before you touch its branch, check its state (`gh pr view <number> --json state`). If it is `MERGED`, never push to its branch: open a new PR from the default branch with only the changed, added or deleted schedule files, watch its checks the same way, and say the revised schedule is not deployed: the one running is the merged one, and the new one runs only once the user merges the new PR and it deploys. The changed schedule is posted only after its PR passes, or with what failed.
|
|
296
286
|
- **After step 7 has posted**, do the same, then post only what changed: the rewritten lines of the read, the changed team and schedule rows, and a new `ask` if the proposed team changed.
|
|
297
287
|
|
|
298
288
|
The run is over when the user picks from the `ask`. Until then keep `facts/`, because each refresh and its checker need it; delete it then. An answer after that is ordinary conversation, not evidence for the read.
|
|
299
289
|
|
|
300
290
|
## 6. Open the pull request, if the workspace has a repo
|
|
301
291
|
|
|
302
|
-
When the workspace is backed by a GitHub repo, open one PR on it that adds
|
|
292
|
+
When the workspace is backed by a GitHub repo, open one PR on it that adds the schedule drafts, as `agents/<bot>/agent/schedules/<id>.md`, and the user's profile as `memory/human.md` (`gh pr create`). Nothing else from `read.md` or `facts.md` goes in it.
|
|
293
|
+
|
|
294
|
+
`memory/human.md` is the one profile every bot recalls on every turn, and the weekly workspace review rewrites it from then on, so it has one format, defined in `profile.mjs` beside the checker. Write it as the `## Who you are` and `## How you work` sections of `read.md`, verbatim, and check it:
|
|
295
|
+
|
|
296
|
+
```sh
|
|
297
|
+
cd /tmp/onboarding-skill && node check-report.mjs --human <repo>/memory/human.md
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Commit it only when `gh repo view --json isPrivate --jq .isPrivate` prints `true`, because it names the user's colleagues and repos. When it prints `false`, the command fails, or the repo already has a `memory/human.md`, leave it out and say so in the message. With no drafts and no profile to add, open no PR. 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.
|
|
303
301
|
|
|
304
302
|
## 7. Post the read, the team and the schedules
|
|
305
303
|
|
|
@@ -316,7 +314,7 @@ The read in two sentences: who they are and how they work, then the one thing to
|
|
|
316
314
|
|
|
317
315
|
| Schedule | Bot | When | What it reports |
|
|
318
316
|
|---|---|---|---|
|
|
319
|
-
(one row per draft, `
|
|
317
|
+
(one row per draft, then `workspace-review` for `main`, which the kit already runs, then one per one-run wake-up)
|
|
320
318
|
|
|
321
319
|
[The read](...) and [the facts it cites](...). The drafts are in [this PR](...): merging it turns them on.
|
|
322
320
|
(one line per source not read or grant declined: what was not read and what to do)
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
// `fact:interview/<name>` cites the user's own answer, saved under a `## <name>` heading in interview.md in a --facts directory
|
|
10
10
|
// (the run saves their pick from the handoff `ask` as `## handoff`).
|
|
11
11
|
// --read read.md as --profile, and every `###` trait under "## How you work" cites at least two different
|
|
12
|
-
// sources (Slack, GitHub, Notion, v0, the Mac's files, the user's answers)
|
|
12
|
+
// sources (Slack, GitHub, Notion, v0, the Mac's files, the user's answers), and its
|
|
13
|
+
// "## Who you are" and "## How you work" sections are a valid profile (profile.mjs)
|
|
14
|
+
// --human memory/human.md the file is a valid profile (profile.mjs), the format memory/human.md is recalled in
|
|
13
15
|
// --phases phases.log the run's step lines are all there and in order: start, quick, first, facts, slices,
|
|
14
16
|
// areas, read, critique, write, pr, post; finding1 to finding4, each optional, come in
|
|
15
17
|
// order after first and before read
|
|
@@ -18,13 +20,13 @@
|
|
|
18
20
|
// reason and the step are good is the writer's judgment, not checked here
|
|
19
21
|
// --post post.md the first bubble (everything above the first `---` line) is at most two sentences, and
|
|
20
22
|
// below it are the "Your team" table (| Bot | Change | Why |) and the Schedule table
|
|
21
|
-
// (| Schedule | Bot | When | What it reports |), with a `
|
|
23
|
+
// (| Schedule | Bot | When | What it reports |), with a `workspace-review` row for `main`
|
|
22
24
|
// --critique critique.md critique.md exists and holds one `keep`, `cut` or `rewrite` line or more; a presence
|
|
23
25
|
// check only, it cannot prove the text is the critique's verbatim output
|
|
24
|
-
// --schedules dir [--read r.md] [--post p.md] every `<bot>/<id>.md` draft has a five-field `cron` and a prompt
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
26
|
+
// --schedules dir [--read r.md] [--post p.md] every `<bot>/<id>.md` draft has a five-field `cron` and a prompt. A draft runs every
|
|
27
|
+
// time its cron matches, so none says it runs once; a draft whose cron pins a date (day
|
|
28
|
+
// and month) or that deletes its own file says it "recurs until" that deletion is "merged
|
|
29
|
+
// and deployed", and so does a line naming it in the read and the post
|
|
28
30
|
// Every --facts file is also checked against its own `returned` counts: an object's `returned: { <key>: n }` says its <key> list had n
|
|
29
31
|
// items when Kit returned it. A slice retypes Kit's output into facts/slack/ and facts/kit/, so a list shorter than its `returned`
|
|
30
32
|
// means items were dropped while copying, and in those two directories a list of objects with no `returned` count fails too.
|
|
@@ -33,6 +35,7 @@
|
|
|
33
35
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
34
36
|
import { join, sep } from "node:path";
|
|
35
37
|
import { pathToFileURL } from "node:url";
|
|
38
|
+
import { profileProblems, profileSections } from "./profile.mjs";
|
|
36
39
|
|
|
37
40
|
const URL_RE = /https?:\/\/[^\s)>\]"'`|]+/g;
|
|
38
41
|
const FACT_RE = /\bfact:[^\s)>\]"'`]+/g;
|
|
@@ -216,7 +219,7 @@ export function postProblems(post) {
|
|
|
216
219
|
if (start >= 0) {
|
|
217
220
|
const rows = [];
|
|
218
221
|
for (let i = start + 2; i < below.length && /^\s*\|.*\|\s*$/.test(below[i]); i++) rows.push(cells(below[i]));
|
|
219
|
-
if (!rows.some(([schedule, bot]) => schedule.replace(/`/g, "") === "
|
|
222
|
+
if (!rows.some(([schedule, bot]) => schedule.replace(/`/g, "") === "workspace-review" && bot.replace(/`/g, "") === "main")) problems.push("the Schedule table has no `workspace-review` row for `main`; every onboarding offers it");
|
|
220
223
|
}
|
|
221
224
|
return problems;
|
|
222
225
|
}
|
|
@@ -232,7 +235,6 @@ const SELF_DELETE = /\bdelet\w*\b[^.\n]*\b(its own|this) (schedule )?file\b/i;
|
|
|
232
235
|
// drafts: [["<bot>/<id>.md", text]]; texts: [[name, text]] of the read and the post, whose lines naming a draft are checked too.
|
|
233
236
|
export function scheduleProblems(drafts, texts = []) {
|
|
234
237
|
const problems = [];
|
|
235
|
-
if (!drafts.some(([f]) => f === "main/memory-review.md")) problems.push("no main/memory-review.md draft; every onboarding offers it");
|
|
236
238
|
for (const [file, text] of drafts) {
|
|
237
239
|
const m = /^(\w[\w-]*)\/(\w[\w-]*)\.md$/.exec(file);
|
|
238
240
|
if (!m) { problems.push(`${file}: a draft is schedules/<bot>/<id>.md`); continue; }
|
|
@@ -277,10 +279,11 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
|
277
279
|
for (const p of profiles) {
|
|
278
280
|
const text = readFileSync(p, "utf8");
|
|
279
281
|
problems.push(...named(p, citationProblems(text, docs.map(([, d]) => d), files)));
|
|
280
|
-
if (reads.includes(p)) problems.push(...named(p, traitProblems(text)));
|
|
282
|
+
if (reads.includes(p)) problems.push(...named(p, [...traitProblems(text), ...profileProblems(profileSections(text))]));
|
|
281
283
|
if (findings.includes(p)) problems.push(...named(p, findingProblems(text)));
|
|
282
284
|
}
|
|
283
285
|
}
|
|
286
|
+
for (const h of after("--human")) problems.push(...profileProblems(readFileSync(h, "utf8")).map((m) => `${h}: ${m}`));
|
|
284
287
|
const [phases] = after("--phases");
|
|
285
288
|
if (phases) problems.push(...phaseProblems(readFileSync(phases, "utf8")));
|
|
286
289
|
const [post] = after("--post");
|
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 180 days on GitHub. With an output directory it writes base.json, calendar.json,\n// reviews.json, issues.json and repos/<owner>__<name>.json for the top repos; with none it prints base.json's document on stdout.\n// With --quick it writes only quick.json, the task candidates the first message picks from (QUICK, below).\n// Counts, snippets (<=200 chars), URLs and CODEOWNERS lines only; no judgment: the model reads this and profiles.\nimport { execFile } from \"node:child_process\";\nimport { mkdir, writeFile } from \"node:fs/promises\";\nimport { dirname, join } from \"node:path\";\nimport { promisify } from \"node:util\";\nimport { pathToFileURL } from \"node:url\";\nimport { snippet } from \"./redact.mjs\";\n\nconst exec = promisify(execFile);\nconst DAYS = 180;\n// contributionsCollection is read in two spans of about 90 days each.\nconst SPAN_DAYS = 90;\nconst TOP_REPOS = 5;\nconst REPO_PR_CAP = 100;\nconst COMMIT_PAGES = 3;\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 } @EXTRA@ }\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 = (extra, pageSize) => `query($q: String!, $endCursor: String) {\n search(query: $q, type: ISSUE, first: ${pageSize}, after: $endCursor) {\n issueCount\n pageInfo { hasNextPage endCursor }\n nodes {${BASE.replace(\"@EXTRA@\", extra)}}\n }\n}`;\n\nexport const realRun = async (args) => (await exec(\"gh\", args, { maxBuffer: 1 << 28 })).stdout;\n\nconst ATTEMPTS = 3;\n// GitHub answers a page of 50 nodes with the state fields (mergeable, check rollup) with an HTML error once an account has hundreds of PRs, so those searches read 25 at a time.\nconst PAGE_SIZE = 50;\nconst STATE_PAGE_SIZE = 25;\n\nasync function retried(run, args) {\n for (let attempt = 1; ; attempt++) {\n try {\n return JSON.parse(await run(args));\n } catch (e) {\n if (attempt === ATTEMPTS) throw e;\n await new Promise((r) => setTimeout(r, 1000 * attempt + Math.random() * 1000));\n }\n }\n}\n\nfunction searchPage(run, q, { withState, withBody }, endCursor) {\n const extra = [withState ? STATE : \"\", withBody ? \"body\" : \"\"].join(\" \");\n return retried(run, [\"api\", \"graphql\", \"--jq\", \".data.search\", \"-f\", `query=${searchQuery(extra, withState ? STATE_PAGE_SIZE : PAGE_SIZE)}`, \"-f\", `q=${q}`, ...(endCursor ? [\"-f\", `endCursor=${endCursor}`] : [])]);\n}\n\n// A stateful search lists at most LIST_CAP items, which is all the output shows, so it stops there; the other searches read every page, since the per-repo counts use them all.\nasync function search(run, q, { withState = false, withBody = false, cap = withState ? LIST_CAP : Infinity } = {}) {\n const nodes = [];\n let total;\n let page = await searchPage(run, q, { withState, withBody });\n for (;;) {\n total ??= page.issueCount;\n nodes.push(...page.nodes.filter((n) => n?.url));\n if (!page.pageInfo.hasNextPage || nodes.length >= cap) break;\n page = await searchPage(run, q, { withState, withBody }, page.pageInfo.endCursor);\n }\n return { total, nodes: nodes.slice(0, cap) };\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\n// The first message's task candidates, each one page read with the state fields. Filtering in the query keeps every approved or\n// changes-requested PR in view however many PRs are open, where a 150-item list of all open PRs can miss them and takes 6 pages.\nconst QUICK = {\n approved: \"is:pr author:@me is:open review:approved archived:false\",\n changesRequested: \"is:pr author:@me is:open review:changes_requested archived:false\",\n reviewRequested: \"is:pr is:open review-requested:@me archived:false sort:created-asc\",\n assignedIssues: \"is:issue is:open assignee:@me archived:false\",\n};\n\nexport async function collectQuick({ run = realRun, now = new Date() } = {}) {\n const unreadable = [];\n const attempt = attempter(unreadable);\n const [login, ...got] = await Promise.all([\n run([\"api\", \"user\"]).then((t) => JSON.parse(t).login),\n ...Object.entries(QUICK).map(([k, q]) => attempt(`search ${k}`, () => search(run, q, { withState: true, cap: STATE_PAGE_SIZE }), { total: 0, nodes: [] })),\n ]);\n const lists = Object.fromEntries(Object.keys(QUICK).map((k, i) => [k, got[i]]));\n return {\n login,\n at: now.toISOString(),\n ...Object.fromEntries(Object.entries(lists).map(([k, { total, nodes }]) => [k, { total, listed: nodes.length, items: nodes.map(item) }])),\n unreadable,\n };\n}\n\nconst sinceDate = (now) => new Date(now.getTime() - DAYS * 86400_000).toISOString().slice(0, 10);\n\nfunction attempter(unreadable) {\n return 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}\n\nexport async function collect(opts) {\n return (await collectBase(opts)).doc;\n}\n\nasync function collectBase({ run = realRun, now = new Date() } = {}) {\n const since = sinceDate(now);\n const unreadable = [];\n const attempt = attempter(unreadable);\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@ 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@ archived:false`,\n };\n // GitHub's search returns at most 1000 results, so a merged-date search reads each 90-day span on its own.\n const mid = new Date(Date.parse(`${since}T00:00:00Z`) + SPAN_DAYS * 86400_000).toISOString().slice(0, 10);\n const spans = [`merged:${since}..${new Date(Date.parse(`${mid}T00:00:00Z`) - 86400_000).toISOString().slice(0, 10)}`, `merged:>=${mid}`];\n const read = async (k, v) => {\n if (!v.includes(\"@MERGED@\")) return search(run, v, { withState: STATEFUL.has(k) });\n const parts = await Promise.all(spans.map((m) => search(run, v.replace(\"@MERGED@\", m))));\n return { total: parts.reduce((n, p) => n + p.total, 0), nodes: parts.flatMap((p) => p.nodes) };\n };\n const got = Object.fromEntries(await Promise.all(Object.entries(q).map(async ([k, v]) => [k, await attempt(`search ${k}`, () => read(k, v), 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 const doc = {\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 return { doc, got };\n}\n\nconst CONTRIBUTIONS = `query($from: DateTime!, $to: DateTime!) {\n viewer { contributionsCollection(from: $from, to: $to) {\n restrictedContributionsCount\n contributionCalendar { weeks { contributionDays { date contributionCount weekday } } }\n commitContributionsByRepository(maxRepositories: 25) { repository { nameWithOwner } contributions(first: 100) { totalCount nodes { occurredAt commitCount } } }\n pullRequestContributionsByRepository(maxRepositories: 25) { repository { nameWithOwner } contributions(first: 100) { totalCount nodes { occurredAt pullRequest { url title } } } }\n pullRequestReviewContributionsByRepository(maxRepositories: 25) { repository { nameWithOwner } contributions(first: 100) { totalCount nodes { occurredAt pullRequestReview { url state comments { totalCount } pullRequest { url title repository { nameWithOwner } } } } } }\n issueContributionsByRepository(maxRepositories: 25) { repository { nameWithOwner } contributions(first: 100) { totalCount nodes { occurredAt issue { url title } } } }\n } }\n}`;\n\nconst contributionSpan = (run, from, to) => retried(run, [\"api\", \"graphql\", \"--jq\", \".data.viewer.contributionsCollection\", \"-f\", `query=${CONTRIBUTIONS}`, \"-f\", `from=${from}`, \"-f\", `to=${to}`]);\n\n// One row per contribution GitHub lists: commits are per repo per day (commitCount), the others one per PR, review or issue.\nfunction contributionRows(c) {\n const rows = [];\n const each = (list, kind, row) => { for (const r of list ?? []) for (const n of r.contributions?.nodes ?? []) rows.push({ kind, repo: r.repository.nameWithOwner, at: n.occurredAt, ...row(n) }); };\n each(c.commitContributionsByRepository, \"commit\", (n) => ({ commits: n.commitCount }));\n each(c.pullRequestContributionsByRepository, \"pr\", (n) => ({ url: n.pullRequest?.url, title: snippet(n.pullRequest?.title) }));\n each(c.pullRequestReviewContributionsByRepository, \"review\", (n) => ({\n url: n.pullRequestReview?.url,\n state: n.pullRequestReview?.state ?? null,\n comments: n.pullRequestReview?.comments?.totalCount ?? 0,\n prUrl: n.pullRequestReview?.pullRequest?.url,\n prTitle: snippet(n.pullRequestReview?.pullRequest?.title),\n }));\n each(c.issueContributionsByRepository, \"issue\", (n) => ({ url: n.issue?.url, title: snippet(n.issue?.title) }));\n return rows;\n}\n\n// Per repo and kind, the contribution total GitHub reports, which can exceed the 100 nodes a span lists.\nfunction contributionTotals(c, into) {\n const add = (list, key, total) => { for (const r of list ?? []) { const t = (into[r.repository.nameWithOwner] ??= { commits: 0, prs: 0, reviews: 0, issues: 0 }); t[key] += total(r); } };\n add(c.commitContributionsByRepository, \"commits\", (r) => (r.contributions?.nodes ?? []).reduce((n, x) => n + (x.commitCount ?? 0), 0));\n add(c.pullRequestContributionsByRepository, \"prs\", (r) => r.contributions?.totalCount ?? 0);\n add(c.pullRequestReviewContributionsByRepository, \"reviews\", (r) => r.contributions?.totalCount ?? 0);\n add(c.issueContributionsByRepository, \"issues\", (r) => r.contributions?.totalCount ?? 0);\n return into;\n}\n\nconst AGENT_MARKER = /co-authored-by|generated with|🤖/i;\nconst CO_AUTHOR = /^\\s*co-authored-by:\\s*([^<\\n]*?)\\s*(?:<[^>\\n]*>)?\\s*$/gim;\n\nconst repoPr = (n) => ({\n title: snippet(n.title),\n body: snippet(n.body),\n url: n.url,\n createdAt: n.createdAt,\n mergedAt: n.mergedAt,\n additions: n.additions,\n deletions: n.deletions,\n agentMarkers: String(n.body ?? \"\").split(\"\\n\").filter((l) => AGENT_MARKER.test(l)).map(snippet),\n});\n\nconst repoCommit = (c) => ({\n sha: c.sha,\n url: c.html_url,\n committedDate: c.commit?.committer?.date ?? null,\n authoredDate: c.commit?.author?.date ?? null,\n headline: snippet(String(c.commit?.message ?? \"\").split(\"\\n\")[0]),\n coAuthors: [...String(c.commit?.message ?? \"\").matchAll(CO_AUTHOR)].map((m) => snippet(m[1])),\n});\n\nasync function repoCommits(run, repo, login, sinceIso) {\n const out = [];\n for (let page = 1; page <= COMMIT_PAGES; page++) {\n const got = await retried(run, [\"api\", `repos/${repo}/commits?author=${encodeURIComponent(login)}&since=${encodeURIComponent(sinceIso)}&per_page=100&page=${page}`]);\n out.push(...got.filter((c) => c?.html_url).map(repoCommit));\n if (got.length < 100) break;\n }\n return out;\n}\n\nconst weekStart = (date, weekday) => new Date(Date.parse(`${date}T00:00:00Z`) - weekday * 86400_000).toISOString().slice(0, 10);\n\nexport async function collectAll({ run = realRun, now = new Date() } = {}) {\n const since = sinceDate(now);\n const until = now.toISOString().slice(0, 10);\n const window = { since, until, days: DAYS };\n const contribUnreadable = [];\n const contribAttempt = attempter(contribUnreadable);\n // Two spans split on a whole UTC day, so no calendar day is read twice; the second runs to now.\n const mid = Date.parse(`${since}T00:00:00Z`) + SPAN_DAYS * 86400_000;\n const spans = [[`${since}T00:00:00.000Z`, new Date(mid - 1000).toISOString()], [new Date(mid).toISOString(), now.toISOString()]];\n const [{ doc: base, got }, ...collections] = await Promise.all([\n collectBase({ run, now }),\n ...spans.map(([from, to]) => contribAttempt(`contributions ${from.slice(0, 10)}..${to.slice(0, 10)}`, () => contributionSpan(run, from, to), null)),\n ]);\n const read = collections.filter(Boolean);\n // GitHub leaves contributions to repos the token can't attribute (private org repos) out of contributionsCollection and\n // counts them only in restrictedContributionsCount, so the PRs the searches found add their own open and merge events.\n const contributed = read.flatMap(contributionRows);\n const seen = new Set(contributed.filter((r) => r.kind === \"pr\").map((r) => r.url));\n const searched = [...got.merged.nodes, ...got.open.nodes].filter((n) => !seen.has(n.url) && n.createdAt >= since).map((n) => ({ kind: \"pr\", repo: n.repository.nameWithOwner, at: n.createdAt, url: n.url, title: snippet(n.title) }));\n const merges = got.merged.nodes.filter((n) => n.mergedAt).map((n) => ({ kind: \"merge\", repo: n.repository.nameWithOwner, at: n.mergedAt, url: n.url }));\n const rows = [...contributed, ...searched, ...merges];\n const restricted = read.reduce((n, c) => n + (c.restrictedContributionsCount ?? 0), 0);\n const totals = {};\n for (const c of read) contributionTotals(c, totals);\n for (const r of searched) (totals[r.repo] ??= { commits: 0, prs: 0, reviews: 0, issues: 0 }).prs += 1;\n\n const days = new Map();\n for (const c of read) for (const w of c.contributionCalendar?.weeks ?? []) for (const d of w.contributionDays ?? []) {\n if (d.date >= since && d.date <= until && !(days.get(d.date)?.count >= d.contributionCount)) days.set(d.date, { date: d.date, count: d.contributionCount, weekday: d.weekday });\n }\n const dayList = [...days.values()].sort((a, b) => a.date.localeCompare(b.date));\n const weeks = new Map();\n for (const d of dayList) { const k = weekStart(d.date, d.weekday); weeks.set(k, (weeks.get(k) ?? 0) + d.count); }\n\n const activeDates = {};\n for (const r of rows) (activeDates[r.repo] ??= new Set()).add(r.at.slice(0, 10));\n const activeByRepo = Object.entries(activeDates).map(([repo, set]) => ({ repo, activeDays: set.size })).sort((a, b) => b.activeDays - a.activeDays || a.repo.localeCompare(b.repo));\n const top = activeByRepo.slice(0, TOP_REPOS);\n\n const reviewRows = rows.filter((r) => r.kind === \"review\" && r.url);\n const reviewItem = (r) => ({ url: r.url, state: r.state, comments: r.comments, occurredAt: r.at, prUrl: r.prUrl, prTitle: r.prTitle, repo: r.repo });\n\n const repoFiles = await Promise.all(top.map(async ({ repo, activeDays }) => {\n const unreadable = [...contribUnreadable];\n const attempt = attempter(unreadable);\n const [prs, commits] = await Promise.all([\n attempt(`search prs ${repo}`, () => search(run, `is:pr author:@me repo:${repo} created:>=${since}`, { withBody: true, cap: REPO_PR_CAP }), { total: 0, nodes: [] }),\n attempt(`commits ${repo}`, () => repoCommits(run, repo, base.login, `${since}T00:00:00Z`), []),\n ]);\n const t = totals[repo] ?? { commits: 0, prs: 0, reviews: 0, issues: 0 };\n return [`repos/${repo.replace(\"/\", \"__\")}.json`, {\n repo,\n window,\n activeDays,\n counts: { commits: t.commits, prs: t.prs, reviews: t.reviews },\n prs: { total: prs.total, listed: prs.nodes.length, items: prs.nodes.map(repoPr) },\n commits,\n reviewComments: reviewRows.filter((r) => r.repo === repo).map(reviewItem),\n unreadable,\n }];\n }));\n\n // Commit contributions carry only a day, so commit times come from the commits read for the top repos.\n const events = [\n ...rows.filter((r) => r.kind !== \"commit\" && r.url).map((r) => ({ at: r.at, kind: r.kind, repo: r.repo, url: r.url })),\n ...repoFiles.flatMap(([, f]) => f.commits.map((c) => ({ at: c.authoredDate ?? c.committedDate, kind: \"commit\", repo: f.repo, url: c.url }))),\n ].filter((e) => e.at).sort((a, b) => a.at.localeCompare(b.at));\n\n const issuesUnreadable = [];\n const issuesAttempt = attempter(issuesUnreadable);\n const empty = { total: 0, nodes: [] };\n const [opened, commented] = await Promise.all([\n issuesAttempt(\"search issues opened\", () => search(run, `is:issue author:@me created:>=${since}`), empty),\n issuesAttempt(\"search issues commented\", () => search(run, `is:issue commenter:@me -author:@me updated:>=${since}`), empty),\n ]);\n\n const reviewTotals = Object.entries(totals).filter(([, t]) => t.reviews).map(([repo, t]) => ({\n repo,\n reviews: t.reviews,\n comments: reviewRows.filter((r) => r.repo === repo).reduce((n, r) => n + r.comments, 0),\n })).sort((a, b) => b.reviews - a.reviews || a.repo.localeCompare(b.repo));\n\n return {\n files: {\n \"base.json\": base,\n \"calendar.json\": {\n window,\n days: dayList.map(({ date, count }) => ({ date, count })),\n byWeek: [...weeks].map(([week, count]) => ({ week, count })),\n activeDaysByRepo: activeByRepo,\n eventDays: Object.entries(Object.groupBy(events, (e) => e.at.slice(0, 10))).map(([date, list]) => ({ date, events: list.length })).sort((a, b) => a.date.localeCompare(b.date)),\n restrictedContributions: restricted,\n basis: \"days: GitHub's calendar. restrictedContributions: private ones it names no repo for. events add the searched PRs, so private repos count; their reviews, and commits outside the top repos, don't.\",\n events,\n unreadable: contribUnreadable,\n },\n \"reviews.json\": {\n window,\n total: reviewRows.length,\n byRepo: reviewTotals,\n items: reviewRows.map(reviewItem).sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)),\n unreadable: [...contribUnreadable],\n },\n \"issues.json\": {\n window,\n opened: { total: opened.total, listed: opened.nodes.length, items: opened.nodes.map(item).sort(byRecent) },\n commented: { total: commented.total, listed: commented.nodes.length, items: commented.nodes.map(item).sort(byRecent) },\n unreadable: issuesUnreadable,\n },\n ...Object.fromEntries(repoFiles),\n },\n };\n}\n\nexport async function writeAll(dir, files) {\n for (const [name, doc] of Object.entries(files)) {\n const path = join(dir, name);\n await mkdir(dirname(path), { recursive: true });\n await writeFile(path, JSON.stringify(doc, null, 1) + \"\\n\");\n }\n}\n\nif (import.meta.url === pathToFileURL(process.argv[1] ?? \"\").href) {\n const args = process.argv.slice(2);\n const dir = args.find((a) => !a.startsWith(\"--\"));\n if (args.includes(\"--quick\")) {\n if (!dir) throw new Error(\"--quick needs an output directory\");\n await writeAll(dir, { \"quick.json\": await collectQuick() });\n process.stdout.write(JSON.stringify([\"quick.json\"]) + \"\\n\");\n } else if (dir) {\n const { files } = await collectAll();\n await writeAll(dir, files);\n process.stdout.write(JSON.stringify(Object.keys(files)) + \"\\n\");\n } else {\n process.stdout.write(JSON.stringify(await collect(), null, 1) + \"\\n\");\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 [q.md...] --facts a.json|dir [...] every URL in each file is in the facts, every `fact:` reference resolves,\n// and every list item cites one or the other, except under a \"Could not tell\"\n// heading; a dir means every .json under it\n// A `fact:local/<file>#<JSON pointer>` reference cites a fact with no web permalink (the Mac's LLM usage): the path is relative to a\n// --facts directory, and the pointer must name a value in it, e.g. fact:local/usage-codex.json#/usage/codex/models/0. Every other fact\n// has a URL, so a `fact:` path outside local/ and interview/ fails: one service then always counts as one source.\n// `fact:interview/<name>` cites the user's own answer, saved under a `## <name>` heading in interview.md in a --facts directory\n// (the run saves their pick from the handoff `ask` as `## handoff`).\n// --read read.md as --profile, and every `###` trait under \"## How you work\" cites at least two different\n// sources (Slack, GitHub, Notion, v0, the Mac's files, the user's answers)\n// --phases phases.log the run's step lines are all there and in order: start, quick, first, facts, slices,\n// areas, read, critique, write, pr, post; finding1 to finding4, each optional, come in\n// order after first and before read\n// --finding f.md as --profile, and the file is one `- ` list item of exactly two sentences (the fact and why\n// it matters, then the next step), each citing a URL or `fact:` reference; whether the\n// reason and the step are good is the writer's judgment, not checked here\n// --post post.md the first bubble (everything above the first `---` line) is at most two sentences, and\n// below it are the \"Your team\" table (| Bot | Change | Why |) and the Schedule table\n// (| Schedule | Bot | When | What it reports |), with a `memory-review` row for `main`\n// --critique critique.md critique.md exists and holds one `keep`, `cut` or `rewrite` line or more; a presence\n// check only, it cannot prove the text is the critique's verbatim output\n// --schedules dir [--read r.md] [--post p.md] every `<bot>/<id>.md` draft has a five-field `cron` and a prompt, and `main/memory-review.md`\n// is there. A draft runs every time its cron matches, so none says it runs once; a draft\n// whose cron pins a date (day and month) or that deletes its own file says it \"recurs until\"\n// that deletion is \"merged and deployed\", and so does a line naming it in the read and the post\n// Every --facts file is also checked against its own `returned` counts: an object's `returned: { <key>: n }` says its <key> list had n\n// items when Kit returned it. A slice retypes Kit's output into facts/slack/ and facts/kit/, so a list shorter than its `returned`\n// means items were dropped while copying, and in those two directories a list of objects with no `returned` count fails too.\n// A URL counts as in the facts when it is a whole string value or appears inside one (a link in a message's text).\n// Exits 1 and prints one line per problem.\nimport { existsSync, readdirSync, readFileSync, statSync } from \"node:fs\";\nimport { join, sep } from \"node:path\";\nimport { pathToFileURL } from \"node:url\";\n\nconst URL_RE = /https?:\\/\\/[^\\s)>\\]\"'`|]+/g;\nconst FACT_RE = /\\bfact:[^\\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?:\\/\\/\\S+$/.test(v)) urls.add(v); for (const u of v.match(URL_RE) ?? []) urls.add(trimUrl(u)); }\n else if (v && typeof v === \"object\") Object.values(v).forEach(walk);\n };\n facts.forEach(walk);\n return urls;\n}\n\nconst unescape = (t) => decodeURIComponent(t).replaceAll(\"~1\", \"/\").replaceAll(\"~0\", \"~\");\nexport function resolveFact(ref, files) {\n const [file, pointer = \"\"] = ref.slice(\"fact:\".length).split(\"#\");\n if (!/^(local|interview)\\//.test(file)) return { problem: `${ref}: only fact:local/... and fact:interview/<name> can be cited; cite the URL instead` };\n if (!Object.hasOwn(files, file)) return { problem: `${ref} names no facts file` };\n let v = files[file];\n for (const t of pointer.split(\"/\").slice(1).map(unescape)) {\n if (v === null || typeof v !== \"object\" || !Object.hasOwn(v, t)) return { problem: `${ref} is not in the facts` };\n v = v[t];\n }\n return { value: v };\n}\n\n// `facts` is a list of parsed facts documents; `files` maps each one's path, as `fact:` references name it, to the same document.\nexport function citationProblems(profile, facts, files = {}) {\n const known = factUrls(facts);\n const problems = [];\n let gaps = false;\n profile.split(\"\\n\").forEach((line, i) => {\n if (/^#{1,6}\\s/.test(line)) gaps = /could not tell/i.test(line);\n const urls = (line.match(URL_RE) ?? []).map(trimUrl);\n const refs = (line.match(FACT_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 for (const r of refs) { const { problem } = resolveFact(r, files); if (problem) problems.push(`line ${i + 1}: ${problem}`); }\n if (!gaps && /^\\s*(?:[-*]|\\d+\\.)\\s/.test(line) && !urls.length && !refs.length) problems.push(`line ${i + 1}: list item cites no permalink: ${line.trim().slice(0, 60)}`);\n });\n return problems;\n}\n\n// interview.md -> { \"interview/handoff\": answer, ... }; a heading with an empty answer is not a fact.\nexport function interviewFacts(text) {\n const out = {};\n for (const m of text.matchAll(/^##[ \\t]+([\\w-]+)[ \\t]*\\n([\\s\\S]*?)(?=^##[ \\t]|(?![\\s\\S]))/gm)) {\n const answer = m[2].trim();\n if (answer) out[`interview/${m[1].toLowerCase()}`] = answer;\n }\n return out;\n}\n\nconst HOST_SOURCES = [[/(^|\\.)slack\\.com$/, \"Slack\"], [/(^|\\.)github\\.com$/, \"GitHub\"], [/(^|\\.)notion\\.(so|site|com)$/, \"Notion\"], [/(^|\\.)v0\\.(dev|app)$/, \"v0\"]];\nexport function sourceOf(citation) {\n if (citation.startsWith(\"fact:\")) {\n const file = citation.slice(\"fact:\".length).split(\"#\")[0];\n return file.startsWith(\"interview/\") ? \"the user's answers\" : file.startsWith(\"local/\") ? \"the Mac's files\" : citation;\n }\n let host;\n try { host = new URL(citation).hostname; } catch { return citation; }\n return HOST_SOURCES.find(([re]) => re.test(host))?.[1] ?? host;\n}\n\nexport function traitProblems(read) {\n const lines = read.split(\"\\n\");\n const start = lines.findIndex((l) => /^##\\s+how you work\\b/i.test(l));\n if (start < 0) return ['read has no \"## How you work\" section'];\n const traits = [];\n for (const line of lines.slice(start + 1)) {\n if (/^#{1,2}\\s/.test(line)) break;\n if (/^###\\s/.test(line)) traits.push({ name: line.replace(/^###\\s+/, \"\").trim(), sources: new Set() });\n else if (traits.length) for (const c of [...(line.match(URL_RE) ?? []), ...(line.match(FACT_RE) ?? [])].map(trimUrl)) traits.at(-1).sources.add(sourceOf(c));\n }\n if (!traits.length) return ['\"## How you work\" has no ### traits'];\n return traits.filter((t) => t.sources.size < 2).map((t) => `trait \"${t.name}\" cites ${t.sources.size ? [...t.sources].join(\", \") : \"no source\"}; a trait needs two different sources`);\n}\n\nconst RETYPED = [\"slack/\", \"kit/\"];\n\nexport function returnedProblems(doc, file) {\n const problems = [];\n const retyped = RETYPED.some((d) => file.startsWith(d) || file.includes(`/${d}`));\n const walk = (v, at) => {\n if (Array.isArray(v)) { v.forEach((x, i) => walk(x, `${at}/${i}`)); return; }\n if (!v || typeof v !== \"object\") return;\n const returned = v.returned && typeof v.returned === \"object\" && !Array.isArray(v.returned) ? v.returned : {};\n for (const [key, n] of Object.entries(returned)) {\n const got = Array.isArray(v[key]) ? v[key].length : null;\n if (got !== n) problems.push(`${file}: ${at}/${key} has ${got === null ? \"no list\" : got} items, but Kit returned ${n}`);\n }\n for (const [key, x] of Object.entries(v)) {\n if (retyped && key !== \"returned\" && Array.isArray(x) && x.some((y) => y && typeof y === \"object\") && !Object.hasOwn(returned, key)) problems.push(`${file}: ${at}/${key} is a list with no returned count`);\n walk(x, `${at}/${key}`);\n }\n };\n walk(doc, \"\");\n return problems;\n}\n\nconst PHASES = [\"start\", \"quick\", \"first\", \"facts\", \"slices\", \"areas\", \"read\", \"critique\", \"write\", \"pr\", \"post\"];\nconst FINDINGS = [\"finding1\", \"finding2\", \"finding3\", \"finding4\"];\n\nexport function phaseProblems(log) {\n const names = log.split(\"\\n\").map((l) => l.trim().split(/\\s+/)[0]).filter(Boolean);\n const problems = [];\n for (const n of names) if (!PHASES.includes(n) && !FINDINGS.includes(n)) problems.push(`phases.log: unknown line \"${n}\"`);\n for (const n of new Set(names)) if (names.indexOf(n) !== names.lastIndexOf(n)) problems.push(`phases.log: \"${n}\" is logged more than once`);\n for (const n of PHASES) if (!names.includes(n)) problems.push(`phases.log: no \"${n}\" line`);\n const steps = names.filter((n) => PHASES.includes(n));\n const order = PHASES.filter((n) => steps.includes(n));\n if (steps.join() !== order.join()) problems.push(`phases.log: steps are logged as ${steps.join(\", \")}; the order is ${order.join(\", \")}`);\n const found = names.filter((n) => FINDINGS.includes(n));\n if (found.join() !== FINDINGS.slice(0, found.length).join()) problems.push(`phases.log: findings are logged as ${found.join(\", \")}; they count up from finding1`);\n const at = (n) => names.indexOf(n);\n for (const f of found) if ((at(\"first\") >= 0 && at(f) < at(\"first\")) || (at(\"read\") >= 0 && at(f) > at(\"read\"))) problems.push(`phases.log: ${f} is logged outside first..read`);\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, URLs and common abbreviations are blanked first, keeping\n// every offset, so their dots don't count; `etc.` before a capital still ends its sentence.\nconst SENTENCE_END = /(?<=[.!?][*_~\"'”’)]*)\\s+/;\nfunction proseMask(text) {\n const blank = (m, c = \"x\") => c.repeat(m.length);\n return text\n .replace(/```[\\s\\S]*?```/g, (m) => m.replace(/[^\\n]/g, \" \"))\n .replace(/`[^`]*`/g, (m) => blank(m))\n .replace(/\\]\\([^)]*\\)/g, (m) => `]${blank(m.slice(1), \" \")}`)\n .replace(URL_RE, (m) => blank(trimUrl(m)) + m.slice(trimUrl(m).length))\n .replace(/\\b(e\\.g|i\\.e|vs)\\./gi, (m) => blank(m))\n .replace(/\\b([Ee]tc)\\.(?!\\s+[A-Z])/g, (m) => blank(m));\n}\n\nexport function sentenceCount(text) {\n return proseMask(text).split(\"\\n\").flatMap((line) => line.split(SENTENCE_END)).filter((s) => /\\w/.test(s)).length;\n}\n\n// A finding: one list item, two sentences (the fact and why it matters, then the next step), each with a citation.\nexport function findingProblems(text) {\n const items = text.split(\"\\n\").filter((l) => l.trim());\n if (items.length !== 1 || !/^\\s*-\\s/.test(items[0])) return [`a finding is one \\`- \\` list item; this file has ${items.length} non-empty lines`];\n const body = items[0].replace(/^\\s*-\\s+/, \"\");\n const n = sentenceCount(body);\n if (n !== 2) return [`the finding has ${n} sentence${n === 1 ? \"\" : \"s\"}; write two: the fact and why it matters, then the next step`];\n const cut = proseMask(body).search(SENTENCE_END);\n const halves = [body.slice(0, cut), body.slice(cut)];\n const cites = (t) => (t.match(URL_RE) ?? []).length + (t.match(FACT_RE) ?? []).length;\n return halves.flatMap((h, i) => (cites(h) ? [] : [`the finding's ${i ? \"second sentence (the next step)\" : \"first sentence (the fact)\"} cites no URL or fact: reference`]));\n}\n\nconst POST_TABLES = [[\"Your team\", [\"Bot\", \"Change\", \"Why\"]], [\"Schedule\", [\"Schedule\", \"Bot\", \"When\", \"What it reports\"]]];\nconst cells = (line) => line.trim().replace(/^\\|/, \"\").replace(/\\|$/, \"\").split(\"|\").map((c) => c.trim());\n\n// The header row of every markdown table in `text`: a `|` row followed by a `|---|` separator row.\nexport function tableHeaders(text) {\n const lines = text.split(\"\\n\");\n return lines.flatMap((l, i) => (/^\\s*\\|.*\\|\\s*$/.test(l) && /^\\s*\\|[\\s:|-]*-[\\s:|-]*\\|\\s*$/.test(lines[i + 1] ?? \"\") ? [cells(l)] : []));\n}\n\nexport function postProblems(post) {\n const bubble = firstBubble(post);\n if (!bubble) return [\"the post has no first bubble\"];\n const problems = [];\n const n = sentenceCount(bubble);\n if (n > MAX_SENTENCES) problems.push(`the first bubble has ${n} sentences; keep it to ${MAX_SENTENCES} and move the rest below a --- line`);\n const headers = tableHeaders(post.slice(post.indexOf(bubble) + bubble.length));\n for (const [name, columns] of POST_TABLES) {\n if (!headers.some((h) => h.join(\"|\") === columns.join(\"|\"))) problems.push(`below the first bubble, the post has no ${name} table with columns | ${columns.join(\" | \")} |`);\n }\n const below = post.slice(post.indexOf(bubble) + bubble.length).split(\"\\n\");\n const start = below.findIndex((l, i) => /^\\s*\\|/.test(l) && cells(l).join(\"|\") === POST_TABLES[1][1].join(\"|\") && /^\\s*\\|[\\s:|-]*-/.test(below[i + 1] ?? \"\"));\n if (start >= 0) {\n const rows = [];\n for (let i = start + 2; i < below.length && /^\\s*\\|.*\\|\\s*$/.test(below[i]); i++) rows.push(cells(below[i]));\n if (!rows.some(([schedule, bot]) => schedule.replace(/`/g, \"\") === \"memory-review\" && bot.replace(/`/g, \"\") === \"main\")) problems.push(\"the Schedule table has no `memory-review` row for `main`; every onboarding offers it\");\n }\n return problems;\n}\n\nexport function critiqueProblems(text) {\n return /^\\s*[-*]?\\s*`?(keep|cut|rewrite)\\b/im.test(text) ? [] : [\"critique.md has no `keep`, `cut` or `rewrite` line; save the critique copy's output verbatim\"];\n}\n\nconst ONE_TIME = /\\bone[- ]time\\b|\\bsingle run\\b|\\bruns? (just )?once\\b|\\bonce only\\b/i;\nconst RECURS = /\\brecurs until\\b[^\\n]*\\bmerged and deployed\\b/i;\nconst SELF_DELETE = /\\bdelet\\w*\\b[^.\\n]*\\b(its own|this) (schedule )?file\\b/i;\n\n// drafts: [[\"<bot>/<id>.md\", text]]; texts: [[name, text]] of the read and the post, whose lines naming a draft are checked too.\nexport function scheduleProblems(drafts, texts = []) {\n const problems = [];\n if (!drafts.some(([f]) => f === \"main/memory-review.md\")) problems.push(\"no main/memory-review.md draft; every onboarding offers it\");\n for (const [file, text] of drafts) {\n const m = /^(\\w[\\w-]*)\\/(\\w[\\w-]*)\\.md$/.exec(file);\n if (!m) { problems.push(`${file}: a draft is schedules/<bot>/<id>.md`); continue; }\n const id = m[2];\n const fm = /^---\\n([\\s\\S]*?)\\n---\\n([\\s\\S]*)$/.exec(text);\n const cron = fm && /^cron:\\s*\"([^\"]*)\"\\s*$/m.exec(fm[1]);\n const fields = cron ? cron[1].trim().split(/\\s+/) : [];\n if (fields.length !== 5) { problems.push(`${file}: front matter needs cron: \"<five fields>\"`); continue; }\n const body = fm[2];\n if (!body.trim()) problems.push(`${file}: the draft has no prompt`);\n if (ONE_TIME.test(body)) problems.push(`${file}: a schedule runs every time its cron matches, so it never runs once; for one run arm schedule__wake__create instead`);\n if (!(/^\\d+$/.test(fields[2]) && /^\\d+$/.test(fields[3])) && !SELF_DELETE.test(body)) continue;\n if (!RECURS.test(body)) problems.push(`${file}: its cron recurs yearly or it deletes its own file, so its prompt says it recurs until that deletion is merged and deployed`);\n for (const [name, t] of texts) {\n const lines = t.split(\"\\n\").filter((l) => l.includes(id));\n if (lines.some((l) => ONE_TIME.test(l))) problems.push(`${name}: calls ${id} one-time, but it recurs until its deletion is merged and deployed`);\n if (!lines.some((l) => RECURS.test(l))) problems.push(`${name}: no line naming ${id} says it recurs until its deletion is merged and deployed`);\n }\n }\n return problems;\n}\n\n// Each .json under a directory, keyed by its path relative to that directory; a file, keyed by the path as given.\nexport const jsonFiles = (path) => (statSync(path).isDirectory()\n ? readdirSync(path, { recursive: true }).filter((f) => f.endsWith(\".json\")).sort().map((f) => [f.split(sep).join(\"/\"), join(path, f)])\n : [[path, path]]);\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 reads = after(\"--read\");\n const findings = after(\"--finding\");\n const profiles = [...reads, ...after(\"--profile\"), ...findings];\n if (profiles.length) {\n const factPaths = after(\"--facts\");\n const docs = factPaths.flatMap(jsonFiles).map(([key, f]) => [key, JSON.parse(readFileSync(f, \"utf8\"))]);\n const interview = factPaths.map((d) => join(d, \"interview.md\")).filter((f) => existsSync(f) && statSync(f).isFile()).map((f) => interviewFacts(readFileSync(f, \"utf8\")));\n const files = Object.assign(Object.fromEntries(docs), ...interview);\n for (const [key, doc] of docs) problems.push(...returnedProblems(doc, key));\n const named = (p, ms) => ms.map((m) => (profiles.length > 1 ? `${p}: ${m}` : m));\n for (const p of profiles) {\n const text = readFileSync(p, \"utf8\");\n problems.push(...named(p, citationProblems(text, docs.map(([, d]) => d), files)));\n if (reads.includes(p)) problems.push(...named(p, traitProblems(text)));\n if (findings.includes(p)) problems.push(...named(p, findingProblems(text)));\n }\n }\n const [phases] = after(\"--phases\");\n if (phases) problems.push(...phaseProblems(readFileSync(phases, \"utf8\")));\n const [post] = after(\"--post\");\n if (post) problems.push(...postProblems(readFileSync(post, \"utf8\")));\n const [critique] = after(\"--critique\");\n if (critique) problems.push(...(existsSync(critique) ? critiqueProblems(readFileSync(critique, \"utf8\")) : [`${critique} is missing; save the critique copy's output verbatim`]));\n const [schedules] = after(\"--schedules\");\n if (schedules) {\n const drafts = readdirSync(schedules, { recursive: true }).map((f) => f.split(sep).join(\"/\")).filter((f) => f.endsWith(\".md\")).sort().map((f) => [f, readFileSync(join(schedules, f), \"utf8\")]);\n problems.push(...scheduleProblems(drafts, [...reads, ...(post ? [post] : [])].map((f) => [f, readFileSync(f, \"utf8\")])));\n }\n problems.forEach((p) => console.log(p));\n process.exit(problems.length ? 1 : 0);\n}\n","slack-facts.callscript.js":"// Facts about the signed-in user (their Slack profile) and their 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 self = await slack.users_info({ user: me });\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 refused = [auth, self, ...ownPages, ...mentionPages].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, user: m.user, 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 lastOwn = Object.fromEntries(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}:${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, realName: (self.user.real_name ?? \"\").slice(0, 160), title: (self.user.profile?.title ?? \"\").slice(0, 160), team: (self.user.profile?.team ?? \"\").slice(0, 160), tz: self.user.tz ?? null, startDate: self.user.profile?.start_date ?? null },\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 returned: { channels: channels.slice(0, 25).length },\n channels: channels.slice(0, 25).map(c => ({ id: c.id, channel: c.channel, count: c.count, threadReplies: c.threadReplies, returned: { recent: c.recent.length }, 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 returned: { mentioners: Math.min(Object.keys(Object.groupBy(mentioned, m => m.user)).length, 15), unansweredItems: open.slice(0, 20).length },\n mentioners: Object.entries(Object.groupBy(mentioned, m => m.user)).map(e => ({ id: e[0], name: e[1][0].author, count: e[1].length })).sort((a, b) => b.count - a.count).slice(0, 15),\n unansweredItems: open.slice(0, 20).map(m => ({ url: m.url, channel: m.name, user: m.user, 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};\n","team-channel.callscript.js":"// Facts about one team channel over the last input.days (default 30): who posts, how much, and the newest 50 messages with permalinks.\n// Every list has its length in the `returned` beside it: the slice retypes this output into facts/kit/, and check-report.mjs fails a\n// file whose list is shorter than its `returned`, so this output stays small enough to copy whole.\n// input: { channel: \"<id as Kit returns it>\", days: 30 }. Never calls search_messages; a DM or group DM is refused before any history is read.\n// History is chained by cursor: each page step fans out over at most one previous page that has_more, so an empty filter ends the chain.\nconst DAYS = input.days ?? 30;\nconst SECRET = [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"];\nconst oldest = String(Math.floor((Date.now() - DAYS * 86400000) / 1000));\nconst sid = input.channel.split(\".\")[0];\nconst [auth, info] = await Promise.all([slack.auth_test(), slack.conversations_info({ channel: input.channel })]);\nif ([auth, info].some(r => r.ok === false)) {\n return { refused: { error: [auth, info].find(r => r.ok === false).error, needed: [auth, info].find(r => r.ok === false).needed ?? null, provided: [auth, info].find(r => r.ok === false).provided ?? null, calls: [auth, info].filter(r => r.ok === false).length } };\n}\nif (info.channel.is_im || info.channel.is_mpim) {\n return { refused: { error: \"dm_not_read\", needed: null, provided: null, calls: 0 } };\n}\nconst p1 = await slack.conversations_history({ channel: input.channel, limit: 200, oldest });\nconst p2 = await Promise.all([p1].filter(p => p.ok !== false && p.has_more && p.response_metadata?.next_cursor).slice(0, 1).map(p => slack.conversations_history({ channel: input.channel, limit: 200, oldest, cursor: p.response_metadata.next_cursor })));\nconst p3 = await Promise.all(p2.filter(p => p.ok !== false && p.has_more && p.response_metadata?.next_cursor).slice(0, 1).map(p => slack.conversations_history({ channel: input.channel, limit: 200, oldest, cursor: p.response_metadata.next_cursor })));\nconst p4 = await Promise.all(p3.filter(p => p.ok !== false && p.has_more && p.response_metadata?.next_cursor).slice(0, 1).map(p => slack.conversations_history({ channel: input.channel, limit: 200, oldest, cursor: p.response_metadata.next_cursor })));\nconst p5 = await Promise.all(p4.filter(p => p.ok !== false && p.has_more && p.response_metadata?.next_cursor).slice(0, 1).map(p => slack.conversations_history({ channel: input.channel, limit: 200, oldest, cursor: p.response_metadata.next_cursor })));\nconst pages = [p1, ...p2, ...p3, ...p4, ...p5];\nif (pages.some(r => r.ok === false)) {\n return { refused: { error: pages.find(r => r.ok === false).error, needed: pages.find(r => r.ok === false).needed ?? null, provided: pages.find(r => r.ok === false).provided ?? null, calls: pages.filter(r => r.ok === false).length } };\n}\nconst all = pages.flatMap(p => p.messages).filter(m => m.user && m.subtype !== \"channel_join\" && m.subtype !== \"channel_leave\");\nconst posters = Object.entries(Object.groupBy(all, m => m.user)).map(e => ({ id: e[0], count: e[1].length })).sort((a, b) => b.count - a.count);\nconst people = await Promise.all(posters.slice(0, 10).map(p => slack.users_info({ user: p.id })));\nif (people.some(r => r.ok === false)) {\n return { refused: { error: people.find(r => r.ok === false).error, needed: people.find(r => r.ok === false).needed ?? null, provided: people.find(r => r.ok === false).provided ?? null, calls: people.filter(r => r.ok === false).length } };\n}\nconst flat = all.slice(0, 50).map(m => ({ m, flat: (m.text ?? \"\").split(\"\\n\").join(\" \") }));\nreturn {\n channel: { id: input.channel, name: info.channel.name, url: `${auth.url}archives/${sid}`, purpose: (info.channel.purpose?.value ?? \"\").split(\"\\n\").join(\" \").slice(0, 160), topic: (info.channel.topic?.value ?? \"\").split(\"\\n\").join(\" \").slice(0, 160), members: info.channel.num_members ?? null },\n window: { days: DAYS, oldest },\n pagesRead: pages.length,\n more: Boolean(pages[pages.length - 1].has_more),\n scope: \"one public or private channel; DMs and group DMs are refused; bot posts without a user and join or leave events are dropped\",\n counts: { returned: { perPoster: posters.slice(0, 25).length }, messages: all.length, threads: all.filter(m => (m.reply_count ?? 0) > 0).length, replies: all.reduce((n, m) => n + (m.reply_count ?? 0), 0), posters: posters.length, perPoster: posters.slice(0, 25) },\n returned: { posters: posters.slice(0, 10).length, messages: flat.length },\n posters: posters.slice(0, 10).map((p, i) => ({ id: p.id, url: `${auth.url}team/${p.id.split(\".\")[0]}`, realName: (people[i].user?.real_name ?? \"\").slice(0, 160), title: (people[i].user?.profile?.title ?? \"\").slice(0, 160), count: p.count })),\n messages: flat.map(x => ({ url: `${auth.url}archives/${sid}/p${x.m.ts.split(\".\").join(\"\")}${x.m.thread_ts && x.m.thread_ts !== x.m.ts ? `?thread_ts=${x.m.thread_ts}` : \"\"}`, ts: x.m.ts, user: x.m.user, text: x.flat.split(\" \").some(t => t.length >= 20 && SECRET.some(p => t.includes(p))) ? \"[withheld]\" : x.flat.slice(0, 160), replyCount: x.m.reply_count ?? 0 }))\n};\n","leadership.callscript.js":"// Facts for choosing leads: recent history of up to 3 leadership or announcement channels, with permalinks, and the profiles of\n// their 20 most frequent posters as candidates (title, team, bot and deleted flags). It never searches: the leadership slice\n// judges which candidates lead from these facts and passes them to lead-posts.callscript.js.\n// input: { channels: [<=3 ids as Kit returns them], days: 90 }. DMs and group DMs are refused before any history is read.\n// With no channels it returns only the user's channel memberships (up to 400), which the slice picks the channels from.\nconst DAYS = input.days ?? 90;\nconst SECRET = [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"];\nconst oldest = String(Math.floor((Date.now() - DAYS * 86400000) / 1000));\nconst channels = (input.channels ?? []).slice(0, 3);\nconst auth = await slack.auth_test();\nif (channels.length === 0) {\n const m1 = await slack.users_conversations({ types: \"public_channel,private_channel\", limit: 200, exclude_archived: true });\n const m2 = await Promise.all([m1].filter(p => p.ok !== false && p.response_metadata?.next_cursor).slice(0, 1).map(p => slack.users_conversations({ types: \"public_channel,private_channel\", limit: 200, exclude_archived: true, cursor: p.response_metadata.next_cursor })));\n return [auth, m1, ...m2].some(r => r.ok === false)\n ? { refused: { error: [auth, m1, ...m2].find(r => r.ok === false).error, needed: [auth, m1, ...m2].find(r => r.ok === false).needed ?? null, provided: [auth, m1, ...m2].find(r => r.ok === false).provided ?? null, calls: [auth, m1, ...m2].filter(r => r.ok === false).length } }\n : { me: { id: auth.user_id, name: auth.user }, scope: \"channel memberships only; nothing is read from them\", more: Boolean([m1, ...m2][m2.length].response_metadata?.next_cursor), returned: { memberships: [m1, ...m2].flatMap(p => p.channels ?? []).length }, memberships: [m1, ...m2].flatMap(p => p.channels ?? []).map(c => ({ id: c.id, name: c.name })).sort((a, b) => a.name.localeCompare(b.name)) };\n}\nconst infos = await Promise.all(channels.slice(0, 3).map(c => slack.conversations_info({ channel: c })));\nif ([auth, ...infos].some(r => r.ok === false)) {\n return { refused: { error: [auth, ...infos].find(r => r.ok === false).error, needed: [auth, ...infos].find(r => r.ok === false).needed ?? null, provided: [auth, ...infos].find(r => r.ok === false).provided ?? null, calls: [auth, ...infos].filter(r => r.ok === false).length } };\n}\nif (infos.some(i => i.channel.is_im || i.channel.is_mpim)) {\n return { refused: { error: \"dm_not_read\", needed: null, provided: null, calls: 0 } };\n}\nconst h1 = await Promise.all(channels.slice(0, 3).map(c => slack.conversations_history({ channel: c, limit: 200, oldest })));\nconst next = h1.map((p, i) => ({ channel: channels[i], cursor: p.response_metadata?.next_cursor, more: p.ok !== false && p.has_more })).filter(x => x.more && x.cursor);\nconst h2 = await Promise.all(next.slice(0, 3).map(x => slack.conversations_history({ channel: x.channel, limit: 200, oldest, cursor: x.cursor })));\nif ([...h1, ...h2].some(r => r.ok === false)) {\n return { refused: { error: [...h1, ...h2].find(r => r.ok === false).error, needed: [...h1, ...h2].find(r => r.ok === false).needed ?? null, provided: [...h1, ...h2].find(r => r.ok === false).provided ?? null, calls: [...h1, ...h2].filter(r => r.ok === false).length } };\n}\nconst perChannel = channels.map((c, i) => ({ id: c, info: infos[i].channel, pages: [h1[i], ...h2.filter((p, j) => next[j].channel === c)] }));\nconst posts = perChannel.flatMap(c => c.pages.flatMap(p => p.messages).filter(m => m.user && m.subtype !== \"channel_join\" && m.subtype !== \"channel_leave\").map(m => ({ m, channel: c.id })));\nconst ranked = Object.entries(Object.groupBy(posts, p => p.m.user)).filter(e => e[0] !== auth.user_id).sort((a, b) => b[1].length - a[1].length).slice(0, 20);\nconst people = await Promise.all(ranked.slice(0, 20).map(e => slack.users_info({ user: e[0] })));\nif (people.some(r => r.ok === false)) {\n return { refused: { error: people.find(r => r.ok === false).error, needed: people.find(r => r.ok === false).needed ?? null, provided: people.find(r => r.ok === false).provided ?? null, calls: people.filter(r => r.ok === false).length } };\n}\nreturn {\n me: { id: auth.user_id, name: auth.user },\n window: { days: DAYS, oldest },\n scope: \"listed public or private channels only; DMs and group DMs are refused; no search\",\n searches: 0,\n returned: { channels: perChannel.length, candidates: ranked.length },\n channels: perChannel.map(c => ({\n id: c.id,\n name: c.info.name,\n url: `${auth.url}archives/${c.id.split(\".\")[0]}`,\n purpose: (c.info.purpose?.value ?? \"\").split(\"\\n\").join(\" \").slice(0, 160),\n pagesRead: c.pages.length,\n more: Boolean(c.pages[c.pages.length - 1].has_more),\n count: c.pages.flatMap(p => p.messages).filter(m => m.user).length,\n returned: { messages: c.pages.flatMap(p => p.messages).filter(m => m.user && m.subtype !== \"channel_join\" && m.subtype !== \"channel_leave\").slice(0, 20).length },\n messages: c.pages.flatMap(p => p.messages).filter(m => m.user && m.subtype !== \"channel_join\" && m.subtype !== \"channel_leave\").slice(0, 20).map(m => ({ m, flat: (m.text ?? \"\").split(\"\\n\").join(\" \") })).map(x => ({ url: `${auth.url}archives/${c.id.split(\".\")[0]}/p${x.m.ts.split(\".\").join(\"\")}${x.m.thread_ts && x.m.thread_ts !== x.m.ts ? `?thread_ts=${x.m.thread_ts}` : \"\"}`, ts: x.m.ts, user: x.m.user, text: x.flat.split(\" \").some(t => t.length >= 20 && SECRET.some(p => t.includes(p))) ? \"[withheld]\" : x.flat.slice(0, 160), replyCount: x.m.reply_count ?? 0 }))\n })),\n candidates: ranked.map((e, i) => ({\n id: e[0],\n url: `${auth.url}team/${e[0].split(\".\")[0]}`,\n realName: (people[i].user?.real_name ?? \"\").slice(0, 160),\n title: (people[i].user?.profile?.title ?? \"\").slice(0, 160),\n team: (people[i].user?.profile?.team ?? \"\").slice(0, 160),\n isBot: Boolean(people[i].user?.is_bot) || e[1].some(p => Boolean(p.m.bot_id)),\n deleted: Boolean(people[i].user?.deleted),\n posts: e[1].length,\n channels: e[1].map(p => p.channel).filter((c, j, all) => all.indexOf(c) === j)\n }))\n};\n","lead-posts.callscript.js":"// Facts about what the chosen leads say: each lead's profile and recent channel posts, with permalinks.\n// input: { leads: [<=10 user ids the leadership slice chose], days: 90 }. One search per lead, every one -is:dm, 10 at most.\n// Slack allows about 20 searches a minute: start it at least 60 seconds after the base script, and on ratelimited wait a minute and rerun.\nconst DAYS = input.days ?? 90;\nconst SECRET = [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"];\nconst after = new Date(Date.now() - (DAYS + 1) * 86400000).slice(0, 10);\nconst auth = await slack.auth_test();\nif (auth.ok === false) {\n return { refused: { error: auth.error, needed: auth.needed ?? null, provided: auth.provided ?? null, calls: 1 } };\n}\nconst leads = (input.leads ?? []).filter((u, i, all) => u !== auth.user_id && all.indexOf(u) === i).slice(0, 10);\nconst people = await Promise.all(leads.slice(0, 10).map(u => slack.users_info({ user: u })));\nconst found = await Promise.all(leads.slice(0, 10).map(u => slack.search_messages({ query: `from:<@${u}> after:${after} -is:dm`, count: 6, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst answers = [...people, ...found];\nif (answers.some(r => r.ok === false)) {\n return { refused: { error: answers.find(r => r.ok === false).error, needed: answers.find(r => r.ok === false).needed ?? null, provided: answers.find(r => r.ok === false).provided ?? null, calls: answers.filter(r => r.ok === false).length } };\n}\nreturn {\n me: { id: auth.user_id, name: auth.user },\n window: { days: DAYS, after },\n scope: \"each lead's channel posts; DMs and group DMs are excluded with -is:dm\",\n searches: found.length,\n returned: { leads: leads.length },\n leads: leads.map((u, i) => ({\n id: u,\n url: `${auth.url}team/${u.split(\".\")[0]}`,\n realName: (people[i].user?.real_name ?? \"\").slice(0, 160),\n title: (people[i].user?.profile?.title ?? \"\").slice(0, 160),\n team: (people[i].user?.profile?.team ?? \"\").slice(0, 160),\n tz: people[i].user?.tz ?? null,\n isBot: Boolean(people[i].user?.is_bot),\n deleted: Boolean(people[i].user?.deleted),\n searchTotal: found[i].messages.paging?.total ?? found[i].messages.matches.length,\n returned: { messages: found[i].messages.matches.filter(m => !m.channel.is_im && !m.channel.is_mpim).length },\n messages: found[i].messages.matches.filter(m => !m.channel.is_im && !m.channel.is_mpim).map(m => ({ m, flat: (m.text ?? \"\").split(\"\\n\").join(\" \") })).map(x => ({ url: x.m.permalink, ts: x.m.ts, channel: x.m.channel.id, name: x.m.channel.name, text: x.flat.split(\" \").some(t => t.length >= 20 && SECRET.some(p => t.includes(p))) ? \"[withheld]\" : x.flat.slice(0, 160) }))\n }))\n};\n","people.callscript.js":"// Profiles of up to 15 Slack users: name, title, team, time zone and start date, with a profile link. Never calls search_messages.\n// input: { users: [<=15 user ids] }.\nconst users = (input.users ?? []).slice(0, 15);\nconst auth = await slack.auth_test();\nconst people = await Promise.all(users.slice(0, 15).map(u => slack.users_info({ user: u })));\nif ([auth, ...people].some(r => r.ok === false)) {\n return { refused: { error: [auth, ...people].find(r => r.ok === false).error, needed: [auth, ...people].find(r => r.ok === false).needed ?? null, provided: [auth, ...people].find(r => r.ok === false).provided ?? null, calls: [auth, ...people].filter(r => r.ok === false).length } };\n}\nreturn {\n returned: { people: users.length },\n people: users.map((u, i) => ({\n id: u,\n url: `${auth.url}team/${u.split(\".\")[0]}`,\n name: (people[i].user.name ?? \"\").slice(0, 160),\n realName: (people[i].user.real_name ?? \"\").slice(0, 160),\n title: (people[i].user.profile?.title ?? \"\").slice(0, 160),\n team: (people[i].user.profile?.team ?? \"\").slice(0, 160),\n tz: people[i].user.tz ?? null,\n startDate: people[i].user.profile?.start_date ?? null,\n isBot: Boolean(people[i].user.is_bot),\n deleted: Boolean(people[i].user.deleted)\n }))\n};\n","notion.callscript.js":"// Notion pages (and Vercel knowledge base results) for up to 5 queries, then the opening text of up to 5 distinct top pages, as <=200-char snippets.\n// input: { queries: [<=5 strings] }. Every result carries its url; page text is tag-stripped, cut to 1500 chars and split into 8 snippets of 200.\nconst SECRET = [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"];\nconst queries = (input.queries ?? []).slice(0, 5);\nconst found = await Promise.all(queries.slice(0, 5).map(q => notion.notion_search({ query: q, page_size: 5, query_type: \"internal\" })));\nconst known = await Promise.all(queries.slice(0, 5).map(q => vercel_knowledge.search({ query: q, numResults: 5 })));\nconst pages = found.flatMap(r => r.results ?? []).filter(r => r.type === \"page\");\nconst top = pages.filter((p, i, a) => a.findIndex(x => x.id === p.id) === i).slice(0, 5);\nconst fetched = await Promise.all(top.slice(0, 5).map(p => notion.notion_fetch({ id: p.id })));\nconst bodies = fetched.map(f => ({ f, body: (f.text ?? \"\").split(\"<content>\").slice(-1)[0].split(\"</content>\")[0].split(\"<\").map((s, i) => i === 0 ? s : s.slice(s.indexOf(\">\") + 1)).join(\" \").split(\"\\n\").join(\" \").split(\" \").filter(w => w.length > 0).join(\" \").slice(0, 1500) }));\nreturn {\n returned: { queries: queries.length, pages: bodies.length },\n queries: queries.map((q, i) => ({\n query: q.slice(0, 160),\n returned: { notion: (found[i].results ?? []).length, knowledge: (known[i].results ?? []).length },\n notion: (found[i].results ?? []).map(r => ({ r, flat: (r.highlight ?? \"\").split(\"\\n\").join(\" \") })).map(x => ({ title: (x.r.title ?? \"\").slice(0, 160), url: x.r.url, type: x.r.type, path: (x.r.path ?? \"\").slice(0, 160), lastEdited: x.r.timestamp ?? null, verified: x.r.verification?.state === \"verified\", highlight: x.flat.split(\" \").some(t => t.length >= 20 && SECRET.some(p => t.includes(p))) ? \"[withheld]\" : x.flat.slice(0, 160) })),\n knowledge: (known[i].results ?? []).map(r => ({ r, flat: (r.highlights ?? []).join(\" \").split(\"\\n\").join(\" \") })).map(x => ({ title: (x.r.title ?? \"\").slice(0, 160), url: x.r.url, highlight: x.flat.split(\" \").some(t => t.length >= 20 && SECRET.some(p => t.includes(p))) ? \"[withheld]\" : x.flat.slice(0, 160) }))\n })),\n pages: bodies.map((b, i) => ({\n id: top[i].id,\n title: (b.f.title ?? top[i].title ?? \"\").slice(0, 160),\n url: b.f.url ?? top[i].url,\n lastEdited: b.f.page_last_edited_at ?? top[i].timestamp ?? null,\n chars: b.body.length,\n returned: { snippets: [0, 1, 2, 3, 4, 5, 6, 7].map(n => b.body.slice(n * 200, n * 200 + 200)).filter(s => s.length > 0).length },\n snippets: [0, 1, 2, 3, 4, 5, 6, 7].map(n => b.body.slice(n * 200, n * 200 + 200)).filter(s => s.length > 0).map(s => s.split(\" \").some(t => t.length >= 20 && SECRET.some(p => t.includes(p))) ? \"[withheld]\" : s)\n }))\n};\n","v0.callscript.js":"// The user's v0 activity over the last input.days (default 180): usage events and cost per model and per week, and chats per week with their urls.\n// input: { days: 180 }. A v0 call that fails returns { refused } naming the call, never zero usage.\nconst DAYS = input.days ?? 180;\nconst startDate = new Date(Date.now() - DAYS * 86400000);\nconst endDate = new Date(Date.now());\nconst startMs = Date.now() - DAYS * 86400000;\ntry { const u1 = await v0.reports_getUsage({ startDate, endDate, limit: 100 }); } catch (e) { return { refused: { error: `reports_getUsage: ${e.message}`.slice(0, 200), code: e.code ?? null } }; }\nconst u2 = await Promise.all([u1].filter(p => p.pagination?.hasMore && p.pagination?.nextCursor).slice(0, 1).map(p => v0.reports_getUsage({ startDate, endDate, limit: 100, cursor: p.pagination.nextCursor })));\nconst u3 = await Promise.all(u2.filter(p => p.pagination?.hasMore && p.pagination?.nextCursor).slice(0, 1).map(p => v0.reports_getUsage({ startDate, endDate, limit: 100, cursor: p.pagination.nextCursor })));\nconst u4 = await Promise.all(u3.filter(p => p.pagination?.hasMore && p.pagination?.nextCursor).slice(0, 1).map(p => v0.reports_getUsage({ startDate, endDate, limit: 100, cursor: p.pagination.nextCursor })));\nconst u5 = await Promise.all(u4.filter(p => p.pagination?.hasMore && p.pagination?.nextCursor).slice(0, 1).map(p => v0.reports_getUsage({ startDate, endDate, limit: 100, cursor: p.pagination.nextCursor })));\ntry { const chats = await v0.chats_find({ limit: 100 }); } catch (e) { return { refused: { error: `chats_find: ${e.message}`.slice(0, 200), code: e.code ?? null } }; }\nconst usagePages = [u1, ...u2, ...u3, ...u4, ...u5];\nconst events = usagePages.flatMap(p => p.data ?? []).map(e => ({ model: String(e.model ?? e.modelId ?? e.type ?? \"unknown\").slice(0, 80), cost: Number(e.totalCost ?? e.cost ?? 0), at: e.createdAt ?? null, day: e.createdAt ? Math.floor(new Date(e.createdAt).getTime() / 86400000) : null }));\nconst inWindow = (chats.data ?? []).filter(c => new Date(c.createdAt ?? c.updatedAt).getTime() >= startMs);\nconst chatDays = inWindow.map(c => ({ c, day: Math.floor(new Date(c.createdAt ?? c.updatedAt).getTime() / 86400000) }));\nreturn {\n window: { days: DAYS, startDate, endDate },\n usage: {\n events: events.length,\n totalCost: Math.round(events.reduce((n, e) => n + e.cost, 0) * 100) / 100,\n pagesRead: usagePages.length,\n more: Boolean(usagePages[usagePages.length - 1].pagination?.hasMore),\n returned: { perModel: Object.keys(Object.groupBy(events, e => e.model)).length, perWeek: Object.keys(Object.groupBy(events.filter(e => e.day !== null), e => new Date((e.day - (e.day + 3) % 7) * 86400000).slice(0, 10))).length },\n perModel: Object.entries(Object.groupBy(events, e => e.model)).map(g => ({ model: g[0], events: g[1].length, cost: Math.round(g[1].reduce((n, e) => n + e.cost, 0) * 100) / 100 })).sort((a, b) => b.events - a.events),\n perWeek: Object.entries(Object.groupBy(events.filter(e => e.day !== null), e => new Date((e.day - (e.day + 3) % 7) * 86400000).slice(0, 10))).map(g => ({ week: g[0], events: g[1].length, cost: Math.round(g[1].reduce((n, e) => n + e.cost, 0) * 100) / 100 })).sort((a, b) => a.week < b.week ? -1 : 1)\n },\n chats: {\n read: (chats.data ?? []).length,\n count: inWindow.length,\n capped: (chats.data ?? []).length >= 100,\n returned: { perWeek: Object.keys(Object.groupBy(chatDays, x => new Date((x.day - (x.day + 3) % 7) * 86400000).slice(0, 10))).length, items: Math.min(chatDays.length, 30) },\n perWeek: Object.entries(Object.groupBy(chatDays, x => new Date((x.day - (x.day + 3) % 7) * 86400000).slice(0, 10))).map(g => ({ week: g[0], count: g[1].length })).sort((a, b) => a.week < b.week ? -1 : 1),\n items: chatDays.sort((a, b) => b.day - a.day).slice(0, 30).map(x => ({ id: x.c.id, name: (x.c.name ?? x.c.title ?? \"\").split(\"\\n\").join(\" \").slice(0, 160), url: x.c.webUrl ?? `https://v0.app/chat/${x.c.id}`, createdAt: x.c.createdAt ?? null, updatedAt: x.c.updatedAt ?? null, favorite: Boolean(x.c.favorite) }))\n }\n};\n"};
|
|
2
|
+
export default {"github-facts.mjs":"#!/usr/bin/env node\n// Facts about the signed-in `gh` user's last 180 days on GitHub. With an output directory it writes base.json, calendar.json,\n// reviews.json, issues.json and repos/<owner>__<name>.json for the top repos; with none it prints base.json's document on stdout.\n// With --quick it writes only quick.json, the task candidates the first message picks from (QUICK, below).\n// Counts, snippets (<=200 chars), URLs and CODEOWNERS lines only; no judgment: the model reads this and profiles.\nimport { execFile } from \"node:child_process\";\nimport { mkdir, writeFile } from \"node:fs/promises\";\nimport { dirname, join } from \"node:path\";\nimport { promisify } from \"node:util\";\nimport { pathToFileURL } from \"node:url\";\nimport { snippet } from \"./redact.mjs\";\n\nconst exec = promisify(execFile);\nconst DAYS = 180;\n// contributionsCollection is read in two spans of about 90 days each.\nconst SPAN_DAYS = 90;\nconst TOP_REPOS = 5;\nconst REPO_PR_CAP = 100;\nconst COMMIT_PAGES = 3;\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 } @EXTRA@ }\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 = (extra, pageSize) => `query($q: String!, $endCursor: String) {\n search(query: $q, type: ISSUE, first: ${pageSize}, after: $endCursor) {\n issueCount\n pageInfo { hasNextPage endCursor }\n nodes {${BASE.replace(\"@EXTRA@\", extra)}}\n }\n}`;\n\nexport const realRun = async (args) => (await exec(\"gh\", args, { maxBuffer: 1 << 28 })).stdout;\n\nconst ATTEMPTS = 3;\n// GitHub answers a page of 50 nodes with the state fields (mergeable, check rollup) with an HTML error once an account has hundreds of PRs, so those searches read 25 at a time.\nconst PAGE_SIZE = 50;\nconst STATE_PAGE_SIZE = 25;\n\nasync function retried(run, args) {\n for (let attempt = 1; ; attempt++) {\n try {\n return JSON.parse(await run(args));\n } catch (e) {\n if (attempt === ATTEMPTS) throw e;\n await new Promise((r) => setTimeout(r, 1000 * attempt + Math.random() * 1000));\n }\n }\n}\n\nfunction searchPage(run, q, { withState, withBody }, endCursor) {\n const extra = [withState ? STATE : \"\", withBody ? \"body\" : \"\"].join(\" \");\n return retried(run, [\"api\", \"graphql\", \"--jq\", \".data.search\", \"-f\", `query=${searchQuery(extra, withState ? STATE_PAGE_SIZE : PAGE_SIZE)}`, \"-f\", `q=${q}`, ...(endCursor ? [\"-f\", `endCursor=${endCursor}`] : [])]);\n}\n\n// A stateful search lists at most LIST_CAP items, which is all the output shows, so it stops there; the other searches read every page, since the per-repo counts use them all.\nasync function search(run, q, { withState = false, withBody = false, cap = withState ? LIST_CAP : Infinity } = {}) {\n const nodes = [];\n let total;\n let page = await searchPage(run, q, { withState, withBody });\n for (;;) {\n total ??= page.issueCount;\n nodes.push(...page.nodes.filter((n) => n?.url));\n if (!page.pageInfo.hasNextPage || nodes.length >= cap) break;\n page = await searchPage(run, q, { withState, withBody }, page.pageInfo.endCursor);\n }\n return { total, nodes: nodes.slice(0, cap) };\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\n// The first message's task candidates, each one page read with the state fields. Filtering in the query keeps every approved or\n// changes-requested PR in view however many PRs are open, where a 150-item list of all open PRs can miss them and takes 6 pages.\nconst QUICK = {\n approved: \"is:pr author:@me is:open review:approved archived:false\",\n changesRequested: \"is:pr author:@me is:open review:changes_requested archived:false\",\n reviewRequested: \"is:pr is:open review-requested:@me archived:false sort:created-asc\",\n assignedIssues: \"is:issue is:open assignee:@me archived:false\",\n};\n\nexport async function collectQuick({ run = realRun, now = new Date() } = {}) {\n const unreadable = [];\n const attempt = attempter(unreadable);\n const [login, ...got] = await Promise.all([\n run([\"api\", \"user\"]).then((t) => JSON.parse(t).login),\n ...Object.entries(QUICK).map(([k, q]) => attempt(`search ${k}`, () => search(run, q, { withState: true, cap: STATE_PAGE_SIZE }), { total: 0, nodes: [] })),\n ]);\n const lists = Object.fromEntries(Object.keys(QUICK).map((k, i) => [k, got[i]]));\n return {\n login,\n at: now.toISOString(),\n ...Object.fromEntries(Object.entries(lists).map(([k, { total, nodes }]) => [k, { total, listed: nodes.length, items: nodes.map(item) }])),\n unreadable,\n };\n}\n\nconst sinceDate = (now) => new Date(now.getTime() - DAYS * 86400_000).toISOString().slice(0, 10);\n\nfunction attempter(unreadable) {\n return 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}\n\nexport async function collect(opts) {\n return (await collectBase(opts)).doc;\n}\n\nasync function collectBase({ run = realRun, now = new Date() } = {}) {\n const since = sinceDate(now);\n const unreadable = [];\n const attempt = attempter(unreadable);\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@ 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@ archived:false`,\n };\n // GitHub's search returns at most 1000 results, so a merged-date search reads each 90-day span on its own.\n const mid = new Date(Date.parse(`${since}T00:00:00Z`) + SPAN_DAYS * 86400_000).toISOString().slice(0, 10);\n const spans = [`merged:${since}..${new Date(Date.parse(`${mid}T00:00:00Z`) - 86400_000).toISOString().slice(0, 10)}`, `merged:>=${mid}`];\n const read = async (k, v) => {\n if (!v.includes(\"@MERGED@\")) return search(run, v, { withState: STATEFUL.has(k) });\n const parts = await Promise.all(spans.map((m) => search(run, v.replace(\"@MERGED@\", m))));\n return { total: parts.reduce((n, p) => n + p.total, 0), nodes: parts.flatMap((p) => p.nodes) };\n };\n const got = Object.fromEntries(await Promise.all(Object.entries(q).map(async ([k, v]) => [k, await attempt(`search ${k}`, () => read(k, v), 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 const doc = {\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 return { doc, got };\n}\n\nconst CONTRIBUTIONS = `query($from: DateTime!, $to: DateTime!) {\n viewer { contributionsCollection(from: $from, to: $to) {\n restrictedContributionsCount\n contributionCalendar { weeks { contributionDays { date contributionCount weekday } } }\n commitContributionsByRepository(maxRepositories: 25) { repository { nameWithOwner } contributions(first: 100) { totalCount nodes { occurredAt commitCount } } }\n pullRequestContributionsByRepository(maxRepositories: 25) { repository { nameWithOwner } contributions(first: 100) { totalCount nodes { occurredAt pullRequest { url title } } } }\n pullRequestReviewContributionsByRepository(maxRepositories: 25) { repository { nameWithOwner } contributions(first: 100) { totalCount nodes { occurredAt pullRequestReview { url state comments { totalCount } pullRequest { url title repository { nameWithOwner } } } } } }\n issueContributionsByRepository(maxRepositories: 25) { repository { nameWithOwner } contributions(first: 100) { totalCount nodes { occurredAt issue { url title } } } }\n } }\n}`;\n\nconst contributionSpan = (run, from, to) => retried(run, [\"api\", \"graphql\", \"--jq\", \".data.viewer.contributionsCollection\", \"-f\", `query=${CONTRIBUTIONS}`, \"-f\", `from=${from}`, \"-f\", `to=${to}`]);\n\n// One row per contribution GitHub lists: commits are per repo per day (commitCount), the others one per PR, review or issue.\nfunction contributionRows(c) {\n const rows = [];\n const each = (list, kind, row) => { for (const r of list ?? []) for (const n of r.contributions?.nodes ?? []) rows.push({ kind, repo: r.repository.nameWithOwner, at: n.occurredAt, ...row(n) }); };\n each(c.commitContributionsByRepository, \"commit\", (n) => ({ commits: n.commitCount }));\n each(c.pullRequestContributionsByRepository, \"pr\", (n) => ({ url: n.pullRequest?.url, title: snippet(n.pullRequest?.title) }));\n each(c.pullRequestReviewContributionsByRepository, \"review\", (n) => ({\n url: n.pullRequestReview?.url,\n state: n.pullRequestReview?.state ?? null,\n comments: n.pullRequestReview?.comments?.totalCount ?? 0,\n prUrl: n.pullRequestReview?.pullRequest?.url,\n prTitle: snippet(n.pullRequestReview?.pullRequest?.title),\n }));\n each(c.issueContributionsByRepository, \"issue\", (n) => ({ url: n.issue?.url, title: snippet(n.issue?.title) }));\n return rows;\n}\n\n// Per repo and kind, the contribution total GitHub reports, which can exceed the 100 nodes a span lists.\nfunction contributionTotals(c, into) {\n const add = (list, key, total) => { for (const r of list ?? []) { const t = (into[r.repository.nameWithOwner] ??= { commits: 0, prs: 0, reviews: 0, issues: 0 }); t[key] += total(r); } };\n add(c.commitContributionsByRepository, \"commits\", (r) => (r.contributions?.nodes ?? []).reduce((n, x) => n + (x.commitCount ?? 0), 0));\n add(c.pullRequestContributionsByRepository, \"prs\", (r) => r.contributions?.totalCount ?? 0);\n add(c.pullRequestReviewContributionsByRepository, \"reviews\", (r) => r.contributions?.totalCount ?? 0);\n add(c.issueContributionsByRepository, \"issues\", (r) => r.contributions?.totalCount ?? 0);\n return into;\n}\n\nconst AGENT_MARKER = /co-authored-by|generated with|🤖/i;\nconst CO_AUTHOR = /^\\s*co-authored-by:\\s*([^<\\n]*?)\\s*(?:<[^>\\n]*>)?\\s*$/gim;\n\nconst repoPr = (n) => ({\n title: snippet(n.title),\n body: snippet(n.body),\n url: n.url,\n createdAt: n.createdAt,\n mergedAt: n.mergedAt,\n additions: n.additions,\n deletions: n.deletions,\n agentMarkers: String(n.body ?? \"\").split(\"\\n\").filter((l) => AGENT_MARKER.test(l)).map(snippet),\n});\n\nconst repoCommit = (c) => ({\n sha: c.sha,\n url: c.html_url,\n committedDate: c.commit?.committer?.date ?? null,\n authoredDate: c.commit?.author?.date ?? null,\n headline: snippet(String(c.commit?.message ?? \"\").split(\"\\n\")[0]),\n coAuthors: [...String(c.commit?.message ?? \"\").matchAll(CO_AUTHOR)].map((m) => snippet(m[1])),\n});\n\nasync function repoCommits(run, repo, login, sinceIso) {\n const out = [];\n for (let page = 1; page <= COMMIT_PAGES; page++) {\n const got = await retried(run, [\"api\", `repos/${repo}/commits?author=${encodeURIComponent(login)}&since=${encodeURIComponent(sinceIso)}&per_page=100&page=${page}`]);\n out.push(...got.filter((c) => c?.html_url).map(repoCommit));\n if (got.length < 100) break;\n }\n return out;\n}\n\nconst weekStart = (date, weekday) => new Date(Date.parse(`${date}T00:00:00Z`) - weekday * 86400_000).toISOString().slice(0, 10);\n\nexport async function collectAll({ run = realRun, now = new Date() } = {}) {\n const since = sinceDate(now);\n const until = now.toISOString().slice(0, 10);\n const window = { since, until, days: DAYS };\n const contribUnreadable = [];\n const contribAttempt = attempter(contribUnreadable);\n // Two spans split on a whole UTC day, so no calendar day is read twice; the second runs to now.\n const mid = Date.parse(`${since}T00:00:00Z`) + SPAN_DAYS * 86400_000;\n const spans = [[`${since}T00:00:00.000Z`, new Date(mid - 1000).toISOString()], [new Date(mid).toISOString(), now.toISOString()]];\n const [{ doc: base, got }, ...collections] = await Promise.all([\n collectBase({ run, now }),\n ...spans.map(([from, to]) => contribAttempt(`contributions ${from.slice(0, 10)}..${to.slice(0, 10)}`, () => contributionSpan(run, from, to), null)),\n ]);\n const read = collections.filter(Boolean);\n // GitHub leaves contributions to repos the token can't attribute (private org repos) out of contributionsCollection and\n // counts them only in restrictedContributionsCount, so the PRs the searches found add their own open and merge events.\n const contributed = read.flatMap(contributionRows);\n const seen = new Set(contributed.filter((r) => r.kind === \"pr\").map((r) => r.url));\n const searched = [...got.merged.nodes, ...got.open.nodes].filter((n) => !seen.has(n.url) && n.createdAt >= since).map((n) => ({ kind: \"pr\", repo: n.repository.nameWithOwner, at: n.createdAt, url: n.url, title: snippet(n.title) }));\n const merges = got.merged.nodes.filter((n) => n.mergedAt).map((n) => ({ kind: \"merge\", repo: n.repository.nameWithOwner, at: n.mergedAt, url: n.url }));\n const rows = [...contributed, ...searched, ...merges];\n const restricted = read.reduce((n, c) => n + (c.restrictedContributionsCount ?? 0), 0);\n const totals = {};\n for (const c of read) contributionTotals(c, totals);\n for (const r of searched) (totals[r.repo] ??= { commits: 0, prs: 0, reviews: 0, issues: 0 }).prs += 1;\n\n const days = new Map();\n for (const c of read) for (const w of c.contributionCalendar?.weeks ?? []) for (const d of w.contributionDays ?? []) {\n if (d.date >= since && d.date <= until && !(days.get(d.date)?.count >= d.contributionCount)) days.set(d.date, { date: d.date, count: d.contributionCount, weekday: d.weekday });\n }\n const dayList = [...days.values()].sort((a, b) => a.date.localeCompare(b.date));\n const weeks = new Map();\n for (const d of dayList) { const k = weekStart(d.date, d.weekday); weeks.set(k, (weeks.get(k) ?? 0) + d.count); }\n\n const activeDates = {};\n for (const r of rows) (activeDates[r.repo] ??= new Set()).add(r.at.slice(0, 10));\n const activeByRepo = Object.entries(activeDates).map(([repo, set]) => ({ repo, activeDays: set.size })).sort((a, b) => b.activeDays - a.activeDays || a.repo.localeCompare(b.repo));\n const top = activeByRepo.slice(0, TOP_REPOS);\n\n const reviewRows = rows.filter((r) => r.kind === \"review\" && r.url);\n const reviewItem = (r) => ({ url: r.url, state: r.state, comments: r.comments, occurredAt: r.at, prUrl: r.prUrl, prTitle: r.prTitle, repo: r.repo });\n\n const repoFiles = await Promise.all(top.map(async ({ repo, activeDays }) => {\n const unreadable = [...contribUnreadable];\n const attempt = attempter(unreadable);\n const [prs, commits] = await Promise.all([\n attempt(`search prs ${repo}`, () => search(run, `is:pr author:@me repo:${repo} created:>=${since}`, { withBody: true, cap: REPO_PR_CAP }), { total: 0, nodes: [] }),\n attempt(`commits ${repo}`, () => repoCommits(run, repo, base.login, `${since}T00:00:00Z`), []),\n ]);\n const t = totals[repo] ?? { commits: 0, prs: 0, reviews: 0, issues: 0 };\n return [`repos/${repo.replace(\"/\", \"__\")}.json`, {\n repo,\n window,\n activeDays,\n counts: { commits: t.commits, prs: t.prs, reviews: t.reviews },\n prs: { total: prs.total, listed: prs.nodes.length, items: prs.nodes.map(repoPr) },\n commits,\n reviewComments: reviewRows.filter((r) => r.repo === repo).map(reviewItem),\n unreadable,\n }];\n }));\n\n // Commit contributions carry only a day, so commit times come from the commits read for the top repos.\n const events = [\n ...rows.filter((r) => r.kind !== \"commit\" && r.url).map((r) => ({ at: r.at, kind: r.kind, repo: r.repo, url: r.url })),\n ...repoFiles.flatMap(([, f]) => f.commits.map((c) => ({ at: c.authoredDate ?? c.committedDate, kind: \"commit\", repo: f.repo, url: c.url }))),\n ].filter((e) => e.at).sort((a, b) => a.at.localeCompare(b.at));\n\n const issuesUnreadable = [];\n const issuesAttempt = attempter(issuesUnreadable);\n const empty = { total: 0, nodes: [] };\n const [opened, commented] = await Promise.all([\n issuesAttempt(\"search issues opened\", () => search(run, `is:issue author:@me created:>=${since}`), empty),\n issuesAttempt(\"search issues commented\", () => search(run, `is:issue commenter:@me -author:@me updated:>=${since}`), empty),\n ]);\n\n const reviewTotals = Object.entries(totals).filter(([, t]) => t.reviews).map(([repo, t]) => ({\n repo,\n reviews: t.reviews,\n comments: reviewRows.filter((r) => r.repo === repo).reduce((n, r) => n + r.comments, 0),\n })).sort((a, b) => b.reviews - a.reviews || a.repo.localeCompare(b.repo));\n\n return {\n files: {\n \"base.json\": base,\n \"calendar.json\": {\n window,\n days: dayList.map(({ date, count }) => ({ date, count })),\n byWeek: [...weeks].map(([week, count]) => ({ week, count })),\n activeDaysByRepo: activeByRepo,\n eventDays: Object.entries(Object.groupBy(events, (e) => e.at.slice(0, 10))).map(([date, list]) => ({ date, events: list.length })).sort((a, b) => a.date.localeCompare(b.date)),\n restrictedContributions: restricted,\n basis: \"days: GitHub's calendar. restrictedContributions: private ones it names no repo for. events add the searched PRs, so private repos count; their reviews, and commits outside the top repos, don't.\",\n events,\n unreadable: contribUnreadable,\n },\n \"reviews.json\": {\n window,\n total: reviewRows.length,\n byRepo: reviewTotals,\n items: reviewRows.map(reviewItem).sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)),\n unreadable: [...contribUnreadable],\n },\n \"issues.json\": {\n window,\n opened: { total: opened.total, listed: opened.nodes.length, items: opened.nodes.map(item).sort(byRecent) },\n commented: { total: commented.total, listed: commented.nodes.length, items: commented.nodes.map(item).sort(byRecent) },\n unreadable: issuesUnreadable,\n },\n ...Object.fromEntries(repoFiles),\n },\n };\n}\n\nexport async function writeAll(dir, files) {\n for (const [name, doc] of Object.entries(files)) {\n const path = join(dir, name);\n await mkdir(dirname(path), { recursive: true });\n await writeFile(path, JSON.stringify(doc, null, 1) + \"\\n\");\n }\n}\n\nif (import.meta.url === pathToFileURL(process.argv[1] ?? \"\").href) {\n const args = process.argv.slice(2);\n const dir = args.find((a) => !a.startsWith(\"--\"));\n if (args.includes(\"--quick\")) {\n if (!dir) throw new Error(\"--quick needs an output directory\");\n await writeAll(dir, { \"quick.json\": await collectQuick() });\n process.stdout.write(JSON.stringify([\"quick.json\"]) + \"\\n\");\n } else if (dir) {\n const { files } = await collectAll();\n await writeAll(dir, files);\n process.stdout.write(JSON.stringify(Object.keys(files)) + \"\\n\");\n } else {\n process.stdout.write(JSON.stringify(await collect(), null, 1) + \"\\n\");\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 [q.md...] --facts a.json|dir [...] every URL in each file is in the facts, every `fact:` reference resolves,\n// and every list item cites one or the other, except under a \"Could not tell\"\n// heading; a dir means every .json under it\n// A `fact:local/<file>#<JSON pointer>` reference cites a fact with no web permalink (the Mac's LLM usage): the path is relative to a\n// --facts directory, and the pointer must name a value in it, e.g. fact:local/usage-codex.json#/usage/codex/models/0. Every other fact\n// has a URL, so a `fact:` path outside local/ and interview/ fails: one service then always counts as one source.\n// `fact:interview/<name>` cites the user's own answer, saved under a `## <name>` heading in interview.md in a --facts directory\n// (the run saves their pick from the handoff `ask` as `## handoff`).\n// --read read.md as --profile, and every `###` trait under \"## How you work\" cites at least two different\n// sources (Slack, GitHub, Notion, v0, the Mac's files, the user's answers), and its\n// \"## Who you are\" and \"## How you work\" sections are a valid profile (profile.mjs)\n// --human memory/human.md the file is a valid profile (profile.mjs), the format memory/human.md is recalled in\n// --phases phases.log the run's step lines are all there and in order: start, quick, first, facts, slices,\n// areas, read, critique, write, pr, post; finding1 to finding4, each optional, come in\n// order after first and before read\n// --finding f.md as --profile, and the file is one `- ` list item of exactly two sentences (the fact and why\n// it matters, then the next step), each citing a URL or `fact:` reference; whether the\n// reason and the step are good is the writer's judgment, not checked here\n// --post post.md the first bubble (everything above the first `---` line) is at most two sentences, and\n// below it are the \"Your team\" table (| Bot | Change | Why |) and the Schedule table\n// (| Schedule | Bot | When | What it reports |), with a `workspace-review` row for `main`\n// --critique critique.md critique.md exists and holds one `keep`, `cut` or `rewrite` line or more; a presence\n// check only, it cannot prove the text is the critique's verbatim output\n// --schedules dir [--read r.md] [--post p.md] every `<bot>/<id>.md` draft has a five-field `cron` and a prompt. A draft runs every\n// time its cron matches, so none says it runs once; a draft whose cron pins a date (day\n// and month) or that deletes its own file says it \"recurs until\" that deletion is \"merged\n// and deployed\", and so does a line naming it in the read and the post\n// Every --facts file is also checked against its own `returned` counts: an object's `returned: { <key>: n }` says its <key> list had n\n// items when Kit returned it. A slice retypes Kit's output into facts/slack/ and facts/kit/, so a list shorter than its `returned`\n// means items were dropped while copying, and in those two directories a list of objects with no `returned` count fails too.\n// A URL counts as in the facts when it is a whole string value or appears inside one (a link in a message's text).\n// Exits 1 and prints one line per problem.\nimport { existsSync, readdirSync, readFileSync, statSync } from \"node:fs\";\nimport { join, sep } from \"node:path\";\nimport { pathToFileURL } from \"node:url\";\nimport { profileProblems, profileSections } from \"./profile.mjs\";\n\nconst URL_RE = /https?:\\/\\/[^\\s)>\\]\"'`|]+/g;\nconst FACT_RE = /\\bfact:[^\\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?:\\/\\/\\S+$/.test(v)) urls.add(v); for (const u of v.match(URL_RE) ?? []) urls.add(trimUrl(u)); }\n else if (v && typeof v === \"object\") Object.values(v).forEach(walk);\n };\n facts.forEach(walk);\n return urls;\n}\n\nconst unescape = (t) => decodeURIComponent(t).replaceAll(\"~1\", \"/\").replaceAll(\"~0\", \"~\");\nexport function resolveFact(ref, files) {\n const [file, pointer = \"\"] = ref.slice(\"fact:\".length).split(\"#\");\n if (!/^(local|interview)\\//.test(file)) return { problem: `${ref}: only fact:local/... and fact:interview/<name> can be cited; cite the URL instead` };\n if (!Object.hasOwn(files, file)) return { problem: `${ref} names no facts file` };\n let v = files[file];\n for (const t of pointer.split(\"/\").slice(1).map(unescape)) {\n if (v === null || typeof v !== \"object\" || !Object.hasOwn(v, t)) return { problem: `${ref} is not in the facts` };\n v = v[t];\n }\n return { value: v };\n}\n\n// `facts` is a list of parsed facts documents; `files` maps each one's path, as `fact:` references name it, to the same document.\nexport function citationProblems(profile, facts, files = {}) {\n const known = factUrls(facts);\n const problems = [];\n let gaps = false;\n profile.split(\"\\n\").forEach((line, i) => {\n if (/^#{1,6}\\s/.test(line)) gaps = /could not tell/i.test(line);\n const urls = (line.match(URL_RE) ?? []).map(trimUrl);\n const refs = (line.match(FACT_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 for (const r of refs) { const { problem } = resolveFact(r, files); if (problem) problems.push(`line ${i + 1}: ${problem}`); }\n if (!gaps && /^\\s*(?:[-*]|\\d+\\.)\\s/.test(line) && !urls.length && !refs.length) problems.push(`line ${i + 1}: list item cites no permalink: ${line.trim().slice(0, 60)}`);\n });\n return problems;\n}\n\n// interview.md -> { \"interview/handoff\": answer, ... }; a heading with an empty answer is not a fact.\nexport function interviewFacts(text) {\n const out = {};\n for (const m of text.matchAll(/^##[ \\t]+([\\w-]+)[ \\t]*\\n([\\s\\S]*?)(?=^##[ \\t]|(?![\\s\\S]))/gm)) {\n const answer = m[2].trim();\n if (answer) out[`interview/${m[1].toLowerCase()}`] = answer;\n }\n return out;\n}\n\nconst HOST_SOURCES = [[/(^|\\.)slack\\.com$/, \"Slack\"], [/(^|\\.)github\\.com$/, \"GitHub\"], [/(^|\\.)notion\\.(so|site|com)$/, \"Notion\"], [/(^|\\.)v0\\.(dev|app)$/, \"v0\"]];\nexport function sourceOf(citation) {\n if (citation.startsWith(\"fact:\")) {\n const file = citation.slice(\"fact:\".length).split(\"#\")[0];\n return file.startsWith(\"interview/\") ? \"the user's answers\" : file.startsWith(\"local/\") ? \"the Mac's files\" : citation;\n }\n let host;\n try { host = new URL(citation).hostname; } catch { return citation; }\n return HOST_SOURCES.find(([re]) => re.test(host))?.[1] ?? host;\n}\n\nexport function traitProblems(read) {\n const lines = read.split(\"\\n\");\n const start = lines.findIndex((l) => /^##\\s+how you work\\b/i.test(l));\n if (start < 0) return ['read has no \"## How you work\" section'];\n const traits = [];\n for (const line of lines.slice(start + 1)) {\n if (/^#{1,2}\\s/.test(line)) break;\n if (/^###\\s/.test(line)) traits.push({ name: line.replace(/^###\\s+/, \"\").trim(), sources: new Set() });\n else if (traits.length) for (const c of [...(line.match(URL_RE) ?? []), ...(line.match(FACT_RE) ?? [])].map(trimUrl)) traits.at(-1).sources.add(sourceOf(c));\n }\n if (!traits.length) return ['\"## How you work\" has no ### traits'];\n return traits.filter((t) => t.sources.size < 2).map((t) => `trait \"${t.name}\" cites ${t.sources.size ? [...t.sources].join(\", \") : \"no source\"}; a trait needs two different sources`);\n}\n\nconst RETYPED = [\"slack/\", \"kit/\"];\n\nexport function returnedProblems(doc, file) {\n const problems = [];\n const retyped = RETYPED.some((d) => file.startsWith(d) || file.includes(`/${d}`));\n const walk = (v, at) => {\n if (Array.isArray(v)) { v.forEach((x, i) => walk(x, `${at}/${i}`)); return; }\n if (!v || typeof v !== \"object\") return;\n const returned = v.returned && typeof v.returned === \"object\" && !Array.isArray(v.returned) ? v.returned : {};\n for (const [key, n] of Object.entries(returned)) {\n const got = Array.isArray(v[key]) ? v[key].length : null;\n if (got !== n) problems.push(`${file}: ${at}/${key} has ${got === null ? \"no list\" : got} items, but Kit returned ${n}`);\n }\n for (const [key, x] of Object.entries(v)) {\n if (retyped && key !== \"returned\" && Array.isArray(x) && x.some((y) => y && typeof y === \"object\") && !Object.hasOwn(returned, key)) problems.push(`${file}: ${at}/${key} is a list with no returned count`);\n walk(x, `${at}/${key}`);\n }\n };\n walk(doc, \"\");\n return problems;\n}\n\nconst PHASES = [\"start\", \"quick\", \"first\", \"facts\", \"slices\", \"areas\", \"read\", \"critique\", \"write\", \"pr\", \"post\"];\nconst FINDINGS = [\"finding1\", \"finding2\", \"finding3\", \"finding4\"];\n\nexport function phaseProblems(log) {\n const names = log.split(\"\\n\").map((l) => l.trim().split(/\\s+/)[0]).filter(Boolean);\n const problems = [];\n for (const n of names) if (!PHASES.includes(n) && !FINDINGS.includes(n)) problems.push(`phases.log: unknown line \"${n}\"`);\n for (const n of new Set(names)) if (names.indexOf(n) !== names.lastIndexOf(n)) problems.push(`phases.log: \"${n}\" is logged more than once`);\n for (const n of PHASES) if (!names.includes(n)) problems.push(`phases.log: no \"${n}\" line`);\n const steps = names.filter((n) => PHASES.includes(n));\n const order = PHASES.filter((n) => steps.includes(n));\n if (steps.join() !== order.join()) problems.push(`phases.log: steps are logged as ${steps.join(\", \")}; the order is ${order.join(\", \")}`);\n const found = names.filter((n) => FINDINGS.includes(n));\n if (found.join() !== FINDINGS.slice(0, found.length).join()) problems.push(`phases.log: findings are logged as ${found.join(\", \")}; they count up from finding1`);\n const at = (n) => names.indexOf(n);\n for (const f of found) if ((at(\"first\") >= 0 && at(f) < at(\"first\")) || (at(\"read\") >= 0 && at(f) > at(\"read\"))) problems.push(`phases.log: ${f} is logged outside first..read`);\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, URLs and common abbreviations are blanked first, keeping\n// every offset, so their dots don't count; `etc.` before a capital still ends its sentence.\nconst SENTENCE_END = /(?<=[.!?][*_~\"'”’)]*)\\s+/;\nfunction proseMask(text) {\n const blank = (m, c = \"x\") => c.repeat(m.length);\n return text\n .replace(/```[\\s\\S]*?```/g, (m) => m.replace(/[^\\n]/g, \" \"))\n .replace(/`[^`]*`/g, (m) => blank(m))\n .replace(/\\]\\([^)]*\\)/g, (m) => `]${blank(m.slice(1), \" \")}`)\n .replace(URL_RE, (m) => blank(trimUrl(m)) + m.slice(trimUrl(m).length))\n .replace(/\\b(e\\.g|i\\.e|vs)\\./gi, (m) => blank(m))\n .replace(/\\b([Ee]tc)\\.(?!\\s+[A-Z])/g, (m) => blank(m));\n}\n\nexport function sentenceCount(text) {\n return proseMask(text).split(\"\\n\").flatMap((line) => line.split(SENTENCE_END)).filter((s) => /\\w/.test(s)).length;\n}\n\n// A finding: one list item, two sentences (the fact and why it matters, then the next step), each with a citation.\nexport function findingProblems(text) {\n const items = text.split(\"\\n\").filter((l) => l.trim());\n if (items.length !== 1 || !/^\\s*-\\s/.test(items[0])) return [`a finding is one \\`- \\` list item; this file has ${items.length} non-empty lines`];\n const body = items[0].replace(/^\\s*-\\s+/, \"\");\n const n = sentenceCount(body);\n if (n !== 2) return [`the finding has ${n} sentence${n === 1 ? \"\" : \"s\"}; write two: the fact and why it matters, then the next step`];\n const cut = proseMask(body).search(SENTENCE_END);\n const halves = [body.slice(0, cut), body.slice(cut)];\n const cites = (t) => (t.match(URL_RE) ?? []).length + (t.match(FACT_RE) ?? []).length;\n return halves.flatMap((h, i) => (cites(h) ? [] : [`the finding's ${i ? \"second sentence (the next step)\" : \"first sentence (the fact)\"} cites no URL or fact: reference`]));\n}\n\nconst POST_TABLES = [[\"Your team\", [\"Bot\", \"Change\", \"Why\"]], [\"Schedule\", [\"Schedule\", \"Bot\", \"When\", \"What it reports\"]]];\nconst cells = (line) => line.trim().replace(/^\\|/, \"\").replace(/\\|$/, \"\").split(\"|\").map((c) => c.trim());\n\n// The header row of every markdown table in `text`: a `|` row followed by a `|---|` separator row.\nexport function tableHeaders(text) {\n const lines = text.split(\"\\n\");\n return lines.flatMap((l, i) => (/^\\s*\\|.*\\|\\s*$/.test(l) && /^\\s*\\|[\\s:|-]*-[\\s:|-]*\\|\\s*$/.test(lines[i + 1] ?? \"\") ? [cells(l)] : []));\n}\n\nexport function postProblems(post) {\n const bubble = firstBubble(post);\n if (!bubble) return [\"the post has no first bubble\"];\n const problems = [];\n const n = sentenceCount(bubble);\n if (n > MAX_SENTENCES) problems.push(`the first bubble has ${n} sentences; keep it to ${MAX_SENTENCES} and move the rest below a --- line`);\n const headers = tableHeaders(post.slice(post.indexOf(bubble) + bubble.length));\n for (const [name, columns] of POST_TABLES) {\n if (!headers.some((h) => h.join(\"|\") === columns.join(\"|\"))) problems.push(`below the first bubble, the post has no ${name} table with columns | ${columns.join(\" | \")} |`);\n }\n const below = post.slice(post.indexOf(bubble) + bubble.length).split(\"\\n\");\n const start = below.findIndex((l, i) => /^\\s*\\|/.test(l) && cells(l).join(\"|\") === POST_TABLES[1][1].join(\"|\") && /^\\s*\\|[\\s:|-]*-/.test(below[i + 1] ?? \"\"));\n if (start >= 0) {\n const rows = [];\n for (let i = start + 2; i < below.length && /^\\s*\\|.*\\|\\s*$/.test(below[i]); i++) rows.push(cells(below[i]));\n if (!rows.some(([schedule, bot]) => schedule.replace(/`/g, \"\") === \"workspace-review\" && bot.replace(/`/g, \"\") === \"main\")) problems.push(\"the Schedule table has no `workspace-review` row for `main`; every onboarding offers it\");\n }\n return problems;\n}\n\nexport function critiqueProblems(text) {\n return /^\\s*[-*]?\\s*`?(keep|cut|rewrite)\\b/im.test(text) ? [] : [\"critique.md has no `keep`, `cut` or `rewrite` line; save the critique copy's output verbatim\"];\n}\n\nconst ONE_TIME = /\\bone[- ]time\\b|\\bsingle run\\b|\\bruns? (just )?once\\b|\\bonce only\\b/i;\nconst RECURS = /\\brecurs until\\b[^\\n]*\\bmerged and deployed\\b/i;\nconst SELF_DELETE = /\\bdelet\\w*\\b[^.\\n]*\\b(its own|this) (schedule )?file\\b/i;\n\n// drafts: [[\"<bot>/<id>.md\", text]]; texts: [[name, text]] of the read and the post, whose lines naming a draft are checked too.\nexport function scheduleProblems(drafts, texts = []) {\n const problems = [];\n for (const [file, text] of drafts) {\n const m = /^(\\w[\\w-]*)\\/(\\w[\\w-]*)\\.md$/.exec(file);\n if (!m) { problems.push(`${file}: a draft is schedules/<bot>/<id>.md`); continue; }\n const id = m[2];\n const fm = /^---\\n([\\s\\S]*?)\\n---\\n([\\s\\S]*)$/.exec(text);\n const cron = fm && /^cron:\\s*\"([^\"]*)\"\\s*$/m.exec(fm[1]);\n const fields = cron ? cron[1].trim().split(/\\s+/) : [];\n if (fields.length !== 5) { problems.push(`${file}: front matter needs cron: \"<five fields>\"`); continue; }\n const body = fm[2];\n if (!body.trim()) problems.push(`${file}: the draft has no prompt`);\n if (ONE_TIME.test(body)) problems.push(`${file}: a schedule runs every time its cron matches, so it never runs once; for one run arm schedule__wake__create instead`);\n if (!(/^\\d+$/.test(fields[2]) && /^\\d+$/.test(fields[3])) && !SELF_DELETE.test(body)) continue;\n if (!RECURS.test(body)) problems.push(`${file}: its cron recurs yearly or it deletes its own file, so its prompt says it recurs until that deletion is merged and deployed`);\n for (const [name, t] of texts) {\n const lines = t.split(\"\\n\").filter((l) => l.includes(id));\n if (lines.some((l) => ONE_TIME.test(l))) problems.push(`${name}: calls ${id} one-time, but it recurs until its deletion is merged and deployed`);\n if (!lines.some((l) => RECURS.test(l))) problems.push(`${name}: no line naming ${id} says it recurs until its deletion is merged and deployed`);\n }\n }\n return problems;\n}\n\n// Each .json under a directory, keyed by its path relative to that directory; a file, keyed by the path as given.\nexport const jsonFiles = (path) => (statSync(path).isDirectory()\n ? readdirSync(path, { recursive: true }).filter((f) => f.endsWith(\".json\")).sort().map((f) => [f.split(sep).join(\"/\"), join(path, f)])\n : [[path, path]]);\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 reads = after(\"--read\");\n const findings = after(\"--finding\");\n const profiles = [...reads, ...after(\"--profile\"), ...findings];\n if (profiles.length) {\n const factPaths = after(\"--facts\");\n const docs = factPaths.flatMap(jsonFiles).map(([key, f]) => [key, JSON.parse(readFileSync(f, \"utf8\"))]);\n const interview = factPaths.map((d) => join(d, \"interview.md\")).filter((f) => existsSync(f) && statSync(f).isFile()).map((f) => interviewFacts(readFileSync(f, \"utf8\")));\n const files = Object.assign(Object.fromEntries(docs), ...interview);\n for (const [key, doc] of docs) problems.push(...returnedProblems(doc, key));\n const named = (p, ms) => ms.map((m) => (profiles.length > 1 ? `${p}: ${m}` : m));\n for (const p of profiles) {\n const text = readFileSync(p, \"utf8\");\n problems.push(...named(p, citationProblems(text, docs.map(([, d]) => d), files)));\n if (reads.includes(p)) problems.push(...named(p, [...traitProblems(text), ...profileProblems(profileSections(text))]));\n if (findings.includes(p)) problems.push(...named(p, findingProblems(text)));\n }\n }\n for (const h of after(\"--human\")) problems.push(...profileProblems(readFileSync(h, \"utf8\")).map((m) => `${h}: ${m}`));\n const [phases] = after(\"--phases\");\n if (phases) problems.push(...phaseProblems(readFileSync(phases, \"utf8\")));\n const [post] = after(\"--post\");\n if (post) problems.push(...postProblems(readFileSync(post, \"utf8\")));\n const [critique] = after(\"--critique\");\n if (critique) problems.push(...(existsSync(critique) ? critiqueProblems(readFileSync(critique, \"utf8\")) : [`${critique} is missing; save the critique copy's output verbatim`]));\n const [schedules] = after(\"--schedules\");\n if (schedules) {\n const drafts = readdirSync(schedules, { recursive: true }).map((f) => f.split(sep).join(\"/\")).filter((f) => f.endsWith(\".md\")).sort().map((f) => [f, readFileSync(join(schedules, f), \"utf8\")]);\n problems.push(...scheduleProblems(drafts, [...reads, ...(post ? [post] : [])].map((f) => [f, readFileSync(f, \"utf8\")])));\n }\n problems.forEach((p) => console.log(p));\n process.exit(problems.length ? 1 : 0);\n}\n","profile.mjs":"// The human's profile: the one format for <workspace>/memory/human.md. memory.mjs\n// validates and recalls it; onboarding's check-report.mjs checks its read.md and\n// the first human.md against it, and the workspace-review skill checks each\n// week's rewrite. This file has no imports, because the onboarding tool copies it\n// beside check-report.mjs into the sandbox.\n//\n// ## Who you are two sentences\n// ## How you work 4 to 6 `###` traits: the claim, at least two `- `\n// evidence items, an `Against:` line, a `Drives:` line\n// ## What you worked on optional; the workspace review adds it\n//\n// Every bot recalls the whole file on every turn, so it is capped at one page.\n\nexport const PROFILE_FILE = \"human.md\";\nexport const PROFILE_SECTIONS = [\"Who you are\", \"How you work\", \"What you worked on\"];\nconst REQUIRED = PROFILE_SECTIONS.slice(0, 2);\nexport const MIN_TRAITS = 4;\nexport const MAX_TRAITS = 6;\nexport const MAX_PROFILE_LINES = 70;\n\nconst heading = (line) => /^##\\s+(.+?)\\s*$/.exec(line)?.[1];\n\n// The `##` sections of a markdown document, in order: [[title, lines]].\nfunction sections(text) {\n const out = [];\n for (const line of text.split(/\\r?\\n/)) {\n const title = heading(line);\n if (title !== undefined) out.push([title, []]);\n else if (out.length) out.at(-1)[1].push(line);\n }\n return out;\n}\n\n// The profile sections of a longer document (onboarding's read.md), as the text\n// of a human.md.\nexport function profileSections(text) {\n return sections(text)\n .filter(([title]) => PROFILE_SECTIONS.includes(title))\n .map(([title, lines]) => [`## ${title}`, ...lines].join(\"\\n\").trimEnd())\n .join(\"\\n\\n\") + \"\\n\";\n}\n\n// One line per way `text` is not a valid profile; [] when it is.\nexport function profileProblems(text) {\n const problems = [];\n const lines = text.split(/\\r?\\n/).filter((l) => l.trim());\n if (lines.length > MAX_PROFILE_LINES) problems.push(`the profile is ${lines.length} lines; keep it to ${MAX_PROFILE_LINES}`);\n const first = lines.findIndex((l) => heading(l) !== undefined);\n const preamble = lines.slice(0, first < 0 ? lines.length : first);\n if (preamble.length > 1 || (preamble.length && !/^#\\s/.test(preamble[0]))) problems.push(`only a \"# \" title may come before \"## ${PROFILE_SECTIONS[0]}\"`);\n const found = sections(text);\n const titles = found.map(([t]) => t);\n for (const t of titles) if (!PROFILE_SECTIONS.includes(t)) problems.push(`unknown section \"## ${t}\" (expected ${PROFILE_SECTIONS.map((s) => `\"## ${s}\"`).join(\", \")})`);\n for (const t of REQUIRED) if (!titles.includes(t)) problems.push(`missing \"## ${t}\"`);\n if (new Set(titles).size !== titles.length) problems.push(\"a section appears twice\");\n const known = titles.filter((t) => PROFILE_SECTIONS.includes(t));\n if (known.some((t, i) => i && PROFILE_SECTIONS.indexOf(t) < PROFILE_SECTIONS.indexOf(known[i - 1]))) problems.push(`sections must come in the order ${PROFILE_SECTIONS.join(\", \")}`);\n for (const [title, body] of found) if (PROFILE_SECTIONS.includes(title) && !body.some((l) => l.trim())) problems.push(`\"## ${title}\" is empty`);\n const how = found.find(([t]) => t === \"How you work\");\n if (how) {\n const traits = [];\n for (const line of how[1]) {\n const name = /^###\\s+(.+?)\\s*$/.exec(line)?.[1];\n if (name) traits.push({ name, claim: false, evidence: 0, against: false, drives: false });\n else if (traits.length && line.trim()) {\n const t = traits.at(-1);\n if (/^\\s*-\\s/.test(line)) t.evidence++;\n else if (/^Against:/.test(line.trim())) t.against = true;\n else if (/^Drives:/.test(line.trim())) t.drives = true;\n else t.claim = true;\n }\n }\n if (traits.length < MIN_TRAITS || traits.length > MAX_TRAITS) problems.push(`\"## How you work\" has ${traits.length} ### traits; it needs ${MIN_TRAITS} to ${MAX_TRAITS}`);\n for (const t of traits) {\n if (!t.claim) problems.push(`trait \"${t.name}\" has no claim sentence`);\n if (t.evidence < 2) problems.push(`trait \"${t.name}\" has ${t.evidence} - evidence items; it needs at least 2`);\n if (!t.against) problems.push(`trait \"${t.name}\" has no Against: line`);\n if (!t.drives) problems.push(`trait \"${t.name}\" has no Drives: line`);\n }\n }\n return problems;\n}\n","slack-facts.callscript.js":"// Facts about the signed-in user (their Slack profile) and their 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 self = await slack.users_info({ user: me });\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 refused = [auth, self, ...ownPages, ...mentionPages].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, user: m.user, 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 lastOwn = Object.fromEntries(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}:${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, realName: (self.user.real_name ?? \"\").slice(0, 160), title: (self.user.profile?.title ?? \"\").slice(0, 160), team: (self.user.profile?.team ?? \"\").slice(0, 160), tz: self.user.tz ?? null, startDate: self.user.profile?.start_date ?? null },\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 returned: { channels: channels.slice(0, 25).length },\n channels: channels.slice(0, 25).map(c => ({ id: c.id, channel: c.channel, count: c.count, threadReplies: c.threadReplies, returned: { recent: c.recent.length }, 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 returned: { mentioners: Math.min(Object.keys(Object.groupBy(mentioned, m => m.user)).length, 15), unansweredItems: open.slice(0, 20).length },\n mentioners: Object.entries(Object.groupBy(mentioned, m => m.user)).map(e => ({ id: e[0], name: e[1][0].author, count: e[1].length })).sort((a, b) => b.count - a.count).slice(0, 15),\n unansweredItems: open.slice(0, 20).map(m => ({ url: m.url, channel: m.name, user: m.user, 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};\n","team-channel.callscript.js":"// Facts about one team channel over the last input.days (default 30): who posts, how much, and the newest 50 messages with permalinks.\n// Every list has its length in the `returned` beside it: the slice retypes this output into facts/kit/, and check-report.mjs fails a\n// file whose list is shorter than its `returned`, so this output stays small enough to copy whole.\n// input: { channel: \"<id as Kit returns it>\", days: 30 }. Never calls search_messages; a DM or group DM is refused before any history is read.\n// History is chained by cursor: each page step fans out over at most one previous page that has_more, so an empty filter ends the chain.\nconst DAYS = input.days ?? 30;\nconst SECRET = [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"];\nconst oldest = String(Math.floor((Date.now() - DAYS * 86400000) / 1000));\nconst sid = input.channel.split(\".\")[0];\nconst [auth, info] = await Promise.all([slack.auth_test(), slack.conversations_info({ channel: input.channel })]);\nif ([auth, info].some(r => r.ok === false)) {\n return { refused: { error: [auth, info].find(r => r.ok === false).error, needed: [auth, info].find(r => r.ok === false).needed ?? null, provided: [auth, info].find(r => r.ok === false).provided ?? null, calls: [auth, info].filter(r => r.ok === false).length } };\n}\nif (info.channel.is_im || info.channel.is_mpim) {\n return { refused: { error: \"dm_not_read\", needed: null, provided: null, calls: 0 } };\n}\nconst p1 = await slack.conversations_history({ channel: input.channel, limit: 200, oldest });\nconst p2 = await Promise.all([p1].filter(p => p.ok !== false && p.has_more && p.response_metadata?.next_cursor).slice(0, 1).map(p => slack.conversations_history({ channel: input.channel, limit: 200, oldest, cursor: p.response_metadata.next_cursor })));\nconst p3 = await Promise.all(p2.filter(p => p.ok !== false && p.has_more && p.response_metadata?.next_cursor).slice(0, 1).map(p => slack.conversations_history({ channel: input.channel, limit: 200, oldest, cursor: p.response_metadata.next_cursor })));\nconst p4 = await Promise.all(p3.filter(p => p.ok !== false && p.has_more && p.response_metadata?.next_cursor).slice(0, 1).map(p => slack.conversations_history({ channel: input.channel, limit: 200, oldest, cursor: p.response_metadata.next_cursor })));\nconst p5 = await Promise.all(p4.filter(p => p.ok !== false && p.has_more && p.response_metadata?.next_cursor).slice(0, 1).map(p => slack.conversations_history({ channel: input.channel, limit: 200, oldest, cursor: p.response_metadata.next_cursor })));\nconst pages = [p1, ...p2, ...p3, ...p4, ...p5];\nif (pages.some(r => r.ok === false)) {\n return { refused: { error: pages.find(r => r.ok === false).error, needed: pages.find(r => r.ok === false).needed ?? null, provided: pages.find(r => r.ok === false).provided ?? null, calls: pages.filter(r => r.ok === false).length } };\n}\nconst all = pages.flatMap(p => p.messages).filter(m => m.user && m.subtype !== \"channel_join\" && m.subtype !== \"channel_leave\");\nconst posters = Object.entries(Object.groupBy(all, m => m.user)).map(e => ({ id: e[0], count: e[1].length })).sort((a, b) => b.count - a.count);\nconst people = await Promise.all(posters.slice(0, 10).map(p => slack.users_info({ user: p.id })));\nif (people.some(r => r.ok === false)) {\n return { refused: { error: people.find(r => r.ok === false).error, needed: people.find(r => r.ok === false).needed ?? null, provided: people.find(r => r.ok === false).provided ?? null, calls: people.filter(r => r.ok === false).length } };\n}\nconst flat = all.slice(0, 50).map(m => ({ m, flat: (m.text ?? \"\").split(\"\\n\").join(\" \") }));\nreturn {\n channel: { id: input.channel, name: info.channel.name, url: `${auth.url}archives/${sid}`, purpose: (info.channel.purpose?.value ?? \"\").split(\"\\n\").join(\" \").slice(0, 160), topic: (info.channel.topic?.value ?? \"\").split(\"\\n\").join(\" \").slice(0, 160), members: info.channel.num_members ?? null },\n window: { days: DAYS, oldest },\n pagesRead: pages.length,\n more: Boolean(pages[pages.length - 1].has_more),\n scope: \"one public or private channel; DMs and group DMs are refused; bot posts without a user and join or leave events are dropped\",\n counts: { returned: { perPoster: posters.slice(0, 25).length }, messages: all.length, threads: all.filter(m => (m.reply_count ?? 0) > 0).length, replies: all.reduce((n, m) => n + (m.reply_count ?? 0), 0), posters: posters.length, perPoster: posters.slice(0, 25) },\n returned: { posters: posters.slice(0, 10).length, messages: flat.length },\n posters: posters.slice(0, 10).map((p, i) => ({ id: p.id, url: `${auth.url}team/${p.id.split(\".\")[0]}`, realName: (people[i].user?.real_name ?? \"\").slice(0, 160), title: (people[i].user?.profile?.title ?? \"\").slice(0, 160), count: p.count })),\n messages: flat.map(x => ({ url: `${auth.url}archives/${sid}/p${x.m.ts.split(\".\").join(\"\")}${x.m.thread_ts && x.m.thread_ts !== x.m.ts ? `?thread_ts=${x.m.thread_ts}` : \"\"}`, ts: x.m.ts, user: x.m.user, text: x.flat.split(\" \").some(t => t.length >= 20 && SECRET.some(p => t.includes(p))) ? \"[withheld]\" : x.flat.slice(0, 160), replyCount: x.m.reply_count ?? 0 }))\n};\n","leadership.callscript.js":"// Facts for choosing leads: recent history of up to 3 leadership or announcement channels, with permalinks, and the profiles of\n// their 20 most frequent posters as candidates (title, team, bot and deleted flags). It never searches: the leadership slice\n// judges which candidates lead from these facts and passes them to lead-posts.callscript.js.\n// input: { channels: [<=3 ids as Kit returns them], days: 90 }. DMs and group DMs are refused before any history is read.\n// With no channels it returns only the user's channel memberships (up to 400), which the slice picks the channels from.\nconst DAYS = input.days ?? 90;\nconst SECRET = [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"];\nconst oldest = String(Math.floor((Date.now() - DAYS * 86400000) / 1000));\nconst channels = (input.channels ?? []).slice(0, 3);\nconst auth = await slack.auth_test();\nif (channels.length === 0) {\n const m1 = await slack.users_conversations({ types: \"public_channel,private_channel\", limit: 200, exclude_archived: true });\n const m2 = await Promise.all([m1].filter(p => p.ok !== false && p.response_metadata?.next_cursor).slice(0, 1).map(p => slack.users_conversations({ types: \"public_channel,private_channel\", limit: 200, exclude_archived: true, cursor: p.response_metadata.next_cursor })));\n return [auth, m1, ...m2].some(r => r.ok === false)\n ? { refused: { error: [auth, m1, ...m2].find(r => r.ok === false).error, needed: [auth, m1, ...m2].find(r => r.ok === false).needed ?? null, provided: [auth, m1, ...m2].find(r => r.ok === false).provided ?? null, calls: [auth, m1, ...m2].filter(r => r.ok === false).length } }\n : { me: { id: auth.user_id, name: auth.user }, scope: \"channel memberships only; nothing is read from them\", more: Boolean([m1, ...m2][m2.length].response_metadata?.next_cursor), returned: { memberships: [m1, ...m2].flatMap(p => p.channels ?? []).length }, memberships: [m1, ...m2].flatMap(p => p.channels ?? []).map(c => ({ id: c.id, name: c.name })).sort((a, b) => a.name.localeCompare(b.name)) };\n}\nconst infos = await Promise.all(channels.slice(0, 3).map(c => slack.conversations_info({ channel: c })));\nif ([auth, ...infos].some(r => r.ok === false)) {\n return { refused: { error: [auth, ...infos].find(r => r.ok === false).error, needed: [auth, ...infos].find(r => r.ok === false).needed ?? null, provided: [auth, ...infos].find(r => r.ok === false).provided ?? null, calls: [auth, ...infos].filter(r => r.ok === false).length } };\n}\nif (infos.some(i => i.channel.is_im || i.channel.is_mpim)) {\n return { refused: { error: \"dm_not_read\", needed: null, provided: null, calls: 0 } };\n}\nconst h1 = await Promise.all(channels.slice(0, 3).map(c => slack.conversations_history({ channel: c, limit: 200, oldest })));\nconst next = h1.map((p, i) => ({ channel: channels[i], cursor: p.response_metadata?.next_cursor, more: p.ok !== false && p.has_more })).filter(x => x.more && x.cursor);\nconst h2 = await Promise.all(next.slice(0, 3).map(x => slack.conversations_history({ channel: x.channel, limit: 200, oldest, cursor: x.cursor })));\nif ([...h1, ...h2].some(r => r.ok === false)) {\n return { refused: { error: [...h1, ...h2].find(r => r.ok === false).error, needed: [...h1, ...h2].find(r => r.ok === false).needed ?? null, provided: [...h1, ...h2].find(r => r.ok === false).provided ?? null, calls: [...h1, ...h2].filter(r => r.ok === false).length } };\n}\nconst perChannel = channels.map((c, i) => ({ id: c, info: infos[i].channel, pages: [h1[i], ...h2.filter((p, j) => next[j].channel === c)] }));\nconst posts = perChannel.flatMap(c => c.pages.flatMap(p => p.messages).filter(m => m.user && m.subtype !== \"channel_join\" && m.subtype !== \"channel_leave\").map(m => ({ m, channel: c.id })));\nconst ranked = Object.entries(Object.groupBy(posts, p => p.m.user)).filter(e => e[0] !== auth.user_id).sort((a, b) => b[1].length - a[1].length).slice(0, 20);\nconst people = await Promise.all(ranked.slice(0, 20).map(e => slack.users_info({ user: e[0] })));\nif (people.some(r => r.ok === false)) {\n return { refused: { error: people.find(r => r.ok === false).error, needed: people.find(r => r.ok === false).needed ?? null, provided: people.find(r => r.ok === false).provided ?? null, calls: people.filter(r => r.ok === false).length } };\n}\nreturn {\n me: { id: auth.user_id, name: auth.user },\n window: { days: DAYS, oldest },\n scope: \"listed public or private channels only; DMs and group DMs are refused; no search\",\n searches: 0,\n returned: { channels: perChannel.length, candidates: ranked.length },\n channels: perChannel.map(c => ({\n id: c.id,\n name: c.info.name,\n url: `${auth.url}archives/${c.id.split(\".\")[0]}`,\n purpose: (c.info.purpose?.value ?? \"\").split(\"\\n\").join(\" \").slice(0, 160),\n pagesRead: c.pages.length,\n more: Boolean(c.pages[c.pages.length - 1].has_more),\n count: c.pages.flatMap(p => p.messages).filter(m => m.user).length,\n returned: { messages: c.pages.flatMap(p => p.messages).filter(m => m.user && m.subtype !== \"channel_join\" && m.subtype !== \"channel_leave\").slice(0, 20).length },\n messages: c.pages.flatMap(p => p.messages).filter(m => m.user && m.subtype !== \"channel_join\" && m.subtype !== \"channel_leave\").slice(0, 20).map(m => ({ m, flat: (m.text ?? \"\").split(\"\\n\").join(\" \") })).map(x => ({ url: `${auth.url}archives/${c.id.split(\".\")[0]}/p${x.m.ts.split(\".\").join(\"\")}${x.m.thread_ts && x.m.thread_ts !== x.m.ts ? `?thread_ts=${x.m.thread_ts}` : \"\"}`, ts: x.m.ts, user: x.m.user, text: x.flat.split(\" \").some(t => t.length >= 20 && SECRET.some(p => t.includes(p))) ? \"[withheld]\" : x.flat.slice(0, 160), replyCount: x.m.reply_count ?? 0 }))\n })),\n candidates: ranked.map((e, i) => ({\n id: e[0],\n url: `${auth.url}team/${e[0].split(\".\")[0]}`,\n realName: (people[i].user?.real_name ?? \"\").slice(0, 160),\n title: (people[i].user?.profile?.title ?? \"\").slice(0, 160),\n team: (people[i].user?.profile?.team ?? \"\").slice(0, 160),\n isBot: Boolean(people[i].user?.is_bot) || e[1].some(p => Boolean(p.m.bot_id)),\n deleted: Boolean(people[i].user?.deleted),\n posts: e[1].length,\n channels: e[1].map(p => p.channel).filter((c, j, all) => all.indexOf(c) === j)\n }))\n};\n","lead-posts.callscript.js":"// Facts about what the chosen leads say: each lead's profile and recent channel posts, with permalinks.\n// input: { leads: [<=10 user ids the leadership slice chose], days: 90 }. One search per lead, every one -is:dm, 10 at most.\n// Slack allows about 20 searches a minute: start it at least 60 seconds after the base script, and on ratelimited wait a minute and rerun.\nconst DAYS = input.days ?? 90;\nconst SECRET = [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"];\nconst after = new Date(Date.now() - (DAYS + 1) * 86400000).slice(0, 10);\nconst auth = await slack.auth_test();\nif (auth.ok === false) {\n return { refused: { error: auth.error, needed: auth.needed ?? null, provided: auth.provided ?? null, calls: 1 } };\n}\nconst leads = (input.leads ?? []).filter((u, i, all) => u !== auth.user_id && all.indexOf(u) === i).slice(0, 10);\nconst people = await Promise.all(leads.slice(0, 10).map(u => slack.users_info({ user: u })));\nconst found = await Promise.all(leads.slice(0, 10).map(u => slack.search_messages({ query: `from:<@${u}> after:${after} -is:dm`, count: 6, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst answers = [...people, ...found];\nif (answers.some(r => r.ok === false)) {\n return { refused: { error: answers.find(r => r.ok === false).error, needed: answers.find(r => r.ok === false).needed ?? null, provided: answers.find(r => r.ok === false).provided ?? null, calls: answers.filter(r => r.ok === false).length } };\n}\nreturn {\n me: { id: auth.user_id, name: auth.user },\n window: { days: DAYS, after },\n scope: \"each lead's channel posts; DMs and group DMs are excluded with -is:dm\",\n searches: found.length,\n returned: { leads: leads.length },\n leads: leads.map((u, i) => ({\n id: u,\n url: `${auth.url}team/${u.split(\".\")[0]}`,\n realName: (people[i].user?.real_name ?? \"\").slice(0, 160),\n title: (people[i].user?.profile?.title ?? \"\").slice(0, 160),\n team: (people[i].user?.profile?.team ?? \"\").slice(0, 160),\n tz: people[i].user?.tz ?? null,\n isBot: Boolean(people[i].user?.is_bot),\n deleted: Boolean(people[i].user?.deleted),\n searchTotal: found[i].messages.paging?.total ?? found[i].messages.matches.length,\n returned: { messages: found[i].messages.matches.filter(m => !m.channel.is_im && !m.channel.is_mpim).length },\n messages: found[i].messages.matches.filter(m => !m.channel.is_im && !m.channel.is_mpim).map(m => ({ m, flat: (m.text ?? \"\").split(\"\\n\").join(\" \") })).map(x => ({ url: x.m.permalink, ts: x.m.ts, channel: x.m.channel.id, name: x.m.channel.name, text: x.flat.split(\" \").some(t => t.length >= 20 && SECRET.some(p => t.includes(p))) ? \"[withheld]\" : x.flat.slice(0, 160) }))\n }))\n};\n","people.callscript.js":"// Profiles of up to 15 Slack users: name, title, team, time zone and start date, with a profile link. Never calls search_messages.\n// input: { users: [<=15 user ids] }.\nconst users = (input.users ?? []).slice(0, 15);\nconst auth = await slack.auth_test();\nconst people = await Promise.all(users.slice(0, 15).map(u => slack.users_info({ user: u })));\nif ([auth, ...people].some(r => r.ok === false)) {\n return { refused: { error: [auth, ...people].find(r => r.ok === false).error, needed: [auth, ...people].find(r => r.ok === false).needed ?? null, provided: [auth, ...people].find(r => r.ok === false).provided ?? null, calls: [auth, ...people].filter(r => r.ok === false).length } };\n}\nreturn {\n returned: { people: users.length },\n people: users.map((u, i) => ({\n id: u,\n url: `${auth.url}team/${u.split(\".\")[0]}`,\n name: (people[i].user.name ?? \"\").slice(0, 160),\n realName: (people[i].user.real_name ?? \"\").slice(0, 160),\n title: (people[i].user.profile?.title ?? \"\").slice(0, 160),\n team: (people[i].user.profile?.team ?? \"\").slice(0, 160),\n tz: people[i].user.tz ?? null,\n startDate: people[i].user.profile?.start_date ?? null,\n isBot: Boolean(people[i].user.is_bot),\n deleted: Boolean(people[i].user.deleted)\n }))\n};\n","notion.callscript.js":"// Notion pages (and Vercel knowledge base results) for up to 5 queries, then the opening text of up to 5 distinct top pages, as <=200-char snippets.\n// input: { queries: [<=5 strings] }. Every result carries its url; page text is tag-stripped, cut to 1500 chars and split into 8 snippets of 200.\nconst SECRET = [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"];\nconst queries = (input.queries ?? []).slice(0, 5);\nconst found = await Promise.all(queries.slice(0, 5).map(q => notion.notion_search({ query: q, page_size: 5, query_type: \"internal\" })));\nconst known = await Promise.all(queries.slice(0, 5).map(q => vercel_knowledge.search({ query: q, numResults: 5 })));\nconst pages = found.flatMap(r => r.results ?? []).filter(r => r.type === \"page\");\nconst top = pages.filter((p, i, a) => a.findIndex(x => x.id === p.id) === i).slice(0, 5);\nconst fetched = await Promise.all(top.slice(0, 5).map(p => notion.notion_fetch({ id: p.id })));\nconst bodies = fetched.map(f => ({ f, body: (f.text ?? \"\").split(\"<content>\").slice(-1)[0].split(\"</content>\")[0].split(\"<\").map((s, i) => i === 0 ? s : s.slice(s.indexOf(\">\") + 1)).join(\" \").split(\"\\n\").join(\" \").split(\" \").filter(w => w.length > 0).join(\" \").slice(0, 1500) }));\nreturn {\n returned: { queries: queries.length, pages: bodies.length },\n queries: queries.map((q, i) => ({\n query: q.slice(0, 160),\n returned: { notion: (found[i].results ?? []).length, knowledge: (known[i].results ?? []).length },\n notion: (found[i].results ?? []).map(r => ({ r, flat: (r.highlight ?? \"\").split(\"\\n\").join(\" \") })).map(x => ({ title: (x.r.title ?? \"\").slice(0, 160), url: x.r.url, type: x.r.type, path: (x.r.path ?? \"\").slice(0, 160), lastEdited: x.r.timestamp ?? null, verified: x.r.verification?.state === \"verified\", highlight: x.flat.split(\" \").some(t => t.length >= 20 && SECRET.some(p => t.includes(p))) ? \"[withheld]\" : x.flat.slice(0, 160) })),\n knowledge: (known[i].results ?? []).map(r => ({ r, flat: (r.highlights ?? []).join(\" \").split(\"\\n\").join(\" \") })).map(x => ({ title: (x.r.title ?? \"\").slice(0, 160), url: x.r.url, highlight: x.flat.split(\" \").some(t => t.length >= 20 && SECRET.some(p => t.includes(p))) ? \"[withheld]\" : x.flat.slice(0, 160) }))\n })),\n pages: bodies.map((b, i) => ({\n id: top[i].id,\n title: (b.f.title ?? top[i].title ?? \"\").slice(0, 160),\n url: b.f.url ?? top[i].url,\n lastEdited: b.f.page_last_edited_at ?? top[i].timestamp ?? null,\n chars: b.body.length,\n returned: { snippets: [0, 1, 2, 3, 4, 5, 6, 7].map(n => b.body.slice(n * 200, n * 200 + 200)).filter(s => s.length > 0).length },\n snippets: [0, 1, 2, 3, 4, 5, 6, 7].map(n => b.body.slice(n * 200, n * 200 + 200)).filter(s => s.length > 0).map(s => s.split(\" \").some(t => t.length >= 20 && SECRET.some(p => t.includes(p))) ? \"[withheld]\" : s)\n }))\n};\n","v0.callscript.js":"// The user's v0 activity over the last input.days (default 180): usage events and cost per model and per week, and chats per week with their urls.\n// input: { days: 180 }. A v0 call that fails returns { refused } naming the call, never zero usage.\nconst DAYS = input.days ?? 180;\nconst startDate = new Date(Date.now() - DAYS * 86400000);\nconst endDate = new Date(Date.now());\nconst startMs = Date.now() - DAYS * 86400000;\ntry { const u1 = await v0.reports_getUsage({ startDate, endDate, limit: 100 }); } catch (e) { return { refused: { error: `reports_getUsage: ${e.message}`.slice(0, 200), code: e.code ?? null } }; }\nconst u2 = await Promise.all([u1].filter(p => p.pagination?.hasMore && p.pagination?.nextCursor).slice(0, 1).map(p => v0.reports_getUsage({ startDate, endDate, limit: 100, cursor: p.pagination.nextCursor })));\nconst u3 = await Promise.all(u2.filter(p => p.pagination?.hasMore && p.pagination?.nextCursor).slice(0, 1).map(p => v0.reports_getUsage({ startDate, endDate, limit: 100, cursor: p.pagination.nextCursor })));\nconst u4 = await Promise.all(u3.filter(p => p.pagination?.hasMore && p.pagination?.nextCursor).slice(0, 1).map(p => v0.reports_getUsage({ startDate, endDate, limit: 100, cursor: p.pagination.nextCursor })));\nconst u5 = await Promise.all(u4.filter(p => p.pagination?.hasMore && p.pagination?.nextCursor).slice(0, 1).map(p => v0.reports_getUsage({ startDate, endDate, limit: 100, cursor: p.pagination.nextCursor })));\ntry { const chats = await v0.chats_find({ limit: 100 }); } catch (e) { return { refused: { error: `chats_find: ${e.message}`.slice(0, 200), code: e.code ?? null } }; }\nconst usagePages = [u1, ...u2, ...u3, ...u4, ...u5];\nconst events = usagePages.flatMap(p => p.data ?? []).map(e => ({ model: String(e.model ?? e.modelId ?? e.type ?? \"unknown\").slice(0, 80), cost: Number(e.totalCost ?? e.cost ?? 0), at: e.createdAt ?? null, day: e.createdAt ? Math.floor(new Date(e.createdAt).getTime() / 86400000) : null }));\nconst inWindow = (chats.data ?? []).filter(c => new Date(c.createdAt ?? c.updatedAt).getTime() >= startMs);\nconst chatDays = inWindow.map(c => ({ c, day: Math.floor(new Date(c.createdAt ?? c.updatedAt).getTime() / 86400000) }));\nreturn {\n window: { days: DAYS, startDate, endDate },\n usage: {\n events: events.length,\n totalCost: Math.round(events.reduce((n, e) => n + e.cost, 0) * 100) / 100,\n pagesRead: usagePages.length,\n more: Boolean(usagePages[usagePages.length - 1].pagination?.hasMore),\n returned: { perModel: Object.keys(Object.groupBy(events, e => e.model)).length, perWeek: Object.keys(Object.groupBy(events.filter(e => e.day !== null), e => new Date((e.day - (e.day + 3) % 7) * 86400000).slice(0, 10))).length },\n perModel: Object.entries(Object.groupBy(events, e => e.model)).map(g => ({ model: g[0], events: g[1].length, cost: Math.round(g[1].reduce((n, e) => n + e.cost, 0) * 100) / 100 })).sort((a, b) => b.events - a.events),\n perWeek: Object.entries(Object.groupBy(events.filter(e => e.day !== null), e => new Date((e.day - (e.day + 3) % 7) * 86400000).slice(0, 10))).map(g => ({ week: g[0], events: g[1].length, cost: Math.round(g[1].reduce((n, e) => n + e.cost, 0) * 100) / 100 })).sort((a, b) => a.week < b.week ? -1 : 1)\n },\n chats: {\n read: (chats.data ?? []).length,\n count: inWindow.length,\n capped: (chats.data ?? []).length >= 100,\n returned: { perWeek: Object.keys(Object.groupBy(chatDays, x => new Date((x.day - (x.day + 3) % 7) * 86400000).slice(0, 10))).length, items: Math.min(chatDays.length, 30) },\n perWeek: Object.entries(Object.groupBy(chatDays, x => new Date((x.day - (x.day + 3) % 7) * 86400000).slice(0, 10))).map(g => ({ week: g[0], count: g[1].length })).sort((a, b) => a.week < b.week ? -1 : 1),\n items: chatDays.sort((a, b) => b.day - a.day).slice(0, 30).map(x => ({ id: x.c.id, name: (x.c.name ?? x.c.title ?? \"\").split(\"\\n\").join(\" \").slice(0, 160), url: x.c.webUrl ?? `https://v0.app/chat/${x.c.id}`, createdAt: x.c.createdAt ?? null, updatedAt: x.c.updatedAt ?? null, favorite: Boolean(x.c.favorite) }))\n }\n};\n"};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The human's profile: the one format for <workspace>/memory/human.md. memory.mjs
|
|
2
|
+
// validates and recalls it; onboarding's check-report.mjs checks its read.md and
|
|
3
|
+
// the first human.md against it, and the workspace-review skill checks each
|
|
4
|
+
// week's rewrite. This file has no imports, because the onboarding tool copies it
|
|
5
|
+
// beside check-report.mjs into the sandbox.
|
|
6
|
+
//
|
|
7
|
+
// ## Who you are two sentences
|
|
8
|
+
// ## How you work 4 to 6 `###` traits: the claim, at least two `- `
|
|
9
|
+
// evidence items, an `Against:` line, a `Drives:` line
|
|
10
|
+
// ## What you worked on optional; the workspace review adds it
|
|
11
|
+
//
|
|
12
|
+
// Every bot recalls the whole file on every turn, so it is capped at one page.
|
|
13
|
+
|
|
14
|
+
export const PROFILE_FILE = "human.md";
|
|
15
|
+
export const PROFILE_SECTIONS = ["Who you are", "How you work", "What you worked on"];
|
|
16
|
+
const REQUIRED = PROFILE_SECTIONS.slice(0, 2);
|
|
17
|
+
export const MIN_TRAITS = 4;
|
|
18
|
+
export const MAX_TRAITS = 6;
|
|
19
|
+
export const MAX_PROFILE_LINES = 70;
|
|
20
|
+
|
|
21
|
+
const heading = (line) => /^##\s+(.+?)\s*$/.exec(line)?.[1];
|
|
22
|
+
|
|
23
|
+
// The `##` sections of a markdown document, in order: [[title, lines]].
|
|
24
|
+
function sections(text) {
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const line of text.split(/\r?\n/)) {
|
|
27
|
+
const title = heading(line);
|
|
28
|
+
if (title !== undefined) out.push([title, []]);
|
|
29
|
+
else if (out.length) out.at(-1)[1].push(line);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The profile sections of a longer document (onboarding's read.md), as the text
|
|
35
|
+
// of a human.md.
|
|
36
|
+
export function profileSections(text) {
|
|
37
|
+
return sections(text)
|
|
38
|
+
.filter(([title]) => PROFILE_SECTIONS.includes(title))
|
|
39
|
+
.map(([title, lines]) => [`## ${title}`, ...lines].join("\n").trimEnd())
|
|
40
|
+
.join("\n\n") + "\n";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// One line per way `text` is not a valid profile; [] when it is.
|
|
44
|
+
export function profileProblems(text) {
|
|
45
|
+
const problems = [];
|
|
46
|
+
const lines = text.split(/\r?\n/).filter((l) => l.trim());
|
|
47
|
+
if (lines.length > MAX_PROFILE_LINES) problems.push(`the profile is ${lines.length} lines; keep it to ${MAX_PROFILE_LINES}`);
|
|
48
|
+
const first = lines.findIndex((l) => heading(l) !== undefined);
|
|
49
|
+
const preamble = lines.slice(0, first < 0 ? lines.length : first);
|
|
50
|
+
if (preamble.length > 1 || (preamble.length && !/^#\s/.test(preamble[0]))) problems.push(`only a "# " title may come before "## ${PROFILE_SECTIONS[0]}"`);
|
|
51
|
+
const found = sections(text);
|
|
52
|
+
const titles = found.map(([t]) => t);
|
|
53
|
+
for (const t of titles) if (!PROFILE_SECTIONS.includes(t)) problems.push(`unknown section "## ${t}" (expected ${PROFILE_SECTIONS.map((s) => `"## ${s}"`).join(", ")})`);
|
|
54
|
+
for (const t of REQUIRED) if (!titles.includes(t)) problems.push(`missing "## ${t}"`);
|
|
55
|
+
if (new Set(titles).size !== titles.length) problems.push("a section appears twice");
|
|
56
|
+
const known = titles.filter((t) => PROFILE_SECTIONS.includes(t));
|
|
57
|
+
if (known.some((t, i) => i && PROFILE_SECTIONS.indexOf(t) < PROFILE_SECTIONS.indexOf(known[i - 1]))) problems.push(`sections must come in the order ${PROFILE_SECTIONS.join(", ")}`);
|
|
58
|
+
for (const [title, body] of found) if (PROFILE_SECTIONS.includes(title) && !body.some((l) => l.trim())) problems.push(`"## ${title}" is empty`);
|
|
59
|
+
const how = found.find(([t]) => t === "How you work");
|
|
60
|
+
if (how) {
|
|
61
|
+
const traits = [];
|
|
62
|
+
for (const line of how[1]) {
|
|
63
|
+
const name = /^###\s+(.+?)\s*$/.exec(line)?.[1];
|
|
64
|
+
if (name) traits.push({ name, claim: false, evidence: 0, against: false, drives: false });
|
|
65
|
+
else if (traits.length && line.trim()) {
|
|
66
|
+
const t = traits.at(-1);
|
|
67
|
+
if (/^\s*-\s/.test(line)) t.evidence++;
|
|
68
|
+
else if (/^Against:/.test(line.trim())) t.against = true;
|
|
69
|
+
else if (/^Drives:/.test(line.trim())) t.drives = true;
|
|
70
|
+
else t.claim = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (traits.length < MIN_TRAITS || traits.length > MAX_TRAITS) problems.push(`"## How you work" has ${traits.length} ### traits; it needs ${MIN_TRAITS} to ${MAX_TRAITS}`);
|
|
74
|
+
for (const t of traits) {
|
|
75
|
+
if (!t.claim) problems.push(`trait "${t.name}" has no claim sentence`);
|
|
76
|
+
if (t.evidence < 2) problems.push(`trait "${t.name}" has ${t.evidence} - evidence items; it needs at least 2`);
|
|
77
|
+
if (!t.against) problems.push(`trait "${t.name}" has no Against: line`);
|
|
78
|
+
if (!t.drives) problems.push(`trait "${t.name}" has no Drives: line`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return problems;
|
|
82
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eve-hub",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.42",
|
|
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",
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const WORKSPACE_REVIEW_PROMPT = "Load the `workspace-review` skill and follow it: score the last 7 days against the week before, open the draft PRs for the human's profile and memory and for the bots and schedules, and post the scorecard and proposals here.";
|
|
2
|
+
/** The one post a firing commits: to main, keyed on the day it fired. */
|
|
3
|
+
export declare const workspaceReviewPost: (now: number) => {
|
|
4
|
+
text: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function fireWorkspaceReview({ fetchImpl, base, now }?: {
|
|
8
|
+
fetchImpl?: typeof fetch;
|
|
9
|
+
base?: string;
|
|
10
|
+
now?: number;
|
|
11
|
+
}): Promise<void>;
|
|
12
|
+
declare const _default: {
|
|
13
|
+
cron: string;
|
|
14
|
+
run: () => Promise<void>;
|
|
15
|
+
};
|
|
16
|
+
export default _default;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// main's workspace-review schedule: every Monday 08:00 UTC, main loads the workspace-review skill (workspace-review/SKILL.md),
|
|
2
|
+
// scores the week, and opens draft PRs for the human's profile and memory and for the bots and schedules, plus a proposals doc
|
|
3
|
+
// for the hub and kit. run() commits one system post
|
|
4
|
+
// to the hub's thread for this schedule (PUT /api/schedules/main/workspace-review/state, store/schedules.ts commitSchedule)
|
|
5
|
+
// as main's deployment: the post @-mentions main, so main's turn runs in a thread the hub sees, and pausing the
|
|
6
|
+
// schedule in the app stops the post. The post is keyed on the UTC day, so a retry of the same firing posts once.
|
|
7
|
+
import { defineSchedule } from "eve/schedules";
|
|
8
|
+
import { registryFetch } from "../registry-fetch.mjs";
|
|
9
|
+
export const WORKSPACE_REVIEW_PROMPT = "Load the `workspace-review` skill and follow it: score the last 7 days against the week before, open the draft PRs for the human's profile and memory and for the bots and schedules, and post the scorecard and proposals here.";
|
|
10
|
+
/** The one post a firing commits: to main, keyed on the day it fired. */
|
|
11
|
+
export const workspaceReviewPost = (now) => ({ text: `@main ${WORKSPACE_REVIEW_PROMPT}`, clientId: `workspace-review:${new Date(now).toISOString().slice(0, 10)}` });
|
|
12
|
+
export async function fireWorkspaceReview({ fetchImpl = fetch, base, now = Date.now() } = {}) {
|
|
13
|
+
const res = await registryFetch("/api/schedules/main/workspace-review/state", {
|
|
14
|
+
method: "PUT",
|
|
15
|
+
headers: { "content-type": "application/json" },
|
|
16
|
+
body: JSON.stringify({ posts: [workspaceReviewPost(now)] }),
|
|
17
|
+
}, { fetchImpl, deadlineMs: 10_000, ...(base === undefined ? {} : { base }) });
|
|
18
|
+
if (!res.ok)
|
|
19
|
+
throw new Error(`PUT /api/schedules/main/workspace-review/state: ${res.status} ${(await res.text()).slice(0, 300)}`);
|
|
20
|
+
}
|
|
21
|
+
export default defineSchedule({
|
|
22
|
+
cron: "0 8 * * 1",
|
|
23
|
+
run: () => fireWorkspaceReview(),
|
|
24
|
+
});
|
|
@@ -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, fan out over their Slack, Notion, GitHub and LLM usage, then write a short cited read of how they work, propose the bot team that fits them for them to pick from, and draft schedules that keep them on track. Reads only; the one write is a pull request the user approves by merging.","content":"# Profile the user from Slack, Notion, GitHub and their LLM usage\n\nThe profile is built from many small reads run at the same time, not one pass. You gather the raw facts (step 1), split them into slices that each read one source or one piece of a source (steps 2 and 3, wave 1), merge the slices into three areas (step 4, wave 2), and then turn the areas into a read of the user in three passes: a read pass, a critique pass and a write pass (step 5). Every claim cites a URL or `fact:` reference that is in the facts, and everything you could not read is said, never guessed.\n\nThe result is two files. `read.md` is about one page on how the user works and the team that fits them. `facts.md` is the appendix it cites: the ten questions below, answered from the facts.\n\nNothing is posted to Slack and nothing is committed by the run itself; its own writes are the pull request in step 6, which the user approves by merging, and a wake-up in this thread for any need that runs once (step 5c), and the first task (below) pushes only once the user says `go` in its thread. The user sees the first message about 20 seconds in, naming the first task, when there is one, that is already being worked on, one message with every Kit approve link if access is pending (step 1), up to four two-sentence findings as the slices finish, one `ask` to confirm what the facts say they would hand off, your answers to anything they ask, and at the end one message with the read, their team and the schedules, followed by one `ask` to pick team changes (step 7). If a step cannot finish, the final message says which and what the user must do; never write the read as if every source had been read.\n\nThe whole run is capped at 25 minutes. Every directory below is under `/tmp/onboarding-skill/`.\n\n## `facts.md` answers these ten questions\n\nEach gets an answer with a citation or a \"Could not tell\" line saying what was missing.\n\n1. Which team the user is on, with their title and start date.\n2. Who they most likely report to, and who leads the team. Say \"inferred\" unless a message says it.\n3. What the team is working on now.\n4. The sibling teams, and how the team rolls up to leadership.\n5. What leadership says the priorities are.\n6. Where their time goes, by repo and by week, over 180 days.\n7. Their cadence: active days, weekday and hour pattern in their Slack time zone, authoring versus reviewing.\n8. The themes of their PRs, per repo.\n9. Which models and coding agents they use, how much, and when.\n10. How much of their shipped work is agent-assisted.\n\nPlus four more: what they shipped and own, what people tag them for, what they missed (unanswered mentions, requested reviews, stale or conflicted PRs), and which open work-streams to close or focus on.\n\n## `read.md` is the core read of the user\n\nAbout one page, with these five sections in this order:\n\n1. `## Who you are`: two sentences. Their role in their own terms, and the one thing to act on first.\n2. `## How you work`: 4 to 6 traits. A trait is a claim about behavior, not a count. Each is a `###` heading naming the trait, then the claim in one sentence, then at least two `-` evidence items from at least two different sources (Slack, GitHub, Notion, v0, the Mac's usage files, the user's answers), each citing as `facts.md` does, then an `Against:` line (what would contradict it, and whether the facts show that) and a `Drives:` line (the decision it drives for the team or the schedules).\n3. `## Where your time and risk go`: where the hours go compared with what leadership says matters, and what is drifting (stale stacks, review debt, areas only they own).\n4. `## Your team`: one table row per change to the current roster (keep, add, change or remove), each naming the trait behind it.\n5. `## Act on first`: three items, each citing a URL from the facts; when the run has a first task, the first item is that task, with its thread linked.\n\nThe traits come from these dimensions. They are a prompt for where to look, not a formula:\n\n- How they use agents: how much they delegate, how many run in parallel, how often subagents run, and how closely they review what comes back.\n- Author or reviewer: who depends on them and who they depend on.\n- Rhythm: their working hours, bursts, and when agents run without them.\n- Spread: how many programs and repos they hold at once, and whether those form one program.\n- Closure: whether work gets finished or piles up as drafts, conflicts and review requests.\n\nThe user's own pick from the handoff `ask` (`fact:interview/handoff`) counts as one source. A trait resting on their pick alone fails the checker. Where their pick and the data disagree (they say review is what they'd keep, against 206 reviews for 1,412 PRs opened), the trait says so plainly, because that gap is often the most useful line in the read.\n\n## Subagents do the work, and you talk with the user\n\nEvery collection and synthesis task runs as a subagent: the Slack base facts, every slice, every area and each pass of the read. You run none of the collection scripts yourself, so you stay free to talk with the user the whole time.\n\nYou do only what a subagent can't or shouldn't:\n\n- the `request_access` and `request_status` calls, and the post with their approve links, because the user answers them;\n- the `onboarding_scripts` tool, once, before anything starts;\n- the one `gh onboarding-facts` command, because subagents never call `git__gh`;\n- planning the slices (step 2), writing each reader's report to its file, and running `check-report.mjs`;\n- the pull request (step 6) and every post.\n\n### Post to the user while the subagents read\n\nThe full read takes about nine minutes, so the user gets something to act on first. Before step 7 you post only these: the first message (step 1), the access post of step 1 if access is pending, the findings (\"Post a finding as each group of slices is checked\", below), the handoff `ask` (\"Confirm what they would hand off\", below), and your answers to anything they ask.\n\nNever ask the user free-text questions about how they work: nobody types answers during a run. What they own and how a day goes come from the `role` and `work` areas; what a bot never does without asking is set by the workspace's standing decisions, so it needs no question. The one thing the facts can only suggest, what they would hand off first, you infer and they confirm with one tap.\n\nNever post progress (\"eleven more subagents started\"), a tour of the team, or a trait or judgment before step 7, except the handoff `ask`: before then a post holds only facts the checker has passed, because the critique (5b) is what tests a judgment, and the `ask` is a guess the user settles.\n\n### The first message and the first task\n\nThe first message comes from `facts/github/quick.json` alone (step 1), in about 20 seconds, so it holds GitHub counts and links and nothing from Slack. One bubble of at most three sentences, written the way a colleague would say it: what you are reading (their Slack, Notion, GitHub and v0, and their Mac's coding-agent usage if a Mac bot is on the team) and that the full read lands in about nine minutes; then the first task as a plan, with its link, the outcome and a short why, and the link to its thread. For example: \"To kick us off, let's grab [eve#5300](https://github.com/vercel/eve/pull/5300) and get it merged. It's already approved and looks mergeable, so the failing checks are all that's left. Let's do that [in this thread](thr_…).\" The why is a plain clause about the item, never the selection rule, a ranking (\"the smallest of your approved open PRs\") or line counts. Say only what is true of the item: \"it's already approved\" for the user's own approved PR, never \"you approved it\".\n\nPick the first task yourself from `quick.json`, as a judgment with a one-line reason, not a score: one item that is small, can be finished in one thread, and is closest to landing. Look in this order: the user's approved PRs (`approved`) whose `checks` fail or that are `CONFLICTING`; their PRs with changes requested (`changesRequested`); the oldest review requests (`reviewRequested`); their assigned issues (`assignedIssues`). A candidate's state is the one `quick.json` holds for it at this run, never one remembered from an earlier run or another thread. With no candidate, the first message says so and names no task; never invent one.\n\nBefore opening a thread, run `list_threads` with `archived: include` and the PR or issue URL as `query`, then again with its `owner/repo#number`. An item an active thread already works on is already moving, so prefer a candidate no active thread holds. If you still pick one an active thread owns, use that thread: post the task there to its owner, the bot that posted its brief or build, and never instruct its builder yourself, because two bots instructing one builder undo each other's work. An archived thread takes no posts, so for an item only an archived thread mentions, open a new thread and link the archived one in the handoff. Otherwise open one thread for the task and hand it over in a post there before the first message goes out: to the builder for the user's own PR or an issue, to the reviewer for a review request. That post opens with one line to the user saying who is on it and what it does, for example \"Ok. I've put @builder on this. It'll find why the checks fail and prepare the fix, and pushes nothing until you say `go`.\" The run assigns a bot that is already on the team and never says it created one. The handoff names the PR or issue URL and says what it may do before the user answers. The builder reads the failing check logs or the conflict, finds the cause and prepares the fix on a local branch, then posts the cause and the diff, and pushes nothing until the user says `go` in that thread, because a push to an approved PR is seen by its reviewers and can dismiss the approval. The reviewer posts a draft review in the thread and sends nothing to GitHub until the user says `go`. Merging waits on the user as for any PR. The task's thread is linked again from `## Act on first` (5a).\n\nBefore writing the first message, check every number, date and name in the message against the exact field in `quick.json` it comes from: a count is `total`, not `listed` (`listed` is only the page that was read); a date is the item's own `createdAt` or `updatedAt`; a search named in `unreadable` was not read, so it is never a zero. `check-report.mjs` only checks that each link is in the facts, not that the sentence states its value right, so this check is yours.\n\nWrite the first message to `/tmp/onboarding-skill/first.md`, run `node check-report.mjs --profile first.md --facts facts` in `/tmp/onboarding-skill`, fix what it prints, and post it.\n\n### Post a finding as each group of slices is checked\n\nWhen every slice of a group below is written and passes the checker, post one finding for it, at most four in the run, in the order they come in:\n\n1. GitHub: `reviews`, the `repo-*` slices and `calendar`.\n2. LLM: `agent-assisted`, and `facts/local/` if the Mac's files are in.\n3. Slack: `mentions` and the `own-*` slices.\n4. Team: the `team-*` slices, `leadership` and `notion`.\n\nA finding is one `- ` list item of two sentences, written to `findings/<n>.md`. The first holds one count or fact copied from the group's slices, with the URL or `fact:` reference it rests on, and why it matters to the user. The second is the next step, what the team or the user does about it, linking the item, channel or thread it acts on. For example: \"103 of 237 Slack mentions in 30 days have no reply from you, and 9 of the 20 listed are in [inc-8535](https://vercel.slack.com/archives/C0AJ0C32XPB/p1794014521602859?thread_ts=1794014424.527769), so V questions still land on you while Philipp takes V over. I can list those 9 threads for Philipp so he answers them in [inc-8535](https://vercel.slack.com/archives/C0AJ0C32XPB) instead.\" A count with no reason or next step is not a finding. Check it with `node check-report.mjs --finding findings/<n>.md --facts facts` before it posts: the checker fails anything but one item of two sentences that each cite a fact; whether the reason and the step are good is yours to judge. Check its count, date and names against the exact fields of the slice it comes from first, as for the first message: `total` against `listed`, the item's own dates, and `unreadable`. Pick the fact the user would act on (\"91 review requests in vercel/eve are waiting on you\"), never a trait or a judgment, and never one already in the first message. A group whose slices found nothing worth a line posts nothing.\n\n### Confirm what they would hand off\n\nWhen the `role` and `work` areas both pass the checker, infer from them what the user would hand off first, and post one `ask`. The question is the inference with the facts behind it, ending \"Right?\", for example \"You'd hand off review chasing and Slack follow-ups first, since 240 reviews wait on you and 103 mentions have no reply. Right?\" The options are \"Yes, start there\", then up to three other readings the areas support, each with a `line` naming its fact, then \"None of these\". Labels are at most 40 characters. This is the run's only `ask` before step 7.\n\nWhen you post the `ask`, write `/tmp/onboarding-skill/facts/handoff-ask.md` with the question as posted and every option's label and `line`. As soon as they answer, append their answer to `/tmp/onboarding-skill/facts/interview.md` under `## handoff`, as three parts: `Asked:` the question word for word, `Offered:` each option's label and `line`, and `Picked:` the label they tapped, or their typed reply word for word. The read cites it as `fact:interview/handoff`, so whoever reads that fact sees what was inferred, what else was offered, and what they confirmed. A pick of \"None of these\" is a fact too: the inference was wrong. Until they answer, the read states the inference as unconfirmed, citing the facts behind it, not `fact:interview/handoff`.\n\nWhen you wait, use `eve__task_wait`: it returns on a task result or on a new message from the user. If the user asks something, answer it in the thread right away, then wait again. If they ask for work, route it as you normally would; the onboarding run goes on. Never wait for their tap: the read uses whatever has arrived when it starts, and a tap that arrives after the read pass started goes to it as \"Evidence that arrives late\" (step 5) says.\n\n### Log when each step ends\n\nThe run's length is judged against the time each step took, so log it. `onboarding_scripts` starts a fresh `/tmp/onboarding-skill/`, so right after it returns, run `echo \"start $(date -u +%T)\" > /tmp/onboarding-skill/phases.log`. When each of these ends, append one line the same way: `quick` (`gh onboarding-facts --quick` returned), `first` (the first message posted), `facts` (the full `gh onboarding-facts` returned), `finding1` to `finding4` (each finding posted, numbered in the order they post), `slices` (every wave 1 report is written and checked), `areas` (every wave 2 area is checked), `read` (5a returned), `critique` (5b returned), `write` (5c's checker prints nothing), `pr` (step 6's checks finished: the PR URL and `pass` or `fail`, or `pr none` when the workspace has no repo), `post` (step 7 posted). Step 7 never posts before the `pr` line is written. After step 7, run `node check-report.mjs --phases phases.log`: it prints any step that is missing, repeated or out of order. Keep `phases.log` with `read.md`.\n\n## 0. Hand the Mac half to the bot on the Mac\n\nYou run on Vercel and can't see the user's Mac. If `list_agents` shows a bot that runs `local`, post one message in this thread before step 1 that mentions it (the first one, if there are several) and asks it to run its local-onboarding skill, including its \"LLM usage\" section. Name the workspace repo for its sandbox PR, or say there is none. Go on without waiting: its half arrives in this thread as its own posts. Never poll for it.\n\nIts LLM-usage post links one facts file per coding agent, written by `agent-usage-facts.mjs --usage-only`: names and counts, with no web permalinks. When it arrives, fetch each linked file with `fetch_upload` into `/tmp/onboarding-skill/facts/local/usage-<source>.json` (`claude-code`, `codex`, `opencode`, `cursor`). These files, not the post's table, are what the profile cites, with `fact:` references (step 5).\n\nIf the files arrive after the `llm` synthesizer of step 4 has started, its area did not read them. Fetch them into `facts/local/`, start one `agent` copy as the `llm` synthesizer on them alone, and check its `areas/llm-local.md` with `node check-report.mjs --profile areas/llm-local.md --facts facts`. Then hand `areas/llm-local.md` on as \"Evidence that arrives late\" (step 5) says.\n\nWith no bot that runs `local`, skip this step and say nothing about a Mac; question 9 then rests on v0 alone, and \"Could not tell\" says the Mac's agent logs were not read.\n\n## 1. Access and raw facts, in one parallel step\n\nKit's tools are `request_access`, `request_status` and `execute`. Hosted, you reach them through the `vercel-kit` connection: `eve__tool({ name: \"vercel-kit__request_access\", input: { ... } })`, and the same shape for the others.\n\nMake all of these calls in one step:\n\n- `request_access({ service, reads: true, reason })` for each of `slack`, `notion` and `v0`, with a reason naming what the profile reads.\n- The `onboarding_scripts` tool: it writes the skill's scripts into `/tmp/onboarding-skill/`, makes the `facts/`, `slices/` and `areas/` directories, and installs the `gh onboarding-facts` extension.\n\nThen three more steps, each started as soon as the one before returns:\n\n1. Run `gh onboarding-facts --quick` with your `git__gh` tool. `--quick` returns in about 6 seconds and writes `facts/github/quick.json`: the user's open PRs that are approved (`approved`) or have changes requested (`changesRequested`), the 25 oldest review requests (`reviewRequested`) and their assigned issues (`assignedIssues`), each with its `total`; PRs carry `reviewDecision`, `mergeable` and `checks`.\n2. Hand over the first task in its thread if there is one and post the first message. Nothing else starts before it posts, because each subagent started first delays it.\n3. Run the full `gh onboarding-facts`, which takes about 80 seconds, and in the same step start the Slack base subagent and the leadership slice (step 3) if Slack is `approved`.\n\n- **GitHub.** Run `gh onboarding-facts` with your `git__gh` tool, one command, no `repository`. Plain `gh` in your shell holds no credential. It reads 180 days and writes `facts/github/`: `base.json` (counts, per-repo totals, merged, open, review requests, assigned issues, unanswered mentions, teams, CODEOWNERS lines; open PRs carry `draft`, `reviewDecision`, `mergeable` and `checks`), `calendar.json` (contributions per day and week, and timestamped events), `reviews.json`, `issues.json`, and `repos/<owner>__<name>.json` for the top 5 repos by active days. `unreadable` in any file lists what could not be read. A failed command means GitHub was not read.\n- **Slack base.** If Slack is `approved`, start one `agent` copy, `slack-base`, that reads `/tmp/onboarding-skill/slack-facts.callscript.js` and passes its text, unchanged, as `script` to `vercel-kit__execute`, handles the results listed below, saves the `output` whole as `facts/slack/base.json` (every list item, matching its `returned` count) in one write once the run has finished, and returns: the leadership slice's searches wait on that file. Give it these rules: never call `git__gh`, never post, and on `consent_required` or `refused` return the `approveUrl`, `run` and `refused.needed` without retrying. You post the link, and once the user approves you continue the same task by its `taskId` with `execute({ run })`. It gets 8 turns.\n- **One access post.** If any `request_access` came back `pending` or `connection_required`, post one message with every pending `approveUrl` and one line each on what it unlocks. Then `request_status({ request })` for each until it is decided. A declined service is a \"Could not tell\" line, never a retry.\n\nSlack's search limit is about 20 a minute per user. Only two scripts in the run call `search_messages`: the base script (18 searches) and the leadership slice's `lead-posts.callscript.js` (at most 10, started at least 60 seconds after the base finished). The leadership slice starts beside the base, because its first two scripts don't search, and waits only before `lead-posts.callscript.js`. No other script searches.\n\nThe base script's results, which the `slack-base` copy handles:\n\n- A `ratelimited` error: 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: post its `approveUrl`, `request_status({ request })` until decided, then `execute({ run })` continues the same run.\n- An `output` with `refused`: Slack answered `missing_scope` (or another refusal), so no facts were read. `refused.needed` names the scope. Call `request_access({ service: \"slack\", capability: \"search_messages\", reason })` and give the user its `approveUrl`, or tell them to reconnect Slack in Kit allowing `refused.needed`. The Slack slices don't start.\n- Any other error goes to the user in the final message. Never continue with fewer facts, and never retry around a refusal.\n- What it holds: `me` (id, name, title, team, time zone, start date), `window` (30 days; `newestOnly: true` means a very active account is covered only back to `own.oldest`), `own` (messages per channel, thread replies, recent snippets), `mentions` (`replied`, `unanswered`, `notJudged`, `capped`, and `mentioners`: the user ids that mention them most). DMs and group DMs are never searched; reactions are not read.\n\n## 2. Plan the slices\n\nFrom the files of step 1, pick the slices. A slice with no data isn't started, so the counts in the table are the most there can be.\n\n- **Channel clusters.** Group `own.channels` into at most 3 clusters by what they are about (team, project, support, social), judged from names and snippets.\n- **Team channels.** Up to 3 channels whose name or purpose matches the user's team as `me.title` names it (Slack's `team` field holds the workspace id, not a team), or where they post most. List them with `conversations_info` only if you can't tell from the name.\n- **Leadership.** Already running since step 1; it picks its own channels and leads (step 3).\n- **People.** Up to 15 user ids from `mentions.mentioners`, most first.\n- **Notion queries.** Up to 5: the team's name, the team plus \"strategy\" or \"roadmap\", and the org's name.\n- **Repos.** The files in `facts/github/repos/`.\n\n## 3. Wave 1: one slice per source, all at once\n\nStart every slice in one step, then wait; the leadership slice is already running from step 1. Each slice reads only its own inputs and writes `slices/<name>.md`: its findings as list items, each citing a URL from its facts, and a `## Could not tell` section. A slice cites the URL itself, copied whole from its facts, query string included (a Slack reply permalink keeps its `?thread_ts=…`, or the checker can't find it). It never cites `fact:github/...`, `fact:slack/...` or any `fact:` path but `fact:local/`: those fail the checker, and every GitHub, Slack, Notion and v0 fact has a URL. Tell each slice the user's login, Slack name, time zone and the window, the exact file paths it reads, the path it writes, and this format.\n\n| Area | Slice | Count | Reads | Runs as |\n|---|---|---|---|---|\n| Role | `own-<cluster>`: what the user writes about and does in that cluster | up to 3 | `facts/slack/base.json` | `reader` |\n| Role | `mentions`: who asks the user for what, and what went unanswered | 1 | `facts/slack/base.json` | `reader` |\n| Role | `team-<channel>`: what the team is doing, who posts, who leads | up to 3 | Kit, `team-channel.callscript.js` | `agent` |\n| Role | `leadership`: what the leads and announcement channels say the priorities are | 1 | Kit, `leadership.callscript.js` then `lead-posts.callscript.js` | `agent` |\n| Role | `people`: titles and teams of the 15 people the user works with most | 1 | Kit, `people.callscript.js` | `agent` |\n| Role | `notion`: strategy and roadmap pages for the team and org | 1 | Kit, `notion.callscript.js` | `agent` |\n| Work | `repo-<owner>__<name>`: active days, cadence, authoring versus reviewing, PR themes | up to 5 | `facts/github/repos/<owner>__<name>.json` | `reader` |\n| Work | `reviews`: whose work the user reviews, where, how deeply | 1 | `facts/github/reviews.json` | `reader` |\n| Work | `issues`: issues opened and joined | 1 | `facts/github/issues.json` | `reader` |\n| Work | `calendar`: weekly volume, weekday and hour pattern in the user's time zone, gaps | 1 | `facts/github/calendar.json` | `reader` |\n| LLM | `agent-assisted`: share of merged PRs and commits with an agent `Co-authored-by:` or a \"Generated with\" line | 1 | `facts/github/repos/` | `reader` |\n| LLM | `v0`: v0 chats and usage over 180 days | 1 | Kit, `v0.callscript.js` | `agent` |\n\n**Reader slices** (`reader` tool) are far cheaper and need no Kit. A reader never writes files, so ask it for its findings as `evidence` lines, each a claim with the URL it rests on, and its gaps in `open`. Write each report to `slices/<name>.md` yourself, unchanged: evidence lines become list items, `open` becomes the `## Could not tell` section. Give each a budget of 4 turns.\n\n**Kit slices** (`agent` copies) each run one script: read `/tmp/onboarding-skill/<script>` and pass its text as `script` to `vercel-kit__execute`, with the slice's `input`:\n\n- `/tmp/onboarding-skill/team-channel.callscript.js`: `{ channel, days: 30 }`, the channel by id. If Kit answers that the script read more than it can keep, run it once more with `days: 14` and say so in the slice.\n- `/tmp/onboarding-skill/leadership.callscript.js`, run twice. First with `{}`: it returns the user's channel memberships, and the slice picks up to 3 announcement or leadership channels from them (names like `announcements`, `eng-leads`, the team's `-leads` channel); this output is not saved. Then with `{ channels, days: 90 }`, channels by id: it reads the channels and returns their 20 most frequent posters as `candidates`, with title, team, `isBot`, `deleted` and post count, and searches nothing. The slice saves it as `facts/kit/leadership.json`, then judges from the candidates and the channels' messages who leads (what they announce, their titles, who others answer to), up to 10, with the known leads from step 2 first. Posting often is not leading: a bot, a deleted user or someone who only relays reminders is not a lead. It writes one line per chosen lead on why. Before it judges, it reads `me` in `facts/slack/base.json` for the user's title and team once that file exists.\n- `/tmp/onboarding-skill/lead-posts.callscript.js`: `{ leads, days: 90 }`, the ids the slice chose. Before it runs, the slice waits until `facts/slack/base.json` exists and 60 seconds have passed since it was written (`stat -c %Y`), checking in commands of at most 60 seconds, because the base's searches and these share Slack's limit of about 20 a minute. It saves the output as `facts/kit/lead-posts.json`.\n- `/tmp/onboarding-skill/people.callscript.js`: `{ users }`\n- `/tmp/onboarding-skill/notion.callscript.js`: `{ queries }`\n- `/tmp/onboarding-skill/v0.callscript.js`: `{ days: 180 }`\n\nThe slice saves the run's `output` as `facts/kit/<slice>.json` whole, every list item included: each list's length sits beside it in `returned`, and `check-report.mjs` fails a facts file under `facts/slack/` or `facts/kit/` whose list is shorter than its `returned` or has no `returned` count. The scripts keep their output to about 50 messages so a slice can copy all of it. It then writes `slices/<slice>.md` from it, and returns. Tell each Kit slice: never call `git__gh`, never post, never call `search_messages` outside its script; on `ratelimited` wait the `retry_after` once and run again, then record the gap; on `consent_required` or `access_denied` stop and return the `approveUrl` without retrying. Post that link, and restart the slice once after the user approves. Start a Kit slice only once its service is approved. Each gets 4 turns, and the leadership slice, which runs three scripts, gets 8.\n\nThis skill names its own count: up to 20 hosted slices run at once, overriding the fanout skill's cap of 3, because every slice only reads and writes its own file.\n\nWait with `eve__task_wait`. A slice still running 10 minutes after it started is cancelled with `eve__task_cancel`, and its area says it timed out.\n\nThen check every slice against its facts and have any slice with problems fixed once:\n\n```sh\ncd /tmp/onboarding-skill && node check-report.mjs --profile slices/*.md --facts facts\n```\n\n## 4. Wave 2: one synthesizer per area, all at once\n\nStart three `agent` copies in one step. Each reads its area's slice files and writes `areas/<area>.md` with list items that keep the slices' citations, a `## Conflicts` section where slices disagree (state both, don't pick one quietly), and a `## Could not tell` section. Each gets 6 turns and is cancelled at 10 minutes.\n\n- `role`: the role slices. Answers questions 1 to 5: team, title, start date, likely manager and team lead, what the team is doing, sibling teams, roll-up to leadership, leadership's priorities.\n- `work`: the work slices and `facts/github/base.json`. Answers questions 6 to 8, plus what they shipped and own, review requests, and which open PRs to close (stale, conflicted, superseded) or focus on.\n- `llm`: the LLM slices, and `facts/local/` if the Mac bot's files have arrived. Answers questions 9 and 10. A claim from a local file cites the value it rests on as `fact:local/usage-<source>.json#<JSON pointer>`, for example `fact:local/usage-codex.json#/usage/codex/models/0`; the times are in the file's `timeZone`.\n\n```sh\ncd /tmp/onboarding-skill && node check-report.mjs --profile areas/*.md --facts facts\n```\n\n## 5. Turn the areas into the read, in three passes\n\nThree passes, one after another. Each runs as an `agent` copy that never calls `git__gh` and never posts. The critique is a fresh copy, so it sees only the draft and the files, not the reasoning that wrote the draft.\n\n### 5a. Read pass\n\nStart one `agent` copy, `read`, with 6 turns, and cancel it at 10 minutes. Keep its `taskId`: steps 5c and late answers continue it. Give it:\n\n- the paths of the three area files, `facts/interview.md` (it may not exist yet), `facts/local/` and `facts/`;\n- the current roster: your `list_agents` output, pasted in, with each bot's `name`, `role`, `kind`, `model`, `runsOn` and subagents;\n- the standing decisions in effect, pasted in;\n- the \"`read.md` is the core read\" section above, word for word, and the rules below.\n\nIt writes `read-draft.md` with the five sections of `read.md`, then returns its path.\n\n- Every trait cites at least two different sources. Every row of `## Your team` names the trait behind it and the setting it changes: an agent entry in `hub.workspace.json` (`name`, `role`, `kind`, `model`, `runsOn`, `subagents`), a schedule, a standing decision, or a sandbox `prepare` step from the Mac half.\n- It proposes at most four changes, because the user picks them from one `ask` (step 7).\n- When the run has a first task, `## Act on first` links its thread (give it the link) as its first item, with what the task is waiting on. With no first task, the three items all come from the facts.\n- It proposes only what the kit can create today. A bot that would need a new tool or a new kind of integration is a \"Would need\" line under the table, not a row.\n- Judge; the scripts only counted. No scoring formula and no thresholds from code: say why in a sentence.\n\n### 5b. Critique pass\n\nStart one more `agent` copy, `critique`, with 4 turns. Give it the paths of `read-draft.md`, the three area files and `facts/interview.md`. For each trait and each `## Your team` row it answers:\n\n- Would this be true of most engineers on the user's team? If so, it is generic: cut it, or say what sharper claim the facts support.\n- Do the two cited sources each support it, or does one only repeat the other?\n- Does an area's `## Conflicts` section contradict it?\n- Does the team change follow from the trait, or would the same roster be proposed for anyone?\n\nIt returns one line per item: `keep`, `cut` or `rewrite`, the item's heading or row, and one line on why. It writes nothing; you save what it returns, verbatim, to `/tmp/onboarding-skill/critique.md`, so a reviewer can check each cut against the draft. The model makes the judgment; no score or threshold decides.\n\n### 5c. Write pass\n\nContinue the `read` copy by its `taskId` with the critique's list and any late evidence that has arrived (below). It applies the critique once and writes:\n\n- `read.md`: the five sections, with every `cut` gone and every `rewrite` rewritten.\n- `facts.md`: the ten questions and the four more, answered from the area files, with a `## Could not tell` section listing every `unreadable` source, `notJudged` mentions, that reactions were not read, the Slack window, a Kit grant the user declined, a slice that timed out, and the Mac half if it hasn't reported.\n- One schedule draft per recurring need it found (a morning pass over unanswered mentions, a weekly stale-PR pass), as `schedules/<bot>/<id>.md` for a bot on the current roster:\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\nA schedule file has only `cron`, so it runs every time the cron matches: `0 14 23 11 *` runs every 23 November, not once. Never call a draft one-time. A need that runs once (a pre-freeze check) is not a draft: when you post step 7, arm it with `schedule__wake__create` in this thread, `expression: { type: \"single\", at, timezone }`, `until` a few minutes after `at`, and the prompt as its note, and list it in the Schedule table with When \"once, <date>\". If a draft must still end by opening a draft PR that deletes its own file, its prompt and every line of the read and the post that names it say it recurs until that PR is merged and deployed: the schedule keeps running until the deployment without the file is live.\n\nEvery run also writes `schedules/main/memory-review.md`, whatever else it found, so the team learns from the user's corrections from the first week:\n\n```md\n---\ncron: \"0 8 * * 1\"\n---\n\nRead every thread from the last 7 days and find each place the human corrected a bot: a rule restated, an answer rejected, work redone their way. For each correction that should outlast its task, write `memory/<id>.md` (or `agents/<bot>/memory/<id>.md` when it is about one bot) with front matter `id`, `status: active`, `since` and `evidence` (the thread and message ids), then the rule in at most 3 lines. When it replaces an entry, set the old one's `status: superseded` and `superseded_by`; never rewrite it. Update `memory/human.md` with what the week showed about who the human is and how they work. Open one draft PR with only those files and never merge it. Post its link in the thread with one line per entry: the rule and the correction it came from. With no corrections, post that and open no PR.\n```\n\nIts row in the step 7 Schedule table says why it is there in the user's own words when the facts have them: link one or two of the user's corrections (a reply that tells a person or an agent to redo it their way) in \"What it reports\". With none in the facts, it says it starts from the corrections they make in this workspace.\n\nRules for both files:\n\n- Every list item cites at least one URL that is in the facts, or a `fact:` reference: `fact:local/usage-<source>.json#<JSON pointer>` for a value in the Mac's files, `fact:interview/handoff` for the user's pick from the handoff `ask`. No URL or reference appears that isn't in the facts. Never cite a web URL for a local count. 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. Both files name the user's repos and colleagues, so they are posted only in this thread and never committed.\n\n```sh\ncd /tmp/onboarding-skill && node check-report.mjs --read read.md --profile facts.md --facts facts \\\n --critique critique.md --schedules schedules\n```\n\n`--read` checks citations and also fails a trait under `## How you work` that cites fewer than two different sources. `--critique` fails when `critique.md` is missing or holds no `keep`, `cut` or `rewrite` line; it checks presence only, not that the text is verbatim. `--schedules` fails a draft without a five-field `cron`, a missing `main/memory-review.md`, a draft that says it runs once, and a date-pinned or self-deleting draft whose prompt and read lines don't say it recurs until its deletion is merged and deployed. The copy fixes every line the checker prints and reruns until it prints nothing. When it returns, run the checker yourself once more; it must print nothing. Keep `read.md`, `facts.md`, `phases.log` and `critique.md`.\n\n### Evidence that arrives late\n\nLate evidence is the user's handoff answer (the whole `## handoff` entry of `facts/interview.md`: asked, offered and picked), or the Mac's `areas/llm-local.md`, that arrives after the read copy started. Evidence that is in before 5a starts goes to 5a like the rest: `facts/interview.md`, and `areas/llm-local.md` beside the three area files. Late evidence never goes into a pass that is running, and it is never dropped.\n\n- **Before 5c starts**, it goes to 5c with the critique's list.\n- **Once 5c has started**, wait for the read copy to return, then continue it by its `taskId` with only the new evidence. It judges again every trait, `## Your team` row and schedule draft the evidence bears on, including one it contradicts, rewrites them in `read.md`, updates the affected answers in `facts.md`, and returns the list of what it changed. When that list adds or rewrites a trait or a team row, start a fresh `critique` copy (5b) on just those items, giving it the same new evidence (for a handoff answer, the whole `## handoff` entry), and continue the read copy with its list. Run the checker until it prints nothing.\n- **Once step 6 has opened its PR**, a refresh that adds, changes or removes a schedule draft changes the PR the same way: write, edit or delete its `agents/<bot>/agent/schedules/<id>.md` on the PR's branch, push, and watch its checks (`gh pr checks`) until they pass, as in step 6. A refresh never drops `memory-review`, so the PR always stays open. Before you touch its branch, check its state (`gh pr view <number> --json state`). If it is `MERGED`, never push to its branch: open a new PR from the default branch with only the changed, added or deleted schedule files, watch its checks the same way, and say the revised schedule is not deployed: the one running is the merged one, and the new one runs only once the user merges the new PR and it deploys. The changed schedule is posted only after its PR passes, or with what failed.\n- **After step 7 has posted**, do the same, then post only what changed: the rewritten lines of the read, the changed team and schedule rows, and a new `ask` if the proposed team changed.\n\nThe run is over when the user picks from the `ask`. Until then keep `facts/`, because each refresh and its checker need it; delete it then. An answer after that is ordinary conversation, not evidence for the read.\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 `read.md` or `facts.md` (`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 the read, the team and the schedules\n\nThe user reads it cold, often only its first line (your writing rules). This one message replaces any tour of the team: it shows the team as it should be for them. Write it to `/tmp/onboarding-skill/post.md` in this shape, load the forms skill for the tables, and check it:\n\n```md\nThe read in two sentences: who they are and how they work, then the one thing to act on first.\n\n---\n\n| Bot | Change | Why |\n|---|---|---|\n(one row per current bot, \"keep\" or the change, then one row per proposed bot, each Why naming its trait)\n\n| Schedule | Bot | When | What it reports |\n|---|---|---|---|\n(one row per draft, `memory-review` for `main` always among them, then one per one-run wake-up)\n\n[The read](...) and [the facts it cites](...). The 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 --schedules /tmp/onboarding-skill/schedules\n```\n\nFix every line it prints and rerun until it prints nothing, then post the file's text in this thread, with `read.md` and `facts.md` linked as uploads.\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 links line. Without a repo, it says the drafts stay in the thread. If the PR's checks still fail after step 6, it says so and what failed, in place of \"merging it turns them on\".\n - One line per source you could not read or Kit grant the user declined: what was not read and what they must do.\n - If you handed the Mac half over in step 0 and that bot hasn't posted yet, one line: its half hasn't reported yet, and if the Hub app is closed on their Mac, it runs once they open it.\n- No data tables in the chat: counts, windows, the ten answers and the \"Could not tell\" section stay in `facts.md`.\n\nThen post one `ask` with one option per proposed change in the \"Your team\" table (an added, changed or removed bot), plus \"None for now\". An `ask` holds at most 5 options, so the read proposes at most four changes. The onboarding run never creates, changes or removes a bot. What the user picks is normal work in this thread: route it to the builder, which edits `hub.workspace.json` and the role files in its own PR, because a new bot is a deploy and a running cost.\n\nOnly the schedule files ever leave the thread.\n"};
|
|
2
|
+
export default {"description":"Right after sign-in: ask for the Kit access you need, fan out over their Slack, Notion, GitHub and LLM usage, then write a short cited read of how they work, propose the bot team that fits them for them to pick from, and draft schedules that keep them on track. Reads only; the one write is a pull request the user approves by merging.","content":"# Profile the user from Slack, Notion, GitHub and their LLM usage\n\nThe profile is built from many small reads run at the same time, not one pass. You gather the raw facts (step 1), split them into slices that each read one source or one piece of a source (steps 2 and 3, wave 1), merge the slices into three areas (step 4, wave 2), and then turn the areas into a read of the user in three passes: a read pass, a critique pass and a write pass (step 5). Every claim cites a URL or `fact:` reference that is in the facts, and everything you could not read is said, never guessed.\n\nThe result is two files. `read.md` is about one page on how the user works and the team that fits them. `facts.md` is the appendix it cites: the ten questions below, answered from the facts.\n\nNothing is posted to Slack and nothing is committed by the run itself; its own writes are the pull request in step 6 (the schedule drafts and the first `memory/human.md`), which the user approves by merging, and a wake-up in this thread for any need that runs once (step 5c), and the first task (below) pushes only once the user says `go` in its thread. The user sees the first message about 20 seconds in, naming the first task, when there is one, that is already being worked on, one message with every Kit approve link if access is pending (step 1), up to four two-sentence findings as the slices finish, one `ask` to confirm what the facts say they would hand off, your answers to anything they ask, and at the end one message with the read, their team and the schedules, followed by one `ask` to pick team changes (step 7). If a step cannot finish, the final message says which and what the user must do; never write the read as if every source had been read.\n\nThe whole run is capped at 25 minutes. Every directory below is under `/tmp/onboarding-skill/`.\n\n## `facts.md` answers these ten questions\n\nEach gets an answer with a citation or a \"Could not tell\" line saying what was missing.\n\n1. Which team the user is on, with their title and start date.\n2. Who they most likely report to, and who leads the team. Say \"inferred\" unless a message says it.\n3. What the team is working on now.\n4. The sibling teams, and how the team rolls up to leadership.\n5. What leadership says the priorities are.\n6. Where their time goes, by repo and by week, over 180 days.\n7. Their cadence: active days, weekday and hour pattern in their Slack time zone, authoring versus reviewing.\n8. The themes of their PRs, per repo.\n9. Which models and coding agents they use, how much, and when.\n10. How much of their shipped work is agent-assisted.\n\nPlus four more: what they shipped and own, what people tag them for, what they missed (unanswered mentions, requested reviews, stale or conflicted PRs), and which open work-streams to close or focus on.\n\n## `read.md` is the core read of the user\n\nAbout one page, with these five sections in this order:\n\n1. `## Who you are`: two sentences. Their role in their own terms, and the one thing to act on first.\n2. `## How you work`: 4 to 6 traits. A trait is a claim about behavior, not a count. Each is a `###` heading naming the trait, then the claim in one sentence, then at least two `-` evidence items from at least two different sources (Slack, GitHub, Notion, v0, the Mac's usage files, the user's answers), each citing as `facts.md` does, then an `Against:` line (what would contradict it, and whether the facts show that) and a `Drives:` line (the decision it drives for the team or the schedules).\n3. `## Where your time and risk go`: where the hours go compared with what leadership says matters, and what is drifting (stale stacks, review debt, areas only they own).\n4. `## Your team`: one table row per change to the current roster (keep, add, change or remove), each naming the trait behind it.\n5. `## Act on first`: three items, each citing a URL from the facts; when the run has a first task, the first item is that task, with its thread linked.\n\nThe traits come from these dimensions. They are a prompt for where to look, not a formula:\n\n- How they use agents: how much they delegate, how many run in parallel, how often subagents run, and how closely they review what comes back.\n- Author or reviewer: who depends on them and who they depend on.\n- Rhythm: their working hours, bursts, and when agents run without them.\n- Spread: how many programs and repos they hold at once, and whether those form one program.\n- Closure: whether work gets finished or piles up as drafts, conflicts and review requests.\n\nThe user's own pick from the handoff `ask` (`fact:interview/handoff`) counts as one source. A trait resting on their pick alone fails the checker. Where their pick and the data disagree (they say review is what they'd keep, against 206 reviews for 1,412 PRs opened), the trait says so plainly, because that gap is often the most useful line in the read.\n\n## Subagents do the work, and you talk with the user\n\nEvery collection and synthesis task runs as a subagent: the Slack base facts, every slice, every area and each pass of the read. You run none of the collection scripts yourself, so you stay free to talk with the user the whole time.\n\nYou do only what a subagent can't or shouldn't:\n\n- the `request_access` and `request_status` calls, and the post with their approve links, because the user answers them;\n- the `onboarding_scripts` tool, once, before anything starts;\n- the one `gh onboarding-facts` command, because subagents never call `git__gh`;\n- planning the slices (step 2), writing each reader's report to its file, and running `check-report.mjs`;\n- the pull request (step 6) and every post.\n\n### Post to the user while the subagents read\n\nThe full read takes about nine minutes, so the user gets something to act on first. Before step 7 you post only these: the first message (step 1), the access post of step 1 if access is pending, the findings (\"Post a finding as each group of slices is checked\", below), the handoff `ask` (\"Confirm what they would hand off\", below), and your answers to anything they ask.\n\nNever ask the user free-text questions about how they work: nobody types answers during a run. What they own and how a day goes come from the `role` and `work` areas; what a bot never does without asking is set by the workspace's standing decisions, so it needs no question. The one thing the facts can only suggest, what they would hand off first, you infer and they confirm with one tap.\n\nNever post progress (\"eleven more subagents started\"), a tour of the team, or a trait or judgment before step 7, except the handoff `ask`: before then a post holds only facts the checker has passed, because the critique (5b) is what tests a judgment, and the `ask` is a guess the user settles.\n\n### The first message and the first task\n\nThe first message comes from `facts/github/quick.json` alone (step 1), in about 20 seconds, so it holds GitHub counts and links and nothing from Slack. One bubble of at most three sentences, written the way a colleague would say it: what you are reading (their Slack, Notion, GitHub and v0, and their Mac's coding-agent usage if a Mac bot is on the team) and that the full read lands in about nine minutes; then the first task as a plan, with its link, the outcome and a short why, and the link to its thread. For example: \"To kick us off, let's grab [eve#5300](https://github.com/vercel/eve/pull/5300) and get it merged. It's already approved and looks mergeable, so the failing checks are all that's left. Let's do that [in this thread](thr_…).\" The why is a plain clause about the item, never the selection rule, a ranking (\"the smallest of your approved open PRs\") or line counts. Say only what is true of the item: \"it's already approved\" for the user's own approved PR, never \"you approved it\".\n\nPick the first task yourself from `quick.json`, as a judgment with a one-line reason, not a score: one item that is small, can be finished in one thread, and is closest to landing. Look in this order: the user's approved PRs (`approved`) whose `checks` fail or that are `CONFLICTING`; their PRs with changes requested (`changesRequested`); the oldest review requests (`reviewRequested`); their assigned issues (`assignedIssues`). A candidate's state is the one `quick.json` holds for it at this run, never one remembered from an earlier run or another thread. With no candidate, the first message says so and names no task; never invent one.\n\nBefore opening a thread, run `list_threads` with `archived: include` and the PR or issue URL as `query`, then again with its `owner/repo#number`. An item an active thread already works on is already moving, so prefer a candidate no active thread holds. If you still pick one an active thread owns, use that thread: post the task there to its owner, the bot that posted its brief or build, and never instruct its builder yourself, because two bots instructing one builder undo each other's work. An archived thread takes no posts, so for an item only an archived thread mentions, open a new thread and link the archived one in the handoff. Otherwise open one thread for the task and hand it over in a post there before the first message goes out: to the builder for the user's own PR or an issue, to the reviewer for a review request. That post opens with one line to the user saying who is on it and what it does, for example \"Ok. I've put @builder on this. It'll find why the checks fail and prepare the fix, and pushes nothing until you say `go`.\" The run assigns a bot that is already on the team and never says it created one. The handoff names the PR or issue URL and says what it may do before the user answers. The builder reads the failing check logs or the conflict, finds the cause and prepares the fix on a local branch, then posts the cause and the diff, and pushes nothing until the user says `go` in that thread, because a push to an approved PR is seen by its reviewers and can dismiss the approval. The reviewer posts a draft review in the thread and sends nothing to GitHub until the user says `go`. Merging waits on the user as for any PR. The task's thread is linked again from `## Act on first` (5a).\n\nBefore writing the first message, check every number, date and name in the message against the exact field in `quick.json` it comes from: a count is `total`, not `listed` (`listed` is only the page that was read); a date is the item's own `createdAt` or `updatedAt`; a search named in `unreadable` was not read, so it is never a zero. `check-report.mjs` only checks that each link is in the facts, not that the sentence states its value right, so this check is yours.\n\nWrite the first message to `/tmp/onboarding-skill/first.md`, run `node check-report.mjs --profile first.md --facts facts` in `/tmp/onboarding-skill`, fix what it prints, and post it.\n\n### Post a finding as each group of slices is checked\n\nWhen every slice of a group below is written and passes the checker, post one finding for it, at most four in the run, in the order they come in:\n\n1. GitHub: `reviews`, the `repo-*` slices and `calendar`.\n2. LLM: `agent-assisted`, and `facts/local/` if the Mac's files are in.\n3. Slack: `mentions` and the `own-*` slices.\n4. Team: the `team-*` slices, `leadership` and `notion`.\n\nA finding is one `- ` list item of two sentences, written to `findings/<n>.md`. The first holds one count or fact copied from the group's slices, with the URL or `fact:` reference it rests on, and why it matters to the user. The second is the next step, what the team or the user does about it, linking the item, channel or thread it acts on. For example: \"103 of 237 Slack mentions in 30 days have no reply from you, and 9 of the 20 listed are in [inc-8535](https://vercel.slack.com/archives/C0AJ0C32XPB/p1794014521602859?thread_ts=1794014424.527769), so V questions still land on you while Philipp takes V over. I can list those 9 threads for Philipp so he answers them in [inc-8535](https://vercel.slack.com/archives/C0AJ0C32XPB) instead.\" A count with no reason or next step is not a finding. Check it with `node check-report.mjs --finding findings/<n>.md --facts facts` before it posts: the checker fails anything but one item of two sentences that each cite a fact; whether the reason and the step are good is yours to judge. Check its count, date and names against the exact fields of the slice it comes from first, as for the first message: `total` against `listed`, the item's own dates, and `unreadable`. Pick the fact the user would act on (\"91 review requests in vercel/eve are waiting on you\"), never a trait or a judgment, and never one already in the first message. A group whose slices found nothing worth a line posts nothing.\n\n### Confirm what they would hand off\n\nWhen the `role` and `work` areas both pass the checker, infer from them what the user would hand off first, and post one `ask`. The question is the inference with the facts behind it, ending \"Right?\", for example \"You'd hand off review chasing and Slack follow-ups first, since 240 reviews wait on you and 103 mentions have no reply. Right?\" The options are \"Yes, start there\", then up to three other readings the areas support, each with a `line` naming its fact, then \"None of these\". Labels are at most 40 characters. This is the run's only `ask` before step 7.\n\nWhen you post the `ask`, write `/tmp/onboarding-skill/facts/handoff-ask.md` with the question as posted and every option's label and `line`. As soon as they answer, append their answer to `/tmp/onboarding-skill/facts/interview.md` under `## handoff`, as three parts: `Asked:` the question word for word, `Offered:` each option's label and `line`, and `Picked:` the label they tapped, or their typed reply word for word. The read cites it as `fact:interview/handoff`, so whoever reads that fact sees what was inferred, what else was offered, and what they confirmed. A pick of \"None of these\" is a fact too: the inference was wrong. Until they answer, the read states the inference as unconfirmed, citing the facts behind it, not `fact:interview/handoff`.\n\nWhen you wait, use `eve__task_wait`: it returns on a task result or on a new message from the user. If the user asks something, answer it in the thread right away, then wait again. If they ask for work, route it as you normally would; the onboarding run goes on. Never wait for their tap: the read uses whatever has arrived when it starts, and a tap that arrives after the read pass started goes to it as \"Evidence that arrives late\" (step 5) says.\n\n### Log when each step ends\n\nThe run's length is judged against the time each step took, so log it. `onboarding_scripts` starts a fresh `/tmp/onboarding-skill/`, so right after it returns, run `echo \"start $(date -u +%T)\" > /tmp/onboarding-skill/phases.log`. When each of these ends, append one line the same way: `quick` (`gh onboarding-facts --quick` returned), `first` (the first message posted), `facts` (the full `gh onboarding-facts` returned), `finding1` to `finding4` (each finding posted, numbered in the order they post), `slices` (every wave 1 report is written and checked), `areas` (every wave 2 area is checked), `read` (5a returned), `critique` (5b returned), `write` (5c's checker prints nothing), `pr` (step 6's checks finished: the PR URL and `pass` or `fail`, or `pr none` when the workspace has no repo), `post` (step 7 posted). Step 7 never posts before the `pr` line is written. After step 7, run `node check-report.mjs --phases phases.log`: it prints any step that is missing, repeated or out of order. Keep `phases.log` with `read.md`.\n\n## 0. Hand the Mac half to the bot on the Mac\n\nYou run on Vercel and can't see the user's Mac. If `list_agents` shows a bot that runs `local`, post one message in this thread before step 1 that mentions it (the first one, if there are several) and asks it to run its local-onboarding skill, including its \"LLM usage\" section. Name the workspace repo for its sandbox PR, or say there is none. Go on without waiting: its half arrives in this thread as its own posts. Never poll for it.\n\nIts LLM-usage post links one facts file per coding agent, written by `agent-usage-facts.mjs --usage-only`: names and counts, with no web permalinks. When it arrives, fetch each linked file with `fetch_upload` into `/tmp/onboarding-skill/facts/local/usage-<source>.json` (`claude-code`, `codex`, `opencode`, `cursor`). These files, not the post's table, are what the profile cites, with `fact:` references (step 5).\n\nIf the files arrive after the `llm` synthesizer of step 4 has started, its area did not read them. Fetch them into `facts/local/`, start one `agent` copy as the `llm` synthesizer on them alone, and check its `areas/llm-local.md` with `node check-report.mjs --profile areas/llm-local.md --facts facts`. Then hand `areas/llm-local.md` on as \"Evidence that arrives late\" (step 5) says.\n\nWith no bot that runs `local`, skip this step and say nothing about a Mac; question 9 then rests on v0 alone, and \"Could not tell\" says the Mac's agent logs were not read.\n\n## 1. Access and raw facts, in one parallel step\n\nKit's tools are `request_access`, `request_status` and `execute`. Hosted, you reach them through the `vercel-kit` connection: `eve__tool({ name: \"vercel-kit__request_access\", input: { ... } })`, and the same shape for the others.\n\nMake all of these calls in one step:\n\n- `request_access({ service, reads: true, reason })` for each of `slack`, `notion` and `v0`, with a reason naming what the profile reads.\n- The `onboarding_scripts` tool: it writes the skill's scripts into `/tmp/onboarding-skill/`, makes the `facts/`, `slices/` and `areas/` directories, and installs the `gh onboarding-facts` extension.\n\nThen three more steps, each started as soon as the one before returns:\n\n1. Run `gh onboarding-facts --quick` with your `git__gh` tool. `--quick` returns in about 6 seconds and writes `facts/github/quick.json`: the user's open PRs that are approved (`approved`) or have changes requested (`changesRequested`), the 25 oldest review requests (`reviewRequested`) and their assigned issues (`assignedIssues`), each with its `total`; PRs carry `reviewDecision`, `mergeable` and `checks`.\n2. Hand over the first task in its thread if there is one and post the first message. Nothing else starts before it posts, because each subagent started first delays it.\n3. Run the full `gh onboarding-facts`, which takes about 80 seconds, and in the same step start the Slack base subagent and the leadership slice (step 3) if Slack is `approved`.\n\n- **GitHub.** Run `gh onboarding-facts` with your `git__gh` tool, one command, no `repository`. Plain `gh` in your shell holds no credential. It reads 180 days and writes `facts/github/`: `base.json` (counts, per-repo totals, merged, open, review requests, assigned issues, unanswered mentions, teams, CODEOWNERS lines; open PRs carry `draft`, `reviewDecision`, `mergeable` and `checks`), `calendar.json` (contributions per day and week, and timestamped events), `reviews.json`, `issues.json`, and `repos/<owner>__<name>.json` for the top 5 repos by active days. `unreadable` in any file lists what could not be read. A failed command means GitHub was not read.\n- **Slack base.** If Slack is `approved`, start one `agent` copy, `slack-base`, that reads `/tmp/onboarding-skill/slack-facts.callscript.js` and passes its text, unchanged, as `script` to `vercel-kit__execute`, handles the results listed below, saves the `output` whole as `facts/slack/base.json` (every list item, matching its `returned` count) in one write once the run has finished, and returns: the leadership slice's searches wait on that file. Give it these rules: never call `git__gh`, never post, and on `consent_required` or `refused` return the `approveUrl`, `run` and `refused.needed` without retrying. You post the link, and once the user approves you continue the same task by its `taskId` with `execute({ run })`. It gets 8 turns.\n- **One access post.** If any `request_access` came back `pending` or `connection_required`, post one message with every pending `approveUrl` and one line each on what it unlocks. Then `request_status({ request })` for each until it is decided. A declined service is a \"Could not tell\" line, never a retry.\n\nSlack's search limit is about 20 a minute per user. Only two scripts in the run call `search_messages`: the base script (18 searches) and the leadership slice's `lead-posts.callscript.js` (at most 10, started at least 60 seconds after the base finished). The leadership slice starts beside the base, because its first two scripts don't search, and waits only before `lead-posts.callscript.js`. No other script searches.\n\nThe base script's results, which the `slack-base` copy handles:\n\n- A `ratelimited` error: 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: post its `approveUrl`, `request_status({ request })` until decided, then `execute({ run })` continues the same run.\n- An `output` with `refused`: Slack answered `missing_scope` (or another refusal), so no facts were read. `refused.needed` names the scope. Call `request_access({ service: \"slack\", capability: \"search_messages\", reason })` and give the user its `approveUrl`, or tell them to reconnect Slack in Kit allowing `refused.needed`. The Slack slices don't start.\n- Any other error goes to the user in the final message. Never continue with fewer facts, and never retry around a refusal.\n- What it holds: `me` (id, name, title, team, time zone, start date), `window` (30 days; `newestOnly: true` means a very active account is covered only back to `own.oldest`), `own` (messages per channel, thread replies, recent snippets), `mentions` (`replied`, `unanswered`, `notJudged`, `capped`, and `mentioners`: the user ids that mention them most). DMs and group DMs are never searched; reactions are not read.\n\n## 2. Plan the slices\n\nFrom the files of step 1, pick the slices. A slice with no data isn't started, so the counts in the table are the most there can be.\n\n- **Channel clusters.** Group `own.channels` into at most 3 clusters by what they are about (team, project, support, social), judged from names and snippets.\n- **Team channels.** Up to 3 channels whose name or purpose matches the user's team as `me.title` names it (Slack's `team` field holds the workspace id, not a team), or where they post most. List them with `conversations_info` only if you can't tell from the name.\n- **Leadership.** Already running since step 1; it picks its own channels and leads (step 3).\n- **People.** Up to 15 user ids from `mentions.mentioners`, most first.\n- **Notion queries.** Up to 5: the team's name, the team plus \"strategy\" or \"roadmap\", and the org's name.\n- **Repos.** The files in `facts/github/repos/`.\n\n## 3. Wave 1: one slice per source, all at once\n\nStart every slice in one step, then wait; the leadership slice is already running from step 1. Each slice reads only its own inputs and writes `slices/<name>.md`: its findings as list items, each citing a URL from its facts, and a `## Could not tell` section. A slice cites the URL itself, copied whole from its facts, query string included (a Slack reply permalink keeps its `?thread_ts=…`, or the checker can't find it). It never cites `fact:github/...`, `fact:slack/...` or any `fact:` path but `fact:local/`: those fail the checker, and every GitHub, Slack, Notion and v0 fact has a URL. Tell each slice the user's login, Slack name, time zone and the window, the exact file paths it reads, the path it writes, and this format.\n\n| Area | Slice | Count | Reads | Runs as |\n|---|---|---|---|---|\n| Role | `own-<cluster>`: what the user writes about and does in that cluster | up to 3 | `facts/slack/base.json` | `reader` |\n| Role | `mentions`: who asks the user for what, and what went unanswered | 1 | `facts/slack/base.json` | `reader` |\n| Role | `team-<channel>`: what the team is doing, who posts, who leads | up to 3 | Kit, `team-channel.callscript.js` | `agent` |\n| Role | `leadership`: what the leads and announcement channels say the priorities are | 1 | Kit, `leadership.callscript.js` then `lead-posts.callscript.js` | `agent` |\n| Role | `people`: titles and teams of the 15 people the user works with most | 1 | Kit, `people.callscript.js` | `agent` |\n| Role | `notion`: strategy and roadmap pages for the team and org | 1 | Kit, `notion.callscript.js` | `agent` |\n| Work | `repo-<owner>__<name>`: active days, cadence, authoring versus reviewing, PR themes | up to 5 | `facts/github/repos/<owner>__<name>.json` | `reader` |\n| Work | `reviews`: whose work the user reviews, where, how deeply | 1 | `facts/github/reviews.json` | `reader` |\n| Work | `issues`: issues opened and joined | 1 | `facts/github/issues.json` | `reader` |\n| Work | `calendar`: weekly volume, weekday and hour pattern in the user's time zone, gaps | 1 | `facts/github/calendar.json` | `reader` |\n| LLM | `agent-assisted`: share of merged PRs and commits with an agent `Co-authored-by:` or a \"Generated with\" line | 1 | `facts/github/repos/` | `reader` |\n| LLM | `v0`: v0 chats and usage over 180 days | 1 | Kit, `v0.callscript.js` | `agent` |\n\n**Reader slices** (`reader` tool) are far cheaper and need no Kit. A reader never writes files, so ask it for its findings as `evidence` lines, each a claim with the URL it rests on, and its gaps in `open`. Write each report to `slices/<name>.md` yourself, unchanged: evidence lines become list items, `open` becomes the `## Could not tell` section. Give each a budget of 4 turns.\n\n**Kit slices** (`agent` copies) each run one script: read `/tmp/onboarding-skill/<script>` and pass its text as `script` to `vercel-kit__execute`, with the slice's `input`:\n\n- `/tmp/onboarding-skill/team-channel.callscript.js`: `{ channel, days: 30 }`, the channel by id. If Kit answers that the script read more than it can keep, run it once more with `days: 14` and say so in the slice.\n- `/tmp/onboarding-skill/leadership.callscript.js`, run twice. First with `{}`: it returns the user's channel memberships, and the slice picks up to 3 announcement or leadership channels from them (names like `announcements`, `eng-leads`, the team's `-leads` channel); this output is not saved. Then with `{ channels, days: 90 }`, channels by id: it reads the channels and returns their 20 most frequent posters as `candidates`, with title, team, `isBot`, `deleted` and post count, and searches nothing. The slice saves it as `facts/kit/leadership.json`, then judges from the candidates and the channels' messages who leads (what they announce, their titles, who others answer to), up to 10, with the known leads from step 2 first. Posting often is not leading: a bot, a deleted user or someone who only relays reminders is not a lead. It writes one line per chosen lead on why. Before it judges, it reads `me` in `facts/slack/base.json` for the user's title and team once that file exists.\n- `/tmp/onboarding-skill/lead-posts.callscript.js`: `{ leads, days: 90 }`, the ids the slice chose. Before it runs, the slice waits until `facts/slack/base.json` exists and 60 seconds have passed since it was written (`stat -c %Y`), checking in commands of at most 60 seconds, because the base's searches and these share Slack's limit of about 20 a minute. It saves the output as `facts/kit/lead-posts.json`.\n- `/tmp/onboarding-skill/people.callscript.js`: `{ users }`\n- `/tmp/onboarding-skill/notion.callscript.js`: `{ queries }`\n- `/tmp/onboarding-skill/v0.callscript.js`: `{ days: 180 }`\n\nThe slice saves the run's `output` as `facts/kit/<slice>.json` whole, every list item included: each list's length sits beside it in `returned`, and `check-report.mjs` fails a facts file under `facts/slack/` or `facts/kit/` whose list is shorter than its `returned` or has no `returned` count. The scripts keep their output to about 50 messages so a slice can copy all of it. It then writes `slices/<slice>.md` from it, and returns. Tell each Kit slice: never call `git__gh`, never post, never call `search_messages` outside its script; on `ratelimited` wait the `retry_after` once and run again, then record the gap; on `consent_required` or `access_denied` stop and return the `approveUrl` without retrying. Post that link, and restart the slice once after the user approves. Start a Kit slice only once its service is approved. Each gets 4 turns, and the leadership slice, which runs three scripts, gets 8.\n\nThis skill names its own count: up to 20 hosted slices run at once, overriding the fanout skill's cap of 3, because every slice only reads and writes its own file.\n\nWait with `eve__task_wait`. A slice still running 10 minutes after it started is cancelled with `eve__task_cancel`, and its area says it timed out.\n\nThen check every slice against its facts and have any slice with problems fixed once:\n\n```sh\ncd /tmp/onboarding-skill && node check-report.mjs --profile slices/*.md --facts facts\n```\n\n## 4. Wave 2: one synthesizer per area, all at once\n\nStart three `agent` copies in one step. Each reads its area's slice files and writes `areas/<area>.md` with list items that keep the slices' citations, a `## Conflicts` section where slices disagree (state both, don't pick one quietly), and a `## Could not tell` section. Each gets 6 turns and is cancelled at 10 minutes.\n\n- `role`: the role slices. Answers questions 1 to 5: team, title, start date, likely manager and team lead, what the team is doing, sibling teams, roll-up to leadership, leadership's priorities.\n- `work`: the work slices and `facts/github/base.json`. Answers questions 6 to 8, plus what they shipped and own, review requests, and which open PRs to close (stale, conflicted, superseded) or focus on.\n- `llm`: the LLM slices, and `facts/local/` if the Mac bot's files have arrived. Answers questions 9 and 10. A claim from a local file cites the value it rests on as `fact:local/usage-<source>.json#<JSON pointer>`, for example `fact:local/usage-codex.json#/usage/codex/models/0`; the times are in the file's `timeZone`.\n\n```sh\ncd /tmp/onboarding-skill && node check-report.mjs --profile areas/*.md --facts facts\n```\n\n## 5. Turn the areas into the read, in three passes\n\nThree passes, one after another. Each runs as an `agent` copy that never calls `git__gh` and never posts. The critique is a fresh copy, so it sees only the draft and the files, not the reasoning that wrote the draft.\n\n### 5a. Read pass\n\nStart one `agent` copy, `read`, with 6 turns, and cancel it at 10 minutes. Keep its `taskId`: steps 5c and late answers continue it. Give it:\n\n- the paths of the three area files, `facts/interview.md` (it may not exist yet), `facts/local/` and `facts/`;\n- the current roster: your `list_agents` output, pasted in, with each bot's `name`, `role`, `kind`, `model`, `runsOn` and subagents;\n- the standing decisions in effect, pasted in;\n- the \"`read.md` is the core read\" section above, word for word, and the rules below.\n\nIt writes `read-draft.md` with the five sections of `read.md`, then returns its path.\n\n- Every trait cites at least two different sources. Every row of `## Your team` names the trait behind it and the setting it changes: an agent entry in `hub.workspace.json` (`name`, `role`, `kind`, `model`, `runsOn`, `subagents`), a schedule, a standing decision, or a sandbox `prepare` step from the Mac half.\n- It proposes at most four changes, because the user picks them from one `ask` (step 7).\n- When the run has a first task, `## Act on first` links its thread (give it the link) as its first item, with what the task is waiting on. With no first task, the three items all come from the facts.\n- It proposes only what the kit can create today. A bot that would need a new tool or a new kind of integration is a \"Would need\" line under the table, not a row.\n- Judge; the scripts only counted. No scoring formula and no thresholds from code: say why in a sentence.\n\n### 5b. Critique pass\n\nStart one more `agent` copy, `critique`, with 4 turns. Give it the paths of `read-draft.md`, the three area files and `facts/interview.md`. For each trait and each `## Your team` row it answers:\n\n- Would this be true of most engineers on the user's team? If so, it is generic: cut it, or say what sharper claim the facts support.\n- Do the two cited sources each support it, or does one only repeat the other?\n- Does an area's `## Conflicts` section contradict it?\n- Does the team change follow from the trait, or would the same roster be proposed for anyone?\n\nIt returns one line per item: `keep`, `cut` or `rewrite`, the item's heading or row, and one line on why. It writes nothing; you save what it returns, verbatim, to `/tmp/onboarding-skill/critique.md`, so a reviewer can check each cut against the draft. The model makes the judgment; no score or threshold decides.\n\n### 5c. Write pass\n\nContinue the `read` copy by its `taskId` with the critique's list and any late evidence that has arrived (below). It applies the critique once and writes:\n\n- `read.md`: the five sections, with every `cut` gone and every `rewrite` rewritten.\n- `facts.md`: the ten questions and the four more, answered from the area files, with a `## Could not tell` section listing every `unreadable` source, `notJudged` mentions, that reactions were not read, the Slack window, a Kit grant the user declined, a slice that timed out, and the Mac half if it hasn't reported.\n- One schedule draft per recurring need it found (a morning pass over unanswered mentions, a weekly stale-PR pass), as `schedules/<bot>/<id>.md` for a bot on the current roster:\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\nA schedule file has only `cron`, so it runs every time the cron matches: `0 14 23 11 *` runs every 23 November, not once. Never call a draft one-time. A need that runs once (a pre-freeze check) is not a draft: when you post step 7, arm it with `schedule__wake__create` in this thread, `expression: { type: \"single\", at, timezone }`, `until` a few minutes after `at`, and the prompt as its note, and list it in the Schedule table with When \"once, <date>\". If a draft must still end by opening a draft PR that deletes its own file, its prompt and every line of the read and the post that names it say it recurs until that PR is merged and deployed: the schedule keeps running until the deployment without the file is live.\n\nmain also runs the kit's `workspace-review` schedule (Mondays 08:00 UTC), which every workspace has without a draft: it scores the week, keeps `memory/human.md` and the memory entries current from the user's corrections, proposes bot and schedule changes in a second draft PR, and ranks hub and kit proposals. Its row in the step 7 Schedule table says why it is there in the user's own words when the facts have them: link one or two of the user's corrections (a reply that tells a person or an agent to redo it their way) in \"What it reports\". With none in the facts, it says it starts from the corrections they make in this workspace.\n\nRules for both files:\n\n- Every list item cites at least one URL that is in the facts, or a `fact:` reference: `fact:local/usage-<source>.json#<JSON pointer>` for a value in the Mac's files, `fact:interview/handoff` for the user's pick from the handoff `ask`. No URL or reference appears that isn't in the facts. Never cite a web URL for a local count. 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. Both files name the user's repos and colleagues, so they are posted only in this thread. The one part committed is the profile, `## Who you are` and `## How you work` copied into `memory/human.md` in step 6, and only to a private workspace repo.\n\n```sh\ncd /tmp/onboarding-skill && node check-report.mjs --read read.md --profile facts.md --facts facts \\\n --critique critique.md --schedules schedules\n```\n\n`--read` checks citations and also fails a trait under `## How you work` that cites fewer than two different sources. `--critique` fails when `critique.md` is missing or holds no `keep`, `cut` or `rewrite` line; it checks presence only, not that the text is verbatim. `--schedules` fails a draft without a five-field `cron`, a draft that says it runs once, and a date-pinned or self-deleting draft whose prompt and read lines don't say it recurs until its deletion is merged and deployed. The copy fixes every line the checker prints and reruns until it prints nothing. When it returns, run the checker yourself once more; it must print nothing. Keep `read.md`, `facts.md`, `phases.log` and `critique.md`.\n\n### Evidence that arrives late\n\nLate evidence is the user's handoff answer (the whole `## handoff` entry of `facts/interview.md`: asked, offered and picked), or the Mac's `areas/llm-local.md`, that arrives after the read copy started. Evidence that is in before 5a starts goes to 5a like the rest: `facts/interview.md`, and `areas/llm-local.md` beside the three area files. Late evidence never goes into a pass that is running, and it is never dropped.\n\n- **Before 5c starts**, it goes to 5c with the critique's list.\n- **Once 5c has started**, wait for the read copy to return, then continue it by its `taskId` with only the new evidence. It judges again every trait, `## Your team` row and schedule draft the evidence bears on, including one it contradicts, rewrites them in `read.md`, updates the affected answers in `facts.md`, and returns the list of what it changed. When that list adds or rewrites a trait or a team row, start a fresh `critique` copy (5b) on just those items, giving it the same new evidence (for a handoff answer, the whole `## handoff` entry), and continue the read copy with its list. Run the checker until it prints nothing.\n- **Once step 6 has opened its PR**, a refresh that adds, changes or removes a schedule draft changes the PR the same way: write, edit or delete its `agents/<bot>/agent/schedules/<id>.md` on the PR's branch, push, and watch its checks (`gh pr checks`) until they pass, as in step 6. Before you touch its branch, check its state (`gh pr view <number> --json state`). If it is `MERGED`, never push to its branch: open a new PR from the default branch with only the changed, added or deleted schedule files, watch its checks the same way, and say the revised schedule is not deployed: the one running is the merged one, and the new one runs only once the user merges the new PR and it deploys. The changed schedule is posted only after its PR passes, or with what failed.\n- **After step 7 has posted**, do the same, then post only what changed: the rewritten lines of the read, the changed team and schedule rows, and a new `ask` if the proposed team changed.\n\nThe run is over when the user picks from the `ask`. Until then keep `facts/`, because each refresh and its checker need it; delete it then. An answer after that is ordinary conversation, not evidence for the read.\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 the schedule drafts, as `agents/<bot>/agent/schedules/<id>.md`, and the user's profile as `memory/human.md` (`gh pr create`). Nothing else from `read.md` or `facts.md` goes in it.\n\n`memory/human.md` is the one profile every bot recalls on every turn, and the weekly workspace review rewrites it from then on, so it has one format, defined in `profile.mjs` beside the checker. Write it as the `## Who you are` and `## How you work` sections of `read.md`, verbatim, and check it:\n\n```sh\ncd /tmp/onboarding-skill && node check-report.mjs --human <repo>/memory/human.md\n```\n\nCommit it only when `gh repo view --json isPrivate --jq .isPrivate` prints `true`, because it names the user's colleagues and repos. When it prints `false`, the command fails, or the repo already has a `memory/human.md`, leave it out and say so in the message. With no drafts and no profile to add, open no PR. 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 the read, the team and the schedules\n\nThe user reads it cold, often only its first line (your writing rules). This one message replaces any tour of the team: it shows the team as it should be for them. Write it to `/tmp/onboarding-skill/post.md` in this shape, load the forms skill for the tables, and check it:\n\n```md\nThe read in two sentences: who they are and how they work, then the one thing to act on first.\n\n---\n\n| Bot | Change | Why |\n|---|---|---|\n(one row per current bot, \"keep\" or the change, then one row per proposed bot, each Why naming its trait)\n\n| Schedule | Bot | When | What it reports |\n|---|---|---|---|\n(one row per draft, then `workspace-review` for `main`, which the kit already runs, then one per one-run wake-up)\n\n[The read](...) and [the facts it cites](...). The 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 --schedules /tmp/onboarding-skill/schedules\n```\n\nFix every line it prints and rerun until it prints nothing, then post the file's text in this thread, with `read.md` and `facts.md` linked as uploads.\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 links line. Without a repo, it says the drafts stay in the thread. If the PR's checks still fail after step 6, it says so and what failed, in place of \"merging it turns them on\".\n - One line per source you could not read or Kit grant the user declined: what was not read and what they must do.\n - If you handed the Mac half over in step 0 and that bot hasn't posted yet, one line: its half hasn't reported yet, and if the Hub app is closed on their Mac, it runs once they open it.\n- No data tables in the chat: counts, windows, the ten answers and the \"Could not tell\" section stay in `facts.md`.\n\nThen post one `ask` with one option per proposed change in the \"Your team\" table (an added, changed or removed bot), plus \"None for now\". An `ask` holds at most 5 options, so the read proposes at most four changes. The onboarding run never creates, changes or removes a bot. What the user picks is normal work in this thread: route it to the builder, which edits `hub.workspace.json` and the role files in its own PR, because a new bot is a deploy and a running cost.\n\nOnly the schedule files ever leave the thread.\n"};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
// Generated by scripts/build-onboarding.mjs from workspace-review/SKILL.md. Do not edit.
|
|
2
|
+
export default {"description":"The weekly workspace review. Load when the workspace-review schedule fires or the human asks you to \"run the workspace review\", and follow it to score the week, open draft PRs for the human's profile and memory and for the bots and schedules, and post ranked hub and kit proposals.","content":"# Weekly workspace review\n\nThe goal is hub's goal 1, a self-driving harness: the bots handle the work and bring the human only what is theirs, at the moment they can act on it, in the form they understand fastest. The review measures how close the workspace is, finds what holds it back, and changes it. Every finding names the measure it moves and that measure's value this week. A finding that moves none goes last, marked optional.\n\nThe `workspace-review` schedule posts into its own thread every Monday at 08:00 UTC. The human can also ask for a run in any thread; then you post the result in that thread. The run takes at most 30 minutes of your time.\n\n## 1. Measure the week\n\nRead the last 14 days, so each number has this week and the week before.\n\n- **Threads.** `list_threads` with `archived: \"include\"`, then `read_thread` on every thread with activity in the window, paging back with `before` until the messages are older than 14 days.\n- **Ledger.** `hub_usage` one day at a time (`since` and `until` one day apart). A window longer than a day can fail with HTTP 500.\n- **Rules.** `memory/` and `agents/<bot>/memory/` in the workspace repo, and the workspace's `hub.workspace.json`, each bot's `instructions.md` and `role.md`, and its schedules.\n- **GitHub.** With `git__gh`, the human's PRs opened, reviewed and merged in the window (`gh search prs --author <login>`, `--reviewed-by <login>`, `--merged`).\n\nCompute these numbers for each of the two weeks:\n\n| Measure | How to count it |\n|---|---|\n| Bots handle the work | Threads closed (archived or phase done) in which the human posted nothing beyond the brief's yes and the merge approval, out of all threads closed |\n| Only what is theirs | Bot posts that mentioned the human with something a bot could have settled: a question the code or the thread history answers, or a menu of options a bot could pick from |\n| At the right time | Threads waiting on the human (`list_threads` with `waitingOn: \"human\"`) and the oldest wait, plus the median hours from a bot's ask to the human's answer |\n| Maximizes understanding | The human's follow-up questions and corrections about a post's length, format, missing visuals or undefined names |\n| Spend | Dollars, turns, turns that posted nothing, failed or cancelled turns, and compactions, from `hub_usage` |\n\nKeep the message link for every post you count. Each number in the post and the PRs cites the messages behind it.\n\nStart one `agent` copy per part below, in parallel, each with the window, the thread ids and the counts, and ask each to return its findings with their message links. Agent copies never call `git__gh`: you make every clone, commit and PR yourself.\n\n## 2. Part 1, the human's profile and memory\n\nThis part moves \"only what is theirs\" and \"maximizes understanding\".\n\n### The profile\n\n`memory/human.md` is the one profile file. Every bot recalls it on every turn, onboarding writes its first version, and you rewrite it here. Its format is defined once, in the kit's `onboarding/profile.mjs` (`## Who you are`, `## How you work` with 4 to 6 traits, then `## What you worked on`, one page at most), and the deploy's `validateMemory` rejects a file that breaks it. Rewrite it from last week's version:\n\n- `## Who you are` says their role in their own words and what they spend their attention on.\n- In `## How you work`, each evidence item is a message link from a different thread. A preference the human stated cites the message. One inferred from behavior says \"inferred\" and cites at least two messages. `Against:` says whether the week showed what would contradict the trait, and `Drives:` says what the bots do differently because of it.\n- `## What you worked on` has one row per project path, with its threads, the days it was active and its turns this week.\n\nKeep a trait from last week's file unless the week contradicts it. Change a trait only with new evidence, and say which message changed it. When the file doesn't exist yet, write it from this week alone.\n\nThe profile names the human's colleagues and repos, so it lives only in a private repo. Before you write it, run `gh repo view --json isPrivate --jq .isPrivate` in the workspace repo's checkout. Unless it prints `true` (it prints `false`, or the command fails), don't create, change or commit `memory/human.md`, and keep its contents out of the PR and its description. Say in your post that the profile was skipped because the repo isn't confirmed private.\n\n### The memory entries\n\n1. Keep each post where the human corrected a bot:\n - a rule restated: the human says how something should always be done (\"never ask me for a token\");\n - an answer rejected: the human says a result, a plan or a post is wrong;\n - work redone their way: the human asks a bot to redo something differently, or does it themselves.\n2. Drop corrections that only apply to the task at hand (\"rename this button\"). Keep the ones another bot would break next week in another thread.\n3. Skip a correction an active entry already says. When a correction changes an active entry, it replaces that entry.\n\nOne file per rule. A rule for the whole team goes in `memory/<id>.md`. A rule about one bot goes in `agents/<bot>/memory/<id>.md`. Shared entries name no bot: a rule for one role says the role (\"the builder\").\n\n```\n---\nid: pr-on-first-push\nstatus: active\nsince: 2026-10-08\nevidence: [thr_084n/msg_08ir, msg_08j0]\n---\nOpen a draft PR the first time you push.\n```\n\n- `id` is lowercase letters, digits and hyphens, and matches the file name.\n- `since` is the date of the correction, as `YYYY-MM-DD`.\n- `evidence` lists the correction's messages. The first item names its thread (`thr_…/msg_…`). A later `msg_…` on its own is another message in the thread cited just before it.\n- The rule follows the front matter in at most 3 lines, as an instruction, with the exact names from the thread.\n- When an entry replaces an old one, write the new entry under a new id. In the old file, change only its front matter: `status: superseded` and `superseded_by: <new id>`. Never rewrite or delete the old rule.\n\nBefore you commit, check the profile and the entries in the workspace repo's checkout and fix every line it prints:\n\n```sh\nnpm install --no-audit --no-fund --ignore-scripts\nnode --input-type=module -e \"import { validateMemory } from 'eve-hub/memory'; validateMemory('.')\"\n```\n\nCommit the entries, and `memory/human.md` when the repo is confirmed private, on one branch of the workspace repo and open one draft PR. Its description lists one line per entry: the rule and a link to the correction it came from.\n\n## 3. Part 2, the bots and schedules\n\nThis part moves all four measures. Look for these, each with its number:\n\n- **Spend that bought nothing.** Turns that posted nothing, failed or cancelled turns, compactions per bot, and threads with high spend and no merge.\n- **Handoffs that cost turns.** Threads that went more than two round trips between the same two bots, and work the human had to redo.\n- **Communication.** Posts the human corrected for length, format or missing visuals, and the threads waiting on them longest, with why each waits.\n- **Asks that weren't theirs.** Posts that asked the human something a bot could have answered, and threads waiting on them that a bot could close.\n- **Schedules.** Schedules whose posts the human never acted on, work the human asked for again and again that a schedule could do, and wake-ups that fired with nothing to do.\n\nFix what the workspace repo can fix: a bot's `instructions.md` or `role.md`, a model or subagent in `hub.workspace.json`, or a workspace schedule added, changed or removed. One commit per change, its message naming the measure it should move and the measure's value this week. Open these on a second branch as a second draft PR, so the human can merge the profile without the bot changes. A change that rests on a trait from part 1 cites it.\n\n## 4. Part 3, the hub and kit\n\nThe hub and the kit live in the hub repository, not the workspace repo, so this part only proposes. It moves \"at the right time\" and \"maximizes understanding\".\n\nLook for posts that needed a `flow`, `sequence` or `chart` form and had none, explanations the human asked for twice, and friction in kit schedules, tools and skills (a tool that failed, a step a bot repeated by hand, a wait no event ended).\n\nWrite a proposals doc (markdown in your sandbox) with one row per proposal, ranked by the measure it moves: the proposal, its evidence as message links, the measure and its value this week, and the role that would own it.\n\n## 5. Post the result\n\nPost once, in the thread the run started in:\n\n1. One or two sentences on what moved since last week.\n2. A `chart` form with the four measures and the spend numbers, this week next to last week.\n3. Links to the two draft PRs and the proposals doc. Say why a PR wasn't opened when its part found nothing.\n4. An `ask` form, one option per proposal, so the human can pick which become briefs. Hand each pick to the planner in its own thread.\n5. The run's own cost: its turns and dollars from `hub_usage`.\n\nNever merge either PR. The human merges them, and they take effect on the deployment that contains them. A run that finds nothing to change posts the chart and says so, and opens no PR.\n"};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { defineSkill } from "eve/skills";
|
|
2
|
+
import skill from "./workspace-review.gen.mjs";
|
|
3
|
+
// The workspace-review skill: workspace-review/SKILL.md, embedded at build time (scripts/build-onboarding.mjs) like
|
|
4
|
+
// the forms skill. main loads it when its workspace-review schedule (schedules/workspace-review.ts) fires.
|
|
5
|
+
export default defineSkill(skill);
|
package/stubs.mjs
CHANGED
|
@@ -28,6 +28,7 @@ export const KIT_FILES = {
|
|
|
28
28
|
export const MAIN_KIT_FILES = {
|
|
29
29
|
...KIT_FILES,
|
|
30
30
|
"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."],
|
|
31
|
+
"skills/workspace-review.ts": ["The workspace-review skill (packages/agent-kit/workspace-review/SKILL.md): the steps main follows when its workspace-review schedule fires."],
|
|
31
32
|
"tools/onboarding_scripts.ts": ["Writes the onboarding-profile skill's sandbox scripts into the sandbox."],
|
|
32
33
|
"tools/create_thread.ts": ["Open a workspace thread for one piece of work."],
|
|
33
34
|
"tools/move_path.ts": ["Rename or merge a path (project/track): every thread under it moves."],
|
|
@@ -43,6 +44,7 @@ export const MAIN_KIT_FILES = {
|
|
|
43
44
|
// Handler schedules: the hub fires them from its minute clock (registry/schedules.mjs).
|
|
44
45
|
"schedules/tracker-prs.ts": ["Every 10 minutes: tracker rows linked to a GitHub PR or issue move to Done when it merged and to Dropped when it closed."],
|
|
45
46
|
"schedules/prune.ts": ["Every 10 minutes: one prune pass over what archived or deleted threads leave on disk: worktrees, scratch, their-only uploads, idle bots' traces and workflow runs; unsaved work kept as refs/bundles."],
|
|
47
|
+
"schedules/workspace-review.ts": ["Mondays 08:00 UTC: asks main to score the week and open draft PRs for the human's profile and memory and for the bots and schedules, plus a proposals doc for the hub and kit."],
|
|
46
48
|
};
|
|
47
49
|
|
|
48
50
|
// The kit stubs a deployed main keeps: ones whose whole import closure in the
|
|
@@ -56,6 +58,8 @@ export const MAIN_KIT_FILES = {
|
|
|
56
58
|
export const DEPLOYED_KIT_FILES = new Set([
|
|
57
59
|
"channels/eve.ts",
|
|
58
60
|
"schedules/tracker-prs.ts",
|
|
61
|
+
"schedules/workspace-review.ts",
|
|
62
|
+
"skills/workspace-review.ts",
|
|
59
63
|
"skills/onboarding-profile.ts",
|
|
60
64
|
"tools/onboarding_scripts.ts",
|
|
61
65
|
...["react", "show", "fetch_upload", "update_thread", "list_threads", "read_thread", "post_message"].map((t) => `tools/${t}.ts`),
|
|
@@ -23,4 +23,4 @@ You are the **coordinator**, the human's chief of staff. The human talks to you
|
|
|
23
23
|
|
|
24
24
|
**Keep the workspace legible.** Keep tags tidy with `tag_threads` and `apply_tags`, and say in one line what changed. On the Dash, the hub sets ready, and a deferral the human made stays. Tags and paths the human set stay too. In a track chat, answer from that track's threads, their states and PRs, and post new work in the thread that owns it.
|
|
25
25
|
|
|
26
|
-
**Memory.** A preference that should outlast the task becomes `memory/<id>.md` (every bot) or `agents/<bot>/memory/<id>.md` (one bot). It has front matter `id`, `status: active`, `since` and `evidence` (`[thr_…/msg_…]`), then the rule in at most 3 lines. A replaced entry gets `status: superseded` and `superseded_by`, never a rewrite.
|
|
26
|
+
**Memory.** A preference that should outlast the task becomes `memory/<id>.md` (every bot) or `agents/<bot>/memory/<id>.md` (one bot). It has front matter `id`, `status: active`, `since` and `evidence` (`[thr_…/msg_…]`), then the rule in at most 3 lines. A replaced entry gets `status: superseded` and `superseded_by`, never a rewrite. The human's profile is `memory/human.md`, one page every bot recalls each turn; only onboarding and the weekly workspace review write it, each in a draft PR.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: workspace-review
|
|
3
|
+
description: The weekly workspace review. Load when the workspace-review schedule fires or the human asks you to "run the workspace review", and follow it to score the week, open draft PRs for the human's profile and memory and for the bots and schedules, and post ranked hub and kit proposals.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Weekly workspace review
|
|
7
|
+
|
|
8
|
+
The goal is hub's goal 1, a self-driving harness: the bots handle the work and bring the human only what is theirs, at the moment they can act on it, in the form they understand fastest. The review measures how close the workspace is, finds what holds it back, and changes it. Every finding names the measure it moves and that measure's value this week. A finding that moves none goes last, marked optional.
|
|
9
|
+
|
|
10
|
+
The `workspace-review` schedule posts into its own thread every Monday at 08:00 UTC. The human can also ask for a run in any thread; then you post the result in that thread. The run takes at most 30 minutes of your time.
|
|
11
|
+
|
|
12
|
+
## 1. Measure the week
|
|
13
|
+
|
|
14
|
+
Read the last 14 days, so each number has this week and the week before.
|
|
15
|
+
|
|
16
|
+
- **Threads.** `list_threads` with `archived: "include"`, then `read_thread` on every thread with activity in the window, paging back with `before` until the messages are older than 14 days.
|
|
17
|
+
- **Ledger.** `hub_usage` one day at a time (`since` and `until` one day apart). A window longer than a day can fail with HTTP 500.
|
|
18
|
+
- **Rules.** `memory/` and `agents/<bot>/memory/` in the workspace repo, and the workspace's `hub.workspace.json`, each bot's `instructions.md` and `role.md`, and its schedules.
|
|
19
|
+
- **GitHub.** With `git__gh`, the human's PRs opened, reviewed and merged in the window (`gh search prs --author <login>`, `--reviewed-by <login>`, `--merged`).
|
|
20
|
+
|
|
21
|
+
Compute these numbers for each of the two weeks:
|
|
22
|
+
|
|
23
|
+
| Measure | How to count it |
|
|
24
|
+
|---|---|
|
|
25
|
+
| Bots handle the work | Threads closed (archived or phase done) in which the human posted nothing beyond the brief's yes and the merge approval, out of all threads closed |
|
|
26
|
+
| Only what is theirs | Bot posts that mentioned the human with something a bot could have settled: a question the code or the thread history answers, or a menu of options a bot could pick from |
|
|
27
|
+
| At the right time | Threads waiting on the human (`list_threads` with `waitingOn: "human"`) and the oldest wait, plus the median hours from a bot's ask to the human's answer |
|
|
28
|
+
| Maximizes understanding | The human's follow-up questions and corrections about a post's length, format, missing visuals or undefined names |
|
|
29
|
+
| Spend | Dollars, turns, turns that posted nothing, failed or cancelled turns, and compactions, from `hub_usage` |
|
|
30
|
+
|
|
31
|
+
Keep the message link for every post you count. Each number in the post and the PRs cites the messages behind it.
|
|
32
|
+
|
|
33
|
+
Start one `agent` copy per part below, in parallel, each with the window, the thread ids and the counts, and ask each to return its findings with their message links. Agent copies never call `git__gh`: you make every clone, commit and PR yourself.
|
|
34
|
+
|
|
35
|
+
## 2. Part 1, the human's profile and memory
|
|
36
|
+
|
|
37
|
+
This part moves "only what is theirs" and "maximizes understanding".
|
|
38
|
+
|
|
39
|
+
### The profile
|
|
40
|
+
|
|
41
|
+
`memory/human.md` is the one profile file. Every bot recalls it on every turn, onboarding writes its first version, and you rewrite it here. Its format is defined once, in the kit's `onboarding/profile.mjs` (`## Who you are`, `## How you work` with 4 to 6 traits, then `## What you worked on`, one page at most), and the deploy's `validateMemory` rejects a file that breaks it. Rewrite it from last week's version:
|
|
42
|
+
|
|
43
|
+
- `## Who you are` says their role in their own words and what they spend their attention on.
|
|
44
|
+
- In `## How you work`, each evidence item is a message link from a different thread. A preference the human stated cites the message. One inferred from behavior says "inferred" and cites at least two messages. `Against:` says whether the week showed what would contradict the trait, and `Drives:` says what the bots do differently because of it.
|
|
45
|
+
- `## What you worked on` has one row per project path, with its threads, the days it was active and its turns this week.
|
|
46
|
+
|
|
47
|
+
Keep a trait from last week's file unless the week contradicts it. Change a trait only with new evidence, and say which message changed it. When the file doesn't exist yet, write it from this week alone.
|
|
48
|
+
|
|
49
|
+
The profile names the human's colleagues and repos, so it lives only in a private repo. Before you write it, run `gh repo view --json isPrivate --jq .isPrivate` in the workspace repo's checkout. Unless it prints `true` (it prints `false`, or the command fails), don't create, change or commit `memory/human.md`, and keep its contents out of the PR and its description. Say in your post that the profile was skipped because the repo isn't confirmed private.
|
|
50
|
+
|
|
51
|
+
### The memory entries
|
|
52
|
+
|
|
53
|
+
1. Keep each post where the human corrected a bot:
|
|
54
|
+
- a rule restated: the human says how something should always be done ("never ask me for a token");
|
|
55
|
+
- an answer rejected: the human says a result, a plan or a post is wrong;
|
|
56
|
+
- work redone their way: the human asks a bot to redo something differently, or does it themselves.
|
|
57
|
+
2. Drop corrections that only apply to the task at hand ("rename this button"). Keep the ones another bot would break next week in another thread.
|
|
58
|
+
3. Skip a correction an active entry already says. When a correction changes an active entry, it replaces that entry.
|
|
59
|
+
|
|
60
|
+
One file per rule. A rule for the whole team goes in `memory/<id>.md`. A rule about one bot goes in `agents/<bot>/memory/<id>.md`. Shared entries name no bot: a rule for one role says the role ("the builder").
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
---
|
|
64
|
+
id: pr-on-first-push
|
|
65
|
+
status: active
|
|
66
|
+
since: 2026-10-08
|
|
67
|
+
evidence: [thr_084n/msg_08ir, msg_08j0]
|
|
68
|
+
---
|
|
69
|
+
Open a draft PR the first time you push.
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- `id` is lowercase letters, digits and hyphens, and matches the file name.
|
|
73
|
+
- `since` is the date of the correction, as `YYYY-MM-DD`.
|
|
74
|
+
- `evidence` lists the correction's messages. The first item names its thread (`thr_…/msg_…`). A later `msg_…` on its own is another message in the thread cited just before it.
|
|
75
|
+
- The rule follows the front matter in at most 3 lines, as an instruction, with the exact names from the thread.
|
|
76
|
+
- When an entry replaces an old one, write the new entry under a new id. In the old file, change only its front matter: `status: superseded` and `superseded_by: <new id>`. Never rewrite or delete the old rule.
|
|
77
|
+
|
|
78
|
+
Before you commit, check the profile and the entries in the workspace repo's checkout and fix every line it prints:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
npm install --no-audit --no-fund --ignore-scripts
|
|
82
|
+
node --input-type=module -e "import { validateMemory } from 'eve-hub/memory'; validateMemory('.')"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Commit the entries, and `memory/human.md` when the repo is confirmed private, on one branch of the workspace repo and open one draft PR. Its description lists one line per entry: the rule and a link to the correction it came from.
|
|
86
|
+
|
|
87
|
+
## 3. Part 2, the bots and schedules
|
|
88
|
+
|
|
89
|
+
This part moves all four measures. Look for these, each with its number:
|
|
90
|
+
|
|
91
|
+
- **Spend that bought nothing.** Turns that posted nothing, failed or cancelled turns, compactions per bot, and threads with high spend and no merge.
|
|
92
|
+
- **Handoffs that cost turns.** Threads that went more than two round trips between the same two bots, and work the human had to redo.
|
|
93
|
+
- **Communication.** Posts the human corrected for length, format or missing visuals, and the threads waiting on them longest, with why each waits.
|
|
94
|
+
- **Asks that weren't theirs.** Posts that asked the human something a bot could have answered, and threads waiting on them that a bot could close.
|
|
95
|
+
- **Schedules.** Schedules whose posts the human never acted on, work the human asked for again and again that a schedule could do, and wake-ups that fired with nothing to do.
|
|
96
|
+
|
|
97
|
+
Fix what the workspace repo can fix: a bot's `instructions.md` or `role.md`, a model or subagent in `hub.workspace.json`, or a workspace schedule added, changed or removed. One commit per change, its message naming the measure it should move and the measure's value this week. Open these on a second branch as a second draft PR, so the human can merge the profile without the bot changes. A change that rests on a trait from part 1 cites it.
|
|
98
|
+
|
|
99
|
+
## 4. Part 3, the hub and kit
|
|
100
|
+
|
|
101
|
+
The hub and the kit live in the hub repository, not the workspace repo, so this part only proposes. It moves "at the right time" and "maximizes understanding".
|
|
102
|
+
|
|
103
|
+
Look for posts that needed a `flow`, `sequence` or `chart` form and had none, explanations the human asked for twice, and friction in kit schedules, tools and skills (a tool that failed, a step a bot repeated by hand, a wait no event ended).
|
|
104
|
+
|
|
105
|
+
Write a proposals doc (markdown in your sandbox) with one row per proposal, ranked by the measure it moves: the proposal, its evidence as message links, the measure and its value this week, and the role that would own it.
|
|
106
|
+
|
|
107
|
+
## 5. Post the result
|
|
108
|
+
|
|
109
|
+
Post once, in the thread the run started in:
|
|
110
|
+
|
|
111
|
+
1. One or two sentences on what moved since last week.
|
|
112
|
+
2. A `chart` form with the four measures and the spend numbers, this week next to last week.
|
|
113
|
+
3. Links to the two draft PRs and the proposals doc. Say why a PR wasn't opened when its part found nothing.
|
|
114
|
+
4. An `ask` form, one option per proposal, so the human can pick which become briefs. Hand each pick to the planner in its own thread.
|
|
115
|
+
5. The run's own cost: its turns and dollars from `hub_usage`.
|
|
116
|
+
|
|
117
|
+
Never merge either PR. The human merges them, and they take effect on the deployment that contains them. A run that finds nothing to change posts the chart and says so, and opens no PR.
|