diff --git a/kits/sheets/app/src/components/HarnessCell.jsx b/kits/sheets/app/src/components/HarnessCell.jsx
index 71a8336..a462677 100644
--- a/kits/sheets/app/src/components/HarnessCell.jsx
+++ b/kits/sheets/app/src/components/HarnessCell.jsx
@@ -69,7 +69,7 @@ export function HarnessCell({ cell, live, readOnly, onRun, onPreviewFile }) {
return (
- {cell.error || 'This cell failed.'}
+ {cell.error || 'This cell failed.'}
{tools}
);
@@ -77,14 +77,14 @@ export function HarnessCell({ cell, live, readOnly, onRun, onPreviewFile }) {
if (status === 'skipped') {
return (
- {cell.error || 'Skipped.'}
+ {cell.error || 'Skipped.'}
{tools}
);
}
return (
- {cell.value}
+ {cell.value}
{tools}
diff --git a/kits/sheets/app/src/components/HarnessConfig.jsx b/kits/sheets/app/src/components/HarnessConfig.jsx
index 879ad37..ebb72ba 100644
--- a/kits/sheets/app/src/components/HarnessConfig.jsx
+++ b/kits/sheets/app/src/components/HarnessConfig.jsx
@@ -35,6 +35,22 @@ export function HarnessConfig({ column, columns, applyPatch, close }) {
const chosen = agents?.find((a) => a.id === harnessId) || null;
+ // The person's own agents and the base agents, in one list with a heading over each. The Select
+ // renders a flat list, so the headings are disabled entries — and they only appear when there is
+ // more than one group, because a lone heading is noise rather than orientation.
+ const options = useMemo(() => {
+ const own = (agents || []).filter((a) => a.kind !== 'base');
+ const bases = (agents || []).filter((a) => a.kind === 'base');
+ const label = (a) => `${a.name}${a.model ? ` · ${a.model}` : ''}${a.unusable ? ` — ${a.unusable}` : ''}`;
+ const out = [];
+ for (const [head, group] of [['Your agents', own], ['Base agents', bases]]) {
+ if (!group.length) continue;
+ if (own.length && bases.length) out.push({ value: `__head_${head}`, label: head, disabled: true });
+ out.push(...group.map((a) => ({ value: a.id, label: label(a), disabled: !!a.unusable })));
+ }
+ return out;
+ }, [agents]);
+
// What this column will actually read, derived from the prompt and the attachments — the same
// function the planner uses, so what is shown and what runs cannot drift apart.
const reads = derivedDeps({ ...column, type: 'harness', harness: { prompt, attach: [...attach] } }, columns)
@@ -82,16 +98,12 @@ export function HarnessConfig({ column, columns, applyPatch, close }) {
label="Agent"
value={harnessId}
onChange={(e) => setHarnessId(e.target.value)}
- placeholder={agents === null ? 'Loading your agents…' : 'Choose an agent…'}
+ placeholder={agents === null ? 'Loading agents…' : 'Choose an agent…'}
disabled={agents === null || agents.length === 0}
hint={agents && agents.length === 0
- ? (loadErr || 'You have no other agents yet. Create one, then choose it here.')
+ ? (loadErr || 'No agent on this deployment can run a column yet.')
: undefined}
- options={(agents || []).map((a) => ({
- value: a.id,
- label: `${a.name}${a.model ? ` · ${a.model}` : ''}${a.unusable ? ` — ${a.unusable}` : ''}`,
- disabled: !!a.unusable,
- }))}
+ options={options}
/>
diff --git a/kits/sheets/app/src/lib/cell.js b/kits/sheets/app/src/lib/cell.js
index cd94d4f..47b4c5f 100644
--- a/kits/sheets/app/src/lib/cell.js
+++ b/kits/sheets/app/src/lib/cell.js
@@ -187,13 +187,16 @@ export function makeCellDispatcher({ sheetId, runId, sheetTitle, columns, onCell
const base = { ...partial, ended_at: now(), session_id: res?.metadata?.session_id || sessionId };
- if (st === 'completed') {
- // "completed" means the agent exited cleanly, which is not the same as answering. A turn
- // that produced neither text nor a file did not fill this cell, and saying it did would
- // be a green tick over nothing.
- if (!value && !artifacts.length) {
- return { ...base, status: 'failed', error: 'The agent finished without answering.' };
- }
+ // A Stop stays a Stop. The person asked for it, and partial output under a green tick
+ // would read as a success they did not get.
+ if (st === 'cancelled') return { ...base, status: 'failed', artifacts, error: 'Stopped.' };
+
+ // AN ANSWER IS AN ANSWER, whatever the turn was LABELLED. A terminal status that is not
+ // "completed" and yet carries text or a file is what a finished turn looks like when its
+ // record is read a moment before the label catches up. Throwing that content away is what
+ // wrote "the turn ended without an answer" into cells whose answer was sitting in the
+ // very record being read.
+ if (value || artifacts.length) {
return {
...base,
status: 'done',
@@ -203,13 +206,17 @@ export function makeCellDispatcher({ sheetId, runId, sheetTitle, columns, onCell
error: null,
};
}
- if (st === 'cancelled') return { ...base, status: 'failed', error: 'Stopped.' };
+
+ // Nothing to show. "completed" means the agent exited cleanly, which is not the same as
+ // answering, so it says that rather than borrowing a failure message.
return {
...base,
status: 'failed',
artifacts,
- error: res?.error?.message
- || (st === 'incomplete' ? 'The turn ended without an answer.' : `The turn ${st}.`),
+ error: st === 'completed'
+ ? 'The agent finished without answering.'
+ : (res?.error?.message
+ || (st === 'incomplete' ? 'The turn ended without an answer.' : `The turn ${st}.`)),
};
}
} finally {
diff --git a/kits/sheets/app/src/lib/model.js b/kits/sheets/app/src/lib/model.js
index 958b2cd..8d782d9 100644
--- a/kits/sheets/app/src/lib/model.js
+++ b/kits/sheets/app/src/lib/model.js
@@ -185,6 +185,12 @@ export function materialize(template, title) {
}
const CHRN = /^chrn_[0-9a-f]{32}$/;
+
+// The base agents, whose id IS the base name and which the server accepts as a harness id
+// directly. Stable across deployments, unlike a chrn_ id, which is why an agent column may name
+// one and why a sheet can arrive already runnable. The picker filters these against what THIS
+// deployment reports; validation only has to know the shape is legitimate.
+const BASES = new Set(['codex', 'claude-code', 'hermes', 'pi', 'dsh', 'opencode', 'qwen']);
const APP_OWNED = ['status', 'run_id', 'response_id', 'session_id', 'artifacts', 'started_at', 'ended_at'];
/** Every way a sheet can be wrong, with the fix for each.
@@ -238,10 +244,10 @@ export function validate(sheet) {
'Either set "type": "harness" or delete the harness object.');
}
if (isHarnessColumn(c) && c.harness) {
- const hid = c.harness.harness_id;
- if (hid !== '' && hid !== undefined && !CHRN.test(String(hid))) {
- err(`${at}.harness.harness_id`, `is ${JSON.stringify(hid)}`,
- 'Leave it "" unless you were given a real agent id — you cannot see the list of agents.');
+ const hid = String(c.harness.harness_id ?? '');
+ if (hid !== '' && !BASES.has(hid) && !CHRN.test(hid)) {
+ err(`${at}.harness.harness_id`, `is ${JSON.stringify(c.harness.harness_id)}`,
+ `Name a base agent (${[...BASES].join(', ')}) or use an agent id you were given.`);
}
const prompt = String(c.harness.prompt || '');
if (!prompt.trim()) {
diff --git a/kits/sheets/app/src/lib/sh.js b/kits/sheets/app/src/lib/sh.js
index e5c559f..83b2995 100644
--- a/kits/sheets/app/src/lib/sh.js
+++ b/kits/sheets/app/src/lib/sh.js
@@ -11,7 +11,7 @@
// /kits/sheets, so it is same-origin with the console's API proxy: the browser sends the console
// session it already has and the proxy attaches the internal key server-side.
import {
- configureKit, kitHarness, listHarnesses, listSessions, sessionDetail, patchSession,
+ configureKit, hr, kitHarness, listHarnesses, listSessions, sessionDetail, patchSession,
deleteSession, readJsonFile, writeFile, sessionTurns, containerFileUrl,
} from 'reifyui/harness';
@@ -24,21 +24,29 @@ export { containerFileUrl, sessionTurns };
/** The Harness this kit launched, or null when it was never launched. */
export const sheetsHarness = kitHarness;
-/** Every Harness an agent COLUMN may run.
+/** Every agent an agent COLUMN may run: the person's own agents, and the BASE agents.
+ *
+ * A base is a first-class choice, not a fallback. Its id IS its base name ("codex", "opencode"),
+ * which the server accepts as a harness id directly, so a column can run one without anybody
+ * having configured a thing first. That is what lets a brand new sheet be runnable the moment it
+ * is created — before, every agent column arrived blank and the person had to go and make an
+ * agent before the Run button meant anything.
*
* Deliberately excludes this kit's own Harness. A sheet whose column runs the sheet's own agent
* would have that agent editing sheet.json while the app is driving a run over it — recursion
* with a file-write race inside it. Excluded at the source of the list rather than validated at
- * run time, so the choice is never offered in the first place.
+ * run time, so the choice is never offered in the first place. The base it happens to sit on is
+ * NOT excluded: that is a different agent with its own session and no interest in sheet.json.
*
* Also excludes harnesses that require request headers: their turns are refused without those
* headers, and this app has nowhere to hold them. They are returned marked rather than dropped,
* so the picker can say why instead of silently having fewer entries than the console shows. */
export async function runnableHarnesses() {
- const [harnesses, mine] = await Promise.all([listHarnesses(), sheetsHarness()]);
- return harnesses
+ const [harnesses, bases, mine] = await Promise.all([listHarnesses(), listBases(), sheetsHarness()]);
+ const own = harnesses
.filter((h) => h.id !== mine?.id)
.map((h) => ({
+ kind: 'agent',
id: h.id,
name: h.name,
base: h.base,
@@ -47,6 +55,45 @@ export async function runnableHarnesses() {
? 'needs request headers this app can’t send'
: '',
}));
+ return [...own, ...bases];
+}
+
+/** The base agents this deployment can actually run.
+ *
+ * Filtered on what the server reports, never on a list written down here: which bases are
+ * installed differs per deployment, and offering one that is not would produce a sheet that looks
+ * configured and fails on the first row. A base with no available model is dropped for the same
+ * reason — the choice would dispatch and then fail. */
+async function listBases() {
+ let bases = [];
+ try {
+ ({ bases = [] } = await hr('/bases'));
+ } catch {
+ return []; // the person's own agents still list; bases just are not offered
+ }
+ return bases
+ .filter((b) => b.status === 'ready' && (b.models || []).some((m) => m.available))
+ .map((b) => ({
+ kind: 'base',
+ id: b.id, // the base id IS the harness id the server accepts
+ name: b.label || b.id,
+ base: b.id,
+ model: b.defaultModel || '',
+ unusable: '',
+ }));
+}
+
+/** The agent an unbound column should get, or '' when this deployment can run none.
+ *
+ * Prefers the base this kit's own Harness runs on. That one is installed and has working
+ * credentials by construction — the sheet in front of you was written by it — so it is the
+ * choice least likely to fail on the first row. */
+export function defaultAgentId(list, mine) {
+ const bases = (list || []).filter((a) => a.kind === 'base' && !a.unusable);
+ if (!bases.length) return '';
+ const ownBase = String(mine?.base || '').toLowerCase();
+ const alias = ownBase === 'claude' ? 'claude-code' : ownBase;
+ return (bases.find((b) => b.id === alias) || bases[0]).id;
}
// ── sheets (= sessions) ────────────────────────────────────────────────────
diff --git a/kits/sheets/app/src/pages/SheetPage.jsx b/kits/sheets/app/src/pages/SheetPage.jsx
index b1b9def..e0276bd 100644
--- a/kits/sheets/app/src/pages/SheetPage.jsx
+++ b/kits/sheets/app/src/pages/SheetPage.jsx
@@ -8,7 +8,7 @@
// The run happens in this tab. There is no workflow engine and no batch endpoint in this
// deployment, so the browser is the orchestrator; the UI says that before you press Run and says
// exactly what happened if you leave.
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Download, HelpCircle, Home } from 'lucide-react';
@@ -16,8 +16,8 @@ import { PaneResizer, useResizablePane, useDialog } from 'reifyui';
import { SheetGrid, FilePreview, fitRowHeights } from 'reifyui';
import { containerFileUrl, getResponse, lastAssistantText, sessionTurns } from 'reifyui/harness';
import {
- getSheet, isPending, markViewed, renameSheet, runnableHarnesses, saveSheet, sheetStatus,
- sheetsHarness,
+ defaultAgentId, getSheet, isPending, markViewed, renameSheet, runnableHarnesses, saveSheet,
+ sheetStatus, sheetsHarness,
} from '../lib/sh';
import {
GRID_TYPES, cellKey, derivedDeps, isHarnessColumn, validate,
@@ -35,6 +35,10 @@ const SAVE_DEBOUNCE_MS = 400;
const STATUS_POLL_MS = 2000; // while a turn is live: the grid fills in as it is written
const STATUS_IDLE_MS = 10000; // while nothing is: still notices a turn started elsewhere
const RELOAD_POLL_MS = 4000;
+// The line height and vertical padding the grid lays a cell out with (reifyui's sheet.css sets
+// line-height: 18px). They turn a measured height into a number of lines for --shg-clamp.
+const CLAMP_LINE = 18;
+const CLAMP_PAD = 10;
const now = () => Math.floor(Date.now() / 1000);
const escapeRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -220,12 +224,38 @@ export function SheetPage({ id: routeId, seed }) {
Promise.all([runnableHarnesses(), sheetsHarness()])
.then(([list, mine]) => {
if (dead) return;
- setEnv({ harnesses: new Map(list.map((h) => [h.id, h])), ownId: mine?.id || '' });
+ setEnv({ harnesses: new Map(list.map((h) => [h.id, h])),
+ ownId: mine?.id || '',
+ ownBase: mine?.base || '' });
})
- .catch(() => { if (!dead) setEnv({ harnesses: new Map(), ownId: '' }); });
+ .catch(() => { if (!dead) setEnv({ harnesses: new Map(), ownId: '', ownBase: '' }); });
return () => { dead = true; };
}, []);
+ // ── an agent column arrives pointing at nothing, so point it somewhere that works ──────────
+ // The builder cannot see the list of agents, and a template ships blank because which agents
+ // exist differs per deployment. Left alone that is a finished-looking sheet whose Run button
+ // refuses on every column until the person opens each menu in turn. So the app fills the blanks
+ // with an agent it has JUST confirmed this deployment can run.
+ //
+ // Blanks only. A column pointing at an agent that no longer exists keeps refusing, because
+ // quietly re-pointing it would run something other than what the sheet says it runs.
+ useEffect(() => {
+ if (!env || !sheet) return;
+ const cols = sheet.columns || [];
+ const blank = (c) => isHarnessColumn(c) && !String((c.harness || {}).harness_id || '').trim();
+ if (!cols.some(blank)) return;
+ const pick = defaultAgentId([...env.harnesses.values()], { base: env.ownBase });
+ if (!pick) return; // nothing runnable here: leave it honest and refusing
+ commit({
+ ...sheet,
+ columns: cols.map((c) => (blank(c)
+ ? { ...c, harness: { ...(c.harness || {}), harness_id: pick,
+ harness_name: env.harnesses.get(pick)?.name || '' } }
+ : c)),
+ });
+ }, [env, sheet, commit]);
+
const adoptSession = useCallback((sid) => {
if (!sid || sid === idRef.current) return;
const [, query = ''] = (window.location.hash || '').split('?');
@@ -375,6 +405,53 @@ export function SheetPage({ id: routeId, seed }) {
}
}, [env, concurrency, commit, dialog]);
+ // ── how many lines of text a cell has room for ────────────────────────────
+ // The grid derives --shg-clamp from a row's STORED height, falling back to 34px — one line —
+ // for any row never explicitly sized. But a row is usually tall because of ONE column: an agent
+ // cell keeping its files under its answer. Every other cell then showed a single ellipsised
+ // line above a block of empty row, and the taller the neighbour, the more space went to waste.
+ //
+ // Three things have to be true at once, and each is a trap on its own:
+ //
+ // The basis cannot be the RENDERED height. More lines makes the row taller, which allows more
+ // lines: measured live the rows climb 92 → 137 → 182 and never settle. So every clamp goes
+ // back to one line before measuring, making the basis the height the row's OTHER content
+ // needs — a quantity the clamp cannot influence.
+ //
+ // The basis cannot be the
. A table cell is stretched to its row and reports the row
+ // height for every column, which makes them all look equally full. The measurement is taken
+ // on the text's own box, which sizes to its content.
+ //
+ // The count cannot be shared by the row. A cell carrying files under its text has far less
+ // room than one carrying nothing, and one number for both is the bug restated.
+ //
+ // Set on each | : a custom property resolves from the nearest ancestor, so this wins over the
+ // row-level value the grid sets, for both this kit's answer text and the grid's own value cell.
+ useLayoutEffect(() => {
+ const trs = gridRef.current?.querySelectorAll('tbody tr[data-row-id]');
+ if (!trs?.length) return;
+ const rows = [...trs];
+ for (const tr of rows) for (const td of tr.children) td.style.setProperty('--shg-clamp', '1');
+ const plan = rows.map((tr) => ({
+ base: tr.offsetHeight, // one reflow, every clamp at its floor
+ cells: [...tr.children].map((td) => {
+ const txt = td.querySelector('[data-shg-clamp-text]');
+ const box = txt?.parentElement;
+ return { td, extra: box ? Math.max(0, box.scrollHeight - txt.offsetHeight) : 0 };
+ }),
+ }));
+ for (const { base, cells } of plan) {
+ const floorLines = Math.max(1, Math.floor((base - CLAMP_PAD) / CLAMP_LINE));
+ // The height the row needs for the cell carrying the most beside its text to still get the
+ // row's baseline count. Every other cell then fills THAT, which is the whole point.
+ const need = Math.max(...cells.map((c) => c.extra)) + floorLines * CLAMP_LINE + CLAMP_PAD;
+ for (const { td, extra } of cells) {
+ td.style.setProperty('--shg-clamp', String(Math.max(floorLines,
+ Math.floor((need - extra - CLAMP_PAD) / CLAMP_LINE))));
+ }
+ }
+ });
+
const stopRun = useCallback(() => { runnerRef.current?.stop(); }, []);
// Leaving mid-run stops the walk. Say so with the platform's own guard rather than inventing a
diff --git a/kits/sheets/kit.json b/kits/sheets/kit.json
index fa35d98..30b816e 100644
--- a/kits/sheets/kit.json
+++ b/kits/sheets/kit.json
@@ -21,7 +21,7 @@
"model": "claude-opus-5"
}
],
- "system_prompt": "You build and edit spreadsheets.\n\nTHE FILE: ./sheet.json, in your current working directory. That exact path, always. Do not search for it, do not look elsewhere in the tree, and do not treat its absence as a puzzle — on a new sheet it simply does not exist yet and you create it there. It is the single source of truth and the only file the app reads.\n\nRead it before every change and write it back WHOLE — the person may have edited the grid between turns. Its schema, and the mistakes that make a sheet fail to load, are in the sheet-design skill: read that skill first, on every request, and validate with its validate_sheet.py before you finish.\n\nA column may be an AGENT column (type \"harness\"): it runs one of the person's other agents once per row. You may create and configure such a column, but you NEVER execute one and you never invent an agent id — you cannot see the list. Leave harness_id as \"\" and tell the person to pick the agent in the column menu. The app runs those columns, and it will not let a sheet run itself.\n\nCells in an agent column carry results the app produced — status, session_id, response_id, artifacts. Never write them and never delete them unless you are asked to clear that column; you cannot recompute them.\n\nWork directly. Every command you spend orienting is a command the person waits through.",
+ "system_prompt": "You build and edit spreadsheets.\n\nTHE FILE: ./sheet.json, in your current working directory. That exact path, always. Do not search for it, do not look elsewhere in the tree, and do not treat its absence as a puzzle — on a new sheet it simply does not exist yet and you create it there. It is the single source of truth and the only file the app reads.\n\nRead it before every change and write it back WHOLE — the person may have edited the grid between turns. Its schema, and the mistakes that make a sheet fail to load, are in the sheet-design skill: read that skill first, on every request, and validate with its validate_sheet.py before you finish.\n\nA column may be an AGENT column (type \"harness\"): it runs an agent once per row. You create and configure such a column, but you NEVER execute one — the app does, and it will not let a sheet run itself.\n\nALWAYS give an agent column a working default so the person can press Run the moment the sheet exists. Set harness_id to a BASE agent id, choosing the one that suits the work: \"codex\", \"claude-code\", \"hermes\", \"pi\", \"dsh\", \"opencode\", \"qwen\". Those ids are stable and you may write them. Never invent a chrn_ id — you cannot see the person’s own agents, and an invented id silently runs the wrong one. If the base you pick is not installed here the app substitutes one that is, so a reasonable choice is always better than leaving it blank. The person can change it in the column menu.\n\nCells in an agent column carry results the app produced — status, session_id, response_id, artifacts. Never write them and never delete them unless you are asked to clear that column; you cannot recompute them.\n\nWork directly. Every command you spend orienting is a command the person waits through.",
"skills": [
"sheet-design"
]
diff --git a/kits/sheets/skills/sheet-design/SKILL.md b/kits/sheets/skills/sheet-design/SKILL.md
index 7a127d1..597beda 100644
--- a/kits/sheets/skills/sheet-design/SKILL.md
+++ b/kits/sheets/skills/sheet-design/SKILL.md
@@ -22,13 +22,13 @@ empty grid or a column with no editor. Match it exactly.
{ "id": "col_site", "name": "Site", "type": "url", "width": 240 },
{ "id": "col_brief", "name": "Brief", "type": "harness", "width": 380,
"harness": {
- "harness_id": "",
+ "harness_id": "codex",
"prompt": "Read {{Site}} and write three sentences on {{Company}}: what they sell, who to, and how they price. Put your full notes in notes.md.",
"attach": []
} },
{ "id": "col_fit", "name": "Overlap", "type": "harness", "width": 300,
"harness": {
- "harness_id": "",
+ "harness_id": "codex",
"prompt": "Score 1-5 how directly this company competes with us, then one line of why.\n\n{{Brief}}",
"attach": ["col_brief"]
} }
@@ -53,9 +53,13 @@ Non-negotiable, because each of these breaks silently:
- **`type` is one of** `text` `number` `select` `tags` `checkbox` `date` `url`
`harness`. Anything else loses its editor.
- **Only a `harness` column carries a `harness` object**, and only it can be run.
-- **`harness_id` is `""`** unless the person gave you a real one. You cannot see
- the list of agents, and an invented id silently runs the wrong agent. Write
- `""` and tell the person to pick it in the column menu.
+- **`harness_id` names a BASE agent**, so the sheet is runnable the moment it
+ exists. The base ids are stable and you may write them: `codex`,
+ `claude-code`, `hermes`, `pi`, `dsh`, `opencode`, `qwen`. Pick the one that
+ suits the work. If it is not installed on this deployment the app substitutes
+ one that is, so a reasonable guess always beats a blank. Never invent a
+ `chrn_` id: those are the person's own agents, you cannot see them, and an
+ invented one silently runs the wrong agent.
- **Never write `status`, `run_id`, `response_id`, `session_id` or `artifacts`
into a cell, and never invent a `run` block.** Those are results the app
produced. Writing them makes the sheet claim a run that never happened; you
@@ -68,10 +72,13 @@ have edited the grid between your turns, and a partial write loses their work.
## What you do and do not run
-An agent column runs one of the person's OTHER agents, once per row. You create
-and configure such columns. **You never execute one.** The app runs them, from
-the browser, in dependency order — and it will not let a sheet run itself, so
-there is no id you could put there that would work.
+An agent column runs an agent once per row. You create and configure such
+columns. **You never execute one.** The app runs them, from the browser, in
+dependency order — and it will not let a sheet run itself, so there is no id
+you could put there that would point back here.
+
+Leave the sheet ready to go. A person who asked for a sheet with agent columns
+wants to press Run, not to open every column menu first.
## Designing the columns
diff --git a/kits/sheets/skills/sheet-design/validate_sheet.py b/kits/sheets/skills/sheet-design/validate_sheet.py
index 4a3bfc9..318f789 100644
--- a/kits/sheets/skills/sheet-design/validate_sheet.py
+++ b/kits/sheets/skills/sheet-design/validate_sheet.py
@@ -22,6 +22,12 @@
STATUSES = {"queued", "running", "done", "failed", "skipped"}
APP_OWNED = ("status", "run_id", "response_id", "session_id", "artifacts", "started_at", "ended_at")
CHRN = re.compile(r"^chrn_[0-9a-f]{32}$")
+
+# The base agents, whose id IS the base name and is accepted as a harness id directly. Stable
+# across deployments, which is exactly why the builder may write one: it cannot see the person's
+# own agents, but it can always name a base. Which of these is actually installed varies, and the
+# app substitutes an available one, so naming a base is never the thing that breaks a sheet.
+BASES = {"codex", "claude-code", "hermes", "pi", "dsh", "opencode", "qwen"}
REF = re.compile(r"\{\{\s*([^}]+?)\s*\}\}")
errors: list[str] = []
@@ -98,11 +104,16 @@ def check_harness(columns: list, by_name: dict) -> None:
if not is_agent:
continue
- hid = cfg.get("harness_id", "")
- if hid != "" and not CHRN.match(str(hid)):
+ hid = str(cfg.get("harness_id", "") or "")
+ if hid == "":
+ warn(f"{at}.harness.harness_id", "is empty",
+ 'name a base agent so the sheet can be run the moment it exists — one of '
+ + ", ".join(sorted(BASES)))
+ elif hid not in BASES and not CHRN.match(hid):
err(f"{at}.harness.harness_id", f"is {json.dumps(hid)}",
- 'leave it "" unless you were given a real agent id — you cannot see the list, '
- "and an invented id silently runs the wrong agent")
+ "use a base agent id (" + ", ".join(sorted(BASES)) + ") or a real chrn_ id you "
+ "were given — you cannot see the person's own agents, and an invented id "
+ "silently runs the wrong one")
prompt = str(cfg.get("prompt") or "")
if not prompt.strip():
|