ostacky 0.10.1 → 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 +14 -5
- package/assets/commands/install-stack.md +1 -1
- package/assets/docs/ostacky-reference.md +16 -0
- package/assets/mcp/ostacky-controller/index.js +225 -37
- package/assets/plugins/engram.ts +13 -9
- package/assets/plugins/ostacky-controller/index.ts +300 -48
- package/dist/cli.js +143 -67
- 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 +8 -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
|
|
@@ -152,6 +330,34 @@ export function mergePluginOwnedState(fresh: any, state: any, ownedKeys: string[
|
|
|
152
330
|
return payload
|
|
153
331
|
}
|
|
154
332
|
|
|
333
|
+
/**
|
|
334
|
+
* multisession-ownership-guard: hay UN state file por proyecto, así que el PENDING
|
|
335
|
+
* puede ser de otra sesión. El gate no debe congelar sesiones ajenas a esa request
|
|
336
|
+
* (antes, cualquier sesión quedaba bloqueada por la confirmación pendiente de otra).
|
|
337
|
+
*
|
|
338
|
+
* No aplica cuando no hay dueño (estado legacy), cuando el caller es el dueño, ni
|
|
339
|
+
* cuando el caller es desconocido (`event.sessionID` ausente → "default"): sin
|
|
340
|
+
* identidad no se puede probar ajeneidad.
|
|
341
|
+
*/
|
|
342
|
+
export function isForeignPendingOwner(state: any, sessionId: string | null | undefined): boolean {
|
|
343
|
+
const owner = state?.ownerSessionId
|
|
344
|
+
if (!owner || !sessionId || sessionId === "default") return false
|
|
345
|
+
return owner !== sessionId
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Cache del gate PENDING. El cache es por `revision`, NO por dueño: si el paso de
|
|
350
|
+
* una sesión ajena dejara un ALLOW, la dueña lo reutilizaría y se saltearía su
|
|
351
|
+
* propia confirmación. Por eso un PENDING ajeno no cachea nada.
|
|
352
|
+
*/
|
|
353
|
+
export function nextPendingGateCache(
|
|
354
|
+
revision: number,
|
|
355
|
+
foreignPendingOwner: boolean
|
|
356
|
+
): { revision: number; result: string } | null {
|
|
357
|
+
if (foreignPendingOwner) return null
|
|
358
|
+
return { revision, result: "ALLOW" }
|
|
359
|
+
}
|
|
360
|
+
|
|
155
361
|
/**
|
|
156
362
|
* Persiste estado SIN poder pisar al MCP.
|
|
157
363
|
*
|
|
@@ -171,6 +377,10 @@ export function persistState(directory: string, state: any, ownedKeys?: string[]
|
|
|
171
377
|
try { mkdirSync(dir, { recursive: true }) } catch {}
|
|
172
378
|
|
|
173
379
|
const release = acquireStateLock(p)
|
|
380
|
+
if (!release) {
|
|
381
|
+
queuePatch(state, ownedKeys)
|
|
382
|
+
return
|
|
383
|
+
}
|
|
174
384
|
try {
|
|
175
385
|
let payload: any = state
|
|
176
386
|
if (ownedKeys && ownedKeys.length > 0) {
|
|
@@ -179,6 +389,16 @@ export function persistState(directory: string, state: any, ownedKeys?: string[]
|
|
|
179
389
|
if (fresh && typeof fresh === "object" && !Array.isArray(fresh)) {
|
|
180
390
|
payload = mergePluginOwnedState(fresh, state, ownedKeys)
|
|
181
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
|
+
}
|
|
182
402
|
}
|
|
183
403
|
let serialized = JSON.stringify(payload, null, 2)
|
|
184
404
|
if (serialized.length > MAX_STATE_FILE_SIZE) {
|
|
@@ -206,6 +426,27 @@ export function persistState(directory: string, state: any, ownedKeys?: string[]
|
|
|
206
426
|
}
|
|
207
427
|
}
|
|
208
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
|
+
|
|
209
450
|
function fastFingerprint(filePath: string): string | null {
|
|
210
451
|
try {
|
|
211
452
|
const s = statSync(filePath)
|
|
@@ -297,6 +538,20 @@ export function marksDiscovery(toolName: string): boolean {
|
|
|
297
538
|
)
|
|
298
539
|
}
|
|
299
540
|
|
|
541
|
+
/**
|
|
542
|
+
* Predicado puro del horizonte del gate de discovery: ¿este prompt invalida el
|
|
543
|
+
* hit estructural de la sesión?
|
|
544
|
+
*
|
|
545
|
+
* El contrato de reuso es POR REQUEST ("reuso del output ya obtenido en el request",
|
|
546
|
+
* remove-discovery-cache-phantom archivado 2026-10-19), no por sesión: la instrucción
|
|
547
|
+
* §1 del agente y record_discovery son por request; el gate era el único desalineado.
|
|
548
|
+
* Los prompts TRIVIALES no invalidan: el plugin les inyecta "No hagas Discovery"
|
|
549
|
+
* (hint :555) — limpiar el hit ahí crearía instrucción y gate contradictorias.
|
|
550
|
+
*/
|
|
551
|
+
export function shouldInvalidateDiscoveryHit(trivial: boolean): boolean {
|
|
552
|
+
return !trivial
|
|
553
|
+
}
|
|
554
|
+
|
|
300
555
|
/**
|
|
301
556
|
* Mismo criterio de "archivo de código" que el gate de read (index.ts:552),
|
|
302
557
|
* sin la cláusula de `pattern` que es específica de glob. Docs/JSON quedan fuera:
|
|
@@ -483,10 +738,15 @@ function extractPathsFromPatch(patchText: string): string[] {
|
|
|
483
738
|
// Objeto literal directo (ver nota en imports): el server V2 acepta
|
|
484
739
|
// `{ id, setup }` sin pasar por `Plugin.define()`.
|
|
485
740
|
export default {
|
|
486
|
-
id:
|
|
741
|
+
id: PLUGIN_ID,
|
|
487
742
|
async setup(ctx) {
|
|
488
743
|
const directory = ctx.location.directory
|
|
489
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
|
+
|
|
490
750
|
// Track trivial flag per session to gate tools
|
|
491
751
|
const trivialBySession = new Map<string, boolean>()
|
|
492
752
|
// Track discovery hit per requestId to avoid blocking after hit
|
|
@@ -550,6 +810,10 @@ export default {
|
|
|
550
810
|
const currentState = state?.state ?? "DONE"
|
|
551
811
|
const trivial = isTrivial(text, currentState)
|
|
552
812
|
trivialBySession.set(sessionId, trivial)
|
|
813
|
+
// Horizonte por request: prompts NO-triviales invalidan el discovery hit —
|
|
814
|
+
// la siguiente lectura de código exige un explore fresco (contrato
|
|
815
|
+
// remove-discovery-cache-phantom + instrucción §1 del agente).
|
|
816
|
+
if (shouldInvalidateDiscoveryHit(trivial)) discoveryHitByRequest.delete(sessionId)
|
|
553
817
|
if (trivial) {
|
|
554
818
|
// Preserve system FULL cacheable, add suffix hint to user prompt text
|
|
555
819
|
const hint = "\n\n[PLUGIN HINT: Saludo trivial — responde breve sin tools. No hagas Discovery.]"
|
|
@@ -558,26 +822,8 @@ export default {
|
|
|
558
822
|
// harden-compaction-resume: auto-inject recovery hint when pending (determinístico, no depende del modelo)
|
|
559
823
|
if (!trivial && state && !["DONE", "INTERPRETATION_PENDING"].includes(state.state)) {
|
|
560
824
|
try {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
: []
|
|
564
|
-
if (pending.length === 0) {
|
|
565
|
-
try {
|
|
566
|
-
const fallbackPath = join(dirname(getStatePath(directory)), ".ostacky-handoff-compaction.json")
|
|
567
|
-
if (existsSync(fallbackPath)) {
|
|
568
|
-
const raw = readFileSync(fallbackPath, "utf-8")
|
|
569
|
-
const data = JSON.parse(raw)
|
|
570
|
-
if (data && Array.isArray(data.pendingTasks) && typeof data.ts === "number" && Date.now() - data.ts < 24 * 60 * 60 * 1000) {
|
|
571
|
-
const fbPending = data.pendingTasks.filter((id: string) => !state.tasks?.[id] || state.tasks[id].status !== "COMPLETED")
|
|
572
|
-
if (fbPending.length > 0) pending = fbPending
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
} catch {}
|
|
576
|
-
}
|
|
577
|
-
if (pending.length > 0) {
|
|
578
|
-
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]`
|
|
579
|
-
event.prompt.text = (event.prompt.text ?? "") + hint
|
|
580
|
-
}
|
|
825
|
+
const hint = recoveryHint(state, directory)
|
|
826
|
+
if (hint) event.prompt.text = (event.prompt.text ?? "") + hint
|
|
581
827
|
} catch {}
|
|
582
828
|
}
|
|
583
829
|
})
|
|
@@ -606,7 +852,9 @@ export default {
|
|
|
606
852
|
checkCount++
|
|
607
853
|
const freshState = readState(directory)
|
|
608
854
|
const pendingStates = ["ROUTE_DECISION_PENDING", "EXECUTION_DECISION_PENDING", "CLARIFICATION_PENDING"]
|
|
609
|
-
|
|
855
|
+
// multisession-ownership-guard: el PENDING de OTRA sesión no congela esta sesión.
|
|
856
|
+
const foreignPendingOwner = !!freshState && isForeignPendingOwner(freshState, sessionId)
|
|
857
|
+
if (freshState && pendingStates.includes(freshState.state) && !foreignPendingOwner) {
|
|
610
858
|
// cache ALLOW per revision, BLOCKED never cached
|
|
611
859
|
if (lastCheck && freshState.revision === lastCheck.revision && checkCount % 5 !== 0 && lastCheck.result === "ALLOW") {
|
|
612
860
|
// reuse cached ALLOW
|
|
@@ -628,7 +876,7 @@ export default {
|
|
|
628
876
|
}
|
|
629
877
|
}
|
|
630
878
|
} else if (freshState) {
|
|
631
|
-
lastCheck =
|
|
879
|
+
lastCheck = nextPendingGateCache(freshState.revision, foreignPendingOwner)
|
|
632
880
|
}
|
|
633
881
|
|
|
634
882
|
// ── 1.6) Engram soft gate (auditable, no BLOCK) — refuerza SHALL 1 de ostacky.md ──
|
|
@@ -728,12 +976,22 @@ export default {
|
|
|
728
976
|
const gated = isCodeReadGated({ isCodeFile, isLiteralGrep, isSpecTasksPath, isDocsPath, isTaskTracked })
|
|
729
977
|
const codegraphOk = gated ? isCodegraphAvailable(directory) : false
|
|
730
978
|
// Sin fallback a disco: la única evidencia válida es un codegraph_explore
|
|
731
|
-
// COMPLETADO en
|
|
732
|
-
// session.deleted
|
|
979
|
+
// COMPLETADO en este request (discoveryHitByRequest; el hook "prompt"
|
|
980
|
+
// invalida el hit en cada prompt no-trivial). session.deleted también lo limpia.
|
|
733
981
|
const hasDiscoveryHit = codegraphOk ? discoveryHitByRequest.get(sessionId) === true : false
|
|
734
982
|
if (shouldBlockCodeRead({
|
|
735
983
|
isCodeFile, isLiteralGrep, isSpecTasksPath, isDocsPath, isTaskTracked, codegraphOk, hasDiscoveryHit,
|
|
736
984
|
})) {
|
|
985
|
+
// Métrica de evasión real (misma convención que engramBypassCount:648):
|
|
986
|
+
// cuántas lecturas de código saltaron el gate sin explore completado.
|
|
987
|
+
try {
|
|
988
|
+
const st = readState(directory)
|
|
989
|
+
if (st) {
|
|
990
|
+
st.codeReadBlockedCount = (st.codeReadBlockedCount || 0) + 1
|
|
991
|
+
persistState(directory, st, ["codeReadBlockedCount"])
|
|
992
|
+
}
|
|
993
|
+
} catch {}
|
|
994
|
+
try { console.warn(`[ostacky] code_read_blocked: sin codegraph_explore completado en sesión (${targetPath || "query"})`) } catch {}
|
|
737
995
|
throw new Error(
|
|
738
996
|
`BLOCKED: sin discovery estructural en esta sesión — llamá codegraph_codegraph_explore("${targetPath || "query"}") antes de Read/Grep/Glob de código`
|
|
739
997
|
)
|
|
@@ -952,9 +1210,10 @@ export default {
|
|
|
952
1210
|
// Track discovery hit to allow subsequent Reads
|
|
953
1211
|
const tool: string = event.tool as string
|
|
954
1212
|
const sessionId: string = (event.sessionID ?? "default") as string
|
|
955
|
-
// (fix-codegraph-gate) Evidencia REAL de discovery: un `codegraph_explore` completado en
|
|
956
|
-
//
|
|
957
|
-
//
|
|
1213
|
+
// (fix-codegraph-gate) Evidencia REAL de discovery: un `codegraph_explore` completado en ESTE
|
|
1214
|
+
// request abre el gate de lecturas de código (el hook "prompt" invalida el hit en cada
|
|
1215
|
+
// prompt no-trivial). Se marca aunque el resultado venga vacío, a propósito:
|
|
1216
|
+
// "CodeGraph no tiene este archivo en su índice" es justamente el caso donde el
|
|
958
1217
|
// Read TIENE que poder pasar si hubo `codegraph_explore`.
|
|
959
1218
|
if (marksDiscovery(tool)) {
|
|
960
1219
|
try {
|
|
@@ -1026,11 +1285,7 @@ export default {
|
|
|
1026
1285
|
name: "ostacky_get_state",
|
|
1027
1286
|
description: "Get Ostacky controller state (plugin, no MCP needed)",
|
|
1028
1287
|
input: emptySchema,
|
|
1029
|
-
execute: async () =>
|
|
1030
|
-
const state = readState(directory)
|
|
1031
|
-
if (!state) return jsonContent({ error: "no state", state: "UNKNOWN", revision: 0 })
|
|
1032
|
-
return jsonContent({ state: state.state, revision: state.revision, requestId: state.requestId, degraded: !!state.degraded, level: state.level, routeChoice: state.routeChoice })
|
|
1033
|
-
},
|
|
1288
|
+
execute: async () => jsonContent(projectStateForTool(readState(directory))),
|
|
1034
1289
|
})
|
|
1035
1290
|
editor.add({
|
|
1036
1291
|
name: "ostacky_get_audit",
|
|
@@ -1131,13 +1386,10 @@ export default {
|
|
|
1131
1386
|
return JSON.stringify(event.messages.slice(0, 2)).slice(0, 800)
|
|
1132
1387
|
} catch { return "" }
|
|
1133
1388
|
})()
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
ts: Date.now(),
|
|
1139
|
-
contextSnippet,
|
|
1140
|
-
}
|
|
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)
|
|
1141
1393
|
const tmp = `${fallbackPath}.tmp.${process.pid}`
|
|
1142
1394
|
writeFileSync(tmp, JSON.stringify(payload, null, 2), "utf-8")
|
|
1143
1395
|
renameSync(tmp, fallbackPath)
|