u-foo 3.0.0 → 3.0.2

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.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/src/agents/prompts/native/tasks.js +4 -1
  3. package/src/app/chat/commandExecutor.js +111 -1
  4. package/src/app/chat/commands.js +2 -1
  5. package/src/app/chat/daemonMessageRouter.js +1 -1
  6. package/src/app/chat/inputSubmitHandler.js +3 -2
  7. package/src/code/agent.js +17 -3
  8. package/src/code/commands.js +3 -3
  9. package/src/code/context/executionSegment.js +5 -0
  10. package/src/code/context/planMode.js +8 -1
  11. package/src/code/context/promptLayers.js +10 -9
  12. package/src/code/dispatch.js +4 -0
  13. package/src/code/index.js +2 -0
  14. package/src/code/modelCommand.js +199 -23
  15. package/src/code/nativeRunner.js +299 -225
  16. package/src/code/protocol/controlPlane.js +93 -0
  17. package/src/code/protocol/faultHarness.js +90 -0
  18. package/src/code/protocol/index.js +20 -0
  19. package/src/code/protocol/loopEvents.js +102 -0
  20. package/src/code/protocol/materialize.js +107 -0
  21. package/src/code/protocol/messageFixtures.js +116 -0
  22. package/src/code/protocol/ownership.js +147 -0
  23. package/src/code/protocol/protocolValidator.js +165 -0
  24. package/src/code/protocol/suspension.js +173 -0
  25. package/src/code/protocol/toolCallLedger.js +222 -0
  26. package/src/code/protocol/transitions.js +97 -0
  27. package/src/code/providers/anthropicMessagesTransport.js +93 -0
  28. package/src/code/providers/index.js +8 -0
  29. package/src/code/providers/modelsCatalog.js +304 -0
  30. package/src/code/providers/openaiChatTransport.js +98 -0
  31. package/src/code/providers/transportContract.js +46 -0
  32. package/src/code/repl.js +45 -29
  33. package/src/code/runtime/taskControl.js +177 -53
  34. package/src/code/runtime/taskFocus.js +30 -10
  35. package/src/code/runtime/taskLoop.js +25 -3
  36. package/src/code/runtime/taskRun.js +172 -2
  37. package/src/code/runtime/workspaceLease.js +41 -0
  38. package/src/code/sessionStore.js +1 -0
  39. package/src/code/taskRoute.js +73 -0
  40. package/src/code/thinkingLevels.js +132 -0
  41. package/src/code/tools/taskRun.js +118 -0
  42. package/src/config.js +10 -1
  43. package/src/ui/format/index.js +48 -3
  44. package/src/ui/ink/ChatApp.js +137 -25
  45. package/src/ui/ink/UcodeApp.js +38 -30
  46. package/src/ui/ink/chatLogModel.js +238 -32
  47. package/src/ui/ink/chatReducer.js +18 -6
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Orthogonal control-plane fields (R5).
5
+ *
6
+ * planningPolicy: whether direct side-effect tools are allowed without a graph.
7
+ * executionOwner: who currently owns workspace / loop advancement.
8
+ *
9
+ * Dual-write with legacy planMode boolean during migration.
10
+ */
11
+
12
+ const PLANNING_POLICIES = Object.freeze(["direct_allowed", "graph_required"]);
13
+
14
+ function emptyExecutionOwner() {
15
+ return { kind: "none", id: "" };
16
+ }
17
+
18
+ function normalizePlanningPolicy(value = "") {
19
+ const raw = String(value || "").trim().toLowerCase();
20
+ if (PLANNING_POLICIES.includes(raw)) return raw;
21
+ return "";
22
+ }
23
+
24
+ function normalizeExecutionOwner(owner = null) {
25
+ if (!owner || typeof owner !== "object") return emptyExecutionOwner();
26
+ const kind = String(owner.kind || "none").trim().toLowerCase();
27
+ if (kind === "agent_loop" || kind === "task_run") {
28
+ return {
29
+ kind,
30
+ id: String(owner.id || owner.taskRunId || owner.agentLoopId || "").trim(),
31
+ };
32
+ }
33
+ return emptyExecutionOwner();
34
+ }
35
+
36
+ /**
37
+ * Sync planningPolicy ↔ planMode. Prefer explicit planningPolicy when present.
38
+ */
39
+ function syncPlanningFields(executionState = null) {
40
+ const state = executionState && typeof executionState === "object" ? executionState : {};
41
+ const explicit = normalizePlanningPolicy(state.planningPolicy);
42
+ if (explicit) {
43
+ state.planningPolicy = explicit;
44
+ state.planMode = explicit === "graph_required";
45
+ } else if (state.planMode === true) {
46
+ state.planningPolicy = "graph_required";
47
+ } else {
48
+ state.planningPolicy = "direct_allowed";
49
+ state.planMode = false;
50
+ }
51
+ state.executionOwner = normalizeExecutionOwner(state.executionOwner);
52
+ return state;
53
+ }
54
+
55
+ function setPlanningPolicy(executionState = null, policy = "direct_allowed", {
56
+ reason = "",
57
+ source = "",
58
+ } = {}) {
59
+ const state = syncPlanningFields(executionState);
60
+ const next = normalizePlanningPolicy(policy) || "direct_allowed";
61
+ state.planningPolicy = next;
62
+ // Dual-write legacy bool
63
+ const { setPlanMode } = require("../context/planMode");
64
+ setPlanMode(state, next === "graph_required", { reason, source });
65
+ state.planningPolicy = next;
66
+ return state;
67
+ }
68
+
69
+ function getPlanningPolicy(executionState = null) {
70
+ return syncPlanningFields(executionState).planningPolicy;
71
+ }
72
+
73
+ function setExecutionOwner(executionState = null, owner = null) {
74
+ const state = syncPlanningFields(executionState);
75
+ state.executionOwner = normalizeExecutionOwner(owner);
76
+ return state.executionOwner;
77
+ }
78
+
79
+ function getExecutionOwner(executionState = null) {
80
+ return syncPlanningFields(executionState).executionOwner;
81
+ }
82
+
83
+ module.exports = {
84
+ PLANNING_POLICIES,
85
+ emptyExecutionOwner,
86
+ normalizePlanningPolicy,
87
+ normalizeExecutionOwner,
88
+ syncPlanningFields,
89
+ setPlanningPolicy,
90
+ getPlanningPolicy,
91
+ setExecutionOwner,
92
+ getExecutionOwner,
93
+ };
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Minimal fault-injection harness for crash/restart tests (Phase 0 skeleton).
5
+ *
6
+ * Usage:
7
+ * armFault("after_prepare_tool_calls");
8
+ * await withFaultPoint("after_prepare_tool_calls", async () => { ... });
9
+ *
10
+ * Armed points throw FaultInjectedError once (or until disarmed).
11
+ */
12
+
13
+ const armed = new Map();
14
+
15
+ class FaultInjectedError extends Error {
16
+ constructor(point = "") {
17
+ super(`fault injected at ${point}`);
18
+ this.name = "FaultInjectedError";
19
+ this.code = "FAULT_INJECTED";
20
+ this.point = String(point || "");
21
+ }
22
+ }
23
+
24
+ /** Known hook names reserved for native loop / resume (R4 expands coverage). */
25
+ const FAULT_POINTS = Object.freeze([
26
+ "after_prepare_tool_calls",
27
+ "before_tool_exec",
28
+ "after_tool_effect",
29
+ "before_result_commit",
30
+ "after_answer_commit",
31
+ "before_provider_resume",
32
+ "after_taskrun_acquire_lease",
33
+ "before_parent_node_sync",
34
+ ]);
35
+
36
+ function armFault(point = "", { times = 1, error = null } = {}) {
37
+ const name = String(point || "").trim();
38
+ if (!name) throw new Error("fault point name required");
39
+ armed.set(name, {
40
+ remaining: Math.max(1, Math.floor(Number(times) || 1)),
41
+ error: error || null,
42
+ });
43
+ return name;
44
+ }
45
+
46
+ function disarmFault(point = "") {
47
+ const name = String(point || "").trim();
48
+ if (!name) {
49
+ armed.clear();
50
+ return;
51
+ }
52
+ armed.delete(name);
53
+ }
54
+
55
+ function isFaultArmed(point = "") {
56
+ return armed.has(String(point || "").trim());
57
+ }
58
+
59
+ function checkFaultPoint(point = "") {
60
+ const name = String(point || "").trim();
61
+ const entry = armed.get(name);
62
+ if (!entry) return;
63
+ entry.remaining -= 1;
64
+ if (entry.remaining <= 0) armed.delete(name);
65
+ if (entry.error) throw entry.error;
66
+ throw new FaultInjectedError(name);
67
+ }
68
+
69
+ /**
70
+ * Run fn; if point is armed, throw before invoking fn.
71
+ */
72
+ async function withFaultPoint(point = "", fn) {
73
+ checkFaultPoint(point);
74
+ return typeof fn === "function" ? fn() : undefined;
75
+ }
76
+
77
+ function listArmedFaults() {
78
+ return Array.from(armed.keys());
79
+ }
80
+
81
+ module.exports = {
82
+ FAULT_POINTS,
83
+ FaultInjectedError,
84
+ armFault,
85
+ disarmFault,
86
+ isFaultArmed,
87
+ checkFaultPoint,
88
+ withFaultPoint,
89
+ listArmedFaults,
90
+ };
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Protocol layer — Tool Call Ledger, validator, ownership, transitions, fault harness.
5
+ *
6
+ * Phase 0 / R1 shadow: observe and validate; do not yet own Provider wire messages.
7
+ */
8
+
9
+ module.exports = {
10
+ ...require("./toolCallLedger"),
11
+ ...require("./protocolValidator"),
12
+ ...require("./ownership"),
13
+ ...require("./transitions"),
14
+ ...require("./faultHarness"),
15
+ ...require("./messageFixtures"),
16
+ ...require("./materialize"),
17
+ ...require("./suspension"),
18
+ ...require("./controlPlane"),
19
+ ...require("./loopEvents"),
20
+ };
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Ordered Agent Loop events for UI clients (R8).
5
+ * Distinct from runtime/runtimeEvents.js (TaskRun wakeup mail).
6
+ */
7
+
8
+ const LOOP_EVENT_TYPES = Object.freeze([
9
+ "thinking_delta",
10
+ "assistant_delta",
11
+ "tool_start",
12
+ "tool_result",
13
+ "artifact_persisted",
14
+ "interaction_requested",
15
+ "interaction_rejected",
16
+ "interaction_resuming",
17
+ "interaction_resolved",
18
+ "interaction_failed",
19
+ "plan_transition",
20
+ "task_run_transition",
21
+ "final_assistant_message",
22
+ "final_summary",
23
+ "error",
24
+ ]);
25
+
26
+ function createLoopEvent(type = "", payload = {}, {
27
+ sessionId = "",
28
+ runId = "",
29
+ sequence = 0,
30
+ eventId = "",
31
+ } = {}) {
32
+ const eventType = String(type || "").trim();
33
+ if (!LOOP_EVENT_TYPES.includes(eventType)) {
34
+ throw new Error(`unknown loop event type: ${type}`);
35
+ }
36
+ return {
37
+ eventId: String(eventId || `le_${Date.now().toString(36)}_${sequence}`),
38
+ sequence: Number(sequence) || 0,
39
+ sessionId: String(sessionId || ""),
40
+ runId: String(runId || ""),
41
+ type: eventType,
42
+ payload: payload && typeof payload === "object" ? payload : {},
43
+ timestamp: new Date().toISOString(),
44
+ };
45
+ }
46
+
47
+ /**
48
+ * UI display policy: whether to echo a final summary after streaming.
49
+ * Prefer final_summary / final_assistant_message events when present.
50
+ */
51
+ function resolveSummaryDisplayPolicy({
52
+ streamed = false,
53
+ sawVisibleText = false,
54
+ finalEventType = "",
55
+ } = {}) {
56
+ const finalType = String(finalEventType || "").trim();
57
+ if (finalType === "final_assistant_message" || finalType === "final_summary") {
58
+ return { echoSummary: true, reason: "explicit_final_event" };
59
+ }
60
+ if (streamed && sawVisibleText) {
61
+ return { echoSummary: false, reason: "already_streamed" };
62
+ }
63
+ return { echoSummary: true, reason: "fallback_summary" };
64
+ }
65
+
66
+ function createLoopEventLog({ sessionId = "", runId = "" } = {}) {
67
+ const events = [];
68
+ let sequence = 0;
69
+ const seen = new Set();
70
+
71
+ function push(type, payload = {}) {
72
+ sequence += 1;
73
+ const event = createLoopEvent(type, payload, {
74
+ sessionId,
75
+ runId,
76
+ sequence,
77
+ });
78
+ if (seen.has(event.eventId)) return null;
79
+ seen.add(event.eventId);
80
+ events.push(event);
81
+ return event;
82
+ }
83
+
84
+ function replayFrom(sequenceCheckpoint = 0) {
85
+ const min = Number(sequenceCheckpoint) || 0;
86
+ return events.filter((e) => e.sequence > min);
87
+ }
88
+
89
+ return {
90
+ push,
91
+ replayFrom,
92
+ list: () => events.slice(),
93
+ get sequence() { return sequence; },
94
+ };
95
+ }
96
+
97
+ module.exports = {
98
+ LOOP_EVENT_TYPES,
99
+ createLoopEvent,
100
+ createLoopEventLog,
101
+ resolveSummaryDisplayPolicy,
102
+ };
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Materialize Provider tool-result messages from a resolved ledger turn.
5
+ * Business branches must resolve on the ledger; they must not hand-write
6
+ * unpaired tool results.
7
+ */
8
+
9
+ const { listCalls } = require("./toolCallLedger");
10
+
11
+ /**
12
+ * Clip helper kept local so protocol does not depend on nativeRunner.
13
+ */
14
+ function clipText(value = "", maxChars = 12000) {
15
+ const text = String(value == null ? "" : value);
16
+ if (text.length <= maxChars) return text;
17
+ return `${text.slice(0, maxChars)}\n...[truncated ${text.length - maxChars} chars]`;
18
+ }
19
+
20
+ function toJsonString(value) {
21
+ try {
22
+ return JSON.stringify(value);
23
+ } catch {
24
+ return String(value);
25
+ }
26
+ }
27
+
28
+ /**
29
+ * @param {object} ledger
30
+ * @param {{
31
+ * transport: object,
32
+ * messages: object[],
33
+ * pendingById: Record<string, object>,
34
+ * }} opts
35
+ * @returns {{ appended: number, flushed: boolean }}
36
+ */
37
+ function materializeResolvedToolResults(ledger, {
38
+ transport = null,
39
+ messages = [],
40
+ pendingById = {},
41
+ } = {}) {
42
+ if (!ledger || !transport || typeof transport.appendToolResult !== "function") {
43
+ return { appended: 0, flushed: false };
44
+ }
45
+
46
+ const collected = [];
47
+ let appended = 0;
48
+ for (const call of listCalls(ledger)) {
49
+ if (call.state !== "resolved") continue;
50
+ const pending = pendingById[call.callId];
51
+ if (!pending) continue;
52
+ transport.appendToolResult({
53
+ messages,
54
+ collected,
55
+ call: pending,
56
+ toolResult: call.resultPayload,
57
+ });
58
+ appended += 1;
59
+ }
60
+
61
+ let flushed = false;
62
+ if (
63
+ collected.length > 0
64
+ && typeof transport.flushToolResults === "function"
65
+ ) {
66
+ transport.flushToolResults({ messages, collected });
67
+ flushed = true;
68
+ }
69
+ return { appended, flushed };
70
+ }
71
+
72
+ /**
73
+ * Append a single resume answer as a tool result (idempotent via ledger resolve).
74
+ */
75
+ function materializeAnswerToolResult(messages = [], resume = null, answer = {}, {
76
+ clip = clipText,
77
+ } = {}) {
78
+ const call = resume && resume.call ? resume.call : null;
79
+ if (!call || !call.source) return { ok: false, error: "missing deferred tool call" };
80
+ const transportName = String(resume.transport || "openai-chat");
81
+ const content = clip(toJsonString(answer), 12000);
82
+ if (transportName === "anthropic-messages") {
83
+ messages.push({
84
+ role: "user",
85
+ content: [{
86
+ type: "tool_result",
87
+ tool_use_id: String(call.source.id || resume.toolCallId || ""),
88
+ content,
89
+ is_error: false,
90
+ }],
91
+ });
92
+ } else {
93
+ messages.push({
94
+ role: "tool",
95
+ tool_call_id: String(call.source.id || resume.toolCallId || ""),
96
+ content,
97
+ });
98
+ }
99
+ return { ok: true };
100
+ }
101
+
102
+ module.exports = {
103
+ materializeResolvedToolResults,
104
+ materializeAnswerToolResult,
105
+ clipText,
106
+ toJsonString,
107
+ };
@@ -0,0 +1,116 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Golden / expected Provider message sequences derived from ledger call records.
5
+ * Used for fixture tests only — does not replace TRANSPORTS materialization (yet).
6
+ */
7
+
8
+ function materializeOpenAiMessages({
9
+ assistantText = null,
10
+ calls = [],
11
+ results = [],
12
+ } = {}) {
13
+ const messages = [];
14
+ const toolCalls = calls.map((call) => ({
15
+ id: call.callId,
16
+ type: "function",
17
+ function: {
18
+ name: call.name,
19
+ arguments: typeof call.argsJson === "string"
20
+ ? call.argsJson
21
+ : JSON.stringify(call.args == null ? {} : call.args),
22
+ },
23
+ }));
24
+
25
+ if (toolCalls.length > 0) {
26
+ messages.push({
27
+ role: "assistant",
28
+ content: assistantText,
29
+ tool_calls: toolCalls,
30
+ });
31
+ } else if (assistantText != null) {
32
+ messages.push({ role: "assistant", content: assistantText });
33
+ }
34
+
35
+ for (const result of results) {
36
+ messages.push({
37
+ role: "tool",
38
+ tool_call_id: result.callId,
39
+ content: typeof result.content === "string"
40
+ ? result.content
41
+ : JSON.stringify(result.content == null ? {} : result.content),
42
+ });
43
+ }
44
+ return messages;
45
+ }
46
+
47
+ function materializeAnthropicMessages({
48
+ assistantBlocks = null,
49
+ calls = [],
50
+ results = [],
51
+ assistantText = "",
52
+ } = {}) {
53
+ const messages = [];
54
+ let content = Array.isArray(assistantBlocks) ? assistantBlocks.slice() : null;
55
+ if (!content) {
56
+ content = [];
57
+ if (assistantText) {
58
+ content.push({ type: "text", text: String(assistantText) });
59
+ }
60
+ for (const call of calls) {
61
+ content.push({
62
+ type: "tool_use",
63
+ id: call.callId,
64
+ name: call.name,
65
+ input: call.args == null ? {} : call.args,
66
+ });
67
+ }
68
+ }
69
+ if (content.length > 0) {
70
+ messages.push({ role: "assistant", content });
71
+ }
72
+
73
+ if (results.length > 0) {
74
+ messages.push({
75
+ role: "user",
76
+ content: results.map((result) => ({
77
+ type: "tool_result",
78
+ tool_use_id: result.callId,
79
+ content: typeof result.content === "string"
80
+ ? result.content
81
+ : JSON.stringify(result.content == null ? {} : result.content),
82
+ is_error: Boolean(result.isError),
83
+ })),
84
+ });
85
+ }
86
+ return messages;
87
+ }
88
+
89
+ /**
90
+ * Build expected messages from a simplified fixture definition.
91
+ * @param {{ provider: string, calls: object[], results?: object[], assistantText?: string }} def
92
+ */
93
+ function materializeFromFixtureDef(def = {}) {
94
+ const provider = String(def.provider || "openai").toLowerCase();
95
+ const calls = Array.isArray(def.calls) ? def.calls : [];
96
+ const results = Array.isArray(def.results) ? def.results : [];
97
+ if (provider === "anthropic" || provider === "anthropic-messages") {
98
+ return materializeAnthropicMessages({
99
+ calls,
100
+ results,
101
+ assistantText: def.assistantText || "",
102
+ assistantBlocks: def.assistantBlocks || null,
103
+ });
104
+ }
105
+ return materializeOpenAiMessages({
106
+ calls,
107
+ results,
108
+ assistantText: def.assistantText != null ? def.assistantText : null,
109
+ });
110
+ }
111
+
112
+ module.exports = {
113
+ materializeOpenAiMessages,
114
+ materializeAnthropicMessages,
115
+ materializeFromFixtureDef,
116
+ };
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Session state ownership table (Phase 0).
5
+ *
6
+ * Durable fields are recovery sources. Projections may be rebuilt from durable
7
+ * state (or from protocolLedger + provider message checkpoint until full
8
+ * event-sourced rebuild exists).
9
+ *
10
+ * See docs/ucode-agent-runtime-remediation-plan.md §4.2 / R4.
11
+ */
12
+
13
+ /** @typedef {"session"|"session/artifacts"|"checkpoint"|"runtime_events"|"execution/session"} Authority */
14
+
15
+ /**
16
+ * @type {ReadonlyArray<{
17
+ * key: string,
18
+ * authority: Authority,
19
+ * durable: boolean,
20
+ * rebuildable: boolean,
21
+ * notes?: string
22
+ * }>}
23
+ */
24
+ const STATE_OWNERSHIP = Object.freeze([
25
+ {
26
+ key: "protocolLedger",
27
+ authority: "session",
28
+ durable: true,
29
+ rebuildable: false,
30
+ notes: "Not yet persisted; planned under DurableSessionState (R1/R4)",
31
+ },
32
+ {
33
+ key: "executionState",
34
+ authority: "session",
35
+ durable: true,
36
+ rebuildable: false,
37
+ },
38
+ {
39
+ key: "transcript.events",
40
+ authority: "session/artifacts",
41
+ durable: true,
42
+ rebuildable: false,
43
+ },
44
+ {
45
+ key: "providerMessages",
46
+ authority: "checkpoint",
47
+ durable: true,
48
+ rebuildable: true,
49
+ notes: "Today: nlMessages stripped on save; rebuilt from transcript when possible",
50
+ },
51
+ {
52
+ key: "workingSet",
53
+ authority: "execution/session",
54
+ durable: true,
55
+ rebuildable: true,
56
+ },
57
+ {
58
+ key: "artifacts",
59
+ authority: "session/artifacts",
60
+ durable: true,
61
+ rebuildable: false,
62
+ },
63
+ {
64
+ key: "rollingSummary",
65
+ authority: "session",
66
+ durable: true,
67
+ rebuildable: true,
68
+ notes: "Optional durable projection (summary field)",
69
+ },
70
+ {
71
+ key: "uiLogs",
72
+ authority: "runtime_events",
73
+ durable: false,
74
+ rebuildable: true,
75
+ },
76
+ {
77
+ key: "planUi",
78
+ authority: "execution/session",
79
+ durable: true,
80
+ rebuildable: true,
81
+ notes: "Band mode preferences; graph view is projection of planGraph",
82
+ },
83
+ {
84
+ key: "sessionStatus",
85
+ authority: "runtime_events",
86
+ durable: false,
87
+ rebuildable: true,
88
+ },
89
+ ]);
90
+
91
+ const DURABLE_FIELDS = Object.freeze(
92
+ STATE_OWNERSHIP.filter((row) => row.durable).map((row) => row.key)
93
+ );
94
+
95
+ const PROJECTION_FIELDS = Object.freeze(
96
+ STATE_OWNERSHIP.filter((row) => row.rebuildable).map((row) => row.key)
97
+ );
98
+
99
+ function getOwnershipRow(key = "") {
100
+ const id = String(key || "").trim();
101
+ return STATE_OWNERSHIP.find((row) => row.key === id) || null;
102
+ }
103
+
104
+ function assertOwnershipTableInvariants() {
105
+ const keys = new Set();
106
+ for (const row of STATE_OWNERSHIP) {
107
+ if (!row.key) throw new Error("ownership row missing key");
108
+ if (keys.has(row.key)) throw new Error(`duplicate ownership key: ${row.key}`);
109
+ keys.add(row.key);
110
+ if (typeof row.durable !== "boolean" || typeof row.rebuildable !== "boolean") {
111
+ throw new Error(`ownership row ${row.key} has invalid flags`);
112
+ }
113
+ }
114
+ return true;
115
+ }
116
+
117
+ /**
118
+ * Atomic commit boundaries (R4). Crash between these stages must have a
119
+ * defined recovery; tools that are not naturally idempotent must not be
120
+ * blindly re-executed when stopped at `started`.
121
+ */
122
+ const ATOMIC_COMMIT_BOUNDARIES = Object.freeze([
123
+ "assistant_tool_calls_accepted",
124
+ "before_side_effect_tool",
125
+ "after_side_effect_persisted",
126
+ "tool_result_committed_to_ledger",
127
+ "before_agent_loop_suspension",
128
+ "after_resume_answer_committed",
129
+ "after_taskrun_terminal_cas",
130
+ ]);
131
+
132
+ const SIDE_EFFECT_INVOCATION_PHASES = Object.freeze([
133
+ "prepared",
134
+ "started",
135
+ "effect_observed",
136
+ "result_committed",
137
+ ]);
138
+
139
+ module.exports = {
140
+ STATE_OWNERSHIP,
141
+ DURABLE_FIELDS,
142
+ PROJECTION_FIELDS,
143
+ getOwnershipRow,
144
+ assertOwnershipTableInvariants,
145
+ ATOMIC_COMMIT_BOUNDARIES,
146
+ SIDE_EFFECT_INVOCATION_PHASES,
147
+ };