From 3b50590dc42c7ceae0a311ba27d64e058da3f1ed Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 10 Sep 2026 20:58:48 -0700 Subject: [PATCH] feat: support interactive maintainer source review --- .changeset/interactive-maintainer-review.md | 5 + packages/intent/src/cli.ts | 5 + packages/intent/src/commands/maintainer.ts | 22 ++- packages/intent/src/review/interactive.ts | 124 ++++++++++++++++ packages/intent/src/review/prompts.ts | 94 ++++++++++++ packages/intent/tests/review-workflow.test.ts | 138 +++++++++++++++++- 6 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 .changeset/interactive-maintainer-review.md create mode 100644 packages/intent/src/review/interactive.ts create mode 100644 packages/intent/src/review/prompts.ts diff --git a/.changeset/interactive-maintainer-review.md b/.changeset/interactive-maintainer-review.md new file mode 100644 index 00000000..58117647 --- /dev/null +++ b/.changeset/interactive-maintainer-review.md @@ -0,0 +1,5 @@ +--- +'@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. diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 44c21bc6..b5f85aed 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -239,6 +239,10 @@ function createCli( 'Prerequisite skill; repeat for multiple skills', ) .option('--base ', 'Git revision to review against') + .option( + '--interactive', + 'Inspect and record maintainer review outcomes in a terminal', + ) .option('--json', 'Output an adoption plan, status, or review as JSON') .option( '--record ', @@ -254,6 +258,7 @@ function createCli( .example('maintainer status --json') .example('maintainer sync') .example('maintainer review --json') + .example('maintainer review --interactive') .example('maintainer check --base origin/main') .action( async ( diff --git a/packages/intent/src/commands/maintainer.ts b/packages/intent/src/commands/maintainer.ts index e150b459..5e5e5b30 100644 --- a/packages/intent/src/commands/maintainer.ts +++ b/packages/intent/src/commands/maintainer.ts @@ -25,11 +25,13 @@ import { runReviewCommand } from './review.js' import { runValidateCommand } from './validate.js' import type { DistributionOptions } from '../maintainer/distribution.js' import type { AdoptionPrompts } from '../maintainer/adopt.js' +import type { ReviewPrompts } from '../review/interactive.js' export interface MaintainerCommandRuntime { isTTY?: boolean isCI?: boolean adoptionPrompts?: AdoptionPrompts + reviewPrompts?: ReviewPrompts } export interface MaintainerCommandOptions extends DistributionOptions { @@ -44,6 +46,7 @@ export interface MaintainerCommandOptions extends DistributionOptions { json?: boolean record?: string apply?: string + interactive?: boolean } export async function runMaintainerCommand( @@ -66,7 +69,7 @@ export async function runMaintainerCommand( ], status: ['artifacts', 'base', 'json'], sync: ['artifacts'], - review: ['base', 'json', 'record'], + review: ['base', 'json', 'record', 'interactive'], check: ['artifacts', 'base'], } if (!allowed[action]) @@ -80,6 +83,23 @@ export async function runMaintainerCommand( fail(`--${key} is not supported by maintainer ${action}.`) } if (action === 'review') { + if (options.interactive) { + if (options.json || options.record) + fail('--interactive cannot be combined with --json or --record.') + if ( + (runtime.isCI ?? isCI) || + !(runtime.isTTY ?? (process.stdin.isTTY && process.stdout.isTTY)) + ) + fail( + 'Interactive review requires a human terminal outside CI. Use --json for a report or maintainer check for a CI gate.', + ) + const { runInteractiveReview } = await import('../review/interactive.js') + const prompts = + runtime.reviewPrompts ?? + (await import('../review/prompts.js')).createReviewPrompts() + await runInteractiveReview(process.cwd(), options.base, prompts) + return + } runReviewCommand(undefined, options) return } diff --git a/packages/intent/src/review/interactive.ts b/packages/intent/src/review/interactive.ts new file mode 100644 index 00000000..87b99591 --- /dev/null +++ b/packages/intent/src/review/interactive.ts @@ -0,0 +1,124 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { projectPath } from '../maintainer/project.js' +import { createReview, recordReview } from './review.js' +import type { ReviewReport } from './review.js' + +type ReviewItem = ReviewReport['items'][number] + +export type ReviewDecision = + | { outcome: 'unresolved' } + | { + outcome: 'updated' | 'no-change' | 'out-of-scope' + reason: string + evidence: Array + } + +export interface ReviewPrompts { + reviewItem: ( + item: ReviewItem, + inspect: (view: 'guidance' | 'changes') => string, + ) => Promise + confirm: (report: ReviewReport) => Promise +} + +function inspectItem( + report: ReviewReport, + item: ReviewItem, + view: 'guidance' | 'changes', +): string { + const read = (path: string) => { + const absolute = projectPath(report.root, path) + return `\n${JSON.stringify(path)}\n${ + existsSync(absolute) ? readFileSync(absolute, 'utf8') : '(deleted)' + }` + } + if (view === 'guidance') { + if (item.kind === 'skill') return read(item.path) + if (item.kind === 'planning') + return Object.keys(item.snapshot) + .filter((path) => + ['domain_map.yaml', 'skill_tree.yaml', 'skill_spec.md'].some( + (name) => path === name || path.endsWith(`/${name}`), + ), + ) + .map(read) + .join('\n') + return 'This source change has no mapped skill.' + } + if (!item.changedFiles.length) + return 'No files changed from the comparison base. This item has no current review outcome.' + for (const path of item.changedFiles) projectPath(report.root, path) + const git = (args: Array) => + execFileSync( + 'git', + ['-c', 'core.fsmonitor=false', '--literal-pathspecs', ...args], + { + cwd: report.root, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + const diff = git([ + 'diff', + '--no-ext-diff', + '--no-textconv', + '--no-renames', + report.base, + '--', + ...item.changedFiles, + ]) + const untracked = git([ + 'ls-files', + '--others', + '--exclude-standard', + '-z', + '--', + ...item.changedFiles, + ]) + .split('\0') + .filter(Boolean) + return ( + [diff, ...untracked.map(read)].filter(Boolean).join('\n') || + 'No diff against the comparison base. Inspect the current guidance and listed source files.' + ) +} + +export async function runInteractiveReview( + cwd: string, + base: string | undefined, + prompts: ReviewPrompts, +): Promise { + const report = createReview(cwd, base) + if (!report.items.length) { + console.log('No pending review items.') + return + } + console.log( + `${report.items.length} item(s) need review. Base: ${report.base}`, + ) + for (const item of report.items) { + const decision = await prompts.reviewItem(structuredClone(item), (view) => + inspectItem(report, item, view), + ) + if (decision === null) { + console.log('Review canceled. No outcomes recorded.') + return + } + Object.assign(item, decision) + } + if (report.items.every((item) => item.outcome === 'unresolved')) { + console.log('All review items remain pending. No outcomes recorded.') + return + } + if (!(await prompts.confirm(structuredClone(report)))) { + console.log('Review canceled. No outcomes recorded.') + return + } + const count = recordReview(cwd, report) + const pending = createReview(cwd, base).items.length + console.log( + `Recorded ${count} review outcome(s). ${pending} item(s) remain pending.`, + ) +} diff --git a/packages/intent/src/review/prompts.ts b/packages/intent/src/review/prompts.ts new file mode 100644 index 00000000..30a6fa5a --- /dev/null +++ b/packages/intent/src/review/prompts.ts @@ -0,0 +1,94 @@ +import { stdin, stdout } from 'node:process' +import { stripVTControlCharacters } from 'node:util' +import { confirm, isCancel, select, text } from '@clack/prompts' +import type { ReviewDecision, ReviewPrompts } from './interactive.js' + +export function createReviewPrompts(): ReviewPrompts { + const io = { input: stdin, output: stdout } + const required = (value: string | undefined) => + value?.trim() ? undefined : 'Enter the review evidence.' + return { + async reviewItem(item, inspect) { + console.log(`\n${item.kind}: ${JSON.stringify(item.path)}`) + for (const problem of item.problems) + console.log(` Unresolved: ${stripVTControlCharacters(problem)}`) + for (const path of item.changedFiles) + console.log(` Changed: ${JSON.stringify(path)}`) + for (;;) { + const action = await select({ + ...io, + message: `Review ${JSON.stringify(item.path)}`, + initialValue: 'changes', + options: [ + { value: 'changes', label: 'View source and guidance changes' }, + { + value: 'guidance', + label: 'View current guidance', + disabled: item.kind === 'source', + }, + { + value: 'updated', + label: 'Record updated guidance', + disabled: item.problems.length > 0, + }, + { + value: 'no-change', + label: 'Record justified no change', + disabled: item.problems.length > 0, + }, + ...(item.kind === 'planning' + ? [] + : [ + { + value: 'out-of-scope', + label: 'Record justified out of scope', + disabled: item.problems.length > 0, + }, + ]), + { value: 'unresolved', label: 'Leave pending' }, + ], + }) + if (isCancel(action)) return null + if (action === 'changes' || action === 'guidance') { + console.log(stripVTControlCharacters(inspect(action))) + continue + } + if (action === 'unresolved') return { outcome: 'unresolved' } + const reason = await text({ + ...io, + message: 'Reason for this outcome', + validate: required, + }) + if (isCancel(reason)) return null + const evidence = await text({ + ...io, + message: 'Evidence (source or command and actual result)', + validate: required, + }) + if (isCancel(evidence)) return null + return { + outcome: action as Exclude, + reason: reason.trim(), + evidence: [evidence.trim()], + } + } + }, + async confirm(report) { + const resolved = report.items.filter( + (item) => item.outcome !== 'unresolved', + ) + for (const item of resolved) { + console.log(`\n${JSON.stringify(item.path)}: ${item.outcome}`) + console.log(` ${stripVTControlCharacters(item.reason ?? '')}`) + for (const evidence of item.evidence ?? []) + console.log(` Evidence: ${stripVTControlCharacters(evidence)}`) + } + const answer = await confirm({ + ...io, + message: `Record ${resolved.length} outcome(s), leaving ${report.items.length - resolved.length} pending?`, + initialValue: false, + }) + return !isCancel(answer) && answer + }, + } +} diff --git a/packages/intent/tests/review-workflow.test.ts b/packages/intent/tests/review-workflow.test.ts index 99a3cc4b..5d7759d4 100644 --- a/packages/intent/tests/review-workflow.test.ts +++ b/packages/intent/tests/review-workflow.test.ts @@ -11,9 +11,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { parse } from 'yaml' -import { afterEach, beforeEach, expect, it } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { main } from '../src/cli.js' import { createReview, recordReview } from '../src/review/review.js' +import type { ReviewPrompts } from '../src/review/interactive.js' let root: string let cwd: string @@ -25,6 +26,8 @@ beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'intent-review-workflow-')) process.chdir(root) execFileSync('git', ['init', '-q']) + writeFileSync('package.json', '{"name":"review-fixture","version":"1.0.0"}\n') + execFileSync('git', ['add', 'package.json']) execFileSync('git', [ '-c', 'user.name=Fixture', @@ -38,9 +41,142 @@ beforeEach(() => { }) afterEach(() => { process.chdir(cwd) + vi.restoreAllMocks() rmSync(root, { recursive: true, force: true }) }) +it('records confirmed interactive outcomes through the existing review checks', async () => { + writeFileSync('new-api.ts', 'export const enabled = true\n') + const reviewItem = vi.fn(() => + Promise.resolve({ + outcome: 'no-change' as const, + reason: 'The internal flag does not change the documented API.', + evidence: ['Inspected new-api.ts and the supported developer tasks.'], + }), + ) + const confirm = vi.fn(() => Promise.resolve(true)) + expect( + await main(['maintainer', 'review', '--interactive'], { + isTTY: true, + isCI: false, + reviewPrompts: { reviewItem, confirm }, + }), + ).toBe(0) + expect(reviewItem).toHaveBeenCalledOnce() + expect(confirm).toHaveBeenCalledOnce() + const state = JSON.parse(readFileSync('.intent/review-state.json', 'utf8')) + expect(state.items['source:new-api.ts']).toMatchObject({ + outcome: 'no-change', + reason: 'The internal flag does not change the documented API.', + }) + expect(createReview(root).items).toEqual([]) +}) + +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)) + const confirm = vi.fn(() => Promise.resolve(false)) + const runtime = { + isTTY: true, + isCI: false, + reviewPrompts: { reviewItem, confirm }, + } + expect(await main(['maintainer', 'review', '--interactive'], runtime)).toBe(0) + expect(existsSync('.intent')).toBe(false) + reviewItem.mockClear() + expect( + await main(['maintainer', 'review', '--interactive'], { + ...runtime, + isCI: true, + }), + ).toBe(1) + expect(reviewItem).not.toHaveBeenCalled() + expect(confirm).not.toHaveBeenCalled() + expect(existsSync('.intent')).toBe(false) +}) + +it('shows mapped guidance and source changes while leaving unresolved items pending', async () => { + writeFileSync('new-api.ts', 'export const enabled = true\n') + writeFileSync('other-api.ts', 'export const pending = true\n') + mkdirSync('skills/query', { recursive: true }) + writeFileSync( + 'skills/query/SKILL.md', + '---\nname: query\ndescription: Query\nsources: [new-api.ts]\n---\nUse the enabled flag.\n', + ) + const reviewItem: ReviewPrompts['reviewItem'] = (item, inspect) => { + if (item.kind !== 'skill') return Promise.resolve({ outcome: 'unresolved' }) + expect(inspect('guidance')).toContain('Use the enabled flag.') + expect(inspect('changes')).toContain('export const enabled = true') + return Promise.resolve({ + outcome: 'updated', + reason: 'Guidance matches the enabled flag.', + evidence: ['Read new-api.ts: enabled is true.'], + }) + } + expect( + await main(['maintainer', 'review', '--interactive'], { + isTTY: true, + isCI: false, + reviewPrompts: { + reviewItem, + confirm: () => Promise.resolve(true), + }, + }), + ).toBe(0) + expect(createReview(root).items.map((item) => item.id)).toEqual([ + 'source:other-api.ts', + ]) +}) + +it('rejects interactive decisions after source edits without recording partial outcomes', async () => { + writeFileSync('new-api.ts', 'export const enabled = true\n') + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + expect( + await main(['maintainer', 'review', '--interactive'], { + isTTY: true, + isCI: false, + reviewPrompts: { + reviewItem: () => + Promise.resolve({ + outcome: 'no-change', + reason: 'Inspected the flag.', + evidence: ['new-api.ts: enabled is true.'], + }), + confirm: () => { + writeFileSync('new-api.ts', 'export const enabled = false\n') + return Promise.resolve(true) + }, + }, + }), + ).toBe(1) + expect(error.mock.calls.flat().join('\n')).toContain( + 'changed since this report', + ) + expect(existsSync('.intent/review-state.json')).toBe(false) +}) + +it('requires evidence and rejects mixed interactive output modes', async () => { + writeFileSync('new-api.ts', 'export const enabled = true\n') + const runtime = { + isTTY: true, + isCI: false, + reviewPrompts: { + reviewItem: () => + Promise.resolve({ + outcome: 'no-change' as const, + reason: '', + evidence: [], + }), + confirm: () => Promise.resolve(true), + }, + } + expect(await main(['maintainer', 'review', '--interactive'], runtime)).toBe(1) + expect( + await main(['maintainer', 'review', '--interactive', '--json'], runtime), + ).toBe(1) + expect(existsSync('.intent/review-state.json')).toBe(false) +}) + it('writes release reminders for unreviewed source and none after a recorded outcome', async () => { writeFileSync('new-api.ts', 'export const enabled = true\n') expect(await main(['review', '--github-review'])).toBe(0)