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.
Files changed (142) hide show
  1. package/.ai/core.dsl +44 -0
  2. package/.ai/skills.dsl +39 -0
  3. package/.ai/verify.dsl +97 -0
  4. package/.ai/workflow.dsl +81 -0
  5. package/.opencode/themes/devloom-night-owl.json +103 -0
  6. package/GUIDE.md +572 -115
  7. package/README.md +583 -109
  8. package/SECURITY.md +93 -0
  9. package/agents/devloom-developer-flash.md +31 -0
  10. package/agents/devloom-developer-senior.md +32 -0
  11. package/agents/devloom-developer.md +21 -55
  12. package/agents/devloom-documenter-flash.md +28 -0
  13. package/agents/devloom-documenter.md +17 -56
  14. package/agents/devloom-orchestrator.md +236 -356
  15. package/agents/devloom-planner-flash.md +34 -0
  16. package/agents/devloom-planner-senior.md +35 -0
  17. package/agents/devloom-planner.md +36 -0
  18. package/agents/devloom-qa-flash.md +32 -0
  19. package/agents/devloom-qa.md +21 -83
  20. package/agents/devloom-security-senior.md +35 -0
  21. package/agents/devloom-security.md +34 -0
  22. package/agents/devloom-verifier.md +33 -0
  23. package/agents/devloom-vision.md +113 -0
  24. package/agents/devloom-visual-critic.md +102 -0
  25. package/agents/devloom-visual-director.md +84 -0
  26. package/commands/devloom-agents.md +49 -0
  27. package/commands/devloom-auto.md +11 -0
  28. package/commands/devloom-context.md +82 -0
  29. package/commands/devloom-deepseek-mimo.md +11 -0
  30. package/commands/devloom-deepseek.md +11 -0
  31. package/commands/devloom-free.md +11 -0
  32. package/commands/devloom-go-economy.md +11 -0
  33. package/commands/devloom-go-flash.md +11 -0
  34. package/commands/devloom-go.md +11 -0
  35. package/commands/devloom-init.md +28 -50
  36. package/commands/devloom-loop-status.md +25 -0
  37. package/commands/devloom-loop.md +36 -0
  38. package/commands/devloom-mimo.md +11 -0
  39. package/commands/devloom-plan.md +13 -0
  40. package/commands/devloom-refresh.md +22 -0
  41. package/commands/devloom-resume.md +30 -49
  42. package/commands/devloom-save.md +69 -0
  43. package/commands/devloom-status.md +9 -18
  44. package/commands/devloom.md +99 -67
  45. package/dist/agents.d.ts +30 -0
  46. package/dist/agents.d.ts.map +1 -0
  47. package/dist/agents.js +139 -0
  48. package/dist/agents.js.map +1 -0
  49. package/dist/bootstrap.d.ts +56 -0
  50. package/dist/bootstrap.d.ts.map +1 -0
  51. package/dist/bootstrap.js +116 -0
  52. package/dist/bootstrap.js.map +1 -0
  53. package/dist/constraints.d.ts +5 -0
  54. package/dist/constraints.d.ts.map +1 -0
  55. package/dist/constraints.js +45 -0
  56. package/dist/constraints.js.map +1 -0
  57. package/dist/context.d.ts +42 -0
  58. package/dist/context.d.ts.map +1 -0
  59. package/dist/context.js +636 -0
  60. package/dist/context.js.map +1 -0
  61. package/dist/guard.d.ts +19 -0
  62. package/dist/guard.d.ts.map +1 -0
  63. package/dist/guard.js +418 -0
  64. package/dist/guard.js.map +1 -0
  65. package/dist/loop.d.ts +64 -0
  66. package/dist/loop.d.ts.map +1 -0
  67. package/dist/loop.js +164 -0
  68. package/dist/loop.js.map +1 -0
  69. package/dist/plugin.d.ts.map +1 -1
  70. package/dist/plugin.js +101 -1
  71. package/dist/plugin.js.map +1 -1
  72. package/dist/tui-agents.d.ts +61 -0
  73. package/dist/tui-agents.d.ts.map +1 -0
  74. package/dist/tui-agents.js +89 -0
  75. package/dist/tui-agents.js.map +1 -0
  76. package/dist/tui.d.ts +6 -0
  77. package/dist/tui.d.ts.map +1 -0
  78. package/dist/tui.js +92 -0
  79. package/dist/tui.js.map +1 -0
  80. package/dist/worktree.d.ts +46 -0
  81. package/dist/worktree.d.ts.map +1 -0
  82. package/dist/worktree.js +272 -0
  83. package/dist/worktree.js.map +1 -0
  84. package/package.json +46 -6
  85. package/patterns/changelog-drafter.md +38 -0
  86. package/patterns/ci-sweeper.md +40 -0
  87. package/patterns/daily-triage.md +37 -0
  88. package/patterns/dependency-sweeper.md +40 -0
  89. package/patterns/design-audit.md +63 -0
  90. package/patterns/issue-triage.md +37 -0
  91. package/patterns/post-merge-cleanup.md +34 -0
  92. package/patterns/pr-babysitter.md +37 -0
  93. package/patterns/registry.yaml +52 -0
  94. package/postinstall.mjs +264 -64
  95. package/project/README.md +29 -0
  96. package/protocol/agent-contracts.md +27 -0
  97. package/protocol/artifact-system.md +53 -0
  98. package/protocol/model-routing.md +194 -0
  99. package/protocol/orchestrator-core.md +43 -0
  100. package/protocol/project-system.md +45 -0
  101. package/protocol/rules.md +18 -0
  102. package/protocol/verification-policy.md +48 -0
  103. package/scripts/briefing.mjs +192 -0
  104. package/scripts/loop-run.mjs +205 -0
  105. package/scripts/model-capabilities.mjs +194 -0
  106. package/scripts/plugin-cache.mjs +269 -0
  107. package/scripts/profile.mjs +755 -0
  108. package/scripts/visual-benchmark.mjs +149 -0
  109. package/scripts/worktree.mjs +445 -0
  110. package/skills/build/development.md +33 -0
  111. package/skills/build/live-docs.md +38 -0
  112. package/skills/build/simplify.md +50 -0
  113. package/skills/build/vision-analysis.md +98 -0
  114. package/skills/design/app-design.md +31 -0
  115. package/skills/design/design-grounding.md +30 -0
  116. package/skills/design/design-system.md +27 -0
  117. package/skills/design/game-design.md +35 -0
  118. package/skills/design/motion-design.md +34 -0
  119. package/skills/design/visual-direction.md +32 -0
  120. package/skills/design/web-design.md +33 -0
  121. package/skills/loop/changelog-drafter.md +32 -0
  122. package/skills/loop/ci-sweeper.md +24 -0
  123. package/skills/loop/constraints.md +21 -0
  124. package/skills/loop/dependency-sweeper.md +24 -0
  125. package/skills/loop/design-audit.md +67 -0
  126. package/skills/loop/issue-triage.md +27 -0
  127. package/skills/loop/post-merge-cleanup.md +24 -0
  128. package/skills/loop/pr-babysitter.md +26 -0
  129. package/skills/loop/triage.md +25 -0
  130. package/skills/loop/verifier.md +20 -0
  131. package/skills/meta/skill-discovery.md +23 -0
  132. package/skills/plan/planning.md +27 -0
  133. package/skills/plan/verification-planning.md +40 -0
  134. package/skills/review/security-review.md +63 -0
  135. package/skills/ship/documentation.md +11 -0
  136. package/skills/verify/app-verification.md +19 -0
  137. package/skills/verify/quality-assurance.md +17 -0
  138. package/skills/verify/visual-critique.md +30 -0
  139. package/skills/verify/visual-quality-gate.md +29 -0
  140. package/skills/verify/visual-regression.md +25 -0
  141. package/agents/devloom-analyst.md +0 -83
  142. 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))