From d5e8d3501506bf86061aae401367d33ec17c9f36 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Fri, 11 Sep 2026 22:54:03 -0700 Subject: [PATCH] feat: record every pending review item in one command Add `maintainer review --unchanged ` and `--updated `. Each records the given outcome for every pending item through the existing fingerprint checks, with the reviewed revision and the files each item changed as evidence, so a maintainer who already knows the answer does not walk a menu or hand-edit a JSON report. The flag is `--unchanged` rather than `--no-change` because cac parses `--no-` as the negation of `--`. --- .changeset/interactive-maintainer-review.md | 2 +- .../references/source-review.md | 2 +- packages/intent/src/cli.ts | 11 ++++ packages/intent/src/commands/maintainer.ts | 63 ++++++++++++++++--- packages/intent/src/review/review.ts | 22 +++++++ packages/intent/tests/review-workflow.test.ts | 34 ++++++++++ 6 files changed, 125 insertions(+), 9 deletions(-) diff --git a/.changeset/interactive-maintainer-review.md b/.changeset/interactive-maintainer-review.md index 58117647..2d578bc3 100644 --- a/.changeset/interactive-maintainer-review.md +++ b/.changeset/interactive-maintainer-review.md @@ -2,4 +2,4 @@ '@tanstack/intent': minor --- -Add optional interactive maintainer review with guidance and source-diff inspection, per-item reasons and evidence, and confirmation before recording. Reuse existing fingerprints and evidence validation, retain JSON workflows, and prohibit interactive prompts in CI. +Add optional interactive maintainer review with guidance and source-diff inspection, per-item reasons and evidence, and confirmation before recording. Reuse existing fingerprints and evidence validation, retain JSON workflows, and prohibit interactive prompts in CI. Add `maintainer review --unchanged ` and `--updated ` to record one outcome for every pending item in a single command, with the reviewed revision and changed files as evidence. diff --git a/packages/intent/meta/generate-skill/references/source-review.md b/packages/intent/meta/generate-skill/references/source-review.md index baac2480..b391a9ab 100644 --- a/packages/intent/meta/generate-skill/references/source-review.md +++ b/packages/intent/meta/generate-skill/references/source-review.md @@ -22,7 +22,7 @@ After guidance edits and task checks, regenerate the JSON report. Save it outsid - `reason`: the concrete behavior comparison and why that outcome follows. - `evidence`: source paths/revisions and actual check results. For behavior-changing guidance, include structural validation, executable task checks, and fresh-consumer evidence or its explicit limitation. -Preserve the report's identity, base and fingerprints. Run `intent maintainer review --record .intent/review.json`. For planning items, use `updated` or an evidence-backed `no-change` covering all three documents. The command rejects a report that annotates nothing, stale fingerprints, and unresolved source mappings or planning files. It writes completed outcomes to `.intent/review-state.json`; unresolved or unannotated items stay pending. Do not invent passing checks, use a generic reason, or mark unrelated items complete just to empty the report. +When every pending item shares one outcome and one reason, run `intent maintainer review --unchanged ""` or `--updated ""`; the command records the reviewed revision and changed files as evidence. Otherwise preserve the report's identity, base and fingerprints and run `intent maintainer review --record .intent/review.json`. For planning items, use `updated` or an evidence-backed `no-change` covering all three documents. The command rejects a report that annotates nothing, stale fingerprints, and unresolved source mappings or planning files. It writes completed outcomes to `.intent/review-state.json`; unresolved or unannotated items stay pending. Do not invent passing checks, use a generic reason, or mark unrelated items complete just to empty the report. Keep the state file with the source/skill change for maintainer review. It contains content hashes, revisions, outcomes and evidence, not source contents. Record operations do not commit or publish. An identical content snapshot suppresses repeated reminders, including a justified no-op; another source or guidance change reopens review. This records an evidence-backed decision, not independent proof that the decision is correct. diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 8f48be28..c7dce173 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -247,6 +247,14 @@ function createCli( '--interactive', 'Inspect and record maintainer review outcomes in a terminal', ) + .option( + '--unchanged ', + 'Record every pending review item as reviewed with no guidance change', + ) + .option( + '--updated ', + 'Record every pending review item as reviewed with updated guidance', + ) .option('--json', 'Output an adoption plan, status, or review as JSON') .option( '--record ', @@ -263,6 +271,9 @@ function createCli( .example('maintainer status --json') .example('maintainer sync') .example('maintainer review --json') + .example( + 'maintainer review --unchanged "internal refactor, public API unchanged"', + ) .example('maintainer review --interactive') .example('maintainer check --base origin/main') .action( diff --git a/packages/intent/src/commands/maintainer.ts b/packages/intent/src/commands/maintainer.ts index 168e8e7c..be8d5450 100644 --- a/packages/intent/src/commands/maintainer.ts +++ b/packages/intent/src/commands/maintainer.ts @@ -13,7 +13,7 @@ import { retireSkill } from '../maintainer/remove.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' +import { createReview, recordPendingReview } from '../review/review.js' import { configureDistribution, readDistribution, @@ -80,6 +80,14 @@ const optionHelp: Record = { task: ['--task ', 'Developer task the skill covers; repeat for more'], base: ['--base ', 'Git revision to review against'], interactive: ['--interactive', 'Inspect and record outcomes in a terminal'], + unchanged: [ + '--unchanged ', + 'Record every pending item as reviewed with no guidance change', + ], + updated: [ + '--updated ', + 'Record every pending item as reviewed with updated guidance', + ], json: ['--json', 'Print JSON instead of text'], record: ['--record ', 'Record outcomes from an annotated JSON report'], } @@ -150,12 +158,17 @@ export const maintainerActions: Record = { }, review: { usage: - 'maintainer review [--json | --interactive | --record ] [--base ]', + 'maintainer review [--unchanged | --updated | --json | --interactive | --record ] [--base ]', summary: 'Find guidance affected by Git changes and record outcomes.', writes: '.intent/review-state.json when recording; nothing otherwise.', - options: ['base', 'json', 'record', 'interactive'].map( - (key) => optionHelp[key]!, - ), + options: [ + 'base', + 'unchanged', + 'updated', + 'json', + 'record', + 'interactive', + ].map((key) => optionHelp[key]!), }, check: { usage: 'maintainer check [--base ]', @@ -214,6 +227,8 @@ export interface MaintainerCommandOptions extends DistributionOptions { record?: string apply?: string interactive?: boolean + unchanged?: string + updated?: string } // An explicit --package is repository-relative. Without one, a command run from @@ -253,7 +268,7 @@ export async function runMaintainerCommand( remove: ['artifacts'], status: ['artifacts', 'base', 'json'], sync: ['artifacts'], - review: ['base', 'json', 'record', 'interactive'], + review: ['base', 'json', 'record', 'interactive', 'unchanged', 'updated'], check: ['artifacts', 'base'], } if (!allowed[action]) @@ -269,6 +284,40 @@ export async function runMaintainerCommand( ) } if (action === 'review') { + const oneShot = + options.unchanged !== undefined + ? ('no-change' as const) + : options.updated !== undefined + ? ('updated' as const) + : undefined + if (oneShot) { + if ( + (options.unchanged !== undefined && options.updated !== undefined) || + options.interactive || + options.json || + options.record + ) + fail( + '--unchanged and --updated record every pending item at once and cannot be combined with each other, --interactive, --json, or --record.', + ) + const reason = options.unchanged ?? options.updated + if (typeof reason !== 'string' || !reason.trim()) + fail( + `--${oneShot === 'no-change' ? 'unchanged' : 'updated'} needs a reason.`, + ) + try { + const count = recordPendingReview( + process.cwd(), + options.base, + oneShot, + reason, + ) + console.log(`Recorded ${count} review outcome(s) as ${oneShot}.`) + } catch (error) { + fail(error instanceof Error ? error.message : String(error)) + } + return + } if (options.interactive) { if (options.json || options.record) fail('--interactive cannot be combined with --json or --record.') @@ -466,7 +515,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 intent maintainer sync, and record review outcomes with intent maintainer review --interactive, or annotate a --json report and pass it to --record .', + 'Maintainer check failed. Resolve the authoring issues, run intent maintainer sync, and record review outcomes with intent maintainer review --unchanged or --updated , with --interactive, or by annotating a --json report and passing it to --record .', ) console.log( 'Maintainer checks passed. Recorded conclusions still depend on the supplied review evidence.', diff --git a/packages/intent/src/review/review.ts b/packages/intent/src/review/review.ts index 3e5b6c30..77c9b0de 100644 --- a/packages/intent/src/review/review.ts +++ b/packages/intent/src/review/review.ts @@ -716,6 +716,28 @@ export function createReview(cwd: string, baseRef?: string): ReviewReport { return { schemaVersion: 1, root, head, base, recording, items } } +// Record one outcome for every pending item in a single command. The +// evidence is the reviewed revision and the files each item changed, so the +// record says what was looked at without asking the maintainer to type it. +export function recordPendingReview( + cwd: string, + baseRef: string | undefined, + outcome: 'no-change' | 'updated', + reason: string, +): number { + if (!reason.trim()) throw new Error(`--${outcome} needs a reason.`) + const report = createReview(cwd, baseRef) + if (report.items.length === 0) throw new Error('Nothing is pending review.') + for (const item of report.items) { + item.outcome = outcome + item.reason = reason.trim() + item.evidence = [ + `${item.changedFiles.length ? `Changed: ${item.changedFiles.join(', ')}` : 'No changed files'} at ${report.head}`, + ] + } + return recordReview(cwd, report) +} + export function recordReview(cwd: string, input: unknown): number { if ( !isObject(input) || diff --git a/packages/intent/tests/review-workflow.test.ts b/packages/intent/tests/review-workflow.test.ts index 541fa358..96efd3d3 100644 --- a/packages/intent/tests/review-workflow.test.ts +++ b/packages/intent/tests/review-workflow.test.ts @@ -75,6 +75,40 @@ it('records confirmed interactive outcomes through the existing review checks', expect(createReview(root).items).toEqual([]) }) +it('records every pending item in one command with the reviewed files as evidence', async () => { + writeFileSync('new-api.ts', 'export const enabled = true\n') + expect( + await main(['maintainer', 'review', '--unchanged', 'internal flag only']), + ).toBe(0) + const state = JSON.parse(readFileSync('.intent/review-state.json', 'utf8')) + expect(state.items['source:new-api.ts']).toMatchObject({ + outcome: 'no-change', + reason: 'internal flag only', + evidence: [expect.stringMatching(/^Changed: new-api\.ts at [a-f0-9]{40}$/)], + }) + expect(createReview(root).items).toEqual([]) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + expect( + await main(['maintainer', 'review', '--updated', 'nothing pending']), + ).toBe(1) + expect(errorSpy.mock.calls.flat().join('\n')).toContain( + 'Nothing is pending review', + ) +}) + +it('rejects a one-shot outcome without a reason or combined with other modes', async () => { + writeFileSync('new-api.ts', 'export const enabled = true\n') + vi.spyOn(console, 'error').mockImplementation(() => {}) + for (const args of [ + ['--unchanged', ' '], + ['--unchanged', 'a', '--updated', 'b'], + ['--updated', 'a', '--json'], + ['--unchanged', 'a', '--interactive'], + ]) + expect(await main(['maintainer', 'review', ...args])).toBe(1) + expect(existsSync('.intent/review-state.json')).toBe(false) +}) + it('keeps interactive cancellation and CI read-only', async () => { writeFileSync('new-api.ts', 'export const enabled = true\n') const reviewItem = vi.fn(() => Promise.resolve(null))