@dzhechkov/harness-core 0.3.145 → 0.3.146

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -156,11 +156,13 @@ export {
156
156
  RECALL_USAGE_LOG_MAX_BYTES,
157
157
  RECALL_USAGE_COMPACT_TARGET_BYTES,
158
158
  formatRecallUsageRecord,
159
+ buildRecallUsageRecord,
159
160
  parseRecallUsageLog,
160
161
  aggregateRecallUsage,
161
162
  buildRecallUsageReport,
162
163
  shouldCompactRecallUsageLogSize,
163
164
  compactRecallUsageLog,
165
+ compactRecallUsageLogChecked,
164
166
  } from './recall-usage.js';
165
167
  export type {
166
168
  RecallUsageReadRecord,
@@ -171,7 +173,53 @@ export type {
171
173
  RecallPatternUsageRef,
172
174
  RecallUsagePatternRow,
173
175
  RecallUsageReport,
176
+ RecallUsageRecordInput,
177
+ CompactRecallUsageOptions,
178
+ CompactRecallUsageResult,
179
+ CompactRecallUsageStatus,
174
180
  } from './recall-usage.js';
181
+ export {
182
+ EVENT_CHAIN_SCOPE,
183
+ EVENT_CHAIN_GENESIS_HASH,
184
+ EVENT_CHAIN_TAIL_BYTES,
185
+ EVENT_CHAIN_FIELD_OVERHEAD_BYTES,
186
+ EVENT_CHAIN_LEDGER_KIND,
187
+ EVENT_CHAIN_DEFECT_KINDS,
188
+ fnv1a32,
189
+ chainHashOf,
190
+ chainLinesOf,
191
+ lastChainLine,
192
+ readTailInfo,
193
+ appendChainedLines,
194
+ EMPTY_LOG_TAIL,
195
+ nextChainFields,
196
+ withChainFields,
197
+ chainRecordLines,
198
+ chainRewrite,
199
+ defaultEventWeight,
200
+ eventWeightOfText,
201
+ verifyEventChain,
202
+ verifyEventChainText,
203
+ renderEventChainVerification,
204
+ rewriteSnapshot,
205
+ rewriteSnapshotUnchanged,
206
+ guardedRewrite,
207
+ DEFAULT_REWRITE_ATTEMPTS,
208
+ } from './event-chain.js';
209
+ export type {
210
+ ChainFields,
211
+ LogTail,
212
+ EventChainLedger,
213
+ RewriteSnapshot,
214
+ GuardedRewriteIo,
215
+ GuardedRewriteResult,
216
+ GuardedRewriteStatus,
217
+ RewriteProposal,
218
+ EventChainDefect,
219
+ EventChainDefectKind,
220
+ EventChainVerification,
221
+ VerifyEventChainOptions,
222
+ } from './event-chain.js';
175
223
  export { decideProvenance, environmentCanMintProvenance, publishArgv, discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, orderByDependencies, syncReadmeVersion } from './publish.js';
176
224
  export { fetchAllDownloads } from './downloads.js';
177
225
  export type { PackageDownloads, DownloadsReport } from './downloads.js';
package/src/operations.ts CHANGED
@@ -788,6 +788,31 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
788
788
  } catch { /* either side absent — covered by other checks */ }
789
789
  }
790
790
 
791
+ // 8b. EVIDENCE-CHAIN INTEGRITY (feature event-chain, ADR-001). `.dz/recall-usage.jsonl` and
792
+ // `.dz/guard-audit.jsonl` are what `dz compounding` and `dz guard promote` decide on; a rewrite
793
+ // that loses or duplicates a record there is a wrong verdict with no symptom. Deliberately OUTSIDE
794
+ // the agentdb-writer branch — the evidence base exists whether or not that writer is deployed.
795
+ // Silent when a log is absent or has never been chained: an unchained file is legal (FR-5), not a
796
+ // fault, and reporting it would train the reader to ignore this line.
797
+ try {
798
+ const { verifyEventChainText, EVENT_CHAIN_SCOPE } = await import('./event-chain.js');
799
+ for (const rel of ['recall-usage.jsonl', 'guard-audit.jsonl']) {
800
+ const p = join(root, '.dz', rel);
801
+ if (!existsSync(p)) continue;
802
+ const v = verifyEventChainText(readFileSync(p, 'utf-8'));
803
+ if (v.chained === 0 || v.ok) continue;
804
+ checks.push({
805
+ name: `evidence chain (.dz/${rel})`,
806
+ ok: false,
807
+ detail:
808
+ `${v.defects.length} defect(s): ${v.defects.slice(0, 3).map((d) => `${d.kind}@L${d.line}`).join(', ')}` +
809
+ ` — learning verdicts computed from this log are unsafe. Scope: ${EVENT_CHAIN_SCOPE}`,
810
+ });
811
+ }
812
+ } catch {
813
+ /* doctor never throws on a diagnostic */
814
+ }
815
+
791
816
  // 9. Vector-tier mirror divergence (dz-rvf-vector-bridge FR-2/ADR R4). INFORMATIONAL, never an
792
817
  // error exit: lexical is the source of truth and `dz consolidate` backfills the mirror. Per
793
818
  // Constraint 7 (QR-10) the line reports BOTH counts so a diverged mirror is not misdiagnosed
@@ -6,9 +6,22 @@
6
6
  * into aggregate JSONL rows when it crosses a bounded size. It deliberately knows nothing about the
7
7
  * filesystem; callers own reads/writes so the hook and statusline can keep their never-block rules.
8
8
  *
9
+ * Compaction RE-CHAINS everything it writes and records what it measured in its input, so that a
10
+ * rewrite which counts an event twice fails `verifyEventChain` instead of producing a well-formed
11
+ * lie — the 2 → 4 → 6 defect below is the reason (see `event-chain.ts`, ADR-002).
12
+ *
9
13
  * @packageDocumentation
10
14
  */
11
15
 
16
+ import {
17
+ EVENT_CHAIN_FIELD_OVERHEAD_BYTES,
18
+ EVENT_CHAIN_LEDGER_KIND,
19
+ chainRewrite,
20
+ defaultEventWeight,
21
+ verifyEventChainText,
22
+ type EventChainDefect,
23
+ } from './event-chain.js';
24
+
12
25
  export const RECALL_USAGE_LOG_RELATIVE = '.dz/recall-usage.jsonl';
13
26
  export const RECALL_USAGE_LOG_MAX_BYTES = 1_048_576;
14
27
  export const RECALL_USAGE_COMPACT_TARGET_BYTES = Math.floor(RECALL_USAGE_LOG_MAX_BYTES * 0.75);
@@ -104,7 +117,7 @@ interface Acc {
104
117
  totalScore: number;
105
118
  }
106
119
 
107
- export function formatRecallUsageRecord(input: {
120
+ export interface RecallUsageRecordInput {
108
121
  readonly dzId?: unknown;
109
122
  readonly score?: unknown;
110
123
  readonly ts?: unknown;
@@ -112,8 +125,18 @@ export function formatRecallUsageRecord(input: {
112
125
  readonly runId?: unknown;
113
126
  readonly eventId?: unknown;
114
127
  readonly queryTruncated?: unknown;
115
- }): string | undefined {
116
- const rec = normalizeReadRecord(input);
128
+ }
129
+
130
+ /**
131
+ * The normalized RECORD, before serialization — the writer needs the object so it can hang the
132
+ * event-chain fields off it (`seq`/`prevHash`, ADR-001) instead of string-splicing a finished line.
133
+ */
134
+ export function buildRecallUsageRecord(input: RecallUsageRecordInput): RecallUsageReadRecord | undefined {
135
+ return normalizeReadRecord(input);
136
+ }
137
+
138
+ export function formatRecallUsageRecord(input: RecallUsageRecordInput): string | undefined {
139
+ const rec = buildRecallUsageRecord(input);
117
140
  return rec === undefined ? undefined : `${JSON.stringify(rec)}\n`;
118
141
  }
119
142
 
@@ -125,6 +148,10 @@ export function parseRecallUsageLog(text: string): ParsedRecallUsageLog {
125
148
  if (trimmed === '') continue;
126
149
  try {
127
150
  const parsed = JSON.parse(trimmed) as unknown;
151
+ // The compaction ledger (ADR-002) is chain bookkeeping, not a usage record. Counting it as an
152
+ // invalid line would make `invalidLines` — a health number the report prints — lie by one per
153
+ // compaction generation.
154
+ if (isRecord(parsed) && parsed['kind'] === EVENT_CHAIN_LEDGER_KIND) continue;
128
155
  const record = normalizeRecord(parsed);
129
156
  if (record === undefined) {
130
157
  invalidLines += 1;
@@ -213,10 +240,68 @@ export function shouldCompactRecallUsageLogSize(
213
240
  return Number.isFinite(sizeBytes) && sizeBytes > validMax(maxBytes);
214
241
  }
215
242
 
216
- export function compactRecallUsageLog(
243
+ export interface CompactRecallUsageOptions {
244
+ readonly maxBytes?: number;
245
+ readonly targetBytes?: number;
246
+ readonly compactedAt?: string;
247
+ /**
248
+ * Compact even when the input's chain is already defective. OFF by default and never set by any
249
+ * automatic caller — see {@link compactRecallUsageLogChecked} for why.
250
+ */
251
+ readonly force?: boolean;
252
+ }
253
+
254
+ export type CompactRecallUsageStatus = 'compacted' | 'refused-dirty' | 'too-large';
255
+
256
+ export interface CompactRecallUsageResult {
257
+ readonly status: CompactRecallUsageStatus;
258
+ /** Empty unless `status === 'compacted'`. */
259
+ readonly text: string;
260
+ /** The input defects that caused a refusal. */
261
+ readonly defects: readonly EventChainDefect[];
262
+ }
263
+
264
+ /**
265
+ * Compaction with its verdict attached.
266
+ *
267
+ * AM-2 (Codex QE HIGH-2) — A REWRITER MUST NOT LAUNDER. Compaction parses the input, drops what it
268
+ * cannot read and re-chains from genesis, so a file carrying a `BrokenLink` or a `DoubleCounted`
269
+ * came out the other side verifying `ok: true`. The strongest evidence check in the system was
270
+ * being erased by the routine that runs automatically at a size threshold — corruption converted
271
+ * into a clean chain, with no record that it ever existed.
272
+ *
273
+ * So: the input is VERIFIED FIRST, and a defective chained region REFUSES. The pre-chain prefix is
274
+ * legal and never blocks anything (FR-5); only real defects do.
275
+ *
276
+ * ACCEPTED CONSEQUENCE, stated because it is the cost: a log that stays defective stops being
277
+ * compacted and grows past its cap. That is the right way round — the size cap is a convenience,
278
+ * the evidence is the product — and it is not silent: `dz doctor` and `dz compounding` both report
279
+ * the chain defect, and the caller logs the refusal.
280
+ */
281
+ export function compactRecallUsageLogChecked(
217
282
  text: string,
218
- opts: { readonly maxBytes?: number; readonly targetBytes?: number; readonly compactedAt?: string } = {},
219
- ): string {
283
+ opts: CompactRecallUsageOptions = {},
284
+ ): CompactRecallUsageResult {
285
+ if (opts.force !== true) {
286
+ const v = verifyEventChainText(typeof text === 'string' ? text : '');
287
+ if (!v.ok) return { status: 'refused-dirty', text: '', defects: v.defects };
288
+ }
289
+ const out = compactVerifiedRecallUsageLog(text, opts);
290
+ return out === ''
291
+ ? { status: 'too-large', text: '', defects: [] }
292
+ : { status: 'compacted', text: out, defects: [] };
293
+ }
294
+
295
+ /**
296
+ * Back-compatible wrapper: the compacted text, or `''` when the rewrite is REFUSED (a defective
297
+ * input) or cannot fit. Callers that need to tell those apart use
298
+ * {@link compactRecallUsageLogChecked}.
299
+ */
300
+ export function compactRecallUsageLog(text: string, opts: CompactRecallUsageOptions = {}): string {
301
+ return compactRecallUsageLogChecked(text, opts).text;
302
+ }
303
+
304
+ function compactVerifiedRecallUsageLog(text: string, opts: CompactRecallUsageOptions): string {
220
305
  const maxBytes = validMax(opts.maxBytes ?? RECALL_USAGE_LOG_MAX_BYTES);
221
306
  const targetBytes = validTarget(opts.targetBytes ?? Math.floor(maxBytes * 0.75), maxBytes);
222
307
  const compactedAt = validTs(opts.compactedAt) ? opts.compactedAt : new Date(0).toISOString();
@@ -233,23 +318,57 @@ export function compactRecallUsageLog(
233
318
  const toAggregate = parsed.filter((r) => !retained.has(r as RecallUsageReadRecord));
234
319
  const stats = aggregateRecallUsage(toAggregate);
235
320
  // Replay rows are budgeted FIRST: they are irreplaceable (the corpus), aggregates are re-derivable.
236
- const lines = [...replayRows.map((r) => JSON.stringify(r)), ...stats.map((s) => aggregateLine(s, compactedAt))];
237
- let out = joinLines(lines);
238
- if (byteLength(out) <= maxBytes) return out;
239
-
240
- const kept: string[] = [];
241
- let used = 0;
242
- for (const line of lines) {
243
- const cost = byteLength(`${line}\n`);
244
- if (kept.length > 0 && used + cost > targetBytes) continue;
245
- if (cost > maxBytes) continue;
246
- if (used + cost <= maxBytes) {
247
- kept.push(line);
248
- used += cost;
321
+ const candidates: RecallUsageRecord[] = [...replayRows, ...stats.map((s) => aggregateRecord(s, compactedAt))];
322
+
323
+ // The event weight of the INPUT, measured before aggregation. It must come from the input — a
324
+ // total derived from the output could never disagree with it (ADR-002), which is exactly how a
325
+ // double-counting rewrite went unseen until a human re-read the code.
326
+ let sourceEvents = 0;
327
+ for (const rec of parsed) sourceEvents += defaultEventWeight(rec as unknown as Record<string, unknown>);
328
+
329
+ // SELECT, then chain (ADR-002 decision 5): trimming a chain after building it punches holes in it.
330
+ // Each candidate is charged its own bytes plus a fixed allowance for the chain fields it will get.
331
+ let selected = candidates;
332
+ if (byteLength(joinLines(candidates.map(serializeWithChainAllowance))) > maxBytes) {
333
+ const kept: RecallUsageRecord[] = [];
334
+ let used = 0;
335
+ for (const rec of candidates) {
336
+ const cost = byteLength(`${serializeWithChainAllowance(rec)}\n`);
337
+ if (kept.length > 0 && used + cost > targetBytes) continue;
338
+ if (cost > maxBytes) continue;
339
+ if (used + cost <= maxBytes) {
340
+ kept.push(rec);
341
+ used += cost;
342
+ }
249
343
  }
344
+ selected = kept;
345
+ }
346
+
347
+ // Final shrink: drop from the TAIL and re-chain, so the survivors are always a valid chain.
348
+ for (;;) {
349
+ const out = joinLines(chainRewrite(selected, { sourceEvents, droppedEvents: droppedEvents(sourceEvents, selected), compactedAt }));
350
+ if (byteLength(out) <= maxBytes) return out;
351
+ if (selected.length === 0) return '';
352
+ selected = selected.slice(0, -1);
250
353
  }
251
- out = joinLines(kept);
252
- return byteLength(out) <= maxBytes ? out : '';
354
+ }
355
+
356
+ /**
357
+ * What the byte budget discarded — so a deliberate trim is never mistaken for lost records.
358
+ *
359
+ * The `Math.max(0, …)` is LOAD-BEARING, not defensive tidiness: it is what stops a rewrite that
360
+ * emits MORE events than it read from explaining its own inflation away with a negative "dropped"
361
+ * figure. Over-accounting therefore always reaches {@link verifyEventChain} as `DoubleCounted`.
362
+ */
363
+ function droppedEvents(sourceEvents: number, selected: readonly RecallUsageRecord[]): number {
364
+ let accounted = 0;
365
+ for (const rec of selected) accounted += defaultEventWeight(rec as unknown as Record<string, unknown>);
366
+ return Math.max(0, sourceEvents - accounted);
367
+ }
368
+
369
+ /** A record's serialized size plus the allowance for the chain fields it will carry. */
370
+ function serializeWithChainAllowance(rec: RecallUsageRecord): string {
371
+ return JSON.stringify(rec) + ' '.repeat(EVENT_CHAIN_FIELD_OVERHEAD_BYTES);
253
372
  }
254
373
 
255
374
  function normalizeRecord(value: unknown): RecallUsageRecord | undefined {
@@ -356,8 +475,8 @@ function mergeAggregate(byId: Map<string, Acc>, rec: RecallUsageAggregateRecord)
356
475
  }
357
476
  }
358
477
 
359
- function aggregateLine(stat: RecallUsageStat, compactedAt: string): string {
360
- const rec: RecallUsageAggregateRecord = {
478
+ function aggregateRecord(stat: RecallUsageStat, compactedAt: string): RecallUsageAggregateRecord {
479
+ return {
361
480
  kind: 'aggregate',
362
481
  dzId: stat.dzId,
363
482
  reads: stat.reads,
@@ -367,7 +486,6 @@ function aggregateLine(stat: RecallUsageStat, compactedAt: string): string {
367
486
  totalScore: stat.avgScore * stat.reads,
368
487
  compactedAt,
369
488
  };
370
- return JSON.stringify(rec);
371
489
  }
372
490
 
373
491
  function patternRef(p: RecallPatternUsageRef): RecallPatternUsageRef {