@evomap/evolver-core 2.0.0-beta.17 → 2.0.0-beta.19

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 (71) hide show
  1. package/dist/algo/candidateAssembly.js +21 -2
  2. package/dist/algo/cycleEngine.d.ts +12 -0
  3. package/dist/algo/cycleEngine.js +36 -4
  4. package/dist/algo/geneHealth.d.ts +2 -2
  5. package/dist/algo/geneHealth.js +5 -4
  6. package/dist/algo/geneSelection.d.ts +1 -1
  7. package/dist/algo/orchestrator.js +9 -2
  8. package/dist/assetstore/assetSidecarRecords.js +4 -0
  9. package/dist/assetstore/assetStoreHealth.js +41 -24
  10. package/dist/assetstore/assetStoreStorage.d.ts +1 -1
  11. package/dist/assetstore/assetStoreStorage.js +16 -7
  12. package/dist/assetstore/localJsonl.d.ts +2 -1
  13. package/dist/assetstore/localJsonl.js +54 -10
  14. package/dist/assetstore/provenance.d.ts +24 -0
  15. package/dist/assetstore/provenance.js +219 -12
  16. package/dist/assetstore/provider.d.ts +20 -1
  17. package/dist/assetstore/provider.js +34 -1
  18. package/dist/bootstrap/index.d.ts +2 -1
  19. package/dist/bootstrap/index.js +2 -1
  20. package/dist/bootstrap/v1EnvCompat.d.ts +110 -0
  21. package/dist/bootstrap/v1EnvCompat.js +256 -0
  22. package/dist/events/public.d.ts +1 -1
  23. package/dist/events/public.js +1 -1
  24. package/dist/events/reports.d.ts +2 -0
  25. package/dist/events/reports.js +4 -0
  26. package/dist/exec/autoExec.d.ts +18 -1
  27. package/dist/exec/autoExec.js +24 -9
  28. package/dist/exec/autonomousCycle.d.ts +19 -4
  29. package/dist/exec/autonomousCycle.js +63 -13
  30. package/dist/exec/claudeBridge.d.ts +25 -7
  31. package/dist/exec/claudeBridge.js +264 -29
  32. package/dist/exec/prompt.js +5 -1
  33. package/dist/exec/runnerRegistry.d.ts +68 -26
  34. package/dist/exec/runnerRegistry.js +307 -72
  35. package/dist/exec/selfPr.js +1 -7
  36. package/dist/feedback/envelope.d.ts +61 -0
  37. package/dist/feedback/envelope.js +168 -0
  38. package/dist/feedback/index.d.ts +1 -0
  39. package/dist/feedback/index.js +1 -0
  40. package/dist/hub/assetCallLog.d.ts +35 -1
  41. package/dist/hub/assetCallLog.js +124 -1
  42. package/dist/hub/bindings.d.ts +8 -1
  43. package/dist/hub/bindings.js +17 -6
  44. package/dist/hub/capability.d.ts +11 -1
  45. package/dist/hub/fake.d.ts +2 -2
  46. package/dist/hub/fake.js +1 -1
  47. package/dist/index.d.ts +3 -1
  48. package/dist/index.js +4 -1
  49. package/dist/mailbox/dispatch.d.ts +1 -1
  50. package/dist/mailbox/dispatch.js +22 -6
  51. package/dist/mailbox/envelope.d.ts +7 -1
  52. package/dist/mailbox/envelope.js +9 -2
  53. package/dist/mailbox/ipcServer.d.ts +10 -2
  54. package/dist/mailbox/ipcServer.js +163 -13
  55. package/dist/mailbox/store.d.ts +38 -2
  56. package/dist/mailbox/store.js +416 -27
  57. package/dist/signals/curriculum.d.ts +55 -0
  58. package/dist/signals/curriculum.js +202 -0
  59. package/dist/signals/expand.js +17 -6
  60. package/dist/signals/index.d.ts +2 -1
  61. package/dist/signals/index.js +2 -1
  62. package/dist/strategy/constraintAblation.js +115 -369
  63. package/dist/strategy/constraintAblationPredicates.d.ts +31 -0
  64. package/dist/strategy/constraintAblationPredicates.js +339 -0
  65. package/dist/trace/index.d.ts +2 -1
  66. package/dist/trace/index.js +2 -1
  67. package/dist/trace/proxyTurns.d.ts +31 -0
  68. package/dist/trace/proxyTurns.js +137 -0
  69. package/dist/verify/validation.d.ts +11 -1
  70. package/dist/verify/validation.js +31 -0
  71. package/package.json +4 -1
@@ -1,6 +1,6 @@
1
1
  import { aggregateLearningHistory } from '../assetstore/learningHistory.js';
2
2
  import { reuseSentiment } from '../ops/reuseOutcomes.js';
3
- import { tagOverlapScore } from '../signals/expand.js';
3
+ import { expandSignals, geneTags, tagOverlapScore, } from '../signals/expand.js';
4
4
  import { bannedGenesFromFailures } from './bans.js';
5
5
  import { geneGenerationSource } from './geneIntake.js';
6
6
  function asStrings(v) {
@@ -68,6 +68,24 @@ async function computeBans(store, signals, limit) {
68
68
  }));
69
69
  return bannedGenesFromFailures(failures, signals);
70
70
  }
71
+ const GENERIC_NAMESPACE_TAGS = new Set(['action', 'area', 'problem', 'risk', 'signal']);
72
+ // Generic namespace tags and inferred action/signal subtypes are too broad to admit a candidate by themselves.
73
+ function hasSelectionAdmissionEvidence(liveSignals, gene) {
74
+ const tags = new Set(geneTags(gene));
75
+ const triggerTags = new Set(expandSignals(gene.signalsMatch ?? []));
76
+ const categoryActionTag = gene.category ? `action:${gene.category.toLowerCase()}` : undefined;
77
+ if (liveSignals.some((signal) => ((triggerTags.has(signal) || signal === categoryActionTag)
78
+ && !GENERIC_NAMESPACE_TAGS.has(signal)))) {
79
+ return true;
80
+ }
81
+ const liveTags = expandSignals(liveSignals);
82
+ if (liveTags.some((tag) => (tags.has(tag)
83
+ && (tag.startsWith('problem:')
84
+ || tag.startsWith('area:')
85
+ || tag.startsWith('risk:')))))
86
+ return true;
87
+ return liveTags.some((tag) => tag.startsWith('signal:') && triggerTags.has(tag));
88
+ }
71
89
  /**
72
90
  * Assemble the full selection pool from the store for the given signals: the relevant scored candidates AND the
73
91
  * distilled-gene fallback pool, in a single pass over the gene list (one store.list + one ban computation). A gene
@@ -106,7 +124,8 @@ export async function assembleSelectionPool(store, signals, opts = {}) {
106
124
  const category = typeof g['category'] === 'string' ? String(g['category']) : undefined;
107
125
  const summary = typeof g['summary'] === 'string' ? String(g['summary']) : undefined;
108
126
  const literal = signalsMatch.some((m) => sigSet.has(m));
109
- const relevant = literal || tagOverlapScore(signals, { signalsMatch, geneId, category, summary }) > 0;
127
+ const tagInput = { signalsMatch, geneId, category, summary };
128
+ const relevant = literal || hasSelectionAdmissionEvidence(signals, tagInput);
110
129
  if (!relevant) {
111
130
  // #97: a trusted, approved, non-banned distilled (or evolved) gene that doesn't match the live signals is not
112
131
  // a normal candidate, but it IS eligible as a last-resort fallback (selection uses it only when nothing clears
@@ -66,6 +66,8 @@ export interface CycleEngineDeps {
66
66
  rng?: () => number;
67
67
  /** Injected environment fingerprint (deterministic tests). Defaults to capturing the real runtime env. */
68
68
  envFingerprint?: () => EnvFingerprint;
69
+ /** Latest external capability gaps. Composition owns persistence/wire details; core reads one bounded snapshot. */
70
+ capabilityGaps?: () => readonly string[];
69
71
  /** 触发评估(常注入 TriggerEngine.evaluate 的包装; 缺省=直接触发). */
70
72
  trigger?: (p: ProblemPattern, now: number) => Promise<TriggerEval> | TriggerEval;
71
73
  /** 可进化人格(可选). 注入后, 每轮:
@@ -82,6 +84,8 @@ export interface CycleInput {
82
84
  cycleId: string;
83
85
  problem: ProblemPattern;
84
86
  signals: readonly string[];
87
+ /** Curriculum targets already prepared before candidate assembly. Omit for direct runCycle callers. */
88
+ curriculumSignals?: readonly string[];
85
89
  category: GepCategory;
86
90
  /** Optional explicit strategy preset name; when set, it wins over history-derived meta-signal auto-detection. */
87
91
  strategyName?: string;
@@ -150,5 +154,13 @@ export interface CycleResult {
150
154
  export declare class CycleEngine {
151
155
  private readonly deps;
152
156
  constructor(deps: CycleEngineDeps);
157
+ /**
158
+ * Add V1-compatible curriculum targets from V2's replayable event history. Public so the orchestrator can run
159
+ * it before candidate assembly; runCycle calls it again for direct callers. Set merging makes that idempotent.
160
+ */
161
+ prepareCurriculumSignals(signals: readonly string[]): {
162
+ signals: string[];
163
+ curriculumSignals: string[];
164
+ };
153
165
  runCycle(input: CycleInput): Promise<CycleResult>;
154
166
  }
@@ -12,6 +12,7 @@ import { solidify } from './solidify.js';
12
12
  import { buildEvolutionEvent } from './evolutionEvent.js';
13
13
  import { cycleRecordsFromEvents } from '../signals/cycleHistoryFromEvents.js';
14
14
  import { computeMetaSignals, deriveCycleHistory } from '../signals/metaSignals.js';
15
+ import { capabilityGapsFromSignals, curriculumOutcomesFromEvents, generateCurriculumSignals, normalizeCapabilityGaps, } from '../signals/curriculum.js';
15
16
  import { resolveStrategy } from './strategyPresets.js';
16
17
  import { classifyCycleFailure, } from './cycleFailureClassifier.js';
17
18
  import { isDistilledGeneId } from './geneIntake.js';
@@ -164,14 +165,44 @@ export class CycleEngine {
164
165
  constructor(deps) {
165
166
  this.deps = deps;
166
167
  }
168
+ /**
169
+ * Add V1-compatible curriculum targets from V2's replayable event history. Public so the orchestrator can run
170
+ * it before candidate assembly; runCycle calls it again for direct callers. Set merging makes that idempotent.
171
+ */
172
+ prepareCurriculumSignals(signals) {
173
+ let externalCapabilityGaps = [];
174
+ try {
175
+ externalCapabilityGaps = normalizeCapabilityGaps(this.deps.capabilityGaps?.() ?? []);
176
+ }
177
+ catch {
178
+ // An optional lifecycle-state reader must never suppress local curriculum generation.
179
+ }
180
+ try {
181
+ const localCapabilityGaps = capabilityGapsFromSignals(signals);
182
+ const curriculumSignals = generateCurriculumSignals({
183
+ outcomes: curriculumOutcomesFromEvents(this.deps.ingestor.readAll()),
184
+ // Preserve existing explicit local-signal precedence; Hub gaps fill the otherwise-missing V1 source.
185
+ capabilityGaps: normalizeCapabilityGaps([...localCapabilityGaps, ...externalCapabilityGaps]),
186
+ });
187
+ return { signals: mergeSignals(signals, curriculumSignals), curriculumSignals };
188
+ }
189
+ catch {
190
+ return { signals: [...signals], curriculumSignals: [] };
191
+ }
192
+ }
167
193
  async runCycle(input) {
168
194
  const { ingestor, store } = this.deps;
169
195
  const now = this.deps.now();
170
196
  const envKey = envFingerprintKey((this.deps.envFingerprint ?? captureEnvFingerprint)());
171
197
  const reasons = [];
172
- const baseSignals = input.signals;
198
+ const baseSignals = [...input.signals];
199
+ const preparedSignals = input.curriculumSignals === undefined
200
+ ? this.prepareCurriculumSignals(input.signals)
201
+ : { signals: mergeSignals(input.signals, input.curriculumSignals), curriculumSignals: [...input.curriculumSignals] };
202
+ const selectionSignals = preparedSignals.signals;
203
+ const curriculumSignals = preparedSignals.curriculumSignals;
173
204
  const metaSignals = historyMetaSignals(ingestor, 100);
174
- const cycleSignals = mergeSignals(baseSignals, metaSignals);
205
+ const cycleSignals = mergeSignals(selectionSignals, metaSignals);
175
206
  // PORT v1 #279 issue-reporter: classify cycle.failed payloads when the caller supplies host context. The
176
207
  // post-solidify failed path adds V2-local context (current gene + blast radius) so hard-filtered local no-op
177
208
  // genes are reachable without re-emitting the old ban_gene soft signal.
@@ -221,6 +252,7 @@ export class CycleEngine {
221
252
  cycleId: input.cycleId,
222
253
  signals: cycleSignals,
223
254
  baseSignals,
255
+ ...(curriculumSignals.length > 0 ? { curriculumSignals } : {}),
224
256
  ...(metaSignals.length > 0 ? { metaSignals, strategy: strategy.name } : {}),
225
257
  },
226
258
  });
@@ -250,7 +282,7 @@ export class CycleEngine {
250
282
  // recentEvents 由事件日志的近期 outcome 派生, 让"失败连击触发变异"在环内真正生效.
251
283
  const sel = await applySelectForRun(personalityDeps, {
252
284
  driftEnabled: plateau.active,
253
- signals: baseSignals,
285
+ signals: selectionSignals,
254
286
  recentEvents: recentOutcomes.map((status) => ({ outcome: { status } })),
255
287
  });
256
288
  personalityForRun = sel.state;
@@ -301,7 +333,7 @@ export class CycleEngine {
301
333
  const epi = epigeneticPenaltyForIds(candidateIds(c), envKey, geneOutcomes);
302
334
  return epi > 0 ? { ...c, epigeneticPenalty: epi } : c;
303
335
  });
304
- const decision = (await this.deps.selection.run({ signals: baseSignals, candidates, floor: input.selectionFloor, ...(input.forcedGeneId !== undefined ? { forcedGeneId: input.forcedGeneId } : {}), ...(exploration ? { exploration } : {}), ...(distilledFallback.length > 0 ? { distilledFallback } : {}), ...(input.antiWarnings && input.antiWarnings.length > 0 ? { antiWarnings: input.antiWarnings } : {}), ...(input.memoryEvidence && input.memoryEvidence.length > 0 ? { memoryEvidence: input.memoryEvidence } : {}) }, { now, cycleId: input.cycleId, ...(this.deps.rng ? { rng: this.deps.rng } : {}) }));
336
+ const decision = (await this.deps.selection.run({ signals: selectionSignals, candidates, floor: input.selectionFloor, ...(input.forcedGeneId !== undefined ? { forcedGeneId: input.forcedGeneId } : {}), ...(exploration ? { exploration } : {}), ...(distilledFallback.length > 0 ? { distilledFallback } : {}), ...(input.antiWarnings && input.antiWarnings.length > 0 ? { antiWarnings: input.antiWarnings } : {}), ...(input.memoryEvidence && input.memoryEvidence.length > 0 ? { memoryEvidence: input.memoryEvidence } : {}) }, { now, cycleId: input.cycleId, ...(this.deps.rng ? { rng: this.deps.rng } : {}) }));
305
337
  await ingestor.ingest({
306
338
  type: 'decision.gene_selected',
307
339
  human: { title: `选 gene ${decision.selectedGeneId ?? '(innovate)'}`, why: decision.candidates.map((c) => `${c.geneId}:${c.score.toFixed(3)}`).join(', ') || '无候选→innovate' },
@@ -12,10 +12,10 @@ export interface GeneHealthWeights {
12
12
  reuse: number;
13
13
  antiPattern: number;
14
14
  }
15
- export declare const HEALTH_WEIGHTS_VERSION = "gh-1";
15
+ export declare const HEALTH_WEIGHTS_VERSION = "gh-2";
16
16
  export declare const DEFAULT_HEALTH_WEIGHTS: GeneHealthWeights;
17
17
  /**
18
- * gene 健康分 = w1·successRate + w2·reuse归一 − w3·antiPattern密度.
18
+ * gene 健康分 = successRate·(w1 + w2·reuse归一) − w3·antiPattern密度.
19
19
  * 输入 = M3-6 聚合视图(不内联 learning_history) + anti_patterns 数 + 复用计数.
20
20
  */
21
21
  export declare function geneHealthScore(view: GeneLearningView, opts?: {
@@ -1,16 +1,17 @@
1
- export const HEALTH_WEIGHTS_VERSION = 'gh-1';
2
- export const DEFAULT_HEALTH_WEIGHTS = { successRate: 0.6, reuse: 0.3, antiPattern: 0.4 };
1
+ export const HEALTH_WEIGHTS_VERSION = 'gh-2';
2
+ export const DEFAULT_HEALTH_WEIGHTS = { successRate: 0.6, reuse: 0.1, antiPattern: 0.4 };
3
3
  /** reuseCount 归一(对数压缩, 复用越多分越高但边际递减). */
4
4
  function reuseScore(count) {
5
5
  return count <= 0 ? 0 : Math.min(1, Math.log10(count + 1) / 2); // 100 次≈封顶
6
6
  }
7
7
  /**
8
- * gene 健康分 = w1·successRate + w2·reuse归一 − w3·antiPattern密度.
8
+ * gene 健康分 = successRate·(w1 + w2·reuse归一) − w3·antiPattern密度.
9
9
  * 输入 = M3-6 聚合视图(不内联 learning_history) + anti_patterns 数 + 复用计数.
10
10
  */
11
11
  export function geneHealthScore(view, opts = {}, w = DEFAULT_HEALTH_WEIGHTS) {
12
12
  const reuseCount = opts.reuseCount ?? view.total;
13
13
  const antiPatternPenalty = Math.min(1, (opts.antiPatternCount ?? 0) / 5); // 5+ anti-pattern 封顶惩罚
14
- const score = w.successRate * view.successRate + w.reuse * reuseScore(reuseCount) - w.antiPattern * antiPatternPenalty;
14
+ const confidenceAdjustedSuccess = view.successRate * (w.successRate + w.reuse * reuseScore(reuseCount));
15
+ const score = confidenceAdjustedSuccess - w.antiPattern * antiPatternPenalty;
15
16
  return { geneId: view.geneId, successRate: view.successRate, reuseCount, antiPatternPenalty, score };
16
17
  }
@@ -134,7 +134,7 @@ export declare const MEMORY_GRAPH_WEIGHT = 0.12;
134
134
  * + CONFIDENCE_WEIGHT × confidence + REUSE_WEIGHT × reuse-sentiment). Bumped whenever a factor is added so golden
135
135
  * weight snapshots track the change. Composed from the health-weights version so a change to either layer shows.
136
136
  */
137
- export declare const SELECTION_WEIGHTS_VERSION = "sel-4(gh-1,conf=0.15,memory=0.12,reuse=0.1)";
137
+ export declare const SELECTION_WEIGHTS_VERSION = "sel-4(gh-2,conf=0.15,memory=0.12,reuse=0.1)";
138
138
  /** 实现1: engine 健康分主导(health 0.6 + 信号匹配 0.4). */
139
139
  export declare const engineHealthSelection: Strategy<SelectionInput, GeneDecision>;
140
140
  /** 实现2: 纯信号匹配采样(忽略 health, 对照基线 — 经验主义要可对比). */
@@ -18,6 +18,12 @@ export async function runEvolutionCycle(engine, store, opts) {
18
18
  console.warn(`[ExplicitSignals] Failed to consume pending signals (non-fatal): ${message}`);
19
19
  }
20
20
  }
21
+ // Curriculum targets must be present before candidate assembly; injecting them only inside CycleEngine would
22
+ // be too late to recall a gene whose signals_match is curriculum_target:*. The engine repeats this enrichment
23
+ // for direct runCycle callers; the operation is deterministic and set-idempotent.
24
+ const baseSignals = signals;
25
+ const preparedCurriculum = engine.prepareCurriculumSignals(baseSignals);
26
+ const selectionSignals = preparedCurriculum.signals;
21
27
  // #268 phase 1 + #274 slice 3: fold cross-runtime reuse counts AND observed recall into the pool (soft re-order).
22
28
  // Absent both → no map → default-off. Recall is folded at a lower weight (RECALL_WEIGHT). Assembly sums a gene's
23
29
  // ids → sentiment; bounded + clamped in geneSelection; the hard trust/review/ban gates in assembly run first.
@@ -38,7 +44,7 @@ export async function runEvolutionCycle(engine, store, opts) {
38
44
  ...(opts.hubCandidates && opts.hubCandidates.length > 0 ? { hubCandidates: opts.hubCandidates } : {}),
39
45
  ...(reuseCounts.size > 0 ? { reuseCounts } : {}),
40
46
  };
41
- const { candidates: assembledCandidates, distilledFallback, antiWarnings } = await assembleSelectionPool(store, signals, asmOpts);
47
+ const { candidates: assembledCandidates, distilledFallback, antiWarnings } = await assembleSelectionPool(store, selectionSignals, asmOpts);
42
48
  const memoryByGene = new Map((opts.memoryGraphAdvice?.genes ?? []).map((evidence) => [evidence.geneId, evidence]));
43
49
  const candidates = assembledCandidates.map((candidate) => {
44
50
  const evidence = memoryByGene.get(candidate.geneId) ?? (candidate.assetId ? memoryByGene.get(candidate.assetId) : undefined);
@@ -53,7 +59,8 @@ export async function runEvolutionCycle(engine, store, opts) {
53
59
  return engine.runCycle({
54
60
  cycleId: opts.cycleId,
55
61
  problem: opts.problem,
56
- signals,
62
+ signals: baseSignals,
63
+ curriculumSignals: preparedCurriculum.curriculumSignals,
57
64
  category: opts.category,
58
65
  ...(opts.strategyName !== undefined ? { strategyName: opts.strategyName } : {}),
59
66
  candidates,
@@ -62,6 +62,9 @@ export function parseProvenanceRecord(value) {
62
62
  const decidedBy = stringField(record, 'decidedBy');
63
63
  const promotedBy = stringField(record, 'promotedBy');
64
64
  const reason = stringField(record, 'reason');
65
+ const frozenContentId = stringField(record, 'frozenContentId');
66
+ if (frozenContentId !== undefined && !/^sha256:[0-9a-f]{64}$/.test(frozenContentId))
67
+ return null;
65
68
  return {
66
69
  assetId,
67
70
  source,
@@ -71,6 +74,7 @@ export function parseProvenanceRecord(value) {
71
74
  ...(decidedBy ? { decidedBy } : {}),
72
75
  ...(promotedBy ? { promotedBy } : {}),
73
76
  ...(reason ? { reason } : {}),
77
+ ...(frozenContentId ? { frozenContentId } : {}),
74
78
  };
75
79
  }
76
80
  export function parseReviewRecord(value) {
@@ -1,7 +1,7 @@
1
1
  import { lstatSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { acquireLock, releaseLock } from '../util/fileLock.js';
4
- import { validateWire, verifyAssetId } from '../wire/index.js';
4
+ import { computeAssetId, validateWire, verifyAssetId } from '../wire/index.js';
5
5
  import { LOCAL_ASSET_FILES } from './assetStoreLayout.js';
6
6
  import { assertOptionalRegularFile, isReliableAssetStoreLockRelease, readUtf8Regular, UnsafeAssetStorePathError, } from './assetStoreStorage.js';
7
7
  import { parseAssetSyncSidecarRecord, parseProvenanceRecord, parseReviewRecord, parseSidecarJsonl, } from './assetSidecarRecords.js';
@@ -107,7 +107,7 @@ function summarize(files, sidecars) {
107
107
  sidecars: [...sidecars],
108
108
  };
109
109
  }
110
- function inspectFile(baseDir, kind, file, maxFileBytes, unverifiedIds) {
110
+ function inspectFile(baseDir, kind, file, maxFileBytes, unverifiedContentIds) {
111
111
  try {
112
112
  const path = join(baseDir, file);
113
113
  const stat = assertOptionalRegularFile(path);
@@ -116,7 +116,7 @@ function inspectFile(baseDir, kind, file, maxFileBytes, unverifiedIds) {
116
116
  if (stat.size > maxFileBytes) {
117
117
  return { ...emptyFile(kind, file, 'unavailable', 'scan_limit_exceeded'), bytes: stat.size };
118
118
  }
119
- const raw = readUtf8Regular(path);
119
+ const raw = readUtf8Regular(path, maxFileBytes);
120
120
  if (raw === null)
121
121
  return emptyFile(kind, file, 'unavailable', 'read_unavailable');
122
122
  const rows = raw.split('\n').filter((line) => line.trim().length > 0);
@@ -144,21 +144,22 @@ function inspectFile(baseDir, kind, file, maxFileBytes, unverifiedIds) {
144
144
  duplicateRows += 1;
145
145
  else
146
146
  seen.add(assetId);
147
- if (!verifyAssetId(record)) {
147
+ const hashMatches = verifyAssetId(record);
148
+ if (!hashMatches) {
148
149
  // A hash mismatch is corruption UNLESS provenance says this id is an unverified hub reuse: the hub
149
150
  // rewrote the delivered bytes and reuse froze them untrusted on purpose (#570). That is expected, not
150
151
  // store rot, so it lands in the benign unverifiedRows bucket and never degrades the store.
151
- if (unverifiedIds.has(assetId))
152
+ if (unverifiedContentIds.get(assetId) === computeAssetId(record))
152
153
  unverifiedRows += 1;
153
154
  else
154
155
  hashMismatchRows += 1;
155
- continue;
156
156
  }
157
- if (kind !== 'AntiGene' && !validateWire(record).ok) {
157
+ const schemaValid = kind === 'AntiGene' || validateWire(record).ok;
158
+ if (!schemaValid) {
158
159
  schemaInvalidRows += 1;
159
- continue;
160
160
  }
161
- validRows += 1;
161
+ if (hashMatches && schemaValid)
162
+ validRows += 1;
162
163
  }
163
164
  catch {
164
165
  corruptRows += 1;
@@ -195,20 +196,36 @@ function inspectFile(baseDir, kind, file, maxFileBytes, unverifiedIds) {
195
196
  }
196
197
  }
197
198
  /**
198
- * Asset ids marked in the provenance sidecar as an unverified hub reuse (reason `unverified_*`, evolver-v2#570).
199
+ * Asset ids whose latest provenance decision marks an unverified hub reuse (evolver-v2#570).
199
200
  * Read directly from `<baseDir>/provenance.jsonl` — NOT via ProvenanceStore — because inspectLocalAssetStore
200
201
  * already holds the shared `.assetstore.lock`, and ProvenanceStore would try to re-acquire it. A missing or
201
- * unreadable sidecar yields an empty set: without provenance every mismatch stays classified as corruption.
202
+ * unreadable sidecar yields an empty map: without provenance every mismatch stays classified as corruption.
202
203
  */
203
- function readUnverifiedReuseIds(baseDir) {
204
- const raw = readUtf8Regular(join(baseDir, 'provenance.jsonl'));
204
+ function readUnverifiedReuseContentIds(baseDir, maxFileBytes) {
205
+ let raw;
206
+ try {
207
+ raw = readUtf8Regular(join(baseDir, 'provenance.jsonl'), maxFileBytes);
208
+ }
209
+ catch {
210
+ return new Map();
211
+ }
205
212
  if (raw === null)
206
- return new Set();
207
- const ids = new Set();
213
+ return new Map();
214
+ const ids = new Map();
208
215
  for (const record of parseSidecarJsonl(raw, parseProvenanceRecord).records) {
209
- if (record.trusted === false && typeof record.reason === 'string' && record.reason.startsWith('unverified_')) {
210
- ids.add(record.assetId);
211
- }
216
+ const frozenContentId = record.frozenContentId;
217
+ const supportedReason = (record.source === 'hub'
218
+ && (record.reason === 'unverified_hub_rewrite' || record.reason === 'unverified_hub_synthesized')) || (record.source === 'migrated' && record.reason === 'unverified_gepx_import');
219
+ const qualifies = supportedReason
220
+ && record.trusted === false
221
+ && record.decision === undefined
222
+ && record.decidedBy === undefined
223
+ && record.promotedBy === undefined
224
+ && frozenContentId !== undefined;
225
+ if (qualifies)
226
+ ids.set(record.assetId, frozenContentId);
227
+ else
228
+ ids.delete(record.assetId);
212
229
  }
213
230
  return ids;
214
231
  }
@@ -222,7 +239,7 @@ function inspectSidecar(baseDir, definition, maxFileBytes) {
222
239
  if (stat.size > maxFileBytes) {
223
240
  return { ...emptySidecar(kind, file, 'unavailable', 'scan_limit_exceeded'), bytes: stat.size };
224
241
  }
225
- const raw = readUtf8Regular(path);
242
+ const raw = readUtf8Regular(path, maxFileBytes);
226
243
  if (raw === null)
227
244
  return emptySidecar(kind, file, 'unavailable', 'read_unavailable');
228
245
  const parsed = parseSidecarJsonl(raw, parseRecord);
@@ -283,14 +300,14 @@ export function inspectLocalAssetStore(baseDir, opts = {}, deps = {}) {
283
300
  catch {
284
301
  return summarize(allFiles('unavailable', 'lock_unavailable'), allSidecars('unavailable', 'lock_unavailable'));
285
302
  }
286
- const maxFileBytes = healthScanLimit(opts.maxFileBytes);
287
- // Read the unverified-reuse set once, under the lock we already hold, so every asset file classifies its
288
- // hash mismatches consistently against the same provenance snapshot.
289
- const unverifiedIds = readUnverifiedReuseIds(baseDir);
290
303
  let report;
291
304
  try {
305
+ const maxFileBytes = healthScanLimit(opts.maxFileBytes);
306
+ // Read the unverified-reuse set once, under the lock we already hold, so every asset file classifies its
307
+ // hash mismatches consistently against the same bounded provenance snapshot.
308
+ const unverifiedContentIds = readUnverifiedReuseContentIds(baseDir, maxFileBytes);
292
309
  report = summarize(Object.entries(LOCAL_ASSET_FILES)
293
- .map(([kind, file]) => inspectFile(baseDir, kind, file, maxFileBytes, unverifiedIds)), LOCAL_ASSET_SIDECARS.map((definition) => inspectSidecar(baseDir, definition, maxFileBytes)));
310
+ .map(([kind, file]) => inspectFile(baseDir, kind, file, maxFileBytes, unverifiedContentIds)), LOCAL_ASSET_SIDECARS.map((definition) => inspectSidecar(baseDir, definition, maxFileBytes)));
294
311
  }
295
312
  catch {
296
313
  report = summarize(allFiles('unavailable', 'read_unavailable'), allSidecars('unavailable', 'read_unavailable'));
@@ -33,7 +33,7 @@ export declare function withAssetStoreLock<T>(lockPath: string, operation: () =>
33
33
  export declare function assertOptionalRegularFile(path: string, role?: AssetStorePathRole): Stats | null;
34
34
  export declare function regularFileFingerprint(path: string): string;
35
35
  export declare function readRegularBuffer(path: string, maxBytes?: number): Buffer | null;
36
- export declare function readUtf8Regular(path: string): string | null;
36
+ export declare function readUtf8Regular(path: string, maxBytes?: number): string | null;
37
37
  export declare function createBufferDurableExclusive(path: string, value: Buffer, opts?: DurableWriteOptions): void;
38
38
  export declare function fsyncDirectoryBestEffort(path: string): void;
39
39
  export declare function appendUtf8Durable(path: string, value: string, opts?: DurableWriteOptions): void;
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { closeSync, constants, fstatSync, fsyncSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, unlinkSync, writeSync, } from 'node:fs';
2
+ import { closeSync, constants, fstatSync, fsyncSync, ftruncateSync, lstatSync, mkdirSync, openSync, readSync, renameSync, unlinkSync, writeSync, } from 'node:fs';
3
3
  import { basename, dirname, join } from 'node:path';
4
4
  import { acquireLock, LockReleaseError, releaseLock, } from '../util/fileLock.js';
5
5
  export class UnsafeAssetStorePathError extends Error {
@@ -152,17 +152,26 @@ export function readRegularBuffer(path, maxBytes = Number.MAX_SAFE_INTEGER) {
152
152
  assertOpenedPathMatches(fd, path, 'asset_file');
153
153
  if (fstatSync(fd).size > maxBytes)
154
154
  throw new AssetStoreReadLimitError();
155
- const value = readFileSync(fd);
156
- if (value.byteLength > maxBytes)
157
- throw new AssetStoreReadLimitError();
158
- return value;
155
+ const chunks = [];
156
+ let total = 0;
157
+ while (total <= maxBytes) {
158
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes - total + 1));
159
+ const bytesRead = readSync(fd, chunk, 0, chunk.byteLength, null);
160
+ if (bytesRead === 0)
161
+ break;
162
+ total += bytesRead;
163
+ if (total > maxBytes)
164
+ throw new AssetStoreReadLimitError();
165
+ chunks.push(bytesRead === chunk.byteLength ? chunk : chunk.subarray(0, bytesRead));
166
+ }
167
+ return Buffer.concat(chunks, total);
159
168
  }
160
169
  finally {
161
170
  closeSync(fd);
162
171
  }
163
172
  }
164
- export function readUtf8Regular(path) {
165
- return readRegularBuffer(path)?.toString('utf8') ?? null;
173
+ export function readUtf8Regular(path, maxBytes = Number.MAX_SAFE_INTEGER) {
174
+ return readRegularBuffer(path, maxBytes)?.toString('utf8') ?? null;
166
175
  }
167
176
  function writeAll(fd, value) {
168
177
  const bytes = typeof value === 'string' ? Buffer.from(value, 'utf8') : value;
@@ -25,8 +25,9 @@ export declare class LocalJsonlProvider implements AssetStoreProvider {
25
25
  * 仅 v1→v2 导入用(硬化 A6 存量冻结); 普通写一律走 put(). record 必须自带 asset_id.
26
26
  */
27
27
  putFrozen(record: AssetRecord): Promise<PutResult>;
28
+ putFrozenConditional(record: AssetRecord, options?: ConditionalPutOptions): Promise<ConditionalPutResult>;
28
29
  get(assetId: string): Promise<AssetRecord | null>;
29
- findByLogicalId(id: string, limit?: number): Promise<AssetRecord[]>;
30
+ findByLogicalId(id: string, limit?: number, kind?: AssetKind): Promise<AssetRecord[]>;
30
31
  list(kind?: AssetKind, limit?: number): Promise<AssetRecord[]>;
31
32
  search(q: SearchQuery): Promise<AssetRecord[]>;
32
33
  /**
@@ -2,7 +2,12 @@ import { join } from 'node:path';
2
2
  import { acquireLock, releaseLock } from '../util/fileLock.js';
3
3
  import { appendUtf8Durable, assertAssetStoreDirectory, assertOptionalRegularFile, ensureAssetStoreDirectory, readUtf8Regular, regularFileFingerprint, replaceUtf8Durable, } from './assetStoreStorage.js';
4
4
  import { LOCAL_ASSET_FILES } from './assetStoreLayout.js';
5
- import { normalizeForPut, } from './provider.js';
5
+ import { FrozenAssetIdCollisionError, frozenAssetRecordsEqual, normalizeForPut, } from './provider.js';
6
+ function resultLogicalId(logicalId) {
7
+ return logicalId !== undefined && logicalId.length > 0 && logicalId === logicalId.trim()
8
+ ? logicalId
9
+ : undefined;
10
+ }
6
11
  function signalsOf(a) {
7
12
  const out = [];
8
13
  for (const key of ['signals_match', 'signals', 'trigger', 'trigger_signals']) {
@@ -109,13 +114,16 @@ export class LocalJsonlProvider {
109
114
  try {
110
115
  // Refresh under the shared lock so another process cannot append between reload and dedupe.
111
116
  this.refreshUnderLock();
112
- if (this.index.has(record.asset_id)) {
117
+ const existing = this.index.get(record.asset_id);
118
+ if (existing) {
119
+ if (!frozenAssetRecordsEqual(existing, record))
120
+ throw new FrozenAssetIdCollisionError(record.asset_id);
113
121
  return {
114
122
  asset_id: record.asset_id,
115
123
  stored: false,
116
124
  verified,
117
125
  status: 'already_exists',
118
- logicalId,
126
+ ...(resultLogicalId(logicalId) ? { logicalId } : {}),
119
127
  };
120
128
  }
121
129
  collision = logicalId === undefined
@@ -129,7 +137,7 @@ export class LocalJsonlProvider {
129
137
  stored: false,
130
138
  verified,
131
139
  status: 'logical_collision',
132
- logicalId,
140
+ ...(resultLogicalId(logicalId) ? { logicalId } : {}),
133
141
  collisionWithAssetId: collision.asset_id,
134
142
  };
135
143
  }
@@ -146,7 +154,7 @@ export class LocalJsonlProvider {
146
154
  verified,
147
155
  status: 'stored',
148
156
  ...(collision ? {
149
- logicalId,
157
+ ...(resultLogicalId(logicalId) ? { logicalId } : {}),
150
158
  collisionWithAssetId: collision.asset_id,
151
159
  } : {}),
152
160
  };
@@ -156,15 +164,42 @@ export class LocalJsonlProvider {
156
164
  * 仅 v1→v2 导入用(硬化 A6 存量冻结); 普通写一律走 put(). record 必须自带 asset_id.
157
165
  */
158
166
  async putFrozen(record) {
167
+ return this.putFrozenConditional(record, { allowLogicalCollision: true });
168
+ }
169
+ async putFrozenConditional(record, options = {}) {
159
170
  if (!record.asset_id)
160
171
  throw new Error('putFrozen 需 record 自带冻结 asset_id');
161
172
  const file = join(this.baseDir, LOCAL_ASSET_FILES[record.type]);
173
+ let logicalId;
174
+ let collision;
162
175
  assertOptionalRegularFile(this.lockPath, 'lock_file');
163
176
  acquireLock(this.lockPath);
164
177
  try {
165
178
  this.refreshUnderLock();
166
- if (this.index.has(record.asset_id))
167
- return { asset_id: record.asset_id, stored: false, verified: false };
179
+ const existing = this.index.get(record.asset_id);
180
+ if (existing) {
181
+ if (!frozenAssetRecordsEqual(existing, record))
182
+ throw new FrozenAssetIdCollisionError(record.asset_id);
183
+ return { asset_id: record.asset_id, stored: false, verified: false, status: 'already_exists' };
184
+ }
185
+ logicalId = typeof record['id'] === 'string' && record['id'].length > 0
186
+ ? record['id']
187
+ : undefined;
188
+ collision = logicalId === undefined
189
+ ? undefined
190
+ : [...this.index.values()].find((candidate) => (candidate.type === record.type
191
+ && candidate['id'] === logicalId
192
+ && candidate.asset_id !== record.asset_id));
193
+ if (collision && !options.allowLogicalCollision) {
194
+ return {
195
+ asset_id: record.asset_id,
196
+ stored: false,
197
+ verified: false,
198
+ status: 'logical_collision',
199
+ ...(resultLogicalId(logicalId) ? { logicalId } : {}),
200
+ collisionWithAssetId: collision.asset_id,
201
+ };
202
+ }
168
203
  appendUtf8Durable(file, `${JSON.stringify(record)}\n`);
169
204
  this.index.set(record.asset_id, record);
170
205
  this.updateFileStateAfterWrite();
@@ -172,18 +207,27 @@ export class LocalJsonlProvider {
172
207
  finally {
173
208
  releaseLock(this.lockPath);
174
209
  }
175
- return { asset_id: record.asset_id, stored: true, verified: false };
210
+ return {
211
+ asset_id: record.asset_id,
212
+ stored: true,
213
+ verified: false,
214
+ status: 'stored',
215
+ ...(collision ? {
216
+ ...(resultLogicalId(logicalId) ? { logicalId } : {}),
217
+ collisionWithAssetId: collision.asset_id,
218
+ } : {}),
219
+ };
176
220
  }
177
221
  async get(assetId) {
178
222
  this.ensureFresh();
179
223
  return this.index.get(assetId) ?? null;
180
224
  }
181
- async findByLogicalId(id, limit = 2) {
225
+ async findByLogicalId(id, limit = 2, kind) {
182
226
  this.ensureFresh();
183
227
  const boundedLimit = Number.isFinite(limit) ? Math.max(1, Math.min(1_000, Math.floor(limit))) : 2;
184
228
  const out = [];
185
229
  for (const record of this.index.values()) {
186
- if (record['id'] !== id)
230
+ if (record['id'] !== id || (kind !== undefined && record.type !== kind))
187
231
  continue;
188
232
  out.push(record);
189
233
  if (out.length >= boundedLimit)
@@ -11,11 +11,18 @@ export interface ProvenanceRecord {
11
11
  /** Legacy promotion actor field kept for existing sidecar readers. */
12
12
  promotedBy?: string;
13
13
  reason?: string;
14
+ /** Canonical content id of the exact hash-mismatched body accepted by an unverified Hub ingest. */
15
+ frozenContentId?: string;
14
16
  }
15
17
  export interface ProvenanceTrustChange {
16
18
  changed: boolean;
17
19
  record: ProvenanceRecord;
18
20
  }
21
+ export declare class ProvenanceWritePendingError extends Error {
22
+ readonly assetId: string;
23
+ readonly code = "PROVENANCE_WRITE_PENDING";
24
+ constructor(assetId: string);
25
+ }
19
26
  /**
20
27
  * Append-only JSONL sidecar (last-write-wins) at <baseDir>/provenance.jsonl. Default for an asset with NO
21
28
  * record = trusted: the only local writers (cycleEngine self-produce, v1 migration) are trusted and never
@@ -36,6 +43,21 @@ export declare class ProvenanceStore {
36
43
  mark(rec: Omit<ProvenanceRecord, 'at'> & {
37
44
  at?: string;
38
45
  }): ProvenanceRecord;
46
+ /** Stage a verified Hub write without replacing an in-flight conservative marker. */
47
+ stageUntrustedWriteTracked(assetId: string, source: ProvenanceSource): {
48
+ record: ProvenanceRecord;
49
+ appended: boolean;
50
+ };
51
+ /** Finalize a verified write unless an operator made an explicit decision during I/O. */
52
+ finalizeUntrustedWrite(assetId: string, source: ProvenanceSource): ProvenanceRecord;
53
+ /** Stage an unverified write without overwriting an explicit trust decision or another conservative marker. */
54
+ stageUnverifiedWrite(assetId: string, source: ProvenanceSource, frozenContentId: string): ProvenanceRecord;
55
+ stageUnverifiedWriteTracked(assetId: string, source: ProvenanceSource, frozenContentId: string): {
56
+ record: ProvenanceRecord;
57
+ appended: boolean;
58
+ };
59
+ /** Atomically replace only a pending/no decision with the health-waiver reason after verified persistence. */
60
+ finalizeUnverifiedWrite(assetId: string, source: ProvenanceSource, reason: string, frozenContentId: string): ProvenanceRecord;
39
61
  rollbackLast(rec: ProvenanceRecord): void;
40
62
  get(assetId: string): ProvenanceRecord | null;
41
63
  /** No record → trusted (local default); a record → its trusted flag. */
@@ -67,6 +89,8 @@ export declare function ingestUntrusted(store: AssetStoreProvider, prov: Provena
67
89
  * audited promotion. `reason` records WHY verification was waived (e.g. hub rewrite vs synthesized payload).
68
90
  */
69
91
  export declare function ingestUnverified(store: AssetStoreProvider, prov: ProvenanceStore, record: AssetRecord, reason: string, source?: ProvenanceSource): Promise<PutResult>;
92
+ /** Atomic frozen variant used by reuse so the logical-id check and append share one provider lock. */
93
+ export declare function ingestUnverifiedConditional(store: AssetStoreProvider, prov: ProvenanceStore, record: AssetRecord, reason: string, options?: ConditionalPutOptions, source?: ProvenanceSource): Promise<ConditionalPutResult>;
70
94
  /**
71
95
  * Conditional variant used by Hub sync to reject a logical-id collision without ever allowing a Hub record
72
96
  * to become implicitly trusted. Providers that cannot make the condition atomically are rejected here.