audrey 1.3.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +70 -71
  3. package/SECURITY.md +2 -3
  4. package/dist/mcp-server/config.d.ts +1 -1
  5. package/dist/mcp-server/config.d.ts.map +1 -1
  6. package/dist/mcp-server/config.js +31 -3
  7. package/dist/mcp-server/config.js.map +1 -1
  8. package/dist/mcp-server/index.d.ts +6 -2
  9. package/dist/mcp-server/index.d.ts.map +1 -1
  10. package/dist/mcp-server/index.js +151 -19
  11. package/dist/mcp-server/index.js.map +1 -1
  12. package/dist/mcp-server/tool-schemas.d.ts +46 -0
  13. package/dist/mcp-server/tool-schemas.d.ts.map +1 -1
  14. package/dist/mcp-server/tool-schemas.js +20 -1
  15. package/dist/mcp-server/tool-schemas.js.map +1 -1
  16. package/dist/src/action-key.d.ts.map +1 -1
  17. package/dist/src/action-key.js +7 -7
  18. package/dist/src/action-key.js.map +1 -1
  19. package/dist/src/adaptive.d.ts +2 -2
  20. package/dist/src/adaptive.d.ts.map +1 -1
  21. package/dist/src/adaptive.js.map +1 -1
  22. package/dist/src/affect.d.ts +2 -2
  23. package/dist/src/affect.d.ts.map +1 -1
  24. package/dist/src/affect.js.map +1 -1
  25. package/dist/src/audrey.d.ts +9 -6
  26. package/dist/src/audrey.d.ts.map +1 -1
  27. package/dist/src/audrey.js +210 -57
  28. package/dist/src/audrey.js.map +1 -1
  29. package/dist/src/autopilot.d.ts +1 -1
  30. package/dist/src/autopilot.d.ts.map +1 -1
  31. package/dist/src/autopilot.js +47 -224
  32. package/dist/src/autopilot.js.map +1 -1
  33. package/dist/src/capsule.d.ts +4 -17
  34. package/dist/src/capsule.d.ts.map +1 -1
  35. package/dist/src/capsule.js +21 -20
  36. package/dist/src/capsule.js.map +1 -1
  37. package/dist/src/causal.d.ts +4 -4
  38. package/dist/src/causal.d.ts.map +1 -1
  39. package/dist/src/causal.js.map +1 -1
  40. package/dist/src/chatgpt-auth.d.ts +77 -0
  41. package/dist/src/chatgpt-auth.d.ts.map +1 -0
  42. package/dist/src/chatgpt-auth.js +887 -0
  43. package/dist/src/chatgpt-auth.js.map +1 -0
  44. package/dist/src/chatgpt-llm.d.ts +18 -0
  45. package/dist/src/chatgpt-llm.d.ts.map +1 -0
  46. package/dist/src/chatgpt-llm.js +157 -0
  47. package/dist/src/chatgpt-llm.js.map +1 -0
  48. package/dist/src/consolidate.d.ts +3 -3
  49. package/dist/src/consolidate.d.ts.map +1 -1
  50. package/dist/src/consolidate.js +30 -2
  51. package/dist/src/consolidate.js.map +1 -1
  52. package/dist/src/controller.d.ts +7 -1
  53. package/dist/src/controller.d.ts.map +1 -1
  54. package/dist/src/controller.js +88 -14
  55. package/dist/src/controller.js.map +1 -1
  56. package/dist/src/db.d.ts +8 -7
  57. package/dist/src/db.d.ts.map +1 -1
  58. package/dist/src/db.js +127 -46
  59. package/dist/src/db.js.map +1 -1
  60. package/dist/src/decay.d.ts +2 -2
  61. package/dist/src/decay.d.ts.map +1 -1
  62. package/dist/src/decay.js.map +1 -1
  63. package/dist/src/embedding.d.ts.map +1 -1
  64. package/dist/src/embedding.js +19 -3
  65. package/dist/src/embedding.js.map +1 -1
  66. package/dist/src/encode.d.ts +16 -2
  67. package/dist/src/encode.d.ts.map +1 -1
  68. package/dist/src/encode.js +56 -17
  69. package/dist/src/encode.js.map +1 -1
  70. package/dist/src/events.d.ts +24 -7
  71. package/dist/src/events.d.ts.map +1 -1
  72. package/dist/src/events.js +130 -3
  73. package/dist/src/events.js.map +1 -1
  74. package/dist/src/export.d.ts +3 -2
  75. package/dist/src/export.d.ts.map +1 -1
  76. package/dist/src/export.js +9 -2
  77. package/dist/src/export.js.map +1 -1
  78. package/dist/src/feedback.d.ts +2 -2
  79. package/dist/src/feedback.d.ts.map +1 -1
  80. package/dist/src/feedback.js.map +1 -1
  81. package/dist/src/forget.d.ts +5 -5
  82. package/dist/src/forget.d.ts.map +1 -1
  83. package/dist/src/forget.js +6 -2
  84. package/dist/src/forget.js.map +1 -1
  85. package/dist/src/fts.d.ts +13 -13
  86. package/dist/src/fts.d.ts.map +1 -1
  87. package/dist/src/fts.js +4 -4
  88. package/dist/src/fts.js.map +1 -1
  89. package/dist/src/grounding.d.ts +5 -5
  90. package/dist/src/grounding.d.ts.map +1 -1
  91. package/dist/src/grounding.js +24 -11
  92. package/dist/src/grounding.js.map +1 -1
  93. package/dist/src/hybrid-recall.d.ts +7 -10
  94. package/dist/src/hybrid-recall.d.ts.map +1 -1
  95. package/dist/src/hybrid-recall.js +25 -57
  96. package/dist/src/hybrid-recall.js.map +1 -1
  97. package/dist/src/impact.d.ts +2 -2
  98. package/dist/src/impact.d.ts.map +1 -1
  99. package/dist/src/impact.js.map +1 -1
  100. package/dist/src/import.d.ts +42 -5
  101. package/dist/src/import.d.ts.map +1 -1
  102. package/dist/src/import.js +101 -42
  103. package/dist/src/import.js.map +1 -1
  104. package/dist/src/index.d.ts +3 -0
  105. package/dist/src/index.d.ts.map +1 -1
  106. package/dist/src/index.js +2 -0
  107. package/dist/src/index.js.map +1 -1
  108. package/dist/src/interference.d.ts +2 -2
  109. package/dist/src/interference.d.ts.map +1 -1
  110. package/dist/src/interference.js.map +1 -1
  111. package/dist/src/introspect.d.ts +2 -2
  112. package/dist/src/introspect.d.ts.map +1 -1
  113. package/dist/src/introspect.js.map +1 -1
  114. package/dist/src/llm-shared.d.ts +10 -0
  115. package/dist/src/llm-shared.d.ts.map +1 -0
  116. package/dist/src/llm-shared.js +20 -0
  117. package/dist/src/llm-shared.js.map +1 -0
  118. package/dist/src/llm.d.ts +2 -1
  119. package/dist/src/llm.d.ts.map +1 -1
  120. package/dist/src/llm.js +13 -22
  121. package/dist/src/llm.js.map +1 -1
  122. package/dist/src/migrate.d.ts +2 -2
  123. package/dist/src/migrate.d.ts.map +1 -1
  124. package/dist/src/migrate.js +34 -40
  125. package/dist/src/migrate.js.map +1 -1
  126. package/dist/src/preflight.d.ts +2 -0
  127. package/dist/src/preflight.d.ts.map +1 -1
  128. package/dist/src/preflight.js +5 -1
  129. package/dist/src/preflight.js.map +1 -1
  130. package/dist/src/project-memory.d.ts +14 -0
  131. package/dist/src/project-memory.d.ts.map +1 -0
  132. package/dist/src/project-memory.js +135 -0
  133. package/dist/src/project-memory.js.map +1 -0
  134. package/dist/src/project.d.ts +2 -0
  135. package/dist/src/project.d.ts.map +1 -1
  136. package/dist/src/project.js +12 -0
  137. package/dist/src/project.js.map +1 -1
  138. package/dist/src/promote.d.ts +2 -2
  139. package/dist/src/promote.d.ts.map +1 -1
  140. package/dist/src/promote.js +2 -26
  141. package/dist/src/promote.js.map +1 -1
  142. package/dist/src/recall-scoring.d.ts +14 -0
  143. package/dist/src/recall-scoring.d.ts.map +1 -0
  144. package/dist/src/recall-scoring.js +167 -0
  145. package/dist/src/recall-scoring.js.map +1 -0
  146. package/dist/src/recall.d.ts +3 -3
  147. package/dist/src/recall.d.ts.map +1 -1
  148. package/dist/src/recall.js +31 -168
  149. package/dist/src/recall.js.map +1 -1
  150. package/dist/src/rollback.d.ts +15 -3
  151. package/dist/src/rollback.d.ts.map +1 -1
  152. package/dist/src/rollback.js +118 -25
  153. package/dist/src/rollback.js.map +1 -1
  154. package/dist/src/routes.d.ts.map +1 -1
  155. package/dist/src/routes.js +102 -33
  156. package/dist/src/routes.js.map +1 -1
  157. package/dist/src/rules-compiler.d.ts +4 -0
  158. package/dist/src/rules-compiler.d.ts.map +1 -1
  159. package/dist/src/rules-compiler.js +34 -2
  160. package/dist/src/rules-compiler.js.map +1 -1
  161. package/dist/src/server.d.ts +2 -0
  162. package/dist/src/server.d.ts.map +1 -1
  163. package/dist/src/server.js +96 -38
  164. package/dist/src/server.js.map +1 -1
  165. package/dist/src/shell-command.d.ts.map +1 -1
  166. package/dist/src/shell-command.js +24 -7
  167. package/dist/src/shell-command.js.map +1 -1
  168. package/dist/src/sqlite.d.ts +31 -0
  169. package/dist/src/sqlite.d.ts.map +1 -0
  170. package/dist/src/sqlite.js +113 -0
  171. package/dist/src/sqlite.js.map +1 -0
  172. package/dist/src/success-checks.d.ts +41 -0
  173. package/dist/src/success-checks.d.ts.map +1 -0
  174. package/dist/src/success-checks.js +164 -0
  175. package/dist/src/success-checks.js.map +1 -0
  176. package/dist/src/tool-trace.d.ts +2 -2
  177. package/dist/src/tool-trace.d.ts.map +1 -1
  178. package/dist/src/tool-trace.js.map +1 -1
  179. package/dist/src/types.d.ts +8 -2
  180. package/dist/src/types.d.ts.map +1 -1
  181. package/dist/src/validate.d.ts +4 -4
  182. package/dist/src/validate.d.ts.map +1 -1
  183. package/dist/src/validate.js +23 -9
  184. package/dist/src/validate.js.map +1 -1
  185. package/package.json +12 -23
@@ -1,8 +1,9 @@
1
1
  import { EventEmitter } from 'node:events';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { createDatabase, closeDatabase, liveRowClause, refreshVectorRow } from './db.js';
3
4
  import { createEmbeddingProvider } from './embedding.js';
4
5
  import { createLLMProvider } from './llm.js';
5
- import { encodeEpisode } from './encode.js';
6
+ import { encodeEpisode, assertEncodeInput } from './encode.js';
6
7
  import { redact } from './redact.js';
7
8
  import { recall as recallFn, recallStream as recallStreamFn } from './recall.js';
8
9
  import { validateMemory } from './validate.js';
@@ -27,15 +28,15 @@ import { buildPreflight } from './preflight.js';
27
28
  import { buildReflexReport } from './reflexes.js';
28
29
  import { beforeAction as guardBeforeAction, afterAction as guardAfterAction, } from './controller.js';
29
30
  import { findPromotionCandidates, } from './promote.js';
30
- import { renderAllRules } from './rules-compiler.js';
31
- import { insertEvent } from './events.js';
31
+ import { renderAllRules, legacyClaudeRuleRelativePath, claudeRuleHasProvenance, } from './rules-compiler.js';
32
+ import { insertEvent, recordPromotion } from './events.js';
32
33
  import { requireAgent, resolveMemoryScope, isContainedIn } from './utils.js';
33
- import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
34
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, lstatSync, openSync, closeSync, fsyncSync, linkSync, rmSync, } from 'node:fs';
34
35
  import { dirname, join, resolve as pathResolve, delimiter as pathDelimiter } from 'node:path';
35
36
  import { ProfileRecorder } from './profile.js';
36
37
  import { performance } from 'node:perf_hooks';
37
38
  import { recordAnchors, verifyAnchors, } from './grounding.js';
38
- import { projectRoot } from './project.js';
39
+ import { checkoutRoot } from './project.js';
39
40
  /**
40
41
  * Thrown by `Audrey.validate()` when a structural lineage check fails. Carries a
41
42
  * stable `code` so HTTP and MCP callers can branch on the failure type without
@@ -52,16 +53,31 @@ export class ValidateLineageError extends Error {
52
53
  function roundMs(value) {
53
54
  return Math.round(value * 1000) / 1000;
54
55
  }
56
+ /** Create and fill a file only if it does not exist; false when it already does. */
57
+ function writeExclusively(path, body) {
58
+ let fd;
59
+ try {
60
+ fd = openSync(path, 'wx', 0o600);
61
+ }
62
+ catch (error) {
63
+ if (error.code === 'EEXIST')
64
+ return false;
65
+ throw error;
66
+ }
67
+ try {
68
+ writeFileSync(fd, body, 'utf8');
69
+ fsyncSync(fd);
70
+ }
71
+ catch (error) {
72
+ closeSync(fd);
73
+ rmSync(path, { force: true });
74
+ throw error;
75
+ }
76
+ closeSync(fd);
77
+ return true;
78
+ }
55
79
  function validateEncodeParams(params) {
56
- if (!params.content || typeof params.content !== 'string') {
57
- throw new Error('content must be a non-empty string');
58
- }
59
- if (params.salience !== undefined && (params.salience < 0 || params.salience > 1)) {
60
- throw new Error('salience must be between 0 and 1');
61
- }
62
- if (params.tags !== undefined && !Array.isArray(params.tags)) {
63
- throw new Error('tags must be an array');
64
- }
80
+ assertEncodeInput(params);
65
81
  }
66
82
  const TRUTH_RESOLUTIONS = new Set([
67
83
  'a_wins',
@@ -188,6 +204,7 @@ export class Audrey extends EventEmitter {
188
204
  defaultRetrievalMode;
189
205
  autoReflect;
190
206
  _migrationPending;
207
+ _migrationPromise = null;
191
208
  _autoConsolidateTimer;
192
209
  _closed;
193
210
  _postEncodeQueue;
@@ -197,7 +214,7 @@ export class Audrey extends EventEmitter {
197
214
  _warmupDurationMs;
198
215
  _lastRecallCheckAt;
199
216
  _lastRecallErrors;
200
- constructor({ dataDir = './audrey-data', agent = 'default', embedding = { provider: 'mock', dimensions: 64 }, llm, confidence = {}, consolidation = {}, decay = {}, interference = {}, context = {}, affect = {}, autoReflect = false, } = {}) {
217
+ constructor({ dataDir = './audrey-data', sharedStore, agent = 'default', embedding = { provider: 'mock', dimensions: 64 }, llm, confidence = {}, consolidation = {}, decay = {}, interference = {}, context = {}, affect = {}, autoReflect = false, } = {}) {
201
218
  super();
202
219
  const dormantThreshold = decay.dormantThreshold ?? 0.1;
203
220
  if (dormantThreshold < 0 || dormantThreshold > 1) {
@@ -212,6 +229,7 @@ export class Audrey extends EventEmitter {
212
229
  this.embeddingProvider = createEmbeddingProvider(embedding);
213
230
  const { db, migrated } = createDatabase(dataDir, {
214
231
  dimensions: this.embeddingProvider.dimensions,
232
+ sharedStore,
215
233
  });
216
234
  this.db = db;
217
235
  this._migrationPending = migrated;
@@ -276,9 +294,15 @@ export class Audrey extends EventEmitter {
276
294
  async _ensureMigrated() {
277
295
  if (!this._migrationPending)
278
296
  return;
279
- const counts = await reembedAll(this.db, this.embeddingProvider);
280
- this._migrationPending = false;
281
- this.emit('migration', counts);
297
+ this._migrationPromise ??= reembedAll(this.db, this.embeddingProvider)
298
+ .then(counts => {
299
+ this._migrationPending = false;
300
+ this.emit('migration', counts);
301
+ })
302
+ .finally(() => {
303
+ this._migrationPromise = null;
304
+ });
305
+ await this._migrationPromise;
282
306
  }
283
307
  startEmbeddingWarmup(text = 'warmup') {
284
308
  if (this._embeddingWarm)
@@ -376,9 +400,9 @@ export class Audrey extends EventEmitter {
376
400
  recordAnchors(this.db, {
377
401
  memoryId: id,
378
402
  memoryType: 'episodic',
379
- agent: this.agent,
403
+ agent: requireAgent(params.agent, this.agent),
380
404
  content: params.content,
381
- projectRoot: projectRoot(cwd),
405
+ projectRoot: checkoutRoot(cwd),
382
406
  });
383
407
  });
384
408
  await this._runPostEncodeStage('validation', async () => {
@@ -517,7 +541,7 @@ export class Audrey extends EventEmitter {
517
541
  const encodedEmbedding = { vector: encodedVector, buffer: encodedBuffer };
518
542
  if (redactionSummary?.redacted)
519
543
  this.emit('redaction', { id, ...redactionSummary });
520
- this.emit('encode', { id, ...params });
544
+ this.emit('encode', { id, ...params, ...sanitizedFields });
521
545
  // The background pipeline (grounding, validation, the contradiction
522
546
  // prompt sent to the cloud LLM) must only ever see what the row stores.
523
547
  // Handing it the raw params would leak a secret redaction just scrubbed.
@@ -535,10 +559,14 @@ export class Audrey extends EventEmitter {
535
559
  }
536
560
  return id;
537
561
  }
538
- async reflect(turns) {
562
+ async reflect(turns, options = {}) {
563
+ const agent = requireAgent(options.agent, this.agent);
539
564
  if (!this.llmProvider)
540
565
  return { encoded: 0, memories: [], skipped: 'no llm provider' };
541
- const prompt = buildReflectionPrompt(turns);
566
+ const prompt = buildReflectionPrompt(turns.map(turn => ({
567
+ role: turn.role,
568
+ content: redact(turn.content).text,
569
+ })));
542
570
  let raw;
543
571
  try {
544
572
  if (typeof this.llmProvider.complete === 'function') {
@@ -573,6 +601,7 @@ export class Audrey extends EventEmitter {
573
601
  try {
574
602
  await this.encode({
575
603
  content: mem.content,
604
+ agent,
576
605
  source: mem.source,
577
606
  salience: mem.salience,
578
607
  tags: mem.tags,
@@ -629,7 +658,7 @@ export class Audrey extends EventEmitter {
629
658
  ids.push(id);
630
659
  if (redactionSummary?.redacted)
631
660
  this.emit('redaction', { id, ...redactionSummary });
632
- this.emit('encode', { id, ...paramsList[i] });
661
+ this.emit('encode', { id, ...paramsList[i], ...sanitizedFields });
633
662
  const encodedEmbedding = { vector: encodedVector, buffer: encodedBuffer };
634
663
  // Same boundary as _encodeInternal: background stages see stored values only.
635
664
  const postEncodeParams = sanitizedFields
@@ -774,9 +803,11 @@ export class Audrey extends EventEmitter {
774
803
  this.db
775
804
  .prepare("UPDATE semantics SET state = 'context_dependent', conditions = ? WHERE id = ?")
776
805
  .run(conditions, id);
806
+ refreshVectorRow(this.db, 'semantics', id);
777
807
  }
778
808
  else {
779
809
  this.db.prepare('UPDATE episodes SET superseded_by = NULL WHERE id = ?').run(id);
810
+ refreshVectorRow(this.db, 'episodes', id);
780
811
  }
781
812
  };
782
813
  if (result.resolution === 'a_wins') {
@@ -897,13 +928,13 @@ export class Audrey extends EventEmitter {
897
928
  const agentClause = memoryScope === 'agent' ? 'AND agent = ?' : '';
898
929
  const agentParam = memoryScope === 'agent' ? [memoryAgent] : [];
899
930
  const recent = this.db
900
- .prepare(`SELECT id, content, source, tags, salience, created_at FROM episodes WHERE "private" = 0 ${agentClause} ORDER BY created_at DESC LIMIT ?`)
931
+ .prepare(`SELECT id, content, source, tags, salience, created_at FROM episodes WHERE "private" = 0 AND superseded_by IS NULL ${agentClause} ORDER BY created_at DESC LIMIT ?`)
901
932
  .all(...agentParam, recentLimit);
902
933
  const principles = this.db
903
934
  .prepare(`SELECT id, content, salience, created_at FROM semantics WHERE state = ? ${agentClause} ORDER BY salience DESC LIMIT ?`)
904
935
  .all('active', ...agentParam, principleLimit);
905
936
  const identity = this.db
906
- .prepare(`SELECT id, content, tags, salience, created_at FROM episodes WHERE "private" = 1 ${agentClause} ORDER BY created_at DESC LIMIT ?`)
937
+ .prepare(`SELECT id, content, tags, salience, created_at FROM episodes WHERE "private" = 1 AND superseded_by IS NULL ${agentClause} ORDER BY created_at DESC LIMIT ?`)
907
938
  .all(...agentParam, identityLimit);
908
939
  const unresolved = this.db
909
940
  .prepare(`SELECT id, content, tags, salience, created_at
@@ -914,12 +945,13 @@ export class Audrey extends EventEmitter {
914
945
  WHERE json_each.type = 'text' AND json_each.value = 'unresolved'
915
946
  )
916
947
  AND salience > 0.3
948
+ AND superseded_by IS NULL
917
949
  ${agentClause}
918
950
  ORDER BY created_at DESC
919
951
  LIMIT 10`)
920
952
  .all(...agentParam);
921
953
  const rawAffectRows = this.db
922
- .prepare(`SELECT affect FROM episodes WHERE affect IS NOT NULL AND affect != '{}' ${agentClause} ORDER BY created_at DESC LIMIT 20`)
954
+ .prepare(`SELECT affect FROM episodes WHERE affect IS NOT NULL AND affect != '{}' AND superseded_by IS NULL ${agentClause} ORDER BY created_at DESC LIMIT 20`)
923
955
  .all(...agentParam);
924
956
  const affectParsed = rawAffectRows
925
957
  .map(r => {
@@ -1005,7 +1037,7 @@ export class Audrey extends EventEmitter {
1005
1037
  suggestConsolidationParams() {
1006
1038
  return suggestParamsFn(this.db);
1007
1039
  }
1008
- validate(input) {
1040
+ validate(input, deferredNotifications) {
1009
1041
  input = { ...input, agent: requireAgent(input.agent, this.agent) };
1010
1042
  let preflightMetadata = null;
1011
1043
  if (input.preflightEventId) {
@@ -1073,7 +1105,13 @@ export class Audrey extends EventEmitter {
1073
1105
  ...(input.evidenceIds ? { preflight_evidence_ids: input.evidenceIds } : {}),
1074
1106
  },
1075
1107
  });
1076
- this.emit('validate', result);
1108
+ const notify = () => {
1109
+ this.emit('validate', result);
1110
+ };
1111
+ if (deferredNotifications)
1112
+ deferredNotifications.push(notify);
1113
+ else
1114
+ notify();
1077
1115
  }
1078
1116
  return result;
1079
1117
  }
@@ -1134,12 +1172,18 @@ export class Audrey extends EventEmitter {
1134
1172
  async waitForIdle() {
1135
1173
  await this._postEncodeQueue;
1136
1174
  }
1137
- observeTool(input) {
1175
+ observeTool(input, deferredNotifications) {
1138
1176
  const result = observeTool(this.db, {
1139
1177
  ...input,
1140
1178
  actorAgent: requireAgent(input.actorAgent, this.agent),
1141
1179
  });
1142
- this.emit('tool-observed', result.event);
1180
+ const notify = () => {
1181
+ this.emit('tool-observed', result.event);
1182
+ };
1183
+ if (deferredNotifications)
1184
+ deferredNotifications.push(notify);
1185
+ else
1186
+ notify();
1143
1187
  return result;
1144
1188
  }
1145
1189
  listEvents(query = {}) {
@@ -1228,45 +1272,154 @@ export class Audrey extends EventEmitter {
1228
1272
  }
1229
1273
  const promotedAt = new Date().toISOString();
1230
1274
  const docs = renderAllRules(candidates, promotedAt);
1275
+ const resolvedPaths = docs.map(doc => doc.relativePath);
1231
1276
  const applied = [];
1232
1277
  if (!dryRun) {
1233
1278
  for (let i = 0; i < candidates.length; i++) {
1234
1279
  const candidate = candidates[i];
1235
1280
  const doc = docs[i];
1236
1281
  const absolutePath = join(projectDir, doc.relativePath);
1282
+ if (!isContainedIn(projectDir, absolutePath)) {
1283
+ throw new Error('promote: refusing to write a rule outside the project through a symlink');
1284
+ }
1237
1285
  mkdirSync(dirname(absolutePath), { recursive: true });
1238
- const overwritten = existsSync(absolutePath);
1239
- writeFileSync(absolutePath, doc.body, 'utf-8');
1240
- insertEvent(this.db, {
1241
- eventType: 'Promotion',
1242
- source: 'promote-command',
1286
+ if (!isContainedIn(projectDir, absolutePath)) {
1287
+ throw new Error('promote: rule destination changed outside the project');
1288
+ }
1289
+ const promotion = {
1290
+ target,
1243
1291
  actorAgent: this.agent,
1244
- toolName: target,
1245
- outcome: 'succeeded',
1246
- cwd: projectDir,
1247
- fileFingerprints: [doc.relativePath],
1248
- redactionState: 'clean',
1249
- metadata: {
1250
- memory_ids: [candidate.memory_id],
1251
- memory_type: candidate.memory_type,
1252
- candidate_id: candidate.candidate_id,
1253
- confidence: Number(candidate.confidence.toFixed(3)),
1254
- evidence_count: candidate.evidence_count,
1255
- failure_prevented: candidate.failure_prevented,
1256
- score: Number(candidate.score.toFixed(2)),
1257
- target,
1258
- absolute_path: absolutePath,
1259
- relative_path: doc.relativePath,
1260
- overwritten,
1261
- },
1262
- });
1292
+ memoryIds: [candidate.memory_id],
1293
+ promotedAt,
1294
+ };
1295
+ const preserveExistingRule = (relativePath, mustMatch) => {
1296
+ const existingPath = join(projectDir, relativePath);
1297
+ if (!isContainedIn(projectDir, existingPath)) {
1298
+ throw new Error('promote: refusing to inspect a rule outside the project');
1299
+ }
1300
+ try {
1301
+ const existing = lstatSync(existingPath);
1302
+ if (existing.isSymbolicLink() || !existing.isFile()) {
1303
+ throw new Error('promote: refusing to replace a non-file rule destination');
1304
+ }
1305
+ }
1306
+ catch (error) {
1307
+ if (error.code === 'ENOENT')
1308
+ return false;
1309
+ throw error;
1310
+ }
1311
+ if (!claudeRuleHasProvenance(readFileSync(existingPath, 'utf8'), candidate)) {
1312
+ if (mustMatch) {
1313
+ throw new Error('promote: refusing to overwrite an existing rule with different provenance');
1314
+ }
1315
+ return false;
1316
+ }
1317
+ recordPromotion(this.db, {
1318
+ ...promotion,
1319
+ relativePath,
1320
+ absolutePath: existingPath,
1321
+ });
1322
+ resolvedPaths[i] = relativePath;
1323
+ return true;
1324
+ };
1325
+ if (preserveExistingRule(doc.relativePath, true))
1326
+ continue;
1327
+ const legacyPath = legacyClaudeRuleRelativePath(candidate);
1328
+ const legacyDirectory = legacyPath.slice(0, legacyPath.lastIndexOf('/') + 1);
1329
+ const legacyName = legacyPath.slice(legacyDirectory.length);
1330
+ const legacyStem = legacyName.slice(0, -3);
1331
+ // Older renderAllRules disambiguated colliding truncated IDs with
1332
+ // numbered suffixes. Inspect actual files, rather than guessing how
1333
+ // many suffixes were once written, and require the complete provenance.
1334
+ const legacyNames = readdirSync(dirname(absolutePath), { withFileTypes: true })
1335
+ .filter(entry => entry.isFile())
1336
+ .map(entry => entry.name)
1337
+ .filter(name => name === legacyName ||
1338
+ (name.startsWith(`${legacyStem}-`) &&
1339
+ /^[1-9][0-9]*\.md$/.test(name.slice(legacyStem.length + 1))));
1340
+ if (legacyNames.some(name => preserveExistingRule(legacyDirectory + name, false)))
1341
+ continue;
1342
+ // Publish a complete file only if its destination is still absent.
1343
+ // A racing promotion must never overwrite an existing, edited rule.
1344
+ const temporary = `${absolutePath}.${randomUUID()}.tmp`;
1345
+ try {
1346
+ const fd = openSync(temporary, 'wx', 0o600);
1347
+ try {
1348
+ writeFileSync(fd, doc.body, 'utf8');
1349
+ fsyncSync(fd);
1350
+ }
1351
+ finally {
1352
+ closeSync(fd);
1353
+ }
1354
+ if (!isContainedIn(projectDir, dirname(absolutePath))) {
1355
+ throw new Error('promote: rule directory changed outside the project');
1356
+ }
1357
+ try {
1358
+ linkSync(temporary, absolutePath);
1359
+ }
1360
+ catch (error) {
1361
+ const code = error.code;
1362
+ if (code === 'EEXIST') {
1363
+ if (preserveExistingRule(doc.relativePath, true))
1364
+ continue;
1365
+ throw error;
1366
+ }
1367
+ // Filesystems without hard links (exFAT, some network and container
1368
+ // mounts) keep the no-overwrite guarantee through an exclusive create.
1369
+ if (code !== 'EPERM' && code !== 'ENOTSUP' && code !== 'EXDEV' && code !== 'ENOSYS') {
1370
+ throw error;
1371
+ }
1372
+ if (!writeExclusively(absolutePath, doc.body)) {
1373
+ if (preserveExistingRule(doc.relativePath, true))
1374
+ continue;
1375
+ throw new Error(`promote: rule destination already exists: ${doc.relativePath}`, {
1376
+ cause: error,
1377
+ });
1378
+ }
1379
+ }
1380
+ }
1381
+ finally {
1382
+ rmSync(temporary, { force: true });
1383
+ }
1384
+ this.db
1385
+ .transaction(() => {
1386
+ recordPromotion(this.db, {
1387
+ ...promotion,
1388
+ relativePath: doc.relativePath,
1389
+ absolutePath,
1390
+ });
1391
+ insertEvent(this.db, {
1392
+ eventType: 'Promotion',
1393
+ source: 'promote-command',
1394
+ actorAgent: this.agent,
1395
+ toolName: target,
1396
+ outcome: 'succeeded',
1397
+ cwd: projectDir,
1398
+ fileFingerprints: [doc.relativePath],
1399
+ redactionState: 'clean',
1400
+ metadata: {
1401
+ memory_ids: [candidate.memory_id],
1402
+ memory_type: candidate.memory_type,
1403
+ candidate_id: candidate.candidate_id,
1404
+ confidence: Number(candidate.confidence.toFixed(3)),
1405
+ evidence_count: candidate.evidence_count,
1406
+ failure_prevented: candidate.failure_prevented,
1407
+ score: Number(candidate.score.toFixed(2)),
1408
+ target,
1409
+ absolute_path: absolutePath,
1410
+ relative_path: doc.relativePath,
1411
+ overwritten: false,
1412
+ },
1413
+ });
1414
+ })
1415
+ .immediate();
1263
1416
  applied.push({
1264
1417
  candidate_id: candidate.candidate_id,
1265
1418
  memory_id: candidate.memory_id,
1266
1419
  target,
1267
1420
  relative_path: doc.relativePath,
1268
1421
  absolute_path: absolutePath,
1269
- overwritten,
1422
+ overwritten: false,
1270
1423
  });
1271
1424
  }
1272
1425
  }
@@ -1277,7 +1430,7 @@ export class Audrey extends EventEmitter {
1277
1430
  promoted_at: promotedAt,
1278
1431
  candidates: candidates.map((c, i) => ({
1279
1432
  ...c,
1280
- rendered_path: docs[i].relativePath,
1433
+ rendered_path: resolvedPaths[i],
1281
1434
  })),
1282
1435
  applied,
1283
1436
  };