@dzhechkov/harness-core 0.8.30 → 0.8.31
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/.dz-manifest.json +233 -93
- package/README.md +269 -0
- package/dist/agentdb-index.d.ts +45 -0
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +345 -85
- package/dist/agentdb-index.js.map +1 -1
- package/dist/agentdb-reindex-marker.d.ts +170 -0
- package/dist/agentdb-reindex-marker.d.ts.map +1 -0
- package/dist/agentdb-reindex-marker.js +293 -0
- package/dist/agentdb-reindex-marker.js.map +1 -0
- package/dist/agentdb-snapshot-rotation.d.ts +169 -0
- package/dist/agentdb-snapshot-rotation.d.ts.map +1 -0
- package/dist/agentdb-snapshot-rotation.js +322 -0
- package/dist/agentdb-snapshot-rotation.js.map +1 -0
- package/dist/agentdb-snapshot.d.ts +126 -0
- package/dist/agentdb-snapshot.d.ts.map +1 -0
- package/dist/agentdb-snapshot.js +195 -0
- package/dist/agentdb-snapshot.js.map +1 -0
- package/dist/apply-leg.d.ts +202 -0
- package/dist/apply-leg.d.ts.map +1 -0
- package/dist/apply-leg.js +975 -0
- package/dist/apply-leg.js.map +1 -0
- package/dist/book-kb.d.ts.map +1 -1
- package/dist/book-kb.js +57 -1
- package/dist/book-kb.js.map +1 -1
- package/dist/brain.d.ts +3 -0
- package/dist/brain.d.ts.map +1 -1
- package/dist/brain.js +1 -1
- package/dist/brain.js.map +1 -1
- package/dist/cadence.d.ts +30 -1
- package/dist/cadence.d.ts.map +1 -1
- package/dist/cadence.js +66 -2
- package/dist/cadence.js.map +1 -1
- package/dist/guard.d.ts +14 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +36 -0
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +23 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -5
- package/dist/index.js.map +1 -1
- package/dist/integration-probe-worker.d.ts.map +1 -1
- package/dist/integration-probe-worker.js +4 -6
- package/dist/integration-probe-worker.js.map +1 -1
- package/dist/integrations-verify.d.ts.map +1 -1
- package/dist/integrations-verify.js +3 -1
- package/dist/integrations-verify.js.map +1 -1
- package/dist/managed-hooks.d.ts +11 -2
- package/dist/managed-hooks.d.ts.map +1 -1
- package/dist/managed-hooks.js +33 -7
- package/dist/managed-hooks.js.map +1 -1
- package/dist/mutation-gate.d.ts +35 -1
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +49 -9
- package/dist/mutation-gate.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +94 -0
- package/dist/operations.js.map +1 -1
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +23 -4
- package/dist/patterns.js.map +1 -1
- package/dist/repository-origin.d.ts +1 -1
- package/dist/repository-origin.d.ts.map +1 -1
- package/dist/repository-origin.js +7 -1
- package/dist/repository-origin.js.map +1 -1
- package/dist/round-exec.d.ts +41 -0
- package/dist/round-exec.d.ts.map +1 -0
- package/dist/round-exec.js +45 -0
- package/dist/round-exec.js.map +1 -0
- package/dist/round.d.ts +109 -0
- package/dist/round.d.ts.map +1 -0
- package/dist/round.js +148 -0
- package/dist/round.js.map +1 -0
- package/dist/run-records.d.ts +8 -0
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +69 -3
- package/dist/run-records.js.map +1 -1
- package/dist/setup.d.ts +8 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +166 -3
- package/dist/setup.js.map +1 -1
- package/dist/sqlite-read-helpers.d.ts +60 -0
- package/dist/sqlite-read-helpers.d.ts.map +1 -0
- package/dist/sqlite-read-helpers.js +118 -0
- package/dist/sqlite-read-helpers.js.map +1 -0
- package/dist/statusline.d.ts +20 -3
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +40 -7
- package/dist/statusline.js.map +1 -1
- package/dist/store-counts.d.ts +30 -0
- package/dist/store-counts.d.ts.map +1 -1
- package/dist/store-counts.js +189 -0
- package/dist/store-counts.js.map +1 -1
- package/dist/usage.d.ts +55 -0
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +184 -33
- package/dist/usage.js.map +1 -1
- package/dist/vector-tier.d.ts +18 -1
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +26 -2
- package/dist/vector-tier.js.map +1 -1
- package/package.json +6 -6
- package/sbom.json +442 -92
- package/src/agentdb-index.ts +315 -32
- package/src/agentdb-reindex-marker.ts +330 -0
- package/src/agentdb-snapshot-rotation.ts +393 -0
- package/src/agentdb-snapshot.ts +222 -0
- package/src/apply-leg.ts +1046 -0
- package/src/book-kb.ts +64 -1
- package/src/brain.ts +11 -2
- package/src/cadence.ts +87 -2
- package/src/guard.ts +46 -0
- package/src/index.ts +58 -5
- package/src/integration-probe-worker.ts +4 -6
- package/src/integrations-verify.ts +3 -1
- package/src/managed-hooks.ts +32 -7
- package/src/mutation-gate.ts +87 -8
- package/src/operations.ts +89 -0
- package/src/patterns.ts +23 -4
- package/src/repository-origin.ts +7 -1
- package/src/round-exec.ts +85 -0
- package/src/round.ts +228 -0
- package/src/run-records.ts +77 -2
- package/src/setup.ts +198 -3
- package/src/sqlite-read-helpers.ts +128 -0
- package/src/statusline.ts +57 -9
- package/src/store-counts.ts +245 -1
- package/src/usage.ts +243 -29
- package/src/vector-tier.ts +45 -4
package/src/book-kb.ts
CHANGED
|
@@ -16,7 +16,10 @@ import { existsSync, mkdirSync } from 'node:fs';
|
|
|
16
16
|
import { pathToFileURL } from 'node:url';
|
|
17
17
|
import { createRequire } from 'node:module';
|
|
18
18
|
|
|
19
|
+
import { openSqliteReadOnly } from '@dzhechkov/memory';
|
|
20
|
+
|
|
19
21
|
import { describeNativeDep, exerciseSqliteOpen, probeNativeDep } from './native-dep-probe.js';
|
|
22
|
+
import { applyReadonlyPragmas } from './sqlite-read-helpers.js';
|
|
20
23
|
|
|
21
24
|
/** A digitized Knowledge Unit as stored in the book KB. */
|
|
22
25
|
export interface BookKU {
|
|
@@ -98,6 +101,66 @@ async function openDb(projectRoot: string, dbPath?: string): Promise<NativeDb |
|
|
|
98
101
|
return db;
|
|
99
102
|
}
|
|
100
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Open `books.sqlite` for READING ONLY (ADR-001, Решение 2) — no `mkdirSync`, no `exec(SCHEMA)`,
|
|
106
|
+
* no `pragma journal_mode`. Same native-dependency probe and resolution as {@link openDb} above
|
|
107
|
+
* (a silently different `better-sqlite3` module instance would be the class of bug
|
|
108
|
+
* `sqlite-readonly.ts`'s `OpenReadOnlyOptions` doc warns about), so `absent`/`unusable` still
|
|
109
|
+
* return `{error}` — the "instrument did not run" contract is unchanged.
|
|
110
|
+
*
|
|
111
|
+
* Critical for FR-6/Риск-4: any OTHER failure (a genuinely unreadable store) must still THROW,
|
|
112
|
+
* exactly like `openDb`. This function is called from `queryBookKnowledge` OUTSIDE its `try` —
|
|
113
|
+
* do not wrap the call in a `try` here or there; that would turn "store unreadable" (exit 1)
|
|
114
|
+
* into "instrument didn't run" (exit 3), which is the regression `books-recall-honesty.test.ts`
|
|
115
|
+
* pins against (measured 2026-08-27).
|
|
116
|
+
*/
|
|
117
|
+
async function openDbForRead(projectRoot: string, dbPath?: string): Promise<NativeDb | { error: string }> {
|
|
118
|
+
const verdict = probeNativeDep(projectRoot, 'better-sqlite3', exerciseSqliteOpen, 'sqlite-open');
|
|
119
|
+
if (verdict.state === 'absent') {
|
|
120
|
+
return { error: 'better-sqlite3 not installed in project (run: dz setup --memory agentdb)' };
|
|
121
|
+
}
|
|
122
|
+
if (verdict.state === 'unusable') return { error: describeNativeDep(verdict) };
|
|
123
|
+
let sqliteUrl: string;
|
|
124
|
+
try {
|
|
125
|
+
const req = createRequire(join(projectRoot, 'package.json'));
|
|
126
|
+
sqliteUrl = pathToFileURL(req.resolve('better-sqlite3')).href;
|
|
127
|
+
} catch {
|
|
128
|
+
return { error: 'better-sqlite3 not installed in project (run: dz setup --memory agentdb)' };
|
|
129
|
+
}
|
|
130
|
+
const path = dbPath ?? bookKbPath(projectRoot);
|
|
131
|
+
// NO mkdirSync, NO exec(SCHEMA), NO pragma journal_mode — this is the read path (FR-1); anything
|
|
132
|
+
// past this point that throws is meant to throw (see the doc comment above).
|
|
133
|
+
const { default: Database } = (await import(sqliteUrl)) as {
|
|
134
|
+
default: new (p: string, o?: { readonly?: boolean; fileMustExist?: boolean }) => unknown;
|
|
135
|
+
};
|
|
136
|
+
const handle = openSqliteReadOnly(path, { Database: Database as never });
|
|
137
|
+
const db = handle.db as {
|
|
138
|
+
pragma: (s: string) => void;
|
|
139
|
+
exec: (s: string) => void;
|
|
140
|
+
prepare: NativeDb['prepare'];
|
|
141
|
+
transaction: NativeDb['transaction'];
|
|
142
|
+
close: () => void;
|
|
143
|
+
};
|
|
144
|
+
// FR-1 (readonly-residuals): a throwing pragma must not leak the connection or a tmp-copy —
|
|
145
|
+
// applyReadonlyPragmas closes + cleans up before rethrowing.
|
|
146
|
+
applyReadonlyPragmas(handle, path);
|
|
147
|
+
return {
|
|
148
|
+
pragma: db.pragma.bind(db),
|
|
149
|
+
exec: db.exec.bind(db),
|
|
150
|
+
prepare: db.prepare.bind(db),
|
|
151
|
+
transaction: db.transaction.bind(db),
|
|
152
|
+
close: () => {
|
|
153
|
+
// `cleanup()` in `finally` — the tmp-copy must be removed even if `db.close()` throws
|
|
154
|
+
// (fix round 1, MEDIUM #3; matches `SqliteReadOnlyStore.close()` in `sqlite-readonly.ts`).
|
|
155
|
+
try {
|
|
156
|
+
db.close();
|
|
157
|
+
} finally {
|
|
158
|
+
handle.cleanup();
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
101
164
|
/**
|
|
102
165
|
* Upsert a batch of KUs for a book. Idempotent per (book, kuId): existing rows for the same
|
|
103
166
|
* kuId are replaced, and — since a re-ingest changes `corpusVersion` — every row for the book
|
|
@@ -164,7 +227,7 @@ export async function queryBookKnowledge(
|
|
|
164
227
|
): Promise<{ hits: BookKUHit[]; error?: string }> {
|
|
165
228
|
const path = opts.dbPath ?? bookKbPath(projectRoot);
|
|
166
229
|
if (!existsSync(path)) return { hits: [] };
|
|
167
|
-
const opened = await
|
|
230
|
+
const opened = await openDbForRead(projectRoot, opts.dbPath);
|
|
168
231
|
if ('error' in opened) return { hits: [], error: opened.error };
|
|
169
232
|
const db = opened;
|
|
170
233
|
try {
|
package/src/brain.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { createRequire } from 'node:module';
|
|
|
23
23
|
import { spawn } from 'node:child_process';
|
|
24
24
|
import { putBookKnowledge, queryBookKnowledge, bookKbPath, type BookKU, type BookKUHit } from './book-kb.js';
|
|
25
25
|
import { indexPatternsToAgentdb, searchAgentdbPatterns, reindexAgentdbRows, type AgentdbRow } from './agentdb-index.js';
|
|
26
|
+
import type { SnapshotRotationReport } from './agentdb-snapshot-rotation.js';
|
|
26
27
|
import { resolveEmbedModel, type EmbedModelConfig } from './embedding-config.js';
|
|
27
28
|
|
|
28
29
|
// ─────────────────────────────────────── Home & paths ───────────────────────────────────────
|
|
@@ -992,7 +993,7 @@ await emb.initialize();
|
|
|
992
993
|
await emb.embed('warm embedding model cache');
|
|
993
994
|
`;
|
|
994
995
|
try {
|
|
995
|
-
const nodeBin =
|
|
996
|
+
const nodeBin = process.execPath;
|
|
996
997
|
const child = spawn(nodeBin, ['--input-type=module', '-e', script], {
|
|
997
998
|
cwd: depsRoot,
|
|
998
999
|
detached: true,
|
|
@@ -1066,7 +1067,15 @@ export async function searchBrainVectors(opts: {
|
|
|
1066
1067
|
export async function reindexBrainVectors(opts: {
|
|
1067
1068
|
brainHome?: string;
|
|
1068
1069
|
depsRoot?: string;
|
|
1069
|
-
}): Promise<{
|
|
1070
|
+
}): Promise<{
|
|
1071
|
+
reembedded: number;
|
|
1072
|
+
model?: string;
|
|
1073
|
+
version?: number;
|
|
1074
|
+
backupPath?: string;
|
|
1075
|
+
error?: string;
|
|
1076
|
+
/** Pre-reindex snapshot rotation outcome — forwarded verbatim from `reindexAgentdbRows` (FR-4). */
|
|
1077
|
+
snapshots?: SnapshotRotationReport;
|
|
1078
|
+
}> {
|
|
1070
1079
|
const home = opts.brainHome ?? brainHome();
|
|
1071
1080
|
const depsRoot = opts.depsRoot ?? process.cwd();
|
|
1072
1081
|
const read = readBookKus({ storePath: brainBooksPath(home), depsRoot });
|
package/src/cadence.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* caught a «year» digest standing on 174 days of data — scale forgery by aggregation; an
|
|
5
5
|
* aggregator that silently computes any requested period repeats it mechanically.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* Five sources, every degradation NAMED in the report, never a silent zero:
|
|
8
8
|
* - graded shipments: features/<slug>/08_qe_report.md through the hardened readQeGrade
|
|
9
9
|
* (prefix-negation aware, all measured real-world grade forms); ungraded reports are a COLUMN;
|
|
10
10
|
* - npm publishes: the dz recap registry-time cache (third-party timestamps);
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* start (the data-driven birth proxy — the no-stubs class of «zero repeats because the rule is
|
|
13
13
|
* young» is excluded by construction);
|
|
14
14
|
* - knowledge reuse: recall events per bucket from .dz/recall-usage.jsonl.
|
|
15
|
+
* - focused round outcomes and execution receipts from the run-cost ledger; package commit counts
|
|
16
|
+
* are injected by the CLI so this module never acquires a git/process dependency.
|
|
15
17
|
*/
|
|
16
18
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
17
19
|
import { join } from 'node:path';
|
|
@@ -76,6 +78,80 @@ export function weeklyBuckets(events: readonly CadenceEvent[], windowStartMs: nu
|
|
|
76
78
|
|
|
77
79
|
export interface GuardDecayRow { readonly rule: string; readonly before: number; readonly inWindow: number }
|
|
78
80
|
|
|
81
|
+
const ROUND_OUTCOMES = [
|
|
82
|
+
'shipped', 'refuted', 'blocked', 'abandoned',
|
|
83
|
+
'done', 'timeout', 'session-limit', 'model-refused', 'failed', 'empty',
|
|
84
|
+
] as const;
|
|
85
|
+
type RoundSummaryOutcome = typeof ROUND_OUTCOMES[number];
|
|
86
|
+
|
|
87
|
+
export interface RoundsSummary {
|
|
88
|
+
readonly total: number;
|
|
89
|
+
readonly byStage: Readonly<Record<'round' | 'round-exec', number>>;
|
|
90
|
+
readonly byOutcome: Readonly<Record<RoundSummaryOutcome, number>>;
|
|
91
|
+
readonly unfinished: readonly {
|
|
92
|
+
readonly slug: string;
|
|
93
|
+
readonly round: number;
|
|
94
|
+
readonly stage: 'round' | 'round-exec';
|
|
95
|
+
readonly outcome: string;
|
|
96
|
+
readonly reason: string | null;
|
|
97
|
+
readonly date: string;
|
|
98
|
+
}[];
|
|
99
|
+
readonly minutes: number;
|
|
100
|
+
readonly tokens: number;
|
|
101
|
+
readonly tokensUnknown: number;
|
|
102
|
+
readonly malformed: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Pure round-ledger projection over a closed time window. Unknown outcomes stay visible. */
|
|
106
|
+
export function summarizeRounds(
|
|
107
|
+
rows: readonly unknown[],
|
|
108
|
+
window: { readonly windowStartMs: number; readonly nowMs: number },
|
|
109
|
+
): RoundsSummary {
|
|
110
|
+
const byStage: Record<'round' | 'round-exec', number> = { round: 0, 'round-exec': 0 };
|
|
111
|
+
const byOutcome = Object.fromEntries(ROUND_OUTCOMES.map((outcome) => [outcome, 0])) as Record<RoundSummaryOutcome, number>;
|
|
112
|
+
const unfinished: Array<RoundsSummary['unfinished'][number]> = [];
|
|
113
|
+
let total = 0;
|
|
114
|
+
let minutes = 0;
|
|
115
|
+
let tokens = 0;
|
|
116
|
+
let tokensUnknown = 0;
|
|
117
|
+
let malformed = 0;
|
|
118
|
+
|
|
119
|
+
for (const candidate of rows) {
|
|
120
|
+
if (candidate === null || typeof candidate !== 'object' || Array.isArray(candidate)) continue;
|
|
121
|
+
const row = candidate as Record<string, unknown>;
|
|
122
|
+
const stage = row['stage'];
|
|
123
|
+
if (stage !== 'round' && stage !== 'round-exec') continue;
|
|
124
|
+
const date = typeof row['date'] === 'string' ? row['date'] : '';
|
|
125
|
+
const ts = Date.parse(date);
|
|
126
|
+
if (!Number.isFinite(ts) || ts < window.windowStartMs || ts > window.nowMs) continue;
|
|
127
|
+
|
|
128
|
+
total += 1;
|
|
129
|
+
byStage[stage] += 1;
|
|
130
|
+
const rawOutcome = typeof row['outcome'] === 'string' ? row['outcome'].trim() : '';
|
|
131
|
+
if ((ROUND_OUTCOMES as readonly string[]).includes(rawOutcome)) {
|
|
132
|
+
byOutcome[rawOutcome as RoundSummaryOutcome] += 1;
|
|
133
|
+
} else {
|
|
134
|
+
malformed += 1;
|
|
135
|
+
}
|
|
136
|
+
if (typeof row['minutes'] === 'number' && Number.isFinite(row['minutes']) && row['minutes'] >= 0) minutes += row['minutes'];
|
|
137
|
+
if (typeof row['tokens'] === 'number' && Number.isFinite(row['tokens']) && row['tokens'] >= 0) tokens += row['tokens'];
|
|
138
|
+
else tokensUnknown += 1;
|
|
139
|
+
|
|
140
|
+
if (rawOutcome !== 'shipped' && rawOutcome !== 'done') {
|
|
141
|
+
const reason = typeof row['reason'] === 'string' && row['reason'].trim() !== '' ? row['reason'].trim() : null;
|
|
142
|
+
unfinished.push({
|
|
143
|
+
slug: typeof row['slug'] === 'string' && row['slug'].trim() !== '' ? row['slug'].trim() : 'unknown',
|
|
144
|
+
round: typeof row['round'] === 'number' && Number.isInteger(row['round']) ? row['round'] : 0,
|
|
145
|
+
stage,
|
|
146
|
+
outcome: rawOutcome === '' ? 'malformed' : rawOutcome,
|
|
147
|
+
reason,
|
|
148
|
+
date,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { total, byStage, byOutcome, unfinished, minutes, tokens, tokensUnknown, malformed };
|
|
153
|
+
}
|
|
154
|
+
|
|
79
155
|
/**
|
|
80
156
|
* Repeat decay over the FIXED set: only rules with at least one event BEFORE the window start
|
|
81
157
|
* qualify (their existence predates the window); newborn rules are EXCLUDED by construction and
|
|
@@ -107,6 +183,7 @@ export interface CadenceReport {
|
|
|
107
183
|
readonly npmPublishes: { weekly: Record<string, number>; degraded: string | null };
|
|
108
184
|
readonly guard: { decay: GuardDecayRow[]; excludedNewborn: string[]; degraded: string | null };
|
|
109
185
|
readonly recalls: { weekly: Record<string, number>; degraded: string | null };
|
|
186
|
+
readonly rounds: RoundsSummary & { readonly commitsInWindow: number | null; readonly degraded: string | null };
|
|
110
187
|
}
|
|
111
188
|
|
|
112
189
|
function safeJsonl(path: string): unknown[] {
|
|
@@ -121,7 +198,7 @@ function safeJsonl(path: string): unknown[] {
|
|
|
121
198
|
}
|
|
122
199
|
|
|
123
200
|
/** Build the full report. `now` injectable — the refusal decision must be testable. */
|
|
124
|
-
export function buildCadenceReport(root: string, window: CadenceWindow, now?: number): CadenceReport {
|
|
201
|
+
export function buildCadenceReport(root: string, window: CadenceWindow, now?: number, commitsInWindow?: number | null): CadenceReport {
|
|
125
202
|
const nowMs = typeof now === 'number' && isFinite(now) ? now : Date.now();
|
|
126
203
|
|
|
127
204
|
// Shipment events: graded 08 reports, dated by the run-cost ledger (fallback: report mtime is
|
|
@@ -216,6 +293,13 @@ export function buildCadenceReport(root: string, window: CadenceWindow, now?: nu
|
|
|
216
293
|
recallWeekly[wk] = (recallWeekly[wk] ?? 0) + 1;
|
|
217
294
|
}
|
|
218
295
|
const recallDegraded = recallRows.length === 0 ? 'no recall-usage events — the reuse leg has nothing to stand on' : null;
|
|
296
|
+
const rounds = summarizeRounds(ledger, { windowStartMs: windowStart, nowMs });
|
|
297
|
+
const roundCommits = typeof commitsInWindow === 'number' && Number.isFinite(commitsInWindow) && commitsInWindow >= 0
|
|
298
|
+
? commitsInWindow
|
|
299
|
+
: null;
|
|
300
|
+
const roundsDegraded = commitsInWindow === null
|
|
301
|
+
? 'git unavailable — commits(packages/) not measured'
|
|
302
|
+
: null;
|
|
219
303
|
|
|
220
304
|
return {
|
|
221
305
|
window, decision, depthDays,
|
|
@@ -223,5 +307,6 @@ export function buildCadenceReport(root: string, window: CadenceWindow, now?: nu
|
|
|
223
307
|
npmPublishes: { weekly: npmWeekly, degraded: npmDegraded },
|
|
224
308
|
guard: { ...guard, degraded: guardDegraded },
|
|
225
309
|
recalls: { weekly: recallWeekly, degraded: recallDegraded },
|
|
310
|
+
rounds: { ...rounds, commitsInWindow: roundCommits, degraded: roundsDegraded },
|
|
226
311
|
};
|
|
227
312
|
}
|
package/src/guard.ts
CHANGED
|
@@ -80,6 +80,20 @@ export interface GuardResult {
|
|
|
80
80
|
/** Facts the CLI injects; each rule reads only the fields it needs. Missing evidence ⇒ that rule is skipped. */
|
|
81
81
|
export interface GuardFacts {
|
|
82
82
|
readonly op: GuardOp;
|
|
83
|
+
/** Package-code commits since the last round receipt; gathered by the CLI, never by core. */
|
|
84
|
+
readonly codeCommitsSinceLastRound?: {
|
|
85
|
+
readonly commits: number | null;
|
|
86
|
+
readonly since: string | null;
|
|
87
|
+
/** false is the explicit `.dz/config.json` `rounds.traced` waiver. */
|
|
88
|
+
readonly enabled?: boolean;
|
|
89
|
+
};
|
|
90
|
+
/** Open focused rounds are gathered from `.dz/rounds/` by the CLI; core receives facts only. */
|
|
91
|
+
readonly openRounds?: readonly {
|
|
92
|
+
readonly slug: string;
|
|
93
|
+
readonly round: number;
|
|
94
|
+
readonly ageMinutes: number;
|
|
95
|
+
readonly pidAlive: boolean | null;
|
|
96
|
+
}[];
|
|
83
97
|
/** Signature evidence gathered by the CLI. The pure evaluator never reads manifests or keys. */
|
|
84
98
|
readonly signedPacks?: readonly {
|
|
85
99
|
readonly name: string;
|
|
@@ -458,6 +472,8 @@ function inspectSignatureFresh(
|
|
|
458
472
|
|
|
459
473
|
/** The built-in rule set (works with no config). Ops are the mutating operations each rule guards. */
|
|
460
474
|
export const DEFAULT_RULES: readonly GuardRule[] = [
|
|
475
|
+
{ id: 'rounds-closed', severity: 'soft', ops: ['publish'], description: 'focused rounds older than 120 minutes are named before publish; a dead owner is reported as an abandoned round' },
|
|
476
|
+
{ id: 'rounds-traced', severity: 'soft', ops: ['publish'], description: '10 or more package-code commits without a round ledger receipt are named; unavailable git evidence is not a pass' },
|
|
461
477
|
{ id: 'no-workspace-star', severity: 'hard', ops: ['publish'], description: 'a published package.json must carry no workspace:* dep (npm ships it verbatim → the install breaks)' },
|
|
462
478
|
{ id: 'plugin-manifest-audit', severity: 'hard', ops: ['publish'], description: 'every .claude-plugin/plugin.json parses and declares a non-empty name, description and a STRICT N.N.N version' },
|
|
463
479
|
{ id: 'sibling-dep-protocol', severity: 'hard', ops: ['publish'], description: 'a dependencies/devDependencies entry on a sibling @dzhechkov package must use the workspace: protocol on disk (peer/optional deps are deliberately exempt — a range is their point)' },
|
|
@@ -597,6 +613,25 @@ function mentionsSlug(haystackLower: string, slug: string): boolean {
|
|
|
597
613
|
}
|
|
598
614
|
|
|
599
615
|
const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[]> = {
|
|
616
|
+
'rounds-closed': (f, sev) => (f.openRounds ?? [])
|
|
617
|
+
.filter((round) => Number.isFinite(round.ageMinutes) && round.ageMinutes > 120)
|
|
618
|
+
.map((round) => ({
|
|
619
|
+
rule: 'rounds-closed',
|
|
620
|
+
severity: sev,
|
|
621
|
+
detail: round.pidAlive === false
|
|
622
|
+
? `abandoned round ${round.slug}#${round.round}: ${round.ageMinutes} min, owner pid is dead`
|
|
623
|
+
: `${round.slug}#${round.round}: open round ${round.ageMinutes} min, owner pid ${round.pidAlive === true ? 'is alive' : 'liveness is unknown'}`,
|
|
624
|
+
})),
|
|
625
|
+
'rounds-traced': (f, sev) => {
|
|
626
|
+
const fact = f.codeCommitsSinceLastRound;
|
|
627
|
+
if (fact === undefined || fact.enabled === false || fact.commits === null || !Number.isFinite(fact.commits)) return [];
|
|
628
|
+
if (fact.commits < 10) return [];
|
|
629
|
+
return [{
|
|
630
|
+
rule: 'rounds-traced',
|
|
631
|
+
severity: sev,
|
|
632
|
+
detail: `${fact.commits} коммитов кода без единой строки круга с ${fact.since ?? 'неизвестной даты'}`,
|
|
633
|
+
}];
|
|
634
|
+
},
|
|
600
635
|
'no-workspace-star': (f, sev) => {
|
|
601
636
|
const out: Violation[] = [];
|
|
602
637
|
for (const p of f.packages ?? []) {
|
|
@@ -1022,6 +1057,10 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
1022
1057
|
|
|
1023
1058
|
/** Per-rule evidence predicates. No entry preserves the rule's existing checked behaviour exactly. */
|
|
1024
1059
|
const HAS_INPUT: Partial<Record<string, (f: GuardFacts) => boolean>> = {
|
|
1060
|
+
'rounds-traced': (f) => {
|
|
1061
|
+
const fact = f.codeCommitsSinceLastRound;
|
|
1062
|
+
return fact !== undefined && fact.enabled !== false && typeof fact.commits === 'number' && Number.isFinite(fact.commits);
|
|
1063
|
+
},
|
|
1025
1064
|
// `!== undefined` пропускал `null`: правило объявлялось проверенным и возвращало чисто по
|
|
1026
1065
|
// ветке «улик нет». Это буквально отсутствие улик, отчитанное как проверка (назвал независимый
|
|
1027
1066
|
// ревьюер 2026-09-04).
|
|
@@ -1232,6 +1271,13 @@ export function evaluateGuard(facts: GuardFacts, rules: readonly GuardRule[] = D
|
|
|
1232
1271
|
// (missing contents ⇒ nothing reported), but a skip nobody can see is fail-SILENT. One aggregate
|
|
1233
1272
|
// note, computed AFTER the verdict so it can never block or warn: information, not a violation.
|
|
1234
1273
|
const notes: string[] = [];
|
|
1274
|
+
if (notEstablished.includes('rounds-traced')) {
|
|
1275
|
+
const fact = facts.codeCommitsSinceLastRound;
|
|
1276
|
+
if (fact?.enabled === false) notes.push('rounds-traced: skipped (.dz/config.json rounds.traced=false)');
|
|
1277
|
+
else if (fact?.commits === null) notes.push(fact.since === null
|
|
1278
|
+
? 'rounds-traced: not measured (ledger has no dated rows)'
|
|
1279
|
+
: 'rounds-traced: not measured (git unavailable)');
|
|
1280
|
+
}
|
|
1235
1281
|
if (checked.includes('no-stubs')) {
|
|
1236
1282
|
const skipped = facts.change?.stubSkipped;
|
|
1237
1283
|
if (typeof skipped === 'number' && Number.isFinite(skipped) && skipped > 0) {
|
package/src/index.ts
CHANGED
|
@@ -182,6 +182,8 @@ export {
|
|
|
182
182
|
BANDIT_RRF_CAP,
|
|
183
183
|
withVectorTimeout,
|
|
184
184
|
isVectorNoise,
|
|
185
|
+
isMirrorableRecord,
|
|
186
|
+
mirrorQuarantineOf,
|
|
185
187
|
patternVectorEntry,
|
|
186
188
|
dreamVectorEntry,
|
|
187
189
|
memoryRecordVectorEntry,
|
|
@@ -208,6 +210,7 @@ export type {
|
|
|
208
210
|
VectorEngineKind,
|
|
209
211
|
VectorEngineMode,
|
|
210
212
|
VectorEntry,
|
|
213
|
+
MirrorQuarantineMetadata,
|
|
211
214
|
VectorHit,
|
|
212
215
|
ImportVectorRow,
|
|
213
216
|
MirrorReceipt,
|
|
@@ -230,7 +233,30 @@ export type {
|
|
|
230
233
|
} from './vector-tier.js';
|
|
231
234
|
export { runSetup, generateHooksConfig, generateAgentdbWriter, writerVersionOf, AGENTDB_WRITER_VERSION,
|
|
232
235
|
agentdbStorePath, agentdbMcpStorePath, agentdbStoreSeparationProblem } from './setup.js';
|
|
233
|
-
|
|
236
|
+
// apply-leg (feature setup-installs-apply-leg, ADR-001): the third self-learning leg (APPLY) as a
|
|
237
|
+
// versioned generator + the ONE measurement dz doctor/parity both read (Decision 3).
|
|
238
|
+
export {
|
|
239
|
+
APPLY_LEG_VERSION,
|
|
240
|
+
applyLegVersionOf,
|
|
241
|
+
bakedCoreDistDirOf,
|
|
242
|
+
recallHookSource,
|
|
243
|
+
embedDaemonSource,
|
|
244
|
+
applyLegHookEntries,
|
|
245
|
+
applyLegStatus,
|
|
246
|
+
applyLegReasonMessage,
|
|
247
|
+
resolveIdleMs,
|
|
248
|
+
IDLE_MS_INT32_MAX,
|
|
249
|
+
} from './apply-leg.js';
|
|
250
|
+
export type {
|
|
251
|
+
ApplyLegHookEntry,
|
|
252
|
+
ApplyLegHelperStatus,
|
|
253
|
+
ApplyLegHookPresence,
|
|
254
|
+
ApplyLegStatus,
|
|
255
|
+
ApplyLegNotInstalledReason,
|
|
256
|
+
ResolvedIdleMs,
|
|
257
|
+
} from './apply-leg.js';
|
|
258
|
+
export { countLearningStoreRowsReadonly, quarantineTierParity } from './store-counts.js';
|
|
259
|
+
export type { QuarantineTierRow, QuarantineTierParity } from './store-counts.js';
|
|
234
260
|
export type { LearningStoreRowCounts } from './store-counts.js';
|
|
235
261
|
export {
|
|
236
262
|
STORE_GUARD_VERSION,
|
|
@@ -265,12 +291,14 @@ export {
|
|
|
265
291
|
segmentRun,
|
|
266
292
|
} from './eta.js';
|
|
267
293
|
export type { CheckpointObservation, EtaEstimate, EtaInput, IncompleteCoverageSample, RunSegment, StageDurationSample, StageSample } from './eta.js';
|
|
268
|
-
export { indexPatternsToAgentdb, resolveAgentdbPath, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder, cosineSimilarity, importVectorsToAgentdb, reindexAgentdbRows, bumpAgentdbUses, clearAgentdbQuarantine, deleteAgentdbByDzIds, readAgentdbRowsByTaskType, DZ_OWNED_TASK_TYPES } from './agentdb-index.js';
|
|
294
|
+
export { indexPatternsToAgentdb, resolveAgentdbPath, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder, cosineSimilarity, importVectorsToAgentdb, reindexAgentdbRows, bumpAgentdbUses, clearAgentdbQuarantine, deleteAgentdbByDzIds, readAgentdbRowsByTaskType, DZ_OWNED_TASK_TYPES, ensureAgentdbSchema } from './agentdb-index.js';
|
|
269
295
|
export type { AgentdbSearchHit, AgentdbSearchResult, AgentdbImportRow } from './agentdb-index.js';
|
|
270
296
|
export { DEFAULT_EMBED_MODEL, LEGACY_EMBED_MODEL, DEFAULT_EMBED_DIM, KNOWN_EMBED_DIMS, resolveEmbedModel, readEmbedManifest, writeEmbedManifest, embedManifestPath, legacyEmbedManifest } from './embedding-config.js';
|
|
271
297
|
export type { EmbedModelConfig, EmbedModelSource, EmbedManifest } from './embedding-config.js';
|
|
272
298
|
export { putBookKnowledge, queryBookKnowledge, bookKbPath } from './book-kb.js';
|
|
273
299
|
export type { BookKU, BookKUHit } from './book-kb.js';
|
|
300
|
+
export { applyReadonlyPragmas, classifySqliteReadFailure, warnOnce } from './sqlite-read-helpers.js';
|
|
301
|
+
export type { PragmaTarget, SqliteReadFailureKind } from './sqlite-read-helpers.js';
|
|
274
302
|
export {
|
|
275
303
|
brainHome,
|
|
276
304
|
brainBooksPath,
|
|
@@ -296,6 +324,24 @@ export {
|
|
|
296
324
|
} from './brain.js';
|
|
297
325
|
export type { BrainQueryResult, BrainRegistry, BrainSource } from './brain.js';
|
|
298
326
|
export type { AgentdbRow, AgentdbIndexResult } from './agentdb-index.js';
|
|
327
|
+
// AM-5 (agentdb-snapshot-lock fix-round): `rotatePreReindexSnapshotsUnlocked` is package-internal
|
|
328
|
+
// ONLY — the public API is `rotatePreReindexSnapshots`, which takes the snapshot lock. Exporting
|
|
329
|
+
// the unlocked primitive from this barrel would hand callers outside the package a way to rotate
|
|
330
|
+
// snapshots with NO mutual exclusion at all, defeating the whole point of this feature.
|
|
331
|
+
export { listPreReindexSnapshots, planSnapshotRotation, rotatePreReindexSnapshots, scanSnapshotDir } from './agentdb-snapshot-rotation.js';
|
|
332
|
+
export type { SnapshotFile, SnapshotFamily, SnapshotRotationPlan, SnapshotRotationReport, RotateSnapshotsOptions } from './agentdb-snapshot-rotation.js';
|
|
333
|
+
export { snapshotSqliteDatabase, restoreSqliteSnapshot } from './agentdb-snapshot.js';
|
|
334
|
+
export type { SnapshotMethod, SnapshotOutcome, SnapshotDbCtor } from './agentdb-snapshot.js';
|
|
335
|
+
export {
|
|
336
|
+
withAgentdbSnapshotLock,
|
|
337
|
+
writeReindexMarker,
|
|
338
|
+
markReindexMarkerRecoveryRequired,
|
|
339
|
+
clearReindexMarker,
|
|
340
|
+
readLiveReindexMarkers,
|
|
341
|
+
reindexMarkerPath,
|
|
342
|
+
REINDEX_MARKER_TTL_MS,
|
|
343
|
+
} from './agentdb-reindex-marker.js';
|
|
344
|
+
export type { ReindexMarker } from './agentdb-reindex-marker.js';
|
|
299
345
|
export { generatePlugin } from './plugin.js';
|
|
300
346
|
export type { PluginManifest } from './plugin.js';
|
|
301
347
|
export type { SetupOptions, SetupResult, SetupStep } from './setup.js';
|
|
@@ -905,6 +951,7 @@ export {
|
|
|
905
951
|
export type { ParserSafeRegionVerdict, ParserSafeRegionRefusal } from './parser-safe-region.js';
|
|
906
952
|
export {
|
|
907
953
|
CLAUDE_USAGE_MODELS,
|
|
954
|
+
computeSpendReport,
|
|
908
955
|
computeUsage,
|
|
909
956
|
deriveUsageCalibration,
|
|
910
957
|
fixedBlockWindowFor,
|
|
@@ -912,10 +959,12 @@ export {
|
|
|
912
959
|
normalizeClaudeUsageModelKey,
|
|
913
960
|
parseWeeklyResetAnchor,
|
|
914
961
|
readUsageLimits,
|
|
962
|
+
spendReport,
|
|
963
|
+
spendInvariantViolations,
|
|
915
964
|
weeklyWindowFor,
|
|
916
965
|
} from './usage.js';
|
|
917
966
|
export { claudeProjectsRoot, rawTokenMixOf, weightedTokensOf } from './usage.js';
|
|
918
|
-
export type { RawTokenMix } from './usage.js';
|
|
967
|
+
export type { RawTokenMix, SpendReport } from './usage.js';
|
|
919
968
|
// Per-stage cost ledger + reconciliation invariant (feature cost-ledger, ADR-001/002/003).
|
|
920
969
|
export {
|
|
921
970
|
COST_LEDGER_SCOPE,
|
|
@@ -1073,8 +1122,8 @@ export * from './backlog.js';
|
|
|
1073
1122
|
export * from './no-stubs.js';
|
|
1074
1123
|
export { quiescenceProbeScript, decideWriterQuiescence, WQ_WINDOW_SECONDS, WQ_MAX_WINDOWS, WQ_REQUIRED_QUIET } from './writer-quiescence.js';
|
|
1075
1124
|
export type { WriterQuiescenceDecision } from './writer-quiescence.js';
|
|
1076
|
-
export { decideCadenceWindow, isoWeekOf, weeklyBuckets, guardRepeatDecay, buildCadenceReport, CADENCE_WINDOW_DAYS } from './cadence.js';
|
|
1077
|
-
export type { CadenceWindow, CadenceReport, CadenceWindowDecision } from './cadence.js';
|
|
1125
|
+
export { decideCadenceWindow, isoWeekOf, weeklyBuckets, guardRepeatDecay, summarizeRounds, buildCadenceReport, CADENCE_WINDOW_DAYS } from './cadence.js';
|
|
1126
|
+
export type { CadenceWindow, CadenceReport, CadenceWindowDecision, RoundsSummary } from './cadence.js';
|
|
1078
1127
|
export { readQeRounds, countQeRounds, QE_ROUNDS_DEFAULT_CEILING } from './qe-rounds.js';
|
|
1079
1128
|
export type { QeRound, QeFailedAttempt, QeRoundsReport, QeRoundsStatus } from './qe-rounds.js';
|
|
1080
1129
|
export {
|
|
@@ -1165,4 +1214,8 @@ export * from './run-registry.js';
|
|
|
1165
1214
|
|
|
1166
1215
|
export { JOURNAL_KINDS, formatLine, parseLine, selectWindow, appendWitnessed } from './journal.js';
|
|
1167
1216
|
export type { JournalKind, JournalEvent, JournalLine, JournalIo } from './journal.js';
|
|
1217
|
+
export { openRound, closeRound, listRounds } from './round.js';
|
|
1218
|
+
export type { RoundState, RoundExecState, RoundLedgerRow } from './round.js';
|
|
1219
|
+
export { parseCodexTokens, classifyRoundExecOutcome, buildRoundExecRow } from './round-exec.js';
|
|
1220
|
+
export type { RoundExecOutcome, RoundExecLedgerRow } from './round-exec.js';
|
|
1168
1221
|
export * from './run-cleanup.js';
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
/** Isolated bounded subprocess runner used by integration registration probes. */
|
|
2
2
|
|
|
3
3
|
import { spawn, type ChildProcess } from 'node:child_process';
|
|
4
|
-
import { realpathSync } from 'node:fs';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { resolve } from 'node:path';
|
|
7
4
|
|
|
8
5
|
export const PROBE_REDACTION_MARKER = '[REDACTED]';
|
|
9
6
|
export const PROBE_TRUNCATION_MARKER = '[TRUNCATED]';
|
|
@@ -214,7 +211,9 @@ export function runProbeWorker(request: ProbeWorkerRequest): Promise<ProbeWorker
|
|
|
214
211
|
const child = spawn(request.command, [...request.args], {
|
|
215
212
|
cwd: request.cwd,
|
|
216
213
|
detached: process.platform !== 'win32',
|
|
217
|
-
|
|
214
|
+
// Strip our own entry marker: a probed command (or its descendants) that imports this module must never
|
|
215
|
+
// run main() — review 2026-09-11 (Sonnet MEDIUM).
|
|
216
|
+
env: Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== 'DZ_INTEGRATION_PROBE_WORKER')),
|
|
218
217
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
219
218
|
windowsHide: true,
|
|
220
219
|
});
|
|
@@ -301,8 +300,7 @@ async function main(): Promise<void> {
|
|
|
301
300
|
process.stdout.write(`${JSON.stringify(await runProbeWorker(request))}\n`);
|
|
302
301
|
}
|
|
303
302
|
|
|
304
|
-
|
|
305
|
-
if (invokedPath !== undefined && realpathSync(resolve(invokedPath)) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
303
|
+
if (process.env.DZ_INTEGRATION_PROBE_WORKER === '1') {
|
|
306
304
|
main().catch((error) => {
|
|
307
305
|
process.stderr.write(`integration probe worker failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
308
306
|
process.exitCode = 1;
|
|
@@ -74,7 +74,8 @@ export const defaultIntegrationProcessPort: IntegrationProcessPort = {
|
|
|
74
74
|
if (!executable.ok) {
|
|
75
75
|
return { status: null, signal: null, stdout: new Uint8Array(), stderr: new Uint8Array(), errorCode: executable.errorCode };
|
|
76
76
|
}
|
|
77
|
-
|
|
77
|
+
// Source-importing tests and the compiled module both execute the built worker.
|
|
78
|
+
const workerPath = fileURLToPath(new URL('../dist/integration-probe-worker.js', import.meta.url));
|
|
78
79
|
const result = spawnSync(process.execPath, [workerPath], {
|
|
79
80
|
input: JSON.stringify({
|
|
80
81
|
...request,
|
|
@@ -83,6 +84,7 @@ export const defaultIntegrationProcessPort: IntegrationProcessPort = {
|
|
|
83
84
|
aggregateMaxBytes: INTEGRATION_PROBE_AGGREGATE_MAX_BYTES,
|
|
84
85
|
}),
|
|
85
86
|
cwd: request.cwd,
|
|
87
|
+
env: { ...process.env, DZ_INTEGRATION_PROBE_WORKER: '1' },
|
|
86
88
|
timeout: request.timeoutMs + 5_000,
|
|
87
89
|
killSignal: 'SIGKILL',
|
|
88
90
|
maxBuffer: 4 * 1024 * 1024,
|
package/src/managed-hooks.ts
CHANGED
|
@@ -86,8 +86,17 @@ export interface MergeManagedHookOptions {
|
|
|
86
86
|
* - Events absent from `managed` are copied through untouched (including unknown ones), and their
|
|
87
87
|
* entries still count toward `foreignPreserved` — the number answers "how many of the user's
|
|
88
88
|
* entries did this registry hold and keep", not "how many survived the touched events".
|
|
89
|
-
* - Within a touched event, non-ours entries keep their relative order and object identity
|
|
90
|
-
*
|
|
89
|
+
* - Within a touched event, non-ours entries keep their relative order and object identity.
|
|
90
|
+
* - A CURRENT (non-legacy) managed entry that is already byte-identical to one of the fresh managed
|
|
91
|
+
* targets stays IN PLACE rather than being dropped and re-appended at the tail (fix round 1,
|
|
92
|
+
* feature `setup-installs-apply-leg`, review Codex C MEDIUM finding "идемпотентность Configure
|
|
93
|
+
* hooks для чужой записи, добавленной ПОСЛЕ writer-а"). Before this, an already-installed managed
|
|
94
|
+
* entry was unconditionally removed and the fresh target re-appended at the tail on EVERY run —
|
|
95
|
+
* invisible while it was the only entry in its event, but a genuinely foreign entry a user added
|
|
96
|
+
* AFTER it would get silently outrun to the front on the very next `dz setup` (MEASURED: `[writer,
|
|
97
|
+
* foreign]` → next run → `[foreign, writer]`). Any managed entry that does NOT exactly match a
|
|
98
|
+
* remaining target (stale content, a legacy shape, or a mixed matcher-group carrying a foreign
|
|
99
|
+
* handler) still gets dropped/salvaged and the fresh target appended at the tail exactly as before.
|
|
91
100
|
* - `merge(merge(x)) === merge(x)`.
|
|
92
101
|
*/
|
|
93
102
|
export function mergeManagedHookEntries(
|
|
@@ -122,18 +131,34 @@ export function mergeManagedHookEntries(
|
|
|
122
131
|
let replacedLegacy = false;
|
|
123
132
|
for (const event of Object.keys(managed)) {
|
|
124
133
|
const current = Array.isArray(hooks[event]) ? (hooks[event] as ManagedHookEntry[]) : [];
|
|
125
|
-
const
|
|
134
|
+
const targets = [...(managed[event] ?? [])];
|
|
135
|
+
const usedTarget = new Array<boolean>(targets.length).fill(false);
|
|
136
|
+
const next: ManagedHookEntry[] = [];
|
|
126
137
|
for (const entry of current) {
|
|
127
138
|
const ours = options.isManaged(entry, event);
|
|
128
139
|
if (!ours) {
|
|
129
|
-
|
|
140
|
+
next.push(entry);
|
|
130
141
|
continue;
|
|
131
142
|
}
|
|
132
|
-
|
|
143
|
+
const isLegacy = options.isLegacy?.(entry, event) === true;
|
|
144
|
+
if (isLegacy) replacedLegacy = true;
|
|
145
|
+
// A current-vintage managed entry already equal to one of the fresh targets is kept IN
|
|
146
|
+
// PLACE (see the doc comment above) — legacy entries always fall through to drop/salvage so
|
|
147
|
+
// they get migrated to the current shape rather than "matching" their own stale bytes.
|
|
148
|
+
if (!isLegacy) {
|
|
149
|
+
const matchIdx = targets.findIndex((t, i) => !usedTarget[i] && JSON.stringify(t) === JSON.stringify(entry));
|
|
150
|
+
if (matchIdx !== -1) {
|
|
151
|
+
usedTarget[matchIdx] = true;
|
|
152
|
+
next.push(entry);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
133
156
|
const salvaged = options.retainForeign?.(entry, event) ?? null;
|
|
134
|
-
if (salvaged !== null)
|
|
157
|
+
if (salvaged !== null) next.push(salvaged);
|
|
158
|
+
}
|
|
159
|
+
for (let i = 0; i < targets.length; i += 1) {
|
|
160
|
+
if (!usedTarget[i]) next.push(targets[i]);
|
|
135
161
|
}
|
|
136
|
-
const next = [...kept, ...(managed[event] ?? [])];
|
|
137
162
|
if (JSON.stringify(next) !== JSON.stringify(current)) changed = true;
|
|
138
163
|
hooks[event] = next;
|
|
139
164
|
}
|