@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.
- package/README.md +14 -15
- package/dist/config.d.ts +31 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +57 -3
- package/dist/config.js.map +1 -1
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js +59 -45
- package/dist/installer.js.map +1 -1
- package/dist/lint-detection.d.ts +2 -2
- package/dist/lint-detection.d.ts.map +1 -1
- package/dist/lint-detection.js +33 -7
- package/dist/lint-detection.js.map +1 -1
- package/dist/project-types.d.ts +7 -2
- package/dist/project-types.d.ts.map +1 -1
- package/dist/project-types.js +36 -3
- package/dist/project-types.js.map +1 -1
- package/dist/templates/agents.d.ts +2 -2
- package/dist/templates/agents.d.ts.map +1 -1
- package/dist/templates/agents.js +551 -100
- package/dist/templates/agents.js.map +1 -1
- package/dist/templates/commands.d.ts.map +1 -1
- package/dist/templates/commands.js +330 -285
- package/dist/templates/commands.js.map +1 -1
- package/dist/templates/docs.js +36 -24
- package/dist/templates/docs.js.map +1 -1
- package/dist/templates/plugins.d.ts.map +1 -1
- package/dist/templates/plugins.js +699 -99
- package/dist/templates/plugins.js.map +1 -1
- package/dist/templates/state.d.ts.map +1 -1
- package/dist/templates/state.js +138 -186
- package/dist/templates/state.js.map +1 -1
- package/dist/templates/support-scripts.d.ts.map +1 -1
- package/dist/templates/support-scripts.js +927 -247
- package/dist/templates/support-scripts.js.map +1 -1
- package/dist/templates/tools.d.ts +2 -2
- package/dist/templates/tools.d.ts.map +1 -1
- package/dist/templates/tools.js +2 -2
- package/dist/templates/tools.js.map +1 -1
- 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", "
|
|
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
|
|
105
|
-
import path from "path"
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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/
|
|
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", "
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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.md — global 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 (
|