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,165 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Protocol validator — fail-closed when STRICT; otherwise returns diagnostics.
5
+ *
6
+ * Before a Provider turn: every declared call must be resolved or legitimately
7
+ * deferred (ask_user). Declared/executing calls block the next turn.
8
+ */
9
+
10
+ const {
11
+ listUnresolved,
12
+ listDeferred,
13
+ listCalls,
14
+ DEFERABLE_TOOLS,
15
+ } = require("./toolCallLedger");
16
+
17
+ /**
18
+ * @returns {{ ok: boolean, code?: string, errors: Array<{ code: string, message: string, callId?: string }> }}
19
+ */
20
+ function assertReadyForProviderTurn(ledger = null) {
21
+ const errors = [];
22
+ if (!ledger) {
23
+ return { ok: true, errors: [], skipped: true };
24
+ }
25
+
26
+ const unresolved = listUnresolved(ledger);
27
+ for (const call of unresolved) {
28
+ errors.push({
29
+ code: "UNRESOLVED_TOOL_CALL",
30
+ message: `tool call ${call.callId} (${call.name}) is still ${call.state}`,
31
+ callId: call.callId,
32
+ });
33
+ }
34
+
35
+ const deferred = listDeferred(ledger);
36
+ for (const call of deferred) {
37
+ if (!DEFERABLE_TOOLS.has(call.name)) {
38
+ errors.push({
39
+ code: "INVALID_DEFER",
40
+ message: `tool call ${call.callId} (${call.name}) is deferred but not deferable`,
41
+ callId: call.callId,
42
+ });
43
+ }
44
+ }
45
+
46
+ // Session-level: deferred ask_user is a legal suspension — next Provider turn
47
+ // must not happen until resume resolves it. Treat deferred as blocking here.
48
+ for (const call of deferred) {
49
+ errors.push({
50
+ code: "DEFERRED_PENDING_RESUME",
51
+ message: `tool call ${call.callId} is deferred; resume before next provider turn`,
52
+ callId: call.callId,
53
+ });
54
+ }
55
+
56
+ if (errors.length > 0) {
57
+ return {
58
+ ok: false,
59
+ code: errors[0].code,
60
+ errors,
61
+ };
62
+ }
63
+ return { ok: true, errors: [] };
64
+ }
65
+
66
+ /**
67
+ * Validate that a batch of declared calls matches policy expectations.
68
+ * Used by tests / shadow diagnostics — does not mutate messages.
69
+ */
70
+ function validateDeclaredBatch(ledger = null, {
71
+ requireAskUserAlone = true,
72
+ rejectPlanWithData = true,
73
+ dataPlaneTools = null,
74
+ } = {}) {
75
+ const errors = [];
76
+ const calls = listCalls(ledger);
77
+ if (calls.length === 0) return { ok: true, errors: [] };
78
+
79
+ const names = calls.map((c) => c.name);
80
+ const hasAskUser = names.includes("ask_user");
81
+ const hasControlPlane = names.includes("plan_graph") || names.includes("task_run");
82
+ const dataSet = dataPlaneTools instanceof Set
83
+ ? dataPlaneTools
84
+ : new Set(["read", "write", "edit", "bash", "artifact_read"]);
85
+ const hasData = names.some((name) => dataSet.has(name));
86
+
87
+ if (requireAskUserAlone && hasAskUser && calls.length > 1) {
88
+ errors.push({
89
+ code: "ASK_USER_MUST_BE_ALONE",
90
+ message: "ask_user must be the only tool call in the turn",
91
+ });
92
+ }
93
+ if (rejectPlanWithData && hasControlPlane && hasData) {
94
+ errors.push({
95
+ code: "MIXED_PLAN_AND_DATA_TOOLS",
96
+ message: "Do not mix plan_graph/task_run with data-plane tools in the same turn",
97
+ });
98
+ }
99
+
100
+ return {
101
+ ok: errors.length === 0,
102
+ code: errors[0] ? errors[0].code : undefined,
103
+ errors,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Env: UFOO_UCODE_PROTOCOL_STRICT
109
+ * - unset / "1" → fail-closed (Phase 1 default)
110
+ * - "0" → shadow diagnose only (rollback)
111
+ * Owner: runtime. Remove after R1 materialize is proven stable.
112
+ */
113
+ function isProtocolStrictEnabled(env = process.env) {
114
+ const raw = String(env && env.UFOO_UCODE_PROTOCOL_STRICT || "").trim();
115
+ if (raw === "0" || raw.toLowerCase() === "false" || raw.toLowerCase() === "off") {
116
+ return false;
117
+ }
118
+ return true;
119
+ }
120
+
121
+ /**
122
+ * Run validator; in shadow mode record violations on ledger, optionally throw.
123
+ * @returns {{ ok: boolean, errors: object[], threw: boolean }}
124
+ */
125
+ function runProviderTurnGate(ledger, {
126
+ strict = null,
127
+ onViolation = null,
128
+ } = {}) {
129
+ const result = assertReadyForProviderTurn(ledger);
130
+ if (result.ok || result.skipped) {
131
+ return { ok: true, errors: [], threw: false };
132
+ }
133
+
134
+ if (typeof onViolation === "function") {
135
+ try {
136
+ onViolation(result);
137
+ } catch {
138
+ // ignore observer errors
139
+ }
140
+ }
141
+
142
+ const { recordViolation } = require("./toolCallLedger");
143
+ for (const err of result.errors) {
144
+ recordViolation(ledger, err);
145
+ }
146
+
147
+ const shouldStrict = strict == null ? isProtocolStrictEnabled() : Boolean(strict);
148
+ if (shouldStrict) {
149
+ const err = new Error(
150
+ `protocol validator failed: ${result.errors.map((e) => e.code).join(", ")}`
151
+ );
152
+ err.code = result.code || "PROTOCOL_VIOLATION";
153
+ err.protocolErrors = result.errors;
154
+ throw err;
155
+ }
156
+
157
+ return { ok: false, errors: result.errors, threw: false };
158
+ }
159
+
160
+ module.exports = {
161
+ assertReadyForProviderTurn,
162
+ validateDeclaredBatch,
163
+ isProtocolStrictEnabled,
164
+ runProviderTurnGate,
165
+ };
@@ -0,0 +1,173 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Unified suspension / resume entry for ask_user and plan checkpoints.
5
+ *
6
+ * UI clients (Ink, readline) should call submitUserInteractionAnswer instead of
7
+ * parsing answers and appending tool results themselves.
8
+ *
9
+ * Lifecycle: running → suspending → suspended → resuming → running | failed
10
+ */
11
+
12
+ const {
13
+ hasPendingUserInteraction,
14
+ getPendingUserInteraction,
15
+ parseUserInteractionInput,
16
+ } = require("../context/userInteraction");
17
+
18
+ const INTERACTION_EVENTS = Object.freeze([
19
+ "interaction_requested",
20
+ "interaction_rejected",
21
+ "interaction_resuming",
22
+ "assistant_delta",
23
+ "interaction_resolved",
24
+ "interaction_failed",
25
+ ]);
26
+
27
+ function emit(onEvent, type, payload = {}) {
28
+ if (typeof onEvent !== "function") return;
29
+ try {
30
+ onEvent({ type, ...payload, timestamp: new Date().toISOString() });
31
+ } catch {
32
+ // UI observer errors must not alter protocol state.
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Application-layer answer submission shared by Ink and readline.
38
+ *
39
+ * @returns {Promise<object>} normalized resume result + display hints
40
+ */
41
+ async function submitUserInteractionAnswer(answerText = "", state = {}, options = {}) {
42
+ const onEvent = typeof options.onEvent === "function" ? options.onEvent : null;
43
+ const text = String(answerText == null ? "" : answerText);
44
+ const trimmed = text.trim();
45
+
46
+ if (!state || typeof state !== "object") {
47
+ emit(onEvent, "interaction_failed", { error: "missing session state" });
48
+ return {
49
+ ok: false,
50
+ code: "MISSING_STATE",
51
+ error: "missing session state",
52
+ waitingUserInteraction: false,
53
+ shouldEchoSummary: false,
54
+ events: ["interaction_failed"],
55
+ };
56
+ }
57
+
58
+ if (!hasPendingUserInteraction(state.executionState)) {
59
+ emit(onEvent, "interaction_rejected", { code: "NO_PENDING_INTERACTION" });
60
+ return {
61
+ ok: true,
62
+ idempotent: true,
63
+ code: "ALREADY_RESOLVED",
64
+ error: "",
65
+ summary: "",
66
+ waitingUserInteraction: false,
67
+ shouldEchoSummary: false,
68
+ events: ["interaction_rejected"],
69
+ };
70
+ }
71
+
72
+ // Slash commands while suspended: defined behavior — reject (except empty).
73
+ if (/^\//.test(trimmed) && !/^\/(exit|quit)\b/i.test(trimmed)) {
74
+ const error = "Answer the pending question first (slash commands are paused while waiting)";
75
+ emit(onEvent, "interaction_rejected", { code: "SUSPENDED_BLOCKS_SLASH", error });
76
+ return {
77
+ ok: false,
78
+ code: "SUSPENDED_BLOCKS_SLASH",
79
+ error,
80
+ waitingUserInteraction: true,
81
+ shouldEchoSummary: false,
82
+ events: ["interaction_rejected"],
83
+ };
84
+ }
85
+
86
+ const pending = getPendingUserInteraction(state.executionState);
87
+ const parsed = parseUserInteractionInput(pending, trimmed);
88
+ if (!parsed.ok) {
89
+ emit(onEvent, "interaction_rejected", {
90
+ code: parsed.code || "INVALID_ANSWER",
91
+ error: parsed.error || "Invalid reply",
92
+ });
93
+ return {
94
+ ok: false,
95
+ code: parsed.code || "INVALID_ANSWER",
96
+ error: parsed.error || "Invalid reply",
97
+ waitingUserInteraction: true,
98
+ shouldEchoSummary: false,
99
+ events: ["interaction_rejected"],
100
+ };
101
+ }
102
+
103
+ emit(onEvent, "interaction_resuming", {
104
+ interactionId: pending && pending.id ? pending.id : "",
105
+ kind: pending && pending.kind ? pending.kind : "",
106
+ });
107
+
108
+ const { resumeAfterUserInteraction } = require("../agent");
109
+ let sawStreamText = false;
110
+ const userOnDelta = typeof options.onDelta === "function" ? options.onDelta : null;
111
+ const result = await resumeAfterUserInteraction(trimmed, state, {
112
+ ...options,
113
+ onDelta: (delta) => {
114
+ const chunk = String(delta || "");
115
+ if (chunk && /[^\s]/.test(chunk)) sawStreamText = true;
116
+ emit(onEvent, "assistant_delta", { text: chunk });
117
+ if (userOnDelta) return userOnDelta(delta);
118
+ return undefined;
119
+ },
120
+ });
121
+
122
+ if (!result || result.ok === false) {
123
+ emit(onEvent, "interaction_failed", {
124
+ error: (result && result.error) || "resume failed",
125
+ code: (result && result.code) || "",
126
+ });
127
+ return {
128
+ ...(result || {}),
129
+ ok: false,
130
+ error: (result && result.error) || "resume failed",
131
+ shouldEchoSummary: false,
132
+ events: ["interaction_resuming", "interaction_failed"],
133
+ };
134
+ }
135
+
136
+ if (result.waitingUserInteraction) {
137
+ emit(onEvent, "interaction_requested", {
138
+ interactionId: result.interactionId || "",
139
+ });
140
+ return {
141
+ ...result,
142
+ shouldEchoSummary: true,
143
+ echoSummaryText: "Still waiting for your reply.",
144
+ events: ["interaction_resuming", "interaction_requested"],
145
+ };
146
+ }
147
+
148
+ emit(onEvent, "interaction_resolved", {
149
+ interactionId: (pending && pending.id) || "",
150
+ streamed: Boolean(result.streamed),
151
+ });
152
+
153
+ const streamedVisible = Boolean(result.streamed && (sawStreamText || options.streamVisible));
154
+ const { resolveSummaryDisplayPolicy } = require("./loopEvents");
155
+ const display = resolveSummaryDisplayPolicy({
156
+ streamed: Boolean(result.streamed),
157
+ sawVisibleText: streamedVisible,
158
+ });
159
+ return {
160
+ ...result,
161
+ shouldEchoSummary: Boolean(result.summary) && display.echoSummary,
162
+ echoSummaryText: result.summary || "",
163
+ displayPolicy: display,
164
+ events: ["interaction_resuming", "assistant_delta", "interaction_resolved"].filter(
165
+ (name, index, all) => all.indexOf(name) === index
166
+ ),
167
+ };
168
+ }
169
+
170
+ module.exports = {
171
+ INTERACTION_EVENTS,
172
+ submitUserInteractionAnswer,
173
+ };
@@ -0,0 +1,222 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Tool Call Ledger — authoritative record of declared tool calls for a turn.
5
+ *
6
+ * Shadow mode (Phase 0 / R1): ledger tracks declare/defer/resolve alongside the
7
+ * existing TRANSPORTS message assembly. Materialize-from-ledger comes later.
8
+ *
9
+ * Call states: declared | executing | deferred | resolved
10
+ */
11
+
12
+ const { createHash, randomUUID } = require("crypto");
13
+
14
+ const CALL_STATES = Object.freeze(["declared", "executing", "deferred", "resolved"]);
15
+ const DEFERABLE_TOOLS = Object.freeze(new Set(["ask_user"]));
16
+
17
+ function digestValue(value) {
18
+ const raw = typeof value === "string" ? value : stableStringify(value);
19
+ return createHash("sha256").update(raw).digest("hex").slice(0, 16);
20
+ }
21
+
22
+ function stableStringify(value) {
23
+ if (value == null) return "null";
24
+ if (typeof value !== "object") return JSON.stringify(value);
25
+ if (Array.isArray(value)) {
26
+ return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
27
+ }
28
+ const keys = Object.keys(value).sort();
29
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
30
+ }
31
+
32
+ function createTurnId() {
33
+ return `turn_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
34
+ }
35
+
36
+ /**
37
+ * @param {{ provider?: string, sessionId?: string, turnId?: string, assistantMessageId?: string }} opts
38
+ */
39
+ function createToolCallLedger(opts = {}) {
40
+ return {
41
+ turnId: String(opts.turnId || createTurnId()),
42
+ provider: String(opts.provider || "").trim(),
43
+ sessionId: String(opts.sessionId || "").trim(),
44
+ assistantMessageId: String(opts.assistantMessageId || "").trim(),
45
+ calls: Object.create(null),
46
+ violations: [],
47
+ createdAt: new Date().toISOString(),
48
+ };
49
+ }
50
+
51
+ function getCall(ledger, callId) {
52
+ if (!ledger || !ledger.calls) return null;
53
+ const id = String(callId || "").trim();
54
+ return id && ledger.calls[id] ? ledger.calls[id] : null;
55
+ }
56
+
57
+ function listCalls(ledger) {
58
+ if (!ledger || !ledger.calls) return [];
59
+ return Object.keys(ledger.calls).map((id) => ledger.calls[id]);
60
+ }
61
+
62
+ function listUnresolved(ledger) {
63
+ return listCalls(ledger).filter((call) => (
64
+ call.state === "declared" || call.state === "executing"
65
+ ));
66
+ }
67
+
68
+ function listDeferred(ledger) {
69
+ return listCalls(ledger).filter((call) => call.state === "deferred");
70
+ }
71
+
72
+ /**
73
+ * Declare one or more tool calls after prepareToolCalls.
74
+ * @param {object} ledger
75
+ * @param {Array<{ callId: string, name: string, args?: object }>} calls
76
+ */
77
+ function declareCalls(ledger, calls = []) {
78
+ if (!ledger || !ledger.calls) {
79
+ return { ok: false, error: "missing ledger" };
80
+ }
81
+ const list = Array.isArray(calls) ? calls : [];
82
+ for (const entry of list) {
83
+ const callId = String(entry && entry.callId || "").trim();
84
+ if (!callId) {
85
+ return { ok: false, error: "callId required" };
86
+ }
87
+ if (ledger.calls[callId]) {
88
+ return { ok: false, error: `duplicate callId: ${callId}`, code: "DUPLICATE_CALL_ID" };
89
+ }
90
+ const name = String(entry.name || "").trim().toLowerCase();
91
+ ledger.calls[callId] = {
92
+ callId,
93
+ name,
94
+ argsDigest: digestValue(entry.args == null ? {} : entry.args),
95
+ state: "declared",
96
+ resultDigest: "",
97
+ isError: false,
98
+ resolvedAt: "",
99
+ deferredAt: "",
100
+ executingAt: "",
101
+ };
102
+ }
103
+ if (!ledger.assistantMessageId && list.length > 0) {
104
+ ledger.assistantMessageId = String(list[0].callId || "");
105
+ }
106
+ return { ok: true, count: list.length };
107
+ }
108
+
109
+ function markExecuting(ledger, callId) {
110
+ const call = getCall(ledger, callId);
111
+ if (!call) return { ok: false, error: "call not found", code: "CALL_NOT_FOUND" };
112
+ if (call.state !== "declared") {
113
+ return { ok: false, error: `cannot execute from ${call.state}`, code: "INVALID_STATE" };
114
+ }
115
+ call.state = "executing";
116
+ call.executingAt = new Date().toISOString();
117
+ return { ok: true, call };
118
+ }
119
+
120
+ /**
121
+ * Defer a call (ask_user only). Leaves assistant tool_call unpaired until resume.
122
+ */
123
+ function deferCall(ledger, callId, { reason = "" } = {}) {
124
+ const call = getCall(ledger, callId);
125
+ if (!call) return { ok: false, error: "call not found", code: "CALL_NOT_FOUND" };
126
+ if (call.state !== "declared" && call.state !== "executing") {
127
+ return { ok: false, error: `cannot defer from ${call.state}`, code: "INVALID_STATE" };
128
+ }
129
+ if (!DEFERABLE_TOOLS.has(call.name)) {
130
+ return {
131
+ ok: false,
132
+ error: `tool ${call.name} cannot be deferred`,
133
+ code: "NOT_DEFERABLE",
134
+ };
135
+ }
136
+ call.state = "deferred";
137
+ call.deferredAt = new Date().toISOString();
138
+ call.deferReason = String(reason || "").trim();
139
+ return { ok: true, call };
140
+ }
141
+
142
+ /**
143
+ * Resolve a declared/executing/deferred call with exactly one result.
144
+ * Idempotent when resultDigest matches a prior resolve.
145
+ */
146
+ function resolveCall(ledger, callId, {
147
+ result = null,
148
+ isError = false,
149
+ allowFromDeferred = true,
150
+ } = {}) {
151
+ const call = getCall(ledger, callId);
152
+ if (!call) return { ok: false, error: "call not found", code: "CALL_NOT_FOUND" };
153
+
154
+ const resultDigest = digestValue(result);
155
+ if (call.state === "resolved") {
156
+ if (call.resultDigest === resultDigest) {
157
+ return { ok: true, call, idempotent: true };
158
+ }
159
+ return {
160
+ ok: false,
161
+ error: "call already resolved with different result",
162
+ code: "DUPLICATE_RESOLVE",
163
+ call,
164
+ };
165
+ }
166
+
167
+ if (call.state === "deferred" && !allowFromDeferred) {
168
+ return { ok: false, error: "call is deferred", code: "STILL_DEFERRED", call };
169
+ }
170
+ if (call.state !== "declared" && call.state !== "executing" && call.state !== "deferred") {
171
+ return { ok: false, error: `cannot resolve from ${call.state}`, code: "INVALID_STATE", call };
172
+ }
173
+
174
+ call.state = "resolved";
175
+ call.resultDigest = resultDigest;
176
+ call.resultPayload = result;
177
+ call.isError = Boolean(isError);
178
+ call.resolvedAt = new Date().toISOString();
179
+ return { ok: true, call, idempotent: false };
180
+ }
181
+
182
+ function recordViolation(ledger, violation = {}) {
183
+ if (!ledger) return;
184
+ if (!Array.isArray(ledger.violations)) ledger.violations = [];
185
+ ledger.violations.push({
186
+ code: String(violation.code || "PROTOCOL_VIOLATION"),
187
+ message: String(violation.message || ""),
188
+ callId: String(violation.callId || ""),
189
+ at: new Date().toISOString(),
190
+ });
191
+ }
192
+
193
+ function snapshotLedger(ledger) {
194
+ if (!ledger) return null;
195
+ return JSON.parse(JSON.stringify({
196
+ turnId: ledger.turnId,
197
+ provider: ledger.provider,
198
+ sessionId: ledger.sessionId,
199
+ assistantMessageId: ledger.assistantMessageId,
200
+ calls: ledger.calls,
201
+ violations: ledger.violations || [],
202
+ createdAt: ledger.createdAt,
203
+ }));
204
+ }
205
+
206
+ module.exports = {
207
+ CALL_STATES,
208
+ DEFERABLE_TOOLS,
209
+ digestValue,
210
+ createTurnId,
211
+ createToolCallLedger,
212
+ getCall,
213
+ listCalls,
214
+ listUnresolved,
215
+ listDeferred,
216
+ declareCalls,
217
+ markExecuting,
218
+ deferCall,
219
+ resolveCall,
220
+ recordViolation,
221
+ snapshotLedger,
222
+ };
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Current-state transition tables for Plan Mode and TaskRun (Phase 0).
5
+ *
6
+ * Documents *as-implemented* rules. R5 dual-writes planningPolicy alongside
7
+ * planMode; executionOwner is orthogonal to planningPolicy.
8
+ *
9
+ * NOTE: Status enums are duplicated in runtime/taskRun.js on purpose to avoid
10
+ * a require cycle (taskRun must not import this module).
11
+ */
12
+
13
+ /** Mirror of runtime/taskRun TASK_RUN_STATUSES (keep in sync). */
14
+ const TASK_RUN_STATUSES = Object.freeze([
15
+ "queued",
16
+ "running",
17
+ "cancelling",
18
+ "succeeded",
19
+ "failed",
20
+ "cancelled",
21
+ ]);
22
+
23
+ const TERMINAL_TASK_RUN = new Set(["succeeded", "failed", "cancelled"]);
24
+
25
+ /** Allowed TaskRun status edges (from → to[]). Terminal states have no outbound edges. */
26
+ const TASK_RUN_TRANSITIONS = Object.freeze({
27
+ queued: Object.freeze(["running", "cancelled"]),
28
+ running: Object.freeze(["succeeded", "failed", "cancelling"]),
29
+ cancelling: Object.freeze(["cancelled", "failed"]),
30
+ succeeded: Object.freeze([]),
31
+ failed: Object.freeze([]),
32
+ cancelled: Object.freeze([]),
33
+ });
34
+
35
+ /**
36
+ * Plan Mode is a session posture boolean today.
37
+ * Combinations with TaskRun are orthogonal: /plan off does not cancel TaskRuns.
38
+ */
39
+ const PLAN_MODE_FACTS = Object.freeze({
40
+ /** /plan on | auto after plan_graph create */
41
+ enterSources: Object.freeze(["user", "auto"]),
42
+ /** /plan off clears planMode; does not cancel graph or TaskRun */
43
+ exitClearsGraph: false,
44
+ exitCancelsTaskRun: false,
45
+ /** When planMode=true, side-effect direct tools may be blocked at runtime */
46
+ blocksDirectSideEffectsWhenOn: true,
47
+ /** Active plan waiting on a task can block data-plane tools even if policy differs later */
48
+ activePlanMayBlockDataTools: true,
49
+ /** TaskRun may continue after planMode is turned off */
50
+ taskRunSurvivesPlanOff: true,
51
+ });
52
+
53
+ /**
54
+ * Forward-looking orthognal fields (not yet stored on executionState).
55
+ * Mapped conceptually for R5 migration tests.
56
+ */
57
+ const FUTURE_POLICY_OWNER = Object.freeze({
58
+ planningPolicy: Object.freeze(["direct_allowed", "graph_required"]),
59
+ executionOwnerKinds: Object.freeze(["none", "agent_loop", "task_run"]),
60
+ mapPlanModeOn: Object.freeze({ planningPolicy: "graph_required" }),
61
+ mapPlanModeOff: Object.freeze({ planningPolicy: "direct_allowed" }),
62
+ });
63
+
64
+ function isAllowedTaskRunTransition(fromStatus = "", toStatus = "") {
65
+ const from = String(fromStatus || "").trim();
66
+ const to = String(toStatus || "").trim();
67
+ const allowed = TASK_RUN_TRANSITIONS[from];
68
+ if (!allowed) return false;
69
+ return allowed.includes(to);
70
+ }
71
+
72
+ function assertTransitionTables() {
73
+ for (const status of TASK_RUN_STATUSES) {
74
+ if (!Object.prototype.hasOwnProperty.call(TASK_RUN_TRANSITIONS, status)) {
75
+ throw new Error(`missing TASK_RUN_TRANSITIONS for ${status}`);
76
+ }
77
+ }
78
+ for (const [from, tos] of Object.entries(TASK_RUN_TRANSITIONS)) {
79
+ if (TERMINAL_TASK_RUN.has(from) && tos.length !== 0) {
80
+ throw new Error(`terminal status ${from} must have empty outbound edges`);
81
+ }
82
+ for (const to of tos) {
83
+ if (!TASK_RUN_STATUSES.includes(to)) {
84
+ throw new Error(`invalid transition ${from} → ${to}`);
85
+ }
86
+ }
87
+ }
88
+ return true;
89
+ }
90
+
91
+ module.exports = {
92
+ TASK_RUN_TRANSITIONS,
93
+ PLAN_MODE_FACTS,
94
+ FUTURE_POLICY_OWNER,
95
+ isAllowedTaskRunTransition,
96
+ assertTransitionTables,
97
+ };