@kodrunhq/opencode-autopilot 1.12.2 → 1.14.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 (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 +5 -4
  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
@@ -27,11 +27,14 @@ export async function handleArchitect(
27
27
  artifactDir: string,
28
28
  _result?: string,
29
29
  ): Promise<DispatchResult> {
30
+ // _result is received from the orchestrator but completion is determined by
31
+ // artifact existence (design.md/critique.md), not by result truthiness.
32
+ // This preserves the three-step arena flow: proposals → critic → complete.
30
33
  const phaseDir = getPhaseDir(artifactDir, "ARCHITECT");
31
34
  const critiqueExists = await fileExists(join(phaseDir, "critique.md"));
32
35
  const designExists = await fileExists(join(phaseDir, "design.md"));
33
36
 
34
- // Step 3: critique or design exists -> complete
37
+ // Step 3: critique or design exists -> complete (idempotency on resume)
35
38
  if (critiqueExists || designExists) {
36
39
  return Object.freeze({
37
40
  action: "complete" as const,
@@ -49,9 +52,9 @@ export async function handleArchitect(
49
52
  action: "dispatch" as const,
50
53
  agent: AGENT_NAMES.CRITIC,
51
54
  prompt: [
52
- `Review architecture proposals in phases/ARCHITECT/proposals/`,
53
- `Read ${getArtifactRef("RECON", "report.md")} and ${getArtifactRef("CHALLENGE", "brief.md")} for context.`,
54
- `Write comparative critique to ${getArtifactRef("ARCHITECT", "critique.md")}`,
55
+ `Review architecture proposals in ${proposalsDir}/`,
56
+ `Read ${getArtifactRef(artifactDir, "RECON", "report.md")} and ${getArtifactRef(artifactDir, "CHALLENGE", "brief.md")} for context.`,
57
+ `Write comparative critique to ${getArtifactRef(artifactDir, "ARCHITECT", "critique.md")}`,
55
58
  `Include: strengths, weaknesses, recommendation, confidence (HIGH/MEDIUM/LOW).`,
56
59
  ].join("\n"),
57
60
  phase: "ARCHITECT",
@@ -63,8 +66,8 @@ export async function handleArchitect(
63
66
  await ensurePhaseDir(artifactDir, "ARCHITECT");
64
67
  const reconEntries = filterByPhase(state.confidence, "RECON");
65
68
  const depth = getMemoryTunedDepth(reconEntries, join(artifactDir, ".."));
66
- const reconRef = getArtifactRef("RECON", "report.md");
67
- const challengeRef = getArtifactRef("CHALLENGE", "brief.md");
69
+ const reconRef = getArtifactRef(artifactDir, "RECON", "report.md");
70
+ const challengeRef = getArtifactRef(artifactDir, "CHALLENGE", "brief.md");
68
71
  const safeIdea = sanitizeTemplateContent(state.idea).replace(/[\r\n]+/g, " ");
69
72
 
70
73
  if (depth === 1) {
@@ -74,7 +77,7 @@ export async function handleArchitect(
74
77
  prompt: [
75
78
  `Read ${reconRef} and ${challengeRef} for context.`,
76
79
  `Design architecture for: ${safeIdea}`,
77
- `Write design to ${getArtifactRef("ARCHITECT", "design.md")}`,
80
+ `Write design to ${getArtifactRef(artifactDir, "ARCHITECT", "design.md")}`,
78
81
  `Include: component diagram, data flow, technology choices, confidence (HIGH/MEDIUM/LOW).`,
79
82
  ].join("\n"),
80
83
  phase: "ARCHITECT",
@@ -90,7 +93,7 @@ export async function handleArchitect(
90
93
  `Read ${reconRef} and ${challengeRef} for context.`,
91
94
  `Design architecture for: ${safeIdea}`,
92
95
  `Constraint: ${constraint}`,
93
- `Write proposal to phases/ARCHITECT/proposals/proposal-${label}.md`,
96
+ `Write proposal to ${join(proposalsDir, `proposal-${label}.md`)}`,
94
97
  `Include: component diagram, data flow, technology choices, confidence (HIGH/MEDIUM/LOW).`,
95
98
  ].join("\n"),
96
99
  });
@@ -55,9 +55,9 @@ function markTasksInProgress(tasks: readonly Task[], taskIds: readonly number[])
55
55
  /**
56
56
  * Build a prompt for a single task dispatch.
57
57
  */
58
- function buildTaskPrompt(task: Task): string {
59
- const planRef = getArtifactRef("PLAN", "tasks.md");
60
- const designRef = getArtifactRef("ARCHITECT", "design.md");
58
+ function buildTaskPrompt(task: Task, artifactDir: string): string {
59
+ const planRef = getArtifactRef(artifactDir, "PLAN", "tasks.md");
60
+ const designRef = getArtifactRef(artifactDir, "ARCHITECT", "design.md");
61
61
  return [
62
62
  `Implement task ${task.id}: ${task.title}.`,
63
63
  `Reference the plan at ${planRef}`,
@@ -110,7 +110,7 @@ function hasCriticalFindings(resultStr: string): boolean {
110
110
  }
111
111
  }
112
112
 
113
- export const handleBuild: PhaseHandler = async (state, _artifactDir, result?) => {
113
+ export const handleBuild: PhaseHandler = async (state, artifactDir, result?) => {
114
114
  const { tasks, buildProgress } = state;
115
115
 
116
116
  // Edge case: no tasks
@@ -159,6 +159,8 @@ export const handleBuild: PhaseHandler = async (state, _artifactDir, result?) =>
159
159
  const blockedIds = nonDoneTasks.map((t) => t.id).join(", ");
160
160
  return Object.freeze({
161
161
  action: "error" as const,
162
+ phase: "BUILD",
163
+ message: `All remaining tasks are BLOCKED due to dependency cycles: [${blockedIds}]`,
162
164
  progress: `All remaining tasks are BLOCKED due to dependency cycles: [${blockedIds}]`,
163
165
  _stateUpdates: Object.freeze({
164
166
  buildProgress: Object.freeze({
@@ -177,7 +179,7 @@ export const handleBuild: PhaseHandler = async (state, _artifactDir, result?) =>
177
179
  const prompt = [
178
180
  `CRITICAL review findings detected. Fix the following issues:`,
179
181
  safeResult,
180
- `Reference ${getArtifactRef("PLAN", "tasks.md")} for context.`,
182
+ `Reference ${getArtifactRef(artifactDir, "PLAN", "tasks.md")} for context.`,
181
183
  ].join(" ");
182
184
 
183
185
  return Object.freeze({
@@ -225,7 +227,7 @@ export const handleBuild: PhaseHandler = async (state, _artifactDir, result?) =>
225
227
  return Object.freeze({
226
228
  action: "dispatch",
227
229
  agent: AGENT_NAMES.BUILD,
228
- prompt: buildTaskPrompt(task),
230
+ prompt: buildTaskPrompt(task, artifactDir),
229
231
  phase: "BUILD",
230
232
  progress: `Wave ${nextWave} — task ${task.id}`,
231
233
  _stateUpdates: {
@@ -239,7 +241,7 @@ export const handleBuild: PhaseHandler = async (state, _artifactDir, result?) =>
239
241
  action: "dispatch_multi",
240
242
  agents: pendingTasks.map((task) => ({
241
243
  agent: AGENT_NAMES.BUILD,
242
- prompt: buildTaskPrompt(task),
244
+ prompt: buildTaskPrompt(task, artifactDir),
243
245
  })),
244
246
  phase: "BUILD",
245
247
  progress: `Wave ${nextWave} — ${pendingTasks.length} concurrent tasks`,
@@ -285,7 +287,7 @@ export const handleBuild: PhaseHandler = async (state, _artifactDir, result?) =>
285
287
  return Object.freeze({
286
288
  action: "dispatch",
287
289
  agent: AGENT_NAMES.BUILD,
288
- prompt: buildTaskPrompt(next),
290
+ prompt: buildTaskPrompt(next, artifactDir),
289
291
  phase: "BUILD",
290
292
  progress: `Wave ${currentWave} — task ${next.id}`,
291
293
  _stateUpdates: {
@@ -367,7 +369,7 @@ export const handleBuild: PhaseHandler = async (state, _artifactDir, result?) =>
367
369
  return Object.freeze({
368
370
  action: "dispatch",
369
371
  agent: AGENT_NAMES.BUILD,
370
- prompt: buildTaskPrompt(task),
372
+ prompt: buildTaskPrompt(task, artifactDir),
371
373
  phase: "BUILD",
372
374
  progress: `Wave ${currentWave} — task ${task.id}`,
373
375
  _stateUpdates: {
@@ -386,7 +388,7 @@ export const handleBuild: PhaseHandler = async (state, _artifactDir, result?) =>
386
388
  action: "dispatch_multi",
387
389
  agents: pendingTasks.map((task) => ({
388
390
  agent: AGENT_NAMES.BUILD,
389
- prompt: buildTaskPrompt(task),
391
+ prompt: buildTaskPrompt(task, artifactDir),
390
392
  })),
391
393
  phase: "BUILD",
392
394
  progress: `Wave ${currentWave} — ${pendingTasks.length} concurrent tasks`,
@@ -1,4 +1,5 @@
1
1
  import { sanitizeTemplateContent } from "../../review/sanitize";
2
+ import { fileExists } from "../../utils/fs-helpers";
2
3
  import { ensurePhaseDir, getArtifactRef } from "../artifacts";
3
4
  import type { PipelineState } from "../types";
4
5
  import { AGENT_NAMES, type DispatchResult } from "./types";
@@ -13,6 +14,11 @@ export async function handleChallenge(
13
14
  result?: string,
14
15
  ): Promise<DispatchResult> {
15
16
  if (result) {
17
+ // Warn if artifact wasn't written (best-effort — still complete the phase)
18
+ const artifactPath = getArtifactRef(artifactDir, "CHALLENGE", "brief.md");
19
+ if (!(await fileExists(artifactPath))) {
20
+ console.warn("[opencode-autopilot] CHALLENGE completed but artifact not found");
21
+ }
16
22
  return Object.freeze({
17
23
  action: "complete" as const,
18
24
  phase: "CHALLENGE",
@@ -21,8 +27,8 @@ export async function handleChallenge(
21
27
  }
22
28
 
23
29
  await ensurePhaseDir(artifactDir, "CHALLENGE");
24
- const reconRef = getArtifactRef("RECON", "report.md");
25
- const outputRef = getArtifactRef("CHALLENGE", "brief.md");
30
+ const reconRef = getArtifactRef(artifactDir, "RECON", "report.md");
31
+ const outputPath = getArtifactRef(artifactDir, "CHALLENGE", "brief.md");
26
32
 
27
33
  const safeIdea = sanitizeTemplateContent(state.idea).replace(/[\r\n]+/g, " ");
28
34
 
@@ -32,7 +38,7 @@ export async function handleChallenge(
32
38
  prompt: [
33
39
  `Read ${reconRef} for research context.`,
34
40
  `Original idea: ${safeIdea}`,
35
- `Propose up to 3 enhancements. Write ambitious brief to ${outputRef}`,
41
+ `Propose up to 3 enhancements. Write ambitious brief to ${outputPath}`,
36
42
  `For each: name, user value, complexity (LOW/MEDIUM/HIGH), accept/reject rationale.`,
37
43
  ].join("\n"),
38
44
  phase: "CHALLENGE",
@@ -2,7 +2,7 @@ import { getArtifactRef } from "../artifacts";
2
2
  import type { DispatchResult, PhaseHandler } from "./types";
3
3
  import { AGENT_NAMES } from "./types";
4
4
 
5
- export const handlePlan: PhaseHandler = async (_state, _artifactDir, result?) => {
5
+ export const handlePlan: PhaseHandler = async (_state, artifactDir, result?) => {
6
6
  if (result) {
7
7
  return Object.freeze({
8
8
  action: "complete",
@@ -11,8 +11,9 @@ export const handlePlan: PhaseHandler = async (_state, _artifactDir, result?) =>
11
11
  } satisfies DispatchResult);
12
12
  }
13
13
 
14
- const architectRef = getArtifactRef("ARCHITECT", "design.md");
15
- const challengeRef = getArtifactRef("CHALLENGE", "brief.md");
14
+ const architectRef = getArtifactRef(artifactDir, "ARCHITECT", "design.md");
15
+ const challengeRef = getArtifactRef(artifactDir, "CHALLENGE", "brief.md");
16
+ const tasksPath = getArtifactRef(artifactDir, "PLAN", "tasks.md");
16
17
 
17
18
  const prompt = [
18
19
  "Read the architecture design at",
@@ -20,7 +21,7 @@ export const handlePlan: PhaseHandler = async (_state, _artifactDir, result?) =>
20
21
  "and the challenge brief at",
21
22
  challengeRef,
22
23
  "then produce a task plan.",
23
- "Write tasks to phases/PLAN/tasks.md.",
24
+ `Write tasks to ${tasksPath}.`,
24
25
  "Each task should have a 300-line diff max.",
25
26
  "Assign wave numbers for parallel execution.",
26
27
  ].join(" ");
@@ -1,4 +1,5 @@
1
1
  import { sanitizeTemplateContent } from "../../review/sanitize";
2
+ import { fileExists } from "../../utils/fs-helpers";
2
3
  import { ensurePhaseDir, getArtifactRef } from "../artifacts";
3
4
  import type { PipelineState } from "../types";
4
5
  import { AGENT_NAMES, type DispatchResult } from "./types";
@@ -13,6 +14,11 @@ export async function handleRecon(
13
14
  result?: string,
14
15
  ): Promise<DispatchResult> {
15
16
  if (result) {
17
+ // Warn if artifact wasn't written (best-effort — still complete the phase)
18
+ const artifactPath = getArtifactRef(artifactDir, "RECON", "report.md");
19
+ if (!(await fileExists(artifactPath))) {
20
+ console.warn("[opencode-autopilot] RECON completed but artifact not found");
21
+ }
16
22
  return Object.freeze({
17
23
  action: "complete" as const,
18
24
  phase: "RECON",
@@ -20,8 +26,8 @@ export async function handleRecon(
20
26
  });
21
27
  }
22
28
 
23
- const phaseDir = await ensurePhaseDir(artifactDir, "RECON");
24
- const outputRef = getArtifactRef("RECON", "report.md");
29
+ await ensurePhaseDir(artifactDir, "RECON");
30
+ const outputPath = getArtifactRef(artifactDir, "RECON", "report.md");
25
31
 
26
32
  const safeIdea = sanitizeTemplateContent(state.idea).replace(/[\r\n]+/g, " ");
27
33
 
@@ -29,9 +35,8 @@ export async function handleRecon(
29
35
  action: "dispatch" as const,
30
36
  agent: AGENT_NAMES.RECON,
31
37
  prompt: [
32
- `Research the following idea and write findings to ${outputRef}`,
38
+ `Research the following idea and write findings to ${outputPath}`,
33
39
  `Idea: ${safeIdea}`,
34
- `Output: ${phaseDir}/report.md`,
35
40
  `Include: Market Analysis, Technology Options, UX Considerations, Feasibility Assessment, Confidence (HIGH/MEDIUM/LOW)`,
36
41
  ].join("\n"),
37
42
  phase: "RECON",
@@ -105,7 +105,9 @@ export const handleRetrospective: PhaseHandler = async (_state, artifactDir, res
105
105
 
106
106
  const artifactRefs = Object.entries(PHASE_ARTIFACTS)
107
107
  .filter(([phase, files]) => files.length > 0 && phase !== "RETROSPECTIVE")
108
- .flatMap(([phase, files]) => files.map((file) => getArtifactRef(phase as Phase, file)));
108
+ .flatMap(([phase, files]) =>
109
+ files.map((file) => getArtifactRef(artifactDir, phase as Phase, file)),
110
+ );
109
111
 
110
112
  const prompt = [
111
113
  "Analyze all phase artifacts:",
@@ -1,8 +1,8 @@
1
- import { getArtifactRef } from "../artifacts";
1
+ import { getArtifactRef, getPhaseDir } from "../artifacts";
2
2
  import type { DispatchResult, PhaseHandler } from "./types";
3
3
  import { AGENT_NAMES } from "./types";
4
4
 
5
- export const handleShip: PhaseHandler = async (_state, _artifactDir, result?) => {
5
+ export const handleShip: PhaseHandler = async (_state, artifactDir, result?) => {
6
6
  if (result) {
7
7
  return Object.freeze({
8
8
  action: "complete",
@@ -11,10 +11,11 @@ export const handleShip: PhaseHandler = async (_state, _artifactDir, result?) =>
11
11
  } satisfies DispatchResult);
12
12
  }
13
13
 
14
- const reconRef = getArtifactRef("RECON", "report.md");
15
- const challengeRef = getArtifactRef("CHALLENGE", "brief.md");
16
- const architectRef = getArtifactRef("ARCHITECT", "design.md");
17
- const planRef = getArtifactRef("PLAN", "tasks.md");
14
+ const reconRef = getArtifactRef(artifactDir, "RECON", "report.md");
15
+ const challengeRef = getArtifactRef(artifactDir, "CHALLENGE", "brief.md");
16
+ const architectRef = getArtifactRef(artifactDir, "ARCHITECT", "design.md");
17
+ const planRef = getArtifactRef(artifactDir, "PLAN", "tasks.md");
18
+ const shipDir = getPhaseDir(artifactDir, "SHIP");
18
19
 
19
20
  const prompt = [
20
21
  "Review all prior phase artifacts:",
@@ -25,7 +26,7 @@ export const handleShip: PhaseHandler = async (_state, _artifactDir, result?) =>
25
26
  "Produce walkthrough.md (architecture overview),",
26
27
  "decisions.md (key decisions with rationale),",
27
28
  "changelog.md (user-facing changes).",
28
- "Write output to phases/SHIP/.",
29
+ `Write output to ${shipDir}/.`,
29
30
  ].join(" ");
30
31
 
31
32
  return Object.freeze({
@@ -22,6 +22,7 @@ export interface DispatchResult {
22
22
  readonly progress?: string;
23
23
  readonly message?: string;
24
24
  readonly _stateUpdates?: Partial<PipelineState>;
25
+ readonly _userProgress?: string;
25
26
  }
26
27
 
27
28
  export type PhaseHandler = (
@@ -9,6 +9,7 @@
9
9
  * to prevent corruption.
10
10
  */
11
11
 
12
+ import { randomBytes } from "node:crypto";
12
13
  import { readFile, rename, writeFile } from "node:fs/promises";
13
14
  import { join } from "node:path";
14
15
  import { ensureDir, isEnoentError } from "../utils/fs-helpers";
@@ -76,7 +77,7 @@ export async function saveLessonMemory(memory: LessonMemory, projectRoot: string
76
77
  const dir = join(projectRoot, ".opencode-autopilot");
77
78
  await ensureDir(dir);
78
79
  const memoryPath = join(dir, LESSON_FILE);
79
- const tmpPath = `${memoryPath}.tmp.${Date.now()}`;
80
+ const tmpPath = `${memoryPath}.tmp.${randomBytes(8).toString("hex")}`;
80
81
  await writeFile(tmpPath, JSON.stringify(validated, null, 2), "utf-8");
81
82
  await rename(tmpPath, memoryPath);
82
83
  }
@@ -0,0 +1,40 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export interface OrchestrationEvent {
5
+ readonly timestamp: string;
6
+ readonly phase: string;
7
+ readonly action: "dispatch" | "dispatch_multi" | "complete" | "error" | "loop_detected";
8
+ readonly agent?: string;
9
+ readonly promptLength?: number;
10
+ readonly attempt?: number;
11
+ readonly message?: string;
12
+ }
13
+
14
+ const LOG_FILE = "orchestration.jsonl";
15
+
16
+ /** Rate-limit: warn about log failures at most once per process. */
17
+ let logWriteWarned = false;
18
+
19
+ /**
20
+ * Append an orchestration event to the project-local JSONL log.
21
+ * Uses synchronous append to survive crashes. Best-effort — errors are swallowed.
22
+ */
23
+ export function logOrchestrationEvent(artifactDir: string, event: OrchestrationEvent): void {
24
+ try {
25
+ mkdirSync(artifactDir, { recursive: true });
26
+ const logPath = join(artifactDir, LOG_FILE);
27
+ // Redact filesystem paths from message to avoid leaking sensitive directory info
28
+ const safe = event.message
29
+ ? { ...event, message: event.message.replace(/[/\\][^\s"']+/g, "[PATH]") }
30
+ : event;
31
+ const line = `${JSON.stringify(safe)}\n`;
32
+ appendFileSync(logPath, line, "utf-8");
33
+ } catch (err) {
34
+ // Best-effort — never block the pipeline. Warn once so operators know logging is broken.
35
+ if (!logWriteWarned) {
36
+ logWriteWarned = true;
37
+ console.warn("[opencode-autopilot] orchestration log write failed:", err);
38
+ }
39
+ }
40
+ }
@@ -1,5 +1,19 @@
1
1
  import type { Phase, PhaseStatus, PipelineState } from "./types";
2
2
 
3
+ /** Maps each phase to its 1-based position for user-facing progress display. */
4
+ export const PHASE_INDEX: Readonly<Record<Phase, number>> = Object.freeze({
5
+ RECON: 1,
6
+ CHALLENGE: 2,
7
+ ARCHITECT: 3,
8
+ EXPLORE: 4,
9
+ PLAN: 5,
10
+ BUILD: 6,
11
+ SHIP: 7,
12
+ RETROSPECTIVE: 8,
13
+ } satisfies Record<Phase, number>);
14
+
15
+ export const TOTAL_PHASES = 8;
16
+
3
17
  /**
4
18
  * Maps each phase to its valid successor. RETROSPECTIVE is terminal (null).
5
19
  */
@@ -84,4 +84,5 @@ export const pipelineStateSchema = z.object({
84
84
  reviewPending: false,
85
85
  }),
86
86
  failureContext: failureContextSchema.nullable().default(null),
87
+ phaseDispatchCounts: z.record(z.string().max(32), z.number().int().min(0).max(1000)).default({}),
87
88
  });
@@ -11,9 +11,10 @@ import { readFile } from "node:fs/promises";
11
11
  import { join } from "node:path";
12
12
  import { sanitizeTemplateContent } from "../review/sanitize";
13
13
  import {
14
- buildMultiSkillContext,
14
+ buildAdaptiveSkillContext,
15
15
  detectProjectStackTags,
16
16
  filterSkillsByStack,
17
+ type SkillMode,
17
18
  } from "../skills/adaptive-injector";
18
19
  import { loadAllSkills } from "../skills/loader";
19
20
  import { isEnoentError } from "../utils/fs-helpers";
@@ -69,7 +70,11 @@ export function buildSkillContext(skillContent: string): string {
69
70
  export async function loadAdaptiveSkillContext(
70
71
  baseDir: string,
71
72
  projectRoot: string,
72
- tokenBudget?: number,
73
+ options?: {
74
+ readonly phase?: string;
75
+ readonly budget?: number;
76
+ readonly mode?: SkillMode;
77
+ },
73
78
  ): Promise<string> {
74
79
  try {
75
80
  const skillsDir = join(baseDir, "skills");
@@ -81,10 +86,10 @@ export async function loadAdaptiveSkillContext(
81
86
  if (allSkills.size === 0) return "";
82
87
 
83
88
  const matchingSkills = filterSkillsByStack(allSkills, projectTags);
84
- return buildMultiSkillContext(matchingSkills, tokenBudget);
85
- } catch {
86
- // Best-effort: all errors return empty string. Caller (injectSkillContext)
87
- // logs the error — no need to re-throw since the call site is also best-effort.
89
+ return buildAdaptiveSkillContext(matchingSkills, options);
90
+ } catch (err) {
91
+ // Best-effort: all errors return empty string.
92
+ console.warn("[opencode-autopilot] adaptive skill load failed:", err);
88
93
  return "";
89
94
  }
90
95
  }
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { readFile, rename, writeFile } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
  import { ensureDir, isEnoentError } from "../utils/fs-helpers";
@@ -45,7 +46,7 @@ export async function saveState(state: PipelineState, artifactDir: string): Prom
45
46
  const validated = pipelineStateSchema.parse(state);
46
47
  await ensureDir(artifactDir);
47
48
  const statePath = join(artifactDir, STATE_FILE);
48
- const tmpPath = `${statePath}.tmp.${Date.now()}`;
49
+ const tmpPath = `${statePath}.tmp.${randomBytes(8).toString("hex")}`;
49
50
  await writeFile(tmpPath, JSON.stringify(validated, null, 2), "utf-8");
50
51
  await rename(tmpPath, statePath);
51
52
  }
@@ -1,10 +1,8 @@
1
1
  /**
2
- * Two-pass deterministic agent selection for the review pipeline.
2
+ * Deterministic agent selection for the review pipeline.
3
3
  *
4
- * Pass 1: Stack gate -- agents with empty relevantStacks always pass;
5
- * agents with non-empty relevantStacks require at least one match.
6
- * Pass 2: Diff relevance scoring -- currently used for future ordering,
7
- * all stack-passing agents run regardless of score.
4
+ * Stack gate: agents with empty relevantStacks always pass;
5
+ * agents with non-empty relevantStacks require at least one match.
8
6
  */
9
7
 
10
8
  /** Minimal agent shape needed for selection (compatible with ReviewAgent from agents/). */
@@ -38,7 +36,7 @@ export interface SelectionResult {
38
36
  */
39
37
  export function selectAgents(
40
38
  detectedStacks: readonly string[],
41
- diffAnalysis: DiffAnalysisInput,
39
+ _diffAnalysis: DiffAnalysisInput,
42
40
  agents: readonly SelectableAgent[],
43
41
  ): SelectionResult {
44
42
  const stackSet = new Set(detectedStacks);
@@ -65,34 +63,8 @@ export function selectAgents(
65
63
  }
66
64
  }
67
65
 
68
- // Pass 2: Compute relevance scores (stored for future ordering, no filtering)
69
- // Scores are intentionally not used for filtering yet
70
- for (const agent of selected) {
71
- computeDiffRelevance(agent, diffAnalysis);
72
- }
73
-
74
66
  return Object.freeze({
75
67
  selected: Object.freeze(selected),
76
68
  excluded: Object.freeze(excluded),
77
69
  });
78
70
  }
79
-
80
- /**
81
- * Compute diff-based relevance score for an agent.
82
- * Base score of 1.0 with bonuses for specific agent-analysis matches.
83
- * Used for future prioritization/ordering, not for filtering.
84
- */
85
- export function computeDiffRelevance(agent: SelectableAgent, analysis: DiffAnalysisInput): number {
86
- let score = 1.0;
87
-
88
- if (agent.name === "security-auditor") {
89
- if (analysis.hasAuth) score += 0.5;
90
- if (analysis.hasConfig) score += 0.3;
91
- }
92
-
93
- if (agent.name === "test-interrogator") {
94
- if (!analysis.hasTests) score += 0.5;
95
- }
96
-
97
- return score;
98
- }
@@ -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
  }