@dzhechkov/harness-core 0.8.39 → 0.8.41

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 (161) hide show
  1. package/.dz-manifest.json +235 -135
  2. package/README.md +91 -8
  3. package/dist/__tests__/golden-baseline.test.js +1 -1
  4. package/dist/__tests__/golden-baseline.test.js.map +1 -1
  5. package/dist/agentdb-index.js +3 -3
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/agentdb-reindex-marker.d.ts +2 -2
  8. package/dist/agentdb-reindex-marker.js +4 -4
  9. package/dist/agentdb-reindex-marker.js.map +1 -1
  10. package/dist/agentdb-snapshot-rotation.d.ts +1 -1
  11. package/dist/agentdb-snapshot-rotation.js +1 -1
  12. package/dist/backup-freshness.d.ts +32 -0
  13. package/dist/backup-freshness.d.ts.map +1 -0
  14. package/dist/backup-freshness.js +69 -0
  15. package/dist/backup-freshness.js.map +1 -0
  16. package/dist/cmd-usage.d.ts +2 -2
  17. package/dist/cmd-usage.d.ts.map +1 -1
  18. package/dist/cmd-usage.js +56 -8
  19. package/dist/cmd-usage.js.map +1 -1
  20. package/dist/codex-hooks-assets.d.ts +2 -1
  21. package/dist/codex-hooks-assets.d.ts.map +1 -1
  22. package/dist/codex-hooks-assets.js +15 -2
  23. package/dist/codex-hooks-assets.js.map +1 -1
  24. package/dist/compounding.d.ts +109 -1
  25. package/dist/compounding.d.ts.map +1 -1
  26. package/dist/compounding.js +172 -9
  27. package/dist/compounding.js.map +1 -1
  28. package/dist/discrimination-gate.d.ts +0 -3
  29. package/dist/discrimination-gate.d.ts.map +1 -1
  30. package/dist/discrimination-gate.js +2 -2
  31. package/dist/discrimination-gate.js.map +1 -1
  32. package/dist/doctor-instrument.d.ts +65 -0
  33. package/dist/doctor-instrument.d.ts.map +1 -0
  34. package/dist/doctor-instrument.js +91 -0
  35. package/dist/doctor-instrument.js.map +1 -0
  36. package/dist/feature-tier.d.ts.map +1 -1
  37. package/dist/feature-tier.js +13 -1
  38. package/dist/feature-tier.js.map +1 -1
  39. package/dist/guard.d.ts +39 -2
  40. package/dist/guard.d.ts.map +1 -1
  41. package/dist/guard.js +117 -14
  42. package/dist/guard.js.map +1 -1
  43. package/dist/index.d.ts +20 -9
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +14 -6
  46. package/dist/index.js.map +1 -1
  47. package/dist/integration-apply.js +2 -2
  48. package/dist/integration-apply.js.map +1 -1
  49. package/dist/lesson-payoff.js +3 -3
  50. package/dist/lesson-payoff.js.map +1 -1
  51. package/dist/mutation-gate.d.ts +30 -0
  52. package/dist/mutation-gate.d.ts.map +1 -1
  53. package/dist/mutation-gate.js +45 -1
  54. package/dist/mutation-gate.js.map +1 -1
  55. package/dist/named-lock.d.ts +5 -2
  56. package/dist/named-lock.d.ts.map +1 -1
  57. package/dist/named-lock.js +30 -10
  58. package/dist/named-lock.js.map +1 -1
  59. package/dist/operations.d.ts +13 -0
  60. package/dist/operations.d.ts.map +1 -1
  61. package/dist/operations.js +112 -2
  62. package/dist/operations.js.map +1 -1
  63. package/dist/pack-inventory.d.ts +51 -0
  64. package/dist/pack-inventory.d.ts.map +1 -0
  65. package/dist/pack-inventory.js +306 -0
  66. package/dist/pack-inventory.js.map +1 -0
  67. package/dist/package-skill-layouts.d.ts +3 -2
  68. package/dist/package-skill-layouts.d.ts.map +1 -1
  69. package/dist/package-skill-layouts.js +3 -2
  70. package/dist/package-skill-layouts.js.map +1 -1
  71. package/dist/patterns.d.ts +8 -0
  72. package/dist/patterns.d.ts.map +1 -1
  73. package/dist/patterns.js +11 -1
  74. package/dist/patterns.js.map +1 -1
  75. package/dist/publish.d.ts +29 -7
  76. package/dist/publish.d.ts.map +1 -1
  77. package/dist/publish.js +92 -15
  78. package/dist/publish.js.map +1 -1
  79. package/dist/registry.d.ts +58 -0
  80. package/dist/registry.d.ts.map +1 -1
  81. package/dist/registry.js +86 -25
  82. package/dist/registry.js.map +1 -1
  83. package/dist/release.d.ts +18 -0
  84. package/dist/release.d.ts.map +1 -1
  85. package/dist/release.js +30 -0
  86. package/dist/release.js.map +1 -1
  87. package/dist/reqe-verdict.d.ts +55 -0
  88. package/dist/reqe-verdict.d.ts.map +1 -0
  89. package/dist/reqe-verdict.js +173 -0
  90. package/dist/reqe-verdict.js.map +1 -0
  91. package/dist/reqe.d.ts +35 -14
  92. package/dist/reqe.d.ts.map +1 -1
  93. package/dist/reqe.js +85 -32
  94. package/dist/reqe.js.map +1 -1
  95. package/dist/round-exec.d.ts +10 -0
  96. package/dist/round-exec.d.ts.map +1 -1
  97. package/dist/round-exec.js +3 -2
  98. package/dist/round-exec.js.map +1 -1
  99. package/dist/round.d.ts +29 -0
  100. package/dist/round.d.ts.map +1 -1
  101. package/dist/round.js +8 -0
  102. package/dist/round.js.map +1 -1
  103. package/dist/session-retro.d.ts +1 -1
  104. package/dist/session-retro.js +3 -3
  105. package/dist/session-retro.js.map +1 -1
  106. package/dist/skill-drift.d.ts +18 -1
  107. package/dist/skill-drift.d.ts.map +1 -1
  108. package/dist/skill-drift.js +46 -13
  109. package/dist/skill-drift.js.map +1 -1
  110. package/dist/statusline.js +2 -2
  111. package/dist/statusline.js.map +1 -1
  112. package/dist/store-guard.js +3 -3
  113. package/dist/store-guard.js.map +1 -1
  114. package/dist/store-lock.d.ts +9 -0
  115. package/dist/store-lock.d.ts.map +1 -1
  116. package/dist/store-lock.js +22 -3
  117. package/dist/store-lock.js.map +1 -1
  118. package/dist/test-receipt.d.ts +66 -0
  119. package/dist/test-receipt.d.ts.map +1 -0
  120. package/dist/test-receipt.js +73 -0
  121. package/dist/test-receipt.js.map +1 -0
  122. package/dist/workflow-run.d.ts.map +1 -1
  123. package/dist/workflow-run.js +7 -8
  124. package/dist/workflow-run.js.map +1 -1
  125. package/package.json +1 -1
  126. package/sbom.json +384 -134
  127. package/src/__tests__/golden-baseline.test.ts +1 -1
  128. package/src/agentdb-index.ts +3 -3
  129. package/src/agentdb-reindex-marker.ts +4 -4
  130. package/src/agentdb-snapshot-rotation.ts +1 -1
  131. package/src/backup-freshness.ts +96 -0
  132. package/src/cmd-usage.ts +59 -8
  133. package/src/codex-hooks-assets.ts +15 -2
  134. package/src/compounding.ts +244 -10
  135. package/src/discrimination-gate.ts +2 -5
  136. package/src/doctor-instrument.ts +153 -0
  137. package/src/feature-tier.ts +14 -1
  138. package/src/guard.ts +128 -17
  139. package/src/index.ts +28 -7
  140. package/src/integration-apply.ts +2 -2
  141. package/src/lesson-payoff.ts +3 -3
  142. package/src/mutation-gate.ts +54 -1
  143. package/src/named-lock.ts +36 -11
  144. package/src/operations.ts +109 -3
  145. package/src/pack-inventory.ts +313 -0
  146. package/src/package-skill-layouts.ts +3 -2
  147. package/src/patterns.ts +18 -1
  148. package/src/publish.ts +101 -22
  149. package/src/registry.ts +114 -18
  150. package/src/release.ts +36 -0
  151. package/src/reqe-verdict.ts +174 -0
  152. package/src/reqe.ts +111 -35
  153. package/src/round-exec.ts +13 -2
  154. package/src/round.ts +38 -0
  155. package/src/session-retro.ts +3 -3
  156. package/src/skill-drift.ts +66 -12
  157. package/src/statusline.ts +2 -2
  158. package/src/store-guard.ts +3 -3
  159. package/src/store-lock.ts +20 -3
  160. package/src/test-receipt.ts +106 -0
  161. package/src/workflow-run.ts +7 -8
@@ -13,7 +13,7 @@ import { DEFAULT_HARMONIZE_THRESHOLD } from '../vector-tier.js';
13
13
 
14
14
  let project: string;
15
15
 
16
- beforeEach(() => { project = mkdtempSync(join(tmpdir(), 'dz-golden-')); });
16
+ beforeEach(() => { project = mkdtempSync(join(tmpdir(), 'dz-golden-')); mkdirSync(join(project, '.dz'), { recursive: true }); /* the store pre-exists: a lock never seeds one */ });
17
17
  afterEach(() => rmSync(project, { recursive: true, force: true }));
18
18
 
19
19
  async function seed(): Promise<void> {
@@ -23,7 +23,7 @@ import { applyReadonlyPragmas } from './sqlite-read-helpers.js';
23
23
  import { rotatePreReindexSnapshotsUnlocked, type SnapshotRotationReport } from './agentdb-snapshot-rotation.js';
24
24
  import { snapshotSqliteDatabase, restoreSqliteSnapshot, type SnapshotMethod, type SnapshotDbCtor } from './agentdb-snapshot.js';
25
25
  import { withAgentdbSnapshotLock, writeReindexMarker, clearReindexMarker, markReindexMarkerRecoveryRequired, reindexMarkerPath, msFromBackupPath } from './agentdb-reindex-marker.js';
26
- import { NamedLockTimeoutError, withNamedLockSync } from './named-lock.js';
26
+ import { NamedLockTimeoutError, withDirLockSync } from './named-lock.js';
27
27
  import type { StoreLockOptions } from './store-lock.js';
28
28
  // The backlog dedup embed form (PURE, zero-dep — no cycle): dz-backlog rows must be embedded in the
29
29
  // SAME bounded form the dedup query uses, including through the reindex path.
@@ -206,7 +206,7 @@ export function readStoreGeneration(projectRoot: string, dbPath?: string): numbe
206
206
  * under mutual exclusion (AM-2, fix-round after Codex review). A bare read→compute→rename with no
207
207
  * lock lets two concurrent writers both read the same current value and both publish `current+1` —
208
208
  * one bump is lost — or lets a DELAYED writer overwrite a later value with an earlier one (the
209
- * counter briefly goes backwards on disk). `withNamedLockSync` (`named-lock.ts`, the repo's
209
+ * counter briefly goes backwards on disk). `withDirLockSync` (`named-lock.ts`, the repo's
210
210
  * advisory lock for a read-modify-write file store — `.claude/rules/cross-runtime-concurrency.md`)
211
211
  * serializes the critical section; the counter is RE-READ from disk *inside* the lock (never trusted
212
212
  * from before acquisition), so the sequence every process observes is strictly monotonic
@@ -303,7 +303,7 @@ export function bumpStoreGeneration(
303
303
  // this sidecar now tracks "last PUBLISHED generation", updated on every successful bump, not just
304
304
  // a recovery.
305
305
  const recoveredMemoryFile = recoveredMemoryFilePath(genFile);
306
- return withNamedLockSync(
306
+ return withDirLockSync(
307
307
  dirname(dbFile),
308
308
  'store-generation',
309
309
  (): { readonly ok: true; readonly generation: number } | { readonly ok: false; readonly error: string } => {
@@ -8,8 +8,8 @@
8
8
  * concurrent `rotate --keep 0` can delete the very snapshot family a live reindex is relying on as
9
9
  * its undo point, with only a 10-minute grace period standing in the way.
10
10
  *
11
- * {@link withAgentdbSnapshotLock} is a thin, dbFile-addressed wrapper over `withNamedLockSync`: the
12
- * lock lives at `<dirname(dbFile)>/.dz/locks/agentdb-snapshot.lock` — a pure function of the
11
+ * {@link withAgentdbSnapshotLock} is a thin, dbFile-addressed wrapper over `withDirLockSync`: the
12
+ * lock lives at `<dirname(dbFile)>/.dz-locks/agentdb-snapshot.lock` (or the pre-existing legacy `.dz/locks` path) — a pure function of the
13
13
  * database's OWN directory, never of the caller's cwd, so a project store and the home brain each
14
14
  * get their own lock (AC-5) regardless of where `dz` happens to be invoked from. AM-7: it defaults
15
15
  * `staleMs` to {@link AGENTDB_SNAPSHOT_LOCK_STALE_MS} (5 minutes) rather than named-lock's ordinary
@@ -56,7 +56,7 @@ import { closeSync, openSync, readFileSync, renameSync, rmSync, writeFileSync }
56
56
  import { randomBytes } from 'node:crypto';
57
57
  import { basename, dirname } from 'node:path';
58
58
 
59
- import { withNamedLockSync } from './named-lock.js';
59
+ import { withDirLockSync } from './named-lock.js';
60
60
  import type { StoreLockOptions } from './store-lock.js';
61
61
 
62
62
  /** A marker older than this is abandoned — its family is no longer protected (FR-3). `pid` is
@@ -125,7 +125,7 @@ export function withAgentdbSnapshotLock<T>(
125
125
  fn: () => T,
126
126
  opts: StoreLockOptions = {},
127
127
  ): T {
128
- return withNamedLockSync(dirname(dbFile), 'agentdb-snapshot', fn, {
128
+ return withDirLockSync(dirname(dbFile), 'agentdb-snapshot', fn, {
129
129
  staleMs: AGENTDB_SNAPSHOT_LOCK_STALE_MS,
130
130
  ...opts,
131
131
  });
@@ -351,7 +351,7 @@ export function rotatePreReindexSnapshotsUnlocked(
351
351
  *
352
352
  * `lockTimeoutMs` is test/tuning-only: omitted, the lock uses its ordinary default deadline.
353
353
  *
354
- * AM-4 (carried forward): `withNamedLockSync` creates `<dirname(dbFile)>/.dz/locks/` before it ever
354
+ * AM-4 (carried forward): `withDirLockSync` creates `<dirname(dbFile)>/.dz-locks/` (or reuses a legacy `.dz/locks/`) before it ever
355
355
  * runs `fn` — if `dirname(dbFile)` exists but is NOT a directory (a pathological store path, exactly
356
356
  * the shape AM-4's own fixture uses), that `mkdirSync` throws `ENOTDIR` before any lock is even
357
357
  * attempted. Scanning is therefore checked FIRST, outside the lock: an unreadable directory is a
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Свежесть резервной копии бэклога по журналу backup-backlog.
3
+ *
4
+ * Порядок строк важнее меток времени: отказ без даты ПОСЛЕ успеха — последний вердикт.
5
+ * Отсутствие вердикта не доказывает ни актуальность копии, ни её отказ.
6
+ */
7
+ export type BackupFreshnessVerdict =
8
+ | { readonly state: 'fresh'; readonly verifiedAt: string; readonly ageHours: number; readonly tasks: number | null }
9
+ | {
10
+ readonly state: 'stale';
11
+ readonly reason: 'too-old' | 'last-run-failed';
12
+ readonly verifiedAt: string | null;
13
+ readonly ageHours: number | null;
14
+ readonly failureLine: string | null;
15
+ }
16
+ | {
17
+ readonly state: 'not-established';
18
+ readonly reason: 'no-verdict' | 'bad-stamp' | 'unrecognized-after-success';
19
+ readonly detail: string;
20
+ readonly unrecognizedLine?: string;
21
+ };
22
+
23
+ /** Чистое решение. Ничего не читает и не запускает — строки и часы подаёт вызывающий. */
24
+ export function decideBackupFreshness(input: {
25
+ readonly lines: readonly string[];
26
+ readonly now: Date;
27
+ readonly maxAgeHours: number;
28
+ }): BackupFreshnessVerdict {
29
+ let last: 'success' | 'failure' | 'unrecognized' | null = null;
30
+ let successLine = '';
31
+ let unrecognizedLine = '';
32
+ let failureLine: string | null = null;
33
+ let verifiedAt: string | null = null;
34
+ let verifiedMs: number | null = null;
35
+
36
+ for (const line of input.lines) {
37
+ if (line.includes('backup-backlog') && ['не удалось', 'НЕ ПРОШЛА', 'ОТПРАВКА ОТКЛОНЕНА', 'не найден'].some((text) => line.includes(text))) {
38
+ last = 'failure';
39
+ failureLine = line;
40
+ continue;
41
+ }
42
+ const isSuccess = line.startsWith('backup-backlog')
43
+ && (line.includes('копия проверена по свежему клону') || line.includes('изменений нет — копия уже актуальна'));
44
+ if (!isSuccess) {
45
+ // Разрешаем только известный прогресс: незнакомый вывод не доказывает успех.
46
+ if (line.trim() === '' || /^backup-backlog(?: \[[^\]]+\])?: отправлено,/.test(line) || /^\s*разобрано задач:/.test(line)) continue;
47
+ if (successLine !== '') {
48
+ last = 'unrecognized';
49
+ unrecognizedLine = line.slice(0, 200);
50
+ }
51
+ continue;
52
+ }
53
+ last = 'success';
54
+ successLine = line;
55
+ const stamp = /\[(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z)\]/.exec(line)?.[1];
56
+ const ms = stamp === undefined ? NaN : Date.parse(stamp);
57
+ // Date.parse нормализует некоторые несуществующие даты: обратная проверка их отвергает.
58
+ const valid = Number.isFinite(ms) && new Date(ms).toISOString() === stamp?.replace('Z', ':00.000Z');
59
+ verifiedAt = valid ? stamp! : null;
60
+ verifiedMs = valid ? ms : null;
61
+ }
62
+
63
+ if (last === null) {
64
+ return { state: 'not-established', reason: 'no-verdict', detail: 'журнал не содержит ни одной строки с вердиктом' };
65
+ }
66
+ if (last === 'unrecognized') {
67
+ return { state: 'not-established', reason: 'unrecognized-after-success', detail: 'неузнанная строка после успеха', unrecognizedLine };
68
+ }
69
+ const elapsed = verifiedMs === null ? null : (input.now.getTime() - verifiedMs) / 3_600_000;
70
+ const ageHours = elapsed === null ? null : Math.round(elapsed * 10) / 10;
71
+ if (last === 'failure') {
72
+ return { state: 'stale', reason: 'last-run-failed', verifiedAt, ageHours, failureLine };
73
+ }
74
+ if (verifiedAt === null || ageHours === null || elapsed === null) {
75
+ return { state: 'not-established', reason: 'bad-stamp', detail: `строка успеха без валидной даты: ${successLine}` };
76
+ }
77
+ if (elapsed > input.maxAgeHours) {
78
+ return { state: 'stale', reason: 'too-old', verifiedAt, ageHours, failureLine: null };
79
+ }
80
+ const tasks = /(\d+)\s+задач/.exec(successLine)?.[1];
81
+ return { state: 'fresh', verifiedAt, ageHours, tasks: tasks === undefined ? null : Number(tasks) };
82
+ }
83
+
84
+ /** Одна строка для человека: измеренный возраст либо конкретная строка отказа. */
85
+ export function renderBackupFreshness(verdict: BackupFreshnessVerdict): string {
86
+ if (verdict.state === 'not-established') {
87
+ return `backlog backup: НЕ УСТАНОВЛЕНО — ${verdict.detail}${verdict.unrecognizedLine === undefined ? '' : `: ${verdict.unrecognizedLine}`}`;
88
+ }
89
+ if (verdict.state === 'fresh') {
90
+ return `backlog backup: копия актуальна — возраст ${verdict.ageHours} ч, проверена ${verdict.verifiedAt}${verdict.tasks === null ? '' : `, ${verdict.tasks} задач`}`;
91
+ }
92
+ if (verdict.reason === 'last-run-failed') {
93
+ return `backlog backup: последний запуск завершился отказом — ${verdict.failureLine}`;
94
+ }
95
+ return `backlog backup: копия устарела — возраст ${verdict.ageHours} ч, проверена ${verdict.verifiedAt}`;
96
+ }
package/src/cmd-usage.ts CHANGED
@@ -21,7 +21,8 @@ import {
21
21
  } from 'node:fs';
22
22
  import { dirname, join, resolve } from 'node:path';
23
23
 
24
- import { withNamedLockSync } from './named-lock.js';
24
+ import { withProjectLockSync } from './named-lock.js';
25
+ import { StoreAbsentError } from './store-lock.js';
25
26
  import { isRepoBoundary } from './repo-boundary.js';
26
27
 
27
28
  export const CMD_USAGE_LOG_RELATIVE = '.dz/cmd-usage.jsonl';
@@ -153,6 +154,12 @@ interface RuleUsage {
153
154
  readonly stats: ReadonlyMap<string, CmdUsageStat>;
154
155
  readonly skipped: number;
155
156
  readonly outOfRange: number;
157
+ /**
158
+ * Audit rows inside the window that recorded which rules they EVALUATED. Zero means this report
159
+ * has no evidence about rules at all — which is a different answer from "the rule is unused", and
160
+ * the distinction is the whole point of counting it (backlog 1bee49dd).
161
+ */
162
+ readonly evaluationRows: number;
156
163
  }
157
164
 
158
165
  const REPO_BOUNDARY_IO = {
@@ -401,9 +408,13 @@ export function loadDeadwoodAllowlist(json: string): DeadwoodAllowlistEntry[] {
401
408
  function parseGuardAuditUsage(text: string, weeks: number, now: Date): RuleUsage {
402
409
  const auditTimestamps: string[] = [];
403
410
  const hits: CmdUsageInvocationRecord[] = [];
411
+ let evaluationRows = 0;
404
412
  let skipped = 0;
405
413
  let outOfRange = 0;
406
414
  const newestAllowed = now.getTime() + DEADWOOD_FUTURE_TOLERANCE_MS;
415
+ // Same window arithmetic `foldCmdUsage` uses, so "counted as evidence" and "counted as a run"
416
+ // cannot disagree about which rows are inside.
417
+ const windowStart = now.getTime() - Math.max(0, weeks) * 7 * DAY_MS;
407
418
  for (const line of text.split('\n')) {
408
419
  if (line.trim() === '') continue;
409
420
  let row: Record<string, unknown>;
@@ -425,17 +436,40 @@ function parseGuardAuditUsage(text: string, weeks: number, now: Date): RuleUsage
425
436
  continue;
426
437
  }
427
438
  auditTimestamps.push(row.ts);
439
+ // A rule's HEALTHY state is silence, so firing cannot measure whether it is alive. `evaluated`
440
+ // records the rules that actually got their turn; rows written before the field existed carry
441
+ // none, and they contribute evidence about firing only.
442
+ //
443
+ // Two conditions, both named by cross-family review (Codex gpt-5.6-sol, 2026-09-21), both of
444
+ // which turn this evidence into a false accusation if skipped:
445
+ // · the row must be INSIDE the window. An instrumented row older than `now - weeks` yields no
446
+ // in-window runs, so counting it as evidence would let one ancient row flip every absent
447
+ // rule from "cannot judge" to "dead".
448
+ // · the array must carry a USABLE id. `evaluated: []` is a row that recorded nothing; treating
449
+ // it as evidence is the same false accusation by a shorter path.
450
+ const evaluatedIds = new Set<string>();
451
+ if (Array.isArray(row.evaluated)) {
452
+ for (const value of row.evaluated) {
453
+ if (typeof value === 'string' && value.trim() !== '') evaluatedIds.add(value);
454
+ }
455
+ }
456
+ if (evaluatedIds.size > 0 && tsMs >= windowStart) evaluationRows += 1;
457
+ for (const id of evaluatedIds) {
458
+ hits.push({ kind: 'cmd', cmd: id, ts: row.ts, v: CMD_USAGE_SCHEMA });
459
+ }
428
460
  const violations = Array.isArray(row.violations) ? row.violations : [];
429
461
  for (const value of violations) {
430
462
  const rule = typeof value === 'object' && value !== null
431
463
  ? (value as { rule?: unknown }).rule
432
464
  : undefined;
433
- if (typeof rule === 'string' && rule.trim() !== '') {
465
+ // One guard run is ONE run. A rule that both evaluated and fired in the same row would be
466
+ // counted twice — 100 warning evaluations reported as 200 runs (same review, second finding).
467
+ if (typeof rule === 'string' && rule.trim() !== '' && !evaluatedIds.has(rule)) {
434
468
  hits.push({ kind: 'cmd', cmd: rule, ts: row.ts, v: CMD_USAGE_SCHEMA });
435
469
  }
436
470
  }
437
471
  }
438
- return { auditTimestamps, stats: foldCmdUsage(hits, weeks, now), skipped, outOfRange };
472
+ return { auditTimestamps, stats: foldCmdUsage(hits, weeks, now), skipped, outOfRange, evaluationRows };
439
473
  }
440
474
 
441
475
  function timestampDepthDays(timestamps: readonly string[], now: Date): number {
@@ -607,6 +641,23 @@ export function buildDeadwoodReport(input: DeadwoodInput): DeadwoodReport {
607
641
  });
608
642
  continue;
609
643
  }
644
+ // A rule the window has no EVALUATION evidence for is unjudged, not unused. Firing is the wrong
645
+ // signal for a guard (silence is its healthy state), so without `evaluated` rows the only honest
646
+ // answer is "this report cannot judge the rule" — exactly what the skill surface already says.
647
+ if (item.kind === 'rule' && rules.evaluationRows === 0
648
+ && (rules.stats.get(item.surface)?.runsInWindow ?? 0) === 0
649
+ // An explicit allowlist entry is an operator's standing statement about this surface; it keeps
650
+ // its own wording. Only the ACCUSING path — "zero usage, consider deprecating" — is withdrawn.
651
+ && !allowlist.has(allowlistKey(item.kind, item.surface))) {
652
+ noInstrumentation.push({
653
+ state: 'no-instrumentation',
654
+ surface: item.surface,
655
+ kind: item.kind,
656
+ reason: 'no guard-audit row in this window recorded which rules it evaluated; a rule that '
657
+ + 'never fires may be a healthy safety net, so firing alone cannot judge it',
658
+ });
659
+ continue;
660
+ }
610
661
  classifyInstrumented(
611
662
  { surface: item.surface, kind: item.kind },
612
663
  item.kind === 'command'
@@ -726,15 +777,15 @@ function compactedText(records: readonly CmdUsageRecord[]): string {
726
777
  *
727
778
  * The named lock protects competing compactors. Emitters deliberately stay
728
779
  * lock-free; this advisory log accepts a possible racing row rather than making
729
- * every command wait on a lock. Every error is swallowed so compaction can never
730
- * turn `dz deadwood` into a gate.
780
+ * every command wait on a lock. An absent store is a no-op; other failures,
781
+ * including lock timeouts, propagate to the caller.
731
782
  */
732
783
  export function compactCmdUsageIfNeeded(root: string): void {
733
784
  try {
734
785
  const resolvedRoot = resolveCmdUsageRoot(root);
735
786
  const path = join(resolvedRoot, CMD_USAGE_LOG_RELATIVE);
736
787
  if (!existsSync(path) || statSync(path).size <= CMD_USAGE_LOG_MAX_BYTES) return;
737
- withNamedLockSync(resolvedRoot, 'cmd-usage', () => {
788
+ withProjectLockSync(resolvedRoot, 'cmd-usage', () => {
738
789
  if (!existsSync(path) || statSync(path).size <= CMD_USAGE_LOG_MAX_BYTES) return;
739
790
  const parsed = parseCmdUsageLines(readFileSync(path, 'utf8'), new Date());
740
791
  // Preserve a recent raw tail for accurate window counts and fold only the older prefix.
@@ -745,7 +796,7 @@ export function compactCmdUsageIfNeeded(root: string): void {
745
796
  writeFileSync(tmp, text, { encoding: 'utf8', mode: 0o600 });
746
797
  renameSync(tmp, path);
747
798
  });
748
- } catch {
749
- /* advisory compaction is fail-open just like the writer and report */
799
+ } catch (err) {
800
+ if (!(err instanceof StoreAbsentError)) throw err;
750
801
  }
751
802
  }
@@ -12,11 +12,12 @@
12
12
  * |----------------|-----------------------------------------------------|------------------------------------------|
13
13
  * | Polarity | mode-driven on POLICY (default `warn` ⇒ exit 0) | **never-block, always** |
14
14
  * | Keys on | presence of `tool_input.command` (AM-8) | `payload.prompt` |
15
- * | Activation | nearest `.dz` walking up from `payload.cwd` (AM-25) | same |
15
+ * | Activation | nearest `.dz` from `payload.cwd`, bounded by the repo and HOME (AM-25) | same |
16
16
  * | Our own failure| exit 0, silent, note to `helper-errors.jsonl` | exit 0, empty stdout, no write (AM-9) |
17
17
  *
18
18
  * `.git` is **not** an activation marker (AM-25): a user-global hook that treats "any git checkout"
19
19
  * as opted-in reaches every repository on the machine, including trees nobody pointed at dz.
20
+ * It only stops the walk at the repository boundary; HOME is never returned or climbed past.
20
21
  *
21
22
  * The self-failure note goes to `$CODEX_HOME/dz-hooks/helper-errors.jsonl` (AM-33), never into a
22
23
  * project — writing it into `<project>/.dz/` would CREATE a `.dz/` in a foreign repo, which is the
@@ -121,15 +122,27 @@ function readPayload() {
121
122
 
122
123
  /**
123
124
  * Walk UP from cwd to the nearest directory containing a \`.dz\` DIRECTORY.
124
- * \`.git\` is deliberately not a marker (AM-25). No root => the helper is inert.
125
+ * HOME is never returned or climbed past. Check \`.dz\` before the repository boundary.
126
+ * \`.git\` only stops the walk, never activates (AM-25). No root => the helper is inert.
125
127
  */
126
128
  function findProjectRoot(startDir) {
127
129
  try {
128
130
  let dir = path.resolve(startDir);
131
+ let home = null;
132
+ // Step-8 HIGH (find-project-root-boundary): compare RESOLVED paths — a trailing slash in HOME (\`/root/\`)
133
+ // would otherwise never equal the walked \`dir\` and the home store would activate as a project root.
134
+ try { home = path.resolve(process.env.HOME || require('node:os').homedir()); } catch (err) { note('find-root', err); }
129
135
  for (let i = 0; i < 64; i += 1) {
136
+ if (dir === home) return null;
130
137
  try {
131
138
  if (fs.statSync(path.join(dir, '.dz')).isDirectory()) return dir;
132
139
  } catch (_) { /* not here; keep walking */ }
140
+ try {
141
+ const git = path.join(dir, '.git');
142
+ const marker = fs.lstatSync(git);
143
+ if (marker.isDirectory() && fs.lstatSync(path.join(git, 'HEAD')).isFile()) return null;
144
+ if (marker.isFile() && fs.readFileSync(git, 'utf8').startsWith('gitdir:')) return null;
145
+ } catch (_) { /* no readable boundary; keep walking */ }
133
146
  const parent = path.dirname(dir);
134
147
  if (parent === dir) return null;
135
148
  dir = parent;