diff --git a/openspec/changes/support-non-asset-files/tasks.md b/openspec/changes/support-non-asset-files/tasks.md index 4044d344..020149a1 100644 --- a/openspec/changes/support-non-asset-files/tasks.md +++ b/openspec/changes/support-non-asset-files/tasks.md @@ -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 diff --git a/packages/cli/src/__tests__/create-build.e2e.test.ts b/packages/cli/src/__tests__/create-build.e2e.test.ts index c7bc87a3..812e4e08 100644 --- a/packages/cli/src/__tests__/create-build.e2e.test.ts +++ b/packages/cli/src/__tests__/create-build.e2e.test.ts @@ -74,6 +74,7 @@ describe('writeScaffold', () => { skills: ['code-review', 'testing-guide'], agents: ['reviewer'], commands: ['deploy'], + readme: { kind: 'disabled' }, }, dir, ) @@ -122,6 +123,7 @@ describe('writeScaffold', () => { skills: ['minimal'], agents: [], commands: [], + readme: { kind: 'disabled' }, }, dir, ) @@ -148,6 +150,7 @@ describe('writeScaffold', () => { skills: ['example'], agents: [], commands: [], + readme: { kind: 'disabled' }, }, dir, ) @@ -169,6 +172,7 @@ describe('writeScaffold', () => { skills: ['cowsay'], agents: [], commands: [], + readme: { kind: 'disabled' }, }, dir, ) @@ -196,6 +200,7 @@ describe('writeScaffold', () => { skills: ['helper'], agents: ['assistant'], commands: [], + readme: { kind: 'disabled' }, }, dir, ) @@ -329,7 +334,15 @@ describe('facet build --verify', () => { async function scaffoldValid(name: string): Promise { 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 diff --git a/packages/cli/src/__tests__/modify.e2e.test.ts b/packages/cli/src/__tests__/modify.e2e.test.ts index 36766eef..9674e7b7 100644 --- a/packages/cli/src/__tests__/modify.e2e.test.ts +++ b/packages/cli/src/__tests__/modify.e2e.test.ts @@ -44,6 +44,7 @@ async function fixture(name: string): Promise { skills: ['greet'], agents: ['helper'], commands: [], + readme: { kind: 'disabled' }, }, dir, ) diff --git a/packages/cli/src/commands/create/__tests__/headless.test.ts b/packages/cli/src/commands/create/__tests__/headless.test.ts index 0bf87e8a..8092af53 100644 --- a/packages/cli/src/commands/create/__tests__/headless.test.ts +++ b/packages/cli/src/commands/create/__tests__/headless.test.ts @@ -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', diff --git a/packages/cli/src/commands/create/headless.ts b/packages/cli/src/commands/create/headless.ts index 7c785aaa..5570eada 100644 --- a/packages/cli/src/commands/create/headless.ts +++ b/packages/cli/src/commands/create/headless.ts @@ -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' @@ -99,6 +99,12 @@ export function decideCreate(flags: Record): 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, @@ -106,6 +112,7 @@ export function decideCreate(flags: Record): CreateDecision { skills, agents, commands, + readme, ...(flags.private === true ? { private: true as const } : {}), } diff --git a/packages/cli/src/commands/create/index.ts b/packages/cli/src/commands/create/index.ts index 34b67310..6275529a 100644 --- a/packages/cli/src/commands/create/index.ts +++ b/packages/cli/src/commands/create/index.ts @@ -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)' }, json: { type: 'boolean', description: 'Emit machine-readable JSON to stdout (headless mode)' }, }, run: async (args: string[], flags: Record): Promise => { diff --git a/packages/cli/src/commands/create/wizard.tsx b/packages/cli/src/commands/create/wizard.tsx index 5d7cbab3..b733046e 100644 --- a/packages/cli/src/commands/create/wizard.tsx +++ b/packages/cli/src/commands/create/wizard.tsx @@ -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 @@ -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() }} @@ -57,29 +50,9 @@ 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 } @@ -87,3 +60,46 @@ export async function runCreateWizardInk(options: RunCreateWizardOptions): Promi 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 } } : {}), + }, + }, + } +} diff --git a/packages/cli/src/tui/context/__tests__/readme-state.test.tsx b/packages/cli/src/tui/context/__tests__/readme-state.test.tsx new file mode 100644 index 00000000..baf4b011 --- /dev/null +++ b/packages/cli/src/tui/context/__tests__/readme-state.test.tsx @@ -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) => void + report: (r: ScaffoldReadme) => void +}) { + const ctx = useFormState() + useEffect(() => { + steps(ctx) + }, [steps, ctx]) + report(ctx.toCreateOptions().readme) + return null +} + +function nextTick(): Promise { + return new Promise((resolve) => setImmediate(resolve)) +} + +async function run(steps: (ctx: ReturnType) => void): Promise { + let result: ScaffoldReadme = { kind: 'disabled' } + const instance = render( + + (result = r)} /> + , + ) + 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' }) + }) +}) diff --git a/packages/cli/src/tui/context/focus-order-context.ts b/packages/cli/src/tui/context/focus-order-context.ts index 682b0a8c..deb13885 100644 --- a/packages/cli/src/tui/context/focus-order-context.ts +++ b/packages/cli/src/tui/context/focus-order-context.ts @@ -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 = new Set(['field-private']) +export const TAB_TOGGLE_FOCUS_IDS: ReadonlySet = new Set(['field-private', 'field-readme']) interface FocusOrderState { focusedId: string | null diff --git a/packages/cli/src/tui/context/form-state-context.ts b/packages/cli/src/tui/context/form-state-context.ts index 7b4fd829..bfdcde53 100644 --- a/packages/cli/src/tui/context/form-state-context.ts +++ b/packages/cli/src/tui/context/form-state-context.ts @@ -1,4 +1,4 @@ -import type { ScaffoldOptions as CreateOptions } from '@agent-facets/engine' +import { type ScaffoldOptions as CreateOptions, readmeTemplate } from '@agent-facets/engine' import { validateAssetNameSegment } from '@agent-facets/protocol' import type { ReactNode } from 'react' import { createContext, createElement, useCallback, useContext, useMemo, useState } from 'react' @@ -21,6 +21,22 @@ export interface AssetSectionState { adding: boolean } +/** + * Create-wizard README state. + * + * `enabled` is the author's default-on/opt-out choice; the `draft` is always + * present so toggling README off and back on never loses authored content. + * + * `draft.origin` guards regeneration: while `seeded`, identity edits refresh + * the template; the first explicit README edit flips it to `authored` and it + * is then preserved verbatim across later identity edits (design D11 — "the + * generated template is an initial value only"). + */ +export interface ReadmeState { + enabled: boolean + draft: { origin: 'seeded' | 'authored'; content: string } +} + export interface FormState { fields: { name: FieldState @@ -34,6 +50,7 @@ export interface FormState { // in the manifest is a serialization concern handled at the output boundary // (scaffold generation for create, `buildManifest` for edit), not here. private: boolean + readme: ReadmeState assets: { skill: AssetSectionState command: AssetSectionState @@ -53,6 +70,10 @@ interface FormStateContextValue { // Privacy operation setPrivate: (value: boolean) => void + // README operations + setReadmeEnabled: (value: boolean) => void + setReadmeContent: (content: string) => void + // Asset operations addAsset: (section: AssetSectionKey, name: string) => void removeAsset: (section: AssetSectionKey, name: string) => void @@ -74,6 +95,12 @@ const defaultAssetSection: AssetSectionState = { adding: false, } +/** README defaults on for interactive create, seeded from the (empty) identity. */ +const defaultReadme: ReadmeState = { + enabled: true, + draft: { origin: 'seeded', content: readmeTemplate('', '') }, +} + const defaultForm: FormState = { fields: { name: { value: '', status: 'empty' }, @@ -81,6 +108,7 @@ const defaultForm: FormState = { version: { value: '', status: 'empty' }, }, private: false, + readme: { enabled: defaultReadme.enabled, draft: { ...defaultReadme.draft } }, assets: { skill: { ...defaultAssetSection }, command: { ...defaultAssetSection }, @@ -93,13 +121,23 @@ const FormStateContext = createContext({ setFieldValue: () => {}, setFieldStatus: () => {}, setPrivate: () => {}, + setReadmeEnabled: () => {}, + setReadmeContent: () => {}, addAsset: () => {}, removeAsset: () => {}, renameAsset: () => {}, setAssetDescription: () => {}, setAssetAdding: () => {}, setAssetEditing: () => {}, - toCreateOptions: () => ({ name: '', version: '', description: '', skills: [], commands: [], agents: [] }), + toCreateOptions: () => ({ + name: '', + version: '', + description: '', + skills: [], + commands: [], + agents: [], + readme: { kind: 'enabled', content: readmeTemplate('', '') }, + }), }) // --- Provider --- @@ -108,13 +146,21 @@ export function FormStateProvider({ children, initialState }: { children: ReactN const [form, setForm] = useState(initialState ?? defaultForm) const setFieldValue = useCallback((field: RequiredFieldKey, value: string) => { - setForm((prev) => ({ - ...prev, - fields: { + setForm((prev) => { + const fields = { ...prev.fields, [field]: { ...prev.fields[field], value }, - }, - })) + } + // While the README draft is still the seeded template, identity edits + // re-seed it from the new name/description. Once authored, it is frozen. + let readme = prev.readme + if ((field === 'name' || field === 'description') && prev.readme.draft.origin === 'seeded') { + const nextName = field === 'name' ? value : prev.fields.name.value + const nextDescription = field === 'description' ? value : prev.fields.description.value + readme = { ...prev.readme, draft: { origin: 'seeded', content: readmeTemplate(nextName, nextDescription) } } + } + return { ...prev, fields, readme } + }) }, []) const setFieldStatus = useCallback((field: RequiredFieldKey, status: FieldStatus) => { @@ -131,6 +177,15 @@ export function FormStateProvider({ children, initialState }: { children: ReactN setForm((prev) => ({ ...prev, private: value })) }, []) + const setReadmeEnabled = useCallback((value: boolean) => { + setForm((prev) => ({ ...prev, readme: { ...prev.readme, enabled: value } })) + }, []) + + const setReadmeContent = useCallback((content: string) => { + // An explicit edit freezes the draft: identity edits no longer re-seed it. + setForm((prev) => ({ ...prev, readme: { ...prev.readme, draft: { origin: 'authored', content } } })) + }, []) + const addAsset = useCallback((section: AssetSectionKey, name: string) => { if (!validateAssetNameSegment(name).ok) return setForm((prev) => { @@ -235,6 +290,7 @@ export function FormStateProvider({ children, initialState }: { children: ReactN skills: form.assets.skill.items, commands: form.assets.command.items, agents: form.assets.agent.items, + readme: form.readme.enabled ? { kind: 'enabled', content: form.readme.draft.content } : { kind: 'disabled' }, // True-only: public is represented by omission, never `private: false`. ...(form.private ? { private: true } : {}), }), @@ -247,6 +303,8 @@ export function FormStateProvider({ children, initialState }: { children: ReactN setFieldValue, setFieldStatus, setPrivate, + setReadmeEnabled, + setReadmeContent, addAsset, removeAsset, renameAsset, @@ -260,6 +318,8 @@ export function FormStateProvider({ children, initialState }: { children: ReactN setFieldValue, setFieldStatus, setPrivate, + setReadmeEnabled, + setReadmeContent, addAsset, removeAsset, renameAsset, 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 5070f70f..744416f8 100644 --- a/packages/cli/src/tui/views/__tests__/confirm-privacy.test.tsx +++ b/packages/cli/src/tui/views/__tests__/confirm-privacy.test.tsx @@ -13,6 +13,7 @@ function formWith(isPrivate: boolean): FormState { version: { value: '0.0.0', status: 'confirmed' }, }, private: isPrivate, + readme: { enabled: false, draft: { origin: 'seeded', content: '' } }, assets: { skill: { items: ['cowsay'], descriptions: { cowsay: 'A skill' }, editing: undefined, adding: false }, command: { items: [], descriptions: {}, editing: undefined, adding: false }, @@ -33,6 +34,7 @@ function renderCreate(isPrivate: boolean) { skills: ['cowsay'], agents: [], commands: [], + readme: { kind: 'disabled' }, ...(isPrivate ? { private: true as const } : {}), }} onConfirm={() => {}} diff --git a/packages/cli/src/tui/views/create/create-view.tsx b/packages/cli/src/tui/views/create/create-view.tsx index b1702f3a..ca705235 100644 --- a/packages/cli/src/tui/views/create/create-view.tsx +++ b/packages/cli/src/tui/views/create/create-view.tsx @@ -20,9 +20,19 @@ const ASSET_LABELS: Record = { } function computeFocusIds(form: ReturnType['form']): string[] { - // NOTE: `field-private` sits between `field-version` and the asset controls. - // This focus list is duplicated in edit-view.tsx; keep both in lockstep. - const ids: string[] = ['field-name', 'field-description', 'field-version', 'field-private'] + // NOTE: `field-private` sits between `field-version` and the asset controls, + // followed by the create-only README card (`field-readme`, `readme-edit-btn`). + // The identity/privacy prefix mirrors edit-view.tsx; the README card is + // create-only (edit routes README through its dedicated panel). Both README + // ids are always present so toggling README off never churns the focus set. + const ids: string[] = [ + 'field-name', + 'field-description', + 'field-version', + 'field-private', + 'field-readme', + 'readme-edit-btn', + ] for (const type of ASSET_TYPES) { const section = form.assets[type] @@ -42,11 +52,13 @@ function computeFocusIds(form: ReturnType['form']): string[ export function CreateView({ onSubmit, onEditDescription, + onEditReadme, }: { onSubmit: () => void onEditDescription?: (section: import('../../context/form-state-context.ts').AssetSectionKey, name: string) => void + onEditReadme?: () => void }) { - const { form, setPrivate } = useFormState() + const { form, setPrivate, setReadmeEnabled } = useFormState() const { setFocusIds, focus, focusedId } = useFocusOrder() // Facet identity: an unscoped slug (`my-facet`) or a scoped `@scope/name` @@ -140,9 +152,29 @@ export function CreateView({ offLabel="Public" onToggle={setPrivate} dimmed={!assetsReady} - onConfirm={() => focus(`add-${ASSET_TYPES[0]}`)} + onConfirm={() => focus('field-readme')} /> + focus('readme-edit-btn')} + /> + + +