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
5 changes: 5 additions & 0 deletions .changeset/interactive-maintainer-review.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions packages/intent/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,10 @@ function createCli(
'Prerequisite skill; repeat for multiple skills',
)
.option('--base <ref>', '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 <file>',
Expand All @@ -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 (
Expand Down
22 changes: 21 additions & 1 deletion packages/intent/src/commands/maintainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -44,6 +46,7 @@ export interface MaintainerCommandOptions extends DistributionOptions {
json?: boolean
record?: string
apply?: string
interactive?: boolean
}

export async function runMaintainerCommand(
Expand All @@ -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])
Expand All @@ -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
}
Expand Down
124 changes: 124 additions & 0 deletions packages/intent/src/review/interactive.ts
Original file line number Diff line number Diff line change
@@ -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<string>
}

export interface ReviewPrompts {
reviewItem: (
item: ReviewItem,
inspect: (view: 'guidance' | 'changes') => string,
) => Promise<ReviewDecision | null>
confirm: (report: ReviewReport) => Promise<boolean>
}

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<string>) =>
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<void> {
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.`,
)
}
94 changes: 94 additions & 0 deletions packages/intent/src/review/prompts.ts
Original file line number Diff line number Diff line change
@@ -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<ReviewDecision['outcome'], 'unresolved'>,
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
},
}
}
Loading
Loading