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