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
6 changes: 3 additions & 3 deletions openspec/changes/support-non-asset-files/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 22 additions & 10 deletions packages/cli/src/__tests__/edit-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion packages/cli/src/commands/edit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)'
}`
}
Comment on lines +31 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate failed edit applies back to the wizard

When a filesystem mutation fails (for example, a scaffold target has a file as its parent), this callback records applyError but still fulfills its Promise<void>. EditWizard.handleConfirm consequently marks the run complete and renders “Changes applied.” before the command prints the failure and exits 1. Return the apply result (or throw/handle a failure at the wizard boundary) so the success view and completion state are reached only after a successful transaction.

Useful? React with 👍 / 👎.

},
buildArg,
})

if (applyError) {
console.error(applyError)
return 1
}

if (!completed) {
console.log('\nCancelled — no changes applied.')
return 1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function renderEdit(isPrivate: boolean) {
return render(
<FocusOrderProvider>
<FormStateProvider initialState={formWith(isPrivate)}>
<EditConfirmView onConfirm={() => {}} onBack={() => {}} />
<EditConfirmView operations={[]} onConfirm={() => {}} onBack={() => {}} />
</FormStateProvider>
</FocusOrderProvider>,
)
Expand Down
31 changes: 30 additions & 1 deletion packages/cli/src/tui/views/edit/edit-confirm-view.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -15,9 +16,18 @@ const ASSET_LABELS: Record<AssetSectionKey, string> = {
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'])
Expand Down Expand Up @@ -78,6 +88,25 @@ export function EditConfirmView({ onConfirm, onBack }: { onConfirm: () => void;
)
})}

<Box flexDirection="column" marginTop={1}>
<Text bold color={THEME.success}>
File changes:
</Text>
{opLines.length === 0 ? (
<Box marginLeft={2}>
<Text dimColor>(manifest only)</Text>
</Box>
) : (
opLines.map((line) => (
<Box key={`${line.verb}:${line.path}`} marginLeft={2}>
<Text color={THEME.hint}>
{line.verb} {line.path}
</Text>
</Box>
))
)}
</Box>

<Box marginTop={1} gap={2}>
<Button
id="edit-apply-btn"
Expand Down
101 changes: 42 additions & 59 deletions packages/cli/src/tui/views/edit/reconciliation-view.tsx
Original file line number Diff line number Diff line change
@@ -1,47 +1,35 @@
import type { ReconciliationItem, ReconciliationResolution } from '@agent-facets/engine'
import {
isAdditionItem,
optionIndexForResolution,
optionLabelsFor,
type ReconciliationItem,
type ReconciliationResolution,
reconciliationItemKey,
resolutionForOption,
} from '@agent-facets/engine'
import { Box, Text } from 'ink'
import { useCallback, useEffect, useMemo } from 'react'
import { Button } from '../../components/button.tsx'
import { ReconciliationItemRow } from '../../components/reconciliation-item.tsx'
import { useFocusOrder } from '../../context/focus-order-context.ts'
import { THEME } from '../../theme.ts'

/** Maps a reconciliation item to a unique key. */
function itemKey(item: ReconciliationItem): string {
return `${item.kind}:${item.type}:${item.name}`
}

/** Returns the two action options for a reconciliation item kind. */
function optionsForKind(kind: ReconciliationItem['kind']): [{ label: string }, { label: string }] {
switch (kind) {
case 'addition':
return [{ label: 'Add to manifest' }, { label: 'Ignore for now' }]
case 'missing':
return [{ label: 'Scaffold template' }, { label: 'Remove from manifest' }]
}
}

/** Converts a selected option index to a resolution for the given item kind. */
function indexToResolution(kind: ReconciliationItem['kind'], index: number): ReconciliationResolution {
switch (kind) {
case 'addition':
return index === 0 ? { action: 'add-to-manifest' } : { action: 'ignore' }
case 'missing':
return index === 0 ? { action: 'scaffold-template' } : { action: 'remove-from-manifest' }
}
}

/** Returns the selected option index for a resolution, or null. */
function resolutionToIndex(
kind: ReconciliationItem['kind'],
resolution: ReconciliationResolution | undefined,
): number | null {
if (!resolution) return null
switch (kind) {
case 'addition':
return resolution.action === 'add-to-manifest' ? 0 : resolution.action === 'ignore' ? 1 : null
case 'missing':
return resolution.action === 'scaffold-template' ? 0 : resolution.action === 'remove-from-manifest' ? 1 : null
import type { ResolvedItem } from './use-edit-session.ts'

/** Human-readable primary line for a reconciliation item, from structured fields. */
function itemDescription(item: ReconciliationItem): string {
switch (item.kind) {
case 'asset-addition':
return item.path
case 'asset-missing':
return `${item.name} (${item.assetType}) — ${item.expectedPath}`
case 'companion-addition':
return item.path
case 'companion-missing':
return item.expectedPath
case 'root-addition':
return item.path
case 'root-missing':
return item.path
}
}

Expand All @@ -52,21 +40,19 @@ export function ReconciliationView({
onContinue,
}: {
items: ReconciliationItem[]
resolutions: Map<string, ReconciliationResolution>
onResolve: (key: string, resolution: ReconciliationResolution) => void
resolutions: Map<string, ResolvedItem>
onResolve: (item: ReconciliationItem, resolution: ReconciliationResolution) => void
onContinue: () => void
}) {
const { setFocusIds, focusedId, focus } = useFocusOrder()

const allResolved = items.every((item) => resolutions.has(itemKey(item)))
const allResolved = items.every((item) => resolutions.has(reconciliationItemKey(item)))

// Group items by kind
const additions = items.filter((i) => i.kind === 'addition')
const missing = items.filter((i) => i.kind === 'missing')
const additions = items.filter((i) => isAdditionItem(i))
const missing = items.filter((i) => !isAdditionItem(i))

// Build focus order: all items then continue button
const focusIds = useMemo(() => {
const ids = items.map((item) => `recon-${itemKey(item)}`)
const ids = items.map((item) => `recon-${reconciliationItemKey(item)}`)
ids.push('recon-continue')
return ids
}, [items])
Expand All @@ -80,22 +66,20 @@ export function ReconciliationView({

const handleSelect = useCallback(
(item: ReconciliationItem, optionIndex: number) => {
const key = itemKey(item)
const resolution = indexToResolution(item.kind, optionIndex)
onResolve(key, resolution)
const key = reconciliationItemKey(item)
onResolve(item, resolutionForOption(item, optionIndex))

// Auto-advance to next unresolved item
const currentIdx = items.findIndex((i) => itemKey(i) === key)
// Auto-advance to the next unresolved item.
const currentIdx = items.findIndex((i) => reconciliationItemKey(i) === key)
for (let i = currentIdx + 1; i < items.length; i++) {
const nextItem = items[i]
if (!nextItem) continue
const nextKey = itemKey(nextItem)
const nextKey = reconciliationItemKey(nextItem)
if (!resolutions.has(nextKey)) {
focus(`recon-${nextKey}`)
return
}
}
// All resolved — focus continue button
focus('recon-continue')
},
[items, resolutions, onResolve, focus],
Expand All @@ -111,16 +95,15 @@ export function ReconciliationView({
</Text>
</Box>
{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 (
<ReconciliationItemRow
key={key}
id={`recon-${key}`}
description={description}
options={optionsForKind(item.kind)}
selectedIndex={resolutionToIndex(item.kind, resolutions.get(key))}
description={itemDescription(item)}
options={[{ label: a }, { label: b }]}
selectedIndex={optionIndexForResolution(item, resolutions.get(key)?.resolution)}
onSelect={(index) => handleSelect(item, index)}
/>
)
Expand Down
17 changes: 8 additions & 9 deletions packages/cli/src/tui/views/edit/success-view.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box flexDirection="column">
Expand All @@ -17,8 +12,12 @@ export function EditSuccessView({ operations, buildArg }: { operations: EditOper
Changes applied.
</Text>
</Text>
<Box marginLeft={2}>
<Text color={THEME.hint}>{parts.join(' · ')}</Text>
<Box flexDirection="column" marginLeft={2}>
{lines.map((line) => (
<Text key={`${line.verb}:${line.path}`} color={THEME.hint}>
{line.verb} {line.path}
</Text>
))}
</Box>
<Text color={THEME.hint}>Run "facet build{buildArg}" to validate your facet.</Text>
</Box>
Expand Down
Loading