ostacky 0.10.2 → 0.10.3
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 +5 -3
- package/assets/agents/ostacky.md +12 -4
- package/assets/commands/install-stack.md +1 -1
- package/assets/docs/ostacky-reference.md +9 -1
- package/assets/mcp/ostacky-controller/index.js +191 -37
- package/assets/plugins/engram.ts +13 -9
- package/assets/plugins/ostacky-controller/index.ts +234 -41
- package/dist/cli.js +130 -58
- package/dist/mcp/openspec/index.js +19335 -0
- package/dist/mcp/openspec/package.json +1 -0
- package/dist/mcp/ostacky-controller/index.js +23115 -0
- package/dist/mcp/ostacky-controller/package.json +1 -0
- package/manifest.json +31 -29
- package/package.json +7 -5
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
// (solo tipos, borrado al transpilar) cargan sin dependencias externas.
|
|
18
18
|
import type { Plugin } from "@opencode/plugin"
|
|
19
19
|
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, statSync, readdirSync, unlinkSync } from "node:fs"
|
|
20
|
-
import { join, dirname, basename, resolve, relative, delimiter } from "node:path"
|
|
20
|
+
import { join, dirname, basename, resolve, relative, delimiter, sep } from "node:path"
|
|
21
|
+
import { homedir } from "node:os"
|
|
22
|
+
import { fileURLToPath } from "node:url"
|
|
21
23
|
import { SENSITIVE_DEFAULT, BASH_SENSITIVE_RE, isSensitive, extractPathsFromBash } from "./security.ts"
|
|
22
24
|
import { isTrivial } from "./tiered.ts"
|
|
23
25
|
import { STATES, TRANSITIONS, DEFAULT_STATE } from "./controller-core.ts"
|
|
@@ -30,6 +32,92 @@ const IDLE_THRESHOLD_MS = 45_000
|
|
|
30
32
|
|
|
31
33
|
// DEFAULT_STATE imported from controller-core.ts
|
|
32
34
|
|
|
35
|
+
// ─── Scope (global vs local) ─────────────────────────────────────────────────
|
|
36
|
+
//
|
|
37
|
+
// OpenCode V2 dedupea plugins por `id` y conserva la PRIMERA ocurrencia en
|
|
38
|
+
// orden de arranque (el config global se descubre antes que el del proyecto).
|
|
39
|
+
// Con el mismo id en las dos copias, la global gana y la local queda
|
|
40
|
+
// `failed: "Duplicate plugin ID"` — el controller deja de aplicar sus gates sin
|
|
41
|
+
// avisar. Para que la precedencia sea local-primero (igual que el resto de la
|
|
42
|
+
// config de OpenCode), la copia global declara OTRO id y se retira sola cuando
|
|
43
|
+
// el proyecto tiene instalación local.
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Config dir GLOBAL de OpenCode. Espeja `Global.Path.config` de OpenCode v2 en
|
|
47
|
+
* las tres plataformas: `OPENCODE_CONFIG_DIR` (override total, sin sufijo) →
|
|
48
|
+
* `$XDG_CONFIG_HOME/opencode` → `~/.config/opencode`. En Windows NO se usa
|
|
49
|
+
* `%APPDATA%` (OpenCode tampoco lo usa para config).
|
|
50
|
+
*/
|
|
51
|
+
export function resolveGlobalConfigDir(opts?: {
|
|
52
|
+
env?: NodeJS.ProcessEnv
|
|
53
|
+
home?: string
|
|
54
|
+
}): string {
|
|
55
|
+
const env = opts?.env ?? process.env
|
|
56
|
+
const home = opts?.home ?? homedir()
|
|
57
|
+
const explicit = env.OPENCODE_CONFIG_DIR?.trim()
|
|
58
|
+
if (explicit) return explicit
|
|
59
|
+
const xdg = env.XDG_CONFIG_HOME?.trim()
|
|
60
|
+
if (xdg) return join(xdg, "opencode")
|
|
61
|
+
return join(home, ".config", "opencode")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* id por copia: la global usa uno propio para no colisionar con la local.
|
|
66
|
+
* En win32 la comparación es case-insensitive porque los paths de Windows lo
|
|
67
|
+
* son: si el path real y el config dir difieren en mayúsculas, sin esto la
|
|
68
|
+
* copia global se clasificaría como local y volvería la colisión de ids.
|
|
69
|
+
*/
|
|
70
|
+
export function pluginIdFor(
|
|
71
|
+
pluginDir: string,
|
|
72
|
+
globalDir: string,
|
|
73
|
+
platform: string = process.platform
|
|
74
|
+
): string {
|
|
75
|
+
if (!pluginDir) return "ostacky-controller"
|
|
76
|
+
try {
|
|
77
|
+
const norm = (p: string) => {
|
|
78
|
+
const abs = resolve(p)
|
|
79
|
+
return platform === "win32" ? abs.toLowerCase() : abs
|
|
80
|
+
}
|
|
81
|
+
const d = norm(pluginDir)
|
|
82
|
+
const g = norm(globalDir)
|
|
83
|
+
if (d === g || d.startsWith(g + sep)) return "ostacky-controller-global"
|
|
84
|
+
} catch {}
|
|
85
|
+
return "ostacky-controller"
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* ¿El proyecto tiene el plugin instalado en scope local? Mira solo el location
|
|
90
|
+
* dir (lo que descubre OpenCode), no ancestros: si el local vive en un ancestro
|
|
91
|
+
* que el server no carga, ceder la precedencia dejaría el proyecto sin gates.
|
|
92
|
+
*/
|
|
93
|
+
export function hasLocalPluginInstall(directory: string): boolean {
|
|
94
|
+
let dir: string
|
|
95
|
+
try {
|
|
96
|
+
dir = resolve(directory)
|
|
97
|
+
} catch {
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
for (const kind of ["plugins", "plugin"]) {
|
|
101
|
+
for (const entry of ["index.ts", "index.js"]) {
|
|
102
|
+
try {
|
|
103
|
+
if (existsSync(join(dir, ".opencode", kind, "ostacky-controller", entry))) return true
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const PLUGIN_DIR = (() => {
|
|
111
|
+
try {
|
|
112
|
+
return dirname(fileURLToPath(import.meta.url))
|
|
113
|
+
} catch {
|
|
114
|
+
return ""
|
|
115
|
+
}
|
|
116
|
+
})()
|
|
117
|
+
|
|
118
|
+
const IS_GLOBAL_COPY = PLUGIN_DIR !== "" && pluginIdFor(PLUGIN_DIR, resolveGlobalConfigDir()) === "ostacky-controller-global"
|
|
119
|
+
const PLUGIN_ID = IS_GLOBAL_COPY ? "ostacky-controller-global" : "ostacky-controller"
|
|
120
|
+
|
|
33
121
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
34
122
|
|
|
35
123
|
function lookupOnPath(name: string): string | null {
|
|
@@ -48,9 +136,18 @@ function lookupOnPath(name: string): string | null {
|
|
|
48
136
|
return null
|
|
49
137
|
}
|
|
50
138
|
|
|
51
|
-
function getStatePath(directory: string): string {
|
|
139
|
+
export function getStatePath(directory: string): string {
|
|
52
140
|
if (process.env.OSTACKY_STATE_PATH) return process.env.OSTACKY_STATE_PATH
|
|
53
141
|
const candidates = [join(directory, "opencode.json"), join(directory, "opencode.jsonc")]
|
|
142
|
+
// El MCP puede estar configurado en el opencode.json GLOBAL: si el plugin solo
|
|
143
|
+
// mira el del proyecto, lee/escribe OTRO state file y todos los gates (PENDING,
|
|
144
|
+
// ownership, lastValidated) quedan ciegos. Mismo orden que assets/plugins/engram.ts.
|
|
145
|
+
try {
|
|
146
|
+
const globalDir = resolveGlobalConfigDir()
|
|
147
|
+
if (globalDir) {
|
|
148
|
+
candidates.push(join(globalDir, "opencode.json"), join(globalDir, "opencode.jsonc"))
|
|
149
|
+
}
|
|
150
|
+
} catch {}
|
|
54
151
|
for (const cand of candidates) {
|
|
55
152
|
try {
|
|
56
153
|
const raw = readFileSync(cand, "utf-8")
|
|
@@ -73,6 +170,59 @@ function readState(directory: string): any | null {
|
|
|
73
170
|
}
|
|
74
171
|
}
|
|
75
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Pendientes para recovery post-compaction. Fuente única del hook `compaction`
|
|
175
|
+
* y del hint de `chat.message`: lastHandoff → fallback <24h → expectedTasks.
|
|
176
|
+
* El checkpoint count-based del MCP solo escribe lastHandoff cada 3er
|
|
177
|
+
* complete_task, así que sin la tercera fuente el hint quedaba mudo en T1/T2.
|
|
178
|
+
*/
|
|
179
|
+
export function recoveryPending(state: any, directory?: string): { pending: string[]; nextSteps: string[] } {
|
|
180
|
+
const isPending = (id: string) => !state?.tasks?.[id] || state.tasks[id].status !== "COMPLETED"
|
|
181
|
+
const fromExpected = () => (Array.isArray(state?.expectedTasks) ? state.expectedTasks.filter(isPending) : [])
|
|
182
|
+
let pending: string[] = Array.isArray(state?.lastHandoff?.pendingTasks)
|
|
183
|
+
? state.lastHandoff.pendingTasks.filter(isPending)
|
|
184
|
+
: []
|
|
185
|
+
let nextSteps: string[] = Array.isArray(state?.lastHandoff?.nextSteps) ? state.lastHandoff.nextSteps : []
|
|
186
|
+
if (pending.length === 0 && directory) {
|
|
187
|
+
try {
|
|
188
|
+
const fallbackPath = join(dirname(getStatePath(directory)), ".ostacky-handoff-compaction.json")
|
|
189
|
+
if (existsSync(fallbackPath)) {
|
|
190
|
+
const data = JSON.parse(readFileSync(fallbackPath, "utf-8"))
|
|
191
|
+
if (data && Array.isArray(data.pendingTasks) && typeof data.ts === "number" && Date.now() - data.ts < 24 * 60 * 60 * 1000) {
|
|
192
|
+
pending = data.pendingTasks.filter(isPending)
|
|
193
|
+
if (Array.isArray(data.nextSteps) && data.nextSteps.length > 0) nextSteps = data.nextSteps
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} catch {}
|
|
197
|
+
}
|
|
198
|
+
if (pending.length === 0) pending = fromExpected()
|
|
199
|
+
if (pending.length === 0) nextSteps = []
|
|
200
|
+
else if (nextSteps.length === 0) nextSteps = [`Continuar con ${pending.join(", ")}`]
|
|
201
|
+
return { pending, nextSteps }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Texto del hint de recovery (null si no hay pendientes). Puro: testeable sin hooks. */
|
|
205
|
+
export function recoveryHint(state: any, directory?: string): string | null {
|
|
206
|
+
if (!state || ["DONE", "INTERPRETATION_PENDING"].includes(state.state)) return null
|
|
207
|
+
const { pending } = recoveryPending(state, directory)
|
|
208
|
+
if (pending.length === 0) return null
|
|
209
|
+
return `\n\n[RECOVERY: te quedan ${pending.slice(0, 3).join(",")}${pending.length > 3 ? `, +${pending.length - 3} más` : ""} - usa get_handoff / mem_context para retomar]`
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Payload del fallback de compaction, con los pendientes reales del state. */
|
|
213
|
+
export function compactionPayload(state: any, directory: string, sessionID?: string, contextSnippet = "") {
|
|
214
|
+
const { pending, nextSteps } = recoveryPending(state, directory)
|
|
215
|
+
return {
|
|
216
|
+
summary: state?.requestId
|
|
217
|
+
? `Compaction fallback for request ${state.requestId} — ${pending.length} pendientes`
|
|
218
|
+
: `Compaction fallback for session ${sessionID ?? "unknown"} — plugin`,
|
|
219
|
+
nextSteps,
|
|
220
|
+
pendingTasks: pending,
|
|
221
|
+
ts: Date.now(),
|
|
222
|
+
contextSnippet,
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
76
226
|
/** Sleep sincrónico acotado — los call sites del plugin son sync. */
|
|
77
227
|
function sleepSync(ms: number): void {
|
|
78
228
|
try {
|
|
@@ -97,10 +247,11 @@ function isPidAlive(pid: number): boolean {
|
|
|
97
247
|
|
|
98
248
|
/**
|
|
99
249
|
* Toma EL MISMO lock que el MCP (`<state>.lock.pid` con `wx`) para serializar
|
|
100
|
-
* plugin ↔ MCP cross-proceso. Devuelve
|
|
101
|
-
* tomar
|
|
250
|
+
* plugin ↔ MCP cross-proceso. Devuelve la función de release, o `null` si no se
|
|
251
|
+
* pudo tomar: el caller NO debe escribir sin lock — un `read` viejo + `rename`
|
|
252
|
+
* posterior puede revertir la transición que el MCP todavía no escribió.
|
|
102
253
|
*/
|
|
103
|
-
function acquireStateLock(statePath: string): () => void {
|
|
254
|
+
function acquireStateLock(statePath: string): (() => void) | null {
|
|
104
255
|
const pidPath = statePath + ".lock.pid"
|
|
105
256
|
const tsPath = statePath + ".lock.timestamp"
|
|
106
257
|
const staleWindow = 15_000
|
|
@@ -119,13 +270,21 @@ function acquireStateLock(statePath: string): () => void {
|
|
|
119
270
|
} catch {}
|
|
120
271
|
try {
|
|
121
272
|
writeFileSync(pidPath, String(process.pid), { encoding: "utf-8", flag: "wx" })
|
|
122
|
-
try {
|
|
123
|
-
|
|
273
|
+
try {
|
|
274
|
+
writeFileSync(tsPath, String(Date.now()), "utf-8")
|
|
275
|
+
acquired = true
|
|
276
|
+
} catch {
|
|
277
|
+
// Sin timestamp el lock es indistinguible de un huérfano y el MCP no
|
|
278
|
+
// puede liberarlo: queda pegado y ninguna escritura vuelve a entrar.
|
|
279
|
+
// Mejor soltarlo y reintentar.
|
|
280
|
+
try { unlinkSync(pidPath) } catch {}
|
|
281
|
+
sleepSync(5 + attempt * 10)
|
|
282
|
+
}
|
|
124
283
|
} catch {
|
|
125
284
|
sleepSync(5 + attempt * 10)
|
|
126
285
|
}
|
|
127
286
|
}
|
|
128
|
-
if (!acquired) return
|
|
287
|
+
if (!acquired) return null
|
|
129
288
|
return () => {
|
|
130
289
|
try {
|
|
131
290
|
if (readFileSync(pidPath, "utf-8").trim() !== String(process.pid)) return
|
|
@@ -139,6 +298,25 @@ function acquireStateLock(statePath: string): () => void {
|
|
|
139
298
|
|
|
140
299
|
let persistSeq = 0
|
|
141
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Patch que no se pudo escribir por falta de lock. Se aplica en la próxima
|
|
303
|
+
* escritura que sí lo consiga: perder un `lastValidated` rompería el gate de
|
|
304
|
+
* `complete_task`, pero escribir sin lock puede revertir un avance del MCP.
|
|
305
|
+
*/
|
|
306
|
+
let pendingPatch: { patch: Record<string, unknown>; ts: number } | null = null
|
|
307
|
+
const PENDING_PATCH_TTL_MS = 60_000
|
|
308
|
+
|
|
309
|
+
function queuePatch(state: any, ownedKeys?: string[]): void {
|
|
310
|
+
if (!ownedKeys || ownedKeys.length === 0) return
|
|
311
|
+
const patch: Record<string, unknown> = {}
|
|
312
|
+
for (const k of ownedKeys) {
|
|
313
|
+
if (Object.prototype.hasOwnProperty.call(state, k)) patch[k] = state[k]
|
|
314
|
+
}
|
|
315
|
+
if (Object.keys(patch).length > 0) {
|
|
316
|
+
pendingPatch = { patch: { ...(pendingPatch?.patch ?? {}), ...patch }, ts: Date.now() }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
142
320
|
/**
|
|
143
321
|
* Merge puro: parte del estado FRESCO de disco y le aplica SOLO las claves que
|
|
144
322
|
* el plugin posee. Garantiza estructuralmente que una escritura del plugin no
|
|
@@ -199,6 +377,10 @@ export function persistState(directory: string, state: any, ownedKeys?: string[]
|
|
|
199
377
|
try { mkdirSync(dir, { recursive: true }) } catch {}
|
|
200
378
|
|
|
201
379
|
const release = acquireStateLock(p)
|
|
380
|
+
if (!release) {
|
|
381
|
+
queuePatch(state, ownedKeys)
|
|
382
|
+
return
|
|
383
|
+
}
|
|
202
384
|
try {
|
|
203
385
|
let payload: any = state
|
|
204
386
|
if (ownedKeys && ownedKeys.length > 0) {
|
|
@@ -207,6 +389,16 @@ export function persistState(directory: string, state: any, ownedKeys?: string[]
|
|
|
207
389
|
if (fresh && typeof fresh === "object" && !Array.isArray(fresh)) {
|
|
208
390
|
payload = mergePluginOwnedState(fresh, state, ownedKeys)
|
|
209
391
|
}
|
|
392
|
+
if (pendingPatch) {
|
|
393
|
+
// TTL: un patch viejo (p.ej. de otra request) no debe satisfacer gates
|
|
394
|
+
// actuales, como un `lastValidated` que ya no corresponde.
|
|
395
|
+
if (Date.now() - pendingPatch.ts > PENDING_PATCH_TTL_MS) {
|
|
396
|
+
pendingPatch = null
|
|
397
|
+
} else {
|
|
398
|
+
payload = { ...payload, ...pendingPatch.patch }
|
|
399
|
+
pendingPatch = null
|
|
400
|
+
}
|
|
401
|
+
}
|
|
210
402
|
}
|
|
211
403
|
let serialized = JSON.stringify(payload, null, 2)
|
|
212
404
|
if (serialized.length > MAX_STATE_FILE_SIZE) {
|
|
@@ -234,6 +426,27 @@ export function persistState(directory: string, state: any, ownedKeys?: string[]
|
|
|
234
426
|
}
|
|
235
427
|
}
|
|
236
428
|
|
|
429
|
+
/**
|
|
430
|
+
* Proyección del state que expone `ostacky_get_state`. Incluye los decision IDs:
|
|
431
|
+
* sin ellos el agente no puede consumir la decisión pendiente sin volver a
|
|
432
|
+
* llamar a get_state por MCP (fuente del doble pregunta).
|
|
433
|
+
*/
|
|
434
|
+
export function projectStateForTool(state: any): Record<string, unknown> {
|
|
435
|
+
if (!state) return { error: "no state", state: "UNKNOWN", revision: 0 }
|
|
436
|
+
return {
|
|
437
|
+
state: state.state,
|
|
438
|
+
revision: state.revision,
|
|
439
|
+
requestId: state.requestId,
|
|
440
|
+
changeId: state.changeId ?? null,
|
|
441
|
+
degraded: !!state.degraded,
|
|
442
|
+
level: state.level ?? null,
|
|
443
|
+
routeChoice: state.routeChoice ?? null,
|
|
444
|
+
routeDecisionId: state.routeDecisionId ?? null,
|
|
445
|
+
executionDecisionId: state.executionDecisionId ?? null,
|
|
446
|
+
executionMode: state.executionMode ?? null,
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
237
450
|
function fastFingerprint(filePath: string): string | null {
|
|
238
451
|
try {
|
|
239
452
|
const s = statSync(filePath)
|
|
@@ -525,10 +738,15 @@ function extractPathsFromPatch(patchText: string): string[] {
|
|
|
525
738
|
// Objeto literal directo (ver nota en imports): el server V2 acepta
|
|
526
739
|
// `{ id, setup }` sin pasar por `Plugin.define()`.
|
|
527
740
|
export default {
|
|
528
|
-
id:
|
|
741
|
+
id: PLUGIN_ID,
|
|
529
742
|
async setup(ctx) {
|
|
530
743
|
const directory = ctx.location.directory
|
|
531
744
|
|
|
745
|
+
// Precedencia local: si el proyecto tiene su propia instalación, esta copia
|
|
746
|
+
// global no registra hooks ni tools (OpenCode ya la cargó porque el config
|
|
747
|
+
// global se descubre primero; ver nota de scope arriba).
|
|
748
|
+
if (IS_GLOBAL_COPY && hasLocalPluginInstall(directory)) return
|
|
749
|
+
|
|
532
750
|
// Track trivial flag per session to gate tools
|
|
533
751
|
const trivialBySession = new Map<string, boolean>()
|
|
534
752
|
// Track discovery hit per requestId to avoid blocking after hit
|
|
@@ -604,26 +822,8 @@ export default {
|
|
|
604
822
|
// harden-compaction-resume: auto-inject recovery hint when pending (determinístico, no depende del modelo)
|
|
605
823
|
if (!trivial && state && !["DONE", "INTERPRETATION_PENDING"].includes(state.state)) {
|
|
606
824
|
try {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
: []
|
|
610
|
-
if (pending.length === 0) {
|
|
611
|
-
try {
|
|
612
|
-
const fallbackPath = join(dirname(getStatePath(directory)), ".ostacky-handoff-compaction.json")
|
|
613
|
-
if (existsSync(fallbackPath)) {
|
|
614
|
-
const raw = readFileSync(fallbackPath, "utf-8")
|
|
615
|
-
const data = JSON.parse(raw)
|
|
616
|
-
if (data && Array.isArray(data.pendingTasks) && typeof data.ts === "number" && Date.now() - data.ts < 24 * 60 * 60 * 1000) {
|
|
617
|
-
const fbPending = data.pendingTasks.filter((id: string) => !state.tasks?.[id] || state.tasks[id].status !== "COMPLETED")
|
|
618
|
-
if (fbPending.length > 0) pending = fbPending
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
} catch {}
|
|
622
|
-
}
|
|
623
|
-
if (pending.length > 0) {
|
|
624
|
-
const hint = `\n\n[RECOVERY: te quedan ${pending.slice(0, 3).join(",")}${pending.length > 3 ? `, +${pending.length - 3} más` : ""} - usa get_handoff / mem_context para retomar]`
|
|
625
|
-
event.prompt.text = (event.prompt.text ?? "") + hint
|
|
626
|
-
}
|
|
825
|
+
const hint = recoveryHint(state, directory)
|
|
826
|
+
if (hint) event.prompt.text = (event.prompt.text ?? "") + hint
|
|
627
827
|
} catch {}
|
|
628
828
|
}
|
|
629
829
|
})
|
|
@@ -1085,11 +1285,7 @@ export default {
|
|
|
1085
1285
|
name: "ostacky_get_state",
|
|
1086
1286
|
description: "Get Ostacky controller state (plugin, no MCP needed)",
|
|
1087
1287
|
input: emptySchema,
|
|
1088
|
-
execute: async () =>
|
|
1089
|
-
const state = readState(directory)
|
|
1090
|
-
if (!state) return jsonContent({ error: "no state", state: "UNKNOWN", revision: 0 })
|
|
1091
|
-
return jsonContent({ state: state.state, revision: state.revision, requestId: state.requestId, degraded: !!state.degraded, level: state.level, routeChoice: state.routeChoice })
|
|
1092
|
-
},
|
|
1288
|
+
execute: async () => jsonContent(projectStateForTool(readState(directory))),
|
|
1093
1289
|
})
|
|
1094
1290
|
editor.add({
|
|
1095
1291
|
name: "ostacky_get_audit",
|
|
@@ -1190,13 +1386,10 @@ export default {
|
|
|
1190
1386
|
return JSON.stringify(event.messages.slice(0, 2)).slice(0, 800)
|
|
1191
1387
|
} catch { return "" }
|
|
1192
1388
|
})()
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
ts: Date.now(),
|
|
1198
|
-
contextSnippet,
|
|
1199
|
-
}
|
|
1389
|
+
// Los pendientes salen del state real, no de una lista vacía: sin esto
|
|
1390
|
+
// el hint de recovery nunca disparaba mid-run (checkpoint cada 3 tasks).
|
|
1391
|
+
const current = readState(directory)
|
|
1392
|
+
const payload = compactionPayload(current, directory, event.sessionID as string | undefined, contextSnippet)
|
|
1200
1393
|
const tmp = `${fallbackPath}.tmp.${process.pid}`
|
|
1201
1394
|
writeFileSync(tmp, JSON.stringify(payload, null, 2), "utf-8")
|
|
1202
1395
|
renameSync(tmp, fallbackPath)
|