@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
@@ -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
package/src/publish.ts CHANGED
@@ -38,12 +38,29 @@ export type ProbeOutcome = {
38
38
  export const REGISTRY_PROBE_BUDGET = 90;
39
39
  export const REGISTRY_PROBE_INTERVAL_MS = 10_000;
40
40
 
41
+ export type RegistryProbe =
42
+ | { kind: 'published'; version: string; scripted?: true }
43
+ | { kind: 'never-published'; scripted?: true }
44
+ | { kind: 'unknown'; reason: string; scripted?: true };
45
+
46
+ /**
47
+ * Scripted registry seam for tests that spawn the REAL bin and therefore cannot inject `exec`
48
+ * (first-publish-not-offline AM-A; same shape as `WF_RUN_DISPATCH_SCRIPT_ENV`). Value: path to a JSON
49
+ * file `{ "<name>": "<x.y.z>" | "E404" | "<npm code>" }`. When set, no `npm view` runs at all and EVERY
50
+ * row carries `probeOverride: true` — a scripted plan can never read as a verified one.
51
+ */
52
+ export const PUBLISH_PROBE_SCRIPT_ENV = 'DZ_PUBLISH_PROBE_SCRIPT';
53
+
41
54
  /** Result for a single package publish attempt. */
42
55
  export interface PublishResult {
43
56
  readonly name: string;
44
57
  readonly oldVersion: string;
45
58
  readonly newVersion: string;
46
59
  readonly status: 'published' | 'skipped' | 'error';
60
+ readonly firstPublish?: boolean;
61
+ readonly probe?: RegistryProbe['kind'];
62
+ /** Present (true) only when `DZ_PUBLISH_PROBE_SCRIPT` answered instead of the registry. */
63
+ readonly probeOverride?: boolean;
47
64
  readonly error?: string | undefined;
48
65
  /** Live publish only: how many registry probes were needed to confirm the exact new version. */
49
66
  readonly registryProbes?: number | undefined;
@@ -176,26 +193,54 @@ export function compareVersions(a: string, b: string): number {
176
193
  return a0 - b0 || a1 - b1 || a2 - b2;
177
194
  }
178
195
 
179
- /**
180
- * The version already published to npm for `name`, or `undefined` if the package
181
- * has never been published (or npm is unreachable). Used to bump from
182
- * max(local, published) so a locally-reverted version can't collide (audit #10).
183
- */
184
196
  type PublishExec = (command: string, options: ExecSyncOptionsWithStringEncoding) => string;
185
197
 
186
- function publishedVersion(name: string, exec: PublishExec = execSync): string | undefined {
198
+ /** Only explicit npm absence signals establish that a package has never been published. */
199
+ export function classifyRegistryProbe(stderr: string, message: string): RegistryProbe {
200
+ const detail = `${stderr}\n${message}`;
201
+ if (/\bcode E404\b/.test(detail) || /\b404 Not Found\b/.test(detail) || /is not in this registry/.test(detail)) {
202
+ return { kind: 'never-published' };
203
+ }
204
+ const reason = /npm error code (\S+)/.exec(detail)?.[1]
205
+ ?? stderr.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0)
206
+ ?? message;
207
+ return { kind: 'unknown', reason };
208
+ }
209
+
210
+ function scriptedProbe(name: string): RegistryProbe | undefined {
211
+ const scriptPath = process.env[PUBLISH_PROBE_SCRIPT_ENV];
212
+ if (typeof scriptPath !== 'string' || scriptPath === '') return undefined;
213
+ let table: Record<string, unknown>;
187
214
  try {
188
- const out = exec(`npm view ${name} version --prefer-online`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8', timeout: 20000 }).trim();
189
- return /^\d+\.\d+\.\d+/.test(out) ? out : undefined;
190
- } catch {
191
- return undefined; // 404 (never published) or offline → fall back to local
215
+ table = JSON.parse(readFileSync(scriptPath, 'utf-8')) as Record<string, unknown>;
216
+ } catch (err) {
217
+ return { kind: 'unknown', reason: `scripted probe unreadable: ${err instanceof Error ? err.message : String(err)}`, scripted: true };
218
+ }
219
+ const answer = table[name];
220
+ if (typeof answer !== 'string') return { kind: 'unknown', reason: `scripted probe has no entry for ${name}`, scripted: true };
221
+ if (/^\d+\.\d+\.\d+/.test(answer)) return { kind: 'published', version: answer, scripted: true };
222
+ if (answer === 'E404') return { kind: 'never-published', scripted: true };
223
+ return { kind: 'unknown', reason: answer, scripted: true };
224
+ }
225
+
226
+ function probeRegistry(name: string, exec: PublishExec = execSync): RegistryProbe {
227
+ const scripted = scriptedProbe(name);
228
+ if (scripted !== undefined) return scripted;
229
+ try {
230
+ const out = exec(`npm view ${name} version --prefer-online`, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8', timeout: 20000 }).trim();
231
+ return /^\d+\.\d+\.\d+/.test(out)
232
+ ? { kind: 'published', version: out }
233
+ : { kind: 'unknown', reason: 'unparsable npm view output' };
234
+ } catch (err) {
235
+ const error = err as { stderr?: unknown; message?: unknown } | null | undefined;
236
+ return classifyRegistryProbe(String(error?.stderr ?? ''), String(error?.message ?? ''));
192
237
  }
193
238
  }
194
239
 
195
240
  /** The higher of the local version and the npm-published version (audit #10). */
196
241
  function maxPublished(name: string, localVersion: string, exec: PublishExec = execSync): string {
197
- const pub = publishedVersion(name, exec);
198
- return pub !== undefined && compareVersions(pub, localVersion) > 0 ? pub : localVersion;
242
+ const probe = probeRegistry(name, exec);
243
+ return probe.kind === 'published' && compareVersions(probe.version, localVersion) > 0 ? probe.version : localVersion;
199
244
  }
200
245
 
201
246
  // ── workspace-floor preflight (feature workspace-dep-protocol, Codex P1) ─────
@@ -829,6 +874,8 @@ export function publishPackages(
829
874
  opts: {
830
875
  dryRun?: boolean | undefined;
831
876
  filter?: string[] | undefined;
877
+ /** Exact CLI-selected batch; an empty list means no targets. Discovery remains unfiltered. */
878
+ targetNames?: readonly string[] | undefined;
832
879
  bumpOnly?: boolean | undefined;
833
880
  /**
834
881
  * Path to the Ed25519 signing key, OUTSIDE the repository. A pack that carries a
@@ -860,8 +907,8 @@ export function publishPackages(
860
907
  /**
861
908
  * Floor probe injection for the workspace-floor preflight (see
862
909
  * `findUnpublishedWorkspaceFloors`). Default: a real `npm view` probe, which runs only on LIVE
863
- * publishes — dry-run stays offline, matching `maxPublished`. Injecting a probe also arms the
864
- * preflight under dry-run, which is how the wiring test drives it without network.
910
+ * publishes. Injecting a probe also arms this floor preflight under dry-run. The package's
911
+ * own registry classification runs in both live and dry-run modes independently.
865
912
  */
866
913
  probeFloor?: ((name: string, version: string) => boolean) | undefined;
867
914
  /** Subprocess injection for tests; the default is Node's synchronous executor. */
@@ -907,9 +954,13 @@ export function publishPackages(
907
954
 
908
955
  const packages = discoverPackages(monorepoRoot);
909
956
  const results: PublishResult[] = [];
910
- const filtered = opts.filter && opts.filter.length > 0
957
+ const registryProbes = new Map<string, RegistryProbe>();
958
+ const matching = opts.filter && opts.filter.length > 0
911
959
  ? packages.filter((p) => opts.filter!.some((f) => matchesPublishFilter(p, f, monorepoRoot)))
912
960
  : packages;
961
+ const filtered = opts.targetNames === undefined
962
+ ? matching
963
+ : matching.filter((p) => opts.targetNames!.includes(p.name));
913
964
  // Publish dependencies before dependents so pnpm rewrites workspace:* to the
914
965
  // freshly-bumped version, never a stale one (the harness-cli@0.3.122 breakage).
915
966
  const ordered = orderByDependencies(filtered);
@@ -1014,11 +1065,29 @@ export function publishPackages(
1014
1065
  failedInBatch.add(pkg.name);
1015
1066
  continue;
1016
1067
  }
1017
- // Bump from max(local, npm-published) so a locally-reverted version can't
1018
- // collide with an already-published one (audit #10). Dry-run stays offline
1019
- // (local only) to keep previews fast and network-free.
1020
- const base = opts.dryRun ? oldVersion : maxPublished(pkg.name, oldVersion, exec);
1021
- const newVersion = bumpPatch(base);
1068
+ // Both modes establish registry state before planning: absence keeps the disk version,
1069
+ // uncertainty refuses a bump, and an existing release bumps from max(local, published).
1070
+ const registryProbe = probeRegistry(pkg.name, exec);
1071
+ registryProbes.set(pkg.name, registryProbe);
1072
+ if (registryProbe.kind === 'unknown') {
1073
+ results.push({
1074
+ name: pkg.name, oldVersion, newVersion: oldVersion,
1075
+ status: opts.dryRun ? 'skipped' : 'error',
1076
+ probe: 'unknown',
1077
+ error: opts.dryRun
1078
+ ? `NOT ESTABLISHED (registry unreachable: ${registryProbe.reason})`
1079
+ : `registry unreachable: ${registryProbe.reason} — cannot tell "never published" from "offline"; not bumping blind`,
1080
+ });
1081
+ if (!opts.dryRun) failedInBatch.add(pkg.name);
1082
+ continue;
1083
+ }
1084
+ const plan = {
1085
+ base: registryProbe.kind === 'published' && compareVersions(registryProbe.version, oldVersion) > 0
1086
+ ? registryProbe.version : oldVersion,
1087
+ firstPublish: registryProbe.kind === 'never-published',
1088
+ probe: registryProbe.kind,
1089
+ };
1090
+ const newVersion = plan.firstPublish ? oldVersion : bumpPatch(plan.base);
1022
1091
 
1023
1092
  // Preflight: refuse to publish a pack whose `files` whitelist would silently
1024
1093
  // drop a skill dir from the tarball (the bug that shipped skills-meta without
@@ -1113,7 +1182,7 @@ export function publishPackages(
1113
1182
  if (opts.dryRun) {
1114
1183
  // NOT a statement that the package would publish cleanly — only that the gates checked ABOVE
1115
1184
  // this line passed. Everything below it (build, re-sign, pack, the package's own
1116
- // `prepublishOnly`, the registry itself) is untouched by a dry run and is named as such.
1185
+ // `prepublishOnly`, the registry's publish receipt) is untouched by a dry run and named as such.
1117
1186
  const NOT_VERIFIED_BY_DRY_RUN = Object.freeze([
1118
1187
  'prepublishOnly пакета (его собственный гейт публикации)',
1119
1188
  'сборка dist из исходников',
@@ -1492,7 +1561,17 @@ export function publishPackages(
1492
1561
  }
1493
1562
 
1494
1563
  return {
1495
- packages: results,
1564
+ // Decorate every final path, including packed transport and gate failures. A dependency
1565
+ // blocked before its own probe has unknown registry state; keep the deps-first short circuit.
1566
+ packages: results.map((result) => {
1567
+ const registryProbe = registryProbes.get(result.name);
1568
+ return {
1569
+ ...result,
1570
+ probe: registryProbe?.kind ?? 'unknown',
1571
+ ...(registryProbe?.kind === 'never-published' ? { firstPublish: true } : {}),
1572
+ ...(registryProbe?.scripted === true ? { probeOverride: true } : {}),
1573
+ };
1574
+ }),
1496
1575
  published: results.filter((r) => r.status === 'published').length,
1497
1576
  skipped: results.filter((r) => r.status === 'skipped').length,
1498
1577
  errors: results.filter((r) => r.status === 'error').length,
package/src/registry.ts CHANGED
@@ -11,6 +11,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync, statSync, type Dir
11
11
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
 
14
+ import { PACKAGE_SKILL_LAYOUTS } from './package-skill-layouts.js';
14
15
  import { stems } from './stem.js';
15
16
 
16
17
  /**
@@ -81,11 +82,13 @@ function readSkillDiscoveryConfig(cwd: string): { skillScopes: string[]; skillDi
81
82
  /** List `skills-*` pack directories across all base dirs, de-duplicated by pack name (first wins). */
82
83
  /**
83
84
  * Does this directory actually carry skills? Answers by LOOKING, so a pack is catalogued for what it
84
- * contains rather than for how it is named. Bounded on purpose: only the three layouts real packs
85
- * use (`skills/<id>/SKILL.md`, `<pack>/<id>/SKILL.md` for a single-skill pack, and a bare
86
- * `SKILL.md`), never a full-tree walk — an unbounded scan over `node_modules` would cost more than
87
- * the catalogue it builds. Templates are excluded: a template is a stamp for making skills, not an
88
- * installed skill, and counting it would list the same name twice.
85
+ * contains rather than for how it is named. Probes PACKAGE_SKILL_LAYOUTS — the ONE table that says
86
+ * where a package keeps its skills (backlog 86b787b8: the same three layouts used to be spelled out
87
+ * by hand here and in `buildRegistry`, so the next layout would land in one place and be forgotten
88
+ * in the other). Bounded on purpose: never a full-tree walk, because an unbounded scan over
89
+ * `node_modules` would cost more than the catalogue it builds. For the flat layout the package
90
+ * itself may also be a skill (a bare `SKILL.md`). `hasSkillMd` skips a `templates` child so a
91
+ * template stamp is not counted twice — it is reached only through its own allowed layout.
89
92
  */
90
93
  function packCarriesSkills(dir: string): boolean {
91
94
  const hasSkillMd = (d: string): boolean => {
@@ -98,13 +101,11 @@ function packCarriesSkills(dir: string): boolean {
98
101
  return false;
99
102
  };
100
103
  try {
101
- if (existsSync(join(dir, 'SKILL.md'))) return true;
102
- if (hasSkillMd(join(dir, 'skills'))) return true;
103
- // A template pack ships the skills it will roll out into the user's project. From the
104
- // catalogue's point of view those skills EXIST — `trip-planner` and `presentation-storyteller`
105
- // are installable answers to a task — so hiding them makes the advisor deny a real capability.
106
- if (hasSkillMd(join(dir, 'templates', '.claude', 'skills'))) return true;
107
- return hasSkillMd(dir);
104
+ return PACKAGE_SKILL_LAYOUTS.some(({ rel }) => {
105
+ const root = rel === '.' ? dir : join(dir, rel);
106
+ if (rel === '.' && existsSync(join(root, 'SKILL.md'))) return true;
107
+ return hasSkillMd(root);
108
+ });
108
109
  } catch {
109
110
  return false;
110
111
  }
@@ -482,17 +483,22 @@ export function buildRegistry(cwd: string): Registry {
482
483
  const entries: RegistryEntry[] = [];
483
484
  const packs = discoverSkillCarryingDirs(cwd);
484
485
 
485
- // A pack keeps its skills in one of three shapes, and reading only the first made 41 real skills
486
+ // The catalogue collects ALL allowed layouts, and reading only the first made 41 real skills
486
487
  // invisible (MEASURED 2026-09-01): the pack root (`skills-*` packs), a `skills/` subdirectory
487
488
  // (health-advisor and friends), and `templates/.claude/skills/` for packs that roll their skills
488
- // out into the user's project. The catalogue answers "what can I use", so all three count.
489
- const SKILL_LAYOUTS = [[], ['skills'], ['templates', '.claude', 'skills']] as const;
489
+ // out into the user's project. A template pack ships the skills it will roll out; from the
490
+ // catalogue's point of view those skills EXIST — `trip-planner` and `presentation-storyteller`
491
+ // are installable answers to a task — so hiding them would make the advisor deny a real
492
+ // capability. The layouts themselves now come from PACKAGE_SKILL_LAYOUTS so the knowledge lives
493
+ // in ONE place; resolvePackageSkillRoots deliberately selects only the FIRST non-empty root
494
+ // (installation semantics, plan AM-4) and therefore cannot serve this enumeration — calling it
495
+ // here would silently drop skills from a pack that fills two layouts (backlog 86b787b8).
490
496
  const seenSkillIds = new Set<string>();
491
497
 
492
498
  for (const { pack, dir: packDir } of packs) {
493
499
  const found: { name: string; root: string }[] = [];
494
- for (const layout of SKILL_LAYOUTS) {
495
- const root = layout.length === 0 ? packDir : join(packDir, ...layout);
500
+ for (const { rel } of PACKAGE_SKILL_LAYOUTS) {
501
+ const root = rel === '.' ? packDir : join(packDir, rel);
496
502
  if (!existsSync(root)) continue;
497
503
  try {
498
504
  for (const e of readdirSync(root, { withFileTypes: true })) {