@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
package/src/operations.ts CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { execFileSync, spawnSync, type SpawnSyncOptionsWithStringEncoding } from 'node:child_process';
10
10
  import { randomBytes } from 'node:crypto';
11
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
11
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
12
12
  import { homedir, tmpdir } from 'node:os';
13
13
  import { dirname, join, resolve as resolvePath } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
@@ -51,6 +51,8 @@ import { WINDSURF_RULES_ROOT } from '@dzhechkov/adapter-windsurf';
51
51
  import { AGENTS_MD_BLOCK_BEGIN, mergeAgentsMd, mergeGeminiMd, mergePolicyBlock, renderAgentsMdSection } from '@dzhechkov/core';
52
52
  import type { CanonicalSkill, EmitResult, SkillAsset } from '@dzhechkov/core';
53
53
  import { computeRiskScore } from './risk-scoring.js';
54
+ import { checkInstrumentFreshness, checkRankingState } from './doctor-instrument.js';
55
+ import { decideBackupFreshness, renderBackupFreshness } from './backup-freshness.js';
54
56
 
55
57
  import { applyEmitResult } from './apply.js';
56
58
  import { describeSkillLoadFailure, discoverSkillIds, loadSkillFromDir } from './skills.js';
@@ -1039,6 +1041,12 @@ export function runMigrate(options: { projectRoot: string }): MigrateReport {
1039
1041
  export interface DoctorCheck {
1040
1042
  readonly name: string;
1041
1043
  readonly ok: boolean;
1044
+ /**
1045
+ * Rendering severity for a check that is NOT a failure. `warn` = measured and worth saying out
1046
+ * loud; `unknown` = the evidence could not be gathered, which is never rendered as a pass. Neither
1047
+ * value participates in `DoctorReport.ok`, so neither can change any caller's exit code.
1048
+ */
1049
+ readonly level?: 'warn' | 'unknown';
1042
1050
  readonly detail: string;
1043
1051
  }
1044
1052
 
@@ -1050,7 +1058,13 @@ export interface DoctorReport {
1050
1058
  }
1051
1059
 
1052
1060
  /** Report environment diagnostics for the harness. */
1053
- export async function runDoctor(options: { projectRoot: string }): Promise<DoctorReport> {
1061
+ /**
1062
+ * `instrumentPath` is the executable that is answering — normally the CLI's own `process.argv[1]`.
1063
+ * It is an INPUT, not something this module reads for itself: `core-boundary` rule A forbids core
1064
+ * from touching process globals, because core is a library and the process belongs to whoever hosts
1065
+ * it. Omitted ⇒ the instrument-freshness check honestly reports that it could not tell.
1066
+ */
1067
+ export async function runDoctor(options: { projectRoot: string; instrumentPath?: string | null }): Promise<DoctorReport> {
1054
1068
  const checks: DoctorCheck[] = [];
1055
1069
  const root = options.projectRoot;
1056
1070
 
@@ -1058,6 +1072,29 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1058
1072
  const nodeMajor = Number(process.versions.node.split('.')[0] ?? '0');
1059
1073
  checks.push({ name: 'node >= 20', ok: nodeMajor >= 20, detail: `node ${process.version}` });
1060
1074
 
1075
+ // Свежесть копии — отдельная ось: ok:false изменил бы код выхода doctor и заблокировал
1076
+ // автоматику публикации. Поэтому отказ копии — WARN, отсутствие свидетельства — UNKNOWN.
1077
+ const backupLogPath = process.env.DZ_BACKLOG_BACKUP_LOG ?? '/var/log/backlog-backup.log';
1078
+ try {
1079
+ const verdict = decideBackupFreshness({
1080
+ lines: readFileSync(backupLogPath, 'utf8').split(/\r?\n/),
1081
+ now: new Date(),
1082
+ maxAgeHours: 36,
1083
+ });
1084
+ checks.push({
1085
+ name: 'backlog backup',
1086
+ ok: true,
1087
+ ...(verdict.state === 'fresh' ? {} : { level: verdict.state === 'stale' ? 'warn' as const : 'unknown' as const }),
1088
+ detail: renderBackupFreshness(verdict),
1089
+ });
1090
+ } catch (error) {
1091
+ const code = (error as NodeJS.ErrnoException).code ?? 'UNKNOWN';
1092
+ const detail = code === 'ENOENT'
1093
+ ? `журнал копии не найден: ${backupLogPath}`
1094
+ : `журнал копии нечитаем: ${backupLogPath} (${code})`;
1095
+ checks.push({ name: 'backlog backup', ok: true, level: 'unknown', detail });
1096
+ }
1097
+
1061
1098
  // 2/3. MONOREPO-ONLY checks, gated on PROJECT KIND (backlog fcf29728: in a consumer project
1062
1099
  // skills-meta and the 10 adapters are not there and MUST not be — both checks were red forever
1063
1100
  // and dz doctor could never exit 0 outside this repository, a standing false BLOCK for any
@@ -1065,6 +1102,74 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1065
1102
  // monorepo (or a fork of it) and owes itself these checks; anything else is a consumer project
1066
1103
  // and gets a NAMED skip — a skip, never a silent pass and never a fail.
1067
1104
  const isMonorepo = existsSync(join(root, 'packages', '@dzhechkov'));
1105
+
1106
+ // The executable is part of the measurement. Resolve the CALLER-SUPPLIED path (including its
1107
+ // symlinks), then attribute it to the nearest package.json. Every read is independent and
1108
+ // fail-quiet so a broken install becomes an explicit UNKNOWN diagnostic instead of throwing.
1109
+ let binPath: string | null = null;
1110
+ try {
1111
+ const given = options.instrumentPath;
1112
+ binPath = given === undefined || given === null ? null : realpathSync(given);
1113
+ } catch { /* unresolved is reported by the pure decider */ }
1114
+ let binVersion: string | null = null;
1115
+ if (binPath !== null) {
1116
+ try {
1117
+ let cursor = dirname(binPath);
1118
+ while (true) {
1119
+ const packagePath = join(cursor, 'package.json');
1120
+ if (existsSync(packagePath)) {
1121
+ const parsed = JSON.parse(readFileSync(packagePath, 'utf8')) as { version?: unknown };
1122
+ binVersion = typeof parsed.version === 'string' ? parsed.version : null;
1123
+ break;
1124
+ }
1125
+ const parent = dirname(cursor);
1126
+ if (parent === cursor) break;
1127
+ cursor = parent;
1128
+ }
1129
+ } catch { binVersion = null; }
1130
+ }
1131
+ let treeVersion: string | null = null;
1132
+ if (isMonorepo) {
1133
+ try {
1134
+ const parsed = JSON.parse(readFileSync(join(root, 'packages', '@dzhechkov', 'harness-cli', 'package.json'), 'utf8')) as { version?: unknown };
1135
+ treeVersion = typeof parsed.version === 'string' ? parsed.version : null;
1136
+ } catch { treeVersion = null; }
1137
+ }
1138
+ let realRoot = resolvePath(root);
1139
+ let realRootResolved = true;
1140
+ // A root that cannot be resolved through its symlinks makes the containment question undecidable
1141
+ // rather than false — the decider is told so explicitly instead of silently comparing two path
1142
+ // forms that need not agree.
1143
+ try { realRoot = realpathSync(root); } catch { realRootResolved = false; }
1144
+ const instrument = checkInstrumentFreshness({ binPath, binVersion, treeVersion, projectRoot: realRoot, projectRootRealpathed: realRootResolved, isMonorepo });
1145
+ checks.push({
1146
+ name: 'doctor instrument freshness',
1147
+ // The pure decider owns the verdict; this wiring only carries it. Never re-derive `ok` here.
1148
+ ok: true,
1149
+ ...(instrument.level === 'ok' ? {} : { level: instrument.level }),
1150
+ detail: instrument.detail,
1151
+ });
1152
+
1153
+ const rankingStatePath = join(realRoot, '.dz', 'lesson-bandit', 'state.json');
1154
+ let rankingFlagOn = false;
1155
+ try {
1156
+ const parsed = JSON.parse(readFileSync(join(root, '.dz', 'config.json'), 'utf8')) as {
1157
+ memory?: { learning?: { banditRerank?: unknown } };
1158
+ };
1159
+ rankingFlagOn = parsed.memory?.learning?.banditRerank === true;
1160
+ } catch { /* malformed config is owned by the config diagnostic; ranking defaults off */ }
1161
+ const ranking = checkRankingState({
1162
+ flagOn: rankingFlagOn,
1163
+ statePath: rankingStatePath,
1164
+ stateExists: existsSync(rankingStatePath),
1165
+ binPath,
1166
+ });
1167
+ checks.push({
1168
+ name: 'bandit ranking state',
1169
+ ok: true,
1170
+ ...(ranking.level === 'warn' ? { level: 'warn' as const } : {}),
1171
+ detail: ranking.detail,
1172
+ });
1068
1173
  checks.push({
1069
1174
  name: '.claude/skills present',
1070
1175
  ok: existsSync(join(root, '.claude', 'skills')),
@@ -1648,7 +1753,8 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1648
1753
  const text = readFileSync(p, 'utf-8');
1649
1754
  const v = verifyEventChainText(text);
1650
1755
  if (v.chained === 0 || v.ok) continue;
1651
- const total = text.split('\n').filter((l) => l.trim() !== '').length;
1756
+ // The verifier already counted non-empty lines; re-deriving it drifts (see cli.ts note).
1757
+ const total = v.lines;
1652
1758
  const age = classifyChainDefects(v, total);
1653
1759
  const named = `${v.defects.length} defect(s): ${v.defects.slice(0, 3).map((d) => `${d.kind}@L${d.line}`).join(', ')}`;
1654
1760
  // A break that an unbroken run has already outlived is not a reason to distrust today's
@@ -0,0 +1,313 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
+ import { dirname, join, relative, resolve, sep } from 'node:path';
5
+ import { scanSecretsChunked } from './guard.js';
6
+ import { NamedLockTimeoutError, withDirLockSync } from './named-lock.js';
7
+ import { listPackFiles, listSignablePackFiles } from './sign.js';
8
+
9
+ export type PackerId = 'npm' | 'pnpm';
10
+ export type InventorySource = 'tarball' | 'npm-dry-run' | 'cache' | 'fallback-walk';
11
+ export type PackExec = (cmd: string, opts: { readonly cwd: string; readonly timeoutMs: number }) => {
12
+ readonly stdout: string;
13
+ readonly stderr: string;
14
+ };
15
+ export type ChunkReader = (absolutePath: string, chunkBytes: number) => Iterable<Buffer>;
16
+
17
+ export interface ListPublishInventoryOptions {
18
+ readonly packer?: PackerId;
19
+ readonly tgzPath?: string;
20
+ readonly exec?: PackExec;
21
+ readonly cacheDir?: string;
22
+ readonly timeoutMs?: number;
23
+ }
24
+
25
+ export interface PublishInventory {
26
+ readonly files: readonly string[];
27
+ readonly source: InventorySource;
28
+ readonly reason?: string;
29
+ readonly packer: PackerId;
30
+ readonly packerVersion: string | null;
31
+ }
32
+
33
+ export interface PublishSecretSeams {
34
+ readonly listInventory?: typeof listPublishInventory;
35
+ readonly reader?: ChunkReader;
36
+ readonly tgzPathFor?: (dir: string) => string | undefined;
37
+ }
38
+
39
+ /** Scan the publish inventory as streams, retaining every coverage gap by name. */
40
+ export function gatherPublishSecretFacts(root: string, packageDirs: readonly string[], seams: PublishSecretSeams = {}):
41
+ { readonly secretFindings: readonly { label: string; name: string }[];
42
+ readonly secretScan: { skipped: number; skippedPaths: string[]; scanned: number; inventory: string } } {
43
+ const listInventory = seams.listInventory ?? listPublishInventory;
44
+ const reader = seams.reader ?? readFileChunks;
45
+ const secretFindings: { label: string; name: string }[] = [];
46
+ const skippedPaths: string[] = [];
47
+ let scanned = 0;
48
+ const counts: Record<InventorySource, number> = { tarball: 0, cache: 0, 'npm-dry-run': 0, 'fallback-walk': 0 };
49
+ const fallbacks: string[] = [];
50
+ const skip = (label: string, reason: string): void => { skippedPaths.push(`${label} (${reason})`); };
51
+
52
+ for (const dir of packageDirs) {
53
+ const packageRoot = join(root, dir);
54
+ let inventory: PublishInventory;
55
+ try {
56
+ // Lead edit (tsc exactOptionalPropertyTypes): an absent tgzPath must be ABSENT, not `undefined`.
57
+ const tgz = seams.tgzPathFor?.(dir);
58
+ inventory = listInventory(packageRoot, {
59
+ cacheDir: join(root, '.dz', 'cache'),
60
+ ...(tgz !== undefined ? { tgzPath: tgz } : {}),
61
+ });
62
+ } catch { skip(dir, 'inventory unreadable'); continue; }
63
+ counts[inventory.source]++;
64
+ if (inventory.source === 'fallback-walk') fallbacks.push(`${dir} (${inventory.reason ?? 'unknown reason'})`);
65
+
66
+ for (const rel of inventory.files) {
67
+ const absolute = join(packageRoot, rel);
68
+ const label = relative(root, absolute).split(sep).join('/');
69
+ try {
70
+ if (!lstatSync(absolute).isFile()) { skip(label, 'not a regular file'); continue; }
71
+ let binary = false;
72
+ let unreadable = false;
73
+ function* textChunks(): Iterable<Buffer> {
74
+ let first = true;
75
+ try {
76
+ for (const chunk of reader(absolute, 1024 * 1024)) {
77
+ if (first && chunk.subarray(0, 8 * 1024).includes(0)) { binary = true; return; }
78
+ first = false;
79
+ yield chunk;
80
+ }
81
+ } catch {
82
+ // End the iterable normally so the scanner can return hits already found.
83
+ unreadable = true;
84
+ skip(label, first ? 'unreadable' : 'unreadable (partial)');
85
+ }
86
+ }
87
+ const findings = scanSecretsChunked(textChunks());
88
+ if (binary) { skip(label, 'binary'); continue; }
89
+ for (const { name } of findings) secretFindings.push({ label, name });
90
+ if (!unreadable) scanned++;
91
+ } catch { skip(label, 'unreadable'); }
92
+ }
93
+ }
94
+
95
+ const sources: InventorySource[] = ['tarball', 'cache', 'npm-dry-run', 'fallback-walk'];
96
+ const summary = sources.filter((source) => counts[source] > 0).map((source) => `${source}×${counts[source]}`).join(', ');
97
+ return { secretFindings, secretScan: {
98
+ skipped: skippedPaths.length, skippedPaths, scanned,
99
+ inventory: summary + (fallbacks.length > 0 ? `: ${fallbacks.join(', ')}` : ''),
100
+ } };
101
+ }
102
+
103
+ /** Reject malformed or empty listings so they cannot suppress scanning. */
104
+ export function parseNpmPackListing(stdout: string): readonly string[] | null {
105
+ let listing: unknown;
106
+ try {
107
+ listing = JSON.parse(stdout);
108
+ } catch {
109
+ return null;
110
+ }
111
+ if (!Array.isArray(listing) || listing.length === 0) return null;
112
+ const paths = new Set<string>();
113
+ for (const entry of listing) {
114
+ if (entry === null || typeof entry !== 'object' || !Array.isArray(entry.files) || entry.files.length === 0) {
115
+ return null;
116
+ }
117
+ for (const file of entry.files) {
118
+ if (file === null || typeof file !== 'object' || typeof file.path !== 'string' || file.path.length === 0) {
119
+ return null;
120
+ }
121
+ paths.add(file.path);
122
+ }
123
+ }
124
+ return [...paths].sort();
125
+ }
126
+
127
+ /** Versioned key over packer identity, package metadata bytes, and the signable file walk. */
128
+ export function computeInventoryCacheKey(packageRoot: string, packer: PackerId, packerVersion: string): string {
129
+ const hash = createHash('sha256');
130
+ hash.update(`pack-inventory/1\n${packer}@${packerVersion}\n`);
131
+ hash.update(readFileSync(join(packageRoot, 'package.json')));
132
+ hash.update('\n');
133
+ try {
134
+ hash.update(readFileSync(join(packageRoot, '.npmignore')));
135
+ } catch (error) {
136
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
137
+ hash.update('<absent>');
138
+ }
139
+ hash.update('\n');
140
+ for (const relpath of listSignablePackFiles(packageRoot).sort()) {
141
+ const { size, mtimeMs } = statSync(join(packageRoot, relpath));
142
+ hash.update(`${relpath}\0${size}\0${mtimeMs}\n`);
143
+ }
144
+ return hash.digest('hex');
145
+ }
146
+
147
+ /** Reuse the read buffer, yielding copies that remain valid after subsequent reads. */
148
+ export function* readFileChunks(absolutePath: string, chunkBytes = 1024 * 1024): Iterable<Buffer> {
149
+ if (!Number.isSafeInteger(chunkBytes) || chunkBytes <= 0) {
150
+ throw new RangeError('chunkBytes must be a positive safe integer');
151
+ }
152
+ const buffer = Buffer.allocUnsafe(chunkBytes);
153
+ const fd = openSync(absolutePath, 'r');
154
+ try {
155
+ let bytesRead: number;
156
+ while ((bytesRead = readSync(fd, buffer, 0, buffer.length, null)) > 0) {
157
+ yield Buffer.from(buffer.subarray(0, bytesRead));
158
+ }
159
+ } finally {
160
+ closeSync(fd);
161
+ }
162
+ }
163
+
164
+ const defaultExec: PackExec = (cmd, { cwd, timeoutMs }) => ({
165
+ stdout: execSync(cmd, { cwd, timeout: timeoutMs, encoding: 'utf8', stdio: 'pipe' }),
166
+ stderr: '',
167
+ });
168
+
169
+ // Scope memoization to the executor as well as the packer so injected runtimes stay independent.
170
+ const versions = new WeakMap<PackExec, Map<PackerId, string | null>>();
171
+
172
+ function packerVersion(exec: PackExec, packer: PackerId, cwd: string, timeoutMs: number): string | null {
173
+ let memo = versions.get(exec);
174
+ if (!memo) { memo = new Map(); versions.set(exec, memo); }
175
+ if (memo.has(packer)) return memo.get(packer)!;
176
+ let version: string | null = null;
177
+ try {
178
+ const started = Date.now();
179
+ const stdout = exec(`${packer} --version`, { cwd, timeoutMs }).stdout.trim();
180
+ if (Date.now() - started <= timeoutMs && stdout.length > 0) version = stdout;
181
+ } catch { /* Unknown versions must never serve or populate the cache. */ }
182
+ memo.set(packer, version);
183
+ return version;
184
+ }
185
+
186
+ function repositoryRoot(packageRoot: string): string {
187
+ for (let dir = packageRoot; ; dir = dirname(dir)) {
188
+ if (existsSync(join(dir, 'pnpm-workspace.yaml')) || existsSync(join(dir, '.git', 'HEAD'))) return dir;
189
+ if (dirname(dir) === dir) return packageRoot; // standalone package
190
+ }
191
+ }
192
+
193
+ interface CacheEntry {
194
+ readonly key: string;
195
+ readonly files: readonly string[];
196
+ readonly packer: PackerId;
197
+ readonly packerVersion: string;
198
+ readonly writtenAt: string;
199
+ }
200
+ interface InventoryCache { schema: 1; entries: Record<string, CacheEntry> }
201
+
202
+ function readCache(path: string): InventoryCache {
203
+ try {
204
+ const cache = JSON.parse(readFileSync(path, 'utf8')) as InventoryCache;
205
+ if (cache?.schema === 1 && cache.entries && typeof cache.entries === 'object' && !Array.isArray(cache.entries)) {
206
+ return cache;
207
+ }
208
+ } catch { /* Missing or unparsable cache is a miss. */ }
209
+ return { schema: 1, entries: {} };
210
+ }
211
+
212
+ /** Listings must stay relative to the package and cannot represent an empty successful scan. */
213
+ function inventoryPaths(paths: readonly string[]): string[] | null {
214
+ const out = new Set<string>();
215
+ for (const path of paths) {
216
+ if (typeof path !== 'string') return null;
217
+ const normalized = path.replace(/\\/g, '/').replace(/^\.\//, '');
218
+ if (!normalized || normalized.startsWith('/') || /^[A-Za-z]:/.test(normalized)
219
+ || normalized.split('/').some((part) => part === '..' || part === '' || part === '.')) return null;
220
+ out.add(normalized);
221
+ }
222
+ return out.size > 0 ? [...out].sort() : null;
223
+ }
224
+
225
+ function firstErrorLine(error: unknown): string {
226
+ const stderr = (error as { stderr?: unknown } | null)?.stderr;
227
+ const text = typeof stderr === 'string' || Buffer.isBuffer(stderr) ? stderr.toString().trim() : '';
228
+ return (text || (error instanceof Error ? error.message : String(error))).split(/\r?\n/)[0]!;
229
+ }
230
+
231
+ /** Prefer the supplied artifact, then a tree/version cache, then stdout from the packer. */
232
+ export function listPublishInventory(packageRoot: string, opts: ListPublishInventoryOptions = {}): PublishInventory {
233
+ packageRoot = resolve(packageRoot);
234
+ const packer = opts.packer ?? 'npm';
235
+ const exec = opts.exec ?? defaultExec;
236
+ const timeoutMs = opts.timeoutMs ?? 15_000;
237
+ let version: string | null = null;
238
+ const fallback = (reason: string): PublishInventory => {
239
+ const files = listPackFiles(packageRoot);
240
+ if (files.length === 0) throw new Error('inventory unreadable');
241
+ return { files, source: 'fallback-walk', reason, packer, packerVersion: version };
242
+ };
243
+
244
+ if (opts.tgzPath !== undefined) {
245
+ try {
246
+ const path = resolve(packageRoot, opts.tgzPath).replace(/'/g, "'\\''");
247
+ const stdout = exec(`tar -tzf '${path}'`, { cwd: packageRoot, timeoutMs }).stdout;
248
+ const entries = stdout.split(/\r?\n/).filter((entry) => entry.length > 0 && !entry.endsWith('/'));
249
+ if (entries.some((entry) => !entry.startsWith('package/'))) throw new Error('unparsable listing');
250
+ const files = inventoryPaths(entries.map((entry) => entry.slice('package/'.length)));
251
+ if (!files) throw new Error('unparsable listing');
252
+ return { files, source: 'tarball', packer, packerVersion: null };
253
+ } catch (error) {
254
+ return fallback(`tarball unreadable: ${firstErrorLine(error)}`);
255
+ }
256
+ }
257
+
258
+ version = packerVersion(exec, packer, packageRoot, timeoutMs);
259
+ const root = opts.cacheDir !== undefined ? dirname(dirname(resolve(opts.cacheDir))) : repositoryRoot(packageRoot);
260
+ const cacheDir = opts.cacheDir ?? join(root, '.dz', 'cache');
261
+ const cachePath = join(cacheDir, 'pack-inventory.json');
262
+ const packageDir = relative(root, packageRoot).split(sep).join('/') || '.';
263
+ let key: string | undefined;
264
+ if (version !== null) {
265
+ try { key = computeInventoryCacheKey(packageRoot, packer, version); }
266
+ catch { /* A tree that cannot be keyed must still be listed/scanned. */ }
267
+ }
268
+ if (key !== undefined) {
269
+ try {
270
+ const cached = withDirLockSync(dirname(cacheDir), 'pack-inventory', () => readCache(cachePath).entries[packageDir]);
271
+ const files = cached && Array.isArray(cached.files) ? inventoryPaths(cached.files) : null;
272
+ if (cached?.key === key && cached.packer === packer && cached.packerVersion === version && files) {
273
+ return { files, source: 'cache', packer, packerVersion: version };
274
+ }
275
+ } catch (error) {
276
+ if (!(error instanceof NamedLockTimeoutError)) throw error;
277
+ key = undefined; // A lock timeout is a miss, with no later write in this call.
278
+ }
279
+ }
280
+
281
+ let stdout: string;
282
+ const started = Date.now();
283
+ try {
284
+ stdout = exec(`${packer} pack --dry-run --json`, { cwd: packageRoot, timeoutMs }).stdout;
285
+ } catch (error) {
286
+ const timedOut = (error as NodeJS.ErrnoException | null)?.code === 'ETIMEDOUT' || Date.now() - started > timeoutMs;
287
+ return fallback(timedOut ? `packer timeout ${timeoutMs} ms` : `packer failed: ${firstErrorLine(error)}`);
288
+ }
289
+ if (Date.now() - started > timeoutMs) return fallback(`packer timeout ${timeoutMs} ms`);
290
+ const parsed = parseNpmPackListing(stdout);
291
+ const files = parsed && inventoryPaths(parsed);
292
+ if (!files) return fallback('unparsable listing');
293
+
294
+ if (key !== undefined && version !== null) {
295
+ const entry: CacheEntry = { key, files, packer, packerVersion: version, writtenAt: new Date().toISOString() };
296
+ try {
297
+ withDirLockSync(dirname(cacheDir), 'pack-inventory', () => {
298
+ // Re-read INSIDE the write transaction, preserving entries another package just added.
299
+ const cache = readCache(cachePath);
300
+ cache.entries[packageDir] = entry;
301
+ mkdirSync(cacheDir, { recursive: true });
302
+ const temp = `${cachePath}.${randomUUID()}.tmp`;
303
+ try {
304
+ writeFileSync(temp, JSON.stringify(cache));
305
+ renameSync(temp, cachePath);
306
+ } finally { rmSync(temp, { force: true }); }
307
+ });
308
+ } catch (error) {
309
+ if (!(error instanceof NamedLockTimeoutError)) throw error;
310
+ }
311
+ }
312
+ return { files, source: 'npm-dry-run', packer, packerVersion: version };
313
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Package skill-layout resolution — where an installed npm package keeps its skills.
3
3
  *
4
- * `dz install <pkg>` (and, later, `dz init`/`dz registry` — see the backlog note below)
4
+ * `dz install <pkg>` and `dz registry`
5
5
  * must find `SKILL.md` skill dirs inside an npm package. Packages in the wild ship three
6
6
  * on-disk arrangements; before this module existed, `cmdInstall` hard-coded exactly one
7
7
  * (`flat`), so every npx-init pack (`templates/.claude/skills/…`) and bare-`skills/`
@@ -51,7 +51,8 @@ export interface PackageSkillRoot {
51
51
  }
52
52
 
53
53
  /**
54
- * Ordered layout probe list. ORDER IS THE CONTRACT — first non-empty wins.
54
+ * Shared ordered layout probe list. The resolver selects the first non-empty root;
55
+ * the registry collects all roots, with the first occurrence of each skill id winning.
55
56
  *
56
57
  * `flat` is probed first so a classic pack can never be re-interpreted; `npx-template`
57
58
  * before `skills-dir` matches the measured census (13 vs 2 packages). Extending support
package/src/patterns.ts CHANGED
@@ -49,6 +49,14 @@ export interface PatternRecord {
49
49
  readonly type: 'rule' | 'success-pattern' | 'lesson-learned';
50
50
  /** Reward signal in [0, 1]. */
51
51
  readonly reward: number;
52
+ /**
53
+ * Whether the reward was explicit or defaulted: measured rewards had only 7 distinct
54
+ * values, a median of 0.8, and covered 5.5% of impressions (338 / 6140).
55
+ * This field does NOT change the default reward value; absent means unknown for legacy records.
56
+ * Set at record creation and unchanged by subsequent reinforce/guard-reinforce calls,
57
+ * even with an explicit --reward, it describes the first invocation, not the latest.
58
+ */
59
+ readonly rewardSource?: 'explicit' | 'default';
52
60
  /** Domain tag (e.g. "performance", "api", "general"). */
53
61
  readonly domain: string;
54
62
  /** ISO-8601 timestamp the record was written. */
@@ -510,12 +518,16 @@ export function patternToRecord(p: PatternRecord): MemoryRecord {
510
518
  // the learn-loop write path.
511
519
  outcome: PATTERN_TYPES.has(p.type) ? p.type : 'lesson-learned',
512
520
  timestamp: p.ts,
513
- metadata: { domain: p.domain ?? 'general', source: p.source ?? 'dz-teach', ...pairMetadata },
521
+ metadata: {
522
+ domain: p.domain ?? 'general', source: p.source ?? 'dz-teach', ...pairMetadata,
523
+ ...(p.rewardSource !== undefined ? { rewardSource: p.rewardSource } : {}),
524
+ },
514
525
  };
515
526
  }
516
527
 
517
528
  /** Anti-corruption mapping: canonical `MemoryRecord` → harness `PatternRecord`. */
518
529
  export function recordToPattern(r: MemoryRecord): PatternRecord {
530
+ const rewardSource = r.metadata?.['rewardSource'];
519
531
  const lessonForm = r.metadata?.['lessonForm'];
520
532
  const lessonPairId = r.metadata?.['lessonPairId'];
521
533
  const pair: { lessonForm?: LessonForm; lessonPairId?: string } = (lessonForm === 'specific' || lessonForm === 'class')
@@ -535,6 +547,7 @@ export function recordToPattern(r: MemoryRecord): PatternRecord {
535
547
  pattern: r.text,
536
548
  type: PATTERN_TYPES.has(r.outcome) ? (r.outcome as PatternRecord['type']) : 'lesson-learned',
537
549
  reward: r.score,
550
+ ...(rewardSource === 'explicit' || rewardSource === 'default' ? { rewardSource } : {}),
538
551
  domain: r.metadata?.['domain'] ?? 'general',
539
552
  ts: r.timestamp,
540
553
  source: r.metadata?.['source'] ?? 'dz-teach',
@@ -1130,6 +1143,10 @@ function migrationRecords(projectRoot: string): MemoryRecord[] {
1130
1143
  * JSON file is never deleted. Returns the total record count after the write.
1131
1144
  */
1132
1145
  export async function recordPattern(projectRoot: string, p: PatternRecord, opts: { quarantine?: boolean } = {}): Promise<number> {
1146
+ // The WRITER creates the store, not the lock (lock-never-seeds-store, ADR-001 item 6). Guarded by
1147
+ // existsSync so a fixture that plants a FILE at .dz/memory still reaches the real open error
1148
+ // below instead of EEXIST here (patterns.test.ts, forced-sqlite cause).
1149
+ if (!existsSync(join(projectRoot, '.dz', 'memory'))) mkdirSync(join(projectRoot, '.dz', 'memory'), { recursive: true });
1133
1150
  if (p.lessonForm === 'class') ensureClassFormMarker(projectRoot);
1134
1151
  const { sqliteBackend } = readLearningConfig(projectRoot);
1135
1152
  // lesson-quarantine: a fresh lesson is a HYPOTHESIS — mark it when the feature is on. Folded