From d0b12f883d259eea41e28577ac6a0307b7b7b11c Mon Sep 17 00:00:00 2001 From: SJ Lee Date: Sat, 8 Aug 2026 17:43:09 +0900 Subject: [PATCH 1/6] refactor: replace backlog-triage judgment heuristics with prompt guidance (#358) Spike Option B: scripts keep deterministic signals (mentions, merged-PR links, dates, labels); the model owns semantic judgment (blocks, depends-on, duplicates, priority and milestone proposals). - triage-relate.js: drop scanPhraseEdges/scanBlocks/scanDependsOn and title-Jaccard duplicates; emits mentions/comment-mentions/merged-pr-link - triage-stale.js: drop scanDuplicateOfClosed/closedIssueMatches/jaccard; keeps inactive/wontfix/invalid/merged-closing-pr - triage-report.js: drop rule-based priority/milestone builders; add --model-actions PATH so model-judged actions render deterministically - Rubrics: relationships.md, stale.md, decision-review.md, SKILL.md 453 tests pass / 0 fail; smoke-test 190 pass; net -454 script/test lines. --- CHANGELOG.md | 2 + skills/backlog-triage/SKILL.md | 41 +++- .../references/classification.md | 3 +- .../references/decision-review.md | 25 +++ .../references/relationships.md | 84 ++++---- skills/backlog-triage/references/stale.md | 42 ++-- .../backlog-triage/scripts/triage-relate.js | 130 +------------ .../scripts/triage-relate.test.js | 160 +--------------- .../backlog-triage/scripts/triage-report.js | 179 ++++-------------- .../scripts/triage-report.test.js | 70 ++++--- skills/backlog-triage/scripts/triage-stale.js | 81 -------- .../scripts/triage-stale.test.js | 37 +--- 12 files changed, 197 insertions(+), 657 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a582479..51e7ac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Each entry links the GitHub issue (the canonical spec) and the merge PR (the shi ### Removed +- **backlog-triage judgment heuristics** — replaced ~265 lines of semantic judgment (`scanPhraseEdges`/`scanBlocks`/`scanDependsOn`, title-Jaccard duplicates in relate/stale, and rule-based `buildPriorityActions`/`buildMilestoneActions`) with prompt-level rubric guidance. Scripts now emit deterministic signals only (mentions, merged-PR links, dates, labels); the model's judged blocks/depends-on/duplicate edges and priority/milestone proposals enter the report through the new `triage-report.js --model-actions PATH`, keeping anchors, dedupe, and the Apply Checklist deterministic. Net −~454 script/test lines. Closes [#358](https://github.com/sungjunlee/dev-backlog/issues/358). + - **Monthly Progress sync and sprint issue publication** — removed 3,758 lines of scripts and dedicated tests after their intended adoption windows finished. Only one monthly issue was ever created (`Progress: April 2026`, #46; none in May–July), and the four sprint issues (#230, #234, #237, #239) were all created on 2026-07-03/04 with none since. No core lifecycle script or dev-relay integration invokes either feature, and `task-progress-reporting` accumulated no Learnings. Closes [#340](https://github.com/sungjunlee/dev-backlog/issues/340). - **Required task mirrors** — the GitHub-native core now resolves task specification, AC, and lifecycle directly from live Issues. Fresh setup and complete sprint execution require no `backlog/tasks/` or `backlog/completed/`; `sync-pull --legacy-export` remains an explicit one-way diagnostic/rollback boundary. Closes [#347](https://github.com/sungjunlee/dev-backlog/issues/347). - **Zero-adopter local tracker and generic compatibility machinery** — measured adoption found 0 of 17 consumers selecting a non-default tracker and GitHub remotes in all 18 known consumers. The local JSON store, local lifecycle tests, and generic/local design surface are removed; GitHub failure is fail-loud with no fallback, while Backlog.md remains manual import/explicit export compatibility only. Closes [#348](https://github.com/sungjunlee/dev-backlog/issues/348). diff --git a/skills/backlog-triage/SKILL.md b/skills/backlog-triage/SKILL.md index 6af5c7f..5e04ae8 100644 --- a/skills/backlog-triage/SKILL.md +++ b/skills/backlog-triage/SKILL.md @@ -68,14 +68,16 @@ The report is a derived artifact under `backlog/triage/`. GitHub Issues remain t Required sections: - `## Classification` — issue buckets by theme, label, age, activity, and milestone state. -- `## Relationships` — mentions, blocks, depends-on, duplicate candidates, and merged closing PR links. +- `## Relationships` — deterministic mentions/comment-mentions/merged-PR links from scripts, plus model-judged blocks, depends-on, and duplicate-candidate edges passed via `--model-actions`. - `## Obsolete Candidates` — anchored close/revisit proposals with evidence. -- `## Priority Proposals` — anchored priority proposals with rationale. -- `## Milestone Suggestions` — anchored milestone proposals grouped into candidate sprint clusters. +- `## Priority Proposals` — model-judged anchored priority proposals with rationale (delivered via `--model-actions`). +- `## Milestone Suggestions` — model-judged anchored milestone proposals grouped into candidate sprint clusters (delivered via `--model-actions`). - `## Alignment` — objective coverage, orphan work, neglected objectives, contradictions, and proposed charter changes; when no charter exists, record that alignment was skipped. - `## Decision Review` — `Do Now`, `Shape First`, `Defer`, and `Drop / Close`. - `## Apply Checklist` — consolidated review surface for every anchored action. +Judgment distribution: scripts own deterministic signals (issue refs, merged-PR links, dates, labels); the model owns semantic judgment (blocks, depends-on, duplicates, priority and milestone proposals). The model's judgment is rendered by `triage-report.js` from a `--model-actions` JSON file, so anchor formatting, dedupe, and the Apply Checklist stay deterministic. + Full section examples and rubric details live in `references/classification.md`, `references/relationships.md`, `references/stale.md`, `references/decision-review.md`, and `references/apply.md`. ## Relationship To dev-backlog @@ -105,19 +107,42 @@ node "$skill_dir/scripts/triage-collect.js" --dry-run --json node "$skill_dir/scripts/triage-apply.js" backlog/triage/YYYY-MM-DD-report.md ``` +Model-judged actions (`--model-actions`) are a JSON array of action objects; each needs `section`, `verb`, `issueNumber`, and `summary`, with `args` carrying the mutation payload (see `references/apply.md` for the anchor grammar each verb maps to): + +```json +[ + { + "section": "priority", + "verb": "set-priority", + "issueNumber": 42, + "args": { "value": "high", "reason": "customer-reported outage blocks the auth theme" }, + "summary": "Set priority:high on #42 — customer-reported outage blocks the auth theme" + }, + { + "section": "milestone", + "verb": "assign-milestone", + "issueNumber": 43, + "args": { "name": "Sprint W34", "cluster": "auth" }, + "cluster": "auth", + "sprintName": "Sprint W34", + "summary": "Assign Sprint W34 to #43 — auth cluster" + } +] +``` + Useful scripts: -- `scripts/triage-collect.js [--repo OWNER/REPO] [--limit N] [--json] [--dry-run] [--with-comments] [--with-closed-issues]` — fetch open issues and write `backlog/triage/.cache/.json`; `--with-comments` enables comment-mention edges and `--with-closed-issues` enables duplicate-of-closed signals, see `references/classification.md`. -- `scripts/triage-relate.js --snapshot PATH [--json]` — detect mentions, blocks, depends-on, duplicates, and merged PR links. -- `scripts/triage-stale.js --snapshot PATH [--since N] [--json]` — flag stale/obsolete candidates with evidence. -- `scripts/triage-report.js --snapshot PATH [--relate PATH] [--stale PATH] [--active-sprint PATH] [--out PATH] [--json]` — render report; creates `.bak` on overwrite. +- `scripts/triage-collect.js [--repo OWNER/REPO] [--limit N] [--json] [--dry-run] [--with-comments] [--with-closed-issues]` — fetch open issues and write `backlog/triage/.cache/.json`; `--with-comments` hydrates comment bodies and `--with-closed-issues` enriches the snapshot with recent closed issues for the model's duplicate judgment, see `references/classification.md`. +- `scripts/triage-relate.js --snapshot PATH [--json]` — deterministic edges: mentions, comment-mentions, and merged closing PR links. +- `scripts/triage-stale.js --snapshot PATH [--since N] [--json]` — deterministic stale/obsolete candidates from dates and labels. +- `scripts/triage-report.js --snapshot PATH [--relate PATH] [--stale PATH] [--active-sprint PATH] [--model-actions PATH] [--out PATH] [--json]` — render report; model-judged actions (blocks/depends-on/duplicate edges, priority and milestone proposals) come from `--model-actions` JSON; creates `.bak` on overwrite. - `scripts/triage-apply.js [--apply] [--yes] [--json]` — parse accepted anchors and execute/dry-run GitHub mutations. - `scripts/triage-apply.integration.test.js` — opt-in live integration test against the disposable sandbox repo; requires `TRIAGE_APPLY_INTEGRATION=1` and `GH_TOKEN`. ## References - `references/classification.md` — bucketing rules and YAML config schema. -- `references/relationships.md` — relationship heuristics and evidence format. +- `references/relationships.md` — deterministic edge rules and the model-judged blocks/depends-on/duplicate rubric. - `references/stale.md` — obsolescence signals, thresholds, and suggested-action grammar. - `references/apply.md` — anchor grammar, parse rules, idempotency contract, and apply-log schema. - `references/decision-review.md` — prompt-driven Do Now / Shape First / Defer / Drop rubric. diff --git a/skills/backlog-triage/references/classification.md b/skills/backlog-triage/references/classification.md index 79ecb90..bc7c9cb 100644 --- a/skills/backlog-triage/references/classification.md +++ b/skills/backlog-triage/references/classification.md @@ -15,7 +15,6 @@ comment_fetch_concurrency: 5 closed_issue_days: 180 closed_issue_limit: 200 stale_days: 60 -duplicate_threshold: 0.75 ``` - `theme_keywords` maps a theme name to title-keyword substrings. The first matching theme wins. @@ -24,7 +23,7 @@ duplicate_threshold: 0.75 - `comment_fetch_concurrency` bounds `--with-comments` fan-out when comment hydration is enabled. - `closed_issue_days` bounds the lookback window for `--with-closed-issues`. - `closed_issue_limit` caps how many recent closed issues are collected for snapshot v2 enrichment. -- `stale_days` and `duplicate_threshold` are collected as config-as-data for downstream scripts (`triage-stale`, `triage-relate`); `triage-collect` does not apply them yet. +- `stale_days` is collected as config-as-data for `triage-stale`; `triage-collect` does not apply it yet. ## Snapshot shape diff --git a/skills/backlog-triage/references/decision-review.md b/skills/backlog-triage/references/decision-review.md index f44578d..4947b9e 100644 --- a/skills/backlog-triage/references/decision-review.md +++ b/skills/backlog-triage/references/decision-review.md @@ -95,3 +95,28 @@ Evidence absent: `spec/system-map.md`. ``` Do not include anchors for non-mutating recommendations. Only include close/relabel/milestone anchors when the normal apply contract supports the action and a human can accept it with a checkbox. + +## Priority and Milestone Proposals + +Priority Proposals and Milestone Suggestions are also prompt-driven and delivered to `triage-report.js --model-actions`. The old script heuristics become judgment guidance: + +### Priority Proposals (`section: "priority"`, `verb: "set-priority"`) + +Suggest `priority:high` only for issues that are all of: + +- not already `priority:high` or `priority:critical` +- not cold (recent activity or warm bucket) +- not already proposed for close +- high-leverage by at least one of: sits in a theme with several recent/warm issues, participates in relationship edges, or is called out by an active sprint or Decision Review + +Rationale must name the trigger (theme heat, relationship edges, sprint focus), not just restate the issue title. + +### Milestone Suggestions (`section: "milestone"`, `verb: "assign-milestone"`) + +Suggest a milestone only for issues that: + +- have no milestone yet +- are not already proposed for close +- are active or related (relationship edges, active theme, or a priority proposal above) + +Set `args.name` to a concrete candidate sprint (`Sprint W`), `args.cluster` to the dominant theme, and put the same values in top-level `sprintName` / `cluster` for grouping. Only suggest a milestone when the issue is genuinely ready for sprint planning; otherwise leave it unassigned. diff --git a/skills/backlog-triage/references/relationships.md b/skills/backlog-triage/references/relationships.md index 389abba..c6e9cb6 100644 --- a/skills/backlog-triage/references/relationships.md +++ b/skills/backlog-triage/references/relationships.md @@ -1,18 +1,17 @@ # Relationships -**Purpose.** `triage-relate.js` reads a previously collected issue snapshot and emits read-only relationship edges for snapshot-resident signals: +**Purpose.** The `## Relationships` section of the triage report maps how open issues connect to each other and to merged closing PRs. Deterministic signals come from `triage-relate.js`; semantic judgment (blocking, dependency, duplication) is the model's job and is rendered from the model's own edge JSON. + +## Script-generated Edges (deterministic) + +`triage-relate.js` reads a previously collected issue snapshot and emits read-only edges for snapshot-resident signals that need no interpretation: - `mentions` from plain `#123` references in issue bodies - `comment-mentions` from plain `#123` references in optional issue comments -- `blocks` from explicit blocking / closing phrases in issue bodies -- `depends-on` from explicit dependency phrases in issue bodies - `merged-pr-link` from per-issue merged closing PR metadata -- `duplicate-candidate` from title-token Jaccard overlap Every emitted edge carries evidence taken directly from the snapshot so downstream report rendering can show why the relationship was inferred without re-fetching from GitHub. -## Implemented Heuristics - ### `mentions` - Source: `issue.body` @@ -42,29 +41,6 @@ Every emitted edge carries evidence taken directly from the snapshot so downstre - `match`: matched issue reference - `snippet`: normalized sentence/line fragment containing the match -### `blocks` - -- Source: `issue.body` -- Keywords used by `scanBlocks`: - - `blocks #123` - - `closes #123` -- Confidence: `1` -- Evidence: - - `phrase`: normalized matched phrase, for example `Blocks #123` - - `snippet`: normalized sentence/line fragment containing the phrase - -### `depends-on` - -- Source: `issue.body` -- Keywords used by `scanDependsOn`: - - `blocked by #123` - - `depends on #123` - - `depends-on #123` -- Confidence: `1` -- Evidence: - - `phrase`: normalized matched phrase, for example `depends on #123` - - `snippet`: normalized sentence/line fragment containing the phrase - ### `merged-pr-link` - Source: `issue.closing_prs` @@ -79,31 +55,38 @@ Every emitted edge carries evidence taken directly from the snapshot so downstre - `pr.mergedAt`: merge timestamp - `pr.url`: closing PR URL when present -### `duplicate-candidate` +## Model-judged Edges (semantic) + +Blocking, dependency, and duplication require reading issue intent, so they are judged by the model reading the snapshot, not by phrase matching or title token overlap. The model emits edges in the same shape as the script (kind, confidence, evidence) and passes them to `triage-report.js --model-actions`. + +### `blocks` -- Source: `issue.title` -- Threshold: `backlog/triage-config.yml -> duplicate_threshold` -- Confidence: Jaccard similarity score -- Canonicalization: - - compare each issue pair once - - emit a single edge with the smaller issue number as `from` +- Read `issue.body` and comment bodies for explicit statements that issue X blocks issue Y, or closes it once completed. +- Only emit for issues that exist in the snapshot; never for `#999`-style dangling references. +- Confidence: `1` when the phrasing is explicit, lower when inferred. - Evidence: - - `score`: rounded Jaccard score (`4` decimal places) - - `overlap`: sorted shared title tokens - - `titles.from`: lower-numbered issue title - - `titles.to`: higher-numbered issue title + - `phrase`: normalized matched phrase, for example `Blocks #123` + - `snippet`: normalized sentence/line fragment containing the phrase -## Jaccard Tokenization Rules +### `depends-on` -Title similarity uses the following normalization before scoring: +- Read `issue.body` and comment bodies for explicit dependency statements: `blocked by #123`, `depends on #123`, `depends-on #123`, or equivalent intent. +- Only emit for issues that exist in the snapshot. +- Confidence: `1` when explicit, lower when inferred. +- Evidence: + - `phrase`: normalized matched phrase, for example `depends on #123` + - `snippet`: normalized sentence/line fragment containing the phrase -- lowercase the title -- extract tokens with regex `[a-z0-9]+` -- drop one-character tokens -- deduplicate tokens per title by converting to a set -- compute `overlap / union` +### `duplicate-candidate` + +- Compare open issues against each other and against closed issues, judging semantic duplication from titles, bodies, labels, and comments — not title-token overlap alone. +- Emit one canonical edge with the smaller issue number as `from`. +- Evidence: + - `reason`: short human-readable why this is a duplicate candidate + - `titles.from`: lower-numbered issue title + - `titles.to`: higher-numbered issue title -If the union is empty, the score is `0` and no edge is emitted. +A duplicate of a closed issue should be proposed as an Obsolete Candidate (close / merge-into), not only as a relationship edge — see `references/stale.md`. ## Evidence Schema @@ -169,8 +152,7 @@ Evidence payloads vary by kind: ```json { - "score": 0.8, - "overlap": ["flow", "oauth", "refresh", "token"], + "reason": "same OAuth refresh flow as #200 with no substantive delta", "titles": { "from": "OAuth token refresh flow", "to": "OAuth token refresh flow redesign" @@ -178,4 +160,4 @@ Evidence payloads vary by kind: } ``` -`triage-relate.js` is intentionally still read-only. Close or duplicate proposals belong to `triage-stale.js` and still require report review plus an accepted apply checkbox before any GitHub mutation. +`triage-relate.js` is intentionally read-only. Close or duplicate proposals still require report review plus an accepted apply checkbox before any GitHub mutation. diff --git a/skills/backlog-triage/references/stale.md b/skills/backlog-triage/references/stale.md index d4d733f..2ce7217 100644 --- a/skills/backlog-triage/references/stale.md +++ b/skills/backlog-triage/references/stale.md @@ -10,12 +10,31 @@ | `wontfix` | Issue has a `wontfix` label (case-insensitive) | `labeled ; explicit wontfix signal` | `close` | | `invalid` | Issue has an `invalid` label (case-insensitive) | `labeled ; explicit invalid signal` | `close` | | `merged-closing-pr` | Optional `closing_prs[]` includes `state: "MERGED"` and non-empty `mergedAt` | `merged closing PR detected: PR # merged at ` | `close` | -| `duplicate-of-closed` | Optional `closed_issues[]` includes an exact or high-overlap title match | `duplicate of closed issue #: title similarity ` | `merge-into:#` | `stale_days` comes from `backlog/triage-config.yml` unless `--since N` is passed, in which case the CLI override wins. Issues with any milestone are exempt from the `inactive` signal even if they are older than the threshold. +## Model-judged duplicate-of-closed + +An open issue that duplicates a closed issue is judged by the model reading titles, bodies, labels, and comments — the script no longer does title-token matching. The model proposes it as an Obsolete Candidate with `suggested_action: merge-into:#` (or `revisit` when the open issue should stay), and the evidence should name the target closed issue: + +```json +{ + "target": { + "number": 44, + "title": "OAuth token refresh worker" + }, + "reason": "open issue duplicates closed #44 with no substantive delta", + "titles": { + "open": "OAuth token refresh worker", + "closed": "OAuth token refresh worker" + } +} +``` + +Guidance: only merge-into when the open issue has no new requirements beyond the closed one; otherwise mark `revisit` and note what is still missing. + ## Evidence schema Each candidate includes a non-empty `evidence` object. @@ -62,27 +81,6 @@ For `invalid`, only `matchedLabel` and `labels` change accordingly. } ``` -### `duplicate-of-closed` - -```json -{ - "target": { - "number": 44, - "title": "OAuth token refresh worker", - "state": "closed", - "closedAt": "2026-06-01T00:00:00.000Z", - "url": "https://github.com/owner/name/issues/44" - }, - "score": 1, - "overlap": ["oauth", "refresh", "token", "worker"], - "exactTitle": true, - "titles": { - "open": "OAuth token refresh worker", - "closed": "OAuth token refresh worker" - } -} -``` - ## Deferred follow-ups - `Referenced code removed`: deferred because the current snapshot has no code-removal evidence and no follow-up implementation is defined yet. diff --git a/skills/backlog-triage/scripts/triage-relate.js b/skills/backlog-triage/scripts/triage-relate.js index 707ae43..bbd5772 100644 --- a/skills/backlog-triage/scripts/triage-relate.js +++ b/skills/backlog-triage/scripts/triage-relate.js @@ -2,9 +2,7 @@ const fs = require("fs"); const path = require("path"); -const { readTriageConfig } = require("../../dev-backlog/scripts/lib"); -const DEFAULT_CONFIG_PATH = path.join("backlog", "triage-config.yml"); function parseArgs(args) { const options = { @@ -239,57 +237,8 @@ function scanCommentMentions(snapshot) { return dedupeEdges(edges); } -function scanPhraseEdges(snapshot, patterns, kind, confidence) { - const edges = []; - const openNumbers = snapshotIssueNumbers(snapshot); - - for (const issue of snapshot.issues) { - const source = typeof issue.body === "string" ? issue.body : ""; - const masked = maskFencedCodeBlocks(source); - for (const pattern of patterns) { - let match; - while ((match = pattern.exec(masked)) !== null) { - const phraseIndex = match.index; - const hashIndex = masked.indexOf("#", phraseIndex); - if (hashIndex === -1 || isIssueRefInUrlToken(masked, hashIndex)) continue; - const target = Number(match[1]); - if (issue.number === target) continue; - if (!openNumbers.has(target)) continue; - - edges.push( - makeEdge({ - from: issue.number, - to: target, - kind, - confidence, - evidence: { - phrase: normalizeSnippet(match[0]), - snippet: extractSnippet(source, phraseIndex, phraseIndex + match[0].length), - }, - }) - ); - } - pattern.lastIndex = 0; - } - } - - return dedupeEdges(edges); -} - -function scanBlocks(snapshot) { - return scanPhraseEdges(snapshot, [/\bblocks\s+#(\d+)\b/gi, /\bcloses\s+#(\d+)\b/gi], "blocks", 1); -} - -function scanDependsOn(snapshot) { - return scanPhraseEdges( - snapshot, - [/\bblocked by\s+#(\d+)\b/gi, /\bdepends(?:\s+on|-on)\s+#(\d+)\b/gi], - "depends-on", - 1 - ); -} function scanMergedPrLinks(snapshot) { const edges = []; @@ -324,65 +273,8 @@ function scanMergedPrLinks(snapshot) { return dedupeEdges(edges); } -function tokenizeTitle(title) { - return new Set( - String(title || "") - .toLowerCase() - .match(/[a-z0-9]+/g)?.filter((token) => token.length > 1) || [] - ); -} -function jaccardSimilarity(left, right) { - const leftTokens = [...left]; - const rightTokens = [...right]; - const union = new Set([...leftTokens, ...rightTokens]); - if (union.size === 0) return { score: 0, overlap: [] }; - const overlap = leftTokens.filter((token) => right.has(token)); - return { - score: overlap.length / union.size, - overlap, - }; -} - -function findDuplicateCandidates(snapshot, config = readTriageConfig("backlog")) { - const threshold = Number(config.duplicate_threshold) || 0; - const edges = []; - - for (let index = 0; index < snapshot.issues.length; index += 1) { - for (let otherIndex = index + 1; otherIndex < snapshot.issues.length; otherIndex += 1) { - const left = snapshot.issues[index]; - const right = snapshot.issues[otherIndex]; - const similarity = jaccardSimilarity(tokenizeTitle(left.title), tokenizeTitle(right.title)); - - if (similarity.score < threshold || similarity.overlap.length === 0) continue; - - const from = Math.min(left.number, right.number); - const to = Math.max(left.number, right.number); - const fromIssue = from === left.number ? left : right; - const toIssue = to === right.number ? right : left; - - edges.push( - makeEdge({ - from, - to, - kind: "duplicate-candidate", - confidence: similarity.score, - evidence: { - score: Number(similarity.score.toFixed(4)), - overlap: similarity.overlap.sort(), - titles: { - from: fromIssue.title, - to: toIssue.title, - }, - }, - }) - ); - } - } - - return dedupeEdges(edges); -} function compareEdges(left, right) { return left.from - right.from || left.to - right.to || left.kind.localeCompare(right.kind); @@ -432,23 +324,11 @@ function readSnapshotFile(snapshotPath) { } } -function resolveBacklogDir(snapshot) { - const configPath = - typeof snapshot.config_path === "string" && snapshot.config_path.trim() - ? snapshot.config_path - : DEFAULT_CONFIG_PATH; - const backlogDir = path.dirname(configPath); - return backlogDir === "." ? "backlog" : backlogDir; -} - -function analyzeSnapshot(snapshot, { config = readTriageConfig(resolveBacklogDir(snapshot)) } = {}) { +function analyzeSnapshot(snapshot) { return sortEdges([ ...scanMentions(snapshot), ...scanCommentMentions(snapshot), - ...scanBlocks(snapshot), - ...scanDependsOn(snapshot), ...scanMergedPrLinks(snapshot), - ...findDuplicateCandidates(snapshot, config), ]); } @@ -460,10 +340,6 @@ function formatEdge(edge) { return `#${edge.from} ${edge.kind} ${prLabel}${mergedAt}`; } - if (edge.kind === "duplicate-candidate") { - return `#${edge.from} ${edge.kind} #${edge.to} (${edge.confidence.toFixed(2)}) ${edge.evidence.titles.from} <> ${edge.evidence.titles.to}`; - } - const snippet = typeof edge.evidence === "object" ? edge.evidence.snippet || edge.evidence.phrase : edge.evidence; return `#${edge.from} ${edge.kind} #${edge.to} ${snippet}`; } @@ -507,12 +383,8 @@ module.exports = { extractIssueRefs, scanMentions, scanCommentMentions, - scanBlocks, - scanDependsOn, scanMergedPrLinks, - findDuplicateCandidates, readSnapshotFile, analyzeSnapshot, sortEdges, - resolveBacklogDir, }; diff --git a/skills/backlog-triage/scripts/triage-relate.test.js b/skills/backlog-triage/scripts/triage-relate.test.js index aa20933..ca1a47e 100644 --- a/skills/backlog-triage/scripts/triage-relate.test.js +++ b/skills/backlog-triage/scripts/triage-relate.test.js @@ -8,10 +8,7 @@ const { extractIssueRefs, scanMentions, scanCommentMentions, - scanBlocks, - scanDependsOn, scanMergedPrLinks, - findDuplicateCandidates, analyzeSnapshot, readSnapshotFile, } = require("./triage-relate.js"); @@ -185,74 +182,6 @@ describe("scanCommentMentions", () => { }); }); -describe("scanBlocks", () => { - it("blocks: emits blocks and closes phrases with concrete evidence", () => { - const snapshot = makeSnapshot({ - issues: [ - makeIssue({ - number: 100, - body: "Blocks #101 until the token flow lands. Later it closes #102 cleanly.", - }), - makeIssue({ number: 101, body: "" }), - makeIssue({ number: 102, body: "" }), - ], - }); - - const edges = scanBlocks(snapshot); - assert.deepEqual( - edges.map((edge) => [edge.from, edge.to, edge.kind, edge.evidence.phrase]), - [ - [100, 101, "blocks", "Blocks #101"], - [100, 102, "blocks", "closes #102"], - ] - ); - assert.match(edges[0].evidence.snippet, /Blocks #101/); - assert.match(edges[1].evidence.snippet, /closes #102/); - }); - - it("blocks: drops phrases targeting issues absent from the snapshot", () => { - // Regression: `Blocks #999` must not emit an edge if #999 is not in snapshot.issues. - const snapshot = makeSnapshot({ - issues: [ - makeIssue({ number: 100, body: "Blocks #999. Also blocks #101." }), - makeIssue({ number: 101, body: "" }), - ], - }); - const edges = scanBlocks(snapshot); - assert.equal(edges.length, 1); - assert.equal(edges[0].to, 101); - }); -}); - -describe("scanDependsOn", () => { - it("depends-on: emits blocked by / depends on / depends-on phrases", () => { - const snapshot = makeSnapshot({ - issues: [ - makeIssue({ - number: 101, - body: "Blocked by #100 today. Then depends on #102 and depends-on #103 later.", - }), - makeIssue({ number: 100, body: "" }), - makeIssue({ number: 102, body: "" }), - makeIssue({ number: 103, body: "" }), - ], - }); - - const edges = scanDependsOn(snapshot); - assert.deepEqual( - edges.map((edge) => [edge.from, edge.to, edge.kind, edge.evidence.phrase]), - [ - [101, 100, "depends-on", "Blocked by #100"], - [101, 102, "depends-on", "depends on #102"], - [101, 103, "depends-on", "depends-on #103"], - ] - ); - assert.match(edges[0].evidence.snippet, /Blocked by #100/); - assert.match(edges[1].evidence.snippet, /depends on #102/); - assert.match(edges[2].evidence.snippet, /depends-on #103/); - }); -}); - describe("scanMergedPrLinks", () => { it("merged-pr-link: emits merged closing PR metadata without implying a close action", () => { const snapshot = makeSnapshot({ @@ -304,52 +233,6 @@ describe("scanMergedPrLinks", () => { }); }); -describe("findDuplicateCandidates", () => { - it("duplicate-candidate: emits one canonical edge with overlap and score", () => { - const snapshot = makeSnapshot({ - issues: [ - makeIssue({ number: 100, title: "OAuth token refresh flow" }), - makeIssue({ number: 200, title: "OAuth token refresh flow redesign" }), - makeIssue({ number: 300, title: "Add rate limiting to API endpoints" }), - ], - }); - - assert.deepEqual(findDuplicateCandidates(snapshot, { duplicate_threshold: 0.75 }), [ - { - from: 100, - to: 200, - kind: "duplicate-candidate", - confidence: 0.8, - evidence: { - score: 0.8, - overlap: ["flow", "oauth", "refresh", "token"], - titles: { - from: "OAuth token refresh flow", - to: "OAuth token refresh flow redesign", - }, - }, - }, - ]); - }); - - it("duplicate-candidate: does not crash on titles that tokenize to the empty set", () => { - // Single-character / stopword-only titles produce zero tokens via tokenizeTitle's length>1 filter. - // Regression test — previously jaccardSimilarity returned scalar 0 on empty union and the caller crashed on similarity.overlap.length. - const snapshot = makeSnapshot({ - issues: [ - makeIssue({ number: 100, title: "A" }), - makeIssue({ number: 200, title: "B" }), - ], - }); - - assert.doesNotThrow(() => - findDuplicateCandidates(snapshot, { duplicate_threshold: 0.0 }) - ); - // Zero tokens → zero overlap → no duplicate-candidate edge emitted. - assert.deepEqual(findDuplicateCandidates(snapshot, { duplicate_threshold: 0.0 }), []); - }); -}); - describe("analyzeSnapshot", () => { let originalCwd; let tempDir; @@ -365,22 +248,7 @@ describe("analyzeSnapshot", () => { fs.rmSync(tempDir, { recursive: true, force: true }); }); - it("honors backlog/triage-config.yml duplicate_threshold and sorts output", () => { - process.chdir(tempDir); - fs.writeFileSync( - path.join(tempDir, "backlog", "triage-config.yml"), - [ - "theme_keywords:", - " auth: [auth, oauth]", - "activity_days:", - " warm: 14", - " cold: 60", - "stale_days: 60", - "duplicate_threshold: 0.99", - "", - ].join("\n") - ); - + it("analyzeSnapshot returns deterministic mention and merged-PR edges only", () => { const snapshot = makeSnapshot({ issues: [ makeIssue({ @@ -401,35 +269,17 @@ describe("analyzeSnapshot", () => { ], }); - const strictEdges = analyzeSnapshot(snapshot); - assert.equal(strictEdges.some((edge) => edge.kind === "duplicate-candidate"), false); - - fs.writeFileSync( - path.join(tempDir, "backlog", "triage-config.yml"), - [ - "theme_keywords:", - " auth: [auth, oauth]", - "activity_days:", - " warm: 14", - " cold: 60", - "stale_days: 60", - "duplicate_threshold: 0.75", - "", - ].join("\n") - ); - - const looseEdges = analyzeSnapshot(snapshot); + const edges = analyzeSnapshot(snapshot); assert.deepEqual( - looseEdges.map((edge) => [edge.from, edge.to, edge.kind]), + edges.map((edge) => [edge.from, edge.to, edge.kind]), [ - [100, 101, "blocks"], [100, 101, "mentions"], - [100, 200, "duplicate-candidate"], [100, 200, "mentions"], - [101, 100, "depends-on"], [101, 100, "mentions"], ] ); + // 블록/중복/의존성 문구 판단은 모델 몫이므로 스크립트는 mentions 만 생성한다. + assert.equal(edges.some((edge) => edge.kind === "blocks" || edge.kind === "depends-on" || edge.kind === "duplicate-candidate"), false); }); it("reads fixture snapshots from disk and errors on missing or malformed JSON", () => { diff --git a/skills/backlog-triage/scripts/triage-report.js b/skills/backlog-triage/scripts/triage-report.js index cf0b82c..d178f9e 100755 --- a/skills/backlog-triage/scripts/triage-report.js +++ b/skills/backlog-triage/scripts/triage-report.js @@ -9,10 +9,10 @@ const DEFAULT_REPORT_DIR = path.join("backlog", "triage"); const OPTIONAL_RELATIONSHIPS_MARKER = "_(comment and closing-PR relationship signals run only when snapshot v2 fields are present)_"; const DEFERRED_OBSOLETE_MARKER = - "_(merged closing-PR and duplicate-of-closed signals run only when snapshot v2 fields are present)_"; + "_(merged closing-PR signals run only when snapshot v2 fields are present)_"; function usage() { - return "Usage: triage-report.js --snapshot PATH [--relate PATH] [--stale PATH] [--active-sprint PATH] [--out PATH] [--json]"; + return "Usage: triage-report.js --snapshot PATH [--relate PATH] [--stale PATH] [--active-sprint PATH] [--model-actions PATH] [--out PATH] [--json]"; } function parseArgs(args) { @@ -21,6 +21,7 @@ function parseArgs(args) { relatePath: undefined, stalePath: undefined, activeSprintPath: undefined, + modelActionsPath: undefined, outPath: undefined, json: false, }; @@ -33,7 +34,7 @@ function parseArgs(args) { continue; } - if (arg === "--snapshot" || arg === "--relate" || arg === "--stale" || arg === "--out") { + if (arg === "--snapshot" || arg === "--relate" || arg === "--stale" || arg === "--model-actions" || arg === "--out") { const nextValue = args[index + 1]; if (!nextValue) { return { ...options, error: `Missing value for ${arg}. ${usage()}` }; @@ -42,6 +43,7 @@ function parseArgs(args) { if (arg === "--snapshot") options.snapshotPath = nextValue; if (arg === "--relate") options.relatePath = nextValue; if (arg === "--stale") options.stalePath = nextValue; + if (arg === "--model-actions") options.modelActionsPath = nextValue; if (arg === "--out") options.outPath = nextValue; index += 1; continue; @@ -59,6 +61,10 @@ function parseArgs(args) { options.stalePath = arg.slice("--stale=".length); continue; } + if (arg.startsWith("--model-actions=")) { + options.modelActionsPath = arg.slice("--model-actions=".length); + continue; + } if (arg.startsWith("--out=")) { options.outPath = arg.slice("--out=".length); continue; @@ -472,143 +478,10 @@ function renderObsoleteCandidates(stale, actions) { return lines.join("\n"); } -function buildThemeStats(snapshot) { - const stats = new Map(); - for (const issue of snapshot.issues) { - const theme = issue.buckets.theme || "uncategorized"; - if (!stats.has(theme)) stats.set(theme, { total: 0, active: 0 }); - const entry = stats.get(theme); - entry.total += 1; - if (issue.buckets.activity === "recent" || issue.buckets.activity === "warm") { - entry.active += 1; - } - } - return stats; -} - -function buildRelationshipCounts(relate) { - const counts = new Map(); - if (!relate) return counts; - - for (const edge of relate.edges) { - if (edge.kind === "merged-pr-link") continue; - counts.set(edge.from, (counts.get(edge.from) || 0) + 1); - if (edge.to !== edge.from) { - counts.set(edge.to, (counts.get(edge.to) || 0) + 1); - } - } - return counts; -} - -function buildClosedIssueSet(obsoleteActions) { - return new Set( - obsoleteActions - .filter((action) => action.verb === "close" || action.verb === "close-duplicate") - .map((action) => action.issueNumber) - ); -} - -function buildPriorityActions(snapshot, relate, obsoleteActions) { - const themeStats = buildThemeStats(snapshot); - const relationshipCounts = buildRelationshipCounts(relate); - const closedIssues = buildClosedIssueSet(obsoleteActions); - const actions = []; - - for (const issue of snapshot.issues) { - if (closedIssues.has(issue.number)) continue; - - const currentPriority = issue.buckets.label?.priority || "medium"; - if (currentPriority === "high" || currentPriority === "critical") continue; - - const theme = issue.buckets.theme || "uncategorized"; - const themeStat = themeStats.get(theme) || { total: 0, active: 0 }; - const relationshipCount = relationshipCounts.get(issue.number) || 0; - const themeHot = theme !== "uncategorized" && themeStat.active >= 2; - const relationshipHot = relationshipCount > 0; - const activityEligible = issue.buckets.activity !== "cold"; - - if (!activityEligible) continue; - if (!themeHot && !relationshipHot) continue; - - const reasons = []; - if (themeHot) reasons.push(`theme ${theme} has ${themeStat.active} recent/warm issues`); - if (relationshipHot) reasons.push(`connected by ${relationshipCount} relationship edge${relationshipCount === 1 ? "" : "s"}`); - - actions.push({ - section: "priority", - verb: "set-priority", - issueNumber: issue.number, - args: { - value: "high", - reason: reasons.join("; "), - }, - summary: `Set priority:high on #${issue.number} — ${reasons.join("; ")}`, - evidence: reasons.join("; "), - }); - } - - return dedupeActions(actions); -} - -function getIsoWeek(date) { - const target = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); - const day = target.getUTCDay() || 7; - target.setUTCDate(target.getUTCDate() + 4 - day); - const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1)); - return Math.ceil((((target - yearStart) / 86400000) + 1) / 7); -} - -function nextSprintName(generated) { - const generatedDate = new Date(generated); - return `Sprint W${getIsoWeek(generatedDate) + 1}`; -} - -function buildMilestoneActions(snapshot, relate, obsoleteActions, priorityActions) { - const relationshipCounts = buildRelationshipCounts(relate); - const closedIssues = buildClosedIssueSet(obsoleteActions); - const priorityIssues = new Set(priorityActions.map((action) => action.issueNumber)); - const sprintName = nextSprintName(snapshot.generated); - const actions = []; - - for (const issue of snapshot.issues) { - if (closedIssues.has(issue.number)) continue; - if (issue.milestone !== null && issue.milestone !== undefined) continue; - - const theme = issue.buckets.theme || "uncategorized"; - const relationshipCount = relationshipCounts.get(issue.number) || 0; - const relationshipHot = relationshipCount > 0; - const activeTheme = theme !== "uncategorized" && issue.buckets.activity !== "cold"; - const priorityHot = priorityIssues.has(issue.number); - - if (!relationshipHot && !activeTheme && !priorityHot) continue; - - const rationale = []; - if (activeTheme) rationale.push(`theme ${theme}`); - if (relationshipHot) rationale.push(`${relationshipCount} relationship edge${relationshipCount === 1 ? "" : "s"}`); - if (priorityHot) rationale.push("priority proposal above"); - - actions.push({ - section: "milestone", - verb: "assign-milestone", - issueNumber: issue.number, - args: { - name: sprintName, - cluster: theme, - }, - cluster: theme, - sprintName, - summary: `Assign ${sprintName} to #${issue.number} — ${rationale.join("; ")}`, - evidence: rationale.join("; "), - }); - } - - return dedupeActions(actions); -} - function renderPriorityProposals(actions) { const lines = [ "## Priority Proposals", - "Heuristic: suggest `priority:high` for non-high issues that are still active and either sit in a theme with multiple recent/warm issues or participate in relationship edges.", + "Model judgment: non-high issues worth escalating, with rationale from theme activity, relationship edges, and sprint focus.", ]; if (actions.length === 0) { @@ -684,13 +557,13 @@ function buildFrontmatter(snapshot, snapshotPath) { ].join("\n"); } -function buildReportModel({ snapshot, snapshotPath, relate, stale, activeSprintContent = "" }) { +function buildReportModel({ snapshot, snapshotPath, relate, stale, activeSprintContent = "", modelActions = [] }) { const issueIndex = buildIssueIndex(snapshot); const protectedIssueNumbers = collectActiveSprintIssueNumbers(activeSprintContent); const obsoleteActions = buildObsoleteActions(stale, { protectedIssueNumbers }); - const priorityActions = buildPriorityActions(snapshot, relate, obsoleteActions); - const milestoneActions = buildMilestoneActions(snapshot, relate, obsoleteActions, priorityActions); - const allActions = [...obsoleteActions, ...priorityActions, ...milestoneActions]; + const priorityActions = modelActions.filter((action) => action.section === "priority"); + const milestoneActions = modelActions.filter((action) => action.section === "milestone"); + const allActions = dedupeActions([...obsoleteActions, ...priorityActions, ...milestoneActions]); const sections = [ { key: "classification", title: "Classification", markdown: renderClassification(snapshot) }, @@ -716,6 +589,24 @@ function buildReportModel({ snapshot, snapshotPath, relate, stale, activeSprintC }; } +function loadModelActions(modelActionsPath) { + if (!modelActionsPath) return []; + const actions = readJsonFile(modelActionsPath, { label: "model actions JSON" }); + if (!Array.isArray(actions)) { + throw new Error(`Invalid model actions JSON at ${modelActionsPath}: expected an array of action objects.`); + } + return actions.map((action) => ({ + section: String(action.section || ""), + verb: String(action.verb || ""), + issueNumber: action.issueNumber, + args: action.args || {}, + cluster: typeof action.cluster === "string" ? action.cluster : undefined, + sprintName: typeof action.sprintName === "string" ? action.sprintName : undefined, + summary: String(action.summary || ""), + evidence: typeof action.evidence === "string" ? action.evidence : undefined, + })); +} + function renderReport(reportModel) { return [ reportModel.frontmatter, @@ -757,12 +648,14 @@ function loadInputs(options) { const activeSprintContent = options.activeSprintPath ? fs.readFileSync(path.resolve(options.activeSprintPath), "utf-8") : ""; + const modelActions = loadModelActions(options.modelActionsPath); return { snapshot, relate, stale, activeSprintContent, + modelActions, }; } @@ -785,6 +678,7 @@ function main() { relate: inputs.relate, stale: inputs.stale, activeSprintContent: inputs.activeSprintContent, + modelActions: inputs.modelActions, }); markdown = renderReport(reportModel); } catch (error) { @@ -817,9 +711,6 @@ module.exports = { extractMarkdownSection, collectActiveSprintIssueNumbers, buildObsoleteActions, - buildRelationshipCounts, - buildPriorityActions, - buildMilestoneActions, formatStaleEvidence, staleCandidateToAction, buildReportModel, diff --git a/skills/backlog-triage/scripts/triage-report.test.js b/skills/backlog-triage/scripts/triage-report.test.js index dafb6f3..f7f67d4 100644 --- a/skills/backlog-triage/scripts/triage-report.test.js +++ b/skills/backlog-triage/scripts/triage-report.test.js @@ -14,7 +14,6 @@ const { writeReportFile, formatStaleEvidence, staleCandidateToAction, - buildRelationshipCounts, collectActiveSprintIssueNumbers, } = require("./triage-report.js"); @@ -148,6 +147,7 @@ describe("parseArgs", () => { relatePath: "relate.json", stalePath: "stale.json", activeSprintPath: undefined, + modelActionsPath: undefined, outPath: "report.md", json: true, } @@ -272,19 +272,6 @@ describe("renderer helpers", () => { assert.deepEqual([...collectActiveSprintIssueNumbers(content)].sort((left, right) => left - right), [101, 102]); }); - it("does not count merged-pr-link as a planning relationship", () => { - const counts = buildRelationshipCounts({ - edges: [ - { from: 101, to: 101, kind: "merged-pr-link" }, - { from: 102, to: 103, kind: "mentions" }, - ], - }); - - assert.equal(counts.has(101), false); - assert.equal(counts.get(102), 1); - assert.equal(counts.get(103), 1); - }); - it("renders no-input placeholders for omitted relate/stale inputs", () => { const model = buildReportModel({ snapshot: makeSnapshot(), @@ -363,7 +350,7 @@ describe("renderer helpers", () => { assert.doesNotMatch(obsolete, /triage:close #105/); }); - it("merged-pr-link alone does not create priority or milestone proposals", () => { + it("model actions drive priority and milestone proposals; none are invented without them", () => { const snapshot = { generated: "2026-04-18T01:30:00.000Z", repo: "sungjunlee/dev-backlog", @@ -386,27 +373,50 @@ describe("renderer helpers", () => { }, ], }; - const relate = { - edges: [ - { - from: 201, - to: 201, - kind: "merged-pr-link", - confidence: 1, - evidence: { pr: { number: 88, mergedAt: "2026-04-18T01:15:00.000Z" } }, - }, - ], - }; + + const baseModel = buildReportModel({ + snapshot, + snapshotPath: "fixtures/snapshot.json", + relate: null, + stale: null, + }); + assert.equal(baseModel.anchors.some((anchor) => anchor.section === "priority"), false); + assert.equal(baseModel.anchors.some((anchor) => anchor.section === "milestone"), false); const model = buildReportModel({ snapshot, snapshotPath: "fixtures/snapshot.json", - relate, + relate: null, stale: null, + modelActions: [ + { + section: "priority", + verb: "set-priority", + issueNumber: 201, + args: { value: "high", reason: "customer-reported outage" }, + summary: "Set priority:high on #201 — customer-reported outage", + evidence: "customer-reported outage", + }, + { + section: "milestone", + verb: "assign-milestone", + issueNumber: 201, + args: { name: "Sprint W17", cluster: "auth" }, + cluster: "auth", + sprintName: "Sprint W17", + summary: "Assign Sprint W17 to #201 — auth cluster", + evidence: "auth cluster", + }, + ], }); - assert.equal(model.anchors.some((anchor) => anchor.section === "priority"), false); - assert.equal(model.anchors.some((anchor) => anchor.section === "milestone"), false); + assert.ok(model.anchors.some((anchor) => anchor.section === "priority" && anchor.line.includes("set-priority") && anchor.line.includes("#201"))); + assert.ok(model.anchors.some((anchor) => anchor.section === "milestone" && anchor.line.includes("assign-milestone") && anchor.line.includes("#201"))); + + const priorityMarkdown = model.sections.find((section) => section.key === "priority").markdown; + const milestoneMarkdown = model.sections.find((section) => section.key === "milestone").markdown; + assert.match(priorityMarkdown, /Set priority:high on #201/); + assert.match(milestoneMarkdown, /Sprint W17/); }); }); @@ -504,7 +514,7 @@ describe("triage-report integration chain", () => { assert.match(markdown, /#101 OAuth token refresh flow comment-mentions #103 Audit token rotation docs/); assert.match(markdown, /#101 OAuth token refresh flow merged-pr-link PR #88; mergedAt 2026-04-18T01:15:00.000Z/); assert.match(markdown, /comment and closing-PR relationship signals run only when snapshot v2 fields are present/); - assert.match(markdown, /merged closing-PR and duplicate-of-closed signals run only when snapshot v2 fields are present/); + assert.match(markdown, /merged closing-PR signals run only when snapshot v2 fields are present/); // Classification groups must match Done Criteria: theme / label / age. const classificationBlock = markdown.split(/^##\s+Classification/m)[1].split(/^##\s/m)[0]; diff --git a/skills/backlog-triage/scripts/triage-stale.js b/skills/backlog-triage/scripts/triage-stale.js index 17ab739..f2eaafe 100755 --- a/skills/backlog-triage/scripts/triage-stale.js +++ b/skills/backlog-triage/scripts/triage-stale.js @@ -13,7 +13,6 @@ const SIGNALS = Object.freeze({ WONTFIX: "wontfix", INVALID: "invalid", MERGED_CLOSING_PR: "merged-closing-pr", - DUPLICATE_OF_CLOSED: "duplicate-of-closed", }); function usage() { @@ -178,8 +177,6 @@ function pickAction(signal, context = {}) { case SIGNALS.INVALID: case SIGNALS.MERGED_CLOSING_PR: return "close"; - case SIGNALS.DUPLICATE_OF_CLOSED: - return context.targetIssueNumber ? `merge-into:#${context.targetIssueNumber}` : "revisit"; default: return context.targetIssueNumber ? `merge-into:#${context.targetIssueNumber}` : "revisit"; } @@ -280,82 +277,6 @@ function scanMergedClosingPr(issue) { return candidates; } -function tokenizeTitle(title) { - return new Set( - String(title || "") - .toLowerCase() - .match(/[a-z0-9]+/g)?.filter((token) => token.length > 1) || [] - ); -} - -function jaccardSimilarity(left, right) { - const leftTokens = [...left]; - const rightTokens = [...right]; - const union = new Set([...leftTokens, ...rightTokens]); - if (union.size === 0) return { score: 0, overlap: [] }; - - const overlap = leftTokens.filter((token) => right.has(token)); - return { - score: overlap.length / union.size, - overlap, - }; -} - -function closedIssueMatches(openIssue, closedIssue, threshold = 0.8) { - if (!Number.isInteger(closedIssue?.number) || typeof closedIssue?.title !== "string") { - return null; - } - - const openTitle = String(openIssue.title || "").trim().toLowerCase(); - const closedTitle = String(closedIssue.title || "").trim().toLowerCase(); - const similarity = jaccardSimilarity(tokenizeTitle(openIssue.title), tokenizeTitle(closedIssue.title)); - const exactTitle = openTitle.length > 0 && openTitle === closedTitle; - if (!exactTitle && similarity.score < threshold) return null; - - return { - score: Number(similarity.score.toFixed(4)), - overlap: similarity.overlap.sort(), - exactTitle, - }; -} - -function scanDuplicateOfClosed(issue, closedIssues, { threshold = 0.8 } = {}) { - if (!Array.isArray(closedIssues) || closedIssues.length === 0) return []; - - const candidates = []; - for (const closedIssue of closedIssues) { - const match = closedIssueMatches(issue, closedIssue, threshold); - if (!match) continue; - - candidates.push( - buildCandidate( - issue, - SIGNALS.DUPLICATE_OF_CLOSED, - `duplicate of closed issue #${closedIssue.number}: title similarity ${match.score.toFixed(2)}`, - { - target: { - number: closedIssue.number, - title: closedIssue.title, - state: closedIssue.state || "closed", - closedAt: typeof closedIssue.closedAt === "string" ? closedIssue.closedAt : null, - url: typeof closedIssue.url === "string" ? closedIssue.url : null, - }, - score: match.score, - overlap: match.overlap, - exactTitle: match.exactTitle, - titles: { - open: issue.title, - closed: closedIssue.title, - }, - }, - { targetIssueNumber: closedIssue.number } - ) - ); - } - - return candidates; -} - function resolveThresholdDays({ since, backlogDir = DEFAULT_BACKLOG_DIR, config } = {}) { if (since !== undefined) return since; const resolvedConfig = config || readTriageConfig(backlogDir); @@ -375,7 +296,6 @@ function analyzeSnapshot(snapshot, { since, backlogDir = DEFAULT_BACKLOG_DIR, co const inactiveCandidate = scanInactive(issue, thresholdDays, snapshot.generated); if (inactiveCandidate) candidates.push(inactiveCandidate); - candidates.push(...scanDuplicateOfClosed(issue, snapshot.closed_issues)); } return { @@ -449,7 +369,6 @@ module.exports = { scanInactive, scanWontfixInvalid, scanMergedClosingPr, - scanDuplicateOfClosed, resolveThresholdDays, analyzeSnapshot, formatCandidate, diff --git a/skills/backlog-triage/scripts/triage-stale.test.js b/skills/backlog-triage/scripts/triage-stale.test.js index 2420012..cd46850 100644 --- a/skills/backlog-triage/scripts/triage-stale.test.js +++ b/skills/backlog-triage/scripts/triage-stale.test.js @@ -12,7 +12,6 @@ const { scanInactive, scanWontfixInvalid, scanMergedClosingPr, - scanDuplicateOfClosed, resolveThresholdDays, analyzeSnapshot, } = require("./triage-stale.js"); @@ -72,7 +71,6 @@ describe("pickAction", () => { }); it("keeps revisit and merge-into actions available for future signals", () => { - assert.equal(pickAction(SIGNALS.DUPLICATE_OF_CLOSED, { targetIssueNumber: 42 }), "merge-into:#42"); assert.equal(pickAction("future-signal", { targetIssueNumber: 42 }), "merge-into:#42"); assert.equal(pickAction("future-signal"), "revisit"); }); @@ -178,36 +176,6 @@ describe("scanMergedClosingPr", () => { }); }); -describe("scanDuplicateOfClosed", () => { - it("flags high-confidence duplicates of closed issues", () => { - const [candidate] = scanDuplicateOfClosed( - { number: 710, title: "OAuth token refresh worker" }, - [ - { - number: 44, - title: "OAuth token refresh worker", - state: "closed", - closedAt: "2026-06-01T00:00:00.000Z", - }, - ] - ); - - assert.equal(candidate.suggested_action, "merge-into:#44"); - assert.match(candidate.reason, /duplicate of closed issue #44/i); - assert.equal(candidate.evidence.target.number, 44); - assert.equal(candidate.evidence.exactTitle, true); - }); - - it("does not flag low-overlap closed issues", () => { - const candidates = scanDuplicateOfClosed( - { number: 711, title: "OAuth token refresh worker" }, - [{ number: 45, title: "Documentation table formatting", state: "closed" }] - ); - - assert.deepEqual(candidates, []); - }); -}); - describe("resolveThresholdDays and analyzeSnapshot", () => { let tempBacklogDir; @@ -255,7 +223,7 @@ describe("resolveThresholdDays and analyzeSnapshot", () => { assert.ok(!numbers.includes(502)); }); - it("includes optional merged-PR and duplicate-of-closed stale signals when snapshot fields are present", () => { + it("includes optional merged-PR stale signals when snapshot fields are present", () => { const snapshot = loadFixtureSnapshot(); snapshot.issues.push({ number: 700, @@ -272,11 +240,10 @@ describe("resolveThresholdDays and analyzeSnapshot", () => { updatedAt: "2026-07-15T00:00:00.000Z", milestone: null, }); - snapshot.closed_issues = [{ number: 44, title: "OAuth token refresh worker", state: "closed" }]; const result = analyzeSnapshot(snapshot, { config: { stale_days: 60 } }); assert.ok(result.candidates.some((candidate) => candidate.number === 700 && candidate.suggested_action === "close")); - assert.ok(result.candidates.some((candidate) => candidate.number === 701 && candidate.suggested_action === "merge-into:#44")); + assert.ok(!result.candidates.some((candidate) => candidate.number === 701)); }); }); From e17c01886eb6b5b28b3b5f84ed237b51e55fd76f Mon Sep 17 00:00:00 2001 From: SJ Lee Date: Sat, 8 Aug 2026 20:20:07 +0900 Subject: [PATCH 2/6] fix: validate model actions and merge relationship/obsolete sections Address review feedback on PR #359 (claude-opus-5 + codex gpt-5.6-sol, both NOT-LGTM): - loadModelActions now validates every entry: allowed section/verb combos, positive safe-integer issueNumber (or from/to for relationship edges), plain-object args, per-section required args, non-empty summary; malformed entries throw with the array index - --model-actions gains section "relationship" (edges merge into Relationships) and section "obsolete" (close/revisit/close-duplicate merge into Obsolete Candidates), so the docs' claims now match behavior and merge-into has a producer again - dedupeActions keys match the apply contract (verb, issueNumber, args) instead of section:verb:issueNumber - priority/milestone sections render evidence; renderObsoleteCandidates shows model actions even without a stale input - docs: SKILL.md phase model + JSON examples, README/workflow-patterns recipes, CHANGELOG line count; drop now-inert duplicate_threshold (lib.js, triage-config.yml, collect/lib tests) - tests: loadModelActions file loading + 7 rejection cases, relationship/ obsolete merge, apply-contract dedupe 464 tests pass / 0 fail; smoke-test 190 pass. --- CHANGELOG.md | 2 +- README.md | 3 +- backlog/triage-config.yml | 1 - skills/backlog-triage/SKILL.md | 21 +- .../scripts/triage-collect.test.js | 1 - .../backlog-triage/scripts/triage-relate.js | 2 - .../scripts/triage-relate.test.js | 2 +- .../backlog-triage/scripts/triage-report.js | 140 ++++++++++-- .../scripts/triage-report.test.js | 211 ++++++++++++++++++ .../references/workflow-patterns.md | 3 +- skills/dev-backlog/scripts/lib.js | 1 - skills/dev-backlog/scripts/lib.test.js | 4 +- 12 files changed, 360 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51e7ac6..4fc73f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Each entry links the GitHub issue (the canonical spec) and the merge PR (the shi ### Removed -- **backlog-triage judgment heuristics** — replaced ~265 lines of semantic judgment (`scanPhraseEdges`/`scanBlocks`/`scanDependsOn`, title-Jaccard duplicates in relate/stale, and rule-based `buildPriorityActions`/`buildMilestoneActions`) with prompt-level rubric guidance. Scripts now emit deterministic signals only (mentions, merged-PR links, dates, labels); the model's judged blocks/depends-on/duplicate edges and priority/milestone proposals enter the report through the new `triage-report.js --model-actions PATH`, keeping anchors, dedupe, and the Apply Checklist deterministic. Net −~454 script/test lines. Closes [#358](https://github.com/sungjunlee/dev-backlog/issues/358). +- **backlog-triage judgment heuristics** — replaced ~265 lines of semantic judgment (`scanPhraseEdges`/`scanBlocks`/`scanDependsOn`, title-Jaccard duplicates in relate/stale, and rule-based `buildPriorityActions`/`buildMilestoneActions`) with prompt-level rubric guidance. Scripts now emit deterministic signals only (mentions, merged-PR links, dates, labels); the model's judged blocks/depends-on/duplicate edges and priority/milestone proposals enter the report through the new `triage-report.js --model-actions PATH`, keeping anchors, dedupe, and the Apply Checklist deterministic. Net −491 script/test lines. Closes [#358](https://github.com/sungjunlee/dev-backlog/issues/358). - **Monthly Progress sync and sprint issue publication** — removed 3,758 lines of scripts and dedicated tests after their intended adoption windows finished. Only one monthly issue was ever created (`Progress: April 2026`, #46; none in May–July), and the four sprint issues (#230, #234, #237, #239) were all created on 2026-07-03/04 with none since. No core lifecycle script or dev-relay integration invokes either feature, and `task-progress-reporting` accumulated no Learnings. Closes [#340](https://github.com/sungjunlee/dev-backlog/issues/340). - **Required task mirrors** — the GitHub-native core now resolves task specification, AC, and lifecycle directly from live Issues. Fresh setup and complete sprint execution require no `backlog/tasks/` or `backlog/completed/`; `sync-pull --legacy-export` remains an explicit one-way diagnostic/rollback boundary. Closes [#347](https://github.com/sungjunlee/dev-backlog/issues/347). diff --git a/README.md b/README.md index 23871a9..92549f6 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,8 @@ SNAP=backlog/triage/.cache/.json node $SKILL/triage-collect.js node $SKILL/triage-relate.js --snapshot $SNAP --json > /tmp/relate.json node $SKILL/triage-stale.js --snapshot $SNAP --json > /tmp/stale.json -node $SKILL/triage-report.js --snapshot $SNAP --relate /tmp/relate.json --stale /tmp/stale.json +# model-authored: blocks/depends-on/duplicate edges + priority/milestone actions +node $SKILL/triage-report.js --snapshot $SNAP --relate /tmp/relate.json --stale /tmp/stale.json --model-actions /tmp/model-actions.json # Apply phase (opt-in): review the report, check accepted proposals, then node $SKILL/triage-apply.js backlog/triage/-report.md --apply diff --git a/backlog/triage-config.yml b/backlog/triage-config.yml index 2b85435..b42741b 100644 --- a/backlog/triage-config.yml +++ b/backlog/triage-config.yml @@ -5,4 +5,3 @@ activity_days: warm: 14 cold: 60 stale_days: 60 -duplicate_threshold: 0.75 diff --git a/skills/backlog-triage/SKILL.md b/skills/backlog-triage/SKILL.md index 5e04ae8..f02a0c2 100644 --- a/skills/backlog-triage/SKILL.md +++ b/skills/backlog-triage/SKILL.md @@ -25,8 +25,8 @@ Phase 1 — Report (default, read-only) Phase 2 — Apply (explicit mutation | Phase | Step | Completion boundary | | --- | --- | --- | | Report | Collect | One `gh` fetch writes a snapshot JSON; downstream steps use `--snapshot PATH` and do not re-fetch. | -| Report | Analyze | Classification, relationships, stale/obsolete signals, Alignment, and Decision Review are computed from the same snapshot/spec evidence. | -| Report | Render | One markdown report is written with anchored proposals and a consolidated Apply Checklist. | +| Report | Analyze | Classification and deterministic signals come from scripts; the model judges blocks/depends-on/duplicates, priority, and milestone actions from the snapshot and writes them to a `--model-actions` JSON file. Alignment and Decision Review are prompt-driven from the same evidence. | +| Report | Render | `triage-report.js` validates the model actions, merges them with deterministic signals, and writes one markdown report with anchored proposals and a consolidated Apply Checklist. | | Apply | Review | A human accepts proposals by flipping paired checkboxes from `[ ]` to `[x]`; unchecked anchors remain inert. | | Apply | Dry-run | `triage-apply.js ` prints intended `gh` mutations without writing. | | Apply | Mutate | `triage-apply.js --apply` executes only accepted actions; `--yes` is required for non-interactive apply. | @@ -107,7 +107,7 @@ node "$skill_dir/scripts/triage-collect.js" --dry-run --json node "$skill_dir/scripts/triage-apply.js" backlog/triage/YYYY-MM-DD-report.md ``` -Model-judged actions (`--model-actions`) are a JSON array of action objects; each needs `section`, `verb`, `issueNumber`, and `summary`, with `args` carrying the mutation payload (see `references/apply.md` for the anchor grammar each verb maps to): +Model-judged actions (`--model-actions`) are a JSON array of action objects. Sections `priority` / `milestone` / `obsolete` carry a positive `issueNumber`, `verb`, `summary`, and `args` with the mutation payload; section `relationship` carries `args.from` / `args.to` / `args.kind` for a model-judged edge. `triage-report.js` validates every entry before rendering (see `references/apply.md` for the anchor grammar each verb maps to): ```json [ @@ -122,10 +122,23 @@ Model-judged actions (`--model-actions`) are a JSON array of action objects; eac "section": "milestone", "verb": "assign-milestone", "issueNumber": 43, - "args": { "name": "Sprint W34", "cluster": "auth" }, + "args": { "name": "Sprint W34" }, "cluster": "auth", "sprintName": "Sprint W34", "summary": "Assign Sprint W34 to #43 — auth cluster" + }, + { + "section": "obsolete", + "verb": "close-duplicate", + "issueNumber": 44, + "args": { "target": "#12", "reason": "open issue duplicates closed #12" }, + "summary": "Close duplicate #44 into #12 — open issue duplicates closed #12" + }, + { + "section": "relationship", + "verb": "edge", + "args": { "from": 45, "to": 46, "kind": "blocks", "evidence": { "phrase": "Blocks #46" } }, + "summary": "Blocks edge 45 -> 46" } ] ``` diff --git a/skills/backlog-triage/scripts/triage-collect.test.js b/skills/backlog-triage/scripts/triage-collect.test.js index 4309d03..4c18642 100644 --- a/skills/backlog-triage/scripts/triage-collect.test.js +++ b/skills/backlog-triage/scripts/triage-collect.test.js @@ -25,7 +25,6 @@ const CONFIG = { cold: 60, }, stale_days: 60, - duplicate_threshold: 0.75, }; function loadFixtureIssues() { diff --git a/skills/backlog-triage/scripts/triage-relate.js b/skills/backlog-triage/scripts/triage-relate.js index bbd5772..22f58e9 100644 --- a/skills/backlog-triage/scripts/triage-relate.js +++ b/skills/backlog-triage/scripts/triage-relate.js @@ -1,8 +1,6 @@ #!/usr/bin/env node const fs = require("fs"); -const path = require("path"); - function parseArgs(args) { const options = { diff --git a/skills/backlog-triage/scripts/triage-relate.test.js b/skills/backlog-triage/scripts/triage-relate.test.js index ca1a47e..9ee5eb6 100644 --- a/skills/backlog-triage/scripts/triage-relate.test.js +++ b/skills/backlog-triage/scripts/triage-relate.test.js @@ -278,7 +278,7 @@ describe("analyzeSnapshot", () => { [101, 100, "mentions"], ] ); - // 블록/중복/의존성 문구 판단은 모델 몫이므로 스크립트는 mentions 만 생성한다. + // Blocking/dependency/duplicate judgment is the model's; the script emits mentions only. assert.equal(edges.some((edge) => edge.kind === "blocks" || edge.kind === "depends-on" || edge.kind === "duplicate-candidate"), false); }); diff --git a/skills/backlog-triage/scripts/triage-report.js b/skills/backlog-triage/scripts/triage-report.js index d178f9e..7b2a00d 100755 --- a/skills/backlog-triage/scripts/triage-report.js +++ b/skills/backlog-triage/scripts/triage-report.js @@ -344,11 +344,19 @@ function actionPriority(action) { return 0; } +function normalizeActionKey(action) { + return JSON.stringify({ + verb: action.verb, + issueNumber: action.issueNumber, + args: action.args && typeof action.args === "object" ? action.args : {}, + }); +} + function dedupeActions(actions) { const selected = new Map(); for (const action of actions) { - const key = `${action.section}:${action.verb}:${action.issueNumber}`; + const key = normalizeActionKey(action); const current = selected.get(key); if (!current || actionPriority(action) > actionPriority(current)) { selected.set(key, action); @@ -462,7 +470,7 @@ function renderActionBlocks(actions, { withEvidence = false } = {}) { function renderObsoleteCandidates(stale, actions) { const lines = ["## Obsolete Candidates"]; - if (!stale) { + if (!stale && actions.length === 0) { lines.push("_(no input provided)_", "", DEFERRED_OBSOLETE_MARKER); return lines.join("\n"); } @@ -489,7 +497,7 @@ function renderPriorityProposals(actions) { return lines.join("\n"); } - lines.push("", ...renderActionBlocks(actions)); + lines.push("", ...renderActionBlocks(actions, { withEvidence: true })); if (lines[lines.length - 1] === "") lines.pop(); return lines.join("\n"); } @@ -509,7 +517,7 @@ function renderMilestoneSuggestions(actions) { for (const [cluster, clusterActions] of sortGroupEntries(byCluster)) { lines.push(`Theme cluster: ${cluster}`); lines.push(""); - lines.push(...renderActionBlocks(clusterActions)); + lines.push(...renderActionBlocks(clusterActions, { withEvidence: true })); if (lines[lines.length - 1] === "") lines.pop(); lines.push(""); } @@ -560,14 +568,23 @@ function buildFrontmatter(snapshot, snapshotPath) { function buildReportModel({ snapshot, snapshotPath, relate, stale, activeSprintContent = "", modelActions = [] }) { const issueIndex = buildIssueIndex(snapshot); const protectedIssueNumbers = collectActiveSprintIssueNumbers(activeSprintContent); - const obsoleteActions = buildObsoleteActions(stale, { protectedIssueNumbers }); + + const modelObsoleteActions = modelActions.filter((action) => action.section === "obsolete"); + const obsoleteActions = dedupeActions([ + ...buildObsoleteActions(stale, { protectedIssueNumbers }), + ...modelObsoleteActions.filter((action) => !protectedIssueNumbers.has(action.issueNumber)), + ]); + + const modelRelationships = modelActions.filter((action) => action.section === "relationship"); + const mergedRelate = mergeModelRelationships(relate, modelRelationships); + const priorityActions = modelActions.filter((action) => action.section === "priority"); const milestoneActions = modelActions.filter((action) => action.section === "milestone"); const allActions = dedupeActions([...obsoleteActions, ...priorityActions, ...milestoneActions]); const sections = [ { key: "classification", title: "Classification", markdown: renderClassification(snapshot) }, - { key: "relationships", title: "Relationships", markdown: renderRelationships(relate, issueIndex) }, + { key: "relationships", title: "Relationships", markdown: renderRelationships(mergedRelate, issueIndex) }, { key: "obsolete", title: "Obsolete Candidates", markdown: renderObsoleteCandidates(stale, obsoleteActions) }, { key: "priority", title: "Priority Proposals", markdown: renderPriorityProposals(priorityActions) }, { key: "milestone", title: "Milestone Suggestions", markdown: renderMilestoneSuggestions(milestoneActions) }, @@ -589,22 +606,110 @@ function buildReportModel({ snapshot, snapshotPath, relate, stale, activeSprintC }; } +function mergeModelRelationships(relate, modelRelationships) { + if (modelRelationships.length === 0) return relate; + + const base = relate && Array.isArray(relate.edges) + ? { ...relate, edges: [...relate.edges] } + : { edges: [] }; + + for (const action of modelRelationships) { + const edge = action.args; + base.edges.push({ + from: edge.from, + to: edge.to, + kind: edge.kind, + confidence: Number.isFinite(edge.confidence) ? edge.confidence : 1, + evidence: typeof edge.evidence === "object" && edge.evidence !== null ? edge.evidence : {}, + }); + } + + return base; +} + +const MODEL_SECTION_VERBS = Object.freeze({ + priority: new Set(["set-priority"]), + milestone: new Set(["assign-milestone"]), + obsolete: new Set(["close", "revisit", "close-duplicate"]), + relationship: new Set(["edge"]), +}); + +const MODEL_EDGE_KINDS = new Set([ + "mentions", + "comment-mentions", + "blocks", + "depends-on", + "duplicate-candidate", + "merged-pr-link", +]); + +function validateModelAction(action, index) { + const label = `model action[${index}]`; + + if (!action || typeof action !== "object" || Array.isArray(action)) { + throw new Error(`${label} must be an object.`); + } + + const section = String(action.section || ""); + if (!MODEL_SECTION_VERBS[section]) { + throw new Error(`${label} has unsupported section "${section}"; expected one of ${Object.keys(MODEL_SECTION_VERBS).join(", ")}.`); + } + + const verb = String(action.verb || ""); + if (!MODEL_SECTION_VERBS[section].has(verb)) { + throw new Error(`${label} uses verb "${verb}" which is not allowed in section "${section}".`); + } + + const args = action.args; + if (!args || typeof args !== "object" || Array.isArray(args)) { + throw new Error(`${label} must carry a plain-object args.`); + } + + if (section === "relationship") { + if (!Number.isSafeInteger(args.from) || args.from <= 0 || !Number.isSafeInteger(args.to) || args.to <= 0) { + throw new Error(`${label} (edge) requires positive safe-integer args.from and args.to.`); + } + if (!MODEL_EDGE_KINDS.has(args.kind)) { + throw new Error(`${label} (edge) has unsupported kind "${args.kind}"; expected one of ${[...MODEL_EDGE_KINDS].join(", ")}.`); + } + } else { + if (!Number.isSafeInteger(action.issueNumber) || action.issueNumber <= 0) { + throw new Error(`${label} must carry a positive safe-integer issueNumber.`); + } + } + + const requiredArgs = section === "priority" ? ["value"] : section === "milestone" ? ["name"] : []; + for (const key of requiredArgs) { + if (!(key in args)) { + throw new Error(`${label} (${verb}) is missing required arg "${key}".`); + } + } + + if (!String(action.summary || "").trim()) { + throw new Error(`${label} must carry a non-empty summary.`); + } +} + function loadModelActions(modelActionsPath) { if (!modelActionsPath) return []; const actions = readJsonFile(modelActionsPath, { label: "model actions JSON" }); if (!Array.isArray(actions)) { throw new Error(`Invalid model actions JSON at ${modelActionsPath}: expected an array of action objects.`); } - return actions.map((action) => ({ - section: String(action.section || ""), - verb: String(action.verb || ""), - issueNumber: action.issueNumber, - args: action.args || {}, - cluster: typeof action.cluster === "string" ? action.cluster : undefined, - sprintName: typeof action.sprintName === "string" ? action.sprintName : undefined, - summary: String(action.summary || ""), - evidence: typeof action.evidence === "string" ? action.evidence : undefined, - })); + + return actions.map((action, index) => { + validateModelAction(action, index); + return { + section: String(action.section), + verb: String(action.verb), + issueNumber: action.section === "relationship" ? undefined : action.issueNumber, + args: action.args, + cluster: typeof action.cluster === "string" ? action.cluster : undefined, + sprintName: typeof action.sprintName === "string" ? action.sprintName : undefined, + summary: String(action.summary), + evidence: typeof action.evidence === "string" ? action.evidence : undefined, + }; + }); } function renderReport(reportModel) { @@ -711,6 +816,9 @@ module.exports = { extractMarkdownSection, collectActiveSprintIssueNumbers, buildObsoleteActions, + loadModelActions, + validateModelAction, + mergeModelRelationships, formatStaleEvidence, staleCandidateToAction, buildReportModel, diff --git a/skills/backlog-triage/scripts/triage-report.test.js b/skills/backlog-triage/scripts/triage-report.test.js index f7f67d4..dd98c52 100644 --- a/skills/backlog-triage/scripts/triage-report.test.js +++ b/skills/backlog-triage/scripts/triage-report.test.js @@ -15,6 +15,9 @@ const { formatStaleEvidence, staleCandidateToAction, collectActiveSprintIssueNumbers, + loadModelActions, + validateModelAction, + mergeModelRelationships, } = require("./triage-report.js"); function makeSnapshot() { @@ -602,3 +605,211 @@ describe("triage-report integration chain", () => { ); }); }); + +describe("loadModelActions", () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "triage-model-actions-")); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function writeActions(actions) { + const actionsPath = path.join(tempDir, "model-actions.json"); + fs.writeFileSync(actionsPath, `${JSON.stringify(actions, null, 2)}\n`); + return actionsPath; + } + + it("loads a valid model-actions file from disk and normalizes fields", () => { + const actionsPath = writeActions([ + { + section: "priority", + verb: "set-priority", + issueNumber: 42, + args: { value: "high", reason: "customer-reported outage" }, + summary: "Set priority:high on #42 — customer-reported outage", + evidence: "customer-reported outage", + }, + { + section: "milestone", + verb: "assign-milestone", + issueNumber: 43, + args: { name: "Sprint W34", cluster: "auth" }, + cluster: "auth", + sprintName: "Sprint W34", + summary: "Assign Sprint W34 to #43 — auth cluster", + }, + ]); + + const actions = loadModelActions(actionsPath); + assert.equal(actions.length, 2); + assert.equal(actions[0].section, "priority"); + assert.equal(actions[1].sprintName, "Sprint W34"); + }); + + it("returns an empty array when no path is given", () => { + assert.deepEqual(loadModelActions(undefined), []); + }); + + it("rejects a non-array model-actions file", () => { + const actionsPath = path.join(tempDir, "model-actions.json"); + fs.writeFileSync(actionsPath, '{"section":"priority"}\n'); + assert.throws(() => loadModelActions(actionsPath), /expected an array/); + }); + + it("rejects unsupported section/verb combinations", () => { + const actionsPath = writeActions([ + { section: "priority", verb: "close", issueNumber: 42, args: {}, summary: "Close #42" }, + ]); + assert.throws(() => loadModelActions(actionsPath), /not allowed in section/); + }); + + it("rejects a malformed issueNumber", () => { + const actionsPath = writeActions([ + { section: "priority", verb: "set-priority", issueNumber: 0, args: { value: "high" }, summary: "x" }, + ]); + assert.throws(() => loadModelActions(actionsPath), /positive safe-integer issueNumber/); + }); + + it("rejects a missing required arg", () => { + const actionsPath = writeActions([ + { section: "milestone", verb: "assign-milestone", issueNumber: 42, args: {}, summary: "x" }, + ]); + assert.throws(() => loadModelActions(actionsPath), /missing required arg "name"/); + }); + + it("rejects an empty summary", () => { + const actionsPath = writeActions([ + { section: "priority", verb: "set-priority", issueNumber: 42, args: { value: "high" }, summary: " " }, + ]); + assert.throws(() => loadModelActions(actionsPath), /non-empty summary/); + }); + + it("validates relationship edges with from/to/kind", () => { + assert.doesNotThrow(() => + validateModelAction( + { section: "relationship", verb: "edge", args: { from: 100, to: 101, kind: "blocks", evidence: { phrase: "Blocks #101" } }, summary: "Blocks edge" }, + 0 + ) + ); + assert.throws( + () => + validateModelAction( + { section: "relationship", verb: "edge", args: { from: 100, to: 101, kind: "not-a-real-kind" }, summary: "x" }, + 0 + ), + /unsupported kind/ + ); + assert.throws( + () => validateModelAction({ section: "relationship", verb: "edge", args: { from: 100, kind: "blocks" }, summary: "x" }, 0), + /args.from and args.to/ + ); + }); +}); + +describe("model-action merge into report model", () => { + function snapshotWith(issues) { + return { + generated: "2026-04-18T01:30:00.000Z", + repo: "sungjunlee/dev-backlog", + config_path: "backlog/triage-config.yml", + issues, + }; + } + + function issue(number, overrides = {}) { + return { + number, + title: `Issue #${number}`, + body: "", + labels: [], + createdAt: "2026-04-10T00:00:00.000Z", + updatedAt: "2026-04-17T00:00:00.000Z", + milestone: null, + buckets: { + label: { type: "feature", priority: "medium", status: "todo" }, + theme: "uncategorized", + age: "7-30d", + activity: "recent", + milestone: "unassigned", + }, + ...overrides, + }; + } + + it("merges model relationship edges into the Relationships section", () => { + const snapshot = snapshotWith([issue(100), issue(101)]); + const model = buildReportModel({ + snapshot, + snapshotPath: "fixtures/snapshot.json", + relate: null, + stale: null, + modelActions: [ + { + section: "relationship", + verb: "edge", + args: { from: 100, to: 101, kind: "blocks", confidence: 1, evidence: { phrase: "Blocks #101" } }, + summary: "Blocks edge 100 -> 101", + }, + ], + }); + + const relationships = model.sections.find((section) => section.key === "relationships").markdown; + assert.match(relationships, /#100 blocks #101/); + }); + + it("merges model obsolete actions into Obsolete Candidates even without a stale input", () => { + const snapshot = snapshotWith([issue(104)]); + const model = buildReportModel({ + snapshot, + snapshotPath: "fixtures/snapshot.json", + relate: null, + stale: null, + modelActions: [ + { + section: "obsolete", + verb: "close-duplicate", + issueNumber: 104, + args: { target: "#44", reason: "open issue duplicates closed #44" }, + summary: "Close duplicate #104 into #44 — open issue duplicates closed #44", + }, + ], + }); + + const obsolete = model.sections.find((section) => section.key === "obsolete").markdown; + assert.match(obsolete, /triage:close-duplicate #104/); + assert.match(obsolete, /Close duplicate #104 into #44/); + }); + + it("dedupes with the apply contract key (verb, issueNumber, args) across sections", () => { + const snapshot = snapshotWith([issue(42)]); + const model = buildReportModel({ + snapshot, + snapshotPath: "fixtures/snapshot.json", + relate: null, + stale: null, + modelActions: [ + { + section: "priority", + verb: "set-priority", + issueNumber: 42, + args: { value: "high", reason: "outage" }, + summary: "Set priority:high on #42 — outage", + }, + { + section: "obsolete", + verb: "set-priority", + issueNumber: 42, + args: { value: "high", reason: "outage" }, + summary: "Set priority:high on #42 — outage (dup)", + }, + ], + }); + + const apply = model.sections.find((section) => section.key === "apply").markdown; + assert.equal((apply.match(/triage:set-priority #42/g) || []).length, 1); + }); +}); diff --git a/skills/dev-backlog/references/workflow-patterns.md b/skills/dev-backlog/references/workflow-patterns.md index 44ba599..a3a1d34 100644 --- a/skills/dev-backlog/references/workflow-patterns.md +++ b/skills/dev-backlog/references/workflow-patterns.md @@ -100,7 +100,8 @@ SNAP=backlog/triage/.cache/.json node $SKILL/triage-collect.js node $SKILL/triage-relate.js --snapshot $SNAP --json > /tmp/relate.json node $SKILL/triage-stale.js --snapshot $SNAP --json > /tmp/stale.json -node $SKILL/triage-report.js --snapshot $SNAP --relate /tmp/relate.json --stale /tmp/stale.json +# model-authored: blocks/depends-on/duplicate edges + priority/milestone actions +node $SKILL/triage-report.js --snapshot $SNAP --relate /tmp/relate.json --stale /tmp/stale.json --model-actions /tmp/model-actions.json # Apply phase (opt-in): check proposals in the report, then node $SKILL/triage-apply.js backlog/triage/-report.md --apply diff --git a/skills/dev-backlog/scripts/lib.js b/skills/dev-backlog/scripts/lib.js index 25c5fc7..c09b986 100644 --- a/skills/dev-backlog/scripts/lib.js +++ b/skills/dev-backlog/scripts/lib.js @@ -55,7 +55,6 @@ const TRIAGE_CONFIG_DEFAULTS = { cold: 60, }, stale_days: 60, - duplicate_threshold: 0.75, }; function stripQuotes(text) { diff --git a/skills/dev-backlog/scripts/lib.test.js b/skills/dev-backlog/scripts/lib.test.js index 5e3bffe..ac4a4da 100644 --- a/skills/dev-backlog/scripts/lib.test.js +++ b/skills/dev-backlog/scripts/lib.test.js @@ -291,7 +291,7 @@ describe("readTriageConfig", () => { " warm: 10", " cold: 45", "stale_days: 75", - "duplicate_threshold: 0.9", + "closed_issue_days: 30", "", ].join("\n") ); @@ -304,7 +304,7 @@ describe("readTriageConfig", () => { }); assert.deepEqual(config.activity_days, { warm: 10, cold: 45 }); assert.equal(config.stale_days, 75); - assert.equal(config.duplicate_threshold, 0.9); + assert.equal(config.closed_issue_days, 30); }); }); From 4ff2f4556bcb152461a1de1d296b50fae5da8e2f Mon Sep 17 00:00:00 2001 From: SJ Lee Date: Sat, 8 Aug 2026 20:32:28 +0900 Subject: [PATCH 3/6] fix: enforce model-action value validation and match apply dedupe key Round-2 review (claude-opus-5 + codex gpt-5.6-sol, both NOT-LGTM): - validateModelAction now rejects null/empty-string required args (priority->value, milestone->name), requires reason for obsolete close and target+reason for close-duplicate, and requires a top-level sprintName for milestone grouping (prevents '### undefined' headings and a localeCompare crash that would abort the whole report) - dedupe key now uses the same normalizeArgs/stableSerialize as triage-apply.js (deferred require to avoid the circular dependency), so key order and whitespace no longer cause duplicate anchors - references/relationships.md and stale.md now document the wire shape the loader actually accepts (section/verb/args wrapper) instead of raw edges or the old suggested_action grammar - drop the inert duplicate_threshold remnants from the report test fixture 467 tests pass / 0 fail; smoke-test 190 pass. --- .../references/relationships.md | 23 ++++++++-- skills/backlog-triage/references/stale.md | 21 ++++----- .../backlog-triage/scripts/triage-report.js | 29 +++++++++---- .../scripts/triage-report.test.js | 43 +++++++++++++++++-- 4 files changed, 91 insertions(+), 25 deletions(-) diff --git a/skills/backlog-triage/references/relationships.md b/skills/backlog-triage/references/relationships.md index c6e9cb6..0134bba 100644 --- a/skills/backlog-triage/references/relationships.md +++ b/skills/backlog-triage/references/relationships.md @@ -57,7 +57,24 @@ Every emitted edge carries evidence taken directly from the snapshot so downstre ## Model-judged Edges (semantic) -Blocking, dependency, and duplication require reading issue intent, so they are judged by the model reading the snapshot, not by phrase matching or title token overlap. The model emits edges in the same shape as the script (kind, confidence, evidence) and passes them to `triage-report.js --model-actions`. +Blocking, dependency, and duplication require reading issue intent, so they are judged by the model reading the snapshot, not by phrase matching or title token overlap. The model emits them as `section: "relationship"` entries in a `--model-actions` JSON file; `triage-report.js` validates each entry and merges it into the Relationships path. Wire shape: + +```json +{ + "section": "relationship", + "verb": "edge", + "args": { + "from": 100, + "to": 101, + "kind": "blocks", + "confidence": 1, + "evidence": { "phrase": "Blocks #101" } + }, + "summary": "Blocks edge 100 -> 101" +} +``` + +`args.kind` must be one of the script's deterministic kinds plus the semantic kinds below; `triage-report.js` rejects unknown kinds. ### `blocks` @@ -81,12 +98,12 @@ Blocking, dependency, and duplication require reading issue intent, so they are - Compare open issues against each other and against closed issues, judging semantic duplication from titles, bodies, labels, and comments — not title-token overlap alone. - Emit one canonical edge with the smaller issue number as `from`. -- Evidence: +- `args.evidence`: - `reason`: short human-readable why this is a duplicate candidate - `titles.from`: lower-numbered issue title - `titles.to`: higher-numbered issue title -A duplicate of a closed issue should be proposed as an Obsolete Candidate (close / merge-into), not only as a relationship edge — see `references/stale.md`. +A duplicate of a closed issue should be proposed as an Obsolete Candidate (`section: "obsolete"`, `verb: "close-duplicate"`), not only as a relationship edge — see `references/stale.md`. ## Evidence Schema diff --git a/skills/backlog-triage/references/stale.md b/skills/backlog-triage/references/stale.md index 2ce7217..ae4570b 100644 --- a/skills/backlog-triage/references/stale.md +++ b/skills/backlog-triage/references/stale.md @@ -17,23 +17,24 @@ Issues with any milestone are exempt from the `inactive` signal even if they are ## Model-judged duplicate-of-closed -An open issue that duplicates a closed issue is judged by the model reading titles, bodies, labels, and comments — the script no longer does title-token matching. The model proposes it as an Obsolete Candidate with `suggested_action: merge-into:#` (or `revisit` when the open issue should stay), and the evidence should name the target closed issue: +An open issue that duplicates a closed issue is judged by the model reading titles, bodies, labels, and comments — the script no longer does title-token matching. The model proposes it as an Obsolete Candidate in a `--model-actions` JSON file, not through the stale script's old `suggested_action` grammar. Wire shape: ```json { - "target": { - "number": 44, - "title": "OAuth token refresh worker" + "section": "obsolete", + "verb": "close-duplicate", + "issueNumber": 44, + "args": { + "target": "#12", + "reason": "open issue duplicates closed #12 with no substantive delta" }, - "reason": "open issue duplicates closed #44 with no substantive delta", - "titles": { - "open": "OAuth token refresh worker", - "closed": "OAuth token refresh worker" - } + "summary": "Close duplicate #44 into #12 — open issue duplicates closed #12" } ``` -Guidance: only merge-into when the open issue has no new requirements beyond the closed one; otherwise mark `revisit` and note what is still missing. +`verb` must be `close-duplicate` (with `args.target` and `args.reason`), `close` (with `args.reason`), or `revisit` (with `args.reason`); `triage-report.js` rejects anything else. + +Guidance: only close-duplicate when the open issue has no new requirements beyond the closed one; otherwise use `revisit` and note what is still missing. ## Evidence schema diff --git a/skills/backlog-triage/scripts/triage-report.js b/skills/backlog-triage/scripts/triage-report.js index 7b2a00d..b50e637 100755 --- a/skills/backlog-triage/scripts/triage-report.js +++ b/skills/backlog-triage/scripts/triage-report.js @@ -345,11 +345,10 @@ function actionPriority(action) { } function normalizeActionKey(action) { - return JSON.stringify({ - verb: action.verb, - issueNumber: action.issueNumber, - args: action.args && typeof action.args === "object" ? action.args : {}, - }); + // Deferred require: triage-apply.js requires this module for ANCHOR_PATTERN, + // so a top-level require would create a circular dependency at load time. + const { normalizeArgs, stableSerialize } = require("./triage-apply.js"); + return `${action.verb}|${action.issueNumber}|${stableSerialize(normalizeArgs(action.args))}`; } function dedupeActions(actions) { @@ -678,13 +677,27 @@ function validateModelAction(action, index) { } } - const requiredArgs = section === "priority" ? ["value"] : section === "milestone" ? ["name"] : []; + const requiredArgs = section === "priority" + ? ["value"] + : section === "milestone" + ? ["name"] + : section === "obsolete" + ? verb === "close-duplicate" + ? ["target", "reason"] + : ["reason"] + : []; + for (const key of requiredArgs) { - if (!(key in args)) { - throw new Error(`${label} (${verb}) is missing required arg "${key}".`); + const value = args[key]; + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`${label} (${verb}) requires a non-empty string arg "${key}".`); } } + if (section === "milestone" && typeof action.sprintName !== "string" || section === "milestone" && !String(action.sprintName || "").trim()) { + throw new Error(`${label} (assign-milestone) requires a non-empty top-level sprintName for grouping.`); + } + if (!String(action.summary || "").trim()) { throw new Error(`${label} must carry a non-empty summary.`); } diff --git a/skills/backlog-triage/scripts/triage-report.test.js b/skills/backlog-triage/scripts/triage-report.test.js index dd98c52..997de5f 100644 --- a/skills/backlog-triage/scripts/triage-report.test.js +++ b/skills/backlog-triage/scripts/triage-report.test.js @@ -446,7 +446,6 @@ describe("triage-report integration chain", () => { " warm: 14", " cold: 60", "stale_days: 60", - "duplicate_threshold: 0.5", "", ].join("\n") ); @@ -459,7 +458,7 @@ describe("triage-report integration chain", () => { const snapshot = makeSnapshot(); fs.writeFileSync(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`); - const relate = { edges: analyzeRelationships(snapshot, { config: { duplicate_threshold: 0.5 } }) }; + const relate = { edges: analyzeRelationships(snapshot) }; fs.writeFileSync(relatePath, `${JSON.stringify(relate, null, 2)}\n`); const stale = analyzeStale(snapshot, { config: { stale_days: 60 } }); @@ -676,9 +675,45 @@ describe("loadModelActions", () => { it("rejects a missing required arg", () => { const actionsPath = writeActions([ - { section: "milestone", verb: "assign-milestone", issueNumber: 42, args: {}, summary: "x" }, + { section: "milestone", verb: "assign-milestone", issueNumber: 42, args: {}, sprintName: "Sprint W34", summary: "x" }, ]); - assert.throws(() => loadModelActions(actionsPath), /missing required arg "name"/); + assert.throws(() => loadModelActions(actionsPath), /requires a non-empty string arg "name"/); + }); + + it("rejects null and empty-string required arg values", () => { + const nullValuePath = writeActions([ + { section: "priority", verb: "set-priority", issueNumber: 42, args: { value: null }, summary: "x" }, + ]); + assert.throws(() => loadModelActions(nullValuePath), /non-empty string arg "value"/); + + const emptyNamePath = writeActions([ + { section: "milestone", verb: "assign-milestone", issueNumber: 42, args: { name: "" }, sprintName: "Sprint W34", summary: "x" }, + ]); + assert.throws(() => loadModelActions(emptyNamePath), /non-empty string arg "name"/); + }); + + it("requires reason for obsolete close and target+reason for close-duplicate", () => { + const closePath = writeActions([ + { section: "obsolete", verb: "close", issueNumber: 42, args: {}, summary: "x" }, + ]); + assert.throws(() => loadModelActions(closePath), /non-empty string arg "reason"/); + + const dupPath = writeActions([ + { section: "obsolete", verb: "close-duplicate", issueNumber: 42, args: { target: "#44" }, summary: "x" }, + ]); + assert.throws(() => loadModelActions(dupPath), /non-empty string arg "reason"/); + + const dupTargetPath = writeActions([ + { section: "obsolete", verb: "close-duplicate", issueNumber: 42, args: { reason: "dup" }, summary: "x" }, + ]); + assert.throws(() => loadModelActions(dupTargetPath), /non-empty string arg "target"/); + }); + + it("requires a top-level sprintName for milestone actions", () => { + const actionsPath = writeActions([ + { section: "milestone", verb: "assign-milestone", issueNumber: 42, args: { name: "Sprint W34" }, summary: "x" }, + ]); + assert.throws(() => loadModelActions(actionsPath), /top-level sprintName/); }); it("rejects an empty summary", () => { From 3c6cea41ed0bfa068b3864c590b2ff1b0b503cf2 Mon Sep 17 00:00:00 2001 From: SJ Lee Date: Sat, 8 Aug 2026 20:38:46 +0900 Subject: [PATCH 4/6] fix: eliminate circular-dependency warnings on CLI runs Round-3 review (both reviewers NOT-LGTM on a single P1): - move module.exports above the main() invocation so the CLI path populates exports before dedupeActions' deferred require of triage-apply.js; verified no warnings on a real CLI run - dedupe priority/milestone section arrays too so the report stays internally consistent with the Apply Checklist - simplify the milestone sprintName check - test: add a dedupe case for args differing only by key order and whitespace (locks in normalizeArgs/stableSerialize parity with apply) 468 tests pass / 0 fail; smoke-test 190 pass. --- .../backlog-triage/scripts/triage-report.js | 10 +++---- .../scripts/triage-report.test.js | 30 ++++++++++++++++++- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/skills/backlog-triage/scripts/triage-report.js b/skills/backlog-triage/scripts/triage-report.js index b50e637..a0b28fb 100755 --- a/skills/backlog-triage/scripts/triage-report.js +++ b/skills/backlog-triage/scripts/triage-report.js @@ -577,8 +577,8 @@ function buildReportModel({ snapshot, snapshotPath, relate, stale, activeSprintC const modelRelationships = modelActions.filter((action) => action.section === "relationship"); const mergedRelate = mergeModelRelationships(relate, modelRelationships); - const priorityActions = modelActions.filter((action) => action.section === "priority"); - const milestoneActions = modelActions.filter((action) => action.section === "milestone"); + const priorityActions = dedupeActions(modelActions.filter((action) => action.section === "priority")); + const milestoneActions = dedupeActions(modelActions.filter((action) => action.section === "milestone")); const allActions = dedupeActions([...obsoleteActions, ...priorityActions, ...milestoneActions]); const sections = [ @@ -694,7 +694,7 @@ function validateModelAction(action, index) { } } - if (section === "milestone" && typeof action.sprintName !== "string" || section === "milestone" && !String(action.sprintName || "").trim()) { + if (section === "milestone" && !String(action.sprintName || "").trim()) { throw new Error(`${label} (assign-milestone) requires a non-empty top-level sprintName for grouping.`); } @@ -812,8 +812,6 @@ function main() { } } -if (require.main === module) main(); - module.exports = { ANCHOR_PATTERN, OPTIONAL_RELATIONSHIPS_MARKER, @@ -840,3 +838,5 @@ module.exports = { writeReportFile, loadInputs, }; + +if (require.main === module) main(); diff --git a/skills/backlog-triage/scripts/triage-report.test.js b/skills/backlog-triage/scripts/triage-report.test.js index 997de5f..5af08ce 100644 --- a/skills/backlog-triage/scripts/triage-report.test.js +++ b/skills/backlog-triage/scripts/triage-report.test.js @@ -17,7 +17,6 @@ const { collectActiveSprintIssueNumbers, loadModelActions, validateModelAction, - mergeModelRelationships, } = require("./triage-report.js"); function makeSnapshot() { @@ -847,4 +846,33 @@ describe("model-action merge into report model", () => { const apply = model.sections.find((section) => section.key === "apply").markdown; assert.equal((apply.match(/triage:set-priority #42/g) || []).length, 1); }); + + it("dedupes args that differ only by key order or whitespace, matching apply normalization", () => { + const snapshot = snapshotWith([issue(42)]); + const model = buildReportModel({ + snapshot, + snapshotPath: "fixtures/snapshot.json", + relate: null, + stale: null, + modelActions: [ + { + section: "priority", + verb: "set-priority", + issueNumber: 42, + args: { value: "high", reason: "outage" }, + summary: "Set priority:high on #42 — outage", + }, + { + section: "obsolete", + verb: "set-priority", + issueNumber: 42, + args: { reason: " outage ", value: "high" }, + summary: "Set priority:high on #42 — outage (reordered/trimmed dup)", + }, + ], + }); + + const apply = model.sections.find((section) => section.key === "apply").markdown; + assert.equal((apply.match(/triage:set-priority #42/g) || []).length, 1); + }); }); From 3d16ca94a4f31918994e3a066b25c4a1a33abbec Mon Sep 17 00:00:00 2001 From: SJ Lee Date: Sat, 8 Aug 2026 20:48:18 +0900 Subject: [PATCH 5/6] fix: restore non-string sprintName rejection for milestone actions Round-4 review: codex flagged P1 (numeric sprintName passed validation then coerced to undefined, rendering a literal '### undefined' milestone group); claude agreed as a suggestion. Restore the typeof check in validateModelAction and add regression tests: - reject numeric sprintName for milestone actions - dedupe two same-key actions within the priority section itself (locks section-level dedupe, not just the Apply Checklist) 470 tests pass / 0 fail; smoke-test 190 pass. --- .../backlog-triage/scripts/triage-report.js | 4 +-- .../scripts/triage-report.test.js | 36 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/skills/backlog-triage/scripts/triage-report.js b/skills/backlog-triage/scripts/triage-report.js index a0b28fb..8ea12be 100755 --- a/skills/backlog-triage/scripts/triage-report.js +++ b/skills/backlog-triage/scripts/triage-report.js @@ -694,8 +694,8 @@ function validateModelAction(action, index) { } } - if (section === "milestone" && !String(action.sprintName || "").trim()) { - throw new Error(`${label} (assign-milestone) requires a non-empty top-level sprintName for grouping.`); + if (section === "milestone" && (typeof action.sprintName !== "string" || !action.sprintName.trim())) { + throw new Error(`${label} (assign-milestone) requires a non-empty string top-level sprintName for grouping.`); } if (!String(action.summary || "").trim()) { diff --git a/skills/backlog-triage/scripts/triage-report.test.js b/skills/backlog-triage/scripts/triage-report.test.js index 5af08ce..3f6ac39 100644 --- a/skills/backlog-triage/scripts/triage-report.test.js +++ b/skills/backlog-triage/scripts/triage-report.test.js @@ -715,6 +715,13 @@ describe("loadModelActions", () => { assert.throws(() => loadModelActions(actionsPath), /top-level sprintName/); }); + it("rejects a non-string sprintName (numeric) for milestone actions", () => { + const actionsPath = writeActions([ + { section: "milestone", verb: "assign-milestone", issueNumber: 42, args: { name: "Sprint W34" }, sprintName: 202608, summary: "x" }, + ]); + assert.throws(() => loadModelActions(actionsPath), /non-empty string top-level sprintName/); + }); + it("rejects an empty summary", () => { const actionsPath = writeActions([ { section: "priority", verb: "set-priority", issueNumber: 42, args: { value: "high" }, summary: " " }, @@ -847,6 +854,35 @@ describe("model-action merge into report model", () => { assert.equal((apply.match(/triage:set-priority #42/g) || []).length, 1); }); + it("dedupes two same-key actions inside the priority section itself", () => { + const snapshot = snapshotWith([issue(42)]); + const model = buildReportModel({ + snapshot, + snapshotPath: "fixtures/snapshot.json", + relate: null, + stale: null, + modelActions: [ + { + section: "priority", + verb: "set-priority", + issueNumber: 42, + args: { value: "high", reason: "outage" }, + summary: "Set priority:high on #42 — outage", + }, + { + section: "priority", + verb: "set-priority", + issueNumber: 42, + args: { value: "high", reason: "outage" }, + summary: "Set priority:high on #42 — outage (duplicate)", + }, + ], + }); + + const priority = model.sections.find((section) => section.key === "priority").markdown; + assert.equal((priority.match(/triage:set-priority #42/g) || []).length, 1); + }); + it("dedupes args that differ only by key order or whitespace, matching apply normalization", () => { const snapshot = snapshotWith([issue(42)]); const model = buildReportModel({ From 57d2d382a3d4913461c0a29da8f27d06de2748bf Mon Sep 17 00:00:00 2001 From: SJ Lee Date: Sat, 8 Aug 2026 20:54:41 +0900 Subject: [PATCH 6/6] fix: validate milestone cluster as optional string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude round-5 suggestion: cluster had the same validate/load asymmetry as sprintName — a non-string cluster passed validation, was coerced to undefined, and silently collapsed into the uncategorized group. Reject a non-string cluster when present so the optional-field surface is uniform. 470 tests pass / 0 fail; smoke-test 190 pass. --- skills/backlog-triage/scripts/triage-report.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/skills/backlog-triage/scripts/triage-report.js b/skills/backlog-triage/scripts/triage-report.js index 8ea12be..1d49598 100755 --- a/skills/backlog-triage/scripts/triage-report.js +++ b/skills/backlog-triage/scripts/triage-report.js @@ -697,6 +697,9 @@ function validateModelAction(action, index) { if (section === "milestone" && (typeof action.sprintName !== "string" || !action.sprintName.trim())) { throw new Error(`${label} (assign-milestone) requires a non-empty string top-level sprintName for grouping.`); } + if (section === "milestone" && action.cluster !== undefined && typeof action.cluster !== "string") { + throw new Error(`${label} (assign-milestone) top-level cluster, when present, must be a string.`); + } if (!String(action.summary || "").trim()) { throw new Error(`${label} must carry a non-empty summary.`);