@kodrunhq/opencode-autopilot 1.11.0 → 1.12.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/assets/commands/oc-brainstorm.md +2 -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/bin/cli.ts +1 -1
- package/bin/configure-tui.ts +1 -1
- package/package.json +1 -1
- package/src/agents/index.ts +4 -2
- 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/orchestrator/fallback/fallback-config.ts +21 -0
- package/src/orchestrator/fallback/mock-interceptor.ts +51 -0
- package/src/tools/configure.ts +1 -1
- package/src/tools/doctor.ts +6 -0
- package/src/utils/language-resolver.ts +34 -0
|
@@ -4,4 +4,6 @@ description: Start a brainstorming session with Socratic design refinement
|
|
|
4
4
|
|
|
5
5
|
Use the brainstorming skill to explore the topic through Socratic questioning. Ask clarifying questions, explore alternatives, generate at least 3 distinct approaches, and present a structured design recommendation.
|
|
6
6
|
|
|
7
|
+
Detect the project language from manifest files (package.json, tsconfig.json, go.mod, Cargo.toml, pom.xml, *.csproj, pyproject.toml) and tailor code examples, library suggestions, and architectural patterns to that language ecosystem.
|
|
8
|
+
|
|
7
9
|
$ARGUMENTS
|
|
@@ -14,6 +14,8 @@ First, gather the PR context:
|
|
|
14
14
|
|
|
15
15
|
Then analyze the PR against these dimensions:
|
|
16
16
|
|
|
17
|
+
Detect the project language from manifest files (package.json, tsconfig.json, go.mod, Cargo.toml, pom.xml, *.csproj, pyproject.toml) and apply language-specific idioms, framework conventions, and ecosystem best practices when reviewing.
|
|
18
|
+
|
|
17
19
|
- **Correctness:** Does the code do what the PR description claims? Are there logic errors, off-by-one bugs, or missing edge cases?
|
|
18
20
|
- **Security:** Are there hardcoded secrets, unsanitized inputs, missing auth checks, or injection vulnerabilities?
|
|
19
21
|
- **Code quality:** Does the code follow the coding-standards skill? Check naming, file size, function size, error handling, immutability, and separation of concerns.
|
|
@@ -4,4 +4,6 @@ description: Implement a feature using strict RED-GREEN-REFACTOR TDD methodology
|
|
|
4
4
|
|
|
5
5
|
Use the tdd-workflow skill to implement the feature following strict RED-GREEN-REFACTOR. Write the failing test first (RED), implement minimally to pass (GREEN), then clean up (REFACTOR).
|
|
6
6
|
|
|
7
|
+
Detect the project language from manifest files (package.json, tsconfig.json, go.mod, Cargo.toml, pom.xml, *.csproj, pyproject.toml) and adapt test framework recommendations, assertion styles, and code examples to that language.
|
|
8
|
+
|
|
7
9
|
$ARGUMENTS
|
|
@@ -4,4 +4,6 @@ description: Decompose a feature into a structured implementation plan with task
|
|
|
4
4
|
|
|
5
5
|
Use the plan-writing skill to decompose the feature into bite-sized tasks with exact file paths, dependency waves, and verification criteria for each task.
|
|
6
6
|
|
|
7
|
+
Detect the project language from manifest files (package.json, tsconfig.json, go.mod, Cargo.toml, pom.xml, *.csproj, pyproject.toml) and reference language-specific tooling, test frameworks, and build commands in the plan.
|
|
8
|
+
|
|
7
9
|
$ARGUMENTS
|
package/bin/cli.ts
CHANGED
|
@@ -308,7 +308,7 @@ export async function runDoctor(options: CliOptions = {}): Promise<void> {
|
|
|
308
308
|
|
|
309
309
|
function printUsage(): void {
|
|
310
310
|
console.log("");
|
|
311
|
-
console.log(bold("Usage:")
|
|
311
|
+
console.log(`${bold("Usage:")} opencode-autopilot <command>`);
|
|
312
312
|
console.log("");
|
|
313
313
|
console.log("Commands:");
|
|
314
314
|
console.log(" install Register the plugin and create starter config");
|
package/bin/configure-tui.ts
CHANGED
|
@@ -307,7 +307,7 @@ export async function runConfigure(configPath: string = CONFIG_PATH): Promise<vo
|
|
|
307
307
|
|
|
308
308
|
const newConfig = {
|
|
309
309
|
...baseConfig,
|
|
310
|
-
version:
|
|
310
|
+
version: 6 as const,
|
|
311
311
|
configured: true,
|
|
312
312
|
groups: groupsRecord,
|
|
313
313
|
overrides: baseConfig.overrides ?? {},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kodrunhq/opencode-autopilot",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.1",
|
|
4
4
|
"description": "Curated agents, skills, and commands for the OpenCode AI coding CLI — autonomous orchestrator, multi-agent code review, model fallback, and in-session asset creation tools.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"keywords": [
|
package/src/agents/index.ts
CHANGED
|
@@ -42,11 +42,13 @@ function registerAgents(
|
|
|
42
42
|
groups: Readonly<Record<string, GroupModelAssignment>>,
|
|
43
43
|
overrides: Readonly<Record<string, AgentOverride>>,
|
|
44
44
|
): void {
|
|
45
|
+
const agentRef = config.agent;
|
|
46
|
+
if (!agentRef) return;
|
|
45
47
|
for (const [name, agentConfig] of Object.entries(agentMap)) {
|
|
46
|
-
if (
|
|
48
|
+
if (agentRef[name] === undefined) {
|
|
47
49
|
// Deep-copy agent config including nested permission to avoid shared references.
|
|
48
50
|
const resolved = resolveModelForAgent(name, groups, overrides);
|
|
49
|
-
|
|
51
|
+
agentRef[name] = {
|
|
50
52
|
...agentConfig,
|
|
51
53
|
...(agentConfig.permission && { permission: { ...agentConfig.permission } }),
|
|
52
54
|
...(resolved && { model: resolved.primary }),
|
package/src/config.ts
CHANGED
|
@@ -2,7 +2,13 @@ import { randomBytes } from "node:crypto";
|
|
|
2
2
|
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
fallbackConfigSchema,
|
|
7
|
+
fallbackConfigSchemaV6,
|
|
8
|
+
fallbackDefaults,
|
|
9
|
+
fallbackDefaultsV6,
|
|
10
|
+
testModeDefaults,
|
|
11
|
+
} from "./orchestrator/fallback/fallback-config";
|
|
6
12
|
import { AGENT_REGISTRY, ALL_GROUP_IDS } from "./registry/model-groups";
|
|
7
13
|
import { ensureDir, isEnoentError } from "./utils/fs-helpers";
|
|
8
14
|
import { getGlobalConfigDir } from "./utils/paths";
|
|
@@ -149,10 +155,37 @@ const pluginConfigSchemaV5 = z
|
|
|
149
155
|
}
|
|
150
156
|
});
|
|
151
157
|
|
|
152
|
-
|
|
153
|
-
export const pluginConfigSchema = pluginConfigSchemaV5;
|
|
158
|
+
type PluginConfigV5 = z.infer<typeof pluginConfigSchemaV5>;
|
|
154
159
|
|
|
155
|
-
|
|
160
|
+
// --- V6 schema ---
|
|
161
|
+
|
|
162
|
+
const pluginConfigSchemaV6 = z
|
|
163
|
+
.object({
|
|
164
|
+
version: z.literal(6),
|
|
165
|
+
configured: z.boolean(),
|
|
166
|
+
groups: z.record(z.string(), groupModelAssignmentSchema).default({}),
|
|
167
|
+
overrides: z.record(z.string(), agentOverrideSchema).default({}),
|
|
168
|
+
orchestrator: orchestratorConfigSchema.default(orchestratorDefaults),
|
|
169
|
+
confidence: confidenceConfigSchema.default(confidenceDefaults),
|
|
170
|
+
fallback: fallbackConfigSchemaV6.default(fallbackDefaultsV6),
|
|
171
|
+
memory: memoryConfigSchema.default(memoryDefaults),
|
|
172
|
+
})
|
|
173
|
+
.superRefine((config, ctx) => {
|
|
174
|
+
for (const groupId of Object.keys(config.groups)) {
|
|
175
|
+
if (!ALL_GROUP_IDS.includes(groupId as (typeof ALL_GROUP_IDS)[number])) {
|
|
176
|
+
ctx.addIssue({
|
|
177
|
+
code: z.ZodIssueCode.custom,
|
|
178
|
+
path: ["groups", groupId],
|
|
179
|
+
message: `Unknown group id "${groupId}". Expected one of: ${ALL_GROUP_IDS.join(", ")}`,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Export aliases updated to v6
|
|
186
|
+
export const pluginConfigSchema = pluginConfigSchemaV6;
|
|
187
|
+
|
|
188
|
+
export type PluginConfig = z.infer<typeof pluginConfigSchemaV6>;
|
|
156
189
|
|
|
157
190
|
export const CONFIG_PATH = join(getGlobalConfigDir(), "opencode-autopilot.json");
|
|
158
191
|
|
|
@@ -228,7 +261,7 @@ function migrateV3toV4(v3Config: PluginConfigV3): PluginConfigV4 {
|
|
|
228
261
|
};
|
|
229
262
|
}
|
|
230
263
|
|
|
231
|
-
function migrateV4toV5(v4Config: PluginConfigV4):
|
|
264
|
+
function migrateV4toV5(v4Config: PluginConfigV4): PluginConfigV5 {
|
|
232
265
|
return {
|
|
233
266
|
version: 5 as const,
|
|
234
267
|
configured: v4Config.configured,
|
|
@@ -241,6 +274,19 @@ function migrateV4toV5(v4Config: PluginConfigV4): PluginConfig {
|
|
|
241
274
|
};
|
|
242
275
|
}
|
|
243
276
|
|
|
277
|
+
function migrateV5toV6(v5Config: PluginConfigV5): PluginConfig {
|
|
278
|
+
return {
|
|
279
|
+
version: 6 as const,
|
|
280
|
+
configured: v5Config.configured,
|
|
281
|
+
groups: v5Config.groups,
|
|
282
|
+
overrides: v5Config.overrides,
|
|
283
|
+
orchestrator: v5Config.orchestrator,
|
|
284
|
+
confidence: v5Config.confidence,
|
|
285
|
+
fallback: { ...v5Config.fallback, testMode: testModeDefaults },
|
|
286
|
+
memory: v5Config.memory,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
244
290
|
// --- Public API ---
|
|
245
291
|
|
|
246
292
|
export async function loadConfig(configPath: string = CONFIG_PATH): Promise<PluginConfig | null> {
|
|
@@ -248,49 +294,61 @@ export async function loadConfig(configPath: string = CONFIG_PATH): Promise<Plug
|
|
|
248
294
|
const raw = await readFile(configPath, "utf-8");
|
|
249
295
|
const parsed = JSON.parse(raw);
|
|
250
296
|
|
|
251
|
-
// Try
|
|
297
|
+
// Try v6 first
|
|
298
|
+
const v6Result = pluginConfigSchemaV6.safeParse(parsed);
|
|
299
|
+
if (v6Result.success) return v6Result.data;
|
|
300
|
+
|
|
301
|
+
// Try v5 and migrate to v6
|
|
252
302
|
const v5Result = pluginConfigSchemaV5.safeParse(parsed);
|
|
253
|
-
if (v5Result.success)
|
|
303
|
+
if (v5Result.success) {
|
|
304
|
+
const migrated = migrateV5toV6(v5Result.data);
|
|
305
|
+
await saveConfig(migrated, configPath);
|
|
306
|
+
return migrated;
|
|
307
|
+
}
|
|
254
308
|
|
|
255
|
-
// Try v4
|
|
309
|
+
// Try v4 → v5 → v6
|
|
256
310
|
const v4Result = pluginConfigSchemaV4.safeParse(parsed);
|
|
257
311
|
if (v4Result.success) {
|
|
258
|
-
const
|
|
312
|
+
const v5 = migrateV4toV5(v4Result.data);
|
|
313
|
+
const migrated = migrateV5toV6(v5);
|
|
259
314
|
await saveConfig(migrated, configPath);
|
|
260
315
|
return migrated;
|
|
261
316
|
}
|
|
262
317
|
|
|
263
|
-
// Try v3 → v4 → v5
|
|
318
|
+
// Try v3 → v4 → v5 → v6
|
|
264
319
|
const v3Result = pluginConfigSchemaV3.safeParse(parsed);
|
|
265
320
|
if (v3Result.success) {
|
|
266
321
|
const v4 = migrateV3toV4(v3Result.data);
|
|
267
|
-
const
|
|
322
|
+
const v5 = migrateV4toV5(v4);
|
|
323
|
+
const migrated = migrateV5toV6(v5);
|
|
268
324
|
await saveConfig(migrated, configPath);
|
|
269
325
|
return migrated;
|
|
270
326
|
}
|
|
271
327
|
|
|
272
|
-
// Try v2 → v3 → v4 → v5
|
|
328
|
+
// Try v2 → v3 → v4 → v5 → v6
|
|
273
329
|
const v2Result = pluginConfigSchemaV2.safeParse(parsed);
|
|
274
330
|
if (v2Result.success) {
|
|
275
331
|
const v3 = migrateV2toV3(v2Result.data);
|
|
276
332
|
const v4 = migrateV3toV4(v3);
|
|
277
|
-
const
|
|
333
|
+
const v5 = migrateV4toV5(v4);
|
|
334
|
+
const migrated = migrateV5toV6(v5);
|
|
278
335
|
await saveConfig(migrated, configPath);
|
|
279
336
|
return migrated;
|
|
280
337
|
}
|
|
281
338
|
|
|
282
|
-
// Try v1 → v2 → v3 → v4 → v5
|
|
339
|
+
// Try v1 → v2 → v3 → v4 → v5 → v6
|
|
283
340
|
const v1Result = pluginConfigSchemaV1.safeParse(parsed);
|
|
284
341
|
if (v1Result.success) {
|
|
285
342
|
const v2 = migrateV1toV2(v1Result.data);
|
|
286
343
|
const v3 = migrateV2toV3(v2);
|
|
287
344
|
const v4 = migrateV3toV4(v3);
|
|
288
|
-
const
|
|
345
|
+
const v5 = migrateV4toV5(v4);
|
|
346
|
+
const migrated = migrateV5toV6(v5);
|
|
289
347
|
await saveConfig(migrated, configPath);
|
|
290
348
|
return migrated;
|
|
291
349
|
}
|
|
292
350
|
|
|
293
|
-
return
|
|
351
|
+
return pluginConfigSchemaV6.parse(parsed); // throw with proper error
|
|
294
352
|
} catch (error: unknown) {
|
|
295
353
|
if (isEnoentError(error)) return null;
|
|
296
354
|
throw error;
|
|
@@ -313,13 +371,13 @@ export function isFirstLoad(config: PluginConfig | null): boolean {
|
|
|
313
371
|
|
|
314
372
|
export function createDefaultConfig(): PluginConfig {
|
|
315
373
|
return {
|
|
316
|
-
version:
|
|
374
|
+
version: 6 as const,
|
|
317
375
|
configured: false,
|
|
318
376
|
groups: {},
|
|
319
377
|
overrides: {},
|
|
320
378
|
orchestrator: orchestratorDefaults,
|
|
321
379
|
confidence: confidenceDefaults,
|
|
322
|
-
fallback:
|
|
380
|
+
fallback: fallbackDefaultsV6,
|
|
323
381
|
memory: memoryDefaults,
|
|
324
382
|
};
|
|
325
383
|
}
|
package/src/health/checks.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { access, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
2
4
|
import type { Config } from "@opencode-ai/plugin";
|
|
5
|
+
import { parse } from "yaml";
|
|
3
6
|
import { loadConfig } from "../config";
|
|
7
|
+
import { DB_FILE, MEMORY_DIR } from "../memory/constants";
|
|
4
8
|
import { AGENT_NAMES } from "../orchestrator/handlers/types";
|
|
9
|
+
import { detectProjectStackTags, filterSkillsByStack } from "../skills/adaptive-injector";
|
|
10
|
+
import { loadAllSkills } from "../skills/loader";
|
|
5
11
|
import { getAssetsDir, getGlobalConfigDir } from "../utils/paths";
|
|
6
12
|
import type { HealthResult } from "./types";
|
|
7
13
|
|
|
@@ -123,3 +129,178 @@ export async function assetHealthCheck(
|
|
|
123
129
|
});
|
|
124
130
|
}
|
|
125
131
|
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Check skill loading status per detected project stack.
|
|
135
|
+
* Reports which stacks are detected and how many skills match.
|
|
136
|
+
* Accepts optional skillsDir for testability (defaults to global config skills dir).
|
|
137
|
+
*/
|
|
138
|
+
export async function skillHealthCheck(
|
|
139
|
+
projectRoot: string,
|
|
140
|
+
skillsDir?: string,
|
|
141
|
+
): Promise<HealthResult> {
|
|
142
|
+
try {
|
|
143
|
+
const tags = await detectProjectStackTags(projectRoot);
|
|
144
|
+
const resolvedSkillsDir = skillsDir ?? join(getGlobalConfigDir(), "skills");
|
|
145
|
+
const allSkills = await loadAllSkills(resolvedSkillsDir);
|
|
146
|
+
const filtered = filterSkillsByStack(allSkills, tags);
|
|
147
|
+
|
|
148
|
+
const stackLabel = tags.length > 0 ? tags.join(", ") : "none";
|
|
149
|
+
return Object.freeze({
|
|
150
|
+
name: "skill-loading",
|
|
151
|
+
status: "pass" as const,
|
|
152
|
+
message: `Detected stacks: [${stackLabel}], ${filtered.size}/${allSkills.size} skills matched`,
|
|
153
|
+
details: Object.freeze([...filtered.keys()]),
|
|
154
|
+
});
|
|
155
|
+
} catch (error: unknown) {
|
|
156
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
157
|
+
return Object.freeze({
|
|
158
|
+
name: "skill-loading",
|
|
159
|
+
status: "fail" as const,
|
|
160
|
+
message: `Skill check failed: ${msg}`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Check memory DB health: existence, readability, observation count.
|
|
167
|
+
* Does NOT call getMemoryDb() to avoid creating an empty DB as a side effect.
|
|
168
|
+
* Uses readonly DB access for safe inspection.
|
|
169
|
+
* Accepts optional baseDir for testability (defaults to global config dir).
|
|
170
|
+
*/
|
|
171
|
+
export async function memoryHealthCheck(baseDir?: string): Promise<HealthResult> {
|
|
172
|
+
const resolvedBase = baseDir ?? getGlobalConfigDir();
|
|
173
|
+
const dbPath = join(resolvedBase, MEMORY_DIR, DB_FILE);
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
await access(dbPath);
|
|
177
|
+
} catch (error: unknown) {
|
|
178
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
179
|
+
if (code === "ENOENT") {
|
|
180
|
+
return Object.freeze({
|
|
181
|
+
name: "memory-db",
|
|
182
|
+
status: "pass" as const,
|
|
183
|
+
message: `Memory DB not yet initialized -- will be created on first memory capture`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
187
|
+
return Object.freeze({
|
|
188
|
+
name: "memory-db",
|
|
189
|
+
status: "fail" as const,
|
|
190
|
+
message: `Memory DB inaccessible: ${msg}`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const fileStat = await stat(dbPath);
|
|
196
|
+
const sizeKB = (fileStat.size / 1024).toFixed(1);
|
|
197
|
+
|
|
198
|
+
if (fileStat.size === 0) {
|
|
199
|
+
return Object.freeze({
|
|
200
|
+
name: "memory-db",
|
|
201
|
+
status: "fail" as const,
|
|
202
|
+
message: `Memory DB exists but is empty (0 bytes)`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const db = new Database(dbPath, { readonly: true });
|
|
207
|
+
try {
|
|
208
|
+
const row = db.query("SELECT COUNT(*) as count FROM observations").get() as {
|
|
209
|
+
count: number;
|
|
210
|
+
} | null;
|
|
211
|
+
const count = row?.count ?? 0;
|
|
212
|
+
return Object.freeze({
|
|
213
|
+
name: "memory-db",
|
|
214
|
+
status: "pass" as const,
|
|
215
|
+
message: `Memory DB exists (${count} observation${count !== 1 ? "s" : ""}, ${sizeKB}KB)`,
|
|
216
|
+
});
|
|
217
|
+
} finally {
|
|
218
|
+
db.close();
|
|
219
|
+
}
|
|
220
|
+
} catch (error: unknown) {
|
|
221
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
222
|
+
return Object.freeze({
|
|
223
|
+
name: "memory-db",
|
|
224
|
+
status: "fail" as const,
|
|
225
|
+
message: `Memory DB read error: ${msg}`,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Expected command files that should exist in the commands directory. */
|
|
231
|
+
const EXPECTED_COMMANDS: readonly string[] = Object.freeze([
|
|
232
|
+
"oc-tdd",
|
|
233
|
+
"oc-review-pr",
|
|
234
|
+
"oc-brainstorm",
|
|
235
|
+
"oc-write-plan",
|
|
236
|
+
"oc-stocktake",
|
|
237
|
+
"oc-update-docs",
|
|
238
|
+
"oc-new-agent",
|
|
239
|
+
"oc-new-skill",
|
|
240
|
+
"oc-new-command",
|
|
241
|
+
"oc-quick",
|
|
242
|
+
"oc-review-agents",
|
|
243
|
+
]);
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Check command accessibility: file existence and valid YAML frontmatter.
|
|
247
|
+
* Verifies each expected command file exists and has a non-empty description.
|
|
248
|
+
*/
|
|
249
|
+
export async function commandHealthCheck(targetDir?: string): Promise<HealthResult> {
|
|
250
|
+
const dir = targetDir ?? getGlobalConfigDir();
|
|
251
|
+
const missing: string[] = [];
|
|
252
|
+
const invalid: string[] = [];
|
|
253
|
+
|
|
254
|
+
await Promise.all(
|
|
255
|
+
EXPECTED_COMMANDS.map(async (name) => {
|
|
256
|
+
const filePath = join(dir, "commands", `${name}.md`);
|
|
257
|
+
try {
|
|
258
|
+
const content = await readFile(filePath, "utf-8");
|
|
259
|
+
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
260
|
+
if (!fmMatch) {
|
|
261
|
+
invalid.push(`${name}: no frontmatter`);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
const parsed = parse(fmMatch[1]);
|
|
266
|
+
if (
|
|
267
|
+
parsed === null ||
|
|
268
|
+
typeof parsed !== "object" ||
|
|
269
|
+
typeof parsed.description !== "string" ||
|
|
270
|
+
parsed.description.trim().length === 0
|
|
271
|
+
) {
|
|
272
|
+
invalid.push(`${name}: missing or empty description`);
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
invalid.push(`${name}: invalid YAML frontmatter`);
|
|
276
|
+
}
|
|
277
|
+
} catch (error: unknown) {
|
|
278
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
279
|
+
if (code === "ENOENT") {
|
|
280
|
+
missing.push(name);
|
|
281
|
+
} else {
|
|
282
|
+
invalid.push(`${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
missing.sort();
|
|
289
|
+
invalid.sort();
|
|
290
|
+
const issues = [...missing.map((n) => `missing: ${n}`), ...invalid];
|
|
291
|
+
|
|
292
|
+
if (issues.length === 0) {
|
|
293
|
+
return Object.freeze({
|
|
294
|
+
name: "command-accessibility",
|
|
295
|
+
status: "pass" as const,
|
|
296
|
+
message: `All ${EXPECTED_COMMANDS.length} commands accessible`,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return Object.freeze({
|
|
301
|
+
name: "command-accessibility",
|
|
302
|
+
status: "fail" as const,
|
|
303
|
+
message: `${issues.length} command issue(s) found`,
|
|
304
|
+
details: Object.freeze(issues),
|
|
305
|
+
});
|
|
306
|
+
}
|
package/src/health/runner.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { Config } from "@opencode-ai/plugin";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
agentHealthCheck,
|
|
4
|
+
assetHealthCheck,
|
|
5
|
+
commandHealthCheck,
|
|
6
|
+
configHealthCheck,
|
|
7
|
+
memoryHealthCheck,
|
|
8
|
+
skillHealthCheck,
|
|
9
|
+
} from "./checks";
|
|
3
10
|
import type { HealthReport, HealthResult } from "./types";
|
|
4
11
|
|
|
5
12
|
/**
|
|
@@ -31,6 +38,7 @@ export async function runHealthChecks(options?: {
|
|
|
31
38
|
openCodeConfig?: Config | null;
|
|
32
39
|
assetsDir?: string;
|
|
33
40
|
targetDir?: string;
|
|
41
|
+
projectRoot?: string;
|
|
34
42
|
}): Promise<HealthReport> {
|
|
35
43
|
const start = Date.now();
|
|
36
44
|
|
|
@@ -38,9 +46,19 @@ export async function runHealthChecks(options?: {
|
|
|
38
46
|
configHealthCheck(options?.configPath),
|
|
39
47
|
agentHealthCheck(options?.openCodeConfig ?? null),
|
|
40
48
|
assetHealthCheck(options?.assetsDir, options?.targetDir),
|
|
49
|
+
skillHealthCheck(options?.projectRoot ?? process.cwd()),
|
|
50
|
+
memoryHealthCheck(options?.targetDir),
|
|
51
|
+
commandHealthCheck(options?.targetDir),
|
|
41
52
|
]);
|
|
42
53
|
|
|
43
|
-
const fallbackNames = [
|
|
54
|
+
const fallbackNames = [
|
|
55
|
+
"config-validity",
|
|
56
|
+
"agent-injection",
|
|
57
|
+
"asset-directories",
|
|
58
|
+
"skill-loading",
|
|
59
|
+
"memory-db",
|
|
60
|
+
"command-accessibility",
|
|
61
|
+
];
|
|
44
62
|
const results: readonly HealthResult[] = Object.freeze(
|
|
45
63
|
settled.map((outcome, i) => settledToResult(outcome, fallbackNames[i])),
|
|
46
64
|
);
|
|
@@ -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) {
|
|
@@ -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
|
+
}
|
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
|
+
}
|