@kodrunhq/opencode-autopilot 0.1.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.
- package/LICENSE +21 -0
- package/README.md +1 -0
- package/assets/agents/placeholder-agent.md +13 -0
- package/assets/commands/configure.md +17 -0
- package/assets/commands/new-agent.md +16 -0
- package/assets/commands/new-command.md +15 -0
- package/assets/commands/new-skill.md +15 -0
- package/assets/commands/review-pr.md +49 -0
- package/assets/skills/.gitkeep +0 -0
- package/assets/skills/coding-standards/SKILL.md +327 -0
- package/package.json +52 -0
- package/src/agents/autopilot.ts +42 -0
- package/src/agents/documenter.ts +44 -0
- package/src/agents/index.ts +49 -0
- package/src/agents/metaprompter.ts +50 -0
- package/src/agents/pipeline/index.ts +25 -0
- package/src/agents/pipeline/oc-architect.ts +49 -0
- package/src/agents/pipeline/oc-challenger.ts +44 -0
- package/src/agents/pipeline/oc-critic.ts +42 -0
- package/src/agents/pipeline/oc-explorer.ts +46 -0
- package/src/agents/pipeline/oc-implementer.ts +56 -0
- package/src/agents/pipeline/oc-planner.ts +45 -0
- package/src/agents/pipeline/oc-researcher.ts +46 -0
- package/src/agents/pipeline/oc-retrospector.ts +42 -0
- package/src/agents/pipeline/oc-reviewer.ts +44 -0
- package/src/agents/pipeline/oc-shipper.ts +42 -0
- package/src/agents/pr-reviewer.ts +74 -0
- package/src/agents/researcher.ts +43 -0
- package/src/config.ts +168 -0
- package/src/index.ts +152 -0
- package/src/installer.ts +130 -0
- package/src/orchestrator/arena.ts +41 -0
- package/src/orchestrator/artifacts.ts +28 -0
- package/src/orchestrator/confidence.ts +59 -0
- package/src/orchestrator/fallback/chat-message-handler.ts +49 -0
- package/src/orchestrator/fallback/error-classifier.ts +148 -0
- package/src/orchestrator/fallback/event-handler.ts +235 -0
- package/src/orchestrator/fallback/fallback-config.ts +16 -0
- package/src/orchestrator/fallback/fallback-manager.ts +323 -0
- package/src/orchestrator/fallback/fallback-state.ts +120 -0
- package/src/orchestrator/fallback/index.ts +11 -0
- package/src/orchestrator/fallback/message-replay.ts +40 -0
- package/src/orchestrator/fallback/resolve-chain.ts +34 -0
- package/src/orchestrator/fallback/tool-execute-handler.ts +44 -0
- package/src/orchestrator/fallback/types.ts +46 -0
- package/src/orchestrator/handlers/architect.ts +114 -0
- package/src/orchestrator/handlers/build.ts +363 -0
- package/src/orchestrator/handlers/challenge.ts +41 -0
- package/src/orchestrator/handlers/explore.ts +9 -0
- package/src/orchestrator/handlers/index.ts +21 -0
- package/src/orchestrator/handlers/plan.ts +35 -0
- package/src/orchestrator/handlers/recon.ts +40 -0
- package/src/orchestrator/handlers/retrospective.ts +123 -0
- package/src/orchestrator/handlers/ship.ts +38 -0
- package/src/orchestrator/handlers/types.ts +31 -0
- package/src/orchestrator/lesson-injection.ts +80 -0
- package/src/orchestrator/lesson-memory.ts +110 -0
- package/src/orchestrator/lesson-schemas.ts +24 -0
- package/src/orchestrator/lesson-types.ts +6 -0
- package/src/orchestrator/phase.ts +76 -0
- package/src/orchestrator/plan.ts +43 -0
- package/src/orchestrator/schemas.ts +86 -0
- package/src/orchestrator/skill-injection.ts +52 -0
- package/src/orchestrator/state.ts +80 -0
- package/src/orchestrator/types.ts +20 -0
- package/src/review/agent-catalog.ts +439 -0
- package/src/review/agents/auth-flow-verifier.ts +47 -0
- package/src/review/agents/code-quality-auditor.ts +51 -0
- package/src/review/agents/concurrency-checker.ts +47 -0
- package/src/review/agents/contract-verifier.ts +45 -0
- package/src/review/agents/database-auditor.ts +47 -0
- package/src/review/agents/dead-code-scanner.ts +47 -0
- package/src/review/agents/go-idioms-auditor.ts +46 -0
- package/src/review/agents/index.ts +82 -0
- package/src/review/agents/logic-auditor.ts +47 -0
- package/src/review/agents/product-thinker.ts +49 -0
- package/src/review/agents/python-django-auditor.ts +46 -0
- package/src/review/agents/react-patterns-auditor.ts +46 -0
- package/src/review/agents/red-team.ts +49 -0
- package/src/review/agents/rust-safety-auditor.ts +46 -0
- package/src/review/agents/scope-intent-verifier.ts +45 -0
- package/src/review/agents/security-auditor.ts +47 -0
- package/src/review/agents/silent-failure-hunter.ts +45 -0
- package/src/review/agents/spec-checker.ts +45 -0
- package/src/review/agents/state-mgmt-auditor.ts +46 -0
- package/src/review/agents/test-interrogator.ts +43 -0
- package/src/review/agents/type-soundness.ts +46 -0
- package/src/review/agents/wiring-inspector.ts +46 -0
- package/src/review/cross-verification.ts +71 -0
- package/src/review/finding-builder.ts +74 -0
- package/src/review/fix-cycle.ts +146 -0
- package/src/review/memory.ts +114 -0
- package/src/review/pipeline.ts +258 -0
- package/src/review/report.ts +141 -0
- package/src/review/sanitize.ts +8 -0
- package/src/review/schemas.ts +75 -0
- package/src/review/selection.ts +98 -0
- package/src/review/severity.ts +71 -0
- package/src/review/stack-gate.ts +127 -0
- package/src/review/types.ts +43 -0
- package/src/templates/agent-template.ts +47 -0
- package/src/templates/command-template.ts +29 -0
- package/src/templates/skill-template.ts +42 -0
- package/src/tools/confidence.ts +93 -0
- package/src/tools/create-agent.ts +81 -0
- package/src/tools/create-command.ts +74 -0
- package/src/tools/create-skill.ts +74 -0
- package/src/tools/forensics.ts +88 -0
- package/src/tools/orchestrate.ts +310 -0
- package/src/tools/phase.ts +92 -0
- package/src/tools/placeholder.ts +11 -0
- package/src/tools/plan.ts +56 -0
- package/src/tools/review.ts +295 -0
- package/src/tools/state.ts +112 -0
- package/src/utils/fs-helpers.ts +39 -0
- package/src/utils/gitignore.ts +27 -0
- package/src/utils/paths.ts +17 -0
- package/src/utils/validators.ts +57 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { fallbackConfigSchema, fallbackDefaults } from "./orchestrator/fallback/fallback-config";
|
|
5
|
+
import { ensureDir, isEnoentError } from "./utils/fs-helpers";
|
|
6
|
+
import { getGlobalConfigDir } from "./utils/paths";
|
|
7
|
+
|
|
8
|
+
// --- V1 schema (internal, for migration) ---
|
|
9
|
+
|
|
10
|
+
const pluginConfigSchemaV1 = z.object({
|
|
11
|
+
version: z.literal(1),
|
|
12
|
+
configured: z.boolean(),
|
|
13
|
+
models: z.record(z.string(), z.string()),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
type PluginConfigV1 = z.infer<typeof pluginConfigSchemaV1>;
|
|
17
|
+
|
|
18
|
+
// --- V2 sub-schemas ---
|
|
19
|
+
|
|
20
|
+
const phasesConfigSchema = z.object({
|
|
21
|
+
recon: z.boolean().default(true),
|
|
22
|
+
challenge: z.boolean().default(true),
|
|
23
|
+
architect: z.boolean().default(true),
|
|
24
|
+
explore: z.boolean().default(true),
|
|
25
|
+
plan: z.boolean().default(true),
|
|
26
|
+
build: z.boolean().default(true),
|
|
27
|
+
ship: z.boolean().default(true),
|
|
28
|
+
retrospective: z.boolean().default(true),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const thresholdsConfigSchema = z.object({
|
|
32
|
+
proceed: z.enum(["HIGH", "MEDIUM", "LOW"]).default("MEDIUM"),
|
|
33
|
+
abort: z.enum(["HIGH", "MEDIUM", "LOW"]).default("LOW"),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const orchestratorConfigSchema = z.object({
|
|
37
|
+
autonomy: z.enum(["full", "supervised", "manual"]).default("full"),
|
|
38
|
+
strictness: z.enum(["strict", "normal", "lenient"]).default("normal"),
|
|
39
|
+
phases: phasesConfigSchema.default(phasesConfigSchema.parse({})),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const confidenceConfigSchema = z.object({
|
|
43
|
+
enabled: z.boolean().default(true),
|
|
44
|
+
thresholds: thresholdsConfigSchema.default(thresholdsConfigSchema.parse({})),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Pre-compute full defaults for nested schema defaults
|
|
48
|
+
const orchestratorDefaults = orchestratorConfigSchema.parse({});
|
|
49
|
+
const confidenceDefaults = confidenceConfigSchema.parse({});
|
|
50
|
+
|
|
51
|
+
// --- V2 schema (internal, for migration) ---
|
|
52
|
+
|
|
53
|
+
const pluginConfigSchemaV2 = z.object({
|
|
54
|
+
version: z.literal(2),
|
|
55
|
+
configured: z.boolean(),
|
|
56
|
+
models: z.record(z.string(), z.string()),
|
|
57
|
+
orchestrator: orchestratorConfigSchema.default(orchestratorDefaults),
|
|
58
|
+
confidence: confidenceConfigSchema.default(confidenceDefaults),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
type PluginConfigV2 = z.infer<typeof pluginConfigSchemaV2>;
|
|
62
|
+
|
|
63
|
+
// --- V3 schema ---
|
|
64
|
+
|
|
65
|
+
const pluginConfigSchemaV3 = z.object({
|
|
66
|
+
version: z.literal(3),
|
|
67
|
+
configured: z.boolean(),
|
|
68
|
+
models: z.record(z.string(), z.string()),
|
|
69
|
+
orchestrator: orchestratorConfigSchema.default(orchestratorDefaults),
|
|
70
|
+
confidence: confidenceConfigSchema.default(confidenceDefaults),
|
|
71
|
+
fallback: fallbackConfigSchema.default(fallbackDefaults),
|
|
72
|
+
fallback_models: z.union([z.string(), z.array(z.string())]).optional(),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Export pluginConfigSchema as alias for v3 (preserves import compatibility)
|
|
76
|
+
export const pluginConfigSchema = pluginConfigSchemaV3;
|
|
77
|
+
|
|
78
|
+
export type PluginConfig = z.infer<typeof pluginConfigSchemaV3>;
|
|
79
|
+
|
|
80
|
+
export const CONFIG_PATH = join(getGlobalConfigDir(), "opencode-autopilot.json");
|
|
81
|
+
|
|
82
|
+
// --- Migration ---
|
|
83
|
+
|
|
84
|
+
function migrateV1toV2(v1Config: PluginConfigV1): PluginConfigV2 {
|
|
85
|
+
return {
|
|
86
|
+
version: 2 as const,
|
|
87
|
+
configured: v1Config.configured,
|
|
88
|
+
models: v1Config.models,
|
|
89
|
+
orchestrator: orchestratorDefaults,
|
|
90
|
+
confidence: confidenceDefaults,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function migrateV2toV3(v2Config: PluginConfigV2): PluginConfig {
|
|
95
|
+
return {
|
|
96
|
+
version: 3 as const,
|
|
97
|
+
configured: v2Config.configured,
|
|
98
|
+
models: v2Config.models,
|
|
99
|
+
orchestrator: v2Config.orchestrator,
|
|
100
|
+
confidence: v2Config.confidence,
|
|
101
|
+
fallback: fallbackDefaults,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- Public API ---
|
|
106
|
+
|
|
107
|
+
export async function loadConfig(configPath: string = CONFIG_PATH): Promise<PluginConfig | null> {
|
|
108
|
+
try {
|
|
109
|
+
const raw = await readFile(configPath, "utf-8");
|
|
110
|
+
const parsed = JSON.parse(raw);
|
|
111
|
+
|
|
112
|
+
// Try v3 first
|
|
113
|
+
const v3Result = pluginConfigSchemaV3.safeParse(parsed);
|
|
114
|
+
if (v3Result.success) {
|
|
115
|
+
return v3Result.data;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Try v2 and migrate to v3
|
|
119
|
+
const v2Result = pluginConfigSchemaV2.safeParse(parsed);
|
|
120
|
+
if (v2Result.success) {
|
|
121
|
+
const migrated = migrateV2toV3(v2Result.data);
|
|
122
|
+
await saveConfig(migrated, configPath);
|
|
123
|
+
return migrated;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Try v1 and double-migrate v1->v2->v3
|
|
127
|
+
const v1Result = pluginConfigSchemaV1.safeParse(parsed);
|
|
128
|
+
if (v1Result.success) {
|
|
129
|
+
const v2 = migrateV1toV2(v1Result.data);
|
|
130
|
+
const migrated = migrateV2toV3(v2);
|
|
131
|
+
await saveConfig(migrated, configPath);
|
|
132
|
+
return migrated;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// None matched -- force v3 parse to get proper error
|
|
136
|
+
return pluginConfigSchemaV3.parse(parsed);
|
|
137
|
+
} catch (error: unknown) {
|
|
138
|
+
if (isEnoentError(error)) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function saveConfig(
|
|
146
|
+
config: PluginConfig,
|
|
147
|
+
configPath: string = CONFIG_PATH,
|
|
148
|
+
): Promise<void> {
|
|
149
|
+
await ensureDir(dirname(configPath));
|
|
150
|
+
const tmpPath = `${configPath}.tmp.${Date.now()}`;
|
|
151
|
+
await writeFile(tmpPath, JSON.stringify(config, null, 2), "utf-8");
|
|
152
|
+
await rename(tmpPath, configPath);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function isFirstLoad(config: PluginConfig | null): boolean {
|
|
156
|
+
return config === null || !config.configured;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function createDefaultConfig(): PluginConfig {
|
|
160
|
+
return {
|
|
161
|
+
version: 3 as const,
|
|
162
|
+
configured: false,
|
|
163
|
+
models: {},
|
|
164
|
+
orchestrator: orchestratorDefaults,
|
|
165
|
+
confidence: confidenceDefaults,
|
|
166
|
+
fallback: fallbackDefaults,
|
|
167
|
+
};
|
|
168
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { Config, Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
import { configHook } from "./agents";
|
|
3
|
+
import { isFirstLoad, loadConfig } from "./config";
|
|
4
|
+
import { installAssets } from "./installer";
|
|
5
|
+
import type { SdkOperations } from "./orchestrator/fallback";
|
|
6
|
+
import {
|
|
7
|
+
createChatMessageHandler,
|
|
8
|
+
createEventHandler,
|
|
9
|
+
createToolExecuteAfterHandler,
|
|
10
|
+
FallbackManager,
|
|
11
|
+
} from "./orchestrator/fallback";
|
|
12
|
+
import { fallbackDefaults } from "./orchestrator/fallback/fallback-config";
|
|
13
|
+
import { resolveChain } from "./orchestrator/fallback/resolve-chain";
|
|
14
|
+
import { ocConfidence } from "./tools/confidence";
|
|
15
|
+
import { ocCreateAgent } from "./tools/create-agent";
|
|
16
|
+
import { ocCreateCommand } from "./tools/create-command";
|
|
17
|
+
import { ocCreateSkill } from "./tools/create-skill";
|
|
18
|
+
import { ocForensics } from "./tools/forensics";
|
|
19
|
+
import { ocOrchestrate } from "./tools/orchestrate";
|
|
20
|
+
import { ocPhase } from "./tools/phase";
|
|
21
|
+
import { ocPlaceholder } from "./tools/placeholder";
|
|
22
|
+
import { ocPlan } from "./tools/plan";
|
|
23
|
+
import { ocReview } from "./tools/review";
|
|
24
|
+
import { ocState } from "./tools/state";
|
|
25
|
+
|
|
26
|
+
let openCodeConfig: Config | null = null;
|
|
27
|
+
|
|
28
|
+
const plugin: Plugin = async (input) => {
|
|
29
|
+
const client = input.client;
|
|
30
|
+
|
|
31
|
+
// Self-healing asset installation on every load
|
|
32
|
+
const installResult = await installAssets();
|
|
33
|
+
if (installResult.errors.length > 0) {
|
|
34
|
+
console.error("[opencode-autopilot] Asset installation errors:", installResult.errors);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Load config for first-load detection and fallback settings
|
|
38
|
+
const config = await loadConfig();
|
|
39
|
+
const fallbackConfig = config?.fallback ?? fallbackDefaults;
|
|
40
|
+
|
|
41
|
+
// --- Fallback subsystem initialization ---
|
|
42
|
+
const sdkOps: SdkOperations = {
|
|
43
|
+
abortSession: async (sessionID) => {
|
|
44
|
+
await client.session.abort({ path: { id: sessionID } });
|
|
45
|
+
},
|
|
46
|
+
getSessionMessages: async (sessionID) => {
|
|
47
|
+
const response = await client.session.messages({
|
|
48
|
+
path: { id: sessionID },
|
|
49
|
+
query: { directory: process.cwd() },
|
|
50
|
+
});
|
|
51
|
+
// Extract parts from the last non-assistant message for replay
|
|
52
|
+
const messages = (response.data ?? []) as ReadonlyArray<{
|
|
53
|
+
role?: string;
|
|
54
|
+
parts?: readonly import("./orchestrator/fallback").MessagePart[];
|
|
55
|
+
}>;
|
|
56
|
+
const lastUserMsg = [...messages].reverse().find((m) => m.role !== "assistant");
|
|
57
|
+
return lastUserMsg?.parts ?? [];
|
|
58
|
+
},
|
|
59
|
+
promptAsync: async (sessionID, model, parts) => {
|
|
60
|
+
await client.session.promptAsync({
|
|
61
|
+
path: { id: sessionID },
|
|
62
|
+
// biome-ignore lint/suspicious/noExplicitAny: MessagePart is a superset of SDK part types
|
|
63
|
+
body: { model, parts: parts as any },
|
|
64
|
+
query: { directory: process.cwd() },
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
showToast: async (title, message, variant) => {
|
|
68
|
+
await client.tui.showToast({
|
|
69
|
+
body: { title, message, variant, duration: 5000 },
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const manager = new FallbackManager({
|
|
75
|
+
config: fallbackConfig,
|
|
76
|
+
resolveFallbackChain: (_sessionID, agentName) => {
|
|
77
|
+
const agentConfigs = openCodeConfig?.agent as
|
|
78
|
+
| Record<string, Record<string, unknown>>
|
|
79
|
+
| undefined;
|
|
80
|
+
return resolveChain(agentName ?? "", agentConfigs, config?.fallback_models);
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const fallbackEventHandler = createEventHandler({
|
|
85
|
+
manager,
|
|
86
|
+
sdk: sdkOps,
|
|
87
|
+
config: fallbackConfig,
|
|
88
|
+
});
|
|
89
|
+
const chatMessageHandler = createChatMessageHandler(manager);
|
|
90
|
+
const toolExecuteAfterHandler = createToolExecuteAfterHandler(manager);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
tool: {
|
|
94
|
+
oc_placeholder: ocPlaceholder,
|
|
95
|
+
oc_create_agent: ocCreateAgent,
|
|
96
|
+
oc_create_skill: ocCreateSkill,
|
|
97
|
+
oc_create_command: ocCreateCommand,
|
|
98
|
+
oc_state: ocState,
|
|
99
|
+
oc_confidence: ocConfidence,
|
|
100
|
+
oc_phase: ocPhase,
|
|
101
|
+
oc_plan: ocPlan,
|
|
102
|
+
oc_orchestrate: ocOrchestrate,
|
|
103
|
+
oc_forensics: ocForensics,
|
|
104
|
+
oc_review: ocReview,
|
|
105
|
+
},
|
|
106
|
+
event: async ({ event }) => {
|
|
107
|
+
if (event.type === "session.created" && isFirstLoad(config)) {
|
|
108
|
+
// First load: config wizard will be triggered via /configure command
|
|
109
|
+
// Phase 2 will add the oc_configure tool
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Fallback event handling (runs for all events)
|
|
113
|
+
if (fallbackConfig.enabled) {
|
|
114
|
+
await fallbackEventHandler({ event });
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
config: async (cfg: Config) => {
|
|
118
|
+
openCodeConfig = cfg;
|
|
119
|
+
await configHook(cfg);
|
|
120
|
+
},
|
|
121
|
+
"chat.message": async (
|
|
122
|
+
hookInput: {
|
|
123
|
+
readonly sessionID: string;
|
|
124
|
+
readonly agent?: string;
|
|
125
|
+
readonly model?: { readonly providerID: string; readonly modelID: string };
|
|
126
|
+
},
|
|
127
|
+
output: {
|
|
128
|
+
message: { model?: { providerID: string; modelID: string } };
|
|
129
|
+
parts: unknown[];
|
|
130
|
+
},
|
|
131
|
+
) => {
|
|
132
|
+
if (fallbackConfig.enabled) {
|
|
133
|
+
await chatMessageHandler(hookInput, output);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
"tool.execute.after": async (
|
|
137
|
+
hookInput: {
|
|
138
|
+
readonly tool: string;
|
|
139
|
+
readonly sessionID: string;
|
|
140
|
+
readonly callID: string;
|
|
141
|
+
readonly args: unknown;
|
|
142
|
+
},
|
|
143
|
+
output: { title: string; output: string; metadata: unknown },
|
|
144
|
+
) => {
|
|
145
|
+
if (fallbackConfig.enabled) {
|
|
146
|
+
await toolExecuteAfterHandler(hookInput, output);
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export default plugin;
|
package/src/installer.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { copyIfMissing, isEnoentError } from "./utils/fs-helpers";
|
|
4
|
+
import { getAssetsDir, getGlobalConfigDir } from "./utils/paths";
|
|
5
|
+
|
|
6
|
+
export interface InstallResult {
|
|
7
|
+
readonly copied: readonly string[];
|
|
8
|
+
readonly skipped: readonly string[];
|
|
9
|
+
readonly errors: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ListEntriesResult {
|
|
13
|
+
readonly entries: ReadonlyArray<{ name: string; isDirectory: boolean }>;
|
|
14
|
+
readonly error: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function listEntries(dirPath: string): Promise<ListEntriesResult> {
|
|
18
|
+
try {
|
|
19
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
20
|
+
return {
|
|
21
|
+
entries: entries.map((e) => ({ name: e.name, isDirectory: e.isDirectory() })),
|
|
22
|
+
error: null,
|
|
23
|
+
};
|
|
24
|
+
} catch (error: unknown) {
|
|
25
|
+
if (isEnoentError(error)) {
|
|
26
|
+
return { entries: [], error: null };
|
|
27
|
+
}
|
|
28
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
29
|
+
return { entries: [], error: `${dirPath}: ${message}` };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function processFiles(
|
|
34
|
+
sourceDir: string,
|
|
35
|
+
targetDir: string,
|
|
36
|
+
category: string,
|
|
37
|
+
): Promise<InstallResult> {
|
|
38
|
+
const copied: string[] = [];
|
|
39
|
+
const skipped: string[] = [];
|
|
40
|
+
const errors: string[] = [];
|
|
41
|
+
|
|
42
|
+
const listing = await listEntries(join(sourceDir, category));
|
|
43
|
+
if (listing.error) {
|
|
44
|
+
errors.push(listing.error);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const entry of listing.entries) {
|
|
48
|
+
if (entry.name === ".gitkeep") continue;
|
|
49
|
+
if (entry.isDirectory) continue;
|
|
50
|
+
|
|
51
|
+
const relativePath = `${category}/${entry.name}`;
|
|
52
|
+
const source = join(sourceDir, relativePath);
|
|
53
|
+
const target = join(targetDir, relativePath);
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const result = await copyIfMissing(source, target);
|
|
57
|
+
if (result.copied) {
|
|
58
|
+
copied.push(relativePath);
|
|
59
|
+
} else {
|
|
60
|
+
skipped.push(relativePath);
|
|
61
|
+
}
|
|
62
|
+
} catch (error: unknown) {
|
|
63
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
64
|
+
errors.push(`${relativePath}: ${message}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { copied, skipped, errors };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function processSkills(sourceDir: string, targetDir: string): Promise<InstallResult> {
|
|
72
|
+
const copied: string[] = [];
|
|
73
|
+
const skipped: string[] = [];
|
|
74
|
+
const errors: string[] = [];
|
|
75
|
+
|
|
76
|
+
const skillListing = await listEntries(join(sourceDir, "skills"));
|
|
77
|
+
if (skillListing.error) {
|
|
78
|
+
errors.push(skillListing.error);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const dir of skillListing.entries) {
|
|
82
|
+
if (!dir.isDirectory) continue;
|
|
83
|
+
if (dir.name === ".gitkeep") continue;
|
|
84
|
+
|
|
85
|
+
const fileListing = await listEntries(join(sourceDir, "skills", dir.name));
|
|
86
|
+
if (fileListing.error) {
|
|
87
|
+
errors.push(fileListing.error);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const file of fileListing.entries) {
|
|
91
|
+
if (file.name === ".gitkeep") continue;
|
|
92
|
+
if (file.isDirectory) continue;
|
|
93
|
+
|
|
94
|
+
const relativePath = `skills/${dir.name}/${file.name}`;
|
|
95
|
+
const source = join(sourceDir, relativePath);
|
|
96
|
+
const target = join(targetDir, relativePath);
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const result = await copyIfMissing(source, target);
|
|
100
|
+
if (result.copied) {
|
|
101
|
+
copied.push(relativePath);
|
|
102
|
+
} else {
|
|
103
|
+
skipped.push(relativePath);
|
|
104
|
+
}
|
|
105
|
+
} catch (error: unknown) {
|
|
106
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
107
|
+
errors.push(`${relativePath}: ${message}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { copied, skipped, errors };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function installAssets(
|
|
116
|
+
assetsDir: string = getAssetsDir(),
|
|
117
|
+
targetDir: string = getGlobalConfigDir(),
|
|
118
|
+
): Promise<InstallResult> {
|
|
119
|
+
const [agents, commands, skills] = await Promise.all([
|
|
120
|
+
processFiles(assetsDir, targetDir, "agents"),
|
|
121
|
+
processFiles(assetsDir, targetDir, "commands"),
|
|
122
|
+
processSkills(assetsDir, targetDir),
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
copied: [...agents.copied, ...commands.copied, ...skills.copied],
|
|
127
|
+
skipped: [...agents.skipped, ...commands.skipped, ...skills.skipped],
|
|
128
|
+
errors: [...agents.errors, ...commands.errors, ...skills.errors],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { summarizeConfidence } from "./confidence";
|
|
2
|
+
import type { ConfidenceEntry } from "./types";
|
|
3
|
+
|
|
4
|
+
type ConfidenceLevel = "HIGH" | "MEDIUM" | "LOW";
|
|
5
|
+
|
|
6
|
+
const DEPTH_MAP: Readonly<Record<ConfidenceLevel, number>> = Object.freeze({
|
|
7
|
+
HIGH: 1,
|
|
8
|
+
MEDIUM: 2,
|
|
9
|
+
LOW: 3,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const LEVEL_ORDER: Readonly<Record<ConfidenceLevel, number>> = Object.freeze({
|
|
13
|
+
HIGH: 3,
|
|
14
|
+
MEDIUM: 2,
|
|
15
|
+
LOW: 1,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Determines arena debate depth based on aggregate confidence.
|
|
20
|
+
* LOW confidence -> 3 proposals, MEDIUM -> 2, HIGH -> 1.
|
|
21
|
+
*/
|
|
22
|
+
export function getDebateDepth(entries: readonly ConfidenceEntry[]): number {
|
|
23
|
+
const { dominant } = summarizeConfidence(entries);
|
|
24
|
+
return DEPTH_MAP[dominant];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns true if any confidence entry is below the given threshold.
|
|
29
|
+
* Empty entries returns false.
|
|
30
|
+
*/
|
|
31
|
+
export function shouldTriggerExplorer(
|
|
32
|
+
entries: readonly ConfidenceEntry[],
|
|
33
|
+
threshold: ConfidenceLevel = "MEDIUM",
|
|
34
|
+
): boolean {
|
|
35
|
+
if (entries.length === 0) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const thresholdOrder = LEVEL_ORDER[threshold];
|
|
40
|
+
return entries.some((entry) => LEVEL_ORDER[entry.level] < thresholdOrder);
|
|
41
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { ensureDir } from "../utils/fs-helpers";
|
|
3
|
+
import type { Phase } from "./types";
|
|
4
|
+
|
|
5
|
+
export function getPhaseDir(artifactDir: string, phase: Phase): string {
|
|
6
|
+
return join(artifactDir, "phases", phase);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function ensurePhaseDir(artifactDir: string, phase: Phase): Promise<string> {
|
|
10
|
+
const dir = getPhaseDir(artifactDir, phase);
|
|
11
|
+
await ensureDir(dir);
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getArtifactRef(phase: Phase, filename: string): string {
|
|
16
|
+
return `phases/${phase}/${filename}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const PHASE_ARTIFACTS: Readonly<Record<string, readonly string[]>> = Object.freeze({
|
|
20
|
+
RECON: Object.freeze(["report.md"]),
|
|
21
|
+
CHALLENGE: Object.freeze(["brief.md"]),
|
|
22
|
+
ARCHITECT: Object.freeze(["design.md"]),
|
|
23
|
+
EXPLORE: Object.freeze([]),
|
|
24
|
+
PLAN: Object.freeze(["tasks.md"]),
|
|
25
|
+
BUILD: Object.freeze([]),
|
|
26
|
+
SHIP: Object.freeze(["walkthrough.md", "decisions.md", "changelog.md"]),
|
|
27
|
+
RETROSPECTIVE: Object.freeze(["lessons.json"]),
|
|
28
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ConfidenceEntry, Phase, PipelineState } from "./types";
|
|
2
|
+
|
|
3
|
+
type ConfidenceLevel = "HIGH" | "MEDIUM" | "LOW";
|
|
4
|
+
|
|
5
|
+
const LEVEL_PRIORITY: readonly ConfidenceLevel[] = ["HIGH", "MEDIUM", "LOW"] as const;
|
|
6
|
+
|
|
7
|
+
export function appendConfidence(
|
|
8
|
+
state: Readonly<PipelineState>,
|
|
9
|
+
entry: Omit<ConfidenceEntry, "timestamp">,
|
|
10
|
+
): PipelineState {
|
|
11
|
+
return {
|
|
12
|
+
...state,
|
|
13
|
+
confidence: [
|
|
14
|
+
...state.confidence,
|
|
15
|
+
{
|
|
16
|
+
...entry,
|
|
17
|
+
timestamp: new Date().toISOString(),
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
lastUpdatedAt: new Date().toISOString(),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function summarizeConfidence(entries: readonly ConfidenceEntry[]): {
|
|
25
|
+
HIGH: number;
|
|
26
|
+
MEDIUM: number;
|
|
27
|
+
LOW: number;
|
|
28
|
+
total: number;
|
|
29
|
+
dominant: ConfidenceLevel;
|
|
30
|
+
} {
|
|
31
|
+
const counts: Record<ConfidenceLevel, number> = { HIGH: 0, MEDIUM: 0, LOW: 0 };
|
|
32
|
+
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
counts[entry.level]++;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const total = entries.length;
|
|
38
|
+
|
|
39
|
+
// Tie-break: prefer higher confidence (HIGH > MEDIUM > LOW)
|
|
40
|
+
let dominant: ConfidenceLevel = "MEDIUM"; // default for empty
|
|
41
|
+
if (total > 0) {
|
|
42
|
+
let maxCount = 0;
|
|
43
|
+
for (const level of LEVEL_PRIORITY) {
|
|
44
|
+
if (counts[level] > maxCount) {
|
|
45
|
+
maxCount = counts[level];
|
|
46
|
+
dominant = level;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { ...counts, total, dominant };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function filterByPhase(
|
|
55
|
+
entries: readonly ConfidenceEntry[],
|
|
56
|
+
phase: Phase,
|
|
57
|
+
): readonly ConfidenceEntry[] {
|
|
58
|
+
return entries.filter((entry) => entry.phase === phase);
|
|
59
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { parseModelString } from "./event-handler";
|
|
2
|
+
import type { FallbackManager } from "./fallback-manager";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Factory that creates a chat.message hook handler bound to the FallbackManager.
|
|
6
|
+
*
|
|
7
|
+
* When a fallback model is active, overrides `output.message.model` so OpenCode
|
|
8
|
+
* sends the request to the fallback provider/model instead of the original.
|
|
9
|
+
*
|
|
10
|
+
* NOTE: The mutation of `output.message.model` is INTENTIONAL -- the OpenCode
|
|
11
|
+
* hook API requires mutating the output object to override the model. This is
|
|
12
|
+
* the one place where mutation is correct (same pattern as configHook mutating
|
|
13
|
+
* config.agent).
|
|
14
|
+
*/
|
|
15
|
+
export function createChatMessageHandler(manager: FallbackManager) {
|
|
16
|
+
return async (
|
|
17
|
+
input: {
|
|
18
|
+
readonly sessionID: string;
|
|
19
|
+
readonly model?: { readonly providerID: string; readonly modelID: string };
|
|
20
|
+
},
|
|
21
|
+
output: {
|
|
22
|
+
message: { model?: { providerID: string; modelID: string } };
|
|
23
|
+
parts: unknown[];
|
|
24
|
+
},
|
|
25
|
+
): Promise<void> => {
|
|
26
|
+
const state = manager.getSessionState(input.sessionID);
|
|
27
|
+
if (!state) return;
|
|
28
|
+
|
|
29
|
+
// Skip if plugin is actively managing a fallback dispatch (Pitfall 3)
|
|
30
|
+
if (manager.isDispatchInFlight(input.sessionID)) return;
|
|
31
|
+
|
|
32
|
+
// Skip during compaction
|
|
33
|
+
if (manager.isCompactionInFlight(input.sessionID)) return;
|
|
34
|
+
|
|
35
|
+
// Auto-recovery check: revert to original model if cooldown expired
|
|
36
|
+
manager.tryRecoverToOriginal(input.sessionID);
|
|
37
|
+
const currentState = manager.getSessionState(input.sessionID);
|
|
38
|
+
if (!currentState) return;
|
|
39
|
+
|
|
40
|
+
// If on primary model, nothing to override
|
|
41
|
+
if (currentState.currentModel === currentState.originalModel) return;
|
|
42
|
+
|
|
43
|
+
// Override outgoing model to fallback model
|
|
44
|
+
const parsed = parseModelString(currentState.currentModel);
|
|
45
|
+
if (parsed) {
|
|
46
|
+
output.message.model = { providerID: parsed.providerID, modelID: parsed.modelID };
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|