sortie-dogs 0.1.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.
@@ -0,0 +1,271 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { isAbsolute, resolve } from "node:path";
4
+ import { RelativePathError } from "../core/path.js";
5
+ import { validateManifest } from "../core/validate-manifest.js";
6
+ import { validateHandoffSchema, validateOperationManifestSchema } from "../core/validate-schema.js";
7
+ import { resolvePluginConfigurationSources, } from "./config.js";
8
+ import { WriteDeniedError, createProjectPaths, createWriteGate, extractWritePaths, resolveProjectRoot, safePath, } from "./gate.js";
9
+ import { createModelRoutingHook, } from "./model-routing-hook.js";
10
+ const INPUT_LIMITS = { config: 64 * 1024, manifest: 512 * 1024, handoff: 2 * 1024 * 1024 };
11
+ const INSPECTION_CACHE = { maximum: 256, ttlMilliseconds: 30 * 60 * 1000 };
12
+ const PROJECT_CONFIG_PATH = ".opencode/sortie-dogs.json";
13
+ const ENV_CONFIG = "SORTIE_DOGS_CONFIG";
14
+ const COORDINATOR_AGENT = "dog-coordinator";
15
+ const SORTIE_TRIGGER = /^\/sortie(?:\s|$)/;
16
+ export class HandoffDeniedError extends Error {
17
+ reason;
18
+ constructor(reason, path, options) {
19
+ super(`Handoff denied for "${safePath(path)}": handoff and operation manifest contract.`, options);
20
+ this.name = "HandoffDeniedError";
21
+ this.reason = reason;
22
+ }
23
+ }
24
+ class PluginInputError extends Error {
25
+ reason;
26
+ constructor(reason, options) {
27
+ super("Plugin input unavailable.", options);
28
+ this.name = "PluginInputError";
29
+ this.reason = reason;
30
+ }
31
+ }
32
+ function isRecord(value) {
33
+ return value !== null && typeof value === "object" && !Array.isArray(value);
34
+ }
35
+ async function readJson(path, limit) {
36
+ let metadata;
37
+ try {
38
+ metadata = await stat(path);
39
+ }
40
+ catch (error) {
41
+ throw new PluginInputError("read-failed", { cause: error });
42
+ }
43
+ if (!metadata.isFile())
44
+ throw new PluginInputError("not-file");
45
+ if (metadata.size > limit)
46
+ throw new PluginInputError("too-large");
47
+ let source;
48
+ try {
49
+ source = await readFile(path, "utf8");
50
+ }
51
+ catch (error) {
52
+ throw new PluginInputError("read-failed", { cause: error });
53
+ }
54
+ try {
55
+ return JSON.parse(source);
56
+ }
57
+ catch (error) {
58
+ throw new PluginInputError("invalid-json", { cause: error });
59
+ }
60
+ }
61
+ async function readOptionalProjectConfig(project) {
62
+ const path = project.absolute(PROJECT_CONFIG_PATH);
63
+ try {
64
+ return await readJson(path, INPUT_LIMITS.config);
65
+ }
66
+ catch (error) {
67
+ if (error instanceof PluginInputError &&
68
+ error.reason === "read-failed" &&
69
+ isRecord(error.cause) &&
70
+ error.cause.code === "ENOENT")
71
+ return undefined;
72
+ throw error;
73
+ }
74
+ }
75
+ function readEnvironmentConfig() {
76
+ const source = process.env[ENV_CONFIG];
77
+ if (source === undefined || source.length === 0)
78
+ return undefined;
79
+ try {
80
+ return JSON.parse(source);
81
+ }
82
+ catch (error) {
83
+ throw new PluginInputError("invalid-json", { cause: error });
84
+ }
85
+ }
86
+ async function loadConfigured(project, config) {
87
+ const manifestPath = await project.toRelativePath(config.operationManifestPath);
88
+ const manifestValue = await readJson(project.absolute(manifestPath), INPUT_LIMITS.manifest);
89
+ const validation = validateOperationManifestSchema(manifestValue);
90
+ if (!validation.ok)
91
+ throw new WriteDeniedError("manifest-unavailable", "<unknown>");
92
+ const gate = await createWriteGate(project, validation.value);
93
+ const handoffPaths = [];
94
+ for (const path of config.handoffPaths)
95
+ handoffPaths.push(await project.toRelativePath(path));
96
+ const hasModelRouting = Object.keys(config.localModelRouting).length > 0 ||
97
+ Object.keys(config.globalModelRouting).length > 0;
98
+ const modelRoutingHook = hasModelRouting
99
+ ? createModelRoutingHook({
100
+ local: config.localModelRouting,
101
+ global: config.globalModelRouting,
102
+ catalog: config.modelCatalog,
103
+ })
104
+ : undefined;
105
+ return { gate, manifest: validation.value, handoffPaths, modelRoutingHook };
106
+ }
107
+ function textPart(part) {
108
+ return isRecord(part) && typeof part.text === "string" ? part.text : undefined;
109
+ }
110
+ function activatesSession(input, output) {
111
+ if (input.agent === COORDINATOR_AGENT || output.message.agent === COORDINATOR_AGENT)
112
+ return true;
113
+ return output.parts.some((part) => {
114
+ const text = textPart(part);
115
+ return text !== undefined && SORTIE_TRIGGER.test(text);
116
+ });
117
+ }
118
+ function stableValue(value) {
119
+ if (Array.isArray(value))
120
+ return value.map(stableValue);
121
+ if (!isRecord(value))
122
+ return value;
123
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
124
+ }
125
+ function inspectionFingerprint(handoff, diagnostics) {
126
+ return createHash("sha256")
127
+ .update(JSON.stringify([stableValue(handoff), stableValue(diagnostics)]))
128
+ .digest("hex");
129
+ }
130
+ function pruneInspectionCache(cache, now) {
131
+ for (const [key, entry] of cache)
132
+ if (entry.expiresAt <= now)
133
+ cache.delete(key);
134
+ while (cache.size >= INSPECTION_CACHE.maximum)
135
+ cache.delete(cache.keys().next().value);
136
+ }
137
+ /** Named OpenCode plugin export. Importing the package has no side effects; invoking it installs active gates. */
138
+ export const SortieDogsPlugin = async (input, options) => {
139
+ let project;
140
+ let loaded;
141
+ let loadFailure;
142
+ let loading;
143
+ async function ensureLoaded() {
144
+ if (loading !== undefined)
145
+ return loading;
146
+ loading = (async () => {
147
+ try {
148
+ project = await createProjectPaths(resolveProjectRoot(input));
149
+ const projectConfig = await readOptionalProjectConfig(project);
150
+ const environmentConfig = readEnvironmentConfig();
151
+ const parsed = resolvePluginConfigurationSources(projectConfig, environmentConfig, options);
152
+ if (parsed.kind === "invalid")
153
+ throw new WriteDeniedError("manifest-unavailable", "<unknown>");
154
+ loaded = await loadConfigured(project, parsed);
155
+ }
156
+ catch (error) {
157
+ loadFailure = error;
158
+ }
159
+ })();
160
+ return loading;
161
+ }
162
+ const inspected = new Map();
163
+ const activeSessions = new Set();
164
+ async function inspect(path, sessionID) {
165
+ await ensureLoaded();
166
+ if (loaded === undefined || project === undefined) {
167
+ throw new HandoffDeniedError("configuration-unavailable", path, { cause: loadFailure });
168
+ }
169
+ let normalized;
170
+ try {
171
+ normalized = await project.toRelativePath(path);
172
+ }
173
+ catch (error) {
174
+ if (error instanceof WriteDeniedError || error instanceof RelativePathError) {
175
+ throw new HandoffDeniedError("path-invalid", path, { cause: error });
176
+ }
177
+ throw error;
178
+ }
179
+ if (!loaded.handoffPaths.includes(normalized))
180
+ return;
181
+ let value;
182
+ try {
183
+ value = await readJson(project.absolute(normalized), INPUT_LIMITS.handoff);
184
+ }
185
+ catch (error) {
186
+ if (error instanceof PluginInputError) {
187
+ throw new HandoffDeniedError("input-unavailable", normalized, { cause: error });
188
+ }
189
+ throw error;
190
+ }
191
+ const validation = validateHandoffSchema(value);
192
+ if (!validation.ok)
193
+ throw new HandoffDeniedError("schema-invalid", normalized);
194
+ const diagnostics = validateManifest(validation.value, loaded.manifest, undefined, false);
195
+ if (diagnostics.some(({ severity }) => severity === "error")) {
196
+ throw new HandoffDeniedError("contract-invalid", normalized);
197
+ }
198
+ const fingerprint = inspectionFingerprint(validation.value, diagnostics);
199
+ if (sessionID === undefined)
200
+ return;
201
+ const key = `${sessionID}\u0000${normalized}`;
202
+ const now = Date.now();
203
+ pruneInspectionCache(inspected, now);
204
+ if (inspected.get(key)?.fingerprint === fingerprint)
205
+ return;
206
+ inspected.delete(key);
207
+ inspected.set(key, { fingerprint, expiresAt: now + INSPECTION_CACHE.ttlMilliseconds });
208
+ }
209
+ return {
210
+ "chat.message": async (chatInput, output) => {
211
+ if (activatesSession(chatInput, output))
212
+ activeSessions.add(chatInput.sessionID);
213
+ if (!activeSessions.has(chatInput.sessionID))
214
+ return;
215
+ await ensureLoaded();
216
+ await loaded?.modelRoutingHook?.(chatInput, output);
217
+ },
218
+ "permission.ask": async (permission) => {
219
+ if (permission.permission !== "edit")
220
+ return;
221
+ if (permission.sessionID !== undefined && !activeSessions.has(permission.sessionID))
222
+ return;
223
+ await ensureLoaded();
224
+ if (loaded === undefined) {
225
+ throw new WriteDeniedError("manifest-unavailable", "<unknown>", { cause: loadFailure });
226
+ }
227
+ for (const pattern of permission.patterns) {
228
+ const path = isAbsolute(pattern) || input.worktree === undefined
229
+ ? pattern
230
+ : resolve(input.worktree, pattern);
231
+ await loaded.gate.checkPath(path);
232
+ }
233
+ },
234
+ "tool.execute.before": async (toolInput, output) => {
235
+ if (!activeSessions.has(toolInput.sessionID))
236
+ return;
237
+ await ensureLoaded();
238
+ if (loaded === undefined) {
239
+ const extraction = extractWritePaths(toolInput.tool, output.args);
240
+ if (extraction.applies) {
241
+ throw new WriteDeniedError("manifest-unavailable", "<unknown>", { cause: loadFailure });
242
+ }
243
+ return;
244
+ }
245
+ await loaded.gate.check(toolInput, output);
246
+ },
247
+ event: async ({ event }) => {
248
+ const eventSessionID = typeof event.properties?.sessionID === "string"
249
+ ? event.properties.sessionID
250
+ : undefined;
251
+ if (eventSessionID === undefined || !activeSessions.has(eventSessionID))
252
+ return;
253
+ if (event.type === "file.edited" && typeof event.properties?.file === "string") {
254
+ await inspect(event.properties.file, eventSessionID);
255
+ }
256
+ else if (event.type === "session.idle" && eventSessionID !== undefined) {
257
+ await ensureLoaded();
258
+ if (loaded === undefined) {
259
+ throw new HandoffDeniedError("configuration-unavailable", "<unknown>", { cause: loadFailure });
260
+ }
261
+ for (const path of loaded.handoffPaths)
262
+ await inspect(path, eventSessionID);
263
+ activeSessions.delete(eventSessionID);
264
+ }
265
+ else if (event.type === "session.deleted") {
266
+ activeSessions.delete(eventSessionID);
267
+ }
268
+ },
269
+ };
270
+ };
271
+ export { InvalidModelTargetError, ModelRoutingDeniedError } from "./model-routing-hook.js";
@@ -0,0 +1,40 @@
1
+ import { type ModelCatalog, type ModelResolutionAttempt, type ModelRoutingConfig } from "./model-routing.js";
2
+ export interface OpenCodeChatMessageInput {
3
+ sessionID: string;
4
+ agent?: string;
5
+ model?: {
6
+ providerID: string;
7
+ modelID: string;
8
+ };
9
+ messageID?: string;
10
+ }
11
+ export interface OpenCodeChatMessageOutput {
12
+ message: {
13
+ agent?: string;
14
+ model: {
15
+ providerID: string;
16
+ modelID: string;
17
+ variant?: string;
18
+ };
19
+ [key: string]: unknown;
20
+ };
21
+ parts: unknown[];
22
+ }
23
+ export type OpenCodeChatMessageHook = (input: OpenCodeChatMessageInput, output: OpenCodeChatMessageOutput) => Promise<void>;
24
+ export interface ModelRoutingHookConfiguration {
25
+ readonly local?: ModelRoutingConfig;
26
+ readonly global?: ModelRoutingConfig;
27
+ readonly catalog: ModelCatalog;
28
+ }
29
+ export declare class ModelRoutingDeniedError extends Error {
30
+ readonly reason = "unresolved-role";
31
+ readonly role: string;
32
+ readonly attempts: readonly ModelResolutionAttempt[];
33
+ constructor(role: string, attempts: readonly ModelResolutionAttempt[]);
34
+ }
35
+ export declare class InvalidModelTargetError extends Error {
36
+ readonly reason = "invalid-model-target";
37
+ constructor();
38
+ }
39
+ /** Deterministic structural OpenCode hook; all availability comes from the supplied catalog. */
40
+ export declare function createModelRoutingHook(config: ModelRoutingHookConfiguration): OpenCodeChatMessageHook;
@@ -0,0 +1,55 @@
1
+ import { resolveModelRoute, } from "./model-routing.js";
2
+ export class ModelRoutingDeniedError extends Error {
3
+ reason = "unresolved-role";
4
+ role;
5
+ attempts;
6
+ constructor(role, attempts) {
7
+ super(`Model routing denied for role "${role}": unresolved role.`);
8
+ this.name = "ModelRoutingDeniedError";
9
+ this.role = role;
10
+ this.attempts = attempts;
11
+ }
12
+ }
13
+ export class InvalidModelTargetError extends Error {
14
+ reason = "invalid-model-target";
15
+ constructor() {
16
+ super("Model routing denied: target must contain provider and model identifiers.");
17
+ this.name = "InvalidModelTargetError";
18
+ }
19
+ }
20
+ function openCodeModel(model) {
21
+ const separator = model.indexOf("/");
22
+ if (separator <= 0 || separator === model.length - 1)
23
+ return undefined;
24
+ return { providerID: model.slice(0, separator), modelID: model.slice(separator + 1) };
25
+ }
26
+ /** Deterministic structural OpenCode hook; all availability comes from the supplied catalog. */
27
+ export function createModelRoutingHook(config) {
28
+ return async (input, output) => {
29
+ const role = input.agent && input.agent.length > 0
30
+ ? input.agent
31
+ : output.message.agent && output.message.agent.length > 0
32
+ ? output.message.agent
33
+ : undefined;
34
+ if (role === undefined)
35
+ return;
36
+ const hasRoute = Object.prototype.hasOwnProperty.call(config.local ?? {}, role) ||
37
+ Object.prototype.hasOwnProperty.call(config.global ?? {}, role);
38
+ if (!hasRoute)
39
+ return;
40
+ const resolution = resolveModelRoute({
41
+ role,
42
+ local: config.local,
43
+ global: config.global,
44
+ catalog: config.catalog,
45
+ });
46
+ if (!resolution.ok)
47
+ throw new ModelRoutingDeniedError(resolution.role, resolution.attempts);
48
+ const model = openCodeModel(resolution.model);
49
+ if (model === undefined)
50
+ throw new InvalidModelTargetError();
51
+ output.message.model = resolution.variant === undefined
52
+ ? model
53
+ : { ...model, variant: resolution.variant };
54
+ };
55
+ }
@@ -0,0 +1,62 @@
1
+ export interface ModelTarget {
2
+ readonly model: string;
3
+ readonly variant?: string;
4
+ }
5
+ export interface RoleModelRoute {
6
+ readonly preferred: ModelTarget;
7
+ readonly fallback?: readonly ModelTarget[];
8
+ }
9
+ export type ModelRoutingConfig = Readonly<Record<string, RoleModelRoute>>;
10
+ export declare const DEDICATED_SOL_MODEL = "openai/gpt-5.6-sol";
11
+ export declare const DEDICATED_SOL_ROLES: readonly ["implementation", "remediation", "blocker-resolution", "sol-worker-mk2a2"];
12
+ /** Fixed Mk2A2 worker routes. These deliberately contain no fallback targets. */
13
+ export declare const DEDICATED_SOL_ROUTING: ModelRoutingConfig;
14
+ export declare const RECOMMENDED_LUNA_MODEL = "openai/gpt-5.6-luna";
15
+ export declare const RECOMMENDED_LUNA_VARIANT = "xhigh";
16
+ export declare const RECOMMENDED_LUNA_ROLES: readonly ["dog-coordinator", "dog-scout"];
17
+ /** Configurable Mk2A2 defaults. Project-local and global configuration may override these routes. */
18
+ export declare const RECOMMENDED_LUNA_ROUTING: ModelRoutingConfig;
19
+ export declare function isDedicatedSolRole(role: string): boolean;
20
+ export interface CatalogModel {
21
+ readonly model: string;
22
+ /** Omit only when the model has no named variants. */
23
+ readonly variants?: readonly string[];
24
+ }
25
+ export interface ModelCatalog {
26
+ readonly project?: readonly CatalogModel[];
27
+ readonly global?: readonly CatalogModel[];
28
+ }
29
+ /** Built-in availability metadata for source-level recommendations; no provider probing required. */
30
+ export declare const BUILT_IN_MODEL_CATALOG: ModelCatalog;
31
+ export interface ResolveModelRouteInput {
32
+ readonly role: string;
33
+ /** Project-local routing. A resolvable local route takes priority. */
34
+ readonly local?: ModelRoutingConfig;
35
+ /** Global routing is consulted only after the local route candidates fail. */
36
+ readonly global?: ModelRoutingConfig;
37
+ readonly catalog: ModelCatalog;
38
+ }
39
+ export interface ResolvedModelRoute {
40
+ readonly ok: true;
41
+ readonly role: string;
42
+ readonly source: "local" | "global";
43
+ readonly catalog: "project" | "global";
44
+ readonly model: string;
45
+ readonly variant?: string;
46
+ }
47
+ export interface ModelResolutionAttempt {
48
+ readonly source: "local" | "global";
49
+ readonly target: ModelTarget;
50
+ readonly reason: "model-unavailable" | "variant-unavailable";
51
+ }
52
+ export interface UnresolvedModelRoute {
53
+ readonly ok: false;
54
+ readonly role: string;
55
+ readonly reason: "unresolved-role";
56
+ readonly attempts: readonly ModelResolutionAttempt[];
57
+ }
58
+ export type ModelRouteResolution = ResolvedModelRoute | UnresolvedModelRoute;
59
+ /** Strict runtime parser for project, environment, and host routing layers. */
60
+ export declare function parseModelRoutingConfig(value: unknown): ModelRoutingConfig | undefined;
61
+ /** Resolve a role deterministically without provider calls or implicit model guesses. */
62
+ export declare function resolveModelRoute(input: ResolveModelRouteInput): ModelRouteResolution;
@@ -0,0 +1,128 @@
1
+ export const DEDICATED_SOL_MODEL = "openai/gpt-5.6-sol";
2
+ export const DEDICATED_SOL_ROLES = [
3
+ "implementation",
4
+ "remediation",
5
+ "blocker-resolution",
6
+ "sol-worker-mk2a2",
7
+ ];
8
+ const dedicatedSolRoleSet = new Set(DEDICATED_SOL_ROLES);
9
+ /** Fixed Mk2A2 worker routes. These deliberately contain no fallback targets. */
10
+ export const DEDICATED_SOL_ROUTING = Object.freeze(Object.fromEntries(DEDICATED_SOL_ROLES.map((role) => [role, Object.freeze({
11
+ preferred: Object.freeze({ model: DEDICATED_SOL_MODEL }),
12
+ })])));
13
+ export const RECOMMENDED_LUNA_MODEL = "openai/gpt-5.6-luna";
14
+ export const RECOMMENDED_LUNA_VARIANT = "xhigh";
15
+ export const RECOMMENDED_LUNA_ROLES = ["dog-coordinator", "dog-scout"];
16
+ /** Configurable Mk2A2 defaults. Project-local and global configuration may override these routes. */
17
+ export const RECOMMENDED_LUNA_ROUTING = Object.freeze(Object.fromEntries(RECOMMENDED_LUNA_ROLES.map((role) => [role, Object.freeze({
18
+ preferred: Object.freeze({
19
+ model: RECOMMENDED_LUNA_MODEL,
20
+ variant: RECOMMENDED_LUNA_VARIANT,
21
+ }),
22
+ })])));
23
+ export function isDedicatedSolRole(role) {
24
+ return dedicatedSolRoleSet.has(role);
25
+ }
26
+ /** Built-in availability metadata for source-level recommendations; no provider probing required. */
27
+ export const BUILT_IN_MODEL_CATALOG = Object.freeze({
28
+ global: Object.freeze([Object.freeze({
29
+ model: RECOMMENDED_LUNA_MODEL,
30
+ variants: Object.freeze([RECOMMENDED_LUNA_VARIANT]),
31
+ })]),
32
+ });
33
+ function isRecord(value) {
34
+ return value !== null && typeof value === "object" && !Array.isArray(value);
35
+ }
36
+ function nonEmptyString(value) {
37
+ return typeof value === "string" && value.length > 0;
38
+ }
39
+ function parseTarget(value) {
40
+ if (!isRecord(value) || Object.keys(value).some((key) => key !== "model" && key !== "variant")) {
41
+ return undefined;
42
+ }
43
+ if (!nonEmptyString(value.model))
44
+ return undefined;
45
+ if (value.variant !== undefined && !nonEmptyString(value.variant))
46
+ return undefined;
47
+ return value.variant === undefined
48
+ ? { model: value.model }
49
+ : { model: value.model, variant: value.variant };
50
+ }
51
+ /** Strict runtime parser for project, environment, and host routing layers. */
52
+ export function parseModelRoutingConfig(value) {
53
+ if (!isRecord(value))
54
+ return undefined;
55
+ const parsed = Object.create(null);
56
+ for (const [role, routeValue] of Object.entries(value)) {
57
+ if (role.length === 0 ||
58
+ Object.prototype.hasOwnProperty.call(Object.prototype, role) ||
59
+ !isRecord(routeValue))
60
+ return undefined;
61
+ if (Object.keys(routeValue).some((key) => key !== "preferred" && key !== "fallback")) {
62
+ return undefined;
63
+ }
64
+ const preferred = parseTarget(routeValue.preferred);
65
+ if (preferred === undefined)
66
+ return undefined;
67
+ const fallbackValue = routeValue.fallback;
68
+ if (fallbackValue !== undefined && !Array.isArray(fallbackValue))
69
+ return undefined;
70
+ const fallback = [];
71
+ for (const candidate of fallbackValue ?? []) {
72
+ const target = parseTarget(candidate);
73
+ if (target === undefined)
74
+ return undefined;
75
+ fallback.push(target);
76
+ }
77
+ parsed[role] = fallbackValue === undefined ? { preferred } : { preferred, fallback };
78
+ }
79
+ return parsed;
80
+ }
81
+ function ownRoute(config, role) {
82
+ return config !== undefined && Object.prototype.hasOwnProperty.call(config, role)
83
+ ? config[role]
84
+ : undefined;
85
+ }
86
+ function findAvailable(target, catalog) {
87
+ let modelFound = false;
88
+ for (const [scope, models] of [
89
+ ["project", catalog.project ?? []],
90
+ ["global", catalog.global ?? []],
91
+ ]) {
92
+ for (const candidate of models) {
93
+ if (candidate.model !== target.model)
94
+ continue;
95
+ modelFound = true;
96
+ if (target.variant === undefined || candidate.variants?.includes(target.variant)) {
97
+ return { catalog: scope };
98
+ }
99
+ }
100
+ }
101
+ return modelFound ? "variant-unavailable" : "model-unavailable";
102
+ }
103
+ /** Resolve a role deterministically without provider calls or implicit model guesses. */
104
+ export function resolveModelRoute(input) {
105
+ const attempts = [];
106
+ for (const [source, route] of [
107
+ ["local", ownRoute(input.local, input.role)],
108
+ ["global", ownRoute(input.global, input.role)],
109
+ ]) {
110
+ if (route === undefined)
111
+ continue;
112
+ for (const target of [route.preferred, ...(route.fallback ?? [])]) {
113
+ const availability = findAvailable(target, input.catalog);
114
+ if (typeof availability === "object") {
115
+ return {
116
+ ok: true,
117
+ role: input.role,
118
+ source,
119
+ catalog: availability.catalog,
120
+ model: target.model,
121
+ ...(target.variant === undefined ? {} : { variant: target.variant }),
122
+ };
123
+ }
124
+ attempts.push({ source, target, reason: availability });
125
+ }
126
+ }
127
+ return { ok: false, role: input.role, reason: "unresolved-role", attempts };
128
+ }
@@ -0,0 +1,37 @@
1
+ export interface RuntimeAsset {
2
+ readonly name: string;
3
+ readonly version: "0.2.0-card05";
4
+ readonly installPath: string;
5
+ readonly content: string;
6
+ }
7
+ export declare const runtimeAssets: readonly [{
8
+ readonly name: "dog-coordinator";
9
+ readonly version: "0.2.0-card05";
10
+ readonly installPath: "agent/dog-coordinator.md";
11
+ readonly content: "---\ndescription: Canonical Mk2A2 coordinator packaged by Sortie-dogs\nmode: primary\n---\n# dog-coordinator\n\nYou are the primary coordinator and the only user-facing agent for the canonical\nMk2A2 workflow. Follow project instructions and preserve the canonical MkII order:\n\n1. Confirm the project target. Before any edit, state a plan of no more than three lines.\n2. Fix the acceptance criteria, editable manifest, worker role, and validation command.\n3. Delegate implementation work to dog-worker with all required context inline.\n4. Evaluate returned validation evidence, apply the canonical review policy, then complete\n coordinator-owned commit and reporting work.\n\nKeep control of the user conversation. Workers return only to you. Never invoke the build\nagent or any alternate coordinator, and never make either one a fallback route.\n\nUse dog-advisor only for Strategy or SourceReview consultation. Keep implementation,\nremediation, and blocker-resolution work on dog-worker. Findings from every subagent return\nthrough dog-coordinator; subagents never report to each other or the user.\n\n## Required scout fan-out\n\nBefore each worker handoff, perform exactly one bounded parallel fan-out containing exactly\nthree dog-scout calls: role A determines the exact manifest, role B determines the canonical\nvalidation command, and role C identifies the blocker owner. Do not add a fourth scout or run\nthese roles sequentially. Union all well-formed facts without voting or majority rules. A scout\nresult is well formed only when it identifies its assigned role and supplies non-empty facts;\ndiscard malformed, timed-out, or empty output without retry. The coordinator fixes the manifest,\nvalidation, and owner from the accepted union plus existing evidence, then hands implementation,\nremediation, or blocker-resolution only to dog-worker.\n\nSCOUT_FANOUT_FIXTURE\n dispatch: exactly three bounded dog-scout calls in one parallel fan-out\n role_A: determine exact source_manifest or operation_manifest\n role_B: determine exact canonical validation command\n role_C: identify blocker owner\n merge: union all well-formed facts; no voting or majority rule\n invalid: malformed | timeout | empty -> discard without retry\n next_route: implementation | remediation | blocker-resolution -> dog-worker only\nEND_SCOUT_FANOUT_FIXTURE\n\n## Worker handoff contract\n\nEvery worker dispatch has one bounded inline context_digest. Bound it to concise,\nacceptance-relevant summaries: never include raw logs, full source files, unrelated history,\nsecrets, or duplicate facts. The effective digest always contains task_id, project_root,\nacceptance, role (implementation, remediation, or blocker-resolution), validation level\n(targeted or full) and exact command, known_facts, relevant_constraints, resume_delta, and\nthe applicable source_manifest or operation_manifest. Include applicable project instructions,\nknown paths, and prior validation fingerprints when they affect the work.\n\nFor the initial dispatch, send all required values inline and mark resume_delta as none. Treat\nthis digest as the candidate source of truth so the worker does not repeat project listing,\ninstruction discovery, known-file reads, Git status, or already-recorded validation.\n\nINITIAL_HANDOFF_FIXTURE\n task_id: task-06\n context_digest:\n project_root: <absolute project root>\n acceptance: <fixed acceptance criteria>\n role: implementation\n validation: { level: full, command: <exact command> }\n known_facts: [<task-relevant fact>]\n relevant_constraints: [<applicable instruction>]\n resume_delta: none\n source_manifest: [<declared source path>]\n operation_manifest: none\nEND_INITIAL_HANDOFF_FIXTURE\n\nFor a same-task resume, retain the prior effective digest. Send the same task_id and only a\nresume_delta containing stale_paths, new_findings, the previous command exit/fingerprint, and\nnext_action. Do not resend unchanged acceptance, role, validation, facts, constraints,\nmanifests, or file content; the preserved values plus this delta form the effective digest.\n\nRESUMED_HANDOFF_FIXTURE\n task_id: task-06\n context_digest:\n mode: same-task-resume\n preserve: [acceptance, role, validation, known_facts, relevant_constraints, source_manifest]\n resume_delta:\n stale_paths: [<path changed since checkpoint>]\n new_findings: [<new fact>]\n previous_exit: <exit and concise fingerprint>\n next_action: <single next action>\nEND_RESUMED_HANDOFF_FIXTURE\n\n## Restart recovery\n\nOn restart or re-entry, remain the primary user-facing coordinator. Reconstruct the effective\ntask context from current project-local durable artifacts plus the latest bounded handoff or\ncheckpoint supplied with the request. Prefer the latest checkpoint for task progress, but\nreconcile its paths with the current project before acting. Preserve the exact source_manifest\nand operation_manifest, including an explicit none, and preserve validation history in attempt\norder with command, exit, and fingerprint. Do not repeat a recorded successful validation unless\nrelevant source changed after that attempt.\n\nContinue the same task through dog-coordinator. Dispatch implementation only to dog-worker using the\nsame-task resume contract and the smallest resume_delta needed for stale paths, new findings,\nand next action. Never route a worker directly to the user.\n\nRESTART_RECOVERY_FIXTURE\n reconstruction: project-local durable artifacts + latest bounded handoff/checkpoint\n preserve: [source_manifest, operation_manifest, validation_history]\n validation_history_entry: { command: <exact command>, exit: <exit>, fingerprint: <concise fingerprint> }\n reconcile: checkpoint paths against current project\n resume_route: dog-coordinator -> dog-worker\n user_route: dog-coordinator only\nEND_RESTART_RECOVERY_FIXTURE\n\nFor takeover of incomplete work, keep the same task_id and effective inline handoff. Add only\nthe bounded resume_delta, set role to remediation or blocker-resolution as appropriate, and\nroute the takeover only to dog-worker. Preserve both manifests and ordered validation history.\n\nTAKEOVER_FIXTURE\n context: same task_id + preserved effective inline handoff + bounded resume_delta\n roles: remediation | blocker-resolution\n route: dog-coordinator -> dog-worker only\n preserve: [source_manifest, operation_manifest, validation_history]\nEND_TAKEOVER_FIXTURE\n\n## Bounded batch continuation\n\nThis normal bounded-batch section applies only while backlogDrain.enabled=false.\nUse one bounded sequential batch per fresh session. A unit becomes attempted at its terminal\nhandoff, and only a successful coordinator commit makes it done. Record a Project status\ncheckpoint for every terminal unit. A blocked unit records its blocker with a concrete needed\naction, then continuation proceeds to the next independent unit. Only a whole-batch blocker or\na user question stops the batch early.\n\nBATCH_CONTINUATION_FIXTURE\n scope: backlogDrain.enabled=false; mode=normal bounded batch\n fresh_session: max_units=3; batchAttempted=0; batchDone=0\n order: sequential\n unit_N_plus_1_start: only after unit N terminal handoff\n terminal_unit: increment batchAttempted; record Project status checkpoint\n successful_commit: increment batchDone\n blocked_unit: record blocker with concrete needed action; continue to next independent unit\n noncomplete_handoff: exact next action required; completed handoff: completion evidence required\n early_stop: only whole-batch blocker or user question\n fourth_unit: rejected\nEND_BATCH_CONTINUATION_FIXTURE\n\nBacklog drain is a configurable, explicit opt-in only. Unless the task entry sets\nbacklogDrain.enabled to true and supplies a positive backlogDrain.maxUnits guard, use the\nunchanged bounded batch above with batchTarget=3. Drain mode remains sequential and keeps the\nsame worker handoff, manifest, validation, review, checkpoint, and coordinator-owned commit\ngates for every unit.\n\nAt drain start and after each compact resume, inventory all non-Done Project items. Request\nitems(first:100), inspect pageInfo, and continue from endCursor while hasNextPage is true; never\ntreat a first page or a capped count as complete inventory. Select the next independent item\nfrom that complete inventory. After each terminal handoff and checkpoint, compact the context,\nresume through dog-coordinator, reinventory, and continue until a stop condition applies.\nTrack a progress fingerprint from the completed inventory and terminal outcomes. Stop rather\nthan loop when a full resume cycle changes neither inventory nor outcomes, when user input is\nrequired, when a proven external blocker prevents the drain, or before attempted units would\nexceed backlogDrain.maxUnits. The attempted-unit count survives every compact resume, is carried\nin both the Project checkpoint and resume_delta, and never resets during the drain run; the max\nguard counts attempted units across that whole run. A blocked item alone does not stop\nindependent work.\n\nBACKLOG_DRAIN_FIXTURE\n default_config: batchTarget=3; backlogDrain.enabled=false\n opt_in_required: backlogDrain.enabled=true; backlogDrain.maxUnits=<positive integer>\n execution: sequential; coordinator_authority=unchanged; per_unit_gates=unchanged\n inventory_page_1: items(first:100)\n inventory_next_page: while pageInfo.hasNextPage; after=pageInfo.endCursor\n inventory_filter: include every item whose status is not Done\n continuation: terminal handoff -> Project checkpoint -> compact resume -> complete reinventory\n attempted_count: survive every compact resume; carry in Project checkpoint and resume_delta\n max_guard_scope: count attempted units across the whole drain run; never reset on resume\n progress: compare complete inventory and terminal outcomes across a full resume cycle\n stop: no progress | user decision | proven external blocker | backlogDrain.maxUnits reached\n blocked_item: continue with next independent item\nEND_BACKLOG_DRAIN_FIXTURE\n\nChoose manifests by mutation type. Source-changing work requires an exact source_manifest;\noperational work requires an exact operation_manifest describing targets and mutations. Mark\nthe unused manifest none; when acceptance explicitly requires both mutation types, declare\nboth. Before dispatch and before each action, match every source write or operational mutation\nto its manifest. Missing, ambiguous, or out-of-scope entries are rejected before mutation and\nfail closed. Never infer permission from acceptance alone.\n\nMANIFEST_SCOPE_FIXTURE\n source_manifest: [src/declared.ts]\n allowed: write src/declared.ts\n rejected: write src/undeclared.ts -> fail closed before mutation\nEND_MANIFEST_SCOPE_FIXTURE\n\n## Validation, review, and commit gates\n\nThe coordinator owns every staging and commit action. Reject and report any worker attempt to\nstage or commit. Run the canonical validation before staging; a nonzero exit blocks both staging\nand commit. Classify candidate risk only after canonical validation. For a low-risk candidate,\nexplicitly record dog-reviewer skipped and permit staging. For a high-risk candidate, run\ndog-reviewer only after canonical validation passes and require its PASS before the coordinator\nstages or commits. Return reviewer findings through dog-coordinator and fail closed while\nunreviewed.\n\nGATE_POLICY_FIXTURE\n risk_rule: high when any source_manifest entry is outside test/, or validation level is targeted; otherwise low\n canonical_validation_nonzero: staging rejected; commit rejected\n worker_stage_or_commit: rejected and reported\n low_risk_validated: independent_review skipped and recorded; staging allowed\n high_risk_unreviewed: staging rejected; commit rejected\n high_risk_validated_reviewed: staging allowed\nEND_GATE_POLICY_FIXTURE\n\nWhen every gate passes, stage only the exact source_manifest paths. Read the cached path set and\nrequire set equality with source_manifest immediately before commit. Any missing or extra cached\npath rejects the commit. Only the coordinator may commit after this equality check passes.\n\nCOMMIT_SCOPE_FIXTURE\n source_manifest: [src/declared.ts]\n coordinator_stage: git add -- src/declared.ts\n cached_paths: [src/declared.ts]\n required: cached_paths set equals source_manifest set\n mismatch: commit rejected\nEND_COMMIT_SCOPE_FIXTURE\n\nAt each checkpoint and terminal return, require concise evidence only. Terminal evidence must\ncontain status, task_id, manifest, decisions, ordered validation entries with exact command,\nexit, and fingerprint, raw_status, diff summary, stale_paths, new_findings, and next_action.\nAn undeclared write or mutation must be reported as rejected, not performed.\n\nTERMINAL_EVIDENCE_FIXTURE\n status: DONE | BLOCKED | NEED_DECISION\n task_id: <stable task id>\n manifest: <entries touched>\n decisions: [<autonomous decision>]\n validation: [{ command: <exact command>, exit: <exit>, fingerprint: <concise fingerprint> }]\n raw_status: <unmodified status evidence>\n diff: <concise diff summary>\n stale_paths: [<path or none>]\n new_findings: [<finding or none>]\n next_action: <single action or none>\nEND_TERMINAL_EVIDENCE_FIXTURE\n";
12
+ }, {
13
+ readonly name: "dog-worker";
14
+ readonly version: "0.2.0-card05";
15
+ readonly installPath: "agent/dog-worker.md";
16
+ readonly content: "---\ndescription: Dedicated Sol worker for the canonical Mk2A2 coordinator\nmode: subagent\n---\n# dog-worker\n\nYou are the dedicated implementation worker for dog-coordinator.\n\nAccept implementation, remediation, and blocker-resolution work only from dog-coordinator.\nExecute the supplied manifest within its acceptance criteria, run the requested validation,\nand return concise change and validation evidence only to dog-coordinator. Do not act as the\nuser-facing coordinator.\n";
17
+ }, {
18
+ readonly name: "dog-scout";
19
+ readonly version: "0.2.0-card05";
20
+ readonly installPath: "agent/dog-scout.md";
21
+ readonly content: "---\ndescription: Bounded evidence scout for dog-coordinator\nmode: subagent\n---\n# dog-scout\n\nAct only as assigned parallel role A (manifest), B (canonical validation), or C (blocker owner).\nInvestigate only the bounded question and paths supplied by dog-coordinator. Do not edit, stage,\ncommit, retry, or become user-facing. Return the assigned role, non-empty concise facts, evidence\npaths, and unresolved risks only to dog-coordinator.\n";
22
+ }, {
23
+ readonly name: "dog-reviewer";
24
+ readonly version: "0.2.0-card05";
25
+ readonly installPath: "agent/dog-reviewer.md";
26
+ readonly content: "---\ndescription: Independent source reviewer for dog-coordinator\nmode: subagent\n---\n# dog-reviewer\n\nOnly after canonical validation, review a high-risk candidate against its acceptance criteria,\nmanifest, and validation evidence. Do not review low-risk candidates. Do not edit, stage,\ncommit, or become user-facing. Return PASS or concrete findings only to dog-coordinator before\nthe coordinator commit.\n";
27
+ }, {
28
+ readonly name: "dog-advisor";
29
+ readonly version: "0.2.0-card05";
30
+ readonly installPath: "agent/dog-advisor.md";
31
+ readonly content: "---\ndescription: Focused technical advisor for dog-coordinator\nmode: subagent\n---\n# dog-advisor\n\nAccept only a bounded Strategy or SourceReview consultation from dog-coordinator. Do not\nimplement, remediate, resolve blockers, edit, stage, commit, dispatch other agents, or become\nuser-facing. Return concise options and a recommendation only to dog-coordinator.\n";
32
+ }, {
33
+ readonly name: "sortie";
34
+ readonly version: "0.2.0-card05";
35
+ readonly installPath: "command/sortie.md";
36
+ readonly content: "---\ndescription: Start the canonical Sortie-dogs Mk2A2 workflow\nagent: dog-coordinator\n---\nRequest: $ARGUMENTS\n\n1. If $ARGUMENTS is empty, request task context and stop; give project init guidance first.\n2. Preflight .opencode/sortie-dogs.version, .opencode/command/sortie.md, and .opencode/agent/\n dog-coordinator.md, dog-worker.md, dog-scout.md, dog-reviewer.md, dog-advisor.md. Report gaps;\n do not edit.\n3. On restart or re-entry, reconstruct context from project-local durable artifacts and the\n latest bounded handoff or checkpoint. Preserve both manifests and ordered validation history;\n resume the same task through dog-coordinator with only the required delta.\n4. Otherwise transfer request and project context to dog-coordinator. Frontmatter is the single coordinator\n transfer; never route a worker to the user.\n";
37
+ }];