@ghl-ai/aw 0.1.44-beta.1 → 0.1.44-beta.2

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.
@@ -0,0 +1,536 @@
1
+ /**
2
+ * c4/diagnostics.mjs — end-of-run health probes + summary formatting.
3
+ *
4
+ * Five exports, each with a separate, tightly-scoped responsibility:
5
+ *
6
+ * summarizeAsOneLine(state)
7
+ * Pure formatter. Renders a single ANSI-clean line for stdout that
8
+ * captures the install verdict at a glance.
9
+ *
10
+ * diagnoseAwRouterView({ awHome })
11
+ * Codex-relevant: replays the upstream `find * /skills/* /SKILL.md`
12
+ * enumeration logic against ~/.aw/.aw_registry/platform/core/skills/
13
+ * and reports which expected stage skills are visible to the
14
+ * SessionStart hook. Catches Bug A failures (registry path empty
15
+ * post-init).
16
+ *
17
+ * diagnosePromptRouterInjection(home, opts)
18
+ * Codex-relevant: spawnSyncs the wrapper at <home>/.codex/hooks/
19
+ * aw-user-prompt-submit.sh with synthetic stdin (steady-state OR
20
+ * first-turn shape per opts.simulateFirstTurn) and asserts the
21
+ * stdout JSON envelope contains <EXTREMELY_IMPORTANT> framing.
22
+ * Tests both that the wrapper is registered AND that it actually
23
+ * emits the router (catches "wrapper installed but does nothing").
24
+ *
25
+ * diagnoseSkillResolution({ harness, home, awHome, eccHome, skills })
26
+ * Multi-path × multi-skill probe. For each skill, checks four
27
+ * candidate paths in priority order. Top-level ok=true ONLY if
28
+ * using-aw-skills AND every stage skill the slim card promises
29
+ * resolve via at least one path. Catches "router fires but
30
+ * dispatches to ENOENT" silent failures.
31
+ *
32
+ * dumpPostInitState({ harness, cwd, awHome, configPaths, writer, shell })
33
+ * Triage dump to stderr (NEVER stdout) — token-redacted gh auth
34
+ * status, git remote state, key-only enumeration of config files
35
+ * (no values, since some keys carry secrets), and the first 10
36
+ * SKILL.md paths visible to the registry enumerator.
37
+ *
38
+ * All probes are pure or replay-style; none mutate state. Adapters
39
+ * (writer, shell, spawnSync) are dependency-injected for testability.
40
+ *
41
+ * Contract: spec.md::§"c4/diagnostics.mjs", tasks.md::4.2.
42
+ */
43
+
44
+ import {
45
+ existsSync,
46
+ readdirSync,
47
+ readFileSync,
48
+ statSync,
49
+ } from 'node:fs';
50
+ import { join } from 'node:path';
51
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
52
+
53
+ /**
54
+ * @typedef {'cursor-cloud' | 'codex-web' | 'claude-web' | 'unknown'} Harness
55
+ */
56
+
57
+ /* ─────────────────────────────────────────────────────────────────────────
58
+ * Stable expected sets — referenced by multiple probes.
59
+ * ───────────────────────────────────────────────────────────────────────── */
60
+
61
+ const EXPECTED_STAGE_SKILLS = Object.freeze([
62
+ 'aw-plan',
63
+ 'aw-build',
64
+ 'aw-investigate',
65
+ 'aw-review',
66
+ ]);
67
+ const ROUTER_SKILL = 'using-aw-skills';
68
+ const DEFAULT_SKILL_LIST = Object.freeze([ROUTER_SKILL, ...EXPECTED_STAGE_SKILLS]);
69
+
70
+ /* ─────────────────────────────────────────────────────────────────────────
71
+ * summarizeAsOneLine
72
+ * ───────────────────────────────────────────────────────────────────────── */
73
+
74
+ const CHECK = '\u2713'; // ✓
75
+ const CROSS = '\u2717'; // ✗
76
+ const DOT = ' \u00B7 '; // " · "
77
+
78
+ /**
79
+ * @param {object} state
80
+ * Combination of PreflightResult + per-harness install outcomes:
81
+ * - state.harness, state.hasToken, state.tokenSource, state.authOk
82
+ * - state.bridge?: { ok: boolean } (codex-web only)
83
+ * - state.injector?: { ok: boolean } (codex-web only)
84
+ * - state.mcpProbe?: 'ok' | 'invalid-token' | 'unauthorized' | 'unreachable' | 'unknown'
85
+ * - state.didInit: boolean
86
+ * - state.durationMs: number
87
+ * @returns {string}
88
+ */
89
+ export function summarizeAsOneLine(state) {
90
+ if (!state || typeof state !== 'object') {
91
+ throw new Error('summarizeAsOneLine: state object required');
92
+ }
93
+
94
+ if (!state.hasToken) {
95
+ return `[aw-c4] ${state.harness}${DOT}no token${DOT}skipped (agent continues)`;
96
+ }
97
+
98
+ const parts = [`[aw-c4] ${state.harness}`];
99
+ // authOk === null means "not probed" (diagnose mode skips the auth probe).
100
+ // Render '?' in that case to avoid the misleading "GITHUB_PAT ✗" tick that
101
+ // would otherwise show on every diagnose run.
102
+ const authTick = state.authOk == null ? '?' : (state.authOk ? CHECK : CROSS);
103
+ parts.push(`${state.tokenSource} ${authTick}`);
104
+
105
+ if (state.harness === 'codex-web') {
106
+ if (state.bridge != null) parts.push(`bridge ${state.bridge.ok ? CHECK : CROSS}`);
107
+ if (state.injector != null) parts.push(`injector ${state.injector.ok ? CHECK : CROSS}`);
108
+ } else {
109
+ parts.push(`auth ${authTick}`);
110
+ parts.push('bridge n/a');
111
+ }
112
+
113
+ if (state.mcpProbe) parts.push(`mcp ${state.mcpProbe}`);
114
+
115
+ const seconds = Number(state.durationMs ?? 0) / 1000;
116
+ parts.push(`init ${seconds.toFixed(1)}s`);
117
+ parts.push(state.didInit ? 'ready' : 'skipped');
118
+
119
+ return parts.join(DOT);
120
+ }
121
+
122
+ /* ─────────────────────────────────────────────────────────────────────────
123
+ * diagnoseAwRouterView
124
+ * ───────────────────────────────────────────────────────────────────────── */
125
+
126
+ /**
127
+ * Replay of the upstream session-start.sh enumerator scan:
128
+ * find <awHome>/.aw_registry/platform -path STAR/skills/STAR/SKILL.md
129
+ *
130
+ * @param {object} opts
131
+ * @param {string} opts.awHome
132
+ * @returns {{ enumerableSkills: string[], expected: string[], missing: string[], ok: boolean }}
133
+ */
134
+ export function diagnoseAwRouterView({ awHome } = {}) {
135
+ if (!awHome) throw new Error('diagnoseAwRouterView: awHome is required');
136
+
137
+ const platformRoot = join(awHome, '.aw_registry/platform');
138
+ const enumerableSkills = enumerateRegistrySkills(platformRoot);
139
+ const expected = [...EXPECTED_STAGE_SKILLS];
140
+ const missing = expected.filter((s) => !enumerableSkills.includes(s));
141
+ const ok = missing.length === 0;
142
+
143
+ return { enumerableSkills, expected, missing, ok };
144
+ }
145
+
146
+ /**
147
+ * Walk <root>/<team>/skills/<skill>/SKILL.md two levels deep, mirroring the
148
+ * upstream find pattern. Returns the deduped sorted list of skill
149
+ * directory names.
150
+ */
151
+ function enumerateRegistrySkills(platformRoot) {
152
+ if (!existsSync(platformRoot)) return [];
153
+ const set = new Set();
154
+ let teams;
155
+ try {
156
+ teams = readdirSync(platformRoot, { withFileTypes: true });
157
+ } catch {
158
+ // readdir on the platform root failed (EACCES, ENOENT). Treat as
159
+ // "no skills enumerable" — the caller's missing-list will surface it.
160
+ return [];
161
+ }
162
+ for (const team of teams) {
163
+ if (!team.isDirectory()) continue;
164
+ const skillsDir = join(platformRoot, team.name, 'skills');
165
+ if (!existsSync(skillsDir)) continue;
166
+ let skills;
167
+ try {
168
+ skills = readdirSync(skillsDir, { withFileTypes: true });
169
+ } catch {
170
+ // readdir on a team's skills/ subdirectory failed — skip this team
171
+ // dir but continue probing siblings.
172
+ continue;
173
+ }
174
+ for (const skill of skills) {
175
+ if (!skill.isDirectory()) continue;
176
+ const skillMd = join(skillsDir, skill.name, 'SKILL.md');
177
+ if (existsSync(skillMd)) set.add(skill.name);
178
+ }
179
+ }
180
+ return [...set].sort();
181
+ }
182
+
183
+ /* ─────────────────────────────────────────────────────────────────────────
184
+ * diagnosePromptRouterInjection (G10)
185
+ * ───────────────────────────────────────────────────────────────────────── */
186
+
187
+ const ROUTER_FRAMING_MARKER = '<EXTREMELY_IMPORTANT>';
188
+ const WRAPPER_HEADER_MARKER = '# aw-c4 router injector v1';
189
+
190
+ /**
191
+ * Replay the Codex per-turn wrapper with synthetic stdin and assert the
192
+ * stdout envelope contains <EXTREMELY_IMPORTANT> framing.
193
+ *
194
+ * `simulateFirstTurn: true` (default — per spec line 996) sends a
195
+ * synthetic first-turn payload with no prior-turn fields, guarding against
196
+ * the failure mode where wrapper logic depends on prior-turn state and
197
+ * silently no-ops on turn 1.
198
+ *
199
+ * Note on the return shape: when `simulateFirstTurn` is false (test-only
200
+ * escape hatch), `firstTurnEmitsRouter` mirrors `emitsFullRouter` (no
201
+ * separate replay was performed). Production calls from c4Command always
202
+ * run with `simulateFirstTurn: true`, so the field always reflects a real
203
+ * first-turn probe in production.
204
+ *
205
+ * @param {string} home
206
+ * @param {object} [opts]
207
+ * @param {boolean} [opts.simulateFirstTurn=true]
208
+ * @param {string} [opts.escapeEnvOverride] Forwarded as AW_PROMPT_ROUTER_INJECT (e.g. '0').
209
+ * @param {Function} [opts.spawnSync] Injectable for tests (defaults to node:child_process).
210
+ * @returns {{ wrapperRegistered: boolean, emitsFullRouter: boolean, firstTurnEmitsRouter: boolean, ok: boolean }}
211
+ */
212
+ export function diagnosePromptRouterInjection(home, opts = {}) {
213
+ if (!home) throw new Error('diagnosePromptRouterInjection: home is required');
214
+
215
+ const simulateFirstTurn = opts.simulateFirstTurn !== false;
216
+ const spawn = opts.spawnSync ?? nodeSpawnSync;
217
+ const wrapperPath = join(home, '.codex/hooks/aw-user-prompt-submit.sh');
218
+
219
+ const wrapperRegistered = isWrapperRegistered(wrapperPath);
220
+ if (!wrapperRegistered) {
221
+ return { wrapperRegistered: false, emitsFullRouter: false, firstTurnEmitsRouter: false, ok: false };
222
+ }
223
+
224
+ const env = { ...process.env };
225
+ if (opts.escapeEnvOverride !== undefined) {
226
+ env.AW_PROMPT_ROUTER_INJECT = String(opts.escapeEnvOverride);
227
+ }
228
+
229
+ // Steady-state replay: payload carries prior-turn fields. cwd is the
230
+ // injected `home` (matches firstTurnStdin) — keeps the probe hermetic and
231
+ // out of the host CWD.
232
+ const steadyOutput = invokeWrapper(spawn, wrapperPath, steadyStateStdin(home), env);
233
+ const emitsFullRouter = envelopeContainsRouter(steadyOutput);
234
+
235
+ // First-turn replay: minimal payload — proves the wrapper does not depend
236
+ // on prior-turn state to emit the router envelope. When opts.simulateFirstTurn
237
+ // is false, firstTurnEmitsRouter mirrors emitsFullRouter (no separate replay
238
+ // is performed). Production callers always run with simulateFirstTurn=true.
239
+ let firstTurnEmitsRouter = emitsFullRouter;
240
+ if (simulateFirstTurn) {
241
+ const firstTurnOutput = invokeWrapper(spawn, wrapperPath, firstTurnStdin(home), env);
242
+ firstTurnEmitsRouter = envelopeContainsRouter(firstTurnOutput);
243
+ }
244
+
245
+ const ok = wrapperRegistered && emitsFullRouter && firstTurnEmitsRouter;
246
+ return { wrapperRegistered, emitsFullRouter, firstTurnEmitsRouter, ok };
247
+ }
248
+
249
+ function isWrapperRegistered(wrapperPath) {
250
+ if (!existsSync(wrapperPath)) return false;
251
+ try {
252
+ return readFileSync(wrapperPath, 'utf8').slice(0, 256).includes(WRAPPER_HEADER_MARKER);
253
+ } catch {
254
+ // Wrapper file became unreadable between existence check and read —
255
+ // fall back to "not registered" so the orchestrator does not try to
256
+ // invoke a half-broken hook.
257
+ return false;
258
+ }
259
+ }
260
+
261
+ function invokeWrapper(spawn, wrapperPath, stdinPayload, env) {
262
+ const result = spawn('bash', [wrapperPath], {
263
+ input: stdinPayload,
264
+ encoding: 'utf8',
265
+ env,
266
+ });
267
+ return result?.stdout ?? '';
268
+ }
269
+
270
+ function steadyStateStdin(cwd) {
271
+ return JSON.stringify({
272
+ prompt: 'steady state diag probe',
273
+ cwd,
274
+ workspace_roots: [cwd],
275
+ tool_results: [],
276
+ previous_response_id: 'resp-prior',
277
+ });
278
+ }
279
+
280
+ function firstTurnStdin(home) {
281
+ return JSON.stringify({
282
+ prompt: 'first turn smoke',
283
+ cwd: home,
284
+ workspace_roots: [home],
285
+ });
286
+ }
287
+
288
+ function envelopeContainsRouter(output) {
289
+ if (typeof output !== 'string' || output.length === 0) return false;
290
+ // Spec invariant: full router envelope places <EXTREMELY_IMPORTANT> in the
291
+ // hookSpecificOutput.additionalContext field. We accept any occurrence in
292
+ // stdout (the wrapper builds a JSON object via python3, so framing always
293
+ // appears in the JSON-encoded form).
294
+ return output.includes(ROUTER_FRAMING_MARKER);
295
+ }
296
+
297
+ /* ─────────────────────────────────────────────────────────────────────────
298
+ * diagnoseSkillResolution (G2)
299
+ * ───────────────────────────────────────────────────────────────────────── */
300
+
301
+ /**
302
+ * @typedef {object} SkillResolutionResult
303
+ * @property {string} skill
304
+ * @property {string[]} candidatePathsChecked
305
+ * @property {string|null} found
306
+ * @property {boolean} ok
307
+ */
308
+
309
+ /**
310
+ * For each skill, probe four candidate paths in priority order. Top-level
311
+ * ok=true ONLY if the router skill (using-aw-skills) AND every stage skill
312
+ * (aw-plan, aw-build, aw-investigate, aw-review) resolve.
313
+ *
314
+ * @param {object} opts
315
+ * @param {Harness} opts.harness
316
+ * @param {string} opts.home
317
+ * @param {string} opts.awHome
318
+ * @param {string} opts.eccHome
319
+ * @param {string[]} [opts.skills] Default: DEFAULT_SKILL_LIST
320
+ * @returns {{
321
+ * perSkill: SkillResolutionResult[],
322
+ * routerSkill: SkillResolutionResult,
323
+ * stageSkillsOk: boolean,
324
+ * ok: boolean
325
+ * }}
326
+ */
327
+ export function diagnoseSkillResolution(opts = {}) {
328
+ if (!opts.harness) throw new Error('diagnoseSkillResolution: harness is required');
329
+ if (!opts.home) throw new Error('diagnoseSkillResolution: home is required');
330
+ if (!opts.awHome) throw new Error('diagnoseSkillResolution: awHome is required');
331
+ if (!opts.eccHome) throw new Error('diagnoseSkillResolution: eccHome is required');
332
+
333
+ const { harness, home, awHome, eccHome } = opts;
334
+ const skills = opts.skills ?? [...DEFAULT_SKILL_LIST];
335
+
336
+ const perSkill = skills.map((skill) => probeOneSkill(skill, harness, home, awHome, eccHome));
337
+ const routerSkill = perSkill.find((e) => e.skill === ROUTER_SKILL) ?? {
338
+ skill: ROUTER_SKILL,
339
+ candidatePathsChecked: [],
340
+ found: null,
341
+ ok: false,
342
+ };
343
+
344
+ const stageEntries = perSkill.filter((e) => e.skill !== ROUTER_SKILL);
345
+ const stageSkillsOk = stageEntries.length > 0 && stageEntries.every((e) => e.ok);
346
+ const ok = routerSkill.ok && stageSkillsOk;
347
+
348
+ return { perSkill, routerSkill, stageSkillsOk, ok };
349
+ }
350
+
351
+ function harnessSkillsDir(harness, home) {
352
+ if (harness === 'claude-web') return join(home, '.claude/skills');
353
+ if (harness === 'cursor-cloud') return join(home, '.cursor/skills');
354
+ // codex-web: registry path is the canonical source; the per-harness skills
355
+ // dir is conventionally absent. We still probe the registry + ECC paths.
356
+ return null;
357
+ }
358
+
359
+ function probeOneSkill(skill, harness, home, awHome, eccHome) {
360
+ const harnessDir = harnessSkillsDir(harness, home);
361
+ const candidatePathsChecked = [];
362
+
363
+ if (harnessDir) {
364
+ candidatePathsChecked.push(join(harnessDir, skill, 'SKILL.md'));
365
+ candidatePathsChecked.push(join(harnessDir, `platform-core-${skill}`, 'SKILL.md'));
366
+ }
367
+ candidatePathsChecked.push(join(awHome, '.aw_registry/platform/core/skills', skill, 'SKILL.md'));
368
+ candidatePathsChecked.push(join(eccHome, 'skills', skill, 'SKILL.md'));
369
+
370
+ let found = null;
371
+ for (const p of candidatePathsChecked) {
372
+ if (existsSync(p)) {
373
+ found = p;
374
+ break;
375
+ }
376
+ }
377
+
378
+ return { skill, candidatePathsChecked, found, ok: found != null };
379
+ }
380
+
381
+ /* ─────────────────────────────────────────────────────────────────────────
382
+ * dumpPostInitState — stderr-only triage dump
383
+ * ───────────────────────────────────────────────────────────────────────── */
384
+
385
+ // Covers GitHub classic PAT prefixes (ghp_, gho_, ghs_, ghu_) and the
386
+ // fine-grained PAT prefix github_pat_ (GA since 2023). The 8-char minimum
387
+ // is intentional — short enough to catch redacted-but-leaked fragments,
388
+ // long enough to avoid false-positive on prose like "ghx_" mentions.
389
+ const TOKEN_PATTERN = /(github_pat_[A-Za-z0-9_]{16,}|gh[opsu]_[A-Za-z0-9_]{8,})/g;
390
+
391
+ const DEFAULT_WRITER = {
392
+ stderr: (s) => process.stderr.write(s),
393
+ stdout: (s) => process.stdout.write(s),
394
+ };
395
+
396
+ function defaultDumpShell(cmd) {
397
+ const r = nodeSpawnSync(cmd, [], { shell: true, encoding: 'utf8' });
398
+ return {
399
+ ok: r.status === 0,
400
+ stdout: r.stdout ?? '',
401
+ stderr: r.stderr ?? '',
402
+ exitCode: r.status ?? -1,
403
+ };
404
+ }
405
+
406
+ /**
407
+ * Triage dump — token-redacted, value-redacted, stderr-only.
408
+ *
409
+ * @param {object} opts
410
+ * @param {Harness} opts.harness
411
+ * @param {string} opts.cwd
412
+ * @param {string} opts.awHome
413
+ * @param {string[]} opts.configPaths
414
+ * @param {object} [opts.writer] Default: { stderr: process.stderr.write, stdout: process.stdout.write }
415
+ * @param {Function} [opts.shell] Default: real spawnSync.
416
+ * @returns {void}
417
+ */
418
+ export function dumpPostInitState(opts = {}) {
419
+ if (!opts.harness) throw new Error('dumpPostInitState: harness is required');
420
+ if (!opts.cwd) throw new Error('dumpPostInitState: cwd is required');
421
+ if (!opts.awHome) throw new Error('dumpPostInitState: awHome is required');
422
+
423
+ const writer = opts.writer ?? DEFAULT_WRITER;
424
+ const shell = typeof opts.shell === 'function' ? opts.shell : defaultDumpShell;
425
+ const configPaths = Array.isArray(opts.configPaths) ? opts.configPaths : [];
426
+
427
+ const lines = [];
428
+ lines.push('─── aw c4 post-init dump ───');
429
+ lines.push(`harness: ${opts.harness}`);
430
+ lines.push(`cwd: ${opts.cwd}`);
431
+ lines.push(`awHome: ${opts.awHome}`);
432
+ lines.push('');
433
+
434
+ // gh auth status (token-redacted).
435
+ lines.push('$ gh auth status --hostname github.com');
436
+ const ghAuth = shell('gh auth status --hostname github.com');
437
+ lines.push(redactTokens(ghAuth.stdout || ghAuth.stderr || '<no output>'));
438
+ lines.push('');
439
+
440
+ // git remote -v.
441
+ lines.push(`$ git -C "${opts.cwd}" remote -v`);
442
+ const remote = shell(`git -C "${opts.cwd}" remote -v`);
443
+ lines.push(redactTokens(remote.stdout || '<none>'));
444
+ lines.push('');
445
+
446
+ // Config-file enumeration — keys only, no values (some keys carry secrets).
447
+ for (const cfg of configPaths) {
448
+ lines.push(`$ keys-of ${cfg}`);
449
+ lines.push(...enumerateConfigKeysOnly(cfg));
450
+ lines.push('');
451
+ }
452
+
453
+ // Registry SKILL.md sample (first 10).
454
+ lines.push(`$ find ${opts.awHome}/.aw_registry -name SKILL.md | head -10`);
455
+ for (const skillPath of sampleRegistrySkillPaths(opts.awHome, 10)) {
456
+ lines.push(` ${skillPath}`);
457
+ }
458
+ lines.push('─── end ───');
459
+ lines.push('');
460
+
461
+ writer.stderr(lines.join('\n'));
462
+ }
463
+
464
+ function redactTokens(text) {
465
+ if (typeof text !== 'string') return '';
466
+ return text.replace(TOKEN_PATTERN, (m) => `${m.slice(0, 4)}\u2026`);
467
+ }
468
+
469
+ function enumerateConfigKeysOnly(filePath) {
470
+ if (!existsSync(filePath)) return [' <file missing>'];
471
+ let body;
472
+ try {
473
+ body = readFileSync(filePath, 'utf8');
474
+ } catch {
475
+ return [' <unreadable>'];
476
+ }
477
+ let parsed;
478
+ try {
479
+ parsed = JSON.parse(body);
480
+ } catch {
481
+ return [' <not JSON; key enumeration skipped>'];
482
+ }
483
+ if (parsed == null || typeof parsed !== 'object') {
484
+ return [' <empty or non-object>'];
485
+ }
486
+ const out = [];
487
+ for (const [k, v] of Object.entries(parsed)) {
488
+ if (v != null && typeof v === 'object' && !Array.isArray(v)) {
489
+ out.push(` ${k} (${Object.keys(v).length} nested keys)`);
490
+ } else if (Array.isArray(v)) {
491
+ out.push(` ${k} (array, ${v.length} entries)`);
492
+ } else {
493
+ out.push(` ${k}`);
494
+ }
495
+ }
496
+ return out;
497
+ }
498
+
499
+ function sampleRegistrySkillPaths(awHome, max) {
500
+ const root = join(awHome, '.aw_registry/platform');
501
+ if (!existsSync(root)) return [' <registry not provisioned>'];
502
+ const out = [];
503
+ walkSkillMd(root, out, max);
504
+ return out.length > 0 ? out : [' <no SKILL.md files>'];
505
+ }
506
+
507
+ function walkSkillMd(dir, out, max) {
508
+ if (out.length >= max) return;
509
+ let entries;
510
+ try {
511
+ entries = readdirSync(dir, { withFileTypes: true });
512
+ } catch {
513
+ // readdir failed on a nested registry dir (EACCES, ENOENT, broken
514
+ // symlink target). Skip silently — the dump is best-effort triage.
515
+ return;
516
+ }
517
+ for (const e of entries) {
518
+ if (out.length >= max) return;
519
+ const p = join(dir, e.name);
520
+ if (e.isFile() && e.name === 'SKILL.md') {
521
+ out.push(p);
522
+ } else if (e.isDirectory()) {
523
+ walkSkillMd(p, out, max);
524
+ } else if (e.isSymbolicLink()) {
525
+ // Cheap stat check — symlinks pointing at SKILL.md (the bridge case)
526
+ // should still appear in the dump.
527
+ try {
528
+ const st = statSync(p);
529
+ if (st.isFile() && e.name === 'SKILL.md') out.push(p);
530
+ else if (st.isDirectory()) walkSkillMd(p, out, max);
531
+ } catch {
532
+ // Broken symlink — skip.
533
+ }
534
+ }
535
+ }
536
+ }