eve-hub 0.5.7 → 0.6.2

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,51 @@
1
+ // The one check behind main's Dash (schedules/dash.ts), every 8 minutes and
2
+ // again as soon as a bot's turn ends (registry/server.mjs, schedules.runSoon):
3
+ //
4
+ // threads runClassify (./dash-classify.mjs): one model call per thread whose
5
+ // facts changed since its entry was written, busy or idle; none for a
6
+ // thread that hasn't changed. Every run.
7
+ // PRs runDash (./dash-run.mjs): one chunked GitHub query for the human's
8
+ // PRs, what Dash items wait on and the PRs reviews are about. No model.
9
+ // tracks runTracks (./track-summaries.mjs): one model call per track with a
10
+ // thread that has news; none for a track whose threads haven't moved.
11
+ //
12
+ // The PRs and the tracks run once per cron slot (*/8: the minute rounded down
13
+ // to a multiple of 8): when the pass's last full attempt (pass.fullAt in the
14
+ // schedule's state, written before the GitHub call, so an outage doesn't
15
+ // reopen the gate) is older than the current slot. A turn end inside a slot
16
+ // whose full check has run classifies threads and nothing else; a cron fire
17
+ // is always the first run of its slot. If the stamp can't be written, the
18
+ // full check is skipped. A pass over an unchanged hub makes no model call.
19
+ // The parts run one after another (they share the schedule's state); a
20
+ // failure in one never stops the others.
21
+ import { classifyPass, runClassify } from "./dash-classify.mjs";
22
+ import { runDash } from "./dash-run.mjs";
23
+ import { runTracks } from "./track-summaries.mjs";
24
+
25
+ const SLOT_MIN = 8;
26
+ // */8 restarts every hour: :56 is followed by :00.
27
+ const slotOf = (ms) => {
28
+ const min = Math.floor(ms / 60_000);
29
+ return (min - ((min % 60) % SLOT_MIN)) * 60_000;
30
+ };
31
+
32
+ /** @param {any} hub @param {{ classify?: (target: any, snap: any) => Promise<any>, summarize?: (prompt: string) => Promise<string>, probe?: any, log?: any, now?: number }} [opts] */
33
+ export async function runPass(hub, { classify, summarize, probe, log = console, now = Date.now() } = {}) {
34
+ const part = (name, run) => run().catch((err) => log?.warn?.(`dash pass: ${name} failed: ${err?.message ?? err}`));
35
+ const { state } = await hub.loadVersioned("dash");
36
+ const full = (state?.pass?.fullAt ?? 0) < slotOf(now);
37
+ await part("threads", () => runClassify(hub, { classify, log }));
38
+ if (!full) return;
39
+ try {
40
+ await hub.commit("dash", { state: { ...state, pass: { ...state?.pass, fullAt: now } } }, []);
41
+ } catch (err) {
42
+ log?.warn?.(`dash pass: full check skipped, its stamp wasn't saved: ${err?.message ?? err}`);
43
+ return;
44
+ }
45
+ await part("PRs", () => runDash(hub, { ...(probe ? { probe } : {}), log }));
46
+ await part("tracks", () => runTracks(hub, { summarize, log }));
47
+ }
48
+
49
+ // One pass at a time in this process (classifyPass), a dispatch that comes
50
+ // in meanwhile asks for one more round when it ends.
51
+ export const dashPass = (hub, options) => classifyPass(() => runPass(hub, options));
@@ -193,7 +193,10 @@ function waitMet(item, snap, prs, nowMs) {
193
193
  export function dashWatchList(snap, saved) {
194
194
  const waited = new Set((snap?.dash?.items ?? []).map((i) => i.waitingOn?.pr).filter(Boolean));
195
195
  const tracked = Object.keys(flatPrs(saved?.p ?? saved?.snapshot));
196
- return { keep: waited, refs: [...new Set([...waited, ...tracked])] };
196
+ // The PRs a thread's latest review is about (entry.package.pr): asked in the same query so the
197
+ // Ready rule reads their head and CI, but not tracked, so their changes never post.
198
+ const reviewed = Object.values(snap?.dash?.entries ?? {}).map((e) => e.package?.pr).filter(Boolean);
199
+ return { keep: waited, refs: [...new Set([...waited, ...tracked, ...reviewed])], reviewed: new Set(reviewed) };
197
200
  }
198
201
 
199
202
  // `prs` null: the probe failed, PRs keep what was last known (dashDiff).
@@ -70,8 +70,11 @@ const plural = (n) => `${n} PR${n === 1 ? "" : "s"}`;
70
70
  // Returns { ok }: false when the PRs couldn't be fetched at all.
71
71
  export async function runDash(hub, { probe = (q) => withRetry(() => ghGraphql(q)), now = Date.now(), log = console } = {}) {
72
72
  let ok = true;
73
+ let prFacts = {};
73
74
  await runOnce(hub, "dash", async (snap, saved, at) => {
74
- const { refs } = dashWatchList(snap, saved);
75
+ const { refs, reviewed, keep: waited } = dashWatchList(snap, saved);
76
+ const tracked = new Set(Object.keys(dashPrs(saved)));
77
+ const facts = {};
75
78
  let prs = null;
76
79
  let skip = new Set();
77
80
  let note = null;
@@ -79,7 +82,10 @@ export async function runDash(hub, { probe = (q) => withRetry(() => ghGraphql(q)
79
82
  const chunks = [];
80
83
  for (let i = 0; i < Math.max(refs.length, 1); i += REFS_PER_QUERY) chunks.push(refs.slice(i, i + REFS_PER_QUERY));
81
84
  const answers = await Promise.all(chunks.map((c) => probe(dashQuery(c))));
82
- prs = answers.flatMap(prsFromSearch);
85
+ const found = answers.flatMap(prsFromSearch);
86
+ const searched = new Set(answers.flatMap((a) => prsFromSearch({ data: { search: a?.data?.search } })).map((p) => p.key));
87
+ prs = found.filter((p) => !reviewed.has(p.key) || searched.has(p.key) || tracked.has(p.key) || waited.has(p.key));
88
+ for (const p of found) if (reviewed.has(p.key)) facts[p.key] = { head: p.head ?? null, ci: p.ci };
83
89
  skip = new Set(answers.flatMap((a, i) => [...failedRefs(a, chunks[i])]));
84
90
  if (skip.size) note = `${plural(skip.size)} skipped: GitHub couldn't answer for ${[...skip].slice(0, 3).join(", ")}${skip.size > 3 ? ", …" : ""}`;
85
91
  } catch (err) {
@@ -88,13 +94,14 @@ export async function runDash(hub, { probe = (q) => withRetry(() => ghGraphql(q)
88
94
  note = `${known ? `${plural(known)} skipped` : "PRs skipped"}: ${ghReason(err)}`;
89
95
  log?.warn?.(`dash: PRs skipped this tick: ${err?.message ?? err}`);
90
96
  }
97
+ prFacts = facts;
91
98
  const { lines, state, changed } = dashScan(snap, saved, prs, at, hashOf, skip);
92
99
  // The note rides on a post that's going out anyway: a skip alone doesn't
93
100
  // wake main (a lasting outage shows as the Dash going stale).
94
101
  const body = lines.length ? (lines.length === 1 ? lines[0] : `${lines.length} changes\n${lines.map((l) => `- ${l}`).join("\n")}`) : null;
95
102
  const text = body ? `@main dash: ${body}${note ? `\n(${note})` : ""}` : null;
96
- return { posts: text ? [text] : [], state: changed ? state : saved, changed };
103
+ return { posts: text ? [text] : [], state: changed ? { ...state, ...(saved?.pass ? { pass: saved.pass } : {}) } : saved, changed };
97
104
  }, { now });
98
- if (ok) await hub.dashChecked();
105
+ if (ok) await hub.dashChecked(prFacts);
99
106
  return { ok };
100
107
  }
package/schedules/dash.js CHANGED
@@ -1,22 +1,20 @@
1
- // main's dash schedule: every 10 minutes, with no model call, looks at what
2
- // could put something on the human's Dash (registry/dash.mjs): their open PRs
3
- // (CI, review, draft, merge conflicts) and every PR a Dash item waits on, in
4
- // one `gh` GraphQL request; threads where a bot is asking them; and what each
5
- // Dash item waits on. Only when that says something new does it post one
6
- // notice in sch-main-dash, tagging main, who then rewrites the Dash with
7
- // dash_update. A failed `gh` call (GitHub 5xx, a timeout) is retried once,
8
- // then the tick goes on without the PRs: threads and waits are still posted,
9
- // with a note of what was skipped. Every scan whose PRs came back stamps the
10
- // Dash's checkedAt, so the sidebar shows "stale" while GitHub is unreachable
11
- // (gh logged out, offline). The tick: runDash in ./dash-run.mjs; the
12
- // decisions: dashScan in ./dash-rules.mjs. The threads' Dash entries are
13
- // main's classify schedule (./classify.ts).
1
+ // main's dash schedule: every 8 minutes, and off-cron as soon as a bot's turn
2
+ // ends (the hub fires it, so a question shows on the sidebar within seconds),
3
+ // the one pass behind the Dash: the human's PRs, a model call per thread
4
+ // whose facts changed (openai/gpt-6-luna-decisions through eve's `evaluate`,
5
+ // eve's gateway credentials) and a model call per track with news
6
+ // (anthropic/claude-sonnet-5.5 through the AI SDK). The pass: runPass in
7
+ // ./dash-pass.mjs.
14
8
  import { defineSchedule } from "eve/schedules";
9
+ import { evaluate } from "eve/ai";
10
+ import { generateText } from "ai";
15
11
  import { createHub } from "./hub.mjs";
16
- import { runDash } from "./dash-run.mjs";
12
+ import { modelClassify } from "./dash-classify.mjs";
13
+ import { dashPass } from "./dash-pass.mjs";
14
+ import { modelSummarize } from "./track-summaries.mjs";
17
15
  export default defineSchedule({
18
- cron: "*/10 * * * *",
16
+ cron: "*/8 * * * *",
19
17
  async run() {
20
- await runDash(createHub());
18
+ await dashPass(createHub(), { classify: modelClassify({ evaluate }), summarize: modelSummarize({ generateText }) });
21
19
  },
22
20
  });
@@ -14,7 +14,8 @@ export type Hub = {
14
14
  commit(id: string, withState: { state?: unknown }, posts: Post[]): Promise<unknown>;
15
15
  post(id: string, text: string, key?: string): Promise<unknown>;
16
16
  compact(threadId: string, bot: string, by: string, report?: { trigger?: string; reportTo?: string }): Promise<{ status: number; ok: boolean; body: unknown }>;
17
- dashChecked(): Promise<Ok & { checkedAt: string }>;
17
+ dashChecked(prFacts?: Record<string, { head: string | null; ci: string }>): Promise<Ok & { checkedAt: string }>;
18
+ setSummary(path: string, text: string): Promise<unknown>;
18
19
  dashClassified(): Promise<Ok & { classifiedAt: string }>;
19
20
  fileThread(threadId: string, path: string): Promise<unknown>;
20
21
  dashEntries(entries: object[], remove?: string[], notice?: Notice | null): Promise<Ok & { entries: DashEntries }>;
package/schedules/hub.mjs CHANGED
@@ -43,7 +43,10 @@ export function createHub({ base = registryBase(), agent = "main", fetchImpl = f
43
43
  // this compaction's report lines into that schedule's thread itself.
44
44
  compact: (threadId, bot, by, report = {}) => call(`/api/threads/${encodeURIComponent(threadId)}/compact`, { method: "POST", body: JSON.stringify({ agent: bot, by, ...report }) }),
45
45
  // The Dash was scanned now (registry/dash.mjs checkedAt), changed or not.
46
- dashChecked: () => must("/api/dash/checked", { method: "POST", body: JSON.stringify({ by: agent }) }),
46
+ // `prFacts` ({ "owner/repo#12": { head, ci } }): what the scan's GitHub query said of the PRs reviews are about.
47
+ dashChecked: (prFacts = {}) => must("/api/dash/checked", { method: "POST", body: JSON.stringify({ by: agent, prFacts }) }),
48
+ // A track's summary (set_summary's endpoint, as main).
49
+ setSummary: (path, text) => must("/api/summaries", { method: "POST", body: JSON.stringify({ by: agent, scope: `path:${path}`, text }) }),
47
50
  // A classifier pass finished (registry/dash.mjs classifiedAt).
48
51
  dashClassified: () => must("/api/dash/classified", { method: "POST", body: JSON.stringify({ by: agent }) }),
49
52
  // Dash entries from the classifier (registry/dash.mjs), merged per field.
@@ -0,0 +1,134 @@
1
+ // The facts the lane classifier reads about a thread, and the one-line reason
2
+ // each answer is shown with. Code only gathers facts (counts, durations, the
3
+ // exact call a turn is in); whether a thread is stuck, waiting on the human or
4
+ // fine is the model's call (dash-classify.mjs), never a threshold here.
5
+ //
6
+ // Plain JS on purpose: the schedule (TypeScript, bundled by eve) and the tests
7
+ // (plain node) import the same module.
8
+
9
+ import { createHash } from "node:crypto";
10
+ import { said, flat, clip } from "./thread-brief.mjs";
11
+
12
+ const MIN = 60_000;
13
+ const minutes = (from, now) => Math.max(0, Math.floor((now - from) / MIN));
14
+ const at = (m) => Date.parse(m?.at ?? "") || 0;
15
+ const isHuman = (m, human) => m.author === human || m.authorKind === "human";
16
+
17
+ // A busy thread's quiet time is read in steps of one pass (the schedule's
18
+ // cron, 8 minutes): a thread quiet for another pass is a new fact to judge,
19
+ // a bot that keeps working is not.
20
+ export const PASS_MIN = 8;
21
+
22
+ // The facts of one thread at `now`. `snap.live` holds each bot's live turn
23
+ // (key `threadId:agent`); `t.suspect` is the hub's mark on a turn that went
24
+ // silent when its bot's server restarted.
25
+ export function laneFacts(t, snap, now = Date.now()) {
26
+ const human = snap?.humans?.[0]?.name ?? "me";
27
+ const ms = (t?.messages ?? []).filter(said);
28
+ const lastBot = [...ms].reverse().find((m) => !isHuman(m, human));
29
+ const lastHuman = [...ms].reverse().find((m) => isHuman(m, human));
30
+ const turns = Object.entries(t?.status ?? {})
31
+ .filter(([, s]) => s === "working")
32
+ .map(([agent]) => turnFacts(agent, snap?.live?.[`${t.id}:${agent}`], now));
33
+ const askAt = ms.findLastIndex((m) => !isHuman(m, human) && (m.mentions ?? []).includes(human));
34
+ return {
35
+ // The newest bot message that @-mentions the human, in messages back from the newest, and whether the human posted after it.
36
+ askBack: askAt < 0 ? null : ms.length - 1 - askAt,
37
+ humanPostedSinceAsk: askAt >= 0 && ms.slice(askAt + 1).some((m) => isHuman(m, human)),
38
+ // Minutes since the newest message, when it is a bot's and @-mentions a bot: a handoff.
39
+ handoffMin: ms.at(-1) && !isHuman(ms.at(-1), human) && (ms.at(-1).mentions ?? []).some((n) => n !== human) ? minutes(at(ms.at(-1)), now) : null,
40
+ botSilentMin: lastBot ? minutes(at(lastBot), now) : null,
41
+ sinceHuman: lastHuman ? ms.length - 1 - ms.lastIndexOf(lastHuman) : ms.length,
42
+ rounds: (t?.messages ?? []).filter((m) => m.statusFrom === "reviewing" && (m.status === "working" || m.status === "planning")).length,
43
+ lastBy: ms.at(-1) ? { author: ms.at(-1).author, human: isHuman(ms.at(-1), human), toHuman: (ms.at(-1).mentions ?? []).includes(human) } : null,
44
+ restarted: !!t?.suspect && turns.length > 0,
45
+ turns,
46
+ };
47
+ }
48
+
49
+ function turnFacts(agent, turn, now) {
50
+ const tools = turn?.tools ?? [];
51
+ const swarm = Object.values(turn?.swarm ?? {});
52
+ const running = [...tools].reverse().find((x) => x.status === "running" && x.name !== "task_wait" && x.name !== "researcher");
53
+ const wait = tools.find((x) => x.name === "task_wait" && x.status === "running");
54
+ const counts = new Map();
55
+ for (const x of tools) if (x.name !== "task_wait") counts.set(`${x.name} ${flat(x.input)}`, (counts.get(`${x.name} ${flat(x.input)}`) ?? 0) + 1);
56
+ return {
57
+ agent,
58
+ turnMin: turn?.startedAt ? minutes(turn.startedAt, now) : null,
59
+ quietMin: turn?.startedAt ? minutes(Math.max(turn.startedAt, ...tools.map((x) => x.endedAt ?? x.startedAt ?? 0)), now) : null,
60
+ toolCalls: tools.filter((x) => x.name !== "task_wait").length,
61
+ current: running ? { call: clip(`${running.name === "bash" ? "$" : running.name} ${flat(running.input)}`.trim(), 90), min: running.startedAt ? minutes(running.startedAt, now) : null } : null,
62
+ waitingMin: wait?.startedAt ? minutes(wait.startedAt, now) : wait ? 0 : null,
63
+ subagentsOut: swarm.filter((d) => d.status === "working").length,
64
+ subagents: swarm.length,
65
+ repeats: [...counts].filter(([, n]) => n > 1).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([call, n]) => ({ call: clip(call, 90), n })),
66
+ };
67
+ }
68
+
69
+ // The facts as lines for the model. Minutes since a bot posted (botSilentMin)
70
+ // is shown only on a dropped row; handoffMin only when nobody is working and the
71
+ // newest message hands work to a bot: how long a handoff sat is the whole
72
+ // question there. Anywhere else a minutes figure only drifted, and a drifting number flipped close
73
+ // calls (thr_0r2r read asks-you at 12 min and 2000, settled at 1127).
74
+ export function factsText(f) {
75
+ const lines = [
76
+ `Messages since the human's last post: ${f.sinceHuman}`,
77
+ ...(f.lastBy ? [`Newest message: from ${f.lastBy.author}${f.lastBy.human ? " (the human)" : ""}${f.lastBy.toHuman ? ", @-mentions the human" : ""}`] : []),
78
+ ...(f.askBack == null ? [] : [`Newest bot message that @-mentions the human: ${f.askBack === 0 ? "the newest message" : `${f.askBack} messages back`}; the human ${f.humanPostedSinceAsk ? "has posted since" : "has not posted since"}`]),
79
+ ...(f.turns?.length || f.handoffMin == null ? [] : [`Nobody is working here, and the newest message went to a bot ${f.handoffMin} minutes ago.`]),
80
+ `Fix/review rounds so far (reviews sent back to work): ${f.rounds}`,
81
+ ...(f.restarted ? ["A bot's server restarted and its turn went silent."] : []),
82
+ ];
83
+ for (const x of f.turns) {
84
+ lines.push(`${x.agent}'s turn: running ${x.turnMin ?? "?"} min, ${x.toolCalls} tool calls, no tool activity for ${x.quietMin ?? "?"} min`);
85
+ if (x.current) lines.push(` current call: ${x.current.call}${x.current.min == null ? "" : ` (${x.current.min} min in)`}`);
86
+ if (x.waitingMin != null) lines.push(` parked in task_wait for ${x.waitingMin} min`);
87
+ if (x.subagents) lines.push(` subagents: ${x.subagentsOut} of ${x.subagents} out`);
88
+ for (const r of x.repeats) lines.push(` repeated ${r.n} times of ${x.toolCalls} calls this turn: ${r.call}`);
89
+ }
90
+ return lines.join("\n");
91
+ }
92
+
93
+ // What changing means for a busy thread: the turns working in it, the
94
+ // subagents out, a parked wait, the worst repeat count, rounds, a restart, and
95
+ // how many passes it has been quiet. A bot moving from one call to the next
96
+ // changes none of these, so a thread that is making progress costs nothing;
97
+ // one that has gone quiet is looked at again every pass.
98
+ export const factsKey = (f) =>
99
+ createHash("sha256")
100
+ .update(JSON.stringify([f.rounds, f.restarted, f.turns.map((x) => [x.agent, x.subagentsOut, x.waitingMin != null, x.repeats[0]?.n ?? 0, x.quietMin == null ? null : Math.floor(x.quietMin / PASS_MIN)])]))
101
+ .digest("hex")
102
+ .slice(0, 8);
103
+
104
+ // A busy thread's basis: what classifyBasis reads, then its facts' key. Due
105
+ // when it differs from the one its entry was made from.
106
+ export const busyBasis = (base, key) => `${base}|f:${key}`;
107
+
108
+ // --- the reason line -----------------------------------------------------
109
+ // Each state the model can pick (dash-classify.mjs STATES) has a template over
110
+ // facts and the thread's own words; the model picks the state, code quotes.
111
+
112
+ const REASON_MAX = 400;
113
+ // A message's paragraphs as written (links as their labels), without the ones
114
+ // that are only a rule or markers.
115
+ export const paragraphs = (text) => String(text ?? "").split(/\n\s*\n/).map(flat).filter((p) => /[\p{L}\p{N}]/u.test(p));
116
+ // `label: “text”`, or just the label when the thread has no such message yet.
117
+ const labelled = (label, text) => (text ? `${label}: “${text}”` : label);
118
+
119
+ // `brief` is the thread's brief (thread-brief.mjs): its last messages, each
120
+ // { id, author, human, text }; `ask` is the paragraph the model picked as the
121
+ // ask (askQuestion), else the newest bot message.
122
+ export function reasonLine(state, brief, f, ask = null) {
123
+ const lastBot = [...(brief?.recent ?? [])].reverse().find((l) => !l.human);
124
+ const x = f.turns[0];
125
+ const doing = x?.current ? `${x.current.call}${x.current.min == null ? "" : `, ${x.current.min} min in`}` : x?.subagentsOut ? `${x.subagentsOut} of ${x.subagents} subagents out` : x?.waitingMin != null ? `waiting on its tasks ${x.waitingMin} min` : x ? "thinking" : null;
126
+ const line = {
127
+ "asks-you": () => labelled("asks you", ask ?? lastBot?.text),
128
+ stuck: () => `possibly stuck: ${doing ?? "silent"}${!x?.current && x?.quietMin ? `, no tool activity ${x.quietMin} min` : ""}${x?.repeats?.length ? `, repeated ${x.repeats[0].n}×` : ""}`,
129
+ dropped: () => `${labelled("stopped without finishing", lastBot?.text)}${f.botSilentMin == null ? "" : ` (${f.botSilentMin} min ago)`}`,
130
+ running: () => (x ? `${x.agent}: ${doing}` : "waiting on something that ends by itself"),
131
+ settled: () => labelled("nothing waiting on you", lastBot?.text),
132
+ }[state];
133
+ return clip(line ? line() : "", REASON_MAX);
134
+ }
@@ -85,10 +85,12 @@ export function threadBrief(t, snap, limits = BRIEF) {
85
85
  const who = (l) => `${l.author}${l.human ? " (human)" : ""}`;
86
86
 
87
87
  // The brief as text: the same block for every reader.
88
+ // "domain" is the label the classifier was measured with (scripts/lane-replay.mjs: "issue type" for the
89
+ // same value cost it two asks and the stall); keep it until a replay says otherwise.
88
90
  export function briefText(b) {
89
91
  return [
90
92
  `Title: ${b.title}`,
91
- `Project: ${b.project ?? "none"}${b.path && b.path !== b.project ? ` · path: ${b.path}` : ""} · issue type: ${b.issueType ?? "none"}`,
93
+ `Project: ${b.project ?? "none"}${b.path && b.path !== b.project ? ` · path: ${b.path}` : ""} · domain: ${b.issueType ?? "none"}`,
92
94
  ...(b.summary ? [`Summary${b.summary.by ? ` (by ${b.summary.by})` : ""}: ${b.summary.text}`] : []),
93
95
  ...(b.opening ? [`Opening message, ${who(b.opening)}: ${b.opening.text}`] : []),
94
96
  ...(b.phase ? [`Phase the bots reported: ${b.phase}`, ...b.reports.map((r) => `- ${r.bot}: ${r.summary}${r.pr ? ` (PR ${r.pr}${r.sha ? ` @ ${r.sha}` : ""})` : ""}`)] : []),
@@ -0,0 +1,108 @@
1
+ // The track summaries of main's dash pass (schedules/dash.ts): every open
2
+ // track (a path below its project, web/checkout; registry: snap.tracks keyed
3
+ // by path) has a free-form summary the human reads above its threads. A pass
4
+ // gives each track with a thread that has messages newer than the last pass
5
+ // that covered it (pass.trackMarks in the dash schedule's state: thread id -> newest message covered) one
6
+ // model call: the track's old summary and those threads' briefs in, the new
7
+ // summary out. Code only gathers facts; the model decides what the summary
8
+ // needs. A call that fails keeps its marks, so the track is asked again next
9
+ // pass.
10
+ //
11
+ // Plain JS on purpose: the schedule (TypeScript, bundled by eve) and the
12
+ // tests (plain node) import the same module.
13
+ import { runOnce } from "./hub.mjs";
14
+ import { said, briefText, threadBrief } from "./thread-brief.mjs";
15
+
16
+ // A track chat is main's own conversation with the human; it doesn't move the track.
17
+ const counted = (t) => !!t && t.kind === "thread" && !t.chatFor;
18
+ const pathOf = (t) => t.tags?.path ?? t.trackId ?? null;
19
+
20
+ // Every filed thread on an open track, as facts; and of those, the ones with
21
+ // a message newer than their mark. A thread without a mark (a first run, or
22
+ // filed onto an open track since) has never been covered, so all of it is
23
+ // news. A summary's write time isn't a mark: it doesn't say which threads'
24
+ // messages the summary covered. Newest first within a track.
25
+ export function tracksFacts(snap, marks = {}) {
26
+ const tracks = snap?.tracks ?? {};
27
+ const all = [];
28
+ for (const t of Object.values(snap?.threads ?? {})) {
29
+ if (!counted(t)) continue;
30
+ const track = tracks[pathOf(t)];
31
+ if (track && track.open !== false) all.push({ id: t.id, track: track.id, archived: !!t.archived, lastAt: (t.messages ?? []).filter(said).at(-1)?.at ?? null });
32
+ }
33
+ const wake = all.filter((f) => f.lastAt && f.lastAt > (marks[f.id] ?? ""));
34
+ wake.sort((a, b) => a.track.localeCompare(b.track) || String(b.lastAt).localeCompare(String(a.lastAt)));
35
+ return { all, wake };
36
+ }
37
+
38
+ export const TRACK_MODEL = "anthropic/claude-sonnet-5.5";
39
+ // Tracks asked per pass and threads shown per track; the rest wait for the next pass.
40
+ export const TRACKS_PER_PASS = 8;
41
+ export const THREADS_PER_TRACK = 6;
42
+ export const SUMMARY_MAX = 1500;
43
+
44
+ // The call for one track: its old summary, then each changed thread's brief
45
+ // and where the Dash put it.
46
+ export function trackPrompt(track, wake, snap) {
47
+ const entries = snap?.dash?.entries ?? {};
48
+ const oldSummary = track.summary?.text ?? snap?.pathSummaries?.[track.id]?.text;
49
+ const threads = wake.slice(0, THREADS_PER_TRACK).map((f) => {
50
+ const t = snap.threads[f.id];
51
+ const e = entries[f.id];
52
+ return [`### ${f.id}${f.archived ? " (archived)" : ""}`, ...(e?.lane ? [`Dash: ${e.lane}${e.reason ? `, ${e.reason}` : ""}`] : []), briefText(threadBrief(t, snap))].join("\n");
53
+ });
54
+ return [
55
+ `You keep the summary of one track of work current. A track groups the threads (chats between ${snap?.humans?.[0]?.name ?? "the human"} and AI bots) of one feature. The human reads the summary above the track's threads: a short markdown note of what's up in that feature (what's moving, what's blocked or waiting on them, what just landed), written for them.`,
56
+ `Track: ${track.title ?? track.id} (${track.id})`,
57
+ oldSummary ? `Current summary:\n${oldSummary}` : "There is no summary yet.",
58
+ `Threads with news since the summary was last written:`,
59
+ ...threads,
60
+ `Write the whole new summary: keep what is still right, fold in the news, drop what is stale. Answer with the summary only, no preamble, at most ${SUMMARY_MAX} characters.`,
61
+ ].join("\n\n");
62
+ }
63
+
64
+ // One pass. State: the dash schedule's own, pass.trackMarks in it: per thread on an open track, the newest message
65
+ // a summary was written for. Saved only when a mark moved. `summarize(prompt)`
66
+ // -> the summary text; `hub.setSummary(path, text)` stores it. A track with no
67
+ // summarize, a failed call or an empty answer keeps its marks.
68
+ // -> { written, wake }
69
+ /** @param {any} hub @param {{ summarize?: (prompt: string) => Promise<string>, log?: any }} [opts] */
70
+ export async function runTracks(hub, { summarize, log = console } = {}) {
71
+ let written = 0;
72
+ let wake = [];
73
+ await runOnce(hub, "dash", async (snap, saved) => {
74
+ const had = saved?.pass?.trackMarks ?? {};
75
+ const facts = tracksFacts(snap, had);
76
+ wake = facts.wake;
77
+ const marks = { ...had, ...Object.fromEntries(facts.all.filter((f) => !f.lastAt).map((f) => [f.id, ""])) };
78
+ const byTrack = Map.groupBy(wake, (f) => f.track);
79
+ const todo = [...byTrack].slice(0, TRACKS_PER_PASS);
80
+ await Promise.all(
81
+ todo.map(async ([id, ws]) => {
82
+ try {
83
+ const text = (await summarize?.(trackPrompt(snap.tracks[id], ws, snap)))?.trim();
84
+ if (!text) return;
85
+ await hub.setSummary(id, text.slice(0, SUMMARY_MAX));
86
+ for (const f of ws.slice(0, THREADS_PER_TRACK)) marks[f.id] = f.lastAt;
87
+ written++;
88
+ } catch (err) {
89
+ log?.warn?.(`tracks: summary for ${id} failed, asked again next pass: ${err?.message ?? err}`);
90
+ }
91
+ }),
92
+ );
93
+ const changed = JSON.stringify(marks) !== JSON.stringify(had);
94
+ return { posts: [], state: { ...saved, pass: { ...saved?.pass, trackMarks: marks } }, changed };
95
+ });
96
+ return { written, wake };
97
+ }
98
+
99
+ // The schedule's summarizer: one text generation per track. Throws on a
100
+ // failed call (runTracks keeps the marks).
101
+ /** @param {{ generateText?: (options: any) => Promise<{ text: string }>, model?: string, timeoutMs?: number }} [options] */
102
+ export function modelSummarize({ generateText, model = TRACK_MODEL, timeoutMs = 60_000 } = {}) {
103
+ return async (prompt) => {
104
+ if (!generateText) return "";
105
+ const r = await generateText({ model, prompt, maxRetries: 1, abortSignal: AbortSignal.timeout(timeoutMs) });
106
+ return r.text;
107
+ };
108
+ }
package/stubs.mjs ADDED
@@ -0,0 +1,46 @@
1
+ // eve finds tools, hooks, and channels by file (the tool name is the file
2
+ // name), so each bot keeps a one-line stub per kit module. Bot file -> what it
3
+ // is, and which kit module it re-exports (default: the same path).
4
+ export const KIT_FILES = {
5
+ "channels/eve.ts": ["Queued eve channel that resolves the registry's human as a user.", "channels/eve-roster-user.ts"],
6
+ "hooks/sandbox.ts": ["Migrates legacy Docker sessions to the host sandbox."],
7
+ "hooks/mention-gate.ts": ["Mention gate: a cheap model decides whether an inbound @-mention or DM needs a turn."],
8
+ "tools/bash.ts": ["eve's bash with a default time limit (kills the process group) and the workspace's code location."],
9
+ "tools/react.ts": ["React to a message with an emoji."],
10
+ "tools/show.ts": ["Move the human's screen to a thread, DM, or message."],
11
+ "tools/browser.ts": ["Its own headless browser, watched live in the Computer view."],
12
+ "tools/attach_image.ts": ["Puts an image (a sandbox file or a browser screenshot) on this turn's reply."],
13
+ "tools/view_image.ts": ["Shows the bot an image (a sandbox file or a browser screenshot) so it can see it."],
14
+ "tools/update_thread.ts": ["Update a thread this bot is in: title, track, domain and summary."],
15
+ "tools/list_threads.ts": ["Every workspace thread, from any session."],
16
+ "tools/read_thread.ts": ["Read any workspace thread."],
17
+ "tools/artifact_publish.ts": ["Publish the next version of an artifact (a change explainer, ...) and show its card in the thread."],
18
+ "tools/artifact_status.ts": ["Whether an artifact this bot published still describes its source."],
19
+ "tools/artifact_list.ts": ["The artifacts this bot owns, or every artifact in the workspace."],
20
+ "skills/change-explainer.ts": ["The change-explainer skill (packages/explainer/skills/change-explainer/SKILL.md), which artifact_publish's change explainers follow."],
21
+ "tools/post_message.ts": ["Post into an existing thread or a bot's DM; with `status`, report the thread's phase."],
22
+ };
23
+
24
+ // main, the orchestrator: the shared files (the same channel as every bot),
25
+ // plus the tools that shape the workspace.
26
+ export const MAIN_KIT_FILES = {
27
+ ...KIT_FILES,
28
+ "tools/create_thread.ts": ["Open a workspace thread for one piece of work."],
29
+ "tools/set_summary.ts": ["Write the summary of a path (project/track) or a thread."],
30
+ "tools/move_path.ts": ["Rename or merge a path (project/track): every thread under it moves."],
31
+ "tools/archive_thread.ts": ["Archive a thread whose work is done."],
32
+ "tools/unarchive_thread.ts": ["Bring an archived thread back."],
33
+ "tools/list_agents.ts": ["The team right now: roles, models, who's busy."],
34
+ "tools/create_agent.ts": ["Add a bot to the workspace."],
35
+ "tools/remove_agent.ts": ["Remove a bot from the workspace."],
36
+ "tools/restart_agent.ts": ["Restart one bot's server."],
37
+ "tools/restart_hub.ts": ["Restart the whole hub; this turn is re-delivered once it's back."],
38
+ "tools/agent_send.ts": ["Queue for, steer, or abort another bot's session."],
39
+ "tools/tag_threads.ts": ["Thread tags: the vocabulary, and staged backfill / merge proposals."],
40
+ "tools/dash_update.ts": ["Set Dash entries: importance, lane, reason and deferred per thread."],
41
+ "tools/apply_tags.ts": ["Retag threads, merge or rename tags: applies a staged proposal or a new batch directly."],
42
+ // Handler schedules: the hub fires them from its minute clock (registry/schedules.mjs).
43
+ "schedules/compact.ts": ["Every 10 minutes: compacts idle sessions once their prompt cache has gone cold, reported in sch-main-compact."],
44
+ "schedules/dash.ts": ["When a bot's turn ends (fired by the hub), and every 8 minutes: the human's PRs, a Dash entry for each thread that changed, and a new summary for each track with news."],
45
+ "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."],
46
+ };
@@ -0,0 +1,4 @@
1
+ - **Team.** There is no `create_agent` or `remove_agent`: the roster is `hub.workspace.json`, and a deployment changes it by commit. If nobody on the team covers the work well, ask the human before going on.
2
+ - **Restarts.** There is no `restart_agent` or `restart_hub`: you cannot restart a bot or the hub. A new deployment replaces the running bots.
3
+ - **Memory.** The standing decisions every bot recalls are the `memory/` and `agents/<bot>/memory/` files built into each deployment. A new entry takes effect when the deployment that contains it is live, not on the next turn.
4
+ - **Schedules.** The build removes the kit's schedules (`dash`, `compact`, `prune`), so no classifier runs, no tick posts, and nothing rewrites track summaries. What you write with `set_summary` and `dash_update` stands until you change it.
@@ -0,0 +1,7 @@
1
+ - **Team.** `create_agent` adds a bot to the workspace; if nobody on the team covers the work well, create a specialist (name, model, role, instructions), then route to it. Give planners and reviewers `preset: "read-only"` so they can't edit files. `remove_agent` removes a bot from the team and shuts it down (name); use it when the human asks. It can't remove main or humans.
2
+ - **Restarts.** `restart_agent <name>` restarts one bot (after its config changed), by default once it's idle; interrupted turns are re-delivered. `restart_hub` restarts the hub (after hub's own code changed): the registry is replaced, and each bot keeps running unless its code, env or config changed. Only you can, and never with `hub restart` in bash, which could kill your own server mid-command. A "Hub restarted" line in the thread means the restart you asked for is done: report it and don't restart again; if the restart cut your turn off, it comes back re-delivered after that line.
3
+ - **Memory.** A new memory entry ships in the next workspace sync PR; each bot recalls it on its next turn, as a `Standing decision <id>:` or `Your standing decision <id>:` message.
4
+ - **Summaries.** Your `dash` schedule rewrites the summary of every track with a thread that has new messages (one model call per track, from the old summary and those threads), every 8 minutes and as soon as a bot's turn ends; you needn't, but a summary you write with `set_summary` stands until a thread on the track moves again. `list_threads` returns thread and track summaries, and the classifier files threads by them.
5
+ - **Classifier.** Your `dash` schedule classifies every non-deferred thread, busy ones included, whenever its facts change (at most once a minute) and otherwise on a growing backoff, and writes the entries itself. It reads the thread; whether it waits on the human is the model's call. You don't rewrite the Dash on each tick.
6
+ - **Ticks.** The same schedule also scans PRs, threads where a bot asks the human, and Dash threads that moved on, and posts in `sch-main-dash` only when something changed. On a tick, fix only what the classifier got wrong with `dash_update` (fields you leave out keep their value): change a `lane` (with its `reason`) the classifier got wrong, raise `importance` for what matters more given the human's role, set `deferred: true` for work the human parked, `remove` an entry that no longer applies. Often nothing needs changing. Don't reply in the tick thread beyond a one-line note, and mention nobody.
7
+ - **Unfiled paths.** The classifier files unfiled threads onto an existing path in their project. When none fits, one notice gives every unfiled thread in that project alongside every path in use. Read their briefs and put each on a listed path where one fits, or a new `project/feature` path, with `update_thread` (`threadId` and `path` only, including threads you aren't in). Merge near-duplicates with `move_path`. Never move a human-locked path.
@@ -0,0 +1,4 @@
1
+ - You run on the owner's Mac, not on Vercel: your shell is the owner's machine, with real filesystem paths.
2
+ - You are made of files in the workspace repository: `agents/<bot>/`, `memory/` and `hub.workspace.json`. A change to them takes effect when you are restarted; memory entries are recalled every turn.
3
+ - To look at an image file yourself (a screenshot you took, a capture), call `view_image` with its path; `read_file` can't read images.
4
+ - Link a local file by its absolute path and line, ``[`docs/setup.md:42`](/Users/<you>/wrk/<repo>/docs/setup.md#L42)``; clicking opens it in the human's editor.
@@ -1,22 +1,21 @@
1
1
  You are **main**, the configured human's long-lived partner and the orchestrator of this workspace. The human talks to you first.
2
2
 
3
3
  ## Know the team at runtime
4
- You don't know in advance which bots exist or what they're good at, and the team changes. Every message you get lists the current team with each bot's role; `list_agents` also tells you each bot's model, what its subagents and `agent` copies are capped to, and what it's doing right now. Check before you route. `list_threads` / `read_thread` show every thread in the workspace, whichever session you're in. `restart_agent <name>` restarts one bot (after its config changed), by default once it's idle; interrupted turns are re-delivered. `restart_hub` restarts the hub (after hub's own code changed): the registry is replaced, and each bot keeps running unless its code, env or config changed. Only you can, and never with `hub restart` in bash, which could kill your own server mid-command. A "Hub restarted" line in the thread means the restart you asked for is done: report it and don't restart again; if the restart cut your turn off, it comes back re-delivered after that line. `agent_send` reaches a bot's session in a thread: `queue` (default) posts your input as its next turn, `steer` lands it in the turn that's running now, and `abort` stops the turn (nothing re-delivered, no restart); abort with input means "stop, do this instead".
4
+ You don't know in advance which bots exist or what they're good at, and the team changes. Every message you get lists the current team with each bot's role; `list_agents` also tells you each bot's model, what its subagents and `agent` copies are capped to, and what it's doing right now. Check before you route. `list_threads` / `read_thread` show every thread in the workspace, whichever session you're in. `agent_send` reaches a bot's session in a thread: `queue` (default) posts your input as its next turn, `steer` lands it in the turn that's running now, and `abort` stops the turn (nothing re-delivered, no restart); abort with input means "stop, do this instead".
5
5
 
6
6
  ## Route like a world-class lead
7
7
  - Send each piece of work straight to the bot whose stated role fits it best. There is no fixed pipeline; match the work, not a habit.
8
8
  - Plan only when the work needs it: unclear scope, many moving parts, or real risk. A clear fix, a bug, a question, or a small change goes directly to whoever does that kind of work.
9
9
  - A bot's threads run in parallel, so being busy elsewhere never makes it unavailable. Route to the best fit and expect it to start now; when two bots fit equally, prefer the idle one to spread the load. Never tell anyone a thread is waiting behind another bot's work.
10
- - If nobody on the team covers the work well, `create_agent` a specialist (name, model, role, instructions), then route to it. Give planners and reviewers `preset: "read-only"` so they can't edit files. Ask the human first only when it's a real judgment call (cost, model choice).
11
10
  - Don't do a thread's work yourself. Answer directly only quick questions you can settle from the conversation.
12
11
  - **Cross-thread chores (merge, rebase, close out, restart) fan out to the threads that own the work.** Post in each owning thread and @-mention the bot that built it. That bot lands its own branch, rebases onto the latest target and fast-forwards (retrying if the target moved), checks the build, and reports the sha in its own thread. Never collect several threads' work into one bot or into your own session. Track which threads have landed and report back where the human asked.
13
12
 
14
13
  ## Opening work
15
14
  **Paths.** A thread is one piece of work: one task, usually one PR. Every thread sits on one **path**, `project/track`, e.g. `web/checkout/cart`: the first segment is the project, the rest is the feature or effort inside it ("checkout redesign", "billing migration"), which spans many threads and PRs. Put each thread you open on the path for its area if one exists, even read broadly (`list_threads` shows each thread's path; a bug or follow-up in an area belongs to it). A new path only for work that clearly starts a new effort, named for the feature, never the task. Near-duplicates (`web/checkout` and `web/checkout-ui`) get merged with `move_path`. Never move a thread whose path the human set (it's locked).
16
15
 
17
- **Summaries.** You lead every track: keep each one's summary current with `set_summary` (scope `path:<path>`, e.g. `path:web/checkout`), a short free-form markdown note for the human of what's up in that feature. You decide what's worth saying and when; there's no fixed format. Every 10 minutes your `tracks` schedule tags the summary writer in `sch-main-tracks` with each thread that has new messages since a notice last listed it, by track, with its facts. The writer is you unless the schedule names another bot; then leave those notices to it. When it's you: read each listed thread (`read_thread`) and rewrite its track's summary where it no longer states where the work stands; leave a summary that's still right. Reply there with one line and mention nobody. A thread can carry a summary too (scope `thread:<id>`: what it's about and where it stands); write one when a thread's opening ask no longer describes it. `list_threads` returns both, and the classifier files threads by them.
16
+ **Summaries.** You lead every track: keep each one's summary current with `set_summary` (scope `path:<path>`, e.g. `path:web/checkout`), a short free-form markdown note for the human of what's up in that feature. You decide what's worth saying and when; there's no fixed format. A thread can carry a summary too (scope `thread:<id>`: what it's about and where it stands); write one when a thread's opening ask no longer describes it. `list_threads` returns both.
18
17
 
19
- **Track chats.** Each path can have one track chat: an ordinary thread on that path, opened from the track page's composer, where the human asks you about the track. Answer from the track's threads, PRs and summary. When a question turns into work, post it in the thread that owns it, or open a new thread on the path and hand it to a builder, and link that thread in your reply. A track chat isn't counted among the path's threads and doesn't trigger your `tracks` schedule.
18
+ **Track chats.** Each path can have one track chat: an ordinary thread on that path, opened from the track page's composer, where the human asks you about the track. Answer from the track's threads, PRs and summary. When a question turns into work, post it in the thread that owns it, or open a new thread on the path and hand it to a builder, and link that thread in your reply. A track chat isn't counted among the path's threads and doesn't move the track's summary.
20
19
 
21
20
  Call `create_thread` per piece of work, with its `path` and a `domain` tag (reuse the ones `list_threads` shows in use; a new one only when none fits). Its first message @-mentions **only** the bot who should start, with the goal, constraints, what "done" looks like, and "validate with the human" when sign-off is needed. Don't prescribe who comes after; bots hand off to each other by checking the workspace state themselves.
22
21
 
@@ -25,20 +24,15 @@ Then reply to the human in 1–3 lines: what you opened and who's on it. In a DM
25
24
  When the human starts a thread themselves (from the new-thread view), it comes to you untitled and already is the piece of work: don't `create_thread` another. File it first with one `update_thread` call (a few words for what's asked as the `title`, a `path` (project/track), a `domain` and a short `summary`; the delivery lists the projects and domains in use and every track with its summary), then route the work inside it by @-mentioning the bot who should start, or answer if it's a quick question.
26
25
 
27
26
  ## Memory
28
- When the human states a durable preference (one that should hold beyond the current task), add it as a memory entry in the workspace repo: `memory/<id>.md` if it holds for every bot (naming no bot; a rule for one role says the role), or `agents/<bot>/memory/<id>.md` if it holds for one bot only (it may name that bot, no other). Front matter `id` (the file name, not already used in the other layer), `status: active`, `since` (today), `evidence` (`[thr_…/msg_…]`), then the rule in at most 3 lines. If it replaces an entry, set that one's `status: superseded` and `superseded_by: <new id>` rather than rewriting it. It ships in the next workspace sync PR; each bot recalls it on its next turn, as a `Standing decision <id>:` or `Your standing decision <id>:` message.
27
+ When the human states a durable preference (one that should hold beyond the current task), add it as a memory entry in the workspace repo: `memory/<id>.md` if it holds for every bot (naming no bot; a rule for one role says the role), or `agents/<bot>/memory/<id>.md` if it holds for one bot only (it may name that bot, no other). Front matter `id` (the file name, not already used in the other layer), `status: active`, `since` (today), `evidence` (`[thr_…/msg_…]`), then the rule in at most 3 lines. If it replaces an entry, set that one's `status: superseded` and `superseded_by: <new id>` rather than rewriting it.
29
28
 
30
29
  ## Shaping the workspace
31
- - `create_agent` adds a bot; see above.
32
- - `remove_agent` removes a bot from the team and shuts it down (name). Use it when the human asks. It can't remove main or humans.
33
30
  - `show` moves the human's screen to a thread or a DM, optionally scrolled to one message. `create_thread` already takes them to the new thread.
34
31
  - `post_message` posts as you into an existing thread (`threadId`) or a bot's DM (`agent`), from any session: e.g. telling several stalled threads to retry. Its @-mention wakes that bot just like a reply (one mention, never yourself); archived threads refuse it. Every bot has it, not just you. Without a target it posts into the thread you're in, which is how you talk there.
35
32
  - **Thread tags** (`project`, `domain`) group the human's sidebar. There's no fixed list: the tags are whatever threads use. Keeping them tidy is your job, ongoing: retag a thread whose tag no longer fits what it's doing, merge near-duplicates and one-offs into bigger tags, rename a tag. Do it as a model pass: `tag_threads` `vocabulary` for what's in use and what's untagged, `list_threads` / `read_thread` to read what the threads actually did (not just their titles), then `apply_tags` with `assignments` (tags per thread), `merges` (domains only: `from` into `to`; a rename is a one-tag merge; projects and paths merge or rename with `move_path`) and/or `labels` (a tag's display name, which heads its sidebar group: `payments-api` → "Payments API"; a tag without one shows title-cased, so name any that read wrong that way). It applies right away, no approval needed; say in a line what you changed. For a big pass the human asked to review first, stage it with `tag_threads` `propose`, post the table, and `apply_tags` its `proposalId` once they say yes. Tags and labels the human set by hand stay put, merges included; change one only when the human asks you to.
36
33
  - `archive_thread` takes a thread off the active list once the human has signed off on it or dropped it; history is kept. Archive only when the human says so or the thread's work is clearly finished, and give a short `reason`. `unarchive_thread` brings one back.
37
34
 
38
35
  ## The Dash
39
- The Dash is the human's view of open threads that need them, grouped by project, then by delay. Each thread can carry one **entry**: `importance` (`high` | `normal` | `low`: how much it matters given the human's role), `delay` (`blocking`: a bot can't go on until the human acts; `land`: finished work waits on a merge or sign-off and goes stale; `fyi`: nothing waits on it), an optional `reason`, and `deferred` (on the backburner: hidden, and not classified again until un-deferred). The sidebar badge counts `blocking` entries. Threads where a bot is working are hidden until they settle.
40
- - **Classifier.** Your `dash` schedule classifies every idle, non-deferred thread that moved on since its entry was written, every 10 minutes, and writes the entries itself. You don't rewrite the Dash on each tick.
41
- - **Ticks.** The same schedule also scans PRs, threads where a bot asks the human, and Dash threads that moved on, and posts in `sch-main-dash` only when something changed. On a tick, fix only what the classifier got wrong with `dash_update` (fields you leave out keep their value): raise `importance` for what matters more given the human's role, set `deferred: true` for work the human parked, `remove` an entry that no longer applies. Often nothing needs changing. Don't reply in the tick thread beyond a one-line note, and mention nobody.
42
- - **Unfiled paths.** The classifier files unfiled threads onto an existing path in their project. When none fits, one notice gives every unfiled thread in that project alongside every path in use. Read their briefs and put each on a listed path where one fits, or a new `project/feature` path, with `update_thread` (`threadId` and `path` only, including threads you aren't in). Merge near-duplicates with `move_path`. Never move a human-locked path.
36
+ The Dash is the human's view of open threads, in four lanes: **needs-you** (the human has to act or decide), **ready** (reviewed work waiting on their sign-off; the hub sets it when the reviewer's latest verdict on the PR is ship at its current head; you can't write it), **running** (a bot's turn is live; an idle thread with a handoff nobody picked up is needs-you) and **fyi**. Each thread can carry one **entry**: `importance` (`high` | `normal` | `low`: how much it matters given the human's role), `lane` with a one-line `reason`, and `deferred` (on the backburner: hidden, and not classified again until un-deferred). The sidebar counts needs-you and ready entries.
43
37
  - **Deferred.** The human can defer or bring back a thread from the Dash themselves; don't undo their call.
44
38
  - **Report only.** The Dash never nudges bots, merges, or posts into the threads it lists.