@kasenri/dsh-orbit 0.5.1

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/lib/guard.js ADDED
@@ -0,0 +1,157 @@
1
+ import { existsSync, realpathSync } from 'node:fs';
2
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
3
+ const GITHUB_REMOTE_PATTERNS = [
4
+ /\bgit\s+push\b/i,
5
+ /\bgh\s+pr\s+(?:create|edit|merge)\b/i,
6
+ /\bgh\s+issue\s+(?:create|edit|close|comment|delete)\b/i,
7
+ /\bgh\s+pr\s+(?:comment|close|reopen|ready|review)\b/i,
8
+ /\bgh\s+workflow\s+(?:run|disable|enable)\b/i,
9
+ /\bgh\s+release\s+(?:create|delete|edit|upload)\b/i,
10
+ /\bgit\s+remote\s+(?:set-url|add|remove)\b/i,
11
+ ];
12
+ const DANGEROUS_PATTERNS = [
13
+ ['destructive_operation', /\brm\s+-[A-Za-z]*r[A-Za-z]*f|\brm\s+-rf\b/i, 'recursive force deletion'],
14
+ ['destructive_operation', /\bgit\s+reset\s+--hard\b|\bgit\s+clean\s+-[A-Za-z]*f/i, 'irreversible git reset/clean'],
15
+ ['production_operation', /\bdrop\s+database\b|\b(?:drop|truncate)\s+(?:table|schema)\b/i, 'irreversible database operation'],
16
+ [
17
+ 'production_operation',
18
+ /\b(?:production|prod)\b.*\b(?:deploy|migrate|database|restart)\b|\b(?:deploy|migrate)\b.*\b(?:production|prod)\b/i,
19
+ 'production operation',
20
+ ],
21
+ [
22
+ 'production_operation',
23
+ /\b(?:kubectl|helm)\s+(?:apply|delete|upgrade|rollback)\b|\bterraform\s+apply\b|\bdocker\s+push\b/i,
24
+ 'deployment or remote registry write',
25
+ ],
26
+ ['production_operation', /\b(?:alembic|prisma|sequelize|rails)\b.*\b(?:upgrade|migrate|db:migrate)\b/i, 'database migration'],
27
+ ['production_operation', /\b(?:ufw|iptables)\b.*\b(?:allow|insert|append)\b|\bdocker\s+run\b.*\s-p\s/i, 'public network exposure'],
28
+ ['secret_operation', /\b(?:cat|less|more|head|tail)\b[^\n]*(?:\.env|secret|credential|auth\.json|cookie)/i, 'credential or secret output'],
29
+ ['secret_operation', /\b(?:curl|wget)\b[^\n]*(?:Authorization|Bearer|api[_-]?key|token)=?/i, 'credential-bearing network request'],
30
+ ];
31
+ const SENSITIVE_ENV_NAME = /(?:^|_)(?:KEY|TOKEN|PASSWORD|PASSWD|SECRET|COOKIE|AUTH|CREDENTIAL|PRIVATE_KEY|ACCESS_TOKEN|REFRESH_TOKEN)(?:_|$)/i;
32
+ const DURABLE_STATE_PATH = /(?:^|[\s/'"])(?:\.\/)?\.cx(?:[/'"\s]|$)/i;
33
+ const DURABLE_STATE_REDIRECTION = /(?:^|[\s;&|])(?:\d+)?>>?\s*["']?(?:[^\s"']*\/)?\.cx(?:[/'"\s]|$)/i;
34
+ const DURABLE_STATE_WRITE_COMMAND = /\b(?:tee|touch|mkdir|install|cp|mv|rm|rmdir|truncate|dd)\b/i;
35
+ const DURABLE_STATE_IN_PLACE_COMMAND = /\b(?:sed|perl)\b[^\n]*\s-[^\n]*\bi\b/i;
36
+ const DURABLE_STATE_WRITE_API = /\b(?:write(?:File|_text)?|write_text|writeFileSync)\s*\(|\bopen\s*\([^,]+,\s*["'][^"']*(?:w|a|x|\+)[^"']*["']/i;
37
+ function block(code, reason, disposition = 'block_continue') {
38
+ return { allowed: false, code, reason, disposition };
39
+ }
40
+ function looksLikeEnvSecret(command) {
41
+ const trimmed = command.trim();
42
+ if (!trimmed)
43
+ return false;
44
+ const envBare = /^(?:env|printenv)\s*$/i.test(trimmed);
45
+ const printenvTarget = /\bprintenv\s+([A-Za-z_][A-Za-z0-9_]*)/i.exec(trimmed);
46
+ if (envBare)
47
+ return true;
48
+ if (printenvTarget && SENSITIVE_ENV_NAME.test(printenvTarget[1] ?? ''))
49
+ return true;
50
+ const envAssign = /\benv\s+((?:[A-Za-z_][A-Za-z0-9_]*=\S*\s*)+)/i.exec(trimmed);
51
+ if (envAssign) {
52
+ const pairs = envAssign[1] ?? '';
53
+ const names = [...pairs.matchAll(/([A-Za-z_][A-Za-z0-9_]*)=/g)].map((match) => match[1] ?? '');
54
+ const hasSecret = names.some((name) => SENSITIVE_ENV_NAME.test(name));
55
+ const tail = trimmed.slice(envAssign.index + envAssign[0].length).trim();
56
+ const onlyAssignments = tail.length === 0;
57
+ if (hasSecret && (onlyAssignments || tail.length > 0))
58
+ return true;
59
+ // `env TMPDIR=/tmp npm test` stays allowed: assignment names are not sensitive.
60
+ return false;
61
+ }
62
+ return false;
63
+ }
64
+ function isDurableStateWrite(command) {
65
+ if (DURABLE_STATE_REDIRECTION.test(command))
66
+ return true;
67
+ if (!DURABLE_STATE_PATH.test(command))
68
+ return false;
69
+ if (DURABLE_STATE_WRITE_COMMAND.test(command))
70
+ return true;
71
+ if (DURABLE_STATE_IN_PLACE_COMMAND.test(command))
72
+ return true;
73
+ if (DURABLE_STATE_WRITE_API.test(command))
74
+ return true;
75
+ return false;
76
+ }
77
+ /** Guard a bash command string. Order matters and mirrors the Pi CX (now Orbit) reference contract. */
78
+ export function guardBashCommand(command, intent = {}) {
79
+ const trimmed = command.trim();
80
+ if (!trimmed)
81
+ return { allowed: true };
82
+ if (intent.github_allowed !== true) {
83
+ for (const pattern of GITHUB_REMOTE_PATTERNS) {
84
+ if (pattern.test(trimmed))
85
+ return block('github_remote_write', `GitHub remote write is not allowed: ${pattern.source}`);
86
+ }
87
+ }
88
+ if (isDurableStateWrite(trimmed)) {
89
+ return block('durable_state_write', 'Only the Orbit controller may write .cx durable state.');
90
+ }
91
+ if (looksLikeEnvSecret(trimmed)) {
92
+ return block('secret_operation', 'Reading or exporting secrets is not allowed.');
93
+ }
94
+ if (/^\s*export\s+[A-Za-z_][A-Za-z0-9_]*\s*=/i.test(trimmed)) {
95
+ const assignment = /^\s*export\s+([A-Za-z_][A-Za-z0-9_]*)\s*=/i.exec(trimmed);
96
+ if (assignment && SENSITIVE_ENV_NAME.test(assignment[1] ?? '')) {
97
+ return block('secret_operation', 'Exporting a secret environment variable is not allowed.');
98
+ }
99
+ }
100
+ if (/^\s*set\s+[A-Za-z_][A-Za-z0-9_]*=/i.test(trimmed)) {
101
+ const assignment = /^\s*set\s+([A-Za-z_][A-Za-z0-9_]*)\s*=/i.exec(trimmed);
102
+ if (assignment && SENSITIVE_ENV_NAME.test(assignment[1] ?? '')) {
103
+ return block('secret_operation', 'Setting a secret environment variable is not allowed.');
104
+ }
105
+ }
106
+ for (const [code, pattern, reason] of DANGEROUS_PATTERNS) {
107
+ if (pattern.test(trimmed))
108
+ return block(code, reason);
109
+ }
110
+ return { allowed: true };
111
+ }
112
+ const WRITE_FILE_TOOLS = new Set(['write', 'edit', 'str_replace_editor']);
113
+ export function guardToolPath(toolName, targetPath, deps) {
114
+ if (!WRITE_FILE_TOOLS.has(toolName))
115
+ return { allowed: true };
116
+ if (typeof targetPath !== 'string' || targetPath.length === 0)
117
+ return { allowed: true };
118
+ const cwd = deps.cwd;
119
+ const absolute = isAbsolute(targetPath) ? targetPath : resolve(cwd, targetPath);
120
+ if (isInsideDurableState(absolute, cwd)) {
121
+ return block('durable_state_write', 'Only the Orbit controller may write .cx durable state.');
122
+ }
123
+ const realpath = deps.realpath ?? safeRealpath;
124
+ if (isInsideDurableState(realpathWithin(absolute, realpath), cwd)) {
125
+ return block('durable_state_write', 'Only the Orbit controller may write .cx durable state (symlink resolved).');
126
+ }
127
+ return { allowed: true };
128
+ }
129
+ function isInsideDurableState(absolute, cwd) {
130
+ const stateRoot = resolve(cwd, '.cx');
131
+ const rel = relative(stateRoot, absolute);
132
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
133
+ }
134
+ function realpathWithin(absolute, realpath) {
135
+ let current = absolute;
136
+ while (!existsSync(current) && dirname(current) !== current) {
137
+ current = dirname(current);
138
+ }
139
+ try {
140
+ const real = realpath(current);
141
+ return join(real, relative(current, absolute));
142
+ }
143
+ catch {
144
+ return absolute;
145
+ }
146
+ }
147
+ function safeRealpath(path) {
148
+ try {
149
+ return realpathSync(path);
150
+ }
151
+ catch {
152
+ return path;
153
+ }
154
+ }
155
+ export function guardReason(decision) {
156
+ return `Blocked by Orbit safety guard (${decision.code}): ${decision.reason}`;
157
+ }
package/lib/host.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/lib/index.js ADDED
@@ -0,0 +1,138 @@
1
+ import z from '@deepseek-ai/schemastery';
2
+ import { installOrbitGestureBoundary, registerOrbitCommand } from "./activation.js";
3
+ import { createOrbitPreExecuteHandler } from "./pipeline-guard.js";
4
+ import { resolveEffectiveRoutes, sessionSelectionOf } from "./routes.js";
5
+ import { OrbitService } from "./service.js";
6
+ import { createOrbitTool } from "./tool.js";
7
+ export const name = 'dsh-orbit';
8
+ export const inject = ['tools', 'agents', 'subagents'];
9
+ const Route = z.object({
10
+ provider: z.string(),
11
+ model: z.string(),
12
+ reasoningEffort: z.string(),
13
+ });
14
+ const RoleRoute = z.object({
15
+ provider: z.string().default(''),
16
+ model: z.string().default(''),
17
+ reasoningEffort: z.string().default(''),
18
+ });
19
+ /** `orbit` settings namespace: the two role routes Orbit persists itself. */
20
+ export const OrbitRouteSettingsSchema = z.object({
21
+ commander: RoleRoute,
22
+ watchdog: RoleRoute,
23
+ });
24
+ export const Config = z.object({
25
+ projectDir: z.string(),
26
+ routes: z
27
+ .object({
28
+ commander: Route,
29
+ executor: Route,
30
+ watchdog: Route,
31
+ })
32
+ .default({
33
+ commander: { provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'high' },
34
+ executor: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'high' },
35
+ watchdog: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'low' },
36
+ }),
37
+ browserTools: z.array(z.string()).default(['agent_browser']),
38
+ commanderReadOnlyTools: z.array(z.string()).default(['read', 'read_image', 'glob', 'grep', 'web_search', 'web_fetch']),
39
+ watchdogTools: z.array(z.string()).default(['read', 'read_image', 'glob', 'grep']),
40
+ executorTools: z
41
+ .array(z.string())
42
+ .default(['read', 'read_image', 'glob', 'grep', 'bash', 'write', 'edit', 'str_replace_editor', 'web_search', 'web_fetch']),
43
+ executorTimeoutMs: z.natural().default(480_000),
44
+ registerTool: z.boolean().default(true),
45
+ registerGuards: z.boolean().default(true),
46
+ slashCommand: z.boolean().default(true),
47
+ });
48
+ export function apply(ctx, config) {
49
+ // Orbit settings bridge: register the `orbit` namespace with the composition
50
+ // routes as its base/default, so a user who never opens the model UI keeps
51
+ // today's behavior exactly. Lazily injected: minimal/headless compositions
52
+ // without a settings provider keep running from `config.routes`.
53
+ let routeSettings;
54
+ ctx.inject(['settings'], (settingsCtx) => {
55
+ const baseRoute = (route) => ({
56
+ provider: route.provider,
57
+ model: route.model,
58
+ reasoningEffort: route.reasoningEffort ?? '',
59
+ });
60
+ const scope = settingsCtx.settings.register('orbit', OrbitRouteSettingsSchema, {
61
+ base: {
62
+ commander: baseRoute(config.routes.commander),
63
+ watchdog: baseRoute(config.routes.watchdog),
64
+ },
65
+ });
66
+ const sync = () => {
67
+ routeSettings = scope.get();
68
+ };
69
+ sync();
70
+ scope.watch(() => {
71
+ sync();
72
+ });
73
+ });
74
+ // A NEW run resolves its three routes exactly once: Commander/Watchdog from
75
+ // the Orbit settings (base = config), Executor from the initiating Session's
76
+ // current model selection (public request-header seam) with the config route
77
+ // as the fallback for surfaces without one. Existing runs resume from their
78
+ // frozen `state.routes`.
79
+ const resolveRoutes = () => resolveEffectiveRoutes({
80
+ configRoutes: config.routes,
81
+ settings: routeSettings,
82
+ sessionSelection: sessionSelectionOf(ctx.agents.currentInitiator()),
83
+ });
84
+ const serviceConfig = {
85
+ routes: config.routes,
86
+ resolveRoutes,
87
+ browserTools: config.browserTools,
88
+ commanderReadOnlyTools: config.commanderReadOnlyTools,
89
+ watchdogTools: config.watchdogTools,
90
+ executorTools: config.executorTools,
91
+ executorTimeoutMs: config.executorTimeoutMs,
92
+ ...(config.projectDir ? { projectDir: config.projectDir } : {}),
93
+ };
94
+ const service = new OrbitService(ctx, serviceConfig);
95
+ ctx.effect(() => () => undefined, 'dsh-orbit.service');
96
+ // Legacy alias: the same OrbitService instance is reachable as `ctx.cx`.
97
+ ctx.provide('cx', service);
98
+ if (config.registerTool) {
99
+ ctx.tools.register(createOrbitTool(ctx));
100
+ ctx.tools.register(createOrbitTool(ctx, { legacy: true }));
101
+ }
102
+ // Deterministic activation surfaces: the closed-namespace `/agent-orbit`
103
+ // host command (surfaces in the Web GUI slash menu through the Harness
104
+ // commands client) and the genuine-user-message gesture boundary for
105
+ // surfaces without command adjudication (headless CLI). Both default on.
106
+ //
107
+ // `commands` is registered lazily, not a required inject: every standard
108
+ // profile mounts it, but a minimal composition that omits the command
109
+ // registry keeps Orbit fully functional — the fiber never pends on it and
110
+ // simply never gains the slash command, while the gesture boundary still
111
+ // works.
112
+ if (config.slashCommand) {
113
+ ctx.inject(['commands'], (commandCtx) => {
114
+ registerOrbitCommand(commandCtx);
115
+ });
116
+ installOrbitGestureBoundary(ctx);
117
+ }
118
+ if (config.registerGuards) {
119
+ const handler = createOrbitPreExecuteHandler(service, {
120
+ competingDriver: (agent) => {
121
+ const reflect = ctx.reflect;
122
+ const goals = reflect?.get('goals');
123
+ if (goals && agent) {
124
+ try {
125
+ const goal = goals.get(agent);
126
+ if (goal?.phase === 'active')
127
+ return 'goal';
128
+ }
129
+ catch {
130
+ return undefined;
131
+ }
132
+ }
133
+ return undefined;
134
+ },
135
+ });
136
+ ctx.on('tools/pre-execute', (exec, next) => handler(exec, next));
137
+ }
138
+ }
package/lib/kernel.js ADDED
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Deterministic Orbit domain rules.
3
+ *
4
+ * This is the pure rule core: it must not import DSH packages, perform IO,
5
+ * read a clock, or use randomness. Runtime values (routes, timestamps, run ids)
6
+ * are supplied by the supervisor, which owns orchestration and persistence.
7
+ */
8
+ import { MAX_CORRECTION_DEPTH, MAX_PLAN_STEPS, MAX_WATCHDOG_CALLS_PER_STEP, MIN_PLAN_STEPS, ORBIT_SCHEMA_VERSION, } from "./types.js";
9
+ /** Capabilities a plan step may request. */
10
+ export const ORBIT_CAPABILITIES = ['browser', 'web-api-recon'];
11
+ const STEP_CAPABILITIES = new Set(ORBIT_CAPABILITIES);
12
+ const PLAN_STEP_ID = /^P\d+$/u;
13
+ export function normalizeCapabilities(value) {
14
+ if (!Array.isArray(value))
15
+ return undefined;
16
+ const result = [];
17
+ for (const item of value) {
18
+ if (typeof item !== 'string' || !STEP_CAPABILITIES.has(item))
19
+ continue;
20
+ if (!result.includes(item))
21
+ result.push(item);
22
+ }
23
+ if (result.length === 0)
24
+ return undefined;
25
+ if (result.includes('web-api-recon') && !result.includes('browser'))
26
+ result.unshift('browser');
27
+ return result;
28
+ }
29
+ export function normalizePlan(plan) {
30
+ if (!Array.isArray(plan.steps) || plan.steps.length < MIN_PLAN_STEPS || plan.steps.length > MAX_PLAN_STEPS) {
31
+ throw new Error(`COMMANDER_PLAN_OUTPUT_INVALID: steps must contain ${MIN_PLAN_STEPS}-${MAX_PLAN_STEPS} entries`);
32
+ }
33
+ const steps = plan.steps.map((item, index) => {
34
+ const record = (item ?? {});
35
+ const rawId = typeof record.id === 'string' ? record.id : '';
36
+ const id = PLAN_STEP_ID.test(rawId) ? rawId : `P${index}`;
37
+ const goal = String(record.goal ?? '').trim();
38
+ const capabilities = normalizeCapabilities(record.capabilities);
39
+ return {
40
+ id,
41
+ goal,
42
+ ...(capabilities ? { capabilities } : {}),
43
+ status: 'pending',
44
+ };
45
+ });
46
+ if (steps.some((step) => !step.goal)) {
47
+ throw new Error('COMMANDER_PLAN_OUTPUT_INVALID: every step needs a goal');
48
+ }
49
+ return { summary: String(plan.summary ?? '').slice(0, 1000), steps };
50
+ }
51
+ export function correctionDepthOf(stepId) {
52
+ const suffix = stepId.split('-', 2)[1];
53
+ return suffix ? Number(suffix) - 1 : 0;
54
+ }
55
+ export function baseStepIdOf(stepId) {
56
+ return stepId.split('-', 2)[0] ?? stepId;
57
+ }
58
+ export function isBaseStepId(stepId) {
59
+ return PLAN_STEP_ID.test(stepId);
60
+ }
61
+ export function explicitLoopBudget(input) {
62
+ const raw = input.approved_loop_count ?? input.max_loops;
63
+ if (raw === undefined)
64
+ return undefined;
65
+ if (!Number.isSafeInteger(raw) || raw <= 0)
66
+ throw new Error('ORBIT_LOOP_BUDGET_INVALID: approved_loop_count must be a positive integer');
67
+ if (raw > 10)
68
+ throw new Error('ORBIT_LOOP_BUDGET_INVALID: approved_loop_count above 10 requires an explicit execution request');
69
+ return raw;
70
+ }
71
+ export function estimateLoopCount(goal) {
72
+ const text = goal.toLowerCase();
73
+ if (/critical|migrate|migration|production|架构|重构/.test(text))
74
+ return 6;
75
+ if (/integration|联调|ui|high/.test(text))
76
+ return 4;
77
+ if (/feature|多文件|multi-file/.test(goal))
78
+ return 3;
79
+ if (/bug|fix|test/.test(text))
80
+ return 2;
81
+ return 1;
82
+ }
83
+ export function updateLoopBudget(state, budget) {
84
+ if (budget < state.loop.used)
85
+ throw new Error(`ORBIT_LOOP_BUDGET_BELOW_USED: requested ${budget}, already used ${state.loop.used}`);
86
+ state.approved_loop_count = budget;
87
+ state.loop = { used: state.loop.used, max: budget };
88
+ state.loop_count = state.loop.used;
89
+ state.remaining_budget = Math.max(0, budget - state.loop.used);
90
+ }
91
+ export function hashGoal(goal) {
92
+ let hash = 0;
93
+ for (let index = 0; index < goal.length; index += 1) {
94
+ hash = (hash * 31 + goal.charCodeAt(index)) | 0;
95
+ }
96
+ return `g${(hash >>> 0).toString(16)}`;
97
+ }
98
+ /** Normalize an APPEND decision into bounded, deduplicated new plan steps. */
99
+ export function normalizeAppend(decision) {
100
+ const items = [];
101
+ if (Array.isArray(decision.next_steps)) {
102
+ for (const entry of decision.next_steps) {
103
+ if (typeof entry === 'string' && entry.trim())
104
+ items.push({ goal: entry.trim() });
105
+ else if (entry && typeof entry === 'object') {
106
+ const goal = String(entry.goal ?? '').trim();
107
+ if (goal) {
108
+ const capabilities = normalizeCapabilities(entry.capabilities);
109
+ items.push({ goal, ...(capabilities ? { capabilities } : {}) });
110
+ }
111
+ }
112
+ }
113
+ }
114
+ if (items.length === 0 && decision.next_step_goal?.trim()) {
115
+ const capabilities = normalizeCapabilities(decision.next_step_capabilities);
116
+ items.push({ goal: decision.next_step_goal.trim(), ...(capabilities ? { capabilities } : {}) });
117
+ }
118
+ return items;
119
+ }
120
+ /** Why a correction step cannot be inserted, if the deterministic budget rules say so. */
121
+ export function correctionBlockCode(state, step) {
122
+ if (correctionDepthOf(step.id) >= MAX_CORRECTION_DEPTH)
123
+ return 'CORRECTION_LIMIT_REACHED';
124
+ const remaining = Math.max(0, state.loop.max - state.loop.used);
125
+ const reservedForLaterStages = state.plan.steps.filter((candidate) => isBaseStepId(candidate.id) && candidate.status === 'pending').length;
126
+ if (remaining <= reservedForLaterStages)
127
+ return 'LOOP_BUDGET_RESERVED_FOR_LATER_STEPS';
128
+ return undefined;
129
+ }
130
+ export function createInitialState(input) {
131
+ const max = explicitLoopBudget({ approved_loop_count: input.approvedLoopCount, max_loops: input.maxLoops }) ??
132
+ estimateLoopCount(input.goal);
133
+ return {
134
+ schema_version: ORBIT_SCHEMA_VERSION,
135
+ active_run_id: input.runId,
136
+ run_id: input.runId,
137
+ phase: 'PLAN',
138
+ status: 'running',
139
+ driver_ownership: 'ACTIVE',
140
+ state_revision: 0,
141
+ updated_at: new Date(input.now).toISOString(),
142
+ goal: input.goal,
143
+ goal_hash: hashGoal(input.goal),
144
+ preset: input.preset ?? 'orbit-lite',
145
+ routes: input.routes,
146
+ loop: { used: 0, max },
147
+ approved_loop_count: max,
148
+ remaining_budget: max,
149
+ loop_count: 0,
150
+ plan: { summary: '', steps: [] },
151
+ changed_files: [],
152
+ test_summary: [],
153
+ last_error: null,
154
+ user_hard_constraints: input.userHardConstraints ? [...input.userHardConstraints] : [],
155
+ github_allowed: input.githubAllowed === true,
156
+ interruption_retries: 0,
157
+ };
158
+ }
159
+ /** A user answers a NEEDS_USER run with the same goal: execution continues. */
160
+ export function resumeFromNeedsUser(state) {
161
+ state.phase = 'EXECUTE';
162
+ state.status = 'running';
163
+ }
164
+ /** Accept the Commander plan and move to execution. */
165
+ export function applyPlan(state, plan) {
166
+ state.plan = plan;
167
+ state.phase = 'EXECUTE';
168
+ state.status = 'running';
169
+ }
170
+ /** PLAN failed or was interrupted: stay resumable in PLAN with the reason. */
171
+ export function recordPlanFailure(state, reason) {
172
+ state.phase = 'PLAN';
173
+ state.status = 'running';
174
+ state.last_error = reason;
175
+ }
176
+ /** Start one step: mark it running, bump its attempt, enter EXECUTE. */
177
+ export function beginStep(state, step) {
178
+ step.status = 'running';
179
+ state.current_step = {
180
+ id: step.id,
181
+ attempt: state.current_step?.id === step.id ? state.current_step.attempt + 1 : 1,
182
+ };
183
+ state.phase = 'EXECUTE';
184
+ state.status = 'running';
185
+ }
186
+ /** The Executor cannot run (capability missing): record a completed child shell for evaluation. */
187
+ export function applyExecutorCapabilityUnavailable(state, stepId) {
188
+ state.child = { status: 'completed' };
189
+ state.last_error = 'BROWSER_CAPABILITY_UNAVAILABLE';
190
+ state.commander = {
191
+ last_decision: state.commander?.last_decision,
192
+ summary: `Executor could not run step ${stepId}: BROWSER_CAPABILITY_UNAVAILABLE (agent_browser tool is not registered).`,
193
+ };
194
+ state.phase = 'EVALUATE';
195
+ }
196
+ /** Record an interrupted Executor attempt and return the new retry count. */
197
+ export function applyExecutorInterrupted(state, input) {
198
+ state.child = { ...(input.childId ? { id: input.childId } : {}), status: 'interrupted' };
199
+ state.last_error = input.lastError;
200
+ state.phase = 'EXECUTE';
201
+ state.status = 'running';
202
+ state.interruption_retries += 1;
203
+ return state.interruption_retries;
204
+ }
205
+ /** Watchdog says RESUME_CHILD: keep the child id for a later resume. */
206
+ export function applyExecutorResume(state, childId) {
207
+ state.child = { id: childId, status: 'interrupted' };
208
+ }
209
+ /** Watchdog says RESTART_STEP: drop the child and reset the retry budget. */
210
+ export function clearExecutorChild(state) {
211
+ state.child = undefined;
212
+ state.interruption_retries = 0;
213
+ }
214
+ /** Apply a real Executor success: consume one loop slot, then enter EVALUATE. */
215
+ export function applyExecutorSuccess(state, input) {
216
+ state.child = { ...(input.childId ? { id: input.childId } : {}), status: 'completed' };
217
+ state.interruption_retries = 0;
218
+ state.loop = { used: state.loop.used + 1, max: state.loop.max };
219
+ state.loop_count = state.loop.used;
220
+ state.remaining_budget = Math.max(0, state.loop.max - state.loop.used);
221
+ state.changed_files = input.changedFiles;
222
+ state.test_summary = input.testSummary;
223
+ state.last_error = null;
224
+ state.commander = {
225
+ last_decision: state.commander?.last_decision,
226
+ summary: input.summary,
227
+ };
228
+ state.phase = 'EVALUATE';
229
+ state.status = 'running';
230
+ }
231
+ /** STEP_EVALUATE says PASS_CURRENT_STEP: mark the step and go back to EXECUTE. */
232
+ export function applyStepPass(state, step, summary) {
233
+ if (step)
234
+ step.status = 'passed';
235
+ state.commander = { last_decision: 'PASS_CURRENT_STEP', summary: summary ?? state.commander?.summary };
236
+ state.phase = 'EXECUTE';
237
+ state.status = 'running';
238
+ }
239
+ /** FINAL_EVALUATE says SUCCESS: the run is done. */
240
+ export function applyFinalSuccess(state, summary) {
241
+ state.commander = { last_decision: 'SUCCESS', summary: summary ?? state.commander?.summary };
242
+ state.phase = 'SUCCESS';
243
+ state.status = 'success';
244
+ }
245
+ /** FINAL_EVALUATE says NEEDS_USER: record the decision, then pause the run. */
246
+ export function applyCommanderNeedsUser(state, reason) {
247
+ state.commander = { last_decision: 'NEEDS_USER', summary: state.commander?.summary };
248
+ enterNeedsUser(state, reason);
249
+ }
250
+ /** FINAL_EVALUATE says APPEND: append bounded new base steps, or explain why not. */
251
+ export function applyFinalAppend(state, decision) {
252
+ const remaining = state.loop.max - state.loop.used;
253
+ const appended = normalizeAppend(decision);
254
+ if (appended.length === 0)
255
+ return 'invalid';
256
+ if (remaining <= 0) {
257
+ enterBudgetExhausted(state);
258
+ return 'budget_exhausted';
259
+ }
260
+ for (const item of appended.slice(0, remaining)) {
261
+ const index = state.plan.steps.filter((candidate) => isBaseStepId(candidate.id)).length;
262
+ state.plan.steps.push({
263
+ id: `P${index}`,
264
+ goal: item.goal,
265
+ ...(item.capabilities ? { capabilities: item.capabilities } : {}),
266
+ status: 'pending',
267
+ });
268
+ }
269
+ state.commander = { last_decision: 'APPEND', summary: decision.summary ?? state.commander?.summary };
270
+ state.phase = 'EXECUTE';
271
+ state.status = 'running';
272
+ return 'appended';
273
+ }
274
+ /** CORRECT_CURRENT_STEP: insert the next correction step after the corrected one. */
275
+ export function applyCorrectionStep(state, step, input) {
276
+ const base = baseStepIdOf(step.id);
277
+ step.status = 'needs_correction';
278
+ const number = correctionDepthOf(step.id) + 2;
279
+ const insertAt = state.plan.steps.indexOf(step) + 1;
280
+ const correctionCapabilities = normalizeCapabilities(input.capabilities) ?? step.capabilities;
281
+ state.plan.steps.splice(insertAt, 0, {
282
+ id: `${base}-${number}`,
283
+ goal: input.nextGoal,
284
+ ...(correctionCapabilities ? { capabilities: correctionCapabilities } : {}),
285
+ status: 'pending',
286
+ });
287
+ state.phase = 'EXECUTE';
288
+ state.status = 'running';
289
+ }
290
+ /** A STRATEGY_RECONSIDER attempt failed temporarily: stay resumable in EVALUATE. */
291
+ export function restoreEvaluationState(state, lastError) {
292
+ state.phase = 'EVALUATE';
293
+ state.status = 'running';
294
+ if (lastError !== undefined)
295
+ state.last_error = lastError;
296
+ }
297
+ /** Mark the bounded per-base strategy challenge as consumed. */
298
+ export function markStrategyChallengeUsed(state, baseStepId) {
299
+ state.strategy_challenge = { base_step_id: baseStepId, used: true };
300
+ }
301
+ /** The run pauses for user guidance; callers may keep an already-recorded error. */
302
+ export function enterNeedsUser(state, lastError) {
303
+ state.phase = 'NEEDS_USER';
304
+ state.status = 'needs_user';
305
+ if (lastError !== undefined)
306
+ state.last_error = lastError;
307
+ }
308
+ /** The loop budget cannot pay for more work. */
309
+ export function enterBudgetExhausted(state, lastError) {
310
+ state.phase = 'BUDGET_EXHAUSTED';
311
+ state.status = 'budget_exhausted';
312
+ if (lastError !== undefined)
313
+ state.last_error = lastError;
314
+ }
315
+ /** The user closed the run. */
316
+ export function stopRun(state) {
317
+ state.phase = 'STOPPED';
318
+ state.status = 'stopped';
319
+ }
320
+ /**
321
+ * Record one Smart Watchdog attempt for a step and report whether the per-step
322
+ * call cap was already reached. At the cap the attempt is not consumed.
323
+ */
324
+ export function openWatchdogAttempt(state, stepId, reasons) {
325
+ const previous = state.smart_watchdog?.step_id === stepId ? state.smart_watchdog : undefined;
326
+ const calls = previous?.calls ?? 0;
327
+ if (calls >= MAX_WATCHDOG_CALLS_PER_STEP) {
328
+ state.smart_watchdog = {
329
+ step_id: stepId,
330
+ calls,
331
+ last_decision: 'CAP_REACHED',
332
+ last_reason: reasons.atCap,
333
+ };
334
+ return true;
335
+ }
336
+ state.smart_watchdog = { step_id: stepId, calls: calls + 1, last_reason: reasons.attempt };
337
+ return false;
338
+ }
339
+ /** Record the Smart Watchdog's last verdict (or unavailability). */
340
+ export function recordWatchdogDecision(state, decision) {
341
+ if (state.smart_watchdog)
342
+ state.smart_watchdog.last_decision = decision;
343
+ }
344
+ /** Count one guard block for the current step+code pair and record it. */
345
+ export function recordGuardRecovery(state, stepId, code) {
346
+ const previous = state.guard_recovery;
347
+ const same = previous?.step_id === stepId && previous?.code === code;
348
+ const count = (same ? previous.count : 0) + 1;
349
+ state.guard_recovery = { step_id: stepId, code, count };
350
+ return count;
351
+ }
352
+ /** A new Commander outcome starts a fresh guard-recovery window. */
353
+ export function clearGuardRecovery(state) {
354
+ state.guard_recovery = undefined;
355
+ }