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.
- package/README.md +13 -9
- package/assets/agents/ostacky.md +26 -3
- package/assets/commands/install-stack.md +2 -2
- package/assets/mcp/ostacky-controller/index.js +847 -67
- package/assets/mcp/ostacky-controller/package.json +1 -1
- package/assets/plugins/engram.ts +41 -5
- package/assets/plugins/ostacky-guard.ts +131 -0
- package/assets/skills/execution-mode-evaluation/SKILL.md +9 -1
- package/assets/skills/graceful-degradation/SKILL.md +9 -0
- package/assets/skills/using-git-worktrees/SKILL.md +8 -0
- package/dist/cli.js +218 -38
- package/manifest.json +31 -31
- package/package.json +1 -1
package/assets/plugins/engram.ts
CHANGED
|
@@ -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
|
-
|
|
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:**
|