ostacky 0.7.2 → 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.2",
3
+ "version": "0.7.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -208,6 +208,46 @@ function stripPrivateTags(str: string): string {
208
208
  return str.replace(/<private>[\s\S]*?<\/private>/gi, "[REDACTED]").trim()
209
209
  }
210
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
+
211
251
  // ─── Plugin Export ───────────────────────────────────────────────────────────
212
252
 
213
253
  export const Engram: Plugin = async (ctx) => {
@@ -556,11 +596,7 @@ export const Engram: Plugin = async (ctx) => {
556
596
  for (const cand of candidates) {
557
597
  try {
558
598
  const raw = readFileSync(cand, "utf-8")
559
- // strip // and /* */ comments for jsonc
560
- let j = raw
561
- .replace(/\/\/.*$/gm, "")
562
- .replace(/\/\*[\s\S]*?\*\//g, "")
563
- .replace(/,\s*([}\]])/g, "$1")
599
+ const j = stripJsoncComments(raw)
564
600
  const cfg = JSON.parse(j)
565
601
  const envPath = (cfg as any)?.mcp?.["ostacky-controller"]?.environment?.OSTACKY_STATE_PATH
566
602
  if (typeof envPath === "string" && envPath) {
@@ -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)?
@@ -170,6 +170,15 @@ When a tool becomes available again during the session:
170
170
  3. **Resume:** Switch back to normal workflow
171
171
  4. **Catch up:** Use the tool to verify recent work
172
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
+
173
182
  ## Guardrails
174
183
 
175
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:**