@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.
- package/.dz-manifest.json +235 -135
- package/README.md +91 -8
- package/dist/__tests__/golden-baseline.test.js +1 -1
- package/dist/__tests__/golden-baseline.test.js.map +1 -1
- package/dist/agentdb-index.js +3 -3
- package/dist/agentdb-index.js.map +1 -1
- package/dist/agentdb-reindex-marker.d.ts +2 -2
- package/dist/agentdb-reindex-marker.js +4 -4
- package/dist/agentdb-reindex-marker.js.map +1 -1
- package/dist/agentdb-snapshot-rotation.d.ts +1 -1
- package/dist/agentdb-snapshot-rotation.js +1 -1
- package/dist/backup-freshness.d.ts +32 -0
- package/dist/backup-freshness.d.ts.map +1 -0
- package/dist/backup-freshness.js +69 -0
- package/dist/backup-freshness.js.map +1 -0
- package/dist/cmd-usage.d.ts +2 -2
- package/dist/cmd-usage.d.ts.map +1 -1
- package/dist/cmd-usage.js +56 -8
- package/dist/cmd-usage.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts +2 -1
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +15 -2
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/compounding.d.ts +109 -1
- package/dist/compounding.d.ts.map +1 -1
- package/dist/compounding.js +172 -9
- package/dist/compounding.js.map +1 -1
- package/dist/discrimination-gate.d.ts +0 -3
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +2 -2
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/doctor-instrument.d.ts +65 -0
- package/dist/doctor-instrument.d.ts.map +1 -0
- package/dist/doctor-instrument.js +91 -0
- package/dist/doctor-instrument.js.map +1 -0
- package/dist/feature-tier.d.ts.map +1 -1
- package/dist/feature-tier.js +13 -1
- package/dist/feature-tier.js.map +1 -1
- package/dist/guard.d.ts +39 -2
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +117 -14
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +20 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -6
- package/dist/index.js.map +1 -1
- package/dist/integration-apply.js +2 -2
- package/dist/integration-apply.js.map +1 -1
- package/dist/lesson-payoff.js +3 -3
- package/dist/lesson-payoff.js.map +1 -1
- package/dist/mutation-gate.d.ts +30 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +45 -1
- package/dist/mutation-gate.js.map +1 -1
- package/dist/named-lock.d.ts +5 -2
- package/dist/named-lock.d.ts.map +1 -1
- package/dist/named-lock.js +30 -10
- package/dist/named-lock.js.map +1 -1
- package/dist/operations.d.ts +13 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +112 -2
- package/dist/operations.js.map +1 -1
- package/dist/pack-inventory.d.ts +51 -0
- package/dist/pack-inventory.d.ts.map +1 -0
- package/dist/pack-inventory.js +306 -0
- package/dist/pack-inventory.js.map +1 -0
- package/dist/package-skill-layouts.d.ts +3 -2
- package/dist/package-skill-layouts.d.ts.map +1 -1
- package/dist/package-skill-layouts.js +3 -2
- package/dist/package-skill-layouts.js.map +1 -1
- package/dist/patterns.d.ts +8 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +11 -1
- package/dist/patterns.js.map +1 -1
- package/dist/publish.d.ts +29 -7
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +92 -15
- package/dist/publish.js.map +1 -1
- package/dist/registry.d.ts +58 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +86 -25
- package/dist/registry.js.map +1 -1
- package/dist/release.d.ts +18 -0
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +30 -0
- package/dist/release.js.map +1 -1
- package/dist/reqe-verdict.d.ts +55 -0
- package/dist/reqe-verdict.d.ts.map +1 -0
- package/dist/reqe-verdict.js +173 -0
- package/dist/reqe-verdict.js.map +1 -0
- package/dist/reqe.d.ts +35 -14
- package/dist/reqe.d.ts.map +1 -1
- package/dist/reqe.js +85 -32
- package/dist/reqe.js.map +1 -1
- package/dist/round-exec.d.ts +10 -0
- package/dist/round-exec.d.ts.map +1 -1
- package/dist/round-exec.js +3 -2
- package/dist/round-exec.js.map +1 -1
- package/dist/round.d.ts +29 -0
- package/dist/round.d.ts.map +1 -1
- package/dist/round.js +8 -0
- package/dist/round.js.map +1 -1
- package/dist/session-retro.d.ts +1 -1
- package/dist/session-retro.js +3 -3
- package/dist/session-retro.js.map +1 -1
- package/dist/skill-drift.d.ts +18 -1
- package/dist/skill-drift.d.ts.map +1 -1
- package/dist/skill-drift.js +46 -13
- package/dist/skill-drift.js.map +1 -1
- package/dist/statusline.js +2 -2
- package/dist/statusline.js.map +1 -1
- package/dist/store-guard.js +3 -3
- package/dist/store-guard.js.map +1 -1
- package/dist/store-lock.d.ts +9 -0
- package/dist/store-lock.d.ts.map +1 -1
- package/dist/store-lock.js +22 -3
- package/dist/store-lock.js.map +1 -1
- package/dist/test-receipt.d.ts +66 -0
- package/dist/test-receipt.d.ts.map +1 -0
- package/dist/test-receipt.js +73 -0
- package/dist/test-receipt.js.map +1 -0
- package/dist/workflow-run.d.ts.map +1 -1
- package/dist/workflow-run.js +7 -8
- package/dist/workflow-run.js.map +1 -1
- package/package.json +1 -1
- package/sbom.json +384 -134
- package/src/__tests__/golden-baseline.test.ts +1 -1
- package/src/agentdb-index.ts +3 -3
- package/src/agentdb-reindex-marker.ts +4 -4
- package/src/agentdb-snapshot-rotation.ts +1 -1
- package/src/backup-freshness.ts +96 -0
- package/src/cmd-usage.ts +59 -8
- package/src/codex-hooks-assets.ts +15 -2
- package/src/compounding.ts +244 -10
- package/src/discrimination-gate.ts +2 -5
- package/src/doctor-instrument.ts +153 -0
- package/src/feature-tier.ts +14 -1
- package/src/guard.ts +128 -17
- package/src/index.ts +28 -7
- package/src/integration-apply.ts +2 -2
- package/src/lesson-payoff.ts +3 -3
- package/src/mutation-gate.ts +54 -1
- package/src/named-lock.ts +36 -11
- package/src/operations.ts +109 -3
- package/src/pack-inventory.ts +313 -0
- package/src/package-skill-layouts.ts +3 -2
- package/src/patterns.ts +18 -1
- package/src/publish.ts +101 -22
- package/src/registry.ts +114 -18
- package/src/release.ts +36 -0
- package/src/reqe-verdict.ts +174 -0
- package/src/reqe.ts +111 -35
- package/src/round-exec.ts +13 -2
- package/src/round.ts +38 -0
- package/src/session-retro.ts +3 -3
- package/src/skill-drift.ts +66 -12
- package/src/statusline.ts +2 -2
- package/src/store-guard.ts +3 -3
- package/src/store-lock.ts +20 -3
- package/src/test-receipt.ts +106 -0
- 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> {
|
package/src/agentdb-index.ts
CHANGED
|
@@ -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,
|
|
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). `
|
|
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
|
|
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 `
|
|
12
|
-
* lock lives at `<dirname(dbFile)>/.dz
|
|
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 {
|
|
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
|
|
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): `
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
730
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
-
*
|
|
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;
|