Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/interactive-maintainer-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <reason>` and `--updated <reason>` to record one outcome for every pending item in a single command, with the reviewed revision and changed files as evidence.
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<reason>"` or `--updated "<reason>"`; 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.

Expand Down
11 changes: 11 additions & 0 deletions packages/intent/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,14 @@ function createCli(
'--interactive',
'Inspect and record maintainer review outcomes in a terminal',
)
.option(
'--unchanged <reason>',
'Record every pending review item as reviewed with no guidance change',
)
.option(
'--updated <reason>',
'Record every pending review item as reviewed with updated guidance',
)
.option('--json', 'Output status or review as JSON')
.option(
'--record <file>',
Expand All @@ -253,6 +261,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(
Expand Down
63 changes: 56 additions & 7 deletions packages/intent/src/commands/maintainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { findExistingSkills } from '../maintainer/existing.js'
import { retireSkill } from '../maintainer/remove.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,
Expand Down Expand Up @@ -72,6 +72,14 @@ const optionHelp: Record<string, [flag: string, description: string]> = {
task: ['--task <text>', 'Developer task the skill covers; repeat for more'],
base: ['--base <ref>', 'Git revision to review against'],
interactive: ['--interactive', 'Inspect and record outcomes in a terminal'],
unchanged: [
'--unchanged <reason>',
'Record every pending item as reviewed with no guidance change',
],
updated: [
'--updated <reason>',
'Record every pending item as reviewed with updated guidance',
],
json: ['--json', 'Print JSON instead of text'],
record: ['--record <file>', 'Record outcomes from an annotated JSON report'],
}
Expand Down Expand Up @@ -133,12 +141,17 @@ export const maintainerActions: Record<string, MaintainerAction> = {
},
review: {
usage:
'maintainer review [--json | --interactive | --record <report.json>] [--base <ref>]',
'maintainer review [--unchanged <reason> | --updated <reason> | --json | --interactive | --record <report.json>] [--base <ref>]',
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 <ref>]',
Expand Down Expand Up @@ -196,6 +209,8 @@ export interface MaintainerCommandOptions extends DistributionOptions {
json?: boolean
record?: string
interactive?: boolean
unchanged?: string
updated?: string
}

// An explicit --package is repository-relative. Without one, a command run from
Expand Down Expand Up @@ -234,7 +249,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])
Expand All @@ -250,6 +265,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.')
Expand Down Expand Up @@ -421,7 +470,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 <report.json>.',
'Maintainer check failed. Resolve the authoring issues, run intent maintainer sync, and record review outcomes with intent maintainer review --unchanged <reason> or --updated <reason>, with --interactive, or by annotating a --json report and passing it to --record <report.json>.',
)
console.log(
'Maintainer checks passed. Recorded conclusions still depend on the supplied review evidence.',
Expand Down
22 changes: 22 additions & 0 deletions packages/intent/src/review/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ||
Expand Down
34 changes: 34 additions & 0 deletions packages/intent/tests/review-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading