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

## 12. Create and Edit Authoring — Research

- [ ] 12.1 Explore: Trace scaffold options, manifest generation, templates, previews, and create wizard state/editor round-trips
- [ ] 12.2 Explore: Trace edit scanner, reconciliation, context, operation, manifest-rewrite, confirmation, and transactional apply types
- [ ] 12.3 Explore: Inspect create/edit focus management and exhaustive UI switches that must represent two independent README paths and path-bearing reconciliation items
- [ ] 12.4 Propose: Define tagged README and supplementary-file states, stable reconciliation identities, headless-create behavior, and an exact-path operation preview for the full authoring block
- [x] 12.1 Explore: Trace scaffold options, manifest generation, templates, previews, and create wizard state/editor round-trips
- [x] 12.2 Explore: Trace edit scanner, reconciliation, context, operation, manifest-rewrite, confirmation, and transactional apply types
- [x] 12.3 Explore: Inspect create/edit focus management and exhaustive UI switches that must represent two independent README paths and path-bearing reconciliation items
- [x] 12.4 Propose: Define tagged README and supplementary-file states, stable reconciliation identities, headless-create behavior, and an exact-path operation preview for the full authoring block

## 13. Create and Edit Authoring — Implementation

- [ ] 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
- [ ] 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
- [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
- [ ] 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
Expand Down
15 changes: 14 additions & 1 deletion packages/cli/src/__tests__/create-build.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ describe('writeScaffold', () => {
skills: ['code-review', 'testing-guide'],
agents: ['reviewer'],
commands: ['deploy'],
readme: { kind: 'disabled' },
},
dir,
)
Expand Down Expand Up @@ -122,6 +123,7 @@ describe('writeScaffold', () => {
skills: ['minimal'],
agents: [],
commands: [],
readme: { kind: 'disabled' },
},
dir,
)
Expand All @@ -148,6 +150,7 @@ describe('writeScaffold', () => {
skills: ['example'],
agents: [],
commands: [],
readme: { kind: 'disabled' },
},
dir,
)
Expand All @@ -169,6 +172,7 @@ describe('writeScaffold', () => {
skills: ['cowsay'],
agents: [],
commands: [],
readme: { kind: 'disabled' },
},
dir,
)
Expand Down Expand Up @@ -196,6 +200,7 @@ describe('writeScaffold', () => {
skills: ['helper'],
agents: ['assistant'],
commands: [],
readme: { kind: 'disabled' },
},
dir,
)
Expand Down Expand Up @@ -329,7 +334,15 @@ describe('facet build --verify', () => {
async function scaffoldValid(name: string): Promise<string> {
const dir = await createFixtureDir(name)
await writeScaffold(
{ name: 'verifiable', version: DEFAULT_VERSION, description: 'x', skills: ['helper'], agents: [], commands: [] },
{
name: 'verifiable',
version: DEFAULT_VERSION,
description: 'x',
skills: ['helper'],
agents: [],
commands: [],
readme: { kind: 'disabled' },
},
dir,
)
return dir
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/__tests__/modify.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ async function fixture(name: string): Promise<string> {
skills: ['greet'],
agents: ['helper'],
commands: [],
readme: { kind: 'disabled' },
},
dir,
)
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/commands/create/__tests__/headless.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,27 @@ describe('decideCreate — headless validation', () => {
skills: ['greet'],
agents: ['helper'],
commands: [],
// README is on by default, seeded from the (empty) description.
readme: { kind: 'enabled', content: '# my-facet\n' },
})
})

test('README is enabled by default and seeded from identity', () => {
const d = decideCreate({ name: 'my-facet', description: 'Neat tools', skill: ['greet'] })
if (d.mode !== 'headless') expect.unreachable()
expect(d.options.readme).toEqual({ kind: 'enabled', content: '# my-facet\n\nNeat tools\n' })
})

test('--no-readme opts out (readme: false)', () => {
const d = decideCreate({ name: 'my-facet', skill: ['greet'], readme: false })
if (d.mode !== 'headless') expect.unreachable()
expect(d.options.readme).toEqual({ kind: 'disabled' })
})

test('a lone --no-readme does not trigger headless mode', () => {
expect(decideCreate({ readme: false }).mode).toBe('wizard')
})

test('honors version, description, and private', () => {
const d = decideCreate({
name: 'my-facet',
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/commands/create/headless.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DEFAULT_VERSION, isValidSemVer, type ScaffoldOptions } from '@agent-facets/engine'
import { DEFAULT_VERSION, isValidSemVer, readmeTemplate, type ScaffoldOptions } from '@agent-facets/engine'
import { validateAssetNameSegment, validateFacetName } from '@agent-facets/protocol'
import type { CliError } from '../../util/errors.ts'

Expand Down Expand Up @@ -99,13 +99,20 @@ export function decideCreate(flags: Record<string, unknown>): CreateDecision {
}
}

// README is on by default; `--no-readme` (parsed as `readme: false`) opts out.
// Headless and interactive create therefore produce the same seeded README by
// default, matching design D11's "never diverge by default" policy.
const readme: ScaffoldOptions['readme'] =
flags.readme === false ? { kind: 'disabled' } : { kind: 'enabled', content: readmeTemplate(name, description) }

const options: ScaffoldOptions = {
name,
version,
description,
skills,
agents,
commands,
readme,
...(flags.private === true ? { private: true as const } : {}),
}

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/create/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const createCommand: Command = {
skill: { type: 'array', description: 'Skill to scaffold, repeatable (headless mode)' },
agent: { type: 'array', description: 'Agent to scaffold, repeatable (headless mode)' },
command: { type: 'array', description: 'Command to scaffold, repeatable (headless mode)' },
readme: { type: 'boolean', description: 'Scaffold a README.md (default on; pass --no-readme to skip)' },
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

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 Honor --no-readme in the wizard

When users run facet create --no-readme without another headless content flag, decideCreate deliberately selects the wizard, but the flag is never passed into its initial form state. The wizard therefore starts with README enabled and will create and declare README.md unless the user manually disables it, despite the advertised --no-readme opt-out. Apply this flag to the wizard state (or make it select an appropriate noninteractive path) as well as the headless options.

Useful? React with 👍 / 👎.

json: { type: 'boolean', description: 'Emit machine-readable JSON to stdout (headless mode)' },
},
run: async (args: string[], flags: Record<string, unknown>): Promise<number> => {
Expand Down
82 changes: 49 additions & 33 deletions packages/cli/src/commands/create/wizard.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
import type { ScaffoldOptions as CreateOptions } from '@agent-facets/engine'
import { render } from 'ink'
import type { AssetSectionKey } from '../../tui/context/form-state-context.ts'
import { openInEditorSync } from '../../tui/editor.ts'
import type { WizardSnapshot } from '../../tui/views/create/wizard.tsx'
import type { EditorRequest, WizardSnapshot } from '../../tui/views/create/wizard.tsx'
import { CreateWizard } from '../../tui/views/create/wizard.tsx'

interface EditorRequest {
section: AssetSectionKey
name: string
description: string
}

export interface RunCreateWizardOptions {
/** Write the scaffold to disk and return the list of created file paths. */
onScaffold: (opts: CreateOptions) => Promise<string[]>
Expand Down Expand Up @@ -46,8 +39,8 @@ export async function runCreateWizardInk(options: RunCreateWizardOptions): Promi
onSnapshot={(s) => {
snapshot = s
}}
onRequestEditor={(section, name, description) => {
pendingEditor = { section, name, description }
onRequestEditor={(request) => {
pendingEditor = request
instance.clear()
instance.unmount()
}}
Expand All @@ -57,33 +50,56 @@ export async function runCreateWizardInk(options: RunCreateWizardOptions): Promi
instance.waitUntilExit().then(() => resolve())
})

if (pendingEditor) {
const req = pendingEditor as EditorRequest
const edited = openInEditorSync(req.description, `${req.name}.md`)
if (snapshot) {
const section = snapshot.form.assets[req.section]
snapshot = {
...snapshot,
selectedItem: undefined,
form: {
...snapshot.form,
assets: {
...snapshot.form.assets,
[req.section]: {
...section,
descriptions: {
...section.descriptions,
...(edited !== null ? { [req.name]: edited.trim() } : {}),
},
},
},
},
}
}
if (pendingEditor && snapshot) {
const req: EditorRequest = pendingEditor
snapshot = mergeEditorResult(snapshot, req)
} else {
done = true
}
}

return completed
}

/**
* Open the external editor for one request and merge its result back into the
* wizard snapshot. Asset descriptions are trimmed (single-line semantics);
* README content is stored verbatim and marked authored so later identity edits
* never regenerate it.
*/
function mergeEditorResult(snapshot: WizardSnapshot, req: EditorRequest): WizardSnapshot {
if (req.kind === 'asset-description') {
const edited = openInEditorSync(req.content, `${req.name}.md`)
const section = snapshot.form.assets[req.section]
return {
...snapshot,
selectedItem: undefined,
form: {
...snapshot.form,
assets: {
...snapshot.form.assets,
[req.section]: {
...section,
descriptions: {
...section.descriptions,
...(edited !== null ? { [req.name]: edited.trim() } : {}),
},
},
},
},
}
}
// README: preserve exact author bytes; mark authored so it is never re-seeded.
const edited = openInEditorSync(req.content, 'README.md')
return {
...snapshot,
selectedItem: undefined,
form: {
...snapshot.form,
readme: {
...snapshot.form.readme,
...(edited !== null ? { draft: { origin: 'authored' as const, content: edited } } : {}),
},
},
}
}
85 changes: 85 additions & 0 deletions packages/cli/src/tui/context/__tests__/readme-state.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, test } from 'bun:test'
import type { ScaffoldReadme } from '@agent-facets/engine'
import { render } from 'ink-testing-library'
import { useEffect } from 'react'
import { FormStateProvider, useFormState } from '../form-state-context.ts'

/**
* Drives a sequence of form mutations on mount, then reports the narrowed
* `toCreateOptions().readme` on every render so assertions observe the settled
* state after React flushes updates.
*/
function Probe({
steps,
report,
}: {
steps: (ctx: ReturnType<typeof useFormState>) => void
report: (r: ScaffoldReadme) => void
}) {
const ctx = useFormState()
useEffect(() => {
steps(ctx)
}, [steps, ctx])
report(ctx.toCreateOptions().readme)
return null
Comment on lines +22 to +24

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 Run probe mutations only once

For each test whose steps callback changes form state, including all of the new mutation tests, ctx gets a new identity after the update, so this effect runs again and invokes the setters again. Because each setter returns a new form object even when the values are unchanged, this produces an unbounded render/effect loop rather than a settled result. Depend only on the stable steps callback (or otherwise guard the setup effect) so the probe performs its sequence once.

Useful? React with 👍 / 👎.

}

function nextTick(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve))
}

async function run(steps: (ctx: ReturnType<typeof useFormState>) => void): Promise<ScaffoldReadme> {
let result: ScaffoldReadme = { kind: 'disabled' }
const instance = render(
<FormStateProvider>
<Probe steps={steps} report={(r) => (result = r)} />
</FormStateProvider>,
)
await nextTick()
instance.unmount()
return result
}

describe('create README form state', () => {
test('README is enabled by default', async () => {
const readme = await run(() => {})
expect(readme.kind).toBe('enabled')
})

test('seeded content re-seeds from identity edits', async () => {
const readme = await run((ctx) => {
ctx.setFieldValue('name', 'my-facet')
ctx.setFieldValue('description', 'Neat tools')
})
if (readme.kind !== 'enabled') expect.unreachable()
expect(readme.content).toBe('# my-facet\n\nNeat tools\n')
})

test('authored content is preserved across later identity edits', async () => {
const readme = await run((ctx) => {
ctx.setFieldValue('name', 'my-facet')
ctx.setReadmeContent('# Custom\n\nHand-written docs.\n')
// A later identity edit MUST NOT regenerate the authored content.
ctx.setFieldValue('name', 'renamed')
})
if (readme.kind !== 'enabled') expect.unreachable()
expect(readme.content).toBe('# Custom\n\nHand-written docs.\n')
})

test('disable then re-enable preserves the draft', async () => {
const readme = await run((ctx) => {
ctx.setReadmeContent('# Kept\n')
ctx.setReadmeEnabled(false)
ctx.setReadmeEnabled(true)
})
if (readme.kind !== 'enabled') expect.unreachable()
expect(readme.content).toBe('# Kept\n')
})

test('disabled narrows to a disabled scaffold option', async () => {
const readme = await run((ctx) => {
ctx.setReadmeEnabled(false)
})
expect(readme).toEqual({ kind: 'disabled' })
})
})
2 changes: 1 addition & 1 deletion packages/cli/src/tui/context/focus-order-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { createContext, createElement, useCallback, useContext, useMemo, useStat
* toggle (`field-private`) uses Tab to flip Public/Private; ↓ still advances,
* and Shift+Tab still moves backward.
*/
export const TAB_TOGGLE_FOCUS_IDS: ReadonlySet<string> = new Set(['field-private'])
export const TAB_TOGGLE_FOCUS_IDS: ReadonlySet<string> = new Set(['field-private', 'field-readme'])

interface FocusOrderState {
focusedId: string | null
Expand Down
Loading