ostacky 0.7.1 → 0.7.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ostacky-controller",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -15,12 +15,26 @@
15
15
  */
16
16
 
17
17
  import type { Plugin } from "@opencode-ai/plugin"
18
+ import { join, dirname, basename } from "path"
19
+ import { readFileSync, writeFileSync, renameSync, mkdirSync } from "fs"
18
20
 
19
21
  // ─── Configuration ───────────────────────────────────────────────────────────
20
22
 
21
23
  const ENGRAM_PORT = parseInt(process.env.ENGRAM_PORT ?? "7437")
22
24
  const ENGRAM_URL = `http://127.0.0.1:${ENGRAM_PORT}`
23
- const ENGRAM_BIN = process.env.ENGRAM_BIN ?? Bun.which("engram") ?? ".opencode/tools/engram/bin/engram"
25
+ // C3/H2 fix: resolve ENGRAM_BIN per ctx.directory with win32 .exe and absolute fallback
26
+ function resolveEngramBin(directory: string): string {
27
+ if (process.env.ENGRAM_BIN) {
28
+ const p = process.env.ENGRAM_BIN
29
+ const isAbs = p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p)
30
+ return isAbs ? p : join(directory, p)
31
+ }
32
+ const which = Bun.which("engram")
33
+ if (which) return which
34
+ const suffix = process.platform === "win32" ? ".exe" : ""
35
+ return join(directory, ".opencode", "tools", "engram", "bin", `engram${suffix}`)
36
+ }
37
+ // ENGRAM_BIN eliminado: reemplazado por resolveEngramBin(ctx.directory) que maneja .exe+absolutización correctamente
24
38
 
25
39
  // Engram's own MCP tools — don't count these as "tool calls" for session stats
26
40
  const ENGRAM_TOOLS = new Set([
@@ -171,12 +185,12 @@ function extractProjectName(directory: string): string {
171
185
  const result = Bun.spawnSync(["git", "-C", directory, "rev-parse", "--show-toplevel"])
172
186
  if (result.exitCode === 0) {
173
187
  const root = result.stdout?.toString().trim()
174
- if (root) return root.split("/").pop() ?? "unknown"
188
+ if (root) return basename(root.replace(/\\/g, "/")) ?? "unknown"
175
189
  }
176
190
  } catch {}
177
191
 
178
- // Final fallback: cwd basename
179
- return directory.split("/").pop() ?? "unknown"
192
+ // Final fallback: cwd basename (cross-platform)
193
+ return basename(directory.replace(/\\/g, "/")) ?? "unknown"
180
194
  }
181
195
 
182
196
  function truncate(str: string, max: number): string {
@@ -194,10 +208,51 @@ function stripPrivateTags(str: string): string {
194
208
  return str.replace(/<private>[\s\S]*?<\/private>/gi, "[REDACTED]").trim()
195
209
  }
196
210
 
211
+ function stripJsoncComments(text: string): string {
212
+ let result = ""
213
+ let i = 0
214
+ let inString = false
215
+ while (i < text.length) {
216
+ const char = text[i]
217
+ const next = text[i + 1]
218
+ if (inString) {
219
+ if (char === "\\") {
220
+ result += char + (next ?? "")
221
+ i += 2
222
+ continue
223
+ }
224
+ if (char === '"') inString = false
225
+ result += char
226
+ i++
227
+ continue
228
+ }
229
+ if (char === '"') {
230
+ inString = true
231
+ result += char
232
+ i++
233
+ continue
234
+ }
235
+ if (char === "/" && next === "/") {
236
+ while (i < text.length && text[i] !== "\n") i++
237
+ continue
238
+ }
239
+ if (char === "/" && next === "*") {
240
+ i += 2
241
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++
242
+ i += 2
243
+ continue
244
+ }
245
+ result += char
246
+ i++
247
+ }
248
+ return result.replace(/,\s*([}\]])/g, "$1")
249
+ }
250
+
197
251
  // ─── Plugin Export ───────────────────────────────────────────────────────────
198
252
 
199
253
  export const Engram: Plugin = async (ctx) => {
200
- const oldProject = ctx.directory.split("/").pop() ?? "unknown"
254
+ // T4: basename multiplataforma — split("/") producía keys basura con backslashes en Windows nativo
255
+ const oldProject = basename(ctx.directory.replace(/\\/g, "/")) ?? "unknown"
201
256
  const project = extractProjectName(ctx.directory)
202
257
 
203
258
  // Track tool counts per session (in-memory only, not critical)
@@ -236,11 +291,12 @@ export const Engram: Plugin = async (ctx) => {
236
291
  })
237
292
  }
238
293
 
239
- // Try to start engram server if not running
294
+ // Try to start engram server if not running — use per-directory resolved bin (win32 .exe + absolute)
295
+ const engramBin = resolveEngramBin(ctx.directory)
240
296
  const running = await isEngramRunning()
241
297
  if (!running) {
242
298
  try {
243
- Bun.spawn([ENGRAM_BIN, "serve"], {
299
+ Bun.spawn([engramBin, "serve"], {
244
300
  stdout: "ignore",
245
301
  stderr: "ignore",
246
302
  stdin: "ignore",
@@ -268,7 +324,7 @@ export const Engram: Plugin = async (ctx) => {
268
324
  const manifestFile = `${ctx.directory}/.engram/manifest.json`
269
325
  const file = Bun.file(manifestFile)
270
326
  if (await file.exists()) {
271
- Bun.spawn([ENGRAM_BIN, "sync", "--import"], {
327
+ Bun.spawn([engramBin, "sync", "--import"], {
272
328
  cwd: ctx.directory,
273
329
  stdout: "ignore",
274
330
  stderr: "ignore",
@@ -513,6 +569,60 @@ export const Engram: Plugin = async (ctx) => {
513
569
  await ensureSession(input.sessionID)
514
570
  }
515
571
 
572
+ // C3: Compaction fallback file — write directly to same anchor as controller's get_handoff
573
+ // Resolves statePath from opencode.json (local) or global config, default .opencode/ostacky-state.json
574
+ try {
575
+ let statePath: string | null = null
576
+ // 1) env var if set
577
+ if (process.env.OSTACKY_STATE_PATH) {
578
+ statePath = process.env.OSTACKY_STATE_PATH
579
+ }
580
+ // 2) try local opencode.json / jsonc in project
581
+ if (!statePath) {
582
+ const candidates = [join(ctx.directory, "opencode.json"), join(ctx.directory, "opencode.jsonc")]
583
+ // also try global config (XDG / APPDATA)
584
+ try {
585
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? ""
586
+ if (home) {
587
+ const xdg = process.env.XDG_CONFIG_HOME ?? join(home, ".config")
588
+ candidates.push(join(xdg, "opencode", "opencode.json"))
589
+ candidates.push(join(xdg, "opencode", "opencode.jsonc"))
590
+ if (process.platform === "win32" && process.env.APPDATA) {
591
+ candidates.push(join(process.env.APPDATA, "opencode", "opencode.json"))
592
+ candidates.push(join(process.env.APPDATA, "opencode", "opencode.jsonc"))
593
+ }
594
+ }
595
+ } catch {}
596
+ for (const cand of candidates) {
597
+ try {
598
+ const raw = readFileSync(cand, "utf-8")
599
+ const j = stripJsoncComments(raw)
600
+ const cfg = JSON.parse(j)
601
+ const envPath = (cfg as any)?.mcp?.["ostacky-controller"]?.environment?.OSTACKY_STATE_PATH
602
+ if (typeof envPath === "string" && envPath) {
603
+ statePath = envPath
604
+ break
605
+ }
606
+ } catch {}
607
+ }
608
+ }
609
+ if (!statePath) statePath = join(ctx.directory, ".opencode", "ostacky-state.json")
610
+ const fallbackPath = join(dirname(statePath), ".ostacky-handoff-compaction.json")
611
+ try { mkdirSync(dirname(fallbackPath), { recursive: true }) } catch {}
612
+ const payload = {
613
+ summary: `Compaction fallback for session ${input.sessionID ?? "unknown"} — project ${project}`,
614
+ nextSteps: [] as string[],
615
+ pendingTasks: [] as string[],
616
+ ts: Date.now(),
617
+ contextSnippet: output.context?.slice(0, 2).join("\n\n").slice(0, 1000) ?? "",
618
+ }
619
+ const tmp = `${fallbackPath}.tmp.${process.pid}`
620
+ writeFileSync(tmp, JSON.stringify(payload, null, 2), "utf-8")
621
+ renameSync(tmp, fallbackPath)
622
+ } catch {
623
+ // fallback is best-effort — never crash compacting
624
+ }
625
+
516
626
  // Inject context from previous sessions
517
627
  const data = await engramFetch(
518
628
  `/context?project=${encodeURIComponent(project)}`
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Ostacky Guard — plugin hermano de Engram
3
+ *
4
+ * Bloquea lecturas sensibles sin autorización previa y bloquea cualquier tool
5
+ * cuando el controller está en estado PENDING.
6
+ *
7
+ * Se registra en `tool.execute.before` y lanza Error si la operación no está
8
+ * permitida. OpenCode aborta el tool antes de tocar disco.
9
+ */
10
+
11
+ import type { Plugin } from "@opencode-ai/plugin"
12
+ import { readFileSync, existsSync } from "node:fs"
13
+ import { join, dirname, basename, resolve, relative } from "node:path"
14
+
15
+ const SENSITIVE_DEFAULT = [
16
+ "**/.env*",
17
+ "**/.secrets/**",
18
+ "**/*.pem",
19
+ "**/*.key",
20
+ "**/.aws/**",
21
+ "**/.ssh/**",
22
+ "**/credentials.json",
23
+ "**/.npmrc",
24
+ ]
25
+
26
+ function getEnvPatterns(): string[] {
27
+ const raw = process.env.OSTACKY_SENSITIVE_PATTERNS
28
+ if (!raw) return SENSITIVE_DEFAULT
29
+ return raw.split(",").map((s) => s.trim()).filter(Boolean)
30
+ }
31
+
32
+ function isSensitive(filePath: string, patterns: string[]): boolean {
33
+ if (!filePath) return false
34
+ const lower = filePath.toLowerCase()
35
+ if (lower.endsWith(".env.example") || lower.endsWith(".env.template") || lower.endsWith(".env.sample")) return false
36
+ for (const pat of patterns) {
37
+ if (pat.includes(".env") && lower.split("/").pop()!.startsWith(".env")) return true
38
+ if (pat.includes(".secrets") && lower.includes(".secrets")) return true
39
+ if (pat.includes("*.pem") && lower.endsWith(".pem")) return true
40
+ if (pat.includes("*.key") && lower.endsWith(".key")) return true
41
+ if (pat.includes(".aws") && lower.includes(".aws")) return true
42
+ if (pat.includes(".ssh") && lower.includes(".ssh")) return true
43
+ if (pat.includes("credentials.json") && lower.endsWith("credentials.json")) return true
44
+ if (pat.includes(".npmrc") && lower.endsWith(".npmrc")) return true
45
+ }
46
+ if (/\.(pem|key)$/i.test(filePath)) return true
47
+ if (filePath.includes(".env")) {
48
+ const base = filePath.split("/").pop() || ""
49
+ if (base.startsWith(".env")) return true
50
+ }
51
+ return false
52
+ }
53
+
54
+ function getStatePath(directory: string): string {
55
+ if (process.env.OSTACKY_STATE_PATH) return process.env.OSTACKY_STATE_PATH
56
+ // try opencode.json
57
+ const candidates = [join(directory, "opencode.json"), join(directory, "opencode.jsonc")]
58
+ for (const cand of candidates) {
59
+ try {
60
+ const raw = readFileSync(cand, "utf-8")
61
+ // naive parse, ignore comments
62
+ const json = JSON.parse(raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""))
63
+ const envPath = (json as any)?.mcp?.["ostacky-controller"]?.environment?.OSTACKY_STATE_PATH
64
+ if (typeof envPath === "string" && envPath) return envPath
65
+ } catch {}
66
+ }
67
+ return join(directory, ".opencode", "ostacky-state.json")
68
+ }
69
+
70
+ function readState(directory: string): any | null {
71
+ const p = getStatePath(directory)
72
+ try {
73
+ const raw = readFileSync(p, "utf-8")
74
+ return JSON.parse(raw)
75
+ } catch {
76
+ return null
77
+ }
78
+ }
79
+
80
+ export const OstackyGuard: Plugin = async (ctx) => {
81
+ const patterns = getEnvPatterns()
82
+
83
+ return {
84
+ "tool.execute.before": async (input, output) => {
85
+ const tool = (input as any).tool as string
86
+ const args = (input as any).args as any
87
+
88
+ // Extraer filePath de args según tool
89
+ let filePath: string | undefined
90
+ if (tool === "read" || tool === "read_mcp_resource" || tool === "grep" || tool === "glob") {
91
+ filePath = args?.filePath || args?.path || args?.pattern || args?.uri || ""
92
+ // Para grep, el pattern puede ser contenido, no file; intentar detectar file
93
+ if (tool === "grep" && args?.include) filePath = args.include
94
+ if (tool === "read_mcp_resource" && typeof args?.uri === "string") {
95
+ // uri puede ser file:// o path
96
+ try {
97
+ const u = args.uri as string
98
+ if (u.startsWith("file://")) filePath = u.slice(7)
99
+ else if (u.includes("/") ) filePath = u
100
+ } catch {}
101
+ }
102
+ }
103
+
104
+ // 10.2: Guard genérico de PENDING — bloquear cualquier tool no-controller cuando estado es PENDING
105
+ const state = readState(ctx.directory)
106
+ const pendingStates = ["ROUTE_DECISION_PENDING", "EXECUTION_DECISION_PENDING", "CLARIFICATION_PENDING"]
107
+ if (state && pendingStates.includes(state.state)) {
108
+ // Permitir solo tools del controller que desbloquean
109
+ const allowedTools = ["consume_route_decision", "consume_execution_decision", "record_clarification", "abandon", "check_file_access", "consume_file_access_decision", "record_user_confirmation"]
110
+ const isControllerTool = tool.startsWith("ostacky-controller_") || allowedTools.some((t) => tool.includes(t))
111
+ if (!isControllerTool) {
112
+ // Bloquear cualquier tool no-controller (Read/Grep/Glob/Bash/Edit/Write) cuando está en PENDING — hard gate genérico
113
+ throw new Error(`BLOCKED: call consume_* first — controller is in ${state.state}. Use consume_route_decision / consume_execution_decision / record_clarification to proceed.`)
114
+ }
115
+ }
116
+
117
+ // 9.1: Guard de credenciales
118
+ if (filePath && isSensitive(filePath, patterns)) {
119
+ const s = readState(ctx.directory)
120
+ const allowed = s?.allowedFiles?.[filePath]
121
+ if (!allowed) {
122
+ const denied = s?.deniedFiles?.[filePath]
123
+ if (denied) {
124
+ throw new Error(`BLOCKED: File ${filePath} requires check_file_access (previously denied)`)
125
+ }
126
+ throw new Error(`BLOCKED: File ${filePath} requires check_file_access`)
127
+ }
128
+ }
129
+ },
130
+ }
131
+ }
@@ -112,6 +112,7 @@ Para cada fase de `tasks.md`, evaluar intra-fase:
112
112
  "reasons": ["razón principal", "razón secundaria"],
113
113
  "codegraphUsed": ["codegraph_codegraph_explore"],
114
114
  "taskCount": <N>,
115
+ "expectedTaskIds": ["T1", "T2", "T3"],
115
116
  "sharedFiles": { "src/archivo.ts": ["task1", "task2"] },
116
117
  "fileClusters": [["task1", "task2"], ["task3"]],
117
118
  "clusterCount": <N>,
@@ -124,6 +125,9 @@ Para cada fase de `tasks.md`, evaluar intra-fase:
124
125
  }
125
126
  ```
126
127
 
128
+ > **Nota 6.1:** `expectedTaskIds` es **obligatorio** cuando `taskCount>0` — el controller lo exige y rechaza snapshot sin él (excepto `early-exit` con `taskCount<=2` y `codegraphUsed:[]` que es válido sin WARN).
129
+ > Ejemplo early-exit válido: `{"recommendation":"INLINE","reasons":["Cambio pequeño"],"codegraphUsed":[],"taskCount":2,"expectedTaskIds":["T1","T2"],"globalRuleTriggered":"early-exit"}`
130
+
127
131
  **Output para el usuario (mostrar en lenguaje natural):**
128
132
 
129
133
  ```markdown
@@ -167,6 +171,8 @@ Para cada fase de `tasks.md`, evaluar intra-fase:
167
171
  | `sequentialDeps` | Dependencias secuenciales entre tasks |
168
172
  | `estLines` | Estimación conservadora |
169
173
  | `hasExplicitContract` | `true` si design.md explicita contratos |
174
+ | `expectedTaskIds` | **Obligatorio** cuando `taskCount>0` — gate del controller |
175
+ | `taskCount` | Total tasks, debe coincidir con `expectedTaskIds.length` |
170
176
 
171
177
  **⚠️ Este skill provee ANÁLISIS, no autorización.** El coordinador muestra el snapshot al usuario y pide confirmación en lenguaje natural, luego espera la respuesta.
172
178
 
@@ -184,6 +190,8 @@ Para cada fase de `tasks.md`, evaluar intra-fase:
184
190
  - [ ] Verifiqué deps ENTRE clusters (no solo intra)?
185
191
  - [ ] Apliqué reglas en orden (1→2a/2b→3a/3b/3c)?
186
192
  - [ ] Anoté `globalRuleTriggered`?
193
+ - [ ] Incluí `expectedTaskIds` (obligatorio cuando `taskCount>0`) y verifiqué que `taskCount == expectedTaskIds.length`?
187
194
  - [ ] Si global es inline, ejecuté Paso 3b por fase?
188
- - [ ] Si global es subagent por clusters (Rule 2b), documenté dispatch?
195
+ - [ ] Si global es subagent por clusters (Rule 2b), documenté dispatch por clusters (máx 3 subagentes, advertir si `clusterCount>3` → oleadas)?
189
196
  - [ ] Output es JSON válido con todos los campos del contrato?
197
+ - [ ] Early-exit con `codegraphUsed:[]` y `taskCount<=2` solo cuando realmente es cambio trivial (no genera WARN)?
@@ -157,6 +157,10 @@ Modo básico: sin validación de edits, sin memoria persistente, sin análisis e
157
157
  ¿Continuar o cancelar?
158
158
  ```
159
159
 
160
+ ## Handoff Fallback (compaction)
161
+
162
+ Si el controller hizo `set_handoff` o el plugin escribió el fallback `dirname(OSTACKY_STATE_PATH)/.ostacky-handoff-compaction.json` antes de compaction, el próximo agente **debe** llamar `get_handoff` al inicio. `get_handoff` primero chequea `lastHandoff` en memoria y si es `null` lee el archivo fallback (mismo ancla que el writer). `clear_handoff` borra ambos. `cleanupTmpFiles` solo borra ese archivo si `ts >24h`. Ver `assets/plugins/engram.ts:experimental.session.compacting` y `assets/mcp/ostacky-controller/index.js:get_handoff`.
163
+
160
164
  ## Recovery After Degradation
161
165
 
162
166
  When a tool becomes available again during the session:
@@ -166,6 +170,15 @@ When a tool becomes available again during the session:
166
170
  3. **Resume:** Switch back to normal workflow
167
171
  4. **Catch up:** Use the tool to verify recent work
168
172
 
173
+ ## Health via get_metrics y doctor (6.2)
174
+
175
+ - **Con Controller:** usar `get_metrics` como health — expone `degraded`, `diskFreeMB`, `auditSize`, `stateFileSize`, `codegraphBypassCount`, `stateOversizedCount`, `degradedEditsCount`, `sensitiveAccess`. Si `diskFreeMB<100` → ⚠️ Disco casi lleno; si `stateOversizedCount>0` → snapshots perdidos.
176
+ - **Sin Controller:** fallback a `ostacky doctor` (lee `.opencode/ostacky-state.json` sin MCP, verifica locks, tamaños, audit, binarios y `manifest.json` hashes). `doctor` es el fallback a `check:skills` cuando MCP caído.
177
+ - **Sin CodeGraph:** `get_metrics.codegraphBypassCount` incrementa cuando `record_discovery` sin `symbols` y no degraded; `get_audit` marca `inefficient: codegraph bypass` para review.
178
+ - **Sin Engram:** continuar sin memoria; `doctor` no requiere Engram.
179
+
180
+ No usar `skill("engram")` — Engram es MCP server, no skill. Usar `engram_mem_*` tools.
181
+
169
182
  ## Guardrails
170
183
 
171
184
  ### During Degradation
@@ -201,6 +201,14 @@ Ready to implement auth feature
201
201
  - Auto-detect and run project setup
202
202
  - Verify clean test baseline
203
203
 
204
+ ## Ostacky Worktree Isolation
205
+
206
+ Cada worktree de git es **aislado** para Ostacky:
207
+
208
+ - **State file independiente:** `findProjectRoot()` resuelve el root del worktree vía `git rev-parse --show-toplevel` (cada worktree tiene su propio `.opencode/ostacky-state.json`). Dos worktrees no comparten `statePath`, por lo que `ostacky-controller` no pisa estados entre worktrees.
209
+ - **Lock independiente:** cada worktree tiene su propio `.lock` y `.backup` rotativo, por lo que 3 agentes en 3 worktrees no corrompen el lock del otro.
210
+ - **Verificación:** `tests` con dos tmp dirs simulando worktrees verifican que no comparten `statePath` y que `doctor` reporta OK en cada uno.
211
+
204
212
  ## Integration
205
213
 
206
214
  **Called by:**