@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.
Files changed (129) hide show
  1. package/.dz-manifest.json +233 -93
  2. package/README.md +269 -0
  3. package/dist/agentdb-index.d.ts +45 -0
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +345 -85
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/agentdb-reindex-marker.d.ts +170 -0
  8. package/dist/agentdb-reindex-marker.d.ts.map +1 -0
  9. package/dist/agentdb-reindex-marker.js +293 -0
  10. package/dist/agentdb-reindex-marker.js.map +1 -0
  11. package/dist/agentdb-snapshot-rotation.d.ts +169 -0
  12. package/dist/agentdb-snapshot-rotation.d.ts.map +1 -0
  13. package/dist/agentdb-snapshot-rotation.js +322 -0
  14. package/dist/agentdb-snapshot-rotation.js.map +1 -0
  15. package/dist/agentdb-snapshot.d.ts +126 -0
  16. package/dist/agentdb-snapshot.d.ts.map +1 -0
  17. package/dist/agentdb-snapshot.js +195 -0
  18. package/dist/agentdb-snapshot.js.map +1 -0
  19. package/dist/apply-leg.d.ts +202 -0
  20. package/dist/apply-leg.d.ts.map +1 -0
  21. package/dist/apply-leg.js +975 -0
  22. package/dist/apply-leg.js.map +1 -0
  23. package/dist/book-kb.d.ts.map +1 -1
  24. package/dist/book-kb.js +57 -1
  25. package/dist/book-kb.js.map +1 -1
  26. package/dist/brain.d.ts +3 -0
  27. package/dist/brain.d.ts.map +1 -1
  28. package/dist/brain.js +1 -1
  29. package/dist/brain.js.map +1 -1
  30. package/dist/cadence.d.ts +30 -1
  31. package/dist/cadence.d.ts.map +1 -1
  32. package/dist/cadence.js +66 -2
  33. package/dist/cadence.js.map +1 -1
  34. package/dist/guard.d.ts +14 -0
  35. package/dist/guard.d.ts.map +1 -1
  36. package/dist/guard.js +36 -0
  37. package/dist/guard.js.map +1 -1
  38. package/dist/index.d.ts +23 -8
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +18 -5
  41. package/dist/index.js.map +1 -1
  42. package/dist/integration-probe-worker.d.ts.map +1 -1
  43. package/dist/integration-probe-worker.js +4 -6
  44. package/dist/integration-probe-worker.js.map +1 -1
  45. package/dist/integrations-verify.d.ts.map +1 -1
  46. package/dist/integrations-verify.js +3 -1
  47. package/dist/integrations-verify.js.map +1 -1
  48. package/dist/managed-hooks.d.ts +11 -2
  49. package/dist/managed-hooks.d.ts.map +1 -1
  50. package/dist/managed-hooks.js +33 -7
  51. package/dist/managed-hooks.js.map +1 -1
  52. package/dist/mutation-gate.d.ts +35 -1
  53. package/dist/mutation-gate.d.ts.map +1 -1
  54. package/dist/mutation-gate.js +49 -9
  55. package/dist/mutation-gate.js.map +1 -1
  56. package/dist/operations.d.ts.map +1 -1
  57. package/dist/operations.js +94 -0
  58. package/dist/operations.js.map +1 -1
  59. package/dist/patterns.d.ts.map +1 -1
  60. package/dist/patterns.js +23 -4
  61. package/dist/patterns.js.map +1 -1
  62. package/dist/repository-origin.d.ts +1 -1
  63. package/dist/repository-origin.d.ts.map +1 -1
  64. package/dist/repository-origin.js +7 -1
  65. package/dist/repository-origin.js.map +1 -1
  66. package/dist/round-exec.d.ts +41 -0
  67. package/dist/round-exec.d.ts.map +1 -0
  68. package/dist/round-exec.js +45 -0
  69. package/dist/round-exec.js.map +1 -0
  70. package/dist/round.d.ts +109 -0
  71. package/dist/round.d.ts.map +1 -0
  72. package/dist/round.js +148 -0
  73. package/dist/round.js.map +1 -0
  74. package/dist/run-records.d.ts +8 -0
  75. package/dist/run-records.d.ts.map +1 -1
  76. package/dist/run-records.js +69 -3
  77. package/dist/run-records.js.map +1 -1
  78. package/dist/setup.d.ts +8 -0
  79. package/dist/setup.d.ts.map +1 -1
  80. package/dist/setup.js +166 -3
  81. package/dist/setup.js.map +1 -1
  82. package/dist/sqlite-read-helpers.d.ts +60 -0
  83. package/dist/sqlite-read-helpers.d.ts.map +1 -0
  84. package/dist/sqlite-read-helpers.js +118 -0
  85. package/dist/sqlite-read-helpers.js.map +1 -0
  86. package/dist/statusline.d.ts +20 -3
  87. package/dist/statusline.d.ts.map +1 -1
  88. package/dist/statusline.js +40 -7
  89. package/dist/statusline.js.map +1 -1
  90. package/dist/store-counts.d.ts +30 -0
  91. package/dist/store-counts.d.ts.map +1 -1
  92. package/dist/store-counts.js +189 -0
  93. package/dist/store-counts.js.map +1 -1
  94. package/dist/usage.d.ts +55 -0
  95. package/dist/usage.d.ts.map +1 -1
  96. package/dist/usage.js +184 -33
  97. package/dist/usage.js.map +1 -1
  98. package/dist/vector-tier.d.ts +18 -1
  99. package/dist/vector-tier.d.ts.map +1 -1
  100. package/dist/vector-tier.js +26 -2
  101. package/dist/vector-tier.js.map +1 -1
  102. package/package.json +6 -6
  103. package/sbom.json +442 -92
  104. package/src/agentdb-index.ts +315 -32
  105. package/src/agentdb-reindex-marker.ts +330 -0
  106. package/src/agentdb-snapshot-rotation.ts +393 -0
  107. package/src/agentdb-snapshot.ts +222 -0
  108. package/src/apply-leg.ts +1046 -0
  109. package/src/book-kb.ts +64 -1
  110. package/src/brain.ts +11 -2
  111. package/src/cadence.ts +87 -2
  112. package/src/guard.ts +46 -0
  113. package/src/index.ts +58 -5
  114. package/src/integration-probe-worker.ts +4 -6
  115. package/src/integrations-verify.ts +3 -1
  116. package/src/managed-hooks.ts +32 -7
  117. package/src/mutation-gate.ts +87 -8
  118. package/src/operations.ts +89 -0
  119. package/src/patterns.ts +23 -4
  120. package/src/repository-origin.ts +7 -1
  121. package/src/round-exec.ts +85 -0
  122. package/src/round.ts +228 -0
  123. package/src/run-records.ts +77 -2
  124. package/src/setup.ts +198 -3
  125. package/src/sqlite-read-helpers.ts +128 -0
  126. package/src/statusline.ts +57 -9
  127. package/src/store-counts.ts +245 -1
  128. package/src/usage.ts +243 -29
  129. 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 openDb(projectRoot, opts.dbPath);
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 = existsSync(process.execPath) ? process.execPath : (process.argv[0] ?? 'node');
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<{ reembedded: number; model?: string; version?: number; backupPath?: string; error?: string }> {
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
- * Four sources, every degradation NAMED in the report, never a silent zero:
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
- export { countLearningStoreRowsReadonly } from './store-counts.js';
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
- env: process.env,
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
- const invokedPath = process.argv[1];
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
- const workerPath = fileURLToPath(new URL('./integration-probe-worker.js', import.meta.url));
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,
@@ -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, then
90
- * the managed entries are appended.
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 kept: ManagedHookEntry[] = [];
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
- kept.push(entry);
140
+ next.push(entry);
130
141
  continue;
131
142
  }
132
- if (options.isLegacy?.(entry, event) === true) replacedLegacy = true;
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) kept.push(salvaged);
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
  }