diff --git a/openspec/changes/support-non-asset-files/design.md b/openspec/changes/support-non-asset-files/design.md index b36128e0..ce64bc0a 100644 --- a/openspec/changes/support-non-asset-files/design.md +++ b/openspec/changes/support-non-asset-files/design.md @@ -167,7 +167,7 @@ Before any materialization, install SHALL require exact agreement among: Any disagreement SHALL return structured failure data containing the facet, asset, canonical path, expected integrity, and actual integrity when available. Frozen mode SHALL fail without rewriting. Normal resolution MAY write a new lock entry only after all checks against the newly resolved artifact succeed. -Drift checking SHALL operate per locked file. Verbatim companion files are hashed directly from disk. For primary files whose adapter representation differs from archive bytes, the adapter `readAsset` contract SHALL return canonical logical content so the engine can compare the corresponding locked canonical integrity without encoding adapter-specific bytes in `facets.lock`. Because archived primary files contain no YAML front matter (the manifest is the metadata source of truth), the canonical logical content of an undrifted primary equals its archive bytes — adapter-added storage encoding is stripped by `readAsset`, so the locked hash is reproducible offline. Reports SHALL identify the exact locked path that drifted. +Drift checking SHALL operate per locked file. Verbatim companion files are hashed directly from disk. For primary files whose adapter representation differs from archive bytes, the adapter `readAsset` contract SHALL return canonical logical content so the engine can compare the corresponding locked canonical integrity without encoding adapter-specific bytes in `facets.lock`. Author-supplied front matter in a primary asset is archived verbatim, but the manifest remains the metadata source of truth: materialization merges manifest-owned metadata over any author front matter (manifest wins), and `readAsset` projects the installed primary back to that canonical logical form by stripping adapter-added storage encoding, so the locked hash is reproducible offline. Reports SHALL identify the exact locked path that drifted. The machine-local receipt SHALL mirror the successfully committed lockfile asset/file ownership set so offline removal and rollback remain exact even after a pulled lockfile drops an entry. Receipt-driven removal supplies that validated ownership set to the adapter delete request (D8), so offline cleanup after a pulled lockfile drops an entry deletes exactly the recorded owned files. The receipt remains adapter-agnostic and stores no adapter-encoded hashes. Receipt and lockfile changes SHALL commit in the same install transaction as materialization; rollback restores all three. Receipts remain untrusted input: identity, path containment, and file-integrity record validation MUST precede deletion, and unowned paths MUST never be deleted. The receipt schema version SHALL become `0.2`; legacy receipt version `1` MAY be refined to primary-only file sets because the legacy system could not install companions. diff --git a/openspec/changes/support-non-asset-files/specs/authoring__facets/spec.md b/openspec/changes/support-non-asset-files/specs/authoring__facets/spec.md index 4baff809..834243cb 100644 --- a/openspec/changes/support-non-asset-files/specs/authoring__facets/spec.md +++ b/openspec/changes/support-non-asset-files/specs/authoring__facets/spec.md @@ -338,7 +338,7 @@ All fields SHALL remain editable. Exit confirmation SHALL prevent accidental los ### Requirement: Authors can build a facet locally for validation and inspection -The system SHALL compile a facet project into a deterministic `.facet` archive after validating the manifest and every source input. It SHALL verify that primary asset files exist, are non-empty, contain no YAML front matter, and resolve from their conventional paths. It SHALL verify that every declared supplementary file exists as a regular file at a safe, collision-free path. It SHALL archive the embedded manifest, every primary asset, and every declared supplementary file, and SHALL record a content hash for every entry. Validation SHALL finish before previous `dist/` output is removed. The build SHALL NOT modify source files and SHALL behave identically in interactive and non-interactive environments. +The system SHALL compile a facet project into a deterministic `.facet` archive after validating the manifest and every source input. It SHALL verify that primary asset files exist, are non-empty, and resolve from their conventional paths. Author-supplied YAML front matter in a primary asset SHALL be permitted and preserved verbatim in the archive; the manifest remains the source of truth for asset metadata, and front matter is reconciled with the manifest at install time rather than rejected at build. It SHALL verify that every declared supplementary file exists as a regular file at a safe, collision-free path. It SHALL archive the embedded manifest, every primary asset, and every declared supplementary file, and SHALL record a content hash for every entry. Validation SHALL finish before previous `dist/` output is removed. The build SHALL NOT modify source files and SHALL behave identically in interactive and non-interactive environments. For a scoped facet identity or other slash-containing output name, the system SHALL create required parent directories below `dist/`. On success, the system SHALL display pipeline progress, the emitted archive-format version, complete entry listing, and integrity hash, followed by a persistent summary. On failure, it SHALL identify the failed stage and structured field or path errors and SHALL suggest the editing command when appropriate. @@ -376,10 +376,11 @@ For a scoped facet identity or other slash-containing output name, the system SH - **WHEN** a declared asset's conventional primary file is missing - **THEN** the system SHALL identify the asset and expected path and write no new output -#### Scenario: Build fails on primary asset front matter +#### Scenario: Primary asset front matter is preserved in the archive -- **WHEN** a primary asset file contains YAML front matter -- **THEN** the system SHALL identify that file and require front-matter removal +- **WHEN** a primary asset file contains author-supplied YAML front matter +- **THEN** the build SHALL succeed and archive the primary bytes verbatim +- **AND** the manifest SHALL remain the source of truth for that asset's metadata #### Scenario: Build fails on empty primary asset @@ -597,64 +598,25 @@ The system SHALL detect missing conventional asset files and missing declared su - **WHEN** top-level `files` declares missing `LICENSE` - **THEN** edit SHALL offer Scaffold at `LICENSE` or Remove Declaration -### Requirement: Edit parses front matter for defaults and strips it +### Requirement: Edit confirms asset identity and preserves primary content -The system SHALL parse YAML front matter only from primary asset files encountered during edit. A parsed `name` or `description` SHALL pre-fill the corresponding asset field; otherwise the conventional filename or skill-directory name SHALL provide the default. The author SHALL confirm every asset's name and description. The final author-confirmed asset name SHALL determine that asset's conventional path on disk. Extra fields SHALL be shown and may be converted to platform configuration or dropped. Converted fields SHALL be placed under a selected known platform or a valid custom kebab-case platform name. Confirmed primary content SHALL be written without front matter. Supplementary files, including README, SHALL NOT be parsed or stripped and SHALL retain exact bytes unless explicitly edited. +The system SHALL require the author to confirm every asset's name and description during edit, defaulting the name to the conventional filename or skill-directory name. The final author-confirmed asset name SHALL determine that asset's conventional path on disk, and confirmed metadata SHALL be written to the manifest, which remains the source of truth. Edit SHALL NOT strip author-supplied front matter from a primary asset file; primary content SHALL be preserved verbatim unless the author explicitly edits it, and manifest metadata is reconciled with any front matter at install time. Supplementary files, including README, SHALL NOT be parsed and SHALL retain exact bytes unless explicitly edited. -#### Scenario: Front matter name pre-fills asset name +#### Scenario: Missing metadata uses conventional name -- **WHEN** `skills/skill/SKILL.md` contains `name: typescript-best-practices` -- **THEN** edit SHALL pre-fill that name and require confirmation +- **WHEN** `skills/code-review/SKILL.md` is reconciled during edit +- **THEN** edit SHALL default the name to `code-review` and require confirmation -#### Scenario: Missing front matter name uses conventional name +#### Scenario: Confirmed metadata is written to the manifest -- **WHEN** `skills/code-review/SKILL.md` has no front-matter name -- **THEN** edit SHALL pre-fill `code-review` +- **WHEN** the author confirms an asset's name and description +- **THEN** Apply SHALL persist that metadata in the manifest +- **AND** the confirmed name SHALL determine the conventional file path -#### Scenario: Front matter description pre-fills description +#### Scenario: Primary content is preserved verbatim -- **WHEN** a primary file contains a front-matter description -- **THEN** edit SHALL pre-fill that value and require confirmation - -#### Scenario: Extra front matter fields are surfaced - -- **WHEN** a primary file contains fields beyond name and description -- **THEN** edit SHALL offer conversion to platform configuration or removal - -#### Scenario: Extra fields convert to known platform configuration - -- **WHEN** the author selects a known platform for extra fields -- **THEN** Apply SHALL place those fields under that platform key - -#### Scenario: Extra fields convert to custom platform configuration - -- **WHEN** the author selects valid custom platform `cursor` -- **THEN** Apply SHALL place those fields under `cursor` - -#### Scenario: Invalid custom platform is rejected - -- **WHEN** the author enters `My Platform` or `CURSOR` -- **THEN** edit SHALL reject the custom platform name - -#### Scenario: Extra fields may be dropped - -- **WHEN** the author chooses Drop -- **THEN** Apply SHALL discard the extra fields - -#### Scenario: Primary front matter is stripped - -- **WHEN** the author confirms a primary asset that contained front matter -- **THEN** the persisted primary file SHALL contain only its markdown body - -#### Scenario: Malformed front matter is treated as absent - -- **WHEN** primary content resembles front matter but cannot be parsed -- **THEN** edit SHALL process it as content without parsed defaults - -#### Scenario: Existing declared primary with front matter is reconciled - -- **WHEN** a declared primary asset contains front matter -- **THEN** edit SHALL show its values and strip them on confirmation +- **WHEN** an existing primary asset file contains author-supplied front matter and the author does not edit its body +- **THEN** Apply SHALL preserve the primary file bytes unchanged #### Scenario: Supplementary front matter-like bytes are preserved @@ -683,7 +645,7 @@ All identity, privacy, asset, README, supplementary-file, and manifest changes S #### Scenario: Confirmation shows all deltas -- **WHEN** a session includes identity, privacy, asset, README, companion, and front-matter changes +- **WHEN** a session includes identity, privacy, asset, README, and companion changes - **THEN** confirmation SHALL list each change and exact affected path #### Scenario: README and companion changes wait for Apply @@ -692,24 +654,25 @@ All identity, privacy, asset, README, supplementary-file, and manifest changes S - **THEN** confirmation SHALL list both exact paths - **AND** neither disk nor manifest SHALL change before Apply -### Requirement: Content files contain no front matter +### Requirement: The manifest is the source of truth for primary asset metadata -The manifest SHALL remain the single source of truth for primary asset metadata. Primary skill, agent, and command files on disk and in archives SHALL contain pure markdown without YAML front matter. Create, edit, and build SHALL enforce this rule for primary asset files. Supplementary files SHALL be exempt because they are opaque bytes and may contain any content, including front-matter-like text. +The manifest SHALL remain the single source of truth for primary skill, agent, and command metadata. Author-supplied YAML front matter in a primary asset file SHALL be permitted: build SHALL preserve it verbatim in the archive, and materialization SHALL reconcile it with the manifest by merging manifest-owned metadata on top of any author front matter (the manifest wins on conflicting keys) before writing the asset to a selected adapter. Scaffolded starter files SHALL contain pure markdown with no front matter. Supplementary files SHALL be opaque bytes and may contain any content, including front-matter-like text, without reconciliation. #### Scenario: Scaffolded primary files have no front matter - **WHEN** create or edit scaffolds a primary asset file - **THEN** the file SHALL contain markdown without YAML front matter -#### Scenario: Build rejects primary front matter +#### Scenario: Primary front matter is preserved and reconciled at install -- **WHEN** a primary asset file contains YAML front matter -- **THEN** the build SHALL fail and identify the file +- **WHEN** a primary asset file contains author-supplied YAML front matter +- **THEN** the build SHALL archive those bytes verbatim +- **AND** materialization SHALL merge manifest-owned metadata on top of the author front matter, with the manifest winning on conflicting keys -#### Scenario: Archive contains clean primary files +#### Scenario: Archive preserves primary bytes - **WHEN** a facet is built -- **THEN** every primary asset entry SHALL contain markdown without YAML front matter +- **THEN** every primary asset entry SHALL contain the author's exact source bytes #### Scenario: Supplementary front matter is allowed diff --git a/openspec/changes/support-non-asset-files/tasks.md b/openspec/changes/support-non-asset-files/tasks.md index c80817ee..c93301ef 100644 --- a/openspec/changes/support-non-asset-files/tasks.md +++ b/openspec/changes/support-non-asset-files/tasks.md @@ -149,12 +149,12 @@ ## 16. Held CLI Release Gate and Final Readiness -- [ ] 16.1 Explore: Audit the completed implementation, package versions, pending Changesets, generated release notes, and release automation to define a minimal held `agent-facets` activation PR with no unintended protocol or adapter publication -- [ ] 16.2 Propose: Present the exact CLI-only pre-1.0 minor changeset, activation evidence, PR base/stack placement, and merge conditions; the user retains sole authority to merge the held release gate -- [ ] 16.3 Implement: Create and submit the tiny held CLI release-gate PR containing the `agent-facets` changeset and final release notes, without merging, publishing, or deploying it -- [ ] 16.4 Verify: Run strict OpenSpec validation, package API/build checks, and the full `bun check` suite, fixing formatter-only findings with `bun format`, then verify implementation coverage scenario-by-scenario across all seven delta specs -- [ ] 16.5 Verify: Confirm the protocol-only release from Section 5 is published and exposes strict `0.1`/`0.2` verification, tagged results, structured failures, and cross-version helpers from a clean consumer install -- [ ] 16.6 Verify: Confirm the adapter SDK and all three first-party adapters are published with `facetAdapterApiVersion: 0.1`, while existing `0.0` CLIs retain compatible `0.0` adapter resolution -- [ ] 16.7 Verify: Confirm the deployed registry pins the released protocol, accepts valid `0.1` and `0.2`, rejects malformed/unsupported archives before persistence, preserves supplementary hashes, and reads only intended primary resources -- [ ] 16.8 Verify: Build the unreleased candidate CLI, publish a representative `0.2` archive to the stage registry, and verify metadata, archive download, stored-content behavior, and legacy `0.1` retention end to end +- [x] 16.1 Explore: Audit the completed implementation, package versions, pending Changesets, generated release notes, and release automation to define a minimal held `agent-facets` activation PR with no unintended protocol or adapter publication +- [x] 16.2 Propose: Present the exact CLI-only pre-1.0 minor changeset, activation evidence, PR base/stack placement, and merge conditions; the user retains sole authority to merge the held release gate +- [x] 16.3 Implement: Create and submit the tiny held CLI release-gate PR containing the `agent-facets` changeset and final release notes, without merging, publishing, or deploying it +- [x] 16.4 Verify: Run strict OpenSpec validation, package API/build checks, and the full `bun check` suite, fixing formatter-only findings with `bun format`, then verify implementation coverage scenario-by-scenario across all seven delta specs +- [x] 16.5 Verify: Confirm the protocol-only release from Section 5 is published and exposes strict `0.1`/`0.2` verification, tagged results, structured failures, and cross-version helpers from a clean consumer install +- [ ] 16.6 Verify: Confirm the adapter SDK and all three first-party adapters are published with `facetAdapterApiVersion: 0.1`, while existing `0.0` CLIs retain compatible `0.0` adapter resolution — BLOCKED (pending adapter release cycle): adapter changeset still pending, `0.1` not yet published; source proven release-ready (`ADAPTER_API_VERSION = '0.1'` + prepack injection); all three first-party adapters currently publish `0.0` and current `0.0` CLIs resolve them +- [ ] 16.7 Verify: Confirm the deployed registry pins the released protocol, accepts valid `0.1` and `0.2`, rejects malformed/unsupported archives before persistence, preserves supplementary hashes, and reads only intended primary resources — BLOCKED (pending registry deployment) +- [ ] 16.8 Verify: Build the unreleased candidate CLI, publish a representative `0.2` archive to the stage registry, and verify metadata, archive download, stored-content behavior, and legacy `0.1` retention end to end — BLOCKED (pending stage registry access) - [ ] 16.9 Review: Present the final activation packet and evidence to the user; the held CLI release-gate PR remains unmerged until the user explicitly authorizes the Changesets version-and-publish sequence diff --git a/packages/cli/src/tui/views/__tests__/validate-asset-name.test.ts b/packages/cli/src/tui/views/__tests__/validate-asset-name.test.ts new file mode 100644 index 00000000..05db5eb3 --- /dev/null +++ b/packages/cli/src/tui/views/__tests__/validate-asset-name.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'bun:test' +import type { FormState } from '../../context/form-state-context.ts' +import { validateAssetNameInWizard } from '../validate-asset-name.ts' + +function assets(overrides: Partial = {}): FormState['assets'] { + const empty = { items: [] as string[], descriptions: {}, adding: false } + return { + skill: { ...empty }, + command: { ...empty }, + agent: { ...empty }, + ...overrides, + } +} + +describe('validateAssetNameInWizard', () => { + test('accepts a valid, unused single-segment name', () => { + expect(validateAssetNameInWizard('skill', 'code-review', assets())).toBeUndefined() + }) + + test('rejects an invalid single-segment name', () => { + const err = validateAssetNameInWizard('skill', 'Code_Review', assets()) + expect(err).toBeString() + expect(err).toContain('Name') + }) + + test('rejects a duplicate within the same type', () => { + const state = assets({ skill: { items: ['review'], descriptions: {}, adding: false } }) + expect(validateAssetNameInWizard('skill', 'review', state)).toContain('already exists') + }) + + test('allows editing an item to its own name', () => { + const state = assets({ skill: { items: ['review'], descriptions: {}, adding: false, editing: 'review' } }) + expect(validateAssetNameInWizard('skill', 'review', state)).toBeUndefined() + }) + + test('rejects a command that collides with an existing skill (shared namespace)', () => { + const state = assets({ skill: { items: ['review'], descriptions: {}, adding: false } }) + const err = validateAssetNameInWizard('command', 'review', state) + expect(err).toContain('already used by a skill') + expect(err).toContain('share one namespace') + }) + + test('rejects a skill that collides with an existing command (shared namespace)', () => { + const state = assets({ command: { items: ['review'], descriptions: {}, adding: false } }) + const err = validateAssetNameInWizard('skill', 'review', state) + expect(err).toContain('already used by a command') + }) + + test('allows an agent to share a name with a skill (separate namespace)', () => { + const state = assets({ skill: { items: ['review'], descriptions: {}, adding: false } }) + expect(validateAssetNameInWizard('agent', 'review', state)).toBeUndefined() + }) + + test('allows an agent to share a name with a command (separate namespace)', () => { + const state = assets({ command: { items: ['review'], descriptions: {}, adding: false } }) + expect(validateAssetNameInWizard('agent', 'review', state)).toBeUndefined() + }) +}) diff --git a/packages/cli/src/tui/views/create/create-view.tsx b/packages/cli/src/tui/views/create/create-view.tsx index ca705235..bee71706 100644 --- a/packages/cli/src/tui/views/create/create-view.tsx +++ b/packages/cli/src/tui/views/create/create-view.tsx @@ -1,5 +1,5 @@ import { DEFAULT_VERSION } from '@agent-facets/engine' -import { parseFacetName, validateAssetNameSegment, validateFacetName } from '@agent-facets/protocol' +import { parseFacetName, validateFacetName } from '@agent-facets/protocol' import { Box, Text } from 'ink' import { useCallback, useEffect } from 'react' import type { AssetType } from '../../../commands/create/types' @@ -10,6 +10,7 @@ import { EditableField } from '../../components/editable-field.tsx' import { useFocusOrder } from '../../context/focus-order-context.ts' import { useFormState } from '../../context/form-state-context.ts' import { WizardLayout } from '../../layouts/wizard-layout.tsx' +import { validateAssetNameInWizard } from '../validate-asset-name.ts' const ASSET_TYPES: AssetType[] = ['skill', 'command', 'agent'] @@ -183,13 +184,7 @@ export function CreateView({ defaultName={form.assets[type].items.length === 0 ? defaultAssetName : undefined} dimmed={!assetsReady} onEditDescription={onEditDescription} - validate={(v) => { - const check = validateAssetNameSegment(v) - if (!check.ok) return `Name ${check.reason}` - const editing = form.assets[type].editing - if (form.assets[type].items.some((item) => item === v && item !== editing)) return `"${v}" already exists` - return undefined - }} + validate={(v) => validateAssetNameInWizard(type, v, form.assets)} /> ))} diff --git a/packages/cli/src/tui/views/edit/edit-view.tsx b/packages/cli/src/tui/views/edit/edit-view.tsx index 1b71e763..7000cc28 100644 --- a/packages/cli/src/tui/views/edit/edit-view.tsx +++ b/packages/cli/src/tui/views/edit/edit-view.tsx @@ -1,5 +1,5 @@ import { DEFAULT_VERSION } from '@agent-facets/engine' -import { validateAssetNameSegment, validateFacetName } from '@agent-facets/protocol' +import { validateFacetName } from '@agent-facets/protocol' import { Box, Text } from 'ink' import { useCallback, useEffect } from 'react' import { AssetSection } from '../../components/asset-section.tsx' @@ -10,6 +10,7 @@ import { useFocusOrder } from '../../context/focus-order-context.ts' import type { AssetSectionKey } from '../../context/form-state-context.ts' import { useFormState } from '../../context/form-state-context.ts' import { THEME } from '../../theme.ts' +import { validateAssetNameInWizard } from '../validate-asset-name.ts' const ASSET_TYPES: AssetSectionKey[] = ['skill', 'command', 'agent'] const ASSET_LABELS: Record = { @@ -125,13 +126,7 @@ export function EditView({ section={type} label={ASSET_LABELS[type]} onEditDescription={onEditDescription} - validate={(v) => { - const check = validateAssetNameSegment(v) - if (!check.ok) return `Name ${check.reason}` - const editing = form.assets[type].editing - if (form.assets[type].items.some((item) => item === v && item !== editing)) return `"${v}" already exists` - return undefined - }} + validate={(v) => validateAssetNameInWizard(type, v, form.assets)} /> ))} diff --git a/packages/cli/src/tui/views/validate-asset-name.ts b/packages/cli/src/tui/views/validate-asset-name.ts new file mode 100644 index 00000000..9a7fb68e --- /dev/null +++ b/packages/cli/src/tui/views/validate-asset-name.ts @@ -0,0 +1,40 @@ +import { validateAssetNameSegment } from '@agent-facets/protocol' +import type { AssetSectionKey, FormState } from '../context/form-state-context.ts' + +/** + * Shared wizard-level asset-name validation for both the create and edit + * views. Returns an error string to display, or `undefined` when the name is + * acceptable at input time. + * + * Enforces three rules, in order: + * 1. the current single-segment asset-name grammar; + * 2. uniqueness within the asset's own type (excluding the item being + * edited); + * 3. the shared skill/command namespace — skills and commands MUST be + * disjoint, so a name already used by the sibling type is rejected. + * Agents occupy a separate namespace and are exempt. + * + * Rule 3 mirrors the build/schema collision check (`facet-manifest.ts`), so an + * author is told about a shared-namespace collision at the wizard rather than + * only at build time. + */ +export function validateAssetNameInWizard( + type: AssetSectionKey, + value: string, + assets: FormState['assets'], +): string | undefined { + const check = validateAssetNameSegment(value) + if (!check.ok) return `Name ${check.reason}` + + const editing = assets[type].editing + if (assets[type].items.some((item) => item === value && item !== editing)) { + return `"${value}" already exists` + } + + const sibling = type === 'skill' ? 'command' : type === 'command' ? 'skill' : undefined + if (sibling && assets[sibling].items.some((item) => item === value)) { + return `"${value}" is already used by a ${sibling} (skills and commands share one namespace)` + } + + return undefined +} diff --git a/packages/cli/src/util/__tests__/archive-compatibility.test.ts b/packages/cli/src/util/__tests__/archive-compatibility.test.ts index b6e6be6b..efe8f9ba 100644 --- a/packages/cli/src/util/__tests__/archive-compatibility.test.ts +++ b/packages/cli/src/util/__tests__/archive-compatibility.test.ts @@ -7,7 +7,7 @@ describe('archiveCompatibilityGuidance', () => { expect(g.what).toContain('archive format 0.2') expect(g.detail).toContain('supported archive formats: 0.1') // Known format → concrete minimum release, not a bare "update to latest". - expect(g.fix).toContain('0.2.0 or later') + expect(g.fix).toContain('0.31.0 or later') }) test('advises updating to latest for an unknown future format without inventing a minimum', () => { diff --git a/packages/cli/src/util/archive-compatibility.ts b/packages/cli/src/util/archive-compatibility.ts index 60439f45..0a78b4f2 100644 --- a/packages/cli/src/util/archive-compatibility.ts +++ b/packages/cli/src/util/archive-compatibility.ts @@ -28,10 +28,10 @@ * formats precisely as they are added. */ const MINIMUM_RELEASE_FOR_FORMAT: Readonly> = { - // The first release that emits/consumes the `0.2` archive format. Present so - // the mapping mechanism is exercised and documented; a CLI that supports - // `0.2` will not itself render `0.2` as unsupported. - '0.2': '0.2.0', + // The first `agent-facets` release that emits/consumes the `0.2` archive + // format. A CLI that supports `0.2` never renders `0.2` as unsupported; this + // entry is what an *older* pre-`0.2` CLI is told to update to. + '0.2': '0.31.0', } export interface ArchiveCompatibilityGuidance { diff --git a/packages/engine/src/__tests__/run-install.test.ts b/packages/engine/src/__tests__/run-install.test.ts index d02c156d..8e997f9f 100644 --- a/packages/engine/src/__tests__/run-install.test.ts +++ b/packages/engine/src/__tests__/run-install.test.ts @@ -373,6 +373,38 @@ describe('runInstall — local source success path', () => { const after = JSON.parse(readFileSync(join(projectRoot, 'facets.lock'), 'utf8')) expect(after.facets['viper-plans'].assets[0].files[0].integrity).toBe(wrong) }) + + test('a locked owned-path set that differs from the plan aborts with RECONCILE_OWNED_PATH_SET', async () => { + const local = buildLocalFixture('viper-plans') + const relPath = `./${local.split('/').pop()}` + writeFileSync(join(projectRoot, 'facets.json'), JSON.stringify({ facets: { 'viper-plans': relPath } })) + + // First install writes a valid 0.2 lockfile. + const first = await runInstall({ projectRoot, adapters: [buildFakeAdapter('test')] }) + if (!first.ok) expect.unreachable() + + // Inject an extra owned-file record into the locked skill entry so the + // locked path set has a path the freshly-derived plan does not own. + const lockPath = join(projectRoot, 'facets.lock') + const lock = JSON.parse(readFileSync(lockPath, 'utf8')) + lock.facets['viper-plans'].assets[0].files.push({ + path: 'skills/planning/references/ghost.md', + integrity: `sha256:${'2'.repeat(64)}`, + }) + writeFileSync(lockPath, JSON.stringify(lock)) + + const second = await runInstall({ projectRoot, adapters: [buildFakeAdapter('test')] }) + if (second.ok) expect.unreachable() + if (second.failure.code !== 'RECONCILE_OWNED_PATH_SET') expect.unreachable() + expect(second.failure.facet).toBe('viper-plans') + expect(second.failure.asset).toBe('skill:planning') + // The extra locked path is reported as missing from the plan. + expect(second.failure.missing).toContain('skills/planning/references/ghost.md') + expect(second.failure.unexpected).toEqual([]) + // Reconciliation runs before materialize, so nothing was written. + if (second.rollback.kind === 'not-needed') expect.unreachable() + expect(second.rollback.entriesUndone).toBe(0) + }) }) describe('runInstall — registry source surfaces REGISTRY_ERROR on resolution failure', () => { diff --git a/packages/protocol/src/__tests__/lockfile-versions.test.ts b/packages/protocol/src/__tests__/lockfile-versions.test.ts index 84600bee..045a9716 100644 --- a/packages/protocol/src/__tests__/lockfile-versions.test.ts +++ b/packages/protocol/src/__tests__/lockfile-versions.test.ts @@ -76,6 +76,22 @@ describe('CurrentLockfileSchema', () => { expect(CurrentLockfileSchema(currentLockfile)).not.toBeInstanceOf(type.errors) }) + test('accepts single-file agent and command entries listing exactly their primary path', () => { + const agent = { + scope: 'user', + type: 'agent', + name: 'reviewer', + files: [{ path: 'agents/reviewer.md', integrity: HASH }], + } + const command = { + scope: 'project', + type: 'command', + name: 'review', + files: [{ path: 'commands/review.md', integrity: HASH }], + } + expect(CurrentLockfileSchema(withAssets([agent, command]))).not.toBeInstanceOf(type.errors) + }) + function withAssets(assets: unknown[]): unknown { return { lockfileVersion: 0.2,