eve-hub 0.16.10 → 0.16.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ export type KitUpdate = { files: Record<string, string>; deletions: string[]; reported: string[] };
2
+ export declare class KitChangeError extends Error {}
3
+ export declare function kitChange(source: { paths: string[]; read: (paths: string[]) => Promise<Map<string, string>> }): Promise<KitUpdate>;
package/kit-change.mjs ADDED
@@ -0,0 +1,66 @@
1
+ // What a kit update changes in a workspace repo, read from any source of its files: the hosted hub reads them over
2
+ // GitHub's API (hosted-hub/src/onboard/repo-bump.ts), `eve-hub update` (update.mjs) from a checkout. syncKit's kit-file
3
+ // changes, the `eve-hub/world` line dropped from each `agent.ts`, and each hosted bot's `sandbox.ts` moved to one
4
+ // `git__gh` runs in (migrateSandbox). Customized files are left alone and reported.
5
+ import { isKitFile, syncKit } from "./sync-kit.mjs";
6
+ import { migrateSandbox } from "./sandbox-migrate.mjs";
7
+ import { agentRunsOn, parseWorkspace, WorkspaceError, WORKSPACE_FILE } from "./workspace.mjs";
8
+ import KIT_MODULES from "./kit-modules.json" with { type: "json" };
9
+
10
+ const AGENT_FILE = /^agents\/([^/]+)\/agent\/(.+)$/;
11
+
12
+ // The line workspaces made before kit 0.13.0 have in `agent.ts`. The kit no longer exports `eve-hub/world`, so a build that keeps it fails.
13
+ const HUB_WORLD_LINE = /^[ \t]*experimental:\s*\{\s*workflow:\s*\{\s*world:\s*(["'])eve-hub\/world\1\s*\}\s*\},?[ \t]*\r?\n/m;
14
+
15
+ // A repo a person must repair before the kit can update it; the message says what to fix.
16
+ export class KitChangeError extends Error {}
17
+
18
+ // `paths` are every file in the repo, workspace-relative; `read(paths)` returns their text by path.
19
+ export async function kitChange({ paths, read }) {
20
+ if (!paths.includes(WORKSPACE_FILE)) throw new KitChangeError(`no ${WORKSPACE_FILE}`);
21
+ const definition = definitionOf((await read([WORKSPACE_FILE])).get(WORKSPACE_FILE));
22
+ const isAgentFile = (path) => /(?:^|\/)agent\.ts$/.test(AGENT_FILE.exec(path)?.[2] ?? "");
23
+ const hostedSandbox = (path) => {
24
+ const m = AGENT_FILE.exec(path);
25
+ return m?.[2] === "sandbox.ts" && definition.agents.some((a) => a.name === m[1]) && agentRunsOn(definition, m[1]) === "hosted";
26
+ };
27
+ const found = paths.filter((p) => isKitFile(AGENT_FILE.exec(p)?.[2] ?? "") || isAgentFile(p) || hostedSandbox(p));
28
+ const text = await read(found);
29
+ const files = new Map(definition.agents.map((a) => [a.name, {}]));
30
+ const agentFiles = [], sandboxes = [];
31
+ for (const p of found) {
32
+ const [, bot, file] = AGENT_FILE.exec(p);
33
+ if (!files.has(bot)) continue;
34
+ if (isAgentFile(p)) agentFiles.push({ path: p, text: text.get(p) });
35
+ if (hostedSandbox(p)) sandboxes.push({ path: p, text: text.get(p) });
36
+ else if (isKitFile(file)) files.get(bot)[file] = text.get(p);
37
+ }
38
+ const tiersOf = (name) => Object.keys(definition.agents.find((a) => a.name === name)?.subagents ?? {});
39
+ const bots = [...files].map(([name, f]) => ({ name, runsOn: agentRunsOn(definition, name), files: f, subagents: tiersOf(name) }));
40
+ const change = syncKit({ bots, modules: KIT_MODULES });
41
+ const path = (bot, file) => `agents/${bot}/agent/${file}`;
42
+ const world = agentFiles.filter((f) => f.text.includes("eve-hub/world")).map((f) => ({ path: f.path, text: f.text.replace(HUB_WORLD_LINE, "") }));
43
+ const moved = sandboxes.map((f) => ({ path: f.path, text: f.text, ...migrateSandbox(f.text) }));
44
+ return {
45
+ files: {
46
+ ...Object.fromEntries(change.write.map((w) => [path(w.bot, w.file), w.content])),
47
+ ...Object.fromEntries(world.filter((f) => !f.text.includes("eve-hub/world")).map((f) => [f.path, f.text])),
48
+ ...Object.fromEntries(moved.filter((f) => f.source !== undefined && f.source !== f.text).map((f) => [f.path, f.source])),
49
+ },
50
+ deletions: change.delete.map((d) => path(d.bot, d.file)),
51
+ reported: [
52
+ ...change.report.map((r) => `${path(r.bot, r.file)} ${r.reason}`),
53
+ ...world.filter((f) => f.text.includes("eve-hub/world")).map((f) => `${f.path} still imports eve-hub/world, which the kit no longer has`),
54
+ ...moved.filter((f) => f.reason).map((f) => `${f.path} ${f.reason}`),
55
+ ],
56
+ };
57
+ }
58
+
59
+ function definitionOf(text) {
60
+ let json;
61
+ try { json = JSON.parse(text); } catch { throw new KitChangeError(`${WORKSPACE_FILE} is not valid JSON; fix it, then retry`); }
62
+ try { return parseWorkspace(json); } catch (e) {
63
+ if (e instanceof WorkspaceError) throw new KitChangeError(`${e.message}; fix it, then retry`);
64
+ throw e;
65
+ }
66
+ }
@@ -0,0 +1,6 @@
1
+ export declare const KIT_STORE: string;
2
+ export declare const KIT_LATEST: string;
3
+ export declare function kitTarball(version: string): string;
4
+ export declare function kitTarballUrl(version: string): string;
5
+ export declare function kitLatestUrl(): string;
6
+ export declare function pinnedVersion(pin: string | undefined): string | undefined;
package/kit-store.mjs ADDED
@@ -0,0 +1,14 @@
1
+ // Where each eve-hub version's tarball is served: a public Vercel Blob store (eve-hub-kit, on vercel-internal-playground),
2
+ // one immutable file per version, uploaded by scripts/publish.mjs before the npm publish. Hosted workspaces pin this URL,
3
+ // so nothing a deploy waits on reads eve-hub from npm, whose publish-time malware scan can hold a new version 15 minutes or more.
4
+ // `latest.json` beside them names the newest release; a workspace's eve-hub-update workflow reads it.
5
+ export const KIT_STORE = "https://fql3r3ljskj7rzcd.public.blob.vercel-storage.com";
6
+ export const kitTarball = (version) => `eve-hub/eve-hub-${version}.tgz`;
7
+ export const kitTarballUrl = (version) => `${KIT_STORE}/${kitTarball(version)}`;
8
+ export const KIT_LATEST = "eve-hub/latest.json";
9
+ export const kitLatestUrl = () => `${KIT_STORE}/${KIT_LATEST}`;
10
+
11
+ // The version a pin names: a tarball URL from the store or a plain version; undefined for anything else (a link, a range).
12
+ export function pinnedVersion(pin) {
13
+ return /(?:^|\/eve-hub-)(\d+\.\d+\.\d+)(?:\.tgz)?$/.exec(pin ?? "")?.[1];
14
+ }
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "eve-hub",
3
- "version": "0.16.10",
3
+ "version": "0.16.12",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Bot-side code shared by every hub agent: registry tools, the roster hook, the eve channel, the host sandbox and browser, and model helpers.",
6
6
  "type": "module",
7
+ "bin": {
8
+ "eve-hub": "./update.mjs"
9
+ },
7
10
  "exports": {
8
11
  "./package.json": "./package.json",
9
12
  "./tools/*": "./tools/*.js",
@@ -30,7 +33,10 @@
30
33
  "./bot-token": "./bot-token.mjs",
31
34
  "./hub-oidc": "./hub-oidc.mjs",
32
35
  "./prepare": "./prepare.mjs",
33
- "./vercel": "./vercel.mjs"
36
+ "./vercel": "./vercel.mjs",
37
+ "./kit-change": "./kit-change.mjs",
38
+ "./kit-store": "./kit-store.mjs",
39
+ "./update": "./update.mjs"
34
40
  },
35
41
  "peerDependencies": {
36
42
  "@vercel/connect": "^2.4.1",
@@ -47,5 +53,8 @@
47
53
  "smol-toml": "^1.9.1",
48
54
  "tree-sitter-bash": "0.25.1",
49
55
  "web-tree-sitter": "0.27.1"
56
+ },
57
+ "hubPins": {
58
+ "eve": "0.88.0"
50
59
  }
51
60
  }
@@ -4,10 +4,13 @@
4
4
  import * as z from "zod";
5
5
 
6
6
  export const VERBS = ["Land", "Answer", "Pick", "Look", "Review", "Read", "Write"];
7
- const Row = z.object({ id: z.string().min(1), title: z.string().min(1).max(120), verb: z.enum(VERBS), minutes: z.int().min(0).max(600), consent: z.boolean() });
7
+ const Row = z.object({
8
+ id: z.string().min(1), title: z.string().min(1).max(120), verb: z.enum(VERBS), minutes: z.int().min(0).max(600), consent: z.boolean(),
9
+ why: z.string().min(1).max(160).optional(), park: z.iso.date().nullable().optional(),
10
+ });
8
11
  export const PlanBody = z.object({
9
12
  line: z.string().min(1).max(240),
10
- chunks: z.array(z.object({ title: z.string().min(1).max(120), why: z.string().min(1).max(240), rows: z.array(Row).min(1) })).max(40),
13
+ chunks: z.array(z.object({ title: z.string().min(1).max(120), why: z.string().min(1).max(240), track: z.string().min(1).max(160).optional(), since: z.string().min(1).max(160).optional(), rows: z.array(Row).min(1) })).max(40),
11
14
  later: z.object({ line: z.string().min(1).max(240), rows: z.array(Row) }).nullable(),
12
15
  moved: z.array(z.object({ ask: z.string().min(1), reason: z.string().min(1).max(240) })),
13
16
  });
@@ -9,6 +9,10 @@
9
9
  // or the local day changed. Answering an ask doesn't make one: the answered row drops out in the
10
10
  // app. The previous plan goes into the prompt as a fact, so the list stands still between visits.
11
11
  //
12
+ // Parking: a row may carry `park`, a local day. The first plan step on or after it defers that
13
+ // thread through the Dash entries, the way the human's own defer does, and stores the plan without
14
+ // the row. The day changing already makes a plan step, so parking needs no timer of its own.
15
+ //
12
16
  // Plain JS on purpose: the schedule (TypeScript, bundled by eve) and the tests and replay (plain
13
17
  // node) import the same module.
14
18
  import { said, clip } from "./thread-brief.mjs";
@@ -56,13 +60,11 @@ export function waitingThreads(snap) {
56
60
  }
57
61
 
58
62
  /** One key per ask: the asking message, or the request. A new ask in a thread that was planned is a new key. */
59
- const askKeys = (snap) => {
60
- const human = humanOf(snap);
61
- return waitingThreads(snap).flatMap((t) => {
62
- const m = askingMessage((t.messages ?? []).filter(said), human);
63
- return [...(m ? [`${t.id}@${m.id}`] : []), ...requestsOf(snap, t.id).map((r) => `${t.id}@${r.id}`)];
64
- }).sort();
63
+ const threadAskKeys = (snap, t, human = humanOf(snap)) => {
64
+ const m = askingMessage((t.messages ?? []).filter(said), human);
65
+ return [...(m ? [`${t.id}@${m.id}`] : []), ...requestsOf(snap, t.id).map((r) => `${t.id}@${r.id}`)];
65
66
  };
67
+ const askKeys = (snap) => waitingThreads(snap).flatMap((t) => threadAskKeys(snap, t)).sort();
66
68
 
67
69
  // --- facts --------------------------------------------------------------------------------
68
70
 
@@ -99,6 +101,34 @@ export function planCards(snap, { now = Date.now(), sizes = {} } = {}) {
99
101
  });
100
102
  }
101
103
 
104
+ /**
105
+ * One fact per track that has a card: when the human last posted anywhere in it, and which of its threads
106
+ * have moved since then, so a chunk's `since` can say what changed there.
107
+ * @param {any} snap @param {{ id: string }[]} cards @param {{ now?: number }} [opts]
108
+ */
109
+ export function trackFacts(snap, cards, { now = Date.now() } = {}) {
110
+ const human = humanOf(snap);
111
+ const wanted = new Set(cards.map((c) => trackOf(snap?.threads?.[c.id])));
112
+ const by = new Map([...wanted].map((track) => [track, { lastHuman: 0, threads: [] }]));
113
+ for (const t of Object.values(snap?.threads ?? {})) {
114
+ const f = by.get(trackOf(t));
115
+ if (!f || t.archived || (t.kind && t.kind !== "thread")) continue;
116
+ const ms = (t.messages ?? []).filter((m) => said(m) && at(m) < now);
117
+ f.lastHuman = Math.max(f.lastHuman, ...ms.filter((m) => isHuman(m, human)).map(at));
118
+ f.threads.push({ t, ms });
119
+ }
120
+ return [...by].map(([track, f]) => {
121
+ const summary = snap?.tracks?.[track]?.summary?.text ?? snap?.pathSummaries?.[track]?.text;
122
+ const moved = f.threads.map(({ t, ms }) => ({ t, fresh: ms.filter((m) => at(m) > f.lastHuman) })).filter((x) => x.fresh.length);
123
+ return {
124
+ track,
125
+ summary: summary ? clip(String(summary), 1500) : null,
126
+ humanLastPostedHere: f.lastHuman ? ago(now - f.lastHuman) : "never",
127
+ movedSince: moved.map(({ t, fresh }) => ({ id: t.id, title: t.title ?? t.id, newMessages: fresh.length, latest: `[${fresh.at(-1).author}] ${clip(String(fresh.at(-1).text), 300)}` })),
128
+ };
129
+ });
130
+ }
131
+
102
132
  /** What the human is like right now, as facts; `decisions` is the text of their standing decisions. */
103
133
  export function aboutHuman(snap, { now = Date.now(), decisions = "" } = {}) {
104
134
  const human = humanOf(snap);
@@ -120,6 +150,7 @@ export function aboutHuman(snap, { now = Date.now(), decisions = "" } = {}) {
120
150
  justAnswered: last ? { id: last.id, title: last.title, minutesAgo: Math.round((now - last.at) / 60_000) } : null,
121
151
  shareOfMessagesInTheSameThreadAsThePreviousOne: mine.length > 1 ? Number((same / (mine.length - 1)).toFixed(2)) : null,
122
152
  localTime: new Intl.DateTimeFormat("en-US", { timeZone: tz, weekday: "long", hour: "numeric", minute: "2-digit" }).format(now),
153
+ today: localDay(now, tz),
123
154
  messagesByLocalHourLast14Days: hist,
124
155
  mostRecentThreads: [...recent.values()],
125
156
  profile: snap?.dash?.about?.profile ? clip(String(snap.dash.about.profile), 3000) : null,
@@ -164,22 +195,26 @@ export function replanReason(snap, now = Date.now()) {
164
195
 
165
196
  const planned = (p) => [...p.chunks.flatMap((c) => c.rows), ...(p.later?.rows ?? [])];
166
197
 
167
- export function planPrompt(cards, about, { human = "me", prev = null, now = Date.now() } = {}) {
198
+ export function planPrompt(cards, about, { human = "me", prev = null, now = Date.now(), tracks = [] } = {}) {
168
199
  const open = new Set(cards.map((c) => c.id));
169
200
  const was = prev ? [...prev.chunks.flatMap((c) => c.rows.map((r) => ({ id: r.id, chunk: c.title }))), ...(prev.later?.rows ?? []).map((r) => ({ id: r.id, chunk: "later" }))].filter((r) => open.has(r.id)) : null;
170
201
  return [
171
202
  `You are main, ${human}'s chief of staff, triaging ${human}'s open threads by hand, exactly as ${human} would if they read every one in full. The cards below are threads where a bot said something after ${human}'s last message.`,
172
203
  `For each card decide: is something actually waiting on ${human} (a question, a decision, an approval, a look, a sign-off only they can give), or not (a status report, a bot-to-bot handoff, already settled, or waiting on someone else)? Judge from the text of the ask, not from tags.`,
173
- `Then order every real ask into chunks the way a careful person would work through them: what matters most and what is blocking others first; keep the same track and repo together so ${human} switches context as little as possible; weigh how taxing each one is (how much to read, diff size, how many decisions). Each card's \`context\` is its track and repo: neighbours with different contexts are a context switch. The rule: never come back to a track. All asks of one track sit next to each other in the order (chunks first, then later), in one chunk or neighbouring ones; within a track, put quick yeses first and deep reads last. The track of the thread they answered last (justAnswered) goes first, and that thread, if something waits there, is its first row: the share of their messages that stay in the previous message's thread is in the facts. A track whose asks can wait goes to "later" as a whole, not a piece of it. The thread ${human} answered last (justAnswered) is the context they already have loaded. Quick yeses that unblock bots go early; deep reads get their own chunk. Put asks that can genuinely wait into "later".`,
204
+ `Then order every real ask the way a careful person would work through them, as a chief of staff briefing ${human}: one chunk per track, so ${human} switches context as little as possible. Each card's \`context\` is its track and repo. Put first what matters most and what is blocking others; weigh how taxing each one is (how much to read, diff size, how many decisions). The rule: never come back to a track. All asks of one track sit in its one chunk, or all in "later"; within a track, put quick yeses first and deep reads last. The track of the thread they answered last (justAnswered) goes first, and that thread, if something waits there, is its first row: the share of their messages that stay in the previous message's thread is in the facts. A track whose asks can wait goes to "later" as a whole, not a piece of it. Quick yeses that unblock bots go early.`,
174
205
  was ? [`The previous plan, in order (move a row only when a fact changed, and say why in "moved"):`, ...was.map((r) => `- ${r.id} (${r.chunk})`)].join("\n") : "There is no previous plan: \"moved\" is empty.",
175
206
  `Facts about ${human}, including their standing decisions (what they have already settled and what they care about); now is ${new Date(now).toISOString()}:\n${JSON.stringify(about)}`,
207
+ `Facts about each track with an ask: its summary, when ${human} last posted anywhere in it, and the threads that moved since then:\n${JSON.stringify(tracks)}`,
176
208
  `Rules for the output:`,
177
209
  `- Row titles are plain words a person would say ("Land the hub-check segfault fix"), never leading with PR numbers or jargon, at most 60 characters.`,
178
- `- Each chunk has "title" and "why": one plain sentence saying why it sits at that position.`,
210
+ `- Each chunk is one track (code fills in its "track" from the cards). "title" names the track's work in plain words. "why" is one plain sentence saying why it sits at that position.`,
211
+ `- Each chunk's "since": one plain sentence, at most 160 characters, saying what changed in that track since ${human} last posted in it (the track facts), e.g. "The reviewer passed the fix and CI went green."`,
212
+ `- Each row's "why": one plain sentence, at most 160 characters, saying what ${human} has to decide or do and which option the asking bot picked, e.g. "The reviewer passed it and the builder recommends landing now."`,
213
+ `- Each row's "park": for an ask that has waited 5 days or more (its waitingSince), a day after today (YYYY-MM-DD, today is ${about?.today ?? "in the facts"}) when it gets set aside if still unanswered, usually tomorrow; null for every other row.`,
179
214
  `- "minutes" is a whole number: an honest estimate of the effort for that row.`,
180
215
  `- "verb" is one of ${VERBS.join(", ")}.`,
181
216
  `- "consent" is true only when the ask is a pure yes/no approval with nothing to read (already reviewed, green).`,
182
- `- "line" (top level) and "later.line": one short line each in main's voice, dry and cheeky on routine work, never on anything risky. You may tease the oldest waiting ask with its real age. Each is one short sentence of at most ${LINE_MAX} characters, with no semicolon and no counts. Titles and "why" are never jokes.`,
217
+ `- "line" (top level) says plainly what changed overall since ${human} last looked, and "later.line" says plainly why those can wait. Each is one short sentence of at most ${LINE_MAX} characters, with no semicolon and no counts.`,
183
218
  `- "moved": each row whose place changed since the previous plan, with a one-sentence reason (empty when there is no previous plan).`,
184
219
  `- Every card id appears exactly once across notAsks, chunks and later. Use only ids from the cards. No empty or placeholder entries.`,
185
220
  `"later" is null when nothing can wait.`,
@@ -189,9 +224,10 @@ export function planPrompt(cards, about, { human = "me", prev = null, now = Date
189
224
 
190
225
  /**
191
226
  * The rules a plan must meet beyond its shape (the schema checks the shape): every card once, only real ids, a
192
- * track's asks together, voice lines short. Throws an Error naming what is wrong; returns the plan (with the planner's `notAsks`).
227
+ * one chunk per track, lines short, park days after today. Throws an Error naming what is wrong; returns the plan
228
+ * (with the planner's `notAsks`), each chunk's `track` set from its cards.
193
229
  */
194
- export function checkPlan(raw, cards) {
230
+ export function checkPlan(raw, cards, { today = null } = {}) {
195
231
  const long = [raw.line, raw.later?.line].find((l) => typeof l === "string" && l.length > LINE_MAX);
196
232
  if (long) throw new Error(`a voice line is ${long.length} characters; keep each to one sentence of at most ${LINE_MAX}`);
197
233
  const ids = new Set(cards.map((c) => c.id));
@@ -203,10 +239,17 @@ export function checkPlan(raw, cards) {
203
239
  const missing = [...ids].filter((id) => !seen.includes(id));
204
240
  if (missing.length) throw new Error(`the plan leaves out ${missing.slice(0, 5).join(", ")}`);
205
241
  const trackOfCard = new Map(cards.map((c) => [c.id, String(c.context).split(" | ")[0]]));
242
+ const mixed = raw.chunks.find((c) => new Set(c.rows.map((r) => trackOfCard.get(r.id))).size > 1);
243
+ if (mixed) throw new Error(`chunk "${mixed.title}" mixes tracks ${[...new Set(mixed.rows.map((r) => trackOfCard.get(r.id)))].join(", ")}; make one chunk per track`);
244
+ const chunkTracks = raw.chunks.map((c) => trackOfCard.get(c.rows[0].id));
245
+ const split = chunkTracks.find((t, i) => chunkTracks.indexOf(t) !== i) ?? (raw.later?.rows ?? []).map((r) => trackOfCard.get(r.id)).find((t) => chunkTracks.includes(t));
246
+ if (split) throw new Error(`track ${split} is split across chunks or between a chunk and "later"; put all of a track's asks in one chunk or all in "later"`);
206
247
  const visits = planned(raw).map((r) => trackOfCard.get(r.id)).filter((t, i, a) => t !== a[i - 1]);
207
248
  const back = visits.find((t, i) => visits.indexOf(t) !== i);
208
249
  if (back) throw new Error(`the plan comes back to track ${back} after another track's asks; put all of a track's asks together`);
209
- return { ...raw, later: raw.later?.rows.length ? raw.later : null, moved: raw.moved.filter((m) => ids.has(m.ask)) };
250
+ const early = today && planned(raw).find((r) => r.park && r.park <= today);
251
+ if (early) throw new Error(`${early.id} parks on ${early.park}, which isn't after today (${today}); park on a later day or set park to null`);
252
+ return { ...raw, chunks: raw.chunks.map((c) => ({ ...c, track: trackOfCard.get(c.rows[0].id) })), later: raw.later?.rows.length ? raw.later : null, moved: raw.moved.filter((m) => ids.has(m.ask)) };
210
253
  }
211
254
 
212
255
  /**
@@ -215,27 +258,60 @@ export function checkPlan(raw, cards) {
215
258
  */
216
259
  export async function makePlan(generate, cards, about, options) {
217
260
  const prompt = planPrompt(cards, about, options);
261
+ const check = (raw) => checkPlan(raw, cards, { today: about?.today ?? null });
218
262
  const first = await generate(prompt);
219
263
  try {
220
- return checkPlan(first, cards);
264
+ return check(first);
221
265
  } catch (err) {
222
- return checkPlan(await generate(`${prompt}\n\nYour previous answer:\n${JSON.stringify(first)}\n\nIt breaks a rule: ${err.message}. Return the corrected full plan.`), cards);
266
+ return check(await generate(`${prompt}\n\nYour previous answer:\n${JSON.stringify(first)}\n\nIt breaks a rule: ${err.message}. Return the corrected full plan.`));
223
267
  }
224
268
  }
225
269
 
270
+ /**
271
+ * Defers each thread of the stored plan whose park day has come and that still waits on the asks that plan saw (a new
272
+ * ask since then is the next plan's to judge), then stores that plan
273
+ * without those rows (same basis, so the defer change still makes a fresh plan). Returns the ids it parked.
274
+ */
275
+ export async function parkDue(hub, snap, now = Date.now()) {
276
+ const prev = snap?.dash?.plan;
277
+ if (!prev) return [];
278
+ let was;
279
+ try { was = new Set(JSON.parse(prev.basis).asks ?? []); } catch { return []; }
280
+ const today = localDay(now, snap?.dash?.about?.tz);
281
+ const sameAsk = new Set(waitingThreads(snap).filter((t) => threadAskKeys(snap, t).every((k) => was.has(k))).map((t) => t.id));
282
+ const due = new Set(planned(prev).filter((r) => r.park && r.park <= today && sameAsk.has(r.id)).map((r) => r.id));
283
+ if (!due.size) return [];
284
+ await hub.dashEntries([...due].map((threadId) => ({ threadId, deferred: true })));
285
+ const keep = (rows) => rows.filter((r) => !due.has(r.id));
286
+ const laterRows = keep(prev.later?.rows ?? []);
287
+ await hub.dashPlan(prev.basis, {
288
+ line: prev.line,
289
+ chunks: prev.chunks.map((c) => ({ ...c, rows: keep(c.rows) })).filter((c) => c.rows.length),
290
+ later: laterRows.length ? { line: prev.later.line, rows: laterRows } : null,
291
+ moved: prev.moved.filter((m) => !due.has(m.ask)),
292
+ });
293
+ return [...due];
294
+ }
295
+
226
296
  /**
227
297
  * One plan step. Returns { planned: id|null, reason }.
228
298
  * @param {any} hub @param {{ generate?: (prompt: string) => Promise<any>, decisions?: string, sizes?: any, now?: number, log?: any, snap?: any }} [opts]
229
299
  */
230
300
  export async function runPlan(hub, { generate, decisions = "", sizes = {}, now = Date.now(), log = console, snap = null } = {}) {
231
301
  if (!generate) return { planned: null, reason: "no model" };
232
- const s = snap ?? (await hub.snapshot());
302
+ let s = snap ?? (await hub.snapshot());
303
+ const parked = await parkDue(hub, s, now);
304
+ if (parked.length) {
305
+ log?.log?.(`dash plan: parked ${parked.join(", ")}`);
306
+ s = await hub.snapshot();
307
+ }
233
308
  const reason = replanReason(s, now);
234
309
  if (!reason) return { planned: null, reason: "plan is current" };
235
310
  if (!waitingThreads(s).length) return { planned: null, reason: "nothing waits on the human" };
236
311
  let plan;
237
312
  try {
238
- const { notAsks, ...body } = await makePlan(generate, planCards(s, { now, sizes }), aboutHuman(s, { now, decisions }), { human: humanOf(s), prev: s?.dash?.plan ?? null, now });
313
+ const cards = planCards(s, { now, sizes });
314
+ const { notAsks, ...body } = await makePlan(generate, cards, aboutHuman(s, { now, decisions }), { human: humanOf(s), prev: s?.dash?.plan ?? null, now, tracks: trackFacts(s, cards, { now }) });
239
315
  plan = body;
240
316
  } catch (err) {
241
317
  log?.warn?.(`dash plan: skipped, ${err?.message ?? err}`);
package/update.d.mts ADDED
@@ -0,0 +1,4 @@
1
+ export type UpdateResult = { version: string; from: string | null; current: boolean; changed: string[]; deleted: string[]; left: { path: string; reason: string }[] };
2
+ export declare function update(options?: { dir?: string; version?: string; eve?: string }): Promise<UpdateResult>;
3
+ export declare function summary(result: UpdateResult): string;
4
+ export declare function commitRequest(result: UpdateResult, at: { dir?: string; repo: string; branch: string; head: string }): unknown;
package/update.mjs ADDED
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // `eve-hub update`, run in a workspace checkout by its eve-hub-update workflow (hosted-hub/lib/workspace-template.mjs)
3
+ // as `npx --yes <this version's tarball URL> update`: moves the repo to this kit. It applies kitChange (kit-change.mjs)
4
+ // to the files on disk and pins `eve-hub` to this version's tarball, and `eve` to the hub's (`hubPins.eve`, stamped by
5
+ // scripts/pack.mjs). Customized files stay as they are and are reported. A repo already on this version is left alone.
6
+ // `--json` prints the result; `--summary <file>` appends what changed (summary); `--commit <file> --repo <o/n>` writes
7
+ // the GitHub request that commits the update onto the checkout's branch (commitRequest).
8
+ import { execFileSync } from "node:child_process";
9
+ import { appendFileSync, readdirSync, readFileSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
10
+ import { dirname, join, relative } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ import { kitChange, KitChangeError } from "./kit-change.mjs";
14
+ import { kitTarballUrl, pinnedVersion } from "./kit-store.mjs";
15
+
16
+ const SKIPPED = new Set([".git", "node_modules", ".eve", ".vercel", ".hub"]);
17
+ const OWN = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "package.json"), "utf8"));
18
+
19
+ function filesIn(root, dir = root) {
20
+ return readdirSync(dir, { withFileTypes: true }).flatMap((e) => SKIPPED.has(e.name) ? []
21
+ : e.isDirectory() ? filesIn(root, join(dir, e.name))
22
+ : e.isFile() ? [relative(root, join(dir, e.name)).split("\\").join("/")] : []);
23
+ }
24
+
25
+ export async function update({ dir = process.cwd(), version = OWN.version, eve = OWN.hubPins?.eve } = {}) {
26
+ const pkgFile = join(dir, "package.json");
27
+ const pkg = JSON.parse(readFileSync(pkgFile, "utf8"));
28
+ const pin = kitTarballUrl(version);
29
+ const current = pkg.dependencies?.["eve-hub"];
30
+ const from = pinnedVersion(current);
31
+ const result = { version, from: from ?? current ?? null, changed: [], deleted: [], left: [] };
32
+ if (current === pin) return { ...result, current: true };
33
+ const change = await kitChange({ paths: filesIn(dir), read: async (paths) => new Map(paths.map((p) => [p, readFileSync(join(dir, p), "utf8")])) });
34
+ for (const [path, text] of Object.entries(change.files)) {
35
+ mkdirSync(dirname(join(dir, path)), { recursive: true });
36
+ writeFileSync(join(dir, path), text);
37
+ }
38
+ for (const path of change.deletions) rmSync(join(dir, path), { force: true });
39
+ writeFileSync(pkgFile, `${JSON.stringify({ ...pkg, dependencies: { ...pkg.dependencies, "eve-hub": pin, ...(eve === undefined ? {} : { eve }) } }, null, 2)}\n`);
40
+ const left = change.reported.map((r) => { const i = r.indexOf(" "); return { path: r.slice(0, i), reason: r.slice(i + 1) }; });
41
+ return { ...result, current: false, changed: ["package.json", ...Object.keys(change.files)].sort(), deleted: change.deletions.sort(), left };
42
+ }
43
+
44
+ export function summary(r) {
45
+ const list = (items) => items.map((i) => `- \`${i}\``).join("\n");
46
+ return [
47
+ `Moves this workspace from eve-hub ${r.from ?? "(unpinned)"} to ${r.version}.`,
48
+ r.changed.length ? `\nChanged:\n${list(r.changed)}` : "",
49
+ r.deleted.length ? `\nDeleted:\n${list(r.deleted)}` : "",
50
+ r.left.length ? `\nKept as you customized them, so this release's version of them is not applied:\n${r.left.map((l) => `- \`${l.path}\` ${l.reason}`).join("\n")}` : "",
51
+ ].join("\n").trim() + "\n";
52
+ }
53
+
54
+ // The GraphQL `createCommitOnBranch` request that commits the update onto `branch` if it is still at `head`. GitHub creates
55
+ // and signs that commit, so a repo whose rules require signed commits takes it; a commit made in the checkout and pushed is unsigned.
56
+ export function commitRequest(r, { dir = process.cwd(), repo, branch, head }) {
57
+ return {
58
+ query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { url } } }",
59
+ variables: { input: { branch: { repositoryNameWithOwner: repo, branchName: branch }, expectedHeadOid: head, message: { headline: `eve-hub@${r.version}`, body: summary(r) },
60
+ fileChanges: { additions: r.changed.map((path) => ({ path, contents: readFileSync(join(dir, path)).toString("base64") })), deletions: r.deleted.map((path) => ({ path })) } } },
61
+ };
62
+ }
63
+
64
+ if (process.argv[1] && fileURLToPath(import.meta.url) === (await import("node:fs")).realpathSync(process.argv[1])) {
65
+ const [command, ...args] = process.argv.slice(2);
66
+ const arg = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
67
+ if (command !== "update" || (arg("--commit") !== undefined && !arg("--repo"))) { console.error("usage: eve-hub update [--json] [--summary <file>] [--commit <file> --repo <owner/name>]"); process.exit(2); }
68
+ try {
69
+ const r = await update();
70
+ if (arg("--summary")) appendFileSync(arg("--summary"), summary(r));
71
+ if (arg("--commit") && !r.current) {
72
+ const git = (...a) => execFileSync("git", a, { encoding: "utf8" }).trim();
73
+ writeFileSync(arg("--commit"), JSON.stringify(commitRequest(r, { repo: arg("--repo"), branch: git("branch", "--show-current"), head: git("rev-parse", "HEAD") })));
74
+ }
75
+ if (args.includes("--json")) console.log(JSON.stringify(r));
76
+ else console.log(r.current ? `already on eve-hub ${r.version}` : `eve-hub ${r.from} -> ${r.version}: ${r.changed.length + r.deleted.length} files changed, ${r.left.length} kept as customized`);
77
+ } catch (e) {
78
+ if (!(e instanceof KitChangeError)) throw e;
79
+ console.error(e.message);
80
+ process.exit(1);
81
+ }
82
+ }