@dzhechkov/harness-core 0.8.40 → 0.8.42
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 +220 -120
- package/README.md +65 -10
- 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 +8 -6
- 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 +43 -1
- package/dist/compounding.d.ts.map +1 -1
- package/dist/compounding.js +96 -0
- 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/guard.d.ts +26 -2
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +162 -17
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +17 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -5
- 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/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.map +1 -1
- package/dist/operations.js +24 -0
- 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 +43 -7
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +153 -59
- package/dist/publish.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +24 -20
- package/dist/registry.js.map +1 -1
- package/dist/release-line.d.ts +15 -1
- package/dist/release-line.d.ts.map +1 -1
- package/dist/release-line.js +40 -13
- package/dist/release-line.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.d.ts +10 -0
- package/dist/round.d.ts.map +1 -1
- package/dist/round.js +7 -0
- package/dist/round.js.map +1 -1
- package/dist/session-retro.d.ts +9 -6
- package/dist/session-retro.d.ts.map +1 -1
- package/dist/session-retro.js +39 -14
- 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/tree-sync.d.ts +5 -0
- package/dist/tree-sync.d.ts.map +1 -0
- package/dist/tree-sync.js +13 -0
- package/dist/tree-sync.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 +8 -8
- package/sbom.json +369 -119
- 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 +7 -6
- package/src/codex-hooks-assets.ts +15 -2
- package/src/compounding.ts +132 -1
- package/src/discrimination-gate.ts +2 -5
- package/src/guard.ts +166 -20
- package/src/index.ts +25 -6
- package/src/integration-apply.ts +2 -2
- package/src/lesson-payoff.ts +3 -3
- package/src/named-lock.ts +36 -11
- package/src/operations.ts +24 -0
- 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 +168 -65
- package/src/registry.ts +23 -17
- package/src/release-line.ts +60 -16
- package/src/reqe-verdict.ts +174 -0
- package/src/reqe.ts +111 -35
- package/src/round.ts +18 -0
- package/src/session-retro.ts +54 -18
- 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/tree-sync.ts +17 -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';
|
|
@@ -776,15 +777,15 @@ function compactedText(records: readonly CmdUsageRecord[]): string {
|
|
|
776
777
|
*
|
|
777
778
|
* The named lock protects competing compactors. Emitters deliberately stay
|
|
778
779
|
* lock-free; this advisory log accepts a possible racing row rather than making
|
|
779
|
-
* every command wait on a lock.
|
|
780
|
-
*
|
|
780
|
+
* every command wait on a lock. An absent store is a no-op; other failures,
|
|
781
|
+
* including lock timeouts, propagate to the caller.
|
|
781
782
|
*/
|
|
782
783
|
export function compactCmdUsageIfNeeded(root: string): void {
|
|
783
784
|
try {
|
|
784
785
|
const resolvedRoot = resolveCmdUsageRoot(root);
|
|
785
786
|
const path = join(resolvedRoot, CMD_USAGE_LOG_RELATIVE);
|
|
786
787
|
if (!existsSync(path) || statSync(path).size <= CMD_USAGE_LOG_MAX_BYTES) return;
|
|
787
|
-
|
|
788
|
+
withProjectLockSync(resolvedRoot, 'cmd-usage', () => {
|
|
788
789
|
if (!existsSync(path) || statSync(path).size <= CMD_USAGE_LOG_MAX_BYTES) return;
|
|
789
790
|
const parsed = parseCmdUsageLines(readFileSync(path, 'utf8'), new Date());
|
|
790
791
|
// Preserve a recent raw tail for accurate window counts and fold only the older prefix.
|
|
@@ -795,7 +796,7 @@ export function compactCmdUsageIfNeeded(root: string): void {
|
|
|
795
796
|
writeFileSync(tmp, text, { encoding: 'utf8', mode: 0o600 });
|
|
796
797
|
renameSync(tmp, path);
|
|
797
798
|
});
|
|
798
|
-
} catch {
|
|
799
|
-
|
|
799
|
+
} catch (err) {
|
|
800
|
+
if (!(err instanceof StoreAbsentError)) throw err;
|
|
800
801
|
}
|
|
801
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;
|
package/src/compounding.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { EVENT_CHAIN_SCOPE, classifyChainDefects, verifyEventChainText } from './event-chain.js';
|
|
19
|
+
import type { GuardOp } from './guard.js';
|
|
19
20
|
import {
|
|
20
21
|
isOffsetIsoTimestamp,
|
|
21
22
|
type PromotionAcceptanceEvidence,
|
|
@@ -156,7 +157,8 @@ export function replayableInstances(
|
|
|
156
157
|
|
|
157
158
|
export interface GuardEvent {
|
|
158
159
|
readonly ts: string;
|
|
159
|
-
|
|
160
|
+
// Includes 'code': the code guard writes audit rows for this operation too.
|
|
161
|
+
readonly op?: GuardOp;
|
|
160
162
|
readonly verdict: string;
|
|
161
163
|
readonly rules: readonly string[]; // violated rule ids
|
|
162
164
|
readonly violations?: readonly { readonly rule: string; readonly contentAnchor?: string }[];
|
|
@@ -825,3 +827,132 @@ export function renderCompoundingReport(r: CompoundingReport): string {
|
|
|
825
827
|
out.push(` VERDICT: ${r.verdict}`);
|
|
826
828
|
return out.join('\n');
|
|
827
829
|
}
|
|
830
|
+
|
|
831
|
+
// ── Доведённая работа: дополнительная метрика, не влияющая на отбор ──
|
|
832
|
+
|
|
833
|
+
export interface LessonOutcomeRow {
|
|
834
|
+
readonly lessons?: readonly string[];
|
|
835
|
+
readonly outcome?: string;
|
|
836
|
+
readonly grade?: string | null;
|
|
837
|
+
readonly slug?: string;
|
|
838
|
+
readonly stage?: string;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
export interface LessonOutcomeCounters {
|
|
842
|
+
pairs: number;
|
|
843
|
+
shipped: number;
|
|
844
|
+
refuted: number;
|
|
845
|
+
blocked: number;
|
|
846
|
+
other: number;
|
|
847
|
+
graded: number;
|
|
848
|
+
grades: Record<string, number>;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export interface LessonOutcomeCoverage {
|
|
852
|
+
readonly lessonsWithOutcome: number;
|
|
853
|
+
readonly pairsTotal: number;
|
|
854
|
+
readonly pairsUngraded: number;
|
|
855
|
+
readonly duplicateRowsDropped: number;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
export interface JoinedLessonOutcomes {
|
|
859
|
+
readonly perLesson: ReadonlyMap<string, LessonOutcomeCounters>;
|
|
860
|
+
readonly totals: LessonOutcomeCounters;
|
|
861
|
+
readonly coverage: LessonOutcomeCoverage;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function emptyLessonOutcomeCounters(): LessonOutcomeCounters {
|
|
865
|
+
return {
|
|
866
|
+
pairs: 0, shipped: 0, refuted: 0, blocked: 0, other: 0, graded: 0,
|
|
867
|
+
grades: Object.create(null) as Record<string, number>,
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Считает пары «урок ↔ исход работы» из уже прочитанных строк леджера.
|
|
873
|
+
* unknown допускает мусор после разбора JSON; поля проверяются перед использованием.
|
|
874
|
+
* Неизвестный или отсутствующий исход попадает в other, пустой грейд — в пары без грейда.
|
|
875
|
+
* Буквы грейдов сохраняются как категории, без перевода в единый балл пользы.
|
|
876
|
+
* Полные JSON-дубликаты строк и повторные id внутри одной строки не умножают пары.
|
|
877
|
+
*/
|
|
878
|
+
export function joinLessonOutcomes(rows: readonly unknown[]): JoinedLessonOutcomes {
|
|
879
|
+
const perLesson = new Map<string, LessonOutcomeCounters>();
|
|
880
|
+
const totals = emptyLessonOutcomeCounters();
|
|
881
|
+
const seenRows = new Set<string>();
|
|
882
|
+
let duplicateRowsDropped = 0;
|
|
883
|
+
for (const value of rows) {
|
|
884
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) continue;
|
|
885
|
+
const row = value as LessonOutcomeRow;
|
|
886
|
+
if (!Array.isArray(row.lessons)) continue;
|
|
887
|
+
// Compare ALL fields; object key order is irrelevant, array order is preserved.
|
|
888
|
+
const canonicalRow = JSON.stringify(value, (_key, part: unknown) => {
|
|
889
|
+
if (part === null || typeof part !== 'object' || Array.isArray(part)) return part;
|
|
890
|
+
const object = part as Record<string, unknown>;
|
|
891
|
+
return Object.fromEntries(Object.keys(object).sort().map((key) => [key, object[key]]));
|
|
892
|
+
});
|
|
893
|
+
if (seenRows.has(canonicalRow)) {
|
|
894
|
+
duplicateRowsDropped++;
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
seenRows.add(canonicalRow);
|
|
898
|
+
const outcome = row.outcome === 'shipped' || row.outcome === 'refuted' || row.outcome === 'blocked'
|
|
899
|
+
? row.outcome : 'other';
|
|
900
|
+
const grade = typeof row.grade === 'string' ? row.grade.trim() : '';
|
|
901
|
+
for (const dzId of new Set(row.lessons)) {
|
|
902
|
+
if (typeof dzId !== 'string' || !dzId.startsWith('teach:')) continue;
|
|
903
|
+
const counts = perLesson.get(dzId) ?? emptyLessonOutcomeCounters();
|
|
904
|
+
perLesson.set(dzId, counts);
|
|
905
|
+
for (const target of [counts, totals]) {
|
|
906
|
+
target.pairs++;
|
|
907
|
+
target[outcome]++;
|
|
908
|
+
if (grade !== '') {
|
|
909
|
+
target.graded++;
|
|
910
|
+
target.grades[grade] = (target.grades[grade] ?? 0) + 1;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
return {
|
|
916
|
+
perLesson,
|
|
917
|
+
totals,
|
|
918
|
+
coverage: {
|
|
919
|
+
lessonsWithOutcome: perLesson.size,
|
|
920
|
+
pairsTotal: totals.pairs,
|
|
921
|
+
pairsUngraded: totals.pairs - totals.graded,
|
|
922
|
+
duplicateRowsDropped,
|
|
923
|
+
},
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Размер стора передаёт вызывающий код: в строках леджера этого знаменателя нет.
|
|
929
|
+
* Для доли покрытия набор joined должен относиться к урокам этого стора.
|
|
930
|
+
* Без знаменателя доля остаётся неизвестной, а не превращается в 100%.
|
|
931
|
+
*/
|
|
932
|
+
export function renderLessonOutcomes(joined: JoinedLessonOutcomes, storeLessonCount?: number): string {
|
|
933
|
+
const { totals, coverage } = joined;
|
|
934
|
+
const percent = (part: number, whole: number): string =>
|
|
935
|
+
`${(whole === 0 ? 0 : part / whole * 100).toFixed(1).replace('.', ',')}%`;
|
|
936
|
+
const validStoreCount = typeof storeLessonCount === 'number'
|
|
937
|
+
&& Number.isSafeInteger(storeLessonCount)
|
|
938
|
+
&& storeLessonCount >= coverage.lessonsWithOutcome;
|
|
939
|
+
const storeCoverage = validStoreCount
|
|
940
|
+
? `${coverage.lessonsWithOutcome}/${storeLessonCount} (${percent(coverage.lessonsWithOutcome, storeLessonCount)})`
|
|
941
|
+
: `${coverage.lessonsWithOutcome}/неизвестно (доля неизвестна: размер стора не задан или некорректен)`;
|
|
942
|
+
const grades = Object.entries(totals.grades)
|
|
943
|
+
.sort(([a], [b]) => a.localeCompare(b, 'ru'))
|
|
944
|
+
.map(([grade, count]) => `${grade}: ${count}`)
|
|
945
|
+
.join(', ');
|
|
946
|
+
return [
|
|
947
|
+
'ДОВЕДЁННАЯ РАБОТА — дополнительная метрика пользы уроков',
|
|
948
|
+
` Пар «урок ↔ исход работы»: ${totals.pairs}; доведено (shipped): ${totals.shipped}; `
|
|
949
|
+
+ `опровергнуто (refuted): ${totals.refuted}; заблокировано (blocked): ${totals.blocked}; прочие исходы: ${totals.other}.`,
|
|
950
|
+
` Грейды (${totals.graded} пар): ${grades || 'нет'}; самоотчёт — грейд ставит ведущий при закрытии круга, это не независимая оценка.`,
|
|
951
|
+
` Доля пар без грейда: ${coverage.pairsUngraded}/${coverage.pairsTotal} (${percent(coverage.pairsUngraded, coverage.pairsTotal)}).`,
|
|
952
|
+
` Покрытие уроков стора хотя бы одной парой: ${storeCoverage}.`,
|
|
953
|
+
...(coverage.duplicateRowsDropped > 0
|
|
954
|
+
? [` Отброшено дубликатов строк леджера: ${coverage.duplicateRowsDropped}.`]
|
|
955
|
+
: []),
|
|
956
|
+
' Отбор уроков по-прежнему использует оценку намерения.',
|
|
957
|
+
].join('\n');
|
|
958
|
+
}
|
|
@@ -394,9 +394,6 @@ export interface DiscriminationResult {
|
|
|
394
394
|
/** compat scalar: worst-of via RANK. A total order can only answer "worst thing present" —
|
|
395
395
|
* everything it destroys travels in findings[] / measurementValid / primaryAction. */
|
|
396
396
|
readonly aggregate: DiscriminationVerdict;
|
|
397
|
-
/** @deprecated compat alias for ONE release — always `findings[0] ?? null` (worst first).
|
|
398
|
-
* Removal in the next minor is a recorded release obligation (ADR-002 Decision item 6). */
|
|
399
|
-
readonly finding: DiscriminationFinding | null;
|
|
400
397
|
/** one per distinct non-clean verdict present, worst-first. */
|
|
401
398
|
readonly findings: readonly DiscriminationFinding[];
|
|
402
399
|
readonly measurementValid: MeasurementValid;
|
|
@@ -821,7 +818,7 @@ export function classifyDiscrimination(input: ClassifyInput): DiscriminationResu
|
|
|
821
818
|
detail:
|
|
822
819
|
'No test was mapped to the ADR safety property, so discrimination could not be evaluated — this is the existing "property untested" finding. Action: map-a-test.',
|
|
823
820
|
};
|
|
824
|
-
return { perTest: [], aggregate: 'CANNOT_ISOLATE',
|
|
821
|
+
return { perTest: [], aggregate: 'CANNOT_ISOLATE', findings: [finding], measurementValid: false, primaryAction: 'map-a-test' };
|
|
825
822
|
}
|
|
826
823
|
|
|
827
824
|
let missingRow = false;
|
|
@@ -876,5 +873,5 @@ export function classifyDiscrimination(input: ClassifyInput): DiscriminationResu
|
|
|
876
873
|
const primaryAction: PrimaryAction =
|
|
877
874
|
aggregate === 'CANNOT_ISOLATE' && missingRow ? 'map-a-test' : ACTION_OF[aggregate];
|
|
878
875
|
|
|
879
|
-
return { perTest, aggregate,
|
|
876
|
+
return { perTest, aggregate, findings, measurementValid, primaryAction };
|
|
880
877
|
}
|