sortie-dogs 0.2.12 → 0.2.14

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/README.md CHANGED
@@ -144,11 +144,22 @@ Optional settings in `.opencode/sortie-dogs.json`:
144
144
  {
145
145
  "operationManifestPath": "operation-manifest.json",
146
146
  "handoffPaths": ["handoff.json"],
147
- "readOnlyTools": ["my_mcp_search"],
148
- "dedicatedWorkerModel": { "model": "provider/model", "variant": "deep" },
149
- "continuation": { "enabled": true, "maxAutoContinues": 3 }
150
- }
151
- ```
147
+ "readOnlyTools": ["my_mcp_search"],
148
+ "dedicatedWorkerModel": { "model": "provider/model", "variant": "deep" },
149
+ "continuation": { "enabled": true, "maxAutoContinues": 3 },
150
+ "reflection": {
151
+ "enabled": false,
152
+ "layers": { "run": true, "project": true, "global": false }
153
+ }
154
+ }
155
+ ```
156
+
157
+ The same schema may be saved globally as
158
+ `~/.config/opencode/sortie-dogs.json` (on Windows,
159
+ `%USERPROFILE%\.config\opencode\sortie-dogs.json`). Precedence is built-in
160
+ defaults, global file, project file, `SORTIE_DOGS_CONFIG`, then plugin factory
161
+ options. OpenCode plugin normalization may omit factory options, so use the
162
+ global file for durable global settings.
152
163
 
153
164
  - `operationManifestPath` moves the manifest; the path is project-relative.
154
165
  - `handoffPaths` lists the handoff files the plugin inspects. A worker can only
@@ -170,8 +181,15 @@ Optional settings in `.opencode/sortie-dogs.json`:
170
181
  `dog-coordinator` session is ever resumed: a child session is never promoted and
171
182
  another coordinator is never adopted. Set `enabled` to `false` to keep every
172
183
  batch manual, raise or lower `maxAutoContinues` (default `3`, maximum `10`) to
173
- change the ceiling, and set `summarizeModel` to pin the compaction model when
174
- the host default is unsuitable.
184
+ change the ceiling, and set `summarizeModel` to pin the compaction model when
185
+ the host default is unsuitable. Normal OpenCode auto-compaction keeps the
186
+ host's auto-continue behavior; Sortie suppresses it only while its own
187
+ explicitly queued rollover owns the resume.
188
+ - `reflection` is an opt-in process-prevention companion for an activated root
189
+ `dog-coordinator`. It is disabled by default. Run and project layers default
190
+ to enabled after opt-in; the cross-project global storage layer remains
191
+ disabled unless explicitly enabled. Child and non-coordinator sessions fail
192
+ closed, and `SORTIE_REFLECTION=0` is an immediate kill switch.
175
193
 
176
194
  ## Why Sortie-dogs
177
195
 
@@ -1,4 +1,17 @@
1
1
  import { type ModelCatalog, type ModelRoutingConfig, type ModelTarget } from "./model-routing.js";
2
+ export interface ReflectionConfiguration {
3
+ readonly enabled: boolean;
4
+ readonly layers: {
5
+ readonly run: boolean;
6
+ readonly project: boolean;
7
+ readonly global: boolean;
8
+ };
9
+ readonly maxInjectedEntries: number;
10
+ readonly maxInjectedTokens: number;
11
+ }
12
+ export type ReflectionPolicyInput = Partial<Omit<ReflectionConfiguration, "layers">> & {
13
+ layers?: Partial<ReflectionConfiguration["layers"]>;
14
+ };
2
15
  export interface SortieDogsPluginOptions {
3
16
  operationManifestPath?: string;
4
17
  handoffPaths?: readonly string[];
@@ -22,6 +35,7 @@ export interface SortieDogsPluginOptions {
22
35
  * only states this to raise the ceiling, choose a compaction model, or switch continuation off.
23
36
  */
24
37
  continuation?: ContinuationPolicyInput;
38
+ reflection?: ReflectionPolicyInput;
25
39
  }
26
40
  export type ContinuationPolicyInput = Partial<ContinuationConfiguration>;
27
41
  export interface ContinuationConfiguration {
@@ -65,6 +79,7 @@ export interface ConfiguredPlugin {
65
79
  freeTierFallbackModels: readonly string[];
66
80
  consultation: ConsultationPolicy;
67
81
  continuation: ContinuationConfiguration;
82
+ reflection: ReflectionConfiguration;
68
83
  }
69
84
  export type PluginConfiguration = ConfiguredPlugin | {
70
85
  kind: "invalid";
@@ -84,3 +99,4 @@ export declare const DEFAULT_PLUGIN_OPTIONS: Readonly<Omit<Required<SortieDogsPl
84
99
  export declare function resolvePluginConfiguration(...values: readonly unknown[]): PluginConfiguration;
85
100
  /** Resolve the plugin's fixed source boundaries: project-local first, environment and host global. */
86
101
  export declare function resolvePluginConfigurationSources(projectValue: unknown, environmentValue: unknown, hostValue: unknown): PluginConfigurationSources;
102
+ export declare function resolvePluginConfigurationSourcesWithGlobal(globalValue: unknown, projectValue: unknown, environmentValue: unknown, hostValue: unknown): PluginConfigurationSources;
@@ -32,6 +32,7 @@ export const DEFAULT_PLUGIN_OPTIONS = {
32
32
  capability: CONTINUATION_CAPABILITY,
33
33
  maxAutoContinues: DEFAULT_MAX_AUTO_CONTINUES,
34
34
  }),
35
+ reflection: Object.freeze({ enabled: false, layers: Object.freeze({ run: true, project: true, global: false }), maxInjectedEntries: 3, maxInjectedTokens: 500 }),
35
36
  };
36
37
  function isRecord(value) {
37
38
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -204,7 +205,7 @@ function parseLayer(value) {
204
205
  return undefined;
205
206
  if (Object.keys(value).some((key) => ![
206
207
  "operationManifestPath", "handoffPaths", "readOnlyTools", "dedicatedWorkerModel",
207
- "modelRouting", "modelCatalog", "freeTierFallbackModels", "consultation", "continuation",
208
+ "modelRouting", "modelCatalog", "freeTierFallbackModels", "consultation", "continuation", "reflection",
208
209
  ].includes(key))) {
209
210
  return undefined;
210
211
  }
@@ -229,6 +230,22 @@ function parseLayer(value) {
229
230
  const continuation = value.continuation === undefined
230
231
  ? undefined
231
232
  : parseContinuationPolicy(value.continuation);
233
+ const reflectionValue = value.reflection;
234
+ let reflection;
235
+ if (reflectionValue !== undefined) {
236
+ if (!isRecord(reflectionValue) || Object.keys(reflectionValue).some((key) => !["enabled", "layers", "maxInjectedEntries", "maxInjectedTokens"].includes(key)))
237
+ return undefined;
238
+ const layers = reflectionValue.layers;
239
+ if (layers !== undefined && (!isRecord(layers) || Object.keys(layers).some((key) => !["run", "project", "global"].includes(key)) || Object.values(layers).some((item) => typeof item !== "boolean")))
240
+ return undefined;
241
+ if (reflectionValue.enabled !== undefined && typeof reflectionValue.enabled !== "boolean")
242
+ return undefined;
243
+ if (reflectionValue.maxInjectedEntries !== undefined && (!positiveInteger(reflectionValue.maxInjectedEntries) || reflectionValue.maxInjectedEntries > 3))
244
+ return undefined;
245
+ if (reflectionValue.maxInjectedTokens !== undefined && (!positiveInteger(reflectionValue.maxInjectedTokens) || reflectionValue.maxInjectedTokens > 500))
246
+ return undefined;
247
+ reflection = { ...(reflectionValue.enabled === undefined ? {} : { enabled: reflectionValue.enabled }), ...(layers === undefined ? {} : { layers: layers }), ...(reflectionValue.maxInjectedEntries === undefined ? {} : { maxInjectedEntries: reflectionValue.maxInjectedEntries }), ...(reflectionValue.maxInjectedTokens === undefined ? {} : { maxInjectedTokens: reflectionValue.maxInjectedTokens }) };
248
+ }
232
249
  if (manifestPath !== undefined && (typeof manifestPath !== "string" || manifestPath.length === 0)) {
233
250
  return undefined;
234
251
  }
@@ -262,6 +279,7 @@ function parseLayer(value) {
262
279
  freeTierFallbackModels: freeTierFallbackModels,
263
280
  consultation,
264
281
  continuation,
282
+ reflection,
265
283
  };
266
284
  }
267
285
  /** Merge defaults, optional project/env configuration, then the host override. */
@@ -275,6 +293,7 @@ export function resolvePluginConfiguration(...values) {
275
293
  let freeTierFallbackModels = DEFAULT_PLUGIN_OPTIONS.freeTierFallbackModels;
276
294
  let consultation = DEFAULT_PLUGIN_OPTIONS.consultation;
277
295
  let continuation = DEFAULT_PLUGIN_OPTIONS.continuation;
296
+ let reflection = DEFAULT_PLUGIN_OPTIONS.reflection;
278
297
  const configuredRoles = new Set();
279
298
  for (const value of values) {
280
299
  const layer = parseLayer(value);
@@ -314,6 +333,8 @@ export function resolvePluginConfiguration(...values) {
314
333
  if (layer.continuation !== undefined) {
315
334
  continuation = Object.freeze({ ...continuation, ...layer.continuation });
316
335
  }
336
+ if (layer.reflection !== undefined)
337
+ reflection = Object.freeze({ ...reflection, ...layer.reflection, layers: Object.freeze({ ...reflection.layers, ...(layer.reflection.layers ?? {}) }) });
317
338
  }
318
339
  modelRouting = {
319
340
  ...Object.fromEntries(Object.entries(modelRouting).filter(([role]) => !isFixedModelRole(role))),
@@ -346,21 +367,28 @@ export function resolvePluginConfiguration(...values) {
346
367
  freeTierFallbackModels,
347
368
  consultation,
348
369
  continuation,
370
+ reflection,
349
371
  };
350
372
  }
351
373
  /** Resolve the plugin's fixed source boundaries: project-local first, environment and host global. */
352
374
  export function resolvePluginConfigurationSources(projectValue, environmentValue, hostValue) {
353
- const configured = resolvePluginConfiguration(projectValue, environmentValue, hostValue);
375
+ return resolvePluginConfigurationSourcesWithGlobal(undefined, projectValue, environmentValue, hostValue);
376
+ }
377
+ export function resolvePluginConfigurationSourcesWithGlobal(globalValue, projectValue, environmentValue, hostValue) {
378
+ const configured = resolvePluginConfiguration(globalValue, projectValue, environmentValue, hostValue);
354
379
  if (configured.kind === "invalid")
355
380
  return configured;
381
+ const globalLayer = parseLayer(globalValue);
356
382
  const projectLayer = parseLayer(projectValue);
357
383
  const environmentLayer = parseLayer(environmentValue);
358
384
  const hostLayer = parseLayer(hostValue);
359
- if (projectLayer === undefined || environmentLayer === undefined || hostLayer === undefined) {
385
+ if (globalLayer === undefined || projectLayer === undefined ||
386
+ environmentLayer === undefined || hostLayer === undefined) {
360
387
  return { kind: "invalid" };
361
388
  }
362
389
  const globalModelRouting = Object.fromEntries(Object.entries({
363
390
  ...recommendedRoleRouting(configured.dedicatedWorkerModel),
391
+ ...(globalLayer.modelRouting ?? {}),
364
392
  ...(environmentLayer.modelRouting ?? {}),
365
393
  ...(hostLayer.modelRouting ?? {}),
366
394
  }).filter(([role]) => !isFixedModelRole(role)));
@@ -125,7 +125,7 @@ export type ContinuationPolicySource = ContinuationPolicy | (() => ContinuationP
125
125
  * without an agent field, or answers for a different directory, instead of failing silently.
126
126
  */
127
127
  export type LocalIdentitySource = (sessionID: string) => ContinuationIdentity | undefined;
128
- export type RolloverAbort = "identity-unavailable" | "child-session" | "summarize-unavailable" | "terminal-identity-rejected";
128
+ export type RolloverAbort = "identity-unavailable" | "child-session" | "summarize-unavailable" | "retries-exhausted" | "terminal-identity-rejected";
129
129
  export interface ContinuationToolContext {
130
130
  readonly sessionID: string;
131
131
  readonly agent?: string | undefined;
@@ -304,9 +304,17 @@ export function createContinuationHooks(client, directory, policySource, timings
304
304
  unrefTimer(setTimeout(async () => {
305
305
  const completed = await runRollover(sessionID);
306
306
  const state = sessions.get(sessionID);
307
+ if (!completed && (state?.cooldownTimer !== undefined || state?.active === true))
308
+ return;
307
309
  if (!completed && state?.pendingRollover === true && attempt < timings.scheduleAttempts) {
308
310
  scheduleRollover(sessionID, attempt + 1);
309
311
  }
312
+ else if (!completed && state?.pendingRollover === true) {
313
+ state.pendingRollover = false;
314
+ state.promptPending = false;
315
+ state.continueReport = undefined;
316
+ warnRollover(sessionID, "retries-exhausted");
317
+ }
310
318
  }, timings.scheduleMilliseconds * (attempt + 1)));
311
319
  }
312
320
  function queueRollover(sessionID, report, resume) {
@@ -446,7 +454,7 @@ export function createContinuationHooks(client, directory, policySource, timings
446
454
  identity.agent !== policy().agent)
447
455
  return;
448
456
  }
449
- if (input.overflow !== true || pending)
457
+ if (pending)
450
458
  output.enabled = false;
451
459
  },
452
460
  async sessionIdle(sessionID) {
@@ -28,6 +28,12 @@ export interface OpenCodeHooks {
28
28
  "tool.execute.before"?: (input: ToolExecuteBeforeInput, output: ToolExecuteBeforeOutput) => Promise<void>;
29
29
  "tool.execute.after"?: (input: TaskToolExecuteAfterInput, output: TaskResultRepairOutput) => Promise<void>;
30
30
  "chat.message"?: OpenCodeChatMessageHook;
31
+ "experimental.chat.system.transform"?: (input: {
32
+ sessionID: string;
33
+ }, output: {
34
+ system?: string[];
35
+ model?: unknown;
36
+ }) => Promise<void>;
31
37
  /** Continuation observes the coordinator's completed final text to honour its fallback markers. */
32
38
  "experimental.text.complete"?: (input: {
33
39
  sessionID: string;
@@ -1,15 +1,16 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile, stat } from "node:fs/promises";
3
- import { isAbsolute, resolve, sep } from "node:path";
3
+ import { isAbsolute, join, resolve, sep } from "node:path";
4
4
  import { RUNTIME_ASSET_VERSION } from "../asset-version.js";
5
5
  import { normalizeRelativePath, RelativePathError } from "../core/path.js";
6
6
  import { validateManifest } from "../core/validate-manifest.js";
7
7
  import { safeSchemaPointer, validateHandoffSchema, validateOperationManifestSchema, } from "../core/validate-schema.js";
8
- import { DEFAULT_PLUGIN_OPTIONS, resolvePluginConfigurationSources, } from "./config.js";
8
+ import { DEFAULT_PLUGIN_OPTIONS, resolvePluginConfiguration, resolvePluginConfigurationSourcesWithGlobal, } from "./config.js";
9
9
  import { CONTINUATION_CAPABILITY, createContinuationHooks, } from "./continuation.js";
10
10
  import { WriteDeniedError, createProjectPaths, createWriteGate, describeUnclassifiedCommand, isKnownReadOnlyTool, normalizeCommand, resolveProjectRoot, safePath, } from "./gate.js";
11
11
  import { createModelRoutingHook, } from "./model-routing-hook.js";
12
12
  import { createTaskResultRepairHook, } from "./task-result-repair.js";
13
+ import { configRoot, nearestPackageVersion, reflectionEnabled, ReflectionError, ReflectionStore } from "../reflection/index.js";
13
14
  const INPUT_LIMITS = { config: 64 * 1024, manifest: 512 * 1024, handoff: 2 * 1024 * 1024 };
14
15
  const INSPECTION_CACHE = { maximum: 256, ttlMilliseconds: 30 * 60 * 1000 };
15
16
  const ACTIVE_SESSION_CACHE = { maximum: 256, ttlMilliseconds: 30 * 60 * 1000 };
@@ -189,6 +190,22 @@ async function readOptionalProjectConfig(project) {
189
190
  throw error;
190
191
  }
191
192
  }
193
+ async function readOptionalGlobalConfig() {
194
+ try {
195
+ const value = await readJson(join(configRoot(), "sortie-dogs.json"), INPUT_LIMITS.config);
196
+ if (resolvePluginConfiguration(value).kind === "invalid") {
197
+ console.warn("[sortie-dogs] global configuration ignored: invalid or unavailable");
198
+ return undefined;
199
+ }
200
+ return value;
201
+ }
202
+ catch (error) {
203
+ if (isAbsentPathError(error))
204
+ return undefined;
205
+ console.warn("[sortie-dogs] global configuration ignored: invalid or unavailable");
206
+ return undefined;
207
+ }
208
+ }
192
209
  function readEnvironmentConfig() {
193
210
  const source = process.env[ENV_CONFIG];
194
211
  if (source === undefined || source.length === 0)
@@ -228,6 +245,7 @@ function loadConfigured(config, handoffBase, client) {
228
245
  readOnlyTools: new Set(config.readOnlyTools.map((tool) => tool.toLowerCase())),
229
246
  modelRoutingHook,
230
247
  continuation: config.continuation,
248
+ reflection: config.reflection,
231
249
  };
232
250
  }
233
251
  function samePath(left, right) {
@@ -392,12 +410,52 @@ export const SortieDogsPlugin = async (input, options) => {
392
410
  const defineTool = validToolCandidate
393
411
  ? toolCandidate
394
412
  : Object.assign((definition) => definition, { schema: { string: () => ({ type: "string" }) } });
413
+ const optionalString = () => {
414
+ const stringSchema = defineTool.schema.string();
415
+ if (isRecord(stringSchema) && typeof stringSchema.optional === "function") {
416
+ return stringSchema.optional();
417
+ }
418
+ return typeof defineTool.schema.optional === "function" ? defineTool.schema.optional(stringSchema) : stringSchema;
419
+ };
395
420
  let project;
421
+ let reflectionStartup = false;
422
+ let reflectionConfiguration;
423
+ let reflectionVersion;
424
+ let reflectionStore;
396
425
  let loaded;
397
426
  let loadFailure;
398
427
  let loading;
399
428
  let manifestAbsent = false;
400
429
  let assetVersionReported = false;
430
+ const globalConfig = await readOptionalGlobalConfig();
431
+ // Project config read is required discovery for its opt-in; no reflection storage/version read
432
+ // occurs unless that resolved config enables reflection. It stays isolated from write-gate load.
433
+ try {
434
+ project = await createProjectPaths(resolveProjectRoot(input));
435
+ const probed = resolvePluginConfigurationSourcesWithGlobal(globalConfig, await readOptionalProjectConfig(project), readEnvironmentConfig(), options);
436
+ if (probed.kind === "configured" && reflectionEnabled(probed.reflection)) {
437
+ reflectionVersion = await nearestPackageVersion();
438
+ reflectionConfiguration = probed.reflection;
439
+ reflectionStore = new ReflectionStore(join(configRoot(), "sortie-dogs", "reflection"), project.root, {
440
+ warn: (code) => {
441
+ const log = input.client?.app;
442
+ if (!isRecord(log) || typeof log.log !== "function")
443
+ return;
444
+ try {
445
+ log.log({ level: "warn", service: "sortie-dogs", message: code });
446
+ }
447
+ catch { /* host logging is best effort */ }
448
+ },
449
+ });
450
+ reflectionStartup = true;
451
+ }
452
+ }
453
+ catch {
454
+ reflectionStartup = false;
455
+ reflectionConfiguration = undefined;
456
+ reflectionVersion = undefined;
457
+ reflectionStore = undefined;
458
+ }
401
459
  /*
402
460
  * Continuation must be callable before the first lazy configuration load completes, so it reads
403
461
  * the effective policy at call time and falls back to the shipped default until then.
@@ -422,7 +480,7 @@ export const SortieDogsPlugin = async (input, options) => {
422
480
  await reportAssetVersionSkew(project);
423
481
  const projectConfig = await readOptionalProjectConfig(project);
424
482
  const environmentConfig = readEnvironmentConfig();
425
- const parsed = resolvePluginConfigurationSources(projectConfig, environmentConfig, options);
483
+ const parsed = resolvePluginConfigurationSourcesWithGlobal(globalConfig, projectConfig, environmentConfig, options);
426
484
  if (parsed.kind === "invalid")
427
485
  throw new WriteDeniedError("manifest-unavailable", "<unknown>");
428
486
  loaded = loadConfigured(parsed, input.worktree ?? project.root, input.client);
@@ -484,6 +542,10 @@ export const SortieDogsPlugin = async (input, options) => {
484
542
  const bindingPins = new Map();
485
543
  const activeSessions = new Map();
486
544
  const coordinatorRoots = new Map();
545
+ const reflectionOwnedRoots = new Set();
546
+ const reflectionClosingRoots = new Set();
547
+ const reflectionInFlight = new Map();
548
+ const reflectionWaiters = new Map();
487
549
  const bindingDenials = new Map();
488
550
  const expiredSessions = new Set();
489
551
  const sessionParents = new Map();
@@ -1117,6 +1179,7 @@ export const SortieDogsPlugin = async (input, options) => {
1117
1179
  return {
1118
1180
  ...(typeof payload.agent === "string" ? { agent: payload.agent } : {}),
1119
1181
  ...(typeof payload.parentID === "string" ? { parentID: payload.parentID } : {}),
1182
+ parentPresent: "parentID" in payload,
1120
1183
  };
1121
1184
  }
1122
1185
  catch {
@@ -1135,7 +1198,7 @@ export const SortieDogsPlugin = async (input, options) => {
1135
1198
  if (child?.parentID === undefined)
1136
1199
  return undefined;
1137
1200
  const parent = await hostSessionIdentity(child.parentID);
1138
- if (parent?.agent !== COORDINATOR_AGENT || parent.parentID !== undefined)
1201
+ if (parent?.agent !== COORDINATOR_AGENT || parent.parentPresent)
1139
1202
  return undefined;
1140
1203
  await rememberCoordinatorRoot(child.parentID);
1141
1204
  rememberParent(sessionID, child.parentID);
@@ -1151,7 +1214,51 @@ export const SortieDogsPlugin = async (input, options) => {
1151
1214
  }
1152
1215
  }
1153
1216
  }
1154
- return {
1217
+ async function reflectionPermitted(sessionID, agent) {
1218
+ if (!reflectionStartup || reflectionStore === undefined || reflectionVersion === undefined || process.env.SORTIE_REFLECTION === "0")
1219
+ return false;
1220
+ if (agent !== undefined && agent !== COORDINATOR_AGENT)
1221
+ return false;
1222
+ if (!isCoordinatorSession(sessionID) || coordinatorRootForSession(sessionID) !== sessionID || sessionParents.has(sessionID))
1223
+ return false;
1224
+ const identity = await hostSessionIdentity(sessionID);
1225
+ if (identity?.agent !== COORDINATOR_AGENT || identity.parentPresent)
1226
+ return false;
1227
+ return true;
1228
+ }
1229
+ async function beginReflection(sessionID, agent) {
1230
+ if (!(await reflectionPermitted(sessionID, agent)) || reflectionClosingRoots.has(sessionID))
1231
+ return false;
1232
+ reflectionOwnedRoots.add(sessionID);
1233
+ reflectionInFlight.set(sessionID, (reflectionInFlight.get(sessionID) ?? 0) + 1);
1234
+ return true;
1235
+ }
1236
+ function endReflection(sessionID) {
1237
+ const remaining = (reflectionInFlight.get(sessionID) ?? 1) - 1;
1238
+ if (remaining > 0) {
1239
+ reflectionInFlight.set(sessionID, remaining);
1240
+ return;
1241
+ }
1242
+ reflectionInFlight.delete(sessionID);
1243
+ for (const resolve of reflectionWaiters.get(sessionID) ?? [])
1244
+ resolve();
1245
+ reflectionWaiters.delete(sessionID);
1246
+ }
1247
+ async function waitForReflections(sessionID) {
1248
+ if ((reflectionInFlight.get(sessionID) ?? 0) === 0)
1249
+ return;
1250
+ await new Promise((resolve) => (reflectionWaiters.get(sessionID) ?? reflectionWaiters.set(sessionID, []).get(sessionID)).push(resolve));
1251
+ }
1252
+ function reflectionWarning(code) {
1253
+ const log = input.client?.app;
1254
+ if (!isRecord(log) || typeof log.log !== "function")
1255
+ return;
1256
+ try {
1257
+ log.log({ level: "warn", service: "sortie-dogs", message: code });
1258
+ }
1259
+ catch { /* host logging is best effort */ }
1260
+ }
1261
+ const hooks = {
1155
1262
  tool: {
1156
1263
  sortie_bind_write_gate: defineTool({
1157
1264
  description: "Bind this active session to one project-relative operation manifest without changing files.",
@@ -1194,6 +1301,36 @@ export const SortieDogsPlugin = async (input, options) => {
1194
1301
  return await continuation.tool.execute({}, context);
1195
1302
  },
1196
1303
  }),
1304
+ ...(reflectionStartup ? {
1305
+ sortie_reflection: defineTool({
1306
+ description: "Record, promote, or clear a bounded process reflection.",
1307
+ args: { action: defineTool.schema.string(), layer: defineTool.schema.string(), scope: optionalString(), trigger: optionalString(), cause: optionalString(), prevention: optionalString(), evidence: optionalString(), evidenceRef: optionalString(), id: optionalString(), promotedRef: optionalString(), confirmation: optionalString() },
1308
+ async execute(args, context) {
1309
+ if (!(await beginReflection(context.sessionID, context.agent)))
1310
+ return "reflection_not_permitted";
1311
+ const layer = args.layer;
1312
+ try {
1313
+ if (!["run", "project", "global"].includes(layer))
1314
+ return "reflection_invalid_layer";
1315
+ if (!(reflectionConfiguration?.layers[layer] ?? false))
1316
+ return "reflection_not_permitted";
1317
+ if (args.action === "record")
1318
+ return JSON.stringify(await reflectionStore.record(layer, context.sessionID, args, reflectionVersion));
1319
+ if (args.action === "promote")
1320
+ return await reflectionStore.promote(layer, context.sessionID, args.id, args.promotedRef, reflectionVersion);
1321
+ if (args.action === "clear")
1322
+ return await reflectionStore.clear(layer, context.sessionID, args.confirmation, reflectionVersion);
1323
+ return "reflection_invalid_action";
1324
+ }
1325
+ catch (error) {
1326
+ return error instanceof ReflectionError ? error.code : "reflection_storage_error";
1327
+ }
1328
+ finally {
1329
+ endReflection(context.sessionID);
1330
+ }
1331
+ },
1332
+ }),
1333
+ } : {}),
1197
1334
  },
1198
1335
  "experimental.text.complete": async (textInput, textOutput) => {
1199
1336
  await continuation.textComplete(textInput, textOutput);
@@ -1238,6 +1375,25 @@ export const SortieDogsPlugin = async (input, options) => {
1238
1375
  await ensureLoaded();
1239
1376
  await loaded?.modelRoutingHook?.(chatInput, output);
1240
1377
  },
1378
+ ...(reflectionStartup ? { "experimental.chat.system.transform": async (transformInput, transformOutput) => {
1379
+ if (!(await beginReflection(transformInput.sessionID)))
1380
+ return;
1381
+ const config = reflectionConfiguration;
1382
+ try {
1383
+ if (!config)
1384
+ return;
1385
+ const buckets = ["run", "project", "global"]
1386
+ .filter((layer) => config.layers[layer])
1387
+ .map((layer) => ({ layer, ...(layer === "global" ? {} : { run: transformInput.sessionID }) }));
1388
+ const text = await reflectionStore.injectBuckets(buckets, config.maxInjectedEntries, config.maxInjectedTokens, reflectionVersion);
1389
+ if (text)
1390
+ transformOutput.system = [...(transformOutput.system ?? []), text];
1391
+ }
1392
+ catch { /* reflection is strictly non-invasive */ }
1393
+ finally {
1394
+ endReflection(transformInput.sessionID);
1395
+ }
1396
+ } } : {}),
1241
1397
  "permission.ask": async (permission) => {
1242
1398
  if (permission.permission !== "edit")
1243
1399
  return;
@@ -1348,6 +1504,28 @@ export const SortieDogsPlugin = async (input, options) => {
1348
1504
  return;
1349
1505
  }
1350
1506
  if (event.type === "session.deleted") {
1507
+ if (reflectionStore !== undefined && reflectionConfiguration?.layers.run && reflectionOwnedRoots.has(eventSessionID)) {
1508
+ reflectionClosingRoots.add(eventSessionID);
1509
+ await waitForReflections(eventSessionID);
1510
+ let deleted = false;
1511
+ for (const delay of [0, 50, 250, 1_000, 5_000]) {
1512
+ if (delay > 0)
1513
+ await new Promise((resolve) => setTimeout(resolve, delay));
1514
+ try {
1515
+ await reflectionStore.deleteRun(eventSessionID);
1516
+ deleted = true;
1517
+ }
1518
+ catch { /* bounded retry below */ }
1519
+ if (deleted)
1520
+ break;
1521
+ }
1522
+ if (deleted) {
1523
+ reflectionOwnedRoots.delete(eventSessionID);
1524
+ reflectionClosingRoots.delete(eventSessionID);
1525
+ }
1526
+ else
1527
+ reflectionWarning("reflection_cleanup_failed");
1528
+ }
1351
1529
  evictSession(eventSessionID);
1352
1530
  continuation.forgetSession(eventSessionID);
1353
1531
  return;
@@ -1377,5 +1555,6 @@ export const SortieDogsPlugin = async (input, options) => {
1377
1555
  }
1378
1556
  },
1379
1557
  };
1558
+ return hooks;
1380
1559
  };
1381
1560
  export { InvalidModelTargetError, ModelRoutingDeniedError } from "./model-routing-hook.js";
@@ -0,0 +1,7 @@
1
+ import type { ReflectionConfiguration } from "../plugin/config.js";
2
+ export type ReflectionLayer = "run" | "project" | "global";
3
+ export declare const DEFAULT_REFLECTION: ReflectionConfiguration;
4
+ export declare function reflectionEnabled(config: ReflectionConfiguration, env?: string | undefined): boolean;
5
+ export declare function projectKey(root: string): string;
6
+ export declare function configRoot(explicit?: string): string;
7
+ export declare function nearestPackageVersion(): Promise<string>;
@@ -0,0 +1,32 @@
1
+ import { createHash } from "node:crypto";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { readFile } from "node:fs/promises";
6
+ export const DEFAULT_REFLECTION = Object.freeze({ enabled: false, layers: Object.freeze({ run: true, project: true, global: false }), maxInjectedEntries: 3, maxInjectedTokens: 500 });
7
+ export function reflectionEnabled(config, env = process.env.SORTIE_REFLECTION) {
8
+ return env === "0" ? false : config.enabled;
9
+ }
10
+ export function projectKey(root) {
11
+ const normalized = resolve(root).replaceAll("\\", "/").replace(/\/+$/u, "");
12
+ return createHash("sha256").update(process.platform === "win32" ? normalized.toLowerCase() : normalized).digest("hex").slice(0, 16);
13
+ }
14
+ export function configRoot(explicit) {
15
+ if (explicit)
16
+ return resolve(explicit);
17
+ if (process.env.XDG_CONFIG_HOME)
18
+ return join(process.env.XDG_CONFIG_HOME, "opencode");
19
+ return join(homedir(), ".config", "opencode");
20
+ }
21
+ export async function nearestPackageVersion() {
22
+ try {
23
+ const file = join(resolve(fileURLToPath(import.meta.url), "..", "..", ".."), "package.json");
24
+ const parsed = JSON.parse(await readFile(file, "utf8"));
25
+ if (typeof parsed.version !== "string" || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u.test(parsed.version))
26
+ throw new Error("invalid");
27
+ return parsed.version;
28
+ }
29
+ catch {
30
+ throw new Error("reflection_version_unavailable");
31
+ }
32
+ }
@@ -0,0 +1,3 @@
1
+ export { DEFAULT_REFLECTION, configRoot, nearestPackageVersion, projectKey, reflectionEnabled } from "./config.js";
2
+ export { ReflectionStore, ReflectionError, estimateInjectionTokens, normalizeField } from "./store.js";
3
+ export type { ReflectionLayer } from "./config.js";
@@ -0,0 +1,2 @@
1
+ export { DEFAULT_REFLECTION, configRoot, nearestPackageVersion, projectKey, reflectionEnabled } from "./config.js";
2
+ export { ReflectionStore, ReflectionError, estimateInjectionTokens, normalizeField } from "./store.js";
@@ -0,0 +1,76 @@
1
+ import { type ReflectionLayer } from "./config.js";
2
+ export type Evidence = "user-correction" | "repeated-process-failure" | "review-artifact-defect" | "retry-policy-violation";
3
+ export type EntryStatus = "active" | "promotable" | "promoted";
4
+ export interface ReflectionEntry {
5
+ id: string;
6
+ scope: string;
7
+ trigger: string;
8
+ cause: string;
9
+ prevention: string;
10
+ evidence: Evidence;
11
+ evidenceRef: string;
12
+ hits: number;
13
+ firstSeen: string;
14
+ lastSeen: string;
15
+ status: EntryStatus;
16
+ promotedRef?: string;
17
+ promotedAtVersion?: string;
18
+ }
19
+ export interface ReflectionBucket {
20
+ v: 1;
21
+ updatedAt: string;
22
+ entries: ReflectionEntry[];
23
+ }
24
+ export interface ReflectionStoreOptions {
25
+ now?: () => number;
26
+ sleep?: (ms: number) => Promise<void>;
27
+ warn?: (code: string) => void;
28
+ processAlive?: (pid: number) => boolean;
29
+ }
30
+ export declare class ReflectionError extends Error {
31
+ readonly code: string;
32
+ constructor(code: string);
33
+ }
34
+ export declare function normalizeField(value: unknown, max: number, scope?: boolean): string;
35
+ export declare function estimateInjectionTokens(text: string): number;
36
+ export declare class ReflectionStore {
37
+ private readonly root;
38
+ private readonly projectRoot;
39
+ private readonly warned;
40
+ private readonly now;
41
+ private readonly sleep;
42
+ private readonly warn;
43
+ private readonly processAlive;
44
+ constructor(root: string, projectRoot: string, options?: ReflectionStoreOptions);
45
+ private file;
46
+ private warning;
47
+ private artifacts;
48
+ private temps;
49
+ private load;
50
+ private validEntry;
51
+ private owner;
52
+ private live;
53
+ private heartbeat;
54
+ private acquire;
55
+ private stale;
56
+ private recoveryClaims;
57
+ private recoverGuard;
58
+ private recoverStale;
59
+ private lock;
60
+ private pathGuard;
61
+ private releaseGuard;
62
+ private withOwnership;
63
+ private release;
64
+ private save;
65
+ read(layer: ReflectionLayer, run?: string, currentVersion?: string): Promise<ReflectionBucket>;
66
+ private transaction;
67
+ record(layer: ReflectionLayer, run: string | undefined, input: Record<string, unknown>, currentVersion: string): Promise<ReflectionEntry>;
68
+ promote(layer: ReflectionLayer, run: string | undefined, id: string, ref: string, currentVersion: string): Promise<string>;
69
+ clear(layer: ReflectionLayer, run: string | undefined, confirmation: string, currentVersion: string): Promise<string>;
70
+ deleteRun(run: string): Promise<void>;
71
+ inject(layer: ReflectionLayer, run: string | undefined, max: number, tokenBudget: number, currentVersion: string): Promise<string>;
72
+ injectBuckets(buckets: readonly {
73
+ layer: ReflectionLayer;
74
+ run?: string;
75
+ }[], max: number, tokenBudget: number, currentVersion?: string): Promise<string>;
76
+ }
@@ -0,0 +1,321 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { chmod, copyFile, mkdir, open, readFile, readdir, rename, rm, stat, unlink } from "node:fs/promises";
3
+ import { dirname, join, basename } from "node:path";
4
+ import { projectKey } from "./config.js";
5
+ export class ReflectionError extends Error {
6
+ code;
7
+ constructor(code) {
8
+ super(code);
9
+ this.code = code;
10
+ }
11
+ }
12
+ const EMPTY = () => ({ v: 1, updatedAt: new Date(0).toISOString(), entries: [] });
13
+ const evidence = new Set(["user-correction", "repeated-process-failure", "review-artifact-defect", "retry-policy-violation"]);
14
+ const caps = { run: 12, project: 24, global: 16 };
15
+ const ages = { run: 6 * 3600000, project: 30 * 86400000, global: 90 * 86400000 };
16
+ const MAX_LEASE_AGE = 60_000;
17
+ const activeTokens = new Set();
18
+ const prohibited = /[\n\r\t\u0000-\u001f\u007f]|```|(?:api[_ -]?key|password|secret|token)|private\s+key|https?:\/\/|(?:^|\s)[+\-]{3}(?:\s|$)|\b[0-9a-f]{32,}\b|\b[A-Za-z0-9+/]{40,}={0,2}\b/iu;
19
+ const jsonBytes = (value) => Buffer.byteLength(JSON.stringify(value), "utf8");
20
+ function version(value) { const match = /^(?:v)?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(value); if (!match)
21
+ return { core: [0, 0, 0] }; return { core: [Number(match[1]), Number(match[2]), Number(match[3])], ...(match[4] === undefined ? {} : { pre: match[4].split(".") }) }; }
22
+ function comparePre(left, right) { for (let index = 0; index < Math.max(left.length, right.length); index++) {
23
+ const a = left[index], b = right[index];
24
+ if (a === undefined)
25
+ return -1;
26
+ if (b === undefined)
27
+ return 1;
28
+ if (a === b)
29
+ continue;
30
+ const aNumber = /^\d+$/u.test(a), bNumber = /^\d+$/u.test(b);
31
+ if (aNumber && bNumber) {
32
+ const normalizedA = a.replace(/^0+(?=\d)/u, ""), normalizedB = b.replace(/^0+(?=\d)/u, "");
33
+ return normalizedA.length === normalizedB.length ? (normalizedA > normalizedB ? 1 : -1) : (normalizedA.length > normalizedB.length ? 1 : -1);
34
+ }
35
+ if (aNumber !== bNumber)
36
+ return aNumber ? -1 : 1;
37
+ return a > b ? 1 : -1;
38
+ } return 0; }
39
+ function greaterVersion(left, right) { const a = version(left), b = version(right); for (let index = 0; index < 3; index++) {
40
+ if (a.core[index] !== b.core[index])
41
+ return a.core[index] > b.core[index];
42
+ } if (a.pre === undefined || b.pre === undefined)
43
+ return a.pre === undefined && b.pre !== undefined; return comparePre(a.pre, b.pre) > 0; }
44
+ function redact(value) { if (!/[\\/]/u.test(value) || /:\/\//u.test(value))
45
+ return value; let result = value.replace(/[A-Za-z]:[\\/](?:[^\s\\/]+[\\/])*([^\s\\/]+)/gu, "$1"); return result.replace(/(^|\s)\/(?:[^\s/]+\/)*([^\s/]+)/gu, (_match, lead, leaf) => `${lead}${leaf}`).replace(/(^|\s)(?:[^\s\\/]+[\\/])+([^\s\\/]+)/gu, (_match, lead, leaf) => `${lead}${leaf}`); }
46
+ export function normalizeField(value, max, scope = false) { if (typeof value !== "string" || value.length === 0)
47
+ throw new ReflectionError("reflection_invalid_input"); const clean = scope ? value.toLowerCase() : redact(value); if (clean.length > max || prohibited.test(clean) || (scope && !/^[a-z0-9-]+$/u.test(clean)))
48
+ throw new ReflectionError("reflection_invalid_input"); return clean; }
49
+ function shortRef(value) { if (typeof value !== "string" || value.length === 0 || value.length > 64 || /[\\/]|\.log\b|(?:log|line|stack\s+trace|diff)\s*[:#]?/iu.test(value) || prohibited.test(value))
50
+ throw new ReflectionError("reflection_invalid_input"); return value; }
51
+ export function estimateInjectionTokens(text) { return Buffer.byteLength(text, "utf8"); }
52
+ function ulid() { const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; let timestamp = Date.now(), time = ""; for (let i = 0; i < 10; i++) {
53
+ time = alphabet[timestamp % 32] + time;
54
+ timestamp = Math.floor(timestamp / 32);
55
+ } let random = ""; for (const byte of randomBytes(16))
56
+ random += alphabet[byte & 31]; return time + random; }
57
+ function pathFor(root, layer, projectRoot, run) { if (layer === "run" && (typeof run !== "string" || !/^[A-Za-z0-9_-]{1,128}$/u.test(run)))
58
+ throw new ReflectionError("reflection_invalid_run"); return layer === "global" ? join(root, "global.json") : layer === "project" ? join(root, "projects", `${projectKey(projectRoot)}.json`) : join(root, "runs", `${run}.json`); }
59
+ export class ReflectionStore {
60
+ root;
61
+ projectRoot;
62
+ warned = new Set();
63
+ now;
64
+ sleep;
65
+ warn;
66
+ processAlive;
67
+ constructor(root, projectRoot, options = {}) {
68
+ this.root = root;
69
+ this.projectRoot = projectRoot;
70
+ this.now = options.now ?? Date.now;
71
+ this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
72
+ this.warn = options.warn ?? (() => undefined);
73
+ this.processAlive = options.processAlive ?? ((pid) => { try {
74
+ process.kill(pid, 0);
75
+ return true;
76
+ }
77
+ catch {
78
+ return false;
79
+ } });
80
+ }
81
+ file(layer, run) { return pathFor(this.root, layer, this.projectRoot, run); }
82
+ warning(file, code) { const key = `${file}:${code}`; if (!this.warned.has(key)) {
83
+ this.warned.add(key);
84
+ this.warn(code);
85
+ } }
86
+ async artifacts(file, olderThan) { const dir = dirname(file), prefix = `${basename(file)}.`; const names = await readdir(dir).catch(() => []); for (const name of names) {
87
+ if (!name.startsWith(prefix) || name.startsWith(`${basename(file)}.lock`))
88
+ continue;
89
+ const path = join(dir, name), info = await stat(path).catch(() => undefined);
90
+ if (olderThan !== undefined && (info === undefined || info.mtimeMs >= olderThan))
91
+ continue;
92
+ await rm(path, { force: true }).catch(() => undefined);
93
+ } }
94
+ async temps(file) { const dir = dirname(file); for (const name of await readdir(dir).catch(() => []))
95
+ if (name.startsWith(`${basename(file)}.`) && name.endsWith(".tmp"))
96
+ await rm(join(dir, name), { force: true }).catch(() => undefined); }
97
+ async load(file, layer, currentVersion, lock) {
98
+ try {
99
+ const parsed = JSON.parse(await readFile(file, "utf8"));
100
+ if (parsed.v !== 0 && parsed.v !== 1 && (parsed.v ?? 0) > 1) {
101
+ this.warning(file, "reflection_future_schema");
102
+ return { bucket: EMPTY(), future: true, legacy: false, changed: false };
103
+ }
104
+ if (parsed.v !== 0 && parsed.v !== 1 || typeof parsed.updatedAt !== "string" || Number.isNaN(Date.parse(parsed.updatedAt)) || !Array.isArray(parsed.entries))
105
+ throw new Error("invalid");
106
+ const bucket = { v: 1, updatedAt: parsed.updatedAt, entries: parsed.entries };
107
+ if (!bucket.entries.every((entry) => this.validEntry(entry)))
108
+ throw new Error("invalid");
109
+ const cutoff = this.now() - ages[layer];
110
+ const before = bucket.entries.length;
111
+ bucket.entries = bucket.entries.filter((entry) => Date.parse(entry.lastSeen) >= cutoff && !(currentVersion && entry.status === "promoted" && entry.promotedAtVersion && greaterVersion(currentVersion, entry.promotedAtVersion)));
112
+ return { bucket, future: false, legacy: parsed.v === 0, changed: parsed.v === 0 || before !== bucket.entries.length };
113
+ }
114
+ catch (error) {
115
+ if (error?.code === "ENOENT")
116
+ return { bucket: EMPTY(), future: false, legacy: false, changed: false };
117
+ await this.withOwnership(lock, async () => { await rename(file, `${file}.corrupt.${this.now()}`); });
118
+ this.warning(file, "reflection_corrupt_json");
119
+ return { bucket: EMPTY(), future: false, legacy: false, changed: false };
120
+ }
121
+ }
122
+ validEntry(entry) { if (!entry || typeof entry !== "object" || jsonBytes(entry) > 512)
123
+ return false; const item = entry; try {
124
+ normalizeField(item.scope, 80, true);
125
+ normalizeField(item.trigger, 160);
126
+ normalizeField(item.cause, 360);
127
+ normalizeField(item.prevention, 500);
128
+ shortRef(item.evidenceRef);
129
+ if (item.status === "promoted") {
130
+ shortRef(item.promotedRef);
131
+ if (typeof item.promotedAtVersion !== "string" || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u.test(item.promotedAtVersion))
132
+ return false;
133
+ }
134
+ return /^[0-9A-HJKMNP-TV-Z]{26}$/u.test(item.id) && evidence.has(item.evidence) && ["active", "promotable", "promoted"].includes(item.status) && Number.isInteger(item.hits) && item.hits > 0 && !Number.isNaN(Date.parse(item.lastSeen)) && !Number.isNaN(Date.parse(item.firstSeen));
135
+ }
136
+ catch {
137
+ return false;
138
+ } }
139
+ owner(token) { return JSON.stringify({ pid: process.pid, token }); }
140
+ live(raw) { try {
141
+ const owner = JSON.parse(raw ?? "");
142
+ if (typeof owner.token !== "string" || typeof owner.pid !== "number" || !Number.isInteger(owner.pid))
143
+ return false;
144
+ return owner.pid === process.pid ? activeTokens.has(owner.token) : this.processAlive(owner.pid);
145
+ }
146
+ catch {
147
+ return false;
148
+ } }
149
+ async heartbeat(handle, owner) { await handle.truncate(0); await handle.write(owner, 0, "utf8"); await handle.sync(); }
150
+ async acquire(path) { const token = randomBytes(24).toString("hex"), owner = this.owner(token); const handle = await open(path, "wx", 0o600); activeTokens.add(token); try {
151
+ await this.heartbeat(handle, owner);
152
+ const timer = setInterval(() => { void this.heartbeat(handle, owner).catch(() => undefined); }, 1000);
153
+ timer.unref();
154
+ return { handle, path, token, timer };
155
+ }
156
+ catch (error) {
157
+ const opened = await handle.stat().catch(() => undefined), removedOpen = await unlink(path).then(() => true).catch(() => false);
158
+ await handle.close().catch(() => undefined);
159
+ if (!removedOpen && opened !== undefined) {
160
+ const current = await stat(path).catch(() => undefined);
161
+ if (current !== undefined && current.dev === opened.dev && current.ino === opened.ino && Date.now() - current.mtimeMs <= 5000)
162
+ await unlink(path).catch(() => undefined);
163
+ }
164
+ activeTokens.delete(token);
165
+ throw error;
166
+ } }
167
+ async stale(path, finiteLease) { const info = await stat(path).catch(() => undefined); if (!info)
168
+ return false; const age = this.now() - info.mtimeMs; return age > 5000 && ((finiteLease && age > MAX_LEASE_AGE) || !this.live(await readFile(path, "utf8").catch(() => undefined))); }
169
+ async recoveryClaims(path) { const prefix = `${basename(path)}.recover.`; const claims = (await readdir(dirname(path)).catch(() => [])).filter((name) => name.startsWith(prefix)).map((name) => join(dirname(path), name)); for (const claim of claims)
170
+ if (await this.stale(claim, false))
171
+ await unlink(claim).catch(() => undefined); return (await readdir(dirname(path)).catch(() => [])).filter((name) => name.startsWith(prefix)).map((name) => join(dirname(path), name)).sort(); }
172
+ async recoverGuard(path) { if (!await this.stale(path, false))
173
+ return false; const claim = await this.acquire(`${path}.recover.${randomBytes(12).toString("hex")}`); try {
174
+ const claims = await this.recoveryClaims(path);
175
+ if (claims[0] !== claim.path || !await this.stale(path, false))
176
+ return false;
177
+ await unlink(path).catch(() => undefined);
178
+ return true;
179
+ }
180
+ finally {
181
+ await this.releaseGuard(claim);
182
+ } }
183
+ async recoverStale(path) { if (!await this.stale(path, true))
184
+ return false; const guard = await this.pathGuard(path); if (!guard)
185
+ return false; try {
186
+ if (!await this.stale(path, true))
187
+ return false;
188
+ await unlink(path).catch(() => undefined);
189
+ return true;
190
+ }
191
+ finally {
192
+ await this.releaseGuard(guard);
193
+ } }
194
+ async lock(file) { const path = `${file}.lock`; for (let attempt = 0; attempt < 3; attempt++) {
195
+ try {
196
+ return await this.acquire(path);
197
+ }
198
+ catch (error) {
199
+ if (error?.code !== "EEXIST")
200
+ throw new ReflectionError("reflection_lock_timeout");
201
+ await this.recoverStale(path);
202
+ await this.sleep(10 * 2 ** attempt);
203
+ }
204
+ } throw new ReflectionError("reflection_lock_timeout"); }
205
+ async pathGuard(path) { const guard = `${path}.guard`; for (let attempt = 0; attempt < 3; attempt++) {
206
+ try {
207
+ if ((await this.recoveryClaims(guard)).length > 0) {
208
+ await this.sleep(2 ** attempt);
209
+ continue;
210
+ }
211
+ const candidate = await this.acquire(guard);
212
+ if ((await this.recoveryClaims(guard)).length === 0)
213
+ return candidate;
214
+ await this.releaseGuard(candidate);
215
+ }
216
+ catch (error) {
217
+ if (error?.code !== "EEXIST")
218
+ return undefined;
219
+ await this.recoverGuard(guard);
220
+ }
221
+ await this.sleep(2 ** attempt);
222
+ } return undefined; }
223
+ async releaseGuard(guard) { clearInterval(guard.timer); await guard.handle.close().catch(() => undefined); try {
224
+ if (await readFile(guard.path, "utf8").catch(() => undefined) === this.owner(guard.token))
225
+ await unlink(guard.path).catch(() => undefined);
226
+ }
227
+ finally {
228
+ activeTokens.delete(guard.token);
229
+ } }
230
+ async withOwnership(lock, action) { const guard = await this.pathGuard(lock.path); if (!guard)
231
+ throw new ReflectionError("reflection_lock_lost"); try {
232
+ if (await readFile(lock.path, "utf8").catch(() => undefined) !== this.owner(lock.token))
233
+ throw new ReflectionError("reflection_lock_lost");
234
+ return await action();
235
+ }
236
+ finally {
237
+ await this.releaseGuard(guard);
238
+ } }
239
+ async release(lock) { clearInterval(lock.timer); await lock.handle.close().catch(() => undefined); try {
240
+ await this.withOwnership(lock, async () => { await unlink(lock.path); });
241
+ }
242
+ catch { /* a newer owner or stale recovery owns the path */ }
243
+ finally {
244
+ activeTokens.delete(lock.token);
245
+ } }
246
+ async save(file, bucket, lock) { if (bucket.entries.some((entry) => jsonBytes(entry) > 512) || jsonBytes(bucket) > 32768)
247
+ throw new ReflectionError("reflection_size_limit"); const temp = `${file}.${ulid()}.tmp`; const handle = await open(temp, "w", 0o600); try {
248
+ await handle.writeFile(JSON.stringify(bucket));
249
+ await handle.sync();
250
+ }
251
+ finally {
252
+ await handle.close();
253
+ } await chmod(temp, 0o600).catch(() => undefined); try {
254
+ await this.withOwnership(lock, async () => { await rename(temp, file); });
255
+ }
256
+ catch (error) {
257
+ await rm(temp, { force: true });
258
+ throw error;
259
+ } const dir = await open(dirname(file), "r").catch(() => undefined); await dir?.sync().catch(() => undefined); await dir?.close().catch(() => undefined); }
260
+ async read(layer, run, currentVersion) { const file = this.file(layer, run); if (!await stat(dirname(file)).catch(() => undefined))
261
+ return EMPTY(); const lock = await this.lock(file); try {
262
+ await this.withOwnership(lock, async () => { await this.temps(file); await this.artifacts(file, this.now() - ages[layer]); });
263
+ const loaded = await this.load(file, layer, currentVersion, lock);
264
+ if (loaded.future)
265
+ return loaded.bucket;
266
+ if (loaded.changed) {
267
+ if (loaded.legacy)
268
+ await this.withOwnership(lock, async () => { await copyFile(file, `${file}.v0.bak`); });
269
+ await this.save(file, loaded.bucket, lock);
270
+ }
271
+ return loaded.bucket;
272
+ }
273
+ finally {
274
+ await this.release(lock);
275
+ } }
276
+ async transaction(layer, run, currentVersion, fn) { const file = this.file(layer, run); await mkdir(dirname(file), { recursive: true, mode: 0o700 }); await chmod(dirname(file), 0o700).catch(() => undefined); const lock = await this.lock(file); try {
277
+ const loaded = await this.load(file, layer, currentVersion, lock);
278
+ if (loaded.future)
279
+ throw new ReflectionError("reflection_future_schema");
280
+ if (loaded.legacy)
281
+ await this.withOwnership(lock, async () => { await copyFile(file, `${file}.v0.bak`); });
282
+ const result = await fn(loaded.bucket, file, lock);
283
+ await this.save(file, loaded.bucket, lock);
284
+ return result;
285
+ }
286
+ finally {
287
+ await this.release(lock);
288
+ } }
289
+ async record(layer, run, input, currentVersion) { if (!evidence.has(input.evidence))
290
+ throw new ReflectionError("reflection_invalid_evidence"); const now = new Date(this.now()).toISOString(); const entry = { id: ulid(), scope: normalizeField(input.scope, 80, true), trigger: normalizeField(input.trigger, 160), cause: normalizeField(input.cause, 360), prevention: normalizeField(input.prevention, 500), evidence: input.evidence, evidenceRef: shortRef(input.evidenceRef), hits: 1, firstSeen: now, lastSeen: now, status: input.evidence === "user-correction" ? "promotable" : "active" }; if (jsonBytes(entry) > 512)
291
+ throw new ReflectionError("reflection_size_limit"); return this.transaction(layer, run, currentVersion, async (bucket) => { const same = bucket.entries.find((item) => item.scope === entry.scope); if (same) {
292
+ same.hits++;
293
+ same.lastSeen = now;
294
+ same.status = same.hits >= 2 || same.evidence === "user-correction" ? "promotable" : same.status;
295
+ bucket.updatedAt = now;
296
+ return same;
297
+ } bucket.entries.push(entry); while (bucket.entries.length > caps[layer])
298
+ bucket.entries.sort((a, b) => a.hits - b.hits || a.lastSeen.localeCompare(b.lastSeen) || a.id.localeCompare(b.id)).shift(); bucket.updatedAt = now; return entry; }); }
299
+ async promote(layer, run, id, ref, currentVersion) { return this.transaction(layer, run, currentVersion, async (bucket) => { const entry = bucket.entries.find((item) => item.id === id); if (!entry)
300
+ throw new ReflectionError("reflection_not_found"); if (entry.status !== "promotable")
301
+ throw new ReflectionError("reflection_not_promotable"); entry.status = "promoted"; entry.promotedRef = shortRef(ref); entry.promotedAtVersion = currentVersion; bucket.updatedAt = new Date(this.now()).toISOString(); return "promoted"; }); }
302
+ async clear(layer, run, confirmation, currentVersion) { if ((layer !== "run") && confirmation !== "CLEAR_REFLECTIONS")
303
+ throw new ReflectionError("reflection_confirmation_required"); return this.transaction(layer, run, currentVersion, async (bucket, file, lock) => { bucket.entries = []; bucket.updatedAt = new Date(this.now()).toISOString(); await this.withOwnership(lock, async () => { await this.artifacts(file); }); return "cleared"; }); }
304
+ async deleteRun(run) { const file = this.file("run", run); if (!await stat(dirname(file)).catch(() => undefined))
305
+ return; const lock = await this.lock(file); try {
306
+ await this.withOwnership(lock, async () => { await this.artifacts(file); await rm(file, { force: true }); });
307
+ }
308
+ finally {
309
+ await this.release(lock);
310
+ } }
311
+ async inject(layer, run, max, tokenBudget, currentVersion) { return this.injectBuckets([{ layer, run }], max, tokenBudget, currentVersion); }
312
+ async injectBuckets(buckets, max, tokenBudget, currentVersion) { const byScope = new Map(); for (const { layer, run } of buckets)
313
+ for (const entry of (await this.read(layer, run, currentVersion)).entries)
314
+ if (!byScope.has(entry.scope))
315
+ byScope.set(entry.scope, entry); const entries = [...byScope.values()].sort((a, b) => b.hits - a.hits || a.lastSeen.localeCompare(b.lastSeen) || a.id.localeCompare(b.id)); const lines = []; for (const entry of entries) {
316
+ const line = `- ${entry.scope}: ${entry.prevention}`;
317
+ if (lines.length >= max || estimateInjectionTokens([...lines, line].join("\n")) > tokenBudget)
318
+ break;
319
+ lines.push(line);
320
+ } return lines.join("\n"); }
321
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "description": "Bounded, validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",