devloom 1.0.0 → 1.2.0
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/.ai/core.dsl +44 -0
- package/.ai/skills.dsl +39 -0
- package/.ai/verify.dsl +97 -0
- package/.ai/workflow.dsl +81 -0
- package/.opencode/themes/devloom-night-owl.json +103 -0
- package/GUIDE.md +572 -115
- package/README.md +583 -109
- package/SECURITY.md +93 -0
- package/agents/devloom-developer-flash.md +31 -0
- package/agents/devloom-developer-senior.md +32 -0
- package/agents/devloom-developer.md +21 -55
- package/agents/devloom-documenter-flash.md +28 -0
- package/agents/devloom-documenter.md +17 -56
- package/agents/devloom-orchestrator.md +236 -356
- package/agents/devloom-planner-flash.md +34 -0
- package/agents/devloom-planner-senior.md +35 -0
- package/agents/devloom-planner.md +36 -0
- package/agents/devloom-qa-flash.md +32 -0
- package/agents/devloom-qa.md +21 -83
- package/agents/devloom-security-senior.md +35 -0
- package/agents/devloom-security.md +34 -0
- package/agents/devloom-verifier.md +33 -0
- package/agents/devloom-vision.md +113 -0
- package/agents/devloom-visual-critic.md +102 -0
- package/agents/devloom-visual-director.md +84 -0
- package/commands/devloom-agents.md +49 -0
- package/commands/devloom-auto.md +11 -0
- package/commands/devloom-context.md +82 -0
- package/commands/devloom-deepseek-mimo.md +11 -0
- package/commands/devloom-deepseek.md +11 -0
- package/commands/devloom-free.md +11 -0
- package/commands/devloom-go-economy.md +11 -0
- package/commands/devloom-go-flash.md +11 -0
- package/commands/devloom-go.md +11 -0
- package/commands/devloom-init.md +28 -50
- package/commands/devloom-loop-status.md +25 -0
- package/commands/devloom-loop.md +36 -0
- package/commands/devloom-mimo.md +11 -0
- package/commands/devloom-plan.md +13 -0
- package/commands/devloom-refresh.md +22 -0
- package/commands/devloom-resume.md +30 -49
- package/commands/devloom-save.md +69 -0
- package/commands/devloom-status.md +9 -18
- package/commands/devloom.md +99 -67
- package/dist/agents.d.ts +30 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/agents.js +139 -0
- package/dist/agents.js.map +1 -0
- package/dist/bootstrap.d.ts +56 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +116 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/constraints.d.ts +5 -0
- package/dist/constraints.d.ts.map +1 -0
- package/dist/constraints.js +45 -0
- package/dist/constraints.js.map +1 -0
- package/dist/context.d.ts +42 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +636 -0
- package/dist/context.js.map +1 -0
- package/dist/guard.d.ts +19 -0
- package/dist/guard.d.ts.map +1 -0
- package/dist/guard.js +418 -0
- package/dist/guard.js.map +1 -0
- package/dist/loop.d.ts +64 -0
- package/dist/loop.d.ts.map +1 -0
- package/dist/loop.js +164 -0
- package/dist/loop.js.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +101 -1
- package/dist/plugin.js.map +1 -1
- package/dist/tui-agents.d.ts +61 -0
- package/dist/tui-agents.d.ts.map +1 -0
- package/dist/tui-agents.js +89 -0
- package/dist/tui-agents.js.map +1 -0
- package/dist/tui.d.ts +6 -0
- package/dist/tui.d.ts.map +1 -0
- package/dist/tui.js +92 -0
- package/dist/tui.js.map +1 -0
- package/dist/worktree.d.ts +46 -0
- package/dist/worktree.d.ts.map +1 -0
- package/dist/worktree.js +272 -0
- package/dist/worktree.js.map +1 -0
- package/package.json +46 -6
- package/patterns/changelog-drafter.md +38 -0
- package/patterns/ci-sweeper.md +40 -0
- package/patterns/daily-triage.md +37 -0
- package/patterns/dependency-sweeper.md +40 -0
- package/patterns/design-audit.md +63 -0
- package/patterns/issue-triage.md +37 -0
- package/patterns/post-merge-cleanup.md +34 -0
- package/patterns/pr-babysitter.md +37 -0
- package/patterns/registry.yaml +52 -0
- package/postinstall.mjs +264 -64
- package/project/README.md +29 -0
- package/protocol/agent-contracts.md +27 -0
- package/protocol/artifact-system.md +53 -0
- package/protocol/model-routing.md +194 -0
- package/protocol/orchestrator-core.md +43 -0
- package/protocol/project-system.md +45 -0
- package/protocol/rules.md +18 -0
- package/protocol/verification-policy.md +48 -0
- package/scripts/briefing.mjs +192 -0
- package/scripts/loop-run.mjs +205 -0
- package/scripts/model-capabilities.mjs +194 -0
- package/scripts/plugin-cache.mjs +269 -0
- package/scripts/profile.mjs +755 -0
- package/scripts/visual-benchmark.mjs +149 -0
- package/scripts/worktree.mjs +445 -0
- package/skills/build/development.md +33 -0
- package/skills/build/live-docs.md +38 -0
- package/skills/build/simplify.md +50 -0
- package/skills/build/vision-analysis.md +98 -0
- package/skills/design/app-design.md +31 -0
- package/skills/design/design-grounding.md +30 -0
- package/skills/design/design-system.md +27 -0
- package/skills/design/game-design.md +35 -0
- package/skills/design/motion-design.md +34 -0
- package/skills/design/visual-direction.md +32 -0
- package/skills/design/web-design.md +33 -0
- package/skills/loop/changelog-drafter.md +32 -0
- package/skills/loop/ci-sweeper.md +24 -0
- package/skills/loop/constraints.md +21 -0
- package/skills/loop/dependency-sweeper.md +24 -0
- package/skills/loop/design-audit.md +67 -0
- package/skills/loop/issue-triage.md +27 -0
- package/skills/loop/post-merge-cleanup.md +24 -0
- package/skills/loop/pr-babysitter.md +26 -0
- package/skills/loop/triage.md +25 -0
- package/skills/loop/verifier.md +20 -0
- package/skills/meta/skill-discovery.md +23 -0
- package/skills/plan/planning.md +27 -0
- package/skills/plan/verification-planning.md +40 -0
- package/skills/review/security-review.md +63 -0
- package/skills/ship/documentation.md +11 -0
- package/skills/verify/app-verification.md +19 -0
- package/skills/verify/quality-assurance.md +17 -0
- package/skills/verify/visual-critique.md +30 -0
- package/skills/verify/visual-quality-gate.md +29 -0
- package/skills/verify/visual-regression.md +25 -0
- package/agents/devloom-analyst.md +0 -83
- package/agents/devloom-architect.md +0 -88
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* DevLoom Loop Runner — Single loop tick executor
|
|
4
|
+
* Usage: node scripts/loop-run.mjs [--pattern <name>] [--cadence <cron>] [--level L1|L2|L3]
|
|
5
|
+
*
|
|
6
|
+
* Reads loop config, validates pattern, checks budget, constructs prompt,
|
|
7
|
+
* logs outcome.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync } from "node:fs"
|
|
10
|
+
import { join, dirname } from "node:path"
|
|
11
|
+
import { fileURLToPath } from "node:url"
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
14
|
+
const ROOT = join(__dirname, "..")
|
|
15
|
+
|
|
16
|
+
function readJson(path, fallback) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(readFileSync(path, "utf8"))
|
|
19
|
+
} catch {
|
|
20
|
+
return fallback
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeJson(path, data) {
|
|
25
|
+
const dir = dirname(path)
|
|
26
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
27
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + "\n")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function nextMidnightISO() {
|
|
31
|
+
const now = new Date()
|
|
32
|
+
const mid = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0, 0)
|
|
33
|
+
return mid.toISOString()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseArgs() {
|
|
37
|
+
const args = {}
|
|
38
|
+
for (let i = 2; i < process.argv.length; i++) {
|
|
39
|
+
const arg = process.argv[i]
|
|
40
|
+
if (arg.startsWith("--")) {
|
|
41
|
+
const key = arg.slice(2)
|
|
42
|
+
const val = process.argv[i + 1]
|
|
43
|
+
if (val && !val.startsWith("--")) {
|
|
44
|
+
args[key] = val
|
|
45
|
+
i++
|
|
46
|
+
} else {
|
|
47
|
+
args[key] = true
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return args
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- Main ---
|
|
55
|
+
async function main() {
|
|
56
|
+
const cli = parseArgs()
|
|
57
|
+
const loopDir = join(ROOT, ".opencode", "devloom", "loop")
|
|
58
|
+
|
|
59
|
+
// 1. Read loop config
|
|
60
|
+
const configPath = join(loopDir, "loop-config.json")
|
|
61
|
+
const config = readJson(configPath, {})
|
|
62
|
+
config.pattern = cli.pattern || config.pattern || "daily-triage"
|
|
63
|
+
config.cadence = cli.cadence || config.cadence || "0 0 * * *"
|
|
64
|
+
config.level = cli.level || config.level || "L1"
|
|
65
|
+
config.paused = config.paused || false
|
|
66
|
+
|
|
67
|
+
// 2a. Read until-done progress if mode is until-done
|
|
68
|
+
let progress = null
|
|
69
|
+
if (config.mode === "until-done") {
|
|
70
|
+
const progressPath = join(loopDir, "progress.json")
|
|
71
|
+
progress = readJson(progressPath, null)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 2. Validate pattern exists
|
|
75
|
+
const registryPath = join(ROOT, "patterns", "registry.yaml")
|
|
76
|
+
const patternPath = join(ROOT, "patterns", `${config.pattern}.md`)
|
|
77
|
+
|
|
78
|
+
if (!existsSync(registryPath)) {
|
|
79
|
+
console.log("Warning: patterns/registry.yaml not found. Pattern validation skipped.")
|
|
80
|
+
}
|
|
81
|
+
if (!existsSync(patternPath)) {
|
|
82
|
+
console.log(`Error: pattern file not found: patterns/${config.pattern}.md`)
|
|
83
|
+
process.exit(1)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 3. Check budget
|
|
87
|
+
const budgetPath = join(loopDir, "budget.json")
|
|
88
|
+
const budget = readJson(budgetPath, { dailyLimit: 500000, spent: 0, resetAt: nextMidnightISO() })
|
|
89
|
+
|
|
90
|
+
const now = new Date()
|
|
91
|
+
const resetAt = new Date(budget.resetAt)
|
|
92
|
+
if (now >= resetAt) {
|
|
93
|
+
budget.spent = 0
|
|
94
|
+
budget.resetAt = nextMidnightISO()
|
|
95
|
+
writeJson(budgetPath, budget)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const remaining = Math.max(0, budget.dailyLimit - budget.spent)
|
|
99
|
+
if (remaining <= 0) {
|
|
100
|
+
config.paused = true
|
|
101
|
+
writeJson(configPath, config)
|
|
102
|
+
console.log(`Budget exceeded (${budget.spent}/${budget.dailyLimit}). Loop paused.`)
|
|
103
|
+
console.log("Reset at:", budget.resetAt)
|
|
104
|
+
process.exit(1)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 3a. Check until-done completion
|
|
108
|
+
if (config.mode === "until-done" && progress) {
|
|
109
|
+
const allVerified = progress.items.every((i) => i.status === "verified")
|
|
110
|
+
if (allVerified) {
|
|
111
|
+
console.log("===== Design-Audit Complete =====")
|
|
112
|
+
console.log(`All ${progress.total} pages verified against design system spec.`)
|
|
113
|
+
console.log("Fix loop finished. No more pages to process.")
|
|
114
|
+
process.exit(0)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 4. Record start time
|
|
119
|
+
const startTime = Date.now()
|
|
120
|
+
|
|
121
|
+
// 5. Pattern cost estimate
|
|
122
|
+
const patternContent = readFileSync(patternPath, "utf8")
|
|
123
|
+
const costMatch = patternContent.match(/cost:\s*(\d+)/)
|
|
124
|
+
const estimatedCost = costMatch ? parseInt(costMatch[1]) : 50000
|
|
125
|
+
|
|
126
|
+
// 6. Construct prompt
|
|
127
|
+
let progressContext = ""
|
|
128
|
+
if (config.mode === "until-done" && progress) {
|
|
129
|
+
const pending = progress.items.filter((i) => i.status === "pending").length
|
|
130
|
+
const inProgress = progress.items.filter((i) => i.status === "in-progress").length
|
|
131
|
+
const byCategory = {}
|
|
132
|
+
for (const item of progress.items) {
|
|
133
|
+
const cat = item.category || "uncategorized"
|
|
134
|
+
byCategory[cat] = (byCategory[cat] || 0) + 1
|
|
135
|
+
}
|
|
136
|
+
const catSummary = Object.entries(byCategory).map(([k, v]) => `${k}=${v}`).join(" ")
|
|
137
|
+
progressContext = `\nProgress: ${progress.verified}/${progress.total} verified, ${pending} pending, ${inProgress} in-progress\nIssues by category: ${catSummary}\nCompletion condition: ${progress.completionCondition}`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const prompt = [
|
|
141
|
+
`[devloom-loop] Running pattern: ${config.pattern}`,
|
|
142
|
+
`Level: ${config.level}`,
|
|
143
|
+
`Cadence: ${config.cadence}`,
|
|
144
|
+
`Mode: ${config.mode || "recurring"}`,
|
|
145
|
+
`Budget remaining: ${remaining}/${budget.dailyLimit}`,
|
|
146
|
+
progressContext,
|
|
147
|
+
"",
|
|
148
|
+
`Pattern loaded from patterns/${config.pattern}.md.`,
|
|
149
|
+
`Execute the pattern's agent chain and log results.`,
|
|
150
|
+
].join("\n")
|
|
151
|
+
|
|
152
|
+
// 7. In headless mode: opencode run "<prompt>"
|
|
153
|
+
// For now, construct the command and log it
|
|
154
|
+
const headlessCmd = `opencode run "${prompt.replace(/"/g, '\\"')}"`
|
|
155
|
+
console.log("===== DevLoom Loop Tick =====")
|
|
156
|
+
console.log("Pattern:", config.pattern)
|
|
157
|
+
console.log("Level:", config.level)
|
|
158
|
+
console.log("Mode:", config.mode || "recurring")
|
|
159
|
+
if (config.mode === "until-done" && progress) {
|
|
160
|
+
console.log("Progress:", `${progress.verified}/${progress.total} verified`)
|
|
161
|
+
const byCat = {}
|
|
162
|
+
for (const item of progress.items) {
|
|
163
|
+
const cat = item.category || "uncategorized"
|
|
164
|
+
byCat[cat] = (byCat[cat] || 0) + 1
|
|
165
|
+
}
|
|
166
|
+
console.log("Issues:", Object.entries(byCat).map(([k, v]) => `${k}=${v}`).join(", "))
|
|
167
|
+
}
|
|
168
|
+
console.log("Budget remaining:", remaining)
|
|
169
|
+
console.log("Estimated cost:", estimatedCost)
|
|
170
|
+
console.log("")
|
|
171
|
+
console.log("Headless command:")
|
|
172
|
+
console.log(headlessCmd)
|
|
173
|
+
console.log("")
|
|
174
|
+
console.log("===== Prompt =====")
|
|
175
|
+
console.log(prompt)
|
|
176
|
+
|
|
177
|
+
// 8. Record outcome
|
|
178
|
+
const durationMs = Date.now() - startTime
|
|
179
|
+
const runLogPath = join(loopDir, "run-log.json")
|
|
180
|
+
const runLog = readJson(runLogPath, [])
|
|
181
|
+
runLog.push({
|
|
182
|
+
timestamp: new Date().toISOString(),
|
|
183
|
+
pattern: config.pattern,
|
|
184
|
+
agentsUsed: [],
|
|
185
|
+
outcome: "success",
|
|
186
|
+
tokenCost: estimatedCost,
|
|
187
|
+
durationMs,
|
|
188
|
+
...(config.mode === "until-done" && progress ? { progress: `${progress.verified}/${progress.total} verified` } : {}),
|
|
189
|
+
})
|
|
190
|
+
writeJson(runLogPath, runLog)
|
|
191
|
+
|
|
192
|
+
// 9. Deduct estimated cost from budget
|
|
193
|
+
budget.spent += estimatedCost
|
|
194
|
+
writeJson(budgetPath, budget)
|
|
195
|
+
|
|
196
|
+
console.log("")
|
|
197
|
+
console.log("Tick completed in", durationMs, "ms")
|
|
198
|
+
console.log("Cost deducted:", estimatedCost)
|
|
199
|
+
console.log("Run log updated")
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
main().catch((err) => {
|
|
203
|
+
console.error("Loop runner error:", err.message)
|
|
204
|
+
process.exit(1)
|
|
205
|
+
})
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Provider-agnostic model identity, candidate resolution, and capability hints
|
|
2
|
+
// for DevLoom profiles. Pure module: no fs, no child_process, no secrets.
|
|
3
|
+
//
|
|
4
|
+
// Design rules:
|
|
5
|
+
// - Model IDs are conceptually `provider/model` (the model segment may itself
|
|
6
|
+
// contain slashes, e.g. `lmstudio/google/gemma-4-26b`). Any provider is valid;
|
|
7
|
+
// availability always comes from `opencode models` at the call site.
|
|
8
|
+
// - MODEL_FAMILIES is the authoritative capability source for known families.
|
|
9
|
+
// `capabilitiesFor()` falls back to conservative name heuristics for unknown
|
|
10
|
+
// models — hints only, never a substitute for availability validation.
|
|
11
|
+
// - Nothing here is MiMo-specific; MiMo is simply the first family registered.
|
|
12
|
+
|
|
13
|
+
/** Conceptual model families with candidate chains + capability declarations. */
|
|
14
|
+
export const MODEL_FAMILIES = {
|
|
15
|
+
mimoV26Flash: {
|
|
16
|
+
id: "mimoV26Flash",
|
|
17
|
+
label: "MiMo V2.6 Flash",
|
|
18
|
+
// Provider prefix is NOT assumed — resolution picks the first available.
|
|
19
|
+
candidates: [
|
|
20
|
+
"mimo/mimo-v2.6-flash",
|
|
21
|
+
"xiaomi/mimo-v2.6-flash",
|
|
22
|
+
"opencode-go/mimo-v2.6-flash",
|
|
23
|
+
],
|
|
24
|
+
familyPattern: /(^|\/)mimo-v2\.6/i,
|
|
25
|
+
capabilities: {
|
|
26
|
+
vision: true,
|
|
27
|
+
multimodal: true,
|
|
28
|
+
coding: "high",
|
|
29
|
+
reasoning: "high",
|
|
30
|
+
toolUse: true,
|
|
31
|
+
longContext: true,
|
|
32
|
+
speed: "fast",
|
|
33
|
+
costClass: "low",
|
|
34
|
+
orchestration: true,
|
|
35
|
+
verification: "high",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
deepseekV41Flash: {
|
|
39
|
+
id: "deepseekV41Flash",
|
|
40
|
+
label: "DeepSeek V4.1 Flash",
|
|
41
|
+
// Fast lane for general roles. Only verified ids enter the chain; the
|
|
42
|
+
// family pattern still discovers same-family variants at resolution time.
|
|
43
|
+
candidates: [
|
|
44
|
+
"opencode-go/deepseek-v4.1-flash",
|
|
45
|
+
],
|
|
46
|
+
familyPattern: /(^|\/)deepseek-v4\.1/i,
|
|
47
|
+
capabilities: {
|
|
48
|
+
vision: false,
|
|
49
|
+
multimodal: false,
|
|
50
|
+
coding: "high",
|
|
51
|
+
reasoning: "high",
|
|
52
|
+
toolUse: true,
|
|
53
|
+
longContext: true,
|
|
54
|
+
speed: "fast",
|
|
55
|
+
costClass: "low",
|
|
56
|
+
orchestration: true,
|
|
57
|
+
verification: "high",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** MiMo V2.6 Flash candidate chain (re-exported for profile manager + tests). */
|
|
63
|
+
export const MIMO_V26_FLASH_CANDIDATES = MODEL_FAMILIES.mimoV26Flash.candidates
|
|
64
|
+
|
|
65
|
+
/** DeepSeek V4.1 Flash candidate chain (general/fast roles). */
|
|
66
|
+
export const DEEPSEEK_V41_FLASH_CANDIDATES = MODEL_FAMILIES.deepseekV41Flash.candidates
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Vision-capable fallbacks used when a vision-required role (vision,
|
|
70
|
+
* visual-critic) resolves to a text-only model. Ordered by existing DevLoom
|
|
71
|
+
* convention: Go multimodal models first, free-tier vision model last.
|
|
72
|
+
*/
|
|
73
|
+
export const VISION_FALLBACK_CANDIDATES = [
|
|
74
|
+
"opencode-go/qwen3.6-plus",
|
|
75
|
+
"opencode-go/glm-5.3-flash",
|
|
76
|
+
"opencode-go/deepseek-v4-flash-vision-exp",
|
|
77
|
+
"opencode-go/minimax-m3",
|
|
78
|
+
"opencode/mimo-v2.5-free",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
/** Roles whose assigned model must accept image input. */
|
|
82
|
+
const VISION_REQUIRED_ROLES = new Set(["vision", "visual-critic"])
|
|
83
|
+
|
|
84
|
+
const PROVIDER_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
85
|
+
const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._+/-]*$/
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* True for any well-formed OpenCode model identifier of the form
|
|
89
|
+
* `provider/model` (the model segment may contain further slashes).
|
|
90
|
+
* Availability is a separate concern — see findAvailableCandidate().
|
|
91
|
+
*/
|
|
92
|
+
export function isValidModelId(id) {
|
|
93
|
+
if (typeof id !== "string") return false
|
|
94
|
+
if (!id || id.length > 256 || /\s/.test(id)) return false
|
|
95
|
+
const slash = id.indexOf("/")
|
|
96
|
+
if (slash <= 0 || slash === id.length - 1) return false
|
|
97
|
+
const provider = id.slice(0, slash)
|
|
98
|
+
const model = id.slice(slash + 1)
|
|
99
|
+
if (!PROVIDER_RE.test(provider)) return false
|
|
100
|
+
if (!MODEL_RE.test(model)) return false
|
|
101
|
+
if (model.startsWith("/") || model.endsWith("/")) return false
|
|
102
|
+
return true
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** First candidate present in the available list, or null. */
|
|
106
|
+
export function findAvailableCandidate(candidates, available) {
|
|
107
|
+
if (!Array.isArray(candidates) || !Array.isArray(available)) return null
|
|
108
|
+
for (const candidate of candidates) {
|
|
109
|
+
if (available.includes(candidate)) return candidate
|
|
110
|
+
}
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve a candidate chain against the available model list.
|
|
116
|
+
*
|
|
117
|
+
* Returns { model, attempted, exact, fallback }:
|
|
118
|
+
* - exact hit on any declared candidate → exact: true
|
|
119
|
+
* - optional opts.familyPattern permits a same-family fallback (e.g.
|
|
120
|
+
* mimo-v2.6-flash → mimo-v2.6-pro) reported with exact: false so callers
|
|
121
|
+
* can surface "requested vs resolved" transparently
|
|
122
|
+
* - otherwise model: null — callers must fail loudly, never pretend
|
|
123
|
+
*/
|
|
124
|
+
export function resolveModelCandidate(candidates, available, opts = {}) {
|
|
125
|
+
const attempted = Array.isArray(candidates) ? [...candidates] : []
|
|
126
|
+
const list = Array.isArray(available) ? available : []
|
|
127
|
+
const exact = findAvailableCandidate(attempted, list)
|
|
128
|
+
if (exact) return { model: exact, attempted, exact: true, fallback: null }
|
|
129
|
+
if (opts.familyPattern instanceof RegExp) {
|
|
130
|
+
const family = list.find((m) => isValidModelId(m) && opts.familyPattern.test(m))
|
|
131
|
+
if (family) return { model: family, attempted, exact: false, fallback: family }
|
|
132
|
+
}
|
|
133
|
+
return { model: null, attempted, exact: false, fallback: null }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function familyCapabilities(modelId) {
|
|
137
|
+
for (const family of Object.values(MODEL_FAMILIES)) {
|
|
138
|
+
if (family.candidates.includes(modelId)) return { ...family.capabilities }
|
|
139
|
+
if (family.familyPattern instanceof RegExp && family.familyPattern.test(modelId)) {
|
|
140
|
+
return { ...family.capabilities }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Capability hints for a model id. Authoritative for registered families;
|
|
148
|
+
* conservative heuristics otherwise. Callers must not treat `vision: false`
|
|
149
|
+
* from the heuristic path as proof a model cannot see images — it only means
|
|
150
|
+
* DevLoom has no evidence, which is the safe direction for routing decisions.
|
|
151
|
+
*/
|
|
152
|
+
export function capabilitiesFor(modelId) {
|
|
153
|
+
const id = typeof modelId === "string" ? modelId : ""
|
|
154
|
+
const known = familyCapabilities(id)
|
|
155
|
+
if (known) return known
|
|
156
|
+
const caps = {
|
|
157
|
+
vision: false,
|
|
158
|
+
multimodal: false,
|
|
159
|
+
coding: "medium",
|
|
160
|
+
reasoning: "medium",
|
|
161
|
+
toolUse: true,
|
|
162
|
+
longContext: false,
|
|
163
|
+
speed: "medium",
|
|
164
|
+
costClass: "unknown",
|
|
165
|
+
orchestration: false,
|
|
166
|
+
verification: "medium",
|
|
167
|
+
}
|
|
168
|
+
// Known multimodal markers observed in OpenCode catalogs.
|
|
169
|
+
if (/vision|minimax-m3|qwen3\.6|mimo-v2\.[56]|glm-5\.3/i.test(id)) {
|
|
170
|
+
caps.vision = true
|
|
171
|
+
caps.multimodal = true
|
|
172
|
+
}
|
|
173
|
+
if (/-free$|big-pickle/.test(id)) caps.costClass = "free"
|
|
174
|
+
if (/flash/i.test(id)) caps.speed = "fast"
|
|
175
|
+
if (/pro|max|kimi-k3|glm-5\.2$/.test(id)) {
|
|
176
|
+
caps.reasoning = "high"
|
|
177
|
+
caps.coding = "high"
|
|
178
|
+
}
|
|
179
|
+
if (/kimi-k2\.7-code|kimi-k3|qwen3\.7|longcat|glm-5\.3$/.test(id)) caps.longContext = true
|
|
180
|
+
return caps
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function roleRequiresVision(role) {
|
|
184
|
+
return VISION_REQUIRED_ROLES.has(role)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Whether a model satisfies a role's capability requirements. */
|
|
188
|
+
export function modelMeetsRole(modelId, role) {
|
|
189
|
+
if (!roleRequiresVision(role)) return { ok: true, missing: [] }
|
|
190
|
+
const caps = capabilitiesFor(modelId)
|
|
191
|
+
const missing = []
|
|
192
|
+
if (!caps.vision) missing.push("vision")
|
|
193
|
+
return { ok: missing.length === 0, missing }
|
|
194
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Shared helpers for keeping the OpenCode plugin cache in sync with the
|
|
3
|
+
// installed DevLoom package.
|
|
4
|
+
//
|
|
5
|
+
// OpenCode installs npm plugins into `~/.cache/opencode/packages/<spec>` with
|
|
6
|
+
// `ignoreScripts: true`, so the package `postinstall` never runs there and the
|
|
7
|
+
// cached copy can silently go stale. These helpers refresh the cached package
|
|
8
|
+
// files (dist, agents, commands, ...) directly from a package source directory.
|
|
9
|
+
import {
|
|
10
|
+
existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync,
|
|
11
|
+
copyFileSync, rmSync, statSync,
|
|
12
|
+
} from "fs"
|
|
13
|
+
import { resolve, dirname, join } from "path"
|
|
14
|
+
import { fileURLToPath } from "url"
|
|
15
|
+
import { homedir, platform } from "os"
|
|
16
|
+
import { execFileSync } from "node:child_process"
|
|
17
|
+
|
|
18
|
+
export function getOpenCodeCacheDir(_platform = platform(), _homedir = homedir(), _env = process.env) {
|
|
19
|
+
if (_platform === "win32") {
|
|
20
|
+
return resolve(_env.LOCALAPPDATA ?? _homedir, "cache", "opencode")
|
|
21
|
+
}
|
|
22
|
+
if (_platform === "darwin") {
|
|
23
|
+
const xdg = resolve(_env.XDG_CACHE_HOME ?? join(_homedir, ".cache"), "opencode")
|
|
24
|
+
const lib = resolve(_homedir, "Library", "Caches", "opencode")
|
|
25
|
+
// OpenCode uses the XDG cache path on macOS too; only fall back to the
|
|
26
|
+
// Library path when that is the one present on disk (older builds).
|
|
27
|
+
if (!existsSync(xdg) && existsSync(lib)) return lib
|
|
28
|
+
return xdg
|
|
29
|
+
}
|
|
30
|
+
return resolve(_env.XDG_CACHE_HOME ?? join(_homedir, ".cache"), "opencode")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Names of OpenCode plugin cache entries that contain a `devloom` package.
|
|
34
|
+
// Both `devloom` (legacy) and `devloom@latest` (current config spec) exist in
|
|
35
|
+
// the wild; refreshing both is harmless and covers every loader path.
|
|
36
|
+
export function openCodePluginEntries(cacheDir) {
|
|
37
|
+
const packagesDir = join(cacheDir, "packages")
|
|
38
|
+
if (!existsSync(packagesDir)) return []
|
|
39
|
+
let entries = []
|
|
40
|
+
try {
|
|
41
|
+
entries = readdirSync(packagesDir)
|
|
42
|
+
} catch {
|
|
43
|
+
return []
|
|
44
|
+
}
|
|
45
|
+
return entries.filter((entry) => {
|
|
46
|
+
try {
|
|
47
|
+
return existsSync(join(packagesDir, entry, "node_modules", "devloom"))
|
|
48
|
+
} catch {
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function copyDir(src, dest) {
|
|
55
|
+
if (existsSync(dest) && !statSync(src).isDirectory()) {
|
|
56
|
+
rmSync(dest, { recursive: true, force: true })
|
|
57
|
+
}
|
|
58
|
+
const stat = statSync(src)
|
|
59
|
+
if (stat.isDirectory()) {
|
|
60
|
+
mkdirSync(dest, { recursive: true })
|
|
61
|
+
for (const name of readdirSync(src)) {
|
|
62
|
+
copyDir(join(src, name), join(dest, name))
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
66
|
+
copyFileSync(src, dest)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Rebuild dist/ from source before it is copied into the OpenCode plugin cache,
|
|
71
|
+
// so a refresh never deploys stale compiled code. Runs the package's declared
|
|
72
|
+
// `build` script (npm run build) when present. Best-effort: never throws —
|
|
73
|
+
// failures are reported in the returned summary and the caller proceeds with
|
|
74
|
+
// whatever dist already exists.
|
|
75
|
+
export function buildPackageDist(sourceDir, exec = execFileSync) {
|
|
76
|
+
const summary = { built: false, skipped: "", errors: [] }
|
|
77
|
+
try {
|
|
78
|
+
const pkg = JSON.parse(readFileSync(join(sourceDir, "package.json"), "utf8"))
|
|
79
|
+
const build = pkg.scripts && typeof pkg.scripts.build === "string" ? pkg.scripts.build : ""
|
|
80
|
+
if (!build) {
|
|
81
|
+
summary.skipped = "no build script in package.json"
|
|
82
|
+
return summary
|
|
83
|
+
}
|
|
84
|
+
exec("npm", ["run", "build"], { cwd: sourceDir, stdio: "ignore", timeout: 120_000 })
|
|
85
|
+
summary.built = true
|
|
86
|
+
} catch (err) {
|
|
87
|
+
summary.errors.push(`build failed: ${err && typeof err.message === "string" ? err.message : String(err)}`)
|
|
88
|
+
}
|
|
89
|
+
return summary
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Copy the current package (dist, agents, commands, ... per package.json
|
|
93
|
+
// `files` plus package.json itself) into every existing OpenCode plugin cache
|
|
94
|
+
// entry. Best-effort: never throws, reports per-entry results.
|
|
95
|
+
export function refreshOpenCodePluginCache(sourceDir, cacheDir = getOpenCodeCacheDir(), exec = execFileSync) {
|
|
96
|
+
const summary = { refreshed: [], skipped: [], errors: [], build: null }
|
|
97
|
+
// Root-cause guard against the "status ok but stale" class of bugs: the
|
|
98
|
+
// refresh pipeline must ship freshly built code, not whatever dist happens
|
|
99
|
+
// to sit in the source tree. Rebuild before copying.
|
|
100
|
+
const build = buildPackageDist(sourceDir, exec)
|
|
101
|
+
summary.build = build
|
|
102
|
+
if (build.errors.length > 0) summary.errors.push(...build.errors)
|
|
103
|
+
let roots = []
|
|
104
|
+
try {
|
|
105
|
+
const pkg = JSON.parse(readFileSync(join(sourceDir, "package.json"), "utf8"))
|
|
106
|
+
roots = Array.isArray(pkg.files) ? pkg.files : []
|
|
107
|
+
} catch (err) {
|
|
108
|
+
summary.errors.push(`cannot read package.json at ${sourceDir}: ${err.message}`)
|
|
109
|
+
return summary
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const entries = openCodePluginEntries(cacheDir)
|
|
113
|
+
if (entries.length === 0) {
|
|
114
|
+
summary.skipped.push(cacheDir)
|
|
115
|
+
return summary
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const toCopy = [...roots, "package.json"]
|
|
119
|
+
for (const entry of entries) {
|
|
120
|
+
const destPkg = join(cacheDir, "packages", entry, "node_modules", "devloom")
|
|
121
|
+
let copied = 0
|
|
122
|
+
for (const root of toCopy) {
|
|
123
|
+
const src = join(sourceDir, root)
|
|
124
|
+
if (!existsSync(src)) continue
|
|
125
|
+
try {
|
|
126
|
+
copyDir(src, join(destPkg, root))
|
|
127
|
+
copied++
|
|
128
|
+
} catch (err) {
|
|
129
|
+
summary.errors.push(`${entry}/${root}: ${err.message}`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (copied > 0) summary.refreshed.push(entry)
|
|
133
|
+
}
|
|
134
|
+
return summary
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Sync the installed agent files into the cached plugin package so a profile
|
|
138
|
+
// change is reflected even in the cache copy. Best-effort, never throws.
|
|
139
|
+
export function refreshOpenCodePluginAgentFiles(agentsDir, cacheDir = getOpenCodeCacheDir()) {
|
|
140
|
+
const summary = { refreshed: [], skipped: [], errors: [] }
|
|
141
|
+
const entries = openCodePluginEntries(cacheDir)
|
|
142
|
+
if (entries.length === 0) {
|
|
143
|
+
summary.skipped.push(cacheDir)
|
|
144
|
+
return summary
|
|
145
|
+
}
|
|
146
|
+
for (const entry of entries) {
|
|
147
|
+
const destAgents = join(cacheDir, "packages", entry, "node_modules", "devloom", "agents")
|
|
148
|
+
try {
|
|
149
|
+
mkdirSync(destAgents, { recursive: true })
|
|
150
|
+
const files = readdirSync(agentsDir).filter((f) => f.startsWith("devloom-") && f.endsWith(".md"))
|
|
151
|
+
for (const file of files) {
|
|
152
|
+
copyFileSync(join(agentsDir, file), join(destAgents, file))
|
|
153
|
+
}
|
|
154
|
+
summary.refreshed.push(entry)
|
|
155
|
+
} catch (err) {
|
|
156
|
+
summary.errors.push(`${entry}: ${err.message}`)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return summary
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Sync the runtime dependencies of the installed package into every existing
|
|
163
|
+
// cache entry. The TUI plugin (dist/tui.js) imports `@opentui/solid` +
|
|
164
|
+
// `solid-js`, and OpenCode installs plugins with `ignoreScripts` so those deps
|
|
165
|
+
// are not guaranteed to exist in the cache. Running `npm install` inside the
|
|
166
|
+
// cached plugin directory mirrors what a normal package install would do,
|
|
167
|
+
// without touching the cache entry root (no pruning of existing packages).
|
|
168
|
+
// Best-effort: never throws, reports per-entry results.
|
|
169
|
+
export function syncOpenCodePluginDependencies(sourceDir, cacheDir = getOpenCodeCacheDir(), exec = execFileSync) {
|
|
170
|
+
const summary = { synced: [], skipped: [], errors: [] }
|
|
171
|
+
const entries = openCodePluginEntries(cacheDir)
|
|
172
|
+
if (entries.length === 0) {
|
|
173
|
+
summary.skipped.push(cacheDir)
|
|
174
|
+
return summary
|
|
175
|
+
}
|
|
176
|
+
for (const entry of entries) {
|
|
177
|
+
const pluginDir = join(cacheDir, "packages", entry, "node_modules", "devloom")
|
|
178
|
+
try {
|
|
179
|
+
exec(
|
|
180
|
+
"npm",
|
|
181
|
+
[
|
|
182
|
+
"install", "--prefix", pluginDir,
|
|
183
|
+
"--no-save", "--ignore-scripts", "--no-audit", "--no-fund",
|
|
184
|
+
"--no-package-lock", "--omit=dev", "--loglevel=error",
|
|
185
|
+
],
|
|
186
|
+
{ stdio: "ignore", timeout: 120_000 }
|
|
187
|
+
)
|
|
188
|
+
summary.synced.push(entry)
|
|
189
|
+
} catch (err) {
|
|
190
|
+
summary.errors.push(`${entry}: ${err && typeof err.message === "string" ? err.message : String(err)}`)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return summary
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Per-entry cache health breakdown.
|
|
197
|
+
// "ok" – every existing cache entry has the config hook that injects DevLoom
|
|
198
|
+
// agents AND the current feature code (the profile-filter wiring in
|
|
199
|
+
// dist/agents.js, detected via `agentVariantVisible`). Conservative:
|
|
200
|
+
// one stale entry makes the whole cache stale.
|
|
201
|
+
// "stale"– at least one cache entry lacks the config hook or the feature code.
|
|
202
|
+
// "missing" – DevLoom was never installed as an OpenCode plugin.
|
|
203
|
+
export function pluginCacheStatusDetail(cacheDir = getOpenCodeCacheDir()) {
|
|
204
|
+
const entries = openCodePluginEntries(cacheDir)
|
|
205
|
+
if (entries.length === 0) return { status: "missing", entries: [] }
|
|
206
|
+
const details = entries.map((entry) => {
|
|
207
|
+
const base = join(cacheDir, "packages", entry, "node_modules", "devloom")
|
|
208
|
+
let hook = false
|
|
209
|
+
let featureCode = false
|
|
210
|
+
let orchestratorLabel = false
|
|
211
|
+
try {
|
|
212
|
+
const pluginJs = join(base, "dist", "plugin.js")
|
|
213
|
+
if (existsSync(pluginJs)) {
|
|
214
|
+
hook = readFileSync(pluginJs, "utf8").includes("injectDevloomAgents")
|
|
215
|
+
}
|
|
216
|
+
} catch { /* entry treated as stale */ }
|
|
217
|
+
try {
|
|
218
|
+
// The profile-filter feature marker: present only in freshly built dist.
|
|
219
|
+
const agentsJs = join(base, "dist", "agents.js")
|
|
220
|
+
if (existsSync(agentsJs)) {
|
|
221
|
+
featureCode = readFileSync(agentsJs, "utf8").includes("agentVariantVisible")
|
|
222
|
+
}
|
|
223
|
+
} catch { /* entry treated as stale */ }
|
|
224
|
+
try {
|
|
225
|
+
const orchestratorMd = join(base, "agents", "devloom-orchestrator.md")
|
|
226
|
+
if (existsSync(orchestratorMd)) {
|
|
227
|
+
orchestratorLabel = /\(profile: [^)]+\)/.test(readFileSync(orchestratorMd, "utf8"))
|
|
228
|
+
}
|
|
229
|
+
} catch { /* label unknown -> false */ }
|
|
230
|
+
return { entry, hook, featureCode, orchestratorLabel }
|
|
231
|
+
})
|
|
232
|
+
const status = details.every((detail) => detail.hook && detail.featureCode) ? "ok" : "stale"
|
|
233
|
+
return { status, entries: details }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function pluginCacheStatus(cacheDir = getOpenCodeCacheDir()) {
|
|
237
|
+
return pluginCacheStatusDetail(cacheDir).status
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function runCli(args, cacheDir = getOpenCodeCacheDir()) {
|
|
241
|
+
const command = args[0] || ""
|
|
242
|
+
if (command === "status") {
|
|
243
|
+
const detail = pluginCacheStatusDetail(cacheDir)
|
|
244
|
+
console.log(`DevLoom plugin cache: ${detail.status}`)
|
|
245
|
+
if (detail.entries.length === 0) {
|
|
246
|
+
console.log(" No DevLoom plugin cache entry found.")
|
|
247
|
+
console.log(" Install the plugin once with: opencode plugin devloom --global")
|
|
248
|
+
console.log(" then refresh the cache with: npm install -g devloom && node $(npm root -g)/devloom/postinstall.mjs")
|
|
249
|
+
return detail.status
|
|
250
|
+
}
|
|
251
|
+
for (const entry of detail.entries) {
|
|
252
|
+
const hook = entry.hook ? "yes" : "NO"
|
|
253
|
+
const feature = entry.featureCode ? "yes" : "NO"
|
|
254
|
+
const label = entry.orchestratorLabel ? "yes" : "no"
|
|
255
|
+
console.log(` ${entry.entry}: config-hook=${hook} feature-code=${feature} orchestrator-profile-label=${label}`)
|
|
256
|
+
}
|
|
257
|
+
if (detail.status !== "ok") {
|
|
258
|
+
console.log(" Fix: run /devloom-refresh, then restart opencode (or: opencode --continue).")
|
|
259
|
+
}
|
|
260
|
+
return detail.status
|
|
261
|
+
}
|
|
262
|
+
console.log("DevLoom plugin cache helpers")
|
|
263
|
+
console.log("Commands:")
|
|
264
|
+
console.log(" devloom plugin-cache status — print cache health (ok/stale/missing) + per-entry info")
|
|
265
|
+
return null
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const isMain = process.argv[1] ? fileURLToPath(import.meta.url) === resolve(process.argv[1]) : false
|
|
269
|
+
if (isMain) runCli(process.argv.slice(2))
|