
Review Triage Phase
- 2 installs
- 418 repo stars
- Updated August 3, 2026
- prisma/prisma-next
Produces canonical review actions from fetched review state and renders action markdown. Use when the user wants only triage/action-planning for the review-framework workflow.
About
Produces canonical review actions from fetched review state and renders action markdown. Use when the user wants only triage/action-planning for the review-framework workflow. name: review-triage-phase description: Produces canonical review actions from fetched review state and renders action markdown. Use when the user wants only triage/action-planning for the review-framework workflow.
- `<output-dir>/review-state.json`
- `<output-dir>/review-targets.json` (optional)
- `<output-dir>/review-state.md` (optional)
- `<output-dir>/summary.txt` (optional)
- `/review-fetch-phase <PR_URL> [output-dir]`
Review Triage Phase by the numbers
- 2 all-time installs (skills.sh)
- Ranked #1,839 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
review-triage-phase capabilities & compatibility
- Capabilities
- review triage phase quick start · review triage phase when to use guidance · review triage phase integration patterns
What review-triage-phase says it does
argument-hint: "[pr-url] [output-dir]"
Run only the triage phase of the review-framework loop:
npx skills add https://github.com/prisma/prisma-next --skill review-triage-phaseAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 418 |
| Last updated | August 3, 2026 |
| Repository | prisma/prisma-next ↗ |
How do I use review-triage-phase correctly?
Produces canonical review actions from fetched review state and renders action markdown. Use when the user wants only triage/action-planning for the review-framework workflow.
Who is it for?
Teams implementing review-triage-phase workflows from the catalog.
Skip if: Skip when requirements clearly match a different specialized stack.
When should I use this skill?
User asks about review-triage-phase, produces canonical review actions from fetched review state and renders action markdown. u.
What you get
Working review-triage-phase setup with validated configuration and next steps.
Files
Review Triage Phase
Run only the triage phase of the review-framework loop:
read current review state artifacts, bootstrap canonical review-actions.json, update triage fields, validate, then render review-actions.md.
Run commands from this skill directory. All script paths below are relative to it.
Inputs
- Required:
- PR URL
- Optional:
- output directory
If output directory is omitted, derive:
wip/reviews/<owner>_<repo>_pr-<number>/
Preconditions
Expected inputs in output dir:
<output-dir>/review-state.json<output-dir>/review-targets.json(optional)<output-dir>/review-state.md(optional)<output-dir>/summary.txt(optional)
If review-state.json is missing, instruct user to run:
/review-fetch-phase <PR_URL> [output-dir]
Note:
review-actions.jsonscaffolding is generated fromreview-state.targets, which now includes unresolved review threads, review bodies, and issue comments.- Compound review bodies: A single
pull_request_reviewtarget may contain multiple distinct findings (e.g., CodeRabbit bundles "outside diff range" comments, actionable comments, and nitpicks into one review body). The triager must decompose these into individual action items during triage — never blanket-dismiss a review body without reading its content.
Behavior
1. Compute deterministic paths:
<output-dir>/review-state.json<output-dir>/review-actions.json<output-dir>/review-actions.md
2. Enforce artifact safety before generation (must be ignored by git):
node ../review-fetch-phase/scripts/guard-review-artifacts-ignored.mjs --dir <output-dir>3. Bootstrap canonical action scaffold from review state:
node ./scripts/bootstrap-review-actions.mjs --in <output-dir>/review-state.json --out <output-dir>/review-actions.json4. Delegate triage to:
./agents/review-triager.md
5. Require triager output contract:
review-actions.jsonis valid v2- targets use node IDs only
- actions remain intentionally ordered
- every
deferaction includes a Linear issue identifier - will-address actions for
review_threadtargets must remain actionable via thread replies (not PR reviews)
6. Create Linear tracking for deferred work:
- create one or more Linear tickets (group related deferred comments)
- attach each deferred action to a Linear issue ID (
linearIssue) - ensure the deferred thread reply includes that Linear ticket URL
7. Validate canonical actions JSON and enforce completed triage decisions:
node ./scripts/validate-review-actions.mjs --in <output-dir>/review-actions.json --require-final8. Render markdown from canonical actions JSON:
node ./scripts/render-review-actions.mjs --in <output-dir>/review-actions.json --out <output-dir>/review-actions.mdSchema contract
review-actions.jsonis canonical and must be schema version2.- No backward compatibility is provided for v1 artifacts.
- The triager edits scaffolded actions in place instead of reconstructing the entire file.
- Triage phase is only complete when there are zero
triage_pendingdecisions. - Review artifacts are generated files and must remain untracked in git.
deferdecisions require Linear tracking (linearIssue) and a thread reply with that link.
Output to user
Return artifact paths:
review-actions.jsonreview-actions.md
Suggest next step:
/review-implement-phase <PR_URL> [output-dir]
You are a review triager: an implementer-focused reviewer responsible for shepherding a PR through iterative GitHub review.
Run commands from the review-triage-phase skill directory. Script paths below are relative to it.
You do not implement code changes in this role. You decide what to do by editing a pre-generated scaffold, and keep review threads moving with clear, polite communication.
Inputs you expect from the delegating command
- PR URL (preferred) or enough context to discover it from the current branch.
- Output paths:
review-state.md+review-state.json(fetched review state; JSON is canonical)review-targets.json(optional helper index derived from review-state)review-actions.md+review-actions.json(your action plan; JSON is canonical)- Optional: scope constraints (what is in-scope/out-of-scope for this PR).
Primary responsibilities
1. Fetch current review state
- Confirm output directory artifact paths are git-ignored before writing:
node ../review-fetch-phase/scripts/guard-review-artifacts-ignored.mjs --dir <output-dir>- Use
node ../review-fetch-phase/scripts/fetch-review-state.mjs --pr <url> --out-json <review-state.json>to write canonical JSON. - Validate and generate derived files using pure scripts:
node ../review-fetch-phase/scripts/validate-review-state.mjs --in <review-state.json>node ../review-fetch-phase/scripts/render-review-state.mjs --in <review-state.json> --out <review-state.md>node ../review-fetch-phase/scripts/summarize-review-state.mjs --in <review-state.json> --format text --out <summary.txt>node ../review-fetch-phase/scripts/extract-review-targets.mjs --in <review-state.json> --out <review-targets.json>- Treat
review-state.jsonas source of truth; markdown is derived.
2. Bootstrap action scaffold
- Generate scaffold with one ordered action per review target:
node ./scripts/bootstrap-review-actions.mjs --in <review-state.json> --out <review-actions.json>- Do not rewrite structure manually when scaffold exists. Edit action fields in place.
3. Triage each review thread/comment
Compound review bodies: pull_request_review targets often contain multiple distinct findings in a single body — especially from automated reviewers like CodeRabbit. These include "outside diff range" comments (which couldn't be posted as inline threads), actionable comments, and nitpick comments. You must read the full-body text of every pull_request_review target and decompose it into individual findings. For each distinct finding:
- Add a new action to
review-actions.jsonwith a sub-indexed actionId (e.g.,A02a_PRR_...,A02b_PRR_...). - All sub-actions share the same
target(the parent review's nodeId, kindpull_request_review) but get their ownsummary,targetFiles,decision, etc. Sub-actions inherit the parent's "no inline thread" admin behavior: the implement phase posts each sub-action's "On it" / "Done" as a top-level PR issue comment viapost-review-thread-reply.mjs(auto-detected from thePRR_…node id) and does not callresolve-review-thread.mjs— there is no thread to resolve. Record the issue-comment id returned by the helper in each sub-action'sdonerecord. - Set the parent scaffold action to
not_actionablewith summary "Decomposed into sub-actions A02a–A02c" (or similar). - Never blanket-dismiss a review body as "automated summary" without reading it first. Automated reviewers embed real findings in their body text.
To read a review body: use the review-state.json canonical data (reviews are stored with full-body text), or fetch it via gh api.
Decide one of for each finding:
- TRIAGE PENDING (initial scaffold placeholder; must be resolved before handoff)
- WILL ADDRESS (needs a code/doc/test change now)
- DEFER (valid, but intentionally postponed; create Linear follow-up and reply with ticket)
- OUT OF SCOPE (belongs in a follow-up PR or different ownership area)
- ALREADY FIXED / OUTDATED (no longer applies)
- WON'T ADDRESS (valid finding the team has decided not to act on; record
rationaleexplaining the decision and optionally a Linear follow-up. Differs from NOT ACTIONABLE in that the finding itself is real and well-formed — we are choosing not to act, not dismissing it as unactionable.) - NOT ACTIONABLE (opinion-only with no clear improvement, or the comment lacks a concrete recommendation we could implement)
4. Administer GitHub threads
- For WILL ADDRESS:
- Reply: acknowledge + state intention to address.
- React with 👍.
- Leave the thread unresolved.
- For everything else:
- Reply: explain politely and concretely why it will not be addressed now (or how it will be deferred).
- React with 👎 if it will not be addressed in this PR (use sparingly but consistently).
- Resolve the thread when appropriate (outdated/out-of-scope/not-addressed).
- For DEFER specifically:
- Create a Linear follow-up issue (group related deferred comments where logical).
- Include the Linear issue URL in the defer reply.
- Record the Linear issue identifier in
review-actions.jsonaslinearIssue.
5. Finalize action plan
- Keep
review-actions.jsonandreview-actions.mdcolocated withreview-state.json. review-actions.jsonmust be canonical v2 and deterministic (2-space indent + trailing newline).- Use node-id-only targets (
target.kind,target.nodeId; optionaltarget.url). - Preserve
actions[]order intentionally. - Resolve every
triage_pendingdecision before finishing. - Run validation before rendering:
node ./scripts/validate-review-actions.mjs --in <review-actions.json> --require-finalreview-actions.mdis derived withnode ./scripts/render-review-actions.mjs --in <review-actions.json> --out <review-actions.md>.
Output formats
review-actions.json (canonical)
Write a structured JSON file that an implementer can consume and update in-place:
- Must include
version: 2 - Must include PR metadata (
pr.url; includepr.nodeIdwhen available) - Must include an
actions[]list - Each action must include:
- stable
actionId targetwithkind+nodeId(and optionalurl)decision(triage_pending|will_address|defer|out_of_scope|already_fixed|not_actionable|wont_address)source(target provenance from review-state)- triage fields:
summary,rationale,targetFiles,acceptance linearIssuewhendecisionisdefer(for exampleTML-1916)status(pending|in_progress|done)- completion field
done(nulluntil completed)
review-actions.md (human summary)
Use this template:
# Review Actions
PR: <url>
Source: `<path to review-actions.json>`
Status: <Triaged | In progress | Complete>
All actions are listed below by default (the renderer's `--view all` mode); pass
`--view will-address` to limit the table to actions triaged as **WILL ADDRESS**.
| Action ID | Decision | Target | Link | Action | Linear | Target files | Acceptance check | Status |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| A01_PRRT_xxx | defer | review_thread / PRRT_xxx | <link> | <what to change> | TML-1916 | <paths> | <how to know it’s done> | pending |Constraints
- Do not commit code changes.
- Do not stage files.
- Only write the review artifacts you were asked for (typically
review-state.*andreview-actions.*). - Store artifacts in deterministic layout:
wip/reviews/<owner>_<repo>_pr-<number>/. - Be polite, concise, and specific.
{
"name": "@prisma-next/skill-review-triage-phase",
"private": true,
"type": "module",
"version": "0.0.0",
"description": "Triage phase package for review-framework skill",
"scripts": {
"bootstrap-actions": "node scripts/bootstrap-review-actions.mjs",
"validate-actions": "node scripts/validate-review-actions.mjs",
"render-actions": "node scripts/render-review-actions.mjs"
}
}
#!/usr/bin/env node
import { realpathSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { assertReviewStateV1 } from '../../review-fetch-phase/scripts/review-artifacts.mjs';
import { assertReviewActionsV1 } from './review-artifacts.mjs';
const EXIT_SUCCESS = 0;
const EXIT_OPERATIONAL = 1;
const EXIT_CLI = 2;
function parseCliArgs(argv) {
const args = argv.slice(2);
const result = { inPath: null, outPath: null, help: false };
if (args.includes('--help')) {
result.help = true;
return result;
}
const knownFlags = new Set(['--in', '--out']);
let index = 0;
while (index < args.length) {
const arg = args[index];
if (!arg.startsWith('--') || !knownFlags.has(arg)) {
throw { code: EXIT_CLI, message: `error: unknown flag "${arg}"` };
}
index += 1;
if (index >= args.length) {
throw { code: EXIT_CLI, message: `error: ${arg} requires a value` };
}
const value = args[index];
if (arg === '--in') {
result.inPath = value;
} else if (arg === '--out') {
result.outPath = value;
}
index += 1;
}
if (!result.inPath || !result.outPath) {
throw { code: EXIT_CLI, message: 'error: --in and --out are required' };
}
if (!result.inPath.endsWith('.json') || !result.outPath.endsWith('.json')) {
throw { code: EXIT_CLI, message: 'error: --in and --out must be .json paths' };
}
return result;
}
function getHelpText() {
return [
'Usage:',
' bootstrap-review-actions.mjs --in <review-state.json> --out <review-actions.json>',
'',
'Purpose:',
' Generate deterministic triage scaffolding with one action per review target.',
].join('\n');
}
function formatCanonicalJson(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}
function toActionFromTarget(target, order) {
return {
actionId: `A${String(order).padStart(2, '0')}_${target.nodeId}`,
target: {
kind: target.kind,
nodeId: target.nodeId,
url: target.url ?? null,
},
source: {
targetKey: target.targetKey,
path: target.path ?? null,
startLine: target.startLine ?? null,
endLine: target.endLine ?? null,
isOutdated: Boolean(target.isOutdated),
isActionableCandidate: Boolean(target.isActionableCandidate),
primaryCommentNodeId: target.primaryCommentNodeId ?? null,
primaryCommentAuthorLogin: target.primaryCommentAuthorLogin ?? null,
primaryCommentCreatedAt: target.primaryCommentCreatedAt ?? null,
},
decision: 'triage_pending',
summary: null,
rationale: null,
targetFiles: target.path ? [target.path] : [],
acceptance: null,
status: 'pending',
done: null,
};
}
const IMPLEMENT_PHASE_SUPPORTED_TARGET_KINDS = new Set(['review_thread', 'pull_request_review']);
function buildReviewActions(reviewState, reviewStatePath) {
const supportedTargets = reviewState.targets.filter((target) =>
IMPLEMENT_PHASE_SUPPORTED_TARGET_KINDS.has(target.kind),
);
const actions = supportedTargets.map((target, index) => toActionFromTarget(target, index + 1));
return {
version: 2,
pr: {
url: reviewState.pr.url,
nodeId: reviewState.pr.nodeId,
},
reviewState: {
path: reviewStatePath,
fetchedAt: reviewState.fetchedAt,
version: reviewState.version,
},
actions,
};
}
async function main() {
const args = parseCliArgs(process.argv);
if (args.help) {
process.stdout.write(`${getHelpText()}\n`);
process.exit(EXIT_SUCCESS);
}
const raw = await readFile(args.inPath, 'utf8');
const reviewState = JSON.parse(raw);
assertReviewStateV1(reviewState);
const reviewActions = buildReviewActions(reviewState, args.inPath);
assertReviewActionsV1(reviewActions);
await mkdir(dirname(args.outPath), { recursive: true });
await writeFile(args.outPath, formatCanonicalJson(reviewActions), 'utf8');
}
const isMain = (() => {
try {
const invokedScriptPath = process.argv[1] ? realpathSync(resolve(process.argv[1])) : null;
const currentModulePath = realpathSync(fileURLToPath(import.meta.url));
return invokedScriptPath !== null && invokedScriptPath === currentModulePath;
} catch {
return false;
}
})();
if (isMain) {
main().catch((error) => {
const code = typeof error?.code === 'number' ? error.code : EXIT_OPERATIONAL;
const message = error?.message ? String(error.message) : String(error);
process.stderr.write(`${message}\n`);
process.exit(code);
});
}
#!/usr/bin/env node
import { realpathSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { assertReviewActionsV1 } from './review-artifacts.mjs';
const EXIT_SUCCESS = 0;
const EXIT_OPERATIONAL = 1;
const EXIT_CLI = 2;
function getHelpText() {
return [
'Usage:',
' render-review-actions.mjs --in <review-actions.json> [--out <review-actions.md>|-] [--view will-address|all] [--help]',
'',
'Purpose:',
' Render deterministic Markdown (review-actions.md) from review-actions.json.',
'',
'Flags:',
' --in <path.json> Input path to review-actions.json.',
' --out <path.md>|- Markdown output path. Use "-" to write to stdout. Defaults to stdout.',
' --view will-address|all',
' Render all actions (default) or only will-address actions.',
' --help Show this help text and exit.',
].join('\n');
}
function parseCliArgs(argv) {
const args = argv.slice(2);
const result = { inPath: null, outPath: null, view: 'all', help: false };
if (args.includes('--help')) {
result.help = true;
return result;
}
const knownFlags = new Set(['--in', '--out', '--view']);
let i = 0;
while (i < args.length) {
const arg = args[i];
if (!arg.startsWith('--')) {
throw { code: EXIT_CLI, message: `error: unknown flag "${arg}"` };
}
const flag = arg;
if (!knownFlags.has(flag)) {
throw { code: EXIT_CLI, message: `error: unknown flag "${flag}"` };
}
i++;
if (i >= args.length) {
throw {
code: EXIT_CLI,
message: `error: ${flag} requires a value`,
};
}
const value = args[i];
i++;
if (flag === '--in') {
result.inPath = value;
} else if (flag === '--out') {
result.outPath = value;
} else if (flag === '--view') {
result.view = value;
}
}
if (!result.inPath) {
throw { code: EXIT_CLI, message: 'error: --in is required' };
}
if (result.inPath === '-') {
throw { code: EXIT_CLI, message: 'error: --in - is not supported' };
}
if (!result.inPath.endsWith('.json')) {
throw { code: EXIT_CLI, message: 'error: --in file path must end with .json' };
}
if (result.outPath !== null && result.outPath !== '-' && !result.outPath.endsWith('.md')) {
throw { code: EXIT_CLI, message: 'error: --out file path must end with .md' };
}
if (result.view !== 'will-address' && result.view !== 'all') {
throw { code: EXIT_CLI, message: 'error: --view must be will-address or all' };
}
return result;
}
function escapeTableCell(value) {
return String(value ?? '')
.replace(/\r?\n/g, ' ')
.replace(/\|/g, '\\|')
.replace(/\s+/g, ' ')
.trim();
}
function formatCodePaths(paths) {
if (!Array.isArray(paths) || paths.length === 0) return '';
return paths.map((p) => `\`${escapeTableCell(p)}\``).join(', ');
}
function computeStatus(actions) {
const statuses = new Set((actions ?? []).map((a) => a?.status).filter(Boolean));
if (statuses.size === 0) return 'Triaged';
if (statuses.has('in_progress')) return 'In progress';
if (statuses.size === 1 && statuses.has('done')) return 'Complete';
if (statuses.size === 1 && statuses.has('pending')) return 'Triaged';
return 'In progress';
}
function formatTarget(target) {
const kind = escapeTableCell(target?.kind);
const nodeId = escapeTableCell(target?.nodeId);
return `${kind} / ${nodeId}`;
}
export function renderReviewActionsMarkdown(payload, { sourcePath }) {
assertReviewActionsV1(payload);
const prUrl = payload?.pr?.url ?? '';
const source = sourcePath ? escapeTableCell(sourcePath) : 'review-actions.json';
const actions = Array.isArray(payload?.actions) ? payload.actions : [];
const view = payload?.meta?.renderView === 'will-address' ? 'will-address' : 'all';
const includedActions =
view === 'all' ? actions : actions.filter((action) => action?.decision === 'will_address');
const lines = [];
lines.push('# Review Actions');
lines.push('');
lines.push(`PR: ${escapeTableCell(prUrl)}`);
lines.push(`Source: \`${source}\``);
lines.push('');
lines.push(`Status: ${computeStatus(includedActions)}`);
lines.push('');
lines.push(
view === 'all'
? 'All actions are listed below.'
: 'Only items triaged as **WILL ADDRESS** are listed below.',
);
lines.push('');
lines.push(
'| Action ID | Decision | Target | Link | Action | Target files | Acceptance check | Status |',
);
lines.push('| --- | --- | --- | --- | --- | --- | --- | --- |');
for (const action of includedActions) {
const actionId = action?.actionId ?? '';
const decision = action?.decision ?? '';
const target = formatTarget(action?.target);
const link = action?.target?.url ?? '';
const summary = action?.summary ?? '';
const targetFiles = formatCodePaths(action?.targetFiles);
const acceptance = action?.acceptance ?? '';
const status = action?.status ?? '';
lines.push(
[
escapeTableCell(actionId),
escapeTableCell(decision),
target,
escapeTableCell(link),
escapeTableCell(summary || '(pending triage)'),
targetFiles,
escapeTableCell(acceptance || ''),
escapeTableCell(status),
]
.join(' | ')
.replace(/^/, '| ')
.replace(/$/, ' |'),
);
}
return `${lines.join('\n')}\n`;
}
async function readJson(path) {
const raw = await readFile(path, 'utf8');
return JSON.parse(raw);
}
async function writeOutput(outPath, text) {
if (!outPath || outPath === '-') {
process.stdout.write(text);
if (!text.endsWith('\n')) process.stdout.write('\n');
return;
}
await mkdir(dirname(outPath), { recursive: true });
await writeFile(outPath, `${text.endsWith('\n') ? text : `${text}\n`}`, 'utf8');
}
async function main() {
const args = parseCliArgs(process.argv);
if (args.help) {
process.stdout.write(`${getHelpText()}\n`);
process.exit(EXIT_SUCCESS);
}
const payload = await readJson(args.inPath);
const markdown = renderReviewActionsMarkdown(
{ ...payload, meta: { ...(payload.meta ?? {}), renderView: args.view } },
{ sourcePath: args.inPath },
);
await writeOutput(args.outPath, markdown);
}
const isMain = (() => {
try {
const invokedScriptPath = process.argv[1] ? realpathSync(resolve(process.argv[1])) : null;
const currentModulePath = realpathSync(fileURLToPath(import.meta.url));
return invokedScriptPath !== null && invokedScriptPath === currentModulePath;
} catch {
return false;
}
})();
if (isMain) {
main().catch((error) => {
const code = typeof error?.code === 'number' ? error.code : EXIT_OPERATIONAL;
const message = error?.message ? String(error.message) : String(error);
process.stderr.write(`${message}\n`);
process.exit(code);
});
}
const REVIEW_ACTIONS_VERSION = 2;
const TARGET_KIND_VALUES = new Set([
'review_thread',
'review_comment',
'pull_request_review',
'issue_comment',
]);
const DECISION_VALUES = new Set([
'triage_pending',
'will_address',
'defer',
'out_of_scope',
'already_fixed',
'not_actionable',
'wont_address',
]);
const STATUS_VALUES = new Set(['pending', 'in_progress', 'done']);
function isNonEmptyString(value) {
return typeof value === 'string' && value.length > 0;
}
function assertReviewActionsV1(reviewActions) {
if (typeof reviewActions !== 'object' || reviewActions === null) {
throw new TypeError('review-actions must be an object');
}
if (reviewActions.version !== REVIEW_ACTIONS_VERSION) {
throw new TypeError(`review-actions version must be ${REVIEW_ACTIONS_VERSION}`);
}
if (typeof reviewActions.pr !== 'object' || reviewActions.pr === null) {
throw new TypeError('review-actions pr must be an object');
}
if (!isNonEmptyString(reviewActions.pr.url)) {
throw new TypeError('review-actions pr.url must be a non-empty string');
}
if (typeof reviewActions.reviewState !== 'object' || reviewActions.reviewState === null) {
throw new TypeError('review-actions reviewState must be an object');
}
if (!isNonEmptyString(reviewActions.reviewState.path)) {
throw new TypeError('review-actions reviewState.path must be a non-empty string');
}
if (!isNonEmptyString(reviewActions.reviewState.fetchedAt)) {
throw new TypeError('review-actions reviewState.fetchedAt must be a non-empty string');
}
if (reviewActions.reviewState.version !== 2) {
throw new TypeError('review-actions reviewState.version must be 2');
}
if (!Array.isArray(reviewActions.actions)) {
throw new TypeError('review-actions actions must be an array');
}
const seenActionIds = new Set();
for (let index = 0; index < reviewActions.actions.length; index += 1) {
const action = reviewActions.actions[index];
const pointer = `review-actions actions[${index}]`;
if (!isNonEmptyString(action?.actionId)) {
throw new TypeError(`${pointer}.actionId must be a non-empty string`);
}
if (seenActionIds.has(action.actionId)) {
throw new TypeError(`${pointer}.actionId must be unique`);
}
seenActionIds.add(action.actionId);
if (typeof action?.target !== 'object' || action.target === null) {
throw new TypeError(`${pointer}.target must be an object`);
}
if (!TARGET_KIND_VALUES.has(action.target.kind)) {
throw new TypeError(`${pointer}.target.kind must be a supported value`);
}
if (!isNonEmptyString(action.target.nodeId)) {
throw new TypeError(`${pointer}.target.nodeId must be a non-empty string`);
}
if (!DECISION_VALUES.has(action?.decision)) {
throw new TypeError(`${pointer}.decision must be a supported value`);
}
if (action.decision === 'wont_address' && !isNonEmptyString(action?.rationale)) {
throw new TypeError(
`${pointer}.rationale must be a non-empty string when decision is wont_address`,
);
}
if (action.decision !== 'triage_pending' && !isNonEmptyString(action?.summary)) {
throw new TypeError(`${pointer}.summary must be a non-empty string once triaged`);
}
if (
action.summary !== null &&
action.summary !== undefined &&
typeof action.summary !== 'string'
) {
throw new TypeError(`${pointer}.summary must be a string or null`);
}
if (
action.rationale !== null &&
action.rationale !== undefined &&
typeof action.rationale !== 'string'
) {
throw new TypeError(`${pointer}.rationale must be a string or null`);
}
if (
action.linearIssue !== null &&
action.linearIssue !== undefined &&
typeof action.linearIssue !== 'string'
) {
throw new TypeError(`${pointer}.linearIssue must be a string or null`);
}
if (action.decision === 'defer' && !isNonEmptyString(action?.linearIssue)) {
throw new TypeError(`${pointer}.linearIssue must be set when decision is defer`);
}
if (!Array.isArray(action?.targetFiles)) {
throw new TypeError(`${pointer}.targetFiles must be an array`);
}
for (let i = 0; i < action.targetFiles.length; i += 1) {
if (!isNonEmptyString(action.targetFiles[i])) {
throw new TypeError(`${pointer}.targetFiles[${i}] must be a non-empty string`);
}
}
if (
action.acceptance !== null &&
action.acceptance !== undefined &&
typeof action.acceptance !== 'string'
) {
throw new TypeError(`${pointer}.acceptance must be a string or null`);
}
if (
action.status === 'done' &&
(typeof action.done !== 'object' ||
action.done === null ||
!isNonEmptyString(action.done.doneAt))
) {
throw new TypeError(`${pointer}.done.doneAt must be present when status is done`);
}
if (action.done !== null && action.done !== undefined) {
if (typeof action.done !== 'object') {
throw new TypeError(`${pointer}.done must be an object or null`);
}
if (!isNonEmptyString(action.done.doneAt)) {
throw new TypeError(`${pointer}.done.doneAt must be a non-empty string`);
}
if (
action.done.summary !== null &&
action.done.summary !== undefined &&
typeof action.done.summary !== 'string'
) {
throw new TypeError(`${pointer}.done.summary must be a string or null`);
}
if (!Array.isArray(action.done.commits)) {
throw new TypeError(`${pointer}.done.commits must be an array`);
}
for (let commitIndex = 0; commitIndex < action.done.commits.length; commitIndex += 1) {
if (!isNonEmptyString(action.done.commits[commitIndex])) {
throw new TypeError(`${pointer}.done.commits[${commitIndex}] must be a non-empty string`);
}
}
}
if (!STATUS_VALUES.has(action?.status)) {
throw new TypeError(`${pointer}.status must be one of pending|in_progress|done`);
}
}
return reviewActions;
}
export { assertReviewActionsV1, REVIEW_ACTIONS_VERSION };
#!/usr/bin/env node
import { realpathSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { assertReviewActionsV1, REVIEW_ACTIONS_VERSION } from './review-artifacts.mjs';
const EXIT_SUCCESS = 0;
const EXIT_OPERATIONAL = 1;
const EXIT_CLI = 2;
function parseCliArgs(argv) {
const args = argv.slice(2);
const result = { inPath: null, requireFinal: false, help: false };
if (args.includes('--help')) {
result.help = true;
return result;
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--require-final') {
result.requireFinal = true;
continue;
}
if (arg !== '--in') {
throw { code: EXIT_CLI, message: `error: unknown flag "${arg}"` };
}
index += 1;
if (index >= args.length) {
throw { code: EXIT_CLI, message: 'error: --in requires a value' };
}
result.inPath = args[index];
}
if (!result.inPath) {
throw { code: EXIT_CLI, message: 'error: --in is required' };
}
if (!result.inPath.endsWith('.json')) {
throw { code: EXIT_CLI, message: 'error: --in file path must end with .json' };
}
return result;
}
function getHelpText() {
return [
'Usage:',
' validate-review-actions.mjs --in <review-actions.json> [--require-final]',
'',
'Purpose:',
` Validate canonical review-actions.json schema (v${REVIEW_ACTIONS_VERSION}).`,
' --require-final fails if any action.decision is triage_pending.',
].join('\n');
}
function assertNoTriagePending(payload) {
const pending = payload.actions.filter((action) => action?.decision === 'triage_pending');
if (pending.length === 0) {
return;
}
const ids = pending.map((action) => action.actionId).join(', ');
throw new Error(
`review-actions contains triage_pending decisions (${pending.length}): ${ids}. Complete triage before finishing this phase.`,
);
}
async function main() {
const args = parseCliArgs(process.argv);
if (args.help) {
process.stdout.write(`${getHelpText()}\n`);
process.exit(EXIT_SUCCESS);
}
const raw = await readFile(args.inPath, 'utf8');
const parsed = JSON.parse(raw);
assertReviewActionsV1(parsed);
if (args.requireFinal) {
assertNoTriagePending(parsed);
}
process.stdout.write(`ok: ${args.inPath}\n`);
}
const isMain = (() => {
try {
const invokedScriptPath = process.argv[1] ? realpathSync(resolve(process.argv[1])) : null;
const currentModulePath = realpathSync(fileURLToPath(import.meta.url));
return invokedScriptPath !== null && invokedScriptPath === currentModulePath;
} catch {
return false;
}
})();
if (isMain) {
main().catch((error) => {
const code = typeof error?.code === 'number' ? error.code : EXIT_OPERATIONAL;
const message = error?.message ? String(error.message) : String(error);
process.stderr.write(`${message}\n`);
process.exit(code);
});
}
Related skills
FAQ
What does review-triage-phase do?
Produces canonical review actions from fetched review state and renders action markdown. Use when the user wants only triage/action-planning for the review-framework workflow.
When should I use review-triage-phase?
User asks about review-triage-phase, produces canonical review actions from fetched review state and renders action markdown. u.
Is this skill safe to install?
Review the Security Audits panel on this page before installing in production.