@rune-kit/rune 2.17.1 → 2.18.1

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.
@@ -62,7 +62,8 @@ export async function checkHookDrift(projectRoot) {
62
62
  platform: id,
63
63
  event: null,
64
64
  status: 'mixed-preset',
65
- message: 'settings.json contains both gentle and strict Rune entries — re-run `rune hooks install --preset <gentle|strict>` to converge',
65
+ message:
66
+ 'settings.json contains both gentle and strict Rune entries — re-run `rune hooks install --preset <gentle|strict>` to converge',
66
67
  });
67
68
  }
68
69
 
@@ -159,7 +160,9 @@ export function formatHookDriftResult(result) {
159
160
  }
160
161
 
161
162
  lines.push('');
162
- lines.push(` Summary: ${result.summary.drifted} drifted, ${result.summary.missing} missing, ${result.summary.errors} error(s).`);
163
+ lines.push(
164
+ ` Summary: ${result.summary.drifted} drifted, ${result.summary.missing} missing, ${result.summary.errors} error(s).`,
165
+ );
163
166
  lines.push(' Resolution: re-run `rune hooks install --preset <gentle|strict>` to re-converge.');
164
167
  lines.push(' This is a reporter — operator decides what to do with the findings.');
165
168
 
@@ -21,11 +21,12 @@
21
21
  */
22
22
 
23
23
  import { existsSync, readFileSync } from 'node:fs';
24
+ import { cp, readdir, readFile, rm } from 'node:fs/promises';
24
25
  import os from 'node:os';
25
26
  import path from 'node:path';
26
27
  import { createInterface } from 'node:readline';
27
28
  import { installHooks } from './hooks/install.js';
28
- import { TIER_ENV_VARS } from './hooks/tiers.js';
29
+ import { resolveTier, TIER_ENV_VARS } from './hooks/tiers.js';
29
30
 
30
31
  export const WELL_KNOWN_TIER_PATHS = {
31
32
  pro: ['D:/Project/Rune/Pro', path.join(os.homedir(), 'rune-pro'), path.join(os.homedir(), 'Project', 'Rune', 'Pro')],
@@ -38,9 +39,9 @@ export const WELL_KNOWN_TIER_PATHS = {
38
39
 
39
40
  /**
40
41
  * @param {{ projectRoot: string, runeRoot: string, args: object }} opts
41
- * @returns {Promise<{ scope: string, tiers: string[], preset: string, written: boolean, files: string[], notes: string[] }>}
42
+ * @returns {Promise<{ scope: string, tiers: string[], preset: string, written: boolean, files: string[], notes: string[], skillResults: Array<{tier: string, installed: string[], skipped: Array<{skill: string, reason: string}>, reason: string|null}> }>}
42
43
  */
43
- export async function runSetup({ projectRoot, runeRoot: _runeRoot, args = {} }) {
44
+ export async function runSetup({ projectRoot, runeRoot, args = {} }) {
44
45
  const detected = detectTiers(projectRoot);
45
46
 
46
47
  // Scope resolution
@@ -88,16 +89,180 @@ export async function runSetup({ projectRoot, runeRoot: _runeRoot, args = {} })
88
89
  dry: args.dry,
89
90
  });
90
91
 
92
+ // Install tier skill files into <runeRoot>/skills/ so Claude Code (and any
93
+ // plugin-aware platform) discovers them alongside Free skills. Without this
94
+ // step, paid tiers ship hooks only — `rune:autopilot` returns "Unknown skill"
95
+ // because Pro/skills/autopilot/ is invisible to the plugin runtime.
96
+ const skillResults = [];
97
+ for (const tier of tiers) {
98
+ try {
99
+ const tierManifest = await resolveTier(tier, projectRoot);
100
+ const skillResult = await installTierSkills({
101
+ tier,
102
+ tierManifest,
103
+ runeRoot,
104
+ dry: args.dry,
105
+ });
106
+ skillResults.push(skillResult);
107
+ } catch (err) {
108
+ skillResults.push({ tier, installed: [], skipped: [], reason: err.message });
109
+ }
110
+ }
111
+
91
112
  return {
92
113
  scope,
93
114
  targetRoot,
94
115
  tiers,
95
116
  preset,
96
117
  detected,
118
+ skillResults,
97
119
  ...result,
98
120
  };
99
121
  }
100
122
 
123
+ /**
124
+ * Copy a tier's skill directories into the Free plugin's skills/ folder so
125
+ * the Claude Code plugin runtime discovers them with the `rune:` prefix.
126
+ *
127
+ * Idempotent: existing skill directories are SKIPPED (not overwritten). This
128
+ * protects Free skills from being clobbered by a same-named Pro skill, and
129
+ * protects an in-place edit of a previously-installed Pro skill from being
130
+ * stomped on a re-run.
131
+ *
132
+ * Pre-condition: tierManifest.source points at the absolute path of the tier's
133
+ * manifest.json (set by validateManifest). Tier root = grandparent of that path.
134
+ *
135
+ * @param {object} opts
136
+ * @param {string} opts.tier
137
+ * @param {import('./hooks/tiers.js').TierManifest} opts.tierManifest
138
+ * @param {string} opts.runeRoot — target is `<runeRoot>/skills/`
139
+ * @param {boolean} [opts.dry]
140
+ * @returns {Promise<{tier: string, installed: string[], skipped: Array<{skill: string, reason: string}>, reason: string|null}>}
141
+ */
142
+ export async function installTierSkills({ tier, tierManifest, runeRoot, dry }) {
143
+ if (!tierManifest?.source) {
144
+ return { tier, installed: [], skipped: [], reason: 'tier manifest source missing' };
145
+ }
146
+ // Defensive: validateManifest accepts any string; only locateTierManifest guarantees absolute.
147
+ // Reject relative to keep tierRoot derivation honest.
148
+ if (!path.isAbsolute(tierManifest.source)) {
149
+ return {
150
+ tier,
151
+ installed: [],
152
+ skipped: [],
153
+ reason: `tier manifest source must be absolute (got ${tierManifest.source})`,
154
+ };
155
+ }
156
+ const tierRoot = path.dirname(path.dirname(tierManifest.source));
157
+ const sourceDir = path.join(tierRoot, 'skills');
158
+ if (!existsSync(sourceDir)) {
159
+ return { tier, installed: [], skipped: [], reason: `no skills/ dir at ${sourceDir}` };
160
+ }
161
+ if (!runeRoot) {
162
+ return { tier, installed: [], skipped: [], reason: 'runeRoot not provided' };
163
+ }
164
+ const targetDir = path.join(runeRoot, 'skills');
165
+ if (!existsSync(targetDir)) {
166
+ return { tier, installed: [], skipped: [], reason: `target skills/ missing at ${targetDir}` };
167
+ }
168
+ const installed = [];
169
+ const skipped = [];
170
+ const entries = await readdir(sourceDir, { withFileTypes: true });
171
+ for (const entry of entries) {
172
+ if (!entry.isDirectory()) {
173
+ // Reject symlinks even when their target is a directory — `cp` with default
174
+ // dereference=false would recreate the symlink and let the Claude Code runtime
175
+ // follow it outside runeRoot/skills/ at read time (POSIX symlink-escape).
176
+ if (entry.isSymbolicLink()) {
177
+ skipped.push({ skill: entry.name, reason: 'rejected: symlink (would escape sandbox)' });
178
+ }
179
+ continue;
180
+ }
181
+ const skillName = entry.name;
182
+ // Guard against path traversal via adversarial directory names (../, /, \).
183
+ // A compromised tier repo with a skill dir like '../../../etc' would otherwise
184
+ // escape runeRoot/skills/ and overwrite arbitrary files. assertSafeTierName
185
+ // in tiers.js covers the tier name but NOT per-skill directory names.
186
+ if (path.basename(skillName) !== skillName || skillName === '.' || skillName === '..') {
187
+ skipped.push({ skill: skillName, reason: 'rejected: unsafe directory name' });
188
+ continue;
189
+ }
190
+ const src = path.join(sourceDir, skillName);
191
+ const dst = path.join(targetDir, skillName);
192
+ if (existsSync(dst)) {
193
+ const drift = await detectVersionDrift(src, dst);
194
+ skipped.push({ skill: skillName, reason: drift || 'already present' });
195
+ continue;
196
+ }
197
+ if (!dry) {
198
+ try {
199
+ // dereference: true → copy symlink targets as content instead of recreating
200
+ // symlinks at dst. Belt-and-suspenders alongside the isSymbolicLink reject above
201
+ // (nested symlinks inside a skill dir would otherwise still recreate).
202
+ await cp(src, dst, { recursive: true, dereference: true });
203
+ } catch (err) {
204
+ // Clean partial-copy residue so next run isn't silently locked-out by
205
+ // existsSync(dst) on a corrupt half-written directory.
206
+ await rm(dst, { recursive: true, force: true }).catch(() => {});
207
+ skipped.push({ skill: skillName, reason: `copy failed: ${err.message}` });
208
+ continue;
209
+ }
210
+ }
211
+ installed.push(skillName);
212
+ }
213
+ return { tier, installed, skipped, reason: null };
214
+ }
215
+
216
+ /**
217
+ * Compare source SKILL.md version against installed version. Returns a string
218
+ * describing the drift if source is newer (so user knows an upgrade exists),
219
+ * or null when versions match / cannot be parsed. Never throws — best-effort.
220
+ *
221
+ * @param {string} srcSkillDir
222
+ * @param {string} dstSkillDir
223
+ * @returns {Promise<string|null>}
224
+ */
225
+ async function detectVersionDrift(srcSkillDir, dstSkillDir) {
226
+ try {
227
+ const [srcVer, dstVer] = await Promise.all([
228
+ readSkillVersion(path.join(srcSkillDir, 'SKILL.md')),
229
+ readSkillVersion(path.join(dstSkillDir, 'SKILL.md')),
230
+ ]);
231
+ if (!srcVer || !dstVer) return null;
232
+ if (srcVer === dstVer) return `already present (v${dstVer})`;
233
+ return `stale: installed v${dstVer}, source has v${srcVer} — delete target dir to upgrade`;
234
+ } catch {
235
+ return null;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Extract version string from a SKILL.md `metadata.version` field. Minimal
241
+ * YAML scan scoped to the `metadata:` block — avoids false positives from
242
+ * multiline description continuation lines that contain `version:` text
243
+ * (e.g. `description: |\n version: 1.0.0 is legacy`). Avoids pulling in a
244
+ * full YAML dependency for one field.
245
+ *
246
+ * Returns null when SKILL.md cannot be read, frontmatter missing, metadata
247
+ * block missing, or version field absent.
248
+ *
249
+ * @param {string} skillMdPath
250
+ * @returns {Promise<string|null>}
251
+ */
252
+ async function readSkillVersion(skillMdPath) {
253
+ try {
254
+ const content = await readFile(skillMdPath, 'utf-8');
255
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
256
+ if (!fmMatch) return null;
257
+ // Require metadata: header followed by an indented version: line.
258
+ // Indent is preserved so a top-level `version:` (outside metadata) doesn't match.
259
+ const verMatch = fmMatch[1].match(/^metadata:\s*\n(?:\s+[^\n]*\n)*?\s+version:\s*["']?([^"'\s#]+)/m);
260
+ return verMatch ? verMatch[1] : null;
261
+ } catch {
262
+ return null;
263
+ }
264
+ }
265
+
101
266
  /**
102
267
  * Auto-detect Pro/Business tiers across env vars, sibling paths, and well-known
103
268
  * locations. Returns { pro: { path, source } | null, business: { ... } | null }.
@@ -222,10 +387,34 @@ export function formatSetupResult(result) {
222
387
  lines.push('');
223
388
  lines.push(' Rune Setup Complete');
224
389
  lines.push(' ──────────────────');
225
- lines.push(` Scope: ${result.scope === 'global' ? 'GLOBAL (~/.claude/settings.json)' : `current project (${result.targetRoot})`}`);
390
+ lines.push(
391
+ ` Scope: ${result.scope === 'global' ? 'GLOBAL (~/.claude/settings.json)' : `current project (${result.targetRoot})`}`,
392
+ );
226
393
  lines.push(` Tiers: Free${result.tiers.length > 0 ? ` + ${result.tiers.join(' + ')}` : ''}`);
227
394
  lines.push(` Preset: ${result.preset}`);
228
395
  lines.push(` Platforms: ${(result.platforms || []).join(', ') || '—'}`);
396
+ for (const sr of result.skillResults || []) {
397
+ if (sr.installed.length > 0) {
398
+ const preview = sr.installed.slice(0, 3).join(', ');
399
+ const more = sr.installed.length > 3 ? `, +${sr.installed.length - 3}` : '';
400
+ lines.push(` Skills: ${sr.tier}: ${sr.installed.length} installed (${preview}${more})`);
401
+ }
402
+ if (sr.skipped.length > 0) {
403
+ const rejected = sr.skipped.filter((s) => s.reason.startsWith('rejected:'));
404
+ const benign = sr.skipped.length - rejected.length;
405
+ const parts = [];
406
+ if (benign > 0) parts.push(`${benign} already present`);
407
+ if (rejected.length > 0) parts.push(`${rejected.length} rejected`);
408
+ lines.push(` Skipped: ${sr.tier}: ${parts.join(', ')}`);
409
+ // Surface rejection details so the operator can investigate a compromised tier repo.
410
+ for (const r of rejected) {
411
+ lines.push(` ⚠ ${r.skill}: ${r.reason}`);
412
+ }
413
+ }
414
+ if (sr.reason) {
415
+ lines.push(` Skill warn:${sr.tier}: ${sr.reason}`);
416
+ }
417
+ }
229
418
  if (result.notes?.length) {
230
419
  lines.push('');
231
420
  lines.push(' Notes:');
@@ -700,11 +700,44 @@ export async function buildAll({
700
700
  await writeFile(path.join(outputDir, 'skill-index.json'), `${JSON.stringify(skillIndex, null, 2)}\n`, 'utf-8');
701
701
  stats.files.push('skill-index.json');
702
702
 
703
- // Generate AGENTS.md for Codex (OpenAI convention not used by other platforms)
704
- if (adapter.name === 'codex') {
705
- const agentsMdContent = generateAgentsMd(stats, adapter);
706
- await writeFile(path.join(outputRoot, 'AGENTS.md'), agentsMdContent, 'utf-8');
707
- stats.files.push('AGENTS.md');
703
+ // Generic extra-files hook: any adapter can emit additional index/bundle files
704
+ // alongside per-skill files (e.g. aider's .aider.conf.yml, qwen's QWEN.md, gemini's GEMINI.md bundle).
705
+ // Hook contract: paths MUST be relative to outputRoot — absolute paths are rejected to prevent
706
+ // accidental writes outside the project tree. Adapters receive a frozen stats snapshot so reads
707
+ // are deterministic regardless of where the hook fires relative to other emit steps.
708
+ if (typeof adapter.generateExtraFiles === 'function') {
709
+ const frozenStats = Object.freeze({ ...stats, files: Object.freeze([...stats.files]) });
710
+ const extras = await adapter.generateExtraFiles({
711
+ parsedSkills,
712
+ stats: frozenStats,
713
+ runeRoot,
714
+ outputRoot,
715
+ outputDir,
716
+ });
717
+ if (Array.isArray(extras)) {
718
+ const outputRootResolved = path.resolve(outputRoot);
719
+ for (const extra of extras) {
720
+ if (!extra || !extra.path || extra.content == null) continue;
721
+ if (path.isAbsolute(extra.path)) {
722
+ stats.errors.push({
723
+ adapter: adapter.name,
724
+ error: `generateExtraFiles must return relative paths, got absolute: ${extra.path}`,
725
+ });
726
+ continue;
727
+ }
728
+ const fullPath = path.resolve(outputRootResolved, extra.path);
729
+ if (fullPath !== outputRootResolved && !fullPath.startsWith(outputRootResolved + path.sep)) {
730
+ stats.errors.push({
731
+ adapter: adapter.name,
732
+ error: `generateExtraFiles path escapes outputRoot: ${extra.path}`,
733
+ });
734
+ continue;
735
+ }
736
+ await mkdir(path.dirname(fullPath), { recursive: true });
737
+ await writeFile(fullPath, extra.content, 'utf-8');
738
+ stats.files.push(path.relative(outputRoot, fullPath).replaceAll('\\', '/'));
739
+ }
740
+ }
708
741
  }
709
742
 
710
743
  // OpenClaw adapter: generate manifest + TypeScript entry point
@@ -774,42 +807,6 @@ function generateIndex(stats, adapter) {
774
807
  return lines.join('\n');
775
808
  }
776
809
 
777
- /**
778
- * Generate AGENTS.md for Codex (OpenAI convention)
779
- * Uses dynamic counts from build stats — no hardcoded skill lists
780
- */
781
- function generateAgentsMd(stats, adapter) {
782
- const lines = [
783
- '# Rune — Project Configuration',
784
- '',
785
- '## Overview',
786
- '',
787
- 'Rune is an interconnected skill ecosystem for AI coding assistants.',
788
- `${stats.skillCount} core skills | 5-layer mesh architecture | ${stats.crossRefsResolved} connections | Multi-platform.`,
789
- 'Philosophy: "Less skills. Deeper connections."',
790
- '',
791
- `Platform: ${adapter.name}`,
792
- '',
793
- '## Skills',
794
- '',
795
- `**${stats.skillCount} core skills** + **${stats.packCount} extension packs**`,
796
- '',
797
- '## Usage',
798
- '',
799
- 'Reference skills using the `Skill` tool or delegate to subagents using the `Agent` tool.',
800
- '',
801
- '## Skills Directory',
802
- '',
803
- `Skills are located in: ${adapter.outputDir}/`,
804
- '',
805
- '---',
806
- '> Rune Skill Mesh — https://github.com/rune-kit/rune',
807
- '',
808
- ];
809
-
810
- return lines.join('\n');
811
- }
812
-
813
810
  /**
814
811
  * Intent keyword patterns for each skill — extracted from description + Triggers section
815
812
  * Maps common user intent words to the skill that handles them
@@ -69,3 +69,94 @@ if (fs.existsSync(runeDir)) {
69
69
  } else {
70
70
  console.log('[Rune: No .rune/ directory found. Run /rune onboard to set up project context.]');
71
71
  }
72
+
73
+ // Tier detection hint (v2.17.1+) — Pro/Business plugins live in private repos
74
+ // and aren't auto-loaded like the Free plugin. If detected at sibling / env /
75
+ // well-known path AND tier hooks aren't already wired in settings.json, nudge
76
+ // user toward `rune setup`. Self-suppressing — once wired, the check fails and
77
+ // the hint stops firing.
78
+ detectTierHint();
79
+
80
+ function detectTierHint() {
81
+ const envVars = { pro: 'RUNE_PRO_ROOT', business: 'RUNE_BUSINESS_ROOT' };
82
+ const wellKnown = {
83
+ pro: [
84
+ 'D:/Project/Rune/Pro',
85
+ path.join(os.homedir(), 'rune-pro'),
86
+ path.join(os.homedir(), 'Project', 'Rune', 'Pro'),
87
+ ],
88
+ business: [
89
+ 'D:/Project/Rune/Business',
90
+ path.join(os.homedir(), 'rune-business'),
91
+ path.join(os.homedir(), 'Project', 'Rune', 'Business'),
92
+ ],
93
+ };
94
+
95
+ const detected = [];
96
+ for (const tier of ['pro', 'business']) {
97
+ let manifest = null;
98
+ let source = null;
99
+
100
+ const fromEnv = process.env[envVars[tier]];
101
+ if (fromEnv) {
102
+ const m = path.join(fromEnv, 'hooks', 'manifest.json');
103
+ if (fs.existsSync(m)) {
104
+ manifest = m;
105
+ source = `$${envVars[tier]}`;
106
+ }
107
+ }
108
+ if (!manifest) {
109
+ const m = path.join(cwd, '..', tier === 'pro' ? 'Pro' : 'Business', 'hooks', 'manifest.json');
110
+ if (fs.existsSync(m)) {
111
+ manifest = m;
112
+ source = 'sibling';
113
+ }
114
+ }
115
+ if (!manifest) {
116
+ for (const root of wellKnown[tier]) {
117
+ const m = path.join(root, 'hooks', 'manifest.json');
118
+ if (fs.existsSync(m)) {
119
+ manifest = m;
120
+ source = 'well-known';
121
+ break;
122
+ }
123
+ }
124
+ }
125
+
126
+ if (manifest) {
127
+ detected.push({ tier, source, version: readManifestVersion(manifest) });
128
+ }
129
+ }
130
+
131
+ if (detected.length === 0) return;
132
+
133
+ // Suppress hint if any tier hook already wired (project-local OR global)
134
+ const tierEnvRe = /\$\{RUNE_[A-Z][A-Z0-9_]*_ROOT\}/;
135
+ const settingsPaths = [path.join(cwd, '.claude', 'settings.json'), path.join(os.homedir(), '.claude', 'settings.json')];
136
+ for (const settingsPath of settingsPaths) {
137
+ if (!fs.existsSync(settingsPath)) continue;
138
+ try {
139
+ const content = fs.readFileSync(settingsPath, 'utf-8');
140
+ if (tierEnvRe.test(content)) return;
141
+ } catch {
142
+ // ignore unreadable settings.json — fall through to print hint
143
+ }
144
+ }
145
+
146
+ console.log('\n=== Rune Tier Hint ===');
147
+ for (const { tier, source, version } of detected) {
148
+ const cap = tier.charAt(0).toUpperCase() + tier.slice(1);
149
+ console.log(`${cap} detected: ${source} (v${version})`);
150
+ }
151
+ const tierFlag = detected.map((d) => d.tier).join(',');
152
+ console.log(`Wire it: \`npx @rune-kit/rune setup --global --tier ${tierFlag}\``);
153
+ console.log('(adds tier-specific hooks: autopilot circuit-breaker, context-sense, statusline)');
154
+ }
155
+
156
+ function readManifestVersion(manifestPath) {
157
+ try {
158
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf-8')).version || 'unknown';
159
+ } catch {
160
+ return 'unknown';
161
+ }
162
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rune-kit/rune",
3
- "version": "2.17.1",
3
+ "version": "2.18.1",
4
4
  "description": "64-skill mesh for AI coding assistants — runtime auto-discipline via native hooks (Claude/Cursor/Windsurf/Antigravity), 5-layer architecture, 215+ connections, multi-platform compiler. v2.17 adds quarantine L3 for prompt-injection advisory on untrusted external content.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: asset-creator
3
- description: "Creates code-based visual assets — SVG icons, OG image HTML templates, social banners, and icon sets. Outputs files with usage instructions."
3
+ description: "Creates code-based visual assets — SVG icons, OG image HTML templates, social banners, and icon sets. Use when generating visuals as code (vector/HTML), NOT raster images — for raster generation see @rune-pro/media. Outputs files with usage instructions."
4
4
  metadata:
5
5
  author: runedev
6
6
  version: "0.2.0"
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: audit
3
- description: "Comprehensive project audit — security, dependencies, code quality, architecture, performance, infra, docs, and mesh analytics. Delegates to specialist skills and generates an 8-dimension health score."
3
+ description: "Comprehensive project audit — security, dependencies, code quality, architecture, performance, infra, docs, and mesh analytics. Use when needing a full 8-dimension health-score snapshot before a major release, M&A diligence, or quarterly review. Delegates to specialist skills (sentinel, dependency-doctor, perf, autopsy, etc.)."
4
4
  metadata:
5
5
  author: runedev
6
- version: "0.4.0"
6
+ version: "0.5.0"
7
7
  layer: L2
8
8
  model: sonnet
9
9
  group: quality
@@ -130,6 +130,24 @@ grep -rn "\.unwrap()" src/ --include="*.rs"
130
130
 
131
131
  Merge autopsy report + supplementary findings.
132
132
 
133
+ **3.5 Zombie Code Detection**
134
+
135
+ Identify code that is effectively dead but hasn't been formally removed. Zombie code increases surface area, confuses contributors, and accumulates security debt.
136
+
137
+ | Signal | Detection Method | Severity |
138
+ |--------|-----------------|----------|
139
+ | No commits in 6+ months | `git log --since="6 months ago" -- <file>` returns empty | MEDIUM |
140
+ | No test coverage | File not imported by any test file (Grep for filename in `**/*.test.*` / `**/*.spec.*`) | MEDIUM |
141
+ | No owner | File not in CODEOWNERS, no author active in last 6 months | LOW |
142
+ | Failing tests referencing the module | Test suite has skipped/failing tests for this module | HIGH |
143
+ | Unpatched CVEs in dependencies only this module uses | Cross-reference dependency-doctor CVE list with per-file imports | HIGH |
144
+ | Orphaned docs | README/docs reference files or APIs that no longer exist | LOW |
145
+
146
+ **Zombie Code Verdict:**
147
+ - 3+ signals on same file/module → flag as **ZOMBIE** in audit report
148
+ - Recommend: archive (move to `_deprecated/`), delete, or assign owner
149
+ - Do NOT auto-delete — present zombie list to user for decision
150
+
133
151
  ---
134
152
 
135
153
  ### Phase 4: Architecture Audit