sortie-dogs 0.14.9 → 0.15.2

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
@@ -19,8 +19,9 @@ evidence-backed result, not more agents for their own sake.
19
19
  [![Node.js](https://img.shields.io/node/v/sortie-dogs)](https://www.npmjs.com/package/sortie-dogs)
20
20
  [![MIT License](https://img.shields.io/npm/l/sortie-dogs)](LICENSE)
21
21
 
22
- **Current release: [v0.14.9](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.14.9)**
23
- · [Release notes](docs/release-v0.14.9.md) · [Execution vision](docs/v0.14.0-execution-vision.md)
22
+ **Current release: [v0.15.2](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.15.2)**
23
+ · [Release notes](docs/release-v0.15.2.md)
24
+ · [Execution vision](docs/v0.14.0-execution-vision.md)
24
25
 
25
26
  [When to use](#when-to-use-sortie-dogs) · [Quick start](#quick-start) · [Workflow](#mission-workflow) · [Models](#default-models) ·
26
27
  [Measured results](#measured-results) · [Configuration](#configuration) · [Documentation](#documentation)
@@ -77,7 +78,7 @@ Use an existing Codex CLI signed in with **ChatGPT**. On Windows, PowerShell **7
77
78
  available; POSIX uses bash. Sortie does not install Codex, start login or use a metered API fallback.
78
79
 
79
80
  ```sh
80
- npm install --save-dev sortie-dogs@0.14.9
81
+ npm install --save-dev sortie-dogs@0.15.2
81
82
  npx --no-install sortie-dogs codex init .
82
83
  ```
83
84
 
@@ -106,7 +107,7 @@ bridge. [Codex guide: SDK, permissions, progress, resume and manifest route](doc
106
107
  Use **OpenCode V2**. In the target project:
107
108
 
108
109
  ```sh
109
- npm install --save-dev sortie-dogs@0.14.9
110
+ npm install --save-dev sortie-dogs@0.15.2
110
111
  npx --no-install sortie-dogs init .
111
112
  ```
112
113
 
@@ -124,6 +125,18 @@ The `v010` command, profile and configuration names remain for compatibility; th
124
125
  v0.10 is installed. [OpenCode setup and configuration](docs/configuration.md) ·
125
126
  [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md).
126
127
 
128
+ ### Project Memory (OpenCode v010, opt-in)
129
+
130
+ With v0.15.2 installed, run `sortie-dogs memory on .` once and reload
131
+ the project plugin. Ordinary public turns then become pending sources; the existing
132
+ Operator records semantic additions, corrections, withdrawals and decisions without
133
+ another save request or a dedicated model call per turn. New sessions recall the
134
+ current project vision and constraints as well as relevant decisions and source references.
135
+ `sortie-dogs memory off .` stops capture and recall without deleting saved data.
136
+ This is not a Codex natural-capture feature, execution permission, Review PASS or proof
137
+ of completion. Existing reflection defaults and explicit settings are unchanged.
138
+ [Scope, attachment retrieval, limits and recovery](docs/v0150-project-memory.md).
139
+
127
140
  ## Mission workflow
128
141
 
129
142
  ```text
@@ -271,7 +284,7 @@ Install the desired package version and rerun **your host's** initializer:
271
284
 
272
285
  An OpenCode config-local bridge may resolve a separate package; npm-global update alone does not
273
286
  update it. [Global/update instructions](docs/configuration.md#global-availability).
274
- The current Mission asset marker is `0.14.9-optional-kofi-v1`; the Codex skill marker is
287
+ The current Mission asset marker is `0.15.2-tactical-return-v1`; the Codex skill marker is
275
288
  `0.14.6-codex-skill-v1`. Installed markers alone do not prove a running host reloaded the new version.
276
289
 
277
290
  There is no supported uninstall command. Remove the dependency and only known Sortie-owned paths;
@@ -287,7 +300,7 @@ follow the [manual removal guide](docs/uninstall.md), never delete the entire `.
287
300
  [SWE-bench history](docs/benchmarks/swebench-lite-history.md) · [Historical local case study](docs/benchmark-reference.md).
288
301
  - **Development:** [Testing](docs/testing.md) · [CLI testing](docs/cli-testing.md) ·
289
302
  [Windows tests](docs/windows-tests.md) · [Release routine](docs/release-batch.md).
290
- - **Releases:** [v0.14.9](docs/release-v0.14.9.md) · [v0.14.8](docs/release-v0.14.8.md) · [v0.14.7](docs/release-v0.14.7.md) · [v0.14.6](docs/release-v0.14.6.md) · [v0.14.5](docs/release-v0.14.5.md) · [v0.14.4](docs/release-v0.14.4.md) · [v0.14.3](docs/release-v0.14.3.md) · [v0.14.2](docs/release-v0.14.2.md) · [v0.14.1](docs/release-v0.14.1.md) · [v0.14.0](docs/release-v0.14.0.md) · [v0.13.22](docs/release-v0.13.22.md) · [v0.13.21](docs/release-v0.13.21.md) · [v0.13.20](docs/release-v0.13.20.md) · [v0.13.19](docs/release-v0.13.19.md) · [v0.13.18](docs/release-v0.13.18.md) · [v0.13.17](docs/release-v0.13.17.md) · [v0.13.16](docs/release-v0.13.16.md) · [v0.13.15](docs/release-v0.13.15.md) · [v0.13.14](docs/release-v0.13.14.md) · [v0.13.13](docs/release-v0.13.13.md) · [v0.13.12](docs/release-v0.13.12.md) · [v0.13.11](docs/release-v0.13.11.md) · [v0.13.10](docs/release-v0.13.10.md) ·
303
+ - **Releases:** [v0.15.2](docs/release-v0.15.2.md) · [v0.15.1](docs/v0.15.1-migration.md) · [v0.15.0](docs/release-v0.15.0.md) · [v0.14.9](docs/release-v0.14.9.md) · [v0.14.8](docs/release-v0.14.8.md) · [v0.14.7](docs/release-v0.14.7.md) · [v0.14.6](docs/release-v0.14.6.md) · [v0.14.5](docs/release-v0.14.5.md) · [v0.14.4](docs/release-v0.14.4.md) · [v0.14.3](docs/release-v0.14.3.md) · [v0.14.2](docs/release-v0.14.2.md) · [v0.14.1](docs/release-v0.14.1.md) · [v0.14.0](docs/release-v0.14.0.md) · [v0.13.22](docs/release-v0.13.22.md) · [v0.13.21](docs/release-v0.13.21.md) · [v0.13.20](docs/release-v0.13.20.md) · [v0.13.19](docs/release-v0.13.19.md) · [v0.13.18](docs/release-v0.13.18.md) · [v0.13.17](docs/release-v0.13.17.md) · [v0.13.16](docs/release-v0.13.16.md) · [v0.13.15](docs/release-v0.13.15.md) · [v0.13.14](docs/release-v0.13.14.md) · [v0.13.13](docs/release-v0.13.13.md) · [v0.13.12](docs/release-v0.13.12.md) · [v0.13.11](docs/release-v0.13.11.md) · [v0.13.10](docs/release-v0.13.10.md) ·
291
304
  [v0.13.9](docs/release-v0.13.9.md) · [All GitHub releases](https://github.com/zufall-upon/Sortie-dogs/releases).
292
305
 
293
306
  ## Support
@@ -3,6 +3,6 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export declare const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export declare const V010_RUNTIME_ASSET_VERSION = "0.14.9-optional-kofi-v1";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.15.2-tactical-return-v1";
7
7
  export declare const CODEX_SKILL_ASSET_VERSION = "0.14.6-codex-skill-v1";
8
8
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION | typeof V010_RUNTIME_ASSET_VERSION | typeof CODEX_SKILL_ASSET_VERSION;
@@ -3,5 +3,5 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export const V010_RUNTIME_ASSET_VERSION = "0.14.9-optional-kofi-v1";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.15.2-tactical-return-v1";
7
7
  export const CODEX_SKILL_ASSET_VERSION = "0.14.6-codex-skill-v1";
package/dist/cli/main.js CHANGED
@@ -28,7 +28,8 @@ const USAGE = `Usage: sortie-dogs lint <handoff.json> [<handoff.json> ...]
28
28
  [--manifest <operation-manifest.json>]
29
29
  [--changed-paths-from <file|->]
30
30
  [--changed-path <path> ...]
31
- [--format text|json] [--quiet] [--strict]`;
31
+ [--format text|json] [--quiet] [--strict]
32
+ Project Memory (opt-in): sortie-dogs memory on|off|status [project-root]`;
32
33
  const INIT_USAGE = `Usage: sortie-dogs init [project-root] [--profile stable|v010]
33
34
  sortie-dogs init --global [--profile stable|v010]
34
35
  This beta package defaults to the v010 profile.`;
@@ -238,6 +239,24 @@ function render(output, format) {
238
239
  return output.map(({ file, pointer, code, severity, message }) => `${file} ${neutralizeTextControlCharacters(pointer || "/")} ${code} ${severity} ${neutralizeTextControlCharacters(message)}\n`).join("");
239
240
  }
240
241
  export async function run(argv) {
242
+ if (argv[0] === "memory") {
243
+ if (!["on", "off", "status"].includes(argv[1] ?? "") || argv.length > 3 || argv[2]?.startsWith("-")) {
244
+ process.stderr.write("Usage: sortie-dogs memory on|off|status [project-root]\nOpt-in once per local project; off retains data and stops capture/recall.\n");
245
+ return 2;
246
+ }
247
+ try {
248
+ const memoryModule = await import(__rewriteRelativeImportExtension(`../core/project-memory.${import.meta.url.endsWith(".ts") ? "ts" : "js"}`));
249
+ const memory = new memoryModule.ProjectMemory(await memoryModule.projectMemoryFile(resolve(argv[2] ?? ".")));
250
+ if (argv[1] !== "status")
251
+ await memory.configure(argv[1] === "on");
252
+ process.stdout.write(JSON.stringify(await memory.recall(resolve(argv[2] ?? "."))) + "\n");
253
+ return 0;
254
+ }
255
+ catch (error) {
256
+ process.stderr.write(`Project Memory: ${error instanceof Error ? error.message : String(error)}\n`);
257
+ return 1;
258
+ }
259
+ }
241
260
  if (argv[0] === "codex") {
242
261
  if (argv[1] === "init") {
243
262
  if (argv[2] === "--help" && argv.length === 3) {
@@ -354,6 +373,8 @@ export async function run(argv) {
354
373
  if (initialized.preservedLegacyPaths.length > 0) {
355
374
  process.stdout.write(`Preserved legacy runtime files: ${initialized.preservedLegacyPaths.join(", ")}.\n`);
356
375
  }
376
+ if (profile === "v010")
377
+ process.stdout.write("Optional Project Memory: sortie-dogs memory on [project-root]. Off stops capture/recall and retains data. Reload the project plugin after first opt-in.\n");
357
378
  const override = profile === "v010"
358
379
  ? await initializer.coordinatorModelOverride(global ? target : resolve(initArgs[0] ?? process.cwd()), global).catch(() => undefined)
359
380
  : undefined;
@@ -144,6 +144,9 @@ export function reduceGoalFlight(records) {
144
144
  const validationAdmissions = new Map();
145
145
  const validationSettlements = new Map();
146
146
  const reopenedValidationEvidence = new Set();
147
+ // Older revisions projected the unit ceiling onto validation capacity. Replay their
148
+ // subsequent, stricter recorded admissions without shrinking the retained capacity.
149
+ let validationAdmissionLimit = null;
147
150
  for (const [index, record] of records.entries()) {
148
151
  requireState(record.sequence === index + 1 && record.previous_hash === previous && HASH.test(record.event_hash), "invalid", "Goal ledger chain is malformed.");
149
152
  previous = record.event_hash;
@@ -170,6 +173,7 @@ export function reduceGoalFlight(records) {
170
173
  validationAdmissions.clear();
171
174
  validationSettlements.clear();
172
175
  reopenedValidationEvidence.clear();
176
+ validationAdmissionLimit = null;
173
177
  continue;
174
178
  }
175
179
  requireState(state.goal_id !== null && event.goal_id === state.goal_id, "transition", "Goal identity mismatch.");
@@ -182,14 +186,13 @@ export function reduceGoalFlight(records) {
182
186
  else if (event.kind === "goal.revised") {
183
187
  requireState(state.phase !== "terminal" && event.revision === state.revision + 1 && event.scope_epoch === state.scope_epoch + 1 &&
184
188
  HASH.test(event.acceptance_fingerprint) && event.budget.max_units >= state.consumed_units &&
185
- event.budget.max_units >= state.validation_budget.consumed &&
186
189
  validAcceptanceContract(event.acceptance_contract) &&
187
190
  (event.reset_no_progress === undefined || typeof event.reset_no_progress === "boolean"), "transition", "Scope revision is stale or resets consumed budget.");
188
191
  state = { ...state, revision: event.revision, scope_epoch: event.scope_epoch,
189
192
  acceptance_fingerprint: event.acceptance_fingerprint, latest_user_message_id: event.origin_user_message_id,
190
193
  selected_agent: event.selected_agent, delivery: event.delivery, budget: event.budget,
191
194
  validation_budget: { ...state.validation_budget,
192
- limit: state.validation_budget.limit === null ? null : event.budget.max_units },
195
+ limit: state.validation_budget.limit === null ? null : Math.max(state.validation_budget.limit, state.validation_budget.consumed, event.budget.max_units) },
193
196
  acceptance_contract: event.acceptance_contract,
194
197
  session_ids: addUnique(state.session_ids, event.session_id), phase: "active", stop_reason: null,
195
198
  receipt: null, tickets: [], ...(event.reset_no_progress === true
@@ -198,6 +201,8 @@ export function reduceGoalFlight(records) {
198
201
  if (event.reset_no_progress === true)
199
202
  replanRevision = null;
200
203
  legacyResetRevision = event.reset_no_progress === undefined;
204
+ if (validationAdmissionLimit !== null)
205
+ validationAdmissionLimit = Math.max(event.budget.max_units, state.validation_budget.consumed);
201
206
  }
202
207
  else if (event.kind === "ticket.issued") {
203
208
  requireState(state.phase === "active" && event.revision === state.revision && event.scope_epoch === state.scope_epoch &&
@@ -318,13 +323,15 @@ export function reduceGoalFlight(records) {
318
323
  const latest = [...validationSettlements.values()].filter(entry => entry.evidence_key === event.evidence_key).at(-1);
319
324
  const blocked = latest !== undefined && latest.outcome !== "passed" && !retryableValidationFailure(latest.outcome, latest.exit_code);
320
325
  requireState(event.scope !== null && event.consumed === state.validation_budget.consumed + 1 &&
321
- (state.validation_budget.limit === null || event.limit >= state.validation_budget.limit) &&
326
+ (validationAdmissionLimit === null || event.limit >= validationAdmissionLimit) &&
322
327
  ((!duplicate && !blocked && !inFlight && reopen === undefined) || (validReopen && !inFlight)) &&
323
328
  !state.validation_budget.reservations.some((entry) => entry.reservation_id === event.reservation_id), "budget", "Validation admission is stale, duplicated, or exhausted.");
324
- state = { ...state, validation_budget: { ...state.validation_budget, consumed: event.consumed, limit: event.limit,
329
+ state = { ...state, validation_budget: { ...state.validation_budget, consumed: event.consumed,
330
+ limit: Math.max(state.validation_budget.limit ?? event.limit, event.limit),
325
331
  reservations: [...state.validation_budget.reservations,
326
332
  { reservation_id: event.reservation_id, operation_id: event.operation_id, evidence_key: event.evidence_key }] } };
327
333
  validationAdmissions.set(event.reservation_id, event);
334
+ validationAdmissionLimit = event.limit;
328
335
  if (reopen !== undefined)
329
336
  reopenedValidationEvidence.add(event.evidence_key);
330
337
  }
@@ -1,3 +1,4 @@
1
+ import { type RareAwards } from "./rare-awards.ts";
1
2
  /** Optional, bounded telemetry retained with the existing terminal goal ledger, never an authority input. */
2
3
  export interface GoalReport {
3
4
  readonly definition: "pre-terminal-host-tokens/v1";
@@ -14,5 +15,6 @@ export interface GoalReport {
14
15
  readonly worker_ms: number;
15
16
  readonly wall_ms: number;
16
17
  };
18
+ readonly rare?: RareAwards;
17
19
  }
18
20
  export declare function validGoalReport(value: unknown): value is GoalReport;
@@ -1,3 +1,4 @@
1
+ import { validRareAwards } from "./rare-awards.js";
1
2
  export function validGoalReport(value) {
2
3
  if (value === null || typeof value !== "object")
3
4
  return false;
@@ -5,11 +6,12 @@ export function validGoalReport(value) {
5
6
  const amount = (n) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
6
7
  const duration = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
7
8
  const overlap = item.overlap;
8
- return Object.keys(item).every((key) => ["definition", "terminal_key", "tokens", "models", "first_pass_eligible", "traits", "overlap"].includes(key)) &&
9
+ return Object.keys(item).every((key) => ["definition", "terminal_key", "tokens", "models", "first_pass_eligible", "traits", "overlap", "rare"].includes(key)) &&
9
10
  item.definition === "pre-terminal-host-tokens/v1" && /^sha256:[a-f0-9]{64}$/u.test(item.terminal_key) &&
10
11
  (item.tokens === null || amount(item.tokens)) && typeof item.first_pass_eligible === "boolean" &&
11
12
  Array.isArray(item.traits) && item.traits.length <= 3 && new Set(item.traits).size === item.traits.length &&
12
13
  item.traits.every((trait) => ["pack-tactics", "recovery", "clean-sweep"].includes(trait)) &&
14
+ (item.rare === undefined || validRareAwards(item.rare)) &&
13
15
  (overlap === undefined || overlap !== null && typeof overlap === "object" &&
14
16
  Object.keys(overlap).every((key) => ["definition", "worker_ms", "wall_ms"].includes(key)) && overlap.definition === "worker-span-union/v1" &&
15
17
  duration(overlap.worker_ms) && duration(overlap.wall_ms) && overlap.worker_ms >= overlap.wall_ms) &&
@@ -136,11 +136,12 @@ function classifyVersionTransition(installedValue, currentValue, v010Restore = f
136
136
  const adjacentPreOneLine = installed.major === 0 && current.major === 0 &&
137
137
  current.minor === installed.minor + 1;
138
138
  // v0.11 used a different runtime. The v010 asset migration restores the 0.10
139
- // preview into the 0.12–0.14 Mission line without selecting a v0.11 execution path.
139
+ // preview into the 0.12–0.15 Mission line without selecting a v0.11 execution path.
140
140
  const restoredPreview = v010Restore && installed.major === 0 && installed.minor === 10 &&
141
- current.major === 0 && [12, 13, 14].includes(current.minor);
142
- const retainedMission = v010Restore && installed.major === 0 && installed.minor === 12 &&
143
- current.major === 0 && current.minor === 14;
141
+ current.major === 0 && [12, 13, 14, 15].includes(current.minor);
142
+ const retainedMission = v010Restore && installed.major === 0 &&
143
+ ((installed.minor === 12 && current.minor === 14) || ([12, 13].includes(installed.minor) && current.minor === 15)) &&
144
+ current.major === 0;
144
145
  return sameLine || adjacentPreOneLine || restoredPreview || retainedMission ? "compatible-update" : "incompatible";
145
146
  }
146
147
  async function metadata(path) {
@@ -0,0 +1,136 @@
1
+ export interface MemorySource {
2
+ id: string;
3
+ session: string;
4
+ message: string;
5
+ actor: string;
6
+ text: string;
7
+ attachments: Array<{
8
+ url: string;
9
+ mime?: string;
10
+ filename?: string;
11
+ }>;
12
+ observedAt: string;
13
+ availability: "retained-reference";
14
+ }
15
+ export interface MemoryChange {
16
+ key: string;
17
+ scope: "project" | "workspace" | "mission";
18
+ context: string;
19
+ kind: "vision" | "constraint" | "decision" | "unresolved" | "result";
20
+ status: "proposed" | "adopted" | "withdrawn" | "unverified";
21
+ text: string;
22
+ reason: string;
23
+ supersedes?: string;
24
+ consultation?: {
25
+ id: string;
26
+ callID: string;
27
+ disposition: "adopted" | "rejected" | "partial";
28
+ effect: "improved" | "unchanged" | "worsened" | "unverified";
29
+ };
30
+ }
31
+ export interface MemoryEvent extends MemoryChange {
32
+ id: string;
33
+ source: string;
34
+ operation: string;
35
+ actor: string;
36
+ at: string;
37
+ }
38
+ /** Stable repository identity uses local Git common-dir, never remote URL or a worktree path. */
39
+ export declare function projectMemoryFile(root: string): Promise<string>;
40
+ /** One atomic document commits events and processed markers together. No reflection TTL or schema reuse. */
41
+ export declare class ProjectMemory {
42
+ readonly file: string;
43
+ constructor(file: string);
44
+ private load;
45
+ private transaction;
46
+ enabled(): Promise<boolean>;
47
+ source(id: string): Promise<MemorySource | undefined>;
48
+ sourceForModel(id: string): Promise<{
49
+ text: string;
50
+ text_truncated: boolean;
51
+ attachments: {
52
+ url: string | undefined;
53
+ url_omitted: boolean;
54
+ filename?: string | undefined;
55
+ mime?: string | undefined;
56
+ source: string;
57
+ index: number;
58
+ }[];
59
+ id: string;
60
+ session: string;
61
+ message: string;
62
+ actor: string;
63
+ observedAt: string;
64
+ availability: "retained-reference";
65
+ } | undefined>;
66
+ /** Materialize inline bytes only on demand; recall must never inject them as model text. */
67
+ attachment(id: string, index: number): Promise<{
68
+ source: string;
69
+ index: number;
70
+ url: string;
71
+ mime?: string;
72
+ filename?: string;
73
+ } | {
74
+ filename?: string | undefined;
75
+ source: string;
76
+ index: number;
77
+ path: string;
78
+ mime: string;
79
+ } | undefined>;
80
+ configure(enabled: boolean): Promise<void>;
81
+ capture(source: Omit<MemorySource, "observedAt" | "availability">): Promise<void>;
82
+ apply(source: string, operation: string, actor: string, changes: MemoryChange[]): Promise<string>;
83
+ recall(workspace: string, mission?: string, query?: string): Promise<{
84
+ enabled: false;
85
+ snapshot?: undefined;
86
+ protected_omitted?: undefined;
87
+ pending?: undefined;
88
+ pending_count?: undefined;
89
+ oldest_pending?: undefined;
90
+ last_success?: undefined;
91
+ sources?: undefined;
92
+ } | {
93
+ enabled: true;
94
+ snapshot: MemoryEvent[];
95
+ protected_omitted: number;
96
+ pending: {
97
+ text: string;
98
+ text_truncated: boolean;
99
+ attachments: {
100
+ url: string | undefined;
101
+ url_omitted: boolean;
102
+ filename?: string | undefined;
103
+ mime?: string | undefined;
104
+ source: string;
105
+ index: number;
106
+ }[];
107
+ id: string;
108
+ session: string;
109
+ message: string;
110
+ actor: string;
111
+ observedAt: string;
112
+ availability: "retained-reference";
113
+ }[];
114
+ pending_count: number;
115
+ oldest_pending: string;
116
+ last_success: string | null;
117
+ sources: {
118
+ text: string;
119
+ text_truncated: boolean;
120
+ attachments: {
121
+ url: string | undefined;
122
+ url_omitted: boolean;
123
+ filename?: string | undefined;
124
+ mime?: string | undefined;
125
+ source: string;
126
+ index: number;
127
+ }[];
128
+ id: string;
129
+ session: string;
130
+ message: string;
131
+ actor: string;
132
+ observedAt: string;
133
+ availability: "retained-reference";
134
+ }[];
135
+ }>;
136
+ }
@@ -0,0 +1,217 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, open, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { durableScopeRoot } from "./durable-scope-root.js";
5
+ import { configRoot } from "../reflection/config.js";
6
+ const empty = () => ({ v: 1, enabled: false, sources: [], events: [], processed: {} });
7
+ const hash = (value) => createHash("sha256").update(value).digest("hex");
8
+ const text = (value, max = 8000) => typeof value === "string" && value.trim().length > 0 && value.length <= max;
9
+ const sourceView = (source, fullText = false) => ({ ...source,
10
+ text: fullText ? source.text : source.text.slice(0, 2000), text_truncated: !fullText && source.text.length > 2000,
11
+ attachments: source.attachments.map((file, index) => ({ source: source.id, index,
12
+ ...(file.mime ? { mime: file.mime.slice(0, 256) } : {}), ...(file.filename ? { filename: file.filename.slice(0, 256) } : {}),
13
+ url: !file.url.startsWith("data:") && file.url.length <= 2048 ? file.url : undefined,
14
+ url_omitted: file.url.startsWith("data:") || file.url.length > 2048 })) });
15
+ /** Stable repository identity uses local Git common-dir, never remote URL or a worktree path. */
16
+ export async function projectMemoryFile(root) {
17
+ // The lease suffix need not exist yet; canonicalize its existing ancestors
18
+ // without creating metadata merely to compute the memory identity.
19
+ const canonical = async (path) => realpath(path).catch(async (error) => {
20
+ if (error.code !== "ENOENT" || dirname(path) === path)
21
+ throw error;
22
+ return join(await canonical(dirname(path)), basename(path));
23
+ });
24
+ const identity = await canonical(await durableScopeRoot(root) ?? resolve(root));
25
+ return join(configRoot(), "sortie-dogs", "project-memory", `${hash(identity)}.json`);
26
+ }
27
+ /** One atomic document commits events and processed markers together. No reflection TTL or schema reuse. */
28
+ export class ProjectMemory {
29
+ file;
30
+ constructor(file) { this.file = file; }
31
+ async load() {
32
+ try {
33
+ const state = JSON.parse(await readFile(this.file, "utf8"));
34
+ if (state.v !== 1 || typeof state.enabled !== "boolean" || !Array.isArray(state.sources) ||
35
+ !Array.isArray(state.events) || !state.processed || typeof state.processed !== "object" || Array.isArray(state.processed) ||
36
+ !state.sources.every(source => source && text(source.id) && text(source.session) && text(source.message) && text(source.actor) &&
37
+ typeof source.text === "string" && Array.isArray(source.attachments) && source.attachments.every(file => file && text(file.url, Number.MAX_SAFE_INTEGER)) &&
38
+ typeof source.observedAt === "string" && source.availability === "retained-reference") ||
39
+ !state.events.every(event => event && text(event.id) && text(event.key) && text(event.context) && text(event.text) && text(event.reason) &&
40
+ text(event.source) && text(event.actor) && text(event.operation) && typeof event.at === "string" &&
41
+ ["project", "workspace", "mission"].includes(event.scope) && ["vision", "constraint", "decision", "unresolved", "result"].includes(event.kind) &&
42
+ ["proposed", "adopted", "withdrawn", "unverified"].includes(event.status)) ||
43
+ !Object.values(state.processed).every(item => item && text(item.operation) && text(item.fingerprint) && typeof item.at === "string"))
44
+ throw new Error("project-memory-invalid-state");
45
+ return state;
46
+ }
47
+ catch (error) {
48
+ if (error.code === "ENOENT")
49
+ return empty();
50
+ throw error;
51
+ }
52
+ }
53
+ async transaction(fn) {
54
+ await mkdir(dirname(this.file), { recursive: true, mode: 0o700 });
55
+ const lock = `${this.file}.lock`;
56
+ let handle;
57
+ for (let attempt = 0; attempt < 40; attempt++) {
58
+ try {
59
+ handle = await open(lock, "wx", 0o600);
60
+ break;
61
+ }
62
+ catch (error) {
63
+ if (error.code !== "EEXIST")
64
+ throw error;
65
+ await new Promise(resolve => setTimeout(resolve, 25));
66
+ }
67
+ }
68
+ if (!handle)
69
+ throw new Error(`project-memory-busy: retry; if interrupted owner stopped, remove ${lock}`);
70
+ const temp = `${this.file}.${randomUUID()}.tmp`;
71
+ try {
72
+ await handle.writeFile(JSON.stringify({ pid: process.pid }));
73
+ const state = await this.load(), result = fn(state);
74
+ const output = await open(temp, "wx", 0o600);
75
+ try {
76
+ await output.writeFile(JSON.stringify(state));
77
+ await output.sync();
78
+ }
79
+ finally {
80
+ await output.close();
81
+ }
82
+ await rename(temp, this.file);
83
+ const directory = await open(dirname(this.file), "r").catch(() => undefined);
84
+ try {
85
+ await directory?.sync();
86
+ }
87
+ finally {
88
+ await directory?.close();
89
+ }
90
+ return result;
91
+ }
92
+ finally {
93
+ await unlink(temp).catch(() => undefined);
94
+ await handle.close();
95
+ await unlink(lock);
96
+ }
97
+ }
98
+ async enabled() { return (await this.load()).enabled; }
99
+ async source(id) {
100
+ const state = await this.load();
101
+ return state.enabled ? state.sources.find(source => source.id === id) : undefined;
102
+ }
103
+ async sourceForModel(id) {
104
+ const source = await this.source(id);
105
+ return source ? sourceView(source, true) : undefined;
106
+ }
107
+ /** Materialize inline bytes only on demand; recall must never inject them as model text. */
108
+ async attachment(id, index) {
109
+ if (!Number.isSafeInteger(index) || index < 0)
110
+ return undefined;
111
+ const source = await this.source(id), file = source?.attachments[index];
112
+ if (!file)
113
+ return undefined;
114
+ if (!file.url.startsWith("data:"))
115
+ return { ...file, source: id, index };
116
+ const response = await fetch(file.url);
117
+ if (!response.ok)
118
+ throw new Error("project-memory-attachment-unavailable");
119
+ const bytes = Buffer.from(await response.arrayBuffer());
120
+ const mime = file.mime ?? response.headers.get("content-type") ?? "application/octet-stream";
121
+ const extension = { "image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp",
122
+ "application/pdf": ".pdf", "text/plain": ".txt" }[mime] ?? ".bin";
123
+ const directory = `${this.file}.attachments`, path = join(directory, `${hash(file.url)}${extension}`);
124
+ await mkdir(directory, { recursive: true, mode: 0o700 });
125
+ const temporary = `${path}.${randomUUID()}.tmp`;
126
+ try {
127
+ await writeFile(temporary, bytes, { flag: "wx", mode: 0o600 });
128
+ await rename(temporary, path);
129
+ }
130
+ finally {
131
+ await unlink(temporary).catch(() => undefined);
132
+ }
133
+ return { source: id, index, path, mime, ...(file.filename ? { filename: file.filename } : {}) };
134
+ }
135
+ async configure(enabled) { await this.transaction(state => { state.enabled = enabled; }); }
136
+ async capture(source) {
137
+ if (!await this.enabled())
138
+ return;
139
+ if (!text(source.id) || !text(source.session) || !text(source.message))
140
+ throw new Error("project-memory-invalid-source");
141
+ await this.transaction(state => {
142
+ if (!state.enabled)
143
+ return;
144
+ const previous = state.sources.find(item => item.id === source.id);
145
+ if (previous) {
146
+ if (JSON.stringify({ ...source }) !== JSON.stringify({ id: previous.id, session: previous.session, message: previous.message,
147
+ actor: previous.actor, text: previous.text, attachments: previous.attachments }))
148
+ throw new Error("project-memory-source-conflict");
149
+ return;
150
+ }
151
+ state.sources.push({ ...source, observedAt: new Date().toISOString(), availability: "retained-reference" });
152
+ });
153
+ }
154
+ async apply(source, operation, actor, changes) {
155
+ if (!text(operation, 256) || !text(actor, 256) || !Array.isArray(changes) || changes.length > 32)
156
+ throw new Error("project-memory-invalid-operation");
157
+ return this.transaction(state => {
158
+ if (!state.enabled)
159
+ throw new Error("project-memory-off");
160
+ if (!state.sources.some(item => item.id === source))
161
+ throw new Error("project-memory-source-not-found");
162
+ const fingerprint = hash(JSON.stringify({ operation, actor, changes }));
163
+ const previous = Object.hasOwn(state.processed, source) ? state.processed[source] : undefined;
164
+ if (previous) {
165
+ if (previous.fingerprint !== fingerprint)
166
+ throw new Error("project-memory-operation-conflict");
167
+ return "already-processed";
168
+ }
169
+ if (Object.values(state.processed).some(item => item.operation === operation))
170
+ throw new Error("project-memory-operation-conflict");
171
+ for (const change of changes) {
172
+ if (!text(change.key, 256) || !text(change.context, 1024) || !text(change.text) || !text(change.reason) ||
173
+ !["project", "workspace", "mission"].includes(change.scope) ||
174
+ !["vision", "constraint", "decision", "unresolved", "result"].includes(change.kind) ||
175
+ !["proposed", "adopted", "withdrawn", "unverified"].includes(change.status))
176
+ throw new Error("project-memory-invalid-change");
177
+ const current = state.events.filter(item => item.key === change.key && item.scope === change.scope && item.context === change.context).at(-1);
178
+ if ((current?.id ?? undefined) !== change.supersedes)
179
+ throw new Error("project-memory-snapshot-conflict");
180
+ if (change.consultation && (!text(change.consultation.id) || !text(change.consultation.callID) ||
181
+ !["adopted", "rejected", "partial"].includes(change.consultation.disposition) ||
182
+ !["improved", "unchanged", "worsened", "unverified"].includes(change.consultation.effect)))
183
+ throw new Error("project-memory-invalid-consultation");
184
+ state.events.push({ ...change, id: randomUUID(), source, operation, actor, at: new Date().toISOString() });
185
+ }
186
+ Object.defineProperty(state.processed, source, { value: { operation, fingerprint, at: new Date().toISOString() }, enumerable: true, configurable: true, writable: true });
187
+ return changes.length ? "applied" : "no-change";
188
+ });
189
+ }
190
+ async recall(workspace, mission, query = "") {
191
+ const state = await this.load();
192
+ if (!state.enabled)
193
+ return { enabled: false };
194
+ const current = new Map();
195
+ for (const event of state.events)
196
+ current.set(JSON.stringify([event.scope, event.context, event.key]), event);
197
+ const snapshot = [...current.values()].filter(event => (event.scope === "project" ||
198
+ event.scope === "workspace" && event.context === workspace || event.scope === "mission" && event.context === mission));
199
+ const terms = query.toLocaleLowerCase().split(/\s+/u).filter(Boolean);
200
+ const protectedCondition = (event) => event.scope === "project" && event.status === "adopted" &&
201
+ (event.kind === "vision" || event.kind === "constraint");
202
+ // Preserve the current adopted purpose on ordinary resume, not obsolete or
203
+ // merely proposed guidance. Explicit searches retain their relevance ordering.
204
+ snapshot.sort((a, b) => (terms.length === 0 ? Number(protectedCondition(b)) - Number(protectedCondition(a)) :
205
+ terms.filter(term => b.text.toLocaleLowerCase().includes(term)).length -
206
+ terms.filter(term => a.text.toLocaleLowerCase().includes(term)).length) || b.at.localeCompare(a.at));
207
+ const selected = snapshot.slice(0, 24);
208
+ const pending = state.sources.filter(source => !Object.hasOwn(state.processed, source.id));
209
+ const excerpt = (source) => sourceView(source);
210
+ return { enabled: true, snapshot: selected,
211
+ protected_omitted: snapshot.filter(protectedCondition).length - selected.filter(protectedCondition).length,
212
+ pending: pending.slice(0, 8).map(excerpt),
213
+ pending_count: pending.length, oldest_pending: pending[0]?.observedAt ?? null,
214
+ last_success: Object.values(state.processed).map(item => item.at).sort().at(-1) ?? null,
215
+ sources: state.sources.filter(source => selected.some(event => event.source === source.id)).slice(-24).map(excerpt) };
216
+ }
217
+ }
@@ -0,0 +1,26 @@
1
+ /** Presentation telemetry only. Never an execution, review, or acceptance input. */
2
+ export type ConditionState = "true" | "false" | "unknown";
3
+ export type RareStatus = "earned" | "not-earned" | "unknown";
4
+ export interface RareEvaluation {
5
+ readonly id: string;
6
+ readonly rule_version: number;
7
+ readonly status: RareStatus;
8
+ readonly evidence: readonly string[];
9
+ }
10
+ export interface RareAwards {
11
+ readonly definition: string;
12
+ readonly evaluations: readonly RareEvaluation[];
13
+ }
14
+ export declare const RARE_RULES: readonly [{
15
+ readonly id: "rare.clean-sweep";
16
+ readonly rule_version: 1;
17
+ }, {
18
+ readonly id: "rare.recovery-coordination";
19
+ readonly rule_version: 1;
20
+ }];
21
+ /** False dominates unknown for an AND; absence of a positive trait is not a negative proof. */
22
+ export declare function evaluateRareAwards(succeeded: boolean, clean: ConditionState, recovery: ConditionState, coordination: ConditionState): RareAwards;
23
+ /** Bounded forward-compatible envelope: unknown definitions/rules remain telemetry, never awards. */
24
+ export declare function validRareAwards(value: unknown): value is RareAwards;
25
+ /** Recognize only this version and its host evidence contract, not a future rule's success string. */
26
+ export declare function rareEvaluation(rare: RareAwards | undefined, id: string): RareEvaluation | undefined;