@dzhechkov/harness-core 0.8.40 → 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 (135) hide show
  1. package/.dz-manifest.json +194 -114
  2. package/README.md +43 -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 +8 -6
  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 +43 -1
  25. package/dist/compounding.d.ts.map +1 -1
  26. package/dist/compounding.js +96 -0
  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/guard.d.ts +22 -2
  33. package/dist/guard.d.ts.map +1 -1
  34. package/dist/guard.js +102 -14
  35. package/dist/guard.js.map +1 -1
  36. package/dist/index.d.ts +15 -6
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +11 -4
  39. package/dist/index.js.map +1 -1
  40. package/dist/integration-apply.js +2 -2
  41. package/dist/integration-apply.js.map +1 -1
  42. package/dist/lesson-payoff.js +3 -3
  43. package/dist/lesson-payoff.js.map +1 -1
  44. package/dist/named-lock.d.ts +5 -2
  45. package/dist/named-lock.d.ts.map +1 -1
  46. package/dist/named-lock.js +30 -10
  47. package/dist/named-lock.js.map +1 -1
  48. package/dist/operations.d.ts.map +1 -1
  49. package/dist/operations.js +24 -0
  50. package/dist/operations.js.map +1 -1
  51. package/dist/pack-inventory.d.ts +51 -0
  52. package/dist/pack-inventory.d.ts.map +1 -0
  53. package/dist/pack-inventory.js +306 -0
  54. package/dist/pack-inventory.js.map +1 -0
  55. package/dist/package-skill-layouts.d.ts +3 -2
  56. package/dist/package-skill-layouts.d.ts.map +1 -1
  57. package/dist/package-skill-layouts.js +3 -2
  58. package/dist/package-skill-layouts.js.map +1 -1
  59. package/dist/patterns.d.ts +8 -0
  60. package/dist/patterns.d.ts.map +1 -1
  61. package/dist/patterns.js +11 -1
  62. package/dist/patterns.js.map +1 -1
  63. package/dist/publish.d.ts +29 -7
  64. package/dist/publish.d.ts.map +1 -1
  65. package/dist/publish.js +92 -15
  66. package/dist/publish.js.map +1 -1
  67. package/dist/registry.d.ts.map +1 -1
  68. package/dist/registry.js +24 -20
  69. package/dist/registry.js.map +1 -1
  70. package/dist/reqe-verdict.d.ts +55 -0
  71. package/dist/reqe-verdict.d.ts.map +1 -0
  72. package/dist/reqe-verdict.js +173 -0
  73. package/dist/reqe-verdict.js.map +1 -0
  74. package/dist/reqe.d.ts +35 -14
  75. package/dist/reqe.d.ts.map +1 -1
  76. package/dist/reqe.js +85 -32
  77. package/dist/reqe.js.map +1 -1
  78. package/dist/round.d.ts +10 -0
  79. package/dist/round.d.ts.map +1 -1
  80. package/dist/round.js +7 -0
  81. package/dist/round.js.map +1 -1
  82. package/dist/session-retro.d.ts +1 -1
  83. package/dist/session-retro.js +3 -3
  84. package/dist/session-retro.js.map +1 -1
  85. package/dist/skill-drift.d.ts +18 -1
  86. package/dist/skill-drift.d.ts.map +1 -1
  87. package/dist/skill-drift.js +46 -13
  88. package/dist/skill-drift.js.map +1 -1
  89. package/dist/statusline.js +2 -2
  90. package/dist/statusline.js.map +1 -1
  91. package/dist/store-guard.js +3 -3
  92. package/dist/store-guard.js.map +1 -1
  93. package/dist/store-lock.d.ts +9 -0
  94. package/dist/store-lock.d.ts.map +1 -1
  95. package/dist/store-lock.js +22 -3
  96. package/dist/store-lock.js.map +1 -1
  97. package/dist/test-receipt.d.ts +66 -0
  98. package/dist/test-receipt.d.ts.map +1 -0
  99. package/dist/test-receipt.js +73 -0
  100. package/dist/test-receipt.js.map +1 -0
  101. package/dist/workflow-run.d.ts.map +1 -1
  102. package/dist/workflow-run.js +7 -8
  103. package/dist/workflow-run.js.map +1 -1
  104. package/package.json +1 -1
  105. package/sbom.json +313 -113
  106. package/src/__tests__/golden-baseline.test.ts +1 -1
  107. package/src/agentdb-index.ts +3 -3
  108. package/src/agentdb-reindex-marker.ts +4 -4
  109. package/src/agentdb-snapshot-rotation.ts +1 -1
  110. package/src/backup-freshness.ts +96 -0
  111. package/src/cmd-usage.ts +7 -6
  112. package/src/codex-hooks-assets.ts +15 -2
  113. package/src/compounding.ts +132 -1
  114. package/src/discrimination-gate.ts +2 -5
  115. package/src/guard.ts +104 -17
  116. package/src/index.ts +23 -5
  117. package/src/integration-apply.ts +2 -2
  118. package/src/lesson-payoff.ts +3 -3
  119. package/src/named-lock.ts +36 -11
  120. package/src/operations.ts +24 -0
  121. package/src/pack-inventory.ts +313 -0
  122. package/src/package-skill-layouts.ts +3 -2
  123. package/src/patterns.ts +18 -1
  124. package/src/publish.ts +101 -22
  125. package/src/registry.ts +23 -17
  126. package/src/reqe-verdict.ts +174 -0
  127. package/src/reqe.ts +111 -35
  128. package/src/round.ts +18 -0
  129. package/src/session-retro.ts +3 -3
  130. package/src/skill-drift.ts +66 -12
  131. package/src/statusline.ts +2 -2
  132. package/src/store-guard.ts +3 -3
  133. package/src/store-lock.ts +20 -3
  134. package/src/test-receipt.ts +106 -0
  135. 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';
@@ -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. Every error is swallowed so compaction can never
780
- * 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.
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
- withNamedLockSync(resolvedRoot, 'cmd-usage', () => {
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
- /* advisory compaction is fail-open just like the writer and report */
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` 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;
@@ -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
- readonly op?: 'publish' | 'teach' | 'consolidate' | 'reindex';
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', finding, findings: [finding], measurementValid: false, primaryAction: 'map-a-test' };
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, finding: findings[0] ?? null, findings, measurementValid, primaryAction };
876
+ return { perTest, aggregate, findings, measurementValid, primaryAction };
880
877
  }