@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.
Files changed (35) hide show
  1. package/assets/commands/oc-brainstorm.md +2 -0
  2. package/assets/commands/oc-review-agents.md +103 -0
  3. package/assets/commands/oc-review-pr.md +2 -0
  4. package/assets/commands/oc-tdd.md +2 -0
  5. package/assets/commands/oc-write-plan.md +2 -0
  6. package/assets/skills/coding-standards/SKILL.md +313 -0
  7. package/assets/skills/csharp-patterns/SKILL.md +327 -0
  8. package/assets/skills/frontend-design/SKILL.md +433 -0
  9. package/assets/skills/java-patterns/SKILL.md +258 -0
  10. package/assets/templates/cli-tool.md +49 -0
  11. package/assets/templates/fullstack.md +71 -0
  12. package/assets/templates/library.md +49 -0
  13. package/assets/templates/web-api.md +60 -0
  14. package/bin/cli.ts +1 -1
  15. package/bin/configure-tui.ts +1 -1
  16. package/package.json +1 -1
  17. package/src/agents/debugger.ts +329 -0
  18. package/src/agents/index.ts +16 -5
  19. package/src/agents/planner.ts +563 -0
  20. package/src/agents/reviewer.ts +270 -0
  21. package/src/config.ts +76 -18
  22. package/src/health/checks.ts +182 -1
  23. package/src/health/runner.ts +20 -2
  24. package/src/hooks/anti-slop.ts +132 -0
  25. package/src/hooks/slop-patterns.ts +71 -0
  26. package/src/index.ts +11 -0
  27. package/src/installer.ts +11 -3
  28. package/src/orchestrator/fallback/fallback-config.ts +21 -0
  29. package/src/orchestrator/fallback/mock-interceptor.ts +51 -0
  30. package/src/registry/model-groups.ts +4 -1
  31. package/src/review/stack-gate.ts +2 -0
  32. package/src/skills/adaptive-injector.ts +47 -2
  33. package/src/tools/configure.ts +1 -1
  34. package/src/tools/doctor.ts +6 -0
  35. 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: [...forceUpdate.updated, ...agents.copied, ...commands.copied, ...skills.copied],
207
- skipped: [...agents.skipped, ...commands.skipped, ...skills.skipped],
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
@@ -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
- return [...new Set(results.flat())];
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
  /**
@@ -314,7 +314,7 @@ async function handleCommit(configPath?: string): Promise<string> {
314
314
  }
315
315
  const newConfig = {
316
316
  ...currentConfig,
317
- version: 5 as const,
317
+ version: 6 as const,
318
318
  configured: true,
319
319
  groups: groupsRecord,
320
320
  overrides: currentConfig.overrides ?? {},
@@ -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
+ }