@kodrunhq/opencode-autopilot 1.11.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.
@@ -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:") + " opencode-autopilot <command>");
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");
@@ -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: 5 as const,
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.11.0",
3
+ "version": "1.12.0",
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": [
@@ -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 (config.agent![name] === undefined) {
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
- config.agent![name] = {
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 { fallbackConfigSchema, fallbackDefaults } from "./orchestrator/fallback/fallback-config";
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
- // Export aliases updated to v5
153
- export const pluginConfigSchema = pluginConfigSchemaV5;
158
+ type PluginConfigV5 = z.infer<typeof pluginConfigSchemaV5>;
154
159
 
155
- export type PluginConfig = z.infer<typeof pluginConfigSchemaV5>;
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): PluginConfig {
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 v5 first
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) return v5Result.data;
303
+ if (v5Result.success) {
304
+ const migrated = migrateV5toV6(v5Result.data);
305
+ await saveConfig(migrated, configPath);
306
+ return migrated;
307
+ }
254
308
 
255
- // Try v4 and migrate to v5
309
+ // Try v4 v5 v6
256
310
  const v4Result = pluginConfigSchemaV4.safeParse(parsed);
257
311
  if (v4Result.success) {
258
- const migrated = migrateV4toV5(v4Result.data);
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 migrated = migrateV4toV5(v4);
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 migrated = migrateV4toV5(v4);
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 migrated = migrateV4toV5(v4);
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 pluginConfigSchemaV5.parse(parsed); // throw with proper error
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: 5 as const,
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: fallbackDefaults,
380
+ fallback: fallbackDefaultsV6,
323
381
  memory: memoryDefaults,
324
382
  };
325
383
  }
@@ -1,7 +1,13 @@
1
- import { access } from "node:fs/promises";
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
+ }
@@ -1,5 +1,12 @@
1
1
  import type { Config } from "@opencode-ai/plugin";
2
- import { agentHealthCheck, assetHealthCheck, configHealthCheck } from "./checks";
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 = ["config-validity", "agent-injection", "asset-directories"];
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
+ }
@@ -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
+ }