tryharness 0.7.22 → 0.7.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.
package/lib/cards.mjs CHANGED
@@ -71,6 +71,8 @@ export function swarmCards(store, entries, { home } = {}) {
71
71
  work.set(row.agent, entry);
72
72
  }
73
73
  const seen = new Map(store.agentsSeen().map((row) => [row.agent, row.last_seen_at]));
74
+ // What its own sessions showed after its changes, from the relay's receipts (a store from before receipts has none).
75
+ const checks = store.receiptCounts?.() ?? {};
74
76
  return entries.map((entry) => {
75
77
  const roster = member(entry.id) ?? {};
76
78
  const done = work.get(entry.id) ?? { done: 0, failed: 0, minutes: [], working: 0, waiting: 0 };
@@ -79,7 +81,7 @@ export function swarmCards(store, entries, { home } = {}) {
79
81
  id: entry.id, name: roster.name ?? entry.id, about: roster.about ?? "", limits: roster.limits ?? null, where: roster.where ?? null,
80
82
  ...(roster.runs === "machine" ? { review: roster.review === true, model: modelLine(store, entry.id, home) } : {}),
81
83
  answers: answersFor(entry),
82
- record: { done: done.done, failed: done.failed, medianMinutes: minutes === null ? null : Math.round(minutes * 10) / 10 },
84
+ record: { done: done.done, failed: done.failed, medianMinutes: minutes === null ? null : Math.round(minutes * 10) / 10, ...(checks[entry.id] ? { checks: checks[entry.id] } : {}) },
83
85
  working: done.working, waiting: done.waiting, lastSeen: seen.get(entry.id) ?? null, note: noteOf(store, entry.id),
84
86
  ...(entry.setup?.state ? { setup: String(entry.setup.state) } : {}),
85
87
  };
@@ -100,6 +102,8 @@ export function cardLine(card, now = Date.now()) {
100
102
  const { done, failed, medianMinutes } = card.record;
101
103
  // Work for an agent whose tasks live on Harness's server is not on this board, so none here says nothing about it.
102
104
  if (done || failed) parts.push(`On this board: ${done} done, ${failed} failed${medianMinutes === null ? "" : `; a median of ${span(medianMinutes)} from start to report`}.`);
105
+ const checks = card.record.checks;
106
+ if (checks) parts.push(`Its own sessions show its changes tested after the last edit ${checks.tested} of ${checks.tested + checks.untested + checks.failing} times${checks.failing ? ` (${checks.failing} with tests failing)` : ""}.`);
103
107
  else if (member(card.id)?.home === "machine") parts.push("Has finished no work on this board yet.");
104
108
  if (card.working || card.waiting) parts.push(`Now: ${card.working} running, ${card.waiting} waiting for it.`);
105
109
  // An agent that picks work up itself: when it last looked says whether it is around.
@@ -0,0 +1,290 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { openAtlas, treeFiles, addFiles, embedMissing, searchFiles, ATLAS_VERSION } from "./atlas.mjs";
5
+ import { readTrail, needs, trailFromTranscript } from "./trails.mjs";
6
+ import { featureRows, swarmPrior, baselineScore, train, modelScore, recallAt, loadModel, saveModel, codeFiles } from "./learn.mjs";
7
+ import { repositoryOf } from "./worktrees.mjs";
8
+ import { createMediaIndex } from "./media.mjs";
9
+
10
+ // The context engine. It keeps the atlas of the code the swarm works in, remembers what every coding session on this
11
+ // computer needed (its trail: what it read before its first edit and what it edited, from any agent), and turns that
12
+ // into a context pack for each brief: the files most likely needed, with the line to start at and why. Overnight, on
13
+ // the charger, it retrains its small ranking model on the runs it has seen and keeps the new model only if it beats the
14
+ // old one on the newest runs. Packs are leads for local agents; nothing here leaves the computer.
15
+
16
+ const PACK_FILES = 8;
17
+ const POOL = 60;
18
+ const HISTORY_DAYS = 60;
19
+ const REFRESH_MS = 15 * 60_000;
20
+ const LEARN_EVERY_MS = 20 * 3_600_000;
21
+ // Vectors of the pieces packs search stay in memory while packs are being made, and go after this long without one.
22
+ const VECTORS_IDLE_MS = 5 * 60_000;
23
+ const HELD_OUT_SHARE = 0.2;
24
+ // A recording's minute or a screenshot joins a brief only this close to the request: on the authored meeting every right
25
+ // answer scored 0.68 or more and no question about something never said passed 0.675 (eval media-search); 0.70 keeps a
26
+ // margin and 16 of 20 right answers. An agent's own search_media has no floor.
27
+ const MEDIA_FLOOR = 0.7;
28
+ const MEDIA_IN_PACK = 2;
29
+ const MIN_TRAINING_RUNS = 30;
30
+
31
+ const git = (cwd, args) => { const out = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28 }); return out.status === 0 ? out.stdout : ""; };
32
+ const commitBefore = (root, at) => git(root, ["rev-list", "-1", `--before=${new Date(at).toISOString()}`, "HEAD"]).trim() || null;
33
+ // Files changed in the two weeks before a commit, by how often; kept per commit, since a commit's history is fixed.
34
+ const recentByCommit = new Map();
35
+ export function recentChanges(root, commit = "HEAD") {
36
+ const sha = git(root, ["rev-parse", commit]).trim();
37
+ if (recentByCommit.has(sha)) return recentByCommit.get(sha);
38
+ const counts = new Map();
39
+ const since = new Date((Number(git(root, ["show", "-s", "--format=%ct", sha]).trim()) - 14 * 86_400) * 1000).toISOString();
40
+ for (const file of git(root, ["log", `--since=${since}`, "--name-only", "--format=", sha]).split("\n")) if (file) counts.set(file, (counts.get(file) ?? 0) + 1);
41
+ if (recentByCommit.size > 500) recentByCommit.clear();
42
+ recentByCommit.set(sha, counts);
43
+ return counts;
44
+ }
45
+ const days = (ms) => Math.round(ms / 86_400_000);
46
+ const AGENT_NAMES = { "claude-code": "Claude Code", codex: "Codex", pi: "Pi", opencode: "OpenCode" };
47
+
48
+ /**
49
+ * Ranks a snapshot's files for a request with every signal the engine has: [{ path, best, score, x }] best first.
50
+ * history: earlier runs in this repository ({ at, edited, read }); model: the learned ranker or null for the default.
51
+ */
52
+ export async function rankFiles({ atlas, inference, query, scope, history = [], at = Date.now(), recent = new Map(), model = null, vectors = null, lane = "handoff", wordLimit }) {
53
+ const candidates = await searchFiles(atlas, inference, query, scope, { vectors, lane, wordLimit });
54
+ const topWords = Math.max(...candidates.map((c) => c.lexical), 0) || 1;
55
+ const hybrid = (c) => c.dense + 0.25 * (c.lexical / topWords) + 0.2 * c.named;
56
+ const byHybrid = [...candidates].sort((a, b) => hybrid(b) - hybrid(a));
57
+ const prior = swarmPrior(history, at);
58
+ // No cross-encoder pass: on the owner's own sessions it moved recall by under one point (context-recall v3).
59
+ const byDense = [...candidates].sort((a, b) => b.dense - a.dense).slice(0, POOL).map((c) => c.path);
60
+ const byWords = [...candidates].sort((a, b) => b.lexical - a.lexical).slice(0, POOL).map((c) => c.path);
61
+ const pool = new Set([...byDense, ...byWords, ...byHybrid.slice(0, POOL).map((c) => c.path), ...candidates.filter((c) => c.named > 0).map((c) => c.path),
62
+ ...[...prior.edits.entries()].sort((a, b) => b[1] - a[1]).slice(0, POOL).map(([file]) => file)]);
63
+ const best = new Map(candidates.map((c) => [c.path, c.best]));
64
+ const scored = featureRows(candidates.filter((c) => pool.has(c.path)), { prior, recent })
65
+ .map((row) => ({ ...row, best: best.get(row.path), score: model ? modelScore(model, row.x) : baselineScore(row.x) }));
66
+ return scored.sort((a, b) => b.score - a.score);
67
+ }
68
+
69
+ export function createContextEngine({ store, inference, home, known, mediaFolders = () => [], mediaReady = () => false, log = () => {}, notify = () => {} }) {
70
+ const atlas = openAtlas(path.join(home, "atlas.db"));
71
+ // Recordings and screenshots in folders the person added (media.mjs), once the image and audio part is here.
72
+ const media = createMediaIndex({ atlas, inference, folders: mediaFolders, log });
73
+ const modelPath = path.join(home, "ranker.json");
74
+ let model = loadModel(modelPath);
75
+ let refreshing = null;
76
+ let timer = null;
77
+ const cached = new Map();
78
+ let dropCache = null;
79
+ /** The vectors for a scope's pieces, read from the atlas once and kept until packs stop for a while. */
80
+ const vectorsFor = (scope) => {
81
+ const wanted = new Set();
82
+ for (const row of scope) for (const hash of atlas.blob(row.blob) ?? []) if (!cached.has(hash)) wanted.add(hash);
83
+ if (wanted.size) for (const [hash, vector] of atlas.vectors(wanted, ATLAS_VERSION)) cached.set(hash, vector);
84
+ clearTimeout(dropCache);
85
+ dropCache = setTimeout(() => cached.clear(), VECTORS_IDLE_MS);
86
+ dropCache.unref?.();
87
+ return cached;
88
+ };
89
+
90
+ /** The repository a folder belongs to and where its files are (a worktree is read where it is, named as its repository). */
91
+ const placeOf = (folder) => {
92
+ const repo = repositoryOf(folder);
93
+ if (!repo) return null;
94
+ const common = git(repo.root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).trim();
95
+ const root = common && path.basename(common) === ".git" ? path.dirname(common) : repo.root;
96
+ return { root, top: repo.root };
97
+ };
98
+
99
+ const roots = () => [...new Set(known().map((place) => placeOf(place.path)?.root).filter(Boolean))];
100
+
101
+ /** Brings one checkout's files into the atlas; returns its files. Pieces are embedded later on the background lane. */
102
+ const indexTree = (top, commit = null) => {
103
+ const rows = treeFiles(top, commit);
104
+ addFiles(atlas, top, rows);
105
+ return rows;
106
+ };
107
+
108
+ /** Records every coding session that ran in a known repository: what it was asked and what it needed. */
109
+ const recordRuns = async () => {
110
+ const rootList = roots();
111
+ const since = new Date(Date.now() - HISTORY_DAYS * 86_400_000).toISOString();
112
+ const trees = new Map();
113
+ let added = 0;
114
+ for (const session of store.sessionSources({ since })) {
115
+ const place = placeOf(session.cwd);
116
+ if (!place || !rootList.includes(place.root)) continue;
117
+ // A session whose source file the agent has since deleted is read from the relay's own copy, once.
118
+ let mtime = 0;
119
+ try { mtime = fs.statSync(session.source).mtimeMs; } catch {}
120
+ if (atlas.runSeen(session.agent, session.session_id) === mtime) continue;
121
+ const trail = mtime ? await readTrail(session.agent, session.source)
122
+ : trailFromTranscript(session.agent, store.sessionTranscript(session.agent, session.session_id), { cwd: session.cwd, startedAt: Date.parse(session.started_at) || null });
123
+ if (!trail?.brief || trail.startedAt === null) continue;
124
+ const commit = commitBefore(place.root, trail.startedAt);
125
+ if (!commit) continue;
126
+ if (!trees.has(commit)) trees.set(commit, new Set(treeFiles(place.root, commit).map((row) => row.path)));
127
+ const need = needs({ ...trail, cwd: trail.cwd && placeOf(trail.cwd)?.top === place.top ? path.join(place.root, path.relative(place.top, trail.cwd)) : trail.cwd }, place.root, trees.get(commit));
128
+ atlas.putRun({ agent: session.agent, sessionId: session.session_id, root: place.root, at: trail.startedAt, brief: trail.brief, edited: need.edited, read: need.read, before: need.beforeFirstEdit, sourceMtime: mtime });
129
+ added += 1;
130
+ }
131
+ return added;
132
+ };
133
+
134
+ const embedRuns = async (lane) => {
135
+ for (;;) {
136
+ const rows = atlas.runsToEmbed(ATLAS_VERSION, 16);
137
+ if (!rows.length) return;
138
+ const vectors = await inference.embed(rows.map((row) => ({ kind: "document", title: "none", text: row.brief })), { lane });
139
+ rows.forEach((row, i) => atlas.putRunVector(row.agent, row.session_id, ATLAS_VERSION, vectors[i]));
140
+ }
141
+ };
142
+
143
+ /** Keeps the atlas current: the known repositories' files, the runs, and every piece's vector, at background priority. */
144
+ const refresh = () => {
145
+ refreshing ||= (async () => {
146
+ const power = inference.power;
147
+ // A first pass over a repository waits for the charger; small catch-ups run on battery too unless it is low.
148
+ if (power.paused()) return { skipped: "power" };
149
+ const first = !atlas.roots().length;
150
+ if (first && !power.onCharger()) return { skipped: "waiting for the charger" };
151
+ let files = 0;
152
+ for (const root of roots()) {
153
+ const rows = indexTree(root);
154
+ atlas.setFiles(root, rows);
155
+ files += rows.length;
156
+ }
157
+ const runs = await recordRuns();
158
+ const pieces = await embedMissing(atlas, inference, { lane: "background" });
159
+ await embedRuns("background");
160
+ const recorded = mediaReady() && mediaFolders().length && power.onCharger() ? await media.refresh({ lane: "background" }) : null;
161
+ return { files, runs, pieces, ...(recorded ? { media: recorded } : {}) };
162
+ })().catch((error) => { log(`context engine: ${error.message}`); return { error: error.message }; }).finally(() => { refreshing = null; });
163
+ return refreshing;
164
+ };
165
+
166
+ const historyFor = (root) => atlas.runs(root).map((run) => ({ at: run.at, edited: run.edited, read: run.read }));
167
+
168
+ /**
169
+ * The context pack for a brief going to a local agent working in folder: { files, earlier, text }. Files are the most
170
+ * likely needed, each with the line to start at and why; earlier runs are sessions whose request was close to this one.
171
+ */
172
+ const pack = async ({ brief, folder, lane = "handoff" }) => {
173
+ const started = performance.now();
174
+ const place = folder && placeOf(folder);
175
+ if (!place || !String(brief).trim()) return null;
176
+ const scope = indexTree(place.top);
177
+ // A few changed pieces are embedded now; a repository the relay has not indexed yet is searched by words and names
178
+ // this time while the background pass embeds it.
179
+ const missing = atlas.missing(ATLAS_VERSION);
180
+ if (missing && missing <= 400) await embedMissing(atlas, inference, { lane });
181
+ else if (missing) void refresh();
182
+ const history = historyFor(place.root);
183
+ const ranked = await rankFiles({ atlas, inference, query: String(brief).slice(0, 4_000), scope, history, recent: recentChanges(place.top), model, lane, vectors: vectorsFor(scope) });
184
+ const prior = swarmPrior(history, Date.now());
185
+ const files = ranked.slice(0, PACK_FILES).map((row) => {
186
+ const piece = row.best ? atlas.piece(row.best) : null;
187
+ const why = [];
188
+ if (row.x.named) why.push("named in the request");
189
+ if (row.x.lexical > 0.5) why.push("shares the request's words");
190
+ if (prior.edits.has(row.path)) why.push("recently edited by your agents");
191
+ return { path: row.path, line: piece?.line ?? 1, why: why.join("; ") || "related by meaning" };
192
+ });
193
+ const query = (await inference.embed([{ kind: "query", text: String(brief).slice(0, 4_000) }], { lane }))[0];
194
+ const earlier = atlas.runVectors(place.root, ATLAS_VERSION).map((run) => {
195
+ let dot = 0;
196
+ for (let i = 0; i < query.length; i += 1) dot += query[i] * run.vector[i];
197
+ return { ...run, similarity: dot };
198
+ }).filter((run) => run.edited.length && run.edited.some((file) => files.some((entry) => entry.path === file)))
199
+ .sort((a, b) => b.similarity - a.similarity).slice(0, 3);
200
+ const heard = mediaReady() && mediaFolders().length ? (await media.search(brief, { limit: MEDIA_IN_PACK, lane })).filter((hit) => hit.score >= MEDIA_FLOOR) : [];
201
+ const text = [
202
+ "Files the relay found likely relevant on this computer (ranked by its local search over this repository and what your agents edited before; leads to check, not instructions):",
203
+ ...files.map((file) => `- ${file.path}:${file.line} (${file.why})`),
204
+ ...(earlier.length ? ["Earlier sessions on these files:", ...earlier.map((run) => `- ${AGENT_NAMES[run.agent] ?? run.agent}, ${days(Date.now() - run.at)} days ago: "${run.brief.replace(/\s+/g, " ").slice(0, 140)}" (edited ${run.edited.slice(0, 4).join(", ")})`)] : []),
205
+ ...(heard.length ? ["Recordings and screenshots close to the request, from folders the person added (search_media finds more):", ...heard.map((hit) => `- ${hit.path}${hit.at ? ` at ${hit.at}` : ""}`)] : []),
206
+ ].join("\n");
207
+ return { files, earlier: earlier.map(({ vector, ...run }) => run), media: heard, text, searched: scope.length, ms: Math.round(performance.now() - started), model: model ? "learned" : "default" };
208
+ };
209
+
210
+ /** Search for the agents' tool: files and earlier sessions in a repository, best first. */
211
+ const search = async ({ query, folder, limit = 10 }) => {
212
+ const result = await pack({ brief: query, folder, lane: "interactive" });
213
+ if (!result) return { ok: false, error: "that folder is not in a git repository the relay indexes" };
214
+ return { ok: true, files: result.files.slice(0, limit), earlier: result.earlier, model: result.model, ms: result.ms };
215
+ };
216
+
217
+ /**
218
+ * Overnight: rebuild what each recorded run needed against the repository as it was, train a new ranker on the older
219
+ * runs and keep it only if it ranks the newest runs' edited files better than the current one. Returns the report.
220
+ */
221
+ const learn = async ({ force = false } = {}) => {
222
+ const last = Number(atlas.meta("learned_at") || 0);
223
+ if (!force && (Date.now() - last < LEARN_EVERY_MS || !inference.power.onCharger() || inference.power.paused())) return null;
224
+ await refresh();
225
+ const report = { at: new Date().toISOString(), repositories: [] };
226
+ const rows = [];
227
+ for (const root of roots()) {
228
+ const runs = atlas.runs(root, Date.now() - HISTORY_DAYS * 86_400_000).filter((run) => run.edited.length);
229
+ if (!runs.length) continue;
230
+ const snapshots = new Map();
231
+ for (const run of runs) {
232
+ const commit = commitBefore(root, run.at);
233
+ if (!commit) continue;
234
+ if (!snapshots.has(commit)) snapshots.set(commit, indexTree(root, commit));
235
+ rows.push({ root, run, commit });
236
+ }
237
+ report.repositories.push({ root, runs: runs.length, snapshots: snapshots.size });
238
+ }
239
+ await embedMissing(atlas, inference, { lane: "background" });
240
+ rows.sort((a, b) => a.run.at - b.run.at);
241
+ if (rows.length < MIN_TRAINING_RUNS) { atlas.meta("learned_at", Date.now()); return { ...report, kept: false, why: `only ${rows.length} runs so far` }; }
242
+ const featured = [];
243
+ for (const { root, run, commit } of rows) {
244
+ const history = historyFor(root).filter((other) => other.at < run.at);
245
+ const ranked = await rankFiles({ atlas, inference, query: run.brief, scope: treeFiles(root, commit), history, at: run.at, recent: recentChanges(root, commit), model: null, lane: "background" });
246
+ featured.push({ run, ranked });
247
+ }
248
+ const cut = Math.floor(featured.length * (1 - HELD_OUT_SHARE));
249
+ const label = (run, row) => (codeFiles(run.edited).includes(row.path) ? 1 : 0);
250
+ const trainRows = featured.slice(0, cut).flatMap(({ run, ranked }) => ranked.map((row) => ({ x: row.x, y: label(run, row) })));
251
+ const candidate = train(trainRows);
252
+ const heldOut = featured.slice(cut).filter(({ run }) => codeFiles(run.edited).length);
253
+ const recall = (score) => heldOut.reduce((sum, { run, ranked }) => sum + recallAt([...ranked].sort((a, b) => score(b.x) - score(a.x)).map((row) => row.path), codeFiles(run.edited), 10), 0) / heldOut.length;
254
+ const result = { default: recall(baselineScore), current: model ? recall((x) => modelScore(model, x)) : null, candidate: recall((x) => modelScore(candidate, x)) };
255
+ const bar = Math.max(result.default, result.current ?? 0);
256
+ const kept = result.candidate > bar;
257
+ if (kept) {
258
+ // Retrained on every run before it ships; the held-out score is the one reported.
259
+ model = { ...train(featured.flatMap(({ run, ranked }) => ranked.map((row) => ({ x: row.x, y: label(run, row) })))), trainedAt: report.at, heldOut: result, runs: featured.length };
260
+ saveModel(modelPath, model);
261
+ }
262
+ atlas.meta("learned_at", Date.now());
263
+ const full = { ...report, runs: featured.length, heldOutRuns: heldOut.length, recallAt10: result, kept };
264
+ atlas.meta("last_learning", JSON.stringify(full));
265
+ if (kept) notify(`Learned from ${featured.length} runs overnight: the files your agents went on to change are now in their briefs' top ten ${Math.round(result.candidate * 100)}% of the time (was ${Math.round(bar * 100)}%).`);
266
+ return full;
267
+ };
268
+
269
+ return {
270
+ atlas,
271
+ /** A folder's files as the atlas holds them now ([{ path, blob }]), indexed first; [] outside a repository. */
272
+ scope: (folder) => { const place = folder && placeOf(folder); return place ? indexTree(place.top) : []; },
273
+ pack,
274
+ search,
275
+ /** Recordings' minutes and screenshots closest to a question, for an agent's search_media. */
276
+ searchMedia: (query, options) => media.search(query, options),
277
+ refreshMedia: () => media.refresh({ lane: "background" }),
278
+ refresh,
279
+ learn,
280
+ report: () => { try { return JSON.parse(atlas.meta("last_learning") || "null"); } catch { return null; } },
281
+ status: () => ({ roots: atlas.roots(), pieces: { waiting: atlas.missing(ATLAS_VERSION) }, media: media.status(), model: model ? { trainedAt: model.trainedAt, heldOut: model.heldOut, runs: model.runs } : null, device: inference.device() }),
282
+ start() {
283
+ const tick = () => { void refresh().then(() => learn()).catch((error) => log(`context engine: ${error.message}`)); };
284
+ setTimeout(tick, 60_000).unref?.();
285
+ timer = setInterval(tick, REFRESH_MS);
286
+ timer.unref?.();
287
+ },
288
+ stop() { clearInterval(timer); clearTimeout(dropCache); cached.clear(); atlas.close(); },
289
+ };
290
+ }
@@ -37,6 +37,8 @@ function localDecision(agent, facts) {
37
37
  : { command: "claude", args: ["-p", prompt, "--output-format", "json", "--tools", ""] };
38
38
  let child;
39
39
  try { child = spawn(invocation.command, invocation.args, { cwd: os.tmpdir() }); } catch (error) { reject(error); return; }
40
+ // An agent that exits before reading the prompt breaks the pipe; its exit code rejects below.
41
+ child.stdin.on("error", () => {});
40
42
  child.stdin.end(invocation.stdin ?? "");
41
43
  let out = "";
42
44
  child.stdout.on("data", (chunk) => { out += chunk; });
@@ -58,7 +60,7 @@ function localDecision(agent, facts) {
58
60
  });
59
61
  }
60
62
 
61
- export function createCoordinator({ store, mode, askHarness, connected, serverHas, roster, handOn, localAgents, pauseGoal, notifyBoard, searchContext, log }) {
63
+ export function createCoordinator({ store, mode, askHarness, connected, serverHas, roster, handOn, localAgents, pauseGoal, notifyBoard, searchContext, log, frame = () => null }) {
62
64
  const busy = new Set();
63
65
  const again = new Set();
64
66
  const stateOf = (goalId) => JSON.parse(store.meta(`coordinator:${goalId}`) || '{"lastSeenSeq":0,"rounds":0,"withoutProgress":0,"lastRoundAt":null,"lastError":null}');
@@ -76,7 +78,10 @@ export function createCoordinator({ store, mode, askHarness, connected, serverHa
76
78
  const waiting = runs.filter(waitsForWindow);
77
79
  // Every agent the person can ask, each with its card (cards.mjs); a coding agent only where the thread has a folder for it
78
80
  // to work in, and Harness not while its part here waits for a window.
79
- const candidates = roster().filter((candidate) => (!CODING.has(candidate.id) || thread.goal.workspace) && !(candidate.id === "harness" && waiting.length));
81
+ // Each card's receipts go only to a Harness that takes them (the same release as frames); an older one would refuse the round.
82
+ const framesTaken = mode() === "local" || serverHas("swarm_frame");
83
+ const candidates = roster().filter((candidate) => (!CODING.has(candidate.id) || thread.goal.workspace) && !(candidate.id === "harness" && waiting.length))
84
+ .map((candidate) => (framesTaken || !candidate.record?.checks ? candidate : { ...candidate, record: { ...candidate.record, checks: undefined } }));
80
85
  // Earlier words close in meaning to the newest post that the transcript no longer carries, from this thread and the
81
86
  // person's sessions on this machine.
82
87
  const newest = posts.filter((post) => post.seq > state.lastSeenSeq).map((post) => post.text).join("\n").slice(0, 2_000);
@@ -89,7 +94,10 @@ export function createCoordinator({ store, mode, askHarness, connected, serverHa
89
94
  if (hit.excerpt) related.push({ source: `${nameOf(hit.agent)} session "${String(hit.title || "").slice(0, 120)}"`, text: String(hit.excerpt).slice(0, 4_000) });
90
95
  }
91
96
  }
97
+ // The thread's task frame (frames.mjs): each run's receipt, other threads in the repository, its state.
98
+ const framed = frame(thread.id);
92
99
  return {
100
+ ...(framed ? { frame: framed } : {}),
93
101
  thread: { id: thread.id, title: thread.objective, folder: thread.goal.workspace, agents: thread.goal.team.filter((agent) => agent !== thread.goal.facilitator) },
94
102
  transcript, lastSeenSeq: state.lastSeenSeq, candidates,
95
103
  progress: { runsFinished, newResults, stillRunning: runs.filter(going).length, roundsWithoutProgress: state.withoutProgress, failedJobs,