sortie-dogs 0.2.12 → 0.2.13

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.
@@ -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";
@@ -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,6 +367,7 @@ 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. */
@@ -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,6 +1,6 @@
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";
@@ -10,6 +10,7 @@ import { CONTINUATION_CAPABILITY, createContinuationHooks, } from "./continuatio
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 };
@@ -228,6 +229,7 @@ function loadConfigured(config, handoffBase, client) {
228
229
  readOnlyTools: new Set(config.readOnlyTools.map((tool) => tool.toLowerCase())),
229
230
  modelRoutingHook,
230
231
  continuation: config.continuation,
232
+ reflection: config.reflection,
231
233
  };
232
234
  }
233
235
  function samePath(left, right) {
@@ -392,12 +394,51 @@ export const SortieDogsPlugin = async (input, options) => {
392
394
  const defineTool = validToolCandidate
393
395
  ? toolCandidate
394
396
  : Object.assign((definition) => definition, { schema: { string: () => ({ type: "string" }) } });
397
+ const optionalString = () => {
398
+ const stringSchema = defineTool.schema.string();
399
+ if (isRecord(stringSchema) && typeof stringSchema.optional === "function") {
400
+ return stringSchema.optional();
401
+ }
402
+ return typeof defineTool.schema.optional === "function" ? defineTool.schema.optional(stringSchema) : stringSchema;
403
+ };
395
404
  let project;
405
+ let reflectionStartup = false;
406
+ let reflectionConfiguration;
407
+ let reflectionVersion;
408
+ let reflectionStore;
396
409
  let loaded;
397
410
  let loadFailure;
398
411
  let loading;
399
412
  let manifestAbsent = false;
400
413
  let assetVersionReported = false;
414
+ // Project config read is required discovery for its opt-in; no reflection storage/version read
415
+ // occurs unless that resolved config enables reflection. It stays isolated from write-gate load.
416
+ try {
417
+ project = await createProjectPaths(resolveProjectRoot(input));
418
+ const probed = resolvePluginConfigurationSources(await readOptionalProjectConfig(project), readEnvironmentConfig(), options);
419
+ if (probed.kind === "configured" && reflectionEnabled(probed.reflection)) {
420
+ reflectionVersion = await nearestPackageVersion();
421
+ reflectionConfiguration = probed.reflection;
422
+ reflectionStore = new ReflectionStore(join(configRoot(), "sortie-dogs", "reflection"), project.root, {
423
+ warn: (code) => {
424
+ const log = input.client?.app;
425
+ if (!isRecord(log) || typeof log.log !== "function")
426
+ return;
427
+ try {
428
+ log.log({ level: "warn", service: "sortie-dogs", message: code });
429
+ }
430
+ catch { /* host logging is best effort */ }
431
+ },
432
+ });
433
+ reflectionStartup = true;
434
+ }
435
+ }
436
+ catch {
437
+ reflectionStartup = false;
438
+ reflectionConfiguration = undefined;
439
+ reflectionVersion = undefined;
440
+ reflectionStore = undefined;
441
+ }
401
442
  /*
402
443
  * Continuation must be callable before the first lazy configuration load completes, so it reads
403
444
  * the effective policy at call time and falls back to the shipped default until then.
@@ -484,6 +525,10 @@ export const SortieDogsPlugin = async (input, options) => {
484
525
  const bindingPins = new Map();
485
526
  const activeSessions = new Map();
486
527
  const coordinatorRoots = new Map();
528
+ const reflectionOwnedRoots = new Set();
529
+ const reflectionClosingRoots = new Set();
530
+ const reflectionInFlight = new Map();
531
+ const reflectionWaiters = new Map();
487
532
  const bindingDenials = new Map();
488
533
  const expiredSessions = new Set();
489
534
  const sessionParents = new Map();
@@ -1117,6 +1162,7 @@ export const SortieDogsPlugin = async (input, options) => {
1117
1162
  return {
1118
1163
  ...(typeof payload.agent === "string" ? { agent: payload.agent } : {}),
1119
1164
  ...(typeof payload.parentID === "string" ? { parentID: payload.parentID } : {}),
1165
+ parentPresent: "parentID" in payload,
1120
1166
  };
1121
1167
  }
1122
1168
  catch {
@@ -1135,7 +1181,7 @@ export const SortieDogsPlugin = async (input, options) => {
1135
1181
  if (child?.parentID === undefined)
1136
1182
  return undefined;
1137
1183
  const parent = await hostSessionIdentity(child.parentID);
1138
- if (parent?.agent !== COORDINATOR_AGENT || parent.parentID !== undefined)
1184
+ if (parent?.agent !== COORDINATOR_AGENT || parent.parentPresent)
1139
1185
  return undefined;
1140
1186
  await rememberCoordinatorRoot(child.parentID);
1141
1187
  rememberParent(sessionID, child.parentID);
@@ -1151,7 +1197,51 @@ export const SortieDogsPlugin = async (input, options) => {
1151
1197
  }
1152
1198
  }
1153
1199
  }
1154
- return {
1200
+ async function reflectionPermitted(sessionID, agent) {
1201
+ if (!reflectionStartup || reflectionStore === undefined || reflectionVersion === undefined || process.env.SORTIE_REFLECTION === "0")
1202
+ return false;
1203
+ if (agent !== undefined && agent !== COORDINATOR_AGENT)
1204
+ return false;
1205
+ if (!isCoordinatorSession(sessionID) || coordinatorRootForSession(sessionID) !== sessionID || sessionParents.has(sessionID))
1206
+ return false;
1207
+ const identity = await hostSessionIdentity(sessionID);
1208
+ if (identity?.agent !== COORDINATOR_AGENT || identity.parentPresent)
1209
+ return false;
1210
+ return true;
1211
+ }
1212
+ async function beginReflection(sessionID, agent) {
1213
+ if (!(await reflectionPermitted(sessionID, agent)) || reflectionClosingRoots.has(sessionID))
1214
+ return false;
1215
+ reflectionOwnedRoots.add(sessionID);
1216
+ reflectionInFlight.set(sessionID, (reflectionInFlight.get(sessionID) ?? 0) + 1);
1217
+ return true;
1218
+ }
1219
+ function endReflection(sessionID) {
1220
+ const remaining = (reflectionInFlight.get(sessionID) ?? 1) - 1;
1221
+ if (remaining > 0) {
1222
+ reflectionInFlight.set(sessionID, remaining);
1223
+ return;
1224
+ }
1225
+ reflectionInFlight.delete(sessionID);
1226
+ for (const resolve of reflectionWaiters.get(sessionID) ?? [])
1227
+ resolve();
1228
+ reflectionWaiters.delete(sessionID);
1229
+ }
1230
+ async function waitForReflections(sessionID) {
1231
+ if ((reflectionInFlight.get(sessionID) ?? 0) === 0)
1232
+ return;
1233
+ await new Promise((resolve) => (reflectionWaiters.get(sessionID) ?? reflectionWaiters.set(sessionID, []).get(sessionID)).push(resolve));
1234
+ }
1235
+ function reflectionWarning(code) {
1236
+ const log = input.client?.app;
1237
+ if (!isRecord(log) || typeof log.log !== "function")
1238
+ return;
1239
+ try {
1240
+ log.log({ level: "warn", service: "sortie-dogs", message: code });
1241
+ }
1242
+ catch { /* host logging is best effort */ }
1243
+ }
1244
+ const hooks = {
1155
1245
  tool: {
1156
1246
  sortie_bind_write_gate: defineTool({
1157
1247
  description: "Bind this active session to one project-relative operation manifest without changing files.",
@@ -1194,6 +1284,36 @@ export const SortieDogsPlugin = async (input, options) => {
1194
1284
  return await continuation.tool.execute({}, context);
1195
1285
  },
1196
1286
  }),
1287
+ ...(reflectionStartup ? {
1288
+ sortie_reflection: defineTool({
1289
+ description: "Record, promote, or clear a bounded process reflection.",
1290
+ 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() },
1291
+ async execute(args, context) {
1292
+ if (!(await beginReflection(context.sessionID, context.agent)))
1293
+ return "reflection_not_permitted";
1294
+ const layer = args.layer;
1295
+ try {
1296
+ if (!["run", "project", "global"].includes(layer))
1297
+ return "reflection_invalid_layer";
1298
+ if (!(reflectionConfiguration?.layers[layer] ?? false))
1299
+ return "reflection_not_permitted";
1300
+ if (args.action === "record")
1301
+ return JSON.stringify(await reflectionStore.record(layer, context.sessionID, args, reflectionVersion));
1302
+ if (args.action === "promote")
1303
+ return await reflectionStore.promote(layer, context.sessionID, args.id, args.promotedRef, reflectionVersion);
1304
+ if (args.action === "clear")
1305
+ return await reflectionStore.clear(layer, context.sessionID, args.confirmation, reflectionVersion);
1306
+ return "reflection_invalid_action";
1307
+ }
1308
+ catch (error) {
1309
+ return error instanceof ReflectionError ? error.code : "reflection_storage_error";
1310
+ }
1311
+ finally {
1312
+ endReflection(context.sessionID);
1313
+ }
1314
+ },
1315
+ }),
1316
+ } : {}),
1197
1317
  },
1198
1318
  "experimental.text.complete": async (textInput, textOutput) => {
1199
1319
  await continuation.textComplete(textInput, textOutput);
@@ -1238,6 +1358,25 @@ export const SortieDogsPlugin = async (input, options) => {
1238
1358
  await ensureLoaded();
1239
1359
  await loaded?.modelRoutingHook?.(chatInput, output);
1240
1360
  },
1361
+ ...(reflectionStartup ? { "experimental.chat.system.transform": async (transformInput, transformOutput) => {
1362
+ if (!(await beginReflection(transformInput.sessionID)))
1363
+ return;
1364
+ const config = reflectionConfiguration;
1365
+ try {
1366
+ if (!config)
1367
+ return;
1368
+ const buckets = ["run", "project", "global"]
1369
+ .filter((layer) => config.layers[layer])
1370
+ .map((layer) => ({ layer, ...(layer === "global" ? {} : { run: transformInput.sessionID }) }));
1371
+ const text = await reflectionStore.injectBuckets(buckets, config.maxInjectedEntries, config.maxInjectedTokens, reflectionVersion);
1372
+ if (text)
1373
+ transformOutput.system = [...(transformOutput.system ?? []), text];
1374
+ }
1375
+ catch { /* reflection is strictly non-invasive */ }
1376
+ finally {
1377
+ endReflection(transformInput.sessionID);
1378
+ }
1379
+ } } : {}),
1241
1380
  "permission.ask": async (permission) => {
1242
1381
  if (permission.permission !== "edit")
1243
1382
  return;
@@ -1348,6 +1487,28 @@ export const SortieDogsPlugin = async (input, options) => {
1348
1487
  return;
1349
1488
  }
1350
1489
  if (event.type === "session.deleted") {
1490
+ if (reflectionStore !== undefined && reflectionConfiguration?.layers.run && reflectionOwnedRoots.has(eventSessionID)) {
1491
+ reflectionClosingRoots.add(eventSessionID);
1492
+ await waitForReflections(eventSessionID);
1493
+ let deleted = false;
1494
+ for (const delay of [0, 50, 250, 1_000, 5_000]) {
1495
+ if (delay > 0)
1496
+ await new Promise((resolve) => setTimeout(resolve, delay));
1497
+ try {
1498
+ await reflectionStore.deleteRun(eventSessionID);
1499
+ deleted = true;
1500
+ }
1501
+ catch { /* bounded retry below */ }
1502
+ if (deleted)
1503
+ break;
1504
+ }
1505
+ if (deleted) {
1506
+ reflectionOwnedRoots.delete(eventSessionID);
1507
+ reflectionClosingRoots.delete(eventSessionID);
1508
+ }
1509
+ else
1510
+ reflectionWarning("reflection_cleanup_failed");
1511
+ }
1351
1512
  evictSession(eventSessionID);
1352
1513
  continuation.forgetSession(eventSessionID);
1353
1514
  return;
@@ -1377,5 +1538,6 @@ export const SortieDogsPlugin = async (input, options) => {
1377
1538
  }
1378
1539
  },
1379
1540
  };
1541
+ return hooks;
1380
1542
  };
1381
1543
  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.13",
4
4
  "description": "Bounded, validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",