From dc4857bf177085fcebfbc8a9be9a9ac5cb0ddbcc Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Fri, 11 Sep 2026 14:33:52 -0700 Subject: [PATCH] fix: remove first-run friction from the maintainer workflow Files Intent writes (agent instruction blocks, generated plugin metadata, the CI workflow, package manifests) and lockfiles no longer surface as unmapped source changes unless a skill maps them. skill_tree.yaml accepts review.ignore for repository-specific patterns. maintainer add registers a skill with the workspace package that owns the current directory instead of silently placing it at the repository root. review --record rejects a report that annotates nothing and names the required fields. validate accepts the per-skill files entries maintainer sync writes. The workflow version constant matches the shipped template. --- .changeset/maintainer-first-run.md | 7 ++ packages/intent/README.md | 29 ++++---- packages/intent/src/commands/maintainer.ts | 23 +++++- packages/intent/src/commands/review.ts | 27 +++++-- packages/intent/src/commands/support.ts | 2 +- packages/intent/src/commands/validate.ts | 40 +++++++++-- packages/intent/src/review/review.ts | 70 ++++++++++++++---- packages/intent/tests/cli.test.ts | 61 ++++++++++++++++ packages/intent/tests/maintainer.test.ts | 82 ++++++++++++++++++++++ packages/intent/tests/review.test.ts | 69 ++++++++++++++++++ 10 files changed, 370 insertions(+), 40 deletions(-) create mode 100644 .changeset/maintainer-first-run.md diff --git a/.changeset/maintainer-first-run.md b/.changeset/maintainer-first-run.md new file mode 100644 index 00000000..fa7ba29d --- /dev/null +++ b/.changeset/maintainer-first-run.md @@ -0,0 +1,7 @@ +--- +'@tanstack/intent': patch +--- + +Stop reporting files Intent writes as unmapped source changes. Agent instruction files, generated plugin metadata, the CI workflow, package manifests, and lockfiles no longer need a recorded review unless a skill maps them; `review.ignore` in `skill_tree.yaml` adds repository-specific patterns. + +Register a skill with the workspace package that owns the current directory when `maintainer add` runs without `--package`. Reject a review record that annotates no outcomes and explain the required fields. Accept per-skill `files` entries written by `maintainer sync` during validation. Advise the current CI workflow version. diff --git a/packages/intent/README.md b/packages/intent/README.md index 8a4aabdd..b1b32e22 100644 --- a/packages/intent/README.md +++ b/packages/intent/README.md @@ -62,13 +62,14 @@ npx @tanstack/intent@latest load @tanstack/query#fetching ### For library maintainers -Generate skills for your library by telling your AI coding agent to run: +Set up the maintainer workflow, then register each skill beside the package that owns it: ```bash -npx @tanstack/intent@latest scaffold +npx @tanstack/intent@latest maintainer setup +npx @tanstack/intent@latest maintainer add caching --domain queries --description "Use when caching queries." --source "src/**" ``` -This walks the agent through domain discovery, skill tree generation, and skill creation — one step at a time with your review at each stage. +Your coding agent authors the guidance with `intent meta generate-skill`. `maintainer status`, `sync`, `review`, and `check` keep the planning records, package metadata, and source reviews consistent. Validate your skill files: @@ -119,17 +120,17 @@ The real risk with any derived artifact is staleness. `npx @tanstack/intent@late ## CLI Commands -| Command | Description | -| -------------------------------------------------- | --------------------------------------------------- | -| `npx @tanstack/intent@latest install` | Set up skill loading guidance in agent config files | -| `npx @tanstack/intent@latest hooks install` | Install hook enforcement for supported agents | -| `npx @tanstack/intent@latest list [--json]` | Discover local intent-enabled packages | -| `npx @tanstack/intent@latest load ` | Load `#` SKILL.md content | -| `npx @tanstack/intent@latest meta` | List meta-skills for library maintainers | -| `npx @tanstack/intent@latest scaffold` | Print the guided skill generation prompt | -| `npx @tanstack/intent@latest validate [dir]` | Validate SKILL.md files | -| `npx @tanstack/intent@latest setup` | Copy CI templates into your repo | -| `npx @tanstack/intent@latest stale [dir] [--json]` | Check skills for version drift | +| Command | Description | +| -------------------------------------------------- | ----------------------------------------------------- | +| `npx @tanstack/intent@latest install` | Set up skill loading guidance in agent config files | +| `npx @tanstack/intent@latest hooks install` | Install hook enforcement for supported agents | +| `npx @tanstack/intent@latest list [--json]` | Discover local intent-enabled packages | +| `npx @tanstack/intent@latest load ` | Load `#` SKILL.md content | +| `npx @tanstack/intent@latest meta` | List meta-skills for library maintainers | +| `npx @tanstack/intent@latest maintainer ` | Set up, author, synchronize, review, and check skills | +| `npx @tanstack/intent@latest validate [dir]` | Validate SKILL.md files | +| `npx @tanstack/intent@latest setup` | Copy CI templates into your repo | +| `npx @tanstack/intent@latest stale [dir] [--json]` | Check skills for version drift | ## License diff --git a/packages/intent/src/commands/maintainer.ts b/packages/intent/src/commands/maintainer.ts index 2859f7d6..6f03ccba 100644 --- a/packages/intent/src/commands/maintainer.ts +++ b/packages/intent/src/commands/maintainer.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { dirname, relative, resolve } from 'node:path' import { isCI } from 'std-env' +import { resolveProjectContext } from '../core/project-context.js' import { fail } from '../shared/cli-error.js' import { readRecord, @@ -50,6 +51,21 @@ export interface MaintainerCommandOptions extends DistributionOptions { interactive?: boolean } +// An explicit --package is repository-relative. Without one, a command run from +// inside a workspace member registers the skill with that member instead of +// silently placing it at the repository root. +function inferOwningPackage( + root: string, + explicit: string | undefined, +): string | undefined { + if (explicit !== undefined) return explicit + const { packageRoot } = resolveProjectContext({ cwd: process.cwd() }) + if (!packageRoot || packageRoot === root) return undefined + const owner = relative(root, packageRoot).replaceAll('\\', '/') + if (!owner || owner.startsWith('..')) return undefined + return owner +} + export async function runMaintainerCommand( action: string, name: string | undefined, @@ -195,7 +211,10 @@ export async function runMaintainerCommand( `Repository distribution: ${distribution?.mode ?? 'unconfigured'}. Run maintainer sync after authoring to update export metadata.`, ) } else if (action === 'add') { - console.log(`Registered ${addSkill(project, name, options)}.`) + const owner = inferOwningPackage(project.root, options.package) + console.log( + `Registered ${addSkill(project, name, { ...options, package: owner })}.`, + ) console.log( 'Next: author the skill and its task coverage, then run intent maintainer sync, maintainer review, and maintainer check.', ) @@ -245,7 +264,7 @@ export async function runMaintainerCommand( await runValidateCommand(dir) if (plan.problems.length || plan.changes.length || review.items.length) fail( - 'Maintainer check failed. Resolve the authoring issues, run maintainer sync, and record review outcomes with maintainer review --record .', + 'Maintainer check failed. Resolve the authoring issues, run intent maintainer sync, and record review outcomes with intent maintainer review --interactive, or annotate a --json report and pass it to --record .', ) console.log( 'Maintainer checks passed. Recorded conclusions still depend on the supplied review evidence.', diff --git a/packages/intent/src/commands/review.ts b/packages/intent/src/commands/review.ts index bc4daca5..ed866e7e 100644 --- a/packages/intent/src/commands/review.ts +++ b/packages/intent/src/commands/review.ts @@ -31,10 +31,29 @@ export function runReviewCommand( throw new Error( '--record cannot be combined with --base, --json or --check.', ) - const count = recordReview( - cwd, - JSON.parse(readFileSync(resolve(options.record), 'utf8')), + const input: unknown = JSON.parse( + readFileSync(resolve(options.record), 'utf8'), ) + if ( + typeof input === 'object' && + input !== null && + Array.isArray((input as { items?: unknown }).items) + ) { + const items = (input as { items: Array }).items + const annotated = items.filter( + (item) => + typeof item === 'object' && + item !== null && + 'outcome' in item && + item.outcome !== undefined && + item.outcome !== 'unresolved', + ) + if (items.length > 0 && annotated.length === 0) + throw new Error( + `${options.record} annotates none of its ${items.length} review item(s). Set outcome (updated, no-change, or out-of-scope), reason, and a non-empty evidence array on each completed item, or use intent maintainer review --interactive in a terminal.`, + ) + } + const count = recordReview(cwd, input) console.log(`Recorded ${count} review outcome(s).`) return } @@ -80,7 +99,7 @@ export function runReviewCommand( console.log(' Use --json for all review items.') if (report.items.length) console.log( - 'Next: run intent meta generate-skill in your coding agent. Review the evidence, run task checks, and record justified outcomes with intent review --record .', + 'Next: run intent meta generate-skill in your coding agent. Review the evidence, run task checks, and record justified outcomes with intent maintainer review --interactive, or annotate intent review --json output and pass it to intent review --record .', ) else console.log( diff --git a/packages/intent/src/commands/support.ts b/packages/intent/src/commands/support.ts index de55f182..ae7a86d6 100644 --- a/packages/intent/src/commands/support.ts +++ b/packages/intent/src/commands/support.ts @@ -27,7 +27,7 @@ export interface StaleTargetResult { workflowAdvisories: Array } -export const INTENT_CHECK_SKILLS_WORKFLOW_VERSION = 4 +export const INTENT_CHECK_SKILLS_WORKFLOW_VERSION = 5 export function getMetaDir(): string { return findMetaDir(dirname(fileURLToPath(import.meta.url))) diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index fe451a19..8fd50030 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -91,7 +91,16 @@ function buildValidationFailure( return lines.join('\n') } -function collectPackagingWarnings(context: ProjectContext): Array { +function filesEntryCovers(entry: string, directory: string): boolean { + if (entry.startsWith('!')) return false + const prefix = entry.replace(/\/(?:\*\*|\*)?$/, '') + return directory === prefix || directory.startsWith(`${prefix}/`) +} + +function collectPackagingWarnings( + context: ProjectContext, + skillFiles: ReadonlyArray, +): Array { if (!context.packageRoot || !context.targetPackageJsonPath) return [] const pkgJsonPath = context.targetPackageJsonPath @@ -134,15 +143,32 @@ function collectPackagingWarnings(context: ProjectContext): Array { const files = pkgJson.files as Array | undefined if (Array.isArray(files)) { - if (!files.includes('skills')) { - warnings.push( - '"skills" is not in the "files" array — skills won\'t be published', - ) + const packageRoot = context.packageRoot + const skillDirs = [ + ...new Set( + skillFiles.map((file) => + relative(packageRoot, dirname(file)).replaceAll('\\', '/'), + ), + ), + ] + // Either the whole skills directory or each skill directory (as written + // by `intent maintainer sync`) publishes the guidance. + for (const directory of skillDirs) { + if (!files.some((entry) => filesEntryCovers(entry, directory))) { + warnings.push( + `"${directory}" is not covered by the "files" array — this skill won't be published`, + ) + } } // In monorepos, _artifacts lives at repo root, not under packages — // the negation pattern is a no-op and shouldn't be added. - if (!context.isMonorepo && !files.includes('!skills/_artifacts')) { + if ( + !context.isMonorepo && + existsSync(join(packageRoot, 'skills', '_artifacts')) && + files.some((entry) => filesEntryCovers(entry, 'skills/_artifacts')) && + !files.includes('!skills/_artifacts') + ) { warnings.push( '"!skills/_artifacts" is not in the "files" array — artifacts will be published unnecessarily', ) @@ -625,7 +651,7 @@ async function runValidateCommandInternal( } validatedCount += skillFiles.length - warnings.push(...collectPackagingWarnings(validateContext)) + warnings.push(...collectPackagingWarnings(validateContext, skillFiles)) } if (options.check) { diff --git a/packages/intent/src/review/review.ts b/packages/intent/src/review/review.ts index 99d7b2b7..e307a0a1 100644 --- a/packages/intent/src/review/review.ts +++ b/packages/intent/src/review/review.ts @@ -57,6 +57,26 @@ interface ReviewState { const statePath = '.intent/review-state.json' const dependencyExclude = ':(top,exclude,glob)**/node_modules/**' +// Files Intent writes or that never carry library guidance. Skills that map +// one of these paths in `sources` still track it; the list only stops the +// paths from surfacing as unmapped changes. +const defaultReviewIgnore = [ + '.intent/**', + 'AGENTS.md', + 'CLAUDE.md', + '.cursorrules', + '.github/copilot-instructions.md', + '.github/workflows/check-skills.yml', + '.claude-plugin/**', + '.cursor-plugin/**', + '**/package.json', + 'pnpm-lock.yaml', + 'package-lock.json', + 'npm-shrinkwrap.json', + 'yarn.lock', + 'bun.lock', + 'bun.lockb', +] const digest = (value: string | Buffer) => createHash('sha256').update(value).digest('hex') const sorted = (values: Iterable) => [...new Set(values)].sort() @@ -277,6 +297,10 @@ function sourcePattern( } else if (packageDir) { path = `${packageDir}/${source}` } + return globPattern(path, source, 'source') +} + +function globPattern(path: string, label: string, kind: string): string { if ( !path || path.startsWith('/') || @@ -285,16 +309,33 @@ function sourcePattern( path.includes(':') || path.split('/').some((part) => part === '..' || part === '.' || part === '') ) { - throw new Error(`Unsupported source path: ${source}`) + throw new Error(`Unsupported ${kind} path: ${label}`) } // Git owns glob matching; braces and extglobs are not Git pathspec syntax. if (/[{}]/.test(path) || /[!+@?*]\(/.test(path)) throw new Error( - `Unsupported source glob: ${source}. Use Git glob syntax (*, ?, [], **).`, + `Unsupported ${kind} glob: ${label}. Use Git glob syntax (*, ?, [], **).`, ) return `:(top,glob)${path}` } +function reviewIgnorePatterns(tree: Record, path: string) { + if (tree.review === undefined) return [] + const ignore = isObject(tree.review) ? tree.review.ignore : undefined + if ( + !isObject(tree.review) || + (ignore !== undefined && + (!Array.isArray(ignore) || + ignore.some((entry) => typeof entry !== 'string' || !entry.trim()))) + ) + throw new Error( + `Invalid review.ignore in ${path}: expected an array of Git glob patterns.`, + ) + return ((ignore ?? []) as Array).map((pattern) => + globPattern(pattern, pattern, 'review.ignore'), + ) +} + export function createReview(cwd: string, baseRef?: string): ReviewReport { let root: string try { @@ -428,15 +469,21 @@ export function createReview(cwd: string, baseRef?: string): ReviewReport { .map((dir) => dirname(dir)) .filter((dir) => dir !== '.' && !files.includes(`${dir}/package.json`)) const declaredSkills = new Set() + const ignorePatterns = defaultReviewIgnore.map((pattern) => + globPattern(pattern, pattern, 'review.ignore'), + ) for (const dir of existingArtifactDirs) { + const treePath = join(dir, 'skill_tree.yaml').replaceAll('\\', '/') + let tree: unknown try { - const tree: unknown = parseYaml( - readFileSync( - safePath(root, join(dir, 'skill_tree.yaml').replaceAll('\\', '/')), - 'utf8', - ), - ) - if (!isObject(tree) || !Array.isArray(tree.skills)) continue + tree = parseYaml(readFileSync(safePath(root, treePath), 'utf8')) + } catch { + // Missing or invalid trees remain unresolved in planning validation below. + continue + } + if (!isObject(tree)) continue + ignorePatterns.push(...reviewIgnorePatterns(tree, treePath)) + if (Array.isArray(tree.skills)) { for (const entry of tree.skills) { if (!isObject(entry) || typeof entry.path !== 'string') continue declaredSkills.add( @@ -445,10 +492,9 @@ export function createReview(cwd: string, baseRef?: string): ReviewReport { : entry.path, ) } - } catch { - // Missing or invalid trees remain unresolved in planning validation below. } } + const ignored = new Set([...list(ignorePatterns), ...diff(ignorePatterns)]) const skillFiles = files.filter( (path) => basename(path) === 'SKILL.md' && @@ -589,7 +635,7 @@ export function createReview(cwd: string, baseRef?: string): ReviewReport { } } for (const path of changed) { - if (covered.has(path) || path.startsWith('.intent/')) continue + if (covered.has(path) || ignored.has(path)) continue add('source', path, [path], []) } for (const id of Object.keys(state?.items ?? {})) { diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 6588258b..ddb6dc3d 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -3439,6 +3439,67 @@ describe('cli commands', () => { ) }) + it('accepts per-skill files entries and names an unpublished skill directory', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-files-')) + tempDirs.push(root) + + writeJson(join(root, 'package.json'), { + name: '@acme/library', + devDependencies: { '@tanstack/intent': '^0.0.18' }, + keywords: ['tanstack-intent'], + files: ['dist', 'skills/covered'], + }) + writeSkillMd(join(root, 'skills', 'covered'), { + name: 'covered', + description: 'Published through its own files entry', + }) + writeSkillMd(join(root, 'skills', 'missing'), { + name: 'missing', + description: 'Not published', + }) + + process.chdir(root) + + expect(await main(['validate'])).toBe(0) + const output = logSpy.mock.calls.flat().join('\n') + expect(output).not.toContain('"skills" is not') + expect(output).not.toContain('"skills/covered"') + expect(output).toContain( + '"skills/missing" is not covered by the "files" array', + ) + expect(output).not.toContain('"!skills/_artifacts"') + }) + + it('still asks to exclude skills/_artifacts when the whole skills directory is published', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-artifacts-')) + tempDirs.push(root) + + writeJson(join(root, 'package.json'), { + name: '@acme/library', + devDependencies: { '@tanstack/intent': '^0.0.18' }, + keywords: ['tanstack-intent'], + files: ['skills'], + }) + writeSkillMd(join(root, 'skills', 'core'), { + name: 'core', + description: 'Core guidance', + }) + mkdirSync(join(root, 'skills', '_artifacts'), { recursive: true }) + for (const name of ['domain_map.yaml', 'skill_tree.yaml']) { + writeFileSync(join(root, 'skills', '_artifacts', name), 'skills: []\n') + } + writeFileSync( + join(root, 'skills', '_artifacts', 'skill_spec.md'), + '# Spec\n', + ) + + process.chdir(root) + + expect(await main(['validate'])).toBe(0) + const output = logSpy.mock.calls.flat().join('\n') + expect(output).toContain('"!skills/_artifacts" is not in the "files" array') + }) + it('skips cleanly when validate is run without a skills directory', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-missing-skills-')) tempDirs.push(root) diff --git a/packages/intent/tests/maintainer.test.ts b/packages/intent/tests/maintainer.test.ts index 5ead42dc..9244b8d7 100644 --- a/packages/intent/tests/maintainer.test.ts +++ b/packages/intent/tests/maintainer.test.ts @@ -612,3 +612,85 @@ it('registers a package-owned skill and synchronizes metadata without replacing expect(await main(['maintainer', 'sync'])).toBe(0) for (const [path, content] of snapshot) expect(read(path)).toBe(content) }) + +it('registers a skill with the workspace package that owns the current directory', async () => { + write('pnpm-workspace.yaml', 'packages:\n - packages/*\n') + write('packages/client/package.json', '{"name":"@library/client"}\n') + write('packages/client/src/query.ts', 'export const query = () => 1\n') + expect(await main(['maintainer', 'setup', '--distribution', 'none'])).toBe(0) + process.chdir(join(root, 'packages/client')) + expect( + await main([ + 'maintainer', + 'add', + 'query', + '--domain', + 'queries', + '--description', + 'Use when querying with Library.', + '--source', + 'src/query.ts', + ]), + ).toBe(0) + expect(existsSync(join(root, 'packages/client/skills/query/SKILL.md'))).toBe( + true, + ) + expect(existsSync(join(root, 'skills'))).toBe(false) + expect(parse(read('_artifacts/skill_tree.yaml')).skills[0]).toMatchObject({ + name: 'query', + package: 'packages/client', + path: 'skills/query/SKILL.md', + }) + expect( + vi + .mocked(console.log) + .mock.calls.flat() + .some((line) => + String(line).includes( + 'Registered packages/client/skills/query/SKILL.md', + ), + ), + ).toBe(true) + // An explicit --package remains repository-relative from any directory. + expect( + await main([ + 'maintainer', + 'add', + 'root-only', + '--package', + '.', + '--domain', + 'setup', + '--description', + 'Use when configuring the workspace.', + '--source', + 'pnpm-workspace.yaml', + ]), + ).toBe(0) + expect(existsSync(join(root, 'skills/root-only/SKILL.md'))).toBe(true) +}) + +it('does not ask for a review of the files setup and sync write', async () => { + write('src/query.ts', 'export const query = () => 1\n') + write('pnpm-lock.yaml', 'lockfileVersion: 9\n') + expect(await main(['maintainer', 'setup', '--distribution', 'none'])).toBe(0) + expect( + await main([ + 'maintainer', + 'add', + 'query', + '--domain', + 'queries', + '--description', + 'Use when querying with Library.', + '--source', + 'src/query.ts', + ]), + ).toBe(0) + expect(await main(['maintainer', 'sync'])).toBe(0) + expect( + createReview(root) + .items.map((item) => item.id) + .sort(), + ).toEqual(['planning:skills/_artifacts', 'skill:skills/query/SKILL.md']) +}) diff --git a/packages/intent/tests/review.test.ts b/packages/intent/tests/review.test.ts index 5d679026..42169646 100644 --- a/packages/intent/tests/review.test.ts +++ b/packages/intent/tests/review.test.ts @@ -1,6 +1,7 @@ import { execFileSync } from 'node:child_process' import { createHash } from 'node:crypto' import { + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -674,3 +675,71 @@ it.each(['skills/_artifacts', '.agents/knowledge/_artifacts'])( ).toContain('must be visible to Git') }, ) + +it('ignores Intent-owned files and lockfiles unless a skill maps them, and honors review.ignore', () => { + accept() + write('AGENTS.md', '# Agents\n') + write('.claude-plugin/plugin.json', '{"name":"library"}\n') + write('.github/workflows/check-skills.yml', 'name: Check Skills\n') + write('pnpm-lock.yaml', 'lockfileVersion: 9\n') + write('packages/client/package.json', '{"name":"client"}\n') + write( + 'package.json', + '{"name":"library","repository":"https://github.com/acme/library","keywords":["tanstack-intent"]}\n', + ) + expect(createReview(root).items).toEqual([]) + + write('docs/guide.md', 'Guide\n') + write('lib/other.ts', 'export const other = 1\n') + expect(createReview(root).items.map((item) => item.id)).toEqual([ + 'source:docs/guide.md', + 'source:lib/other.ts', + ]) + + planningRecords('_artifacts') + write( + '_artifacts/skill_tree.yaml', + 'library: { name: library }\nreview:\n ignore: [docs/**]\nskills: []\n', + ) + expect( + createReview(root) + .items.map((item) => item.id) + .filter((id) => id.startsWith('source:')), + ).toEqual(['source:lib/other.ts']) + + write( + '_artifacts/skill_tree.yaml', + 'library: { name: library }\nreview:\n ignore: [{ bad: true }]\nskills: []\n', + ) + expect(() => createReview(root)).toThrow(/Invalid review.ignore/) + write( + '_artifacts/skill_tree.yaml', + 'library: { name: library }\nreview:\n ignore: ["../outside/**"]\nskills: []\n', + ) + expect(() => createReview(root)).toThrow(/Unsupported review.ignore path/) +}) + +it('still tracks an ignored path when a skill maps it as a source', () => { + skill(['acme/library:package.json']) + git('add', '.') + git('commit', '-qm', 'map the manifest') + accept() + write('package.json', '{"name":"library","version":"2.0.0"}\n') + const report = createReview(root) + expect(report.items.map((item) => item.id)).toEqual([ + 'skill:skills/request/SKILL.md', + ]) + expect(report.items[0]?.changedFiles).toEqual(['package.json']) +}) + +it('rejects a review record that annotates nothing instead of silently recording zero outcomes', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const report = join(root, '.intent/review.json') + write('.intent/review.json', JSON.stringify(createReview(root))) + expect(await main(['review', root, '--record', report])).toBe(1) + expect(errorSpy.mock.calls.flat().join('\n')).toMatch( + /annotates none of its 1 review item\(s\).*--interactive/, + ) + expect(existsSync(join(root, '.intent/review-state.json'))).toBe(false) + errorSpy.mockRestore() +})