@kleber.mottajr/juninho 2.0.0 → 2.0.2

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.
@@ -11,9 +11,6 @@ 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.state-paths.ts"), STATE_PATHS);
15
- (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.feature-state-paths.ts"), FEATURE_STATE_PATHS);
16
- (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.juninho-config.ts"), JUNINHO_CONFIG);
17
14
  (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.task-runtime.ts"), TASK_RUNTIME);
18
15
  (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.task-board.ts"), TASK_BOARD);
19
16
  (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.notify.ts"), NOTIFY);
@@ -29,949 +26,993 @@ function writePlugins(projectDir, projectType = "node-nextjs", isKotlin = false)
29
26
  // Write initial skill-map.json for dynamic extension by /j.finish-setup
30
27
  (0, fs_1.writeFileSync)(path_1.default.join(projectDir, ".opencode", "skill-map.json"), JSON.stringify(getBaseSkillMap(projectType, isKotlin), null, 2) + "\n");
31
28
  }
32
- const STATE_PATHS = `import path from "path"
33
-
34
- export function resolveStateFile(directory: string, filename: string): string {
35
- return path.join(directory, ".opencode", "state", filename)
36
- }
29
+ // ─── Env Protection ──────────────────────────────────────────────────────────
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
37
61
  `;
38
- const FEATURE_STATE_PATHS = `import { mkdirSync } from "fs"
62
+ // ─── Plan Autoload (evolved) ─────────────────────────────────────────────────
63
+ const PLAN_AUTOLOAD = `import type { Plugin } from "@opencode-ai/plugin"
64
+ import { existsSync, readFileSync } from "fs"
39
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"
40
68
 
41
- export function featureStateDir(directory: string, featureSlug: string): string {
42
- return path.join(directory, "docs", "specs", featureSlug, "state")
43
- }
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
44
201
 
45
- export function featureStateTaskDir(directory: string, featureSlug: string, taskID: string): string {
46
- return path.join(featureStateDir(directory, featureSlug), "tasks", "task-" + taskID)
202
+ output.context.push(renderPlan(loaded.planPath, loaded.planContent, loaded.specPath, loaded.contextPath, loaded.targets, loaded.referenceProjects))
203
+ },
204
+ }
205
+ }) satisfies Plugin
206
+ `;
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
47
227
  }
48
228
 
49
- export function featureStateSessionsDir(directory: string, featureSlug: string): string {
50
- return path.join(featureStateDir(directory, featureSlug), "sessions")
229
+ interface DomainEntry {
230
+ domain: string
231
+ keywords: string[]
232
+ files: Array<{ path: string; description: string }>
51
233
  }
52
234
 
53
- export function ensureFeatureStateStructure(directory: string, featureSlug: string): void {
54
- mkdirSync(featureStateDir(directory, featureSlug), { recursive: true })
55
- mkdirSync(path.join(featureStateDir(directory, featureSlug), "tasks"), { recursive: true })
56
- mkdirSync(featureStateSessionsDir(directory, featureSlug), { recursive: true })
235
+ interface CollectedEntry {
236
+ content: string
237
+ priority: number
238
+ type: "principle" | "domain"
239
+ label: string
57
240
  }
58
241
 
59
- export function featureStateTaskPaths(directory: string, featureSlug: string, taskID: string) {
60
- const taskDir = featureStateTaskDir(directory, featureSlug, taskID)
61
- return {
62
- taskDir,
63
- statePath: path.join(taskDir, "execution-state.md"),
64
- retryStatePath: path.join(taskDir, "retry-state.json"),
65
- runtimePath: path.join(taskDir, "runtime.json"),
66
- validatorPath: path.join(taskDir, "validator-work.md"),
67
- }
242
+ interface RuntimeTaskMetadata {
243
+ featureSlug?: string
244
+ taskID?: string
245
+ planPath?: string
246
+ targetRepoRoot?: string
247
+ originalPrompt?: string
68
248
  }
69
249
 
70
- export function featureStateSessionRuntimePath(directory: string, featureSlug: string, sessionID: string): string {
71
- return path.join(featureStateSessionsDir(directory, featureSlug), sessionID + "-runtime.json")
250
+ interface TaskPlanContext {
251
+ taskID: string
252
+ files: string[]
253
+ action: string
254
+ verify: string
255
+ done: string
72
256
  }
73
257
 
74
- export function featureStateImplementerLogPath(directory: string, featureSlug: string): string {
75
- return path.join(featureStateDir(directory, featureSlug), "implementer-work.md")
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
76
268
  }
77
269
 
78
- export function featureStateManifestPath(directory: string, featureSlug: string): string {
79
- return path.join(featureStateDir(directory, featureSlug), "integration-state.json")
270
+ interface TaskContractSeed {
271
+ featureSlug?: string
272
+ taskID?: string
273
+ planPath?: string
274
+ specPath?: string
275
+ contextPath?: string
276
+ taskContractPath?: string
277
+ targetRepoRoot?: string
80
278
  }
81
279
 
82
- export function featureStateReadmePath(directory: string, featureSlug: string): string {
83
- return path.join(featureStateDir(directory, featureSlug), "README.md")
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)
84
306
  }
85
- `;
86
- const JUNINHO_CONFIG = `import { existsSync, readFileSync } from "fs"
87
- import path from "path"
88
307
 
89
- export type JuninhoConfig = {
90
- strong?: string
91
- medium?: string
92
- weak?: string
93
- projectType?: string
94
- isKotlin?: boolean
95
- buildTool?: string
96
- workflow?: {
97
- automation?: {
98
- nonInteractive?: boolean
99
- autoApproveArtifacts?: boolean
100
- }
101
- implement?: {
102
- preCommitScope?: string
103
- postImplementFullCheck?: boolean
104
- reenterImplementOnFullCheckFailure?: boolean
105
- }
106
- unify?: {
107
- enabled?: boolean
108
- updatePersistentContext?: boolean
109
- updateDomainDocs?: boolean
110
- updateDomainIndex?: boolean
111
- cleanupIntegratedTaskBranches?: boolean
112
- createPullRequest?: boolean
113
- createDeliveryPrBody?: boolean
114
- }
115
- documentation?: {
116
- preferAgentsMdForLocalRules?: boolean
117
- preferDomainDocsForBusinessBehavior?: boolean
118
- preferPrincipleDocsForCrossCuttingTech?: boolean
119
- syncMarkers?: boolean
120
- }
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()
121
319
  }
122
- }
123
320
 
124
- const DEFAULT_CONFIG: JuninhoConfig = {
125
- workflow: {
126
- automation: {
127
- nonInteractive: false,
128
- autoApproveArtifacts: false,
129
- },
130
- implement: {
131
- preCommitScope: "related",
132
- postImplementFullCheck: true,
133
- reenterImplementOnFullCheckFailure: true,
134
- },
135
- unify: {
136
- enabled: true,
137
- updatePersistentContext: true,
138
- updateDomainDocs: true,
139
- updateDomainIndex: true,
140
- cleanupIntegratedTaskBranches: true,
141
- createPullRequest: true,
142
- createDeliveryPrBody: true,
143
- },
144
- documentation: {
145
- preferAgentsMdForLocalRules: true,
146
- preferDomainDocsForBusinessBehavior: true,
147
- preferPrincipleDocsForCrossCuttingTech: true,
148
- syncMarkers: true,
149
- },
150
- },
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
151
336
  }
152
337
 
153
- export function loadJuninhoConfig(directory: string): JuninhoConfig {
154
- const configPath = path.join(directory, ".opencode", "juninho-config.json")
155
- if (existsSync(configPath)) {
156
- try {
157
- const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as JuninhoConfig
158
- return {
159
- ...DEFAULT_CONFIG,
160
- ...parsed,
161
- workflow: {
162
- ...DEFAULT_CONFIG.workflow,
163
- ...parsed.workflow,
164
- automation: {
165
- ...DEFAULT_CONFIG.workflow?.automation,
166
- ...parsed.workflow?.automation,
167
- },
168
- implement: {
169
- ...DEFAULT_CONFIG.workflow?.implement,
170
- ...parsed.workflow?.implement,
171
- },
172
- unify: {
173
- ...DEFAULT_CONFIG.workflow?.unify,
174
- ...parsed.workflow?.unify,
175
- },
176
- documentation: {
177
- ...DEFAULT_CONFIG.workflow?.documentation,
178
- ...parsed.workflow?.documentation,
179
- },
180
- },
181
- }
182
- } catch {
183
- // Fall through to defaults.
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() ?? "" })
184
360
  }
361
+
362
+ entries.push({ domain, keywords, files })
185
363
  }
186
364
 
187
- return DEFAULT_CONFIG
365
+ return entries
188
366
  }
189
- `;
190
- const TASK_RUNTIME = `import type { Plugin } from "@opencode-ai/plugin"
191
- import { mkdirSync, writeFileSync } from "fs"
192
- import path from "path"
193
- import {
194
- ensureFeatureStateStructure,
195
- featureStateSessionRuntimePath,
196
- featureStateTaskPaths,
197
- } from "./j.feature-state-paths"
198
367
 
199
- type RuntimeTaskMetadata = {
200
- featureSlug: string
201
- taskID: string
202
- attempt: number
203
- statePath: string
204
- retryStatePath: string
205
- runtimePath: string
206
- worktreeDirectory?: string
207
- parentSessionID: string
208
- ownerSessionID?: string
209
- ownerSessionTitle?: string
210
- originalPrompt: string
368
+ function stripCodeBlocks(text: string): string {
369
+ let stripped = text.replace(/\\\`\\\`\\\`[\\s\\S]*?\\\`\\\`\\\`/g, "")
370
+ stripped = stripped.replace(/\\\`[^\\\`\\n]+\\\`/g, "")
371
+ return stripped
211
372
  }
212
373
 
213
- function extractFeatureSlug(prompt: string): string | null {
214
- return prompt.match(/docs\/specs\/([^/]+)\//)?.[1] ?? null
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
215
380
  }
216
381
 
217
- function extractTaskID(prompt: string): string | null {
218
- return prompt.match(/(?:Execute|Validate) task\s+(\d+)\b/i)?.[1] ?? null
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
219
391
  }
220
392
 
221
- function extractAttempt(prompt: string): number {
222
- const raw = prompt.match(/Attempt:\s*(\d+)/i)?.[1]
223
- return raw ? Number.parseInt(raw, 10) : 1
393
+ function escapeRegex(value: string): string {
394
+ return value.replace(/[.*+?^$()|[\\]{}]/g, "\\\\$&")
224
395
  }
225
396
 
226
- function extractWorktreeDirectory(prompt: string, directory: string): string | undefined {
227
- const raw = prompt.match(/worktree\s+([^:\n]+)/i)?.[1]?.trim()
228
- if (!raw) return undefined
229
- return path.isAbsolute(raw) ? raw : path.join(directory, raw)
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)
230
401
  }
231
402
 
232
- function buildMetadata(directory: string, parentSessionID: string, prompt: string): RuntimeTaskMetadata | null {
233
- const featureSlug = extractFeatureSlug(prompt)
234
- const taskID = extractTaskID(prompt)
235
- if (!featureSlug || !taskID) return null
403
+ const MAX_CONTEXT_BYTES = 8000
236
404
 
237
- ensureFeatureStateStructure(directory, featureSlug)
238
- const taskPaths = featureStateTaskPaths(directory, featureSlug, taskID)
239
- mkdirSync(taskPaths.taskDir, { recursive: true })
405
+ class ContextCollector {
406
+ private collected = new Map<string, CollectedEntry>()
407
+ private totalBytes = 0
240
408
 
241
- return {
242
- featureSlug,
243
- taskID,
244
- attempt: extractAttempt(prompt),
245
- statePath: taskPaths.statePath,
246
- retryStatePath: taskPaths.retryStatePath,
247
- runtimePath: taskPaths.runtimePath,
248
- worktreeDirectory: extractWorktreeDirectory(prompt, directory),
249
- parentSessionID,
250
- originalPrompt: prompt,
409
+ has(key: string): boolean {
410
+ return this.collected.has(key)
251
411
  }
252
- }
253
412
 
254
- function sessionRuntimePath(directory: string, metadata: RuntimeTaskMetadata, sessionID: string): string {
255
- return featureStateSessionRuntimePath(directory, metadata.featureSlug, sessionID)
256
- }
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
257
417
 
258
- function writeMetadata(filePath: string, metadata: RuntimeTaskMetadata): void {
259
- writeFileSync(filePath, JSON.stringify(metadata, null, 2) + "\n", "utf-8")
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
+ }
260
439
  }
261
440
 
262
- export default (async ({ directory }: { directory: string }) => {
263
- const pendingByParent = new Map<string, RuntimeTaskMetadata[]>()
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
+ }
264
447
 
265
- return {
266
- "tool.execute.before": async (
267
- input: { tool: string; sessionID: string; callID: string },
268
- output: { args: Record<string, unknown> }
269
- ) => {
270
- if (input.tool !== "Task" && input.tool !== "task") return
448
+ if (candidateProjectRoots.size === 0) {
449
+ const fallback = resolveProjectPaths(directory, loadActivePlanTarget(directory) ?? {})
450
+ if (fallback?.projectRoot) candidateProjectRoots.add(fallback.projectRoot)
451
+ }
271
452
 
272
- const prompt = typeof output.args?.prompt === "string" ? output.args.prompt : ""
273
- const metadata = buildMetadata(directory, input.sessionID, prompt)
274
- if (!metadata) return
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
+ }
275
469
 
276
- const queue = pendingByParent.get(input.sessionID) ?? []
277
- queue.push(metadata)
278
- pendingByParent.set(input.sessionID, queue)
279
- },
470
+ return null
471
+ }
280
472
 
281
- event: async ({ event }: { event: { type: string; properties?: Record<string, unknown> } }) => {
282
- if (event.type !== "session.created") return
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
+ }
283
482
 
284
- const sessionID = typeof event.properties?.sessionID === "string" ? event.properties.sessionID : undefined
285
- const info = typeof event.properties?.info === "object" && event.properties.info
286
- ? (event.properties.info as Record<string, unknown>)
287
- : undefined
288
- const parentID = typeof info?.parentID === "string" ? info.parentID : undefined
289
- const title = typeof info?.title === "string" ? info.title : ""
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
+ }
290
496
 
291
- if (!sessionID || !parentID) return
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
+ }
292
517
 
293
- const queue = pendingByParent.get(parentID)
294
- if (!queue || queue.length === 0) return
518
+ function extractFeatureSlugFromPath(filePath: string): string | null {
519
+ return filePath.match(/docs\\/specs\\/([^/]+)\\//)?.[1] ?? null
520
+ }
295
521
 
296
- const titleTaskID = extractTaskID(title)
297
- const index = titleTaskID ? queue.findIndex((item) => item.taskID === titleTaskID) : 0
298
- const resolvedIndex = index >= 0 ? index : 0
299
- const [metadata] = queue.splice(resolvedIndex, 1)
300
- if (!metadata) return
522
+ function extractFeatureSlugFromPrompt(prompt: string): string | null {
523
+ return prompt.match(/docs\\/specs\\/([^/]+)\\//)?.[1] ?? null
524
+ }
301
525
 
302
- if (queue.length > 0) pendingByParent.set(parentID, queue)
303
- else pendingByParent.delete(parentID)
526
+ function extractPlanPathFromPrompt(prompt: string): string | null {
527
+ return prompt.match(/docs\\/specs\\/[^\\s]+\\/plan\\.md/)?.[0] ?? null
528
+ }
304
529
 
305
- const resolvedMetadata: RuntimeTaskMetadata = {
306
- ...metadata,
307
- ownerSessionID: sessionID,
308
- ownerSessionTitle: title || undefined,
309
- }
530
+ function readIfExists(filePath: string): string {
531
+ return existsSync(filePath) ? readFileSync(filePath, "utf-8") : ""
532
+ }
310
533
 
311
- writeMetadata(metadata.runtimePath, resolvedMetadata)
312
- writeMetadata(sessionRuntimePath(directory, metadata, sessionID), resolvedMetadata)
313
- },
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
+ }
314
548
  }
315
- }) satisfies Plugin
316
- `;
317
- const TASK_BOARD = `import type { Plugin } from "@opencode-ai/plugin"
318
- import { existsSync, readFileSync } from "fs"
319
- import path from "path"
320
- import { featureStateManifestPath, featureStateTaskPaths } from "./j.feature-state-paths"
321
- import { resolveStateFile } from "./j.state-paths"
322
549
 
323
- type TaskBoardRow = {
324
- id: string
325
- name: string
326
- wave: string
327
- depends: string
328
- status: string
329
- attempt: string
330
- heartbeat: string
331
- retryCount: string
332
- validatedCommit: string
333
- featureCommit: string
334
- integrationStatus: string
335
- }
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
336
556
 
337
- function getActiveFeatureSlug(directory: string): string | null {
338
- const statePath = resolveStateFile(directory, "execution-state.md")
339
- if (!existsSync(statePath)) return null
557
+ const absolutePath = path.isAbsolute(contractPathArg) ? contractPathArg : path.join(directory, contractPathArg)
558
+ if (!existsSync(absolutePath)) return null
340
559
 
341
- const content = readFileSync(statePath, "utf-8")
342
- return content.match(/\*\*Feature slug\*\*:\s*(?:\`)?([^\`\s]+)/)?.[1] ?? null
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
+ }
343
569
  }
344
570
 
345
- function parsePlan(planPath: string): Array<{ id: string; name: string; wave: string; depends: string }> {
346
- if (!existsSync(planPath)) return []
571
+ function loadTaskPlanContext(planPath: string, taskID: string | undefined): TaskPlanContext | null {
572
+ if (!taskID || !existsSync(planPath)) return null
347
573
  const content = readFileSync(planPath, "utf-8")
348
- const tasks = Array.from(content.matchAll(/<task id="([^"]+)" wave="([^"]+)" agent="[^"]+" depends="([^"]*)">([\s\S]*?)<\/task>/g))
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
349
577
 
350
- return tasks.map((match) => ({
351
- id: match[1],
352
- wave: match[2],
353
- depends: match[3] || "-",
354
- name: match[4].match(/<n>([\s\S]*?)<\/n>/)?.[1]?.trim() ?? "Task " + match[1],
355
- }))
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
+ }
356
592
  }
357
593
 
358
- function readStateValue(content: string, label: string): string {
359
- return content.match(new RegExp("- \\*\\*" + label + "\\*\\*:\\s*([^\\n]+)"))?.[1]?.trim() ?? "-"
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 }
360
603
  }
361
604
 
362
- function readRetryCount(retryPath: string): string {
363
- if (!existsSync(retryPath)) return "0"
364
- try {
365
- const parsed = JSON.parse(readFileSync(retryPath, "utf-8")) as { autoRetryCount?: number }
366
- return typeof parsed.autoRetryCount === "number" ? String(parsed.autoRetryCount) : "0"
367
- } catch {
368
- return "0"
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")))
369
635
  }
636
+
637
+ const rawText = texts.filter(Boolean).join(" ").toLowerCase()
638
+ return { keywords: extractKeywords(stripCodeBlocks(rawText)), rawText }
370
639
  }
371
640
 
372
- function buildBoard(directory: string): string | null {
373
- const slug = getActiveFeatureSlug(directory)
374
- if (!slug) return null
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
+ }
375
645
 
376
- const featureDir = path.join(directory, "docs", "specs", slug)
377
- const planPath = path.join(featureDir, "plan.md")
378
- const integrationPath = featureStateManifestPath(directory, slug)
379
- if (!existsSync(planPath)) return null
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
+ }
380
649
 
381
- const planTasks = parsePlan(planPath)
382
- if (planTasks.length === 0) return null
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
+ }
383
653
 
384
- let integrationManifest: { tasks?: Record<string, any> } | null = null
385
- if (existsSync(integrationPath)) {
386
- try {
387
- integrationManifest = JSON.parse(readFileSync(integrationPath, "utf-8")) as { tasks?: Record<string, any> }
388
- } catch {
389
- integrationManifest = null
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)
390
707
  }
391
708
  }
392
709
 
393
- const rows: TaskBoardRow[] = planTasks.map((task) => {
394
- const taskPaths = featureStateTaskPaths(directory, slug, task.id)
395
- const content = existsSync(taskPaths.statePath) ? readFileSync(taskPaths.statePath, "utf-8") : ""
396
- const integrationEntry = integrationManifest?.tasks?.[task.id]
710
+ return addedKeys
711
+ }
397
712
 
398
- return {
399
- id: task.id,
400
- name: task.name,
401
- wave: task.wave,
402
- depends: task.depends,
403
- status: content ? readStateValue(content, "Status") : "PENDING",
404
- attempt: content ? readStateValue(content, "Attempt") : "-",
405
- heartbeat: content ? readStateValue(content, "Last heartbeat") : "-",
406
- retryCount: readRetryCount(taskPaths.retryStatePath),
407
- validatedCommit: integrationEntry?.validatedCommit ?? "-",
408
- featureCommit: integrationEntry?.integration?.integratedCommit ?? "-",
409
- integrationStatus: integrationEntry?.integration?.method
410
- ? String(integrationEntry.integration.status ?? "pending") + "/" + String(integrationEntry.integration.method)
411
- : integrationEntry?.integration?.status ?? "pending",
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
+ }
412
765
  }
413
- })
414
-
415
- return [
416
- "[task-board] Feature: " + slug,
417
- "",
418
- "| ID | Wave | Depends | Status | Attempt | Retries | Validated Commit | Feature Commit | Integration | Heartbeat | Task |",
419
- "|----|------|---------|--------|---------|---------|------------------|----------------|-------------|-----------|------|",
420
- ...rows.map((row) =>
421
- "| " + row.id + " | " + row.wave + " | " + row.depends + " | " + row.status + " | " + row.attempt + " | " + row.retryCount + " | " + row.validatedCommit + " | " + row.featureCommit + " | " + row.integrationStatus + " | " + row.heartbeat + " | " + row.name + " |"
422
- ),
423
- ].join("\n")
424
- }
425
766
 
426
- export default (async ({ directory }: { directory: string }) => {
427
- const lastBoardBySession = new Map<string, string>()
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
428
772
 
429
- return {
430
- "tool.execute.after": async (
431
- input: { tool: string; sessionID: string; callID: string; args: any },
432
- output: { title: string; output: string; metadata: any }
433
- ) => {
434
- const board = buildBoard(directory)
435
- if (!board) return
436
- if (lastBoardBySession.get(input.sessionID) === board) return
773
+ for (const file of entry.files.slice(0, 3)) {
774
+ const dedupKey = \`domain:\${entry.domain}:\${file.path}\`
775
+ if (collector.has(dedupKey)) continue
437
776
 
438
- lastBoardBySession.set(input.sessionID, board)
439
- output.output += "\n\n" + board
440
- },
441
- "experimental.session.compacting": async (
442
- _input: { sessionID?: string },
443
- output: { context: string[] }
444
- ) => {
445
- const board = buildBoard(directory)
446
- if (!board) return
777
+ const domainPath = path.join(domainRoot, file.path)
778
+ if (!existsSync(domainPath)) continue
447
779
 
448
- output.context.push(board)
449
- },
780
+ const content = readFileSync(domainPath, "utf-8")
781
+ if (collector.add(dedupKey, content, 10, "domain", \`\${entry.domain} / \${file.path}\`)) addedKeys.push(dedupKey)
782
+ }
783
+ }
450
784
  }
451
- }) satisfies Plugin
452
- `;
453
- const NOTIFY = `import type { Plugin } from "@opencode-ai/plugin"
454
- import { execFileSync } from "child_process"
455
- import { platform } from "os"
456
-
457
- const TITLE = "opencode"
458
785
 
459
- function escapeAppleScript(value: string): string {
460
- return value.replace(/\\/g, "\\\\").replace(/\"/g, '\\\"')
786
+ return addedKeys
461
787
  }
462
788
 
463
- function sendNotification(message: string): void {
464
- try {
465
- const os = platform()
466
- if (os === "darwin") {
467
- const script = 'display notification "' + escapeAppleScript(message) + '" with title "' + TITLE + '" sound name "Glass"'
468
- execFileSync("osascript", ["-e", script], {
469
- stdio: "ignore",
470
- timeout: 5000,
471
- })
472
- return
473
- }
474
- if (os === "linux") {
475
- execFileSync("notify-send", [TITLE, message, "--expire-time=5000"], {
476
- stdio: "ignore",
477
- timeout: 5000,
478
- })
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)
479
801
  }
480
- } catch {
481
- // Never block the session on notification failures.
802
+ return collector
482
803
  }
483
- }
484
804
 
485
- export default (async (_ctx: { directory: string }) => ({
486
- "session.idle": async (_input: Record<string, unknown>, output: { metadata?: Record<string, unknown> }) => {
487
- const reason = typeof output.metadata?.reason === "string" ? output.metadata.reason : "idle session detected"
488
- sendNotification(reason)
489
- },
490
- })) satisfies Plugin
491
- `;
492
- // ─── Env Protection ──────────────────────────────────────────────────────────
493
- const ENV_PROTECTION = `import type { Plugin } from "@opencode-ai/plugin"
494
-
495
- // Blocks reads/writes of sensitive files before any tool executes.
496
- // Real API: tool.execute.before(input, output) — throw Error to abort.
497
-
498
- const SENSITIVE = [
499
- /\\.env($|\\.)/i,
500
- /secret/i,
501
- /credential/i,
502
- /\\.pem$/i,
503
- /id_rsa/i,
504
- /\\.key$/i,
505
- ]
506
-
507
- export default (async ({ directory: _directory }: { directory: string }) => ({
508
- "tool.execute.before": async (
509
- input: { tool: string; sessionID: string; callID: string },
510
- output: { args: any }
511
- ) => {
512
- const filePath: string =
513
- output.args?.path ?? output.args?.file_path ?? output.args?.filename ?? ""
514
- if (!filePath) return
515
-
516
- if (SENSITIVE.some((p) => p.test(filePath))) {
517
- throw new Error(
518
- \`[env-protection] Blocked access to sensitive file: \${filePath}\\n\` +
519
- \`If intentional, temporarily disable the env-protection plugin.\`
520
- )
521
- }
522
- },
523
- })) satisfies Plugin
524
- `;
525
- // ─── Auto Format ─────────────────────────────────────────────────────────────
526
- const AUTO_FORMAT = `import type { Plugin } from "@opencode-ai/plugin"
527
- import { execSync } from "child_process"
528
- import path from "path"
529
-
530
- // Auto-formats files after Write/Edit tool calls.
531
- // Real API: tool.execute.after(input, output) — input.args has the file path.
532
-
533
- const FORMATTERS: Record<string, string> = {
534
- ".ts": "prettier --write",
535
- ".tsx": "prettier --write",
536
- ".js": "prettier --write",
537
- ".jsx": "prettier --write",
538
- ".json": "prettier --write",
539
- ".css": "prettier --write",
540
- ".scss": "prettier --write",
541
- ".md": "prettier --write",
542
- ".py": "black",
543
- ".go": "gofmt -w",
544
- ".rs": "rustfmt",
545
- }
546
-
547
- export default (async ({ directory: _directory }: { directory: string }) => ({
548
- "tool.execute.after": async (
549
- input: { tool: string; sessionID: string; callID: string; args: any },
550
- _output: { title: string; output: string; metadata: any }
551
- ) => {
552
- if (!["Write", "Edit", "MultiEdit"].includes(input.tool)) return
553
-
554
- const filePath: string = input.args?.path ?? input.args?.file_path ?? ""
555
- if (!filePath) return
556
-
557
- const formatter = FORMATTERS[path.extname(filePath)]
558
- if (!formatter) return
559
-
560
- try {
561
- execSync(\`\${formatter} "\${filePath}"\`, { stdio: "ignore" })
562
- } catch {
563
- // Formatter not available — skip silently
564
- }
565
- },
566
- })) satisfies Plugin
567
- `;
568
- // ─── Plan Autoload ────────────────────────────────────────────────────────────
569
- const PLAN_AUTOLOAD = `import type { Plugin } from "@opencode-ai/plugin"
570
- import { existsSync, readFileSync } from "fs"
571
- import path from "path"
572
- import { resolveStateFile } from "./j.state-paths"
805
+ function loadTaskKeywords(sessionID: string): Set<string> {
806
+ if (taskKeywordsLoaded.has(sessionID)) return new Set()
807
+ taskKeywordsLoaded.add(sessionID)
573
808
 
574
- // Injects active plan into agent context when an active-plan state pointer exists.
575
- // Uses chat.message for initial injection, tool.execute.after(Read) as a
576
- // fallback, and experimental.session.compacting to survive session compaction.
577
- // The active-plan pointer stays on disk so later messages, compaction, and
578
- // write-time guards can all resolve the same active plan consistently.
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
+ }
579
823
 
580
- export default (async ({ directory }: { directory: string }) => {
581
- const planInjectedSessions = new Set<string>()
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()
582
827
 
583
- function loadActivePlan(): { planPath: string; planContent: string } | null {
584
- const activePlanFile = resolveStateFile(directory, "active-plan.json")
585
- if (!existsSync(activePlanFile)) return null
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
+ }
586
834
 
587
- const state = JSON.parse(readFileSync(activePlanFile, "utf-8")) as { planPath?: string }
588
- const planPath = state.planPath?.trim()
589
- if (!planPath) return null
590
- const fullPath = path.isAbsolute(planPath) ? planPath : path.join(directory, planPath)
591
- if (!existsSync(fullPath)) return null
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
+ }
592
848
 
593
- return { planPath, planContent: readFileSync(fullPath, "utf-8") }
849
+ function hasTaskScopedRuntime(sessionID: string): boolean {
850
+ return Boolean(loadRuntimeMetadata(directory, sessionID)?.taskID)
594
851
  }
595
852
 
596
- function renderPlan(planPath: string, planContent: string): string {
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
+
597
871
  return (
598
- \`[plan-autoload] Active plan detected at \${planPath}:\\n\\n\${planContent}\\n\\n\` +
599
- \`Use /j.implement to execute this plan, or /j.plan to revise it.\`
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)
600
874
  )
601
875
  }
602
876
 
603
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
+
604
930
  "chat.message": async (
605
931
  input: { sessionID: string },
606
932
  output: { message: { system?: string }; parts: unknown[] }
607
933
  ) => {
608
- if (planInjectedSessions.has(input.sessionID)) return
609
-
610
- const loaded = loadActivePlan()
611
- if (!loaded) return
612
-
613
- planInjectedSessions.add(input.sessionID)
614
- output.message.system = output.message.system
615
- ? output.message.system + "\\n\\n" + renderPlan(loaded.planPath, loaded.planContent)
616
- : renderPlan(loaded.planPath, loaded.planContent)
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)
617
946
  },
947
+
618
948
  "tool.execute.after": async (
619
949
  input: { tool: string; sessionID: string; callID: string; args: any },
620
950
  output: { title: string; output: string; metadata: any }
621
951
  ) => {
622
- if (input.tool !== "Read" || planInjectedSessions.has(input.sessionID)) return
623
-
624
- const loaded = loadActivePlan()
625
- if (!loaded) return
626
-
627
- planInjectedSessions.add(input.sessionID)
628
- output.output += "\\n\\n" + renderPlan(loaded.planPath, loaded.planContent)
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)
629
988
  },
630
989
 
631
990
  "experimental.session.compacting": async (
632
- _input: { sessionID?: string },
633
- output: { context: string[] }
991
+ input: { sessionID?: string },
992
+ output: { context: string[]; prompt?: string }
634
993
  ) => {
635
- const loaded = loadActivePlan()
636
- if (!loaded) return
994
+ if (!input.sessionID) return
995
+
996
+ const collector = collectorsBySession.get(input.sessionID)
997
+ if (!collector) return
637
998
 
638
- output.context.push(renderPlan(loaded.planPath, loaded.planContent))
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
+ )
639
1006
  },
640
1007
  }
641
1008
  }) satisfies Plugin
642
1009
  `;
643
- // ─── CARL Inject ──────────────────────────────────────────────────────────────
644
- const CARL_INJECT = `import type { Plugin } from "@opencode-ai/plugin"
645
- import { existsSync, readFileSync } from "fs"
646
- import path from "path"
647
-
648
- // CARL v2 = Context-Aware Retrieval Layer
649
- // Content-aware keyword detection inspired by oh-my-opencode.
650
- // Two hooks:
651
- // tool.execute.after (Read) — extracts keywords from FILE CONTENT (not just
652
- // path) after stripping code blocks. On first trigger per session, also
653
- // reads execution-state.md for task-awareness. Injects matching principles
654
- // and domain docs into the Read output.
655
- // experimental.session.compacting — re-injects all collected docs so they
656
- // survive context window resets.
657
- //
658
- // Key improvements over v1:
659
- // - Analyzes stripped file content for keyword matching (understands context)
660
- // - Word-boundary regex matching (prevents "auth" matching "authorize")
661
- // - Task-awareness from execution-state.md (understands the goal)
662
- // - Budget cap prevents context overflow
663
- // - Compaction survival via second hook
664
-
665
- // ── Types ──
666
-
667
- interface PrincipleEntry {
668
- key: string
669
- recall: string[]
670
- file: string
671
- priority: number
672
- }
673
-
674
- interface DomainEntry {
675
- domain: string
676
- keywords: string[]
677
- files: Array<{ path: string; description: string }>
678
- }
679
-
680
- interface CollectedEntry {
681
- content: string
682
- priority: number
683
- type: "principle" | "domain"
684
- label: string
685
- }
686
-
687
- // ── Parsing ──
688
-
689
- function parsePrinciplesManifest(content: string): PrincipleEntry[] {
690
- const entries: PrincipleEntry[] = []
691
- const lines = content.split("\\n").filter((l) => !l.startsWith("#") && l.trim())
692
-
693
- const byKey: Record<string, Record<string, string>> = {}
694
- for (const line of lines) {
695
- const match = /^([A-Z_]+)_(STATE|RECALL|FILE|PRIORITY)=(.*)$/.exec(line)
696
- if (!match) continue
697
- const [, prefix, field, value] = match
698
- if (!byKey[prefix]) byKey[prefix] = {}
699
- byKey[prefix][field] = value.trim()
700
- }
701
-
702
- for (const [key, fields] of Object.entries(byKey)) {
703
- if (fields["STATE"] !== "active") continue
704
- if (!fields["RECALL"] || !fields["FILE"]) continue
705
- entries.push({
706
- key,
707
- recall: fields["RECALL"].split(",").map((k) => k.trim().toLowerCase()),
708
- file: fields["FILE"],
709
- priority: parseInt(fields["PRIORITY"] ?? "1", 10),
710
- })
711
- }
712
-
713
- return entries
714
- }
715
-
716
- function parseDomainIndex(content: string): DomainEntry[] {
717
- const entries: DomainEntry[] = []
718
- const sections = content.split(/^## /m).slice(1)
719
-
720
- for (const section of sections) {
721
- const lines = section.split("\\n")
722
- const domain = lines[0].trim()
723
- const keywordsLine = lines.find((l) => l.startsWith("Keywords:"))
724
- const filesStart = lines.findIndex((l) => l.startsWith("Files:"))
725
-
726
- if (!keywordsLine || filesStart === -1) continue
727
-
728
- const keywords = keywordsLine
729
- .replace("Keywords:", "")
730
- .split(",")
731
- .map((k) => k.trim().toLowerCase())
732
-
733
- const files: Array<{ path: string; description: string }> = []
734
- for (let i = filesStart + 1; i < lines.length; i++) {
735
- const fileMatch = /^\\s+-\\s+([^—]+)(?:—\\s+(.*))?$/.exec(lines[i])
736
- if (!fileMatch) break
737
- files.push({ path: fileMatch[1].trim(), description: fileMatch[2]?.trim() ?? "" })
738
- }
739
-
740
- entries.push({ domain, keywords, files })
741
- }
742
-
743
- return entries
744
- }
745
-
746
- // ── Content Analysis (oh-my-opencode style) ──
747
-
748
- function stripCodeBlocks(text: string): string {
749
- // Remove fenced code blocks and inline code (backtick-wrapped)
750
- // Prevents false keyword matches from variable names, imports, etc.
751
- let stripped = text.replace(/\`\`\`[\\s\\S]*?\`\`\`/g, "")
752
- stripped = stripped.replace(/\`[^\`\\n]+\`/g, "")
753
- return stripped
754
- }
755
-
756
- function extractKeywords(text: string): Set<string> {
757
- // Extract meaningful words from text (stripped of code) for matching
758
- const words = new Set<string>()
759
- for (const w of text.split(/[^a-zA-Z0-9_-]+/).filter((w) => w.length >= 3)) {
760
- words.add(w.toLowerCase())
761
- }
762
- return words
763
- }
764
-
765
- function extractPathKeywords(filePath: string): Set<string> {
766
- // Secondary signal: meaningful words from the file path
767
- const parts = filePath.replace(/\\\\/g, "/").split("/")
768
- const words = new Set<string>()
769
- for (const part of parts) {
770
- for (const w of part.split(/[^a-zA-Z0-9_-]+/).filter((w) => w.length >= 3)) {
771
- words.add(w.toLowerCase())
772
- }
773
- }
774
- return words
775
- }
776
-
777
- function escapeRegex(value: string): string {
778
- return value.replace(/[.*+?^$()|[\\]{}]/g, "\\$&")
779
- }
780
-
781
- function matchKeyword(keyword: string, textWords: Set<string>, rawText: string): boolean {
782
- // Word-boundary matching — "auth" matches "auth" but NOT "authorize" or "author"
783
- // First check exact set membership (fast path), then regex fallback for
784
- // short tokens and multi-word recall terms.
785
- if (textWords.has(keyword)) return true
786
-
787
- const pattern = new RegExp("\\b" + escapeRegex(keyword) + "\\b", "i")
788
- return pattern.test(rawText)
789
- }
790
-
791
- // ── ContextCollector — budget-aware dedup singleton ──
792
-
793
- const MAX_CONTEXT_BYTES = 8000
794
-
795
- class ContextCollector {
796
- private collected = new Map<string, CollectedEntry>()
797
- private totalBytes = 0
798
-
799
- has(key: string): boolean {
800
- return this.collected.has(key)
801
- }
802
-
803
- add(key: string, content: string, priority: number, type: "principle" | "domain", label: string): boolean {
804
- if (this.collected.has(key)) return false
805
- const size = Buffer.byteLength(content, "utf-8")
806
- if (this.totalBytes + size > MAX_CONTEXT_BYTES) return false
807
-
808
- this.collected.set(key, { content, priority, type, label })
809
- this.totalBytes += size
810
- return true
811
- }
812
-
813
- getNewEntries(keys: string[]): CollectedEntry[] {
814
- return keys
815
- .filter((k) => this.collected.has(k))
816
- .map((k) => this.collected.get(k)!)
817
- .sort((a, b) => a.priority - b.priority)
818
- }
819
-
820
- getAll(): CollectedEntry[] {
821
- return Array.from(this.collected.values()).sort((a, b) => a.priority - b.priority)
822
- }
823
-
824
- formatForOutput(entries: CollectedEntry[]): string {
825
- return entries
826
- .map((e) => \`[carl-inject] \${e.type === "principle" ? "Principle" : "Domain"} (\${e.label}):\\n\${e.content}\`)
827
- .join("\\n\\n---\\n\\n")
828
- }
829
- }
830
-
831
- // ── Plugin ──
832
-
833
- export default (async ({ directory }: { directory: string }) => {
834
- const collector = new ContextCollector()
835
- const taskKeywordsLoaded = new Set<string>()
836
-
837
- function loadTaskKeywords(sessionID: string): Set<string> {
838
- // Fire-once per session: extract keywords from execution-state.md
839
- // to understand what the agent is working on (task awareness)
840
- if (taskKeywordsLoaded.has(sessionID)) return new Set()
841
- taskKeywordsLoaded.add(sessionID)
842
-
843
- const statePath = path.join(directory, ".opencode", "state", "execution-state.md")
844
- if (!existsSync(statePath)) return new Set()
845
-
846
- const state = readFileSync(statePath, "utf-8")
847
- // Extract Goal + Task List sections — these describe what the agent is doing
848
- const goalMatch = /\\*\\*Goal\\*\\*:\\s*(.+)/i.exec(state)
849
- const taskLines = state.split("\\n").filter((l) => /^\\s*-\\s*\\[/.test(l))
850
-
851
- const taskText = [goalMatch?.[1] ?? "", ...taskLines].join(" ")
852
- return extractKeywords(stripCodeBlocks(taskText))
853
- }
854
-
855
- function matchAgainstSources(keywords: Set<string>, rawText: string): string[] {
856
- const manifestPath = path.join(directory, "docs", "principles", "manifest")
857
- const indexPath = path.join(directory, "docs", "domain", "INDEX.md")
858
- const addedKeys: string[] = []
859
-
860
- // ── Principles manifest ──
861
- if (existsSync(manifestPath)) {
862
- const manifest = readFileSync(manifestPath, "utf-8")
863
- const principles = parsePrinciplesManifest(manifest)
864
-
865
- for (const entry of principles) {
866
- const dedupKey = \`principle:\${entry.key}\`
867
- if (collector.has(dedupKey)) continue
868
-
869
- const matched = entry.recall.some((kw) => matchKeyword(kw, keywords, rawText))
870
- if (!matched) continue
871
-
872
- const entryFilePath = path.join(directory, entry.file)
873
- if (!existsSync(entryFilePath)) continue
874
-
875
- const content = readFileSync(entryFilePath, "utf-8")
876
- if (collector.add(dedupKey, content, entry.priority, "principle", entry.key)) {
877
- addedKeys.push(dedupKey)
878
- }
879
- }
880
- }
881
-
882
- // ── Domain index ──
883
- if (existsSync(indexPath)) {
884
- const index = readFileSync(indexPath, "utf-8")
885
- const domains = parseDomainIndex(index)
886
-
887
- for (const entry of domains) {
888
- const matched = entry.keywords.some((kw) => matchKeyword(kw, keywords, rawText))
889
- if (!matched) continue
890
-
891
- for (const file of entry.files.slice(0, 3)) {
892
- const dedupKey = \`domain:\${entry.domain}:\${file.path}\`
893
- if (collector.has(dedupKey)) continue
894
-
895
- const domainFilePath = path.join(directory, "docs", "domain", file.path)
896
- if (!existsSync(domainFilePath)) continue
897
-
898
- const content = readFileSync(domainFilePath, "utf-8")
899
- if (collector.add(dedupKey, content, 10, "domain", \`\${entry.domain} / \${file.path}\`)) {
900
- addedKeys.push(dedupKey)
901
- }
902
- }
903
- }
904
- }
905
-
906
- return addedKeys
907
- }
908
-
909
- return {
910
- "tool.execute.after": async (
911
- input: { tool: string; sessionID: string; callID: string; args: any },
912
- output: { title: string; output: string; metadata: any }
913
- ) => {
914
- if (input.tool !== "Read") return
915
-
916
- const filePath: string = input.args?.path ?? input.args?.file_path ?? ""
917
- if (!filePath) return
918
-
919
- // ── Collect keywords from multiple signals ──
920
- const allKeywords = new Set<string>()
921
-
922
- // Signal 1: Task awareness (fire-once per session)
923
- const taskKw = loadTaskKeywords(input.sessionID)
924
- for (const kw of taskKw) allKeywords.add(kw)
925
-
926
- // Signal 2: File content analysis (primary — understands what agent reads)
927
- const fileContent = output.output ?? ""
928
- const strippedContent = stripCodeBlocks(fileContent)
929
- const contentKw = extractKeywords(strippedContent)
930
- for (const kw of contentKw) allKeywords.add(kw)
931
-
932
- // Signal 3: Path keywords (secondary — cheap, complements content)
933
- const pathKw = extractPathKeywords(filePath)
934
- for (const kw of pathKw) allKeywords.add(kw)
935
-
936
- if (allKeywords.size === 0) return
937
-
938
- const rawSignal = [
939
- strippedContent,
940
- filePath,
941
- ...Array.from(taskKw),
942
- ...Array.from(pathKw),
943
- ].join(" ").toLowerCase()
944
-
945
- // ── Match and inject ──
946
- const addedKeys = matchAgainstSources(allKeywords, rawSignal)
947
- if (addedKeys.length === 0) return
948
-
949
- const newEntries = collector.getNewEntries(addedKeys)
950
- if (newEntries.length > 0) {
951
- output.output += "\\n\\n" + collector.formatForOutput(newEntries)
952
- }
953
- },
954
-
955
- "experimental.session.compacting": async (
956
- _input: Record<string, unknown>,
957
- output: { context: string[]; prompt?: string }
958
- ) => {
959
- const all = collector.getAll()
960
- if (all.length === 0) return
961
-
962
- output.context.push(
963
- "[carl-inject] Previously injected context (principles + domain docs):\\n\\n" +
964
- collector.formatForOutput(all)
965
- )
966
- },
967
- }
968
- }) satisfies Plugin
969
- `;
970
1010
  // ─── Skill Inject (reads from skill-map.json for dynamic extension) ─────────
971
1011
  function getBaseSkillMap(projectType, isKotlin) {
972
1012
  // Universal patterns (all types)
973
1013
  const universal = [
974
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" },
975
1016
  { pattern: "docs\\/domain\\/.*\\.md$", skill: "j.domain-doc-writing" },
976
1017
  { pattern: "docs\\/principles\\/.*(?:\\.md|manifest)$", skill: "j.principle-doc-writing" },
977
1018
  { pattern: "(^|\\/)(\\.opencode\\/scripts|scripts)\\/.*\\.sh$", skill: "j.shell-script-writing" },
@@ -1033,6 +1074,7 @@ function skillInject(projectType, isKotlin) {
1033
1074
  'import type { Plugin } from "@opencode-ai/plugin"',
1034
1075
  'import { existsSync, readFileSync } from "fs"',
1035
1076
  'import path from "path"',
1077
+ 'import { findContainingProjectRoot } from "../lib/j.workspace-paths"',
1036
1078
  '',
1037
1079
  '// Injects skill instructions via tool.execute.after on Read + Write.',
1038
1080
  '// SKILL_MAP is loaded from .opencode/skill-map.json for dynamic',
@@ -1053,6 +1095,23 @@ function skillInject(projectType, isKotlin) {
1053
1095
  ' return entries.map((e) => ({ pattern: new RegExp(e.pattern), skill: e.skill }))',
1054
1096
  '}',
1055
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
+ '',
1056
1115
  'export default (async ({ directory }: { directory: string }) => {',
1057
1116
  ' const injectedSkills = new Set<string>()',
1058
1117
  ' const skillMap = loadSkillMap(directory)',
@@ -1074,8 +1133,8 @@ function skillInject(projectType, isKotlin) {
1074
1133
  ' if (injectedSkills.has(key)) return',
1075
1134
  ' injectedSkills.add(key)',
1076
1135
  '',
1077
- ' const skillPath = path.join(directory, ".opencode", "skills", match.skill, "SKILL.md")',
1078
- ' if (!existsSync(skillPath)) return',
1136
+ ' const skillPath = resolveSkillPath(directory, match.skill, filePath)',
1137
+ ' if (!skillPath) return',
1079
1138
  '',
1080
1139
  ' const skillContent = readFileSync(skillPath, "utf-8")',
1081
1140
  ' output.output +=',
@@ -1083,8 +1142,8 @@ function skillInject(projectType, isKotlin) {
1083
1142
  ' } else if (["Write", "Edit", "MultiEdit"].includes(input.tool)) {',
1084
1143
  ' if (injectedSkills.has(key)) return',
1085
1144
  '',
1086
- ' const skillPath = path.join(directory, ".opencode", "skills", match.skill, "SKILL.md")',
1087
- ' if (!existsSync(skillPath)) return',
1145
+ ' const skillPath = resolveSkillPath(directory, match.skill, filePath)',
1146
+ ' if (!skillPath) return',
1088
1147
  '',
1089
1148
  ' injectedSkills.add(key)',
1090
1149
  ' output.output +=',
@@ -1097,107 +1156,1237 @@ function skillInject(projectType, isKotlin) {
1097
1156
  ];
1098
1157
  return lines.join('\n') + '\n';
1099
1158
  }
1100
- // ─── Intent Gate ─────────────────────────────────────────────────────────────
1101
- const INTENT_GATE = `import type { Plugin } from "@opencode-ai/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
1224
+ `;
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
1387
+ `;
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
1443
+ `;
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"
1102
2214
  import { existsSync, readFileSync } from "fs"
1103
2215
  import path from "path"
2216
+ import { featureStateManifestPath, featureStateTaskPaths } from "./j.feature-state-paths"
1104
2217
  import { resolveStateFile } from "./j.state-paths"
1105
-
1106
- // Scope-guard: after any Write/Edit, checks if the modified file is part of
1107
- // the current plan. If it drifts outside the plan scope, appends a warning.
1108
- // Uses tool.execute.after on Write/Edit — agent sees the warning and can
1109
- // course-correct before continuing.
1110
-
1111
- function extractPlanFiles(planContent: string): Set<string> {
1112
- const files = new Set<string>()
1113
- // Matches common plan file references: paths with extensions, bullet paths, etc.
1114
- const pathPattern = /(?:^|\\s|\\/|\\|)[\\w\\-./]+\\.[a-z]{1,5}\\b/gi
1115
- for (const match of planContent.matchAll(pathPattern)) {
1116
- const cleaned = match[0].replace(/^[\\s/|]+/, "").trim()
1117
- if (cleaned.endsWith(".") || cleaned.length < 4) continue
1118
- files.add(cleaned)
1119
- }
1120
- return files
1121
- }
1122
-
1123
- function loadActivePlanContent(directory: string): string | null {
1124
- const activePlanPath = resolveStateFile(directory, "active-plan.json")
1125
- if (existsSync(activePlanPath)) {
1126
- const declaredPath = JSON.parse(readFileSync(activePlanPath, "utf-8")).planPath?.trim()
1127
- if (!declaredPath) return null
1128
- const resolvedPath = path.isAbsolute(declaredPath)
1129
- ? declaredPath
1130
- : path.join(directory, declaredPath)
1131
- if (existsSync(resolvedPath)) {
1132
- return readFileSync(resolvedPath, "utf-8")
1133
- }
1134
- }
1135
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 {
1136
2234
  const statePath = resolveStateFile(directory, "execution-state.md")
1137
2235
  if (!existsSync(statePath)) return null
1138
2236
 
1139
- const stateContent = readFileSync(statePath, "utf-8")
1140
- const planMatch = stateContent.match(/\\*\\*Plan\\*\\*:\\s*(?:\`)?([^\`\\n\\s]+)(?:\`)?/)
1141
- const declaredPlan = planMatch?.[1]?.trim()
1142
- if (!declaredPlan) return null
2237
+ const content = readFileSync(statePath, "utf-8")
2238
+ return content.match(/\\*\\*Feature slug\\*\\*:\\s*(?:\\\`)?([^\\\`\\s]+)/)?.[1] ?? null
2239
+ }
1143
2240
 
1144
- const resolvedPlan = path.isAbsolute(declaredPlan)
1145
- ? declaredPlan
1146
- : path.join(directory, declaredPlan)
1147
- if (!existsSync(resolvedPlan)) return null
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))
1148
2245
 
1149
- return readFileSync(resolvedPlan, "utf-8")
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
+ }))
1150
2252
  }
1151
2253
 
1152
- export default (async ({ directory }: { directory: string }) => {
1153
- const planFilesBySession = new Map<string, Set<string>>()
2254
+ function readStateValue(content: string, label: string): string {
2255
+ return content.match(new RegExp("- \\\\*\\\\*" + label + "\\\\*\\\\*:\\\\s*([^\\\\n]+)"))?.[1]?.trim() ?? "-"
2256
+ }
1154
2257
 
1155
- function getPlanFiles(sessionID: string): Set<string> {
1156
- const existing = planFilesBySession.get(sessionID)
1157
- if (existing) return existing
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
+ }
1158
2267
 
1159
- const planFiles = new Set<string>()
1160
- const content = loadActivePlanContent(directory)
1161
- if (content) {
1162
- for (const file of extractPlanFiles(content)) planFiles.add(file)
1163
- }
2268
+ function buildBoard(directory: string): string | null {
2269
+ const slug = getActiveFeatureSlug(directory)
2270
+ if (!slug) return null
1164
2271
 
1165
- planFilesBySession.set(sessionID, planFiles)
1166
- return planFiles
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
+ }
1167
2287
  }
1168
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
+
1169
2325
  return {
1170
- "tool.execute.after": async (
1171
- input: { tool: string; sessionID: string; callID: string; args: any },
1172
- output: { title: string; output: string; metadata: any }
1173
- ) => {
1174
- if (!["Write", "Edit", "MultiEdit"].includes(input.tool)) return
1175
-
1176
- const filePath: string = input.args?.path ?? input.args?.file_path ?? ""
1177
- if (!filePath) return
1178
-
1179
- const planFiles = getPlanFiles(input.sessionID)
1180
-
1181
- // No plan loaded — nothing to guard
1182
- if (planFiles.size === 0) return
1183
-
1184
- const relPath = path.relative(directory, filePath).replace(/\\\\\\\\/g, "/")
1185
-
1186
- // Check if the modified file matches any plan reference
1187
- const inScope = [...planFiles].some(
1188
- (pf) => relPath.endsWith(pf) || relPath.includes(pf) || pf.includes(relPath)
1189
- )
1190
-
1191
- if (!inScope) {
1192
- output.output +=
1193
- \`\\n\\n[intent-gate] SCOPE WARNING: "\${relPath}" is not referenced in the current plan. \` +
1194
- \`Verify this change is necessary for the current task before continuing.\`
1195
- }
1196
- },
1197
- }
1198
- }) satisfies Plugin
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
1199
2388
  `;
1200
- // ─── Todo Enforcer ────────────────────────────────────────────────────────────
2389
+ // ─── Todo Enforcer (disabled/optional) ───────────────────────────────────────
1201
2390
  const TODO_ENFORCER = `import type { Plugin } from "@opencode-ai/plugin"
1202
2391
  import { existsSync, readFileSync, readdirSync } from "fs"
1203
2392
  import path from "path"
@@ -1303,314 +2492,199 @@ function getIncompleteTasks(directory: string): string[] {
1303
2492
  }
1304
2493
  return all
1305
2494
  }
1306
-
1307
- export default (async ({ directory }: { directory: string }) => ({
1308
- "experimental.session.compacting": async (
1309
- _input: Record<string, unknown>,
1310
- output: { context: string[]; prompt?: string }
1311
- ) => {
1312
- const incomplete = getIncompleteTasks(directory)
1313
- if (incomplete.length === 0) return
1314
-
1315
- output.context.push(
1316
- \`[todo-enforcer] \${incomplete.length} incomplete task(s) remaining:\\n\\n\` +
1317
- incomplete.join("\\n") +
1318
- \`\\n\\nDo not stop until all tasks are complete. Continue working.\`
1319
- )
1320
- },
1321
- "tool.execute.after": async (
1322
- input: { tool: string; sessionID: string; callID: string; args: any },
1323
- output: { title: string; output: string; metadata: any }
1324
- ) => {
1325
- if (!["Write", "Edit", "MultiEdit"].includes(input.tool)) return
1326
-
1327
- const incomplete = getIncompleteTasks(directory)
1328
- if (incomplete.length === 0) return
1329
-
1330
- output.output +=
1331
- \`\\n\\n[todo-enforcer] \${incomplete.length} task(s) still pending. Continue working.\`
1332
- },
1333
- })) satisfies Plugin
1334
- `;
1335
- // ─── Comment Checker ──────────────────────────────────────────────────────────
1336
- const COMMENT_CHECKER = `import type { Plugin } from "@opencode-ai/plugin"
1337
-
1338
- // Detects obvious/redundant comments after Write/Edit and appends a reminder.
1339
- // Uses tool.execute.after — appends to output.output so agent sees the warning.
1340
-
1341
- const OBVIOUS_PATTERNS = [
1342
- /\\/\\/ increment .*/i,
1343
- /\\/\\/ set .* to/i,
1344
- /\\/\\/ return .*/i,
1345
- /\\/\\/ call .*/i,
1346
- /\\/\\/ create .* variable/i,
1347
- /\\/\\/ check if/i,
1348
- /\\/\\/ loop (through|over|for)/i,
1349
- /\\/\\/ define function/i,
1350
- /\\/\\/ initialize/i,
1351
- /\\/\\/ assign/i,
1352
- ]
1353
-
1354
- const IGNORE_PATTERNS = [
1355
- /\\/\\/\\s*@ts-/,
1356
- /\\/\\/\\s*eslint/,
1357
- /\\/\\/\\s*TODO/i,
1358
- /\\/\\/\\s*FIXME/i,
1359
- /\\/\\/\\s*HACK/i,
1360
- /\\/\\/\\s*NOTE:/i,
1361
- /\\/\\/\\s*BUG:/i,
1362
- /\\/\\*\\*/,
1363
- /\\s*\\*\\s/,
1364
- /given|when|then/i,
1365
- /describe|it\\(/,
1366
- ]
1367
-
1368
- function hasObviousComments(content: string): string[] {
1369
- const lines = content.split("\\n")
1370
- const found: string[] = []
1371
-
1372
- for (let i = 0; i < lines.length; i++) {
1373
- const line = lines[i]
1374
- if (IGNORE_PATTERNS.some((p) => p.test(line))) continue
1375
- if (OBVIOUS_PATTERNS.some((p) => p.test(line))) {
1376
- found.push(\`Line \${i + 1}: \${line.trim()}\`)
1377
- }
1378
- }
1379
-
1380
- return found
1381
- }
1382
-
1383
- export default (async ({ directory: _directory }: { directory: string }) => ({
1384
- "tool.execute.after": async (
1385
- input: { tool: string; sessionID: string; callID: string; args: any },
1386
- output: { title: string; output: string; metadata: any }
1387
- ) => {
1388
- if (!["Write", "Edit"].includes(input.tool)) return
1389
-
1390
- const content: string = input.args?.content ?? input.args?.new_string ?? ""
1391
- if (!content) return
1392
-
1393
- const obvious = hasObviousComments(content)
1394
- if (obvious.length === 0) return
1395
-
1396
- output.output +=
1397
- \`\\n\\n[comment-checker] \${obvious.length} potentially obvious comment(s) detected:\\n\` +
1398
- obvious.slice(0, 3).join("\\n") +
1399
- \`\\nConsider removing redundant comments — code should be self-documenting.\`
1400
- },
1401
- })) satisfies Plugin
1402
- `;
1403
- // ─── Hashline Read ────────────────────────────────────────────────────────────
1404
- const HASHLINE_READ = `import type { Plugin } from "@opencode-ai/plugin"
1405
- import crypto from "crypto"
1406
-
1407
- // Tags each line in Read output with NN#XX: prefix for stable hash references.
1408
- // Agent uses these tags when editing — hashline-edit.ts validates them.
1409
- // Uses tool.execute.after — sets output.output to the tagged version.
1410
-
1411
- function hashLine(line: string): string {
1412
- return crypto.createHash("md5").update(line).digest("hex").slice(0, 2)
1413
- }
1414
-
1415
- function addHashlines(content: string): string {
1416
- return content
1417
- .split("\\n")
1418
- .map((line, i) => {
1419
- const lineNum = String(i + 1).padStart(3, "0")
1420
- const hash = hashLine(line)
1421
- return \`\${lineNum}#\${hash}: \${line}\`
1422
- })
1423
- .join("\\n")
1424
- }
1425
-
1426
- export default (async ({ directory: _directory }: { directory: string }) => ({
1427
- "tool.execute.after": async (
1428
- input: { tool: string; sessionID: string; callID: string; args: any },
1429
- output: { title: string; output: string; metadata: any }
1430
- ) => {
1431
- if (input.tool !== "Read") return
1432
- if (typeof output.output !== "string") return
1433
-
1434
- output.output = addHashlines(output.output)
1435
- },
1436
- })) satisfies Plugin
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
1437
2523
  `;
1438
- // ─── Hashline Edit ────────────────────────────────────────────────────────────
1439
- const HASHLINE_EDIT = `import type { Plugin } from "@opencode-ai/plugin"
1440
- import { existsSync, readFileSync } from "fs"
1441
- import crypto from "crypto"
1442
-
1443
- // Validates hashline references before Edit tool calls.
1444
- // Throws an Error (aborts the edit) if referenced hashes are stale.
1445
- // Uses tool.execute.before — output.args has the edit arguments.
1446
-
1447
- function hashLine(line: string): string {
1448
- return crypto.createHash("md5").update(line).digest("hex").slice(0, 2)
1449
- }
1450
-
1451
- const HASHLINE_REF = /^(\\d{3})#([a-f0-9]{2}):/
1452
-
1453
- function extractHashlineRefs(text: string): Array<{ lineNum: number; hash: string }> {
1454
- return text
1455
- .split("\\n")
1456
- .map((line) => {
1457
- const match = HASHLINE_REF.exec(line)
1458
- if (!match) return null
1459
- return { lineNum: parseInt(match[1], 10), hash: match[2] }
1460
- })
1461
- .filter((r): r is { lineNum: number; hash: string } => r !== null)
1462
- }
1463
-
1464
- export default (async ({ directory: _directory }: { directory: string }) => ({
1465
- "tool.execute.before": async (
1466
- input: { tool: string; sessionID: string; callID: string },
1467
- output: { args: any }
1468
- ) => {
1469
- if (input.tool !== "Edit") return
1470
-
1471
- const filePath: string = output.args?.path ?? output.args?.file_path ?? ""
1472
- const oldString: string = output.args?.old_string ?? ""
1473
-
1474
- if (!filePath || !oldString || !existsSync(filePath)) return
1475
-
1476
- const refs = extractHashlineRefs(oldString)
1477
- if (refs.length === 0) return
1478
-
1479
- const currentLines = readFileSync(filePath, "utf-8").split("\\n")
1480
-
1481
- for (const ref of refs) {
1482
- const lineIndex = ref.lineNum - 1
1483
- if (lineIndex >= currentLines.length) {
1484
- throw new Error(
1485
- \`[hashline-edit] Stale reference: line \${ref.lineNum} no longer exists in \${filePath}.\\n\` +
1486
- \`Re-read the file to get current hashlines.\`
1487
- )
1488
- }
1489
-
1490
- const currentHash = hashLine(currentLines[lineIndex])
1491
- if (currentHash !== ref.hash) {
1492
- throw new Error(
1493
- \`[hashline-edit] Stale reference at line \${ref.lineNum}: expected hash \${ref.hash}, got \${currentHash}.\\n\` +
1494
- \`Re-read the file to get current hashlines.\`
1495
- )
1496
- }
1497
- }
1498
- },
1499
- })) 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
1500
2591
  `;
1501
- // ─── Directory Agents Injector ────────────────────────────────────────────────
1502
- const DIR_AGENTS_INJECTOR = `import type { Plugin } from "@opencode-ai/plugin"
1503
- import { existsSync, readFileSync } from "fs"
1504
- import path from "path"
1505
-
1506
- // Tier 1 context mechanismhierarchical AGENTS.md injection.
1507
- // When an agent reads a file, walks the directory tree from the file's location
1508
- // to the project root and appends every AGENTS.md found to the Read output.
1509
- // Injects from root → most specific (additive, layered context).
1510
- // Uses tool.execute.after on Read — appends to output.output.
1511
-
1512
- function findAgentsMdFiles(filePath: string, projectRoot: string): string[] {
1513
- const result: string[] = []
1514
- let current = path.dirname(filePath)
1515
-
1516
- // Walk up to project root (exclusive root AGENTS.md is auto-loaded by OpenCode)
1517
- while (current !== projectRoot && current !== path.dirname(current)) {
1518
- const agentsMd = path.join(current, "AGENTS.md")
1519
- if (existsSync(agentsMd)) {
1520
- result.unshift(agentsMd) // prepend for root → specific order
1521
- }
1522
- current = path.dirname(current)
1523
- }
1524
-
1525
- return result
1526
- }
1527
-
1528
- export default (async ({ directory }: { directory: string }) => {
1529
- const injectedPaths = new Set<string>()
1530
-
1531
- return {
1532
- "tool.execute.after": async (
1533
- input: { tool: string; sessionID: string; callID: string; args: any },
1534
- output: { title: string; output: string; metadata: any }
1535
- ) => {
1536
- if (input.tool !== "Read") return
1537
-
1538
- const filePath: string = input.args?.path ?? input.args?.file_path ?? ""
1539
- if (!filePath || !filePath.startsWith(directory)) return
1540
-
1541
- const agentsMdFiles = findAgentsMdFiles(filePath, directory)
1542
- const toInject: string[] = []
1543
-
1544
- for (const agentsPath of agentsMdFiles) {
1545
- if (injectedPaths.has(agentsPath)) continue
1546
- injectedPaths.add(agentsPath)
1547
-
1548
- const content = readFileSync(agentsPath, "utf-8")
1549
- const relPath = path.relative(directory, agentsPath)
1550
- toInject.push(\`[directory-agents-injector] Context from \${relPath}:\\n\\n\${content}\`)
1551
- }
1552
-
1553
- if (toInject.length > 0) {
1554
- output.output += "\\n\\n" + toInject.join("\\n\\n---\\n\\n")
1555
- }
1556
- },
1557
- }
1558
- }) satisfies Plugin
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
1559
2626
  `;
1560
- // ─── Memory (persistent-context injection) ────────────────────────────────────
1561
- const MEMORY = `import type { Plugin } from "@opencode-ai/plugin"
1562
- import { existsSync, readFileSync } from "fs"
1563
- import path from "path"
1564
-
1565
- // Injects persistent-context.md (cross-session repo memory, like OpenClaw).
1566
- // This file is written by UNIFY and contains project conventions, decisions,
1567
- // and patterns accumulated across sessions.
1568
- // Two hooks:
1569
- // tool.execute.after injects on the FIRST tool call of a session so the
1570
- // agent has repo memory from the very beginning.
1571
- // experimental.session.compacting — re-injects during compaction so memory
1572
- // survives context window resets.
1573
-
1574
- function loadMemory(directory: string): string | null {
1575
- const memoryPath = path.join(directory, ".opencode", "state", "persistent-context.md")
1576
- if (!existsSync(memoryPath)) return null
1577
-
1578
- const content = readFileSync(memoryPath, "utf-8").trim()
1579
- if (!content) return null
1580
-
1581
- return content
1582
- }
1583
-
1584
- export default (async ({ directory }: { directory: string }) => {
1585
- const injectedSessions = new Set<string>()
1586
-
1587
- return {
1588
- "tool.execute.after": async (
1589
- input: { tool: string; sessionID: string; callID: string; args: any },
1590
- output: { title: string; output: string; metadata: any }
1591
- ) => {
1592
- // Fire once per session — first tool call triggers injection
1593
- if (injectedSessions.has(input.sessionID)) return
1594
- injectedSessions.add(input.sessionID)
1595
-
1596
- const memory = loadMemory(directory)
1597
- if (!memory) return
1598
-
1599
- output.output +=
1600
- \`\\n\\n[memory] Project memory (persistent-context):\\n\\n\${memory}\`
1601
- },
1602
- "experimental.session.compacting": async (
1603
- _input: Record<string, unknown>,
1604
- output: { context: string[]; prompt?: string }
1605
- ) => {
1606
- const memory = loadMemory(directory)
1607
- if (!memory) return
1608
-
1609
- output.context.push(
1610
- \`[memory] Project memory (persistent-context):\\n\\n\${memory}\`
1611
- )
1612
- },
1613
- }
1614
- }) 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
1615
2689
  `;
1616
2690
  //# sourceMappingURL=plugins.js.map