@kleber.mottajr/juninho 1.3.0 → 2.0.1

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 (37) hide show
  1. package/README.md +14 -15
  2. package/dist/config.d.ts +29 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +57 -3
  5. package/dist/config.js.map +1 -1
  6. package/dist/installer.d.ts.map +1 -1
  7. package/dist/installer.js +159 -53
  8. package/dist/installer.js.map +1 -1
  9. package/dist/project-types.d.ts.map +1 -1
  10. package/dist/project-types.js +6 -0
  11. package/dist/project-types.js.map +1 -1
  12. package/dist/templates/agents.d.ts.map +1 -1
  13. package/dist/templates/agents.js +925 -162
  14. package/dist/templates/agents.js.map +1 -1
  15. package/dist/templates/commands.d.ts.map +1 -1
  16. package/dist/templates/commands.js +747 -626
  17. package/dist/templates/commands.js.map +1 -1
  18. package/dist/templates/docs.d.ts.map +1 -1
  19. package/dist/templates/docs.js +49 -24
  20. package/dist/templates/docs.js.map +1 -1
  21. package/dist/templates/lib.d.ts +2 -0
  22. package/dist/templates/lib.d.ts.map +1 -0
  23. package/dist/templates/lib.js +506 -0
  24. package/dist/templates/lib.js.map +1 -0
  25. package/dist/templates/plugins.d.ts.map +1 -1
  26. package/dist/templates/plugins.js +2530 -856
  27. package/dist/templates/plugins.js.map +1 -1
  28. package/dist/templates/skills.d.ts.map +1 -1
  29. package/dist/templates/skills.js +30 -0
  30. package/dist/templates/skills.js.map +1 -1
  31. package/dist/templates/state.d.ts.map +1 -1
  32. package/dist/templates/state.js +159 -186
  33. package/dist/templates/state.js.map +1 -1
  34. package/dist/templates/support-scripts.d.ts.map +1 -1
  35. package/dist/templates/support-scripts.js +1014 -249
  36. package/dist/templates/support-scripts.js.map +1 -1
  37. package/package.json +8 -2
@@ -11,6 +11,9 @@ function writePlugins(projectDir, projectType = "node-nextjs", isKotlin = false)
11
11
  (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.env-protection.ts"), ENV_PROTECTION);
12
12
  (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.auto-format.ts"), AUTO_FORMAT);
13
13
  (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.plan-autoload.ts"), PLAN_AUTOLOAD);
14
+ (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.task-runtime.ts"), TASK_RUNTIME);
15
+ (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.task-board.ts"), TASK_BOARD);
16
+ (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.notify.ts"), NOTIFY);
14
17
  (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.carl-inject.ts"), CARL_INJECT);
15
18
  (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.skill-inject.ts"), skillInject(projectType, isKotlin));
16
19
  (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.intent-gate.ts"), INTENT_GATE);
@@ -21,473 +24,995 @@ function writePlugins(projectDir, projectType = "node-nextjs", isKotlin = false)
21
24
  (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.directory-agents-injector.ts"), DIR_AGENTS_INJECTOR);
22
25
  (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.memory.ts"), MEMORY);
23
26
  // Write initial skill-map.json for dynamic extension by /j.finish-setup
24
- (0, fs_1.writeFileSync)(path_1.default.join(projectDir, ".opencode", "state", "skill-map.json"), JSON.stringify(getBaseSkillMap(projectType, isKotlin), null, 2) + "\n");
27
+ (0, fs_1.writeFileSync)(path_1.default.join(projectDir, ".opencode", "skill-map.json"), JSON.stringify(getBaseSkillMap(projectType, isKotlin), null, 2) + "\n");
25
28
  }
26
29
  // ─── Env Protection ──────────────────────────────────────────────────────────
27
- const ENV_PROTECTION = `import type { Plugin } from "@opencode-ai/plugin"
28
-
29
- // Blocks reads/writes of sensitive files before any tool executes.
30
- // Real API: tool.execute.before(input, output) — throw Error to abort.
31
-
32
- const SENSITIVE = [
33
- /\\.env($|\\.)/i,
34
- /secret/i,
35
- /credential/i,
36
- /\\.pem$/i,
37
- /id_rsa/i,
38
- /\\.key$/i,
39
- ]
40
-
41
- export default (async ({ directory: _directory }: { directory: string }) => ({
42
- "tool.execute.before": async (
43
- input: { tool: string; sessionID: string; callID: string },
44
- output: { args: any }
45
- ) => {
46
- const filePath: string =
47
- output.args?.path ?? output.args?.file_path ?? output.args?.filename ?? ""
48
- if (!filePath) return
49
-
50
- if (SENSITIVE.some((p) => p.test(filePath))) {
51
- throw new Error(
52
- \`[env-protection] Blocked access to sensitive file: \${filePath}\\n\` +
53
- \`If intentional, temporarily disable the env-protection plugin.\`
54
- )
55
- }
56
- },
57
- })) satisfies Plugin
58
- `;
59
- // ─── Auto Format ─────────────────────────────────────────────────────────────
60
- const AUTO_FORMAT = `import type { Plugin } from "@opencode-ai/plugin"
61
- import { execSync } from "child_process"
62
- import path from "path"
63
-
64
- // Auto-formats files after Write/Edit tool calls.
65
- // Real API: tool.execute.after(input, output) — input.args has the file path.
66
-
67
- const FORMATTERS: Record<string, string> = {
68
- ".ts": "prettier --write",
69
- ".tsx": "prettier --write",
70
- ".js": "prettier --write",
71
- ".jsx": "prettier --write",
72
- ".json": "prettier --write",
73
- ".css": "prettier --write",
74
- ".scss": "prettier --write",
75
- ".md": "prettier --write",
76
- ".py": "black",
77
- ".go": "gofmt -w",
78
- ".rs": "rustfmt",
79
- }
80
-
81
- export default (async ({ directory: _directory }: { directory: string }) => ({
82
- "tool.execute.after": async (
83
- input: { tool: string; sessionID: string; callID: string; args: any },
84
- _output: { title: string; output: string; metadata: any }
85
- ) => {
86
- if (!["Write", "Edit", "MultiEdit"].includes(input.tool)) return
87
-
88
- const filePath: string = input.args?.path ?? input.args?.file_path ?? ""
89
- if (!filePath) return
90
-
91
- const formatter = FORMATTERS[path.extname(filePath)]
92
- if (!formatter) return
93
-
94
- try {
95
- execSync(\`\${formatter} "\${filePath}"\`, { stdio: "ignore" })
96
- } catch {
97
- // Formatter not available — skip silently
98
- }
99
- },
100
- })) satisfies Plugin
30
+ const ENV_PROTECTION = `import type { Plugin } from "@opencode-ai/plugin"
31
+
32
+ // Blocks reads/writes of sensitive files before any tool executes.
33
+ // Real API: tool.execute.before(input, output) — throw Error to abort.
34
+
35
+ const SENSITIVE = [
36
+ /\\.env($|\\.)/i,
37
+ /secret/i,
38
+ /credential/i,
39
+ /\\.pem$/i,
40
+ /id_rsa/i,
41
+ /\\.key$/i,
42
+ ]
43
+
44
+ export default (async ({ directory: _directory }: { directory: string }) => ({
45
+ "tool.execute.before": async (
46
+ input: { tool: string; sessionID: string; callID: string },
47
+ output: { args: any }
48
+ ) => {
49
+ const filePath: string =
50
+ output.args?.path ?? output.args?.file_path ?? output.args?.filename ?? ""
51
+ if (!filePath) return
52
+
53
+ if (SENSITIVE.some((p) => p.test(filePath))) {
54
+ throw new Error(
55
+ \`[env-protection] Blocked access to sensitive file: \${filePath}\\n\` +
56
+ \`If intentional, temporarily disable the env-protection plugin.\`
57
+ )
58
+ }
59
+ },
60
+ })) satisfies Plugin
101
61
  `;
102
- // ─── Plan Autoload ────────────────────────────────────────────────────────────
103
- const PLAN_AUTOLOAD = `import type { Plugin } from "@opencode-ai/plugin"
104
- import { existsSync, readFileSync, unlinkSync } from "fs"
105
- import path from "path"
106
-
107
- // Injects active plan into agent context when a .plan-ready IPC flag exists.
108
- // Uses tool.execute.after on Read — the first Read triggers plan injection.
109
- // Also uses experimental.session.compacting to survive session compaction.
110
- // The .plan-ready flag is deleted after first injection (fire-once).
111
-
112
- export default (async ({ directory }: { directory: string }) => {
113
- let planInjected = false
114
-
115
- return {
116
- "tool.execute.after": async (
117
- input: { tool: string; sessionID: string; callID: string; args: any },
118
- output: { title: string; output: string; metadata: any }
119
- ) => {
120
- if (input.tool !== "Read" || planInjected) return
121
-
122
- const readyFile = path.join(directory, ".opencode", "state", ".plan-ready")
123
- if (!existsSync(readyFile)) return
124
-
125
- const planPath = readFileSync(readyFile, "utf-8").trim()
126
- const fullPath = path.isAbsolute(planPath) ? planPath : path.join(directory, planPath)
127
- if (!existsSync(fullPath)) return
128
-
129
- const planContent = readFileSync(fullPath, "utf-8")
130
- planInjected = true
131
-
132
- try { unlinkSync(readyFile) } catch { /* ok */ }
133
-
134
- output.output +=
135
- \`\\n\\n[plan-autoload] Active plan detected at \${planPath}:\\n\\n\${planContent}\\n\\n\` +
136
- \`Use /j.implement to execute this plan, or /j.plan to revise it.\`
137
- },
138
-
139
- "experimental.session.compacting": async (
140
- _input: { sessionID?: string },
141
- output: { context: string[] }
142
- ) => {
143
- // Ensure plan survives session compaction
144
- const readyFile = path.join(directory, ".opencode", "state", ".plan-ready")
145
- if (existsSync(readyFile)) {
146
- const planPath = readFileSync(readyFile, "utf-8").trim()
147
- const fullPath = path.isAbsolute(planPath) ? planPath : path.join(directory, planPath)
148
- if (existsSync(fullPath)) {
149
- const planContent = readFileSync(fullPath, "utf-8")
150
- output.context.push(
151
- \`[plan-autoload] Active plan at \${planPath}:\\n\\n\${planContent}\`
152
- )
153
- }
154
- }
155
- },
156
- }
157
- }) satisfies Plugin
62
+ // ─── Plan Autoload (evolved) ─────────────────────────────────────────────────
63
+ const PLAN_AUTOLOAD = `import type { Plugin } from "@opencode-ai/plugin"
64
+ import { existsSync, readFileSync } from "fs"
65
+ import path from "path"
66
+ import { resolveStateFile } from "../lib/j.state-paths"
67
+ import { loadActivePlanReferenceProjects, loadActivePlanTarget, loadActivePlanTargets, resolvePathFromProjectRoot, resolveProjectPaths } from "../lib/j.workspace-paths"
68
+
69
+ // Injects active plan into agent context when an active-plan state pointer exists.
70
+ // Uses chat.message for initial injection, tool.execute.after(Read) as a
71
+ // fallback, and experimental.session.compacting to survive session compaction.
72
+ // The active-plan pointer stays on disk so later messages, compaction, and
73
+ // write-time guards can all resolve the same active plan consistently.
74
+
75
+ export default (async ({ directory }: { directory: string }) => {
76
+ const planInjectedSessions = new Set<string>()
77
+
78
+ function loadActivePlan(): { planPath: string; planContent: string; specPath?: string; contextPath?: string; targets?: Array<{ projectLabel: string; planPath: string; planContent?: string; specPath?: string; contextPath?: string }>; referenceProjects?: Array<{ projectLabel: string; reason?: string }> } | null {
79
+ const activePlanFile = resolveStateFile(directory, "active-plan.json")
80
+ if (!existsSync(activePlanFile)) return null
81
+
82
+ const state = loadActivePlanTarget(directory) ?? JSON.parse(readFileSync(activePlanFile, "utf-8")) as { planPath?: string; specPath?: string; contextPath?: string; targetRepoRoot?: string }
83
+ const planPath = state.planPath?.trim()
84
+ if (!planPath) return null
85
+ const projectPaths = resolveProjectPaths(directory, {
86
+ targetRepoRoot: state.targetRepoRoot,
87
+ planPath,
88
+ specPath: state.specPath,
89
+ contextPath: state.contextPath,
90
+ })
91
+ const fullPath = path.isAbsolute(planPath)
92
+ ? planPath
93
+ : projectPaths
94
+ ? resolvePathFromProjectRoot(projectPaths.projectRoot, planPath)
95
+ : path.join(directory, planPath)
96
+ if (!existsSync(fullPath)) return null
97
+
98
+ return {
99
+ planPath,
100
+ planContent: readFileSync(fullPath, "utf-8"),
101
+ specPath: state.specPath?.trim() || undefined,
102
+ contextPath: state.contextPath?.trim() || undefined,
103
+ targets: loadActivePlanTargets(directory)
104
+ .map((target) => {
105
+ const projectPaths = resolveProjectPaths(directory, { targetRepoRoot: target.targetRepoRoot, planPath: target.planPath })
106
+ if (!projectPaths || !target.planPath) return null
107
+ const targetPlanFullPath = path.isAbsolute(target.planPath)
108
+ ? target.planPath
109
+ : resolvePathFromProjectRoot(projectPaths.projectRoot, target.planPath)
110
+ const targetPlanContent = existsSync(targetPlanFullPath) ? readFileSync(targetPlanFullPath, "utf-8") : undefined
111
+ return {
112
+ projectLabel: projectPaths.projectLabel,
113
+ planPath: target.planPath,
114
+ planContent: targetPlanContent,
115
+ specPath: target.specPath?.trim() || undefined,
116
+ contextPath: target.contextPath?.trim() || undefined,
117
+ }
118
+ })
119
+ .filter((entry): entry is { projectLabel: string; planPath: string; planContent?: string; specPath?: string; contextPath?: string } => Boolean(entry)),
120
+ referenceProjects: loadActivePlanReferenceProjects(directory)
121
+ .map((project) => {
122
+ const projectPaths = resolveProjectPaths(directory, { targetRepoRoot: project.targetRepoRoot })
123
+ if (!projectPaths) return null
124
+ return {
125
+ projectLabel: projectPaths.projectLabel,
126
+ reason: project.reason?.trim() || undefined,
127
+ }
128
+ })
129
+ .filter((entry): entry is { projectLabel: string; reason?: string } => Boolean(entry)),
130
+ }
131
+ }
132
+
133
+ function renderPlan(planPath: string, planContent: string, specPath?: string, contextPath?: string, targets?: Array<{ projectLabel: string; planPath: string; planContent?: string; specPath?: string; contextPath?: string }>, referenceProjects?: Array<{ projectLabel: string; reason?: string }>): string {
134
+ const contractLines = [
135
+ \`[plan-autoload] Active plan detected at \${planPath}:\`,
136
+ specPath ? \`[plan-autoload] Spec contract: \${specPath}\` : "[plan-autoload] Spec contract: N/A",
137
+ contextPath ? \`[plan-autoload] Context contract: \${contextPath}\` : "[plan-autoload] Context contract: N/A",
138
+ ...(targets && targets.length > 1
139
+ ? ["[plan-autoload] Multi-project write targets:", ...targets.map((target) => \`- \${target.projectLabel}: plan=\${target.planPath}\${target.specPath ? \` spec=\${target.specPath}\` : ""}\${target.contextPath ? \` context=\${target.contextPath}\` : ""}\`)]
140
+ : []),
141
+ ...(targets && targets.length > 1
142
+ ? ["[plan-autoload] /j.implement must iterate every write target and must not stop after the first target."]
143
+ : []),
144
+ ...(referenceProjects && referenceProjects.length > 0
145
+ ? ["[plan-autoload] Reference projects:", ...referenceProjects.map((project) => \`- \${project.projectLabel}\${project.reason ? \`: \${project.reason}\` : ""}\`)]
146
+ : []),
147
+ "",
148
+ planContent,
149
+ ...(targets && targets.length > 1
150
+ ? targets
151
+ .filter((t) => t.planContent && t.planContent !== planContent)
152
+ .flatMap((t) => [
153
+ "",
154
+ \`[plan-autoload] Plan content for \${t.projectLabel} (\${t.planPath}):\`,
155
+ "",
156
+ t.planContent!,
157
+ ])
158
+ : []),
159
+ "",
160
+ "Use /j.implement to execute this plan, or /j.plan to revise it.",
161
+ ]
162
+ return (
163
+ contractLines.join("\\n")
164
+ )
165
+ }
166
+
167
+ return {
168
+ "chat.message": async (
169
+ input: { sessionID: string },
170
+ output: { message: { system?: string }; parts: unknown[] }
171
+ ) => {
172
+ if (planInjectedSessions.has(input.sessionID)) return
173
+
174
+ const loaded = loadActivePlan()
175
+ if (!loaded) return
176
+
177
+ planInjectedSessions.add(input.sessionID)
178
+ output.message.system = output.message.system
179
+ ? output.message.system + "\\n\\n" + renderPlan(loaded.planPath, loaded.planContent, loaded.specPath, loaded.contextPath, loaded.targets, loaded.referenceProjects)
180
+ : renderPlan(loaded.planPath, loaded.planContent, loaded.specPath, loaded.contextPath, loaded.targets, loaded.referenceProjects)
181
+ },
182
+ "tool.execute.after": async (
183
+ input: { tool: string; sessionID: string; callID: string; args: any },
184
+ output: { title: string; output: string; metadata: any }
185
+ ) => {
186
+ if (input.tool !== "Read" || planInjectedSessions.has(input.sessionID)) return
187
+
188
+ const loaded = loadActivePlan()
189
+ if (!loaded) return
190
+
191
+ planInjectedSessions.add(input.sessionID)
192
+ output.output += "\\n\\n" + renderPlan(loaded.planPath, loaded.planContent, loaded.specPath, loaded.contextPath, loaded.targets, loaded.referenceProjects)
193
+ },
194
+
195
+ "experimental.session.compacting": async (
196
+ _input: { sessionID?: string },
197
+ output: { context: string[] }
198
+ ) => {
199
+ const loaded = loadActivePlan()
200
+ if (!loaded) return
201
+
202
+ output.context.push(renderPlan(loaded.planPath, loaded.planContent, loaded.specPath, loaded.contextPath, loaded.targets, loaded.referenceProjects))
203
+ },
204
+ }
205
+ }) satisfies Plugin
158
206
  `;
159
- // ─── CARL Inject ──────────────────────────────────────────────────────────────
160
- const CARL_INJECT = `import type { Plugin } from "@opencode-ai/plugin"
161
- import { existsSync, readFileSync } from "fs"
162
- import path from "path"
163
-
164
- // CARL v2 = Context-Aware Retrieval Layer
165
- // Content-aware keyword detection inspired by oh-my-opencode.
166
- // Two hooks:
167
- // tool.execute.after (Read) — extracts keywords from FILE CONTENT (not just
168
- // path) after stripping code blocks. On first trigger per session, also
169
- // reads execution-state.md for task-awareness. Injects matching principles
170
- // and domain docs into the Read output.
171
- // experimental.session.compacting — re-injects all collected docs so they
172
- // survive context window resets.
173
- //
174
- // Key improvements over v1:
175
- // - Analyzes stripped file content for keyword matching (understands context)
176
- // - Word-boundary regex matching (prevents "auth" matching "authorize")
177
- // - Task-awareness from execution-state.md (understands the goal)
178
- // - Budget cap prevents context overflow
179
- // - Compaction survival via second hook
180
-
181
- // ── Types ──
182
-
183
- interface PrincipleEntry {
184
- key: string
185
- recall: string[]
186
- file: string
187
- priority: number
188
- }
189
-
190
- interface DomainEntry {
191
- domain: string
192
- keywords: string[]
193
- files: Array<{ path: string; description: string }>
194
- }
195
-
196
- interface CollectedEntry {
197
- content: string
198
- priority: number
199
- type: "principle" | "domain"
200
- label: string
201
- }
202
-
203
- // ── Parsing ──
204
-
205
- function parsePrinciplesManifest(content: string): PrincipleEntry[] {
206
- const entries: PrincipleEntry[] = []
207
- const lines = content.split("\\n").filter((l) => !l.startsWith("#") && l.trim())
208
-
209
- const byKey: Record<string, Record<string, string>> = {}
210
- for (const line of lines) {
211
- const match = /^([A-Z_]+)_(STATE|RECALL|FILE|PRIORITY)=(.*)$/.exec(line)
212
- if (!match) continue
213
- const [, prefix, field, value] = match
214
- if (!byKey[prefix]) byKey[prefix] = {}
215
- byKey[prefix][field] = value.trim()
216
- }
217
-
218
- for (const [key, fields] of Object.entries(byKey)) {
219
- if (fields["STATE"] !== "active") continue
220
- if (!fields["RECALL"] || !fields["FILE"]) continue
221
- entries.push({
222
- key,
223
- recall: fields["RECALL"].split(",").map((k) => k.trim().toLowerCase()),
224
- file: fields["FILE"],
225
- priority: parseInt(fields["PRIORITY"] ?? "1", 10),
226
- })
227
- }
228
-
229
- return entries
230
- }
231
-
232
- function parseDomainIndex(content: string): DomainEntry[] {
233
- const entries: DomainEntry[] = []
234
- const sections = content.split(/^## /m).slice(1)
235
-
236
- for (const section of sections) {
237
- const lines = section.split("\\n")
238
- const domain = lines[0].trim()
239
- const keywordsLine = lines.find((l) => l.startsWith("Keywords:"))
240
- const filesStart = lines.findIndex((l) => l.startsWith("Files:"))
241
-
242
- if (!keywordsLine || filesStart === -1) continue
243
-
244
- const keywords = keywordsLine
245
- .replace("Keywords:", "")
246
- .split(",")
247
- .map((k) => k.trim().toLowerCase())
248
-
249
- const files: Array<{ path: string; description: string }> = []
250
- for (let i = filesStart + 1; i < lines.length; i++) {
251
- const fileMatch = /^\\s+-\\s+([^—]+)(?:—\\s+(.*))?$/.exec(lines[i])
252
- if (!fileMatch) break
253
- files.push({ path: fileMatch[1].trim(), description: fileMatch[2]?.trim() ?? "" })
254
- }
255
-
256
- entries.push({ domain, keywords, files })
257
- }
258
-
259
- return entries
260
- }
261
-
262
- // ── Content Analysis (oh-my-opencode style) ──
263
-
264
- function stripCodeBlocks(text: string): string {
265
- // Remove fenced code blocks and inline code (backtick-wrapped)
266
- // Prevents false keyword matches from variable names, imports, etc.
267
- let stripped = text.replace(/\`\`\`[\\s\\S]*?\`\`\`/g, "")
268
- stripped = stripped.replace(/\`[^\`\\n]+\`/g, "")
269
- return stripped
270
- }
271
-
272
- function extractKeywords(text: string): Set<string> {
273
- // Extract meaningful words from text (stripped of code) for matching
274
- const words = new Set<string>()
275
- for (const w of text.split(/[^a-zA-Z0-9_-]+/).filter((w) => w.length >= 3)) {
276
- words.add(w.toLowerCase())
277
- }
278
- return words
279
- }
280
-
281
- function extractPathKeywords(filePath: string): Set<string> {
282
- // Secondary signal: meaningful words from the file path
283
- const parts = filePath.replace(/\\\\/g, "/").split("/")
284
- const words = new Set<string>()
285
- for (const part of parts) {
286
- for (const w of part.split(/[^a-zA-Z0-9_-]+/).filter((w) => w.length >= 3)) {
287
- words.add(w.toLowerCase())
288
- }
289
- }
290
- return words
291
- }
292
-
293
- function escapeRegex(value: string): string {
294
- return value.replace(/[.*+?^$()|[\\]{}]/g, "\\$&")
295
- }
296
-
297
- function matchKeyword(keyword: string, textWords: Set<string>, rawText: string): boolean {
298
- // Word-boundary matching "auth" matches "auth" but NOT "authorize" or "author"
299
- // First check exact set membership (fast path), then regex fallback for
300
- // short tokens and multi-word recall terms.
301
- if (textWords.has(keyword)) return true
302
-
303
- const pattern = new RegExp("\\b" + escapeRegex(keyword) + "\\b", "i")
304
- return pattern.test(rawText)
305
- }
306
-
307
- // ── ContextCollector budget-aware dedup singleton ──
308
-
309
- const MAX_CONTEXT_BYTES = 8000
310
-
311
- class ContextCollector {
312
- private collected = new Map<string, CollectedEntry>()
313
- private totalBytes = 0
314
-
315
- has(key: string): boolean {
316
- return this.collected.has(key)
317
- }
318
-
319
- add(key: string, content: string, priority: number, type: "principle" | "domain", label: string): boolean {
320
- if (this.collected.has(key)) return false
321
- const size = Buffer.byteLength(content, "utf-8")
322
- if (this.totalBytes + size > MAX_CONTEXT_BYTES) return false
323
-
324
- this.collected.set(key, { content, priority, type, label })
325
- this.totalBytes += size
326
- return true
327
- }
328
-
329
- getNewEntries(keys: string[]): CollectedEntry[] {
330
- return keys
331
- .filter((k) => this.collected.has(k))
332
- .map((k) => this.collected.get(k)!)
333
- .sort((a, b) => a.priority - b.priority)
334
- }
335
-
336
- getAll(): CollectedEntry[] {
337
- return Array.from(this.collected.values()).sort((a, b) => a.priority - b.priority)
338
- }
339
-
340
- formatForOutput(entries: CollectedEntry[]): string {
341
- return entries
342
- .map((e) => \`[carl-inject] \${e.type === "principle" ? "Principle" : "Domain"} (\${e.label}):\\n\${e.content}\`)
343
- .join("\\n\\n---\\n\\n")
344
- }
345
- }
346
-
347
- // ── Plugin ──
348
-
349
- export default (async ({ directory }: { directory: string }) => {
350
- const collector = new ContextCollector()
351
- const taskKeywordsLoaded = new Set<string>()
352
-
353
- function loadTaskKeywords(sessionID: string): Set<string> {
354
- // Fire-once per session: extract keywords from execution-state.md
355
- // to understand what the agent is working on (task awareness)
356
- if (taskKeywordsLoaded.has(sessionID)) return new Set()
357
- taskKeywordsLoaded.add(sessionID)
358
-
359
- const statePath = path.join(directory, ".opencode", "state", "execution-state.md")
360
- if (!existsSync(statePath)) return new Set()
361
-
362
- const state = readFileSync(statePath, "utf-8")
363
- // Extract Goal + Task List sections — these describe what the agent is doing
364
- const goalMatch = /\\*\\*Goal\\*\\*:\\s*(.+)/i.exec(state)
365
- const taskLines = state.split("\\n").filter((l) => /^\\s*-\\s*\\[/.test(l))
366
-
367
- const taskText = [goalMatch?.[1] ?? "", ...taskLines].join(" ")
368
- return extractKeywords(stripCodeBlocks(taskText))
369
- }
370
-
371
- function matchAgainstSources(keywords: Set<string>, rawText: string): string[] {
372
- const manifestPath = path.join(directory, "docs", "principles", "manifest")
373
- const indexPath = path.join(directory, "docs", "domain", "INDEX.md")
374
- const addedKeys: string[] = []
375
-
376
- // ── Principles manifest ──
377
- if (existsSync(manifestPath)) {
378
- const manifest = readFileSync(manifestPath, "utf-8")
379
- const principles = parsePrinciplesManifest(manifest)
380
-
381
- for (const entry of principles) {
382
- const dedupKey = \`principle:\${entry.key}\`
383
- if (collector.has(dedupKey)) continue
384
-
385
- const matched = entry.recall.some((kw) => matchKeyword(kw, keywords, rawText))
386
- if (!matched) continue
387
-
388
- const entryFilePath = path.join(directory, entry.file)
389
- if (!existsSync(entryFilePath)) continue
390
-
391
- const content = readFileSync(entryFilePath, "utf-8")
392
- if (collector.add(dedupKey, content, entry.priority, "principle", entry.key)) {
393
- addedKeys.push(dedupKey)
394
- }
395
- }
396
- }
397
-
398
- // ── Domain index ──
399
- if (existsSync(indexPath)) {
400
- const index = readFileSync(indexPath, "utf-8")
401
- const domains = parseDomainIndex(index)
402
-
403
- for (const entry of domains) {
404
- const matched = entry.keywords.some((kw) => matchKeyword(kw, keywords, rawText))
405
- if (!matched) continue
406
-
407
- for (const file of entry.files.slice(0, 3)) {
408
- const dedupKey = \`domain:\${entry.domain}:\${file.path}\`
409
- if (collector.has(dedupKey)) continue
410
-
411
- const domainFilePath = path.join(directory, "docs", "domain", file.path)
412
- if (!existsSync(domainFilePath)) continue
413
-
414
- const content = readFileSync(domainFilePath, "utf-8")
415
- if (collector.add(dedupKey, content, 10, "domain", \`\${entry.domain} / \${file.path}\`)) {
416
- addedKeys.push(dedupKey)
417
- }
418
- }
419
- }
420
- }
421
-
422
- return addedKeys
423
- }
424
-
425
- return {
426
- "tool.execute.after": async (
427
- input: { tool: string; sessionID: string; callID: string; args: any },
428
- output: { title: string; output: string; metadata: any }
429
- ) => {
430
- if (input.tool !== "Read") return
431
-
432
- const filePath: string = input.args?.path ?? input.args?.file_path ?? ""
433
- if (!filePath) return
434
-
435
- // ── Collect keywords from multiple signals ──
436
- const allKeywords = new Set<string>()
437
-
438
- // Signal 1: Task awareness (fire-once per session)
439
- const taskKw = loadTaskKeywords(input.sessionID)
440
- for (const kw of taskKw) allKeywords.add(kw)
441
-
442
- // Signal 2: File content analysis (primary — understands what agent reads)
443
- const fileContent = output.output ?? ""
444
- const strippedContent = stripCodeBlocks(fileContent)
445
- const contentKw = extractKeywords(strippedContent)
446
- for (const kw of contentKw) allKeywords.add(kw)
447
-
448
- // Signal 3: Path keywords (secondary — cheap, complements content)
449
- const pathKw = extractPathKeywords(filePath)
450
- for (const kw of pathKw) allKeywords.add(kw)
451
-
452
- if (allKeywords.size === 0) return
453
-
454
- const rawSignal = [
455
- strippedContent,
456
- filePath,
457
- ...Array.from(taskKw),
458
- ...Array.from(pathKw),
459
- ].join(" ").toLowerCase()
460
-
461
- // ── Match and inject ──
462
- const addedKeys = matchAgainstSources(allKeywords, rawSignal)
463
- if (addedKeys.length === 0) return
464
-
465
- const newEntries = collector.getNewEntries(addedKeys)
466
- if (newEntries.length > 0) {
467
- output.output += "\\n\\n" + collector.formatForOutput(newEntries)
468
- }
469
- },
470
-
471
- "experimental.session.compacting": async (
472
- _input: Record<string, unknown>,
473
- output: { context: string[]; prompt?: string }
474
- ) => {
475
- const all = collector.getAll()
476
- if (all.length === 0) return
477
-
478
- output.context.push(
479
- "[carl-inject] Previously injected context (principles + domain docs):\\n\\n" +
480
- collector.formatForOutput(all)
481
- )
482
- },
483
- }
484
- }) satisfies Plugin
207
+ // ─── CARL Inject (evolved v3) ────────────────────────────────────────────────
208
+ const CARL_INJECT = `import type { Plugin } from "@opencode-ai/plugin"
209
+ import { existsSync, readdirSync, readFileSync } from "fs"
210
+ import path from "path"
211
+ import { loadActivePlanTarget, loadActivePlanTargets, resolvePathFromProjectRoot, resolveProjectPaths } from "../lib/j.workspace-paths"
212
+ import { featureStateTaskPaths } from "../lib/j.feature-state-paths"
213
+
214
+ // CARL v3 = Context-Aware Retrieval Layer
215
+ // Goals:
216
+ // - Preload task-scoped context for child implementer sessions before exploratory reads
217
+ // - Keep read-time enrichment for additional context discovered while working
218
+ // - Always load canonical principles when configured in the manifest
219
+ // - Rehydrate collected context during compaction
220
+
221
+ interface PrincipleEntry {
222
+ key: string
223
+ recall: string[]
224
+ file: string
225
+ priority: number
226
+ always: boolean
227
+ }
228
+
229
+ interface DomainEntry {
230
+ domain: string
231
+ keywords: string[]
232
+ files: Array<{ path: string; description: string }>
233
+ }
234
+
235
+ interface CollectedEntry {
236
+ content: string
237
+ priority: number
238
+ type: "principle" | "domain"
239
+ label: string
240
+ }
241
+
242
+ interface RuntimeTaskMetadata {
243
+ featureSlug?: string
244
+ taskID?: string
245
+ planPath?: string
246
+ targetRepoRoot?: string
247
+ originalPrompt?: string
248
+ }
249
+
250
+ interface TaskPlanContext {
251
+ taskID: string
252
+ files: string[]
253
+ action: string
254
+ verify: string
255
+ done: string
256
+ }
257
+
258
+ interface PendingStartupSeed {
259
+ prompt: string
260
+ subagentType?: string
261
+ featureSlug?: string
262
+ planPath?: string
263
+ taskID?: string
264
+ specPath?: string
265
+ contextPath?: string
266
+ taskContractPath?: string
267
+ targetRepoRoot?: string
268
+ }
269
+
270
+ interface TaskContractSeed {
271
+ featureSlug?: string
272
+ taskID?: string
273
+ planPath?: string
274
+ specPath?: string
275
+ contextPath?: string
276
+ taskContractPath?: string
277
+ targetRepoRoot?: string
278
+ }
279
+
280
+ const GENERIC_CARL_KEYWORDS = new Set([
281
+ "api",
282
+ "controller",
283
+ "endpoint",
284
+ "handler",
285
+ "http",
286
+ "integration",
287
+ "mock",
288
+ "request",
289
+ "response",
290
+ "rest",
291
+ "route",
292
+ "spec",
293
+ "test",
294
+ "tests",
295
+ "unit",
296
+ ])
297
+
298
+ const STARTUP_DOMAIN_SCORE_FLOOR_RATIO = 0.65
299
+ const FLOW_DOMAINS_WITH_BALANCE_COMPANION = new Set(["Cashout", "Orders", "Order", "Operational-entry", "Inactive-fee"])
300
+ const BALANCE_COMPANION_SIGNALS = ["available", "balance", "credit", "debit", "escrow", "loss", "reserve"]
301
+ const STARTUP_SEEDED_SUBAGENTS = new Set(["j.implementer", "j.checker", "j.planner", "j.spec-writer"])
302
+
303
+ function shouldSeedStartupPrompt(prompt: string, subagentType?: string): boolean {
304
+ if (subagentType && STARTUP_SEEDED_SUBAGENTS.has(subagentType)) return true
305
+ return /\\bactive plan\\b/i.test(prompt) || /docs\\/specs\\/[^\\s]+\\/(?:plan|spec)\\.md/.test(prompt) || /\\btask\\s+\\d+\\b/i.test(prompt)
306
+ }
307
+
308
+ function parsePrinciplesManifest(content: string): PrincipleEntry[] {
309
+ const entries: PrincipleEntry[] = []
310
+ const lines = content.split("\\n").filter((line) => !line.startsWith("#") && line.trim())
311
+
312
+ const byKey: Record<string, Record<string, string>> = {}
313
+ for (const line of lines) {
314
+ const match = /^([A-Z_]+)_(STATE|RECALL|FILE|PRIORITY|ALWAYS)=(.*)$/.exec(line)
315
+ if (!match) continue
316
+ const [, prefix, field, value] = match
317
+ if (!byKey[prefix]) byKey[prefix] = {}
318
+ byKey[prefix][field] = value.trim()
319
+ }
320
+
321
+ for (const [key, fields] of Object.entries(byKey)) {
322
+ if (fields["STATE"] !== "active") continue
323
+ if (!fields["FILE"]) continue
324
+ entries.push({
325
+ key,
326
+ recall: fields["RECALL"]
327
+ ? fields["RECALL"].split(",").map((keyword) => keyword.trim().toLowerCase()).filter(Boolean)
328
+ : [],
329
+ file: fields["FILE"],
330
+ priority: parseInt(fields["PRIORITY"] ?? "50", 10),
331
+ always: /^(1|true|yes)$/i.test(fields["ALWAYS"] ?? "false"),
332
+ })
333
+ }
334
+
335
+ return entries
336
+ }
337
+
338
+ function parseDomainIndex(content: string): DomainEntry[] {
339
+ const entries: DomainEntry[] = []
340
+ const sections = content.split(/^## /m).slice(1)
341
+
342
+ for (const section of sections) {
343
+ const lines = section.split("\\n")
344
+ const domain = lines[0].trim()
345
+ const keywordsLine = lines.find((line) => line.startsWith("Keywords:"))
346
+ const filesStart = lines.findIndex((line) => line.startsWith("Files:"))
347
+ if (!keywordsLine || filesStart === -1) continue
348
+
349
+ const keywords = keywordsLine
350
+ .replace("Keywords:", "")
351
+ .split(",")
352
+ .map((keyword) => keyword.trim().toLowerCase())
353
+ .filter(Boolean)
354
+
355
+ const files: Array<{ path: string; description: string }> = []
356
+ for (let index = filesStart + 1; index < lines.length; index += 1) {
357
+ const fileMatch = /^\\s*-\\s+([^—]+)(?:—\\s+(.*))?$/.exec(lines[index])
358
+ if (!fileMatch) break
359
+ files.push({ path: fileMatch[1].trim(), description: fileMatch[2]?.trim() ?? "" })
360
+ }
361
+
362
+ entries.push({ domain, keywords, files })
363
+ }
364
+
365
+ return entries
366
+ }
367
+
368
+ function stripCodeBlocks(text: string): string {
369
+ let stripped = text.replace(/\\\`\\\`\\\`[\\s\\S]*?\\\`\\\`\\\`/g, "")
370
+ stripped = stripped.replace(/\\\`[^\\\`\\n]+\\\`/g, "")
371
+ return stripped
372
+ }
373
+
374
+ function extractKeywords(text: string): Set<string> {
375
+ const words = new Set<string>()
376
+ for (const word of text.split(/[^a-zA-Z0-9_-]+/).filter((candidate) => candidate.length >= 3)) {
377
+ words.add(word.toLowerCase())
378
+ }
379
+ return words
380
+ }
381
+
382
+ function extractPathKeywords(filePath: string): Set<string> {
383
+ const parts = filePath.replace(/\\\\/g, "/").split("/")
384
+ const words = new Set<string>()
385
+ for (const part of parts) {
386
+ for (const word of part.split(/[^a-zA-Z0-9_-]+/).filter((candidate) => candidate.length >= 3)) {
387
+ words.add(word.toLowerCase())
388
+ }
389
+ }
390
+ return words
391
+ }
392
+
393
+ function escapeRegex(value: string): string {
394
+ return value.replace(/[.*+?^$()|[\\]{}]/g, "\\\\$&")
395
+ }
396
+
397
+ function matchKeyword(keyword: string, textWords: Set<string>, rawText: string): boolean {
398
+ if (textWords.has(keyword)) return true
399
+ const pattern = new RegExp("\\\\b" + escapeRegex(keyword) + "\\\\b", "i")
400
+ return pattern.test(rawText)
401
+ }
402
+
403
+ const MAX_CONTEXT_BYTES = 8000
404
+
405
+ class ContextCollector {
406
+ private collected = new Map<string, CollectedEntry>()
407
+ private totalBytes = 0
408
+
409
+ has(key: string): boolean {
410
+ return this.collected.has(key)
411
+ }
412
+
413
+ add(key: string, content: string, priority: number, type: "principle" | "domain", label: string): boolean {
414
+ if (this.collected.has(key)) return false
415
+ const size = Buffer.byteLength(content, "utf-8")
416
+ if (this.totalBytes + size > MAX_CONTEXT_BYTES) return false
417
+
418
+ this.collected.set(key, { content, priority, type, label })
419
+ this.totalBytes += size
420
+ return true
421
+ }
422
+
423
+ getNewEntries(keys: string[]): CollectedEntry[] {
424
+ return keys
425
+ .filter((key) => this.collected.has(key))
426
+ .map((key) => this.collected.get(key)!)
427
+ .sort((left, right) => left.priority - right.priority)
428
+ }
429
+
430
+ getAll(): CollectedEntry[] {
431
+ return Array.from(this.collected.values()).sort((left, right) => left.priority - right.priority)
432
+ }
433
+
434
+ formatForOutput(entries: CollectedEntry[]): string {
435
+ return entries
436
+ .map((entry) => \`[carl-inject] \${entry.type === "principle" ? "Principle" : "Domain"} (\${entry.label}):\\n\${entry.content}\`)
437
+ .join("\\n\\n---\\n\\n")
438
+ }
439
+ }
440
+
441
+ function loadRuntimeMetadata(directory: string, sessionID: string): RuntimeTaskMetadata | null {
442
+ const activeTargets = loadActivePlanTargets(directory)
443
+ const candidateProjectRoots = new Set<string>()
444
+ for (const target of activeTargets) {
445
+ if (target?.targetRepoRoot) candidateProjectRoots.add(target.targetRepoRoot)
446
+ }
447
+
448
+ if (candidateProjectRoots.size === 0) {
449
+ const fallback = resolveProjectPaths(directory, loadActivePlanTarget(directory) ?? {})
450
+ if (fallback?.projectRoot) candidateProjectRoots.add(fallback.projectRoot)
451
+ }
452
+
453
+ for (const projectRoot of candidateProjectRoots) {
454
+ const projectPaths = resolveProjectPaths(directory, { targetRepoRoot: projectRoot })
455
+ const specsDir = projectPaths?.specsRoot
456
+ if (!specsDir || !existsSync(specsDir)) continue
457
+
458
+ const featureDirs = readDirectoryNames(specsDir)
459
+ for (const featureSlug of featureDirs) {
460
+ const runtimePath = path.join(specsDir, featureSlug, "state", "sessions", \`\${sessionID}-runtime.json\`)
461
+ if (!existsSync(runtimePath)) continue
462
+ try {
463
+ return JSON.parse(readFileSync(runtimePath, "utf-8")) as RuntimeTaskMetadata
464
+ } catch {
465
+ return null
466
+ }
467
+ }
468
+ }
469
+
470
+ return null
471
+ }
472
+
473
+ function readDirectoryNames(target: string): string[] {
474
+ try {
475
+ return readdirSync(target, { withFileTypes: true })
476
+ .filter((entry) => entry.isDirectory())
477
+ .map((entry) => entry.name)
478
+ } catch {
479
+ return []
480
+ }
481
+ }
482
+
483
+ function resolvePlanPath(directory: string, runtime: RuntimeTaskMetadata | null): string | null {
484
+ const runtimePlanPath = runtime?.planPath?.trim()
485
+ if (runtimePlanPath) {
486
+ const projectPaths = resolveProjectPaths(directory, {
487
+ targetRepoRoot: runtime?.targetRepoRoot,
488
+ planPath: runtimePlanPath,
489
+ })
490
+ return path.isAbsolute(runtimePlanPath)
491
+ ? runtimePlanPath
492
+ : projectPaths
493
+ ? resolvePathFromProjectRoot(projectPaths.projectRoot, runtimePlanPath)
494
+ : path.join(directory, runtimePlanPath)
495
+ }
496
+
497
+ const activePlan = loadActivePlanTarget(directory)
498
+ if (!activePlan) return null
499
+ try {
500
+ const relativePath = activePlan.planPath?.trim()
501
+ if (!relativePath) return null
502
+ const projectPaths = resolveProjectPaths(directory, {
503
+ targetRepoRoot: activePlan.targetRepoRoot,
504
+ planPath: relativePath,
505
+ specPath: activePlan.specPath,
506
+ contextPath: activePlan.contextPath,
507
+ })
508
+ return path.isAbsolute(relativePath)
509
+ ? relativePath
510
+ : projectPaths
511
+ ? resolvePathFromProjectRoot(projectPaths.projectRoot, relativePath)
512
+ : path.join(directory, relativePath)
513
+ } catch {
514
+ return null
515
+ }
516
+ }
517
+
518
+ function extractFeatureSlugFromPath(filePath: string): string | null {
519
+ return filePath.match(/docs\\/specs\\/([^/]+)\\//)?.[1] ?? null
520
+ }
521
+
522
+ function extractFeatureSlugFromPrompt(prompt: string): string | null {
523
+ return prompt.match(/docs\\/specs\\/([^/]+)\\//)?.[1] ?? null
524
+ }
525
+
526
+ function extractPlanPathFromPrompt(prompt: string): string | null {
527
+ return prompt.match(/docs\\/specs\\/[^\\s]+\\/plan\\.md/)?.[0] ?? null
528
+ }
529
+
530
+ function readIfExists(filePath: string): string {
531
+ return existsSync(filePath) ? readFileSync(filePath, "utf-8") : ""
532
+ }
533
+
534
+ function loadTaskContractSeed(directory: string, args: Record<string, unknown>): TaskContractSeed | null {
535
+ const contractArg = typeof args.contract === "object" && args.contract
536
+ ? (args.contract as Record<string, unknown>)
537
+ : null
538
+ if (contractArg) {
539
+ return {
540
+ featureSlug: typeof contractArg.featureSlug === "string" ? contractArg.featureSlug : undefined,
541
+ taskID: typeof contractArg.taskID === "string" ? contractArg.taskID : typeof contractArg.taskID === "number" ? String(contractArg.taskID) : undefined,
542
+ planPath: typeof contractArg.planPath === "string" ? contractArg.planPath : undefined,
543
+ specPath: typeof contractArg.specPath === "string" ? contractArg.specPath : undefined,
544
+ contextPath: typeof contractArg.contextPath === "string" ? contractArg.contextPath : undefined,
545
+ taskContractPath: typeof contractArg.taskContractPath === "string" ? contractArg.taskContractPath : undefined,
546
+ targetRepoRoot: typeof contractArg.targetRepoRoot === "string" ? contractArg.targetRepoRoot : undefined,
547
+ }
548
+ }
549
+
550
+ const contractPathArg = typeof args.task_contract_path === "string"
551
+ ? args.task_contract_path
552
+ : typeof args.taskContractPath === "string"
553
+ ? args.taskContractPath
554
+ : undefined
555
+ if (!contractPathArg) return null
556
+
557
+ const absolutePath = path.isAbsolute(contractPathArg) ? contractPathArg : path.join(directory, contractPathArg)
558
+ if (!existsSync(absolutePath)) return null
559
+
560
+ try {
561
+ const contract = JSON.parse(readFileSync(absolutePath, "utf-8")) as TaskContractSeed
562
+ return {
563
+ ...contract,
564
+ taskContractPath: contractPathArg,
565
+ }
566
+ } catch {
567
+ return null
568
+ }
569
+ }
570
+
571
+ function loadTaskPlanContext(planPath: string, taskID: string | undefined): TaskPlanContext | null {
572
+ if (!taskID || !existsSync(planPath)) return null
573
+ const content = readFileSync(planPath, "utf-8")
574
+ const match = Array.from(content.matchAll(/<task id="([^"]+)" wave="[^"]+" agent="[^"]+" depends="[^"]*">[\\s\\S]*?<\\/task>/g))
575
+ .find((candidate) => candidate[1] === taskID)
576
+ if (!match) return null
577
+
578
+ const body = match[2]
579
+ const files = (body.match(/<files>[\\s\\S]*?<\\/files>/)?.[1] ?? "")
580
+ .replace(/\\r/g, "")
581
+ .split(/,|\\n/)
582
+ .map((file) => file.trim())
583
+ .filter(Boolean)
584
+
585
+ return {
586
+ taskID,
587
+ files,
588
+ action: (body.match(/<action>[\\s\\S]*?<\\/action>/)?.[1] ?? "").trim(),
589
+ verify: (body.match(/<verify>[\\s\\S]*?<\\/verify>/)?.[1] ?? "").trim(),
590
+ done: (body.match(/<done>[\\s\\S]*?<\\/done>/)?.[1] ?? "").trim(),
591
+ }
592
+ }
593
+
594
+ function taskSignals(runtime: RuntimeTaskMetadata | null, taskContext: TaskPlanContext | null): { keywords: Set<string>; rawText: string } {
595
+ const texts = [
596
+ runtime?.originalPrompt ?? "",
597
+ taskContext?.action ?? "",
598
+ taskContext?.done ?? "",
599
+ ...(taskContext?.files ?? []),
600
+ ]
601
+ const rawText = texts.join(" ").toLowerCase()
602
+ return { keywords: extractKeywords(stripCodeBlocks(rawText)), rawText }
603
+ }
604
+
605
+ function startupSeedSignals(directory: string, seed: PendingStartupSeed): { keywords: Set<string>; rawText: string } {
606
+ const projectPaths = resolveProjectPaths(directory, {
607
+ prompt: seed.prompt,
608
+ targetRepoRoot: seed.targetRepoRoot,
609
+ planPath: seed.planPath,
610
+ specPath: seed.specPath,
611
+ contextPath: seed.contextPath,
612
+ taskContractPath: seed.taskContractPath,
613
+ })
614
+ const projectRoot = projectPaths?.projectRoot ?? directory
615
+ const specsRoot = projectPaths?.specsRoot ?? path.join(directory, "docs", "specs")
616
+ const resolvedPlanPath = seed.planPath
617
+ ? path.isAbsolute(seed.planPath)
618
+ ? seed.planPath
619
+ : resolvePathFromProjectRoot(projectRoot, seed.planPath)
620
+ : resolvePlanPath(directory, null)
621
+ const featureSlug = seed.featureSlug ?? (resolvedPlanPath ? extractFeatureSlugFromPath(resolvedPlanPath) : null)
622
+
623
+ const texts = [seed.prompt]
624
+ if (seed.taskContractPath) {
625
+ const absoluteTaskContract = path.isAbsolute(seed.taskContractPath)
626
+ ? seed.taskContractPath
627
+ : resolvePathFromProjectRoot(projectRoot, seed.taskContractPath)
628
+ texts.push(readIfExists(absoluteTaskContract))
629
+ }
630
+ if (resolvedPlanPath) texts.push(readIfExists(resolvedPlanPath))
631
+ if (featureSlug) {
632
+ texts.push(readIfExists(seed.specPath ? resolvePathFromProjectRoot(projectRoot, seed.specPath) : path.join(specsRoot, featureSlug, "spec.md")))
633
+ texts.push(readIfExists(seed.contextPath ? resolvePathFromProjectRoot(projectRoot, seed.contextPath) : path.join(specsRoot, featureSlug, "CONTEXT.md")))
634
+ texts.push(readIfExists(path.join(specsRoot, featureSlug, "state", "functional-validation-plan.md")))
635
+ }
636
+
637
+ const rawText = texts.filter(Boolean).join(" ").toLowerCase()
638
+ return { keywords: extractKeywords(stripCodeBlocks(rawText)), rawText }
639
+ }
640
+
641
+ function isTestFocusedTask(taskContext: TaskPlanContext | null, runtime: RuntimeTaskMetadata | null): boolean {
642
+ const fileHints = taskContext?.files ?? []
643
+ return fileHints.some((file) => /(^|\\/)src\\/test\\//.test(file) || /(Test|IT)\\.(kt|java)$/.test(file))
644
+ }
645
+
646
+ function isTestFocusedRead(filePath: string, rawText: string): boolean {
647
+ return /(^|\\/)src\\/test\\//.test(filePath) || /(Test|IT)\\.(kt|java)$/.test(filePath) || /@Test\\b/.test(rawText)
648
+ }
649
+
650
+ function isPromptTestFocused(rawText: string): boolean {
651
+ return /(^|\\s)(src\\/test\\/|test\\s+file|test\\s+suite|unit\\s+test|integration\\s+test|write\\s+tests?|add\\s+tests?|implement\\s+tests?)/.test(rawText)
652
+ }
653
+
654
+ function effectiveRecallKeywords(entry: PrincipleEntry | DomainEntry, options?: { mode?: "startup" | "read"; testFocused?: boolean }): string[] {
655
+ const recall = "recall" in entry ? entry.recall : entry.keywords
656
+ const mode = options?.mode ?? "read"
657
+ if (mode === "startup") {
658
+ if ("always" in entry && entry.always) return recall
659
+ if ("key" in entry && entry.key === "TEST") return options?.testFocused ? recall : []
660
+ } else if ("key" in entry && entry.key === "TEST" && options?.testFocused) {
661
+ return recall
662
+ }
663
+
664
+ return recall.filter((keyword) => !GENERIC_CARL_KEYWORDS.has(keyword))
665
+ }
666
+
667
+ function addPrinciples(
668
+ directory: string,
669
+ collector: ContextCollector,
670
+ keywords: Set<string>,
671
+ rawText: string,
672
+ options?: { includeAlways?: boolean; mode?: "startup" | "read"; testFocused?: boolean }
673
+ ): string[] {
674
+ const targets = loadActivePlanTargets(directory)
675
+ const projectPathsList = targets.length > 0
676
+ ? targets.map((t) => resolveProjectPaths(directory, t)).filter((p): p is NonNullable<typeof p> => Boolean(p))
677
+ : [resolveProjectPaths(directory, {})].filter((p): p is NonNullable<typeof p> => Boolean(p))
678
+
679
+ const addedKeys: string[] = []
680
+ const seenManifests = new Set<string>()
681
+
682
+ for (const projectPaths of projectPathsList) {
683
+ const manifestRoot = projectPaths.principlesRoot
684
+ const manifestPathResolved = path.join(manifestRoot, "manifest")
685
+ if (!existsSync(manifestPathResolved)) continue
686
+ if (seenManifests.has(manifestPathResolved)) continue
687
+ seenManifests.add(manifestPathResolved)
688
+
689
+ const manifest = readFileSync(manifestPathResolved, "utf-8")
690
+ const entries = parsePrinciplesManifest(manifest)
691
+
692
+ for (const entry of entries) {
693
+ const dedupKey = \`principle:\${entry.key}\`
694
+ if (collector.has(dedupKey)) continue
695
+
696
+ const recallKeywords = effectiveRecallKeywords(entry, { mode: options?.mode, testFocused: options?.testFocused })
697
+ const matchedRecall = recallKeywords.some((keyword) => matchKeyword(keyword, keywords, rawText))
698
+ if (!matchedRecall && !(options?.includeAlways && entry.always)) continue
699
+
700
+ const filePath = path.isAbsolute(entry.file)
701
+ ? entry.file
702
+ : resolvePathFromProjectRoot(projectPaths.projectRoot, entry.file)
703
+ if (!existsSync(filePath)) continue
704
+
705
+ const content = readFileSync(filePath, "utf-8")
706
+ if (collector.add(dedupKey, content, entry.priority, "principle", entry.key)) addedKeys.push(dedupKey)
707
+ }
708
+ }
709
+
710
+ return addedKeys
711
+ }
712
+
713
+ function addDomains(
714
+ directory: string,
715
+ collector: ContextCollector,
716
+ keywords: Set<string>,
717
+ rawText: string,
718
+ options?: { mode?: "startup" | "read"; testFocused?: boolean }
719
+ ): string[] {
720
+ const targets = loadActivePlanTargets(directory)
721
+ const projectPathsList = targets.length > 0
722
+ ? targets.map((t) => resolveProjectPaths(directory, t)).filter((p): p is NonNullable<typeof p> => Boolean(p))
723
+ : [resolveProjectPaths(directory, {})].filter((p): p is NonNullable<typeof p> => Boolean(p))
724
+
725
+ const addedKeys: string[] = []
726
+ const seenIndexes = new Set<string>()
727
+
728
+ for (const projectPaths of projectPathsList) {
729
+ const domainRoot = projectPaths.domainRoot
730
+ const indexPath = path.join(domainRoot, "INDEX.md")
731
+ if (!existsSync(indexPath)) continue
732
+ if (seenIndexes.has(indexPath)) continue
733
+ seenIndexes.add(indexPath)
734
+
735
+ const index = readFileSync(indexPath, "utf-8")
736
+ const domains = parseDomainIndex(index)
737
+ const scoredMatches = domains
738
+ .map((entry) => {
739
+ const recallKeywords = effectiveRecallKeywords(entry, { mode: options?.mode, testFocused: options?.testFocused })
740
+ const matchedKeywords = recallKeywords.filter((keyword) => matchKeyword(keyword, keywords, rawText))
741
+ return {
742
+ entry,
743
+ matchedKeywords,
744
+ score: matchedKeywords.reduce((sum, keyword) => sum + Math.max(keyword.length, 1), 0),
745
+ }
746
+ })
747
+ .filter((candidate) => candidate.matchedKeywords.length > 0)
748
+
749
+ let allowedDomains: Set<string> | null = null
750
+ if ((options?.mode ?? "read") === "startup" && scoredMatches.length > 0) {
751
+ const bestScore = Math.max(...scoredMatches.map((candidate) => candidate.score))
752
+ allowedDomains = new Set(
753
+ scoredMatches
754
+ .filter((candidate) => candidate.score >= bestScore * STARTUP_DOMAIN_SCORE_FLOOR_RATIO)
755
+ .map((candidate) => candidate.entry.domain)
756
+ )
757
+
758
+ const bestDomains = scoredMatches.filter((candidate) => candidate.score === bestScore).map((candidate) => candidate.entry.domain)
759
+ const hasFlowWinner = bestDomains.some((domain) => FLOW_DOMAINS_WITH_BALANCE_COMPANION.has(domain))
760
+ const balanceCandidate = scoredMatches.find((candidate) => candidate.entry.domain.toLowerCase() === "balance")
761
+ const hasBalanceSignals = BALANCE_COMPANION_SIGNALS.some((signal) => rawText.includes(signal))
762
+ if (hasFlowWinner && balanceCandidate && hasBalanceSignals) {
763
+ allowedDomains.add(balanceCandidate.entry.domain)
764
+ }
765
+ }
766
+
767
+ for (const entry of domains) {
768
+ if (allowedDomains && !allowedDomains.has(entry.domain)) continue
769
+ const recallKeywords = effectiveRecallKeywords(entry, { mode: options?.mode, testFocused: options?.testFocused })
770
+ const matched = recallKeywords.some((keyword) => matchKeyword(keyword, keywords, rawText))
771
+ if (!matched) continue
772
+
773
+ for (const file of entry.files.slice(0, 3)) {
774
+ const dedupKey = \`domain:\${entry.domain}:\${file.path}\`
775
+ if (collector.has(dedupKey)) continue
776
+
777
+ const domainPath = path.join(domainRoot, file.path)
778
+ if (!existsSync(domainPath)) continue
779
+
780
+ const content = readFileSync(domainPath, "utf-8")
781
+ if (collector.add(dedupKey, content, 10, "domain", \`\${entry.domain} / \${file.path}\`)) addedKeys.push(dedupKey)
782
+ }
783
+ }
784
+ }
785
+
786
+ return addedKeys
787
+ }
788
+
789
+ export default (async ({ directory }: { directory: string }) => {
790
+ const collectorsBySession = new Map<string, ContextCollector>()
791
+ const taskKeywordsLoaded = new Set<string>()
792
+ const preloadedSessions = new Set<string>()
793
+ const pendingStartupSeedsByParent = new Map<string, PendingStartupSeed[]>()
794
+ const startupSeedBySession = new Map<string, PendingStartupSeed>()
795
+
796
+ function collectorForSession(sessionID: string): ContextCollector {
797
+ let collector = collectorsBySession.get(sessionID)
798
+ if (!collector) {
799
+ collector = new ContextCollector()
800
+ collectorsBySession.set(sessionID, collector)
801
+ }
802
+ return collector
803
+ }
804
+
805
+ function loadTaskKeywords(sessionID: string): Set<string> {
806
+ if (taskKeywordsLoaded.has(sessionID)) return new Set()
807
+ taskKeywordsLoaded.add(sessionID)
808
+
809
+ // Try task-scoped execution state first (feature/task-local)
810
+ const runtime = loadRuntimeMetadata(directory, sessionID)
811
+ if (runtime?.featureSlug && runtime?.taskID) {
812
+ const taskPaths = featureStateTaskPaths(directory, runtime.featureSlug, runtime.taskID, {
813
+ targetRepoRoot: runtime.targetRepoRoot,
814
+ })
815
+ if (existsSync(taskPaths.statePath)) {
816
+ const state = readFileSync(taskPaths.statePath, "utf-8")
817
+ const goalMatch = /\\*\\*Goal\\*\\*:\\s*(.+)/i.exec(state)
818
+ const taskLines = state.split("\\n").filter((line) => /^\\s*-\\s*\\[/.test(line))
819
+ const taskText = [goalMatch?.[1] ?? "", ...taskLines].join(" ")
820
+ return extractKeywords(stripCodeBlocks(taskText))
821
+ }
822
+ }
823
+
824
+ // Fallback to workspace-global state for backward compatibility
825
+ const statePath = path.join(directory, ".opencode", "state", "execution-state.md")
826
+ if (!existsSync(statePath)) return new Set()
827
+
828
+ const state = readFileSync(statePath, "utf-8")
829
+ const goalMatch = /\\*\\*Goal\\*\\*:\\s*(.+)/i.exec(state)
830
+ const taskLines = state.split("\\n").filter((line) => /^\\s*-\\s*\\[/.test(line))
831
+ const taskText = [goalMatch?.[1] ?? "", ...taskLines].join(" ")
832
+ return extractKeywords(stripCodeBlocks(taskText))
833
+ }
834
+
835
+ function injectTaskScopedContext(sessionID: string): CollectedEntry[] {
836
+ const collector = collectorForSession(sessionID)
837
+ const runtime = loadRuntimeMetadata(directory, sessionID)
838
+ const planPath = resolvePlanPath(directory, runtime)
839
+ const taskContext = planPath ? loadTaskPlanContext(planPath, runtime?.taskID) : null
840
+ const signals = taskSignals(runtime, taskContext)
841
+ const testFocused = isTestFocusedTask(taskContext, runtime)
842
+ const addedKeys = [
843
+ ...addPrinciples(directory, collector, signals.keywords, signals.rawText, { includeAlways: true, mode: "startup", testFocused }),
844
+ ...addDomains(directory, collector, signals.keywords, signals.rawText, { mode: "startup", testFocused }),
845
+ ]
846
+ return collector.getNewEntries(addedKeys)
847
+ }
848
+
849
+ function hasTaskScopedRuntime(sessionID: string): boolean {
850
+ return Boolean(loadRuntimeMetadata(directory, sessionID)?.taskID)
851
+ }
852
+
853
+ function injectMainAgentStartupContext(sessionID: string): CollectedEntry[] {
854
+ const seed = startupSeedBySession.get(sessionID)
855
+ if (!seed) return []
856
+
857
+ const collector = collectorForSession(sessionID)
858
+ const signals = startupSeedSignals(directory, seed)
859
+ const testFocused = isPromptTestFocused(signals.rawText)
860
+ const addedKeys = [
861
+ ...addPrinciples(directory, collector, signals.keywords, signals.rawText, { includeAlways: true, mode: "startup", testFocused }),
862
+ ...addDomains(directory, collector, signals.keywords, signals.rawText, { mode: "startup", testFocused }),
863
+ ]
864
+ return collector.getNewEntries(addedKeys)
865
+ }
866
+
867
+ function renderStartupContext(entries: CollectedEntry[], collector: ContextCollector, scope: "task" | "session"): string | null {
868
+ const injected = entries.length > 0 ? entries : collector.getAll()
869
+ if (injected.length === 0) return null
870
+
871
+ return (
872
+ \`[carl-inject] \${scope === "task" ? "Task-scoped" : "Delegated session"} startup context. Use this before searching the repo or opening README/principles/domain docs:\\n\\n\` +
873
+ collector.formatForOutput(injected)
874
+ )
875
+ }
876
+
877
+ return {
878
+ event: async ({ event }: { event: { type: string; properties?: Record<string, unknown> } }) => {
879
+ if (event.type !== "session.created") return
880
+ const sessionID = typeof event.properties?.sessionID === "string" ? event.properties.sessionID : undefined
881
+ if (!sessionID) return
882
+ const info = typeof event.properties?.info === "object" && event.properties.info
883
+ ? (event.properties.info as Record<string, unknown>)
884
+ : undefined
885
+ const parentID = typeof info?.parentID === "string" ? info.parentID : undefined
886
+ if (parentID) {
887
+ const queue = pendingStartupSeedsByParent.get(parentID)
888
+ const seed = queue?.shift()
889
+ if (seed) {
890
+ startupSeedBySession.set(sessionID, seed)
891
+ if (queue && queue.length > 0) pendingStartupSeedsByParent.set(parentID, queue)
892
+ else pendingStartupSeedsByParent.delete(parentID)
893
+ }
894
+ }
895
+ injectTaskScopedContext(sessionID)
896
+ injectMainAgentStartupContext(sessionID)
897
+ },
898
+
899
+ "tool.execute.before": async (
900
+ input: { tool: string; sessionID: string },
901
+ output: { args: Record<string, unknown> }
902
+ ) => {
903
+ if (input.tool !== "Task" && input.tool !== "task") return
904
+
905
+ const subagentType = typeof output.args?.subagent_type === "string"
906
+ ? output.args.subagent_type
907
+ : typeof output.args?.subagentType === "string"
908
+ ? output.args.subagentType
909
+ : undefined
910
+ const prompt = typeof output.args?.prompt === "string" ? output.args.prompt.trim() : ""
911
+ if (!prompt) return
912
+ if (!shouldSeedStartupPrompt(prompt, subagentType)) return
913
+ const taskContract = loadTaskContractSeed(directory, output.args)
914
+
915
+ const queue = pendingStartupSeedsByParent.get(input.sessionID) ?? []
916
+ queue.push({
917
+ prompt,
918
+ subagentType,
919
+ featureSlug: taskContract?.featureSlug ?? extractFeatureSlugFromPrompt(prompt) ?? undefined,
920
+ taskID: taskContract?.taskID,
921
+ targetRepoRoot: taskContract?.targetRepoRoot,
922
+ planPath: taskContract?.planPath ?? extractPlanPathFromPrompt(prompt) ?? undefined,
923
+ specPath: taskContract?.specPath,
924
+ contextPath: taskContract?.contextPath,
925
+ taskContractPath: taskContract?.taskContractPath,
926
+ })
927
+ pendingStartupSeedsByParent.set(input.sessionID, queue)
928
+ },
929
+
930
+ "chat.message": async (
931
+ input: { sessionID: string },
932
+ output: { message: { system?: string }; parts: unknown[] }
933
+ ) => {
934
+ if (preloadedSessions.has(input.sessionID)) return
935
+
936
+ const collector = collectorForSession(input.sessionID)
937
+ const taskScoped = hasTaskScopedRuntime(input.sessionID)
938
+ const taskEntries = taskScoped ? injectTaskScopedContext(input.sessionID) : []
939
+ const scope = taskScoped ? "task" : "session"
940
+ const newEntries = taskScoped ? taskEntries : injectMainAgentStartupContext(input.sessionID)
941
+ const rendered = renderStartupContext(newEntries, collector, scope)
942
+ if (!rendered) return
943
+
944
+ output.message.system = output.message.system ? \`\${output.message.system}\\n\\n\${rendered}\` : rendered
945
+ preloadedSessions.add(input.sessionID)
946
+ },
947
+
948
+ "tool.execute.after": async (
949
+ input: { tool: string; sessionID: string; callID: string; args: any },
950
+ output: { title: string; output: string; metadata: any }
951
+ ) => {
952
+ if (input.tool !== "Read") return
953
+
954
+ const filePath: string = input.args?.path ?? input.args?.file_path ?? ""
955
+ if (!filePath) return
956
+
957
+ const allKeywords = new Set<string>()
958
+ const taskKeywords = loadTaskKeywords(input.sessionID)
959
+ for (const keyword of taskKeywords) allKeywords.add(keyword)
960
+
961
+ const fileContent = output.output ?? ""
962
+ const strippedContent = stripCodeBlocks(fileContent)
963
+ const contentKeywords = extractKeywords(strippedContent)
964
+ for (const keyword of contentKeywords) allKeywords.add(keyword)
965
+
966
+ const pathKeywords = extractPathKeywords(filePath)
967
+ for (const keyword of pathKeywords) allKeywords.add(keyword)
968
+
969
+ if (allKeywords.size === 0) return
970
+
971
+ const rawSignal = [
972
+ strippedContent,
973
+ filePath,
974
+ ...Array.from(taskKeywords),
975
+ ...Array.from(pathKeywords),
976
+ ].join(" ").toLowerCase()
977
+ const testFocused = isTestFocusedRead(filePath, rawSignal)
978
+
979
+ const collector = collectorForSession(input.sessionID)
980
+ const addedKeys = [
981
+ ...addPrinciples(directory, collector, allKeywords, rawSignal, { includeAlways: true, mode: "read", testFocused }),
982
+ ...addDomains(directory, collector, allKeywords, rawSignal, { mode: "read", testFocused }),
983
+ ]
984
+ if (addedKeys.length === 0) return
985
+
986
+ const newEntries = collector.getNewEntries(addedKeys)
987
+ if (newEntries.length > 0) output.output += "\\n\\n" + collector.formatForOutput(newEntries)
988
+ },
989
+
990
+ "experimental.session.compacting": async (
991
+ input: { sessionID?: string },
992
+ output: { context: string[]; prompt?: string }
993
+ ) => {
994
+ if (!input.sessionID) return
995
+
996
+ const collector = collectorsBySession.get(input.sessionID)
997
+ if (!collector) return
998
+
999
+ const all = collector.getAll()
1000
+ if (all.length === 0) return
1001
+
1002
+ output.context.push(
1003
+ "[carl-inject] Previously injected context (principles + domain docs):\\n\\n" +
1004
+ collector.formatForOutput(all)
1005
+ )
1006
+ },
1007
+ }
1008
+ }) satisfies Plugin
485
1009
  `;
486
1010
  // ─── Skill Inject (reads from skill-map.json for dynamic extension) ─────────
487
1011
  function getBaseSkillMap(projectType, isKotlin) {
488
1012
  // Universal patterns (all types)
489
1013
  const universal = [
490
1014
  { pattern: "(^|\\/)AGENTS\\.md$", skill: "j.agents-md-writing" },
1015
+ { pattern: "(^|\\/)\\.opencode\\/skills\\/[^/]+\\/SKILL\\.md$|(^|\\/)\\.opencode\\/skill-map\\.json$|(^|\\/)\\.opencode\\/evals\\/.*(skill|behavioral).*(\\.xml|\\.json|\\.md|\\.ts)$", skill: "skill-creator" },
491
1016
  { pattern: "docs\\/domain\\/.*\\.md$", skill: "j.domain-doc-writing" },
492
1017
  { pattern: "docs\\/principles\\/.*(?:\\.md|manifest)$", skill: "j.principle-doc-writing" },
493
1018
  { pattern: "(^|\\/)(\\.opencode\\/scripts|scripts)\\/.*\\.sh$", skill: "j.shell-script-writing" },
@@ -549,15 +1074,16 @@ function skillInject(projectType, isKotlin) {
549
1074
  'import type { Plugin } from "@opencode-ai/plugin"',
550
1075
  'import { existsSync, readFileSync } from "fs"',
551
1076
  'import path from "path"',
1077
+ 'import { findContainingProjectRoot } from "../lib/j.workspace-paths"',
552
1078
  '',
553
1079
  '// Injects skill instructions via tool.execute.after on Read + Write.',
554
- '// SKILL_MAP is loaded from .opencode/state/skill-map.json for dynamic',
1080
+ '// SKILL_MAP is loaded from .opencode/skill-map.json for dynamic',
555
1081
  '// extension by /j.finish-setup. Falls back to hardcoded base patterns.',
556
1082
  '',
557
1083
  'interface SkillMapEntry { pattern: string; skill: string }',
558
1084
  '',
559
1085
  'function loadSkillMap(directory: string): Array<{ pattern: RegExp; skill: string }> {',
560
- ' const mapPath = path.join(directory, ".opencode", "state", "skill-map.json")',
1086
+ ' const mapPath = path.join(directory, ".opencode", "skill-map.json")',
561
1087
  ' let entries: SkillMapEntry[] = []',
562
1088
  '',
563
1089
  ' if (existsSync(mapPath)) {',
@@ -569,6 +1095,23 @@ function skillInject(projectType, isKotlin) {
569
1095
  ' return entries.map((e) => ({ pattern: new RegExp(e.pattern), skill: e.skill }))',
570
1096
  '}',
571
1097
  '',
1098
+ 'function resolveSkillPath(directory: string, skillName: string, filePath?: string): string | null {',
1099
+ ' // Check workspace-root skills first',
1100
+ ' const workspacePath = path.join(directory, ".opencode", "skills", skillName, "SKILL.md")',
1101
+ ' if (existsSync(workspacePath)) return workspacePath',
1102
+ '',
1103
+ ' // Check target project root skills as fallback',
1104
+ ' if (filePath) {',
1105
+ ' const projectRoot = findContainingProjectRoot(directory, filePath)',
1106
+ ' if (projectRoot && projectRoot !== directory) {',
1107
+ ' const projectPath = path.join(projectRoot, ".opencode", "skills", skillName, "SKILL.md")',
1108
+ ' if (existsSync(projectPath)) return projectPath',
1109
+ ' }',
1110
+ ' }',
1111
+ '',
1112
+ ' return null',
1113
+ '}',
1114
+ '',
572
1115
  'export default (async ({ directory }: { directory: string }) => {',
573
1116
  ' const injectedSkills = new Set<string>()',
574
1117
  ' const skillMap = loadSkillMap(directory)',
@@ -590,8 +1133,8 @@ function skillInject(projectType, isKotlin) {
590
1133
  ' if (injectedSkills.has(key)) return',
591
1134
  ' injectedSkills.add(key)',
592
1135
  '',
593
- ' const skillPath = path.join(directory, ".opencode", "skills", match.skill, "SKILL.md")',
594
- ' if (!existsSync(skillPath)) return',
1136
+ ' const skillPath = resolveSkillPath(directory, match.skill, filePath)',
1137
+ ' if (!skillPath) return',
595
1138
  '',
596
1139
  ' const skillContent = readFileSync(skillPath, "utf-8")',
597
1140
  ' output.output +=',
@@ -599,8 +1142,8 @@ function skillInject(projectType, isKotlin) {
599
1142
  ' } else if (["Write", "Edit", "MultiEdit"].includes(input.tool)) {',
600
1143
  ' if (injectedSkills.has(key)) return',
601
1144
  '',
602
- ' const skillPath = path.join(directory, ".opencode", "skills", match.skill, "SKILL.md")',
603
- ' if (!existsSync(skillPath)) return',
1145
+ ' const skillPath = resolveSkillPath(directory, match.skill, filePath)',
1146
+ ' if (!skillPath) return',
604
1147
  '',
605
1148
  ' injectedSkills.add(key)',
606
1149
  ' output.output +=',
@@ -613,404 +1156,1535 @@ function skillInject(projectType, isKotlin) {
613
1156
  ];
614
1157
  return lines.join('\n') + '\n';
615
1158
  }
616
- // ─── Intent Gate ─────────────────────────────────────────────────────────────
617
- const INTENT_GATE = `import type { Plugin } from "@opencode-ai/plugin"
618
- import { existsSync, readFileSync } from "fs"
619
- import path from "path"
620
-
621
- // Scope-guard: after any Write/Edit, checks if the modified file is part of
622
- // the current plan. If it drifts outside the plan scope, appends a warning.
623
- // Uses tool.execute.after on Write/Edit agent sees the warning and can
624
- // course-correct before continuing.
625
-
626
- function extractPlanFiles(planContent: string): Set<string> {
627
- const files = new Set<string>()
628
- // Matches common plan file references: paths with extensions, bullet paths, etc.
629
- const pathPattern = /(?:^|\\s|\\/|\\|)[\\w\\-./]+\\.[a-z]{1,5}\\b/gi
630
- for (const match of planContent.matchAll(pathPattern)) {
631
- const cleaned = match[0].replace(/^[\\s/|]+/, "").trim()
632
- if (cleaned.endsWith(".") || cleaned.length < 4) continue
633
- files.add(cleaned)
634
- }
635
- return files
636
- }
637
-
638
- export default (async ({ directory }: { directory: string }) => {
639
- let planFiles: Set<string> | null = null
640
-
641
- return {
642
- "tool.execute.after": async (
643
- input: { tool: string; sessionID: string; callID: string; args: any },
644
- output: { title: string; output: string; metadata: any }
645
- ) => {
646
- if (!["Write", "Edit", "MultiEdit"].includes(input.tool)) return
647
-
648
- const filePath: string = input.args?.path ?? input.args?.file_path ?? ""
649
- if (!filePath) return
650
-
651
- // Lazy-load plan files on first Write/Edit
652
- if (planFiles === null) {
653
- planFiles = new Set<string>()
654
- const planDir = path.join(directory, ".opencode", "state")
655
- // Try multiple plan file names
656
- for (const name of ["plan.md", "plan-ready.md"]) {
657
- const planPath = path.join(planDir, name)
658
- if (existsSync(planPath)) {
659
- const content = readFileSync(planPath, "utf-8")
660
- for (const f of extractPlanFiles(content)) planFiles.add(f)
661
- }
662
- }
663
- }
664
-
665
- // No plan loaded — nothing to guard
666
- if (planFiles.size === 0) return
667
-
668
- const relPath = path.relative(directory, filePath).replace(/\\\\\\\\/g, "/")
669
-
670
- // Check if the modified file matches any plan reference
671
- const inScope = [...planFiles].some(
672
- (pf) => relPath.endsWith(pf) || relPath.includes(pf) || pf.includes(relPath)
673
- )
674
-
675
- if (!inScope) {
676
- output.output +=
677
- \`\\n\\n[intent-gate] ⚠ SCOPE WARNING: "\${relPath}" is not referenced in the current plan. \` +
678
- \`Verify this change is necessary for the current task before continuing.\`
679
- }
680
- },
681
- }
682
- }) satisfies Plugin
1159
+ // ─── Directory Agents Injector (evolved) ─────────────────────────────────────
1160
+ const DIR_AGENTS_INJECTOR = `import type { Plugin } from "@opencode-ai/plugin"
1161
+ import { existsSync, readFileSync } from "fs"
1162
+ import path from "path"
1163
+ import { findContainingProjectRoot } from "../lib/j.workspace-paths"
1164
+
1165
+ // Tier 1 context mechanism hierarchical AGENTS.md injection.
1166
+ // When an agent reads a file, walks the directory tree from the file's location
1167
+ // to the project root and appends every AGENTS.md found to the Read output.
1168
+ // Injects from root → most specific (additive, layered context).
1169
+ // Uses tool.execute.after on Read — appends to output.output.
1170
+
1171
+ function findAgentsMdFiles(filePath: string, projectRoot: string): string[] {
1172
+ const result: string[] = []
1173
+ let current = path.dirname(filePath)
1174
+
1175
+ // Walk up to project root (exclusive root AGENTS.md is auto-loaded by OpenCode)
1176
+ while (current !== projectRoot && current !== path.dirname(current)) {
1177
+ const agentsMd = path.join(current, "AGENTS.md")
1178
+ if (existsSync(agentsMd)) {
1179
+ result.unshift(agentsMd) // prepend for root → specific order
1180
+ }
1181
+ current = path.dirname(current)
1182
+ }
1183
+
1184
+ return result
1185
+ }
1186
+
1187
+ export default (async ({ directory }: { directory: string }) => {
1188
+ const injectedPathsBySession = new Map<string, Set<string>>()
1189
+
1190
+ return {
1191
+ "tool.execute.after": async (
1192
+ input: { tool: string; sessionID: string; callID: string; args: any },
1193
+ output: { title: string; output: string; metadata: any }
1194
+ ) => {
1195
+ if (input.tool !== "Read") return
1196
+
1197
+ const filePath: string = input.args?.path ?? input.args?.file_path ?? ""
1198
+ if (!filePath || !filePath.startsWith(directory)) return
1199
+
1200
+ // Resolve the actual project root containing this file, not the workspace root
1201
+ const projectRoot = findContainingProjectRoot(directory, filePath) ?? directory
1202
+
1203
+ const injectedPaths = injectedPathsBySession.get(input.sessionID) ?? new Set<string>()
1204
+ injectedPathsBySession.set(input.sessionID, injectedPaths)
1205
+
1206
+ const agentsMdFiles = findAgentsMdFiles(filePath, projectRoot)
1207
+ const toInject: string[] = []
1208
+
1209
+ for (const agentsPath of agentsMdFiles) {
1210
+ if (injectedPaths.has(agentsPath)) continue
1211
+ injectedPaths.add(agentsPath)
1212
+
1213
+ const content = readFileSync(agentsPath, "utf-8")
1214
+ const relPath = path.relative(projectRoot, agentsPath)
1215
+ toInject.push(\`[directory-agents-injector] Context from \${relPath}:\\n\\n\${content}\`)
1216
+ }
1217
+
1218
+ if (toInject.length > 0) {
1219
+ output.output += "\\n\\n" + toInject.join("\\n\\n---\\n\\n")
1220
+ }
1221
+ },
1222
+ }
1223
+ }) satisfies Plugin
683
1224
  `;
684
- // ─── Todo Enforcer ────────────────────────────────────────────────────────────
685
- const TODO_ENFORCER = `import type { Plugin } from "@opencode-ai/plugin"
686
- import { existsSync, readFileSync } from "fs"
687
- import path from "path"
688
-
689
- // Re-injects incomplete tasks to prevent the agent from forgetting pending work.
690
- // Two hooks:
691
- // experimental.session.compacting injects pending tasks into compaction
692
- // context so they survive context window resets.
693
- // tool.execute.after on Write/Edit — lean reminder of pending count after
694
- // file modifications, nudging the agent to continue.
695
-
696
- function getIncompleteTasks(directory: string): string[] {
697
- const statePath = path.join(directory, ".opencode", "state", "execution-state.md")
698
- if (!existsSync(statePath)) return []
699
-
700
- const state = readFileSync(statePath, "utf-8")
701
- return state
702
- .split("\\n")
703
- .filter((line) => /^\\s*-\\s*\\[\\s*\\]/.test(line))
704
- .map((line) => line.trim())
705
- }
706
-
707
- export default (async ({ directory }: { directory: string }) => ({
708
- "experimental.session.compacting": async (
709
- _input: Record<string, unknown>,
710
- output: { context: string[]; prompt?: string }
711
- ) => {
712
- const incomplete = getIncompleteTasks(directory)
713
- if (incomplete.length === 0) return
714
-
715
- output.context.push(
716
- \`[todo-enforcer] \${incomplete.length} incomplete task(s) remaining:\\n\\n\` +
717
- incomplete.join("\\n") +
718
- \`\\n\\nDo not stop until all tasks are complete. Continue working.\`
719
- )
720
- },
721
- "tool.execute.after": async (
722
- input: { tool: string; sessionID: string; callID: string; args: any },
723
- output: { title: string; output: string; metadata: any }
724
- ) => {
725
- if (!["Write", "Edit", "MultiEdit"].includes(input.tool)) return
726
-
727
- const incomplete = getIncompleteTasks(directory)
728
- if (incomplete.length === 0) return
729
-
730
- output.output +=
731
- \`\\n\\n[todo-enforcer] \${incomplete.length} task(s) still pending. Continue working.\`
732
- },
733
- })) satisfies Plugin
1225
+ // ─── Intent Gate (evolved) ───────────────────────────────────────────────────
1226
+ const INTENT_GATE = `import type { Plugin } from "@opencode-ai/plugin"
1227
+ import { existsSync, readFileSync, readdirSync } from "fs"
1228
+ import path from "path"
1229
+ import { resolveStateFile } from "../lib/j.state-paths"
1230
+ import { loadActivePlanTarget, loadActivePlanTargets, resolvePathFromProjectRoot, resolveProjectPaths } from "../lib/j.workspace-paths"
1231
+
1232
+ // Scope-guard: after any Write/Edit, checks if the modified file is part of
1233
+ // the current plan. If it drifts outside the plan scope, appends a warning.
1234
+ // Uses tool.execute.after on Write/Edit — agent sees the warning and can
1235
+ // course-correct before continuing.
1236
+
1237
+ function extractPlanFiles(planContent: string): Set<string> {
1238
+ const files = new Set<string>()
1239
+ // Matches common plan file references: paths with extensions, bullet paths, etc.
1240
+ const pathPattern = /(?:^|\\s|\\/|\\|)[\\w\\-./]+\\.[a-z]{1,5}\\b/gi
1241
+ for (const match of planContent.matchAll(pathPattern)) {
1242
+ const cleaned = match[0].replace(/^[\\s/|]+/, "").trim()
1243
+ if (cleaned.endsWith(".") || cleaned.length < 4) continue
1244
+ files.add(cleaned)
1245
+ }
1246
+ return files
1247
+ }
1248
+
1249
+ function loadActivePlanContent(directory: string): string | null {
1250
+ const activePlanPath = resolveStateFile(directory, "active-plan.json")
1251
+ if (existsSync(activePlanPath)) {
1252
+ const activePlan = loadActivePlanTarget(directory) ?? JSON.parse(readFileSync(activePlanPath, "utf-8")) as { planPath?: string; targetRepoRoot?: string }
1253
+ const declaredPath = activePlan.planPath?.trim()
1254
+ if (!declaredPath) return null
1255
+ const projectPaths = resolveProjectPaths(directory, { targetRepoRoot: activePlan.targetRepoRoot, planPath: declaredPath })
1256
+ const resolvedPath = path.isAbsolute(declaredPath)
1257
+ ? declaredPath
1258
+ : projectPaths
1259
+ ? resolvePathFromProjectRoot(projectPaths.projectRoot, declaredPath)
1260
+ : path.join(directory, declaredPath)
1261
+ if (existsSync(resolvedPath)) {
1262
+ return readFileSync(resolvedPath, "utf-8")
1263
+ }
1264
+ }
1265
+
1266
+ const statePath = resolveStateFile(directory, "execution-state.md")
1267
+ if (!existsSync(statePath)) return null
1268
+
1269
+ const stateContent = readFileSync(statePath, "utf-8")
1270
+ const planMatch = stateContent.match(/\\*\\*Plan\\*\\*:\\s*(?:\\\`)?([^\\\`\\n\\s]+)(?:\\\`)?/)
1271
+ const declaredPlan = planMatch?.[1]?.trim()
1272
+ if (!declaredPlan) return null
1273
+
1274
+ const activePlan = loadActivePlanTarget(directory)
1275
+ const projectPaths = resolveProjectPaths(directory, { targetRepoRoot: activePlan?.targetRepoRoot, planPath: declaredPlan })
1276
+ const resolvedPlan = path.isAbsolute(declaredPlan)
1277
+ ? declaredPlan
1278
+ : projectPaths
1279
+ ? resolvePathFromProjectRoot(projectPaths.projectRoot, declaredPlan)
1280
+ : path.join(directory, declaredPlan)
1281
+ if (!existsSync(resolvedPlan)) return null
1282
+
1283
+ return readFileSync(resolvedPlan, "utf-8")
1284
+ }
1285
+
1286
+ function readDirectoryNames(target: string): string[] {
1287
+ try {
1288
+ return readdirSync(target, { withFileTypes: true })
1289
+ .filter((entry) => entry.isDirectory())
1290
+ .map((entry) => entry.name)
1291
+ } catch {
1292
+ return []
1293
+ }
1294
+ }
1295
+
1296
+ function resolveSessionProjectRoot(directory: string, sessionID: string): string | null {
1297
+ const targets = loadActivePlanTargets(directory)
1298
+ for (const target of targets) {
1299
+ if (!target.targetRepoRoot) continue
1300
+ const projectPaths = resolveProjectPaths(directory, { targetRepoRoot: target.targetRepoRoot, planPath: target.planPath })
1301
+ const specsRoot = projectPaths?.specsRoot
1302
+ if (!specsRoot || !existsSync(specsRoot)) continue
1303
+
1304
+ for (const featureSlug of readDirectoryNames(specsRoot)) {
1305
+ const runtimePath = path.join(specsRoot, featureSlug, "state", "sessions", \`\${sessionID}-runtime.json\`)
1306
+ if (!existsSync(runtimePath)) continue
1307
+ try {
1308
+ const runtime = JSON.parse(readFileSync(runtimePath, "utf-8")) as { targetRepoRoot?: string }
1309
+ return runtime.targetRepoRoot?.trim() || target.targetRepoRoot
1310
+ } catch {
1311
+ return target.targetRepoRoot
1312
+ }
1313
+ }
1314
+ }
1315
+
1316
+ return loadActivePlanTarget(directory)?.targetRepoRoot?.trim() || null
1317
+ }
1318
+
1319
+ function repoScopeWarning(directory: string, sessionID: string, filePath: string): string | null {
1320
+ if (!path.isAbsolute(filePath)) return null
1321
+
1322
+ const targetProjectRoot = resolveSessionProjectRoot(directory, sessionID)
1323
+ if (!targetProjectRoot) return null
1324
+
1325
+ const normalizedFilePath = path.resolve(filePath)
1326
+ const harnessRoot = path.join(directory, ".opencode")
1327
+ if (normalizedFilePath.startsWith(targetProjectRoot)) return null
1328
+ if (normalizedFilePath.startsWith(harnessRoot)) return null
1329
+
1330
+ const relPath = path.relative(directory, normalizedFilePath).replace(/\\\\/g, "/")
1331
+ const relTarget = path.relative(directory, targetProjectRoot).replace(/\\\\/g, "/")
1332
+ return \`[intent-gate] REPO WARNING: "\${relPath}" is outside the current task/project scope. This session is scoped to "\${relTarget}".\`
1333
+ }
1334
+
1335
+ export default (async ({ directory }: { directory: string }) => {
1336
+ const planFilesBySession = new Map<string, Set<string>>()
1337
+
1338
+ function getPlanFiles(sessionID: string): Set<string> {
1339
+ const existing = planFilesBySession.get(sessionID)
1340
+ if (existing) return existing
1341
+
1342
+ const planFiles = new Set<string>()
1343
+ const content = loadActivePlanContent(directory)
1344
+ if (content) {
1345
+ for (const file of extractPlanFiles(content)) planFiles.add(file)
1346
+ }
1347
+
1348
+ planFilesBySession.set(sessionID, planFiles)
1349
+ return planFiles
1350
+ }
1351
+
1352
+ return {
1353
+ "tool.execute.after": async (
1354
+ input: { tool: string; sessionID: string; callID: string; args: any },
1355
+ output: { title: string; output: string; metadata: any }
1356
+ ) => {
1357
+ if (!["Read", "Write", "Edit", "MultiEdit"].includes(input.tool)) return
1358
+
1359
+ const filePath: string = input.args?.path ?? input.args?.file_path ?? ""
1360
+ if (!filePath) return
1361
+
1362
+ const scopeWarning = repoScopeWarning(directory, input.sessionID, filePath)
1363
+ if (scopeWarning) output.output += \`\\n\\n\${scopeWarning}\`
1364
+
1365
+ if (!["Write", "Edit", "MultiEdit"].includes(input.tool)) return
1366
+
1367
+ const planFiles = getPlanFiles(input.sessionID)
1368
+
1369
+ // No plan loaded — nothing to guard
1370
+ if (planFiles.size === 0) return
1371
+
1372
+ const relPath = path.relative(directory, filePath).replace(/\\\\\\\\/g, "/")
1373
+
1374
+ // Check if the modified file matches any plan reference
1375
+ const inScope = [...planFiles].some(
1376
+ (pf) => relPath.endsWith(pf) || relPath.includes(pf) || pf.includes(relPath)
1377
+ )
1378
+
1379
+ if (!inScope) {
1380
+ output.output +=
1381
+ \`\\n\\n[intent-gate] ⚠ SCOPE WARNING: "\${relPath}" is not referenced in the current plan. \` +
1382
+ \`Verify this change is necessary for the current task before continuing.\`
1383
+ }
1384
+ },
1385
+ }
1386
+ }) satisfies Plugin
734
1387
  `;
735
- // ─── Comment Checker ──────────────────────────────────────────────────────────
736
- const COMMENT_CHECKER = `import type { Plugin } from "@opencode-ai/plugin"
737
-
738
- // Detects obvious/redundant comments after Write/Edit and appends a reminder.
739
- // Uses tool.execute.after — appends to output.output so agent sees the warning.
740
-
741
- const OBVIOUS_PATTERNS = [
742
- /\\/\\/ increment .*/i,
743
- /\\/\\/ set .* to/i,
744
- /\\/\\/ return .*/i,
745
- /\\/\\/ call .*/i,
746
- /\\/\\/ create .* variable/i,
747
- /\\/\\/ check if/i,
748
- /\\/\\/ loop (through|over|for)/i,
749
- /\\/\\/ define function/i,
750
- /\\/\\/ initialize/i,
751
- /\\/\\/ assign/i,
752
- ]
753
-
754
- const IGNORE_PATTERNS = [
755
- /\\/\\/\\s*@ts-/,
756
- /\\/\\/\\s*eslint/,
757
- /\\/\\/\\s*TODO/i,
758
- /\\/\\/\\s*FIXME/i,
759
- /\\/\\/\\s*HACK/i,
760
- /\\/\\/\\s*NOTE:/i,
761
- /\\/\\/\\s*BUG:/i,
762
- /\\/\\*\\*/,
763
- /\\s*\\*\\s/,
764
- /given|when|then/i,
765
- /describe|it\\(/,
766
- ]
767
-
768
- function hasObviousComments(content: string): string[] {
769
- const lines = content.split("\\n")
770
- const found: string[] = []
771
-
772
- for (let i = 0; i < lines.length; i++) {
773
- const line = lines[i]
774
- if (IGNORE_PATTERNS.some((p) => p.test(line))) continue
775
- if (OBVIOUS_PATTERNS.some((p) => p.test(line))) {
776
- found.push(\`Line \${i + 1}: \${line.trim()}\`)
777
- }
778
- }
779
-
780
- return found
781
- }
782
-
783
- export default (async ({ directory: _directory }: { directory: string }) => ({
784
- "tool.execute.after": async (
785
- input: { tool: string; sessionID: string; callID: string; args: any },
786
- output: { title: string; output: string; metadata: any }
787
- ) => {
788
- if (!["Write", "Edit"].includes(input.tool)) return
789
-
790
- const content: string = input.args?.content ?? input.args?.new_string ?? ""
791
- if (!content) return
792
-
793
- const obvious = hasObviousComments(content)
794
- if (obvious.length === 0) return
795
-
796
- output.output +=
797
- \`\\n\\n[comment-checker] \${obvious.length} potentially obvious comment(s) detected:\\n\` +
798
- obvious.slice(0, 3).join("\\n") +
799
- \`\\nConsider removing redundant comments — code should be self-documenting.\`
800
- },
801
- })) satisfies Plugin
1388
+ // ─── Memory (evolved) ────────────────────────────────────────────────────────
1389
+ const MEMORY = `import type { Plugin } from "@opencode-ai/plugin"
1390
+ import { existsSync, readFileSync } from "fs"
1391
+ import path from "path"
1392
+
1393
+ // Injects persistent-context.md (cross-session repo memory, like OpenClaw).
1394
+ // This file is written by UNIFY and contains project conventions, decisions,
1395
+ // and patterns accumulated across sessions.
1396
+ // Two hooks:
1397
+ // tool.execute.after injects on the FIRST tool call of a session so the
1398
+ // agent has repo memory from the very beginning.
1399
+ // experimental.session.compacting re-injects during compaction so memory
1400
+ // survives context window resets.
1401
+
1402
+ function loadMemory(directory: string): string | null {
1403
+ const memoryPath = path.join(directory, ".opencode", "state", "persistent-context.md")
1404
+ if (!existsSync(memoryPath)) return null
1405
+
1406
+ const content = readFileSync(memoryPath, "utf-8").trim()
1407
+ if (!content) return null
1408
+
1409
+ return content
1410
+ }
1411
+
1412
+ export default (async ({ directory }: { directory: string }) => {
1413
+ const injectedSessions = new Set<string>()
1414
+
1415
+ return {
1416
+ "tool.execute.after": async (
1417
+ input: { tool: string; sessionID: string; callID: string; args: any },
1418
+ output: { title: string; output: string; metadata: any }
1419
+ ) => {
1420
+ // Fire once per session — first tool call triggers injection
1421
+ if (injectedSessions.has(input.sessionID)) return
1422
+ injectedSessions.add(input.sessionID)
1423
+
1424
+ const memory = loadMemory(directory)
1425
+ if (!memory) return
1426
+
1427
+ output.output +=
1428
+ \`\\n\\n[memory] Project memory (persistent-context):\\n\\n\${memory}\`
1429
+ },
1430
+ "experimental.session.compacting": async (
1431
+ _input: Record<string, unknown>,
1432
+ output: { context: string[]; prompt?: string }
1433
+ ) => {
1434
+ const memory = loadMemory(directory)
1435
+ if (!memory) return
1436
+
1437
+ output.context.push(
1438
+ \`[memory] Project memory (persistent-context):\\n\\n\${memory}\`
1439
+ )
1440
+ },
1441
+ }
1442
+ }) satisfies Plugin
802
1443
  `;
803
- // ─── Hashline Read ────────────────────────────────────────────────────────────
804
- const HASHLINE_READ = `import type { Plugin } from "@opencode-ai/plugin"
805
- import crypto from "crypto"
806
-
807
- // Tags each line in Read output with NN#XX: prefix for stable hash references.
808
- // Agent uses these tags when editing — hashline-edit.ts validates them.
809
- // Uses tool.execute.after — sets output.output to the tagged version.
810
-
811
- function hashLine(line: string): string {
812
- return crypto.createHash("md5").update(line).digest("hex").slice(0, 2)
813
- }
814
-
815
- function addHashlines(content: string): string {
816
- return content
817
- .split("\\n")
818
- .map((line, i) => {
819
- const lineNum = String(i + 1).padStart(3, "0")
820
- const hash = hashLine(line)
821
- return \`\${lineNum}#\${hash}: \${line}\`
822
- })
823
- .join("\\n")
824
- }
825
-
826
- export default (async ({ directory: _directory }: { directory: string }) => ({
827
- "tool.execute.after": async (
828
- input: { tool: string; sessionID: string; callID: string; args: any },
829
- output: { title: string; output: string; metadata: any }
830
- ) => {
831
- if (input.tool !== "Read") return
832
- if (typeof output.output !== "string") return
833
-
834
- output.output = addHashlines(output.output)
835
- },
836
- })) satisfies Plugin
1444
+ // ─── Task Runtime (evolved) ──────────────────────────────────────────────────
1445
+ const TASK_RUNTIME = `import type { Plugin } from "@opencode-ai/plugin"
1446
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"
1447
+ import path from "path"
1448
+ import {
1449
+ ensureFeatureStateStructure,
1450
+ featureStateSessionRuntimePath,
1451
+ featureStateTaskPaths,
1452
+ } from "../lib/j.feature-state-paths"
1453
+ import { loadJuninhoConfig } from "../lib/j.juninho-config"
1454
+ import { resolveStateFile } from "../lib/j.state-paths"
1455
+ import { loadActivePlanTarget, loadActivePlanTargets, resolveProjectPaths } from "../lib/j.workspace-paths"
1456
+
1457
+ type RuntimeTaskMetadata = {
1458
+ featureSlug: string
1459
+ taskID: string
1460
+ attempt: number
1461
+ stage: "implement" | "validate" | "check-reentry"
1462
+ planBranch: string
1463
+ planPath: string
1464
+ specPath: string
1465
+ contextPath: string
1466
+ statePath: string
1467
+ retryStatePath: string
1468
+ runtimePath: string
1469
+ taskContractPath: string
1470
+ targetRepoRoot: string
1471
+ parentSessionID: string
1472
+ ownerSessionID?: string
1473
+ ownerSessionTitle?: string
1474
+ originalPrompt: string
1475
+ }
1476
+
1477
+ type ActivePlanContract = {
1478
+ slug?: string
1479
+ planPath?: string
1480
+ specPath?: string
1481
+ contextPath?: string
1482
+ workflowContractPath?: string
1483
+ targetRepoRoot?: string
1484
+ writeTargets?: Array<{
1485
+ project?: string
1486
+ planPath?: string
1487
+ specPath?: string
1488
+ contextPath?: string
1489
+ targetRepoRoot?: string
1490
+ }>
1491
+ }
1492
+
1493
+ type TaskContract = {
1494
+ featureSlug?: string
1495
+ taskID?: string
1496
+ attempt?: number
1497
+ stage?: RuntimeTaskMetadata["stage"]
1498
+ planPath?: string
1499
+ specPath?: string
1500
+ contextPath?: string
1501
+ taskContractPath?: string
1502
+ targetRepoRoot?: string
1503
+ }
1504
+
1505
+ type PersistedTaskContract = {
1506
+ featureSlug: string
1507
+ taskID: string
1508
+ attempt: number
1509
+ stage: RuntimeTaskMetadata["stage"]
1510
+ planPath: string
1511
+ specPath: string
1512
+ contextPath: string
1513
+ taskContractPath: string
1514
+ targetRepoRoot: string
1515
+ parentSessionID: string
1516
+ ownerSessionID?: string
1517
+ ownerSessionTitle?: string
1518
+ originalPrompt: string
1519
+ }
1520
+
1521
+ type RetryState = {
1522
+ taskId: number
1523
+ attempt: number
1524
+ automaticRetriesUsed: number
1525
+ lastUpdatedAt: string
1526
+ lastReason?: string
1527
+ abortedSessionId?: string
1528
+ retriedFromAttempt?: number
1529
+ }
1530
+
1531
+ type TrackedSession = {
1532
+ metadata: RuntimeTaskMetadata
1533
+ startedAtMs: number
1534
+ lastEventAtMs: number
1535
+ }
1536
+
1537
+ type SessionStatus = { type: "idle" | "retry" | "busy" }
1538
+
1539
+ type SessionStatusMap = Record<string, SessionStatus>
1540
+
1541
+ type RuntimeRecord = {
1542
+ taskId?: number
1543
+ taskID?: string
1544
+ featureSlug?: string
1545
+ attempt?: number
1546
+ branch?: string
1547
+ planBranch?: string
1548
+ status?: string
1549
+ sessionId?: string
1550
+ ownerSessionID?: string
1551
+ startedAt?: string
1552
+ lastHeartbeat?: string
1553
+ stage?: RuntimeTaskMetadata["stage"]
1554
+ planPath?: string
1555
+ specPath?: string
1556
+ contextPath?: string
1557
+ statePath?: string
1558
+ retryStatePath?: string
1559
+ runtimePath?: string
1560
+ taskContractPath?: string
1561
+ targetRepoRoot?: string
1562
+ parentSessionID?: string
1563
+ ownerSessionTitle?: string
1564
+ originalPrompt?: string
1565
+ }
1566
+
1567
+ const MAX_AUTOMATIC_RETRIES = 1
1568
+ const TASK_START_TIMEOUT_MS = 2 * 60 * 1000
1569
+ const IMPLEMENT_STALE_MS = 5 * 60 * 1000
1570
+ const VALIDATE_STALE_MS = 3 * 60 * 1000
1571
+ const BUSY_GRACE_MULTIPLIER = 2
1572
+ const WATCHDOG_POLL_MS = 30 * 1000
1573
+
1574
+ function toRepoRelative(directory: string, filePath: string): string {
1575
+ return path.relative(directory, filePath) || "."
1576
+ }
1577
+
1578
+ function toProjectRelative(projectRoot: string, filePath: string): string {
1579
+ return path.isAbsolute(filePath) ? path.relative(projectRoot, filePath) || "." : filePath
1580
+ }
1581
+
1582
+ function absoluteFromWorkspace(directory: string, filePath: string): string {
1583
+ return path.isAbsolute(filePath) ? filePath : path.join(directory, filePath)
1584
+ }
1585
+
1586
+ function isoNow(): string {
1587
+ return new Date().toISOString()
1588
+ }
1589
+
1590
+ function extractFeatureSlug(prompt: string): string | null {
1591
+ return prompt.match(/docs\\/specs\\/([^/]+)\\//)?.[1] ?? null
1592
+ }
1593
+
1594
+ function extractPlanPath(prompt: string): string | null {
1595
+ return prompt.match(/(?:[\\w.-]+\\/)+docs\\/specs\\/[^\\s]+\\/plan\\.md|docs\\/specs\\/[^\\s]+\\/plan\\.md/)?.[0] ?? null
1596
+ }
1597
+
1598
+ function extractFeatureSlugFromPlanPath(planPath: string): string | null {
1599
+ return planPath.match(/docs\\/specs\\/([^/]+)\\//)?.[1] ?? null
1600
+ }
1601
+
1602
+ function extractStructuredString(prompt: string, label: string): string | null {
1603
+ const match = prompt.match(new RegExp(\`^\${label}:\\\\s*(.+)$\`, "im"))?.[1]?.trim()
1604
+ return match && match.length > 0 ? match : null
1605
+ }
1606
+
1607
+ function extractTaskID(prompt: string): string | null {
1608
+ const explicitTask = extractStructuredString(prompt, "Task")
1609
+ if (explicitTask?.match(/^\\d+$/)) return explicitTask
1610
+ return prompt.match(/(?:Execute|executing|Validate|validating) task\\s+(\\d+)\\b/i)?.[1] ?? null
1611
+ }
1612
+
1613
+ function extractStage(prompt: string): RuntimeTaskMetadata["stage"] {
1614
+ const explicitStage = extractStructuredString(prompt, "Stage")?.toLowerCase()
1615
+ if (explicitStage === "validate") return "validate"
1616
+ if (explicitStage === "check-reentry") return "check-reentry"
1617
+ if (explicitStage === "implement") return "implement"
1618
+ if (/\\bvalidate\\b|\\bvalidator\\b/i.test(prompt)) return "validate"
1619
+ if (/check-review\\.md|check-all-output\\.txt|functional-validation-plan\\.md/i.test(prompt)) return "check-reentry"
1620
+ return "implement"
1621
+ }
1622
+
1623
+ function extractAttempt(prompt: string): number {
1624
+ const raw = prompt.match(/Attempt:\\s*(\\d+)/i)?.[1]
1625
+ return raw ? Number.parseInt(raw, 10) : 1
1626
+ }
1627
+
1628
+ function loadActivePlan(directory: string): ActivePlanContract | null {
1629
+ const activePlanFile = resolveStateFile(directory, "active-plan.json")
1630
+ if (!existsSync(activePlanFile)) return null
1631
+
1632
+ try {
1633
+ const parsed = JSON.parse(readFileSync(activePlanFile, "utf-8")) as ActivePlanContract
1634
+ const primary = loadActivePlanTarget(directory)
1635
+ return {
1636
+ ...parsed,
1637
+ ...(primary ?? {}),
1638
+ writeTargets: loadActivePlanTargets(directory),
1639
+ }
1640
+ } catch {
1641
+ return null
1642
+ }
1643
+ }
1644
+
1645
+ function selectMatchingWriteTarget(prompt: string, activePlan: ActivePlanContract | null) {
1646
+ const targets = Array.isArray(activePlan?.writeTargets) ? activePlan.writeTargets : []
1647
+ if (targets.length <= 1) return null
1648
+
1649
+ const normalizedPrompt = prompt.trim()
1650
+ if (!normalizedPrompt) return null
1651
+
1652
+ const scored = targets.map((target) => {
1653
+ const repoRoot = target.targetRepoRoot?.trim()
1654
+ const planPath = target.planPath?.trim()
1655
+ const specPath = target.specPath?.trim()
1656
+ const contextPath = target.contextPath?.trim()
1657
+ const project = target.project?.trim()
1658
+ let score = 0
1659
+
1660
+ if (repoRoot && normalizedPrompt.includes(repoRoot)) score += 100
1661
+ if (project && normalizedPrompt.includes(project)) score += 50
1662
+ if (planPath && normalizedPrompt.includes(planPath)) score += 10
1663
+ if (specPath && normalizedPrompt.includes(specPath)) score += 5
1664
+ if (contextPath && normalizedPrompt.includes(contextPath)) score += 5
1665
+
1666
+ return { target, score }
1667
+ })
1668
+
1669
+ scored.sort((left, right) => right.score - left.score)
1670
+ return scored[0]?.score ? scored[0].target : null
1671
+ }
1672
+
1673
+ function loadTaskContract(directory: string, args: Record<string, unknown>): TaskContract | null {
1674
+ const contractArg = typeof args.contract === "object" && args.contract
1675
+ ? (args.contract as Record<string, unknown>)
1676
+ : null
1677
+
1678
+ if (contractArg) {
1679
+ return {
1680
+ featureSlug: typeof contractArg.featureSlug === "string" ? contractArg.featureSlug : undefined,
1681
+ taskID: typeof contractArg.taskID === "string" ? contractArg.taskID : typeof contractArg.taskID === "number" ? String(contractArg.taskID) : undefined,
1682
+ attempt: typeof contractArg.attempt === "number" ? contractArg.attempt : undefined,
1683
+ stage: contractArg.stage === "implement" || contractArg.stage === "validate" || contractArg.stage === "check-reentry"
1684
+ ? contractArg.stage
1685
+ : undefined,
1686
+ planPath: typeof contractArg.planPath === "string" ? contractArg.planPath : undefined,
1687
+ specPath: typeof contractArg.specPath === "string" ? contractArg.specPath : undefined,
1688
+ contextPath: typeof contractArg.contextPath === "string" ? contractArg.contextPath : undefined,
1689
+ taskContractPath: typeof contractArg.taskContractPath === "string" ? contractArg.taskContractPath : undefined,
1690
+ targetRepoRoot: typeof contractArg.targetRepoRoot === "string" ? contractArg.targetRepoRoot : undefined,
1691
+ }
1692
+ }
1693
+
1694
+ const contractPathArg = typeof args.task_contract_path === "string"
1695
+ ? args.task_contract_path
1696
+ : typeof args.taskContractPath === "string"
1697
+ ? args.taskContractPath
1698
+ : undefined
1699
+ if (!contractPathArg) return null
1700
+
1701
+ const absolutePath = path.isAbsolute(contractPathArg) ? contractPathArg : path.join(directory, contractPathArg)
1702
+ if (!existsSync(absolutePath)) return null
1703
+
1704
+ try {
1705
+ const contract = JSON.parse(readFileSync(absolutePath, "utf-8")) as TaskContract
1706
+ return {
1707
+ ...contract,
1708
+ taskContractPath: contractPathArg,
1709
+ }
1710
+ } catch {
1711
+ return null
1712
+ }
1713
+ }
1714
+
1715
+ function buildMetadata(directory: string, parentSessionID: string, prompt: string, args: Record<string, unknown>): RuntimeTaskMetadata | null {
1716
+ const config = loadJuninhoConfig(directory)
1717
+ if (config.workflow?.implement?.watchdogSessionStale === false) return null
1718
+
1719
+ const activePlan = loadActivePlan(directory)
1720
+ const taskContract = loadTaskContract(directory, args)
1721
+ const matchedTarget = selectMatchingWriteTarget(prompt, activePlan)
1722
+ const promptPlanPath = extractPlanPath(prompt)
1723
+ const planPath = taskContract?.planPath?.trim() ?? promptPlanPath ?? matchedTarget?.planPath?.trim() ?? activePlan?.planPath?.trim() ?? null
1724
+ const featureSlug = taskContract?.featureSlug?.trim() ?? extractFeatureSlug(prompt) ?? activePlan?.slug?.trim() ?? (planPath ? extractFeatureSlugFromPlanPath(planPath) : null)
1725
+ const taskID = taskContract?.taskID?.trim() ?? extractTaskID(prompt)
1726
+ if (!featureSlug || !taskID) return null
1727
+
1728
+ const projectPaths = resolveProjectPaths(directory, {
1729
+ prompt,
1730
+ targetRepoRoot: taskContract?.targetRepoRoot?.trim() || matchedTarget?.targetRepoRoot?.trim() || activePlan?.targetRepoRoot,
1731
+ planPath: planPath ?? undefined,
1732
+ specPath: taskContract?.specPath?.trim() || matchedTarget?.specPath?.trim() || activePlan?.specPath?.trim(),
1733
+ contextPath: taskContract?.contextPath?.trim() || matchedTarget?.contextPath?.trim() || activePlan?.contextPath?.trim(),
1734
+ taskContractPath: taskContract?.taskContractPath?.trim(),
1735
+ })
1736
+ if (!projectPaths) return null
1737
+
1738
+ ensureFeatureStateStructure(directory, featureSlug, { targetRepoRoot: projectPaths.projectRoot })
1739
+ const taskPaths = featureStateTaskPaths(directory, featureSlug, taskID, { targetRepoRoot: projectPaths.projectRoot })
1740
+ mkdirSync(taskPaths.taskDir, { recursive: true })
1741
+
1742
+ const taskContractPath = toProjectRelative(
1743
+ projectPaths.projectRoot,
1744
+ taskContract?.taskContractPath?.trim() || taskPaths.contractPath
1745
+ )
1746
+
1747
+ return {
1748
+ featureSlug,
1749
+ taskID,
1750
+ attempt: taskContract?.attempt ?? extractAttempt(prompt),
1751
+ stage: taskContract?.stage ?? extractStage(prompt),
1752
+ planBranch: "feature/" + featureSlug,
1753
+ planPath: toProjectRelative(projectPaths.projectRoot, planPath || \`docs/specs/\${featureSlug}/plan.md\`),
1754
+ specPath: toProjectRelative(projectPaths.projectRoot, taskContract?.specPath?.trim() || matchedTarget?.specPath?.trim() || activePlan?.specPath?.trim() || \`docs/specs/\${featureSlug}/spec.md\`),
1755
+ contextPath: toProjectRelative(projectPaths.projectRoot, taskContract?.contextPath?.trim() || matchedTarget?.contextPath?.trim() || activePlan?.contextPath?.trim() || \`docs/specs/\${featureSlug}/CONTEXT.md\`),
1756
+ statePath: toRepoRelative(directory, taskPaths.statePath),
1757
+ retryStatePath: toRepoRelative(directory, taskPaths.retryStatePath),
1758
+ runtimePath: toRepoRelative(directory, taskPaths.runtimePath),
1759
+ taskContractPath,
1760
+ targetRepoRoot: projectPaths.projectRoot,
1761
+ parentSessionID,
1762
+ originalPrompt: prompt,
1763
+ }
1764
+ }
1765
+
1766
+ function sessionRuntimePath(directory: string, metadata: RuntimeTaskMetadata, sessionID: string): string {
1767
+ return featureStateSessionRuntimePath(directory, metadata.featureSlug, sessionID, { targetRepoRoot: metadata.targetRepoRoot })
1768
+ }
1769
+
1770
+ function readJsonFile<T>(filePath: string): T | null {
1771
+ if (!existsSync(filePath)) return null
1772
+ try {
1773
+ return JSON.parse(readFileSync(filePath, "utf-8")) as T
1774
+ } catch {
1775
+ return null
1776
+ }
1777
+ }
1778
+
1779
+ function writeJsonFile(filePath: string, value: unknown): void {
1780
+ mkdirSync(path.dirname(filePath), { recursive: true })
1781
+ writeFileSync(filePath, JSON.stringify(value, null, 2) + "\\n", "utf-8")
1782
+ }
1783
+
1784
+ function writeMetadata(filePath: string, metadata: RuntimeTaskMetadata): void {
1785
+ const existing = readJsonFile<RuntimeRecord>(filePath) ?? {}
1786
+ const sessionChanged = existing.attempt !== metadata.attempt
1787
+ || existing.ownerSessionID !== metadata.ownerSessionID
1788
+ || existing.sessionId !== metadata.ownerSessionID
1789
+ const now = isoNow()
1790
+ const next: RuntimeRecord = {
1791
+ ...existing,
1792
+ taskId: existing.taskId ?? Number.parseInt(metadata.taskID, 10),
1793
+ taskID: metadata.taskID,
1794
+ featureSlug: metadata.featureSlug,
1795
+ attempt: metadata.attempt,
1796
+ branch: existing.branch ?? metadata.planBranch,
1797
+ planBranch: metadata.planBranch,
1798
+ status: sessionChanged ? undefined : existing.status,
1799
+ sessionId: metadata.ownerSessionID ?? existing.sessionId,
1800
+ ownerSessionID: metadata.ownerSessionID,
1801
+ startedAt: sessionChanged ? now : existing.startedAt,
1802
+ lastHeartbeat: sessionChanged ? now : existing.lastHeartbeat,
1803
+ stage: metadata.stage,
1804
+ planPath: metadata.planPath,
1805
+ specPath: metadata.specPath,
1806
+ contextPath: metadata.contextPath,
1807
+ statePath: metadata.statePath,
1808
+ retryStatePath: metadata.retryStatePath,
1809
+ runtimePath: metadata.runtimePath,
1810
+ taskContractPath: metadata.taskContractPath,
1811
+ targetRepoRoot: metadata.targetRepoRoot,
1812
+ parentSessionID: metadata.parentSessionID,
1813
+ ownerSessionTitle: metadata.ownerSessionTitle,
1814
+ originalPrompt: metadata.originalPrompt,
1815
+ }
1816
+ writeJsonFile(filePath, next)
1817
+ }
1818
+
1819
+ function writeTaskContract(directory: string, metadata: RuntimeTaskMetadata): void {
1820
+ const contractPath = path.join(metadata.targetRepoRoot, metadata.taskContractPath)
1821
+ const payload: PersistedTaskContract = {
1822
+ featureSlug: metadata.featureSlug,
1823
+ taskID: metadata.taskID,
1824
+ attempt: metadata.attempt,
1825
+ stage: metadata.stage,
1826
+ planPath: metadata.planPath,
1827
+ specPath: metadata.specPath,
1828
+ contextPath: metadata.contextPath,
1829
+ taskContractPath: metadata.taskContractPath,
1830
+ targetRepoRoot: metadata.targetRepoRoot,
1831
+ parentSessionID: metadata.parentSessionID,
1832
+ ownerSessionID: metadata.ownerSessionID,
1833
+ ownerSessionTitle: metadata.ownerSessionTitle,
1834
+ originalPrompt: metadata.originalPrompt,
1835
+ }
1836
+ writeJsonFile(contractPath, payload)
1837
+ }
1838
+
1839
+ function readExecutionState(filePath: string): { status?: string; attempt?: number; lastHeartbeat?: string } {
1840
+ if (!existsSync(filePath)) return {}
1841
+ const content = readFileSync(filePath, "utf-8")
1842
+ const status = content.match(/\\*\\*Status\\*\\*:\\s*([^\\n]+)/)?.[1]?.trim()
1843
+ const attemptRaw = content.match(/\\*\\*Attempt\\*\\*:\\s*(\\d+)/)?.[1]
1844
+ const lastHeartbeat = content.match(/\\*\\*Last heartbeat\\*\\*:\\s*([^\\n]+)/)?.[1]?.trim()
1845
+ return {
1846
+ status,
1847
+ attempt: attemptRaw ? Number.parseInt(attemptRaw, 10) : undefined,
1848
+ lastHeartbeat,
1849
+ }
1850
+ }
1851
+
1852
+ function readRuntimeStatus(filePath: string): { status?: string; attempt?: number; lastHeartbeat?: string; sessionID?: string } {
1853
+ const parsed = readJsonFile<RuntimeRecord>(filePath)
1854
+ if (!parsed) return {}
1855
+ return {
1856
+ status: parsed.status,
1857
+ attempt: typeof parsed.attempt === "number" ? parsed.attempt : undefined,
1858
+ lastHeartbeat: parsed.lastHeartbeat,
1859
+ sessionID: parsed.sessionId ?? parsed.ownerSessionID,
1860
+ }
1861
+ }
1862
+
1863
+ function writeRuntimeStatus(filePath: string, patch: Partial<RuntimeRecord>): void {
1864
+ const existing = readJsonFile<RuntimeRecord>(filePath) ?? {}
1865
+ const next: RuntimeRecord = {
1866
+ ...existing,
1867
+ ...patch,
1868
+ }
1869
+ writeJsonFile(filePath, next)
1870
+ }
1871
+
1872
+ function readRetryState(filePath: string, taskID: string, attempt: number): RetryState {
1873
+ const existing = readJsonFile<RetryState>(filePath)
1874
+ return {
1875
+ taskId: Number.parseInt(taskID, 10),
1876
+ attempt,
1877
+ automaticRetriesUsed: existing?.automaticRetriesUsed ?? 0,
1878
+ lastUpdatedAt: existing?.lastUpdatedAt ?? isoNow(),
1879
+ lastReason: existing?.lastReason,
1880
+ abortedSessionId: existing?.abortedSessionId,
1881
+ retriedFromAttempt: existing?.retriedFromAttempt,
1882
+ }
1883
+ }
1884
+
1885
+ function writeRetryState(filePath: string, next: RetryState): void {
1886
+ writeJsonFile(filePath, {
1887
+ ...next,
1888
+ lastUpdatedAt: isoNow(),
1889
+ })
1890
+ }
1891
+
1892
+ function markSupersededExecutionState(filePath: string, attempt: number): void {
1893
+ if (!existsSync(filePath)) return
1894
+ const content = readFileSync(filePath, "utf-8")
1895
+ const nextContent = content
1896
+ .replace(/(\\*\\*Status\\*\\*:\\s*)([^\\n]+)/, \`$1SUPERSEDED\`)
1897
+ .replace(/(\\*\\*Last heartbeat\\*\\*:\\s*)([^\\n]+)/, \`$1\${isoNow()}\`)
1898
+ const retryLine = \`- **Retry of**: \${attempt}\`
1899
+ const finalContent = nextContent.includes("**Retry of**:")
1900
+ ? nextContent.replace(/(\\*\\*Retry of\\*\\*:\\s*)([^\\n]+)/, \`$1\${attempt}\`)
1901
+ : nextContent.replace(/(\\*\\*Depends on\\*\\*:[^\\n]*\\n)/, \`$1\${retryLine}\\n\`)
1902
+ writeFileSync(filePath, finalContent, "utf-8")
1903
+ }
1904
+
1905
+ function isTerminalStatus(status?: string): boolean {
1906
+ return status === "COMPLETE" || status === "FAILED" || status === "BLOCKED" || status === "SUPERSEDED"
1907
+ }
1908
+
1909
+ function parseStaleThresholdMs(stage: RuntimeTaskMetadata["stage"], busy: boolean): number {
1910
+ const base = stage === "validate" ? VALIDATE_STALE_MS : IMPLEMENT_STALE_MS
1911
+ return busy ? base * BUSY_GRACE_MULTIPLIER : base
1912
+ }
1913
+
1914
+ function buildRetryPrompt(metadata: RuntimeTaskMetadata, nextAttempt: number, reason: string): string {
1915
+ return [
1916
+ metadata.originalPrompt.trim(),
1917
+ \`Attempt: \${nextAttempt}\`,
1918
+ \`Retry of: \${metadata.attempt}\`,
1919
+ \`Stage: \${metadata.stage}\`,
1920
+ \`Target Repo Root: \${metadata.targetRepoRoot}\`,
1921
+ \`Plan: \${metadata.planPath}\`,
1922
+ \`Spec: \${metadata.specPath}\`,
1923
+ \`Context: \${metadata.contextPath}\`,
1924
+ \`Task Contract Path: \${metadata.taskContractPath}\`,
1925
+ \`Retry reason: \${reason}\`,
1926
+ \`Read the existing execution state, validator output, retry state, and task contract before acting. Reuse partial artifacts and continue from the current task state instead of starting over.\`,
1927
+ ].join("\\n")
1928
+ }
1929
+
1930
+ async function readSessionStatuses(client: any, directory: string): Promise<SessionStatusMap> {
1931
+ try {
1932
+ const result = await client.session.status({ directory })
1933
+ if (result?.data) return result.data as SessionStatusMap
1934
+ return result as SessionStatusMap
1935
+ } catch {
1936
+ return {}
1937
+ }
1938
+ }
1939
+
1940
+ async function bestEffortAbortSession(client: any, directory: string, sessionID?: string): Promise<boolean> {
1941
+ if (!sessionID) return false
1942
+
1943
+ try {
1944
+ await client.session.abort({ sessionID, directory })
1945
+ return true
1946
+ } catch {
1947
+ try {
1948
+ await client.session.delete({ sessionID, directory })
1949
+ return true
1950
+ } catch {
1951
+ return false
1952
+ }
1953
+ }
1954
+ }
1955
+
1956
+ async function relaunchAttempt(client: any, metadata: RuntimeTaskMetadata, nextAttempt: number, reason: string): Promise<string | null> {
1957
+ try {
1958
+ const created = await client.session.create({
1959
+ directory: metadata.targetRepoRoot,
1960
+ parentID: metadata.parentSessionID,
1961
+ title: \`Execute task \${metadata.taskID} (retry \${nextAttempt})\`,
1962
+ })
1963
+ const newSessionID = created?.data?.id ?? created?.id
1964
+ if (!newSessionID) return null
1965
+
1966
+ await client.session.promptAsync({
1967
+ sessionID: newSessionID,
1968
+ directory: metadata.targetRepoRoot,
1969
+ agent: metadata.stage === "validate" ? "j.validator" : "j.implementer",
1970
+ parts: [{ type: "text", text: buildRetryPrompt(metadata, nextAttempt, reason) }],
1971
+ })
1972
+
1973
+ return newSessionID
1974
+ } catch {
1975
+ return null
1976
+ }
1977
+ }
1978
+
1979
+ async function maybeRetryTrackedSession(
1980
+ client: any,
1981
+ directory: string,
1982
+ tracked: TrackedSession,
1983
+ statusMap: SessionStatusMap,
1984
+ trackedBySession: Map<string, TrackedSession>
1985
+ ): Promise<void> {
1986
+ const { metadata } = tracked
1987
+ const statePath = absoluteFromWorkspace(directory, metadata.statePath)
1988
+ const runtimePath = absoluteFromWorkspace(directory, metadata.runtimePath)
1989
+ const retryPath = absoluteFromWorkspace(directory, metadata.retryStatePath)
1990
+
1991
+ const taskState = readExecutionState(statePath)
1992
+ const runtimeState = readRuntimeStatus(runtimePath)
1993
+ const effectiveStatus = taskState.status ?? runtimeState.status
1994
+ if (isTerminalStatus(effectiveStatus)) {
1995
+ trackedBySession.delete(metadata.ownerSessionID ?? "")
1996
+ return
1997
+ }
1998
+
1999
+ const effectiveAttempt = taskState.attempt ?? runtimeState.attempt ?? metadata.attempt
2000
+ if (effectiveAttempt > metadata.attempt) {
2001
+ trackedBySession.delete(metadata.ownerSessionID ?? "")
2002
+ return
2003
+ }
2004
+
2005
+ const retryState = readRetryState(retryPath, metadata.taskID, metadata.attempt)
2006
+ if (retryState.automaticRetriesUsed >= MAX_AUTOMATIC_RETRIES) return
2007
+
2008
+ const sessionID = metadata.ownerSessionID
2009
+ const statusType = sessionID ? statusMap[sessionID]?.type : undefined
2010
+ const heartbeatSource = taskState.lastHeartbeat ?? runtimeState.lastHeartbeat
2011
+ const heartbeatMs = heartbeatSource ? Date.parse(heartbeatSource) : tracked.startedAtMs
2012
+ const ageMs = Date.now() - heartbeatMs
2013
+ const thresholdMs = effectiveStatus === undefined
2014
+ ? TASK_START_TIMEOUT_MS
2015
+ : parseStaleThresholdMs(metadata.stage, statusType === "busy")
2016
+
2017
+ if (Number.isNaN(ageMs) || ageMs < thresholdMs) return
2018
+
2019
+ const aborted = await bestEffortAbortSession(client, metadata.targetRepoRoot, sessionID)
2020
+ const nextAttempt = metadata.attempt + 1
2021
+ const retryReason = metadata.stage === "validate" ? "stale-validator-session" : "stale-task-session"
2022
+
2023
+ const retriedMetadata: RuntimeTaskMetadata = {
2024
+ ...metadata,
2025
+ attempt: nextAttempt,
2026
+ }
2027
+ const newSessionID = await relaunchAttempt(client, retriedMetadata, nextAttempt, retryReason)
2028
+ if (!newSessionID) return
2029
+
2030
+ markSupersededExecutionState(statePath, metadata.attempt)
2031
+ if (sessionID) {
2032
+ writeRuntimeStatus(sessionRuntimePath(directory, metadata, sessionID), {
2033
+ status: "SUPERSEDED",
2034
+ lastHeartbeat: isoNow(),
2035
+ sessionId: sessionID,
2036
+ ownerSessionID: sessionID,
2037
+ })
2038
+ }
2039
+ writeRetryState(retryPath, {
2040
+ ...retryState,
2041
+ attempt: nextAttempt,
2042
+ automaticRetriesUsed: retryState.automaticRetriesUsed + 1,
2043
+ lastReason: retryReason,
2044
+ abortedSessionId: aborted ? sessionID : retryState.abortedSessionId,
2045
+ retriedFromAttempt: metadata.attempt,
2046
+ })
2047
+
2048
+ const nextMetadata: RuntimeTaskMetadata = {
2049
+ ...retriedMetadata,
2050
+ ownerSessionID: newSessionID,
2051
+ ownerSessionTitle: \`Execute task \${metadata.taskID} (retry \${nextAttempt})\`,
2052
+ }
2053
+
2054
+ trackedBySession.delete(sessionID ?? "")
2055
+ trackedBySession.set(newSessionID, {
2056
+ metadata: nextMetadata,
2057
+ startedAtMs: Date.now(),
2058
+ lastEventAtMs: Date.now(),
2059
+ })
2060
+
2061
+ writeTaskContract(directory, nextMetadata)
2062
+ writeMetadata(runtimePath, nextMetadata)
2063
+ writeMetadata(sessionRuntimePath(directory, nextMetadata, newSessionID), nextMetadata)
2064
+ }
2065
+
2066
+ export default (async ({ directory, client }: { directory: string; client?: any }) => {
2067
+ const config = loadJuninhoConfig(directory)
2068
+ const watchdogEnabled = config.workflow?.implement?.watchdogSessionStale !== false
2069
+ const pendingByParent = new Map<string, RuntimeTaskMetadata[]>()
2070
+ const trackedBySession = new Map<string, TrackedSession>()
2071
+
2072
+ async function runWatchdogSweep(): Promise<void> {
2073
+ if (!watchdogEnabled || !client?.session?.status || !client?.session?.create || !client?.session?.promptAsync) return
2074
+ if (trackedBySession.size === 0) return
2075
+
2076
+ const statusMap = await readSessionStatuses(client, directory)
2077
+ for (const tracked of Array.from(trackedBySession.values())) {
2078
+ await maybeRetryTrackedSession(client, directory, tracked, statusMap, trackedBySession)
2079
+ }
2080
+ }
2081
+
2082
+ if (watchdogEnabled && client?.session?.status && client?.session?.create && client?.session?.promptAsync) {
2083
+ const interval = setInterval(() => {
2084
+ void runWatchdogSweep()
2085
+ }, WATCHDOG_POLL_MS)
2086
+ interval.unref?.()
2087
+ }
2088
+
2089
+ return {
2090
+ "tool.execute.before": async (
2091
+ input: { tool: string; sessionID: string },
2092
+ output: { args: Record<string, unknown> }
2093
+ ) => {
2094
+ if (input.tool !== "Task" && input.tool !== "task") return
2095
+
2096
+ const prompt = typeof output.args?.prompt === "string" ? output.args.prompt : ""
2097
+ const metadata = buildMetadata(directory, input.sessionID, prompt, output.args)
2098
+ if (!metadata) return
2099
+
2100
+ writeTaskContract(directory, metadata)
2101
+ const retryPath = absoluteFromWorkspace(directory, metadata.retryStatePath)
2102
+ if (!existsSync(retryPath)) {
2103
+ writeRetryState(retryPath, readRetryState(retryPath, metadata.taskID, metadata.attempt))
2104
+ }
2105
+
2106
+ const queue = pendingByParent.get(input.sessionID) ?? []
2107
+ queue.push(metadata)
2108
+ pendingByParent.set(input.sessionID, queue)
2109
+ },
2110
+
2111
+ event: async ({ event }: { event: { type: string; properties?: Record<string, unknown> } }) => {
2112
+ if (event.type === "session.created") {
2113
+ const sessionID = typeof event.properties?.sessionID === "string" ? event.properties.sessionID : undefined
2114
+ const info = typeof event.properties?.info === "object" && event.properties.info
2115
+ ? (event.properties.info as Record<string, unknown>)
2116
+ : undefined
2117
+ const parentID = typeof info?.parentID === "string" ? info.parentID : undefined
2118
+ const title = typeof info?.title === "string" ? info.title : ""
2119
+ if (!sessionID || !parentID) return
2120
+
2121
+ const queue = pendingByParent.get(parentID)
2122
+ if (!queue || queue.length === 0) return
2123
+
2124
+ const titleTaskID = extractTaskID(title)
2125
+ const index = titleTaskID ? queue.findIndex((item) => item.taskID === titleTaskID) : 0
2126
+ const resolvedIndex = index >= 0 ? index : 0
2127
+ const [metadata] = queue.splice(resolvedIndex, 1)
2128
+ if (!metadata) return
2129
+
2130
+ if (queue.length > 0) pendingByParent.set(parentID, queue)
2131
+ else pendingByParent.delete(parentID)
2132
+
2133
+ const resolvedMetadata: RuntimeTaskMetadata = {
2134
+ ...metadata,
2135
+ ownerSessionID: sessionID,
2136
+ ownerSessionTitle: title || undefined,
2137
+ }
2138
+
2139
+ writeMetadata(absoluteFromWorkspace(directory, resolvedMetadata.runtimePath), resolvedMetadata)
2140
+ writeMetadata(sessionRuntimePath(directory, resolvedMetadata, sessionID), resolvedMetadata)
2141
+ writeTaskContract(directory, resolvedMetadata)
2142
+ trackedBySession.set(sessionID, {
2143
+ metadata: resolvedMetadata,
2144
+ startedAtMs: Date.now(),
2145
+ lastEventAtMs: Date.now(),
2146
+ })
2147
+ return
2148
+ }
2149
+
2150
+ if (event.type === "session.deleted") {
2151
+ const sessionID = typeof event.properties?.sessionID === "string" ? event.properties.sessionID : undefined
2152
+ if (sessionID) trackedBySession.delete(sessionID)
2153
+ return
2154
+ }
2155
+
2156
+ if (event.type !== "session.status" && event.type !== "session.idle") return
2157
+
2158
+ const sessionID = typeof event.properties?.sessionID === "string" ? event.properties.sessionID : undefined
2159
+ if (!sessionID) return
2160
+ const tracked = trackedBySession.get(sessionID)
2161
+ if (!tracked) return
2162
+
2163
+ tracked.lastEventAtMs = Date.now()
2164
+ await runWatchdogSweep()
2165
+ },
2166
+ }
2167
+ }) satisfies Plugin
2168
+ `;
2169
+ // ─── Auto Format (disabled/optional) ────────────────────────────────────────
2170
+ const AUTO_FORMAT = `import type { Plugin } from "@opencode-ai/plugin"
2171
+ import { execSync } from "child_process"
2172
+ import path from "path"
2173
+
2174
+ // Auto-formats files after Write/Edit tool calls.
2175
+ // Real API: tool.execute.after(input, output) — input.args has the file path.
2176
+
2177
+ const FORMATTERS: Record<string, string> = {
2178
+ ".ts": "prettier --write",
2179
+ ".tsx": "prettier --write",
2180
+ ".js": "prettier --write",
2181
+ ".jsx": "prettier --write",
2182
+ ".json": "prettier --write",
2183
+ ".css": "prettier --write",
2184
+ ".scss": "prettier --write",
2185
+ ".md": "prettier --write",
2186
+ ".py": "black",
2187
+ ".go": "gofmt -w",
2188
+ ".rs": "rustfmt",
2189
+ }
2190
+
2191
+ export default (async ({ directory: _directory }: { directory: string }) => ({
2192
+ "tool.execute.after": async (
2193
+ input: { tool: string; sessionID: string; callID: string; args: any },
2194
+ _output: { title: string; output: string; metadata: any }
2195
+ ) => {
2196
+ if (!["Write", "Edit", "MultiEdit"].includes(input.tool)) return
2197
+
2198
+ const filePath: string = input.args?.path ?? input.args?.file_path ?? ""
2199
+ if (!filePath) return
2200
+
2201
+ const formatter = FORMATTERS[path.extname(filePath)]
2202
+ if (!formatter) return
2203
+
2204
+ try {
2205
+ execSync(\`\${formatter} "\${filePath}"\`, { stdio: "ignore" })
2206
+ } catch {
2207
+ // Formatter not available — skip silently
2208
+ }
2209
+ },
2210
+ })) satisfies Plugin
2211
+ `;
2212
+ // ─── Task Board (disabled/optional) ──────────────────────────────────────────
2213
+ const TASK_BOARD = `import type { Plugin } from "@opencode-ai/plugin"
2214
+ import { existsSync, readFileSync } from "fs"
2215
+ import path from "path"
2216
+ import { featureStateManifestPath, featureStateTaskPaths } from "./j.feature-state-paths"
2217
+ import { resolveStateFile } from "./j.state-paths"
2218
+
2219
+ type TaskBoardRow = {
2220
+ id: string
2221
+ name: string
2222
+ wave: string
2223
+ depends: string
2224
+ status: string
2225
+ attempt: string
2226
+ heartbeat: string
2227
+ retryCount: string
2228
+ validatedCommit: string
2229
+ featureCommit: string
2230
+ integrationStatus: string
2231
+ }
2232
+
2233
+ function getActiveFeatureSlug(directory: string): string | null {
2234
+ const statePath = resolveStateFile(directory, "execution-state.md")
2235
+ if (!existsSync(statePath)) return null
2236
+
2237
+ const content = readFileSync(statePath, "utf-8")
2238
+ return content.match(/\\*\\*Feature slug\\*\\*:\\s*(?:\\\`)?([^\\\`\\s]+)/)?.[1] ?? null
2239
+ }
2240
+
2241
+ function parsePlan(planPath: string): Array<{ id: string; name: string; wave: string; depends: string }> {
2242
+ if (!existsSync(planPath)) return []
2243
+ const content = readFileSync(planPath, "utf-8")
2244
+ const tasks = Array.from(content.matchAll(/<task id="([^"]+)" wave="([^"]+)" agent="[^"]+" depends="([^"]*)">[\\s\\S]*?<\\/task>/g))
2245
+
2246
+ return tasks.map((match) => ({
2247
+ id: match[1],
2248
+ wave: match[2],
2249
+ depends: match[3] || "-",
2250
+ name: match[4].match(/<n>[\\s\\S]*?<\\/n>/)?.[1]?.trim() ?? "Task " + match[1],
2251
+ }))
2252
+ }
2253
+
2254
+ function readStateValue(content: string, label: string): string {
2255
+ return content.match(new RegExp("- \\\\*\\\\*" + label + "\\\\*\\\\*:\\\\s*([^\\\\n]+)"))?.[1]?.trim() ?? "-"
2256
+ }
2257
+
2258
+ function readRetryCount(retryPath: string): string {
2259
+ if (!existsSync(retryPath)) return "0"
2260
+ try {
2261
+ const parsed = JSON.parse(readFileSync(retryPath, "utf-8")) as { autoRetryCount?: number }
2262
+ return typeof parsed.autoRetryCount === "number" ? String(parsed.autoRetryCount) : "0"
2263
+ } catch {
2264
+ return "0"
2265
+ }
2266
+ }
2267
+
2268
+ function buildBoard(directory: string): string | null {
2269
+ const slug = getActiveFeatureSlug(directory)
2270
+ if (!slug) return null
2271
+
2272
+ const featureDir = path.join(directory, "docs", "specs", slug)
2273
+ const planPath = path.join(featureDir, "plan.md")
2274
+ const integrationPath = featureStateManifestPath(directory, slug)
2275
+ if (!existsSync(planPath)) return null
2276
+
2277
+ const planTasks = parsePlan(planPath)
2278
+ if (planTasks.length === 0) return null
2279
+
2280
+ let integrationManifest: { tasks?: Record<string, any> } | null = null
2281
+ if (existsSync(integrationPath)) {
2282
+ try {
2283
+ integrationManifest = JSON.parse(readFileSync(integrationPath, "utf-8")) as { tasks?: Record<string, any> }
2284
+ } catch {
2285
+ integrationManifest = null
2286
+ }
2287
+ }
2288
+
2289
+ const rows: TaskBoardRow[] = planTasks.map((task) => {
2290
+ const taskPaths = featureStateTaskPaths(directory, slug, task.id)
2291
+ const content = existsSync(taskPaths.statePath) ? readFileSync(taskPaths.statePath, "utf-8") : ""
2292
+ const integrationEntry = integrationManifest?.tasks?.[task.id]
2293
+
2294
+ return {
2295
+ id: task.id,
2296
+ name: task.name,
2297
+ wave: task.wave,
2298
+ depends: task.depends,
2299
+ status: content ? readStateValue(content, "Status") : "PENDING",
2300
+ attempt: content ? readStateValue(content, "Attempt") : "-",
2301
+ heartbeat: content ? readStateValue(content, "Last heartbeat") : "-",
2302
+ retryCount: readRetryCount(taskPaths.retryStatePath),
2303
+ validatedCommit: integrationEntry?.validatedCommit ?? "-",
2304
+ featureCommit: integrationEntry?.integration?.integratedCommit ?? "-",
2305
+ integrationStatus: integrationEntry?.integration?.method
2306
+ ? String(integrationEntry.integration.status ?? "pending") + "/" + String(integrationEntry.integration.method)
2307
+ : integrationEntry?.integration?.status ?? "pending",
2308
+ }
2309
+ })
2310
+
2311
+ return [
2312
+ "[task-board] Feature: " + slug,
2313
+ "",
2314
+ "| ID | Wave | Depends | Status | Attempt | Retries | Validated Commit | Feature Commit | Integration | Heartbeat | Task |",
2315
+ "|----|------|---------|--------|---------|---------|------------------|----------------|-------------|-----------|------|",
2316
+ ...rows.map((row) =>
2317
+ "| " + row.id + " | " + row.wave + " | " + row.depends + " | " + row.status + " | " + row.attempt + " | " + row.retryCount + " | " + row.validatedCommit + " | " + row.featureCommit + " | " + row.integrationStatus + " | " + row.heartbeat + " | " + row.name + " |"
2318
+ ),
2319
+ ].join("\\n")
2320
+ }
2321
+
2322
+ export default (async ({ directory }: { directory: string }) => {
2323
+ const lastBoardBySession = new Map<string, string>()
2324
+
2325
+ return {
2326
+ "tool.execute.after": async (
2327
+ input: { tool: string; sessionID: string; callID: string; args: any },
2328
+ output: { title: string; output: string; metadata: any }
2329
+ ) => {
2330
+ const board = buildBoard(directory)
2331
+ if (!board) return
2332
+ if (lastBoardBySession.get(input.sessionID) === board) return
2333
+
2334
+ lastBoardBySession.set(input.sessionID, board)
2335
+ output.output += "\\n\\n" + board
2336
+ },
2337
+ "experimental.session.compacting": async (
2338
+ _input: { sessionID?: string },
2339
+ output: { context: string[] }
2340
+ ) => {
2341
+ const board = buildBoard(directory)
2342
+ if (!board) return
2343
+
2344
+ output.context.push(board)
2345
+ },
2346
+ }
2347
+ }) satisfies Plugin
2348
+ `;
2349
+ // ─── Notify (disabled/optional) ──────────────────────────────────────────────
2350
+ const NOTIFY = `import type { Plugin } from "@opencode-ai/plugin"
2351
+ import { execFileSync } from "child_process"
2352
+ import { platform } from "os"
2353
+
2354
+ const TITLE = "opencode"
2355
+
2356
+ function escapeAppleScript(value: string): string {
2357
+ return value.replace(/\\\\/g, "\\\\\\\\").replace(/\\"/g, '\\\\\\"')
2358
+ }
2359
+
2360
+ function sendNotification(message: string): void {
2361
+ try {
2362
+ const os = platform()
2363
+ if (os === "darwin") {
2364
+ const script = 'display notification "' + escapeAppleScript(message) + '" with title "' + TITLE + '" sound name "Glass"'
2365
+ execFileSync("osascript", ["-e", script], {
2366
+ stdio: "ignore",
2367
+ timeout: 5000,
2368
+ })
2369
+ return
2370
+ }
2371
+ if (os === "linux") {
2372
+ execFileSync("notify-send", [TITLE, message, "--expire-time=5000"], {
2373
+ stdio: "ignore",
2374
+ timeout: 5000,
2375
+ })
2376
+ }
2377
+ } catch {
2378
+ // Never block the session on notification failures.
2379
+ }
2380
+ }
2381
+
2382
+ export default (async (_ctx: { directory: string }) => ({
2383
+ "session.idle": async (_input: Record<string, unknown>, output: { metadata?: Record<string, unknown> }) => {
2384
+ const reason = typeof output.metadata?.reason === "string" ? output.metadata.reason : "idle session detected"
2385
+ sendNotification(reason)
2386
+ },
2387
+ })) satisfies Plugin
837
2388
  `;
838
- // ─── Hashline Edit ────────────────────────────────────────────────────────────
839
- const HASHLINE_EDIT = `import type { Plugin } from "@opencode-ai/plugin"
840
- import { existsSync, readFileSync } from "fs"
841
- import crypto from "crypto"
842
-
843
- // Validates hashline references before Edit tool calls.
844
- // Throws an Error (aborts the edit) if referenced hashes are stale.
845
- // Uses tool.execute.before output.args has the edit arguments.
846
-
847
- function hashLine(line: string): string {
848
- return crypto.createHash("md5").update(line).digest("hex").slice(0, 2)
849
- }
850
-
851
- const HASHLINE_REF = /^(\\d{3})#([a-f0-9]{2}):/
852
-
853
- function extractHashlineRefs(text: string): Array<{ lineNum: number; hash: string }> {
854
- return text
855
- .split("\\n")
856
- .map((line) => {
857
- const match = HASHLINE_REF.exec(line)
858
- if (!match) return null
859
- return { lineNum: parseInt(match[1], 10), hash: match[2] }
860
- })
861
- .filter((r): r is { lineNum: number; hash: string } => r !== null)
862
- }
863
-
864
- export default (async ({ directory: _directory }: { directory: string }) => ({
865
- "tool.execute.before": async (
866
- input: { tool: string; sessionID: string; callID: string },
867
- output: { args: any }
868
- ) => {
869
- if (input.tool !== "Edit") return
870
-
871
- const filePath: string = output.args?.path ?? output.args?.file_path ?? ""
872
- const oldString: string = output.args?.old_string ?? ""
873
-
874
- if (!filePath || !oldString || !existsSync(filePath)) return
875
-
876
- const refs = extractHashlineRefs(oldString)
877
- if (refs.length === 0) return
878
-
879
- const currentLines = readFileSync(filePath, "utf-8").split("\\n")
880
-
881
- for (const ref of refs) {
882
- const lineIndex = ref.lineNum - 1
883
- if (lineIndex >= currentLines.length) {
884
- throw new Error(
885
- \`[hashline-edit] Stale reference: line \${ref.lineNum} no longer exists in \${filePath}.\\n\` +
886
- \`Re-read the file to get current hashlines.\`
887
- )
888
- }
889
-
890
- const currentHash = hashLine(currentLines[lineIndex])
891
- if (currentHash !== ref.hash) {
892
- throw new Error(
893
- \`[hashline-edit] Stale reference at line \${ref.lineNum}: expected hash \${ref.hash}, got \${currentHash}.\\n\` +
894
- \`Re-read the file to get current hashlines.\`
895
- )
896
- }
897
- }
898
- },
899
- })) satisfies Plugin
2389
+ // ─── Todo Enforcer (disabled/optional) ───────────────────────────────────────
2390
+ const TODO_ENFORCER = `import type { Plugin } from "@opencode-ai/plugin"
2391
+ import { existsSync, readFileSync, readdirSync } from "fs"
2392
+ import path from "path"
2393
+ import { featureStateTaskDir } from "./j.feature-state-paths"
2394
+ import { resolveStateFile } from "./j.state-paths"
2395
+
2396
+ // Re-injects incomplete tasks to prevent the agent from forgetting pending work.
2397
+ // Three sources of truth (checked in order):
2398
+ // 1. .opencode/state/execution-state.md global session summary
2399
+ // 2. docs/specs/{slug}/state/tasks/task-*/execution-state.md — per-task state files
2400
+ //
2401
+ // Two hooks:
2402
+ // experimental.session.compacting injects pending tasks into compaction
2403
+ // context so they survive context window resets.
2404
+ // tool.execute.after on Write/Edit lean reminder of pending count after
2405
+ // file modifications, nudging the agent to continue.
2406
+
2407
+ function getIncompleteFromFile(filePath: string): string[] {
2408
+ if (!existsSync(filePath)) return []
2409
+ const content = readFileSync(filePath, "utf-8")
2410
+ return content
2411
+ .split("\\n")
2412
+ .filter((line) => /^\\s*-\\s*\\[\\s*\\]/.test(line))
2413
+ .map((line) => line.trim())
2414
+ }
2415
+
2416
+ function parseTaskState(filePath: string): string | null {
2417
+ if (!existsSync(filePath)) return null
2418
+
2419
+ const content = readFileSync(filePath, "utf-8")
2420
+ const statusMatch = content.match(/- \*\*Status\*\*:\s*([^\n]+)/)
2421
+ const waveMatch = content.match(/- \*\*Wave\*\*:\s*([^\n]+)/)
2422
+ const attemptMatch = content.match(/- \*\*Attempt\*\*:\s*([^\n]+)/)
2423
+ const heartbeatMatch = content.match(/- \*\*Last heartbeat\*\*:\s*([^\n]+)/)
2424
+ const failureMatch = content.match(/## Failure Details \(if FAILED\/BLOCKED\)\n([\s\S]*)$/)
2425
+ const fileNameMatch = filePath.match(/tasks\/task-(\d+)\/execution-state\.md$/)
2426
+
2427
+ const taskID = fileNameMatch?.[1] ?? "?"
2428
+ const status = statusMatch?.[1]?.trim()
2429
+ if (!status || status === "COMPLETE") return null
2430
+
2431
+ const wave = waveMatch?.[1]?.trim() ?? "?"
2432
+ const attempt = attemptMatch?.[1]?.trim() ?? "1"
2433
+ const heartbeat = heartbeatMatch?.[1]?.trim()
2434
+ const failure = failureMatch?.[1]?.trim()
2435
+
2436
+ let summary = "- [ ] Task " + taskID + " (wave " + wave + ", attempt " + attempt + ") — " + status
2437
+ if (heartbeat) summary += " heartbeat " + heartbeat
2438
+ if (status === "FAILED" || status === "BLOCKED") {
2439
+ const detail = failure && failure !== "None." ? failure.split("\\n")[0].trim() : "see task state"
2440
+ summary += " — " + detail
2441
+ }
2442
+
2443
+ return summary
2444
+ }
2445
+
2446
+ function getActiveFeatureSlug(directory: string): string | null {
2447
+ const statePath = resolveStateFile(directory, "execution-state.md")
2448
+ if (!existsSync(statePath)) return null
2449
+
2450
+ const content = readFileSync(statePath, "utf-8")
2451
+ const planMatch = content.match(/\*\*Plan\*\*:\s*(?:\`)?(?:docs\/specs\/([^/\`\s]+)\/plan\.md)/)
2452
+ if (planMatch) return planMatch[1]
2453
+
2454
+ const slugMatch = content.match(/\*\*Feature slug\*\*:\s*(?:\`)?([^\`\s]+)/)
2455
+ if (slugMatch) return slugMatch[1]
2456
+
2457
+ return null
2458
+ }
2459
+
2460
+ function getPerTaskIncomplete(directory: string, slug: string): string[] {
2461
+ const tasksDir = path.join(directory, "docs", "specs", slug, "state", "tasks")
2462
+ if (!existsSync(tasksDir)) return []
2463
+
2464
+ const tasks: string[] = []
2465
+ try {
2466
+ const taskDirs = readdirSync(tasksDir).filter((f) => f.startsWith("task-"))
2467
+ for (const taskDirName of taskDirs) {
2468
+ const taskDir = featureStateTaskDir(directory, slug, taskDirName.replace(/^task-/, ""))
2469
+ const summary = parseTaskState(path.join(taskDir, "execution-state.md"))
2470
+ if (summary) tasks.push(summary)
2471
+ }
2472
+ } catch {
2473
+ // Directory read failed — silently skip
2474
+ }
2475
+ return tasks
2476
+ }
2477
+
2478
+ function getIncompleteTasks(directory: string): string[] {
2479
+ const globalPath = resolveStateFile(directory, "execution-state.md")
2480
+ const globalTasks = getIncompleteFromFile(globalPath)
2481
+
2482
+ const slug = getActiveFeatureSlug(directory)
2483
+ const perTaskTasks = slug ? getPerTaskIncomplete(directory, slug) : []
2484
+
2485
+ const seen = new Set<string>()
2486
+ const all: string[] = []
2487
+ for (const task of [...globalTasks, ...perTaskTasks]) {
2488
+ if (!seen.has(task)) {
2489
+ seen.add(task)
2490
+ all.push(task)
2491
+ }
2492
+ }
2493
+ return all
2494
+ }
2495
+
2496
+ export default (async ({ directory }: { directory: string }) => ({
2497
+ "experimental.session.compacting": async (
2498
+ _input: Record<string, unknown>,
2499
+ output: { context: string[]; prompt?: string }
2500
+ ) => {
2501
+ const incomplete = getIncompleteTasks(directory)
2502
+ if (incomplete.length === 0) return
2503
+
2504
+ output.context.push(
2505
+ \`[todo-enforcer] \${incomplete.length} incomplete task(s) remaining:\\n\\n\` +
2506
+ incomplete.join("\\n") +
2507
+ \`\\n\\nDo not stop until all tasks are complete. Continue working.\`
2508
+ )
2509
+ },
2510
+ "tool.execute.after": async (
2511
+ input: { tool: string; sessionID: string; callID: string; args: any },
2512
+ output: { title: string; output: string; metadata: any }
2513
+ ) => {
2514
+ if (!["Write", "Edit", "MultiEdit"].includes(input.tool)) return
2515
+
2516
+ const incomplete = getIncompleteTasks(directory)
2517
+ if (incomplete.length === 0) return
2518
+
2519
+ output.output +=
2520
+ \`\\n\\n[todo-enforcer] \${incomplete.length} task(s) still pending. Continue working.\`
2521
+ },
2522
+ })) satisfies Plugin
900
2523
  `;
901
- // ─── Directory Agents Injector ────────────────────────────────────────────────
902
- const DIR_AGENTS_INJECTOR = `import type { Plugin } from "@opencode-ai/plugin"
903
- import { existsSync, readFileSync } from "fs"
904
- import path from "path"
905
-
906
- // Tier 1 context mechanism — hierarchical AGENTS.md injection.
907
- // When an agent reads a file, walks the directory tree from the file's location
908
- // to the project root and appends every AGENTS.md found to the Read output.
909
- // Injects from root → most specific (additive, layered context).
910
- // Uses tool.execute.after on Read — appends to output.output.
911
-
912
- function findAgentsMdFiles(filePath: string, projectRoot: string): string[] {
913
- const result: string[] = []
914
- let current = path.dirname(filePath)
915
-
916
- // Walk up to project root (exclusive — root AGENTS.md is auto-loaded by OpenCode)
917
- while (current !== projectRoot && current !== path.dirname(current)) {
918
- const agentsMd = path.join(current, "AGENTS.md")
919
- if (existsSync(agentsMd)) {
920
- result.unshift(agentsMd) // prepend for root → specific order
921
- }
922
- current = path.dirname(current)
923
- }
924
-
925
- return result
926
- }
927
-
928
- export default (async ({ directory }: { directory: string }) => {
929
- const injectedPaths = new Set<string>()
930
-
931
- return {
932
- "tool.execute.after": async (
933
- input: { tool: string; sessionID: string; callID: string; args: any },
934
- output: { title: string; output: string; metadata: any }
935
- ) => {
936
- if (input.tool !== "Read") return
937
-
938
- const filePath: string = input.args?.path ?? input.args?.file_path ?? ""
939
- if (!filePath || !filePath.startsWith(directory)) return
940
-
941
- const agentsMdFiles = findAgentsMdFiles(filePath, directory)
942
- const toInject: string[] = []
943
-
944
- for (const agentsPath of agentsMdFiles) {
945
- if (injectedPaths.has(agentsPath)) continue
946
- injectedPaths.add(agentsPath)
947
-
948
- const content = readFileSync(agentsPath, "utf-8")
949
- const relPath = path.relative(directory, agentsPath)
950
- toInject.push(\`[directory-agents-injector] Context from \${relPath}:\\n\\n\${content}\`)
951
- }
952
-
953
- if (toInject.length > 0) {
954
- output.output += "\\n\\n" + toInject.join("\\n\\n---\\n\\n")
955
- }
956
- },
957
- }
958
- }) satisfies Plugin
2524
+ // ─── Comment Checker (disabled/optional) ─────────────────────────────────────
2525
+ const COMMENT_CHECKER = `import type { Plugin } from "@opencode-ai/plugin"
2526
+
2527
+ // Detects obvious/redundant comments after Write/Edit and appends a reminder.
2528
+ // Uses tool.execute.after — appends to output.output so agent sees the warning.
2529
+
2530
+ const OBVIOUS_PATTERNS = [
2531
+ /\\/\\/ increment .*/i,
2532
+ /\\/\\/ set .* to/i,
2533
+ /\\/\\/ return .*/i,
2534
+ /\\/\\/ call .*/i,
2535
+ /\\/\\/ create .* variable/i,
2536
+ /\\/\\/ check if/i,
2537
+ /\\/\\/ loop (through|over|for)/i,
2538
+ /\\/\\/ define function/i,
2539
+ /\\/\\/ initialize/i,
2540
+ /\\/\\/ assign/i,
2541
+ ]
2542
+
2543
+ const IGNORE_PATTERNS = [
2544
+ /\\/\\/\\s*@ts-/,
2545
+ /\\/\\/\\s*eslint/,
2546
+ /\\/\\/\\s*TODO/i,
2547
+ /\\/\\/\\s*FIXME/i,
2548
+ /\\/\\/\\s*HACK/i,
2549
+ /\\/\\/\\s*NOTE:/i,
2550
+ /\\/\\/\\s*BUG:/i,
2551
+ /\\/\\*\\*/,
2552
+ /\\s*\\*\\s/,
2553
+ /given|when|then/i,
2554
+ /describe|it\\(/,
2555
+ ]
2556
+
2557
+ function hasObviousComments(content: string): string[] {
2558
+ const lines = content.split("\\n")
2559
+ const found: string[] = []
2560
+
2561
+ for (let i = 0; i < lines.length; i++) {
2562
+ const line = lines[i]
2563
+ if (IGNORE_PATTERNS.some((p) => p.test(line))) continue
2564
+ if (OBVIOUS_PATTERNS.some((p) => p.test(line))) {
2565
+ found.push(\`Line \${i + 1}: \${line.trim()}\`)
2566
+ }
2567
+ }
2568
+
2569
+ return found
2570
+ }
2571
+
2572
+ export default (async ({ directory: _directory }: { directory: string }) => ({
2573
+ "tool.execute.after": async (
2574
+ input: { tool: string; sessionID: string; callID: string; args: any },
2575
+ output: { title: string; output: string; metadata: any }
2576
+ ) => {
2577
+ if (!["Write", "Edit"].includes(input.tool)) return
2578
+
2579
+ const content: string = input.args?.content ?? input.args?.new_string ?? ""
2580
+ if (!content) return
2581
+
2582
+ const obvious = hasObviousComments(content)
2583
+ if (obvious.length === 0) return
2584
+
2585
+ output.output +=
2586
+ \`\\n\\n[comment-checker] \${obvious.length} potentially obvious comment(s) detected:\\n\` +
2587
+ obvious.slice(0, 3).join("\\n") +
2588
+ \`\\nConsider removing redundant comments — code should be self-documenting.\`
2589
+ },
2590
+ })) satisfies Plugin
2591
+ `;
2592
+ // ─── Hashline Read (disabled/optional) ───────────────────────────────────────
2593
+ const HASHLINE_READ = `import type { Plugin } from "@opencode-ai/plugin"
2594
+ import crypto from "crypto"
2595
+
2596
+ // Tags each line in Read output with NN#XX: prefix for stable hash references.
2597
+ // Agent uses these tags when editing — hashline-edit.ts validates them.
2598
+ // Uses tool.execute.after — sets output.output to the tagged version.
2599
+
2600
+ function hashLine(line: string): string {
2601
+ return crypto.createHash("md5").update(line).digest("hex").slice(0, 2)
2602
+ }
2603
+
2604
+ function addHashlines(content: string): string {
2605
+ return content
2606
+ .split("\\n")
2607
+ .map((line, i) => {
2608
+ const lineNum = String(i + 1).padStart(3, "0")
2609
+ const hash = hashLine(line)
2610
+ return \`\${lineNum}#\${hash}: \${line}\`
2611
+ })
2612
+ .join("\\n")
2613
+ }
2614
+
2615
+ export default (async ({ directory: _directory }: { directory: string }) => ({
2616
+ "tool.execute.after": async (
2617
+ input: { tool: string; sessionID: string; callID: string; args: any },
2618
+ output: { title: string; output: string; metadata: any }
2619
+ ) => {
2620
+ if (input.tool !== "Read") return
2621
+ if (typeof output.output !== "string") return
2622
+
2623
+ output.output = addHashlines(output.output)
2624
+ },
2625
+ })) satisfies Plugin
959
2626
  `;
960
- // ─── Memory (persistent-context injection) ────────────────────────────────────
961
- const MEMORY = `import type { Plugin } from "@opencode-ai/plugin"
962
- import { existsSync, readFileSync } from "fs"
963
- import path from "path"
964
-
965
- // Injects persistent-context.md (cross-session repo memory, like OpenClaw).
966
- // This file is written by UNIFY and contains project conventions, decisions,
967
- // and patterns accumulated across sessions.
968
- // Two hooks:
969
- // tool.execute.after injects on the FIRST tool call of a session so the
970
- // agent has repo memory from the very beginning.
971
- // experimental.session.compacting — re-injects during compaction so memory
972
- // survives context window resets.
973
-
974
- function loadMemory(directory: string): string | null {
975
- const memoryPath = path.join(directory, ".opencode", "state", "persistent-context.md")
976
- if (!existsSync(memoryPath)) return null
977
-
978
- const content = readFileSync(memoryPath, "utf-8").trim()
979
- if (!content) return null
980
-
981
- return content
982
- }
983
-
984
- export default (async ({ directory }: { directory: string }) => {
985
- const injectedSessions = new Set<string>()
986
-
987
- return {
988
- "tool.execute.after": async (
989
- input: { tool: string; sessionID: string; callID: string; args: any },
990
- output: { title: string; output: string; metadata: any }
991
- ) => {
992
- // Fire once per session — first tool call triggers injection
993
- if (injectedSessions.has(input.sessionID)) return
994
- injectedSessions.add(input.sessionID)
995
-
996
- const memory = loadMemory(directory)
997
- if (!memory) return
998
-
999
- output.output +=
1000
- \`\\n\\n[memory] Project memory (persistent-context):\\n\\n\${memory}\`
1001
- },
1002
- "experimental.session.compacting": async (
1003
- _input: Record<string, unknown>,
1004
- output: { context: string[]; prompt?: string }
1005
- ) => {
1006
- const memory = loadMemory(directory)
1007
- if (!memory) return
1008
-
1009
- output.context.push(
1010
- \`[memory] Project memory (persistent-context):\\n\\n\${memory}\`
1011
- )
1012
- },
1013
- }
1014
- }) satisfies Plugin
2627
+ // ─── Hashline Edit (disabled/optional) ───────────────────────────────────────
2628
+ const HASHLINE_EDIT = `import type { Plugin } from "@opencode-ai/plugin"
2629
+ import { existsSync, readFileSync } from "fs"
2630
+ import crypto from "crypto"
2631
+
2632
+ // Validates hashline references before Edit tool calls.
2633
+ // Throws an Error (aborts the edit) if referenced hashes are stale.
2634
+ // Uses tool.execute.before output.args has the edit arguments.
2635
+
2636
+ function hashLine(line: string): string {
2637
+ return crypto.createHash("md5").update(line).digest("hex").slice(0, 2)
2638
+ }
2639
+
2640
+ const HASHLINE_REF = /^(\\d{3})#([a-f0-9]{2}):/
2641
+
2642
+ function extractHashlineRefs(text: string): Array<{ lineNum: number; hash: string }> {
2643
+ return text
2644
+ .split("\\n")
2645
+ .map((line) => {
2646
+ const match = HASHLINE_REF.exec(line)
2647
+ if (!match) return null
2648
+ return { lineNum: parseInt(match[1], 10), hash: match[2] }
2649
+ })
2650
+ .filter((r): r is { lineNum: number; hash: string } => r !== null)
2651
+ }
2652
+
2653
+ export default (async ({ directory: _directory }: { directory: string }) => ({
2654
+ "tool.execute.before": async (
2655
+ input: { tool: string; sessionID: string; callID: string },
2656
+ output: { args: any }
2657
+ ) => {
2658
+ if (input.tool !== "Edit") return
2659
+
2660
+ const filePath: string = output.args?.path ?? output.args?.file_path ?? ""
2661
+ const oldString: string = output.args?.old_string ?? ""
2662
+
2663
+ if (!filePath || !oldString || !existsSync(filePath)) return
2664
+
2665
+ const refs = extractHashlineRefs(oldString)
2666
+ if (refs.length === 0) return
2667
+
2668
+ const currentLines = readFileSync(filePath, "utf-8").split("\\n")
2669
+
2670
+ for (const ref of refs) {
2671
+ const lineIndex = ref.lineNum - 1
2672
+ if (lineIndex >= currentLines.length) {
2673
+ throw new Error(
2674
+ \`[hashline-edit] Stale reference: line \${ref.lineNum} no longer exists in \${filePath}.\\n\` +
2675
+ \`Re-read the file to get current hashlines.\`
2676
+ )
2677
+ }
2678
+
2679
+ const currentHash = hashLine(currentLines[lineIndex])
2680
+ if (currentHash !== ref.hash) {
2681
+ throw new Error(
2682
+ \`[hashline-edit] Stale reference at line \${ref.lineNum}: expected hash \${ref.hash}, got \${currentHash}.\\n\` +
2683
+ \`Re-read the file to get current hashlines.\`
2684
+ )
2685
+ }
2686
+ }
2687
+ },
2688
+ })) satisfies Plugin
1015
2689
  `;
1016
2690
  //# sourceMappingURL=plugins.js.map