trantor 0.18.49 → 0.18.51
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 +98 -12
- package/bin/doctor.mjs +61 -1
- 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/bin/duty.mjs +68 -1
- package/bin/write-handoff.mjs +7 -1
- package/deploy/setup.sh +38 -0
- package/hooks/lib/handoff.mjs +158 -2
- package/hooks/sessionstart.mjs +10 -0
- package/lib/duty-nudges.mjs +48 -10
- package/lib/state/apply.mjs +170 -0
- package/lib/state/derive.mjs +229 -0
- package/lib/state/gate.mjs +355 -0
- package/lib/state/migrate.mjs +126 -0
- package/lib/state/promote.mjs +122 -0
- package/lib/state/schema.mjs +216 -0
- package/lib/state/store.mjs +442 -0
- package/lib/state/validate.mjs +231 -0
- package/package.json +1 -1
|
@@ -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,122 @@
|
|
|
1
|
+
/* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: promote is the seam between the pure plan
|
|
2
|
+
* and a live hub. State, promotions and the hub response all arrive from outside this module, so
|
|
3
|
+
* the few typeof/Number.isInteger guards below ARE the boundary decode — the same justification the
|
|
4
|
+
* other lib/state modules carry — not a substitute for one. */
|
|
5
|
+
// Trantor State P3 — the promoter (TDD §4.7). Turns the delta a turn produced (applyTurn's
|
|
6
|
+
// `promoted` plan) into AT MOST ONE /task/update `note`, deduped by content hash, and advances the
|
|
7
|
+
// runtime-owned ext._promoted ONLY after the hub answers 2xx — never when the plan is computed.
|
|
8
|
+
//
|
|
9
|
+
// The line this file exists to hold: the card is the shared record, state is private working
|
|
10
|
+
// memory, and promotion is one-directional (state → card note). It never moves a card's STATUS —
|
|
11
|
+
// that is the seat's relay_task_move, one owner — and it never promotes scratch (in_flight, next,
|
|
12
|
+
// the notes tail, cursor, files, done_count, ext): those churn every turn and flooding the log is
|
|
13
|
+
// #6669's lesson. Empty deltas are skipped entirely. A failed POST is non-fatal and never rolls
|
|
14
|
+
// back state; it leaves the hash where it was, so the next turn re-sends the same content, which
|
|
15
|
+
// the content hash makes harmless if the POST in fact landed and only the response was lost.
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { signedPost } from "../../hooks/lib/api.mjs";
|
|
18
|
+
|
|
19
|
+
/** CARDLOG-CONTRACT: a card-log note text is ≤ 2000 chars. The hub caps it; we never exceed it. */
|
|
20
|
+
export const PROMOTE_NOTE_CAP = 2000;
|
|
21
|
+
|
|
22
|
+
/** Human label per Promotion kind — the kind names from the pure plan, unchanged. */
|
|
23
|
+
export const PROMOTE_LABELS = {
|
|
24
|
+
done: "done",
|
|
25
|
+
blocker_added: "blocked",
|
|
26
|
+
blocker_cleared: "unblocked",
|
|
27
|
+
verify: "verify",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** One note line per promotion, `<label>: <text>`. Malformed rows are skipped, never rendered. */
|
|
31
|
+
export function noteLines(promotions) {
|
|
32
|
+
if (!Array.isArray(promotions)) return [];
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const p of promotions) {
|
|
35
|
+
if (!p || typeof p.kind !== "string" || typeof p.text !== "string") continue;
|
|
36
|
+
out.push(`${PROMOTE_LABELS[p.kind] || p.kind}: ${p.text}`);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Compose the plan into one note ≤ PROMOTE_NOTE_CAP. Deterministic — the same delta always
|
|
42
|
+
* renders the same note, which is what makes content-hash dedupe meaningful.
|
|
43
|
+
* @returns {string} "" when the delta is empty, else the note */
|
|
44
|
+
export function composeNote(promotions, cap = PROMOTE_NOTE_CAP) {
|
|
45
|
+
const lines = noteLines(promotions);
|
|
46
|
+
if (!lines.length) return "";
|
|
47
|
+
const note = lines.join("\n");
|
|
48
|
+
if (note.length <= cap) return note;
|
|
49
|
+
// Over the cap: keep the head (done lines lead — they are the record of what shipped), drop the
|
|
50
|
+
// tail, and say so. A hard mid-line slice is #6528; whole-line elision is not.
|
|
51
|
+
const kept = [];
|
|
52
|
+
let len = 0;
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
const add = line.length + (kept.length ? 1 : 0);
|
|
55
|
+
if (len + add <= cap) { kept.push(line); len += add; continue; }
|
|
56
|
+
if (!kept.length) return `${line.slice(0, Math.max(0, cap - 1))}…`;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
let out = kept.join("\n");
|
|
60
|
+
const dropped = lines.length - kept.length;
|
|
61
|
+
if (dropped > 0) {
|
|
62
|
+
const marker = `\n… (+${dropped} more)`;
|
|
63
|
+
out = out.length + marker.length <= cap ? out + marker : out.slice(0, cap - marker.length) + marker;
|
|
64
|
+
}
|
|
65
|
+
return out.length <= cap ? out : out.slice(0, cap);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** sha256 hex of the note — the content identity. A re-run of the same delta yields the same hash. */
|
|
69
|
+
export function noteHash(note) {
|
|
70
|
+
return createHash("sha256").update(String(note ?? ""), "utf8").digest("hex");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Promote one turn's delta to the card. Pure over its inputs except for the one POST; `post` is
|
|
75
|
+
* injectable so the ordering contract is testable without a hub (defaults to the house client).
|
|
76
|
+
*
|
|
77
|
+
* @param {object} state WorkingState — reads state.card and state.ext._promoted only
|
|
78
|
+
* @param {object[]} promotions the plan applyTurn returned (its `promoted` field)
|
|
79
|
+
* @param {{ by?: string, project?: string, post?: Function, timeoutMs?: number }} [opts]
|
|
80
|
+
* by = session id (defaults to state.cursor.by); post(payload, opts) -> {ok,status,json}
|
|
81
|
+
* @returns {Promise<{ok:boolean, sent:boolean, skipped?:string, note:string, hash:string,
|
|
82
|
+
* state:object, status?:number, error?:string}>} — `state` is the input untouched on failure and
|
|
83
|
+
* a clone with ext._promoted = hash on 2xx; the caller persists it at its leisure.
|
|
84
|
+
*/
|
|
85
|
+
export async function promote(state, promotions, { by, project, post, timeoutMs } = {}) {
|
|
86
|
+
if (!state || !Number.isInteger(state?.card)) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false, sent: false, note: "", hash: "",
|
|
89
|
+
state: state ?? null, error: "promote needs a state whose card is an integer",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const note = composeNote(promotions);
|
|
93
|
+
if (!note) return { ok: true, sent: false, skipped: "empty", note, hash: "", state };
|
|
94
|
+
const hash = noteHash(note);
|
|
95
|
+
const last = state.ext && typeof state.ext === "object" && state.ext._promoted !== undefined
|
|
96
|
+
? state.ext._promoted : undefined;
|
|
97
|
+
if (typeof last === "string" && last === hash) {
|
|
98
|
+
return { ok: true, sent: false, skipped: "duplicate", note, hash, state };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const session = String(by || (state.cursor && typeof state.cursor === "object" && state.cursor.by) || "").slice(0, 120);
|
|
102
|
+
const payload = { id: state.card, by: session, note };
|
|
103
|
+
if (project) payload.project = project;
|
|
104
|
+
|
|
105
|
+
const send = post
|
|
106
|
+
|| ((pl, o) => signedPost("/task/update", pl, { project: o?.project, session: o?.session, timeoutMs: o?.timeoutMs }));
|
|
107
|
+
let res;
|
|
108
|
+
try { res = await send(payload, { project, session, timeoutMs }); }
|
|
109
|
+
catch { res = null; }
|
|
110
|
+
|
|
111
|
+
const status = res && Number.isInteger(res.status) ? res.status : 0;
|
|
112
|
+
const confirmed = !!res && res.ok === true && status >= 200 && status < 300;
|
|
113
|
+
if (!confirmed) {
|
|
114
|
+
const error = res && res.json && typeof res.json === "object" && typeof res.json.error === "string"
|
|
115
|
+
? res.json.error : (res ? `hub answered ${status}` : "post threw");
|
|
116
|
+
return { ok: false, sent: false, note, hash, status, error, state };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const ext = state.ext && typeof state.ext === "object" ? { ...state.ext } : {};
|
|
120
|
+
const advanced = { ...state, ext: { ...ext, _promoted: hash } };
|
|
121
|
+
return { ok: true, sent: true, skipped: undefined, note, hash, status, state: advanced };
|
|
122
|
+
}
|
|
@@ -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
|
+
}
|