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
package/lib/duty-nudges.mjs
CHANGED
|
@@ -32,17 +32,15 @@ export function dutyEscalations(messages) {
|
|
|
32
32
|
export function readDutyNudgeState(path) {
|
|
33
33
|
try {
|
|
34
34
|
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
if (!(parsed?.nudged instanceof Object) || Array.isArray(parsed.nudged)) throw new Error("invalid nudged state");
|
|
36
|
+
if (!(parsed.planned instanceof Object) || Array.isArray(parsed.planned)) parsed.planned = {};
|
|
37
|
+
return parsed;
|
|
38
38
|
} catch {
|
|
39
|
-
return { version: 1, nudged: {} };
|
|
39
|
+
return { version: 1, nudged: {}, planned: {} };
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
const state = readDutyNudgeState(statePath);
|
|
45
|
-
const items = dutyEscalations(messages).filter(item => !state.nudged[item.id]);
|
|
43
|
+
function buildPlan(items, owner = "") {
|
|
46
44
|
const targets = [];
|
|
47
45
|
for (const item of items) {
|
|
48
46
|
let target = targets.find(candidate => candidate.recipient === item.recipient);
|
|
@@ -52,7 +50,37 @@ export function planDutyNudges(messages, statePath) {
|
|
|
52
50
|
}
|
|
53
51
|
if (!target.ids.includes(item.id)) target.ids.push(item.id);
|
|
54
52
|
}
|
|
55
|
-
return { items, targets };
|
|
53
|
+
return { items, targets, owner };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function planDutyNudges(messages, statePath) {
|
|
57
|
+
const state = readDutyNudgeState(statePath);
|
|
58
|
+
const items = dutyEscalations(messages).filter(item => !state.nudged[item.id] && !state.planned[item.id]);
|
|
59
|
+
return buildPlan(items);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function processAlive(pid) {
|
|
63
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
64
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function claimDutyNudges({ messages, statePath, owner, pid = process.pid, now = Date.now() }) {
|
|
68
|
+
let plan = buildPlan([], owner);
|
|
69
|
+
await withStateLock(statePath, state => {
|
|
70
|
+
for (const [id, claim] of Object.entries(state.planned)) {
|
|
71
|
+
if (now - Number(claim?.plannedAt || 0) > 30 * 60 * 1000 || !processAlive(Number(claim?.pid || 0))) {
|
|
72
|
+
delete state.planned[id];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const items = dutyEscalations(messages).filter(item => !state.nudged[item.id] && !state.planned[item.id]);
|
|
76
|
+
plan = buildPlan(items, owner);
|
|
77
|
+
for (const item of items) {
|
|
78
|
+
state.planned[item.id] = {
|
|
79
|
+
owner, pid, recipient: item.recipient, project: item.project, plannedAt: now,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
return plan;
|
|
56
84
|
}
|
|
57
85
|
|
|
58
86
|
export function dutyNudgeDirective(plan) {
|
|
@@ -93,7 +121,8 @@ export function observedDutyNudgeIds(transcriptDir, sinceMs) {
|
|
|
93
121
|
for (const use of uses) {
|
|
94
122
|
const input = use.input || {};
|
|
95
123
|
const text = String(input.message || input.content || "");
|
|
96
|
-
if (!text.startsWith("Trantor delivery nudge from the duty seat:")
|
|
124
|
+
if (!text.startsWith("Trantor delivery nudge from the duty seat:")
|
|
125
|
+
|| !text.endsWith("This nudge carries no message content; the signed bus messages are the source of truth.")) continue;
|
|
97
126
|
for (const id of idsIn(text)) ids.add(id);
|
|
98
127
|
}
|
|
99
128
|
}
|
|
@@ -106,7 +135,7 @@ function writeDutyNudgeState(path, state) {
|
|
|
106
135
|
const entries = Object.entries(state.nudged)
|
|
107
136
|
.sort((a, b) => Number(b[1]?.nudgedAt || 0) - Number(a[1]?.nudgedAt || 0))
|
|
108
137
|
.slice(0, 5000);
|
|
109
|
-
const next = { version: 1, nudged: Object.fromEntries(entries) };
|
|
138
|
+
const next = { version: 1, nudged: Object.fromEntries(entries), planned: state.planned || {} };
|
|
110
139
|
const temporary = `${path}.${process.pid}.tmp`;
|
|
111
140
|
writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
112
141
|
renameSync(temporary, path);
|
|
@@ -136,6 +165,7 @@ export async function recordDutyNudges({ plan, observedIds, statePath, now = Dat
|
|
|
136
165
|
await withStateLock(statePath, state => {
|
|
137
166
|
for (const item of nudged) {
|
|
138
167
|
state.nudged[item.id] = { recipient: item.recipient, project: item.project, nudgedAt: now };
|
|
168
|
+
delete state.planned[item.id];
|
|
139
169
|
}
|
|
140
170
|
});
|
|
141
171
|
return nudged;
|
|
@@ -148,5 +178,13 @@ export async function auditDutyNudges({ plan, observedIds, statePath, reportFail
|
|
|
148
178
|
ids: target.ids.filter(id => !observedIds.has(id)),
|
|
149
179
|
})).filter(target => target.ids.length);
|
|
150
180
|
for (const target of missing) await reportFailure(target);
|
|
181
|
+
if (missing.length) {
|
|
182
|
+
const missingIds = new Set(missing.flatMap(target => target.ids));
|
|
183
|
+
await withStateLock(statePath, state => {
|
|
184
|
+
for (const id of missingIds) {
|
|
185
|
+
if (state.planned[id]?.owner === plan.owner) delete state.planned[id];
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
151
189
|
return { missing, nudged };
|
|
152
190
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: ctx carries harness facts from the driver and set() merges model-supplied values; both are decoded here at the boundary rather than trusted. */
|
|
2
|
+
// Trantor State — apply (TDD §4.2, stages 4-5). Validate, then apply to a CLONE, then the runtime
|
|
3
|
+
// pass. Pure: `ctx` carries the clock and the harness facts, so this function is a function of its
|
|
4
|
+
// arguments and nothing else.
|
|
5
|
+
//
|
|
6
|
+
// One apply point per turn is the whole crash-safety argument (§4.4): a turn killed mid-flight has
|
|
7
|
+
// never partially applied a patch, because the patch is applied to a clone that is only returned
|
|
8
|
+
// when every op passed.
|
|
9
|
+
import { CAPS, LISTS, COMPACTING_LISTS, RUNTIME_EXT_KEYS, cloneState } from "./schema.mjs";
|
|
10
|
+
import { validateTurn } from "./validate.mjs";
|
|
11
|
+
|
|
12
|
+
/** Deep-merge one level, the `set` semantics of §3.2: null deletes an optional key. */
|
|
13
|
+
function setField(state, field, value) {
|
|
14
|
+
const [root, sub] = String(field).split(".");
|
|
15
|
+
if (sub === undefined) {
|
|
16
|
+
if (value === null && root === "ext") { state.ext = {}; return; }
|
|
17
|
+
state[root] = value;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (value === null) { delete state[root][sub]; return; }
|
|
21
|
+
const cur = state[root][sub];
|
|
22
|
+
const mergeable = typeof cur === "object" && cur !== null && !Array.isArray(cur)
|
|
23
|
+
&& typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
|
+
state[root][sub] = mergeable ? { ...cur, ...value } : value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Keep `notes` under CAPS.NOTES by evicting whole lines from the FRONT — the tail is the recent
|
|
28
|
+
* half, and a mid-string elision is #6528, the failure this whole schema exists to prevent. */
|
|
29
|
+
function capNotes(notes) {
|
|
30
|
+
if (Buffer.byteLength(notes, "utf8") <= CAPS.NOTES) return notes;
|
|
31
|
+
const lines = notes.split("\n");
|
|
32
|
+
while (lines.length > 1 && Buffer.byteLength(lines.join("\n"), "utf8") > CAPS.NOTES) lines.shift();
|
|
33
|
+
let out = lines.join("\n");
|
|
34
|
+
while (out.length && Buffer.byteLength(out, "utf8") > CAPS.NOTES) out = out.slice(1);
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The five-stage pipeline. `turn` is the TurnResult `{ patch, action }` — §4.8's driver snippet
|
|
40
|
+
* calls this slot `patch`, which is the same object under a shorter name.
|
|
41
|
+
*
|
|
42
|
+
* ctx: { now, by, verify, files, gate_attempted, gate }
|
|
43
|
+
* - `verify` and `files` are harness facts from a gate that actually ran, never testimony.
|
|
44
|
+
* - `gate_attempted` splits NEEDS_GATE from UNVERIFIED_DONE (§4.8).
|
|
45
|
+
* - `gate` is the memo record runGate returned, written to ext._gate here so it lands through
|
|
46
|
+
* the single apply point rather than as a side effect somewhere else.
|
|
47
|
+
*
|
|
48
|
+
* @returns {{ ok: true, state: object, promoted: object[] }
|
|
49
|
+
* | { ok: false, code: string, at: string, message: string }}
|
|
50
|
+
*/
|
|
51
|
+
export function applyTurn(state, turn, ctx = {}) {
|
|
52
|
+
const v = validateTurn(state, turn, ctx);
|
|
53
|
+
if (!v.ok) return v;
|
|
54
|
+
|
|
55
|
+
// ---- stage 4: apply, in order, on a structural clone ----
|
|
56
|
+
const next = cloneState(state);
|
|
57
|
+
for (const op of v.ops) {
|
|
58
|
+
if (op.set) { setField(next, op.set.field, op.set.value); continue; }
|
|
59
|
+
if (op.add) { next[op.add.list].push(op.add.item); continue; }
|
|
60
|
+
if (op.remove) {
|
|
61
|
+
next[op.remove.list] = next[op.remove.list].filter(i => i.id !== op.remove.id);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const { id, from, to } = op.move;
|
|
65
|
+
const at = next[from].findIndex(i => i.id === id);
|
|
66
|
+
const [item] = next[from].splice(at, 1);
|
|
67
|
+
next[to].push(item);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---- stage 5: the runtime pass. Not model-visible, not model-writable. ----
|
|
71
|
+
|
|
72
|
+
// `verify` is REWRITTEN wholesale, never merged: it describes the gate that ran THIS turn, or
|
|
73
|
+
// nothing. That is what makes "evidence at the current rev" enforceable without a rev stamp on
|
|
74
|
+
// every field (§4.2).
|
|
75
|
+
next.verify = ctx.verify && typeof ctx.verify === "object" ? { ...ctx.verify } : {};
|
|
76
|
+
|
|
77
|
+
// Harness file facts: tier 1 sets `touched` from git, the gate sets `verified` + `hash`, and
|
|
78
|
+
// tier 1 EXPIRES a credit whose bytes have moved (R12). Setting evidence is the gate's
|
|
79
|
+
// privilege; expiring it belongs to the only thing that runs every turn.
|
|
80
|
+
for (const [path, fact] of Object.entries(ctx.files || {})) {
|
|
81
|
+
const cur = next.files[path] || { touched: false, verified: false };
|
|
82
|
+
const merged = { ...cur };
|
|
83
|
+
if (fact.touched !== undefined) merged.touched = fact.touched;
|
|
84
|
+
if (fact.verified !== undefined) merged.verified = fact.verified;
|
|
85
|
+
if (fact.blast_radius !== undefined) merged.blast_radius = fact.blast_radius;
|
|
86
|
+
if (fact.hash !== undefined) merged.hash = fact.hash;
|
|
87
|
+
if (merged.verified === false) delete merged.hash;
|
|
88
|
+
next.files[path] = merged;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (ctx.gate && typeof ctx.gate === "object") next.ext._gate = { ...ctx.gate };
|
|
92
|
+
if (ctx.promoted !== undefined) next.ext._promoted = ctx.promoted;
|
|
93
|
+
|
|
94
|
+
// ---- compaction: history compacts, working lists never reach here (they were rejected) ----
|
|
95
|
+
for (const list of COMPACTING_LISTS) {
|
|
96
|
+
if (next[list].length > CAPS.LIST) {
|
|
97
|
+
const overflow = next[list].length - CAPS.LIST;
|
|
98
|
+
next[list] = next[list].slice(overflow);
|
|
99
|
+
if (list === "done") next.done_count += overflow;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
next.done_count = Math.max(next.done_count, 0);
|
|
103
|
+
const paths = Object.keys(next.files);
|
|
104
|
+
if (paths.length > CAPS.FILES) {
|
|
105
|
+
// Drop the least interesting first: untouched and uncredited paths carry no evidence.
|
|
106
|
+
const rank = (p) => (next.files[p].verified ? 2 : next.files[p].touched ? 1 : 0);
|
|
107
|
+
const doomed = paths.sort((a, b) => rank(a) - rank(b)).slice(0, paths.length - CAPS.FILES);
|
|
108
|
+
for (const p of doomed) delete next.files[p];
|
|
109
|
+
next.files_count += doomed.length;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- cap enforcement on content ----
|
|
113
|
+
next.task = next.task.slice(0, CAPS.TASK);
|
|
114
|
+
next.notes = capNotes(next.notes);
|
|
115
|
+
const extKeys = Object.keys(next.ext).filter(k => !RUNTIME_EXT_KEYS.includes(k));
|
|
116
|
+
for (const k of extKeys.slice(CAPS.EXT_KEYS)) delete next.ext[k];
|
|
117
|
+
let extBytes = Buffer.byteLength(JSON.stringify(
|
|
118
|
+
Object.fromEntries(Object.entries(next.ext).filter(([k]) => !RUNTIME_EXT_KEYS.includes(k))),
|
|
119
|
+
), "utf8");
|
|
120
|
+
for (const k of extKeys.slice(0, CAPS.EXT_KEYS).reverse()) {
|
|
121
|
+
if (extBytes <= CAPS.EXT_BYTES) break;
|
|
122
|
+
extBytes -= Buffer.byteLength(JSON.stringify({ [k]: next.ext[k] }), "utf8");
|
|
123
|
+
delete next.ext[k];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
next.cursor = {
|
|
127
|
+
turn: state.cursor.turn + 1,
|
|
128
|
+
ts: Number.isInteger(ctx.now) ? ctx.now : state.cursor.ts,
|
|
129
|
+
by: typeof ctx.by === "string" ? ctx.by : state.cursor.by,
|
|
130
|
+
};
|
|
131
|
+
next.rev = state.rev + 1;
|
|
132
|
+
|
|
133
|
+
return { ok: true, state: next, promoted: promotionPlan(state, next) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The delta the board is allowed to see (TDD §4.7): what shipped, what is blocking, what the
|
|
138
|
+
* evidence says. `in_flight`/`next` are scratch and promote nothing — that churn is #6669's
|
|
139
|
+
* lesson. The promoter (P3) composes these into at most one card note per turn.
|
|
140
|
+
* @returns {{ kind: string, text: string }[]}
|
|
141
|
+
*/
|
|
142
|
+
export function promotionPlan(before, after) {
|
|
143
|
+
const out = [];
|
|
144
|
+
const wasDone = new Set(before.done.map(i => i.id));
|
|
145
|
+
for (const item of after.done) {
|
|
146
|
+
if (wasDone.has(item.id)) continue;
|
|
147
|
+
const ev = item.paths?.length ? ` (${item.paths.join(", ")})` : "";
|
|
148
|
+
out.push({ kind: "done", text: `${item.text}${ev}` });
|
|
149
|
+
}
|
|
150
|
+
const wasBlocked = new Map(before.blockers.map(i => [i.id, i]));
|
|
151
|
+
for (const item of after.blockers) {
|
|
152
|
+
if (!wasBlocked.has(item.id)) out.push({ kind: "blocker_added", text: item.text });
|
|
153
|
+
}
|
|
154
|
+
const nowBlocked = new Set(after.blockers.map(i => i.id));
|
|
155
|
+
for (const item of before.blockers) {
|
|
156
|
+
if (!nowBlocked.has(item.id)) out.push({ kind: "blocker_cleared", text: item.text });
|
|
157
|
+
}
|
|
158
|
+
for (const field of ["built", "tested", "observed"]) {
|
|
159
|
+
if (after.verify[field] === true && before.verify[field] !== true) {
|
|
160
|
+
const cmd = after.verify.cmd ? ` — ${after.verify.cmd}` : "";
|
|
161
|
+
out.push({ kind: "verify", text: `${field}${cmd}` });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Every id currently in the state, for the "arrays change only by id" property. */
|
|
168
|
+
export function allIds(state) {
|
|
169
|
+
return LISTS.flatMap(l => state[l].map(i => i.id));
|
|
170
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: migration reads objects written by an OLDER schema version off disk — the type of any field is exactly what cannot be assumed, which is why a mismatch is salvaged into notes instead of coerced. */
|
|
2
|
+
// Trantor State — migration (TDD §4.3). A field the newer schema does not know is never dropped:
|
|
3
|
+
// it is appended to `notes` as `migrated:<path>=<json>`. That is #6528 generalised, and it is the
|
|
4
|
+
// single rule that makes the whole scheme safe to version.
|
|
5
|
+
import { CAPS, CURRENT_VERSION, ERR, KNOWN_FIELDS, emptyState, stateError } from "./schema.mjs";
|
|
6
|
+
|
|
7
|
+
/** Append `migrated:` lines to notes, oldest evicted first when the cap bites. */
|
|
8
|
+
function noteMigrated(state, lines) {
|
|
9
|
+
if (!lines.length) return state;
|
|
10
|
+
const joined = [state.notes, ...lines].filter(Boolean).join("\n");
|
|
11
|
+
// Truncate to fit CAPS.NOTES by dropping from the front — the newest migrated line is the one
|
|
12
|
+
// most likely to matter, and a mid-line elision is exactly the bug this rule exists to prevent.
|
|
13
|
+
const all = joined.split("\n");
|
|
14
|
+
while (all.length > 1 && Buffer.byteLength(all.join("\n"), "utf8") > CAPS.NOTES) all.shift();
|
|
15
|
+
state.notes = all.join("\n").slice(-CAPS.NOTES);
|
|
16
|
+
return state;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Sweep every key the v3 schema does not define into `notes`. Returns the swept state.
|
|
21
|
+
*/
|
|
22
|
+
function absorbUnknown(state) {
|
|
23
|
+
const lines = [];
|
|
24
|
+
for (const key of Object.keys(state)) {
|
|
25
|
+
if (KNOWN_FIELDS.includes(key)) continue;
|
|
26
|
+
lines.push(`migrated:${key}=${JSON.stringify(state[key])}`);
|
|
27
|
+
delete state[key];
|
|
28
|
+
}
|
|
29
|
+
return noteMigrated(state, lines);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** v2 → v3: `rev`, the two compaction counters and FileFact.hash are new; everything else carries
|
|
33
|
+
* over by name. Unknown v2 keys go to notes rather than being dropped. */
|
|
34
|
+
function up2to3(v2) {
|
|
35
|
+
const out = { ...emptyState(0) };
|
|
36
|
+
// A value of the wrong TYPE is not silently coerced to a default — that is field loss wearing a
|
|
37
|
+
// helpful face, and zero-field-loss means zero. It keeps the default and the original value goes
|
|
38
|
+
// to notes, exactly like an unknown key.
|
|
39
|
+
const salvaged = [];
|
|
40
|
+
const carry = (key, value, typeOk) => {
|
|
41
|
+
if (value === undefined) return false;
|
|
42
|
+
if (typeOk) return true;
|
|
43
|
+
salvaged.push(`migrated:${key}=${JSON.stringify(value)}`);
|
|
44
|
+
return false;
|
|
45
|
+
};
|
|
46
|
+
if (carry("card", v2.card, Number.isInteger(v2.card))) out.card = v2.card;
|
|
47
|
+
for (const key of ["task", "notes"]) {
|
|
48
|
+
if (carry(key, v2[key], typeof v2[key] === "string")) out[key] = v2[key];
|
|
49
|
+
}
|
|
50
|
+
for (const list of ["done", "in_flight", "next", "blockers"]) {
|
|
51
|
+
if (Array.isArray(v2[list])) {
|
|
52
|
+
out[list] = v2[list]
|
|
53
|
+
.filter(i => i && typeof i.id === "string" && typeof i.text === "string")
|
|
54
|
+
.map(i => (Array.isArray(i.paths) ? { id: i.id, text: i.text, paths: i.paths } : { id: i.id, text: i.text }));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (v2.files && typeof v2.files === "object") {
|
|
58
|
+
for (const [p, f] of Object.entries(v2.files)) {
|
|
59
|
+
if (!f || typeof f !== "object") continue;
|
|
60
|
+
out.files[p] = { touched: f.touched === true, verified: f.verified === true };
|
|
61
|
+
if (Number.isInteger(f.blast_radius)) out.files[p].blast_radius = f.blast_radius;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (v2.verify && typeof v2.verify === "object") out.verify = { ...v2.verify };
|
|
65
|
+
if (v2.ext && typeof v2.ext === "object") out.ext = { ...v2.ext };
|
|
66
|
+
if (v2.cursor && typeof v2.cursor === "object") {
|
|
67
|
+
for (const [k, isOk] of [["turn", Number.isInteger(v2.cursor.turn)], ["ts", Number.isInteger(v2.cursor.ts)], ["by", typeof v2.cursor.by === "string"]]) {
|
|
68
|
+
if (carry(`cursor.${k}`, v2.cursor[k], isOk)) out.cursor[k] = v2.cursor[k];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (carry("done_count", v2.done_count, Number.isInteger(v2.done_count))) out.done_count = v2.done_count;
|
|
72
|
+
if (carry("files_count", v2.files_count, Number.isInteger(v2.files_count))) out.files_count = v2.files_count;
|
|
73
|
+
|
|
74
|
+
// Carry the unknowns across BEFORE absorbing, so they are swept from the v3 object and land in
|
|
75
|
+
// notes rather than vanishing with the v2 shell.
|
|
76
|
+
const carried = { ...out };
|
|
77
|
+
for (const key of Object.keys(v2)) {
|
|
78
|
+
if (KNOWN_FIELDS.includes(key) || key === "schema_version") continue;
|
|
79
|
+
carried[key] = v2[key];
|
|
80
|
+
}
|
|
81
|
+
return noteMigrated(absorbUnknown(carried), salvaged);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Applied in sequence on read until schema_version matches CURRENT. */
|
|
85
|
+
export const MIGRATIONS = { 2: up2to3 };
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Migrate an object of any known version up to v3.
|
|
89
|
+
* An unmigratable object returns MIGRATE_FAILED; the STORE (P1) is what keeps the original file
|
|
90
|
+
* untouched under `.v<n>.json` rather than half-upgrading it — this function only reports.
|
|
91
|
+
* @returns {{ ok: true, state: object, migrated: boolean } | { ok: false, code: string, at: string, message: string }}
|
|
92
|
+
*/
|
|
93
|
+
export function migrate(obj) {
|
|
94
|
+
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
|
|
95
|
+
return { ok: false, code: ERR.MIGRATE_FAILED, at: "state", message: "state must be an object" };
|
|
96
|
+
}
|
|
97
|
+
let cur = structuredClone(obj);
|
|
98
|
+
let steps = 0;
|
|
99
|
+
const from = cur.schema_version;
|
|
100
|
+
if (!Number.isInteger(from) || from < 1 || from > CURRENT_VERSION) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false, code: ERR.MIGRATE_FAILED, at: "schema_version",
|
|
103
|
+
message: `no migration path from schema_version ${JSON.stringify(from)} to ${CURRENT_VERSION}`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
while (cur.schema_version !== CURRENT_VERSION) {
|
|
107
|
+
const step = MIGRATIONS[cur.schema_version];
|
|
108
|
+
if (!step) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false, code: ERR.MIGRATE_FAILED, at: `v${cur.schema_version}`,
|
|
111
|
+
message: `no migration registered for schema_version ${cur.schema_version}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
cur = step(cur);
|
|
115
|
+
cur.schema_version = CURRENT_VERSION;
|
|
116
|
+
if (++steps > CURRENT_VERSION) {
|
|
117
|
+
return { ok: false, code: ERR.MIGRATE_FAILED, at: "migrate", message: "migration did not converge" };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (steps === 0) cur = absorbUnknown(cur);
|
|
121
|
+
const why = stateError(cur);
|
|
122
|
+
if (why) {
|
|
123
|
+
return { ok: false, code: ERR.MIGRATE_FAILED, at: "state", message: `migrated object is not schema-valid: ${why}` };
|
|
124
|
+
}
|
|
125
|
+
return { ok: true, state: cur, migrated: steps > 0 };
|
|
126
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: this file IS the I/O boundary decoder. A WorkingState is JSON read off disk and a TurnResult is untrusted model output; stateError/itemError/isAction are the parse step that establishes the contract every other module then relies on, so the typeof checks here are the boundary, not a substitute for one. */
|
|
2
|
+
// Trantor State — the wire contract (TDD §3). One place for the shapes, the caps, the write
|
|
3
|
+
// matrix and the evidence marker; validate.mjs and apply.mjs hold behaviour, this file holds
|
|
4
|
+
// facts. Nothing here reads the disk or the clock.
|
|
5
|
+
//
|
|
6
|
+
// The whole package is dark at P0: nothing in the running product imports it yet.
|
|
7
|
+
|
|
8
|
+
/** @typedef {{ id: string, text: string, paths?: string[] }} Item // id <= CAPS.ID, text <= CAPS.ITEM */
|
|
9
|
+
/** @typedef {{ touched: boolean, verified: boolean, hash?: string, blast_radius?: number }} FileFact
|
|
10
|
+
* // every field harness-written; `hash` = the path's blob sha when the gate credited it (TDD §4.2) */
|
|
11
|
+
|
|
12
|
+
/** @typedef {{
|
|
13
|
+
* schema_version: 3,
|
|
14
|
+
* card: number,
|
|
15
|
+
* task: string,
|
|
16
|
+
* done: Item[], in_flight: Item[], next: Item[], blockers: Item[],
|
|
17
|
+
* done_count: number, files_count: number,
|
|
18
|
+
* files: Record<string, FileFact>,
|
|
19
|
+
* verify: { built?: boolean, tested?: boolean, observed?: boolean, cmd?: string, exit?: number },
|
|
20
|
+
* notes: string,
|
|
21
|
+
* ext: Record<string, unknown>,
|
|
22
|
+
* cursor: { turn: number, ts: number, by: string },
|
|
23
|
+
* rev: number
|
|
24
|
+
* }} WorkingState */
|
|
25
|
+
|
|
26
|
+
/** @typedef {{ tool: string, input: object } | { done: true } | { ask: string } | { continue: true }} Action */
|
|
27
|
+
/** @typedef {{ patch: Op[], action: Action }} TurnResult */
|
|
28
|
+
/** @typedef {{ set: { field: string, value: unknown } }
|
|
29
|
+
* | { add: { list: ListName, item: Item } }
|
|
30
|
+
* | { remove: { list: ListName, id: string } }
|
|
31
|
+
* | { move: { id: string, from: ListName, to: ListName } }} Op */
|
|
32
|
+
|
|
33
|
+
/** @typedef {{ kind: "done"|"blocker_added"|"blocker_cleared"|"verify", text: string }} Promotion */
|
|
34
|
+
|
|
35
|
+
export const CURRENT_VERSION = 3;
|
|
36
|
+
|
|
37
|
+
/** Caps — one table, one place (TDD §3.1). A driver that wants a cap imports this. */
|
|
38
|
+
export const CAPS = {
|
|
39
|
+
ID: 32, // chars per item id — ids render into the prompt and key every array op
|
|
40
|
+
ITEM: 240, // chars per item PROSE, after the evidence marker is extracted
|
|
41
|
+
ITEM_PATHS: 8, // evidence paths per item
|
|
42
|
+
PATH: 400, // chars per files key
|
|
43
|
+
NOTES: 2048, // bytes of the notes tail
|
|
44
|
+
LIST: 40, // items per list; `done` overflow compacts into done_count, the
|
|
45
|
+
// working lists reject instead (TDD §3.1)
|
|
46
|
+
FILES: 200, // paths; overflow compacts into files_count
|
|
47
|
+
EXT_KEYS: 8, EXT_BYTES: 4096,
|
|
48
|
+
TAIL_TOKENS: 2000, OBS_TOKENS: 4000,
|
|
49
|
+
TASK: 400,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** The four lists a patch may address. */
|
|
53
|
+
export const LISTS = ["done", "in_flight", "next", "blockers"];
|
|
54
|
+
|
|
55
|
+
/** Append-only history: overflow COMPACTS into a `_count`. Everything else in LISTS is a working
|
|
56
|
+
* list, where overflow is a CAP rejection — dropping the oldest blocker silently is the worst
|
|
57
|
+
* thing this object could do (TDD §3.1). */
|
|
58
|
+
export const COMPACTING_LISTS = ["done"];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The evidence marker, made structural (TDD §3). A model writes it inline —
|
|
62
|
+
* "wire the promoter @lib/state/promote.mjs,test/state/test-promote.mjs"
|
|
63
|
+
* — and the validator extracts it into `paths` BEFORE any cap is applied. This regex lives here
|
|
64
|
+
* and nowhere else; a second copy is how the two halves drift apart.
|
|
65
|
+
*/
|
|
66
|
+
export const EVIDENCE_MARKER = /\s+@((?:[\w./-]+)(?:,[\w./-]+)*)\s*$/;
|
|
67
|
+
|
|
68
|
+
/** Rejection codes the pure core returns (TDD §3.3). `STALE` is deliberately absent: it is the
|
|
69
|
+
* CAS outcome of the store's commit(), which no branch in here can produce. */
|
|
70
|
+
export const ERR = {
|
|
71
|
+
SCHEMA: "SCHEMA",
|
|
72
|
+
READONLY_FIELD: "READONLY_FIELD",
|
|
73
|
+
CAP: "CAP",
|
|
74
|
+
UNKNOWN_LIST: "UNKNOWN_LIST",
|
|
75
|
+
UNKNOWN_FIELD: "UNKNOWN_FIELD",
|
|
76
|
+
DUP_ID: "DUP_ID",
|
|
77
|
+
NO_SUCH_ID: "NO_SUCH_ID",
|
|
78
|
+
UNVERIFIED_DONE: "UNVERIFIED_DONE",
|
|
79
|
+
NEEDS_GATE: "NEEDS_GATE",
|
|
80
|
+
BAD_ACTION: "BAD_ACTION",
|
|
81
|
+
MIGRATE_FAILED: "MIGRATE_FAILED",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Malformed OUTPUT — the seat failed to speak the grammar. These count against the §7.3 invalid-
|
|
85
|
+
* patch budget and the circuit breaker. */
|
|
86
|
+
export const MALFORMED_CODES = [
|
|
87
|
+
ERR.SCHEMA, ERR.BAD_ACTION, ERR.READONLY_FIELD, ERR.CAP,
|
|
88
|
+
ERR.UNKNOWN_FIELD, ERR.UNKNOWN_LIST, ERR.DUP_ID, ERR.NO_SUCH_ID,
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
/** Well-formed patch, wrong CODE — the system working. Never counted against the budget, or the
|
|
92
|
+
* breaker trips on the healthiest seat there is: one writing perfect patches against a failing
|
|
93
|
+
* test (TDD §7.3). */
|
|
94
|
+
export const EVIDENCE_CODES = [ERR.UNVERIFIED_DONE, ERR.NEEDS_GATE];
|
|
95
|
+
|
|
96
|
+
/** Every field a `set` op may write. Anything else that IS a schema field is READONLY_FIELD;
|
|
97
|
+
* anything the schema does not define at all is UNKNOWN_FIELD (TDD §4.2 stage 3). */
|
|
98
|
+
export const SETTABLE_FIELDS = ["task", "notes", "ext"];
|
|
99
|
+
|
|
100
|
+
/** Harness/runtime fields, never model-writable (TDD §2 / §4.2 write matrix). `files` is listed
|
|
101
|
+
* whole because every FileFact field — touched, verified, hash, blast_radius — is harness-written:
|
|
102
|
+
* git is ground truth for the first, the gate for the rest. */
|
|
103
|
+
export const READONLY_FIELDS = [
|
|
104
|
+
"verify", "files", "card", "schema_version", "cursor", "done_count", "files_count", "rev",
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
/** Runtime-owned `ext` keys (TDD §4.7, §4.8). Model-unwritable like the rest of the matrix. */
|
|
108
|
+
export const RUNTIME_EXT_KEYS = ["_gate", "_promoted"];
|
|
109
|
+
|
|
110
|
+
/** Every field name the v3 schema defines. A `set` naming anything outside this is UNKNOWN_FIELD:
|
|
111
|
+
* an unknown field quietly accepted is the write matrix ceasing to be enforceable, because
|
|
112
|
+
* tomorrow's real field arrives as today's typo. */
|
|
113
|
+
export const KNOWN_FIELDS = [
|
|
114
|
+
"schema_version", "card", "task", ...LISTS, "done_count", "files_count",
|
|
115
|
+
"files", "verify", "notes", "ext", "cursor", "rev",
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
/** A blank, schema-valid v3 state. */
|
|
119
|
+
export function emptyState(card = 0, by = "") {
|
|
120
|
+
return {
|
|
121
|
+
schema_version: CURRENT_VERSION,
|
|
122
|
+
card,
|
|
123
|
+
task: "",
|
|
124
|
+
done: [], in_flight: [], next: [], blockers: [],
|
|
125
|
+
done_count: 0, files_count: 0,
|
|
126
|
+
files: {},
|
|
127
|
+
verify: {},
|
|
128
|
+
notes: "",
|
|
129
|
+
ext: {},
|
|
130
|
+
cursor: { turn: 0, ts: 0, by },
|
|
131
|
+
rev: 0,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
136
|
+
const isStr = (v) => typeof v === "string";
|
|
137
|
+
const isInt = (v) => typeof v === "number" && Number.isInteger(v);
|
|
138
|
+
|
|
139
|
+
/** Shape-check one Item. Returns an error string, or "" when it is well-formed. */
|
|
140
|
+
export function itemError(item) {
|
|
141
|
+
if (!isObj(item)) return "item must be an object";
|
|
142
|
+
if (!isStr(item.id) || !item.id) return "item.id must be a non-empty string";
|
|
143
|
+
if (!isStr(item.text)) return "item.text must be a string";
|
|
144
|
+
if (item.paths !== undefined) {
|
|
145
|
+
if (!Array.isArray(item.paths) || !item.paths.every(isStr)) return "item.paths must be string[]";
|
|
146
|
+
}
|
|
147
|
+
for (const k of Object.keys(item)) {
|
|
148
|
+
if (!["id", "text", "paths"].includes(k)) return `item has unknown key "${k}"`;
|
|
149
|
+
}
|
|
150
|
+
return "";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Shape-check a whole WorkingState. This is what "re-validates against the schema" means in the
|
|
155
|
+
* Phase-0 property test, so it is deliberately total: it never throws, it returns the first
|
|
156
|
+
* problem it finds as a string, and "" means valid.
|
|
157
|
+
*/
|
|
158
|
+
export function stateError(s) {
|
|
159
|
+
if (!isObj(s)) return "state must be an object";
|
|
160
|
+
if (s.schema_version !== CURRENT_VERSION) return `schema_version must be ${CURRENT_VERSION}`;
|
|
161
|
+
if (!isInt(s.card)) return "card must be an integer";
|
|
162
|
+
if (!isStr(s.task)) return "task must be a string";
|
|
163
|
+
if (s.task.length > CAPS.TASK) return "task exceeds CAPS.TASK";
|
|
164
|
+
for (const list of LISTS) {
|
|
165
|
+
if (!Array.isArray(s[list])) return `${list} must be an array`;
|
|
166
|
+
if (s[list].length > CAPS.LIST) return `${list} exceeds CAPS.LIST`;
|
|
167
|
+
for (const item of s[list]) {
|
|
168
|
+
const e = itemError(item);
|
|
169
|
+
if (e) return `${list}: ${e}`;
|
|
170
|
+
if (item.id.length > CAPS.ID) return `${list}: item.id exceeds CAPS.ID`;
|
|
171
|
+
if (item.text.length > CAPS.ITEM) return `${list}: item.text exceeds CAPS.ITEM`;
|
|
172
|
+
if (item.paths && item.paths.length > CAPS.ITEM_PATHS) return `${list}: item.paths exceeds CAPS.ITEM_PATHS`;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (!isInt(s.done_count) || s.done_count < 0) return "done_count must be a non-negative integer";
|
|
176
|
+
if (!isInt(s.files_count) || s.files_count < 0) return "files_count must be a non-negative integer";
|
|
177
|
+
if (!isObj(s.files)) return "files must be an object";
|
|
178
|
+
if (Object.keys(s.files).length > CAPS.FILES) return "files exceeds CAPS.FILES";
|
|
179
|
+
for (const [p, f] of Object.entries(s.files)) {
|
|
180
|
+
if (p.length > CAPS.PATH) return `files["${p}"] key exceeds CAPS.PATH`;
|
|
181
|
+
if (!isObj(f)) return `files["${p}"] must be an object`;
|
|
182
|
+
if (typeof f.touched !== "boolean") return `files["${p}"].touched must be a boolean`;
|
|
183
|
+
if (typeof f.verified !== "boolean") return `files["${p}"].verified must be a boolean`;
|
|
184
|
+
if (f.hash !== undefined && !isStr(f.hash)) return `files["${p}"].hash must be a string`;
|
|
185
|
+
if (f.blast_radius !== undefined && !isInt(f.blast_radius)) return `files["${p}"].blast_radius must be an integer`;
|
|
186
|
+
}
|
|
187
|
+
if (!isObj(s.verify)) return "verify must be an object";
|
|
188
|
+
if (!isStr(s.notes)) return "notes must be a string";
|
|
189
|
+
if (Buffer.byteLength(s.notes, "utf8") > CAPS.NOTES) return "notes exceeds CAPS.NOTES";
|
|
190
|
+
if (!isObj(s.ext)) return "ext must be an object";
|
|
191
|
+
if (Object.keys(s.ext).filter(k => !RUNTIME_EXT_KEYS.includes(k)).length > CAPS.EXT_KEYS) return "ext exceeds CAPS.EXT_KEYS";
|
|
192
|
+
if (!isObj(s.cursor)) return "cursor must be an object";
|
|
193
|
+
if (!isInt(s.cursor.turn)) return "cursor.turn must be an integer";
|
|
194
|
+
if (!isInt(s.cursor.ts)) return "cursor.ts must be an integer";
|
|
195
|
+
if (!isStr(s.cursor.by)) return "cursor.by must be a string";
|
|
196
|
+
if (!isInt(s.rev) || s.rev < 0) return "rev must be a non-negative integer";
|
|
197
|
+
for (const k of Object.keys(s)) {
|
|
198
|
+
if (!KNOWN_FIELDS.includes(k)) return `state has unknown key "${k}"`;
|
|
199
|
+
}
|
|
200
|
+
return "";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The one Action shape check. Missing or unrecognised → BAD_ACTION at the caller (TDD §4.2). */
|
|
204
|
+
export function isAction(a) {
|
|
205
|
+
if (!isObj(a)) return false;
|
|
206
|
+
if (a.done === true) return Object.keys(a).length === 1;
|
|
207
|
+
if (a.continue === true) return Object.keys(a).length === 1;
|
|
208
|
+
if (isStr(a.ask)) return Object.keys(a).length === 1;
|
|
209
|
+
if (isStr(a.tool)) return isObj(a.input) && Object.keys(a).length === 2;
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Structural clone, so an aborted patch can never leave a half-applied state behind. */
|
|
214
|
+
export function cloneState(s) {
|
|
215
|
+
return structuredClone(s);
|
|
216
|
+
}
|