Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions openspec/changes/support-non-asset-files/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,15 @@

- [x] 9.1 Implement: Replace numeric-order lockfile handling with exact legacy-alpha-`1` and current-`0.2` loading, normal-mode migration, and frozen-mode no-rewrite behavior
- [x] 9.2 Implement: Derive sorted lockfile asset file records from the verified materialization subset and recomputed entry hashes rather than copying self-declared hash values
- [ ] 9.3 Implement: Enforce pre-materialization agreement among facet integrity, asset identities, complete owned path sets, recomputed entry hashes, and verified build-manifest hashes with path-specific result variants, running the adapter-compatibility preflight (positional `0.0` rejected by a `{0.1}` CLI) ahead of archive-version dispatch and per-file reconciliation
- [ ] 9.4 Implement: Introduce receipt `0.2` asset/file ownership, safe legacy refinement, project-isolated bootstrap, and containment validation that treats receipt data as untrusted
- [ ] 9.5 Implement: Commit lockfile, receipt, and adapter state transactionally and ensure frozen consistency gates complete before receipt-driven cleanup begins
- [ ] 9.6 Implement: Materialize only primary assets and owned skill companions through tagged adapter requests carrying validated ownership sets from the lockfile and receipt, with per-file skip/repair behavior and rollback journal preimages
- [ ] 9.7 Implement: Make drift and removal path-specific, preserve unowned files, and support offline multi-file cleanup from receipts without cache or network access
- [ ] 9.8 Implement: Render lockfile, archive-version, per-file mismatch, adapter-bundle, and receipt failures exhaustively in CLI install output using one compatibility table for known format transitions
- [ ] 9.9 Implement: Add engine and CLI tests for migration, frozen failures, receipt corruption/isolation, pulled-lockfile cleanup, per-file drift, integrity mismatch, rollback, archive-only withholding, and exact diagnostics
- [ ] 9.10 Implement: Add a full-cycle end-to-end test that builds and verifies a facet with skill companions and archive-only files, installs it, detects and repairs single-file drift, exercises interrupted-install convergence on re-run without deleting unowned files, and removes it offline from the receipt, then exercises the same install path with an immutable legacy `0.1` archive
- [ ] 9.11 Verify: Run focused install, materialization, receipt, lockfile, cache, registry, and CLI install tests
- [x] 9.3 Implement: Enforce pre-materialization agreement among facet integrity, asset identities, complete owned path sets, recomputed entry hashes, and verified build-manifest hashes with path-specific result variants, running the adapter-compatibility preflight (positional `0.0` rejected by a `{0.1}` CLI) ahead of archive-version dispatch and per-file reconciliation
- [x] 9.4 Implement: Introduce receipt `0.2` asset/file ownership, safe legacy refinement, project-isolated bootstrap, and containment validation that treats receipt data as untrusted
- [x] 9.5 Implement: Commit lockfile, receipt, and adapter state transactionally and ensure frozen consistency gates complete before receipt-driven cleanup begins
- [x] 9.6 Implement: Materialize only primary assets and owned skill companions through tagged adapter requests carrying validated ownership sets from the lockfile and receipt, with per-file skip/repair behavior and rollback journal preimages
- [x] 9.7 Implement: Make drift and removal path-specific, preserve unowned files, and support offline multi-file cleanup from receipts without cache or network access
- [x] 9.8 Implement: Render lockfile, archive-version, per-file mismatch, adapter-bundle, and receipt failures exhaustively in CLI install output using one compatibility table for known format transitions
- [x] 9.9 Implement: Add engine and CLI tests for migration, frozen failures, receipt corruption/isolation, pulled-lockfile cleanup, per-file drift, integrity mismatch, rollback, archive-only withholding, and exact diagnostics
- [x] 9.10 Implement: Add a full-cycle end-to-end test that builds and verifies a facet with skill companions and archive-only files, installs it, detects and repairs single-file drift, exercises interrupted-install convergence on re-run without deleting unowned files, and removes it offline from the receipt, then exercises the same install path with an immutable legacy `0.1` archive
- [x] 9.11 Verify: Run focused install, materialization, receipt, lockfile, cache, registry, and CLI install tests

## 10. Current Producer and Build Pipeline — Research

Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/__tests__/install-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,36 @@ describe('InstallView — integrity failure', () => {
expect(frame).toContain('No assets were written')
instance.unmount()
})

// 9.8: pre-materialization per-file reconciliation failures render with the
// exact drifting path and both hashes.
test('renders a per-file reconcile failure with the exact path', async () => {
const failure: RunInstallFailure = {
code: 'RECONCILE_PER_FILE_INTEGRITY',
facet: 'viper-plans',
asset: 'skill:planning',
path: 'skills/planning/references/api.md',
expected: `sha256:${'a'.repeat(64)}`,
actual: `sha256:${'b'.repeat(64)}`,
}
const events: StageEvent[] = [
{ kind: 'install-start', totalFacets: 1 },
{ kind: 'facet-start', facet: 'viper-plans', specifier: 'github:a/v' },
{ kind: 'facet-failure', facet: 'viper-plans', failure },
]
const result: RunInstallResult = {
ok: false,
failure,
rollback: { kind: 'not-needed', reason: 'test fixture' },
}
const instance = render(createElement(InstallView, { mode: 'add', run: makeFakeRun(events, result) }))
await settle()
const frame = findContentFrame(instance.frames)
expect(frame).toContain('file integrity mismatch')
expect(frame).toContain('skills/planning/references/api.md')
expect(frame).toContain(failure.code === 'RECONCILE_PER_FILE_INTEGRITY' ? failure.expected : '')
instance.unmount()
})
})

describe('InstallView — parse error failure', () => {
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/tui/views/install/facet-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,14 @@ function oneLineFailureSummary(failure: RunInstallFailure): string {
return 'failed to load facet.json'
case 'ADAPTER_INSTALL_FAILED':
return `adapter ${failure.adapter} failed during materialization`
case 'RECONCILE_FACET_INTEGRITY':
return 'lockfile integrity mismatch'
case 'RECONCILE_ASSET_IDENTITY':
return 'lockfile asset set mismatch'
case 'RECONCILE_OWNED_PATH_SET':
return `lockfile file set mismatch (${failure.asset})`
case 'RECONCILE_PER_FILE_INTEGRITY':
return `file integrity mismatch: ${failure.path}`
default:
return 'install failed'
}
Expand Down
59 changes: 59 additions & 0 deletions packages/cli/src/tui/views/install/failure-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,65 @@ export function FailureBlock({ failure }: { failure: RunInstallFailure }): React
</Text>
</Box>
)
case 'RECONCILE_FACET_INTEGRITY':
return (
<Box flexDirection="column" marginTop={1}>
<Text color={THEME.warning} bold>
✕ lockfile integrity mismatch for {failure.facet}
</Text>
<Text color={THEME.hint}> expected {failure.expected}</Text>
<Text color={THEME.hint}> actual {failure.actual}</Text>
<Text color={THEME.hint}>
{' '}
The lockfile disagrees with the resolved content. Delete facets.lock and re-run.
</Text>
</Box>
)
case 'RECONCILE_ASSET_IDENTITY':
return (
<Box flexDirection="column" marginTop={1}>
<Text color={THEME.warning} bold>
✕ lockfile asset set does not match resolved content for {failure.facet}
</Text>
{failure.missing.length > 0 && (
<Text color={THEME.hint}> locked but not resolved: {failure.missing.join(', ')}</Text>
)}
{failure.unexpected.length > 0 && (
<Text color={THEME.hint}> resolved but not locked: {failure.unexpected.join(', ')}</Text>
)}
<Text color={THEME.hint}> Delete facets.lock and re-run, or `facet add` to update it.</Text>
</Box>
)
case 'RECONCILE_OWNED_PATH_SET':
return (
<Box flexDirection="column" marginTop={1}>
<Text color={THEME.warning} bold>
✕ lockfile file set does not match resolved content for {failure.facet} ({failure.asset})
</Text>
{failure.missing.length > 0 && (
<Text color={THEME.hint}> locked but not resolved: {failure.missing.join(', ')}</Text>
)}
{failure.unexpected.length > 0 && (
<Text color={THEME.hint}> resolved but not locked: {failure.unexpected.join(', ')}</Text>
)}
<Text color={THEME.hint}> Delete facets.lock and re-run, or `facet add` to update it.</Text>
</Box>
)
case 'RECONCILE_PER_FILE_INTEGRITY':
return (
<Box flexDirection="column" marginTop={1}>
<Text color={THEME.warning} bold>
✕ file integrity mismatch: {failure.path}
</Text>
<Text color={THEME.hint}>
{' '}
in {failure.facet} ({failure.asset})
</Text>
<Text color={THEME.hint}> expected {failure.expected}</Text>
<Text color={THEME.hint}> actual {failure.actual}</Text>
<Text color={THEME.hint}> Delete facets.lock and re-run, or `facet add` to update it.</Text>
</Box>
)
default: {
// Exhaustiveness guard: any new `RunInstallFailure` variant must
// get a `case` arm above. Without this, an un-rendered failure
Expand Down
27 changes: 27 additions & 0 deletions packages/cli/src/util/__tests__/archive-compatibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, test } from 'bun:test'
import { archiveCompatibilityGuidance } from '../archive-compatibility.ts'

describe('archiveCompatibilityGuidance', () => {
test('names the minimum supporting release for a known newer format', () => {
const g = archiveCompatibilityGuidance('0.2', ['0.1'])
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')
})

test('advises updating to latest for an unknown future format without inventing a minimum', () => {
const g = archiveCompatibilityGuidance('0.9', ['0.1', '0.2'])
expect(g.what).toContain('archive format 0.9')
expect(g.fix).toContain('latest release')
// No fabricated minimum version for a format this CLI cannot know about.
expect(g.fix).not.toMatch(/\d+\.\d+\.\d+ or later/)
})

test('handles a missing/unparseable declared format', () => {
const g = archiveCompatibilityGuidance(undefined, ['0.1', '0.2'])
expect(g.what).toContain('does not recognize')
expect(g.detail).toContain('0.1, 0.2')
expect(g.fix).toContain('latest release')
})
})
81 changes: 81 additions & 0 deletions packages/cli/src/util/archive-compatibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* The single CLI-side compatibility table mapping a known archive format to
* the minimum `agent-facets` release that supports it (design D4, task 9.8).
*
* This is the ONE authoritative place the CLI turns an unsupported
* archive-format failure into actionable upgrade guidance. For a KNOWN newer
* format, it names the minimum supporting release ("update agent-facets to
* <version> or later"). For an UNKNOWN future format, it advises updating to
* the latest release without inventing a minimum version — an already-shipped
* CLI cannot know the minimum release for a format defined after it was built.
*
* The table intentionally lives in the CLI, not the protocol: it maps a spec
* artifact version to a specific npm package release, which is a distribution
* fact this CLI owns, not a normative part of the protocol.
*/

/**
* Known archive-format → minimum supporting `agent-facets` release. Keys are
* the exact numeric `facetVersion` values rendered as strings (the form the
* verifier reports as `observed`). Extend this map when a future format ships
* with a known minimum CLI release.
*
* `0.1` and `0.2` are the formats THIS CLI already supports, so they never
* reach the unsupported-format path; the table is for formats NEWER than what
* a given installed CLI understands. It is seeded with the `0.2` boundary so
* an older pre-`0.2` CLI (which lacks this table entirely) is the only build
* that shows a generic message — every `0.2`-aware release maps known newer
* formats precisely as they are added.
*/
const MINIMUM_RELEASE_FOR_FORMAT: Readonly<Record<string, string>> = {
// 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',
}

export interface ArchiveCompatibilityGuidance {
/** The one-line "what went wrong" summary. */
what: string
/** Supporting detail (supported formats, and a minimum release when known). */
detail: string
/** The actionable fix line. */
fix: string
}

/**
* Render actionable upgrade guidance for an unsupported archive format.
*
* @param observed the archive's declared `facetVersion` (string), or
* `undefined` when the archive did not declare a parseable
* version.
* @param supported the archive formats this CLI supports.
*/
export function archiveCompatibilityGuidance(
observed: string | undefined,
supported: readonly string[],
): ArchiveCompatibilityGuidance {
if (observed === undefined) {
return {
what: 'this facet uses an archive format this CLI does not recognize',
detail: `supported archive formats: ${supported.join(', ')}`,
fix: 'update agent-facets to the latest release with `facet self-update` and try again',
}
}

const minimumRelease = MINIMUM_RELEASE_FOR_FORMAT[observed]
if (minimumRelease !== undefined) {
return {
what: `this facet uses archive format ${observed}, which this CLI does not support`,
detail: `supported archive formats: ${supported.join(', ')}`,
fix: `update agent-facets to ${minimumRelease} or later (e.g. \`facet self-update\`) and try again`,
}
}

// Unknown future format: no minimum is invented.
return {
what: `this facet uses archive format ${observed}, which this CLI does not support`,
detail: `supported archive formats: ${supported.join(', ')}`,
fix: 'update agent-facets to the latest release with `facet self-update` and try again',
}
}
19 changes: 8 additions & 11 deletions packages/cli/src/util/registry-errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { RegistryError } from '@agent-facets/engine'
import { archiveCompatibilityGuidance } from './archive-compatibility.ts'
import type { CliError } from './errors.ts'

/**
Expand Down Expand Up @@ -56,16 +57,12 @@ export function translateEngineRegistryError(err: RegistryError): CliError {
fix: 'try again; if persistent, file a bug',
}
case 'UNSUPPORTED_ARCHIVE':
// Basic upgrade guidance for now; the full facet-format →
// minimum-CLI-release compatibility table lands with the install
// failure-rendering work.
return {
what:
err.observed === undefined
? 'this facet uses an archive format this CLI does not recognize'
: `this facet uses archive format ${err.observed}, which this CLI does not support`,
detail: `supported archive formats: ${err.supported.join(', ')}`,
fix: 'update agent-facets with `facet self-update` and try again',
}
// The single compatibility table names the minimum supporting release
// for a known newer format, or advises updating to latest for an
// unknown future one (design D4, task 9.8).
return archiveCompatibilityGuidance(
err.observed === undefined ? undefined : String(err.observed),
err.supported.map(String),
)
}
}
Loading