@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.
Files changed (146) hide show
  1. package/.dz-manifest.json +220 -120
  2. package/README.md +65 -10
  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 +26 -2
  33. package/dist/guard.d.ts.map +1 -1
  34. package/dist/guard.js +162 -17
  35. package/dist/guard.js.map +1 -1
  36. package/dist/index.d.ts +17 -7
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +12 -5
  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 +43 -7
  64. package/dist/publish.d.ts.map +1 -1
  65. package/dist/publish.js +153 -59
  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/release-line.d.ts +15 -1
  71. package/dist/release-line.d.ts.map +1 -1
  72. package/dist/release-line.js +40 -13
  73. package/dist/release-line.js.map +1 -1
  74. package/dist/reqe-verdict.d.ts +55 -0
  75. package/dist/reqe-verdict.d.ts.map +1 -0
  76. package/dist/reqe-verdict.js +173 -0
  77. package/dist/reqe-verdict.js.map +1 -0
  78. package/dist/reqe.d.ts +35 -14
  79. package/dist/reqe.d.ts.map +1 -1
  80. package/dist/reqe.js +85 -32
  81. package/dist/reqe.js.map +1 -1
  82. package/dist/round.d.ts +10 -0
  83. package/dist/round.d.ts.map +1 -1
  84. package/dist/round.js +7 -0
  85. package/dist/round.js.map +1 -1
  86. package/dist/session-retro.d.ts +9 -6
  87. package/dist/session-retro.d.ts.map +1 -1
  88. package/dist/session-retro.js +39 -14
  89. package/dist/session-retro.js.map +1 -1
  90. package/dist/skill-drift.d.ts +18 -1
  91. package/dist/skill-drift.d.ts.map +1 -1
  92. package/dist/skill-drift.js +46 -13
  93. package/dist/skill-drift.js.map +1 -1
  94. package/dist/statusline.js +2 -2
  95. package/dist/statusline.js.map +1 -1
  96. package/dist/store-guard.js +3 -3
  97. package/dist/store-guard.js.map +1 -1
  98. package/dist/store-lock.d.ts +9 -0
  99. package/dist/store-lock.d.ts.map +1 -1
  100. package/dist/store-lock.js +22 -3
  101. package/dist/store-lock.js.map +1 -1
  102. package/dist/test-receipt.d.ts +66 -0
  103. package/dist/test-receipt.d.ts.map +1 -0
  104. package/dist/test-receipt.js +73 -0
  105. package/dist/test-receipt.js.map +1 -0
  106. package/dist/tree-sync.d.ts +5 -0
  107. package/dist/tree-sync.d.ts.map +1 -0
  108. package/dist/tree-sync.js +13 -0
  109. package/dist/tree-sync.js.map +1 -0
  110. package/dist/workflow-run.d.ts.map +1 -1
  111. package/dist/workflow-run.js +7 -8
  112. package/dist/workflow-run.js.map +1 -1
  113. package/package.json +8 -8
  114. package/sbom.json +369 -119
  115. package/src/__tests__/golden-baseline.test.ts +1 -1
  116. package/src/agentdb-index.ts +3 -3
  117. package/src/agentdb-reindex-marker.ts +4 -4
  118. package/src/agentdb-snapshot-rotation.ts +1 -1
  119. package/src/backup-freshness.ts +96 -0
  120. package/src/cmd-usage.ts +7 -6
  121. package/src/codex-hooks-assets.ts +15 -2
  122. package/src/compounding.ts +132 -1
  123. package/src/discrimination-gate.ts +2 -5
  124. package/src/guard.ts +166 -20
  125. package/src/index.ts +25 -6
  126. package/src/integration-apply.ts +2 -2
  127. package/src/lesson-payoff.ts +3 -3
  128. package/src/named-lock.ts +36 -11
  129. package/src/operations.ts +24 -0
  130. package/src/pack-inventory.ts +313 -0
  131. package/src/package-skill-layouts.ts +3 -2
  132. package/src/patterns.ts +18 -1
  133. package/src/publish.ts +168 -65
  134. package/src/registry.ts +23 -17
  135. package/src/release-line.ts +60 -16
  136. package/src/reqe-verdict.ts +174 -0
  137. package/src/reqe.ts +111 -35
  138. package/src/round.ts +18 -0
  139. package/src/session-retro.ts +54 -18
  140. package/src/skill-drift.ts +66 -12
  141. package/src/statusline.ts +2 -2
  142. package/src/store-guard.ts +3 -3
  143. package/src/store-lock.ts +20 -3
  144. package/src/test-receipt.ts +106 -0
  145. package/src/tree-sync.ts +17 -0
  146. package/src/workflow-run.ts +7 -8
@@ -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