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
1 change: 1 addition & 0 deletions .dev-backlog/sprints/_context.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- Plan refs are `#N` and nothing else. The one parser is `parseIssueRef` / `parsePlanCheckbox` in `scripts/lib.js`; `scripts/lib.sh` mirrors it as `RE_ISSUE_REF` for the shell rails.
- Active tracks partition by scope through the ONE `scopesOverlap()` in `scripts/lib.js` — never re-implement it. `component:` equality or explicit `scope:` globs decide; disjoint tracks coexist as a portfolio and overlap fails loud. Single-track text output is the compatibility surface (the G4 anchor in the smoke test); never snapshot `--json`, which is schema-versioned instead.
- Scripts own exactly four surfaces: the sprint-file contract, fail-loud shared-state guards (track overlap, unmoored `[~]`, `status: completed`), the `sprint-state` JSON recovery rail, and the `triage-apply` human gate. The keep test for any surface is (1) guards shared or irreversible state, (2) a deterministic check the model cannot cheaply redo, (3) a wire contract another tool consumes; failing all three means delete, not rewrite, and a reviewer defending a deleted doc must show the consumer reading it. A surface that stays keeps its CLI contract; interfaces change only when an issue explicitly changes them. Completed sprint files are disposable and may be deleted at any time: the record of what was done and how is the Issue, the epic, and the PR, and `_context.md` is the only thing kept after a close.
- Spec rev 21 (#494) leads the runtime: `spec/*` states the epic #504 target (checklist-only triage anchors, conservative cross-axis scope checks, a waiting next batch behind an earlier `[~]`, session-edited sprint close with no close script), while SKILL.md and the scripts describe current behavior until issues #496–#502 land. Execute from SKILL.md and the scripts; grade future work against `spec/*`; retire this line when the last of those issues merges.

## Conventions

Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ Each entry links the GitHub issue (the canonical spec) and the merge PR (the shi
### Changed

- Completed sprint files are disposable (maintainer decision 2026-09-20): the "immutable history / permanent record" wording is withdrawn in `spec/capabilities.md` (decision row), `spec/system-map.md`, SKILL.md, and `_context.md`; the repo's own 36 completed sprint files are deleted (git history keeps them); `status: completed` is still never flipped back. BREAKING for nothing in code.
- Lean contract ratified, charter rev 21 (maintainer approval on #494): `spec/charter.md`, `spec/capabilities.md`, and `spec/system-map.md` state the target behavior that epic #504 implements (one approval surface for triage, runtime retirement, native tools); the runtime is unchanged by this entry. Refs [#494](https://github.com/sungjunlee/dev-backlog/issues/494), PR [#505](https://github.com/sungjunlee/dev-backlog/pull/505).

### Fixed

- `triage-apply.js` validates every accepted action before any GitHub call or apply-log write (dry-run included) — issue numbers, strict `key=value` argument grammar, required and bounded values, canonical `milestone=` with the legacy `name=` fallback, one priority / milestone / closing action per issue — and fails with report line numbers (rules in `skills/backlog-triage/references/apply.md`); apply refuses a non-GitHub or unreadable task authority. Closes [#495](https://github.com/sungjunlee/dev-backlog/issues/495), PR [#505](https://github.com/sungjunlee/dev-backlog/pull/505).

## [0.16.0] — 2026-09-20

Expand Down
4 changes: 2 additions & 2 deletions skills/backlog-triage/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ Every proposal is an anchor comment followed by a checkbox line:
- [ ] close #42 - merged PR #87 already exists
```

Verbs: `close`, `revisit`, `close-duplicate` (`target=#N`), `set-priority` (`value=high|medium|low`), `assign-milestone` (`milestone="Name"`). Args are `key="value"` or `key=value`; the first `#N` after the verb is the target.
Verbs: `close` (`reason="..."`), `revisit` (`reason="..."`), `close-duplicate` (`target=#N`), `set-priority` (`value=high|medium|low`), `assign-milestone` (`milestone="Name"`; legacy `name=` is accepted on Apply). Args are `key="value"` or `key=value`; the first `#N` after the verb is the target.
Boundary: never propose closing an issue named in an active sprint's Plan or Running Context. Never mutate GitHub or `spec/*` in this mode.
Done when the report exists with every section present, every proposal anchored, and no close targets an in-flight issue.

## Apply

Goal: only accepted actions reach GitHub.
Rail: after a human flips `[ ]` → `[x]` next to an anchor, `node <installed skill dir>/scripts/triage-apply.js <report.md>` prints the plan (dry-run); the same with `--apply --yes` executes accepted actions, dedupes by `(verb, issueNumber, normalizedArgs)`, skips unknown verbs, logs `already-applied` on re-runs, and writes an audit log beside the report.
Rail: after a human flips `[ ]` → `[x]` next to an anchor, `node <installed skill dir>/scripts/triage-apply.js <report.md>` prints the plan (dry-run); the same with `--apply --yes` validates every accepted known action and the GitHub authority before any write, then executes accepted actions, dedupes by `(verb, issueNumber, normalizedArgs)` (legacy `name=` keyed as `milestone=`), skips unknown verbs, logs `already-applied` on re-runs, and writes an audit log beside the report.
Done when every accepted action is applied or logged `already-applied` and nothing unaccepted was touched.

Scripts resolve from the installed `backlog-triage` skill directory; run from the target project root. Sprint files, milestones, and Issue AC belong to `dev-backlog`.
Expand Down
16 changes: 13 additions & 3 deletions skills/backlog-triage/references/apply.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Examples:
```html
<!-- triage:close #104 reason="inactive/stale: no activity for 107 days" -->
<!-- triage:set-priority #101 value=high reason="theme auth has 4 recent/warm issues" -->
<!-- triage:assign-milestone #103 name="Sprint W17" cluster=auth -->
<!-- triage:assign-milestone #103 milestone="Sprint W17" cluster=auth -->
<!-- triage:close-duplicate #88 target=#42 reason="duplicate candidate converged on #42" -->
```

Expand Down Expand Up @@ -96,11 +96,21 @@ Multiple actions may target the same issue when the actions are distinct. Exampl

```html
<!-- triage:set-priority #42 value=high reason="linked to active auth work" -->
<!-- triage:assign-milestone #42 name="Sprint W17" cluster=auth -->
<!-- triage:assign-milestone #42 milestone="Sprint W17" cluster=auth -->
```

That is valid. These are separate proposals and therefore separate anchor+checkbox pairs.

For `assign-milestone`, `milestone=` is canonical. Apply accepts legacy `name=` only when `milestone=` is absent; supplying both with different values is invalid. Accepted known actions are validated together before any GitHub call or apply-log write, including in dry-run. Any of the following fails the whole report with its line numbers:

- an issue number that is not a positive safe integer;
- argument text that is not whitespace-separated `key=value` / `key="value"` pairs, or a key given twice;
- a missing or blank required argument, an invalid `set-priority` value or `close-duplicate` target (`#N` with N a positive safe integer, not the issue itself), or any value containing a NUL character or more than 16,000 UTF-8 bytes;
- two priority or two milestone assignments for the same issue, whether the values differ or repeat with other arguments changed (legacy `name=` counts as `milestone=`; an otherwise identical alias pair is one action, in the report and in the apply log), or two different duplicate targets;
- more than one accepted closing action (`close` or `close-duplicate`) for the same issue.

Unchecked actions remain inert and unknown verbs keep their skip-and-log behavior. In apply mode, an absent `.dev-backlog/.tracker` or a first line of `github` is required; any other or unreadable authority stops before a write.

The invalid case is duplicating the exact same action for the same issue without new meaning, for example two separate `close` anchors for `#42` that differ only by wording.

## Report / Apply Contract
Expand Down Expand Up @@ -132,6 +142,6 @@ Every proposal emitted in a source section is re-emitted in `## Apply Checklist`

### Deduplication rule for the apply step

Because the same action anchor appears in both its source section and the Apply Checklist, `triage-apply.js` deduplicates by `(verb, issueNumber, normalizedArgs)`, where `normalizedArgs` means sorted keys with trimmed string values before stable serialization.
Because the same action anchor appears in both its source section and the Apply Checklist, `triage-apply.js` deduplicates by `(verb, issueNumber, normalizedArgs)`, where `normalizedArgs` means sorted keys with trimmed string values before stable serialization; for `assign-milestone`, a legacy `name=` that does not disagree with `milestone=` is rewritten to `milestone=` first, so the alias pair is one action in the report and in the apply log.

A user may check the box in either location to accept the action; the deduped action is treated as accepted when *any* occurrence of that anchor is checked. Unknown verbs still parse, but `triage-apply.js` logs and skips them during execution.
28 changes: 28 additions & 0 deletions skills/backlog-triage/scripts/anchor.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,32 @@ function parseAnchorArgs(argText) {
return args;
}

// Strict grammar check for the args payload: whitespace-separated key=value or
// key="value" pairs, each key once, nothing left over.
function argSyntaxErrors(argText) {
const errors = [];
const source = typeof argText === "string" ? argText.trim() : "";
const pair = /([\w-]+)=(?:"((?:\\"|[^"])*)"|([^\s"]+))(?=\s|$)/y;
const seen = new Set();
let index = 0;
while (index < source.length) {
if (/\s/.test(source[index])) {
index += 1;
continue;
}
pair.lastIndex = index;
const match = pair.exec(source);
if (!match) {
errors.push(`unparsed argument text ${JSON.stringify(source.slice(index, index + 40))}`);
break;
}
if (seen.has(match[1])) errors.push(`${match[1]} is given more than once`);
seen.add(match[1]);
index = pair.lastIndex;
}
return errors;
}

function parseAnchor(line) {
const match = String(line || "").match(ANCHOR_PATTERN);
if (!match) return null;
Expand All @@ -23,11 +49,13 @@ function parseAnchor(line) {
issueNumber: Number(match[2]),
argsText: match[3] || "",
args: parseAnchorArgs(match[3] || ""),
argErrors: argSyntaxErrors(match[3] || ""),
};
}

module.exports = {
ANCHOR_PATTERN,
parseAnchorArgs,
argSyntaxErrors,
parseAnchor,
};
145 changes: 140 additions & 5 deletions skills/backlog-triage/scripts/triage-apply.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
const fs = require("fs");
const path = require("path");
const { execFileSync } = require("node:child_process");
const { GH_EXEC_DEFAULTS } = require("../../dev-backlog/scripts/lib.js");
const { GH_EXEC_DEFAULTS, readTaskAuthority } = require("../../dev-backlog/scripts/lib.js");
const { ANCHOR_PATTERN, parseAnchor } = require("./anchor.js");

const { DEFAULT_BACKLOG_DIR, defaultTriageDir } = require("../../dev-backlog/scripts/execution-root.js");
Expand Down Expand Up @@ -103,8 +103,18 @@ function stableSerialize(value) {
return JSON.stringify(value);
}

// Keys treat the legacy milestone alias `name=` as `milestone=`, so an alias pair in one
// report dedupes and a log written with either spelling matches a rerun with the other.
function canonicalKeyArgs(verb, normalizedArgs) {
if (verb !== "assign-milestone" || normalizedArgs.name === undefined) return normalizedArgs;
if (normalizedArgs.milestone !== undefined && normalizedArgs.milestone !== normalizedArgs.name) return normalizedArgs;
const { name, ...rest } = normalizedArgs;
return { ...rest, milestone: name };
}

function buildActionKey(action) {
return `${action.verb}|${action.issueNumber}|${stableSerialize(action.normalizedArgs || normalizeArgs(action.args))}`;
const normalizedArgs = action.normalizedArgs || normalizeArgs(action.args);
return `${action.verb}|${action.issueNumber}|${stableSerialize(canonicalKeyArgs(action.verb, normalizedArgs))}`;
}

function parseFrontmatter(text) {
Expand Down Expand Up @@ -175,9 +185,10 @@ function parseReport(text) {
normalizedArgs,
checked,
knownVerb,
key: `${anchor.verb}|${anchor.issueNumber}|${stableSerialize(normalizedArgs)}`,
key: buildActionKey({ verb: anchor.verb, issueNumber: anchor.issueNumber, normalizedArgs }),
anchorLine: index + 1,
checkboxLine: checkboxIndex + 1,
argErrors: anchor.argErrors,
};

anchors.push(entry);
Expand Down Expand Up @@ -214,6 +225,7 @@ function dedupActions(anchors) {
anchorLine: anchor.anchorLine,
checkboxLine: anchor.checkboxLine,
checked: anchor.checked,
argErrors: anchor.argErrors,
},
],
});
Expand All @@ -227,12 +239,115 @@ function dedupActions(anchors) {
anchorLine: anchor.anchorLine,
checkboxLine: anchor.checkboxLine,
checked: anchor.checked,
argErrors: anchor.argErrors,
});
}

return [...deduped.values()];
}

// Keeps every gh command line under Windows's 32,767-character limit (and so under GitHub's
// comment limit and Linux's per-argument limit), so an accepted action cannot fail at spawn
// time after earlier writes. The one joined argument, the close-duplicate comment, adds only
// a safe-integer target to its reason.
const MAX_ARG_BYTES = 16000;

function validateAcceptedActions(actions) {
const errors = [];
const fieldsByIssue = new Map();
const closesByIssue = new Map();

for (const action of actions) {
if (!action.checked || !action.knownVerb) continue;

const line = action.occurrences.find((occurrence) => occurrence.checked)?.anchorLine
|| action.occurrences[0].anchorLine;
const location = `line ${line}: triage:${action.verb} #${action.issueNumber}`;
const args = action.normalizedArgs;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject unparsed anchor argument text

When a checked known anchor contains malformed or duplicate argument text, validation sees only the object already produced by parseAnchorArgs; for example, reason="first" reason="second" silently keeps the latter value, while reason="stale" garbage ignores garbage. Both therefore pass preflight and can trigger an irreversible GitHub write despite violating the documented key-value grammar. Validate that the entire argument payload is consumed exactly once per key before accepting the action.

AGENTS.md reference: AGENTS.md:L44-L48

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0f073e9: argSyntaxErrors in anchor.js requires the whole args payload to be whitespace-separated key=value / key="value" pairs with each key once; a checked occurrence with a repeated key or leftover text fails preflight with its line. Covered by tests for both examples.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Measure argument limits before trimming

When an accepted quoted value exceeds 60,000 bytes only because of leading or trailing whitespace, normalizeArgs() strips that whitespace before this assignment, so the byte-limit check sees the shortened value and accepts it. The original unbounded value remains in action.args and is persisted by buildLogEntry() after the GitHub mutation; if that oversized append fails, the mutation is left unrecorded and a retry can repeat it. Validate the raw parsed values before trimming, or consistently store the validated normalized values.

AGENTS.md reference: AGENTS.md:L44-L48

Useful? React with 👍 / 👎.

for (const occurrence of action.occurrences) {
if (!occurrence.checked) continue;
for (const message of occurrence.argErrors || []) {
errors.push(`line ${occurrence.anchorLine}: triage:${action.verb} #${action.issueNumber}: ${message}.`);
}
}
if (!Number.isSafeInteger(action.issueNumber) || action.issueNumber < 1) {
errors.push(`${location}: issue number must be a positive safe integer.`);
}
for (const [key, argValue] of Object.entries(args)) {
if (String(argValue).includes("\u0000")) {
errors.push(`${location}: ${key} must not contain a NUL character.`);
}
if (Buffer.byteLength(String(argValue), "utf8") > MAX_ARG_BYTES) {
errors.push(`${location}: ${key} must be at most ${MAX_ARG_BYTES} bytes.`);
Comment on lines +276 to +281

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cap the rendered duplicate comment

When a checked close-duplicate supplies a target and reason that are each below 60,000 bytes, this per-value check accepts them even though formatDuplicateComment() concatenates both into one argument. For example, a 59,999-digit target plus a 60,000-byte reason produces a 120,015-byte comment body, exceeding the 65,536-character ceiling this block is explicitly intended to enforce; if this action follows valid actions, gh issue comment can fail only after those earlier mutations have occurred. Validate the rendered comment size, or impose a realistic bound on target, during whole-set preflight.

AGENTS.md reference: AGENTS.md:L44-L48

Useful? React with 👍 / 👎.

}
}
const required = {
close: ["reason"],
revisit: ["reason"],
"close-duplicate": ["target"],
"set-priority": ["value"],
"assign-milestone": [],
}[action.verb];

for (const key of required) {
if (!String(args[key] || "").trim()) {
errors.push(`${location}: ${key} is required and must not be blank.`);
}
}

let field;
let value;
if (action.verb === "set-priority") {
field = "priority";
value = args.value;
if (value && !["high", "medium", "low"].includes(value)) {
errors.push(`${location}: value must be high, medium, or low.`);
}
} else if (action.verb === "assign-milestone") {
field = "milestone";
value = args.milestone === undefined ? args.name : args.milestone;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Canonicalize the legacy milestone alias before deduplication

When two checked proposals assign the same milestone using milestone="A" and legacy name="A"—for example, when a legacy source proposal is repeated canonically in the checklist—this fallback recognizes equivalent values, but the earlier raw-argument deduplication gives them different action keys. Validation therefore succeeds and apply executes the identical gh issue edit ... --milestone A mutation twice; normalize name into milestone before building action keys so aliases share one deterministic action.

AGENTS.md reference: AGENTS.md:L44-L48

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0f073e9: a second milestone assignment for an issue is rejected in preflight whether the value differs or repeats, and legacy name= counts as milestone=, so the alias pair fails with both line numbers before any write.

if (!String(value || "").trim()) {
errors.push(`${location}: milestone is required and must not be blank.`);
}
if (args.milestone !== undefined && args.name !== undefined && args.milestone !== args.name) {
errors.push(`${location}: milestone and legacy name have different values.`);
}
} else if (action.verb === "close-duplicate") {
field = "duplicate target";
value = args.target;
if (value && (!/^#[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value.slice(1))))) {
errors.push(`${location}: target must be #N with a positive issue number.`);
} else if (value && Number(value.slice(1)) === action.issueNumber) {
errors.push(`${location}: target must not be the issue itself.`);
}
}

if (action.verb === "close" || action.verb === "close-duplicate") {
const previousClose = closesByIssue.get(action.issueNumber);
const bothDuplicates = previousClose?.verb === "close-duplicate" && action.verb === "close-duplicate";
if (!previousClose) {
closesByIssue.set(action.issueNumber, { verb: action.verb, target: args.target, line });
} else if (!(bothDuplicates && previousClose.target !== args.target)) {
errors.push(`${location}: more than one accepted closing action for #${action.issueNumber}; line ${previousClose.line} already closes it.`);
}
}

if (!field || !value) continue;
if (!fieldsByIssue.has(action.issueNumber)) fieldsByIssue.set(action.issueNumber, new Map());
const fields = fieldsByIssue.get(action.issueNumber);
Comment on lines +335 to +337

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject duplicate closes before any write

When the same issue has two checked close anchors with different reason wording—the case explicitly declared invalid in references/apply.md—deduplication treats them as distinct, while this branch skips both because close never assigns field. Apply consequently emits two comments and two close mutations instead of failing preflight, so a harmless wording variation can cause duplicated GitHub writes; detect multiple accepted closes for an issue during validation.

AGENTS.md reference: AGENTS.md:L44-L48

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 43b2902: preflight now rejects a second accepted closing action (close or close-duplicate) for the same issue with both report line numbers, before any GitHub call or apply-log write, in dry-run and apply. Different duplicate targets keep the existing conflict message. Covered by a new test (three pairings × dry-run/apply, zero gh calls, no log).

const previous = fields.get(field);
if (previous && previous.value !== value) {
errors.push(`${location}: conflicting ${field} ${JSON.stringify(value)}; line ${previous.line} assigns ${JSON.stringify(previous.value)}.`);
} else if (previous && field !== "duplicate target") {
errors.push(`${location}: ${field} ${JSON.stringify(value)} is already assigned on line ${previous.line}; keep one anchor.`);
} else if (!previous) {
fields.set(field, { value, line });
Comment on lines +338 to +344

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject duplicate same-value field assignments

When two checked assign-milestone anchors target the same issue and milestone but differ only in a non-semantic argument such as reason, their action keys remain distinct and this equality branch accepts both; apply then executes the identical gh issue edit --milestone mutation twice. This contradicts the duplicate rule in skills/backlog-triage/references/apply.md:106, so reject or semantically deduplicate repeated same-value assignments during preflight.

AGENTS.md reference: AGENTS.md:L44-L48

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0f073e9: any second priority or milestone assignment for the same issue fails preflight ("already assigned on line N; keep one anchor"), regardless of non-semantic args such as reason. Covered by a new test.

}
}

return errors;
}

function formatRevisitComment(reason) {
return `triage: revisit — ${String(reason || "").trim()}`;
}
Expand Down Expand Up @@ -293,7 +408,7 @@ function toGhCommands(action, context = {}) {
];
case "assign-milestone":
return [
["issue", "edit", issue, "--milestone", String(args.name || "").trim()],
["issue", "edit", issue, "--milestone", String(args.milestone ?? args.name ?? "").trim()],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Canonicalize milestone aliases in persisted action keys

When an action was first applied with legacy name="A" and the report is later rewritten using the newly canonical milestone="A", readApplyLog() reconstructs the old key from {name: "A"} while the current action uses {milestone: "A"}. Fresh evidence after the duplicate-assignment fix is that these two sequential runs still execute the identical gh issue edit ... --milestone A mutation twice instead of returning already-applied; rejecting aliases that coexist in one report does not protect reruns after report migration. Canonicalize the alias before building and persisting action keys.

AGENTS.md reference: AGENTS.md:L44-L48

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 96eb862: action keys canonicalize legacy name= to milestone= (when they do not disagree), so an alias pair in one report is one action and a rerun after the spelling change is already-applied. Test covers both.

];
default:
return [];
Expand Down Expand Up @@ -721,6 +836,26 @@ function execute(argv = process.argv.slice(2), deps = {}) {
};
}

const deduped = dedupActions(report.anchors);
const validationErrors = validateAcceptedActions(deduped);
if (validationErrors.length > 0) {
return {
exitCode: 1,
error: `Invalid accepted triage actions:\n${validationErrors.map((error) => `- ${error}`).join("\n")}`,
};
}

if (options.apply) {
try {
const authority = readTaskAuthority(path.resolve(cwd, DEFAULT_BACKLOG_DIR));
if (authority !== "github") {
throw new Error(`task authority is ${JSON.stringify(authority)}; triage apply requires github.`);
}
} catch (error) {
return { exitCode: 1, error: `Cannot apply triage actions: ${error.message}` };
}
}

try {
ensureApplyAllowed(options, {
confirmApply: deps.confirmApply,
Expand All @@ -736,7 +871,6 @@ function execute(argv = process.argv.slice(2), deps = {}) {
};
}

const deduped = dedupActions(report.anchors);
const applyMode = options.apply ? "apply" : "dry-run";
const result = {
report: reportPath,
Expand Down Expand Up @@ -888,6 +1022,7 @@ module.exports = {
parseFrontmatter,
parseReport,
dedupActions,
validateAcceptedActions,
formatRevisitComment,
formatDuplicateComment,
buildPriorityEditCommand,
Expand Down
Loading
Loading