ostacky 0.4.1 → 0.5.0

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.
@@ -0,0 +1,1017 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * ostacky-controller — persisted state machine for Ostacky orchestration.
5
+ *
6
+ * Usage:
7
+ * import { OstackyController, buildExecutionSnapshot, STATES, RESULTS } from "./index.js";
8
+ * const ctl = new OstackyController({ statePath: ".opencode/ostacky-state.json" });
9
+ *
10
+ * State storage:
11
+ * Persistent JSON file at statePath. Atomic writes via tmpfile+rename.
12
+ * Default storage is project-local. Safe to commit the schema (not the data).
13
+ *
14
+ * Fallback (controller unavailable):
15
+ * Ostacky degrades gracefully: reports reduced confidence, preserves
16
+ * natural-language confirmation gates, defaults to inline execution.
17
+ * Subagent execution is NEVER authorized without explicit user confirmation,
18
+ * even in degraded mode.
19
+ *
20
+ * Recovery:
21
+ * If state file is corrupted or stale, call controller.replan() from BLOCKED,
22
+ * or delete the state file and start a new request.
23
+ */
24
+
25
+ /* Exposes structured operations for:
26
+ * - Starting / resuming a request
27
+ * - Recording clarification and discovery
28
+ * - Consuming route decisions (spec / directo)
29
+ * - Authorizing side-effect actions
30
+ * - Recording execution snapshots
31
+ * - Consuming execution-mode decisions
32
+ * - Validating edits (EDITABLE / ALREADY_APPLIED / CONFLICT)
33
+ * - Completing tasks
34
+ *
35
+ * Persists state as atomic JSON writes to a configurable path.
36
+ */
37
+
38
+ import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
39
+ import { dirname } from "node:path";
40
+
41
+ // ─── Constants ──────────────────────────────────────────────────────────────────
42
+
43
+ export const STATES = Object.freeze({
44
+ INTERPRETATION_PENDING: "INTERPRETATION_PENDING",
45
+ CLARIFICATION_PENDING: "CLARIFICATION_PENDING",
46
+ DISCOVERY: "DISCOVERY",
47
+ LEVEL_RESOLVED: "LEVEL_RESOLVED",
48
+ ROUTE_DECISION_PENDING: "ROUTE_DECISION_PENDING",
49
+ SPECIFICATION: "SPECIFICATION",
50
+ EXECUTION_ANALYSIS: "EXECUTION_ANALYSIS",
51
+ EXECUTION_DECISION_PENDING: "EXECUTION_DECISION_PENDING",
52
+ EXECUTING_INLINE: "EXECUTING_INLINE",
53
+ EXECUTING_SUBAGENTS: "EXECUTING_SUBAGENTS",
54
+ SYNC: "SYNC",
55
+ DONE: "DONE",
56
+ BLOCKED: "BLOCKED",
57
+ });
58
+
59
+ export const RESULTS = Object.freeze({
60
+ OK: "OK",
61
+ INVALID_TRANSITION: "INVALID_TRANSITION",
62
+ DECISION_ALREADY_CONSUMED: "DECISION_ALREADY_CONSUMED",
63
+ ACTION_NOT_AUTHORIZED: "ACTION_NOT_AUTHORIZED",
64
+ EDITABLE: "EDITABLE",
65
+ ALREADY_APPLIED: "ALREADY_APPLIED",
66
+ CONFLICT: "CONFLICT",
67
+ REPLAN_REQUIRED: "REPLAN_REQUIRED",
68
+ });
69
+
70
+ export const ACTIONS = Object.freeze({
71
+ OPENSPEC_PROPOSE: "openspec-propose",
72
+ OPENSPEC_APPLY: "openspec-apply",
73
+ EXECUTION_START: "execution-start",
74
+ EDIT: "edit",
75
+ TASK_COMPLETE: "task-complete",
76
+ SYNC: "sync",
77
+ });
78
+
79
+ // ─── Transition table ────────────────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Maps current state → allowed transitions.
83
+ * Each entry is an object with a `to` state and optional `via` (the action/operation
84
+ * that triggers the transition).
85
+ */
86
+ const TRANSITIONS = {
87
+ [STATES.INTERPRETATION_PENDING]: [
88
+ { to: STATES.CLARIFICATION_PENDING, via: "request_clarification" },
89
+ { to: STATES.DISCOVERY, via: "proceed_to_discovery" },
90
+ { to: STATES.ROUTE_DECISION_PENDING, via: "record_discovery" },
91
+ { to: STATES.BLOCKED, via: "block" },
92
+ ],
93
+ [STATES.CLARIFICATION_PENDING]: [
94
+ { to: STATES.DISCOVERY, via: "record_clarification" },
95
+ { to: STATES.BLOCKED, via: "block" },
96
+ { to: STATES.BLOCKED, via: "abandon" },
97
+ ],
98
+ [STATES.DISCOVERY]: [
99
+ { to: STATES.LEVEL_RESOLVED, via: "record_discovery" },
100
+ { to: STATES.BLOCKED, via: "block" },
101
+ { to: STATES.BLOCKED, via: "abandon" },
102
+ ],
103
+ [STATES.LEVEL_RESOLVED]: [
104
+ { to: STATES.ROUTE_DECISION_PENDING, via: "route_decision_pending" },
105
+ { to: STATES.BLOCKED, via: "block" },
106
+ ],
107
+ [STATES.ROUTE_DECISION_PENDING]: [
108
+ { to: STATES.SPECIFICATION, via: "consume_route_decision", choice: "SPEC" },
109
+ { to: STATES.EXECUTION_ANALYSIS, via: "consume_route_decision", choice: "DIRECT" },
110
+ { to: STATES.BLOCKED, via: "block" },
111
+ ],
112
+ [STATES.SPECIFICATION]: [
113
+ { to: STATES.EXECUTION_ANALYSIS, via: "spec_complete" },
114
+ { to: STATES.BLOCKED, via: "block" },
115
+ { to: STATES.BLOCKED, via: "abandon" },
116
+ ],
117
+ [STATES.EXECUTION_ANALYSIS]: [
118
+ { to: STATES.EXECUTION_DECISION_PENDING, via: "analysis_complete" },
119
+ { to: STATES.BLOCKED, via: "block" },
120
+ { to: STATES.BLOCKED, via: "abandon" },
121
+ ],
122
+ [STATES.EXECUTION_DECISION_PENDING]: [
123
+ { to: STATES.EXECUTING_INLINE, via: "consume_execution_decision", mode: "INLINE" },
124
+ { to: STATES.EXECUTING_SUBAGENTS, via: "consume_execution_decision", mode: "SUBAGENT_DRIVEN" },
125
+ { to: STATES.BLOCKED, via: "block" },
126
+ ],
127
+ [STATES.EXECUTING_INLINE]: [
128
+ { to: STATES.SYNC, via: "implementation_complete" },
129
+ { to: STATES.BLOCKED, via: "block" },
130
+ ],
131
+ [STATES.EXECUTING_SUBAGENTS]: [
132
+ { to: STATES.SYNC, via: "implementation_complete" },
133
+ { to: STATES.BLOCKED, via: "block" },
134
+ ],
135
+ [STATES.BLOCKED]: [
136
+ { to: STATES.INTERPRETATION_PENDING, via: "replan" },
137
+ { to: STATES.DONE, via: "abandon" },
138
+ ],
139
+ [STATES.SYNC]: [
140
+ { to: STATES.DONE, via: "sync_complete" },
141
+ { to: STATES.BLOCKED, via: "block" },
142
+ ],
143
+ [STATES.DONE]: [],
144
+ };
145
+
146
+ // ─── Authorizable actions per state ──────────────────────────────────────────────
147
+
148
+ /**
149
+ * Which side-effect actions are authorized in each state.
150
+ * An action is an { action, state } pair with optional extra requirements.
151
+ */
152
+ const AUTHORIZATIONS = {
153
+ [STATES.SPECIFICATION]: [ACTIONS.OPENSPEC_PROPOSE, ACTIONS.OPENSPEC_APPLY],
154
+ [STATES.EXECUTING_INLINE]: [ACTIONS.EXECUTION_START, ACTIONS.EDIT, ACTIONS.TASK_COMPLETE],
155
+ [STATES.EXECUTING_SUBAGENTS]: [ACTIONS.EXECUTION_START, ACTIONS.EDIT, ACTIONS.TASK_COMPLETE],
156
+ [STATES.SYNC]: [ACTIONS.SYNC],
157
+ [STATES.EDITABLE_VALIDATED]: [ACTIONS.EDIT],
158
+ };
159
+
160
+ // ─── Default state ───────────────────────────────────────────────────────────────
161
+
162
+ const DEFAULT_STATE = Object.freeze({
163
+ state: STATES.INTERPRETATION_PENDING,
164
+ revision: 0,
165
+ requestId: null,
166
+ changeId: null,
167
+ routeDecisionId: null,
168
+ routeChoice: null,
169
+ executionDecisionId: null,
170
+ executionMode: null,
171
+ snapshots: { codegraph: null, execution: null },
172
+ tasks: {},
173
+ fileFingerprints: {},
174
+ error: null,
175
+ });
176
+
177
+ // ─── Helpers ─────────────────────────────────────────────────────────────────────
178
+
179
+ function isAllowedTransition(from, via, choiceOrMode) {
180
+ const transitions = TRANSITIONS[from] || [];
181
+ for (const t of transitions) {
182
+ if (t.via !== via) continue;
183
+ if (t.choice !== undefined && t.choice !== choiceOrMode) continue;
184
+ if (t.mode !== undefined && t.mode !== choiceOrMode) continue;
185
+ return t.to;
186
+ }
187
+ return null;
188
+ }
189
+
190
+ function isActionAuthorized(state, action) {
191
+ const allowed = AUTHORIZATIONS[state];
192
+ return allowed ? allowed.includes(action) : false;
193
+ }
194
+
195
+ /**
196
+ * Validates and normalizes an execution snapshot.
197
+ * Returns the snapshot with defaults filled in, or throws on invalid structure.
198
+ * Pure function — no state access.
199
+ */
200
+ export function buildExecutionSnapshot(input = {}) {
201
+ const {
202
+ filesPerTask = {},
203
+ sharedFiles = {},
204
+ fileClusters = [],
205
+ sequentialDeps = [],
206
+ estLines = 0,
207
+ hasExplicitContract = false,
208
+ taskCount = 0,
209
+ clusterCount = 0,
210
+ recommendation = "INLINE",
211
+ reasons = [],
212
+ codegraphUsed = [],
213
+ } = input;
214
+
215
+ // Validate recommendation
216
+ if (!["INLINE", "SUBAGENT_DRIVEN"].includes(recommendation)) {
217
+ throw new Error(`Invalid recommendation: ${recommendation}. Must be INLINE or SUBAGENT_DRIVEN`);
218
+ }
219
+
220
+ return {
221
+ filesPerTask,
222
+ sharedFiles,
223
+ fileClusters,
224
+ sequentialDeps,
225
+ estLines,
226
+ hasExplicitContract,
227
+ taskCount,
228
+ clusterCount,
229
+ recommendation,
230
+ reasons,
231
+ codegraphUsed,
232
+ };
233
+ }
234
+
235
+ // ─── Controller class ────────────────────────────────────────────────────────────
236
+
237
+ export class OstackyController {
238
+ #statePath;
239
+ #state;
240
+ #loaded;
241
+
242
+ /**
243
+ * @param {Object} opts
244
+ * @param {string} opts.statePath - Path to the persistent state JSON file.
245
+ * @param {Object} [opts.initialState] - Override for initial state (testing).
246
+ */
247
+ constructor(opts = {}) {
248
+ this.#statePath = opts.statePath;
249
+ this.#state = opts.initialState ? { ...DEFAULT_STATE, ...opts.initialState } : null;
250
+ this.#loaded = false;
251
+ }
252
+
253
+ // ── Internal persistence ──────────────────────────────────────────────────────
254
+
255
+ #load() {
256
+ if (this.#loaded) return;
257
+ if (!this.#statePath) {
258
+ this.#state = { ...DEFAULT_STATE };
259
+ this.#loaded = true;
260
+ return;
261
+ }
262
+ try {
263
+ const raw = readFileSync(this.#statePath, "utf8");
264
+ this.#state = { ...DEFAULT_STATE, ...JSON.parse(raw) };
265
+ } catch {
266
+ this.#state = { ...DEFAULT_STATE };
267
+ }
268
+ this.#loaded = true;
269
+ }
270
+
271
+ #persist() {
272
+ if (!this.#statePath) return;
273
+ const dir = dirname(this.#statePath);
274
+ mkdirSync(dir, { recursive: true });
275
+ const tmp = this.#statePath + ".tmp." + process.pid;
276
+ writeFileSync(tmp, JSON.stringify(this.#state, null, 2), "utf8");
277
+ renameSync(tmp, this.#statePath);
278
+ }
279
+
280
+ #transition(to, changes = {}) {
281
+ this.#state.revision++;
282
+ this.#state.state = to;
283
+ Object.assign(this.#state, changes);
284
+ this.#persist();
285
+ }
286
+
287
+ #checkPendingDecisionId(decisionId) {
288
+ if (!this.#state.routeDecisionId && !this.#state.executionDecisionId) {
289
+ // No pending decision, or we check specific cases below
290
+ }
291
+ if (this.#state.routeDecisionId && this.#state.routeDecisionId === decisionId && this.#state.routeChoice) {
292
+ return RESULTS.DECISION_ALREADY_CONSUMED;
293
+ }
294
+ if (this.#state.executionDecisionId && this.#state.executionDecisionId === decisionId && this.#state.executionMode) {
295
+ return RESULTS.DECISION_ALREADY_CONSUMED;
296
+ }
297
+ return null;
298
+ }
299
+
300
+ // ── Public API ─────────────────────────────────────────────────────────────────
301
+
302
+ /**
303
+ * Start or resume a request.
304
+ * @param {Object} params
305
+ * @param {string} params.requestId
306
+ * @param {string} [params.changeId]
307
+ * @returns {Promise<{status: string, state: string, revision: number, requestId: string}>}
308
+ */
309
+ async startRequest({ requestId, changeId } = {}) {
310
+ this.#load();
311
+
312
+ if (this.#state.state !== STATES.INTERPRETATION_PENDING && this.#state.state !== STATES.BLOCKED && this.#state.state !== STATES.DONE) {
313
+ // Already in an active flow — resume path
314
+ return {
315
+ status: RESULTS.OK,
316
+ state: this.#state.state,
317
+ revision: this.#state.revision,
318
+ requestId: this.#state.requestId,
319
+ changeId: this.#state.changeId,
320
+ routeDecisionId: this.#state.routeDecisionId,
321
+ routeChoice: this.#state.routeChoice,
322
+ };
323
+ }
324
+
325
+ // Start fresh or replan from BLOCKED
326
+ this.#transition(STATES.INTERPRETATION_PENDING, {
327
+ requestId: requestId || "req-" + Date.now(),
328
+ changeId: changeId || null,
329
+ routeDecisionId: null,
330
+ routeChoice: null,
331
+ executionDecisionId: null,
332
+ executionMode: null,
333
+ snapshots: { codegraph: null, execution: null },
334
+ tasks: {},
335
+ fileFingerprints: {},
336
+ error: null,
337
+ });
338
+
339
+ return {
340
+ status: RESULTS.OK,
341
+ state: this.#state.state,
342
+ revision: this.#state.revision,
343
+ requestId: this.#state.requestId,
344
+ changeId: this.#state.changeId,
345
+ };
346
+ }
347
+
348
+ /**
349
+ * Record that clarification was requested.
350
+ * @param {Object} params
351
+ * @param {string} [params.question]
352
+ * @returns {Promise<{status: string, state: string, revision: number}>}
353
+ */
354
+ async requestClarification({ question } = {}) {
355
+ this.#load();
356
+ const to = isAllowedTransition(this.#state.state, "request_clarification");
357
+ if (!to) {
358
+ return {
359
+ status: RESULTS.INVALID_TRANSITION,
360
+ state: this.#state.state,
361
+ revision: this.#state.revision,
362
+ reason: `Cannot request clarification from state ${this.#state.state}`,
363
+ };
364
+ }
365
+ this.#transition(to, { error: question ? `Clarification: ${question}` : null });
366
+ return { status: RESULTS.OK, state: this.#state.state, revision: this.#state.revision };
367
+ }
368
+
369
+ /**
370
+ * Record that clarification was answered.
371
+ * @returns {Promise<{status: string, state: string, revision: number}>}
372
+ */
373
+ async recordClarification() {
374
+ this.#load();
375
+ const to = isAllowedTransition(this.#state.state, "record_clarification");
376
+ if (!to) {
377
+ return {
378
+ status: RESULTS.INVALID_TRANSITION,
379
+ state: this.#state.state,
380
+ revision: this.#state.revision,
381
+ reason: `Cannot record clarification from state ${this.#state.state}`,
382
+ };
383
+ }
384
+ this.#transition(to, { error: null });
385
+ return { status: RESULTS.OK, state: this.#state.state, revision: this.#state.revision };
386
+ }
387
+
388
+ /**
389
+ * Record that discovery is complete and a level was determined.
390
+ * @param {Object} params
391
+ * @param {string} params.level - '0', '0+1', or '1+'
392
+ * @param {string} params.routeDecisionId - Unique ID for the pending route decision
393
+ * @param {Object} [params.snapshot] - Optional CodeGraph snapshot reference
394
+ * @returns {Promise<{status: string, state: string, revision: number, level: string}>}
395
+ */
396
+ async recordDiscovery({ level, routeDecisionId, snapshot } = {}) {
397
+ this.#load();
398
+ const to = isAllowedTransition(this.#state.state, "record_discovery");
399
+ if (!to) {
400
+ return {
401
+ status: RESULTS.INVALID_TRANSITION,
402
+ state: this.#state.state,
403
+ revision: this.#state.revision,
404
+ reason: `Cannot record discovery from state ${this.#state.state}`,
405
+ };
406
+ }
407
+
408
+ if (!["0", "0+1", "1+"].includes(level)) {
409
+ return {
410
+ status: RESULTS.INVALID_TRANSITION,
411
+ state: this.#state.state,
412
+ revision: this.#state.revision,
413
+ reason: `Invalid level: ${level}. Must be '0', '0+1', or '1+'`,
414
+ };
415
+ }
416
+
417
+ this.#transition(STATES.ROUTE_DECISION_PENDING, {
418
+ routeDecisionId: routeDecisionId || "route-" + Date.now(),
419
+ routeChoice: null,
420
+ snapshots: {
421
+ ...this.#state.snapshots,
422
+ codegraph: snapshot || this.#state.snapshots.codegraph,
423
+ },
424
+ });
425
+
426
+ // Default routing: Level 0/0+1 → DIRECT, Level 1+ → SPEC
427
+ const defaultChoice = level === "1+" ? "SPEC" : "DIRECT";
428
+
429
+ return {
430
+ status: RESULTS.OK,
431
+ state: this.#state.state,
432
+ revision: this.#state.revision,
433
+ level,
434
+ routeDecisionId: this.#state.routeDecisionId,
435
+ defaultChoice,
436
+ };
437
+ }
438
+
439
+ /**
440
+ * Consume the route decision (spec / directo).
441
+ * Only valid in ROUTE_DECISION_PENDING. Consumed exactly once per decisionId.
442
+ * @param {Object} params
443
+ * @param {string} params.decisionId - Must match the pending routeDecisionId
444
+ * @param {string} params.choice - 'SPEC' or 'DIRECT'
445
+ * @returns {Promise<{status: string, state: string, revision: number}>}
446
+ */
447
+ async consumeRouteDecision({ decisionId, choice } = {}) {
448
+ this.#load();
449
+
450
+ if (this.#state.state !== STATES.ROUTE_DECISION_PENDING) {
451
+ // Check if already consumed: same decisionId with a routeChoice set
452
+ if (this.#state.routeDecisionId === decisionId && this.#state.routeChoice) {
453
+ return {
454
+ status: RESULTS.DECISION_ALREADY_CONSUMED,
455
+ state: this.#state.state,
456
+ revision: this.#state.revision,
457
+ reason: `Route decision ${decisionId} already consumed as ${this.#state.routeChoice}`,
458
+ };
459
+ }
460
+ return {
461
+ status: RESULTS.INVALID_TRANSITION,
462
+ state: this.#state.state,
463
+ revision: this.#state.revision,
464
+ reason: `Cannot consume route decision from state ${this.#state.state}`,
465
+ };
466
+ }
467
+
468
+ if (this.#state.routeDecisionId !== decisionId) {
469
+ return {
470
+ status: RESULTS.INVALID_TRANSITION,
471
+ state: this.#state.state,
472
+ revision: this.#state.revision,
473
+ reason: `Decision ID mismatch: expected ${this.#state.routeDecisionId}, got ${decisionId}`,
474
+ };
475
+ }
476
+
477
+ if (choice !== "SPEC" && choice !== "DIRECT") {
478
+ return {
479
+ status: RESULTS.INVALID_TRANSITION,
480
+ state: this.#state.state,
481
+ revision: this.#state.revision,
482
+ reason: `Invalid choice: ${choice}. Must be SPEC or DIRECT`,
483
+ };
484
+ }
485
+
486
+ // Check if already consumed
487
+ if (this.#state.routeChoice) {
488
+ return {
489
+ status: RESULTS.DECISION_ALREADY_CONSUMED,
490
+ state: this.#state.state,
491
+ revision: this.#state.revision,
492
+ reason: `Route decision ${decisionId} already consumed as ${this.#state.routeChoice}`,
493
+ };
494
+ }
495
+
496
+ const to = isAllowedTransition(this.#state.state, "consume_route_decision", choice);
497
+ if (!to) {
498
+ return {
499
+ status: RESULTS.INVALID_TRANSITION,
500
+ state: this.#state.state,
501
+ revision: this.#state.revision,
502
+ reason: `Route ${choice} not allowed from ${this.#state.state}`,
503
+ };
504
+ }
505
+
506
+ this.#transition(to, { routeChoice: choice });
507
+ return {
508
+ status: RESULTS.OK,
509
+ state: this.#state.state,
510
+ revision: this.#state.revision,
511
+ routeChoice: choice,
512
+ allowedActions: isActionAuthorized(this.#state.state, ACTIONS.OPENSPEC_PROPOSE)
513
+ ? [ACTIONS.OPENSPEC_PROPOSE]
514
+ : [],
515
+ };
516
+ }
517
+
518
+ /**
519
+ * Record that spec phase is complete (transition from SPECIFICATION → EXECUTION_ANALYSIS).
520
+ * @returns {Promise<{status: string, state: string, revision: number}>}
521
+ */
522
+ async specComplete() {
523
+ this.#load();
524
+ const to = isAllowedTransition(this.#state.state, "spec_complete");
525
+ if (!to) {
526
+ return {
527
+ status: RESULTS.INVALID_TRANSITION,
528
+ state: this.#state.state,
529
+ revision: this.#state.revision,
530
+ reason: `Cannot complete spec from state ${this.#state.state}`,
531
+ };
532
+ }
533
+ this.#transition(to);
534
+ return { status: RESULTS.OK, state: this.#state.state, revision: this.#state.revision };
535
+ }
536
+
537
+ /**
538
+ * Record an execution analysis snapshot.
539
+ * @param {Object} params
540
+ * @param {string} params.executionDecisionId - Unique ID for the pending execution mode decision
541
+ * @param {Object} params.snapshot - Execution analysis data
542
+ * @param {string} params.snapshot.recommendation - 'INLINE' or 'SUBAGENT_DRIVEN'
543
+ * @param {Array<string>} params.snapshot.sharedFiles - Shared files between tasks
544
+ * @param {number} params.snapshot.estimatedLines - Estimated total lines changed
545
+ * @param {Array<string>} params.snapshot.reasons - Reasons for the recommendation
546
+ * @returns {Promise<{status: string, state: string, revision: number}>}
547
+ */
548
+ async recordExecutionAnalysis({ executionDecisionId, snapshot } = {}) {
549
+ this.#load();
550
+ const to = isAllowedTransition(this.#state.state, "analysis_complete");
551
+ if (!to) {
552
+ return {
553
+ status: RESULTS.INVALID_TRANSITION,
554
+ state: this.#state.state,
555
+ revision: this.#state.revision,
556
+ reason: `Cannot record execution analysis from state ${this.#state.state}`,
557
+ };
558
+ }
559
+
560
+ this.#transition(STATES.EXECUTION_DECISION_PENDING, {
561
+ executionDecisionId: executionDecisionId || "exec-" + Date.now(),
562
+ executionMode: null,
563
+ snapshots: {
564
+ ...this.#state.snapshots,
565
+ execution: snapshot || null,
566
+ },
567
+ });
568
+
569
+ return {
570
+ status: RESULTS.OK,
571
+ state: this.#state.state,
572
+ revision: this.#state.revision,
573
+ executionDecisionId: this.#state.executionDecisionId,
574
+ };
575
+ }
576
+
577
+ /**
578
+ * Consume the execution mode decision (inline / subagent-driven).
579
+ * @param {Object} params
580
+ * @param {string} params.decisionId
581
+ * @param {string} params.mode - 'INLINE' or 'SUBAGENT_DRIVEN'
582
+ * @returns {Promise<{status: string, state: string, revision: number, allowedActions: string[]}>}
583
+ */
584
+ async consumeExecutionDecision({ decisionId, mode } = {}) {
585
+ this.#load();
586
+
587
+ if (this.#state.state !== STATES.EXECUTION_DECISION_PENDING) {
588
+ if (this.#state.executionDecisionId === decisionId && this.#state.executionMode) {
589
+ return {
590
+ status: RESULTS.DECISION_ALREADY_CONSUMED,
591
+ state: this.#state.state,
592
+ revision: this.#state.revision,
593
+ reason: `Execution decision ${decisionId} already consumed as ${this.#state.executionMode}`,
594
+ };
595
+ }
596
+ return {
597
+ status: RESULTS.INVALID_TRANSITION,
598
+ state: this.#state.state,
599
+ revision: this.#state.revision,
600
+ reason: `Cannot consume execution decision from state ${this.#state.state}`,
601
+ };
602
+ }
603
+
604
+ if (this.#state.executionDecisionId !== decisionId) {
605
+ return {
606
+ status: RESULTS.INVALID_TRANSITION,
607
+ state: this.#state.state,
608
+ revision: this.#state.revision,
609
+ reason: `Decision ID mismatch: expected ${this.#state.executionDecisionId}, got ${decisionId}`,
610
+ };
611
+ }
612
+
613
+ if (mode !== "INLINE" && mode !== "SUBAGENT_DRIVEN") {
614
+ return {
615
+ status: RESULTS.INVALID_TRANSITION,
616
+ state: this.#state.state,
617
+ revision: this.#state.revision,
618
+ reason: `Invalid mode: ${mode}. Must be INLINE or SUBAGENT_DRIVEN`,
619
+ };
620
+ }
621
+
622
+ if (this.#state.executionMode) {
623
+ return {
624
+ status: RESULTS.DECISION_ALREADY_CONSUMED,
625
+ state: this.#state.state,
626
+ revision: this.#state.revision,
627
+ reason: `Execution decision ${decisionId} already consumed as ${this.#state.executionMode}`,
628
+ };
629
+ }
630
+
631
+ const to = isAllowedTransition(this.#state.state, "consume_execution_decision", mode);
632
+ if (!to) {
633
+ return {
634
+ status: RESULTS.INVALID_TRANSITION,
635
+ state: this.#state.state,
636
+ revision: this.#state.revision,
637
+ reason: `Mode ${mode} not allowed from ${this.#state.state}`,
638
+ };
639
+ }
640
+
641
+ this.#transition(to, { executionMode: mode });
642
+
643
+ return {
644
+ status: RESULTS.OK,
645
+ state: this.#state.state,
646
+ revision: this.#state.revision,
647
+ executionMode: mode,
648
+ allowedActions: isActionAuthorized(this.#state.state, ACTIONS.EXECUTION_START)
649
+ ? [ACTIONS.EXECUTION_START, ACTIONS.EDIT, ACTIONS.TASK_COMPLETE]
650
+ : [],
651
+ };
652
+ }
653
+
654
+ /**
655
+ * Authorize a side-effect action based on current state.
656
+ * @param {string} action - One of ACTIONS values
657
+ * @param {Object} [context] - Optional context (e.g. { editResult } for edit action)
658
+ * @returns {Promise<{status: string, state: string, revision: number, allowed: boolean}>}
659
+ */
660
+ async authorize(action, context = {}) {
661
+ this.#load();
662
+
663
+ // Special case: edit requires an EDITABLE validation result
664
+ if (action === ACTIONS.EDIT) {
665
+ if (context.editResult !== RESULTS.EDITABLE) {
666
+ return {
667
+ status: RESULTS.ACTION_NOT_AUTHORIZED,
668
+ state: this.#state.state,
669
+ revision: this.#state.revision,
670
+ reason: `Edit requires EDITABLE result, got ${context.editResult || "none"}`,
671
+ allowed: false,
672
+ };
673
+ }
674
+ }
675
+
676
+ if (!isActionAuthorized(this.#state.state, action)) {
677
+ return {
678
+ status: RESULTS.INVALID_TRANSITION,
679
+ state: this.#state.state,
680
+ revision: this.#state.revision,
681
+ reason: `Action ${action} not authorized from state ${this.#state.state}`,
682
+ allowed: false,
683
+ };
684
+ }
685
+
686
+ return {
687
+ status: RESULTS.OK,
688
+ state: this.#state.state,
689
+ revision: this.#state.revision,
690
+ allowed: true,
691
+ };
692
+ }
693
+
694
+ /**
695
+ * Validate an edit operation before applying it.
696
+ * Pure function — does not depend on persisted state.
697
+ *
698
+ * @param {Object} params
699
+ * @param {string} params.oldString
700
+ * @param {string} params.newString
701
+ * @param {string} params.content - Fresh file content
702
+ * @returns {{status: string, reason?: string}}
703
+ */
704
+ validateEdit({ oldString, newString, content } = {}) {
705
+ // Same content → already applied
706
+ if (oldString === newString) {
707
+ return { status: RESULTS.ALREADY_APPLIED, reason: "oldString equals newString" };
708
+ }
709
+
710
+ const oldPresent = content.includes(oldString);
711
+ const newPresent = content.includes(newString);
712
+
713
+ if (oldPresent) {
714
+ return { status: RESULTS.EDITABLE };
715
+ }
716
+
717
+ // old not present
718
+ if (newPresent) {
719
+ return { status: RESULTS.ALREADY_APPLIED, reason: "newString already present in content" };
720
+ }
721
+
722
+ // Neither old nor new present
723
+ return { status: RESULTS.CONFLICT, reason: "oldString not found and newString not present" };
724
+ }
725
+
726
+ /**
727
+ * Mark a task as completed.
728
+ * @param {Object} params
729
+ * @param {string} params.taskId
730
+ * @param {string} [params.note] - Optional note
731
+ * @returns {Promise<{status: string, state: string, revision: number, taskState: Object}>}
732
+ */
733
+ async completeTask({ taskId, note } = {}) {
734
+ this.#load();
735
+
736
+ const executingStates = [STATES.EXECUTING_INLINE, STATES.EXECUTING_SUBAGENTS];
737
+ if (!executingStates.includes(this.#state.state)) {
738
+ return {
739
+ status: RESULTS.INVALID_TRANSITION,
740
+ state: this.#state.state,
741
+ revision: this.#state.revision,
742
+ reason: `Cannot complete task from state ${this.#state.state}`,
743
+ };
744
+ }
745
+
746
+ const taskState = {
747
+ completedAt: new Date().toISOString(),
748
+ note: note || null,
749
+ revision: this.#state.revision,
750
+ };
751
+
752
+ this.#state.tasks[taskId] = taskState;
753
+ this.#persist();
754
+
755
+ return {
756
+ status: RESULTS.OK,
757
+ state: this.#state.state,
758
+ revision: this.#state.revision,
759
+ taskState,
760
+ };
761
+ }
762
+
763
+ /**
764
+ * Transition to BLOCKED state.
765
+ * @param {Object} params
766
+ * @param {string} [params.reason]
767
+ * @returns {Promise<{status: string, state: string, revision: number}>}
768
+ */
769
+ async block({ reason } = {}) {
770
+ this.#load();
771
+ const to = isAllowedTransition(this.#state.state, "block");
772
+ if (!to) {
773
+ return {
774
+ status: RESULTS.INVALID_TRANSITION,
775
+ state: this.#state.state,
776
+ revision: this.#state.revision,
777
+ reason: `Cannot block from state ${this.#state.state}`,
778
+ };
779
+ }
780
+ this.#transition(to, { error: reason || "Blocked" });
781
+ return { status: RESULTS.OK, state: this.#state.state, revision: this.#state.revision };
782
+ }
783
+
784
+ /**
785
+ * Replan: transition from BLOCKED back to INTERPRETATION_PENDING.
786
+ * @param {Object} params
787
+ * @param {string} [params.reason]
788
+ * @returns {Promise<{status: string, state: string, revision: number}>}
789
+ */
790
+ async replan({ reason } = {}) {
791
+ this.#load();
792
+ const to = isAllowedTransition(this.#state.state, "replan");
793
+ if (!to) {
794
+ return {
795
+ status: RESULTS.INVALID_TRANSITION,
796
+ state: this.#state.state,
797
+ revision: this.#state.revision,
798
+ reason: `Cannot replan from state ${this.#state.state}`,
799
+ };
800
+ }
801
+ this.#transition(to, {
802
+ error: reason || null,
803
+ // Preserve requestId and changeId, but reset workflow state
804
+ routeDecisionId: null,
805
+ routeChoice: null,
806
+ executionDecisionId: null,
807
+ executionMode: null,
808
+ snapshots: { codegraph: null, execution: null },
809
+ tasks: {},
810
+ fileFingerprints: {},
811
+ });
812
+ return { status: RESULTS.OK, state: this.#state.state, revision: this.#state.revision };
813
+ }
814
+
815
+ /**
816
+ * Transition to SYNC state (implementation complete).
817
+ * @returns {Promise<{status: string, state: string, revision: number}>}
818
+ */
819
+ async implementationComplete() {
820
+ this.#load();
821
+ const to = isAllowedTransition(this.#state.state, "implementation_complete");
822
+ if (!to) {
823
+ return {
824
+ status: RESULTS.INVALID_TRANSITION,
825
+ state: this.#state.state,
826
+ revision: this.#state.revision,
827
+ reason: `Cannot complete implementation from state ${this.#state.state}`,
828
+ };
829
+ }
830
+ this.#transition(to);
831
+ return { status: RESULTS.OK, state: this.#state.state, revision: this.#state.revision };
832
+ }
833
+
834
+ /**
835
+ * Transition to DONE (sync complete).
836
+ * @returns {Promise<{status: string, state: string, revision: number}>}
837
+ */
838
+ async syncComplete() {
839
+ this.#load();
840
+ const to = isAllowedTransition(this.#state.state, "sync_complete");
841
+ if (!to) {
842
+ return {
843
+ status: RESULTS.INVALID_TRANSITION,
844
+ state: this.#state.state,
845
+ revision: this.#state.revision,
846
+ reason: `Cannot complete sync from state ${this.#state.state}`,
847
+ };
848
+ }
849
+ this.#transition(to);
850
+ return { status: RESULTS.OK, state: this.#state.state, revision: this.#state.revision };
851
+ }
852
+
853
+ /**
854
+ * Get current state (reads from persistent store).
855
+ * @returns {Promise<Object>}
856
+ */
857
+ async getState() {
858
+ this.#load();
859
+ return { ...this.#state };
860
+ }
861
+
862
+ /**
863
+ * Get current task states.
864
+ * @returns {Promise<Object>}
865
+ */
866
+ async getTasks() {
867
+ this.#load();
868
+ return { ...this.#state.tasks };
869
+ }
870
+
871
+ /**
872
+ * Record a CodeGraph snapshot for reuse across phases.
873
+ * @param {Object} snapshot - The CodeGraph context/impact/trace data
874
+ * @returns {Promise<{status: string, revision: number}>}
875
+ */
876
+ async recordCodegraphSnapshot(snapshot) {
877
+ this.#load();
878
+ this.#state.snapshots = {
879
+ ...this.#state.snapshots,
880
+ codegraph: {
881
+ data: snapshot,
882
+ capturedAt: new Date().toISOString(),
883
+ revision: this.#state.revision,
884
+ },
885
+ };
886
+ // Track used CodeGraph calls for instrumentation
887
+ if (snapshot?.calls) {
888
+ this.#state.lastCodegraphCalls = snapshot.calls;
889
+ }
890
+ this.#persist();
891
+ return { status: RESULTS.OK, revision: this.#state.revision };
892
+ }
893
+
894
+ /**
895
+ * Get the stored CodeGraph snapshot, or null if none.
896
+ * @returns {Promise<Object|null>}
897
+ */
898
+ async getCodegraphSnapshot() {
899
+ this.#load();
900
+ return this.#state.snapshots?.codegraph || null;
901
+ }
902
+
903
+ /**
904
+ * Record file fingerprints (mtime + size) for staleness detection.
905
+ * @param {Object<string, {mtime: number, size: number}>} fingerprints - File path → fingerprint
906
+ * @returns {Promise<{status: string, revision: number}>}
907
+ */
908
+ async recordFileFingerprints(fingerprints) {
909
+ this.#load();
910
+ Object.assign(this.#state.fileFingerprints, fingerprints);
911
+ this.#persist();
912
+ return { status: RESULTS.OK, revision: this.#state.revision };
913
+ }
914
+
915
+ /**
916
+ * Validate a snapshot revision AND file fingerprints for staleness.
917
+ * Returns REPLAN_REQUIRED if revision doesn't match or fingerprints changed.
918
+ * @param {number} snapshotRevision
919
+ * @param {Object<string, {mtime: number, size: number}>} [currentFingerprints]
920
+ * @returns {Promise<{valid: boolean, status?: string, currentRevision: number, staleFingerprints?: string[]}>}
921
+ */
922
+ async validateSnapshot(snapshotRevision, currentFingerprints) {
923
+ this.#load();
924
+ const result = {
925
+ valid: true,
926
+ currentRevision: this.#state.revision,
927
+ };
928
+
929
+ if (snapshotRevision !== this.#state.revision) {
930
+ result.valid = false;
931
+ result.status = RESULTS.REPLAN_REQUIRED;
932
+ return result;
933
+ }
934
+
935
+ if (currentFingerprints && this.#state.fileFingerprints) {
936
+ const stale = [];
937
+ for (const [file, fp] of Object.entries(currentFingerprints)) {
938
+ const stored = this.#state.fileFingerprints[file];
939
+ if (stored && (stored.mtime !== fp.mtime || stored.size !== fp.size)) {
940
+ stale.push(file);
941
+ }
942
+ }
943
+ if (stale.length > 0) {
944
+ result.valid = false;
945
+ result.status = RESULTS.REPLAN_REQUIRED;
946
+ result.staleFingerprints = stale;
947
+ }
948
+ }
949
+
950
+ if (!result.valid) {
951
+ result.status = result.status || RESULTS.REPLAN_REQUIRED;
952
+ }
953
+
954
+ return result;
955
+ }
956
+
957
+ /**
958
+ * Record a stable key for Engram boundary persistence.
959
+ * @param {Object} params
960
+ * @param {string} params.changeKey - e.g. "change/redesign-ostacky-orchestration"
961
+ * @param {string} [params.taskKey] - e.g. "task/controller-core"
962
+ * @returns {Promise<{status: string}>}
963
+ */
964
+ async recordEngramKey({ changeKey, taskKey } = {}) {
965
+ this.#load();
966
+ if (changeKey) this.#state.changeKey = changeKey;
967
+ if (taskKey) this.#state.taskKey = taskKey;
968
+ this.#persist();
969
+ return { status: RESULTS.OK };
970
+ }
971
+
972
+ /**
973
+ * Get stored Engram keys.
974
+ * @returns {Promise<{changeKey?: string, taskKey?: string}>}
975
+ */
976
+ async getEngramKeys() {
977
+ this.#load();
978
+ return {
979
+ changeKey: this.#state.changeKey,
980
+ taskKey: this.#state.taskKey,
981
+ };
982
+ }
983
+
984
+ /**
985
+ * Increment an instrumentation counter.
986
+ * @param {string} name - Counter name (e.g. "codegraph_calls", "engram_calls", "subagent_dispatches")
987
+ * @param {number} [by=1] - Increment amount
988
+ * @returns {Promise<number>} - New counter value
989
+ */
990
+ async incrementCounter(name, by = 1) {
991
+ this.#load();
992
+ if (!this.#state.counters) this.#state.counters = {};
993
+ this.#state.counters[name] = (this.#state.counters[name] || 0) + by;
994
+ this.#persist();
995
+ return this.#state.counters[name];
996
+ }
997
+
998
+ /**
999
+ * Get all instrumentation counters.
1000
+ * @returns {Promise<Object<string, number>>}
1001
+ */
1002
+ async getCounters() {
1003
+ this.#load();
1004
+ return { ...(this.#state.counters || {}) };
1005
+ }
1006
+
1007
+ /**
1008
+ * Reset all instrumentation counters.
1009
+ * @returns {Promise<{status: string}>}
1010
+ */
1011
+ async resetCounters() {
1012
+ this.#load();
1013
+ this.#state.counters = {};
1014
+ this.#persist();
1015
+ return { status: RESULTS.OK };
1016
+ }
1017
+ }