diff --git a/CHANGELOG.md b/CHANGELOG.md index a582479..4fc73f7 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 −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). - **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/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 6af5c7f..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. | @@ -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,55 @@ 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. 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 +[ + { + "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", + "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" + } +] +``` + 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..0134bba 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,55 @@ 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 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: -- 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` +```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` + +- 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` + +- 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 -Title similarity uses the following normalization before scoring: +### `duplicate-candidate` -- 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` +- 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`. +- `args.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 (`section: "obsolete"`, `verb: "close-duplicate"`), not only as a relationship edge — see `references/stale.md`. ## Evidence Schema @@ -169,8 +169,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 +177,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..ae4570b 100644 --- a/skills/backlog-triage/references/stale.md +++ b/skills/backlog-triage/references/stale.md @@ -10,12 +10,32 @@ | `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 in a `--model-actions` JSON file, not through the stale script's old `suggested_action` grammar. Wire shape: + +```json +{ + "section": "obsolete", + "verb": "close-duplicate", + "issueNumber": 44, + "args": { + "target": "#12", + "reason": "open issue duplicates closed #12 with no substantive delta" + }, + "summary": "Close duplicate #44 into #12 — open issue duplicates closed #12" +} +``` + +`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 Each candidate includes a non-empty `evidence` object. @@ -62,27 +82,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-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 707ae43..22f58e9 100644 --- a/skills/backlog-triage/scripts/triage-relate.js +++ b/skills/backlog-triage/scripts/triage-relate.js @@ -1,10 +1,6 @@ #!/usr/bin/env node 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 +235,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 +271,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 +322,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 +338,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 +381,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..9ee5eb6 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"], ] ); + // 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); }); 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..1d49598 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; @@ -338,11 +344,18 @@ function actionPriority(action) { return 0; } +function normalizeActionKey(action) { + // 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) { 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); @@ -456,7 +469,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"); } @@ -472,143 +485,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) { @@ -616,7 +496,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"); } @@ -636,7 +516,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(""); } @@ -684,17 +564,26 @@ 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 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 = dedupeActions(modelActions.filter((action) => action.section === "priority")); + const milestoneActions = dedupeActions(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) }, @@ -716,6 +605,129 @@ 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"] + : section === "obsolete" + ? verb === "close-duplicate" + ? ["target", "reason"] + : ["reason"] + : []; + + for (const key of requiredArgs) { + 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" || !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.`); + } +} + +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, 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) { return [ reportModel.frontmatter, @@ -757,12 +769,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 +799,7 @@ function main() { relate: inputs.relate, stale: inputs.stale, activeSprintContent: inputs.activeSprintContent, + modelActions: inputs.modelActions, }); markdown = renderReport(reportModel); } catch (error) { @@ -800,8 +815,6 @@ function main() { } } -if (require.main === module) main(); - module.exports = { ANCHOR_PATTERN, OPTIONAL_RELATIONSHIPS_MARKER, @@ -817,9 +830,9 @@ module.exports = { extractMarkdownSection, collectActiveSprintIssueNumbers, buildObsoleteActions, - buildRelationshipCounts, - buildPriorityActions, - buildMilestoneActions, + loadModelActions, + validateModelAction, + mergeModelRelationships, formatStaleEvidence, staleCandidateToAction, buildReportModel, @@ -828,3 +841,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 dafb6f3..3f6ac39 100644 --- a/skills/backlog-triage/scripts/triage-report.test.js +++ b/skills/backlog-triage/scripts/triage-report.test.js @@ -14,8 +14,9 @@ const { writeReportFile, formatStaleEvidence, staleCandidateToAction, - buildRelationshipCounts, collectActiveSprintIssueNumbers, + loadModelActions, + validateModelAction, } = require("./triage-report.js"); function makeSnapshot() { @@ -148,6 +149,7 @@ describe("parseArgs", () => { relatePath: "relate.json", stalePath: "stale.json", activeSprintPath: undefined, + modelActionsPath: undefined, outPath: "report.md", json: true, } @@ -272,19 +274,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 +352,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 +375,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/); }); }); @@ -433,7 +445,6 @@ describe("triage-report integration chain", () => { " warm: 14", " cold: 60", "stale_days: 60", - "duplicate_threshold: 0.5", "", ].join("\n") ); @@ -446,7 +457,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 } }); @@ -504,7 +515,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]; @@ -592,3 +603,312 @@ 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: {}, sprintName: "Sprint W34", summary: "x" }, + ]); + 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 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: " " }, + ]); + 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); + }); + + 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({ + 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); + }); +}); 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)); }); }); 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); }); });