@kodrunhq/opencode-autopilot 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/commands/oc-brainstorm.md +2 -0
- package/assets/commands/oc-review-agents.md +103 -0
- package/assets/commands/oc-review-pr.md +2 -0
- package/assets/commands/oc-tdd.md +2 -0
- package/assets/commands/oc-write-plan.md +2 -0
- package/assets/skills/coding-standards/SKILL.md +313 -0
- package/assets/skills/csharp-patterns/SKILL.md +327 -0
- package/assets/skills/frontend-design/SKILL.md +433 -0
- package/assets/skills/java-patterns/SKILL.md +258 -0
- package/assets/templates/cli-tool.md +49 -0
- package/assets/templates/fullstack.md +71 -0
- package/assets/templates/library.md +49 -0
- package/assets/templates/web-api.md +60 -0
- package/bin/cli.ts +1 -1
- package/bin/configure-tui.ts +1 -1
- package/package.json +1 -1
- package/src/agents/debugger.ts +329 -0
- package/src/agents/index.ts +16 -5
- package/src/agents/planner.ts +563 -0
- package/src/agents/reviewer.ts +270 -0
- package/src/config.ts +76 -18
- package/src/health/checks.ts +182 -1
- package/src/health/runner.ts +20 -2
- package/src/hooks/anti-slop.ts +132 -0
- package/src/hooks/slop-patterns.ts +71 -0
- package/src/index.ts +11 -0
- package/src/installer.ts +11 -3
- package/src/orchestrator/fallback/fallback-config.ts +21 -0
- package/src/orchestrator/fallback/mock-interceptor.ts +51 -0
- package/src/registry/model-groups.ts +4 -1
- package/src/review/stack-gate.ts +2 -0
- package/src/skills/adaptive-injector.ts +47 -2
- package/src/tools/configure.ts +1 -1
- package/src/tools/doctor.ts +6 -0
- package/src/utils/language-resolver.ts +34 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anti-slop hook: detects AI-generated comment bloat in code files.
|
|
3
|
+
* Warn-only (non-blocking) -- fires as PostToolUse after file-writing tools.
|
|
4
|
+
*/
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { extname, isAbsolute, resolve } from "node:path";
|
|
7
|
+
import {
|
|
8
|
+
CODE_EXTENSIONS,
|
|
9
|
+
COMMENT_PATTERNS,
|
|
10
|
+
EXT_COMMENT_STYLE,
|
|
11
|
+
SLOP_PATTERNS,
|
|
12
|
+
} from "./slop-patterns";
|
|
13
|
+
|
|
14
|
+
/** A single detected slop comment occurrence. */
|
|
15
|
+
export interface SlopFinding {
|
|
16
|
+
readonly line: number;
|
|
17
|
+
readonly text: string;
|
|
18
|
+
readonly pattern: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Returns true if the file path has a code extension eligible for scanning. */
|
|
22
|
+
export function isCodeFile(filePath: string): boolean {
|
|
23
|
+
return CODE_EXTENSIONS.has(extname(filePath).toLowerCase());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Scans content for slop comments matching curated patterns.
|
|
28
|
+
* Only examines comment text (not raw code) to avoid false positives.
|
|
29
|
+
*/
|
|
30
|
+
export function scanForSlopComments(content: string, ext: string): readonly SlopFinding[] {
|
|
31
|
+
const commentStyle = EXT_COMMENT_STYLE[ext];
|
|
32
|
+
if (!commentStyle) return Object.freeze([]);
|
|
33
|
+
|
|
34
|
+
const commentRegex = COMMENT_PATTERNS[commentStyle];
|
|
35
|
+
if (!commentRegex) return Object.freeze([]);
|
|
36
|
+
|
|
37
|
+
const lines = content.split("\n");
|
|
38
|
+
const findings: SlopFinding[] = [];
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < lines.length; i++) {
|
|
41
|
+
const match = commentRegex.exec(lines[i]);
|
|
42
|
+
if (!match?.[1]) continue;
|
|
43
|
+
|
|
44
|
+
const commentText = match[1].trim();
|
|
45
|
+
|
|
46
|
+
for (const pattern of SLOP_PATTERNS) {
|
|
47
|
+
if (pattern.test(commentText)) {
|
|
48
|
+
findings.push(
|
|
49
|
+
Object.freeze({
|
|
50
|
+
line: i + 1,
|
|
51
|
+
text: commentText,
|
|
52
|
+
pattern: pattern.source,
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
break; // one finding per line
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return Object.freeze(findings);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Tools that write files and should be scanned for slop. */
|
|
64
|
+
const FILE_WRITING_TOOLS: ReadonlySet<string> = Object.freeze(
|
|
65
|
+
new Set(["write_file", "edit_file", "write", "edit", "create_file"]),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Creates a tool.execute.after handler that scans for slop comments.
|
|
70
|
+
* Best-effort: never throws, never blocks the pipeline.
|
|
71
|
+
*/
|
|
72
|
+
export function createAntiSlopHandler(options: {
|
|
73
|
+
readonly showToast: (
|
|
74
|
+
title: string,
|
|
75
|
+
message: string,
|
|
76
|
+
variant: "info" | "warning" | "error",
|
|
77
|
+
) => Promise<void>;
|
|
78
|
+
}) {
|
|
79
|
+
return async (
|
|
80
|
+
hookInput: {
|
|
81
|
+
readonly tool: string;
|
|
82
|
+
readonly sessionID: string;
|
|
83
|
+
readonly callID: string;
|
|
84
|
+
readonly args: unknown;
|
|
85
|
+
},
|
|
86
|
+
_output: { title: string; output: string; metadata: unknown },
|
|
87
|
+
): Promise<void> => {
|
|
88
|
+
if (!FILE_WRITING_TOOLS.has(hookInput.tool)) return;
|
|
89
|
+
|
|
90
|
+
// Extract file path from args with type-safe narrowing
|
|
91
|
+
const args = hookInput.args;
|
|
92
|
+
if (args === null || typeof args !== "object") return;
|
|
93
|
+
const record = args as Record<string, unknown>;
|
|
94
|
+
const rawPath = record.file_path ?? record.filePath ?? record.path ?? record.file;
|
|
95
|
+
if (typeof rawPath !== "string" || rawPath.length === 0) return;
|
|
96
|
+
|
|
97
|
+
// Validate path is absolute and within cwd (prevent path traversal)
|
|
98
|
+
if (!isAbsolute(rawPath)) return;
|
|
99
|
+
const resolved = resolve(rawPath);
|
|
100
|
+
const cwd = process.cwd();
|
|
101
|
+
if (!resolved.startsWith(`${cwd}/`) && resolved !== cwd) return;
|
|
102
|
+
|
|
103
|
+
if (!isCodeFile(resolved)) return;
|
|
104
|
+
const ext = extname(resolved).toLowerCase();
|
|
105
|
+
|
|
106
|
+
// Read the actual file content — output.output is the tool's result message, not file content
|
|
107
|
+
let fileContent: string;
|
|
108
|
+
try {
|
|
109
|
+
fileContent = await readFile(resolved, "utf-8");
|
|
110
|
+
} catch {
|
|
111
|
+
return; // file unreadable — best-effort, skip
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const findings = scanForSlopComments(fileContent, ext);
|
|
115
|
+
if (findings.length === 0) return;
|
|
116
|
+
|
|
117
|
+
const preview = findings
|
|
118
|
+
.slice(0, 5)
|
|
119
|
+
.map((f) => `L${f.line}: ${f.text}`)
|
|
120
|
+
.join("\n");
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
await options.showToast(
|
|
124
|
+
"Anti-Slop Warning",
|
|
125
|
+
`${findings.length} AI comment(s) detected:\n${preview}`,
|
|
126
|
+
"warning",
|
|
127
|
+
);
|
|
128
|
+
} catch {
|
|
129
|
+
// best-effort -- toast failure is non-fatal
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated regex patterns for detecting AI-generated comment bloat ("slop").
|
|
3
|
+
* All exports are frozen for immutability.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Code file extensions eligible for anti-slop scanning. */
|
|
7
|
+
export const CODE_EXTENSIONS: ReadonlySet<string> = Object.freeze(
|
|
8
|
+
new Set([
|
|
9
|
+
".ts",
|
|
10
|
+
".tsx",
|
|
11
|
+
".js",
|
|
12
|
+
".jsx",
|
|
13
|
+
".py",
|
|
14
|
+
".go",
|
|
15
|
+
".rs",
|
|
16
|
+
".java",
|
|
17
|
+
".cs",
|
|
18
|
+
".rb",
|
|
19
|
+
".cpp",
|
|
20
|
+
".c",
|
|
21
|
+
".h",
|
|
22
|
+
]),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
/** Maps file extension to its single-line comment prefix. */
|
|
26
|
+
export const EXT_COMMENT_STYLE: Readonly<Record<string, string>> = Object.freeze({
|
|
27
|
+
".ts": "//",
|
|
28
|
+
".tsx": "//",
|
|
29
|
+
".js": "//",
|
|
30
|
+
".jsx": "//",
|
|
31
|
+
".java": "//",
|
|
32
|
+
".cs": "//",
|
|
33
|
+
".go": "//",
|
|
34
|
+
".rs": "//",
|
|
35
|
+
".c": "//",
|
|
36
|
+
".cpp": "//",
|
|
37
|
+
".h": "//",
|
|
38
|
+
".py": "#",
|
|
39
|
+
".rb": "#",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** Regex to extract comment text from a line given its comment prefix.
|
|
43
|
+
* Matches both full-line comments and inline trailing comments.
|
|
44
|
+
* Negative lookbehind (?<!:) prevents matching :// in URLs. */
|
|
45
|
+
export const COMMENT_PATTERNS: Readonly<Record<string, RegExp>> = Object.freeze({
|
|
46
|
+
"//": /(?<!:)\/\/\s*(.+)/,
|
|
47
|
+
"#": /#\s*(.+)/,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Patterns matching obvious/sycophantic AI comment text.
|
|
52
|
+
* Tested against extracted comment body only (not raw code lines).
|
|
53
|
+
*/
|
|
54
|
+
export const SLOP_PATTERNS: readonly RegExp[] = Object.freeze([
|
|
55
|
+
/^increment\s+.*\s+by\s+\d+$/i,
|
|
56
|
+
/^decrement\s+.*\s+by\s+\d+$/i,
|
|
57
|
+
/^return\s+the\s+(result|value|data)\s*$/i,
|
|
58
|
+
/^(?:this|the)\s+(?:function|method|class)\s+(?:does|will|is used to|handles)/i,
|
|
59
|
+
/^(?:initialize|init)\s+(?:the\s+)?(?:variable|value|state)/i,
|
|
60
|
+
/^import\s+(?:the\s+)?(?:necessary|required|needed)/i,
|
|
61
|
+
/^define\s+(?:the\s+)?(?:interface|type|class|function)/i,
|
|
62
|
+
/\belegantly?\b/i,
|
|
63
|
+
/\brobust(?:ly|ness)?\b/i,
|
|
64
|
+
/\bcomprehensive(?:ly)?\b/i,
|
|
65
|
+
/\bseamless(?:ly)?\b/i,
|
|
66
|
+
/\blever(?:age|aging)\b/i,
|
|
67
|
+
/\bpowerful\b/i,
|
|
68
|
+
/\bsophisticated\b/i,
|
|
69
|
+
/\bstate[\s-]of[\s-]the[\s-]art\b/i,
|
|
70
|
+
/\bcutting[\s-]edge\b/i,
|
|
71
|
+
]);
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Config, Plugin } from "@opencode-ai/plugin";
|
|
|
2
2
|
import { configHook } from "./agents";
|
|
3
3
|
import { isFirstLoad, loadConfig } from "./config";
|
|
4
4
|
import { runHealthChecks } from "./health/runner";
|
|
5
|
+
import { createAntiSlopHandler } from "./hooks/anti-slop";
|
|
5
6
|
import { installAssets } from "./installer";
|
|
6
7
|
import { createMemoryCaptureHandler, createMemoryInjector, getMemoryDb } from "./memory";
|
|
7
8
|
import { ContextMonitor } from "./observability/context-monitor";
|
|
@@ -150,6 +151,9 @@ const plugin: Plugin = async (input) => {
|
|
|
150
151
|
const chatMessageHandler = createChatMessageHandler(manager);
|
|
151
152
|
const toolExecuteAfterHandler = createToolExecuteAfterHandler(manager);
|
|
152
153
|
|
|
154
|
+
// --- Anti-slop hook initialization ---
|
|
155
|
+
const antiSlopHandler = createAntiSlopHandler({ showToast: sdkOps.showToast });
|
|
156
|
+
|
|
153
157
|
// --- Memory subsystem initialization ---
|
|
154
158
|
const memoryConfig = config?.memory ?? {
|
|
155
159
|
enabled: true,
|
|
@@ -288,6 +292,13 @@ const plugin: Plugin = async (input) => {
|
|
|
288
292
|
if (fallbackConfig.enabled) {
|
|
289
293
|
await toolExecuteAfterHandler(hookInput, output);
|
|
290
294
|
}
|
|
295
|
+
|
|
296
|
+
// Anti-slop comment detection (best-effort, non-blocking)
|
|
297
|
+
try {
|
|
298
|
+
await antiSlopHandler(hookInput, output);
|
|
299
|
+
} catch {
|
|
300
|
+
// best-effort
|
|
301
|
+
}
|
|
291
302
|
},
|
|
292
303
|
"experimental.chat.system.transform": async (input, output) => {
|
|
293
304
|
if (memoryInjector) {
|
package/src/installer.ts
CHANGED
|
@@ -196,21 +196,29 @@ export async function installAssets(
|
|
|
196
196
|
// Force-overwrite assets with critical fixes
|
|
197
197
|
const forceUpdate = await forceUpdateAssets(assetsDir, targetDir);
|
|
198
198
|
|
|
199
|
-
const [agents, commands, skills] = await Promise.all([
|
|
199
|
+
const [agents, commands, skills, templates] = await Promise.all([
|
|
200
200
|
processFiles(assetsDir, targetDir, "agents"),
|
|
201
201
|
processFiles(assetsDir, targetDir, "commands"),
|
|
202
202
|
processSkills(assetsDir, targetDir),
|
|
203
|
+
processFiles(assetsDir, targetDir, "templates"),
|
|
203
204
|
]);
|
|
204
205
|
|
|
205
206
|
return {
|
|
206
|
-
copied: [
|
|
207
|
-
|
|
207
|
+
copied: [
|
|
208
|
+
...forceUpdate.updated,
|
|
209
|
+
...agents.copied,
|
|
210
|
+
...commands.copied,
|
|
211
|
+
...skills.copied,
|
|
212
|
+
...templates.copied,
|
|
213
|
+
],
|
|
214
|
+
skipped: [...agents.skipped, ...commands.skipped, ...skills.skipped, ...templates.skipped],
|
|
208
215
|
errors: [
|
|
209
216
|
...cleanup.errors,
|
|
210
217
|
...forceUpdate.errors,
|
|
211
218
|
...agents.errors,
|
|
212
219
|
...commands.errors,
|
|
213
220
|
...skills.errors,
|
|
221
|
+
...templates.errors,
|
|
214
222
|
],
|
|
215
223
|
};
|
|
216
224
|
}
|
|
@@ -14,3 +14,24 @@ export type FallbackConfig = z.infer<typeof fallbackConfigSchema>;
|
|
|
14
14
|
|
|
15
15
|
// Pre-compute defaults for Zod v4 nested default compatibility
|
|
16
16
|
export const fallbackDefaults = fallbackConfigSchema.parse({});
|
|
17
|
+
|
|
18
|
+
// --- Test mode sub-schema (v6) ---
|
|
19
|
+
|
|
20
|
+
export const testModeSchema = z.object({
|
|
21
|
+
enabled: z.boolean().default(false),
|
|
22
|
+
sequence: z
|
|
23
|
+
.array(z.enum(["rate_limit", "quota_exceeded", "service_unavailable", "malformed", "timeout"]))
|
|
24
|
+
.default([]),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export type TestModeConfig = z.infer<typeof testModeSchema>;
|
|
28
|
+
export const testModeDefaults = testModeSchema.parse({});
|
|
29
|
+
|
|
30
|
+
// --- V6 fallback schema (extends base with testMode) ---
|
|
31
|
+
|
|
32
|
+
export const fallbackConfigSchemaV6 = fallbackConfigSchema.extend({
|
|
33
|
+
testMode: testModeSchema.default(testModeDefaults),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export type FallbackConfigV6 = z.infer<typeof fallbackConfigSchemaV6>;
|
|
37
|
+
export const fallbackDefaultsV6 = fallbackConfigSchemaV6.parse({});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createMockError } from "../../observability/mock/mock-provider";
|
|
2
|
+
import type { MockFailureMode } from "../../observability/mock/types";
|
|
3
|
+
import type { TestModeConfig } from "./fallback-config";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Deterministic sequence interceptor for fallback chain testing.
|
|
7
|
+
* Cycles through a configured sequence of failure modes, generating
|
|
8
|
+
* mock error objects compatible with the error classifier.
|
|
9
|
+
*/
|
|
10
|
+
export class MockInterceptor {
|
|
11
|
+
private index = 0;
|
|
12
|
+
private readonly sequence: readonly MockFailureMode[];
|
|
13
|
+
|
|
14
|
+
constructor(sequence: readonly MockFailureMode[]) {
|
|
15
|
+
this.sequence = sequence;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Get the next failure mode in the sequence (cycles). */
|
|
19
|
+
nextMode(): MockFailureMode {
|
|
20
|
+
if (this.sequence.length === 0) {
|
|
21
|
+
throw new Error("MockInterceptor: cannot call nextMode() on an empty sequence");
|
|
22
|
+
}
|
|
23
|
+
const mode = this.sequence[this.index % this.sequence.length];
|
|
24
|
+
this.index = (this.index + 1) % this.sequence.length;
|
|
25
|
+
return mode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Get the next mock error object (frozen, matches error-classifier shapes). */
|
|
29
|
+
nextError(): unknown {
|
|
30
|
+
return createMockError(this.nextMode());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Reset the cycle index to 0. */
|
|
34
|
+
reset(): void {
|
|
35
|
+
this.index = 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Current position in the sequence. */
|
|
39
|
+
get position(): number {
|
|
40
|
+
return this.index;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Factory: returns MockInterceptor if testMode is enabled and has a
|
|
46
|
+
* non-empty sequence, null otherwise.
|
|
47
|
+
*/
|
|
48
|
+
export function createMockInterceptor(config: TestModeConfig): MockInterceptor | null {
|
|
49
|
+
if (!config.enabled || config.sequence.length === 0) return null;
|
|
50
|
+
return new MockInterceptor(config.sequence as readonly MockFailureMode[]);
|
|
51
|
+
}
|
|
@@ -15,6 +15,7 @@ export const AGENT_REGISTRY: Readonly<Record<string, AgentEntry>> = deepFreeze({
|
|
|
15
15
|
"oc-architect": { group: "architects" },
|
|
16
16
|
"oc-planner": { group: "architects" },
|
|
17
17
|
autopilot: { group: "architects" },
|
|
18
|
+
planner: { group: "architects" },
|
|
18
19
|
|
|
19
20
|
// ── Challengers ────────────────────────────────────────────
|
|
20
21
|
// Adversarial to Architects: critique proposals, enhance ideas
|
|
@@ -22,8 +23,9 @@ export const AGENT_REGISTRY: Readonly<Record<string, AgentEntry>> = deepFreeze({
|
|
|
22
23
|
"oc-challenger": { group: "challengers" },
|
|
23
24
|
|
|
24
25
|
// ── Builders ───────────────────────────────────────────────
|
|
25
|
-
// Code generation
|
|
26
|
+
// Code generation and debugging
|
|
26
27
|
"oc-implementer": { group: "builders" },
|
|
28
|
+
debugger: { group: "builders" },
|
|
27
29
|
|
|
28
30
|
// ── Reviewers ──────────────────────────────────────────────
|
|
29
31
|
// Code analysis, adversarial to Builders
|
|
@@ -32,6 +34,7 @@ export const AGENT_REGISTRY: Readonly<Record<string, AgentEntry>> = deepFreeze({
|
|
|
32
34
|
// src/review/types.ts, not AgentConfig. The review pipeline resolves their
|
|
33
35
|
// model via resolveModelForGroup("reviewers") directly.
|
|
34
36
|
"oc-reviewer": { group: "reviewers" },
|
|
37
|
+
reviewer: { group: "reviewers" },
|
|
35
38
|
|
|
36
39
|
// ── Red Team ───────────────────────────────────────────────
|
|
37
40
|
// Final adversarial pass
|
package/src/review/stack-gate.ts
CHANGED
|
@@ -54,6 +54,8 @@ const EXTENSION_TAGS: Readonly<Record<string, readonly string[]>> = Object.freez
|
|
|
54
54
|
".svelte": Object.freeze(["svelte", "javascript"]),
|
|
55
55
|
".kt": Object.freeze(["kotlin"]),
|
|
56
56
|
".kts": Object.freeze(["kotlin"]),
|
|
57
|
+
".java": Object.freeze(["java"]),
|
|
58
|
+
".cs": Object.freeze(["csharp"]),
|
|
57
59
|
});
|
|
58
60
|
|
|
59
61
|
/**
|
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
* filtering even before any git diff is available.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { access } from "node:fs/promises";
|
|
10
|
+
import { access, readdir } from "node:fs/promises";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { sanitizeTemplateContent } from "../review/sanitize";
|
|
13
|
+
import { isEnoentError } from "../utils/fs-helpers";
|
|
13
14
|
import { resolveDependencyOrder } from "./dependency-resolver";
|
|
14
15
|
import type { LoadedSkill } from "./loader";
|
|
15
16
|
|
|
@@ -32,6 +33,21 @@ const MANIFEST_TAGS: Readonly<Record<string, readonly string[]>> = Object.freeze
|
|
|
32
33
|
"requirements.txt": Object.freeze(["python"]),
|
|
33
34
|
Pipfile: Object.freeze(["python"]),
|
|
34
35
|
Gemfile: Object.freeze(["ruby"]),
|
|
36
|
+
"pom.xml": Object.freeze(["java"]),
|
|
37
|
+
"build.gradle": Object.freeze(["java"]),
|
|
38
|
+
"build.gradle.kts": Object.freeze(["java"]),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Extension-based manifest patterns for languages that use variable filenames
|
|
43
|
+
* (e.g., MyProject.csproj, MySolution.sln). Detected via readdir + endsWith
|
|
44
|
+
* matching on the project root directory. Only checks immediate children —
|
|
45
|
+
* nested .csproj files (e.g., src/MyProject/MyProject.csproj) require the
|
|
46
|
+
* .sln file at root or diff-path detection via stack-gate.ts.
|
|
47
|
+
*/
|
|
48
|
+
const EXT_MANIFEST_TAGS: Readonly<Record<string, readonly string[]>> = Object.freeze({
|
|
49
|
+
".csproj": Object.freeze(["csharp"]),
|
|
50
|
+
".sln": Object.freeze(["csharp"]),
|
|
35
51
|
});
|
|
36
52
|
|
|
37
53
|
/**
|
|
@@ -39,6 +55,8 @@ const MANIFEST_TAGS: Readonly<Record<string, readonly string[]>> = Object.freeze
|
|
|
39
55
|
* Complements detectStackTags (which works on file paths from git diff).
|
|
40
56
|
*/
|
|
41
57
|
export async function detectProjectStackTags(projectRoot: string): Promise<readonly string[]> {
|
|
58
|
+
const tags = new Set<string>();
|
|
59
|
+
|
|
42
60
|
const results = await Promise.all(
|
|
43
61
|
Object.entries(MANIFEST_TAGS).map(async ([manifest, manifestTags]) => {
|
|
44
62
|
try {
|
|
@@ -50,7 +68,34 @@ export async function detectProjectStackTags(projectRoot: string): Promise<reado
|
|
|
50
68
|
}),
|
|
51
69
|
);
|
|
52
70
|
|
|
53
|
-
|
|
71
|
+
for (const result of results) {
|
|
72
|
+
for (const tag of result) {
|
|
73
|
+
tags.add(tag);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check extension-based manifests (e.g., *.csproj, *.sln)
|
|
78
|
+
try {
|
|
79
|
+
const entries = await readdir(projectRoot);
|
|
80
|
+
for (const [ext, extTags] of Object.entries(EXT_MANIFEST_TAGS)) {
|
|
81
|
+
if (entries.some((entry) => entry.endsWith(ext))) {
|
|
82
|
+
for (const tag of extTags) {
|
|
83
|
+
tags.add(tag);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch (error: unknown) {
|
|
88
|
+
// ENOENT is expected (directory may not exist) — skip silently.
|
|
89
|
+
// Other errors (EACCES, etc.) are logged but non-fatal.
|
|
90
|
+
if (!isEnoentError(error)) {
|
|
91
|
+
console.error(
|
|
92
|
+
"[adaptive-injector] readdir failed for project root, skipping extension detection:",
|
|
93
|
+
error instanceof Error ? error.message : String(error),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return [...tags];
|
|
54
99
|
}
|
|
55
100
|
|
|
56
101
|
/**
|
package/src/tools/configure.ts
CHANGED
|
@@ -314,7 +314,7 @@ async function handleCommit(configPath?: string): Promise<string> {
|
|
|
314
314
|
}
|
|
315
315
|
const newConfig = {
|
|
316
316
|
...currentConfig,
|
|
317
|
-
version:
|
|
317
|
+
version: 6 as const,
|
|
318
318
|
configured: true,
|
|
319
319
|
groups: groupsRecord,
|
|
320
320
|
overrides: currentConfig.overrides ?? {},
|
package/src/tools/doctor.ts
CHANGED
|
@@ -21,6 +21,7 @@ interface DoctorOptions {
|
|
|
21
21
|
readonly openCodeConfig?: Config | null;
|
|
22
22
|
readonly assetsDir?: string;
|
|
23
23
|
readonly targetDir?: string;
|
|
24
|
+
readonly projectRoot?: string;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/**
|
|
@@ -31,6 +32,10 @@ const FIX_SUGGESTIONS: Readonly<Record<string, string>> = Object.freeze({
|
|
|
31
32
|
"Run `bunx @kodrunhq/opencode-autopilot configure` to reconfigure, or delete ~/.config/opencode/opencode-autopilot.json to reset",
|
|
32
33
|
"agent-injection": "Restart OpenCode to trigger agent re-injection via config hook",
|
|
33
34
|
"asset-directories": "Restart OpenCode to trigger asset reinstallation",
|
|
35
|
+
"skill-loading": "Ensure skills directory exists in ~/.config/opencode/skills/",
|
|
36
|
+
"memory-db":
|
|
37
|
+
"Memory DB is created automatically on first memory capture -- use the plugin normally to initialize",
|
|
38
|
+
"command-accessibility": "Restart OpenCode to trigger command reinstallation from bundled assets",
|
|
34
39
|
});
|
|
35
40
|
|
|
36
41
|
function getFixSuggestion(checkName: string): string {
|
|
@@ -72,6 +77,7 @@ export async function doctorCore(options?: DoctorOptions): Promise<string> {
|
|
|
72
77
|
openCodeConfig: options?.openCodeConfig,
|
|
73
78
|
assetsDir: options?.assetsDir,
|
|
74
79
|
targetDir: options?.targetDir,
|
|
80
|
+
projectRoot: options?.projectRoot,
|
|
75
81
|
});
|
|
76
82
|
|
|
77
83
|
// Map health results to doctor checks with fix suggestions
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { detectProjectStackTags } from "../skills/adaptive-injector";
|
|
2
|
+
|
|
3
|
+
/** Cache: projectRoot -> resolved language string. */
|
|
4
|
+
const cache = new Map<string, string>();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve project language tags as a human-readable string.
|
|
8
|
+
* Caches per projectRoot to avoid repeated filesystem access.
|
|
9
|
+
*/
|
|
10
|
+
export async function resolveLanguageTag(projectRoot: string): Promise<string> {
|
|
11
|
+
const cached = cache.get(projectRoot);
|
|
12
|
+
if (cached !== undefined) return cached;
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const tags = await detectProjectStackTags(projectRoot);
|
|
16
|
+
const result = tags.length > 0 ? [...tags].sort().join(", ") : "unknown";
|
|
17
|
+
cache.set(projectRoot, result);
|
|
18
|
+
return result;
|
|
19
|
+
} catch {
|
|
20
|
+
return "unknown";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Substitute $LANGUAGE in a text string with the resolved language tag.
|
|
26
|
+
*/
|
|
27
|
+
export function substituteLanguageVar(text: string, language: string): string {
|
|
28
|
+
return text.replaceAll("$LANGUAGE", language);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Clear the language cache (for testing). */
|
|
32
|
+
export function clearLanguageCache(): void {
|
|
33
|
+
cache.clear();
|
|
34
|
+
}
|