@kleber.mottajr/juninho 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +14 -15
  2. package/dist/config.d.ts +31 -1
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +57 -3
  5. package/dist/config.js.map +1 -1
  6. package/dist/installer.d.ts.map +1 -1
  7. package/dist/installer.js +59 -45
  8. package/dist/installer.js.map +1 -1
  9. package/dist/lint-detection.d.ts +2 -2
  10. package/dist/lint-detection.d.ts.map +1 -1
  11. package/dist/lint-detection.js +33 -7
  12. package/dist/lint-detection.js.map +1 -1
  13. package/dist/project-types.d.ts +7 -2
  14. package/dist/project-types.d.ts.map +1 -1
  15. package/dist/project-types.js +36 -3
  16. package/dist/project-types.js.map +1 -1
  17. package/dist/templates/agents.d.ts +2 -2
  18. package/dist/templates/agents.d.ts.map +1 -1
  19. package/dist/templates/agents.js +551 -100
  20. package/dist/templates/agents.js.map +1 -1
  21. package/dist/templates/commands.d.ts.map +1 -1
  22. package/dist/templates/commands.js +330 -285
  23. package/dist/templates/commands.js.map +1 -1
  24. package/dist/templates/docs.js +36 -24
  25. package/dist/templates/docs.js.map +1 -1
  26. package/dist/templates/plugins.d.ts.map +1 -1
  27. package/dist/templates/plugins.js +699 -99
  28. package/dist/templates/plugins.js.map +1 -1
  29. package/dist/templates/state.d.ts.map +1 -1
  30. package/dist/templates/state.js +138 -186
  31. package/dist/templates/state.js.map +1 -1
  32. package/dist/templates/support-scripts.d.ts.map +1 -1
  33. package/dist/templates/support-scripts.js +927 -247
  34. package/dist/templates/support-scripts.js.map +1 -1
  35. package/dist/templates/tools.d.ts +2 -2
  36. package/dist/templates/tools.d.ts.map +1 -1
  37. package/dist/templates/tools.js +2 -2
  38. package/dist/templates/tools.js.map +1 -1
  39. package/package.json +5 -2
@@ -11,6 +11,12 @@ 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
+ (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.task-runtime.ts"), TASK_RUNTIME);
18
+ (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.task-board.ts"), TASK_BOARD);
19
+ (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.notify.ts"), NOTIFY);
14
20
  (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.carl-inject.ts"), CARL_INJECT);
15
21
  (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.skill-inject.ts"), skillInject(projectType, isKotlin));
16
22
  (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.intent-gate.ts"), INTENT_GATE);
@@ -21,8 +27,468 @@ function writePlugins(projectDir, projectType = "node-nextjs", isKotlin = false)
21
27
  (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.directory-agents-injector.ts"), DIR_AGENTS_INJECTOR);
22
28
  (0, fs_1.writeFileSync)(path_1.default.join(pluginsDir, "j.memory.ts"), MEMORY);
23
29
  // Write initial skill-map.json for dynamic extension by /j.finish-setup
24
- (0, fs_1.writeFileSync)(path_1.default.join(projectDir, ".opencode", "state", "skill-map.json"), JSON.stringify(getBaseSkillMap(projectType, isKotlin), null, 2) + "\n");
30
+ (0, fs_1.writeFileSync)(path_1.default.join(projectDir, ".opencode", "skill-map.json"), JSON.stringify(getBaseSkillMap(projectType, isKotlin), null, 2) + "\n");
25
31
  }
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
+ }
37
+ `;
38
+ const FEATURE_STATE_PATHS = `import { mkdirSync } from "fs"
39
+ import path from "path"
40
+
41
+ export function featureStateDir(directory: string, featureSlug: string): string {
42
+ return path.join(directory, "docs", "specs", featureSlug, "state")
43
+ }
44
+
45
+ export function featureStateTaskDir(directory: string, featureSlug: string, taskID: string): string {
46
+ return path.join(featureStateDir(directory, featureSlug), "tasks", "task-" + taskID)
47
+ }
48
+
49
+ export function featureStateSessionsDir(directory: string, featureSlug: string): string {
50
+ return path.join(featureStateDir(directory, featureSlug), "sessions")
51
+ }
52
+
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 })
57
+ }
58
+
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
+ }
68
+ }
69
+
70
+ export function featureStateSessionRuntimePath(directory: string, featureSlug: string, sessionID: string): string {
71
+ return path.join(featureStateSessionsDir(directory, featureSlug), sessionID + "-runtime.json")
72
+ }
73
+
74
+ export function featureStateImplementerLogPath(directory: string, featureSlug: string): string {
75
+ return path.join(featureStateDir(directory, featureSlug), "implementer-work.md")
76
+ }
77
+
78
+ export function featureStateManifestPath(directory: string, featureSlug: string): string {
79
+ return path.join(featureStateDir(directory, featureSlug), "integration-state.json")
80
+ }
81
+
82
+ export function featureStateReadmePath(directory: string, featureSlug: string): string {
83
+ return path.join(featureStateDir(directory, featureSlug), "README.md")
84
+ }
85
+ `;
86
+ const JUNINHO_CONFIG = `import { existsSync, readFileSync } from "fs"
87
+ import path from "path"
88
+
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
+ }
121
+ }
122
+ }
123
+
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
+ },
151
+ }
152
+
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.
184
+ }
185
+ }
186
+
187
+ return DEFAULT_CONFIG
188
+ }
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
+
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
211
+ }
212
+
213
+ function extractFeatureSlug(prompt: string): string | null {
214
+ return prompt.match(/docs\/specs\/([^/]+)\//)?.[1] ?? null
215
+ }
216
+
217
+ function extractTaskID(prompt: string): string | null {
218
+ return prompt.match(/(?:Execute|Validate) task\s+(\d+)\b/i)?.[1] ?? null
219
+ }
220
+
221
+ function extractAttempt(prompt: string): number {
222
+ const raw = prompt.match(/Attempt:\s*(\d+)/i)?.[1]
223
+ return raw ? Number.parseInt(raw, 10) : 1
224
+ }
225
+
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)
230
+ }
231
+
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
236
+
237
+ ensureFeatureStateStructure(directory, featureSlug)
238
+ const taskPaths = featureStateTaskPaths(directory, featureSlug, taskID)
239
+ mkdirSync(taskPaths.taskDir, { recursive: true })
240
+
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,
251
+ }
252
+ }
253
+
254
+ function sessionRuntimePath(directory: string, metadata: RuntimeTaskMetadata, sessionID: string): string {
255
+ return featureStateSessionRuntimePath(directory, metadata.featureSlug, sessionID)
256
+ }
257
+
258
+ function writeMetadata(filePath: string, metadata: RuntimeTaskMetadata): void {
259
+ writeFileSync(filePath, JSON.stringify(metadata, null, 2) + "\n", "utf-8")
260
+ }
261
+
262
+ export default (async ({ directory }: { directory: string }) => {
263
+ const pendingByParent = new Map<string, RuntimeTaskMetadata[]>()
264
+
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
271
+
272
+ const prompt = typeof output.args?.prompt === "string" ? output.args.prompt : ""
273
+ const metadata = buildMetadata(directory, input.sessionID, prompt)
274
+ if (!metadata) return
275
+
276
+ const queue = pendingByParent.get(input.sessionID) ?? []
277
+ queue.push(metadata)
278
+ pendingByParent.set(input.sessionID, queue)
279
+ },
280
+
281
+ event: async ({ event }: { event: { type: string; properties?: Record<string, unknown> } }) => {
282
+ if (event.type !== "session.created") return
283
+
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 : ""
290
+
291
+ if (!sessionID || !parentID) return
292
+
293
+ const queue = pendingByParent.get(parentID)
294
+ if (!queue || queue.length === 0) return
295
+
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
301
+
302
+ if (queue.length > 0) pendingByParent.set(parentID, queue)
303
+ else pendingByParent.delete(parentID)
304
+
305
+ const resolvedMetadata: RuntimeTaskMetadata = {
306
+ ...metadata,
307
+ ownerSessionID: sessionID,
308
+ ownerSessionTitle: title || undefined,
309
+ }
310
+
311
+ writeMetadata(metadata.runtimePath, resolvedMetadata)
312
+ writeMetadata(sessionRuntimePath(directory, metadata, sessionID), resolvedMetadata)
313
+ },
314
+ }
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
+
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
+ }
336
+
337
+ function getActiveFeatureSlug(directory: string): string | null {
338
+ const statePath = resolveStateFile(directory, "execution-state.md")
339
+ if (!existsSync(statePath)) return null
340
+
341
+ const content = readFileSync(statePath, "utf-8")
342
+ return content.match(/\*\*Feature slug\*\*:\s*(?:\`)?([^\`\s]+)/)?.[1] ?? null
343
+ }
344
+
345
+ function parsePlan(planPath: string): Array<{ id: string; name: string; wave: string; depends: string }> {
346
+ if (!existsSync(planPath)) return []
347
+ const content = readFileSync(planPath, "utf-8")
348
+ const tasks = Array.from(content.matchAll(/<task id="([^"]+)" wave="([^"]+)" agent="[^"]+" depends="([^"]*)">([\s\S]*?)<\/task>/g))
349
+
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
+ }))
356
+ }
357
+
358
+ function readStateValue(content: string, label: string): string {
359
+ return content.match(new RegExp("- \\*\\*" + label + "\\*\\*:\\s*([^\\n]+)"))?.[1]?.trim() ?? "-"
360
+ }
361
+
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"
369
+ }
370
+ }
371
+
372
+ function buildBoard(directory: string): string | null {
373
+ const slug = getActiveFeatureSlug(directory)
374
+ if (!slug) return null
375
+
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
380
+
381
+ const planTasks = parsePlan(planPath)
382
+ if (planTasks.length === 0) return null
383
+
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
390
+ }
391
+ }
392
+
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]
397
+
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",
412
+ }
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
+
426
+ export default (async ({ directory }: { directory: string }) => {
427
+ const lastBoardBySession = new Map<string, string>()
428
+
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
437
+
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
447
+
448
+ output.context.push(board)
449
+ },
450
+ }
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
+
459
+ function escapeAppleScript(value: string): string {
460
+ return value.replace(/\\/g, "\\\\").replace(/\"/g, '\\\"')
461
+ }
462
+
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
+ })
479
+ }
480
+ } catch {
481
+ // Never block the session on notification failures.
482
+ }
483
+ }
484
+
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
+ `;
26
492
  // ─── Env Protection ──────────────────────────────────────────────────────────
27
493
  const ENV_PROTECTION = `import type { Plugin } from "@opencode-ai/plugin"
28
494
 
@@ -100,61 +566,79 @@ export default (async ({ directory: _directory }: { directory: string }) => ({
100
566
  })) satisfies Plugin
101
567
  `;
102
568
  // ─── Plan Autoload ────────────────────────────────────────────────────────────
103
- const PLAN_AUTOLOAD = `import type { Plugin } from "@opencode-ai/plugin"
104
- import { existsSync, readFileSync, unlinkSync } from "fs"
105
- import path from "path"
106
-
107
- // Injects active plan into agent context when a .plan-ready IPC flag exists.
108
- // Uses tool.execute.after on Read the first Read triggers plan injection.
109
- // Also uses experimental.session.compacting to survive session compaction.
110
- // The .plan-ready flag is deleted after first injection (fire-once).
111
-
112
- export default (async ({ directory }: { directory: string }) => {
113
- let planInjected = false
114
-
115
- return {
116
- "tool.execute.after": async (
117
- input: { tool: string; sessionID: string; callID: string; args: any },
118
- output: { title: string; output: string; metadata: any }
119
- ) => {
120
- if (input.tool !== "Read" || planInjected) return
121
-
122
- const readyFile = path.join(directory, ".opencode", "state", ".plan-ready")
123
- if (!existsSync(readyFile)) return
124
-
125
- const planPath = readFileSync(readyFile, "utf-8").trim()
126
- const fullPath = path.isAbsolute(planPath) ? planPath : path.join(directory, planPath)
127
- if (!existsSync(fullPath)) return
128
-
129
- const planContent = readFileSync(fullPath, "utf-8")
130
- planInjected = true
131
-
132
- try { unlinkSync(readyFile) } catch { /* ok */ }
133
-
134
- output.output +=
135
- \`\\n\\n[plan-autoload] Active plan detected at \${planPath}:\\n\\n\${planContent}\\n\\n\` +
136
- \`Use /j.implement to execute this plan, or /j.plan to revise it.\`
137
- },
138
-
139
- "experimental.session.compacting": async (
140
- _input: { sessionID?: string },
141
- output: { context: string[] }
142
- ) => {
143
- // Ensure plan survives session compaction
144
- const readyFile = path.join(directory, ".opencode", "state", ".plan-ready")
145
- if (existsSync(readyFile)) {
146
- const planPath = readFileSync(readyFile, "utf-8").trim()
147
- const fullPath = path.isAbsolute(planPath) ? planPath : path.join(directory, planPath)
148
- if (existsSync(fullPath)) {
149
- const planContent = readFileSync(fullPath, "utf-8")
150
- output.context.push(
151
- \`[plan-autoload] Active plan at \${planPath}:\\n\\n\${planContent}\`
152
- )
153
- }
154
- }
155
- },
156
- }
157
- }) satisfies Plugin
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"
573
+
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.
579
+
580
+ export default (async ({ directory }: { directory: string }) => {
581
+ const planInjectedSessions = new Set<string>()
582
+
583
+ function loadActivePlan(): { planPath: string; planContent: string } | null {
584
+ const activePlanFile = resolveStateFile(directory, "active-plan.json")
585
+ if (!existsSync(activePlanFile)) return null
586
+
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
592
+
593
+ return { planPath, planContent: readFileSync(fullPath, "utf-8") }
594
+ }
595
+
596
+ function renderPlan(planPath: string, planContent: string): string {
597
+ 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.\`
600
+ )
601
+ }
602
+
603
+ return {
604
+ "chat.message": async (
605
+ input: { sessionID: string },
606
+ output: { message: { system?: string }; parts: unknown[] }
607
+ ) => {
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)
617
+ },
618
+ "tool.execute.after": async (
619
+ input: { tool: string; sessionID: string; callID: string; args: any },
620
+ output: { title: string; output: string; metadata: any }
621
+ ) => {
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)
629
+ },
630
+
631
+ "experimental.session.compacting": async (
632
+ _input: { sessionID?: string },
633
+ output: { context: string[] }
634
+ ) => {
635
+ const loaded = loadActivePlan()
636
+ if (!loaded) return
637
+
638
+ output.context.push(renderPlan(loaded.planPath, loaded.planContent))
639
+ },
640
+ }
641
+ }) satisfies Plugin
158
642
  `;
159
643
  // ─── CARL Inject ──────────────────────────────────────────────────────────────
160
644
  const CARL_INJECT = `import type { Plugin } from "@opencode-ai/plugin"
@@ -551,13 +1035,13 @@ function skillInject(projectType, isKotlin) {
551
1035
  'import path from "path"',
552
1036
  '',
553
1037
  '// Injects skill instructions via tool.execute.after on Read + Write.',
554
- '// SKILL_MAP is loaded from .opencode/state/skill-map.json for dynamic',
1038
+ '// SKILL_MAP is loaded from .opencode/skill-map.json for dynamic',
555
1039
  '// extension by /j.finish-setup. Falls back to hardcoded base patterns.',
556
1040
  '',
557
1041
  'interface SkillMapEntry { pattern: string; skill: string }',
558
1042
  '',
559
1043
  'function loadSkillMap(directory: string): Array<{ pattern: RegExp; skill: string }> {',
560
- ' const mapPath = path.join(directory, ".opencode", "state", "skill-map.json")',
1044
+ ' const mapPath = path.join(directory, ".opencode", "skill-map.json")',
561
1045
  ' let entries: SkillMapEntry[] = []',
562
1046
  '',
563
1047
  ' if (existsSync(mapPath)) {',
@@ -614,9 +1098,10 @@ function skillInject(projectType, isKotlin) {
614
1098
  return lines.join('\n') + '\n';
615
1099
  }
616
1100
  // ─── Intent Gate ─────────────────────────────────────────────────────────────
617
- const INTENT_GATE = `import type { Plugin } from "@opencode-ai/plugin"
618
- import { existsSync, readFileSync } from "fs"
619
- import path from "path"
1101
+ const INTENT_GATE = `import type { Plugin } from "@opencode-ai/plugin"
1102
+ import { existsSync, readFileSync } from "fs"
1103
+ import path from "path"
1104
+ import { resolveStateFile } from "./j.state-paths"
620
1105
 
621
1106
  // Scope-guard: after any Write/Edit, checks if the modified file is part of
622
1107
  // the current plan. If it drifts outside the plan scope, appends a warning.
@@ -635,10 +1120,53 @@ function extractPlanFiles(planContent: string): Set<string> {
635
1120
  return files
636
1121
  }
637
1122
 
638
- export default (async ({ directory }: { directory: string }) => {
639
- let planFiles: Set<string> | null = null
640
-
641
- return {
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
+
1136
+ const statePath = resolveStateFile(directory, "execution-state.md")
1137
+ if (!existsSync(statePath)) return null
1138
+
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
1143
+
1144
+ const resolvedPlan = path.isAbsolute(declaredPlan)
1145
+ ? declaredPlan
1146
+ : path.join(directory, declaredPlan)
1147
+ if (!existsSync(resolvedPlan)) return null
1148
+
1149
+ return readFileSync(resolvedPlan, "utf-8")
1150
+ }
1151
+
1152
+ export default (async ({ directory }: { directory: string }) => {
1153
+ const planFilesBySession = new Map<string, Set<string>>()
1154
+
1155
+ function getPlanFiles(sessionID: string): Set<string> {
1156
+ const existing = planFilesBySession.get(sessionID)
1157
+ if (existing) return existing
1158
+
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
+ }
1164
+
1165
+ planFilesBySession.set(sessionID, planFiles)
1166
+ return planFiles
1167
+ }
1168
+
1169
+ return {
642
1170
  "tool.execute.after": async (
643
1171
  input: { tool: string; sessionID: string; callID: string; args: any },
644
1172
  output: { title: string; output: string; metadata: any }
@@ -648,19 +1176,7 @@ export default (async ({ directory }: { directory: string }) => {
648
1176
  const filePath: string = input.args?.path ?? input.args?.file_path ?? ""
649
1177
  if (!filePath) return
650
1178
 
651
- // Lazy-load plan files on first Write/Edit
652
- if (planFiles === null) {
653
- planFiles = new Set<string>()
654
- const planDir = path.join(directory, ".opencode", "state")
655
- // Try multiple plan file names
656
- for (const name of ["plan.md", "plan-ready.md"]) {
657
- const planPath = path.join(planDir, name)
658
- if (existsSync(planPath)) {
659
- const content = readFileSync(planPath, "utf-8")
660
- for (const f of extractPlanFiles(content)) planFiles.add(f)
661
- }
662
- }
663
- }
1179
+ const planFiles = getPlanFiles(input.sessionID)
664
1180
 
665
1181
  // No plan loaded — nothing to guard
666
1182
  if (planFiles.size === 0) return
@@ -682,27 +1198,111 @@ export default (async ({ directory }: { directory: string }) => {
682
1198
  }) satisfies Plugin
683
1199
  `;
684
1200
  // ─── Todo Enforcer ────────────────────────────────────────────────────────────
685
- const TODO_ENFORCER = `import type { Plugin } from "@opencode-ai/plugin"
686
- import { existsSync, readFileSync } from "fs"
687
- import path from "path"
688
-
689
- // Re-injects incomplete tasks to prevent the agent from forgetting pending work.
690
- // Two hooks:
691
- // experimental.session.compacting injects pending tasks into compaction
692
- // context so they survive context window resets.
693
- // tool.execute.after on Write/Editlean reminder of pending count after
694
- // file modifications, nudging the agent to continue.
695
-
696
- function getIncompleteTasks(directory: string): string[] {
697
- const statePath = path.join(directory, ".opencode", "state", "execution-state.md")
698
- if (!existsSync(statePath)) return []
699
-
700
- const state = readFileSync(statePath, "utf-8")
701
- return state
702
- .split("\\n")
703
- .filter((line) => /^\\s*-\\s*\\[\\s*\\]/.test(line))
704
- .map((line) => line.trim())
705
- }
1201
+ const TODO_ENFORCER = `import type { Plugin } from "@opencode-ai/plugin"
1202
+ import { existsSync, readFileSync, readdirSync } from "fs"
1203
+ import path from "path"
1204
+ import { featureStateTaskDir } from "./j.feature-state-paths"
1205
+ import { resolveStateFile } from "./j.state-paths"
1206
+
1207
+ // Re-injects incomplete tasks to prevent the agent from forgetting pending work.
1208
+ // Three sources of truth (checked in order):
1209
+ // 1. .opencode/state/execution-state.mdglobal session summary
1210
+ // 2. docs/specs/{slug}/state/tasks/task-*/execution-state.md per-task state files
1211
+ //
1212
+ // Two hooks:
1213
+ // experimental.session.compacting injects pending tasks into compaction
1214
+ // context so they survive context window resets.
1215
+ // tool.execute.after on Write/Edit — lean reminder of pending count after
1216
+ // file modifications, nudging the agent to continue.
1217
+
1218
+ function getIncompleteFromFile(filePath: string): string[] {
1219
+ if (!existsSync(filePath)) return []
1220
+ const content = readFileSync(filePath, "utf-8")
1221
+ return content
1222
+ .split("\\n")
1223
+ .filter((line) => /^\\s*-\\s*\\[\\s*\\]/.test(line))
1224
+ .map((line) => line.trim())
1225
+ }
1226
+
1227
+ function parseTaskState(filePath: string): string | null {
1228
+ if (!existsSync(filePath)) return null
1229
+
1230
+ const content = readFileSync(filePath, "utf-8")
1231
+ const statusMatch = content.match(/- \*\*Status\*\*:\s*([^\n]+)/)
1232
+ const waveMatch = content.match(/- \*\*Wave\*\*:\s*([^\n]+)/)
1233
+ const attemptMatch = content.match(/- \*\*Attempt\*\*:\s*([^\n]+)/)
1234
+ const heartbeatMatch = content.match(/- \*\*Last heartbeat\*\*:\s*([^\n]+)/)
1235
+ const failureMatch = content.match(/## Failure Details \(if FAILED\/BLOCKED\)\n([\s\S]*)$/)
1236
+ const fileNameMatch = filePath.match(/tasks\/task-(\d+)\/execution-state\.md$/)
1237
+
1238
+ const taskID = fileNameMatch?.[1] ?? "?"
1239
+ const status = statusMatch?.[1]?.trim()
1240
+ if (!status || status === "COMPLETE") return null
1241
+
1242
+ const wave = waveMatch?.[1]?.trim() ?? "?"
1243
+ const attempt = attemptMatch?.[1]?.trim() ?? "1"
1244
+ const heartbeat = heartbeatMatch?.[1]?.trim()
1245
+ const failure = failureMatch?.[1]?.trim()
1246
+
1247
+ let summary = "- [ ] Task " + taskID + " (wave " + wave + ", attempt " + attempt + ") — " + status
1248
+ if (heartbeat) summary += " — heartbeat " + heartbeat
1249
+ if (status === "FAILED" || status === "BLOCKED") {
1250
+ const detail = failure && failure !== "None." ? failure.split("\\n")[0].trim() : "see task state"
1251
+ summary += " — " + detail
1252
+ }
1253
+
1254
+ return summary
1255
+ }
1256
+
1257
+ function getActiveFeatureSlug(directory: string): string | null {
1258
+ const statePath = resolveStateFile(directory, "execution-state.md")
1259
+ if (!existsSync(statePath)) return null
1260
+
1261
+ const content = readFileSync(statePath, "utf-8")
1262
+ const planMatch = content.match(/\*\*Plan\*\*:\s*(?:\`)?(?:docs\/specs\/([^/\`\s]+)\/plan\.md)/)
1263
+ if (planMatch) return planMatch[1]
1264
+
1265
+ const slugMatch = content.match(/\*\*Feature slug\*\*:\s*(?:\`)?([^\`\s]+)/)
1266
+ if (slugMatch) return slugMatch[1]
1267
+
1268
+ return null
1269
+ }
1270
+
1271
+ function getPerTaskIncomplete(directory: string, slug: string): string[] {
1272
+ const tasksDir = path.join(directory, "docs", "specs", slug, "state", "tasks")
1273
+ if (!existsSync(tasksDir)) return []
1274
+
1275
+ const tasks: string[] = []
1276
+ try {
1277
+ const taskDirs = readdirSync(tasksDir).filter((f) => f.startsWith("task-"))
1278
+ for (const taskDirName of taskDirs) {
1279
+ const taskDir = featureStateTaskDir(directory, slug, taskDirName.replace(/^task-/, ""))
1280
+ const summary = parseTaskState(path.join(taskDir, "execution-state.md"))
1281
+ if (summary) tasks.push(summary)
1282
+ }
1283
+ } catch {
1284
+ // Directory read failed — silently skip
1285
+ }
1286
+ return tasks
1287
+ }
1288
+
1289
+ function getIncompleteTasks(directory: string): string[] {
1290
+ const globalPath = resolveStateFile(directory, "execution-state.md")
1291
+ const globalTasks = getIncompleteFromFile(globalPath)
1292
+
1293
+ const slug = getActiveFeatureSlug(directory)
1294
+ const perTaskTasks = slug ? getPerTaskIncomplete(directory, slug) : []
1295
+
1296
+ const seen = new Set<string>()
1297
+ const all: string[] = []
1298
+ for (const task of [...globalTasks, ...perTaskTasks]) {
1299
+ if (!seen.has(task)) {
1300
+ seen.add(task)
1301
+ all.push(task)
1302
+ }
1303
+ }
1304
+ return all
1305
+ }
706
1306
 
707
1307
  export default (async ({ directory }: { directory: string }) => ({
708
1308
  "experimental.session.compacting": async (