@dzhechkov/harness-core 0.8.34 → 0.8.35

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/skills.ts CHANGED
@@ -4,8 +4,8 @@
4
4
  * @packageDocumentation
5
5
  */
6
6
 
7
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
8
- import { join, relative } from 'node:path';
7
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
8
+ import { isAbsolute, join, relative, sep } from 'node:path';
9
9
 
10
10
  import { parse as parseYaml } from 'yaml';
11
11
 
@@ -55,15 +55,259 @@ function readAssetContent(path: string): { encoding: 'utf-8' | 'base64'; content
55
55
  return { encoding: 'utf-8', content: decoded };
56
56
  }
57
57
 
58
- /** Recursively list every file under `dir`. */
59
- function walkFiles(dir: string): string[] {
60
- const out: string[] = [];
61
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
58
+ /**
59
+ * One filesystem entry skipped during skill asset discovery, named so nothing
60
+ * vanishes silently (feature `skills-walk-symlinks-and-junk`, FR-1/FR-2). `path`
61
+ * is the entry's own path (not its symlink target); `reason` is a short,
62
+ * stable, human-readable tag: `'junk file (<pattern>)'` (e.g. `'junk file
63
+ * (*.pyc)'` — fix-round 1 HIGH-1(b): the pattern that matched, not just the
64
+ * verdict), `'junk directory (<name>)'`, `'broken symlink'`, `'symlink escapes
65
+ * the skill directory'` (fix-round 1 AM-8), `'symlink cycle — directory
66
+ * already visited'`, or `'unreadable directory (<errno message>)'` (fix-round
67
+ * 1 MEDIUM-3).
68
+ */
69
+ export interface SkippedEntry {
70
+ readonly path: string;
71
+ readonly reason: string;
72
+ }
73
+
74
+ /** The result of {@link walkFiles}: the real assets found, plus everything skipped. */
75
+ export interface SkillWalkResult {
76
+ readonly files: readonly string[];
77
+ readonly skipped: readonly SkippedEntry[];
78
+ }
79
+
80
+ /**
81
+ * Directory names that never carry legitimate skill assets — build/cache artifacts a
82
+ * skill author does not intend to ship. **This list IS the published contract for "what
83
+ * counts as junk"** (fix-round 1 HIGH-1(a), REFUTED-by-contract — see
84
+ * `packages/@dzhechkov/harness-core/README.md`, "What counts as junk"): a skill cannot
85
+ * ship a directory bearing one of these exact names as an asset, on purpose or by
86
+ * accident — that is the deliberate, documented trade the design makes, not an
87
+ * oversight to be widened into content-sniffing heuristics. NOT a whitelist of allowed
88
+ * directories: any OTHER name (including a dotdir the author added on purpose) is
89
+ * walked as usual — filtering a user's own files is not this list's job (FR-2,
90
+ * requirements AC-1).
91
+ */
92
+ export const SKILL_JUNK_DIRS: ReadonlySet<string> = new Set([
93
+ '__pycache__', // Python bytecode cache
94
+ 'node_modules', // an accidentally-vendored dependency tree
95
+ '.git', // VCS metadata
96
+ '__MACOSX', // macOS zip-archive resource-fork sidecar directory (fix-round 1 MEDIUM-2)
97
+ '.pytest_cache', // pytest's cache directory (fix-round 1 MEDIUM-2)
98
+ '.mypy_cache', // mypy's cache directory (fix-round 1 MEDIUM-2)
99
+ ]);
100
+
101
+ /** Exact junk filenames skipped during skill asset discovery (FR-2). */
102
+ export const SKILL_JUNK_FILES: ReadonlySet<string> = new Set([
103
+ '.DS_Store', // macOS Finder folder metadata
104
+ 'Thumbs.db', // Windows Explorer thumbnail cache
105
+ ]);
106
+
107
+ /** Junk filename SUFFIXES skipped during skill asset discovery (FR-2). */
108
+ const SKILL_JUNK_FILE_SUFFIXES: readonly string[] = [
109
+ '.pyc', // Python bytecode
110
+ '.pyo', // Python optimized bytecode
111
+ '.swp', // Vim swap file
112
+ '.swo', // Vim swap file, second form left after a crash recovery (fix-round 1 MEDIUM-2)
113
+ // A trailing `~` (editor backup) is deliberately NOT on this list (Codex r2 HIGH, lead
114
+ // decision): it is the one pattern a legitimate asset name can plausibly end with
115
+ // (`notes~`), and FR-2's list is conservative by contract — a false positive here would
116
+ // silently drop a real asset, which AC-1 forbids. Backup files ending in `~` ship as assets.
117
+ ];
118
+
119
+ /** Junk filename PREFIXES skipped during skill asset discovery (fix-round 1 MEDIUM-2). */
120
+ const SKILL_JUNK_FILE_PREFIXES: readonly string[] = [
121
+ '.#', // Emacs lock file (e.g. `.#notes.txt`), left behind by an unclean editor exit
122
+ ];
123
+
124
+ /** True when `name` matches a documented junk-file pattern (FR-2). */
125
+ export function isSkillJunkFile(name: string): boolean {
126
+ if (SKILL_JUNK_FILES.has(name)) return true;
127
+ if (SKILL_JUNK_FILE_SUFFIXES.some((suffix) => name.endsWith(suffix))) return true;
128
+ return SKILL_JUNK_FILE_PREFIXES.some((prefix) => name.startsWith(prefix));
129
+ }
130
+
131
+ /**
132
+ * The ONE decision point `walkFiles` calls to ask "is this entry junk?" — a directory
133
+ * check and a file check both funnel through here so a single mutation can prove (or
134
+ * disprove) that junk filtering, as a whole, is wired in (registry entry
135
+ * `walk-filters-junk`). Splitting this into two never-both-mutated call sites would let
136
+ * a mutation of just one half pass unnoticed while the other half still filtered.
137
+ */
138
+ function isJunkEntry(name: string, kind: 'file' | 'directory'): boolean {
139
+ return kind === 'directory' ? SKILL_JUNK_DIRS.has(name) : isSkillJunkFile(name);
140
+ }
141
+
142
+ /**
143
+ * Which junk PATTERN `name` matched — used to build a `reason` that NAMES the pattern,
144
+ * not just the verdict (fix-round 1 HIGH-1(b): `'junk file (*.pyc)'`, never a bare
145
+ * `'junk file'`). Kept separate from {@link isJunkEntry} (the one go/no-go point the
146
+ * `walk-filters-junk` mutation targets) so a mutation of the decision does not also
147
+ * have to fake this label to stay silent.
148
+ */
149
+ function junkPatternLabel(name: string, kind: 'file' | 'directory'): string {
150
+ if (kind === 'directory') return name;
151
+ if (SKILL_JUNK_FILES.has(name)) return name;
152
+ for (const suffix of SKILL_JUNK_FILE_SUFFIXES) {
153
+ if (name.endsWith(suffix)) return `*${suffix}`;
154
+ }
155
+ for (const prefix of SKILL_JUNK_FILE_PREFIXES) {
156
+ if (name.startsWith(prefix)) return `${prefix}*`;
157
+ }
158
+ return name; // unreachable when isJunkEntry(name, kind) is true; kept total, not partial.
159
+ }
160
+
161
+ /**
162
+ * True when `targetRealPath` lies within `rootRealDir` — refuses a symlink whose
163
+ * resolved target escapes the skill directory (lead item AM-8: `assets/secret ->
164
+ * /etc/hostname`, or `-> ../../..`, must never be followed and bundled as an asset).
165
+ * Uses `relative()` plus a leading-`..`/absolute check rather than a lexical
166
+ * `startsWith`, the SAME idiom as `containedUnderRoot` in
167
+ * `packages/@dzhechkov/harness-cli/src/cli.ts` (read-only reference for technique) —
168
+ * a lexical string-prefix check is fooled by a sibling directory that happens to share
169
+ * the root as a text prefix (root `/a/b` vs. target `/a/bc`).
170
+ */
171
+ function isContained(rootRealDir: string, targetRealPath: string): boolean {
172
+ if (targetRealPath === rootRealDir) return true;
173
+ const rel = relative(rootRealDir, targetRealPath);
174
+ // Codex r2 MEDIUM (lead fix): only a `..` COMPONENT escapes — a file legitimately named
175
+ // `..asset` yields rel === '..asset', which is inside the root.
176
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
177
+ }
178
+
179
+ /**
180
+ * Recursively list every real, non-junk file under `dir`, resolving symlinks to
181
+ * their targets and guarding against symlink cycles.
182
+ *
183
+ * A `Dirent` from `readdirSync` answers `false` to BOTH `isDirectory()` and
184
+ * `isFile()` for a symlink entry — trusting those two checks alone silently drops
185
+ * every symlinked asset (MEASURED: 2 of 4 fixture assets vanished, exit 0). This
186
+ * resolves each symlink with `statSync` (which follows the link) before deciding
187
+ * whether it names a file or a directory.
188
+ *
189
+ * Cycle guard: each directory's `realpath` is recorded in `seenRealDirs` while
190
+ * it is being walked and REMOVED again when its walk returns — the set is the
191
+ * chain of ANCESTORS on the current recursion path, not every directory ever
192
+ * visited. A directory (reached directly or through a symlink) whose real path is
193
+ * already on that chain ends the walk there instead of recursing — this is what
194
+ * stops `a -> ..` from hanging (AC-2; registry entry `walk-guards-cycles`). Two
195
+ * non-cyclic aliases of the same directory (`alias1 -> shared`, `alias2 -> shared`)
196
+ * are BOTH walked under their own logical paths (Codex r2 HIGH, lead fix): an
197
+ * alias is not a cycle, and the earlier visited-set semantics silently dropped the
198
+ * second one as if it were.
199
+ *
200
+ * Containment guard (lead item AM-8): every symlink's resolved target is checked
201
+ * against `rootRealDir` — the real path of the directory the OUTERMOST call was
202
+ * given (the skill directory itself, for every caller in this file) — before it is
203
+ * followed. A symlink whose target resolves outside that root is skipped, named,
204
+ * never bundled as an asset; `rootRealDir` is threaded through every recursive call
205
+ * so a nested symlinked directory is still checked against the ORIGINAL skill root,
206
+ * not against whichever subdirectory happens to be walking it.
207
+ *
208
+ * `readdirSync` failure (fix-round 1 MEDIUM-3) — e.g. an unreadable directory whose
209
+ * own `realpath` still resolved — produces a named `skipped` entry, same as an
210
+ * unresolvable `realpathSync`; it never throws out of this function or out of
211
+ * {@link loadSkillFromDir}.
212
+ */
213
+ export function walkFiles(
214
+ dir: string,
215
+ seenRealDirs: Set<string> = new Set<string>(),
216
+ rootRealDir?: string,
217
+ ): SkillWalkResult {
218
+ const files: string[] = [];
219
+ const skipped: SkippedEntry[] = [];
220
+
221
+ let realDir: string;
222
+ try {
223
+ realDir = realpathSync(dir);
224
+ } catch (error) {
225
+ skipped.push({ path: dir, reason: `unreadable directory (${error instanceof Error ? error.message : String(error)})` });
226
+ return { files, skipped };
227
+ }
228
+ if (seenRealDirs.has(realDir)) {
229
+ skipped.push({ path: dir, reason: 'symlink cycle — directory already visited' });
230
+ return { files, skipped };
231
+ }
232
+ seenRealDirs.add(realDir);
233
+ const root = rootRealDir ?? realDir;
234
+
235
+ let entries;
236
+ try {
237
+ entries = readdirSync(dir, { withFileTypes: true });
238
+ } catch (error) {
239
+ skipped.push({ path: dir, reason: `unreadable directory (${error instanceof Error ? error.message : String(error)})` });
240
+ seenRealDirs.delete(realDir);
241
+ return { files, skipped };
242
+ }
243
+
244
+ for (const entry of entries) {
62
245
  const full = join(dir, entry.name);
63
- if (entry.isDirectory()) out.push(...walkFiles(full));
64
- else if (entry.isFile()) out.push(full);
246
+
247
+ if (entry.isSymbolicLink()) {
248
+ let target;
249
+ try {
250
+ target = statSync(full); // follows the link
251
+ } catch {
252
+ skipped.push({ path: full, reason: 'broken symlink' });
253
+ continue;
254
+ }
255
+ let resolvedReal: string;
256
+ try {
257
+ resolvedReal = realpathSync(full);
258
+ } catch {
259
+ // statSync just followed this same link successfully, so this is very unlikely
260
+ // (a race with something deleting the target); treat it the same as broken.
261
+ skipped.push({ path: full, reason: 'broken symlink' });
262
+ continue;
263
+ }
264
+ if (!isContained(root, resolvedReal)) {
265
+ skipped.push({ path: full, reason: 'symlink escapes the skill directory' });
266
+ continue;
267
+ }
268
+ if (target.isDirectory()) {
269
+ if (isJunkEntry(entry.name, 'directory')) {
270
+ skipped.push({ path: full, reason: `junk directory (${junkPatternLabel(entry.name, 'directory')})` });
271
+ continue;
272
+ }
273
+ const nested = walkFiles(full, seenRealDirs, root);
274
+ files.push(...nested.files);
275
+ skipped.push(...nested.skipped);
276
+ } else if (target.isFile()) {
277
+ if (isJunkEntry(entry.name, 'file')) {
278
+ skipped.push({ path: full, reason: `junk file (${junkPatternLabel(entry.name, 'file')})` });
279
+ continue;
280
+ }
281
+ files.push(full);
282
+ } else {
283
+ skipped.push({ path: full, reason: 'symlink target is neither a file nor a directory' });
284
+ }
285
+ continue;
286
+ }
287
+
288
+ if (entry.isDirectory()) {
289
+ if (isJunkEntry(entry.name, 'directory')) {
290
+ skipped.push({ path: full, reason: `junk directory (${junkPatternLabel(entry.name, 'directory')})` });
291
+ continue;
292
+ }
293
+ const nested = walkFiles(full, seenRealDirs, root);
294
+ files.push(...nested.files);
295
+ skipped.push(...nested.skipped);
296
+ } else if (entry.isFile()) {
297
+ if (isJunkEntry(entry.name, 'file')) {
298
+ skipped.push({ path: full, reason: `junk file (${junkPatternLabel(entry.name, 'file')})` });
299
+ continue;
300
+ }
301
+ files.push(full);
302
+ } else {
303
+ skipped.push({ path: full, reason: 'not a regular file, directory, or symlink' });
304
+ }
65
305
  }
66
- return out;
306
+
307
+ // Ancestor-chain semantics (see the cycle-guard doc above): this directory's walk is
308
+ // over, so a sibling alias of it must be allowed to walk it again under its own path.
309
+ seenRealDirs.delete(realDir);
310
+ return { files, skipped };
67
311
  }
68
312
 
69
313
  /** Return the ids of every `<skillsDir>/<id>/SKILL.md`, sorted. */
@@ -88,6 +332,9 @@ export function discoverSkillIds(skillsDir: string): string[] {
88
332
  */
89
333
  export function listSkills(skillsDir: string): SkillSummary[] {
90
334
  return discoverSkillIds(skillsDir).map((id) => {
335
+ // Codex r3 (lead fix): the SKILL.md containment check guards EVERY public reader, not
336
+ // only loadSkillFromDir/getSkillInfo — an escaping SKILL.md symlink is refused here too.
337
+ assertSkillMdContained(join(skillsDir, id), join(skillsDir, id, 'SKILL.md'), id);
91
338
  const document = parseSkillDocument(readFileSync(join(skillsDir, id, 'SKILL.md'), 'utf-8'));
92
339
  const frontmatter = ClaudeSkillFrontmatterSchema.parse(parseYaml(document.frontmatterYaml));
93
340
  return { id, description: frontmatter.description };
@@ -146,6 +393,9 @@ export function describeSkillLoadFailure(
146
393
  const path = join(skillsDir, id, 'SKILL.md');
147
394
  let firstLine = '';
148
395
  try {
396
+ // Codex r3 (lead fix): never read an ESCAPING SKILL.md even for a diagnostic snippet —
397
+ // the first line of a file outside the skill directory is not ours to print.
398
+ assertSkillMdContained(join(skillsDir, id), path, id);
149
399
  const raw = readFileSync(path, 'utf-8');
150
400
  const line = (raw.split('\n', 1)[0] ?? '').replace(/\r$/, '').trim();
151
401
  firstLine =
@@ -176,6 +426,8 @@ export function listSkillsDetailed(skillsDir: string): SkillListing {
176
426
  const failures: SkillLoadFailure[] = [];
177
427
  for (const id of discoverSkillIds(skillsDir)) {
178
428
  try {
429
+ // Codex r3 (lead fix): refused BEFORE the read; the throw lands in `failures` below.
430
+ assertSkillMdContained(join(skillsDir, id), join(skillsDir, id, 'SKILL.md'), id);
179
431
  const document = parseSkillDocument(readFileSync(join(skillsDir, id, 'SKILL.md'), 'utf-8'));
180
432
  const frontmatter = ClaudeSkillFrontmatterSchema.parse(parseYaml(document.frontmatterYaml));
181
433
  skills.push({ id, description: frontmatter.description });
@@ -258,15 +510,36 @@ export function formatSkillApplyFailures(failures: readonly SkillApplyFailure[])
258
510
  }
259
511
 
260
512
  /** Get detailed info about a single skill without loading all assets. */
513
+ /**
514
+ * Codex r2 CRITICAL (lead fix, AM-8 completed): the walk's containment guard runs AFTER
515
+ * `SKILL.md` has already been read, so a `SKILL.md` that is itself a symlink escaping
516
+ * the skill directory (`SKILL.md -> /etc/motd`, `-> ../../outside.md`) was followed and
517
+ * installed regardless. `SKILL.md` is mandatory, so an escaping one cannot be "skipped" —
518
+ * the whole skill is REFUSED with a named reason, never loaded from outside its directory.
519
+ * An in-tree `SKILL.md` symlink (a real file elsewhere inside the same skill directory)
520
+ * still loads. Returns nothing; throws on escape.
521
+ */
522
+ function assertSkillMdContained(skillDir: string, skillMdPath: string, id: string): void {
523
+ if (!lstatSync(skillMdPath).isSymbolicLink()) return;
524
+ const rootReal = realpathSync(skillDir);
525
+ const targetReal = realpathSync(skillMdPath);
526
+ if (!isContained(rootReal, targetReal)) {
527
+ throw new Error(
528
+ `skill ${JSON.stringify(id)}: SKILL.md is a symlink escaping the skill directory (-> ${targetReal}) — refused`,
529
+ );
530
+ }
531
+ }
532
+
261
533
  export function getSkillInfo(skillsDir: string, id: string): SkillInfo | undefined {
262
534
  const skillDir = join(skillsDir, id);
263
535
  const skillMdPath = join(skillDir, 'SKILL.md');
264
536
  if (!existsSync(skillMdPath)) return undefined;
537
+ assertSkillMdContained(skillDir, skillMdPath, id);
265
538
  const document = parseSkillDocument(readFileSync(skillMdPath, 'utf-8'));
266
539
  const fm = parseYaml(document.frontmatterYaml) as Record<string, unknown>;
267
540
  const parsed = ClaudeSkillFrontmatterSchema.parse(fm);
268
541
  const assetPaths = walkFiles(skillDir)
269
- .filter((p) => p !== skillMdPath)
542
+ .files.filter((p) => p !== skillMdPath)
270
543
  .map((p) => relative(skillDir, p).split('\\').join('/'))
271
544
  .sort();
272
545
  return {
@@ -281,21 +554,35 @@ export function getSkillInfo(skillsDir: string, id: string): SkillInfo | undefin
281
554
  };
282
555
  }
283
556
 
557
+ /**
558
+ * A {@link CanonicalSkill} plus, optionally, the {@link SkippedEntry} list
559
+ * {@link loadSkillFromDir} collected while walking the skill's directory. The
560
+ * field is additive and optional — a `CanonicalSkill`-typed caller (every
561
+ * adapter, every existing consumer) sees exactly the shape it always saw;
562
+ * only a caller that reads `.skipped` learns about junk/broken-symlink skips.
563
+ */
564
+ export type LoadedSkill = CanonicalSkill & { readonly skipped?: readonly SkippedEntry[] };
565
+
284
566
  /**
285
567
  * Load one `<skillsDir>/<id>/` directory into a {@link CanonicalSkill}: its
286
- * `SKILL.md` document plus every other file as a bundled asset.
568
+ * `SKILL.md` document plus every other file as a bundled asset. Junk entries
569
+ * and broken/cyclic symlinks encountered along the way are named in
570
+ * `.skipped` (feature `skills-walk-symlinks-and-junk`, FR-1/FR-2) — omitted
571
+ * entirely when nothing was skipped, never a silent drop.
287
572
  *
288
573
  * @throws if the skill directory has no `SKILL.md`.
289
574
  */
290
- export function loadSkillFromDir(skillsDir: string, id: string): CanonicalSkill {
575
+ export function loadSkillFromDir(skillsDir: string, id: string): LoadedSkill {
291
576
  const skillDir = join(skillsDir, id);
292
577
  const skillMdPath = join(skillDir, 'SKILL.md');
293
578
  if (!existsSync(skillMdPath)) {
294
579
  throw new Error(`skill not found: ${JSON.stringify(id)} (looked in ${skillsDir})`);
295
580
  }
581
+ assertSkillMdContained(skillDir, skillMdPath, id);
296
582
  const document = parseSkillDocument(readFileSync(skillMdPath, 'utf-8'));
297
583
  const frontmatter = ClaudeSkillFrontmatterSchema.parse(parseYaml(document.frontmatterYaml));
298
- const assets: SkillAsset[] = walkFiles(skillDir)
584
+ const walk = walkFiles(skillDir);
585
+ const assets: SkillAsset[] = walk.files
299
586
  .filter((path) => path !== skillMdPath)
300
587
  .map((path) => {
301
588
  const { encoding, content } = readAssetContent(path);
@@ -306,5 +593,7 @@ export function loadSkillFromDir(skillsDir: string, id: string): CanonicalSkill
306
593
  };
307
594
  })
308
595
  .sort((a, b) => a.path.localeCompare(b.path));
309
- return { id, frontmatter, document, assets };
596
+ return walk.skipped.length > 0
597
+ ? { id, frontmatter, document, assets, skipped: walk.skipped }
598
+ : { id, frontmatter, document, assets };
310
599
  }
@@ -69,6 +69,7 @@ import {
69
69
  reindexAgentdbRows,
70
70
  readAgentdbRowsByTaskType,
71
71
  DZ_OWNED_TASK_TYPES,
72
+ readStoreGeneration,
72
73
  } from './agentdb-index.js';
73
74
  // smart-backlog (ADR-001/005 lifecycle): `dz vector reindex` must re-embed dz-backlog rows too, or
74
75
  // they rot in a stale embedding space after a model bump. One-directional import — backlog.ts imports
@@ -857,12 +858,17 @@ interface EngineCacheEntry {
857
858
  readonly stat: AgentdbDbStat;
858
859
  }
859
860
 
860
- /** The three independent invalidation signals AM-6 asks for, plus the recency stamp
861
+ /** The four independent invalidation signals: the three AM-6 asks for (mtime/size/inode) PLUS the
862
+ * store's own write-generation counter (`store-generation-counter`, FR-2) — a same-size same-tick
863
+ * temp+rename replace can still leave mtime/size/inode all coincidentally unchanged on a coarse
864
+ * filesystem, but `indexPatternsToAgentdb` bumps the generation on every real write, so it is the
865
+ * one signal that can never coincidentally match a stale cache entry. Plus the recency stamp
861
866
  * {@link touchEngineCacheEntry} needs for the bounded-size eviction below. */
862
867
  interface AgentdbDbStat {
863
868
  readonly mtimeMs: number;
864
869
  readonly size: number;
865
870
  readonly ino: number;
871
+ readonly generation: number;
866
872
  lastUsedAt: number;
867
873
  }
868
874
 
@@ -897,21 +903,25 @@ function engineCacheKey(projectRoot: string): string {
897
903
 
898
904
  /** stat facts of `<root>/.dz/agentdb.db`, or `-1`/`-1`/`-1` when absent — a distinct, stable cache
899
905
  * key for "no store yet" so a project that later gains a store is never confused with one that
900
- * never had (statSync's own floor is mtime 0). Never throws. */
901
- function agentdbDbStat(projectRoot: string): { mtimeMs: number; size: number; ino: number } {
906
+ * never had (statSync's own floor is mtime 0). `generation` is read regardless of whether the stat
907
+ * itself succeeded (`readStoreGeneration` already degrades a missing/corrupt counter file to `0`,
908
+ * FR-2's compatibility floor for a pre-existing store). Never throws. */
909
+ function agentdbDbStat(projectRoot: string): { mtimeMs: number; size: number; ino: number; generation: number } {
910
+ const generation = readStoreGeneration(projectRoot);
902
911
  try {
903
912
  const st = statSync(join(projectRoot, '.dz', 'agentdb.db'));
904
- return { mtimeMs: st.mtimeMs, size: st.size, ino: st.ino };
913
+ return { mtimeMs: st.mtimeMs, size: st.size, ino: st.ino, generation };
905
914
  } catch {
906
- return { mtimeMs: -1, size: -1, ino: -1 };
915
+ return { mtimeMs: -1, size: -1, ino: -1, generation };
907
916
  }
908
917
  }
909
918
 
910
- /** True when NONE of the three independent signals changed — the only case where a cached engine
911
- * may still be trusted (AM-6). Any one of them differing (a same-tick replace still bumps size or
912
- * gets a fresh inode from a temp+rename write) forces a re-resolve. */
913
- function agentdbDbStatUnchanged(a: AgentdbDbStat, b: { mtimeMs: number; size: number; ino: number }): boolean {
914
- return a.mtimeMs === b.mtimeMs && a.size === b.size && a.ino === b.ino;
919
+ /** True when NONE of the four independent signals changed — the only case where a cached engine
920
+ * may still be trusted (AM-6, and `store-generation-counter` FR-2). Any one of them differing (a
921
+ * same-tick replace still bumps size or gets a fresh inode from a temp+rename write, and every real
922
+ * write bumps the generation regardless) forces a re-resolve. */
923
+ function agentdbDbStatUnchanged(a: AgentdbDbStat, b: { mtimeMs: number; size: number; ino: number; generation: number }): boolean {
924
+ return a.mtimeMs === b.mtimeMs && a.size === b.size && a.ino === b.ino && a.generation === b.generation;
915
925
  }
916
926
 
917
927
  /** Evict the least-recently-used entry once the cache is at capacity — called only on a genuine