diff --git a/openspec/changes/support-non-asset-files/tasks.md b/openspec/changes/support-non-asset-files/tasks.md
index 020149a1..b432734d 100644
--- a/openspec/changes/support-non-asset-files/tasks.md
+++ b/openspec/changes/support-non-asset-files/tasks.md
@@ -125,10 +125,10 @@
- [x] 13.1 Implement: Add an editable default `README.md` scaffold option and template that writes the file and top-level declaration atomically without regenerating authored content after identity edits
- [x] 13.2 Implement: Add the dedicated create README card/editor flow, optional disable behavior, state snapshotting, and explicit confirmation preview, and align headless create with the documented policy
-- [ ] 13.3 Implement: Extend edit scanning and reconciliation for undeclared skill companions, common root files, and missing declared supplementary files while routing only exact `README.md` and `README` paths to a dedicated panel
+- [x] 13.3 Implement: Extend edit scanning and reconciliation for undeclared skill companions, common root files, and missing declared supplementary files while routing only exact `README.md` and `README` paths to a dedicated panel
- [ ] 13.4 Implement: Add independent tagged states and actions for both conventional README paths, preserving bytes on adoption and retaining exact paths for scaffold, edit, removal, and declaration changes
-- [ ] 13.5 Implement: Replace string-parsed reconciliation keys with stable structured identities and represent file/declaration operations as tagged variants with no invalid combinations
-- [ ] 13.6 Implement: Apply README, companion, and generic supplementary changes transactionally with manifest edits and show every queued exact-path operation before Apply
+- [x] 13.5 Implement: Replace string-parsed reconciliation keys with stable structured identities and represent file/declaration operations as tagged variants with no invalid combinations
+- [x] 13.6 Implement: Apply README, companion, and generic supplementary changes transactionally with manifest edits and show every queued exact-path operation before Apply
- [ ] 13.7 Implement: Add engine, CLI, TUI, integration, and create-build end-to-end tests covering README defaults/disable/edit preservation in interactive and headless creates, both README paths, adoption, missing-file choices, companion discovery, skill deletion preserving undeclared files, confirmation, cancellation, and buildability
- [ ] 13.8 Verify: Run focused scaffold, edit, create, TUI, integration, and end-to-end tests
diff --git a/packages/cli/src/__tests__/edit-integration.test.ts b/packages/cli/src/__tests__/edit-integration.test.ts
index a94fc7a3..5d286540 100644
--- a/packages/cli/src/__tests__/edit-integration.test.ts
+++ b/packages/cli/src/__tests__/edit-integration.test.ts
@@ -33,9 +33,9 @@ describe('edit integration', () => {
const result = await buildEditContext(dir)
expect(result.ok).toBe(true)
if (!result.ok) expect.unreachable()
- const additions = result.context.reconciliationItems.filter((i) => i.kind === 'addition')
+ const additions = result.context.reconciliationItems.filter((i) => i.kind === 'asset-addition')
expect(additions).toHaveLength(1)
- expect(additions[0]?.name).toBe('new-one')
+ expect(additions[0]?.kind === 'asset-addition' && additions[0].name).toBe('new-one')
})
test('buildEditContext detects missing files in manifest', async () => {
@@ -55,9 +55,9 @@ describe('edit integration', () => {
const result = await buildEditContext(dir)
expect(result.ok).toBe(true)
if (!result.ok) expect.unreachable()
- const missing = result.context.reconciliationItems.filter((i) => i.kind === 'missing')
+ const missing = result.context.reconciliationItems.filter((i) => i.kind === 'asset-missing')
expect(missing).toHaveLength(1)
- expect(missing[0]?.name).toBe('gone')
+ expect(missing[0]?.kind === 'asset-missing' && missing[0].name).toBe('gone')
})
test('buildEditContext does NOT flag matched files that contain front matter', async () => {
@@ -97,9 +97,13 @@ describe('edit integration', () => {
version: '1.0.0',
skills: { helper: { description: 'A helper skill' } },
}
- const operations: EditOperation[] = [{ op: 'write-manifest' }, { op: 'scaffold', type: 'skills', name: 'helper' }]
+ const operations: EditOperation[] = [
+ { op: 'write-manifest', manifest },
+ { op: 'scaffold-asset', assetType: 'skills', name: 'helper' },
+ ]
- await applyOperations(manifest, operations, dir)
+ const applied = await applyOperations(operations, dir)
+ expect(applied.ok).toBe(true)
const manifestExists = await Bun.file(join(dir, 'facet.json')).exists()
expect(manifestExists).toBe(true)
@@ -114,9 +118,13 @@ describe('edit integration', () => {
await Bun.write(join(dir, 'skills/old/SKILL.md'), '# Old skill')
const manifest = { name: 'test', version: '1.0.0', skills: { remaining: { description: 'Remaining' } } }
- const operations: EditOperation[] = [{ op: 'write-manifest' }, { op: 'delete-file', type: 'skills', name: 'old' }]
+ const operations: EditOperation[] = [
+ { op: 'write-manifest', manifest },
+ { op: 'delete-asset', assetType: 'skills', name: 'old', companionPaths: [] },
+ ]
- await applyOperations(manifest, operations, dir)
+ const applied = await applyOperations(operations, dir)
+ expect(applied.ok).toBe(true)
const deleted = await Bun.file(join(dir, 'skills/old/SKILL.md')).exists()
expect(deleted).toBe(false)
@@ -129,9 +137,13 @@ describe('edit integration', () => {
version: '1.0.0',
skills: { example: { description: 'An example skill' } },
}
- const operations: EditOperation[] = [{ op: 'write-manifest' }, { op: 'scaffold', type: 'skills', name: 'example' }]
+ const operations: EditOperation[] = [
+ { op: 'write-manifest', manifest },
+ { op: 'scaffold-asset', assetType: 'skills', name: 'example' },
+ ]
- await applyOperations(manifest, operations, dir)
+ const applied = await applyOperations(operations, dir)
+ expect(applied.ok).toBe(true)
const buildResult = await runBuildPipeline(dir)
expect(buildResult.ok).toBe(true)
diff --git a/packages/cli/src/commands/edit/index.ts b/packages/cli/src/commands/edit/index.ts
index 36b7bf64..84eb75d6 100644
--- a/packages/cli/src/commands/edit/index.ts
+++ b/packages/cli/src/commands/edit/index.ts
@@ -24,11 +24,24 @@ export const editCommand: Command = {
if (!loaded.ok) return loaded.exitCode
const buildArg = args[0] ? ` ${displayDir}` : ''
+ let applyError: string | null = null
const completed = await runEditWizardInk(loaded.context, {
- onApply: (result) => applyEditOperations(result.manifest, result.operations, rootDir),
+ onApply: async (result) => {
+ const applied = await applyEditOperations(result.operations, rootDir)
+ if (!applied.ok) {
+ applyError = `Failed to apply changes at ${applied.failedPath}: ${applied.reason}${
+ applied.rollbackOk ? ' (rolled back)' : ' (rollback incomplete)'
+ }`
+ }
+ },
buildArg,
})
+ if (applyError) {
+ console.error(applyError)
+ return 1
+ }
+
if (!completed) {
console.log('\nCancelled — no changes applied.')
return 1
diff --git a/packages/cli/src/tui/views/__tests__/confirm-privacy.test.tsx b/packages/cli/src/tui/views/__tests__/confirm-privacy.test.tsx
index 744416f8..deda3cab 100644
--- a/packages/cli/src/tui/views/__tests__/confirm-privacy.test.tsx
+++ b/packages/cli/src/tui/views/__tests__/confirm-privacy.test.tsx
@@ -49,7 +49,7 @@ function renderEdit(isPrivate: boolean) {
return render(
- {}} onBack={() => {}} />
+ {}} onBack={() => {}} />
,
)
diff --git a/packages/cli/src/tui/views/edit/edit-confirm-view.tsx b/packages/cli/src/tui/views/edit/edit-confirm-view.tsx
index 5d32c399..1e77ba8c 100644
--- a/packages/cli/src/tui/views/edit/edit-confirm-view.tsx
+++ b/packages/cli/src/tui/views/edit/edit-confirm-view.tsx
@@ -1,4 +1,5 @@
import { ASSET_TYPE_COLORS } from '@agent-facets/brand'
+import { type EditOperation, previewEditOperations } from '@agent-facets/engine'
import { Box, Text } from 'ink'
import { useEffect } from 'react'
import { truncateDescription } from '../../components/asset-description.tsx'
@@ -15,9 +16,18 @@ const ASSET_LABELS: Record = {
agent: 'Agents',
}
-export function EditConfirmView({ onConfirm, onBack }: { onConfirm: () => void; onBack: () => void }) {
+export function EditConfirmView({
+ operations,
+ onConfirm,
+ onBack,
+}: {
+ operations: EditOperation[]
+ onConfirm: () => void
+ onBack: () => void
+}) {
const { form } = useFormState()
const { setFocusIds, focus, focusedId } = useFocusOrder()
+ const opLines = previewEditOperations(operations)
useEffect(() => {
setFocusIds(['edit-apply-btn', 'edit-back-btn'])
@@ -78,6 +88,25 @@ export function EditConfirmView({ onConfirm, onBack }: { onConfirm: () => void;
)
})}
+
+
+ File changes:
+
+ {opLines.length === 0 ? (
+
+ (manifest only)
+
+ ) : (
+ opLines.map((line) => (
+
+
+ {line.verb} {line.path}
+
+
+ ))
+ )}
+
+
{groupItems.map((item) => {
- const key = itemKey(item)
- const description = item.kind === 'missing' ? `${item.name} (${item.type}) — ${item.expectedPath}` : item.path
-
+ const key = reconciliationItemKey(item)
+ const [a, b] = optionLabelsFor(item)
return (
handleSelect(item, index)}
/>
)
diff --git a/packages/cli/src/tui/views/edit/success-view.tsx b/packages/cli/src/tui/views/edit/success-view.tsx
index f133a105..8e5dc248 100644
--- a/packages/cli/src/tui/views/edit/success-view.tsx
+++ b/packages/cli/src/tui/views/edit/success-view.tsx
@@ -1,14 +1,9 @@
-import type { EditOperation } from '@agent-facets/engine'
+import { type EditOperation, previewEditOperations } from '@agent-facets/engine'
import { Box, Text } from 'ink'
import { THEME } from '../../theme.ts'
export function EditSuccessView({ operations, buildArg }: { operations: EditOperation[]; buildArg: string }) {
- const scaffolded = operations.filter((op) => op.op === 'scaffold').length
- const deleted = operations.filter((op) => op.op === 'delete-file').length
- const parts: string[] = []
- if (scaffolded > 0) parts.push(`${scaffolded} scaffolded`)
- if (deleted > 0) parts.push(`${deleted} removed`)
- parts.push('manifest updated')
+ const lines = previewEditOperations(operations)
return (
@@ -17,8 +12,12 @@ export function EditSuccessView({ operations, buildArg }: { operations: EditOper
Changes applied.
-
- {parts.join(' · ')}
+
+ {lines.map((line) => (
+
+ {line.verb} {line.path}
+
+ ))}
Run "facet build{buildArg}" to validate your facet.
diff --git a/packages/cli/src/tui/views/edit/use-edit-session.ts b/packages/cli/src/tui/views/edit/use-edit-session.ts
index 4ea27ea1..43dba265 100644
--- a/packages/cli/src/tui/views/edit/use-edit-session.ts
+++ b/packages/cli/src/tui/views/edit/use-edit-session.ts
@@ -1,8 +1,25 @@
-import type { EditContext, EditOperation, EditResult, ReconciliationResolution } from '@agent-facets/engine'
+import {
+ addSkillCompanion,
+ addTopLevelFile,
+ type EditContext,
+ type EditOperation,
+ type EditResult,
+ type ReconciliationItem,
+ type ReconciliationResolution,
+ reconciliationItemKey,
+ removeSkillCompanion,
+ removeTopLevelFile,
+} from '@agent-facets/engine'
import type { FacetManifest } from '@agent-facets/protocol'
import { useCallback, useState } from 'react'
import type { AssetSectionKey, FormState } from '../../context/form-state-context.ts'
+/** A resolved reconciliation item: the structured item plus the chosen action. */
+export interface ResolvedItem {
+ item: ReconciliationItem
+ resolution: ReconciliationResolution
+}
+
/** Maps form section keys to manifest asset keys. */
const FORM_TO_MANIFEST: Record = {
skill: 'skills',
@@ -23,11 +40,7 @@ export function buildManifest(original: FacetManifest, form: FormState): FacetMa
}
// Privacy is handled after `...original` so a private→public edit actively
- // removes a spread-in `private: true`. The form is binary, but the manifest
- // can represent public either by omission or by an explicit `private: false`:
- // - private → write `private: true`
- // - public + original false → preserve `private: false` (already spread in)
- // - public + original omitted/true → delete `private`
+ // removes a spread-in `private: true`.
if (form.private) {
manifest.private = true
} else if (original.private !== false) {
@@ -41,10 +54,8 @@ export function buildManifest(original: FacetManifest, form: FormState): FacetMa
const items = form.assets[formKey].items
if (items.length > 0) {
// Start each descriptor from the original so descriptor-level metadata
- // (notably per-asset `adapters` front-matter, which the form never
- // surfaces) survives the edit round-trip. Only `description` — the one
- // field the form edits — is overwritten. New assets have no original
- // descriptor, so they collapse to `{ description }`.
+ // (per-asset `adapters`, and skill `files` companion declarations) survives
+ // the round-trip. Only `description` is overwritten from the form.
const originalSection = original[manifestKey]
const section: NonNullable = {}
for (const name of items) {
@@ -63,72 +74,109 @@ export function buildManifest(original: FacetManifest, form: FormState): FacetMa
return manifest
}
-/** Builds the list of file operations from resolutions + form changes. */
+/**
+ * Apply supplementary-declaration deltas (companion/root add/remove) to the
+ * form-derived manifest. Supplementary files are not modeled in the form, so
+ * their declaration changes are applied here as pure manifest mutations. README
+ * declarations are handled by the README panel, not here.
+ */
+function applySupplementaryDeltas(manifest: FacetManifest, resolved: ResolvedItem[]): FacetManifest {
+ let next = manifest
+ for (const { item, resolution } of resolved) {
+ switch (item.kind) {
+ case 'companion-addition':
+ if (resolution.action === 'add') next = addSkillCompanion(next, item.skill, item.relPath)
+ break
+ case 'companion-missing':
+ if (resolution.action === 'remove') next = removeSkillCompanion(next, item.skill, item.relPath)
+ break
+ case 'root-addition':
+ if (resolution.action === 'add') next = addTopLevelFile(next, item.path)
+ break
+ case 'root-missing':
+ if (resolution.action === 'remove') next = removeTopLevelFile(next, item.path)
+ break
+ // asset-* items are reflected through the form / operation list.
+ }
+ }
+ return next
+}
+
+/** Builds the queued operation list from resolutions + form asset changes. */
function buildOperations(
context: EditContext,
form: FormState,
- resolutions: Map,
+ resolved: ResolvedItem[],
+ finalManifest: FacetManifest,
): EditOperation[] {
- const operations: EditOperation[] = [{ op: 'write-manifest' }]
-
- // Operations from reconciliation resolutions
- for (const [key, resolution] of resolutions) {
- const parts = key.split(':')
- const assetType = parts[1] as 'skills' | 'agents' | 'commands'
- const name = parts[2]
- if (!assetType || !name) continue
+ const operations: EditOperation[] = [{ op: 'write-manifest', manifest: finalManifest }]
- if (resolution.action === 'scaffold-template') {
- operations.push({ op: 'scaffold', type: assetType, name })
+ // Supplementary + asset scaffolds driven by reconciliation resolutions.
+ for (const { item, resolution } of resolved) {
+ if (item.kind === 'asset-missing' && resolution.action === 'scaffold') {
+ operations.push({ op: 'scaffold-asset', assetType: item.assetType, name: item.name })
+ }
+ // Missing supplementary files scaffold as empty bytes (valid content).
+ if (item.kind === 'companion-missing' && resolution.action === 'scaffold') {
+ operations.push({ op: 'write-file', path: item.expectedPath, content: '' })
+ }
+ if (item.kind === 'root-missing' && resolution.action === 'scaffold') {
+ operations.push({ op: 'write-file', path: item.path, content: '' })
}
}
- // New assets added during editing (not from reconciliation)
+ // Names already present on disk (discovered additions) MUST NOT be scaffolded
+ // over — that would overwrite an existing file with a template.
+ const onDiskAssetNames = new Set(
+ context.reconciliationItems
+ .filter((i): i is Extract => i.kind === 'asset-addition')
+ .map((i) => `${i.assetType}:${i.name}`),
+ )
+
for (const [formKey, manifestKey] of Object.entries(FORM_TO_MANIFEST) as [
AssetSectionKey,
'skills' | 'agents' | 'commands',
][]) {
- const originalSection = context.manifest[manifestKey]
- const originalNames =
- originalSection && typeof originalSection === 'object' && !Array.isArray(originalSection)
- ? Object.keys(originalSection)
- : []
+ const originalNames = Object.keys(context.manifest[manifestKey] ?? {})
+ // Genuinely new assets added in the form → scaffold a starter file.
for (const name of form.assets[formKey].items) {
- const isFromReconciliation = resolutions.has(`addition:${manifestKey}:${name}`)
- if (!originalNames.includes(name) && !isFromReconciliation) {
- operations.push({ op: 'scaffold', type: manifestKey, name })
- }
+ if (originalNames.includes(name)) continue
+ if (onDiskAssetNames.has(`${manifestKey}:${name}`)) continue
+ operations.push({ op: 'scaffold-asset', assetType: manifestKey, name })
}
- // Removed assets
+ // Removed assets → delete the primary and any declared companions (skills).
for (const name of originalNames) {
- if (!form.assets[formKey].items.includes(name)) {
- operations.push({ op: 'delete-file', type: manifestKey, name })
- }
+ if (form.assets[formKey].items.includes(name)) continue
+ const companionPaths =
+ manifestKey === 'skills'
+ ? (context.manifest.skills?.[name]?.files ?? []).map((rel) => `skills/${name}/${rel}`)
+ : []
+ operations.push({ op: 'delete-asset', assetType: manifestKey, name, companionPaths })
}
}
return operations
}
-export function useEditSession(context: EditContext) {
- const [resolutions, setResolutions] = useState