trantor 0.18.49 → 0.18.50
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/baton-pane.mjs +62 -9
- package/bin/baton.mjs +8 -2
- package/bin/connect.mjs +44 -4
- package/bin/crew/open.mjs +54 -5
- package/bin/crew-runner.mjs +29 -11
- package/bin/drill-report.mjs +80 -0
- package/bin/drill-report.test.mjs +146 -0
- package/bin/drill-seams.mjs +157 -0
- package/bin/drill-surface.mjs +157 -54
- package/deploy/setup.sh +38 -0
- package/hooks/lib/handoff.mjs +43 -1
- package/lib/duty-nudges.mjs +48 -10
- package/lib/state/apply.mjs +170 -0
- package/lib/state/migrate.mjs +126 -0
- package/lib/state/schema.mjs +216 -0
- package/lib/state/validate.mjs +231 -0
- package/package.json +1 -1
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: the validator's whole job is decoding an untrusted TurnResult at the boundary — a model may emit any JSON at all, and every typeof here is that parse, returning a named rejection rather than narrowing a value that was never established. */
|
|
2
|
+
// Trantor State — the validator (TDD §4.2, stages 1-3). Pure and total: same arguments, same
|
|
3
|
+
// answer, no disk, no clock, no throw. Rejection is a first-class RETURN, not an exception,
|
|
4
|
+
// because the rejection text is fed back to the seat as its next observation.
|
|
5
|
+
import {
|
|
6
|
+
CAPS, LISTS, COMPACTING_LISTS, ERR, EVIDENCE_MARKER, SETTABLE_FIELDS, READONLY_FIELDS,
|
|
7
|
+
RUNTIME_EXT_KEYS, KNOWN_FIELDS, itemError, isAction,
|
|
8
|
+
} from "./schema.mjs";
|
|
9
|
+
|
|
10
|
+
const reject = (code, at, message) => ({ ok: false, code, at, message });
|
|
11
|
+
|
|
12
|
+
/** Which top-level field a `set` path addresses: "ext.foo" → "ext". */
|
|
13
|
+
const rootOf = (field) => String(field).split(".")[0];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Pull the evidence marker out of an item's text BEFORE any cap is applied, and merge it with any
|
|
17
|
+
* `paths` the patch supplied directly (TDD §3: the two routes converge on the same field).
|
|
18
|
+
* Order is the whole point: extract, then cap, so a long text can never truncate away the paths
|
|
19
|
+
* route (b) depends on.
|
|
20
|
+
* @returns {{ ok: true, item: object } | { ok: false, reason: string }}
|
|
21
|
+
*/
|
|
22
|
+
export function extractEvidence(item) {
|
|
23
|
+
const marked = EVIDENCE_MARKER.exec(item.text);
|
|
24
|
+
const fromMarker = marked ? marked[1].split(",").filter(Boolean) : [];
|
|
25
|
+
const prose = marked ? item.text.slice(0, marked.index) : item.text;
|
|
26
|
+
const paths = [...new Set([...(item.paths || []), ...fromMarker])];
|
|
27
|
+
|
|
28
|
+
// A malformed marker is rejected naming the marker, never accepted-and-trimmed into silence.
|
|
29
|
+
if (paths.length > CAPS.ITEM_PATHS) {
|
|
30
|
+
return { ok: false, reason: `evidence marker carries ${paths.length} paths, CAPS.ITEM_PATHS is ${CAPS.ITEM_PATHS}` };
|
|
31
|
+
}
|
|
32
|
+
for (const p of paths) {
|
|
33
|
+
if (p.length > CAPS.PATH) return { ok: false, reason: `evidence path exceeds CAPS.PATH (${CAPS.PATH}): "${p.slice(0, 60)}…"` };
|
|
34
|
+
if (p.startsWith("/") || p.split("/").includes("..")) {
|
|
35
|
+
return { ok: false, reason: `evidence path escapes the worktree: "${p}"` };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const out = { id: item.id, text: prose.slice(0, CAPS.ITEM) };
|
|
40
|
+
if (paths.length) out.paths = paths;
|
|
41
|
+
return { ok: true, item: out };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Does this item have the evidence a `move → done` needs (TDD §4.2)?
|
|
46
|
+
* Route (a): the gate went green this turn. Route (b): every path it names is credited.
|
|
47
|
+
*/
|
|
48
|
+
export function hasEvidence(state, item) {
|
|
49
|
+
if (state.verify.tested === true && state.verify.exit === 0) return true;
|
|
50
|
+
const paths = item.paths || [];
|
|
51
|
+
if (!paths.length) return false;
|
|
52
|
+
return paths.every(p => state.files[p]?.verified === true);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Stages 1-3. On success returns the normalised ops (markers extracted, prose capped) for apply.mjs
|
|
57
|
+
* to run; on failure the rejection the seat will read.
|
|
58
|
+
*
|
|
59
|
+
* `ctx.gate_attempted` is what splits NEEDS_GATE from UNVERIFIED_DONE, and it is the difference
|
|
60
|
+
* between "no gate has run" and "a gate ran and came back red" — two causes that look identical in
|
|
61
|
+
* the state. Without it the core returns NEEDS_GATE both times and §4.8's one-retry bound is false.
|
|
62
|
+
*/
|
|
63
|
+
export function validateTurn(state, turn, ctx = {}) {
|
|
64
|
+
// ---- stage 1: shape ----
|
|
65
|
+
if (typeof turn !== "object" || turn === null || Array.isArray(turn)) {
|
|
66
|
+
return reject(ERR.SCHEMA, "turn", "TurnResult must be an object with { patch, action }");
|
|
67
|
+
}
|
|
68
|
+
if (!Array.isArray(turn.patch)) {
|
|
69
|
+
return reject(ERR.SCHEMA, "turn.patch", "TurnResult.patch must be an array of ops");
|
|
70
|
+
}
|
|
71
|
+
if (!isAction(turn.action)) {
|
|
72
|
+
return reject(ERR.BAD_ACTION, "turn.action",
|
|
73
|
+
"every turn must act, finish, or ask: one of { tool, input } | { done: true } | { ask } | { continue: true }");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const OPERATORS = ["set", "add", "remove", "move"];
|
|
77
|
+
const normalised = [];
|
|
78
|
+
|
|
79
|
+
// Lengths are simulated as we go, so an `add` that would overflow a working list is caught
|
|
80
|
+
// before anything is applied — all-or-nothing is what makes "no valid patch corrupts state"
|
|
81
|
+
// testable in the first place.
|
|
82
|
+
const lengths = Object.fromEntries(LISTS.map(l => [l, state[l].length]));
|
|
83
|
+
// id → which list holds it, and id → the item itself. Both are simulated forward through the
|
|
84
|
+
// patch so ops later in the same patch see what the earlier ones did.
|
|
85
|
+
const listOf = new Map();
|
|
86
|
+
const itemOf = new Map();
|
|
87
|
+
for (const l of LISTS) for (const it of state[l]) { listOf.set(it.id, l); itemOf.set(it.id, it); }
|
|
88
|
+
let taskSet = state.task !== "";
|
|
89
|
+
|
|
90
|
+
for (let n = 0; n < turn.patch.length; n++) {
|
|
91
|
+
const op = turn.patch[n];
|
|
92
|
+
if (typeof op !== "object" || op === null || Array.isArray(op)) {
|
|
93
|
+
return reject(ERR.SCHEMA, `patch[${n}]`, "each op must be an object");
|
|
94
|
+
}
|
|
95
|
+
const keys = Object.keys(op).filter(k => OPERATORS.includes(k));
|
|
96
|
+
if (keys.length !== 1 || Object.keys(op).length !== 1) {
|
|
97
|
+
return reject(ERR.SCHEMA, `patch[${n}]`,
|
|
98
|
+
`each op has exactly one operator key (${OPERATORS.join(" | ")}); got [${Object.keys(op).join(", ")}]`);
|
|
99
|
+
}
|
|
100
|
+
const kind = keys[0];
|
|
101
|
+
const body = op[kind];
|
|
102
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
103
|
+
return reject(ERR.SCHEMA, `patch[${n}].${kind}`, `${kind} takes an object`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (kind === "set") {
|
|
107
|
+
const { field, value } = body;
|
|
108
|
+
if (typeof field !== "string" || !field) {
|
|
109
|
+
return reject(ERR.SCHEMA, `patch[${n}].set`, "set.field must be a non-empty string");
|
|
110
|
+
}
|
|
111
|
+
const root = rootOf(field);
|
|
112
|
+
// ---- stage 2: the write matrix. One table lookup, and it is the whole "a seat marks its
|
|
113
|
+
// own work verified" hole.
|
|
114
|
+
if (READONLY_FIELDS.includes(root)) {
|
|
115
|
+
return reject(ERR.READONLY_FIELD, `set:${field}`,
|
|
116
|
+
`${field} is harness-written and never model-writable. Evidence comes from a gate that ran, not from the patch.`);
|
|
117
|
+
}
|
|
118
|
+
if (root === "ext" && RUNTIME_EXT_KEYS.includes(field.split(".")[1])) {
|
|
119
|
+
return reject(ERR.READONLY_FIELD, `set:${field}`, `ext.${field.split(".")[1]} is runtime-owned`);
|
|
120
|
+
}
|
|
121
|
+
if (LISTS.includes(root)) {
|
|
122
|
+
return reject(ERR.READONLY_FIELD, `set:${field}`,
|
|
123
|
+
`${root} changes by add/remove/move only, never by set — arrays change only by id`);
|
|
124
|
+
}
|
|
125
|
+
if (!KNOWN_FIELDS.includes(root)) {
|
|
126
|
+
return reject(ERR.UNKNOWN_FIELD, `set:${field}`,
|
|
127
|
+
`the schema defines no field "${root}". An unknown field is a rejection, never a silently created key.`);
|
|
128
|
+
}
|
|
129
|
+
if (!SETTABLE_FIELDS.includes(root)) {
|
|
130
|
+
return reject(ERR.READONLY_FIELD, `set:${field}`, `${field} is not settable`);
|
|
131
|
+
}
|
|
132
|
+
if (root === "task") {
|
|
133
|
+
if (field !== "task") return reject(ERR.UNKNOWN_FIELD, `set:${field}`, "task is a scalar; set it whole");
|
|
134
|
+
if (typeof value !== "string") return reject(ERR.SCHEMA, `set:${field}`, "task must be a string");
|
|
135
|
+
if (taskSet) return reject(ERR.READONLY_FIELD, "set:task", "task is set-once: it is writable only while empty");
|
|
136
|
+
if (value.length > CAPS.TASK) return reject(ERR.CAP, "set:task", `task exceeds CAPS.TASK (${CAPS.TASK})`);
|
|
137
|
+
taskSet = value !== "";
|
|
138
|
+
}
|
|
139
|
+
if (root === "notes") {
|
|
140
|
+
if (typeof value !== "string") return reject(ERR.SCHEMA, `set:${field}`, "notes must be a string");
|
|
141
|
+
}
|
|
142
|
+
normalised.push({ set: { field, value } });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (kind === "add") {
|
|
147
|
+
const { list, item } = body;
|
|
148
|
+
if (!LISTS.includes(list)) {
|
|
149
|
+
return reject(ERR.UNKNOWN_LIST, `patch[${n}].add`, `no list "${list}"; one of ${LISTS.join(", ")}`);
|
|
150
|
+
}
|
|
151
|
+
const why = itemError(item);
|
|
152
|
+
if (why) return reject(ERR.SCHEMA, `add:${list}`, why);
|
|
153
|
+
if (item.id.length > CAPS.ID) {
|
|
154
|
+
return reject(ERR.CAP, `add:${item.id.slice(0, 16)}…`,
|
|
155
|
+
`item.id exceeds CAPS.ID (${CAPS.ID}). Over-long ids are rejected, not trimmed — a trimmed id no longer addresses the item the next op names.`);
|
|
156
|
+
}
|
|
157
|
+
if (listOf.has(item.id)) return reject(ERR.DUP_ID, `add:${item.id}`, `id "${item.id}" already exists`);
|
|
158
|
+
const ev = extractEvidence(item);
|
|
159
|
+
if (!ev.ok) return reject(ERR.CAP, `add:${item.id}`, ev.reason);
|
|
160
|
+
if (lengths[list] + 1 > CAPS.LIST) {
|
|
161
|
+
if (!COMPACTING_LISTS.includes(list)) {
|
|
162
|
+
return reject(ERR.CAP, `add:${list}`,
|
|
163
|
+
`${list} is at CAPS.LIST (${CAPS.LIST}) and is a working list: overflow is a rejection, not a silent drop`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
listOf.set(item.id, list);
|
|
167
|
+
itemOf.set(item.id, ev.item);
|
|
168
|
+
lengths[list]++;
|
|
169
|
+
normalised.push({ add: { list, item: ev.item } });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (kind === "remove") {
|
|
174
|
+
const { list, id } = body;
|
|
175
|
+
if (!LISTS.includes(list)) {
|
|
176
|
+
return reject(ERR.UNKNOWN_LIST, `patch[${n}].remove`, `no list "${list}"; one of ${LISTS.join(", ")}`);
|
|
177
|
+
}
|
|
178
|
+
if (typeof id !== "string" || !id) return reject(ERR.SCHEMA, `patch[${n}].remove`, "remove.id must be a non-empty string");
|
|
179
|
+
if (listOf.get(id) !== list) {
|
|
180
|
+
return reject(ERR.NO_SUCH_ID, `remove:${id}`,
|
|
181
|
+
listOf.has(id) ? `item "${id}" is in ${listOf.get(id)}, not ${list}` : `no item "${id}" to remove`);
|
|
182
|
+
}
|
|
183
|
+
listOf.delete(id);
|
|
184
|
+
itemOf.delete(id);
|
|
185
|
+
lengths[list]--;
|
|
186
|
+
normalised.push({ remove: { list, id } });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// move
|
|
191
|
+
const { id, from, to } = body;
|
|
192
|
+
if (!LISTS.includes(from) || !LISTS.includes(to)) {
|
|
193
|
+
return reject(ERR.UNKNOWN_LIST, `patch[${n}].move`, `move needs two known lists; got "${from}" → "${to}"`);
|
|
194
|
+
}
|
|
195
|
+
if (typeof id !== "string" || !id) return reject(ERR.SCHEMA, `patch[${n}].move`, "move.id must be a non-empty string");
|
|
196
|
+
if (listOf.get(id) !== from) {
|
|
197
|
+
return reject(ERR.NO_SUCH_ID, `move:${id}`,
|
|
198
|
+
listOf.has(id) ? `item "${id}" is in ${listOf.get(id)}, not ${from}` : `no item "${id}" to move`);
|
|
199
|
+
}
|
|
200
|
+
if (lengths[to] + 1 > CAPS.LIST && !COMPACTING_LISTS.includes(to)) {
|
|
201
|
+
return reject(ERR.CAP, `move:${id}`, `${to} is at CAPS.LIST (${CAPS.LIST})`);
|
|
202
|
+
}
|
|
203
|
+
if (to === "done") {
|
|
204
|
+
const item = itemOf.get(id);
|
|
205
|
+
if (item && !hasEvidence(state, item)) {
|
|
206
|
+
// The split that makes §4.8's one-retry bound a fact: a gate that ran and failed looks
|
|
207
|
+
// exactly like a gate that never ran, unless the driver says which happened.
|
|
208
|
+
if (ctx.gate_attempted) {
|
|
209
|
+
const g = ctx.gate_attempted;
|
|
210
|
+
return {
|
|
211
|
+
...reject(ERR.UNVERIFIED_DONE, `move:${id}`,
|
|
212
|
+
`move ${id} → done rejected: the gate ran and did not pass (${g.cmd || "gate"}, exit ${g.exit}). ` +
|
|
213
|
+
`Fix the failure, then move it.`),
|
|
214
|
+
gate: { cmd: g.cmd, exit: g.exit, tail: g.tail },
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
...reject(ERR.NEEDS_GATE, `move:${id}`,
|
|
219
|
+
`move ${id} → done needs evidence: no gate has run at this state. Run it, then re-apply.`),
|
|
220
|
+
gate: { items: [id], paths: item.paths || [] },
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
listOf.set(id, to);
|
|
225
|
+
lengths[from]--;
|
|
226
|
+
lengths[to]++;
|
|
227
|
+
normalised.push({ move: { id, from, to } });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return { ok: true, ops: normalised };
|
|
231
|
+
}
|