@kodrunhq/opencode-autopilot 1.12.2 → 1.14.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.
Files changed (68) hide show
  1. package/assets/commands/oc-brainstorm.md +1 -0
  2. package/assets/commands/oc-new-agent.md +1 -0
  3. package/assets/commands/oc-new-command.md +1 -0
  4. package/assets/commands/oc-new-skill.md +1 -0
  5. package/assets/commands/oc-quick.md +1 -0
  6. package/assets/commands/oc-refactor.md +26 -0
  7. package/assets/commands/oc-review-agents.md +1 -0
  8. package/assets/commands/oc-review-pr.md +1 -0
  9. package/assets/commands/oc-security-audit.md +20 -0
  10. package/assets/commands/oc-stocktake.md +1 -0
  11. package/assets/commands/oc-tdd.md +1 -0
  12. package/assets/commands/oc-update-docs.md +1 -0
  13. package/assets/commands/oc-write-plan.md +1 -0
  14. package/assets/skills/api-design/SKILL.md +391 -0
  15. package/assets/skills/brainstorming/SKILL.md +1 -0
  16. package/assets/skills/code-review/SKILL.md +1 -0
  17. package/assets/skills/coding-standards/SKILL.md +1 -0
  18. package/assets/skills/csharp-patterns/SKILL.md +1 -0
  19. package/assets/skills/database-patterns/SKILL.md +270 -0
  20. package/assets/skills/docker-deployment/SKILL.md +326 -0
  21. package/assets/skills/e2e-testing/SKILL.md +1 -0
  22. package/assets/skills/frontend-design/SKILL.md +1 -0
  23. package/assets/skills/git-worktrees/SKILL.md +1 -0
  24. package/assets/skills/go-patterns/SKILL.md +1 -0
  25. package/assets/skills/java-patterns/SKILL.md +1 -0
  26. package/assets/skills/plan-executing/SKILL.md +1 -0
  27. package/assets/skills/plan-writing/SKILL.md +1 -0
  28. package/assets/skills/python-patterns/SKILL.md +1 -0
  29. package/assets/skills/rust-patterns/SKILL.md +1 -0
  30. package/assets/skills/security-patterns/SKILL.md +312 -0
  31. package/assets/skills/strategic-compaction/SKILL.md +1 -0
  32. package/assets/skills/systematic-debugging/SKILL.md +1 -0
  33. package/assets/skills/tdd-workflow/SKILL.md +1 -0
  34. package/assets/skills/typescript-patterns/SKILL.md +1 -0
  35. package/assets/skills/verification/SKILL.md +1 -0
  36. package/package.json +1 -1
  37. package/src/agents/db-specialist.ts +295 -0
  38. package/src/agents/devops.ts +352 -0
  39. package/src/agents/frontend-engineer.ts +541 -0
  40. package/src/agents/index.ts +12 -0
  41. package/src/agents/security-auditor.ts +348 -0
  42. package/src/hooks/anti-slop.ts +40 -1
  43. package/src/hooks/slop-patterns.ts +24 -4
  44. package/src/installer.ts +29 -2
  45. package/src/memory/capture.ts +9 -4
  46. package/src/memory/decay.ts +11 -0
  47. package/src/memory/retrieval.ts +31 -2
  48. package/src/orchestrator/artifacts.ts +7 -2
  49. package/src/orchestrator/confidence.ts +3 -2
  50. package/src/orchestrator/handlers/architect.ts +11 -8
  51. package/src/orchestrator/handlers/build.ts +12 -10
  52. package/src/orchestrator/handlers/challenge.ts +9 -3
  53. package/src/orchestrator/handlers/plan.ts +115 -9
  54. package/src/orchestrator/handlers/recon.ts +9 -4
  55. package/src/orchestrator/handlers/retrospective.ts +3 -1
  56. package/src/orchestrator/handlers/ship.ts +8 -7
  57. package/src/orchestrator/handlers/types.ts +1 -0
  58. package/src/orchestrator/lesson-memory.ts +2 -1
  59. package/src/orchestrator/orchestration-logger.ts +40 -0
  60. package/src/orchestrator/phase.ts +14 -0
  61. package/src/orchestrator/schemas.ts +1 -0
  62. package/src/orchestrator/skill-injection.ts +11 -6
  63. package/src/orchestrator/state.ts +2 -1
  64. package/src/review/selection.ts +4 -32
  65. package/src/skills/adaptive-injector.ts +96 -5
  66. package/src/skills/loader.ts +4 -1
  67. package/src/tools/orchestrate.ts +141 -18
  68. package/src/tools/review.ts +2 -1
@@ -18,6 +18,24 @@ const DEFAULT_TOKEN_BUDGET = 8000;
18
18
  /** Rough estimate: 1 token ~ 4 chars */
19
19
  const CHARS_PER_TOKEN = 4;
20
20
 
21
+ /**
22
+ * Maps pipeline phases to the skill names relevant for that phase.
23
+ * Skills not in the list for the current phase are excluded from injection,
24
+ * preventing the full 13-19KB per-skill content from bloating every dispatch.
25
+ */
26
+ export const PHASE_SKILL_MAP: Readonly<Record<string, readonly string[]>> = Object.freeze({
27
+ RECON: ["plan-writing"],
28
+ CHALLENGE: ["plan-writing"],
29
+ ARCHITECT: ["plan-writing"],
30
+ PLAN: ["plan-writing", "plan-executing"],
31
+ BUILD: ["coding-standards", "tdd-workflow"],
32
+ SHIP: ["plan-executing"],
33
+ RETROSPECTIVE: [],
34
+ EXPLORE: [],
35
+ });
36
+
37
+ export type SkillMode = "summary" | "full";
38
+
21
39
  /**
22
40
  * Manifest files that indicate project stack.
23
41
  * Checks project root for these files to detect the stack.
@@ -121,14 +139,40 @@ export function filterSkillsByStack(
121
139
  return filtered;
122
140
  }
123
141
 
142
+ /**
143
+ * Build a compact summary for a single skill: frontmatter name + description
144
+ * (max 200 chars). Used in summary mode to avoid injecting full skill content.
145
+ */
146
+ export function buildSkillSummary(skill: LoadedSkill): string {
147
+ const { name, description } = skill.frontmatter;
148
+ const safeName = sanitizeTemplateContent(name);
149
+ const safeDesc = sanitizeTemplateContent((description ?? "").slice(0, 200));
150
+ return `[Skill: ${safeName}]\n${safeDesc}`;
151
+ }
152
+
153
+ /**
154
+ * In full mode, truncate skill content at the first `## ` heading boundary
155
+ * that exceeds the per-skill character budget. Preserves structure instead
156
+ * of collapsing all newlines.
157
+ */
158
+ function truncateAtSectionBoundary(content: string, maxChars: number): string {
159
+ if (content.length <= maxChars) return content;
160
+ const cutPoint = content.lastIndexOf("\n## ", maxChars);
161
+ if (cutPoint > 0) return content.slice(0, cutPoint);
162
+ return content.slice(0, maxChars);
163
+ }
164
+
124
165
  /**
125
166
  * Build multi-skill context string with dependency ordering and token budget.
126
167
  * Skills are ordered by dependency (prerequisites first), then concatenated
127
168
  * until the token budget is exhausted.
169
+ *
170
+ * @param mode - "summary" emits only name + description (compact); "full" preserves structure
128
171
  */
129
172
  export function buildMultiSkillContext(
130
173
  skills: ReadonlyMap<string, LoadedSkill>,
131
174
  tokenBudget: number = DEFAULT_TOKEN_BUDGET,
175
+ mode: SkillMode = "summary",
132
176
  ): string {
133
177
  if (skills.size === 0) return "";
134
178
 
@@ -151,17 +195,64 @@ export function buildMultiSkillContext(
151
195
  const skill = skills.get(name);
152
196
  if (!skill) continue;
153
197
 
154
- const collapsed = skill.content.replace(/[\r\n]+/g, " ");
155
- const header = `[Skill: ${name}]\n`;
198
+ let section: string;
199
+ if (mode === "summary") {
200
+ section = sanitizeTemplateContent(buildSkillSummary(skill));
201
+ } else {
202
+ // Full mode: preserve structure, truncate at section boundaries
203
+ const header = `[Skill: ${name}]\n`;
204
+ const perSkillBudget = Math.max(charBudget - totalChars - header.length, 0);
205
+ const truncated = truncateAtSectionBoundary(skill.content, perSkillBudget);
206
+ const sanitized = sanitizeTemplateContent(truncated);
207
+ section = `${header}${sanitized}`;
208
+ }
209
+
156
210
  const separator = sections.length > 0 ? 2 : 0; // "\n\n"
157
- const sectionCost = collapsed.length + header.length + separator;
211
+ const sectionCost = section.length + separator;
158
212
  if (totalChars + sectionCost > charBudget) break;
159
213
 
160
- const sanitized = sanitizeTemplateContent(collapsed);
161
- sections.push(`${header}${sanitized}`);
214
+ sections.push(section);
162
215
  totalChars += sectionCost;
163
216
  }
164
217
 
165
218
  if (sections.length === 0) return "";
166
219
  return `\n\nSkills context (follow these conventions and methodologies):\n${sections.join("\n\n")}`;
167
220
  }
221
+
222
+ /**
223
+ * Build adaptive skill context with optional phase filtering.
224
+ *
225
+ * When `phase` is provided, only skills listed in PHASE_SKILL_MAP for that
226
+ * phase are included (pipeline dispatch path). When omitted, all stack-filtered
227
+ * skills are included (direct chat injection path).
228
+ */
229
+ export function buildAdaptiveSkillContext(
230
+ skills: ReadonlyMap<string, LoadedSkill>,
231
+ options?: {
232
+ readonly phase?: string;
233
+ readonly budget?: number;
234
+ readonly mode?: SkillMode;
235
+ },
236
+ ): string {
237
+ const phase = options?.phase;
238
+ const budget = options?.budget ?? DEFAULT_TOKEN_BUDGET;
239
+ const mode = options?.mode ?? "summary";
240
+
241
+ if (phase !== undefined) {
242
+ const allowedNames = PHASE_SKILL_MAP[phase] ?? [];
243
+ if (allowedNames.length === 0) return "";
244
+
245
+ const allowedSet = new Set(allowedNames);
246
+ const filtered = new Map<string, LoadedSkill>();
247
+ for (const [name, skill] of skills) {
248
+ if (allowedSet.has(name)) {
249
+ filtered.set(name, skill);
250
+ }
251
+ }
252
+
253
+ return buildMultiSkillContext(filtered, budget, mode);
254
+ }
255
+
256
+ // No phase -- include all provided skills (caller already stack-filtered)
257
+ return buildMultiSkillContext(skills, budget, mode);
258
+ }
@@ -84,5 +84,8 @@ export async function loadAllSkills(skillsDir: string): Promise<ReadonlyMap<stri
84
84
  if (!isEnoentError(error)) throw error;
85
85
  }
86
86
 
87
- return skills;
87
+ // Sort alphabetically by name for deterministic ordering regardless of
88
+ // filesystem readdir order (which varies across OS and FS types).
89
+ const sorted = new Map([...skills.entries()].sort(([a], [b]) => a.localeCompare(b)));
90
+ return Object.freeze(sorted);
88
91
  }
@@ -4,10 +4,11 @@ import { PHASE_HANDLERS } from "../orchestrator/handlers/index";
4
4
  import type { DispatchResult } from "../orchestrator/handlers/types";
5
5
  import { buildLessonContext } from "../orchestrator/lesson-injection";
6
6
  import { loadLessonMemory } from "../orchestrator/lesson-memory";
7
- import { completePhase, getNextPhase } from "../orchestrator/phase";
7
+ import { logOrchestrationEvent } from "../orchestrator/orchestration-logger";
8
+ import { completePhase, getNextPhase, PHASE_INDEX, TOTAL_PHASES } from "../orchestrator/phase";
8
9
  import { loadAdaptiveSkillContext } from "../orchestrator/skill-injection";
9
10
  import { createInitialState, loadState, patchState, saveState } from "../orchestrator/state";
10
- import type { Phase } from "../orchestrator/types";
11
+ import type { Phase, PipelineState } from "../orchestrator/types";
11
12
  import { isEnoentError } from "../utils/fs-helpers";
12
13
  import { ensureGitignore } from "../utils/gitignore";
13
14
  import { getGlobalConfigDir, getProjectArtifactDir } from "../utils/paths";
@@ -23,10 +24,10 @@ interface OrchestrateArgs {
23
24
  * Returns the updated state.
24
25
  */
25
26
  async function applyStateUpdates(
26
- state: Readonly<import("../orchestrator/types").PipelineState>,
27
+ state: Readonly<PipelineState>,
27
28
  handlerResult: DispatchResult,
28
29
  artifactDir: string,
29
- ): Promise<import("../orchestrator/types").PipelineState> {
30
+ ): Promise<PipelineState> {
30
31
  const updates = handlerResult._stateUpdates;
31
32
  if (updates) {
32
33
  const updated = patchState(state, updates);
@@ -99,10 +100,18 @@ async function injectLessonContext(
99
100
  * Attempt to inject stack-filtered adaptive skill context into a dispatch prompt.
100
101
  * Best-effort: failures are silently swallowed to avoid breaking dispatch.
101
102
  */
102
- async function injectSkillContext(prompt: string, projectRoot?: string): Promise<string> {
103
+ async function injectSkillContext(
104
+ prompt: string,
105
+ projectRoot?: string,
106
+ phase?: string,
107
+ ): Promise<string> {
103
108
  try {
104
109
  const baseDir = getGlobalConfigDir();
105
- const ctx = await loadAdaptiveSkillContext(baseDir, projectRoot ?? process.cwd());
110
+ const ctx = await loadAdaptiveSkillContext(baseDir, projectRoot ?? process.cwd(), {
111
+ phase,
112
+ budget: 1500,
113
+ mode: "summary",
114
+ });
106
115
  if (ctx) return prompt + ctx;
107
116
  } catch (err) {
108
117
  console.warn("[opencode-autopilot] skill injection failed:", err);
@@ -110,13 +119,63 @@ async function injectSkillContext(prompt: string, projectRoot?: string): Promise
110
119
  return prompt;
111
120
  }
112
121
 
122
+ /** Build a human-readable progress string for user-facing display. */
123
+ function buildUserProgress(phase: string, label?: string, attempt?: number): string {
124
+ const idx = PHASE_INDEX[phase as Phase] ?? 0;
125
+ const desc = label ?? "dispatching";
126
+ const att = attempt != null ? ` (attempt ${attempt})` : "";
127
+ return `Phase ${idx}/${TOTAL_PHASES}: ${phase} — ${desc}${att}`;
128
+ }
129
+
130
+ /** Per-phase dispatch limits. BUILD is high because of multi-task waves. */
131
+ const MAX_PHASE_DISPATCHES: Readonly<Record<string, number>> = Object.freeze({
132
+ RECON: 3,
133
+ CHALLENGE: 3,
134
+ ARCHITECT: 10,
135
+ EXPLORE: 3,
136
+ PLAN: 5,
137
+ BUILD: 100,
138
+ SHIP: 5,
139
+ RETROSPECTIVE: 3,
140
+ });
141
+
142
+ /**
143
+ * Circuit breaker: increment per-phase dispatch count and abort if limit exceeded.
144
+ * Returns `{ abortMsg, newCount }`. When `abortMsg` is non-null the caller must
145
+ * return it immediately. `newCount` is the authoritative post-increment value.
146
+ */
147
+ async function checkCircuitBreaker(
148
+ currentState: Readonly<PipelineState>,
149
+ phase: string,
150
+ artifactDir: string,
151
+ ): Promise<{ readonly abortMsg: string | null; readonly newCount: number }> {
152
+ const counts = { ...(currentState.phaseDispatchCounts ?? {}) };
153
+ counts[phase] = (counts[phase] ?? 0) + 1;
154
+ const newCount = counts[phase];
155
+ const limit = MAX_PHASE_DISPATCHES[phase] ?? 5;
156
+ if (newCount > limit) {
157
+ const msg = `Phase ${phase} exceeded max dispatches (${newCount}/${limit}) — possible infinite loop detected. Aborting.`;
158
+ logOrchestrationEvent(artifactDir, {
159
+ timestamp: new Date().toISOString(),
160
+ phase,
161
+ action: "loop_detected",
162
+ attempt: newCount,
163
+ message: msg,
164
+ });
165
+ return { abortMsg: JSON.stringify({ action: "error", message: msg }), newCount };
166
+ }
167
+ const withCounts = patchState(currentState, { phaseDispatchCounts: counts });
168
+ await saveState(withCounts, artifactDir);
169
+ return { abortMsg: null, newCount };
170
+ }
171
+
113
172
  /**
114
173
  * Process a handler's DispatchResult, handling complete/dispatch/dispatch_multi/error.
115
174
  * On complete, advances the phase and invokes the next handler.
116
175
  */
117
176
  async function processHandlerResult(
118
177
  handlerResult: DispatchResult,
119
- state: Readonly<import("../orchestrator/types").PipelineState>,
178
+ state: Readonly<PipelineState>,
120
179
  artifactDir: string,
121
180
  ): Promise<string> {
122
181
  // Apply state updates from handler if present
@@ -124,19 +183,50 @@ async function processHandlerResult(
124
183
 
125
184
  switch (handlerResult.action) {
126
185
  case "error":
186
+ logOrchestrationEvent(artifactDir, {
187
+ timestamp: new Date().toISOString(),
188
+ phase: handlerResult.phase ?? currentState.currentPhase ?? "UNKNOWN",
189
+ action: "error",
190
+ message: handlerResult.message?.slice(0, 500),
191
+ });
127
192
  return JSON.stringify(handlerResult);
128
193
 
129
194
  case "dispatch": {
195
+ // Circuit breaker
196
+ const phase = handlerResult.phase ?? currentState.currentPhase ?? "UNKNOWN";
197
+ const { abortMsg, newCount: attempt } = await checkCircuitBreaker(
198
+ currentState,
199
+ phase,
200
+ artifactDir,
201
+ );
202
+ if (abortMsg) return abortMsg;
203
+
204
+ // Log the dispatch event before any inline-review or context injection
205
+ const progress = buildUserProgress(phase, handlerResult.progress, attempt);
206
+ logOrchestrationEvent(artifactDir, {
207
+ timestamp: new Date().toISOString(),
208
+ phase,
209
+ action: "dispatch",
210
+ agent: handlerResult.agent,
211
+ promptLength: handlerResult.prompt?.length,
212
+ attempt,
213
+ });
214
+
130
215
  // Check if this is a review dispatch that should be inlined
131
216
  const { inlined, reviewResult } = await maybeInlineReview(handlerResult, artifactDir);
132
217
  if (inlined && reviewResult) {
133
- // Feed the review result back into the current phase handler
134
218
  const reloadedState = await loadState(artifactDir);
135
219
  if (reloadedState?.currentPhase) {
136
220
  const handler = PHASE_HANDLERS[reloadedState.currentPhase];
137
221
  const nextResult = await handler(reloadedState, artifactDir, reviewResult);
138
222
  return processHandlerResult(nextResult, reloadedState, artifactDir);
139
223
  }
224
+ // State unavailable or pipeline completed after inline review — return complete
225
+ return JSON.stringify({
226
+ action: "complete",
227
+ summary: "Inline review completed; no active phase.",
228
+ _userProgress: progress,
229
+ });
140
230
  }
141
231
  // Inject lesson + skill context into dispatch prompt (best-effort)
142
232
  if (handlerResult.prompt && handlerResult.phase) {
@@ -145,34 +235,60 @@ async function processHandlerResult(
145
235
  handlerResult.phase,
146
236
  artifactDir,
147
237
  );
148
- const withSkills = await injectSkillContext(enrichedPrompt, join(artifactDir, ".."));
238
+ const withSkills = await injectSkillContext(
239
+ enrichedPrompt,
240
+ join(artifactDir, ".."),
241
+ handlerResult.phase,
242
+ );
149
243
  if (withSkills !== handlerResult.prompt) {
150
- return JSON.stringify({ ...handlerResult, prompt: withSkills });
244
+ return JSON.stringify({ ...handlerResult, prompt: withSkills, _userProgress: progress });
151
245
  }
152
246
  }
153
- return JSON.stringify(handlerResult);
247
+ return JSON.stringify({ ...handlerResult, _userProgress: progress });
154
248
  }
155
249
 
156
250
  case "dispatch_multi": {
251
+ // Circuit breaker
252
+ const phase = handlerResult.phase ?? currentState.currentPhase ?? "UNKNOWN";
253
+ const { abortMsg, newCount: attempt } = await checkCircuitBreaker(
254
+ currentState,
255
+ phase,
256
+ artifactDir,
257
+ );
258
+ if (abortMsg) return abortMsg;
259
+
260
+ const progress = buildUserProgress(phase, handlerResult.progress, attempt);
261
+ logOrchestrationEvent(artifactDir, {
262
+ timestamp: new Date().toISOString(),
263
+ phase,
264
+ action: "dispatch_multi",
265
+ agent: `${handlerResult.agents?.length ?? 0} agents`,
266
+ attempt,
267
+ });
268
+
157
269
  // Inject lesson + skill context into each agent's prompt (best-effort)
158
270
  // Load lesson and skill context once and reuse for all agents in the batch
159
271
  if (handlerResult.agents && handlerResult.phase) {
160
- const lessonSuffix = await injectLessonContext(
272
+ const lessonSuffix = await injectLessonContext("", handlerResult.phase, artifactDir);
273
+ const skillSuffix = await injectSkillContext(
161
274
  "",
162
- handlerResult.phase as string,
163
- artifactDir,
275
+ join(artifactDir, ".."),
276
+ handlerResult.phase,
164
277
  );
165
- const skillSuffix = await injectSkillContext("", join(artifactDir, ".."));
166
278
  const combinedSuffix = lessonSuffix + (skillSuffix || "");
167
279
  if (combinedSuffix) {
168
280
  const enrichedAgents = handlerResult.agents.map((entry) => ({
169
281
  ...entry,
170
282
  prompt: entry.prompt + combinedSuffix,
171
283
  }));
172
- return JSON.stringify({ ...handlerResult, agents: enrichedAgents });
284
+ return JSON.stringify({
285
+ ...handlerResult,
286
+ agents: enrichedAgents,
287
+ _userProgress: progress,
288
+ });
173
289
  }
174
290
  }
175
- return JSON.stringify(handlerResult);
291
+ return JSON.stringify({ ...handlerResult, _userProgress: progress });
176
292
  }
177
293
 
178
294
  case "complete": {
@@ -183,6 +299,11 @@ async function processHandlerResult(
183
299
  });
184
300
  }
185
301
 
302
+ logOrchestrationEvent(artifactDir, {
303
+ timestamp: new Date().toISOString(),
304
+ phase: currentState.currentPhase,
305
+ action: "complete",
306
+ });
186
307
  const nextPhase = getNextPhase(currentState.currentPhase);
187
308
  const advanced = completePhase(currentState);
188
309
  await saveState(advanced, artifactDir);
@@ -191,9 +312,11 @@ async function processHandlerResult(
191
312
  // Terminal phase completed
192
313
  const finished = { ...advanced, status: "COMPLETED" as const };
193
314
  await saveState(finished, artifactDir);
315
+ const idx = PHASE_INDEX[currentState.currentPhase] ?? TOTAL_PHASES;
194
316
  return JSON.stringify({
195
317
  action: "complete",
196
- summary: `Pipeline completed all 8 phases. Idea: ${currentState.idea}`,
318
+ summary: `Pipeline completed all ${TOTAL_PHASES} phases. Idea: ${currentState.idea}`,
319
+ _userProgress: `Completed ${currentState.currentPhase} (${idx}/${TOTAL_PHASES}), pipeline finished`,
197
320
  });
198
321
  }
199
322
 
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { execFile } from "node:child_process";
15
+ import { randomBytes } from "node:crypto";
15
16
  import { readFile, rename, unlink, writeFile } from "node:fs/promises";
16
17
  import { join } from "node:path";
17
18
  import { promisify } from "node:util";
@@ -110,7 +111,7 @@ async function saveReviewState(state: ReviewState, artifactDir: string): Promise
110
111
  // Validate before writing (bidirectional validation, same as orchestrator state)
111
112
  const validated = reviewStateSchema.parse(state);
112
113
  const statePath = join(artifactDir, STATE_FILE);
113
- const tmpPath = `${statePath}.tmp.${Date.now()}`;
114
+ const tmpPath = `${statePath}.tmp.${randomBytes(8).toString("hex")}`;
114
115
  await writeFile(tmpPath, JSON.stringify(validated, null, 2), "utf-8");
115
116
  await rename(tmpPath, statePath);
116
117
  }