trantor 0.18.62 → 0.18.63
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/.claude-plugin/plugin.json +1 -1
- package/bin/bridge.mjs +1 -0
- package/bin/crew/core.mjs +6 -8
- package/bin/crew-payload.mjs +13 -2
- package/bin/crew-runner.mjs +53 -10
- package/bin/doctor.mjs +12 -0
- package/bin/drill-report.mjs +1 -1
- package/bin/drill-report.test.mjs +1 -0
- package/bin/reconcile.mjs +22 -4
- package/bin/turn-watchdog.mjs +35 -23
- package/docs/BUILD-DOCTRINE.md +117 -0
- package/hooks/sessionstart.mjs +25 -0
- package/hub/routes/cards.mjs +9 -1
- package/hub/store.mjs +22 -6
- package/lib/autonomy.mjs +2 -1
- package/lib/decode.mjs +13 -0
- package/lib/identity.mjs +4 -2
- package/lib/model-catalog.mjs +8 -19
- package/lib/project.mjs +15 -10
- package/lib/seat-record.mjs +3 -14
- package/lib/seat-why.mjs +4 -3
- package/lib/seats.mjs +15 -11
- package/lib/state/driver.mjs +20 -6
- package/lib/state/flags.mjs +26 -0
- package/lib/subagent-manifest.mjs +2 -1
- package/lib/subagent-scan.mjs +0 -0
- package/mcp.mjs +20 -10
- package/package.json +3 -2
- package/skills/crew/SKILL.md +18 -7
- package/skills/prd-review/SKILL.md +12 -3
- package/ui.html +6 -3
package/hub/store.mjs
CHANGED
|
@@ -44,6 +44,24 @@ function appendTaskNote(t, b, ts = Date.now()) {
|
|
|
44
44
|
if (!b || typeof b.note !== "string") return false;
|
|
45
45
|
return appendTaskLog(t, b.by || "", b.note, ts);
|
|
46
46
|
}
|
|
47
|
+
// #6452: a card's drill line (build doctrine rule 1) — the `drill` field, a checklist item or a log
|
|
48
|
+
// note starting "Drill:", or the move's own note; that note also counts when it names the gate
|
|
49
|
+
// command it ran (the orchestrator closes with "verified at <sha>" + the gate). The command shapes
|
|
50
|
+
// mirror hooks/lib/hollow-move.mjs, minus bare pass counts: "12/12" names no gate.
|
|
51
|
+
const DRILL_MAX = 300;
|
|
52
|
+
const DRILL_LINE = /^\s*drill:/i;
|
|
53
|
+
const GATE_CMD = /(node\s+test\/|npm\s+(run\s+)?test|pnpm\s+test|yarn\s+test|vitest|pytest|go\s+test|cargo\s+test|make\s+test)/i;
|
|
54
|
+
function cleanDrill(v) {
|
|
55
|
+
const s = stripNulText(v).replace(/\s+/g, " ").trim();
|
|
56
|
+
return s ? s.slice(0, DRILL_MAX) : "";
|
|
57
|
+
}
|
|
58
|
+
function hasDrillLine(t, b = {}) {
|
|
59
|
+
if (cleanDrill(b.drill) || cleanDrill(t.drill)) return true;
|
|
60
|
+
if (Array.isArray(t.checklist) && t.checklist.some(c => DRILL_LINE.test(String(c?.text ?? "")))) return true;
|
|
61
|
+
if (Array.isArray(t.log) && t.log.some(e => DRILL_LINE.test(String(e?.text ?? "")))) return true;
|
|
62
|
+
return typeof b.note === "string" && (DRILL_LINE.test(b.note) || GATE_CMD.test(b.note));
|
|
63
|
+
}
|
|
64
|
+
|
|
47
65
|
// Card checklists (#5624): acceptance items are the one honest denominator for a progress bar.
|
|
48
66
|
// Accepts plain strings (fresh items) or {text,done} (round-trips); caps 20 items x 200 chars.
|
|
49
67
|
// Returns null for a non-array so callers can distinguish "not sent" from "sent empty".
|
|
@@ -106,11 +124,9 @@ function normalizeState(loaded = {}) {
|
|
|
106
124
|
// migrate old numeric form
|
|
107
125
|
s.peers[session] = typeof v === "number"
|
|
108
126
|
? { lastSeen: v, status: "", project: "" }
|
|
109
|
-
// #6170: `kind`
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// values, and the kinds still came back empty on the first live restart. llm/model stay
|
|
113
|
-
// out on purpose: those ARE in-memory presence, re-supplied by the next heartbeat.
|
|
127
|
+
// #6170: `kind` rides the load. This normalizer rebuilds every peer from an explicit field
|
|
128
|
+
// list, so a field missing here is dropped however faithfully the store returned it (the kinds
|
|
129
|
+
// came back empty on the first live restart). llm/model stay out: they are in-memory presence.
|
|
114
130
|
: { lastSeen: v.lastSeen || 0, status: v.status || "", project: v.project || "", pubkey: v.pubkey || "", identity: v.identity || null, authWarning: v.authWarning || "", hookVersion: v.hookVersion || "", kind: v.kind || "", deliveredUpTo: v.deliveredUpTo || v.delivered_up_to || 0, _on: v._on === true || v.online === true };
|
|
115
131
|
}
|
|
116
132
|
return s;
|
|
@@ -240,6 +256,6 @@ setInterval(persist, persistTickMs).unref?.();
|
|
|
240
256
|
|
|
241
257
|
return {
|
|
242
258
|
state, durableStore, persist, persistHealth, markDirty, reload, startChangeSubscription,
|
|
243
|
-
HUB_SRC, appendTaskLog, appendTaskNote, cleanChecklist, stripNulText,
|
|
259
|
+
HUB_SRC, appendTaskLog, appendTaskNote, cleanChecklist, cleanDrill, hasDrillLine, stripNulText,
|
|
244
260
|
};
|
|
245
261
|
}
|
package/lib/autonomy.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
4
4
|
import { join, dirname } from "node:path";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
|
+
import { asRecord } from "./decode.mjs";
|
|
6
7
|
|
|
7
8
|
export const AUTONOMY_PATH = () =>
|
|
8
9
|
join(process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus"), "autonomy.json");
|
|
@@ -32,7 +33,7 @@ export function loadAutonomy() {
|
|
|
32
33
|
return {
|
|
33
34
|
version: 1,
|
|
34
35
|
defaults: { ...DEFAULTS, ...(raw.defaults || {}) },
|
|
35
|
-
projects: raw.projects
|
|
36
|
+
projects: asRecord(raw.projects) ?? {},
|
|
36
37
|
};
|
|
37
38
|
} catch {
|
|
38
39
|
// A corrupt file must not hand out permissions nobody granted. Fall back to the safe defaults.
|
package/lib/decode.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: this module IS the I/O boundary decoder for
|
|
2
|
+
lib/. Everything it is handed came off disk (config.json, autonomy.json, model-catalog.json), out
|
|
3
|
+
of a JSONL transcript written by whatever CLI version produced it, or off an HTTP request — the
|
|
4
|
+
type of any field is exactly what cannot be assumed, so these typeof checks are the parse that
|
|
5
|
+
establishes the contract, not a substitute for one (#7174). */
|
|
6
|
+
// Each helper returns the value at its known shape or null, so callers branch on a domain value.
|
|
7
|
+
// Deliberately permissive where the inline checks they replaced were: asRecord admits arrays and
|
|
8
|
+
// asNumber admits NaN, because `typeof x === "object"` and `typeof x === "number"` did (#7174).
|
|
9
|
+
|
|
10
|
+
export function asRecord(v) { return v && typeof v === "object" ? v : null; }
|
|
11
|
+
export function asString(v) { return typeof v === "string" ? v : null; }
|
|
12
|
+
export function asNumber(v) { return typeof v === "number" ? v : null; }
|
|
13
|
+
export function asFunction(v) { return typeof v === "function" ? v : null; }
|
package/lib/identity.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { generateKeyPairSync, createPublicKey, createPrivateKey, sign as cryptoS
|
|
|
4
4
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
|
+
import { asFunction, asString } from "./decode.mjs";
|
|
7
8
|
|
|
8
9
|
export const SCHEME = "trantor-v1";
|
|
9
10
|
export const INST_SCHEME = "trantor-inst-v1";
|
|
@@ -105,7 +106,7 @@ export function publicView(identity) {
|
|
|
105
106
|
// --- canonical request -------------------------------------------------------------------------
|
|
106
107
|
export function bodyHash(body) {
|
|
107
108
|
if (body === undefined || body === null || body === "") return "";
|
|
108
|
-
const buf = Buffer.isBuffer(body) ? body : Buffer.from(
|
|
109
|
+
const buf = Buffer.isBuffer(body) ? body : Buffer.from(asString(body) ?? JSON.stringify(body), "utf8");
|
|
109
110
|
return createHash("sha256").update(buf).digest("hex");
|
|
110
111
|
}
|
|
111
112
|
|
|
@@ -199,7 +200,8 @@ export function verifyEndorsement({ durablePubkey, instancePubkey, instanceId, c
|
|
|
199
200
|
// (is this pubkey known? may it touch this project?) belong to the hub, which owns that state.
|
|
200
201
|
// Returns { ok, pubkey, ts, nonce, reason }.
|
|
201
202
|
export function verifyRequest({ headers, method, path, body, now = Date.now() }) {
|
|
202
|
-
const
|
|
203
|
+
const lookup = asFunction(headers?.get);
|
|
204
|
+
const get = (k) => (lookup ? lookup.call(headers, k) : headers?.[k] ?? headers?.[k.toLowerCase()]);
|
|
203
205
|
const pubkey = get(HDR.pubkey), sig = get(HDR.sig), ts = get(HDR.ts), nonce = get(HDR.nonce);
|
|
204
206
|
if (!pubkey || !sig || !ts || !nonce) return { ok: false, reason: "unsigned" };
|
|
205
207
|
if (!/^[0-9a-f]{64}$/i.test(pubkey)) return { ok: false, reason: "bad-pubkey" };
|
package/lib/model-catalog.mjs
CHANGED
|
@@ -1,17 +1,9 @@
|
|
|
1
|
-
// lib/model-catalog.mjs — the declarative model catalog (
|
|
2
|
-
//
|
|
3
|
-
// configs/model-catalog.json records, per "<provider>/<model-id>": which API kinds it speaks,
|
|
4
|
-
// context window, max output, input modalities, and — the part capabilities.json does not have —
|
|
5
|
-
// an `effort` block mapping each crew difficulty (easy/medium/hard) to CONCRETE request
|
|
6
|
-
// parameters PER API KIND (reasoning_effort, thinking, effort). Scores pick WHICH model; this
|
|
7
|
-
// catalog says HOW to call it. A model missing from the catalog still works at its provider
|
|
8
|
-
// default: lookup() returns an entry whose status says "not in catalog, provider default".
|
|
9
|
-
//
|
|
10
|
-
// Model ids come from `trantor models` / the provider adapters and CLI defaults — never typed
|
|
11
|
-
// from memory — and every entry cites its limits with a url.
|
|
1
|
+
// lib/model-catalog.mjs — the declarative model catalog (#7777): scores pick WHICH model, this
|
|
2
|
+
// says HOW to call it. Shape and rules: docs/CONTRACT-lib.md, Providers and balances.
|
|
12
3
|
import { readFileSync } from "node:fs";
|
|
13
4
|
import { dirname, join } from "node:path";
|
|
14
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { asRecord } from "./decode.mjs";
|
|
15
7
|
|
|
16
8
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
9
|
export const CATALOG_PATH = process.env.TRANTOR_MODEL_CATALOG || join(ROOT, "configs", "model-catalog.json");
|
|
@@ -22,8 +14,8 @@ export function loadCatalog(path = CATALOG_PATH) {
|
|
|
22
14
|
if (CACHE && path === CATALOG_PATH) return CACHE;
|
|
23
15
|
let cat;
|
|
24
16
|
try { cat = JSON.parse(readFileSync(path, "utf8")); } catch { cat = null; }
|
|
25
|
-
if (!cat
|
|
26
|
-
if (!cat.models
|
|
17
|
+
if (!asRecord(cat)) cat = { version: 0, models: {} };
|
|
18
|
+
if (!asRecord(cat.models)) cat.models = {};
|
|
27
19
|
if (path === CATALOG_PATH) CACHE = cat;
|
|
28
20
|
return cat;
|
|
29
21
|
}
|
|
@@ -87,14 +79,11 @@ export function resolveEffort(agent, modelId, difficulty, cat = loadCatalog()) {
|
|
|
87
79
|
const kinds = Object.keys(level);
|
|
88
80
|
params = kinds.length === 1 ? level[kinds[0]] : {};
|
|
89
81
|
}
|
|
90
|
-
return { found: true, agent, model: entry.id, difficulty, api, params: params
|
|
82
|
+
return { found: true, agent, model: entry.id, difficulty, api, params: asRecord(params) ?? {} };
|
|
91
83
|
}
|
|
92
84
|
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
// actually carry are applied (codex: -c model_reasoning_effort; claude: --effort; opencode
|
|
96
|
-
// seats: --variant, opencode's provider-specific reasoning effort); anything else stays at
|
|
97
|
-
// provider default and the line says so.
|
|
85
|
+
// The per-CLI argument carrying the effort parameters, plus the one log line the runner prints.
|
|
86
|
+
// Only what a CLI can actually carry is applied; the rest stays at provider default and says so.
|
|
98
87
|
export function cliEffortFlag(agent, effort) {
|
|
99
88
|
if (!effort) return { flag: "", text: "" };
|
|
100
89
|
const difficulty = effort.difficulty || "?";
|
package/lib/project.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { execSync } from "node:child_process";
|
|
|
5
5
|
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, readdirSync, statSync, realpathSync } from "node:fs";
|
|
6
6
|
import { basename, join, dirname, resolve, sep } from "node:path";
|
|
7
7
|
import { homedir, hostname } from "node:os";
|
|
8
|
+
import { asRecord, asString } from "./decode.mjs";
|
|
8
9
|
|
|
9
10
|
export function gitRoot(dir) {
|
|
10
11
|
try {
|
|
@@ -192,7 +193,7 @@ export function writeOrchSession(project, sid, by = "unknown") {
|
|
|
192
193
|
function configPath() { return join(busDir(), "config.json"); }
|
|
193
194
|
|
|
194
195
|
export function readConfig() {
|
|
195
|
-
try { const c = configPath(); if (existsSync(c)) { const j = JSON.parse(readFileSync(c, "utf8")); if (j
|
|
196
|
+
try { const c = configPath(); if (existsSync(c)) { const j = asRecord(JSON.parse(readFileSync(c, "utf8"))); if (j) return j; } } catch {}
|
|
196
197
|
return {};
|
|
197
198
|
}
|
|
198
199
|
|
|
@@ -203,9 +204,10 @@ export function resolveHubInfo(project, env = process.env) {
|
|
|
203
204
|
if (env.RELAY_URL) return { url: env.RELAY_URL, via: "env" };
|
|
204
205
|
const cfg = readConfig();
|
|
205
206
|
const name = project || resolveProject();
|
|
206
|
-
const
|
|
207
|
-
if (
|
|
208
|
-
|
|
207
|
+
const pinned = asString(cfg?.hubs?.[name]);
|
|
208
|
+
if (pinned) return { url: pinned, via: "pin" };
|
|
209
|
+
const globalUrl = asString(cfg?.url);
|
|
210
|
+
if (globalUrl) return { url: globalUrl, via: "global" };
|
|
209
211
|
} catch {}
|
|
210
212
|
return { url: DEFAULT_HUB_URL, via: "default" };
|
|
211
213
|
}
|
|
@@ -217,7 +219,7 @@ export function resolveHub(project, env = process.env) {
|
|
|
217
219
|
// Every project the operator has deliberately pinned — the "expected one of these" list a
|
|
218
220
|
// misplaced session needs in order to fix itself.
|
|
219
221
|
export function knownProjects() {
|
|
220
|
-
try {
|
|
222
|
+
try { return Object.keys(asRecord(readConfig()?.hubs) ?? {}).sort(); } catch { return []; }
|
|
221
223
|
}
|
|
222
224
|
|
|
223
225
|
// How many IMMEDIATE children of `dir` are git repos. Bounded (first 200 entries) and fail-safe:
|
|
@@ -272,10 +274,12 @@ function writeConfig(cfg) {
|
|
|
272
274
|
// Pin a project to a hub. URL must be absolute http(s); trailing slash stripped so
|
|
273
275
|
// `${hub}/path` concatenation never double-slashes.
|
|
274
276
|
export function setProjectHub(project, url) {
|
|
275
|
-
if (!project
|
|
277
|
+
if (!asString(project)) throw new Error("project required");
|
|
276
278
|
if (!/^https?:\/\//.test(String(url || ""))) throw new Error("url must start with http:// or https://");
|
|
277
279
|
const cfg = readConfig();
|
|
278
|
-
|
|
280
|
+
const hubs = { ...(asRecord(cfg.hubs) ?? {}) };
|
|
281
|
+
hubs[project] = String(url).replace(/\/+$/, "");
|
|
282
|
+
cfg.hubs = hubs;
|
|
279
283
|
writeConfig(cfg);
|
|
280
284
|
}
|
|
281
285
|
|
|
@@ -283,9 +287,10 @@ export function setProjectHub(project, url) {
|
|
|
283
287
|
// a mapping existed.
|
|
284
288
|
export function unsetProjectHub(project) {
|
|
285
289
|
const cfg = readConfig();
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
290
|
+
const hubs = asRecord(cfg.hubs);
|
|
291
|
+
if (!hubs || !(project in hubs)) return false;
|
|
292
|
+
delete hubs[project];
|
|
293
|
+
if (!Object.keys(hubs).length) delete cfg.hubs;
|
|
289
294
|
writeConfig(cfg);
|
|
290
295
|
return true;
|
|
291
296
|
}
|
package/lib/seat-record.mjs
CHANGED
|
@@ -1,17 +1,6 @@
|
|
|
1
|
-
// #7762: the seat record is DERIVED
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// tokens + card id. From those three this computes, PER PROJECT and PER SEAT, the last
|
|
5
|
-
// RECORD_LIMIT cards as completed / empty / bounced:
|
|
6
|
-
// bounced — a testing→doing move by someone other than the seat (the assigner sent it back),
|
|
7
|
-
// or a HOLLOW: note on the card (#7750)
|
|
8
|
-
// completed — the card reached done with no bounce on its trail
|
|
9
|
-
// empty — the card never reached done and none of the seat's ledger turns on it completed
|
|
10
|
-
// (outcome empty/stalled/cut — the turn produced nothing)
|
|
11
|
-
// relay_advise (bin/advise.mjs) benches a seat at a difficulty when its last STRIKE cards there
|
|
12
|
-
// are all empty/bounced. The ONLY forgiveness paths: `trantor seat-record --reset <seat>`
|
|
13
|
-
// (drops evidence older than the reset stamp) and new completed cards aging the bad ones out
|
|
14
|
-
// of the STRIKE window — so no seat is blacklisted forever.
|
|
1
|
+
// #7762: the seat record is DERIVED from card history and the runner's ledger, never a store, so
|
|
2
|
+
// a seat benched by relay_advise can always be un-benched. Rules: docs/CONTRACT-lib.md, Crew turn
|
|
3
|
+
// policy and failure classification.
|
|
15
4
|
import { readFileSync, writeFileSync, renameSync, readdirSync, mkdirSync } from "node:fs";
|
|
16
5
|
import { join, dirname } from "node:path";
|
|
17
6
|
import { homedir } from "node:os";
|
package/lib/seat-why.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { execSync } from "node:child_process";
|
|
7
|
+
import { asNumber, asRecord } from "./decode.mjs";
|
|
7
8
|
|
|
8
9
|
const busDir = () => process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
|
|
9
10
|
|
|
@@ -18,7 +19,7 @@ function readTelemetry(file) {
|
|
|
18
19
|
const out = [];
|
|
19
20
|
for (const line of readF(file).split("\n")) {
|
|
20
21
|
if (!line.trim()) continue;
|
|
21
|
-
try { const r = JSON.parse(line); if (r
|
|
22
|
+
try { const r = asRecord(JSON.parse(line)); if (r) out.push(r); } catch {}
|
|
22
23
|
}
|
|
23
24
|
return out;
|
|
24
25
|
}
|
|
@@ -60,7 +61,7 @@ const rel = (ts) => {
|
|
|
60
61
|
// print none contribute 0, which is why the count is reported alongside — "3 of 7 turns reported".
|
|
61
62
|
export function todaySpend(telemetry, now = Date.now()) {
|
|
62
63
|
const midnight = new Date(now); midnight.setHours(0, 0, 0, 0);
|
|
63
|
-
const rows = telemetry.filter(r =>
|
|
64
|
+
const rows = telemetry.filter(r => asNumber(r.turn) !== null && r.ts >= midnight.getTime());
|
|
64
65
|
const withTokens = rows.filter(r => Number(r.tokens) > 0);
|
|
65
66
|
return {
|
|
66
67
|
turns: rows.length,
|
|
@@ -86,7 +87,7 @@ export function seatWhy(project, agent, opts = {}) {
|
|
|
86
87
|
const telemetry = readTelemetry(join(dir, "logs", `${agent}-${project}.jsonl`));
|
|
87
88
|
const pane = findPane(dir, project, agent);
|
|
88
89
|
const pids = opts.pidCheck ? opts.pidCheck(project, agent) : scanPids(project, agent);
|
|
89
|
-
const last = [...telemetry].reverse().find(r =>
|
|
90
|
+
const last = [...telemetry].reverse().find(r => asNumber(r.turn) !== null) || null;
|
|
90
91
|
const boots = telemetry.filter(r => r.boot).length;
|
|
91
92
|
const errTail = errText.trim().split("\n").filter(Boolean).slice(-2).join(" | ").slice(0, 240);
|
|
92
93
|
|
package/lib/seats.mjs
CHANGED
|
@@ -4,39 +4,43 @@ import { execFileSync, spawn } from "node:child_process";
|
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { resolve } from "node:path";
|
|
6
6
|
import { readConfig, writeConfigPublic, resolveProject, resolveHubInfo } from "./project.mjs";
|
|
7
|
+
import { asRecord, asString } from "./decode.mjs";
|
|
7
8
|
|
|
8
9
|
// ── the registry ────────────────────────────────────────────────────────────────
|
|
9
10
|
// config.json gains `seats: { <project>: { dir } }`. Same file as `hubs`, same shape of decision:
|
|
10
11
|
// a deliberate operator statement, not an inference.
|
|
11
12
|
export function readSeats() {
|
|
12
13
|
try {
|
|
13
|
-
const s = readConfig()?.seats;
|
|
14
|
-
if (!s
|
|
14
|
+
const s = asRecord(readConfig()?.seats);
|
|
15
|
+
if (!s) return {};
|
|
15
16
|
const out = {};
|
|
16
17
|
for (const [p, v] of Object.entries(s)) {
|
|
17
|
-
const dir =
|
|
18
|
-
const agent = (
|
|
19
|
-
if (
|
|
18
|
+
const dir = asString(v) ?? asRecord(v)?.dir;
|
|
19
|
+
const agent = asString(asRecord(v)?.agent) || "claude";
|
|
20
|
+
if (asString(dir)) out[p] = { dir, agent };
|
|
20
21
|
}
|
|
21
22
|
return out;
|
|
22
23
|
} catch { return {}; }
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export function declareSeat(project, dir, agent = "claude") {
|
|
26
|
-
if (!project
|
|
27
|
+
if (!asString(project)) throw new Error("project required");
|
|
27
28
|
const abs = resolve(dir || process.cwd());
|
|
28
29
|
if (!existsSync(abs)) throw new Error(`directory does not exist: ${abs}`);
|
|
29
30
|
const cfg = readConfig();
|
|
30
|
-
|
|
31
|
+
const seats = { ...(asRecord(cfg.seats) ?? {}) };
|
|
32
|
+
seats[project] = { dir: abs, agent };
|
|
33
|
+
cfg.seats = seats;
|
|
31
34
|
writeConfigPublic(cfg);
|
|
32
35
|
return { project, dir: abs, agent };
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
export function undeclareSeat(project) {
|
|
36
39
|
const cfg = readConfig();
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
const seats = asRecord(cfg.seats);
|
|
41
|
+
if (!seats || !(project in seats)) return false;
|
|
42
|
+
delete seats[project];
|
|
43
|
+
if (!Object.keys(seats).length) delete cfg.seats;
|
|
40
44
|
writeConfigPublic(cfg);
|
|
41
45
|
return true;
|
|
42
46
|
}
|
|
@@ -123,7 +127,7 @@ export function launchSeat(seat, { command, dryRun = false } = {}) {
|
|
|
123
127
|
// a guessed seat is exactly the kind of inference this file exists to replace.
|
|
124
128
|
export function suggestSeats(workspace) {
|
|
125
129
|
const cfg = readConfig();
|
|
126
|
-
const pinned =
|
|
130
|
+
const pinned = Object.keys(asRecord(cfg?.hubs) ?? {});
|
|
127
131
|
const declared = readSeats();
|
|
128
132
|
const out = [];
|
|
129
133
|
for (const project of pinned) {
|
package/lib/state/driver.mjs
CHANGED
|
@@ -15,8 +15,9 @@ import { fromEnvelope } from "./cost.mjs";
|
|
|
15
15
|
// upward is the deliberate inversion, since no number gets a second home. Its dispatch is guarded.
|
|
16
16
|
import { PATCH_BUDGET, appendJsonl, patchesPath, runPath, seatClass } from "../../bin/state-bench.mjs";
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
// The flag name lives in flags.mjs (#7159), beside the resolution list — re-exported so the
|
|
19
|
+
// runner's import surface is unchanged.
|
|
20
|
+
export { STATE_ENV } from "./flags.mjs";
|
|
20
21
|
|
|
21
22
|
/** §7.3's window: the rolling invalid rate is measured over a seat's last 20 turns. */
|
|
22
23
|
export const BREAKER_WINDOW = 20;
|
|
@@ -268,6 +269,7 @@ export async function runStep({
|
|
|
268
269
|
let gateRun = null;
|
|
269
270
|
// Whether the CLI was CUT at the time box. It is the transport that knows, so the transport says
|
|
270
271
|
// so on the way back rather than the caller guessing before the turn has run (§8.7 reads it).
|
|
272
|
+
// Per attempt, not per step (#7226): a cut on the first attempt belongs to that attempt's row.
|
|
271
273
|
let stepCut = cut === true;
|
|
272
274
|
|
|
273
275
|
// §7.3's fallback ladder: a MALFORMED patch earns exactly one retry, with the rejection message
|
|
@@ -282,9 +284,11 @@ export async function runStep({
|
|
|
282
284
|
trace.push("cli");
|
|
283
285
|
const run = await D.callCli(prompt, { attempt });
|
|
284
286
|
exit = run && Number.isInteger(run.exit) ? run.exit : 1;
|
|
285
|
-
|
|
287
|
+
stepCut = cut === true || (run && run.cut === true);
|
|
286
288
|
const parsed = parseEnvelope(run ? run.stdout : "");
|
|
287
|
-
|
|
289
|
+
// The price of THIS call. A retry that came back unpriced must not inherit the first attempt's
|
|
290
|
+
// cost into its own row: two rows, one price each, or the bench sums a call twice.
|
|
291
|
+
cost = parsed.cost || null;
|
|
288
292
|
|
|
289
293
|
// ---- 4. TIER 1: git touch + credit expiry, EVERY turn, before the apply ----
|
|
290
294
|
trace.push("tier1");
|
|
@@ -325,6 +329,14 @@ export async function runStep({
|
|
|
325
329
|
if (attempt === 0) first = rejection ? rejection.code : null;
|
|
326
330
|
if (!rejection) break;
|
|
327
331
|
if (attempt >= 1 || !MALFORMED_CODES.includes(rejection.code)) break;
|
|
332
|
+
// #7226: the attempt about to be retried was a CLI call of its own — often a boxed one, since a
|
|
333
|
+
// cut turn is no output and no output is malformed — so it gets its own run row HERE, before the
|
|
334
|
+
// retry's call. Written now, a runner that dies during the retry cannot fold it into the step's
|
|
335
|
+
// row, and §8.7 counts every turn the box actually ran instead of one per step.
|
|
336
|
+
recordStep(D, project, card, {
|
|
337
|
+
turn: state.cursor.turn, rev: null, by: seat, ts: now, exit, cost, cut: stepCut, disturbed,
|
|
338
|
+
action: null, verify: null, verified_paths: [], rejected: rejection, retry: true,
|
|
339
|
+
});
|
|
328
340
|
attempt++;
|
|
329
341
|
obs = `${observation}\n\n${rejectionObservation(rejection)}`.trim();
|
|
330
342
|
}
|
|
@@ -424,8 +436,9 @@ export async function runStep({
|
|
|
424
436
|
};
|
|
425
437
|
}
|
|
426
438
|
|
|
427
|
-
/** One run
|
|
428
|
-
*
|
|
439
|
+
/** One run row for `~/.agent-bus/state/runs/<project>-<card>.jsonl` — one per CLI call the step made,
|
|
440
|
+
* so a retried attempt is a row of its own (#7226). The FIELD NAMES ARE A CONTRACT with
|
|
441
|
+
* bin/state-bench.mjs (docs/CONTRACT-state.md §run record); a missing cost stays null, never 0. */
|
|
429
442
|
export function recordStep(D, project, card, row) {
|
|
430
443
|
const c = row.cost || {};
|
|
431
444
|
const out = {
|
|
@@ -448,6 +461,7 @@ export function recordStep(D, project, card, row) {
|
|
|
448
461
|
};
|
|
449
462
|
if (row.cut) out.cut = true;
|
|
450
463
|
if (row.disturbed) out.disturbed = true;
|
|
464
|
+
if (row.retry) out.retry = true; // #7226: the attempt runStep retried in-step; its outcome is the next row
|
|
451
465
|
try { D.appendJsonl(runPath(project, card), out); } catch { /* a recorder that throws must not end a turn */ }
|
|
452
466
|
return out;
|
|
453
467
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Trantor State flags (#7159) — the ONE place the runner's TRANTOR_STATE* names resolve. The runner
|
|
2
|
+
// boots from its OWN process env, so a flag set in ~/.agent-bus/.env (the file the turn wrapper
|
|
3
|
+
// sources on the spawned CLI, one level too deep for the runner) never reached it. Here process env
|
|
4
|
+
// wins and the file fills every name the launcher did not set; `trantor doctor` reports the layers.
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { parseEnvFile } from "../provider-keys.mjs";
|
|
8
|
+
|
|
9
|
+
/** §4.6: absent or not "1" = the transcript path, byte for byte. The name lives here so the list
|
|
10
|
+
* below and the runner's armed check cannot drift; driver.mjs re-exports it for its importers. */
|
|
11
|
+
export const STATE_ENV = "TRANTOR_STATE_ASSEMBLE";
|
|
12
|
+
|
|
13
|
+
// The names bin/crew/core.mjs forwards from whoever ran `trantor up` — one list, so forwarding and
|
|
14
|
+
// resolution cannot drift apart.
|
|
15
|
+
export const STATE_FLAGS = ["TRANTOR_STATE", "TRANTOR_STATE_ASSEMBLE", "TRANTOR_STATE_HANDOFF", "TRANTOR_STATE_GATE"];
|
|
16
|
+
|
|
17
|
+
/** `{ [name]: { value, layer } }` per flag; layer is "process env", "~/.agent-bus/.env (crew)" or
|
|
18
|
+
* "unset". Pure — env in, no writes — so the doctor reports the same answer the runner got. */
|
|
19
|
+
export function resolveStateFlags(env = process.env, file = join(homedir(), ".agent-bus", ".env")) {
|
|
20
|
+
const fromFile = parseEnvFile(file); // a missing file parses to {}
|
|
21
|
+
return Object.fromEntries(STATE_FLAGS.map((name) => {
|
|
22
|
+
if (env[name] !== undefined) return [name, { value: env[name], layer: "process env" }];
|
|
23
|
+
if (fromFile[name] !== undefined) return [name, { value: fromFile[name], layer: "~/.agent-bus/.env (crew)" }];
|
|
24
|
+
return [name, { value: "", layer: "unset" }];
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
|
4
4
|
import { join, dirname, basename, relative } from "node:path";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
|
+
import { asString } from "./decode.mjs";
|
|
6
7
|
|
|
7
8
|
const EDIT_TOOLS = /^(Write|Edit|MultiEdit|NotebookEdit)$/;
|
|
8
9
|
|
|
@@ -80,7 +81,7 @@ function analyzeAgent(meta, completedIds, projectRoot) {
|
|
|
80
81
|
const content = b.input.content ?? b.input.new_string ?? "";
|
|
81
82
|
wrote.set(b.input.file_path, content.length); // last write to a path wins
|
|
82
83
|
}
|
|
83
|
-
if (b?.type === "text"
|
|
84
|
+
if (b?.type === "text") { const text = asString(b.text)?.trim(); if (text) result = text; }
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
const status = meta.toolUseId && completedIds.has(meta.toolUseId) ? "completed" : "in-flight";
|
package/lib/subagent-scan.mjs
CHANGED
|
Binary file
|
package/mcp.mjs
CHANGED
|
@@ -197,11 +197,11 @@ server.tool("relay_contracts", "What you dispatched and are still owed. Lists ev
|
|
|
197
197
|
});
|
|
198
198
|
|
|
199
199
|
server.tool("relay_task_add", "Add a Kanban card to a project's board on the dashboard (what you're about to work on). Defaults: THIS project, assigned to you, status 'todo'. Pass `project` to target another board — e.g. when you orchestrate a crew that runs in a different directory than the one you launched Claude from. Keep the team's progress visible. Attach a `note` whenever context isn't obvious from the title — it lands on the card's permanent log ({ts,by,text}, kept: last 40).",
|
|
200
|
-
{ title: z.string().describe("short task title"), status: z.enum(["todo","doing","testing","failed","done","blocked"]).optional(), assignee: z.string().optional().describe("session id to assign (default: you)"), difficulty: z.enum(["easy","medium","hard"]).optional().describe("difficulty tag — drives model/agent routing (relay_advise) and shows on the board"), model: z.string().optional().describe("the model this card is routed to (from relay_advise routing, or the CLI default) — shown on the card"), deps: z.array(z.number()).optional().describe("card ids this card depends on — drawn as branch edges in the Flow view (e.g. integration depends on every crew card)"), phase: z.string().optional().describe("phase/milestone this card belongs to (e.g. 'P5', 'Auth', 'Launch') — groups it in the Flow view's phase flowchart. Optional; otherwise inferred from the title prefix + time."), note: z.string().max(2000).optional().describe("optional card-log entry (<=2000 chars): context, the plan, or a link — stored on the card as {ts,by,text}"), project: z.string().optional().describe("board to add to (default: this session's project). Set to the crew's project when you orchestrate from a different directory"), checklist: z.array(z.string().max(200)).max(20).optional().describe("acceptance items for the card — the honest denominator for its progress bar. Tick them off with relay_task_check as each is truly met") },
|
|
201
|
-
async ({ title, status, assignee, difficulty, model, deps, phase, note, project, checklist }) => {
|
|
200
|
+
{ title: z.string().describe("short task title"), status: z.enum(["todo","doing","testing","failed","done","blocked"]).optional(), assignee: z.string().optional().describe("session id to assign (default: you)"), difficulty: z.enum(["easy","medium","hard"]).optional().describe("difficulty tag — drives model/agent routing (relay_advise) and shows on the board"), model: z.string().optional().describe("the model this card is routed to (from relay_advise routing, or the CLI default) — shown on the card"), deps: z.array(z.number()).optional().describe("card ids this card depends on — drawn as branch edges in the Flow view (e.g. integration depends on every crew card)"), phase: z.string().optional().describe("phase/milestone this card belongs to (e.g. 'P5', 'Auth', 'Launch') — groups it in the Flow view's phase flowchart. Optional; otherwise inferred from the title prefix + time."), note: z.string().max(2000).optional().describe("optional card-log entry (<=2000 chars): context, the plan, or a link — stored on the card as {ts,by,text}"), project: z.string().optional().describe("board to add to (default: this session's project). Set to the crew's project when you orchestrate from a different directory"), checklist: z.array(z.string().max(200)).max(20).optional().describe("acceptance items for the card — the honest denominator for its progress bar. Tick them off with relay_task_check as each is truly met"), drill: z.string().max(300).optional().describe("the card's DRILL (build doctrine rule 1, <=300 chars): the exact thing a person does on the built artifact and what they must see — not a test command. The hub refuses to move a card to done without a drill line (this field, a checklist item or a note starting with 'Drill:'), so a card cut without one is not ready to be worked") },
|
|
201
|
+
async ({ title, status, assignee, difficulty, model, deps, phase, note, project, checklist, drill }) => {
|
|
202
202
|
const proj = project || PROJECT;
|
|
203
|
-
const { task } = await api("POST", "/task", { project: proj, title, status: status || "todo", assignee: assignee || SESSION, difficulty, model, deps, phase, note, checklist, by: SESSION });
|
|
204
|
-
return { content: [{ type: "text", text: `card #${task.id} added to ${proj}: "${title}" [${task.status}]${phase?` · phase ${phase}`:""}${task.checklist?.length?` · ${task.checklist.length} acceptance item(s)`:""}` }] };
|
|
203
|
+
const { task } = await api("POST", "/task", { project: proj, title, status: status || "todo", assignee: assignee || SESSION, difficulty, model, deps, phase, note, checklist, drill, by: SESSION });
|
|
204
|
+
return { content: [{ type: "text", text: `card #${task.id} added to ${proj}: "${title}" [${task.status}]${phase?` · phase ${phase}`:""}${task.checklist?.length?` · ${task.checklist.length} acceptance item(s)`:""}${task.drill ? " · drill set" : " · NO DRILL — the hub will refuse done until one is set"}` }] };
|
|
205
205
|
});
|
|
206
206
|
|
|
207
207
|
server.tool("relay_task_check", "Tick (or untick) ONE acceptance item on a card's checklist — the card's progress bar reads checked/total, so tick an item only when it is genuinely met (tests run, behavior observed), never to make the bar move. Items are 0-indexed in the order relay_task_add listed them.",
|
|
@@ -220,9 +220,9 @@ server.tool("relay_phase_goal", "Set what a PHASE is for — its goal — shown
|
|
|
220
220
|
return { content: [{ type: "text", text: `phase "${phase}" goal set for ${proj}` }] };
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
-
server.tool("relay_task_move", "Move a Kanban card as you progress: todo -> doing -> testing -> done. NEVER move straight to done: move to 'testing' when you finish, run the project's tests/typecheck, then 'done' only if green — or 'failed' (with a relay_send explaining what broke) if not. The orchestrator bounces failed cards back to doing. blocked = waiting on something external. A move to 'testing' or 'done' MUST carry a `note` (<=2000 chars): what you changed and the evidence (the test command + counts). The note lands on the card's permanent log — the board shows its ·N count, so a silent move reads as unverified work.
|
|
224
|
-
{ id: z.number(), status: z.enum(["todo","doing","testing","failed","done","blocked"]), note: z.string().max(2000).optional().describe("card-log entry (<=2000 chars) — REQUIRED on moves to testing/done: what changed + the evidence (command, pass counts)") },
|
|
225
|
-
async ({ id, status, note }) => {
|
|
223
|
+
server.tool("relay_task_move", "Move a Kanban card as you progress: todo -> doing -> testing -> done. NEVER move straight to done: move to 'testing' when you finish, run the project's tests/typecheck, then 'done' only if green — or 'failed' (with a relay_send explaining what broke) if not. The orchestrator bounces failed cards back to doing. blocked = waiting on something external. A move to 'testing' or 'done' MUST carry a `note` (<=2000 chars): what you changed and the evidence (the test command + counts). The note lands on the card's permanent log — the board shows its ·N count, so a silent move reads as unverified work.",
|
|
224
|
+
{ id: z.number(), status: z.enum(["todo","doing","testing","failed","done","blocked"]), note: z.string().max(2000).optional().describe("card-log entry (<=2000 chars) — REQUIRED on moves to testing/done: what changed + the evidence (command, pass counts)"), drill: z.string().max(300).optional().describe("set or fix the card's drill line (<=300 chars) in the same call — what a person does on the built artifact and must see. A move to done is refused by the hub when the card has no drill line") },
|
|
225
|
+
async ({ id, status, note, drill }) => {
|
|
226
226
|
// #7750: seat-side hollow-move check. The hub cannot see this worktree, so the evidence is
|
|
227
227
|
// gathered HERE at report time; a hollow move still lands (never blocks a no-code card), the
|
|
228
228
|
// note is prefixed HOLLOW: and the card's assigner gets one bus line. Never throws into the move.
|
|
@@ -250,7 +250,14 @@ server.tool("relay_task_move", "Move a Kanban card as you progress: todo -> doin
|
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
} catch { /* the flag must never break a move */ }
|
|
253
|
-
|
|
253
|
+
try {
|
|
254
|
+
await api("POST", "/task/update", { id, status, note: outNote, drill, by: SESSION, ...(blast ? { blast } : {}) });
|
|
255
|
+
} catch (error) {
|
|
256
|
+
// #6452: a refused move is the teaching moment — say why the hub said no (no drill line,
|
|
257
|
+
// frozen assignee), not "hub 409".
|
|
258
|
+
if (error?.status === 409) return { content: [{ type: "text", text: `REFUSED: card #${id} stays where it is — ${error.hubError || error.message}` }], isError: true };
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
254
261
|
return { content: [{ type: "text", text: `card #${id} -> ${status}` }] };
|
|
255
262
|
});
|
|
256
263
|
|
|
@@ -374,7 +381,10 @@ function cardView(tasks, id, proj) {
|
|
|
374
381
|
const card = tasks.find(t => t.id === id);
|
|
375
382
|
if (!card) return `${proj}: no card #${id}`;
|
|
376
383
|
const out = [`#${card.id} ${card.title}`,
|
|
377
|
-
`status: ${card.status}${card.assignee ? ` · @${card.assignee}` : ""}${card.difficulty ? ` · ${card.difficulty}` : ""}${card.model ? ` · ${card.model}` : ""}
|
|
384
|
+
`status: ${card.status}${card.assignee ? ` · @${card.assignee}` : ""}${card.difficulty ? ` · ${card.difficulty}` : ""}${card.model ? ` · ${card.model}` : ""}`,
|
|
385
|
+
// #6452: the drill is what a person does on the built artifact to see this card's work; the
|
|
386
|
+
// hub refuses done without one, so a card view says so up front.
|
|
387
|
+
card.drill ? `drill: ${card.drill}` : `drill: (none on the card — the hub refuses a move to done until a drill line exists: relay_task_move with \`drill\`, a checklist item or a note starting "Drill:")`];
|
|
378
388
|
|
|
379
389
|
const deps = (Array.isArray(card.deps) ? card.deps : []).map(d => {
|
|
380
390
|
const t = tasks.find(x => x.id === d);
|
|
@@ -430,7 +440,7 @@ server.tool("relay_board", "Show a project's Kanban board (all cards + their sta
|
|
|
430
440
|
if (!tasks.length) return { content: [{ type: "text", text: `${proj}: no cards yet` }] };
|
|
431
441
|
if (card) return { content: [{ type: "text", text: cardView(tasks, card, proj) }] };
|
|
432
442
|
const by = { todo: [], doing: [], testing: [], failed: [], done: [], blocked: [] };
|
|
433
|
-
for (const t of tasks) (by[t.status] || by.todo).push(`#${t.id} ${t.title}${t.assignee ? ` (@${t.assignee})` : ""}${noteCount(t) ? ` ·${noteCount(t)}` : ""}`);
|
|
443
|
+
for (const t of tasks) (by[t.status] || by.todo).push(`#${t.id} ${t.title}${t.assignee ? ` (@${t.assignee})` : ""}${noteCount(t) ? ` ·${noteCount(t)}` : ""}${t.drill ? " ·drill" : ""}`);
|
|
434
444
|
const cols = Object.entries(by).filter(([, v]) => v.length).map(([k, v]) => `${k.toUpperCase()}:\n ${v.join("\n ")}`);
|
|
435
445
|
return { content: [{ type: "text", text: `${proj} board\n${cols.join("\n")}` }] };
|
|
436
446
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.63",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"trantor": "bin/cli.mjs"
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
".claude-plugin/",
|
|
29
29
|
"README.md",
|
|
30
30
|
"LICENSE",
|
|
31
|
-
"engine/"
|
|
31
|
+
"engine/",
|
|
32
|
+
"docs/BUILD-DOCTRINE.md"
|
|
32
33
|
],
|
|
33
34
|
"repository": {
|
|
34
35
|
"type": "git",
|
package/skills/crew/SKILL.md
CHANGED
|
@@ -36,9 +36,14 @@ explicit EVENT/INTERFACE CONTRACT — cross-agent bugs come from contract drift.
|
|
|
36
36
|
from the brief, hooks, hub brief + first card) — it never spawns a session; firing the crew
|
|
37
37
|
is this phase's job.
|
|
38
38
|
1. `relay_project_brief("<what + why + goal>")`
|
|
39
|
-
2. One card per package: `relay_task_add(title, assignee, difficulty, model)` — set
|
|
40
|
-
to the advisor-routed model (or the CLI's default name); difficulty + model show as
|
|
41
|
-
on the card. Assignees: `codex:<project>` etc. Keep one for yourself.
|
|
39
|
+
2. One card per package: `relay_task_add(title, assignee, difficulty, model, drill)` — set
|
|
40
|
+
`model` to the advisor-routed model (or the CLI's default name); difficulty + model show as
|
|
41
|
+
badges on the card. Assignees: `codex:<project>` etc. Keep one for yourself.
|
|
42
|
+
**Every card names its drill** (build doctrine rule 1): `drill` is the exact thing a person
|
|
43
|
+
does on the built artifact and what they must see ("open Settings, toggle X, the badge turns
|
|
44
|
+
green"), not a test command. A card without a drill line is not ready to be worked, and the
|
|
45
|
+
hub refuses to move it to done: `/task/update` answers 409 unless the card carries a `drill`,
|
|
46
|
+
a checklist item or a note starting with `Drill:`. Cutting a card without one is a defect.
|
|
42
47
|
3. Open the dashboard: **`trantor ui`** — which opens the **desktop app**, not a browser.
|
|
43
48
|
Do NOT open the hub URL in a browser. A remote hub runs `auth:enforce`, and a browser cannot
|
|
44
49
|
sign its requests: the page loads but `/projects`, `/tasks` and `/peers` all return 401, so the
|
|
@@ -150,10 +155,16 @@ orchestrator runs the full suite at integration) plus `node bin/slop-gate.mjs` w
|
|
|
150
155
|
one (the anti-slop lint over the seat's changed files — a card must not reach done failing it);
|
|
151
156
|
`done` only green, and moves to testing/done carry a `note` with the evidence — the note is the
|
|
152
157
|
card's permanent story; `failed` (+ bus report) pulses red on the board until you bounce it.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
158
|
+
**The seat that wrote the code never closes its own card to done.** `testing` is the seat's
|
|
159
|
+
last move; `done` is yours, after you ran the card's drill on the built artifact (the installed
|
|
160
|
+
app, the live hub, the real CLI) and wrote the result on the card. Say so in every contract:
|
|
161
|
+
"move to testing with the evidence, then stop; the orchestrator runs the drill and closes". The
|
|
162
|
+
hub enforces the drill half: a move to done on a card with no drill line is refused (409), your
|
|
163
|
+
own moves included. Enforce the rest — bounce anything that skipped the gate (bounces are
|
|
164
|
+
visible: "↩ bounced" on the card, history in its tooltip). When all report testing: run each
|
|
165
|
+
drill, close the cards that pass, bounce the rest, integrate, fix contract mismatches
|
|
166
|
+
YOURSELF, move your own card through testing → done (its drill run and noted, same as any
|
|
167
|
+
other), broadcast "🚀 <thing> is live", and when the user is finished: `trantor down`.
|
|
157
168
|
|
|
158
169
|
## Rules
|
|
159
170
|
- Coordinate ONLY over the bus; messages <280 chars; the dashboard lanes are the user's view.
|