@kleber.mottajr/juninho 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js +110 -16
- package/dist/installer.js.map +1 -1
- package/dist/project-types.d.ts.map +1 -1
- package/dist/project-types.js +6 -0
- package/dist/project-types.js.map +1 -1
- package/dist/templates/agents.d.ts.map +1 -1
- package/dist/templates/agents.js +670 -358
- package/dist/templates/agents.js.map +1 -1
- package/dist/templates/commands.js +525 -449
- package/dist/templates/commands.js.map +1 -1
- package/dist/templates/docs.d.ts.map +1 -1
- package/dist/templates/docs.js +13 -0
- package/dist/templates/docs.js.map +1 -1
- package/dist/templates/lib.d.ts +2 -0
- package/dist/templates/lib.d.ts.map +1 -0
- package/dist/templates/lib.js +506 -0
- package/dist/templates/lib.js.map +1 -0
- package/dist/templates/plugins.d.ts.map +1 -1
- package/dist/templates/plugins.js +2287 -1213
- package/dist/templates/plugins.js.map +1 -1
- package/dist/templates/skills.d.ts.map +1 -1
- package/dist/templates/skills.js +30 -0
- package/dist/templates/skills.js.map +1 -1
- package/dist/templates/state.d.ts.map +1 -1
- package/dist/templates/state.js +21 -0
- package/dist/templates/state.js.map +1 -1
- package/dist/templates/support-scripts.d.ts.map +1 -1
- package/dist/templates/support-scripts.js +85 -0
- package/dist/templates/support-scripts.js.map +1 -1
- package/package.json +4 -1
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
229
|
+
interface DomainEntry {
|
|
230
|
+
domain: string
|
|
231
|
+
keywords: string[]
|
|
232
|
+
files: Array<{ path: string; description: string }>
|
|
51
233
|
}
|
|
52
234
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
235
|
+
interface CollectedEntry {
|
|
236
|
+
content: string
|
|
237
|
+
priority: number
|
|
238
|
+
type: "principle" | "domain"
|
|
239
|
+
label: string
|
|
57
240
|
}
|
|
58
241
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
71
|
-
|
|
250
|
+
interface TaskPlanContext {
|
|
251
|
+
taskID: string
|
|
252
|
+
files: string[]
|
|
253
|
+
action: string
|
|
254
|
+
verify: string
|
|
255
|
+
done: string
|
|
72
256
|
}
|
|
73
257
|
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
214
|
-
|
|
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
|
|
218
|
-
|
|
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
|
|
222
|
-
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
405
|
+
class ContextCollector {
|
|
406
|
+
private collected = new Map<string, CollectedEntry>()
|
|
407
|
+
private totalBytes = 0
|
|
240
408
|
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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
|
-
|
|
263
|
-
const
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
pendingByParent.set(input.sessionID, queue)
|
|
279
|
-
},
|
|
470
|
+
return null
|
|
471
|
+
}
|
|
280
472
|
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
518
|
+
function extractFeatureSlugFromPath(filePath: string): string | null {
|
|
519
|
+
return filePath.match(/docs\\/specs\\/([^/]+)\\//)?.[1] ?? null
|
|
520
|
+
}
|
|
295
521
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
303
|
-
|
|
526
|
+
function extractPlanPathFromPrompt(prompt: string): string | null {
|
|
527
|
+
return prompt.match(/docs\\/specs\\/[^\\s]+\\/plan\\.md/)?.[0] ?? null
|
|
528
|
+
}
|
|
304
529
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
ownerSessionTitle: title || undefined,
|
|
309
|
-
}
|
|
530
|
+
function readIfExists(filePath: string): string {
|
|
531
|
+
return existsSync(filePath) ? readFileSync(filePath, "utf-8") : ""
|
|
532
|
+
}
|
|
310
533
|
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
342
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
|
359
|
-
|
|
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
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
|
373
|
-
const
|
|
374
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
382
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
394
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
-
|
|
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
|
-
|
|
460
|
-
return value.replace(/\\/g, "\\\\").replace(/\"/g, '\\\"')
|
|
786
|
+
return addedKeys
|
|
461
787
|
}
|
|
462
788
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
481
|
-
// Never block the session on notification failures.
|
|
802
|
+
return collector
|
|
482
803
|
}
|
|
483
|
-
}
|
|
484
804
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
//
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
581
|
-
|
|
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
|
-
|
|
584
|
-
const
|
|
585
|
-
|
|
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
|
-
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
const
|
|
591
|
-
|
|
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
|
-
|
|
849
|
+
function hasTaskScopedRuntime(sessionID: string): boolean {
|
|
850
|
+
return Boolean(loadRuntimeMetadata(directory, sessionID)?.taskID)
|
|
594
851
|
}
|
|
595
852
|
|
|
596
|
-
function
|
|
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
|
-
\`[
|
|
599
|
-
|
|
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 (
|
|
609
|
-
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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"
|
|
623
|
-
|
|
624
|
-
const
|
|
625
|
-
if (!
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
633
|
-
output: { context: string[] }
|
|
991
|
+
input: { sessionID?: string },
|
|
992
|
+
output: { context: string[]; prompt?: string }
|
|
634
993
|
) => {
|
|
635
|
-
|
|
636
|
-
|
|
994
|
+
if (!input.sessionID) return
|
|
995
|
+
|
|
996
|
+
const collector = collectorsBySession.get(input.sessionID)
|
|
997
|
+
if (!collector) return
|
|
637
998
|
|
|
638
|
-
|
|
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 =
|
|
1078
|
-
' if (!
|
|
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 =
|
|
1087
|
-
' if (!
|
|
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
|
-
// ───
|
|
1101
|
-
const
|
|
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
|
|
1140
|
-
|
|
1141
|
-
|
|
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
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
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
|
|
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
|
-
|
|
1153
|
-
|
|
2254
|
+
function readStateValue(content: string, label: string): string {
|
|
2255
|
+
return content.match(new RegExp("- \\\\*\\\\*" + label + "\\\\*\\\\*:\\\\s*([^\\\\n]+)"))?.[1]?.trim() ?? "-"
|
|
2256
|
+
}
|
|
1154
2257
|
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
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
|
-
|
|
1166
|
-
|
|
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
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|
-
// ───
|
|
1439
|
-
const
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
const
|
|
1477
|
-
if (
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
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
|
-
// ───
|
|
1502
|
-
const
|
|
1503
|
-
import
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
//
|
|
1507
|
-
//
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
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
|
-
// ───
|
|
1561
|
-
const
|
|
1562
|
-
import { existsSync, readFileSync } from "fs"
|
|
1563
|
-
import
|
|
1564
|
-
|
|
1565
|
-
//
|
|
1566
|
-
//
|
|
1567
|
-
//
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
)
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
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
|