eve-hub 0.16.21 → 0.16.23

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.
@@ -1,17 +0,0 @@
1
- // The shape of a Dash plan as the planner must return it (generateText `output`). The hub's check is
2
- // the wire's DashPlanIn (packages/wire/src/routes.ts); test/dash-plan.test.mjs fails if the two differ.
3
- // `notAsks` is the planner's own: threads it judged not to wait on the human, never sent to the hub.
4
- import * as z from "zod";
5
-
6
- export const VERBS = ["Land", "Answer", "Pick", "Look", "Review", "Read", "Write"];
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
- });
11
- export const PlanBody = z.object({
12
- line: z.string().min(1).max(240),
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),
14
- later: z.object({ line: z.string().min(1).max(240), rows: z.array(Row) }).nullable(),
15
- moved: z.array(z.object({ ask: z.string().min(1), reason: z.string().min(1).max(240) })),
16
- });
17
- export const PlanOut = PlanBody.extend({ notAsks: z.array(z.object({ id: z.string().min(1), why: z.string() })) });
@@ -1,332 +0,0 @@
1
- // The Dash plan of main's dash pass (dash.ts): one model call reads every thread that waits on the
2
- // human, in full, and orders the real asks the way a person triaging by hand would. Code only
3
- // gathers facts (what the bots said, how big the PRs are, what is stacked on what, what the human
4
- // answered last, standing decisions); the model judges what is an ask, the chunks, the order and the
5
- // voice. The hub stores the result (POST /api/dash/plan) as a new row; it is never overwritten.
6
- // Design: docs/plans/dash-triage-brief.md.
7
- //
8
- // A plan is made when it is missing, a new ask appeared, the human pinned or deferred something,
9
- // or the local day changed. Answering an ask doesn't make one: the answered row drops out in the
10
- // app. The previous plan goes into the prompt as a fact, so the list stands still between visits.
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
- //
16
- // Plain JS on purpose: the schedule (TypeScript, bundled by eve) and the tests and replay (plain
17
- // node) import the same module.
18
- import { said, clip } from "./thread-brief.mjs";
19
- import { VERBS } from "./dash-plan-schema.mjs";
20
-
21
- export const PLAN_MODEL = "anthropic/claude-opus-5.5";
22
- export const PLAN_TIMEOUT_MS = 240_000;
23
- const MS_DAY = 86_400_000;
24
- const PRS_PER_CARD = 6;
25
- export const LINE_MAX = 90;
26
- // The wire's PLAN_BASIS_MAX (SetDashPlan.basis); test/dash-plan.test.mjs keeps the two equal.
27
- export const PLAN_BASIS_MAX = 100_000;
28
-
29
- const humanOf = (snap) => snap?.humans?.[0]?.name ?? "me";
30
- const isHuman = (m, human) => m.author === human || m.authorKind === "human";
31
- const at = (m) => Date.parse(m?.at ?? "") || 0;
32
- export const trackOf = (t) => t?.tags?.path ?? t?.trackId ?? t?.tags?.project ?? "unfiled";
33
- export const repoOf = (t) => {
34
- const pr = Object.values(t?.work?.bots ?? {}).map((b) => b?.pr).find(Boolean);
35
- const m = typeof pr === "string" ? pr.match(/^([\w.-]+\/[\w.-]+)#\d+$/) : null;
36
- return m ? m[1] : null;
37
- };
38
- const words = (s) => (String(s).match(/\S+/g) ?? []).length;
39
- const ago = (ms) => {
40
- const m = Math.max(0, ms) / 60_000;
41
- return m < 2 ? "now" : m < 60 ? `${Math.round(m)}m` : m < 2880 ? `${Math.round(m / 60)}h` : `${Math.round(m / 1440)}d`;
42
- };
43
-
44
- /** A verdict post's open questions, each with its options and who picked which: what the human still has to answer. */
45
- const decisionsOf = (m) => (m.decisions ?? []).map((d) => `\n decision: ${d.question} Options: ${(d.options ?? []).map((o) => o.label + (o.pickedBy ? ` (picked by ${o.pickedBy})` : "")).join(" | ")}`).join("");
46
-
47
- const requestsOf = (snap, id) => Object.values(snap?.requests ?? {}).filter((r) => r.threadId === id && (r.status ?? "pending") === "pending");
48
-
49
- /** The first message of the ask a thread has now: a bot @-mentioned the human after their last message. */
50
- const askingMessage = (ms, human) => {
51
- const after = ms.slice(ms.findLastIndex((m) => isHuman(m, human)) + 1);
52
- return after.find((m) => !isHuman(m, human) && (m.mentions ?? []).includes(human)) ?? null;
53
- };
54
-
55
- /** The threads that wait on the human: the plan's candidates. A pending request (a tool approval, a question) is an ask. */
56
- export function waitingThreads(snap) {
57
- const human = humanOf(snap);
58
- return Object.values(snap?.threads ?? {}).filter((t) => {
59
- if (t.archived || t.schedule || (t.kind && t.kind !== "thread") || snap?.dash?.entries?.[t.id]?.deferred) return false;
60
- return !!askingMessage((t.messages ?? []).filter(said), human) || requestsOf(snap, t.id).length > 0;
61
- });
62
- }
63
-
64
- /** One key per ask: the asking message, or the request. A new ask in a thread that was planned is a new key. */
65
- const threadAskKeys = (snap, t, human = humanOf(snap)) => {
66
- const m = askingMessage((t.messages ?? []).filter(said), human);
67
- return [...(m ? [`${t.id}@${m.id}`] : []), ...requestsOf(snap, t.id).map((r) => `${t.id}@${r.id}`)];
68
- };
69
- const askKeys = (snap) => waitingThreads(snap).flatMap((t) => threadAskKeys(snap, t)).sort();
70
-
71
- // --- facts --------------------------------------------------------------------------------
72
-
73
- const PR_URL = /github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/g;
74
-
75
- /**
76
- * One card per waiting thread, facts only. `sizes` is each PR's title and diff size as GitHub last said (dash-run.mjs).
77
- * @param {any} snap @param {{ now?: number, sizes?: Record<string, { title: string, add: number, del: number, files: number }> }} [opts]
78
- */
79
- export function planCards(snap, { now = Date.now(), sizes = {} } = {}) {
80
- const human = humanOf(snap);
81
- const threads = waitingThreads(snap);
82
- const all = Object.values(snap?.threads ?? {});
83
- return threads.map((t) => {
84
- const ms = (t.messages ?? []).filter(said);
85
- const after = ms.slice(ms.findLastIndex((m) => isHuman(m, human)) + 1).filter((m) => !isHuman(m, human));
86
- const text = [...after.map((m) => `[${m.author}] ${m.text}${decisionsOf(m)}`), ...requestsOf(snap, t.id).map((r) => `[${r.agent}, waiting on a request] ${r.prompt}`)].join("\n\n");
87
- const refs = new Map();
88
- for (const m of ms.slice(-10)) for (const x of String(m.text).matchAll(PR_URL)) refs.set(`${x[1]}#${x[2]}`, [x[1], x[2]]);
89
- const prs = [...refs.values()].slice(0, PRS_PER_CARD).map(([r, n]) => ({ number: Number(n), ...sizes[`${r}#${n}`] })).filter((p) => p.files != null);
90
- const nums = prs.map((p) => p.number);
91
- const mentioned = all.filter((o) => o.id !== t.id && (o.messages ?? []).some((m) => now - at(m) < 48 * 3_600_000 && at(m) < now && (String(m.text).includes(t.id) || nums.some((n) => new RegExp(`(?:pull/|#)${n}\\b`).test(String(m.text)))))).map((o) => o.id);
92
- return {
93
- id: t.id,
94
- title: t.title ?? t.id,
95
- context: `${trackOf(t)} | ${repoOf(t) ?? "no repo"}`,
96
- askText: text.slice(-2500),
97
- askWords: words(text),
98
- prs,
99
- waitingSince: after[0]?.at ?? null,
100
- waitingAge: after[0] ? ago(now - at(after[0])) : "",
101
- threadsMentioningThis: mentioned,
102
- };
103
- });
104
- }
105
-
106
- /**
107
- * One fact per track that has a card: when the human last posted anywhere in it, and which of its threads
108
- * have moved since then, so a chunk's `since` can say what changed there.
109
- * @param {any} snap @param {{ id: string }[]} cards @param {{ now?: number }} [opts]
110
- */
111
- export function trackFacts(snap, cards, { now = Date.now() } = {}) {
112
- const human = humanOf(snap);
113
- const wanted = new Set(cards.map((c) => trackOf(snap?.threads?.[c.id])));
114
- const by = new Map([...wanted].map((track) => [track, { lastHuman: 0, threads: [] }]));
115
- for (const t of Object.values(snap?.threads ?? {})) {
116
- const f = by.get(trackOf(t));
117
- if (!f || t.archived || (t.kind && t.kind !== "thread")) continue;
118
- const ms = (t.messages ?? []).filter((m) => said(m) && at(m) < now);
119
- f.lastHuman = Math.max(f.lastHuman, ...ms.filter((m) => isHuman(m, human)).map(at));
120
- f.threads.push({ t, ms });
121
- }
122
- return [...by].map(([track, f]) => {
123
- const summary = snap?.tracks?.[track]?.summary?.text ?? snap?.pathSummaries?.[track]?.text;
124
- const moved = f.threads.map(({ t, ms }) => ({ t, fresh: ms.filter((m) => at(m) > f.lastHuman) })).filter((x) => x.fresh.length);
125
- return {
126
- track,
127
- summary: summary ? clip(String(summary), 1500) : null,
128
- humanLastPostedHere: f.lastHuman ? ago(now - f.lastHuman) : "never",
129
- 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)}` })),
130
- };
131
- });
132
- }
133
-
134
- /** What the human is like right now, as facts; `decisions` is the text of their standing decisions. */
135
- export function aboutHuman(snap, { now = Date.now(), decisions = "" } = {}) {
136
- const human = humanOf(snap);
137
- const tz = snap?.dash?.about?.tz || "UTC";
138
- const hour = new Intl.DateTimeFormat("en-US", { hour: "numeric", hourCycle: "h23", timeZone: tz });
139
- const hist = Array(24).fill(0);
140
- const mine = [];
141
- for (const t of Object.values(snap?.threads ?? {})) for (const m of t.messages ?? []) {
142
- if (!said(m) || !isHuman(m, human) || at(m) >= now) continue;
143
- mine.push({ id: t.id, title: t.title ?? t.id, at: at(m) });
144
- if (now - at(m) < 14 * MS_DAY) hist[Number(hour.format(at(m)))]++;
145
- }
146
- mine.sort((a, b) => a.at - b.at);
147
- const last = mine.at(-1);
148
- const same = mine.filter((x, i) => i && mine[i - 1].id === x.id).length;
149
- const recent = new Map();
150
- for (const x of [...mine].reverse()) if (!recent.has(x.id) && recent.size < 5) recent.set(x.id, { id: x.id, title: x.title, minutesAgo: Math.round((now - x.at) / 60_000) });
151
- return {
152
- justAnswered: last ? { id: last.id, title: last.title, minutesAgo: Math.round((now - last.at) / 60_000) } : null,
153
- shareOfMessagesInTheSameThreadAsThePreviousOne: mine.length > 1 ? Number((same / (mine.length - 1)).toFixed(2)) : null,
154
- localTime: new Intl.DateTimeFormat("en-US", { timeZone: tz, weekday: "long", hour: "numeric", minute: "2-digit" }).format(now),
155
- today: localDay(now, tz),
156
- messagesByLocalHourLast14Days: hist,
157
- mostRecentThreads: [...recent.values()],
158
- profile: snap?.dash?.about?.profile ? clip(String(snap.dash.about.profile), 3000) : null,
159
- standingDecisions: clip(decisions, 40_000),
160
- };
161
- }
162
-
163
- // --- re-plan ------------------------------------------------------------------------------
164
-
165
- const localDay = (now, tz) => {
166
- try { return new Intl.DateTimeFormat("en-CA", { timeZone: tz || "UTC" }).format(now); } catch { return new Date(now).toISOString().slice(0, 10); }
167
- };
168
-
169
- /** What a plan was made from: the asks (the asking message or request of each waiting thread), pins, defers and the local day (the next pass compares it). */
170
- export function planBasis(snap, now = Date.now()) {
171
- const human = humanOf(snap);
172
- const deferred = Object.entries(snap?.dash?.entries ?? {}).filter(([, e]) => e.deferred).map(([id]) => id).sort();
173
- return JSON.stringify({
174
- asks: askKeys(snap),
175
- pins: [...(snap?.pins?.[human] ?? [])].sort(),
176
- deferred,
177
- day: localDay(now, snap?.dash?.about?.tz),
178
- });
179
- }
180
-
181
- /** Why a new plan is needed, or null. An ask that was answered is not a reason. */
182
- export function replanReason(snap, now = Date.now()) {
183
- const prev = snap?.dash?.plan;
184
- if (!prev) return "no plan yet";
185
- let was;
186
- try { was = JSON.parse(prev.basis); } catch { return "the last plan's basis is unreadable"; }
187
- const next = JSON.parse(planBasis(snap, now));
188
- if (was.day !== next.day) return "a new day";
189
- const fresh = next.asks.find((id) => !(was.asks ?? []).includes(id));
190
- if (fresh) return `a new ask: ${fresh}`;
191
- if (JSON.stringify(was.pins) !== JSON.stringify(next.pins)) return "a pin changed";
192
- if (JSON.stringify(was.deferred) !== JSON.stringify(next.deferred)) return "a defer changed";
193
- return null;
194
- }
195
-
196
- // --- the call -----------------------------------------------------------------------------
197
-
198
- const planned = (p) => [...p.chunks.flatMap((c) => c.rows), ...(p.later?.rows ?? [])];
199
-
200
- export function planPrompt(cards, about, { human = "me", prev = null, now = Date.now(), tracks = [] } = {}) {
201
- const open = new Set(cards.map((c) => c.id));
202
- 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;
203
- return [
204
- `You are the coordinator, ${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.`,
205
- `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.`,
206
- `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.`,
207
- 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.",
208
- `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)}`,
209
- `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)}`,
210
- `Rules for the output:`,
211
- `- 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.`,
212
- `- 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.`,
213
- `- 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."`,
214
- `- 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."`,
215
- `- 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.`,
216
- `- "minutes" is a whole number: an honest estimate of the effort for that row.`,
217
- `- "verb" is one of ${VERBS.join(", ")}.`,
218
- `- "consent" is true only when the ask is a pure yes/no approval with nothing to read (already reviewed, green).`,
219
- `- "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.`,
220
- `- "moved": each row whose place changed since the previous plan, with a one-sentence reason (empty when there is no previous plan).`,
221
- `- Every card id appears exactly once across notAsks, chunks and later. Use only ids from the cards. No empty or placeholder entries.`,
222
- `"later" is null when nothing can wait.`,
223
- `Cards:\n${JSON.stringify(cards)}`,
224
- ].join("\n\n");
225
- }
226
-
227
- /**
228
- * The rules a plan must meet beyond its shape (the schema checks the shape): every card once, only real ids, a
229
- * one chunk per track, lines short, park days after today. Throws an Error naming what is wrong; returns the plan
230
- * (with the planner's `notAsks`), each chunk's `track` set from its cards.
231
- */
232
- export function checkPlan(raw, cards, { today = null } = {}) {
233
- const long = [raw.line, raw.later?.line].find((l) => typeof l === "string" && l.length > LINE_MAX);
234
- if (long) throw new Error(`a voice line is ${long.length} characters; keep each to one sentence of at most ${LINE_MAX}`);
235
- const ids = new Set(cards.map((c) => c.id));
236
- const seen = [...raw.notAsks.map((x) => x.id), ...planned(raw).map((r) => r.id)];
237
- const unknown = seen.filter((id) => !ids.has(id));
238
- if (unknown.length) throw new Error(`the plan names threads that aren't cards: ${unknown.slice(0, 5).join(", ")}`);
239
- const twice = seen.filter((id, i) => seen.indexOf(id) !== i);
240
- if (twice.length) throw new Error(`the plan places ${twice.slice(0, 5).join(", ")} twice`);
241
- const missing = [...ids].filter((id) => !seen.includes(id));
242
- if (missing.length) throw new Error(`the plan leaves out ${missing.slice(0, 5).join(", ")}`);
243
- const trackOfCard = new Map(cards.map((c) => [c.id, String(c.context).split(" | ")[0]]));
244
- const mixed = raw.chunks.find((c) => new Set(c.rows.map((r) => trackOfCard.get(r.id))).size > 1);
245
- 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`);
246
- const chunkTracks = raw.chunks.map((c) => trackOfCard.get(c.rows[0].id));
247
- const split = chunkTracks.find((t, i) => chunkTracks.indexOf(t) !== i) ?? (raw.later?.rows ?? []).map((r) => trackOfCard.get(r.id)).find((t) => chunkTracks.includes(t));
248
- 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"`);
249
- const visits = planned(raw).map((r) => trackOfCard.get(r.id)).filter((t, i, a) => t !== a[i - 1]);
250
- const back = visits.find((t, i) => visits.indexOf(t) !== i);
251
- 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`);
252
- const early = today && planned(raw).find((r) => r.park && r.park <= today);
253
- 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`);
254
- 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)) };
255
- }
256
-
257
- /**
258
- * The model's plan for these cards, with one repair round when its answer breaks a rule.
259
- * `generate(prompt)` is the model's answer as an object of the plan's schema (dash-plan-schema.mjs PlanOut).
260
- */
261
- export async function makePlan(generate, cards, about, options) {
262
- const prompt = planPrompt(cards, about, options);
263
- const check = (raw) => checkPlan(raw, cards, { today: about?.today ?? null });
264
- const first = await generate(prompt);
265
- try {
266
- return check(first);
267
- } catch (err) {
268
- 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.`));
269
- }
270
- }
271
-
272
- /**
273
- * Defers each thread of the stored plan whose park day has come and that still waits on the asks that plan saw (a new
274
- * ask since then is the next plan's to judge), then stores that plan
275
- * without those rows (same basis, so the defer change still makes a fresh plan). Returns the ids it parked.
276
- */
277
- export async function parkDue(hub, snap, now = Date.now()) {
278
- const prev = snap?.dash?.plan;
279
- if (!prev) return [];
280
- let was;
281
- try { was = new Set(JSON.parse(prev.basis).asks ?? []); } catch { return []; }
282
- const today = localDay(now, snap?.dash?.about?.tz);
283
- const sameAsk = new Set(waitingThreads(snap).filter((t) => threadAskKeys(snap, t).every((k) => was.has(k))).map((t) => t.id));
284
- const due = new Set(planned(prev).filter((r) => r.park && r.park <= today && sameAsk.has(r.id)).map((r) => r.id));
285
- if (!due.size) return [];
286
- await hub.dashEntries([...due].map((threadId) => ({ threadId, deferred: true })));
287
- const keep = (rows) => rows.filter((r) => !due.has(r.id));
288
- const laterRows = keep(prev.later?.rows ?? []);
289
- await hub.dashPlan(prev.basis, {
290
- line: prev.line,
291
- chunks: prev.chunks.map((c) => ({ ...c, rows: keep(c.rows) })).filter((c) => c.rows.length),
292
- later: laterRows.length ? { line: prev.later.line, rows: laterRows } : null,
293
- moved: prev.moved.filter((m) => !due.has(m.ask)),
294
- });
295
- return [...due];
296
- }
297
-
298
- /**
299
- * One plan step. Returns { planned: id|null, reason }.
300
- * @param {any} hub @param {{ generate?: (prompt: string) => Promise<any>, decisions?: string, sizes?: any, now?: number, log?: any, snap?: any }} [opts]
301
- */
302
- export async function runPlan(hub, { generate, decisions = "", sizes = {}, now = Date.now(), log = console, snap = null } = {}) {
303
- if (!generate) return { planned: null, reason: "no model" };
304
- let s = snap ?? (await hub.snapshot());
305
- const parked = await parkDue(hub, s, now);
306
- if (parked.length) {
307
- log?.log?.(`dash plan: parked ${parked.join(", ")}`);
308
- s = await hub.snapshot();
309
- }
310
- const reason = replanReason(s, now);
311
- if (!reason) return { planned: null, reason: "plan is current" };
312
- if (!waitingThreads(s).length) return { planned: null, reason: "nothing waits on the human" };
313
- const basis = planBasis(s, now);
314
- if (basis.length > PLAN_BASIS_MAX) {
315
- const why = `${JSON.parse(basis).asks.length} asks exceed the plan basis limit`;
316
- log?.warn?.(`dash plan: skipped, ${why}`);
317
- return { planned: null, reason: why };
318
- }
319
- let plan;
320
- try {
321
- const cards = planCards(s, { now, sizes });
322
- 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 }) });
323
- plan = body;
324
- } catch (err) {
325
- log?.warn?.(`dash plan: skipped, ${err?.message ?? err}`);
326
- return { planned: null, reason: String(err?.message ?? err) };
327
- }
328
- if (!plan.chunks.length && !plan.later) return { planned: null, reason: "no asks" };
329
- const { id } = await hub.dashPlan(basis, plan);
330
- log?.log?.(`dash plan: ${id} (${reason}): ${plan.chunks.length} chunks, ${planned(plan).length} rows`);
331
- return { planned: id, reason };
332
- }