prs inspect --layers Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add __layerTrace metadata recording during skill extension and a prs inspect <skill> CLI command to visualize per-property provenance and layer breakdown.
Architecture: mergeSkillValue() in extensions.ts records a LayerTraceEntry for each property merge, stored as __layerTrace on the skill object (in SKILL_PRESERVE_PROPERTIES). A new prs inspect CLI command resolves the entry file, finds the target skill, reads trace metadata, and formats output (property view, layer view, or JSON).
Tech Stack: TypeScript, Commander.js, Vitest, chalk
Spec: docs/superpowers/specs/2026-04-03-prs-inspect-layers-design.md
File Structure¶
| File | Responsibility |
|---|---|
packages/resolver/src/extensions.ts | Record __layerTrace entries in mergeSkillValue, add to SKILL_PRESERVE_PROPERTIES |
packages/resolver/src/__tests__/skill-references.spec.ts | Trace recording unit tests |
packages/cli/src/commands/inspect.ts | New prs inspect command implementation |
packages/cli/src/cli.ts | Register inspect command |
packages/cli/src/types.ts | InspectOptions interface |
Task 1: Record __layerTrace in mergeSkillValue¶
Files: - Modify: packages/resolver/src/extensions.ts:36,456-500 - Test: packages/resolver/src/__tests__/skill-references.spec.ts
- Step 1: Write failing tests for trace recording
Add to packages/resolver/src/__tests__/skill-references.spec.ts inside the describe('skill-aware @extend semantics', ...) block:
describe('__layerTrace recording', () => {
it('should record trace entry when replacing a property via @extend', () => {
const ast = createProgram({
blocks: [
createBlock(
'skills',
createObjectContent({
expert: createObjectContent({
description: 'Base expert',
content: createTextContent('Base instructions'),
}) as unknown as Value,
})
),
],
extends: [
createExtendBlock(
'skills.expert',
createObjectContent({
description: 'Overridden description',
})
),
],
});
const result = applyExtends(ast);
const skills = result.blocks[0]?.content as ObjectContent;
const expert = skills.properties['expert'] as Record<string, unknown>;
const trace = expert['__layerTrace'] as Array<Record<string, string>>;
expect(trace).toBeDefined();
expect(trace).toHaveLength(1);
expect(trace[0]!.property).toBe('description');
expect(trace[0]!.strategy).toBe('replace');
expect(trace[0]!.action).toBe('replaced');
expect(trace[0]!.source).toBeDefined();
});
it('should record trace entry when appending references via @extend', () => {
const ast = createProgram({
blocks: [
createBlock(
'skills',
createObjectContent({
expert: createObjectContent({
description: 'Base expert',
references: createArrayContent(['base.md']) as unknown as Value,
}) as unknown as Value,
})
),
],
extends: [
createExtendBlock(
'skills.expert',
createObjectContent({
references: createArrayContent(['overlay.md']) as unknown as Value,
})
),
],
});
const result = applyExtends(ast);
const skills = result.blocks[0]?.content as ObjectContent;
const expert = skills.properties['expert'] as Record<string, unknown>;
const trace = expert['__layerTrace'] as Array<Record<string, string>>;
expect(trace).toBeDefined();
expect(trace).toHaveLength(1);
expect(trace[0]!.property).toBe('references');
expect(trace[0]!.strategy).toBe('append');
expect(trace[0]!.action).toBe('appended');
});
it('should record trace entry for merge-strategy properties', () => {
const ast = createProgram({
blocks: [
createBlock(
'skills',
createObjectContent({
expert: createObjectContent({
description: 'Base',
params: createObjectContent({ name: 'string' }) as unknown as Value,
}) as unknown as Value,
})
),
],
extends: [
createExtendBlock(
'skills.expert',
createObjectContent({
params: createObjectContent({ age: 'number' }) as unknown as Value,
})
),
],
});
const result = applyExtends(ast);
const skills = result.blocks[0]?.content as ObjectContent;
const expert = skills.properties['expert'] as Record<string, unknown>;
const trace = expert['__layerTrace'] as Array<Record<string, string>>;
expect(trace).toBeDefined();
expect(trace).toHaveLength(1);
expect(trace[0]!.property).toBe('params');
expect(trace[0]!.strategy).toBe('merge');
expect(trace[0]!.action).toBe('merged');
});
it('should record source file from ext.loc.file', () => {
const loc = { file: '/project/overlay.prs', line: 1, column: 1 };
const ast = createProgram({
blocks: [
createBlock(
'skills',
createObjectContent({
expert: createObjectContent({
description: 'Base',
}) as unknown as Value,
})
),
],
extends: [
{
type: 'ExtendBlock' as const,
targetPath: 'skills.expert',
content: {
type: 'ObjectContent' as const,
properties: { description: 'New' },
loc,
},
loc,
},
],
});
const result = applyExtends(ast);
const skills = result.blocks[0]?.content as ObjectContent;
const expert = skills.properties['expert'] as Record<string, unknown>;
const trace = expert['__layerTrace'] as Array<Record<string, string>>;
expect(trace[0]!.source).toBe('/project/overlay.prs');
});
it('should accumulate trace entries across multiple extends', () => {
const ast = createProgram({
blocks: [
createBlock(
'skills',
createObjectContent({
expert: createObjectContent({
description: 'Base',
references: createArrayContent(['base.md']) as unknown as Value,
}) as unknown as Value,
})
),
],
extends: [
createExtendBlock(
'skills.expert',
createObjectContent({
description: 'Layer 2',
})
),
createExtendBlock(
'skills.expert',
createObjectContent({
references: createArrayContent(['layer3.md']) as unknown as Value,
})
),
],
});
const result = applyExtends(ast);
const skills = result.blocks[0]?.content as ObjectContent;
const expert = skills.properties['expert'] as Record<string, unknown>;
const trace = expert['__layerTrace'] as Array<Record<string, string>>;
expect(trace).toHaveLength(2);
expect(trace[0]!.property).toBe('description');
expect(trace[1]!.property).toBe('references');
});
it('should not have __layerTrace when no extends are applied', () => {
const ast = createProgram({
blocks: [
createBlock(
'skills',
createObjectContent({
expert: createObjectContent({
description: 'Base only',
}) as unknown as Value,
})
),
],
});
const result = applyExtends(ast);
const skills = result.blocks[0]?.content as ObjectContent;
const expert = skills.properties['expert'] as Record<string, unknown>;
expect(expert['__layerTrace']).toBeUndefined();
});
it('should prevent __layerTrace from being overwritten by @extend', () => {
const ast = createProgram({
blocks: [
createBlock(
'skills',
createObjectContent({
expert: createObjectContent({
description: 'Base',
}) as unknown as Value,
})
),
],
extends: [
createExtendBlock(
'skills.expert',
createObjectContent({
description: 'First extend',
})
),
createExtendBlock(
'skills.expert',
createObjectContent({
__layerTrace: 'attempt to overwrite' as unknown as Value,
})
),
],
});
const result = applyExtends(ast);
const skills = result.blocks[0]?.content as ObjectContent;
const expert = skills.properties['expert'] as Record<string, unknown>;
const trace = expert['__layerTrace'] as Array<Record<string, string>>;
// Should still be the array from the first extend, not the string from the second
expect(Array.isArray(trace)).toBe(true);
expect(trace).toHaveLength(1);
});
});
- Step 2: Run tests to verify they fail
Run: pnpm nx test resolver -- --testPathPattern=skill-references -t "__layerTrace" Expected: FAIL — no trace recording exists
- Step 3: Add
__layerTracetoSKILL_PRESERVE_PROPERTIES
In packages/resolver/src/extensions.ts, update line 36:
const SKILL_PRESERVE_PROPERTIES = new Set(['composedFrom', '__composedFrom', 'sealed', '__layerTrace']);
- Step 4: Add trace recording to
mergeSkillValue
In packages/resolver/src/extensions.ts, modify mergeSkillValue function. Add a trace array at the top of the function (after the base/flatten setup, around line 455), and record entries in each strategy branch. At the end, store the trace on the base object.
Add after line 454 (the flatten loop closing brace):
const trace: Array<{ property: string; source: string; strategy: string; action: string }> = [];
const sourceFile = ext.loc?.file ?? '<unknown>';
Then modify each strategy branch to record a trace entry after the merge. In the replace branch (after line 474):
In the append branch — after the if/else if/else block that handles processAppendWithNegations (after line 488):
In the merge branch — after the if/else if/else block (after line 500):
In the fallback branch — after the if/else block (around line 515):
Then, before return base as unknown as Value; at the end of the function, add:
// Store accumulated layer trace entries
if (trace.length > 0) {
const existingTrace = Array.isArray(base['__layerTrace']) ? (base['__layerTrace'] as unknown[]) : [];
base['__layerTrace'] = [...existingTrace, ...trace] as unknown as Value;
}
- Step 5: Run tests to verify they pass
Run: pnpm nx test resolver -- --testPathPattern=skill-references -t "__layerTrace" Expected: ALL PASS
- Step 6: Run full resolver test suite
Run: pnpm nx test resolver Expected: ALL PASS
- Step 7: Commit
git add packages/resolver/src/extensions.ts packages/resolver/src/__tests__/skill-references.spec.ts
git commit -m "feat(resolver): record __layerTrace metadata during skill extension merges (#203)"
Task 2: Add InspectOptions type and register CLI command¶
Files: - Modify: packages/cli/src/types.ts - Modify: packages/cli/src/cli.ts - Create: packages/cli/src/commands/inspect.ts
- Step 1: Add
InspectOptionsto types.ts
Add at the end of packages/cli/src/types.ts (before the closing of the file):
/**
* Options for the inspect command.
*/
export interface InspectOptions {
/** Show layer-level view instead of property-level */
layers?: boolean;
/** Output format */
format?: 'text' | 'json';
/** Path to custom config file */
config?: string;
/** Working directory (project root) */
cwd?: string;
}
- Step 2: Create the inspect command file
Create packages/cli/src/commands/inspect.ts:
import { existsSync } from 'fs';
import { resolve, basename } from 'path';
import type { ObjectContent, TextContent, Value } from '@promptscript/core';
import { Resolver } from '@promptscript/resolver';
import type { InspectOptions } from '../types.js';
import { loadConfig } from '../config/loader.js';
import { resolveRegistryPath } from '../utils/registry-resolver.js';
import { ConsoleOutput, createSpinner } from '../output/console.js';
interface LayerTraceEntry {
property: string;
source: string;
strategy: string;
action: string;
}
/**
* `prs inspect <skill-name>` — show per-property provenance for a skill.
*/
export async function inspectCommand(
skillName: string,
options: InspectOptions
): Promise<void> {
const isJson = options.format === 'json';
const spinner = isJson ? createSpinner('').stop() : createSpinner('Resolving...').start();
try {
const config = await loadConfig(options.config);
const registry = await resolveRegistryPath(config);
const resolver = new Resolver({
registryPath: registry.path,
localPath: './.promptscript',
registries: config.registries,
});
const entryPath = resolve('./.promptscript/project.prs');
if (!existsSync(entryPath)) {
spinner.stop();
ConsoleOutput.error(`Entry file not found: ${entryPath}`);
process.exitCode = 1;
return;
}
const result = await resolver.resolve(entryPath);
if (!result.ast) {
spinner.stop();
ConsoleOutput.error('Resolution failed');
for (const err of result.errors) {
ConsoleOutput.error(` ${err.message}`);
}
process.exitCode = 1;
return;
}
spinner.stop();
// Find @skills block
const skillsBlock = result.ast.blocks.find((b) => b.name === 'skills');
if (!skillsBlock || skillsBlock.content.type !== 'ObjectContent') {
ConsoleOutput.error('No @skills block in resolved output');
process.exitCode = 1;
return;
}
const content = skillsBlock.content as ObjectContent;
const availableSkills = Object.keys(content.properties);
// Find the target skill
const skillValue = content.properties[skillName];
if (!skillValue || typeof skillValue !== 'object' || skillValue === null || Array.isArray(skillValue)) {
ConsoleOutput.error(
`Skill '${skillName}' not found. Available skills: ${availableSkills.join(', ')}`
);
process.exitCode = 1;
return;
}
const skill = skillValue as Record<string, unknown>;
const trace = (Array.isArray(skill['__layerTrace']) ? skill['__layerTrace'] : []) as LayerTraceEntry[];
const sealed = Array.isArray(skill['sealed']) ? (skill['sealed'] as string[]) : skill['sealed'] === true ? ['(all replace)'] : [];
const composedFrom = Array.isArray(skill['__composedFrom']) ? skill['__composedFrom'] : null;
const baseSource = skillsBlock.loc?.file ?? '<unknown>';
if (isJson) {
outputJson(skillName, skill, trace, sealed, composedFrom, baseSource);
} else if (options.layers) {
outputLayers(skillName, skill, trace, sealed, baseSource);
} else {
outputProperties(skillName, skill, trace, sealed, baseSource);
}
} catch (error) {
spinner.stop();
ConsoleOutput.error(`Inspect failed: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
}
}
function summarizeValue(val: unknown): string {
if (val === undefined || val === null) return '(empty)';
if (typeof val === 'string') {
return val.length > 40 ? `"${val.slice(0, 37)}..."` : `"${val}"`;
}
if (typeof val === 'boolean') return String(val);
if (Array.isArray(val)) return `${val.length} items`;
if (typeof val === 'object' && val !== null) {
const obj = val as Record<string, unknown>;
if (obj['type'] === 'TextContent') {
const text = (obj as TextContent).value;
const lines = text.split('\n').length;
return `(${lines} lines)`;
}
return `{${Object.keys(obj).length} keys}`;
}
return String(val);
}
function shortPath(source: string): string {
return basename(source);
}
function getPropertySource(
propName: string,
trace: LayerTraceEntry[],
baseSource: string
): { source: string; strategy: string } {
// Find the last trace entry for this property (most recent layer wins)
for (let i = trace.length - 1; i >= 0; i--) {
if (trace[i]!.property === propName) {
return { source: trace[i]!.source, strategy: trace[i]!.strategy };
}
}
return { source: baseSource, strategy: 'base' };
}
const INTERNAL_KEYS = new Set(['type', 'loc', 'properties', '__layerTrace', '__composedFrom', 'composedFrom']);
function outputProperties(
skillName: string,
skill: Record<string, unknown>,
trace: LayerTraceEntry[],
sealed: string[],
baseSource: string
): void {
console.log(`\nSkill: ${skillName}\n`);
const sealedSet = new Set(sealed);
for (const [key, val] of Object.entries(skill)) {
if (INTERNAL_KEYS.has(key)) continue;
const { source, strategy } = getPropertySource(key, trace, baseSource);
const isSealed = sealedSet.has(key) || (skill['sealed'] === true && strategy === 'base');
const tag = isSealed ? '[sealed]' : strategy !== 'base' ? `[${strategy}]` : '';
const summary = summarizeValue(val);
console.log(` ${key.padEnd(18)} ${summary.padEnd(30)} ${tag.padEnd(10)} ← ${shortPath(source)}`);
}
console.log('');
}
function outputLayers(
skillName: string,
skill: Record<string, unknown>,
trace: LayerTraceEntry[],
sealed: string[],
baseSource: string
): void {
// Group trace entries by source
const layers = new Map<string, LayerTraceEntry[]>();
for (const entry of trace) {
const existing = layers.get(entry.source) ?? [];
existing.push(entry);
layers.set(entry.source, existing);
}
const totalLayers = 1 + layers.size;
console.log(`\nSkill: ${skillName} (${totalLayers} layer${totalLayers > 1 ? 's' : ''})\n`);
// Layer 1: base
console.log(`Layer 1 — ${shortPath(baseSource)} (base)`);
for (const [key, val] of Object.entries(skill)) {
if (INTERNAL_KEYS.has(key)) continue;
// Show property if it's from the base (no trace entry for it, or there IS a trace but base still set it)
const hasTraceEntry = trace.some((t) => t.property === key);
if (!hasTraceEntry) {
console.log(` + ${key}: ${summarizeValue(val)}`);
}
}
if (sealed.length > 0) {
console.log(` + sealed: [${sealed.join(', ')}]`);
}
console.log('');
// Extension layers
let layerNum = 2;
for (const [source, entries] of layers) {
console.log(`Layer ${layerNum} — ${shortPath(source)} (@extend)`);
for (const entry of entries) {
const symbol = entry.action === 'replaced' ? '~' : entry.action === 'negated' ? '-' : '+';
console.log(` ${symbol} ${entry.property}: ${entry.action}`);
}
console.log('');
layerNum++;
}
}
function outputJson(
skillName: string,
skill: Record<string, unknown>,
trace: LayerTraceEntry[],
sealed: string[],
composedFrom: unknown,
baseSource: string
): void {
const layers = new Map<string, LayerTraceEntry[]>();
for (const entry of trace) {
const existing = layers.get(entry.source) ?? [];
existing.push(entry);
layers.set(entry.source, existing);
}
const properties: Record<string, unknown> = {};
for (const [key, val] of Object.entries(skill)) {
if (INTERNAL_KEYS.has(key)) continue;
const { source, strategy } = getPropertySource(key, trace, baseSource);
properties[key] = {
value: summarizeValue(val),
source: shortPath(source),
strategy,
sealed: sealed.includes(key),
};
}
const output = {
skill: skillName,
baseSource: shortPath(baseSource),
layers: Array.from(layers.entries()).map(([source, changes]) => ({
source: shortPath(source),
type: 'extend',
changes,
})),
properties,
sealed,
composedFrom: composedFrom ?? null,
};
console.log(JSON.stringify(output, null, 2));
}
- Step 3: Register the command in cli.ts
In packages/cli/src/cli.ts, add the import near the other command imports:
Then register the command (after the validate command registration, around line 122):
program
.command('inspect <skill-name>')
.description('Inspect skill composition layers and property provenance')
.option('--layers', 'Show layer-level breakdown')
.option('--format <format>', 'Output format (text, json)', 'text')
.option('-c, --config <path>', 'Path to custom config file')
.option('--cwd <dir>', 'Working directory (project root)')
.action((skillName, opts) => inspectCommand(skillName, opts));
- Step 4: Run typecheck to verify compilation
Run: pnpm nx typecheck cli Expected: PASS (or known pre-existing warnings only)
- Step 5: Commit
git add packages/cli/src/commands/inspect.ts packages/cli/src/cli.ts packages/cli/src/types.ts
git commit -m "feat(cli): add prs inspect command for skill layer debugging (#203)"
Task 3: Run full verification pipeline¶
Files: None (verification only)
- Step 1: Format
Run: pnpm run format
- Step 2: Lint
Run: pnpm run lint Expected: PASS
- Step 3: Typecheck
Run: pnpm run typecheck Expected: PASS
- Step 4: Test all packages
Run: pnpm run test Expected: ALL PASS
- Step 5: Validate PRS files
Run: pnpm prs validate --strict Expected: PASS
- Step 6: Schema, skill, grammar checks
Run: pnpm schema:check && pnpm skill:check && pnpm grammar:check Expected: ALL PASS
- Step 7: Fix any issues and commit
If any step fails, fix and commit:
Task 4: Update documentation and ROADMAP¶
Files: - Modify: docs/guides/skill-overlays.md - Modify: ROADMAP.md
- Step 1: Add inspect section to skill-overlays guide
In docs/guides/skill-overlays.md, before the ## Validation Rules section, add:
## Debugging with `prs inspect`
Use `prs inspect` to see how layers compose a skill:
```bash
# Property-level view (default) — shows each property with source
prs inspect code-review
# Layer-level view — groups changes by source file
prs inspect code-review --layers
# JSON output for tooling
prs inspect code-review --format json
The property view shows each property's current value, merge strategy, and which file contributed it. The layer view shows what each @extend changed.
prs inspect --layers — Show per-property merge layers for a compiled skill (base vs overlay) - [x] prs inspect --layers — Show per-property merge layers for a compiled skill (base vs overlay) - [ ] **Step 3: Update docs snapshots if needed**
Run: `node --import @swc-node/register/esm-register scripts/validate-docs-examples.mts --update-snapshots`
- [ ] **Step 4: Format, commit**
```bash
pnpm run format
git add docs/guides/skill-overlays.md ROADMAP.md docs/__snapshots__/
git commit -m "docs: document prs inspect command and update ROADMAP (#203)"