From 149fb358c6222293e4ed502866d9b0918bd10ef8 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 10 Sep 2026 20:50:05 -0700 Subject: [PATCH 1/2] feat: guide adoption of existing library skills --- .changeset/guided-maintainer-adoption.md | 5 + packages/intent/meta/generate-skill/SKILL.md | 2 +- .../references/maintainer-commands.md | 9 +- packages/intent/src/cli.ts | 31 +- packages/intent/src/commands/maintainer.ts | 80 +++- packages/intent/src/maintainer/add.ts | 246 ++++++------ packages/intent/src/maintainer/adopt.ts | 355 ++++++++++++++++++ .../intent/src/maintainer/adoption-prompts.ts | 124 ++++++ .../intent/src/maintainer/distribution.ts | 62 +-- packages/intent/src/maintainer/project.ts | 46 ++- .../tests/integration/packed-release.test.ts | 2 +- packages/intent/tests/maintainer.test.ts | 290 ++++++++++++++ 12 files changed, 1089 insertions(+), 163 deletions(-) create mode 100644 .changeset/guided-maintainer-adoption.md create mode 100644 packages/intent/src/maintainer/adopt.ts create mode 100644 packages/intent/src/maintainer/adoption-prompts.ts diff --git a/.changeset/guided-maintainer-adoption.md b/.changeset/guided-maintainer-adoption.md new file mode 100644 index 00000000..76eb881e --- /dev/null +++ b/.changeset/guided-maintainer-adoption.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': minor +--- + +Add guided adoption of existing package-owned skills with a read-only JSON plan, explicit batch registration and distribution choices, and interactive confirmation. Preserve authored guidance and prior records, reject stale plans, leave semantic review pending, and keep CI noninteractive. diff --git a/packages/intent/meta/generate-skill/SKILL.md b/packages/intent/meta/generate-skill/SKILL.md index e575bba3..8dd05229 100644 --- a/packages/intent/meta/generate-skill/SKILL.md +++ b/packages/intent/meta/generate-skill/SKILL.md @@ -1,7 +1,7 @@ --- name: generate-skill description: > - Use when creating or extending library skill batches, updating guidance after library source or docs change, configuring repository skill distribution, or acting on an Intent review report, including when the maintainer asks only for the library change. For full-library discovery or taxonomy design, use domain-discovery; for generating an approved full-library tree, use tree-generator. + Use when creating, adopting, or extending library skill batches, updating guidance after library source or docs change, configuring repository skill distribution, or acting on an Intent review report, including when the maintainer asks only for the library change. For full-library discovery or taxonomy design, use domain-discovery; for generating an approved full-library tree, use tree-generator. metadata: purpose: > Author and maintain library skills when creating an initial skill batch, changing library source or docs, or acting on an Intent review report. Use the current task and source evidence to create useful guidance, verify it on representative tasks, and update affected skills. diff --git a/packages/intent/meta/generate-skill/references/maintainer-commands.md b/packages/intent/meta/generate-skill/references/maintainer-commands.md index d07418a7..6a676b03 100644 --- a/packages/intent/meta/generate-skill/references/maintainer-commands.md +++ b/packages/intent/meta/generate-skill/references/maintainer-commands.md @@ -1,6 +1,6 @@ # Run the maintainer workflow -Use the repository's Intent command for all six actions. These commands perform bookkeeping; the maintainer or coding agent still supplies task knowledge, source-backed guidance, and review conclusions. +Use the repository's Intent commands for bookkeeping; the maintainer or coding agent supplies task knowledge, source-backed guidance, and review conclusions. 1. Run `intent maintainer setup` once. It installs repository guidance and creates missing planning records, preserving existing documents. A monorepo uses one shared record and package-owned skill directories. If several record locations exist, select the established one with `--artifacts `; do not merge them by guessing. Read [repository distribution](distribution.md) and explain the option to the maintainer during setup. Save the selected skills or opt-out with the setup command; do not infer a public selection from directory placement or repeat a recorded decision. 2. For a new task, run `intent maintainer add --domain --description --source `. In a monorepo, pass `--package packages/`. Repeat `--source` or `--requires` for multiple entries. Source paths are relative to the owning package; `owner/repo:path` is relative to the repository. Use `--path /SKILL.md` for an established custom layout. To register an existing skill, supply its name, domain, package, and path; its frontmatter supplies the other fields. @@ -11,4 +11,11 @@ Use the repository's Intent command for all six actions. These commands perform Keep unimplemented future skills in the tree with `status: planned` and retired entries with `status: retired`. They remain part of the cumulative record but do not count as implemented skills or enter package publishing configuration. An active entry with a missing file is an error to resolve, not an invitation to delete the entry. Local prerequisite slugs are checked against implemented tree entries; verify external package prerequisites and the developer task through the task-quality procedure. +## Adopt existing skills + +1. Preview registrations with `intent maintainer adopt --json`. For a custom directory outside `skills/`, add `--path `. Preserve the established planning location with `--artifacts` when several exist. +2. Save the plan, mark chosen unregistered entries with `selected: true`, and supply their missing `domain`. Keep all identities and the fingerprint unchanged. Preserve authored descriptions, purposes, and skill contents. +3. Record an explicit distribution selection or opt-out, or retain the current choice. Apply with `intent maintainer adopt --apply `; regenerate the plan if source or records changed. A human terminal can run `intent maintainer adopt` for the same choices and confirmation. CI stays noninteractive; use JSON reporting or an explicitly authorized apply. +4. Complete task coverage and source review through the normal workflow. Adoption creates missing records and installs maintainer guidance, but does not author skills, approve content, synchronize, or publish. Use `maintainer check --base ` as the read-only CI gate. + The former `scaffold` command is removed. Use `maintainer setup` and `maintainer add` for file creation, and `meta generate-skill` for authoring guidance. Full-library research and taxonomy design remain available through `meta domain-discovery` and `meta tree-generator` when requested. diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 803d4256..44c21bc6 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -14,11 +14,16 @@ import type { import type { ListCommandOptions } from './commands/list.js' import type { LoadCommandOptions } from './commands/load.js' import type { StaleCommandOptions } from './commands/stale.js' -import type { MaintainerCommandOptions } from './commands/maintainer.js' +import type { + MaintainerCommandOptions, + MaintainerCommandRuntime, +} from './commands/maintainer.js' import type { ReviewCommandOptions } from './commands/review.js' import type { ValidateCommandOptions } from './commands/validate.js' -function createCli(runtime: InstallCommandRuntime = {}): CAC { +function createCli( + runtime: InstallCommandRuntime & MaintainerCommandRuntime = {}, +): CAC { const cli = cac('intent') cli.usage(' [options]') @@ -191,7 +196,9 @@ function createCli(runtime: InstallCommandRuntime = {}): CAC { 'maintainer [name]', 'Set up, author, synchronize, and check library skills', ) - .usage('maintainer [name] [options]') + .usage( + 'maintainer [name] [options]', + ) .option( '--artifacts ', 'Established planning directory, relative to the repository root', @@ -200,7 +207,14 @@ function createCli(runtime: InstallCommandRuntime = {}): CAC { '--package ', 'Owning package directory, relative to the repository root', ) - .option('--path ', 'SKILL.md path, relative to the owning package') + .option( + '--path ', + 'Skill path for add, or repository-relative custom directory for adopt', + ) + .option( + '--apply ', + 'Apply reviewed adoption choices from a JSON plan', + ) .option('--domain ', 'Domain for a new skill') .option( '--distribution ', @@ -225,12 +239,15 @@ function createCli(runtime: InstallCommandRuntime = {}): CAC { 'Prerequisite skill; repeat for multiple skills', ) .option('--base ', 'Git revision to review against') - .option('--json', 'Output status or review as JSON') + .option('--json', 'Output an adoption plan, status, or review as JSON') .option( '--record ', 'Record outcomes from an annotated review report', ) .example('maintainer setup') + .example('maintainer adopt') + .example('maintainer adopt --json') + .example('maintainer adopt --apply adoption.json') .example( 'maintainer add caching --domain queries --description "Use when caching queries." --source "src/**"', ) @@ -246,7 +263,7 @@ function createCli(runtime: InstallCommandRuntime = {}): CAC { ) => { const { runMaintainerCommand } = await import('./commands/maintainer.js') - await runMaintainerCommand(action, name, options) + await runMaintainerCommand(action, name, options, runtime) }, ) @@ -366,7 +383,7 @@ function createCli(runtime: InstallCommandRuntime = {}): CAC { export async function main( argv: Array = process.argv.slice(2), - runtime: InstallCommandRuntime = {}, + runtime: InstallCommandRuntime & MaintainerCommandRuntime = {}, ) { try { const cli = createCli(runtime) diff --git a/packages/intent/src/commands/maintainer.ts b/packages/intent/src/commands/maintainer.ts index 530a93b8..e150b459 100644 --- a/packages/intent/src/commands/maintainer.ts +++ b/packages/intent/src/commands/maintainer.ts @@ -1,10 +1,13 @@ -import { dirname, relative } from 'node:path' +import { readFileSync } from 'node:fs' +import { dirname, relative, resolve } from 'node:path' +import { isCI } from 'std-env' import { fail } from '../shared/cli-error.js' import { resolveMaintainerProject, setupRecords, } from '../maintainer/project.js' import { addSkill } from '../maintainer/add.js' +import { createAdoptionPlan, planAdoptionChanges } from '../maintainer/adopt.js' import { planMaintainerSync } from '../maintainer/sync.js' import { withMaintainerLock, writeChanges } from '../maintainer/files.js' import { createReview } from '../review/review.js' @@ -21,6 +24,13 @@ import { import { runReviewCommand } from './review.js' import { runValidateCommand } from './validate.js' import type { DistributionOptions } from '../maintainer/distribution.js' +import type { AdoptionPrompts } from '../maintainer/adopt.js' + +export interface MaintainerCommandRuntime { + isTTY?: boolean + isCI?: boolean + adoptionPrompts?: AdoptionPrompts +} export interface MaintainerCommandOptions extends DistributionOptions { artifacts?: string @@ -33,15 +43,18 @@ export interface MaintainerCommandOptions extends DistributionOptions { base?: string json?: boolean record?: string + apply?: string } export async function runMaintainerCommand( action: string, name: string | undefined, options: MaintainerCommandOptions, + runtime: MaintainerCommandRuntime = {}, ): Promise { const allowed: Record> = { setup: ['artifacts', 'distribution', 'repository', 'pluginName', 'skill'], + adopt: ['artifacts', 'json', 'path', 'apply'], add: [ 'artifacts', 'package', @@ -58,7 +71,7 @@ export async function runMaintainerCommand( } if (!allowed[action]) fail( - `Unknown maintainer action: ${action}. Expected setup, add, status, sync, review, or check.`, + `Unknown maintainer action: ${action}. Expected setup, adopt, add, status, sync, review, or check.`, ) if (name !== undefined && action !== 'add') fail(`maintainer ${action} does not take a skill name.`) @@ -71,6 +84,66 @@ export async function runMaintainerCommand( return } const project = resolveMaintainerProject(process.cwd(), options.artifacts) + if (action === 'adopt') { + let input: unknown + if (options.apply) { + if (options.json || options.path) + fail('--apply cannot be combined with --json or --path.') + input = JSON.parse(readFileSync(resolve(options.apply), 'utf8')) + } else { + const plan = createAdoptionPlan(project, options.path) + if (options.json) { + console.log(JSON.stringify(plan, null, 2)) + return + } + if ( + (runtime.isCI ?? isCI) || + !(runtime.isTTY ?? (process.stdin.isTTY && process.stdout.isTTY)) + ) + fail( + 'Use maintainer adopt --json to preview, then --apply with explicit choices in noninteractive sessions.', + ) + for (const skill of plan.skills) + console.log( + `${JSON.stringify(skill.id)}: ${skill.status}${skill.problems.length ? ` (${skill.problems.join('; ')})` : ''}`, + ) + const prompts = + runtime.adoptionPrompts ?? + ( + await import('../maintainer/adoption-prompts.js') + ).createAdoptionPrompts() + const chosen = await prompts.choose(plan) + if (chosen === null) { + console.log('Adoption canceled. No files changed.') + return + } + const preview = planAdoptionChanges(project, chosen) + const files = preview.changes.map((change) => + relative(project.root, change.path), + ) + if (!(await prompts.confirm(chosen, files))) { + console.log('Adoption canceled. No files changed.') + return + } + input = chosen + } + await withMaintainerLock(project.root, () => { + const plan = planAdoptionChanges(project, input) + writeChanges(project.root, plan.changes) + writeIntentSkillsBlock({ + ...buildMaintainerGuidanceBlock( + detectIntentCommandPackageManager(project.root), + ), + root: project.root, + namespace: 'intent-maintainer', + skipWhenEmpty: false, + }) + console.log( + `Registered ${plan.paths.length} skill(s). Authored task coverage and source review remain required.`, + ) + }) + return + } if (['setup', 'add', 'sync'].includes(action)) { await withMaintainerLock(project.root, () => { if (action === 'setup') { @@ -90,6 +163,9 @@ export async function runMaintainerCommand( console.log( 'Next: intent maintainer add --domain --description --source . Use --package for a workspace package. Use intent meta generate-skill for the authoring procedure.', ) + console.log( + 'For existing skills, run intent maintainer adopt to review registrations.', + ) const distribution = readDistribution(project) if (!distribution) console.log(distributionChoice) console.log( diff --git a/packages/intent/src/maintainer/add.ts b/packages/intent/src/maintainer/add.ts index 859dea38..dc5aedd3 100644 --- a/packages/intent/src/maintainer/add.ts +++ b/packages/intent/src/maintainer/add.ts @@ -39,125 +39,145 @@ export function addSkill( name: string | undefined, options: AddSkillOptions, ): string { - if (!name || name.length > 64 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) - throw new Error( - 'Choose a skill name of at most 64 lowercase letters, numbers, and hyphens.', - ) - if (!options.domain?.trim()) - throw new Error('Choose the task domain with --domain .') - const entries = skillEntries(project) - if (entries.some((entry) => (entry.slug ?? entry.name) === name)) - throw new Error( - `Skill ${name} is already registered. Edit its SKILL.md, then run intent maintainer sync.`, - ) - const packageDir = options.package === '.' ? undefined : options.package - const entry: SkillEntry = { - name, - slug: name, - domain: options.domain, - ...(packageDir ? { package: packageDir } : {}), - path: options.path ?? `skills/${name}/SKILL.md`, - } - const path = skillPath(project, entry) - if (basename(dirname(path)) !== name) - throw new Error('The skill name must match its parent directory.') - if (entries.some((existing) => skillPath(project, existing) === path)) - throw new Error(`Skill path is already registered: ${entry.path}`) - const packageRoot = packageDir - ? projectPath(project.root, packageDir) - : project.root - const manifestPath = projectPath( - project.root, - packageDir ? `${packageDir}/package.json` : 'package.json', - ) - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) - if ( - resolveProjectContext({ cwd: project.root, targetPath: path }) - .packageRoot !== packageRoot - ) - throw new Error('The skill path must belong to the selected package.') - let frontmatter: Record - const changes: Array = [] - if (existsSync(path)) { - if (options.description || options.source || options.requires) + const plan = planAddSkills(project, [{ name, options }]) + writeChanges(project.root, plan.changes) + return plan.paths[0]! +} + +export function planAddSkills( + project: MaintainerProject, + additions: Array<{ name: string | undefined; options: AddSkillOptions }>, + initialChanges: Array = [], +) { + const changes = [...initialChanges] + const entries = skillEntries(project, changes) + const tree = readRecord(project, 'skill_tree.yaml', changes) + const map = readRecord(project, 'domain_map.yaml', changes) + const specPath = recordPath(project, 'skill_spec.md') + const specChange = changes.find((change) => change.path === specPath) + const spec = existsSync(specPath) ? readFileSync(specPath, 'utf8') : null + let nextSpec = specChange?.content ?? spec + if (nextSpec === null) + throw new Error('Missing skill_spec.md. Run intent maintainer setup.') + const paths: Array = [] + for (const { name, options } of additions) { + if (!name || name.length > 64 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) throw new Error( - 'To register an existing skill, supply its --path and --domain; edit its frontmatter directly before running sync.', + 'Choose a skill name of at most 64 lowercase letters, numbers, and hyphens.', ) - const parsed = parseFrontmatter(path) - if (!isObject(parsed) || parsed.name !== name) + if (!options.domain?.trim()) + throw new Error('Choose the task domain with --domain .') + if (entries.some((entry) => (entry.slug ?? entry.name) === name)) throw new Error( - 'Existing skill has invalid frontmatter or a different name.', + `Skill ${name} is already registered. Edit its SKILL.md, then run intent maintainer sync.`, ) - frontmatter = parsed - } else { - if (!options.description?.trim()) - throw new Error('A new skill needs --description .') - const sources = stringList( - options.source === undefined ? [] : [options.source].flat(), - 'sources', - ) - if (!sources.length) - throw new Error('A new skill needs at least one --source .') - frontmatter = { - name, - description: options.description, - metadata: { library: manifest.name }, - sources, - ...(options.requires - ? { requires: stringList([options.requires].flat(), 'requires') } - : {}), - } - changes.push({ - path, - source: null, - content: `---\n${stringify(frontmatter)}---\n\n${authoringMarker}\n\nWrite the task procedure, working examples, source-backed pitfalls, and completion checks. Add metadata.purpose in your own words. Remove the authoring marker after writing and checking the guidance.\n`, - }) - } - if ( - typeof frontmatter.description !== 'string' || - !frontmatter.description.trim() - ) - throw new Error('A skill needs a non-empty description.') - entry.description = frontmatter.description - entry.sources = stringList(frontmatter.sources ?? [], 'sources') - entry.requires = stringList(frontmatter.requires ?? [], 'requires') - if ( - isObject(frontmatter.metadata) && - typeof frontmatter.metadata.purpose === 'string' - ) - entry.purpose = frontmatter.metadata.purpose - const tree = readRecord(project, 'skill_tree.yaml') - tree.document.addIn(['skills'], entry) - changes.push({ - path: tree.path, - source: tree.source, - content: tree.document.toString(), - }) - const map = readRecord(project, 'domain_map.yaml') - const mapSkills: Array> = map.document.toJS().skills - if (!mapSkills.some((skill) => skill.slug === name)) { - map.document.addIn(['skills'], { + const packageDir = options.package === '.' ? undefined : options.package + const entry: SkillEntry = { name, slug: name, domain: options.domain, - description: entry.purpose ?? entry.description, - ...(packageDir ? { packages: [manifest.name] } : {}), - tasks: [], - covers: [], - }) + ...(packageDir ? { package: packageDir } : {}), + path: options.path ?? `skills/${name}/SKILL.md`, + } + const path = skillPath(project, entry) + if (basename(dirname(path)) !== name) + throw new Error('The skill name must match its parent directory.') + if (entries.some((existing) => skillPath(project, existing) === path)) + throw new Error(`Skill path is already registered: ${entry.path}`) + const packageRoot = packageDir + ? projectPath(project.root, packageDir) + : project.root + const manifestPath = projectPath( + project.root, + packageDir ? `${packageDir}/package.json` : 'package.json', + ) + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + if ( + resolveProjectContext({ cwd: project.root, targetPath: path }) + .packageRoot !== packageRoot + ) + throw new Error('The skill path must belong to the selected package.') + let frontmatter: Record + if (existsSync(path)) { + if (options.description || options.source || options.requires) + throw new Error( + 'To register an existing skill, supply its --path and --domain; edit its frontmatter directly before running sync.', + ) + const parsed = parseFrontmatter(path) + if (!isObject(parsed) || parsed.name !== name) + throw new Error( + 'Existing skill has invalid frontmatter or a different name.', + ) + frontmatter = parsed + } else { + if (!options.description?.trim()) + throw new Error('A new skill needs --description .') + const sources = stringList( + options.source === undefined ? [] : [options.source].flat(), + 'sources', + ) + if (!sources.length) + throw new Error('A new skill needs at least one --source .') + frontmatter = { + name, + description: options.description, + metadata: { library: manifest.name }, + sources, + ...(options.requires + ? { requires: stringList([options.requires].flat(), 'requires') } + : {}), + } + changes.push({ + path, + source: null, + content: `---\n${stringify(frontmatter)}---\n\n${authoringMarker}\n\nWrite the task procedure, working examples, source-backed pitfalls, and completion checks. Add metadata.purpose in your own words. Remove the authoring marker after writing and checking the guidance.\n`, + }) + } + if ( + typeof frontmatter.description !== 'string' || + !frontmatter.description.trim() + ) + throw new Error('A skill needs a non-empty description.') + entry.description = frontmatter.description + entry.sources = stringList(frontmatter.sources ?? [], 'sources') + entry.requires = stringList(frontmatter.requires ?? [], 'requires') + if ( + isObject(frontmatter.metadata) && + typeof frontmatter.metadata.purpose === 'string' + ) + entry.purpose = frontmatter.metadata.purpose + tree.document.addIn(['skills'], entry) + entries.push(entry) + const mapSkills: Array> = map.document.toJS().skills + if (!mapSkills.some((skill) => skill.slug === name)) { + map.document.addIn(['skills'], { + name, + slug: name, + domain: options.domain, + description: entry.purpose ?? entry.description, + ...(packageDir ? { packages: [manifest.name] } : {}), + tasks: [], + covers: [], + }) + } + nextSpec = `${nextSpec.trimEnd()}\n\n- Registered \`${name}\` in \`${packageDir ?? '.'}\` (domain \`${options.domain}\`). Task coverage, decisions, and checks still need to be recorded.\n` + paths.push(join(packageDir ?? '', entry.path)) + } + if (additions.length) { + changes.push( + { + path: tree.path, + source: tree.source, + content: tree.document.toString(), + }, + { path: map.path, source: map.source, content: map.document.toString() }, + { path: specPath, source: spec, content: nextSpec }, + ) + } + return { + changes: [ + ...new Map(changes.map((change) => [change.path, change])).values(), + ], + paths, } - changes.push({ - path: map.path, - source: map.source, - content: map.document.toString(), - }) - const specPath = recordPath(project, 'skill_spec.md') - const spec = readFileSync(specPath, 'utf8') - changes.push({ - path: specPath, - source: spec, - content: `${spec.trimEnd()}\n\n- Registered \`${name}\` in \`${packageDir ?? '.'}\` (domain \`${options.domain}\`). Task coverage, decisions, and checks still need to be recorded.\n`, - }) - writeChanges(project.root, changes) - return join(packageDir ?? '', entry.path) } diff --git a/packages/intent/src/maintainer/adopt.ts b/packages/intent/src/maintainer/adopt.ts new file mode 100644 index 00000000..7ee62c87 --- /dev/null +++ b/packages/intent/src/maintainer/adopt.ts @@ -0,0 +1,355 @@ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { basename, dirname, relative } from 'node:path' +import { resolveProjectContext } from '../core/project-context.js' +import { resolveWorkspacePackages } from '../setup/workspace-patterns.js' +import { parseFrontmatter } from '../shared/utils.js' +import { planAddSkills, stringList } from './add.js' +import { + inferDistributionRepository, + planDistributionChoice, + readDistribution, +} from './distribution.js' +import { + isObject, + planSetupRecords, + projectPath, + readRecord, + recordPath, + skillEntries, + skillPath, +} from './project.js' +import type { MaintainerProject } from './project.js' + +export interface AdoptionSkill { + id: string + name: string + package: string + path: string + description: string + domain: string + status: + | 'unregistered' + | 'registered' + | 'missing' + | 'invalid' + | 'conflict' + | 'planned' + | 'retired' + problems: Array + selected: boolean +} + +export function createAdoptionPlan( + project: MaintainerProject, + directory?: string, +) { + if (directory) projectPath(project.root, directory) + const files = execFileSync( + 'git', + [ + '-c', + 'core.fsmonitor=false', + 'ls-files', + '--cached', + '--others', + '--exclude-standard', + '-z', + ], + { cwd: project.root, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }, + ) + .split('\0') + .filter(Boolean) + const records = ['domain_map.yaml', 'skill_spec.md', 'skill_tree.yaml'].map( + (name) => { + const path = recordPath(project, name) + return [path, existsSync(path) ? readFileSync(path, 'utf8') : null] + }, + ) + const hasTree = existsSync(recordPath(project, 'skill_tree.yaml')) + const entries = hasTree ? skillEntries(project) : [] + const registered = new Map( + entries.map((entry) => [ + relative(project.root, skillPath(project, entry)).replaceAll('\\', '/'), + entry, + ]), + ) + const mapPath = recordPath(project, 'domain_map.yaml') + const mapped = existsSync(mapPath) + ? readRecord(project, 'domain_map.yaml').document.toJS().skills + : [] + const paths = [ + ...new Set([ + ...files.filter( + (path) => + basename(path) === 'SKILL.md' && + !path + .split('/') + .some((part) => part.startsWith('.') || part === 'node_modules') && + (/(^|\/)skills\//.test(path) || + (directory && path.startsWith(`${directory}/`))), + ), + ...registered.keys(), + ]), + ].sort() + const snapshot: Array = [ + records, + ...[ + 'package.json', + 'pnpm-workspace.yaml', + 'AGENTS.md', + 'CLAUDE.md', + '.cursorrules', + '.github/copilot-instructions.md', + '.claude-plugin/plugin.json', + '.cursor-plugin/plugin.json', + ].map((name) => { + const path = projectPath(project.root, name) + return [name, existsSync(path) ? readFileSync(path, 'utf8') : null] + }), + ] + const packageRoots = new Set([ + project.root, + ...resolveWorkspacePackages( + project.root, + resolveProjectContext({ cwd: project.root }).workspacePatterns, + ), + ]) + const skills = paths.flatMap((id) => { + const entry = registered.get(id) + const candidate: AdoptionSkill = { + id, + name: String(entry?.slug ?? entry?.name ?? basename(dirname(id))), + package: entry?.package ?? '', + path: entry?.path ?? id, + description: '', + domain: typeof entry?.domain === 'string' ? entry.domain : '', + status: entry ? 'registered' : 'unregistered', + problems: [], + selected: false, + } + if (entry?.status === 'planned' || entry?.status === 'retired') { + candidate.status = entry.status + return [candidate] + } + try { + const absolute = projectPath(project.root, id) + if (!existsSync(absolute)) { + candidate.status = 'missing' + candidate.problems.push('Registered skill file is missing.') + snapshot.push([id, null]) + return [candidate] + } + const context = resolveProjectContext({ + cwd: project.root, + targetPath: absolute, + }) + if (!context.packageRoot) + throw new Error('Skill has no owning package.json.') + if ( + !entry && + !packageRoots.has(context.packageRoot) && + !(directory && id.startsWith(`${directory}/`)) + ) + return [] + candidate.package = relative( + project.root, + context.packageRoot, + ).replaceAll('\\', '/') + candidate.path = relative(context.packageRoot, absolute).replaceAll( + '\\', + '/', + ) + const manifest = projectPath( + project.root, + candidate.package + ? `${candidate.package}/package.json` + : 'package.json', + ) + snapshot.push([ + id, + readFileSync(absolute, 'utf8'), + readFileSync(manifest, 'utf8'), + ]) + const frontmatter = parseFrontmatter(absolute) + if ( + !frontmatter || + typeof frontmatter.name !== 'string' || + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(frontmatter.name) || + frontmatter.name.length > 64 || + frontmatter.name !== basename(dirname(id)) || + (entry && frontmatter.name !== candidate.name) + ) + throw new Error( + 'Skill name must match its directory and registered identity.', + ) + candidate.name = frontmatter.name + if ( + typeof frontmatter.description !== 'string' || + !frontmatter.description.trim() + ) + throw new Error('Skill needs a non-empty description.') + candidate.description = frontmatter.description + stringList(frontmatter.sources ?? [], 'sources') + stringList(frontmatter.requires ?? [], 'requires') + const mapping = mapped.find( + (value: unknown) => + typeof value === 'object' && + value !== null && + 'slug' in value && + value.slug === candidate.name, + ) + if (!candidate.domain && typeof mapping?.domain === 'string') + candidate.domain = mapping.domain + if (entry && (entry.package ?? '') !== candidate.package) + throw new Error('Skill ownership differs from its registration.') + } catch (error) { + candidate.status = 'invalid' + candidate.problems.push( + error instanceof Error ? error.message : String(error), + ) + } + return [candidate] + }) + for (const candidate of skills) { + if ( + skills.some( + (other) => other.id !== candidate.id && other.name === candidate.name, + ) + ) { + candidate.status = 'conflict' + candidate.problems.push( + 'Another skill has the same name. Resolve the identity before adoption.', + ) + } + } + return { + schemaVersion: 1 as const, + root: project.root, + artifacts: project.artifacts, + directory, + fingerprint: createHash('sha256') + .update(JSON.stringify([project, directory, snapshot, skills])) + .digest('hex'), + distribution: (hasTree ? readDistribution(project) : undefined) ?? { + mode: 'unconfigured' as const, + repository: inferDistributionRepository(project), + }, + skills, + } +} + +export type AdoptionPlan = ReturnType + +export interface AdoptionPrompts { + choose: (plan: AdoptionPlan) => Promise + confirm: (plan: AdoptionPlan, files: Array) => Promise +} + +export function planAdoptionChanges( + project: MaintainerProject, + input: unknown, +) { + if ( + !isObject(input) || + input.schemaVersion !== 1 || + input.root !== project.root || + input.artifacts !== project.artifacts || + !Array.isArray(input.skills) || + (input.directory !== undefined && typeof input.directory !== 'string') + ) + throw new Error( + 'Use an adoption plan from maintainer adopt --json in this repository.', + ) + const current = createAdoptionPlan(project, input.directory) + if (input.fingerprint !== current.fingerprint) + throw new Error( + 'Skills or planning records changed. Create a new adoption plan before applying.', + ) + if (input.skills.length !== current.skills.length) + throw new Error( + 'Keep every adoption plan entry; change selected and domain only.', + ) + const seen = new Set() + const additions = input.skills.flatMap((value: unknown) => { + if ( + !isObject(value) || + typeof value.id !== 'string' || + typeof value.selected !== 'boolean' || + typeof value.domain !== 'string' || + seen.has(value.id) + ) + throw new Error( + 'Each adoption choice needs a unique id, selected boolean, and domain string.', + ) + seen.add(value.id) + const candidate = current.skills.find((skill) => skill.id === value.id) + if ( + !candidate || + ['name', 'package', 'path', 'status'].some( + (key) => value[key] !== candidate[key as keyof AdoptionSkill], + ) + ) + throw new Error('Adoption identities changed. Create a new plan.') + if (!value.selected) return [] + if (candidate.status !== 'unregistered') + throw new Error(`Cannot adopt ${candidate.id}: ${candidate.status}.`) + if (!value.domain.trim()) + throw new Error(`Choose a domain for ${candidate.name}.`) + return [ + { + name: candidate.name, + options: { + package: candidate.package || undefined, + path: candidate.path, + domain: value.domain.trim(), + }, + }, + ] + }) + const plan = planAddSkills(project, additions, planSetupRecords(project)) + const choice = input.distribution + if ( + !isObject(choice) || + !['repo', 'none', 'unconfigured'].includes(String(choice.mode)) + ) + throw new Error( + 'Choose repository distribution, none, or leave the current choice unchanged.', + ) + if ( + choice.mode === 'unconfigured' && + current.distribution.mode !== 'unconfigured' + ) + throw new Error( + 'Preserve the distribution choice or select an explicit opt-out.', + ) + const distribution = planDistributionChoice( + project, + choice.mode === 'unconfigured' + ? {} + : { + distribution: String(choice.mode), + ...(choice.mode === 'repo' + ? { + repository: + typeof choice.repository === 'string' + ? choice.repository + : undefined, + pluginName: + typeof choice.name === 'string' ? choice.name : undefined, + skill: stringList(choice.skills, 'distribution.skills'), + } + : {}), + }, + plan.changes, + ) + if (distribution) { + const index = plan.changes.findIndex( + (change) => change.path === distribution.path, + ) + if (index < 0) plan.changes.push(distribution) + else plan.changes[index] = distribution + } + return plan +} diff --git a/packages/intent/src/maintainer/adoption-prompts.ts b/packages/intent/src/maintainer/adoption-prompts.ts new file mode 100644 index 00000000..590992b6 --- /dev/null +++ b/packages/intent/src/maintainer/adoption-prompts.ts @@ -0,0 +1,124 @@ +import { stdin, stdout } from 'node:process' +import { stripVTControlCharacters } from 'node:util' +import { + autocompleteMultiselect, + confirm, + isCancel, + select, + text, +} from '@clack/prompts' +import type { AdoptionPrompts } from './adopt.js' + +export function createAdoptionPrompts(): AdoptionPrompts { + const io = { input: stdin, output: stdout } + return { + async choose(plan) { + const available = plan.skills.filter( + (skill) => skill.status === 'unregistered', + ) + if (available.length) { + const selected = await autocompleteMultiselect({ + ...io, + message: 'Choose existing library skills to register', + options: available.map((skill) => ({ + value: skill.id, + label: skill.name, + hint: stripVTControlCharacters(skill.id), + })), + initialValues: [], + required: false, + maxItems: 6, + }) + if (isCancel(selected)) return null + for (const skill of available) { + skill.selected = selected.includes(skill.id) + if (!skill.selected || skill.domain.trim()) continue + const domain = await text({ + ...io, + message: `Domain for ${skill.name}`, + validate: (value) => + value?.trim() ? undefined : 'Enter the task domain.', + }) + if (isCancel(domain)) return null + skill.domain = domain.trim() + } + } + const mode = await select({ + ...io, + message: 'Repository distribution', + initialValue: 'keep', + options: [ + { + value: 'keep', + label: + plan.distribution.mode === 'unconfigured' + ? 'Decide later' + : `Keep current choice (${plan.distribution.mode})`, + }, + { value: 'repo', label: 'Choose skills for repository distribution' }, + { value: 'none', label: 'Package-only distribution' }, + ], + }) + if (isCancel(mode)) return null + if (mode === 'none') plan.distribution = { mode: 'none' } + if (mode === 'repo') { + const candidates = plan.skills.filter( + (skill) => skill.status === 'registered' || skill.selected, + ) + if (!candidates.length) + throw new Error( + 'Select skills to register before configuring exports.', + ) + const selected = await autocompleteMultiselect({ + ...io, + message: 'Select repository exports and their local prerequisites', + options: candidates.map((skill) => ({ + value: skill.name, + label: skill.name, + hint: stripVTControlCharacters(skill.id), + })), + initialValues: + 'skills' in plan.distribution ? plan.distribution.skills : [], + required: true, + maxItems: 6, + }) + if (isCancel(selected)) return null + let repository = plan.distribution.repository ?? '' + if (!repository) { + const answer = await text({ + ...io, + message: 'GitHub repository (owner/repo)', + validate: (value) => + value?.trim() ? undefined : 'Enter the GitHub repository.', + }) + if (isCancel(answer)) return null + repository = answer.trim() + } + plan.distribution = { + ...plan.distribution, + mode: 'repo', + repository, + skills: selected, + } + } + return plan + }, + async confirm(plan, files) { + console.log('\nProposed registrations:') + for (const skill of plan.skills.filter((entry) => entry.selected)) + console.log( + stripVTControlCharacters(` ${skill.id} -> ${skill.domain}`), + ) + console.log(`Distribution: ${plan.distribution.mode}`) + console.log('Planning files:') + for (const file of files) + console.log(` ${stripVTControlCharacters(file)}`) + const answer = await confirm({ + ...io, + message: 'Apply these choices and install maintainer guidance?', + initialValue: false, + }) + return !isCancel(answer) && answer + }, + } +} diff --git a/packages/intent/src/maintainer/distribution.ts b/packages/intent/src/maintainer/distribution.ts index a37624c2..32dfaf3d 100644 --- a/packages/intent/src/maintainer/distribution.ts +++ b/packages/intent/src/maintainer/distribution.ts @@ -22,7 +22,7 @@ export interface DistributionOptions { skill?: string | Array } -interface Distribution { +export interface Distribution { mode: 'repo' | 'none' repository?: string name?: string @@ -35,9 +35,13 @@ const namePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ export function readDistribution( project: MaintainerProject, + changes: ReadonlyArray = [], ): Distribution | undefined { - const value: unknown = readRecord(project, 'skill_tree.yaml').document.toJS() - .distribution + const value: unknown = readRecord( + project, + 'skill_tree.yaml', + changes, + ).document.toJS().distribution if (value === undefined) return undefined if (!isObject(value) || !['repo', 'none'].includes(String(value.mode))) throw new Error('skill_tree.yaml distribution.mode must be repo or none.') @@ -66,10 +70,35 @@ function readJson(path: string): Record { return value } +export function inferDistributionRepository( + project: MaintainerProject, +): string { + const manifest = readJson(projectPath(project.root, 'package.json')) + const declared = isObject(manifest.repository) + ? manifest.repository.url + : manifest.repository + return typeof declared === 'string' + ? declared + .replace(/^git\+/, '') + .replace(/^https?:\/\/github\.com\//, '') + .replace(/^git@github\.com:/, '') + .replace(/\.git$/, '') + : '' +} + export function configureDistribution( project: MaintainerProject, options: DistributionOptions, ): void { + const change = planDistributionChoice(project, options) + if (change) writeChanges(project.root, [change]) +} + +export function planDistributionChoice( + project: MaintainerProject, + options: DistributionOptions, + changes: ReadonlyArray = [], +): FileChange | undefined { if (!options.distribution) { if (options.repository || options.pluginName || options.skill) throw new Error( @@ -79,7 +108,7 @@ export function configureDistribution( } if (!['repo', 'none'].includes(options.distribution)) throw new Error('--distribution must be repo or none.') - const previous = readDistribution(project) + const previous = readDistribution(project, changes) let distribution: Distribution if (options.distribution === 'none') { if (options.repository || options.pluginName || options.skill) @@ -88,21 +117,10 @@ export function configureDistribution( ) distribution = { ...previous, mode: 'none' } } else { - const manifest = readJson(projectPath(project.root, 'package.json')) - const repositoryField = manifest.repository - const declared = isObject(repositoryField) - ? repositoryField.url - : repositoryField const repository = options.repository ?? previous?.repository ?? - (typeof declared === 'string' - ? declared - .replace(/^git\+/, '') - .replace(/^https?:\/\/github\.com\//, '') - .replace(/^git@github\.com:/, '') - .replace(/\.git$/, '') - : '') + inferDistributionRepository(project) if (!repositoryPattern.test(repository)) throw new Error( 'Choose a GitHub repository with --repository .', @@ -136,7 +154,7 @@ export function configureDistribution( throw new Error( 'Select public skills explicitly with --skill (repeat for multiple skills).', ) - const entries = skillEntries(project) + const entries = skillEntries(project, changes) for (const selected of skills) { const entry = entries.find( (skill) => (skill.slug ?? skill.name) === selected, @@ -150,12 +168,14 @@ export function configureDistribution( } distribution = { mode: 'repo', repository, name, skills } } - const tree = readRecord(project, 'skill_tree.yaml') + const tree = readRecord(project, 'skill_tree.yaml', changes) if (JSON.stringify(previous) === JSON.stringify(distribution)) return tree.document.set('distribution', distribution) - writeChanges(project.root, [ - { path: tree.path, source: tree.source, content: tree.document.toString() }, - ]) + return { + path: tree.path, + source: tree.source, + content: tree.document.toString(), + } } function jsonChange( diff --git a/packages/intent/src/maintainer/project.ts b/packages/intent/src/maintainer/project.ts index 4aaf6182..044868a3 100644 --- a/packages/intent/src/maintainer/project.ts +++ b/packages/intent/src/maintainer/project.ts @@ -1,14 +1,10 @@ import { execFileSync } from 'node:child_process' -import { - existsSync, - lstatSync, - mkdirSync, - readFileSync, - writeFileSync, -} from 'node:fs' +import { existsSync, lstatSync, readFileSync } from 'node:fs' import { basename, dirname, join, relative } from 'node:path' import { parseDocument, stringify } from 'yaml' import { resolveProjectContext } from '../core/project-context.js' +import { writeChanges } from './files.js' +import type { FileChange } from './files.js' export const authoringMarker = '' const recordNames = ['domain_map.yaml', 'skill_spec.md', 'skill_tree.yaml'] @@ -89,12 +85,18 @@ export function recordPath(project: MaintainerProject, name: string): string { ) } -export function readRecord(project: MaintainerProject, name: string) { +export function readRecord( + project: MaintainerProject, + name: string, + changes: ReadonlyArray = [], +) { const path = recordPath(project, name) - if (!existsSync(path)) + const pending = changes.find((change) => change.path === path) + const source = existsSync(path) ? readFileSync(path, 'utf8') : null + const content = pending?.content ?? source + if (content === null) throw new Error(`Missing ${name}. Run intent maintainer setup.`) - const source = readFileSync(path, 'utf8') - const document = parseDocument(source) + const document = parseDocument(content) const parsed: unknown = document.toJS() if ( document.errors.length || @@ -117,10 +119,14 @@ export interface SkillEntry extends Record { package?: string } -export function skillEntries(project: MaintainerProject): Array { +export function skillEntries( + project: MaintainerProject, + changes: ReadonlyArray = [], +): Array { const entries: Array = readRecord( project, 'skill_tree.yaml', + changes, ).document.toJS().skills const names = new Set() const paths = new Set() @@ -160,6 +166,14 @@ export function skillPath( } export function setupRecords(project: MaintainerProject): Array { + const changes = planSetupRecords(project) + writeChanges(project.root, changes) + return changes.map((change) => relative(project.root, change.path)) +} + +export function planSetupRecords( + project: MaintainerProject, +): Array { const { root } = project const context = resolveProjectContext({ cwd: root }) if (!context.packageRoot) @@ -201,13 +215,11 @@ export function setupRecords(project: MaintainerProject): Array { if (name.endsWith('.yaml') && existsSync(recordPath(project, name))) readRecord(project, name) } - const created: Array = [] + const changes: Array = [] for (const [name, content] of Object.entries(defaults)) { const path = recordPath(project, name) if (existsSync(path)) continue - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, content, { flag: 'wx' }) - created.push(relative(root, path)) + changes.push({ path, source: null, content }) } - return created + return changes } diff --git a/packages/intent/tests/integration/packed-release.test.ts b/packages/intent/tests/integration/packed-release.test.ts index b067c623..99e804f1 100644 --- a/packages/intent/tests/integration/packed-release.test.ts +++ b/packages/intent/tests/integration/packed-release.test.ts @@ -145,7 +145,7 @@ describe('packed release', () => { } expect(run(['scaffold']).status).toBe(1) expect(run(['maintainer', '--help']).stdout).toContain( - 'setup|add|status|sync|review|check', + 'setup|adopt|add|status|sync|review|check', ) }) diff --git a/packages/intent/tests/maintainer.test.ts b/packages/intent/tests/maintainer.test.ts index f5da52de..5ead42dc 100644 --- a/packages/intent/tests/maintainer.test.ts +++ b/packages/intent/tests/maintainer.test.ts @@ -14,6 +14,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { parse } from 'yaml' import { main } from '../src/cli.js' import { createReview } from '../src/review/review.js' +import type { AdoptionPlan } from '../src/maintainer/adopt.js' let root: string let previousCwd: string @@ -223,6 +224,295 @@ it('preserves a planning record located directly at the repository root', async ) }) +it('previews existing library skills without writing or including agent and dependency skills', async () => { + write('pnpm-workspace.yaml', 'packages: [packages/*]\n') + write('packages/client/package.json', '{"name":"@library/client"}\n') + for (const directory of [ + 'skills/root-task', + 'packages/client/skills/query', + '.agents/skills/agent-only', + '.github/skills/review', + 'node_modules/dependency/skills/dependency', + ]) { + const name = directory.split('/').at(-1) + write( + `${directory}/SKILL.md`, + `---\nname: ${name}\ndescription: Use for ${name}.\nsources: [package.json]\n---\nExisting guidance.\n`, + ) + } + expect(await main(['maintainer', 'adopt', '--json'])).toBe(0) + const plan = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + expect(plan.skills).toEqual([ + expect.objectContaining({ + name: 'query', + package: 'packages/client', + path: 'skills/query/SKILL.md', + status: 'unregistered', + selected: false, + }), + expect.objectContaining({ + name: 'root-task', + package: '', + path: 'skills/root-task/SKILL.md', + status: 'unregistered', + selected: false, + }), + ]) + expect(existsSync(join(root, '_artifacts'))).toBe(false) + expect(existsSync(join(root, '.intent'))).toBe(false) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) +}) + +it('excludes nested fixture packages unless their directory is explicitly requested', async () => { + write('pnpm-workspace.yaml', 'packages: [packages/*]\n') + for (const directory of ['packages/client', 'tests/fixtures/client']) { + write(`${directory}/package.json`, '{"name":"client"}\n') + write( + `${directory}/skills/query/SKILL.md`, + '---\nname: query\ndescription: Query\n---\nGuidance.\n', + ) + } + expect(await main(['maintainer', 'adopt', '--json'])).toBe(0) + const plan = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + expect(plan.skills.map((skill: { id: string }) => skill.id)).toEqual([ + 'packages/client/skills/query/SKILL.md', + ]) + vi.mocked(console.log).mockClear() + expect( + await main([ + 'maintainer', + 'adopt', + '--path', + 'tests/fixtures/client/skills', + '--json', + ]), + ).toBe(0) + const explicit = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + expect(explicit.skills).toHaveLength(2) +}) + +it('adopts a confirmed batch without rewriting skills or approving their content', async () => { + const guidance = + '---\nname: query\ndescription: Use when querying.\nmetadata:\n purpose: Preserve this purpose.\nsources: [package.json]\n---\nAuthored guidance.\n' + write('skills/query/SKILL.md', guidance) + expect(await main(['maintainer', 'adopt', '--json'])).toBe(0) + const plan = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + plan.skills[0].selected = true + plan.skills[0].domain = 'queries' + plan.distribution = { mode: 'none' } + write('adoption.json', JSON.stringify(plan)) + expect(await main(['maintainer', 'adopt', '--apply', 'adoption.json'])).toBe( + 0, + ) + expect(read('skills/query/SKILL.md')).toBe(guidance) + expect( + parse(read('skills/_artifacts/skill_tree.yaml')).skills[0], + ).toMatchObject({ + name: 'query', + domain: 'queries', + purpose: 'Preserve this purpose.', + }) + expect( + parse(read('skills/_artifacts/domain_map.yaml')).skills[0].tasks, + ).toEqual([]) + expect(read('skills/_artifacts/skill_spec.md')).toContain( + 'intent:needs-authoring', + ) + expect(read('AGENTS.md')).toContain('maintainer check') + expect(existsSync(join(root, '.intent/review-state.json'))).toBe(false) + expect(await main(['maintainer', 'check'])).toBe(1) + const before = ['skill_tree.yaml', 'domain_map.yaml', 'skill_spec.md'].map( + (name) => read(`skills/_artifacts/${name}`), + ) + vi.mocked(console.log).mockClear() + expect(await main(['maintainer', 'adopt', '--json'])).toBe(0) + const repeated = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + expect(repeated.skills[0].status).toBe('registered') + write('adoption.json', JSON.stringify(repeated)) + expect(await main(['maintainer', 'adopt', '--apply', 'adoption.json'])).toBe( + 0, + ) + expect( + ['skill_tree.yaml', 'domain_map.yaml', 'skill_spec.md'].map((name) => + read(`skills/_artifacts/${name}`), + ), + ).toEqual(before) +}) + +it('rejects invalid batch choices and stale adoption plans before creating records', async () => { + for (const name of ['query', 'cache']) + write( + `skills/${name}/SKILL.md`, + `---\nname: ${name}\ndescription: ${name}\n---\nGuidance.\n`, + ) + expect(await main(['maintainer', 'adopt', '--json'])).toBe(0) + const plan = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + for (const candidate of plan.skills) candidate.selected = true + plan.skills[0].domain = 'queries' + write('adoption.json', JSON.stringify(plan)) + expect(await main(['maintainer', 'adopt', '--apply', 'adoption.json'])).toBe( + 1, + ) + expect(existsSync(join(root, 'skills/_artifacts'))).toBe(false) + plan.skills[1].domain = 'queries' + write('adoption.json', JSON.stringify(plan)) + write( + 'skills/query/SKILL.md', + read('skills/query/SKILL.md') + 'Later change.\n', + ) + expect(await main(['maintainer', 'adopt', '--apply', 'adoption.json'])).toBe( + 1, + ) + expect(vi.mocked(console.error).mock.calls.flat().join('\n')).toContain( + 'changed', + ) + expect(existsSync(join(root, 'skills/_artifacts'))).toBe(false) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) +}) + +it('requires interactive confirmation and keeps cancellation read-only', async () => { + write( + 'skills/query/SKILL.md', + '---\nname: query\ndescription: Query\n---\nGuidance.\n', + ) + const choose = vi.fn((plan: AdoptionPlan) => { + plan.skills[0]!.selected = true + plan.skills[0]!.domain = 'queries' + plan.distribution = { mode: 'none' } + return Promise.resolve(plan) + }) + const confirm = vi.fn(() => Promise.resolve(false)) + const runtime = { + isTTY: true, + isCI: false, + adoptionPrompts: { choose, confirm }, + } + expect(await main(['maintainer', 'adopt'], runtime)).toBe(0) + expect(confirm).toHaveBeenCalledOnce() + expect(existsSync(join(root, 'skills/_artifacts'))).toBe(false) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + confirm.mockResolvedValue(true) + expect(await main(['maintainer', 'adopt'], runtime)).toBe(0) + expect(parse(read('skills/_artifacts/skill_tree.yaml')).skills[0].name).toBe( + 'query', + ) +}) + +it('requires explicit noninteractive adoption and reports custom-root conflicts', async () => { + write( + 'guidance/query/SKILL.md', + '---\nname: query\ndescription: Query\n---\nGuidance.\n', + ) + expect(await main(['maintainer', 'adopt'], { isTTY: false })).toBe(1) + expect(existsSync(join(root, '.intent'))).toBe(false) + vi.mocked(console.log).mockClear() + expect( + await main(['maintainer', 'adopt', '--path', 'guidance', '--json']), + ).toBe(0) + const plan = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + expect(plan.skills[0].id).toBe('guidance/query/SKILL.md') + write('skills/query/SKILL.md', read('guidance/query/SKILL.md')) + vi.mocked(console.log).mockClear() + expect( + await main(['maintainer', 'adopt', '--path', 'guidance', '--json']), + ).toBe(0) + const conflicts = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + expect( + conflicts.skills.map((skill: { status: string }) => skill.status), + ).toEqual(['conflict', 'conflict']) +}) + +it('never prompts in CI even when a terminal is attached', async () => { + const choose = vi.fn(() => Promise.resolve(null)) + const confirm = vi.fn(() => Promise.resolve(false)) + const runtime = { + isTTY: true, + isCI: true, + adoptionPrompts: { choose, confirm }, + } + expect(await main(['maintainer', 'adopt'], runtime)).toBe(1) + expect(await main(['maintainer', 'adopt', '--json'], runtime)).toBe(0) + expect(choose).not.toHaveBeenCalled() + expect(confirm).not.toHaveBeenCalled() + expect(existsSync(join(root, '.intent'))).toBe(false) + expect(existsSync(join(root, 'skills/_artifacts'))).toBe(false) +}) + +it('keeps planned and retired entries separate from missing active skills', async () => { + expect(await main(['maintainer', 'setup'])).toBe(0) + write( + 'skills/_artifacts/skill_tree.yaml', + 'skills:\n - name: future\n path: skills/future/SKILL.md\n status: planned\n - name: old\n path: skills/old/SKILL.md\n status: retired\n - name: missing\n path: skills/missing/SKILL.md\n', + ) + vi.mocked(console.log).mockClear() + expect(await main(['maintainer', 'adopt', '--json'])).toBe(0) + const plan = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + expect(plan.skills.map((skill: { status: string }) => skill.status)).toEqual([ + 'planned', + 'missing', + 'retired', + ]) +}) + +it('rejects adoption after repository instructions change', async () => { + write( + 'skills/query/SKILL.md', + '---\nname: query\ndescription: Query\n---\nGuidance.\n', + ) + expect(await main(['maintainer', 'adopt', '--json'])).toBe(0) + const plan = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + plan.skills[0].selected = true + plan.skills[0].domain = 'queries' + write('adoption.json', JSON.stringify(plan)) + write('AGENTS.md', 'New maintainer instructions.\n') + expect(await main(['maintainer', 'adopt', '--apply', 'adoption.json'])).toBe( + 1, + ) + expect(read('AGENTS.md')).toBe('New maintainer instructions.\n') + expect(existsSync(join(root, 'skills/_artifacts'))).toBe(false) +}) + +it('adopts two packages and saves an explicit distribution selection', async () => { + write( + 'package.json', + '{"name":"library","repository":"https://github.com/acme/library"}\n', + ) + write('pnpm-workspace.yaml', 'packages: [packages/*]\n') + for (const name of ['query', 'cache']) { + write(`packages/${name}/package.json`, `{"name":"@library/${name}"}\n`) + write( + `packages/${name}/skills/${name}/SKILL.md`, + `---\nname: ${name}\ndescription: Use for ${name}.\nsources: [package.json]\n---\nAuthored ${name} guidance.\n`, + ) + } + expect(await main(['maintainer', 'adopt', '--json'])).toBe(0) + const plan = JSON.parse(String(vi.mocked(console.log).mock.calls[0]![0])) + for (const candidate of plan.skills) { + candidate.selected = true + candidate.domain = 'queries' + } + plan.distribution = { + mode: 'repo', + repository: 'acme/library', + skills: ['query'], + } + write('adoption.json', JSON.stringify(plan)) + expect(await main(['maintainer', 'adopt', '--apply', 'adoption.json'])).toBe( + 0, + ) + const tree = parse(read('_artifacts/skill_tree.yaml')) + expect( + tree.skills.map((skill: { package: string }) => skill.package), + ).toEqual(['packages/cache', 'packages/query']) + expect(tree.distribution).toEqual({ + mode: 'repo', + repository: 'acme/library', + name: 'acme-library', + skills: ['query'], + }) + expect(existsSync(join(root, '.claude-plugin'))).toBe(false) +}) + afterEach(() => { process.chdir(previousCwd) vi.restoreAllMocks() From 183aab0bac25aab5ef9c50bd4882798e61a90b9e Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 10 Sep 2026 20:58:10 -0700 Subject: [PATCH 2/2] chore: keep adoption guidance in the docs PR --- packages/intent/meta/generate-skill/SKILL.md | 2 +- .../generate-skill/references/maintainer-commands.md | 9 +-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/intent/meta/generate-skill/SKILL.md b/packages/intent/meta/generate-skill/SKILL.md index 8dd05229..e575bba3 100644 --- a/packages/intent/meta/generate-skill/SKILL.md +++ b/packages/intent/meta/generate-skill/SKILL.md @@ -1,7 +1,7 @@ --- name: generate-skill description: > - Use when creating, adopting, or extending library skill batches, updating guidance after library source or docs change, configuring repository skill distribution, or acting on an Intent review report, including when the maintainer asks only for the library change. For full-library discovery or taxonomy design, use domain-discovery; for generating an approved full-library tree, use tree-generator. + Use when creating or extending library skill batches, updating guidance after library source or docs change, configuring repository skill distribution, or acting on an Intent review report, including when the maintainer asks only for the library change. For full-library discovery or taxonomy design, use domain-discovery; for generating an approved full-library tree, use tree-generator. metadata: purpose: > Author and maintain library skills when creating an initial skill batch, changing library source or docs, or acting on an Intent review report. Use the current task and source evidence to create useful guidance, verify it on representative tasks, and update affected skills. diff --git a/packages/intent/meta/generate-skill/references/maintainer-commands.md b/packages/intent/meta/generate-skill/references/maintainer-commands.md index 6a676b03..d07418a7 100644 --- a/packages/intent/meta/generate-skill/references/maintainer-commands.md +++ b/packages/intent/meta/generate-skill/references/maintainer-commands.md @@ -1,6 +1,6 @@ # Run the maintainer workflow -Use the repository's Intent commands for bookkeeping; the maintainer or coding agent supplies task knowledge, source-backed guidance, and review conclusions. +Use the repository's Intent command for all six actions. These commands perform bookkeeping; the maintainer or coding agent still supplies task knowledge, source-backed guidance, and review conclusions. 1. Run `intent maintainer setup` once. It installs repository guidance and creates missing planning records, preserving existing documents. A monorepo uses one shared record and package-owned skill directories. If several record locations exist, select the established one with `--artifacts `; do not merge them by guessing. Read [repository distribution](distribution.md) and explain the option to the maintainer during setup. Save the selected skills or opt-out with the setup command; do not infer a public selection from directory placement or repeat a recorded decision. 2. For a new task, run `intent maintainer add --domain --description --source `. In a monorepo, pass `--package packages/`. Repeat `--source` or `--requires` for multiple entries. Source paths are relative to the owning package; `owner/repo:path` is relative to the repository. Use `--path /SKILL.md` for an established custom layout. To register an existing skill, supply its name, domain, package, and path; its frontmatter supplies the other fields. @@ -11,11 +11,4 @@ Use the repository's Intent commands for bookkeeping; the maintainer or coding a Keep unimplemented future skills in the tree with `status: planned` and retired entries with `status: retired`. They remain part of the cumulative record but do not count as implemented skills or enter package publishing configuration. An active entry with a missing file is an error to resolve, not an invitation to delete the entry. Local prerequisite slugs are checked against implemented tree entries; verify external package prerequisites and the developer task through the task-quality procedure. -## Adopt existing skills - -1. Preview registrations with `intent maintainer adopt --json`. For a custom directory outside `skills/`, add `--path `. Preserve the established planning location with `--artifacts` when several exist. -2. Save the plan, mark chosen unregistered entries with `selected: true`, and supply their missing `domain`. Keep all identities and the fingerprint unchanged. Preserve authored descriptions, purposes, and skill contents. -3. Record an explicit distribution selection or opt-out, or retain the current choice. Apply with `intent maintainer adopt --apply `; regenerate the plan if source or records changed. A human terminal can run `intent maintainer adopt` for the same choices and confirmation. CI stays noninteractive; use JSON reporting or an explicitly authorized apply. -4. Complete task coverage and source review through the normal workflow. Adoption creates missing records and installs maintainer guidance, but does not author skills, approve content, synchronize, or publish. Use `maintainer check --base ` as the read-only CI gate. - The former `scaffold` command is removed. Use `maintainer setup` and `maintainer add` for file creation, and `meta generate-skill` for authoring guidance. Full-library research and taxonomy design remain available through `meta domain-discovery` and `meta tree-generator` when requested.