@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.
- package/.dz-manifest.json +194 -114
- package/README.md +43 -8
- package/dist/__tests__/golden-baseline.test.js +1 -1
- package/dist/__tests__/golden-baseline.test.js.map +1 -1
- package/dist/agentdb-index.js +3 -3
- package/dist/agentdb-index.js.map +1 -1
- package/dist/agentdb-reindex-marker.d.ts +2 -2
- package/dist/agentdb-reindex-marker.js +4 -4
- package/dist/agentdb-reindex-marker.js.map +1 -1
- package/dist/agentdb-snapshot-rotation.d.ts +1 -1
- package/dist/agentdb-snapshot-rotation.js +1 -1
- package/dist/backup-freshness.d.ts +32 -0
- package/dist/backup-freshness.d.ts.map +1 -0
- package/dist/backup-freshness.js +69 -0
- package/dist/backup-freshness.js.map +1 -0
- package/dist/cmd-usage.d.ts +2 -2
- package/dist/cmd-usage.d.ts.map +1 -1
- package/dist/cmd-usage.js +8 -6
- package/dist/cmd-usage.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts +2 -1
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +15 -2
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/compounding.d.ts +43 -1
- package/dist/compounding.d.ts.map +1 -1
- package/dist/compounding.js +96 -0
- package/dist/compounding.js.map +1 -1
- package/dist/discrimination-gate.d.ts +0 -3
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +2 -2
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/guard.d.ts +22 -2
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +102 -14
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +15 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -4
- package/dist/index.js.map +1 -1
- package/dist/integration-apply.js +2 -2
- package/dist/integration-apply.js.map +1 -1
- package/dist/lesson-payoff.js +3 -3
- package/dist/lesson-payoff.js.map +1 -1
- package/dist/named-lock.d.ts +5 -2
- package/dist/named-lock.d.ts.map +1 -1
- package/dist/named-lock.js +30 -10
- package/dist/named-lock.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +24 -0
- package/dist/operations.js.map +1 -1
- package/dist/pack-inventory.d.ts +51 -0
- package/dist/pack-inventory.d.ts.map +1 -0
- package/dist/pack-inventory.js +306 -0
- package/dist/pack-inventory.js.map +1 -0
- package/dist/package-skill-layouts.d.ts +3 -2
- package/dist/package-skill-layouts.d.ts.map +1 -1
- package/dist/package-skill-layouts.js +3 -2
- package/dist/package-skill-layouts.js.map +1 -1
- package/dist/patterns.d.ts +8 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +11 -1
- package/dist/patterns.js.map +1 -1
- package/dist/publish.d.ts +29 -7
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +92 -15
- package/dist/publish.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +24 -20
- package/dist/registry.js.map +1 -1
- package/dist/reqe-verdict.d.ts +55 -0
- package/dist/reqe-verdict.d.ts.map +1 -0
- package/dist/reqe-verdict.js +173 -0
- package/dist/reqe-verdict.js.map +1 -0
- package/dist/reqe.d.ts +35 -14
- package/dist/reqe.d.ts.map +1 -1
- package/dist/reqe.js +85 -32
- package/dist/reqe.js.map +1 -1
- package/dist/round.d.ts +10 -0
- package/dist/round.d.ts.map +1 -1
- package/dist/round.js +7 -0
- package/dist/round.js.map +1 -1
- package/dist/session-retro.d.ts +1 -1
- package/dist/session-retro.js +3 -3
- package/dist/session-retro.js.map +1 -1
- package/dist/skill-drift.d.ts +18 -1
- package/dist/skill-drift.d.ts.map +1 -1
- package/dist/skill-drift.js +46 -13
- package/dist/skill-drift.js.map +1 -1
- package/dist/statusline.js +2 -2
- package/dist/statusline.js.map +1 -1
- package/dist/store-guard.js +3 -3
- package/dist/store-guard.js.map +1 -1
- package/dist/store-lock.d.ts +9 -0
- package/dist/store-lock.d.ts.map +1 -1
- package/dist/store-lock.js +22 -3
- package/dist/store-lock.js.map +1 -1
- package/dist/test-receipt.d.ts +66 -0
- package/dist/test-receipt.d.ts.map +1 -0
- package/dist/test-receipt.js +73 -0
- package/dist/test-receipt.js.map +1 -0
- package/dist/workflow-run.d.ts.map +1 -1
- package/dist/workflow-run.js +7 -8
- package/dist/workflow-run.js.map +1 -1
- package/package.json +1 -1
- package/sbom.json +313 -113
- package/src/__tests__/golden-baseline.test.ts +1 -1
- package/src/agentdb-index.ts +3 -3
- package/src/agentdb-reindex-marker.ts +4 -4
- package/src/agentdb-snapshot-rotation.ts +1 -1
- package/src/backup-freshness.ts +96 -0
- package/src/cmd-usage.ts +7 -6
- package/src/codex-hooks-assets.ts +15 -2
- package/src/compounding.ts +132 -1
- package/src/discrimination-gate.ts +2 -5
- package/src/guard.ts +104 -17
- package/src/index.ts +23 -5
- package/src/integration-apply.ts +2 -2
- package/src/lesson-payoff.ts +3 -3
- package/src/named-lock.ts +36 -11
- package/src/operations.ts +24 -0
- package/src/pack-inventory.ts +313 -0
- package/src/package-skill-layouts.ts +3 -2
- package/src/patterns.ts +18 -1
- package/src/publish.ts +101 -22
- package/src/registry.ts +23 -17
- package/src/reqe-verdict.ts +174 -0
- package/src/reqe.ts +111 -35
- package/src/round.ts +18 -0
- package/src/session-retro.ts +3 -3
- package/src/skill-drift.ts +66 -12
- package/src/statusline.ts +2 -2
- package/src/store-guard.ts +3 -3
- package/src/store-lock.ts +20 -3
- package/src/test-receipt.ts +106 -0
- package/src/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>`
|
|
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
|
-
*
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
198
|
-
return
|
|
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
|
|
864
|
-
*
|
|
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
|
|
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
|
-
//
|
|
1018
|
-
//
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* the
|
|
88
|
-
*
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
//
|
|
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.
|
|
489
|
-
|
|
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
|
|
495
|
-
const root =
|
|
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 })) {
|