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.
- package/README.md +9 -9
- package/assets/agents/ostacky.md +78 -218
- package/assets/commands/install-stack.md +25 -0
- package/assets/commands/opsx-sync.md +2 -1
- package/assets/mcp/ask-user-server/index.js +271 -0
- package/assets/mcp/ask-user-server/package.json +6 -0
- package/assets/mcp/ostacky-controller/controller.test.mjs +447 -0
- package/assets/mcp/ostacky-controller/index.js +1017 -0
- package/assets/skills/execution-mode-evaluation/SKILL.md +45 -55
- package/assets/skills/subagent-driven-development/SKILL.md +9 -1
- package/assets/skills/writing-plans/SKILL.md +3 -19
- package/dist/cli.js +160 -34
- package/manifest.json +44 -28
- package/package.json +1 -1
|
@@ -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
|
+
}
|