byterover-cli 3.6.1 → 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/core/interfaces/cipher-services.d.ts +7 -0
  3. package/dist/agent/infra/agent/cipher-agent.js +4 -2
  4. package/dist/agent/infra/agent/service-initializer.js +28 -14
  5. package/dist/agent/infra/sandbox/curate-service.d.ts +4 -2
  6. package/dist/agent/infra/sandbox/curate-service.js +6 -4
  7. package/dist/agent/infra/tools/implementations/curate-tool.d.ts +4 -2
  8. package/dist/agent/infra/tools/implementations/curate-tool.js +169 -25
  9. package/dist/agent/infra/tools/implementations/expand-knowledge-tool.d.ts +2 -0
  10. package/dist/agent/infra/tools/implementations/expand-knowledge-tool.js +1 -1
  11. package/dist/agent/infra/tools/implementations/memory-symbol-tree.d.ts +0 -8
  12. package/dist/agent/infra/tools/implementations/memory-symbol-tree.js +3 -15
  13. package/dist/agent/infra/tools/implementations/search-knowledge-service.d.ts +49 -4
  14. package/dist/agent/infra/tools/implementations/search-knowledge-service.js +123 -53
  15. package/dist/agent/infra/tools/implementations/search-knowledge-tool.d.ts +2 -0
  16. package/dist/agent/infra/tools/tool-provider.js +1 -0
  17. package/dist/agent/infra/tools/tool-registry.d.ts +7 -0
  18. package/dist/agent/infra/tools/tool-registry.js +13 -6
  19. package/dist/oclif/commands/dream.d.ts +4 -0
  20. package/dist/oclif/commands/dream.js +31 -13
  21. package/dist/server/constants.d.ts +1 -1
  22. package/dist/server/constants.js +20 -1
  23. package/dist/server/core/domain/knowledge/markdown-writer.d.ts +12 -42
  24. package/dist/server/core/domain/knowledge/markdown-writer.js +55 -96
  25. package/dist/server/core/domain/knowledge/memory-scoring.d.ts +18 -37
  26. package/dist/server/core/domain/knowledge/memory-scoring.js +36 -85
  27. package/dist/server/core/domain/knowledge/runtime-signals-schema.d.ts +59 -0
  28. package/dist/server/core/domain/knowledge/runtime-signals-schema.js +46 -0
  29. package/dist/server/core/domain/knowledge/sidecar-logging.d.ts +14 -0
  30. package/dist/server/core/domain/knowledge/sidecar-logging.js +18 -0
  31. package/dist/server/core/interfaces/storage/i-runtime-signal-store.d.ts +111 -0
  32. package/dist/server/core/interfaces/storage/i-runtime-signal-store.js +38 -0
  33. package/dist/server/infra/context-tree/file-context-tree-archive-service.d.ts +16 -6
  34. package/dist/server/infra/context-tree/file-context-tree-archive-service.js +91 -32
  35. package/dist/server/infra/context-tree/file-context-tree-manifest-service.d.ts +14 -0
  36. package/dist/server/infra/context-tree/file-context-tree-manifest-service.js +20 -7
  37. package/dist/server/infra/context-tree/runtime-signal-store.d.ts +46 -0
  38. package/dist/server/infra/context-tree/runtime-signal-store.js +118 -0
  39. package/dist/server/infra/daemon/agent-process.js +25 -4
  40. package/dist/server/infra/dream/operations/consolidate.d.ts +17 -0
  41. package/dist/server/infra/dream/operations/consolidate.js +40 -19
  42. package/dist/server/infra/dream/operations/prune.d.ts +18 -0
  43. package/dist/server/infra/dream/operations/prune.js +31 -20
  44. package/dist/server/infra/dream/operations/synthesize.d.ts +13 -0
  45. package/dist/server/infra/dream/operations/synthesize.js +15 -3
  46. package/dist/server/infra/executor/dream-executor.d.ts +8 -0
  47. package/dist/server/infra/executor/dream-executor.js +3 -0
  48. package/dist/server/templates/skill/SKILL.md +79 -22
  49. package/oclif.manifest.json +429 -429
  50. package/package.json +1 -1
@@ -0,0 +1,46 @@
1
+ /**
2
+ * IKeyStorage-backed implementation of {@link IRuntimeSignalStore}.
3
+ *
4
+ * Uses composite keys `["signals", ...pathSegments]`. Atomicity within a
5
+ * single process comes from `IKeyStorage.update`'s per-key RWLock; the
6
+ * interface docs cover the cross-process caveat.
7
+ */
8
+ import type { IKeyStorage } from '../../../agent/core/interfaces/i-key-storage.js';
9
+ import type { ILogger } from '../../../agent/core/interfaces/i-logger.js';
10
+ import type { IRuntimeSignalStore, RuntimeSignalsUpdater } from '../../core/interfaces/storage/i-runtime-signal-store.js';
11
+ import { type RuntimeSignals } from '../../core/domain/knowledge/runtime-signals-schema.js';
12
+ export declare class RuntimeSignalStore implements IRuntimeSignalStore {
13
+ private readonly keyStorage;
14
+ private readonly logger;
15
+ constructor(keyStorage: IKeyStorage, logger: ILogger);
16
+ batchUpdate(updates: Map<string, RuntimeSignalsUpdater>): Promise<void>;
17
+ delete(relPath: string): Promise<void>;
18
+ get(relPath: string): Promise<RuntimeSignals>;
19
+ getMany(relPaths: readonly string[]): Promise<Map<string, RuntimeSignals>>;
20
+ list(): Promise<Map<string, RuntimeSignals>>;
21
+ set(relPath: string, signals: RuntimeSignals): Promise<void>;
22
+ update(relPath: string, updater: RuntimeSignalsUpdater): Promise<RuntimeSignals>;
23
+ /**
24
+ * Reconstruct the relative path from a `["signals", ...segments]` key.
25
+ * Returns null for keys that do not belong to this store (defensive against
26
+ * the remote chance of namespace collisions during listing).
27
+ */
28
+ private relPathFromKey;
29
+ /**
30
+ * Encode a relative path into a composite storage key.
31
+ *
32
+ * FileKeyStorage rejects `/` inside segments, so each path component
33
+ * becomes its own segment. Empty components (from leading, trailing, or
34
+ * consecutive slashes) are dropped so the encoding is insensitive to
35
+ * path normalization variants.
36
+ */
37
+ private signalKey;
38
+ /**
39
+ * Coerce an unknown stored value into a valid RuntimeSignals record.
40
+ *
41
+ * Missing (`undefined`) yields defaults silently — the common fresh-path
42
+ * case. Corrupt stored data logs a warning and also falls back to
43
+ * defaults so callers never have to handle read errors inline.
44
+ */
45
+ private validateOrDefault;
46
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * IKeyStorage-backed implementation of {@link IRuntimeSignalStore}.
3
+ *
4
+ * Uses composite keys `["signals", ...pathSegments]`. Atomicity within a
5
+ * single process comes from `IKeyStorage.update`'s per-key RWLock; the
6
+ * interface docs cover the cross-process caveat.
7
+ */
8
+ import { createDefaultRuntimeSignals, RuntimeSignalsSchema, } from '../../core/domain/knowledge/runtime-signals-schema.js';
9
+ const SIGNALS_PREFIX = 'signals';
10
+ // The store does not enforce the importance ↔ maturity hysteresis —
11
+ // callers bumping importance must recompute maturity via `determineTier`
12
+ // in the same updater. Invariant upheld at every write site; see
13
+ // interface-level docs for the rationale. Orphan-entry cleanup is tracked
14
+ // in features/runtime-signals/backlog.md (`pruneOrphans`).
15
+ export class RuntimeSignalStore {
16
+ keyStorage;
17
+ logger;
18
+ constructor(keyStorage, logger) {
19
+ this.keyStorage = keyStorage;
20
+ this.logger = logger;
21
+ }
22
+ async batchUpdate(updates) {
23
+ // Different relPaths do not share a per-key lock, so parallel updates
24
+ // scale naturally. Each individual update remains atomic because
25
+ // IKeyStorage.update is atomic per key (within one process).
26
+ await Promise.all([...updates.entries()].map(([relPath, updater]) => this.update(relPath, updater)));
27
+ }
28
+ async delete(relPath) {
29
+ await this.keyStorage.delete(this.signalKey(relPath));
30
+ }
31
+ async get(relPath) {
32
+ const raw = await this.keyStorage.get(this.signalKey(relPath));
33
+ return this.validateOrDefault(raw, relPath);
34
+ }
35
+ async getMany(relPaths) {
36
+ // Only include paths that have a stored record. Callers distinguish
37
+ // missing via `.has(path)`; ergonomic default-on-miss via
38
+ // `map.get(path) ?? createDefaultRuntimeSignals()`.
39
+ const entries = await Promise.all(relPaths.map(async (relPath) => {
40
+ const raw = await this.keyStorage.get(this.signalKey(relPath));
41
+ if (raw === undefined)
42
+ return null;
43
+ const parsed = RuntimeSignalsSchema.safeParse(raw);
44
+ if (parsed.success) {
45
+ return [relPath, parsed.data];
46
+ }
47
+ this.logger.warn(`RuntimeSignalStore: discarding corrupt entry for ${relPath}: ${parsed.error.message}`);
48
+ return null;
49
+ }));
50
+ return new Map(entries.filter((entry) => entry !== null));
51
+ }
52
+ async list() {
53
+ const entries = await this.keyStorage.listWithValues([SIGNALS_PREFIX]);
54
+ const result = new Map();
55
+ for (const entry of entries) {
56
+ const relPath = this.relPathFromKey(entry.key);
57
+ if (relPath === null)
58
+ continue;
59
+ result.set(relPath, this.validateOrDefault(entry.value, relPath));
60
+ }
61
+ return result;
62
+ }
63
+ async set(relPath, signals) {
64
+ const validated = RuntimeSignalsSchema.parse(signals);
65
+ await this.keyStorage.set(this.signalKey(relPath), validated);
66
+ }
67
+ async update(relPath, updater) {
68
+ return this.keyStorage.update(this.signalKey(relPath), (current) => {
69
+ // `current` is typed as RuntimeSignals but the underlying value may be
70
+ // anything the disk held (missing, partial, corrupt). validateOrDefault
71
+ // coerces it into a valid record before the updater runs.
72
+ const base = this.validateOrDefault(current, relPath);
73
+ const merged = updater(base);
74
+ // Re-validate updater output so a buggy caller cannot land invalid
75
+ // data (e.g. importance out of range) on disk.
76
+ return RuntimeSignalsSchema.parse(merged);
77
+ });
78
+ }
79
+ /**
80
+ * Reconstruct the relative path from a `["signals", ...segments]` key.
81
+ * Returns null for keys that do not belong to this store (defensive against
82
+ * the remote chance of namespace collisions during listing).
83
+ */
84
+ relPathFromKey(key) {
85
+ if (key.length < 2 || key[0] !== SIGNALS_PREFIX)
86
+ return null;
87
+ return key.slice(1).join('/');
88
+ }
89
+ /**
90
+ * Encode a relative path into a composite storage key.
91
+ *
92
+ * FileKeyStorage rejects `/` inside segments, so each path component
93
+ * becomes its own segment. Empty components (from leading, trailing, or
94
+ * consecutive slashes) are dropped so the encoding is insensitive to
95
+ * path normalization variants.
96
+ */
97
+ signalKey(relPath) {
98
+ const segments = relPath.split('/').filter((s) => s.length > 0);
99
+ return [SIGNALS_PREFIX, ...segments];
100
+ }
101
+ /**
102
+ * Coerce an unknown stored value into a valid RuntimeSignals record.
103
+ *
104
+ * Missing (`undefined`) yields defaults silently — the common fresh-path
105
+ * case. Corrupt stored data logs a warning and also falls back to
106
+ * defaults so callers never have to handle read errors inline.
107
+ */
108
+ validateOrDefault(raw, relPath) {
109
+ if (raw === undefined) {
110
+ return createDefaultRuntimeSignals();
111
+ }
112
+ const parsed = RuntimeSignalsSchema.safeParse(raw);
113
+ if (parsed.success)
114
+ return parsed.data;
115
+ this.logger.warn(`RuntimeSignalStore: discarding corrupt entry for ${relPath}: ${parsed.error.message}`);
116
+ return createDefaultRuntimeSignals();
117
+ }
118
+ }
@@ -27,6 +27,7 @@ import { CipherAgent } from '../../../agent/infra/agent/index.js';
27
27
  import { FileSystemService } from '../../../agent/infra/file-system/file-system-service.js';
28
28
  import { FolderPackService } from '../../../agent/infra/folder-pack/folder-pack-service.js';
29
29
  import { SessionMetadataStore } from '../../../agent/infra/session/session-metadata-store.js';
30
+ import { FileKeyStorage } from '../../../agent/infra/storage/file-key-storage.js';
30
31
  import { createSearchKnowledgeService } from '../../../agent/infra/tools/implementations/search-knowledge-service.js';
31
32
  import { AuthEvents } from '../../../shared/transport/events/auth-events.js';
32
33
  import { decodeSearchContent } from '../../../shared/transport/search-content.js';
@@ -36,6 +37,7 @@ import { serializeTaskError, TaskError, TaskErrorCode } from '../../core/domain/
36
37
  import { loadSources } from '../../core/domain/source/source-schema.js';
37
38
  import { TransportAgentEventNames, TransportDaemonEventNames, TransportStateEventNames, TransportTaskEventNames, } from '../../core/domain/transport/schemas.js';
38
39
  import { FileContextTreeArchiveService } from '../context-tree/file-context-tree-archive-service.js';
40
+ import { RuntimeSignalStore } from '../context-tree/runtime-signal-store.js';
39
41
  import { DreamLockService } from '../dream/dream-lock-service.js';
40
42
  import { DreamLogStore } from '../dream/dream-log-store.js';
41
43
  import { DreamStateService } from '../dream/dream-state-service.js';
@@ -300,7 +302,25 @@ async function start() {
300
302
  workingDirectory: projectPath,
301
303
  });
302
304
  await fileSystemService.initialize();
303
- const searchService = createSearchKnowledgeService(fileSystemService, { baseDirectory: projectPath });
305
+ // Runtime-signal sidecar for this daemon. FileKeyStorage is file-backed
306
+ // under configResult.storagePath, so the daemon and any other process for
307
+ // the same project write to the same on-disk store. `brv search`, curate,
308
+ // and archive in this daemon all mirror scoring writes through it.
309
+ const daemonKeyStorage = new FileKeyStorage({
310
+ storageDir: configResult.storagePath,
311
+ });
312
+ await daemonKeyStorage.initialize();
313
+ const daemonLogger = {
314
+ debug: (msg) => agentLog(msg),
315
+ error: (msg) => agentLog(msg),
316
+ info: (msg) => agentLog(msg),
317
+ warn: (msg) => agentLog(msg),
318
+ };
319
+ const daemonRuntimeSignalStore = new RuntimeSignalStore(daemonKeyStorage, daemonLogger);
320
+ const searchService = createSearchKnowledgeService(fileSystemService, {
321
+ baseDirectory: projectPath,
322
+ runtimeSignalStore: daemonRuntimeSignalStore,
323
+ });
304
324
  // 7. Create executors and listen for task:execute from pool
305
325
  const curateExecutor = new CurateExecutor();
306
326
  const folderPackService = new FolderPackService(fileSystemService);
@@ -316,7 +336,7 @@ async function start() {
316
336
  transport.on(TransportTaskEventNames.EXECUTE, (task) => {
317
337
  agentLog(`task:execute received taskId=${task.taskId} type=${task.type} activeTaskCount=${activeTaskCount + 1}`);
318
338
  // eslint-disable-next-line no-void
319
- void executeTask(task, curateExecutor, folderPackExecutor, queryExecutor, searchExecutor, searchService, configResult.storagePath);
339
+ void executeTask(task, curateExecutor, folderPackExecutor, queryExecutor, searchExecutor, searchService, configResult.storagePath, daemonRuntimeSignalStore);
320
340
  });
321
341
  // 8. Register with transport server (for TransportHandlers tracking)
322
342
  await transport.requestWithAck('agent:register', { projectPath });
@@ -324,7 +344,7 @@ async function start() {
324
344
  process.send?.({ clientId, type: 'ready' });
325
345
  agentLog('Ready — listening for tasks');
326
346
  }
327
- async function executeTask(task, curateExecutor, folderPackExecutor, queryExecutor, searchExecutor, searchKnowledgeService, storagePath) {
347
+ async function executeTask(task, curateExecutor, folderPackExecutor, queryExecutor, searchExecutor, searchKnowledgeService, storagePath, runtimeSignalStore) {
328
348
  const { clientCwd, clientId, content, files, folderPath, force, taskId, trigger, type, worktreeRoot } = task;
329
349
  if (!transport || !agent)
330
350
  return;
@@ -438,12 +458,13 @@ async function executeTask(task, curateExecutor, folderPackExecutor, queryExecut
438
458
  break;
439
459
  }
440
460
  const dreamExecutor = new DreamExecutor({
441
- archiveService: new FileContextTreeArchiveService(),
461
+ archiveService: new FileContextTreeArchiveService(runtimeSignalStore),
442
462
  curateLogStore: new FileCurateLogStore({ baseDir: storagePath }),
443
463
  dreamLockService,
444
464
  dreamLogStore: new DreamLogStore({ baseDir: brvDir }),
445
465
  dreamStateService,
446
466
  reviewBackupStore: new FileReviewBackupStore(brvDir),
467
+ runtimeSignalStore,
447
468
  searchService: searchKnowledgeService,
448
469
  });
449
470
  const dreamResult = await dreamExecutor.executeWithAgent(agent, {
@@ -11,6 +11,7 @@
11
11
  * Never throws — returns partial results on errors.
12
12
  */
13
13
  import type { ICipherAgent } from '../../../../agent/core/interfaces/i-cipher-agent.js';
14
+ import type { ILogger } from '../../../../agent/core/interfaces/i-logger.js';
14
15
  import type { DreamOperation } from '../dream-log-schema.js';
15
16
  import type { DreamState } from '../dream-state-schema.js';
16
17
  export type ConsolidateDeps = {
@@ -27,9 +28,25 @@ export type ConsolidateDeps = {
27
28
  update(updater: (state: DreamState) => DreamState): Promise<DreamState>;
28
29
  write(state: DreamState): Promise<void>;
29
30
  };
31
+ /**
32
+ * Optional logger. When provided, per-file sidecar failures during the
33
+ * CROSS_REFERENCE review gate emit a warn so silent swallows are visible.
34
+ */
35
+ logger?: ILogger;
30
36
  reviewBackupStore?: {
31
37
  save(relativePath: string, content: string): Promise<void>;
32
38
  };
39
+ /**
40
+ * Optional. When present, the CROSS_REFERENCE review-gate consults the
41
+ * sidecar to check whether any input file has `maturity === 'core'`. Absent
42
+ * store or missing entries mean no file qualifies as core — review is
43
+ * skipped, matching the pre-migration behaviour for paths without scoring.
44
+ */
45
+ runtimeSignalStore?: {
46
+ get(relPath: string): Promise<{
47
+ maturity: 'core' | 'draft' | 'validated';
48
+ }>;
49
+ };
33
50
  searchService: {
34
51
  search(query: string, options?: {
35
52
  limit?: number;
@@ -14,7 +14,7 @@ import { dump as yamlDump, load as yamlLoad } from 'js-yaml';
14
14
  import { randomUUID } from 'node:crypto';
15
15
  import { access, mkdir, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
16
16
  import { dirname, join } from 'node:path';
17
- import { parseFrontmatterScoring } from '../../../core/domain/knowledge/markdown-writer.js';
17
+ import { warnSidecarFailure } from '../../../core/domain/knowledge/sidecar-logging.js';
18
18
  import { ConsolidateResponseSchema } from '../dream-response-schemas.js';
19
19
  import { parseDreamResponse } from '../parse-dream-response.js';
20
20
  /**
@@ -148,7 +148,13 @@ async function processDomain(domain, files, deps, hints = []) {
148
148
  for (const action of parsed.actions) {
149
149
  try {
150
150
  // eslint-disable-next-line no-await-in-loop
151
- const op = await executeAction(action, contextTreeDir, fileContents, deps.reviewBackupStore);
151
+ const op = await executeAction(action, {
152
+ contextTreeDir,
153
+ fileContents,
154
+ logger: deps.logger,
155
+ reviewBackupStore: deps.reviewBackupStore,
156
+ runtimeSignalStore: deps.runtimeSignalStore,
157
+ });
152
158
  if (op)
153
159
  results.push(op);
154
160
  }
@@ -338,23 +344,24 @@ function buildPrompt(changedFiles, relatedFiles, filesPayload, pendingMergeHints
338
344
  lines.push('', 'File contents:', fileBlocks, '', 'For each pair/group of related files, classify the relationship and recommend an action:', '- MERGE: Files are redundant/overlapping → combine into one, specify outputFile and mergedContent', '- TEMPORAL_UPDATE: File has contradictory/outdated info → rewrite with temporal narrative, specify updatedContent', '- CROSS_REFERENCE: Files are complementary → add cross-references (no content changes needed)', '- SKIP: Files are genuinely unrelated → no action needed', '', 'Respond with JSON matching this schema:', '```', '{ "actions": [{ "type": "MERGE"|"TEMPORAL_UPDATE"|"CROSS_REFERENCE"|"SKIP", "files": ["path1", ...], "reason": "...", "confidence?": 0.0-1.0, "mergedContent?": "...", "outputFile?": "...", "updatedContent?": "..." }] }', '```', '', 'Rules:', '- Default to MERGE when files share >50% of content or cover the same topic. SKIP only when files are genuinely on unrelated topics.', '- Returning all SKIP when duplicates exist is a failure, not caution.', '- For MERGE, choose the richer/more complete file as outputFile. The mergedContent should preserve all unique details from both sources.', '- For TEMPORAL_UPDATE, preserve all facts and add temporal context. Include confidence (0-1) indicating certainty that the update is correct.', '- For CROSS_REFERENCE, just list the files — the system will add frontmatter links.', '- Preserve all diagrams, tables, code examples, and structured data verbatim.');
339
345
  return lines.join('\n');
340
346
  }
341
- async function executeAction(action, contextTreeDir, fileContents, reviewBackupStore) {
347
+ async function executeAction(action, ctx) {
342
348
  switch (action.type) {
343
349
  case 'CROSS_REFERENCE': {
344
- return executeCrossReference(action, contextTreeDir, fileContents, reviewBackupStore);
350
+ return executeCrossReference(action, ctx);
345
351
  }
346
352
  case 'MERGE': {
347
- return executeMerge(action, contextTreeDir, fileContents, reviewBackupStore);
353
+ return executeMerge(action, ctx);
348
354
  }
349
355
  case 'SKIP': {
350
356
  return undefined;
351
357
  }
352
358
  case 'TEMPORAL_UPDATE': {
353
- return executeTemporalUpdate(action, contextTreeDir, fileContents, reviewBackupStore);
359
+ return executeTemporalUpdate(action, ctx);
354
360
  }
355
361
  }
356
362
  }
357
- async function executeMerge(action, contextTreeDir, fileContents, reviewBackupStore) {
363
+ async function executeMerge(action, ctx) {
364
+ const { contextTreeDir, fileContents, reviewBackupStore, runtimeSignalStore } = ctx;
358
365
  const outputFile = action.outputFile ?? action.files[0];
359
366
  if (!action.mergedContent) {
360
367
  throw new Error(`MERGE action missing mergedContent for ${outputFile}`);
@@ -386,7 +393,7 @@ async function executeMerge(action, contextTreeDir, fileContents, reviewBackupSt
386
393
  const toDelete = action.files.filter((f) => f !== outputFile);
387
394
  await Promise.all(toDelete.map((f) => unlink(join(contextTreeDir, f)).catch(() => { })));
388
395
  // Determine needsReview
389
- const needsReview = determineNeedsReview('MERGE', action.files, fileContents);
396
+ const needsReview = await determineNeedsReview('MERGE', action.files, { runtimeSignalStore });
390
397
  return {
391
398
  action: 'MERGE',
392
399
  inputFiles: action.files,
@@ -397,7 +404,8 @@ async function executeMerge(action, contextTreeDir, fileContents, reviewBackupSt
397
404
  type: 'CONSOLIDATE',
398
405
  };
399
406
  }
400
- async function executeTemporalUpdate(action, contextTreeDir, fileContents, reviewBackupStore) {
407
+ async function executeTemporalUpdate(action, ctx) {
408
+ const { contextTreeDir, fileContents, reviewBackupStore, runtimeSignalStore } = ctx;
401
409
  const targetFile = action.files[0];
402
410
  if (!action.updatedContent) {
403
411
  throw new Error(`TEMPORAL_UPDATE action missing updatedContent for ${targetFile}`);
@@ -409,7 +417,10 @@ async function executeTemporalUpdate(action, contextTreeDir, fileContents, revie
409
417
  if (original !== undefined) {
410
418
  previousTexts[targetFile] = original;
411
419
  }
412
- const needsReview = determineNeedsReview('TEMPORAL_UPDATE', action.files, fileContents, action.confidence);
420
+ const needsReview = await determineNeedsReview('TEMPORAL_UPDATE', action.files, {
421
+ confidence: action.confidence,
422
+ runtimeSignalStore,
423
+ });
413
424
  // Create review backup only when the operation needs human review
414
425
  if (reviewBackupStore && original !== undefined && needsReview) {
415
426
  try {
@@ -432,7 +443,8 @@ async function executeTemporalUpdate(action, contextTreeDir, fileContents, revie
432
443
  type: 'CONSOLIDATE',
433
444
  };
434
445
  }
435
- async function executeCrossReference(action, contextTreeDir, fileContents, reviewBackupStore) {
446
+ async function executeCrossReference(action, ctx) {
447
+ const { contextTreeDir, fileContents, logger, reviewBackupStore, runtimeSignalStore } = ctx;
436
448
  const previousTexts = {};
437
449
  for (const file of action.files) {
438
450
  const content = fileContents.get(file);
@@ -440,7 +452,7 @@ async function executeCrossReference(action, contextTreeDir, fileContents, revie
440
452
  previousTexts[file] = content;
441
453
  }
442
454
  }
443
- const needsReview = determineNeedsReview('CROSS_REFERENCE', action.files, fileContents);
455
+ const needsReview = await determineNeedsReview('CROSS_REFERENCE', action.files, { logger, runtimeSignalStore });
444
456
  if (needsReview && reviewBackupStore) {
445
457
  await Promise.all(Object.entries(previousTexts).map(([file, content]) => reviewBackupStore.save(file, content).catch(() => { })));
446
458
  }
@@ -494,21 +506,30 @@ async function addRelatedLinks(filePath, relatedPaths) {
494
506
  const yaml = yamlDump({ related: relatedPaths }, { flowLevel: 1, lineWidth: -1, sortKeys: true }).trimEnd();
495
507
  await atomicWrite(filePath, `---\n${yaml}\n---\n${content}`);
496
508
  }
497
- function determineNeedsReview(actionType, files, fileContents, confidence) {
509
+ async function determineNeedsReview(actionType, files, opts) {
498
510
  // MERGE always needs review
499
511
  if (actionType === 'MERGE')
500
512
  return true;
501
513
  // TEMPORAL_UPDATE: needs review when confidence is low or absent
502
514
  if (actionType === 'TEMPORAL_UPDATE')
503
- return (confidence ?? 0) < 0.7;
504
- // CROSS_REFERENCE: only if any file has core maturity
515
+ return (opts.confidence ?? 0) < 0.7;
516
+ // CROSS_REFERENCE: only if any file has core maturity in the sidecar.
517
+ // Without a store, no file can qualify as core — review is skipped, which
518
+ // matches the pre-migration default when no scoring was present.
519
+ const { logger, runtimeSignalStore } = opts;
520
+ if (!runtimeSignalStore)
521
+ return false;
505
522
  for (const file of files) {
506
- const content = fileContents.get(file);
507
- if (content) {
508
- const scoring = parseFrontmatterScoring(content);
509
- if (scoring?.maturity === 'core')
523
+ try {
524
+ // eslint-disable-next-line no-await-in-loop
525
+ const signals = await runtimeSignalStore.get(file);
526
+ if (signals.maturity === 'core')
510
527
  return true;
511
528
  }
529
+ catch (error) {
530
+ // Ignore per-file sidecar failures — continue checking remaining files.
531
+ warnSidecarFailure(logger, 'consolidate', 'get', `${file} (CROSS_REFERENCE gate)`, error);
532
+ }
512
533
  }
513
534
  return false;
514
535
  }
@@ -12,6 +12,7 @@
12
12
  * Never throws — returns empty array on errors.
13
13
  */
14
14
  import type { ICipherAgent } from '../../../../agent/core/interfaces/i-cipher-agent.js';
15
+ import type { ILogger } from '../../../../agent/core/interfaces/i-logger.js';
15
16
  import type { DreamOperation } from '../dream-log-schema.js';
16
17
  import type { DreamState } from '../dream-state-schema.js';
17
18
  export type PruneDeps = {
@@ -31,10 +32,27 @@ export type PruneDeps = {
31
32
  update(updater: (state: DreamState) => DreamState): Promise<DreamState>;
32
33
  write(state: DreamState): Promise<void>;
33
34
  };
35
+ /**
36
+ * Optional logger. When provided, sidecar failures in the candidate scan
37
+ * emit a warn so the fail-open degradation is visible.
38
+ */
39
+ logger?: ILogger;
34
40
  projectRoot: string;
35
41
  reviewBackupStore?: {
36
42
  save(relativePath: string, content: string): Promise<void>;
37
43
  };
44
+ /**
45
+ * Runtime-signal sidecar. Source of truth for `importance` and `maturity`
46
+ * used in prune's candidacy decisions. Absent store or missing-per-path
47
+ * entries are treated as defaults (importance 50, maturity 'draft') —
48
+ * matches the plan's "paths without entries use defaults" principle.
49
+ */
50
+ runtimeSignalStore?: {
51
+ list(): Promise<Map<string, {
52
+ importance: number;
53
+ maturity: 'core' | 'draft' | 'validated';
54
+ }>>;
55
+ };
38
56
  signal?: AbortSignal;
39
57
  taskId: string;
40
58
  };
@@ -13,6 +13,8 @@
13
13
  */
14
14
  import { readdir, readFile, stat, utimes } from 'node:fs/promises';
15
15
  import { join } from 'node:path';
16
+ import { DEFAULT_IMPORTANCE, DEFAULT_MATURITY } from '../../../core/domain/knowledge/runtime-signals-schema.js';
17
+ import { warnSidecarFailure } from '../../../core/domain/knowledge/sidecar-logging.js';
16
18
  import { isExcludedFromSync } from '../../context-tree/derived-artifact.js';
17
19
  import { toUnixPath } from '../../context-tree/path-utils.js';
18
20
  import { PruneResponseSchema } from '../dream-response-schemas.js';
@@ -48,10 +50,25 @@ export async function prune(deps) {
48
50
  async function findCandidates(deps) {
49
51
  const candidateMap = new Map();
50
52
  const now = Date.now();
53
+ // Source of truth for importance/maturity is the sidecar. Preload once per
54
+ // scan so per-path lookups are O(1) map reads instead of repeated regex
55
+ // passes over markdown content. On sidecar failure the map is empty and
56
+ // every path falls back to defaults (importance 50, maturity 'draft').
57
+ let signalsByPath;
58
+ try {
59
+ signalsByPath = deps.runtimeSignalStore ? await deps.runtimeSignalStore.list() : new Map();
60
+ }
61
+ catch (error) {
62
+ warnSidecarFailure(deps.logger, 'prune', 'list', 'findCandidates', error);
63
+ signalsByPath = new Map();
64
+ }
51
65
  // Signal A: archive service importance decay
52
66
  try {
53
67
  const importancePaths = await deps.archiveService.findArchiveCandidates(deps.projectRoot);
54
- const infoResults = await Promise.all(importancePaths.map(async (path) => ({ info: await readCandidateInfo(deps.contextTreeDir, path, now), path })));
68
+ const infoResults = await Promise.all(importancePaths.map(async (path) => ({
69
+ info: await readCandidateInfo(deps.contextTreeDir, path, now, signalsByPath),
70
+ path,
71
+ })));
55
72
  for (const { info, path } of infoResults) {
56
73
  if (info && info.maturity !== 'core') {
57
74
  candidateMap.set(path, { ...info, signal: 'importance' });
@@ -63,7 +80,7 @@ async function findCandidates(deps) {
63
80
  }
64
81
  // Signal B: mtime staleness
65
82
  try {
66
- const stalePaths = await findStaleFiles(deps.contextTreeDir, now);
83
+ const stalePaths = await findStaleFiles(deps.contextTreeDir, now, signalsByPath);
67
84
  for (const { info, path } of stalePaths) {
68
85
  if (candidateMap.has(path)) {
69
86
  // Already found by Signal A — mark as both
@@ -84,16 +101,16 @@ async function findCandidates(deps) {
84
101
  candidates.sort((a, b) => b.daysSinceModified - a.daysSinceModified);
85
102
  return candidates.slice(0, MAX_CANDIDATES);
86
103
  }
87
- async function readCandidateInfo(contextTreeDir, relativePath, now) {
104
+ async function readCandidateInfo(contextTreeDir, relativePath, now, signalsByPath) {
88
105
  try {
89
106
  const fullPath = join(contextTreeDir, relativePath);
90
- const content = await readFile(fullPath, 'utf8');
91
107
  const fileStat = await stat(fullPath);
92
108
  const daysSinceModified = (now - fileStat.mtimeMs) / MS_PER_DAY;
109
+ const signals = signalsByPath.get(relativePath);
93
110
  return {
94
111
  daysSinceModified,
95
- importance: extractImportance(content),
96
- maturity: extractMaturity(content),
112
+ importance: signals?.importance ?? DEFAULT_IMPORTANCE,
113
+ maturity: signals?.maturity ?? DEFAULT_MATURITY,
97
114
  path: relativePath,
98
115
  signal: 'importance',
99
116
  };
@@ -102,13 +119,16 @@ async function readCandidateInfo(contextTreeDir, relativePath, now) {
102
119
  return undefined;
103
120
  }
104
121
  }
105
- async function findStaleFiles(contextTreeDir, now) {
122
+ async function findStaleFiles(contextTreeDir, now, signalsByPath) {
106
123
  const results = [];
107
124
  await walkMdFiles(contextTreeDir, async (relativePath, fullPath) => {
108
125
  try {
109
- const content = await readFile(fullPath, 'utf8');
110
- const maturity = extractMaturity(content);
111
- // core files NEVER pruned
126
+ const signals = signalsByPath.get(relativePath);
127
+ const maturity = signals?.maturity ?? DEFAULT_MATURITY;
128
+ // core files NEVER pruned. Absent sidecar entry means maturity defaults
129
+ // to 'draft', so core protection depends on the sidecar being populated.
130
+ // That is intentional: post-migration, a file is only 'core' when the
131
+ // maturity tier has been earned via repeated access / curate updates.
112
132
  if (maturity === 'core')
113
133
  return;
114
134
  const threshold = maturity === 'validated' ? VALIDATED_STALE_DAYS : DRAFT_STALE_DAYS;
@@ -118,7 +138,7 @@ async function findStaleFiles(contextTreeDir, now) {
118
138
  results.push({
119
139
  info: {
120
140
  daysSinceModified,
121
- importance: extractImportance(content),
141
+ importance: signals?.importance ?? DEFAULT_IMPORTANCE,
122
142
  maturity,
123
143
  path: relativePath,
124
144
  signal: 'mtime',
@@ -351,12 +371,3 @@ async function writePendingMerge(decision, deps) {
351
371
  };
352
372
  });
353
373
  }
354
- // ── Frontmatter helpers ────────────────────────────────────────────────────
355
- function extractMaturity(content) {
356
- const match = /^maturity:\s*['"]?(core|draft|validated)['"]?/m.exec(content);
357
- return match?.[1] ?? 'draft';
358
- }
359
- function extractImportance(content) {
360
- const match = /^importance:\s*(\d+(?:\.\d+)?)/m.exec(content);
361
- return match ? Number.parseFloat(match[1]) : 50;
362
- }
@@ -11,10 +11,23 @@
11
11
  * Never throws — returns empty array on errors.
12
12
  */
13
13
  import type { ICipherAgent } from '../../../../agent/core/interfaces/i-cipher-agent.js';
14
+ import type { ILogger } from '../../../../agent/core/interfaces/i-logger.js';
15
+ import type { IRuntimeSignalStore } from '../../../core/interfaces/storage/i-runtime-signal-store.js';
14
16
  import type { DreamOperation } from '../dream-log-schema.js';
15
17
  export type SynthesizeDeps = {
16
18
  agent: ICipherAgent;
17
19
  contextTreeDir: string;
20
+ /**
21
+ * Optional logger. When provided, sidecar seed failures emit a warn
22
+ * so the fail-open degradation is observable rather than silent.
23
+ */
24
+ logger?: ILogger;
25
+ /**
26
+ * Optional sidecar store for runtime ranking signals. When provided,
27
+ * newly created synthesis files are seeded with default signals so
28
+ * ranking data lives in the sidecar rather than in markdown frontmatter.
29
+ */
30
+ runtimeSignalStore?: IRuntimeSignalStore;
18
31
  searchService: {
19
32
  search(query: string, options?: {
20
33
  limit?: number;
@@ -14,6 +14,8 @@ import { dump as yamlDump, load as yamlLoad } from 'js-yaml';
14
14
  import { randomUUID } from 'node:crypto';
15
15
  import { access, mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises';
16
16
  import { dirname, join, resolve } from 'node:path';
17
+ import { createDefaultRuntimeSignals } from '../../../core/domain/knowledge/runtime-signals-schema.js';
18
+ import { warnSidecarFailure } from '../../../core/domain/knowledge/sidecar-logging.js';
17
19
  import { isDescendantOf } from '../../../utils/path-utils.js';
18
20
  import { SynthesizeResponseSchema } from '../dream-response-schemas.js';
19
21
  import { parseDreamResponse } from '../parse-dream-response.js';
@@ -66,7 +68,7 @@ export async function synthesize(deps) {
66
68
  for (const candidate of novel) {
67
69
  try {
68
70
  // eslint-disable-next-line no-await-in-loop
69
- const op = await writeSynthesisFile(candidate, contextTreeDir);
71
+ const op = await writeSynthesisFile(candidate, contextTreeDir, deps.runtimeSignalStore, deps.logger);
70
72
  if (op)
71
73
  results.push(op);
72
74
  }
@@ -181,7 +183,7 @@ async function isDuplicateCandidate(candidate, existingSyntheses, searchService)
181
183
  return false; // Search failure → assume novel
182
184
  }
183
185
  }
184
- async function writeSynthesisFile(candidate, contextTreeDir) {
186
+ async function writeSynthesisFile(candidate, contextTreeDir, runtimeSignalStore, logger) {
185
187
  const slug = slugify(candidate.title);
186
188
  const relativePath = `${candidate.placement}/${slug}.md`;
187
189
  const absPath = resolve(contextTreeDir, relativePath);
@@ -201,7 +203,6 @@ async function writeSynthesisFile(candidate, contextTreeDir) {
201
203
  /* eslint-disable camelcase */
202
204
  const frontmatter = {
203
205
  confidence: candidate.confidence,
204
- maturity: 'draft',
205
206
  sources,
206
207
  synthesized_at: new Date().toISOString(),
207
208
  type: 'synthesis',
@@ -220,6 +221,17 @@ async function writeSynthesisFile(candidate, contextTreeDir) {
220
221
  ].join('\n');
221
222
  const content = `---\n${yaml}\n---\n\n${body}`;
222
223
  await atomicWrite(absPath, content);
224
+ // Seed the sidecar with default signals so ranking data lives in the
225
+ // sidecar rather than in markdown frontmatter. Best-effort — a sidecar
226
+ // failure must never prevent the synthesis file from being created.
227
+ if (runtimeSignalStore) {
228
+ try {
229
+ await runtimeSignalStore.set(relativePath, createDefaultRuntimeSignals());
230
+ }
231
+ catch (error) {
232
+ warnSidecarFailure(logger, 'synthesize', 'seed', relativePath, error);
233
+ }
234
+ }
223
235
  return {
224
236
  action: 'CREATE',
225
237
  confidence: candidate.confidence,