From eeb0e7eb4103e56a4881ea7fa21c4752c7288b24 Mon Sep 17 00:00:00 2001 From: SJ Lee Date: Sun, 27 Sep 2026 18:36:13 +0900 Subject: [PATCH 01/13] fix: preflight accepted triage actions before any write (#495) --- skills/backlog-triage/SKILL.md | 4 +- skills/backlog-triage/references/apply.md | 6 +- skills/backlog-triage/scripts/triage-apply.js | 92 ++++++++++- tests/backlog-triage/triage-apply.test.js | 143 +++++++++++++++++- 4 files changed, 237 insertions(+), 8 deletions(-) diff --git a/skills/backlog-triage/SKILL.md b/skills/backlog-triage/SKILL.md index 8aa3472..a5b2eba 100644 --- a/skills/backlog-triage/SKILL.md +++ b/skills/backlog-triage/SKILL.md @@ -22,14 +22,14 @@ Every proposal is an anchor comment followed by a checkbox line: - [ ] close #42 - merged PR #87 already exists ``` -Verbs: `close`, `revisit`, `close-duplicate` (`target=#N`), `set-priority` (`value=high|medium|low`), `assign-milestone` (`milestone="Name"`). Args are `key="value"` or `key=value`; the first `#N` after the verb is the target. +Verbs: `close` (`reason="..."`), `revisit` (`reason="..."`), `close-duplicate` (`target=#N`), `set-priority` (`value=high|medium|low`), `assign-milestone` (`milestone="Name"`; legacy `name=` is accepted on Apply). Args are `key="value"` or `key=value`; the first `#N` after the verb is the target. Boundary: never propose closing an issue named in an active sprint's Plan or Running Context. Never mutate GitHub or `spec/*` in this mode. Done when the report exists with every section present, every proposal anchored, and no close targets an in-flight issue. ## Apply Goal: only accepted actions reach GitHub. -Rail: after a human flips `[ ]` → `[x]` next to an anchor, `node /scripts/triage-apply.js ` prints the plan (dry-run); the same with `--apply --yes` executes accepted actions, dedupes by `(verb, issueNumber, normalizedArgs)`, skips unknown verbs, logs `already-applied` on re-runs, and writes an audit log beside the report. +Rail: after a human flips `[ ]` → `[x]` next to an anchor, `node /scripts/triage-apply.js ` prints the plan (dry-run); the same with `--apply --yes` validates every accepted known action and the GitHub authority before any write, then executes accepted actions, dedupes by `(verb, issueNumber, normalizedArgs)`, skips unknown verbs, logs `already-applied` on re-runs, and writes an audit log beside the report. Done when every accepted action is applied or logged `already-applied` and nothing unaccepted was touched. Scripts resolve from the installed `backlog-triage` skill directory; run from the target project root. Sprint files, milestones, and Issue AC belong to `dev-backlog`. diff --git a/skills/backlog-triage/references/apply.md b/skills/backlog-triage/references/apply.md index dc67fa3..f69f93a 100644 --- a/skills/backlog-triage/references/apply.md +++ b/skills/backlog-triage/references/apply.md @@ -59,7 +59,7 @@ Examples: ```html - + ``` @@ -96,11 +96,13 @@ Multiple actions may target the same issue when the actions are distinct. Exampl ```html - + ``` That is valid. These are separate proposals and therefore separate anchor+checkbox pairs. +For `assign-milestone`, `milestone=` is canonical. Apply accepts legacy `name=` only when `milestone=` is absent; supplying both with different values is invalid. Accepted known actions are validated together before any GitHub call or apply-log write, including in dry-run. A report with invalid accepted arguments or conflicting priority, milestone, or duplicate target assignments fails with report line numbers. Unchecked actions remain inert and unknown verbs keep their skip-and-log behavior. In apply mode, an absent `.dev-backlog/.tracker` or a first line of `github` is required; any other or unreadable authority stops before a write. + The invalid case is duplicating the exact same action for the same issue without new meaning, for example two separate `close` anchors for `#42` that differ only by wording. ## Report / Apply Contract diff --git a/skills/backlog-triage/scripts/triage-apply.js b/skills/backlog-triage/scripts/triage-apply.js index 5f406ff..ff64787 100644 --- a/skills/backlog-triage/scripts/triage-apply.js +++ b/skills/backlog-triage/scripts/triage-apply.js @@ -3,7 +3,7 @@ const fs = require("fs"); const path = require("path"); const { execFileSync } = require("node:child_process"); -const { GH_EXEC_DEFAULTS } = require("../../dev-backlog/scripts/lib.js"); +const { GH_EXEC_DEFAULTS, readTaskAuthority } = require("../../dev-backlog/scripts/lib.js"); const { ANCHOR_PATTERN, parseAnchor } = require("./anchor.js"); const { DEFAULT_BACKLOG_DIR, defaultTriageDir } = require("../../dev-backlog/scripts/execution-root.js"); @@ -233,6 +233,72 @@ function dedupActions(anchors) { return [...deduped.values()]; } +function validateAcceptedActions(actions) { + const errors = []; + const fieldsByIssue = new Map(); + + for (const action of actions) { + if (!action.checked || !action.knownVerb) continue; + + const line = action.occurrences.find((occurrence) => occurrence.checked)?.anchorLine + || action.occurrences[0].anchorLine; + const location = `line ${line}: triage:${action.verb} #${action.issueNumber}`; + const args = action.normalizedArgs; + const required = { + close: ["reason"], + revisit: ["reason"], + "close-duplicate": ["target"], + "set-priority": ["value"], + "assign-milestone": [], + }[action.verb]; + + for (const key of required) { + if (!String(args[key] || "").trim()) { + errors.push(`${location}: ${key} is required and must not be blank.`); + } + } + + let field; + let value; + if (action.verb === "set-priority") { + field = "priority"; + value = args.value; + if (value && !["high", "medium", "low"].includes(value)) { + errors.push(`${location}: value must be high, medium, or low.`); + } + } else if (action.verb === "assign-milestone") { + field = "milestone"; + value = args.milestone === undefined ? args.name : args.milestone; + if (!String(value || "").trim()) { + errors.push(`${location}: milestone is required and must not be blank.`); + } + if (args.milestone !== undefined && args.name !== undefined && args.milestone !== args.name) { + errors.push(`${location}: milestone and legacy name have different values.`); + } + } else if (action.verb === "close-duplicate") { + field = "duplicate target"; + value = args.target; + if (value && !/^#[1-9]\d*$/.test(value)) { + errors.push(`${location}: target must be #N with a positive issue number.`); + } else if (value && Number(value.slice(1)) === action.issueNumber) { + errors.push(`${location}: target must not be the issue itself.`); + } + } + + if (!field || !value) continue; + if (!fieldsByIssue.has(action.issueNumber)) fieldsByIssue.set(action.issueNumber, new Map()); + const fields = fieldsByIssue.get(action.issueNumber); + const previous = fields.get(field); + if (previous && previous.value !== value) { + errors.push(`${location}: conflicting ${field} ${JSON.stringify(value)}; line ${previous.line} assigns ${JSON.stringify(previous.value)}.`); + } else if (!previous) { + fields.set(field, { value, line }); + } + } + + return errors; +} + function formatRevisitComment(reason) { return `triage: revisit — ${String(reason || "").trim()}`; } @@ -293,7 +359,7 @@ function toGhCommands(action, context = {}) { ]; case "assign-milestone": return [ - ["issue", "edit", issue, "--milestone", String(args.name || "").trim()], + ["issue", "edit", issue, "--milestone", String(args.milestone ?? args.name ?? "").trim()], ]; default: return []; @@ -721,6 +787,26 @@ function execute(argv = process.argv.slice(2), deps = {}) { }; } + const deduped = dedupActions(report.anchors); + const validationErrors = validateAcceptedActions(deduped); + if (validationErrors.length > 0) { + return { + exitCode: 1, + error: `Invalid accepted triage actions:\n${validationErrors.map((error) => `- ${error}`).join("\n")}`, + }; + } + + if (options.apply) { + try { + const authority = readTaskAuthority(path.resolve(cwd, DEFAULT_BACKLOG_DIR)); + if (authority !== "github") { + throw new Error(`task authority is ${JSON.stringify(authority)}; triage apply requires github.`); + } + } catch (error) { + return { exitCode: 1, error: `Cannot apply triage actions: ${error.message}` }; + } + } + try { ensureApplyAllowed(options, { confirmApply: deps.confirmApply, @@ -736,7 +822,6 @@ function execute(argv = process.argv.slice(2), deps = {}) { }; } - const deduped = dedupActions(report.anchors); const applyMode = options.apply ? "apply" : "dry-run"; const result = { report: reportPath, @@ -888,6 +973,7 @@ module.exports = { parseFrontmatter, parseReport, dedupActions, + validateAcceptedActions, formatRevisitComment, formatDuplicateComment, buildPriorityEditCommand, diff --git a/tests/backlog-triage/triage-apply.test.js b/tests/backlog-triage/triage-apply.test.js index 784281d..ec2ee6f 100644 --- a/tests/backlog-triage/triage-apply.test.js +++ b/tests/backlog-triage/triage-apply.test.js @@ -3,7 +3,8 @@ const assert = require("node:assert/strict"); const fs = require("fs"); const os = require("os"); const path = require("path"); -const { execFileSync } = require("node:child_process"); +const { execFileSync, spawnSync } = require("node:child_process"); +const { writeGhFixture } = require("../fakes/fake-gh-fixture.js"); const SKILL_SCRIPTS = path.resolve(__dirname, "../../skills/backlog-triage/scripts"); const { parseArgs, @@ -61,6 +62,15 @@ function makeReport() { ].join("\n"); } +function acceptedReport(...anchors) { + return [ + "---", + "generated: 2026-04-18", + "---", + ...anchors.flatMap((anchor) => [anchor, "- [x] Accept", ""]), + ].join("\n"); +} + describe("parseArgs", () => { it("parses the positional report path and flags", () => { assert.deepEqual(parseArgs(["report.md", "--apply", "--yes", "--json"]), { @@ -187,6 +197,10 @@ describe("toGhCommands", () => { toGhCommands({ verb: "assign-milestone", issueNumber: 102, args: { name: "Sprint W17" } }), [["issue", "edit", "102", "--milestone", "Sprint W17"]] ); + assert.deepEqual( + toGhCommands({ verb: "assign-milestone", issueNumber: 102, args: { milestone: "Sprint W18" } }), + [["issue", "edit", "102", "--milestone", "Sprint W18"]] + ); }); }); @@ -395,4 +409,131 @@ describe("execute", () => { assert.equal(result.exitCode, 1); assert.match(result.error, /failed to read report/i); }); + + it("accepts canonical and legacy milestone keys and keeps re-runs idempotent", () => { + fs.writeFileSync(reportPath, acceptedReport( + '', + '' + )); + const calls = []; + const runGh = (argv) => { + calls.push(argv); + return { status: 0, stdout: "", stderr: "" }; + }; + const first = execute([reportPath, "--apply", "--yes"], { cwd: repoRoot, runGh }); + const second = execute([reportPath, "--apply", "--yes"], { cwd: repoRoot, runGh }); + + assert.equal(first.exitCode, 0); + assert.equal(second.exitCode, 0); + assert.deepEqual(calls, [ + ["issue", "edit", "102", "--milestone", "Sprint W17"], + ["issue", "edit", "103", "--milestone", "Sprint W18"], + ]); + assert.deepEqual(second.actions.map((action) => action.result), ["already-applied", "already-applied"]); + }); + + it("rejects every invalid accepted action before any gh invocation or log write", () => { + const cases = [ + ['', /reason is required/], + ['', /reason is required/], + ['', /value must be high/], + ['', /value is required/], + ['', /target is required/], + ['', /target must be #N/], + ['', /target must not be the issue itself/], + ['', /milestone is required/], + ['', /different values/], + ]; + const fixture = writeGhFixture(tempDir); + const logPath = resolveLogPath("2026-04-18", repoRoot); + for (const [anchor, expected] of cases) { + fs.writeFileSync(reportPath, acceptedReport(anchor)); + const result = spawnSync(process.execPath, [path.join(SKILL_SCRIPTS, "triage-apply.js"), reportPath, "--apply", "--yes"], { + cwd: repoRoot, + encoding: "utf-8", + env: fixture.env, + }); + assert.equal(result.status, 1, anchor); + assert.match(result.stderr, expected, anchor); + assert.match(result.stderr, /line 4/, anchor); + assert.equal(fixture.calls().length, 0, anchor); + assert.equal(fs.existsSync(logPath), false, anchor); + } + }); + + it("aggregates later invalid actions and conflicting assignments in apply and dry-run", () => { + fs.writeFileSync(reportPath, acceptedReport( + '', + '', + '', + '', + '', + '', + '', + '' + )); + const fixture = writeGhFixture(tempDir); + const logPath = resolveLogPath("2026-04-18", repoRoot); + fs.writeFileSync(logPath, "existing log\n"); + for (const flags of [[], ["--apply", "--yes"]]) { + const result = spawnSync(process.execPath, [path.join(SKILL_SCRIPTS, "triage-apply.js"), reportPath, ...flags], { + cwd: repoRoot, + encoding: "utf-8", + env: fixture.env, + }); + assert.equal(result.status, 1); + for (const line of [10, 16, 22, 25]) assert.match(result.stderr, new RegExp(`line ${line}:`)); + assert.match(result.stderr, /conflicting priority/); + assert.match(result.stderr, /conflicting milestone/); + assert.match(result.stderr, /conflicting duplicate target/); + assert.match(result.stderr, /reason is required/); + assert.equal(fixture.calls().length, 0); + assert.equal(fs.readFileSync(logPath, "utf-8"), "existing log\n"); + } + }); + + it("leaves unchecked invalid anchors inert and unknown verbs skipped", () => { + fs.writeFileSync(reportPath, [ + "---", "generated: 2026-04-18", "---", + '', "- [ ] Leave pending", "", + '', "- [x] Accept unknown", "", + ].join("\n")); + const result = execute([reportPath, "--apply", "--yes"], { cwd: repoRoot, runGh: () => assert.fail("gh invoked") }); + assert.equal(result.exitCode, 0); + assert.deepEqual(result.skipped.map((entry) => entry.result), ["skipped-pending", "skipped-unknown-verb"]); + assert.equal(fs.readFileSync(resolveLogPath("2026-04-18", repoRoot), "utf-8").trim().split("\n").length, 2); + }); + + it("guards the task authority before GitHub and apply log writes", () => { + fs.writeFileSync(reportPath, acceptedReport('')); + const tracker = path.join(repoRoot, ".dev-backlog", ".tracker"); + const calls = []; + const runGh = (argv) => { + calls.push(argv); + return { status: 0, stdout: "", stderr: "" }; + }; + const logPath = resolveLogPath("2026-04-18", repoRoot); + for (const value of ["backlog", "gitlab", "mystery", ""]) { + fs.writeFileSync(tracker, `${value}\n`); + const result = execute([reportPath, "--apply", "--yes"], { cwd: repoRoot, runGh }); + assert.equal(result.exitCode, 1, value); + assert.match(result.error, /authority/i); + assert.equal(calls.length, 0); + assert.equal(fs.existsSync(logPath), false); + } + fs.rmSync(tracker); + fs.mkdirSync(tracker); + const unreadable = execute([reportPath, "--apply", "--yes"], { cwd: repoRoot, runGh }); + assert.equal(unreadable.exitCode, 1); + assert.match(unreadable.error, /cannot read task authority/i); + assert.equal(calls.length, 0); + assert.equal(fs.existsSync(logPath), false); + fs.rmdirSync(tracker); + + assert.equal(execute([reportPath, "--apply", "--yes"], { cwd: repoRoot, runGh }).exitCode, 0); + assert.equal(calls.length, 1); + fs.writeFileSync(tracker, "github\nignored second line\n"); + assert.equal(execute([reportPath, "--apply", "--yes"], { cwd: repoRoot, runGh }).exitCode, 0); + assert.equal(calls.length, 1); + }); }); From 47e8604bd70efaf44615167e31731e9f0114970c Mon Sep 17 00:00:00 2001 From: SJ Lee Date: Sun, 27 Sep 2026 19:14:15 +0900 Subject: [PATCH 02/13] docs: ratify approved lean contract amendments (#494) --- spec/capabilities.md | 27 ++++++++++++++++----------- spec/charter.md | 11 ++++++----- spec/system-map.md | 8 ++++---- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/spec/capabilities.md b/spec/capabilities.md index f98a5b2..7376147 100644 --- a/spec/capabilities.md +++ b/spec/capabilities.md @@ -5,7 +5,7 @@ The middle layer between [`charter.md`](charter.md) and the active sprint. Capability headings are routing handles. Sprint `component:` is a free track-scope string; by convention it names a capability heading here, but nothing lints it. Concurrent active tracks partition by -`component:` equality or by `scope:` globs — one axis per track, never both. +`component:` equality or by `scope:` directory prefixes — one axis per track, never both. Retired capabilities (never restore as living contracts): `backlog-sync` last text at git [`4fea158`](https://github.com/sungjunlee/dev-backlog/blob/4fea158/spec/capabilities.md); @@ -25,9 +25,9 @@ Mutation: [`spec/README.md`](README.md) § Mutation. - Fail-loud CLI availability and authentication errors: a failed live read stops execution **Out-of-scope:** -- A tracker abstraction, or an authority inferred from installed CLIs or switched at runtime (the authority is only ever the declared `.tracker` line, `github` when the file is absent): adding an authority means adding a table row (documented CLI verbs) plus its entry in the scripts' allow-list, and a row needs a measured consumer — the GitLab row is the one standing exception (unmeasured as of 2026-09-18, carried because it is one table row and one allow-list entry, no adapter, dropped at the next reassess if still unpinned); the `files` adapter code and ports stay parked at `v0.11.0`, the GitLab adapter at `a8ddb7d` +- A tracker abstraction, or an authority inferred from installed CLIs or switched at runtime (the authority is only ever the declared `.tracker` line, `github` when the file is absent): adding an authority means adding a table row (documented CLI verbs), never a script allow-list entry; rows are `measured` or `documented, unverified`, a `documented, unverified` row claims no support, and a new row enters only as `measured` (a measured consumer, or each verb verified against an authorized target) — GitLab is `documented, unverified` (kept at the 2026-09-27 reassess) and Gitea (`tea`) is not a row until verified; the `files` adapter code and ports stay parked at `v0.11.0`, the GitLab adapter at `a8ddb7d` - Task mirrors, diagnostic exports, or any local copy of Issue state used as authority (triage reports are advisory, never authority) -- Scripts that wrap the authority CLI for reading tasks, resolving specifications, or seeding Plans; scripts never invoke `gh`, `backlog`, or `glab` for task state (`triage-apply` and `--close-milestone` are the GitHub-only exceptions) +- Scripts that wrap the authority CLI for reading tasks, resolving specifications, or seeding Plans; scripts never invoke `gh`, `backlog`, or `glab` for task or milestone state (`triage-apply` is the one exception, GitHub-only) - GitHub Projects fields as task specification or lifecycle state ### Expected Behaviors @@ -57,6 +57,7 @@ Mutation: [`spec/README.md`](README.md) § Mutation. | 2026-09-17 | `gitlab` is parked: no measured consumer (no `glab` installed, no repo pins the key); `TRACKER_KEYS` returns to `github`, `files`; the adapter stays retrievable at `a8ddb7d` (#421, #424; gate 2026-09-17) | charter measured-consumer rule | 2026-09-15 gitlab row | | 2026-09-18 | Task authority generalized without code (charter rev 20, epic #472): `.dev-backlog/.tracker` is one line the session reads (absent = `github`; `backlog`/`files`; `gitlab`), the SKILL.md Task Authority table carries the read/create/close verbs, scripts stay authority-neutral (`sprint-state` JSON `tracker` field reflects the line; `--close-milestone` refuses non-GitHub). `backlog` is measured (maintainer repos on the Backlog.md CLI with no GitHub remote); `gitlab` is a documented, unmeasured row | GitHub-less and self-hosted use are maintainer needs; the remaining coupling was prose | 2026-09-17 G1/G2 row's prose (adapter parking stands); 2026-09-17 gitlab-parked row's prose (adapter stays at `a8ddb7d`) | | 2026-09-19 | Clarification of the 2026-09-18 row: "without code" means without an adapter — v0.15.0 added 56 authority-neutral script lines (`readTaskAuthority` allow-list, JSON `tracker` field, `--close-milestone` guard, setup line) | the row was published with v0.15.0 and stays; the wording was wrong | wording of the 2026-09-18 row | +| 2026-09-27 | Authority rows without an allow-list (charter rev 21, #494, epic #504; gate: 2026-09-27 [user approval relayed through gateway](https://github.com/sungjunlee/dev-backlog/issues/494#issuecomment-5854919334)): rows are `measured` or `documented, unverified`; GitLab stays `documented, unverified` with no claim of support; Gitea (`tea`) enters only as `measured`, after #503 verifies it against an authorized target; `--close-milestone` retires, so `triage-apply` is the one scripted writer | the allow-list duplicated the table and guarded nothing the session does not already stop on; milestone closing is a session CLI call like milestone seeding | 2026-09-18 row's allow-list and `--close-milestone` guard clauses; the "GitLab row is the one standing exception" Out-of-scope clause | --- @@ -67,7 +68,7 @@ Mutation: [`spec/README.md`](README.md) § Mutation. **In-scope:** - `.dev-backlog/sprints/*.md` body + frontmatter (status, milestone, objectives, and the track-scope key: `component:` or `scope:`) - Checkbox state machine: `[ ]` not started → `[~]` in flight → `[x]` done -- `sprint-init.js`, `sprint-close.sh`, `find_active_sprint`/`resolve_track`, `next.sh`, `status.sh` +- `sprint-init.js`, the shared sprint reader `sprint-state.js` (active tracks, track selection, next batch, status, recovery JSON), and `backlog-doctor.js` **Out-of-scope:** - Tasks outside the active sprint (their specification and lifecycle remain in the task authority) @@ -77,12 +78,13 @@ Mutation: [`spec/README.md`](README.md) § Mutation. ### Expected Behaviors - The default Issue → implementation → PR → closure path creates no sprint. A sprint is admitted only for ordered multi-Issue batches, delegated/parallel handoff, cross-Issue or cross-session context, or concurrent track coordination; duration, estimate, and milestone membership alone never trigger one. -- No two sprint files with `status: active` declare overlapping scope — overlap fails loud through the one shared `scopesOverlap` predicate (`component:` equality or `scope:` path-prefix collision; surfaced by `sprint-init` refusal, `sprint-state` `OVERLAPPING_TRACKS`, and the doctor's `Active tracks overlap on scope` verdict). Disjoint-scope tracks coexist as a portfolio; a single active track behaves exactly as before; once more than one track is active, any track without a declared axis cannot be proven disjoint and surfaces an informational doctor warning. +- No two sprint files with `status: active` declare overlapping scope — overlap fails loud through the one shared overlap check (`component:` equality, or one `scope:` directory prefix containing another; surfaced by `sprint-init` refusal, `sprint-state` `OVERLAPPING_TRACKS`, and the doctor's `Active tracks overlap on scope` verdict). The `scope:` language is directory prefixes only: `dir`, `dir/`, `dir/*`, `dir/**` (a trailing `*` segment is treated as the whole directory); `sprint-init` rejects other glob metacharacters, and readers treat an unsupported entry in an existing file as unknown. Disjoint-scope tracks coexist as a portfolio; a single active track behaves exactly as before; once more than one track is active, a track without a declared axis, a `component:` track next to a `scope:` track, or a track with an unknown `scope:` entry cannot be proven disjoint: the doctor reports it as "cannot prove disjoint" (a warning), never as "scopes disjoint". +- The next batch is the first Plan batch with `[ ]` items. That candidate is reported as waiting on the named in-flight refs, not as actionable, only when a strictly earlier batch still has `[~]` items; `[ ]` items in the same batch as a `[~]` item stay actionable. A Plan without batch headings lists every `[ ]` item, unchanged. There is no dependency graph and no scheduler. - Every `[~]` line carries a PR or branch pointer in-line — never an unmoored `[~]`. -- One successful `sprint-close.sh` invocation flips the sprint to `status: completed` and appends the final Progress entry (`N/M tasks completed`); remaining `[ ]` / `[~]` items only print a warning and never block the close, so before closing the session decides whether to finish them, strike them with a Progress entry, or carry them to the next sprint. +- Closing a sprint is a deliberate session edit; no lifecycle script performs or refuses it. Before the edit, the session reports the remaining `[ ]` and `[~]` counts and each remaining item's disposition — finish it, strike it with a Progress entry, or carry it to the next sprint; this report replaces the warning `sprint-close.sh` printed. The session then changes frontmatter `status: active` to `status: completed` and appends the final Progress entry (`N/M tasks completed`). After the edit, the session reads the target file's frontmatter and final Progress entry directly, and the shared reader or the doctor confirms the sprint no longer appears among active tracks; those tools select active files and do not validate a completed file's Progress. Closing the sprint's milestone is a separate authority call the session makes. +- By convention a completed sprint is not flipped back to `active` — reopening the work means a new sprint; nothing enforces this. Completed sprint files are disposable and may be deleted at any time: the record of what was done and how is the Issue, the epic, and the PR, and the only thing to keep is what was promoted to `_context.md`. ### Hard Constraints -- Never mutate a sprint's `status: completed` back to `active` — reopening the work means a new sprint. Completed sprint files are disposable and may be deleted at any time: the record of what was done and how is the Issue, the epic, and the PR, and the only thing to keep is what was promoted to `_context.md`. - Never silently delete sprint Plan items — strike them with a Progress entry or convert to `[~]` moored by a PR or branch pointer plus a parking note instead. - Never copy Issue acceptance criteria into a sprint or let sprint checkbox state own Issue lifecycle. @@ -105,6 +107,7 @@ Mutation: [`spec/README.md`](README.md) § Mutation. | 2026-09-19 | Relay deprecated: the `sprint-state` JSON is the fresh-session recovery rail, not a consumer contract; `[run:id]` pointers and the `run_id` field are deleted; `[~]` is moored by a PR or branch pointer only | no reader left for run pointers | the `[run:id]` pointer grammar and the "explicit no-work-yet annotation" clause in sprint-execution's Expected Behaviors (multi-track PRD, 2026-07) | | 2026-09-20 | Sprint close stays warn-only: `sprint-close.sh` prints `Warning: N todo, M in-flight items remaining` and closes anyway, and the Expected Behavior is amended to say so; no script change | whether an item is "explicitly struck in Progress" is prose the session writes and no deterministic check can read, and the irreversible part (`status: completed` never flips back) is already a Hard Constraint; adding a refusal plus a `--force` flag would be a new rail against the charter's subtraction direction | the "runs only when every Plan item is `[x]` or explicitly struck in Progress" clause in this capability's Expected Behaviors | | 2026-09-20 | Completed sprint files are disposable and may be deleted at any time; after a close the only thing kept is what was promoted to `_context.md`. `status: completed` is still never flipped back — reopening the work is a new sprint | a sprint is a disposable work unit, not a record: what was done and how already lives in the Issue, the epic, and the PR, so retaining closed sprint files only adds surface to read and to drift; the repo's own 36 completed files are deleted with this decision and remain in git history | the "completed sprints are immutable history" half of this capability's Hard Constraint, and the "Deletion stops at history: v1.0.0 is reserved and is not a cleanup cut, so completed sprint files stay" gotcha in `_context.md` | +| 2026-09-27 | Lean sprint contract (charter rev 21, #494, epic #504; gate: 2026-09-27 [user approval relayed through gateway](https://github.com/sungjunlee/dev-backlog/issues/494#issuecomment-5854919334)): In-scope scripts are `sprint-init.js`, the shared reader `sprint-state.js`, and `backlog-doctor.js`; `scope:` is directory prefixes only; the doctor reports cross-axis pairs and unknown `scope:` entries as "cannot prove disjoint", never "scopes disjoint"; a candidate `[ ]` batch waits only on `[~]` refs in strictly earlier batches (same-batch `[ ]` items stay actionable); close is a deliberate session edit, preceded by the session's report of remaining counts and dispositions and followed by a direct read of the file and an active-track check; never-reopen becomes a convention | a boolean overlap predicate reported cross-axis tracks as disjoint, which the fail-loud guarantee forbids; a second (bash) parser and a close wrapper guard nothing once completed files are disposable; a flip back is recoverable from git and no longer irreversible | 2026-09-20 warn-only close row's `sprint-close.sh` mechanism and its printed warning (the session's pre-close report replaces the warning; no script refuses a close); the never-reopen Hard Constraint kept by the 2026-09-20 disposable row; "path-prefix collision" glob wording; bash-side `find_active_sprint`/`resolve_track` | --- @@ -113,9 +116,9 @@ Mutation: [`spec/README.md`](README.md) § Mutation. **Goal:** Open Issues are classified, related, flagged stale, aligned to charter Objectives, and reviewed for next action without humans maintaining a parallel triage spreadsheet. **In-scope:** -- `backlog-triage` report written by the session from `gh` reads (fixed eight-section shape, anchors) and the `triage-apply` human-gated pipeline -- Charter-aware Alignment Check (Issue → Objective mapping) -- Spec-aware Decision Review (`Do Now`, `Shape First`, `Defer`, `Drop / Close`) +- `backlog-triage` report written by the session from `gh` reads — one approval surface, `## Apply Checklist` (the only fixed heading and the only section whose anchors are parsed), plus prose evidence without anchors — and the `triage-apply` human-gated pipeline +- Charter-aware alignment evidence (material orphans, neglected Objectives, contradictions) when a charter exists +- Spec-aware decision recommendations (`Do Now`, `Shape First`, `Defer`, `Drop / Close`) as prose evidence - The advisory triage report artifact under `.dev-backlog/triage/` **Out-of-scope:** @@ -125,7 +128,8 @@ Mutation: [`spec/README.md`](README.md) § Mutation. ### Expected Behaviors - Default `backlog-triage` invocation is **advisory** — it produces a markdown report and never mutates GitHub state. Mutation requires `--apply`. -- Alignment Check maps every open Issue to ≥1 Objective OR surfaces it as an orphan in the report — no silent drops. +- `triage-apply` reads anchors only from `## Apply Checklist`; an anchor outside it is rejected before any write with a message naming the line. +- When a charter exists, the report surfaces material alignment gaps (orphan work, neglected Objectives, contradictions) with evidence; when none exists it says alignment was skipped. No mandatory per-issue table and no empty headings. - Decision Review uses charter, capabilities, system map, active sprint context, and triage signals as bounded evidence, then emits non-mutating recommendations. ### Hard Constraints @@ -142,3 +146,4 @@ Mutation: [`spec/README.md`](README.md) § Mutation. | 2026-05-22 | Alignment Check is prompt-driven inside `backlog-triage`, not a new `triage-*.js` | Issue → Objective mapping is semantic, unlike the deterministic relate/stale scripts | — | | 2026-05-31 | Decision Review is prompt-driven and report-only inside `backlog-triage` | Final backlog recommendations need semantic spec evidence; `triage-apply.js` should remain limited to explicit issue mutations | — | | 2026-09-17 | The collect/relate/stale/report scripts are deleted after a fixture-backed real-execution A/B showed the scripts-less report equivalent on both models (epic #440, #433; artifacts docs/conformance/2026-09-17-cc/433/) | scripts wrapped gh and recomputed judgments the session makes; only triage-apply passes the keep test | 2026-05-22/31 script-vs-prompt split | +| 2026-09-27 | One approval surface (#494, epic #504; gate: 2026-09-27 [user approval relayed through gateway](https://github.com/sungjunlee/dev-backlog/issues/494#issuecomment-5854919334)): `## Apply Checklist` is the only fixed heading and the only parsed section; alignment is useful evidence (material gaps, or "skipped" without a charter) rather than a per-issue table; empty headings are not required | eight mandatory sections produced empty headings and let anchors outside the checklist reach the writer; O4 needs detectable drift, not a table row per issue | the "fixed eight-section shape" In-scope clause; the "maps every open Issue to ≥1 Objective" Expected Behavior | diff --git a/spec/charter.md b/spec/charter.md index 6b6d8a0..b3da04e 100644 --- a/spec/charter.md +++ b/spec/charter.md @@ -1,6 +1,6 @@ --- -last_amended: 2026-09-18 -revision: 20 +last_amended: 2026-09-27 +revision: 21 --- # dev-backlog Charter @@ -25,12 +25,12 @@ This repo is a personal toolkit, not an externally adopted product. Keep the hot path small enough to start again. ## Non-Goals -- Tracker abstraction in code — the task authority is one `.dev-backlog/.tracker` line the session reads; scripts stay authority-neutral (`#N` refs, sprint files) and there is no adapter layer, port, export, or per-tracker script. Supported authorities are rows of the SKILL.md Task Authority table (GitHub Issues by default; Backlog.md CLI; GitLab), each a documented set of CLI verbs plus one allow-list entry, not an adapter; a new row needs a **measured consumer** (a maintainer-run repo that pins it and uses it), with one standing exception: the GitLab row is carried unmeasured because it costs one table row and one allow-list entry, no adapter code, and it is dropped at the next reassess if no repo has pinned it. The `files` adapter, the frozen adapter ports, and the diagnostic export stay parked at tag `v0.11.0`. No multi-master sync, no SaaS connectors (Jira, Linear, Notion). -- A database, server, or background daemon — Markdown + bash + node built-ins only; no mystery state. +- Tracker abstraction in code — the task authority is one `.dev-backlog/.tracker` line the session reads; scripts stay authority-neutral (`#N` refs, sprint files) and there is no adapter layer, port, export, or per-tracker script. Supported authorities are rows of the SKILL.md Task Authority table, each a documented set of CLI verbs, not an adapter; no script keeps an allow-list of authority names. Each row is `measured` (a **measured consumer** — a maintainer-run repo that pins it and uses it — or a recorded run that verified each verb against an authorized target) or `documented, unverified` (listed with its verbs, no claim of support): GitHub Issues (default) and the Backlog.md CLI are `measured`; GitLab is `documented, unverified`, kept at the 2026-09-27 reassess because it costs one table row and no code. A new row enters only as `measured`: Gitea (`tea`) is not a row until its verbs are verified against an authorized target. The `files` adapter, the frozen adapter ports, and the diagnostic export stay parked at tag `v0.11.0`. No multi-master sync, no SaaS connectors (Jira, Linear, Notion). +- A database, server, or background daemon — Markdown + Node built-ins only; no mystery state. - Silent sync or dual-write state — every authority mutation is explicit; mirrors, Projects boards, indexes, and summaries are projections and never become independent write targets or fallback authorities. - A lifecycle-owning workflow engine or a knowledge base — the spec axis is a yardstick and `_context.md` holds rediscovery-prone HOW-knowledge; neither is a doc store. - Required ecosystem integrations — GitHub Projects stays optional; delegation to another agent is the session's choice, not a dev-backlog integration. No compiled project-memory / retrieval layer (#350 no-go). -- Wrapping the authority CLI — a script survives only as a guard on shared or irreversible state, a deterministic check a session cannot cheaply redo, or a wire contract another surface consumes (today only the `sprint-state` JSON, the fresh-session recovery rail behind `next.sh --json` / `status.sh --json`). Task reading, spec resolution, milestone seeding, and issue listing are calls to the declared authority's CLI (`gh` by default) that the session makes itself. +- Wrapping the authority CLI — a script survives only as a guard on shared or irreversible state, a deterministic check a session cannot cheaply redo, or the fresh-session recovery rail (the `sprint-state` JSON, versioned by `schema_version`, with no compatibility promise to other consumers). Task reading, spec resolution, milestone seeding, milestone closing, and issue listing are calls to the declared authority's CLI (`gh` by default) that the session makes itself. ## Objectives - O10 — The declared task authority (GitHub Issues by default) is the standalone task-definition and lifecycle authority; simple Issue → PR work is sprint-free, while complex work preserves continuity in one admitted sprint per track without task mirrors, dual writes, or required ecosystem integrations. @@ -55,3 +55,4 @@ Retired IDs (never reuse): O3, O5–O9. Full texts: git | 2026-09-18 | Task authority generalized without code (rev 20, epic #472; gate 2026-09-18 "권장하는 대로 계속 진행"): one `.dev-backlog/.tracker` line read by the session (absent = `github`; `backlog` with `files` as the legacy spelling; `gitlab`), a three-verb table in SKILL.md replaces the `gh` rails, +56 script lines read the line (allow-list `github`, `backlog`/`files`, `gitlab`; anything else fails loud), make the JSON `tracker` field honest, and keep `--close-milestone` GitHub-only. The parked `files` adapter code stays parked; `backlog` is measured (maintainer repos with the Backlog.md CLI and no GitHub remote), `gitlab` is a documented row until a repo pins it | GitHub stays priority 0, but GitHub-less use via Backlog.md and self-hosted GitLab are real maintainer needs; the coupling that #440 left was prose plus one JSON field, so generality costs a table, not an adapter | rev-19 "Tracker generality" Non-Goal and the 2026-09-17 G1 row's prose (its code parking stands) | | 2026-09-19 | Clarification of the 2026-09-18 row: "without code" means without an adapter — the wave added 56 authority-neutral script lines | the row shipped with v0.15.0 and stays; the wording was wrong | wording of the 2026-09-18 row | | 2026-09-19 | The relay skill series is deprecated (maintainer decision): dev-backlog names no delegate, the `[run:id]` Plan pointer and the JSON `run_id` field are deleted, and the `sprint-state` JSON stays as the fresh-session recovery rail rather than a consumer contract. `component:` naming a capability heading is a plain convention | the only consumer of the JSON and the only writer of `[run:]` pointers was relay; a rail with no reader is not a wire contract | "Relay optional" in Non-Goals; "read by dev-relay" as the JSON's keep-test justification | +| 2026-09-27 | Lean contract (rev 21, #494, epic #504; gate: 2026-09-27 [user approval relayed through gateway](https://github.com/sungjunlee/dev-backlog/issues/494#issuecomment-5854919334)): authority rows are `measured` or `documented, unverified` and scripts keep no authority allow-list; GitLab stays `documented, unverified` (no claim of support) and Gitea (`tea`) enters only after verification against an authorized target (#503); the runtime is Markdown + Node built-ins; milestone closing is a session CLI call like milestone seeding, so `triage-apply` is the only scripted authority writer; the recovery rail is the `sprint-state` JSON with no compatibility promise | the allow-list and the bash runtime guard nothing the session does not already stop on; a close flag that wraps `gh` is the wrapping this Non-Goal forbids; this plan is the reassess the rev-20 GitLab exception named, and an honest `documented, unverified` label costs one row | rev-20 "one allow-list entry" row wording and the GitLab "dropped at the next reassess" exception; "Markdown + bash + node built-ins"; the `next.sh --json` / `status.sh --json` pointer and the "wire contract another surface consumes" keep-test clause | diff --git a/spec/system-map.md b/spec/system-map.md index 9ea2dfd..50d7e06 100644 --- a/spec/system-map.md +++ b/spec/system-map.md @@ -2,7 +2,7 @@ ## System Shape -dev-backlog is a skill suite plus a few deterministic Node/Bash helpers. One declared task authority — GitHub Issues by default, the Backlog.md CLI or GitLab by a one-line `.dev-backlog/.tracker` — is the canonical task-definition and lifecycle authority, read and written by the session through that authority's CLI (the only scripted writer is `triage-apply`, GitHub-only, under its human gate). No task mirror, no export. A sprint file exists only when execution needs continuity beyond one Issue and its PR. +dev-backlog is a skill suite plus a few deterministic Node helpers. One declared task authority — GitHub Issues by default, the Backlog.md CLI or GitLab by a one-line `.dev-backlog/.tracker` — is the canonical task-definition and lifecycle authority, read and written by the session through that authority's CLI (the only scripted writer is `triage-apply`, GitHub-only, under its human gate). No task mirror, no export. A sprint file exists only when execution needs continuity beyond one Issue and its PR. ```text Task authority: GitHub Issue by default (spec + lifecycle + native planning fields) @@ -35,15 +35,15 @@ Retrieval/memory is not a product surface (#350 no-go). handoff, cross-Issue/session context, or concurrent-track coordination. 3. **Execute**: Issue AC and lifecycle stay on the task authority; an admitted sprint carries Plan, Running Context, and Progress. -4. **Complete**: merge the PR, close the Issue; close an admitted sprint only when every Plan item is `[x]` or the rest are struck or carried with a Progress entry (`sprint-close.sh` only warns). +4. **Complete**: merge the PR, close the Issue; close an admitted sprint only when every Plan item is `[x]` or the rest are struck or carried with a Progress entry; before the close the session reports what remains and how each item is disposed, then edits the sprint frontmatter and confirms the sprint no longer appears among active tracks; no lifecycle script performs the close. 5. **Groom** (optional): triage is advisory until `--apply`. ## Storage And External Systems -- Task authority — selected by `.dev-backlog/.tracker` when present, `github` when the file is absent: GitHub Issues (`gh`), the Backlog.md CLI, or GitLab (`glab`); sole task authority, read by the session, never by a script. +- Task authority — selected by `.dev-backlog/.tracker` when present, `github` when the file is absent: GitHub Issues (`gh`), the Backlog.md CLI, or GitLab (`glab`, documented, unverified); sole task authority, read by the session, never by a script. - `.dev-backlog/sprints/` — admitted execution state; completed sprints are disposable; delete them freely, the record is the Issue/epic/PR. - `spec/*` — durable direction when present. -- `.dev-backlog/.tracker` — one line (`github` when absent, `backlog` or its legacy spelling `files`, `gitlab`); no adapter code: the `files` adapter stays parked at `v0.11.0`, the GitLab adapter at `a8ddb7d`. +- `.dev-backlog/.tracker` — one line naming a Task Authority table row (`github`, `backlog` or its legacy spelling `files`, `gitlab`); only an absent file defaults to `github`, and a present file that is empty, blank, unreadable, or names no row stops the session; scripts keep no allow-list of authority names; `triage-apply` runs only when the file is absent or names `github`, and otherwise stops before any `gh` call or apply-log write; no adapter code: the `files` adapter stays parked at `v0.11.0`, the GitLab adapter at `a8ddb7d`. ## Project-Wide Invariants From 43b29025a88e084a5820d9b135f55075451a1ceb Mon Sep 17 00:00:00 2001 From: SJ Lee Date: Tue, 29 Sep 2026 21:10:10 +0900 Subject: [PATCH 03/13] fix: reject a second accepted closing action for the same issue (#505 review) Two accepted `close` anchors for one issue that differ only in reason wording, a `close` plus a `close-duplicate`, or two same-target `close-duplicate` anchors survived dedup and produced duplicate comment and close writes. Preflight now fails them with both report line numbers before any GitHub call or apply-log write; different duplicate targets keep their existing conflict message. CHANGELOG Unreleased gains the #494 and #495 lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X9gZYk7FcYSLAZBBe7JXvZ --- CHANGELOG.md | 5 ++++ skills/backlog-triage/references/apply.md | 2 +- skills/backlog-triage/scripts/triage-apply.js | 11 +++++++++ tests/backlog-triage/triage-apply.test.js | 24 +++++++++++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ec7c3b..30eff08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ Each entry links the GitHub issue (the canonical spec) and the merge PR (the shi ### Changed - Completed sprint files are disposable (maintainer decision 2026-09-20): the "immutable history / permanent record" wording is withdrawn in `spec/capabilities.md` (decision row), `spec/system-map.md`, SKILL.md, and `_context.md`; the repo's own 36 completed sprint files are deleted (git history keeps them); `status: completed` is still never flipped back. BREAKING for nothing in code. +- Lean contract ratified, charter rev 21 (maintainer approval on #494): `spec/charter.md`, `spec/capabilities.md`, and `spec/system-map.md` state the target behavior that epic #504 implements (one approval surface for triage, runtime retirement, native tools); the runtime is unchanged by this entry. Refs [#494](https://github.com/sungjunlee/dev-backlog/issues/494), PR [#505](https://github.com/sungjunlee/dev-backlog/pull/505). + +### Fixed + +- `triage-apply.js` validates every accepted action before any GitHub call or apply-log write (dry-run included): required arguments, `set-priority` values, `close-duplicate` targets, canonical `milestone=` with the legacy `name=` fallback, conflicting assignments, and a second accepted closing action for the same issue fail with report line numbers; apply refuses a non-GitHub or unreadable task authority. Closes [#495](https://github.com/sungjunlee/dev-backlog/issues/495), PR [#505](https://github.com/sungjunlee/dev-backlog/pull/505). ## [0.16.0] — 2026-09-20 diff --git a/skills/backlog-triage/references/apply.md b/skills/backlog-triage/references/apply.md index f69f93a..e063f9a 100644 --- a/skills/backlog-triage/references/apply.md +++ b/skills/backlog-triage/references/apply.md @@ -101,7 +101,7 @@ Multiple actions may target the same issue when the actions are distinct. Exampl That is valid. These are separate proposals and therefore separate anchor+checkbox pairs. -For `assign-milestone`, `milestone=` is canonical. Apply accepts legacy `name=` only when `milestone=` is absent; supplying both with different values is invalid. Accepted known actions are validated together before any GitHub call or apply-log write, including in dry-run. A report with invalid accepted arguments or conflicting priority, milestone, or duplicate target assignments fails with report line numbers. Unchecked actions remain inert and unknown verbs keep their skip-and-log behavior. In apply mode, an absent `.dev-backlog/.tracker` or a first line of `github` is required; any other or unreadable authority stops before a write. +For `assign-milestone`, `milestone=` is canonical. Apply accepts legacy `name=` only when `milestone=` is absent; supplying both with different values is invalid. Accepted known actions are validated together before any GitHub call or apply-log write, including in dry-run. A report with invalid accepted arguments or conflicting priority, milestone, or duplicate target assignments, or more than one accepted closing action (`close` or `close-duplicate`) for the same issue, fails with report line numbers. Unchecked actions remain inert and unknown verbs keep their skip-and-log behavior. In apply mode, an absent `.dev-backlog/.tracker` or a first line of `github` is required; any other or unreadable authority stops before a write. The invalid case is duplicating the exact same action for the same issue without new meaning, for example two separate `close` anchors for `#42` that differ only by wording. diff --git a/skills/backlog-triage/scripts/triage-apply.js b/skills/backlog-triage/scripts/triage-apply.js index ff64787..20339e9 100644 --- a/skills/backlog-triage/scripts/triage-apply.js +++ b/skills/backlog-triage/scripts/triage-apply.js @@ -236,6 +236,7 @@ function dedupActions(anchors) { function validateAcceptedActions(actions) { const errors = []; const fieldsByIssue = new Map(); + const closesByIssue = new Map(); for (const action of actions) { if (!action.checked || !action.knownVerb) continue; @@ -285,6 +286,16 @@ function validateAcceptedActions(actions) { } } + if (action.verb === "close" || action.verb === "close-duplicate") { + const previousClose = closesByIssue.get(action.issueNumber); + const bothDuplicates = previousClose?.verb === "close-duplicate" && action.verb === "close-duplicate"; + if (!previousClose) { + closesByIssue.set(action.issueNumber, { verb: action.verb, target: args.target, line }); + } else if (!(bothDuplicates && previousClose.target !== args.target)) { + errors.push(`${location}: more than one accepted closing action for #${action.issueNumber}; line ${previousClose.line} already closes it.`); + } + } + if (!field || !value) continue; if (!fieldsByIssue.has(action.issueNumber)) fieldsByIssue.set(action.issueNumber, new Map()); const fields = fieldsByIssue.get(action.issueNumber); diff --git a/tests/backlog-triage/triage-apply.test.js b/tests/backlog-triage/triage-apply.test.js index ec2ee6f..3ae7f62 100644 --- a/tests/backlog-triage/triage-apply.test.js +++ b/tests/backlog-triage/triage-apply.test.js @@ -492,6 +492,30 @@ describe("execute", () => { } }); + it("rejects a second accepted closing action for the same issue before any write", () => { + const pairs = [ + ['', ''], + ['', ''], + ['', ''], + ]; + const fixture = writeGhFixture(tempDir); + const logPath = resolveLogPath("2026-04-18", repoRoot); + for (const [first, second] of pairs) { + fs.writeFileSync(reportPath, acceptedReport(first, second)); + for (const flags of [[], ["--apply", "--yes"]]) { + const result = spawnSync(process.execPath, [path.join(SKILL_SCRIPTS, "triage-apply.js"), reportPath, ...flags], { + cwd: repoRoot, + encoding: "utf-8", + env: fixture.env, + }); + assert.equal(result.status, 1, second); + assert.match(result.stderr, /line 7: .*more than one accepted closing action for #42; line 4 already closes it/, second); + assert.equal(fixture.calls().length, 0, second); + assert.equal(fs.existsSync(logPath), false, second); + } + } + }); + it("leaves unchecked invalid anchors inert and unknown verbs skipped", () => { fs.writeFileSync(reportPath, [ "---", "generated: 2026-04-18", "---", From ef99bb89abb8c5ddd12bf54f98fcd2fc3b16d2ad Mon Sep 17 00:00:00 2001 From: SJ Lee Date: Tue, 29 Sep 2026 21:47:17 +0900 Subject: [PATCH 04/13] =?UTF-8?q?docs:=20record=20that=20spec=20rev=2021?= =?UTF-8?q?=20leads=20the=20runtime=20until=20#496=E2=80=93#502=20land=20(?= =?UTF-8?q?#505=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ratified spec states the epic #504 target (checklist-only triage anchors, conservative cross-axis scope checks, a waiting next batch behind an earlier `[~]`, session-edited sprint close). The approved spec text stays verbatim; `_context.md` says to execute from SKILL.md and the scripts, grade future work against spec, and retire the line when the last issue merges. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X9gZYk7FcYSLAZBBe7JXvZ --- .dev-backlog/sprints/_context.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.dev-backlog/sprints/_context.md b/.dev-backlog/sprints/_context.md index 81f34ff..74c6469 100644 --- a/.dev-backlog/sprints/_context.md +++ b/.dev-backlog/sprints/_context.md @@ -7,6 +7,7 @@ - Plan refs are `#N` and nothing else. The one parser is `parseIssueRef` / `parsePlanCheckbox` in `scripts/lib.js`; `scripts/lib.sh` mirrors it as `RE_ISSUE_REF` for the shell rails. - Active tracks partition by scope through the ONE `scopesOverlap()` in `scripts/lib.js` — never re-implement it. `component:` equality or explicit `scope:` globs decide; disjoint tracks coexist as a portfolio and overlap fails loud. Single-track text output is the compatibility surface (the G4 anchor in the smoke test); never snapshot `--json`, which is schema-versioned instead. - Scripts own exactly four surfaces: the sprint-file contract, fail-loud shared-state guards (track overlap, unmoored `[~]`, `status: completed`), the `sprint-state` JSON recovery rail, and the `triage-apply` human gate. The keep test for any surface is (1) guards shared or irreversible state, (2) a deterministic check the model cannot cheaply redo, (3) a wire contract another tool consumes; failing all three means delete, not rewrite, and a reviewer defending a deleted doc must show the consumer reading it. A surface that stays keeps its CLI contract; interfaces change only when an issue explicitly changes them. Completed sprint files are disposable and may be deleted at any time: the record of what was done and how is the Issue, the epic, and the PR, and `_context.md` is the only thing kept after a close. +- Spec rev 21 (#494) leads the runtime: `spec/*` states the epic #504 target (checklist-only triage anchors, conservative cross-axis scope checks, a waiting next batch behind an earlier `[~]`, session-edited sprint close with no close script), while SKILL.md and the scripts describe current behavior until issues #496–#502 land. Execute from SKILL.md and the scripts; grade future work against `spec/*`; retire this line when the last of those issues merges. ## Conventions From 32e0dc252bb84ac982b3e4380f08ff01723a57fb Mon Sep 17 00:00:00 2001 From: SJ Lee Date: Tue, 29 Sep 2026 21:52:34 +0900 Subject: [PATCH 05/13] fix: reject accepted anchors whose issue number is not a positive integer (#505 review) `#0` (or a number beyond the safe-integer range) passed preflight, so an earlier valid action could reach GitHub and the apply log before the bad one failed. Preflight now names the line and stops before any write; the aggregate test places the bad anchor after a valid revisit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X9gZYk7FcYSLAZBBe7JXvZ --- CHANGELOG.md | 2 +- skills/backlog-triage/references/apply.md | 2 +- skills/backlog-triage/scripts/triage-apply.js | 3 +++ tests/backlog-triage/triage-apply.test.js | 8 ++++++-- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30eff08..8bf7244 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Each entry links the GitHub issue (the canonical spec) and the merge PR (the shi ### Fixed -- `triage-apply.js` validates every accepted action before any GitHub call or apply-log write (dry-run included): required arguments, `set-priority` values, `close-duplicate` targets, canonical `milestone=` with the legacy `name=` fallback, conflicting assignments, and a second accepted closing action for the same issue fail with report line numbers; apply refuses a non-GitHub or unreadable task authority. Closes [#495](https://github.com/sungjunlee/dev-backlog/issues/495), PR [#505](https://github.com/sungjunlee/dev-backlog/pull/505). +- `triage-apply.js` validates every accepted action before any GitHub call or apply-log write (dry-run included): positive issue numbers, required arguments, `set-priority` values, `close-duplicate` targets, canonical `milestone=` with the legacy `name=` fallback, conflicting assignments, and a second accepted closing action for the same issue fail with report line numbers; apply refuses a non-GitHub or unreadable task authority. Closes [#495](https://github.com/sungjunlee/dev-backlog/issues/495), PR [#505](https://github.com/sungjunlee/dev-backlog/pull/505). ## [0.16.0] — 2026-09-20 diff --git a/skills/backlog-triage/references/apply.md b/skills/backlog-triage/references/apply.md index e063f9a..7048883 100644 --- a/skills/backlog-triage/references/apply.md +++ b/skills/backlog-triage/references/apply.md @@ -101,7 +101,7 @@ Multiple actions may target the same issue when the actions are distinct. Exampl That is valid. These are separate proposals and therefore separate anchor+checkbox pairs. -For `assign-milestone`, `milestone=` is canonical. Apply accepts legacy `name=` only when `milestone=` is absent; supplying both with different values is invalid. Accepted known actions are validated together before any GitHub call or apply-log write, including in dry-run. A report with invalid accepted arguments or conflicting priority, milestone, or duplicate target assignments, or more than one accepted closing action (`close` or `close-duplicate`) for the same issue, fails with report line numbers. Unchecked actions remain inert and unknown verbs keep their skip-and-log behavior. In apply mode, an absent `.dev-backlog/.tracker` or a first line of `github` is required; any other or unreadable authority stops before a write. +For `assign-milestone`, `milestone=` is canonical. Apply accepts legacy `name=` only when `milestone=` is absent; supplying both with different values is invalid. Accepted known actions are validated together before any GitHub call or apply-log write, including in dry-run. A report with an accepted anchor whose issue number is not a positive integer, invalid accepted arguments, or conflicting priority, milestone, or duplicate target assignments, or more than one accepted closing action (`close` or `close-duplicate`) for the same issue, fails with report line numbers. Unchecked actions remain inert and unknown verbs keep their skip-and-log behavior. In apply mode, an absent `.dev-backlog/.tracker` or a first line of `github` is required; any other or unreadable authority stops before a write. The invalid case is duplicating the exact same action for the same issue without new meaning, for example two separate `close` anchors for `#42` that differ only by wording. diff --git a/skills/backlog-triage/scripts/triage-apply.js b/skills/backlog-triage/scripts/triage-apply.js index 20339e9..fafb020 100644 --- a/skills/backlog-triage/scripts/triage-apply.js +++ b/skills/backlog-triage/scripts/triage-apply.js @@ -245,6 +245,9 @@ function validateAcceptedActions(actions) { || action.occurrences[0].anchorLine; const location = `line ${line}: triage:${action.verb} #${action.issueNumber}`; const args = action.normalizedArgs; + if (!Number.isSafeInteger(action.issueNumber) || action.issueNumber < 1) { + errors.push(`${location}: issue number must be a positive integer.`); + } const required = { close: ["reason"], revisit: ["reason"], diff --git a/tests/backlog-triage/triage-apply.test.js b/tests/backlog-triage/triage-apply.test.js index 3ae7f62..a8eb139 100644 --- a/tests/backlog-triage/triage-apply.test.js +++ b/tests/backlog-triage/triage-apply.test.js @@ -443,6 +443,8 @@ describe("execute", () => { ['', /target must not be the issue itself/], ['', /milestone is required/], ['', /different values/], + ['', /issue number must be a positive integer/], + ['', /issue number must be a positive integer/], ]; const fixture = writeGhFixture(tempDir); const logPath = resolveLogPath("2026-04-18", repoRoot); @@ -470,7 +472,8 @@ describe("execute", () => { '', '', '', - '' + '', + '' )); const fixture = writeGhFixture(tempDir); const logPath = resolveLogPath("2026-04-18", repoRoot); @@ -482,11 +485,12 @@ describe("execute", () => { env: fixture.env, }); assert.equal(result.status, 1); - for (const line of [10, 16, 22, 25]) assert.match(result.stderr, new RegExp(`line ${line}:`)); + for (const line of [10, 16, 22, 25, 28]) assert.match(result.stderr, new RegExp(`line ${line}:`)); assert.match(result.stderr, /conflicting priority/); assert.match(result.stderr, /conflicting milestone/); assert.match(result.stderr, /conflicting duplicate target/); assert.match(result.stderr, /reason is required/); + assert.match(result.stderr, /line 28: .*issue number must be a positive integer/); assert.equal(fixture.calls().length, 0); assert.equal(fs.readFileSync(logPath, "utf-8"), "existing log\n"); } From bdf945dbd175e3504b04d791de0ff10c813c9186 Mon Sep 17 00:00:00 2001 From: SJ Lee Date: Tue, 29 Sep 2026 22:16:12 +0900 Subject: [PATCH 06/13] fix: reject NUL characters in accepted triage arguments before any write (#505 review) A NUL in a reason or milestone value passed preflight and then failed inside execFileSync after earlier actions had been applied. Preflight now names the line and key; tests cover a single action and a NUL value placed after valid actions (zero gh calls, log unchanged). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X9gZYk7FcYSLAZBBe7JXvZ --- skills/backlog-triage/references/apply.md | 2 +- skills/backlog-triage/scripts/triage-apply.js | 5 +++++ tests/backlog-triage/triage-apply.test.js | 7 +++++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/skills/backlog-triage/references/apply.md b/skills/backlog-triage/references/apply.md index 7048883..e337ae3 100644 --- a/skills/backlog-triage/references/apply.md +++ b/skills/backlog-triage/references/apply.md @@ -101,7 +101,7 @@ Multiple actions may target the same issue when the actions are distinct. Exampl That is valid. These are separate proposals and therefore separate anchor+checkbox pairs. -For `assign-milestone`, `milestone=` is canonical. Apply accepts legacy `name=` only when `milestone=` is absent; supplying both with different values is invalid. Accepted known actions are validated together before any GitHub call or apply-log write, including in dry-run. A report with an accepted anchor whose issue number is not a positive integer, invalid accepted arguments, or conflicting priority, milestone, or duplicate target assignments, or more than one accepted closing action (`close` or `close-duplicate`) for the same issue, fails with report line numbers. Unchecked actions remain inert and unknown verbs keep their skip-and-log behavior. In apply mode, an absent `.dev-backlog/.tracker` or a first line of `github` is required; any other or unreadable authority stops before a write. +For `assign-milestone`, `milestone=` is canonical. Apply accepts legacy `name=` only when `milestone=` is absent; supplying both with different values is invalid. Accepted known actions are validated together before any GitHub call or apply-log write, including in dry-run. A report with an accepted anchor whose issue number is not a positive integer, invalid accepted arguments (including a NUL character in any value), or conflicting priority, milestone, or duplicate target assignments, or more than one accepted closing action (`close` or `close-duplicate`) for the same issue, fails with report line numbers. Unchecked actions remain inert and unknown verbs keep their skip-and-log behavior. In apply mode, an absent `.dev-backlog/.tracker` or a first line of `github` is required; any other or unreadable authority stops before a write. The invalid case is duplicating the exact same action for the same issue without new meaning, for example two separate `close` anchors for `#42` that differ only by wording. diff --git a/skills/backlog-triage/scripts/triage-apply.js b/skills/backlog-triage/scripts/triage-apply.js index fafb020..cee8810 100644 --- a/skills/backlog-triage/scripts/triage-apply.js +++ b/skills/backlog-triage/scripts/triage-apply.js @@ -248,6 +248,11 @@ function validateAcceptedActions(actions) { if (!Number.isSafeInteger(action.issueNumber) || action.issueNumber < 1) { errors.push(`${location}: issue number must be a positive integer.`); } + for (const [key, argValue] of Object.entries(args)) { + if (String(argValue).includes("\u0000")) { + errors.push(`${location}: ${key} must not contain a NUL character.`); + } + } const required = { close: ["reason"], revisit: ["reason"], diff --git a/tests/backlog-triage/triage-apply.test.js b/tests/backlog-triage/triage-apply.test.js index a8eb139..63a8bf8 100644 --- a/tests/backlog-triage/triage-apply.test.js +++ b/tests/backlog-triage/triage-apply.test.js @@ -444,6 +444,7 @@ describe("execute", () => { ['', /milestone is required/], ['', /different values/], ['', /issue number must be a positive integer/], + ['', /reason must not contain a NUL character/], ['', /issue number must be a positive integer/], ]; const fixture = writeGhFixture(tempDir); @@ -473,7 +474,8 @@ describe("execute", () => { '', '', '', - '' + '', + '' )); const fixture = writeGhFixture(tempDir); const logPath = resolveLogPath("2026-04-18", repoRoot); @@ -485,12 +487,13 @@ describe("execute", () => { env: fixture.env, }); assert.equal(result.status, 1); - for (const line of [10, 16, 22, 25, 28]) assert.match(result.stderr, new RegExp(`line ${line}:`)); + for (const line of [10, 16, 22, 25, 28, 31]) assert.match(result.stderr, new RegExp(`line ${line}:`)); assert.match(result.stderr, /conflicting priority/); assert.match(result.stderr, /conflicting milestone/); assert.match(result.stderr, /conflicting duplicate target/); assert.match(result.stderr, /reason is required/); assert.match(result.stderr, /line 28: .*issue number must be a positive integer/); + assert.match(result.stderr, /line 31: .*milestone must not contain a NUL character/); assert.equal(fixture.calls().length, 0); assert.equal(fs.readFileSync(logPath, "utf-8"), "existing log\n"); } From 177a551db1dfe751926e06f8b5280f0beb74a9b4 Mon Sep 17 00:00:00 2001 From: SJ Lee Date: Tue, 29 Sep 2026 22:25:58 +0900 Subject: [PATCH 07/13] fix: cap each accepted triage argument at 60,000 UTF-8 bytes before any write (#505 review) A 1 MiB reason or milestone passed preflight and failed with E2BIG at spawn time after earlier actions had been applied. The cap sits below GitHub's 65,536-character comment limit and Linux's 128 KiB per-argument limit (a close-duplicate comment joins two capped values). This closes the "passes preflight, fails deterministically at spawn" class; network, missing-target, and GitHub-side validation failures stay with the apply log's resume. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X9gZYk7FcYSLAZBBe7JXvZ --- skills/backlog-triage/references/apply.md | 2 +- skills/backlog-triage/scripts/triage-apply.js | 7 +++++++ tests/backlog-triage/triage-apply.test.js | 6 ++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/skills/backlog-triage/references/apply.md b/skills/backlog-triage/references/apply.md index e337ae3..2be008c 100644 --- a/skills/backlog-triage/references/apply.md +++ b/skills/backlog-triage/references/apply.md @@ -101,7 +101,7 @@ Multiple actions may target the same issue when the actions are distinct. Exampl That is valid. These are separate proposals and therefore separate anchor+checkbox pairs. -For `assign-milestone`, `milestone=` is canonical. Apply accepts legacy `name=` only when `milestone=` is absent; supplying both with different values is invalid. Accepted known actions are validated together before any GitHub call or apply-log write, including in dry-run. A report with an accepted anchor whose issue number is not a positive integer, invalid accepted arguments (including a NUL character in any value), or conflicting priority, milestone, or duplicate target assignments, or more than one accepted closing action (`close` or `close-duplicate`) for the same issue, fails with report line numbers. Unchecked actions remain inert and unknown verbs keep their skip-and-log behavior. In apply mode, an absent `.dev-backlog/.tracker` or a first line of `github` is required; any other or unreadable authority stops before a write. +For `assign-milestone`, `milestone=` is canonical. Apply accepts legacy `name=` only when `milestone=` is absent; supplying both with different values is invalid. Accepted known actions are validated together before any GitHub call or apply-log write, including in dry-run. A report with an accepted anchor whose issue number is not a positive integer, invalid accepted arguments (including a NUL character or more than 60,000 UTF-8 bytes in any value), or conflicting priority, milestone, or duplicate target assignments, or more than one accepted closing action (`close` or `close-duplicate`) for the same issue, fails with report line numbers. Unchecked actions remain inert and unknown verbs keep their skip-and-log behavior. In apply mode, an absent `.dev-backlog/.tracker` or a first line of `github` is required; any other or unreadable authority stops before a write. The invalid case is duplicating the exact same action for the same issue without new meaning, for example two separate `close` anchors for `#42` that differ only by wording. diff --git a/skills/backlog-triage/scripts/triage-apply.js b/skills/backlog-triage/scripts/triage-apply.js index cee8810..7c28257 100644 --- a/skills/backlog-triage/scripts/triage-apply.js +++ b/skills/backlog-triage/scripts/triage-apply.js @@ -233,6 +233,10 @@ function dedupActions(anchors) { return [...deduped.values()]; } +// Below GitHub's 65,536-character comment limit and Linux's 128 KiB per-argument limit, +// so an accepted action cannot fail at spawn time after earlier writes. +const MAX_ARG_BYTES = 60000; + function validateAcceptedActions(actions) { const errors = []; const fieldsByIssue = new Map(); @@ -252,6 +256,9 @@ function validateAcceptedActions(actions) { if (String(argValue).includes("\u0000")) { errors.push(`${location}: ${key} must not contain a NUL character.`); } + if (Buffer.byteLength(String(argValue), "utf8") > MAX_ARG_BYTES) { + errors.push(`${location}: ${key} must be at most ${MAX_ARG_BYTES} bytes.`); + } } const required = { close: ["reason"], diff --git a/tests/backlog-triage/triage-apply.test.js b/tests/backlog-triage/triage-apply.test.js index 63a8bf8..94dc685 100644 --- a/tests/backlog-triage/triage-apply.test.js +++ b/tests/backlog-triage/triage-apply.test.js @@ -475,7 +475,8 @@ describe("execute", () => { '', '', '', - '' + '', + `` )); const fixture = writeGhFixture(tempDir); const logPath = resolveLogPath("2026-04-18", repoRoot); @@ -487,13 +488,14 @@ describe("execute", () => { env: fixture.env, }); assert.equal(result.status, 1); - for (const line of [10, 16, 22, 25, 28, 31]) assert.match(result.stderr, new RegExp(`line ${line}:`)); + for (const line of [10, 16, 22, 25, 28, 31, 34]) assert.match(result.stderr, new RegExp(`line ${line}:`)); assert.match(result.stderr, /conflicting priority/); assert.match(result.stderr, /conflicting milestone/); assert.match(result.stderr, /conflicting duplicate target/); assert.match(result.stderr, /reason is required/); assert.match(result.stderr, /line 28: .*issue number must be a positive integer/); assert.match(result.stderr, /line 31: .*milestone must not contain a NUL character/); + assert.match(result.stderr, /line 34: .*reason must be at most 60000 bytes/); assert.equal(fixture.calls().length, 0); assert.equal(fs.readFileSync(logPath, "utf-8"), "existing log\n"); } From 0f073e96f9869aac79205b197e2afeae37aa92bb Mon Sep 17 00:00:00 2001 From: SJ Lee Date: Tue, 29 Sep 2026 22:35:58 +0900 Subject: [PATCH 08/13] fix: strict anchor argument grammar and one assignment per field (#505 review) Codex connector review: `reason="a" reason="b"` kept the last value and trailing text was ignored, so a malformed accepted anchor could still write; and two same-value milestone or priority anchors (legacy `name=` alias, or a different reason) produced the same edit twice. Preflight now rejects a repeated key, unparsed argument text, and any second priority or milestone assignment for an issue. apply.md lists the preflight rules; the CHANGELOG line points there instead of enumerating them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X9gZYk7FcYSLAZBBe7JXvZ --- CHANGELOG.md | 2 +- skills/backlog-triage/references/apply.md | 10 ++++++- skills/backlog-triage/scripts/anchor.js | 28 +++++++++++++++++++ skills/backlog-triage/scripts/triage-apply.js | 11 ++++++++ tests/backlog-triage/triage-apply.test.js | 23 +++++++++++++++ 5 files changed, 72 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bf7244..cde62be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Each entry links the GitHub issue (the canonical spec) and the merge PR (the shi ### Fixed -- `triage-apply.js` validates every accepted action before any GitHub call or apply-log write (dry-run included): positive issue numbers, required arguments, `set-priority` values, `close-duplicate` targets, canonical `milestone=` with the legacy `name=` fallback, conflicting assignments, and a second accepted closing action for the same issue fail with report line numbers; apply refuses a non-GitHub or unreadable task authority. Closes [#495](https://github.com/sungjunlee/dev-backlog/issues/495), PR [#505](https://github.com/sungjunlee/dev-backlog/pull/505). +- `triage-apply.js` validates every accepted action before any GitHub call or apply-log write (dry-run included) — issue numbers, strict `key=value` argument grammar, required and bounded values, canonical `milestone=` with the legacy `name=` fallback, one priority / milestone / closing action per issue — and fails with report line numbers (rules in `skills/backlog-triage/references/apply.md`); apply refuses a non-GitHub or unreadable task authority. Closes [#495](https://github.com/sungjunlee/dev-backlog/issues/495), PR [#505](https://github.com/sungjunlee/dev-backlog/pull/505). ## [0.16.0] — 2026-09-20 diff --git a/skills/backlog-triage/references/apply.md b/skills/backlog-triage/references/apply.md index 2be008c..1a6c90d 100644 --- a/skills/backlog-triage/references/apply.md +++ b/skills/backlog-triage/references/apply.md @@ -101,7 +101,15 @@ Multiple actions may target the same issue when the actions are distinct. Exampl That is valid. These are separate proposals and therefore separate anchor+checkbox pairs. -For `assign-milestone`, `milestone=` is canonical. Apply accepts legacy `name=` only when `milestone=` is absent; supplying both with different values is invalid. Accepted known actions are validated together before any GitHub call or apply-log write, including in dry-run. A report with an accepted anchor whose issue number is not a positive integer, invalid accepted arguments (including a NUL character or more than 60,000 UTF-8 bytes in any value), or conflicting priority, milestone, or duplicate target assignments, or more than one accepted closing action (`close` or `close-duplicate`) for the same issue, fails with report line numbers. Unchecked actions remain inert and unknown verbs keep their skip-and-log behavior. In apply mode, an absent `.dev-backlog/.tracker` or a first line of `github` is required; any other or unreadable authority stops before a write. +For `assign-milestone`, `milestone=` is canonical. Apply accepts legacy `name=` only when `milestone=` is absent; supplying both with different values is invalid. Accepted known actions are validated together before any GitHub call or apply-log write, including in dry-run. Any of the following fails the whole report with its line numbers: + +- an issue number that is not a positive integer; +- argument text that is not whitespace-separated `key=value` / `key="value"` pairs, or a key given twice; +- a missing or blank required argument, an invalid `set-priority` value or `close-duplicate` target, or any value containing a NUL character or more than 60,000 UTF-8 bytes; +- two priority or two milestone assignments for the same issue, whether the values differ or repeat (legacy `name=` counts as `milestone=`), or two different duplicate targets; +- more than one accepted closing action (`close` or `close-duplicate`) for the same issue. + +Unchecked actions remain inert and unknown verbs keep their skip-and-log behavior. In apply mode, an absent `.dev-backlog/.tracker` or a first line of `github` is required; any other or unreadable authority stops before a write. The invalid case is duplicating the exact same action for the same issue without new meaning, for example two separate `close` anchors for `#42` that differ only by wording. diff --git a/skills/backlog-triage/scripts/anchor.js b/skills/backlog-triage/scripts/anchor.js index 09cea71..8ab8c66 100644 --- a/skills/backlog-triage/scripts/anchor.js +++ b/skills/backlog-triage/scripts/anchor.js @@ -14,6 +14,32 @@ function parseAnchorArgs(argText) { return args; } +// Strict grammar check for the args payload: whitespace-separated key=value or +// key="value" pairs, each key once, nothing left over. +function argSyntaxErrors(argText) { + const errors = []; + const source = typeof argText === "string" ? argText.trim() : ""; + const pair = /([\w-]+)=(?:"((?:\\"|[^"])*)"|([^\s"]+))(?=\s|$)/y; + const seen = new Set(); + let index = 0; + while (index < source.length) { + if (/\s/.test(source[index])) { + index += 1; + continue; + } + pair.lastIndex = index; + const match = pair.exec(source); + if (!match) { + errors.push(`unparsed argument text ${JSON.stringify(source.slice(index, index + 40))}`); + break; + } + if (seen.has(match[1])) errors.push(`${match[1]} is given more than once`); + seen.add(match[1]); + index = pair.lastIndex; + } + return errors; +} + function parseAnchor(line) { const match = String(line || "").match(ANCHOR_PATTERN); if (!match) return null; @@ -23,11 +49,13 @@ function parseAnchor(line) { issueNumber: Number(match[2]), argsText: match[3] || "", args: parseAnchorArgs(match[3] || ""), + argErrors: argSyntaxErrors(match[3] || ""), }; } module.exports = { ANCHOR_PATTERN, parseAnchorArgs, + argSyntaxErrors, parseAnchor, }; diff --git a/skills/backlog-triage/scripts/triage-apply.js b/skills/backlog-triage/scripts/triage-apply.js index 7c28257..36328f9 100644 --- a/skills/backlog-triage/scripts/triage-apply.js +++ b/skills/backlog-triage/scripts/triage-apply.js @@ -178,6 +178,7 @@ function parseReport(text) { key: `${anchor.verb}|${anchor.issueNumber}|${stableSerialize(normalizedArgs)}`, anchorLine: index + 1, checkboxLine: checkboxIndex + 1, + argErrors: anchor.argErrors, }; anchors.push(entry); @@ -214,6 +215,7 @@ function dedupActions(anchors) { anchorLine: anchor.anchorLine, checkboxLine: anchor.checkboxLine, checked: anchor.checked, + argErrors: anchor.argErrors, }, ], }); @@ -227,6 +229,7 @@ function dedupActions(anchors) { anchorLine: anchor.anchorLine, checkboxLine: anchor.checkboxLine, checked: anchor.checked, + argErrors: anchor.argErrors, }); } @@ -249,6 +252,12 @@ function validateAcceptedActions(actions) { || action.occurrences[0].anchorLine; const location = `line ${line}: triage:${action.verb} #${action.issueNumber}`; const args = action.normalizedArgs; + for (const occurrence of action.occurrences) { + if (!occurrence.checked) continue; + for (const message of occurrence.argErrors || []) { + errors.push(`line ${occurrence.anchorLine}: triage:${action.verb} #${action.issueNumber}: ${message}.`); + } + } if (!Number.isSafeInteger(action.issueNumber) || action.issueNumber < 1) { errors.push(`${location}: issue number must be a positive integer.`); } @@ -317,6 +326,8 @@ function validateAcceptedActions(actions) { const previous = fields.get(field); if (previous && previous.value !== value) { errors.push(`${location}: conflicting ${field} ${JSON.stringify(value)}; line ${previous.line} assigns ${JSON.stringify(previous.value)}.`); + } else if (previous && field !== "duplicate target") { + errors.push(`${location}: ${field} ${JSON.stringify(value)} is already assigned on line ${previous.line}; keep one anchor.`); } else if (!previous) { fields.set(field, { value, line }); } diff --git a/tests/backlog-triage/triage-apply.test.js b/tests/backlog-triage/triage-apply.test.js index 94dc685..c3f5cfa 100644 --- a/tests/backlog-triage/triage-apply.test.js +++ b/tests/backlog-triage/triage-apply.test.js @@ -444,6 +444,8 @@ describe("execute", () => { ['', /milestone is required/], ['', /different values/], ['', /issue number must be a positive integer/], + ['', /reason is given more than once/], + ['', /unparsed argument text "garbage"/], ['', /reason must not contain a NUL character/], ['', /issue number must be a positive integer/], ]; @@ -525,6 +527,27 @@ describe("execute", () => { } }); + it("rejects a repeated same-value assignment, including the legacy milestone alias", () => { + const pairs = [ + ['', '', /milestone "A" is already assigned on line 4/], + ['', '', /priority "high" is already assigned on line 4/], + ]; + const fixture = writeGhFixture(tempDir); + const logPath = resolveLogPath("2026-04-18", repoRoot); + for (const [first, second, expected] of pairs) { + fs.writeFileSync(reportPath, acceptedReport(first, second)); + const result = spawnSync(process.execPath, [path.join(SKILL_SCRIPTS, "triage-apply.js"), reportPath, "--apply", "--yes"], { + cwd: repoRoot, + encoding: "utf-8", + env: fixture.env, + }); + assert.equal(result.status, 1, second); + assert.match(result.stderr, new RegExp(`line 7: .*${expected.source}`), second); + assert.equal(fixture.calls().length, 0, second); + assert.equal(fs.existsSync(logPath), false, second); + } + }); + it("leaves unchecked invalid anchors inert and unknown verbs skipped", () => { fs.writeFileSync(reportPath, [ "---", "generated: 2026-04-18", "---", From d7bdeb04e94ff858e2dab3618b3357f531f98947 Mon Sep 17 00:00:00 2001 From: SJ Lee Date: Tue, 29 Sep 2026 22:38:33 +0900 Subject: [PATCH 09/13] fix: bound the close-duplicate target so the joined comment stays under the cap (#505 review) A 59,999-digit target passed the per-value cap, and joined with a 60,000-byte reason the duplicate comment exceeded GitHub's comment limit. The target must now be #N with N a positive safe integer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X9gZYk7FcYSLAZBBe7JXvZ --- skills/backlog-triage/references/apply.md | 2 +- skills/backlog-triage/scripts/triage-apply.js | 5 +++-- tests/backlog-triage/triage-apply.test.js | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/skills/backlog-triage/references/apply.md b/skills/backlog-triage/references/apply.md index 1a6c90d..2261f7f 100644 --- a/skills/backlog-triage/references/apply.md +++ b/skills/backlog-triage/references/apply.md @@ -105,7 +105,7 @@ For `assign-milestone`, `milestone=` is canonical. Apply accepts legacy `name=` - an issue number that is not a positive integer; - argument text that is not whitespace-separated `key=value` / `key="value"` pairs, or a key given twice; -- a missing or blank required argument, an invalid `set-priority` value or `close-duplicate` target, or any value containing a NUL character or more than 60,000 UTF-8 bytes; +- a missing or blank required argument, an invalid `set-priority` value or `close-duplicate` target (`#N` with N a positive safe integer, not the issue itself), or any value containing a NUL character or more than 60,000 UTF-8 bytes; - two priority or two milestone assignments for the same issue, whether the values differ or repeat (legacy `name=` counts as `milestone=`), or two different duplicate targets; - more than one accepted closing action (`close` or `close-duplicate`) for the same issue. diff --git a/skills/backlog-triage/scripts/triage-apply.js b/skills/backlog-triage/scripts/triage-apply.js index 36328f9..32250e1 100644 --- a/skills/backlog-triage/scripts/triage-apply.js +++ b/skills/backlog-triage/scripts/triage-apply.js @@ -237,7 +237,8 @@ function dedupActions(anchors) { } // Below GitHub's 65,536-character comment limit and Linux's 128 KiB per-argument limit, -// so an accepted action cannot fail at spawn time after earlier writes. +// so an accepted action cannot fail at spawn time after earlier writes. The one joined +// argument, the close-duplicate comment, adds only a safe-integer target to its reason. const MAX_ARG_BYTES = 60000; function validateAcceptedActions(actions) { @@ -303,7 +304,7 @@ function validateAcceptedActions(actions) { } else if (action.verb === "close-duplicate") { field = "duplicate target"; value = args.target; - if (value && !/^#[1-9]\d*$/.test(value)) { + if (value && (!/^#[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value.slice(1))))) { errors.push(`${location}: target must be #N with a positive issue number.`); } else if (value && Number(value.slice(1)) === action.issueNumber) { errors.push(`${location}: target must not be the issue itself.`); diff --git a/tests/backlog-triage/triage-apply.test.js b/tests/backlog-triage/triage-apply.test.js index c3f5cfa..402bd42 100644 --- a/tests/backlog-triage/triage-apply.test.js +++ b/tests/backlog-triage/triage-apply.test.js @@ -441,6 +441,7 @@ describe("execute", () => { ['', /target is required/], ['', /target must be #N/], ['', /target must not be the issue itself/], + [``, /target must be #N/], ['', /milestone is required/], ['', /different values/], ['', /issue number must be a positive integer/], From 8941ad9f78e74d4120e3e1f104e616129effe8bf Mon Sep 17 00:00:00 2001 From: SJ Lee Date: Tue, 29 Sep 2026 23:01:46 +0900 Subject: [PATCH 10/13] test: pin a fully checked source + checklist report to one write per action (#505 review) Grok round 6 P3s: nothing asserted that a report with every source anchor and its Apply Checklist copy checked still dedupes and applies each action once; and the issue-number message now says "positive safe integer", matching the duplicate-target rule. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X9gZYk7FcYSLAZBBe7JXvZ --- skills/backlog-triage/references/apply.md | 2 +- skills/backlog-triage/scripts/triage-apply.js | 2 +- tests/backlog-triage/triage-apply.test.js | 27 ++++++++++++++++--- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/skills/backlog-triage/references/apply.md b/skills/backlog-triage/references/apply.md index 2261f7f..3b5c043 100644 --- a/skills/backlog-triage/references/apply.md +++ b/skills/backlog-triage/references/apply.md @@ -103,7 +103,7 @@ That is valid. These are separate proposals and therefore separate anchor+checkb For `assign-milestone`, `milestone=` is canonical. Apply accepts legacy `name=` only when `milestone=` is absent; supplying both with different values is invalid. Accepted known actions are validated together before any GitHub call or apply-log write, including in dry-run. Any of the following fails the whole report with its line numbers: -- an issue number that is not a positive integer; +- an issue number that is not a positive safe integer; - argument text that is not whitespace-separated `key=value` / `key="value"` pairs, or a key given twice; - a missing or blank required argument, an invalid `set-priority` value or `close-duplicate` target (`#N` with N a positive safe integer, not the issue itself), or any value containing a NUL character or more than 60,000 UTF-8 bytes; - two priority or two milestone assignments for the same issue, whether the values differ or repeat (legacy `name=` counts as `milestone=`), or two different duplicate targets; diff --git a/skills/backlog-triage/scripts/triage-apply.js b/skills/backlog-triage/scripts/triage-apply.js index 32250e1..45e532c 100644 --- a/skills/backlog-triage/scripts/triage-apply.js +++ b/skills/backlog-triage/scripts/triage-apply.js @@ -260,7 +260,7 @@ function validateAcceptedActions(actions) { } } if (!Number.isSafeInteger(action.issueNumber) || action.issueNumber < 1) { - errors.push(`${location}: issue number must be a positive integer.`); + errors.push(`${location}: issue number must be a positive safe integer.`); } for (const [key, argValue] of Object.entries(args)) { if (String(argValue).includes("\u0000")) { diff --git a/tests/backlog-triage/triage-apply.test.js b/tests/backlog-triage/triage-apply.test.js index 402bd42..0f5a5a1 100644 --- a/tests/backlog-triage/triage-apply.test.js +++ b/tests/backlog-triage/triage-apply.test.js @@ -432,6 +432,27 @@ describe("execute", () => { assert.deepEqual(second.actions.map((action) => action.result), ["already-applied", "already-applied"]); }); + it("applies a report whose source anchors and checklist copies are all checked exactly once", () => { + fs.writeFileSync(reportPath, makeReport().replace(/- \[ \]/g, "- [x]")); + const calls = []; + const runGh = (argv) => { + calls.push(argv); + return { status: 0, stdout: argv[1] === "view" ? '{"labels":[]}' : "", stderr: "" }; + }; + const result = execute([reportPath, "--apply", "--yes"], { cwd: repoRoot, runGh }); + + assert.equal(result.exitCode, 0); + const writes = calls.filter((argv) => argv[1] !== "view").map((argv) => argv.slice(0, 3).join(" ")); + assert.deepEqual(writes.sort(), [ + "issue close 103", + "issue close 104", + "issue comment 103", + "issue comment 104", + "issue edit 101", + "issue edit 102", + ]); + }); + it("rejects every invalid accepted action before any gh invocation or log write", () => { const cases = [ ['', /reason is required/], @@ -444,11 +465,11 @@ describe("execute", () => { [``, /target must be #N/], ['', /milestone is required/], ['', /different values/], - ['', /issue number must be a positive integer/], + ['', /issue number must be a positive safe integer/], ['', /reason is given more than once/], ['', /unparsed argument text "garbage"/], ['', /reason must not contain a NUL character/], - ['', /issue number must be a positive integer/], + ['', /issue number must be a positive safe integer/], ]; const fixture = writeGhFixture(tempDir); const logPath = resolveLogPath("2026-04-18", repoRoot); @@ -496,7 +517,7 @@ describe("execute", () => { assert.match(result.stderr, /conflicting milestone/); assert.match(result.stderr, /conflicting duplicate target/); assert.match(result.stderr, /reason is required/); - assert.match(result.stderr, /line 28: .*issue number must be a positive integer/); + assert.match(result.stderr, /line 28: .*issue number must be a positive safe integer/); assert.match(result.stderr, /line 31: .*milestone must not contain a NUL character/); assert.match(result.stderr, /line 34: .*reason must be at most 60000 bytes/); assert.equal(fixture.calls().length, 0); From 96eb862314ac45640c3e47ab5de5064b214d5b28 Mon Sep 17 00:00:00 2001 From: SJ Lee Date: Tue, 29 Sep 2026 23:07:50 +0900 Subject: [PATCH 11/13] fix: canonicalize the legacy milestone alias in action keys (#505 review) Codex connector: a log written with `name="A"` did not match a rerun of the same assignment rewritten as `milestone="A"`, so the edit ran again. Action keys now treat `name=` as `milestone=` (when they do not disagree), so an alias pair in one report is one action and reruns across the spelling change are `already-applied`. Other verbs' keys are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X9gZYk7FcYSLAZBBe7JXvZ --- skills/backlog-triage/references/apply.md | 2 +- skills/backlog-triage/scripts/triage-apply.js | 14 +++++++++-- tests/backlog-triage/triage-apply.test.js | 24 +++++++++++++++++-- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/skills/backlog-triage/references/apply.md b/skills/backlog-triage/references/apply.md index 3b5c043..910d6b1 100644 --- a/skills/backlog-triage/references/apply.md +++ b/skills/backlog-triage/references/apply.md @@ -106,7 +106,7 @@ For `assign-milestone`, `milestone=` is canonical. Apply accepts legacy `name=` - an issue number that is not a positive safe integer; - argument text that is not whitespace-separated `key=value` / `key="value"` pairs, or a key given twice; - a missing or blank required argument, an invalid `set-priority` value or `close-duplicate` target (`#N` with N a positive safe integer, not the issue itself), or any value containing a NUL character or more than 60,000 UTF-8 bytes; -- two priority or two milestone assignments for the same issue, whether the values differ or repeat (legacy `name=` counts as `milestone=`), or two different duplicate targets; +- two priority or two milestone assignments for the same issue, whether the values differ or repeat with other arguments changed (legacy `name=` counts as `milestone=`; an otherwise identical alias pair is one action, in the report and in the apply log), or two different duplicate targets; - more than one accepted closing action (`close` or `close-duplicate`) for the same issue. Unchecked actions remain inert and unknown verbs keep their skip-and-log behavior. In apply mode, an absent `.dev-backlog/.tracker` or a first line of `github` is required; any other or unreadable authority stops before a write. diff --git a/skills/backlog-triage/scripts/triage-apply.js b/skills/backlog-triage/scripts/triage-apply.js index 45e532c..a71a0ec 100644 --- a/skills/backlog-triage/scripts/triage-apply.js +++ b/skills/backlog-triage/scripts/triage-apply.js @@ -103,8 +103,18 @@ function stableSerialize(value) { return JSON.stringify(value); } +// Keys treat the legacy milestone alias `name=` as `milestone=`, so an alias pair in one +// report dedupes and a log written with either spelling matches a rerun with the other. +function canonicalKeyArgs(verb, normalizedArgs) { + if (verb !== "assign-milestone" || normalizedArgs.name === undefined) return normalizedArgs; + if (normalizedArgs.milestone !== undefined && normalizedArgs.milestone !== normalizedArgs.name) return normalizedArgs; + const { name, ...rest } = normalizedArgs; + return { ...rest, milestone: name }; +} + function buildActionKey(action) { - return `${action.verb}|${action.issueNumber}|${stableSerialize(action.normalizedArgs || normalizeArgs(action.args))}`; + const normalizedArgs = action.normalizedArgs || normalizeArgs(action.args); + return `${action.verb}|${action.issueNumber}|${stableSerialize(canonicalKeyArgs(action.verb, normalizedArgs))}`; } function parseFrontmatter(text) { @@ -175,7 +185,7 @@ function parseReport(text) { normalizedArgs, checked, knownVerb, - key: `${anchor.verb}|${anchor.issueNumber}|${stableSerialize(normalizedArgs)}`, + key: buildActionKey({ verb: anchor.verb, issueNumber: anchor.issueNumber, normalizedArgs }), anchorLine: index + 1, checkboxLine: checkboxIndex + 1, argErrors: anchor.argErrors, diff --git a/tests/backlog-triage/triage-apply.test.js b/tests/backlog-triage/triage-apply.test.js index 0f5a5a1..261b75c 100644 --- a/tests/backlog-triage/triage-apply.test.js +++ b/tests/backlog-triage/triage-apply.test.js @@ -453,6 +453,26 @@ describe("execute", () => { ]); }); + it("treats the legacy milestone alias as the canonical key within a report and across reruns", () => { + const calls = []; + const runGh = (argv) => { + calls.push(argv); + return { status: 0, stdout: "", stderr: "" }; + }; + fs.writeFileSync(reportPath, acceptedReport( + '', + '' + )); + const first = execute([reportPath, "--apply", "--yes"], { cwd: repoRoot, runGh }); + fs.writeFileSync(reportPath, acceptedReport('')); + const second = execute([reportPath, "--apply", "--yes"], { cwd: repoRoot, runGh }); + + assert.equal(first.exitCode, 0); + assert.equal(second.exitCode, 0); + assert.deepEqual(calls, [["issue", "edit", "102", "--milestone", "Sprint W17"]]); + assert.deepEqual(second.actions.map((action) => action.result), ["already-applied"]); + }); + it("rejects every invalid accepted action before any gh invocation or log write", () => { const cases = [ ['', /reason is required/], @@ -549,9 +569,9 @@ describe("execute", () => { } }); - it("rejects a repeated same-value assignment, including the legacy milestone alias", () => { + it("rejects a repeated same-value assignment that differs in other arguments, including the legacy alias", () => { const pairs = [ - ['', '', /milestone "A" is already assigned on line 4/], + ['', '', /milestone "A" is already assigned on line 4/], ['', '', /priority "high" is already assigned on line 4/], ]; const fixture = writeGhFixture(tempDir); From 05af28c2a2a44a71d5a44ce1913b60a63b7daf16 Mon Sep 17 00:00:00 2001 From: SJ Lee Date: Tue, 29 Sep 2026 23:13:14 +0900 Subject: [PATCH 12/13] fix: lower the per-value cap to 16,000 bytes for the Windows command line (#505 review) Codex connector: native Windows limits a command line to about 32,767 UTF-16 characters, so a 40,000-character reason passed the 60,000-byte cap and failed at spawn after earlier writes. 16,000 UTF-8 bytes keeps the longest gh command (close-duplicate comment) under that limit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X9gZYk7FcYSLAZBBe7JXvZ --- skills/backlog-triage/references/apply.md | 2 +- skills/backlog-triage/scripts/triage-apply.js | 9 +++++---- tests/backlog-triage/triage-apply.test.js | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/skills/backlog-triage/references/apply.md b/skills/backlog-triage/references/apply.md index 910d6b1..59047fc 100644 --- a/skills/backlog-triage/references/apply.md +++ b/skills/backlog-triage/references/apply.md @@ -105,7 +105,7 @@ For `assign-milestone`, `milestone=` is canonical. Apply accepts legacy `name=` - an issue number that is not a positive safe integer; - argument text that is not whitespace-separated `key=value` / `key="value"` pairs, or a key given twice; -- a missing or blank required argument, an invalid `set-priority` value or `close-duplicate` target (`#N` with N a positive safe integer, not the issue itself), or any value containing a NUL character or more than 60,000 UTF-8 bytes; +- a missing or blank required argument, an invalid `set-priority` value or `close-duplicate` target (`#N` with N a positive safe integer, not the issue itself), or any value containing a NUL character or more than 16,000 UTF-8 bytes; - two priority or two milestone assignments for the same issue, whether the values differ or repeat with other arguments changed (legacy `name=` counts as `milestone=`; an otherwise identical alias pair is one action, in the report and in the apply log), or two different duplicate targets; - more than one accepted closing action (`close` or `close-duplicate`) for the same issue. diff --git a/skills/backlog-triage/scripts/triage-apply.js b/skills/backlog-triage/scripts/triage-apply.js index a71a0ec..0a9ff4b 100644 --- a/skills/backlog-triage/scripts/triage-apply.js +++ b/skills/backlog-triage/scripts/triage-apply.js @@ -246,10 +246,11 @@ function dedupActions(anchors) { return [...deduped.values()]; } -// Below GitHub's 65,536-character comment limit and Linux's 128 KiB per-argument limit, -// so an accepted action cannot fail at spawn time after earlier writes. The one joined -// argument, the close-duplicate comment, adds only a safe-integer target to its reason. -const MAX_ARG_BYTES = 60000; +// Keeps every gh command line under Windows's 32,767-character limit (and so under GitHub's +// comment limit and Linux's per-argument limit), so an accepted action cannot fail at spawn +// time after earlier writes. The one joined argument, the close-duplicate comment, adds only +// a safe-integer target to its reason. +const MAX_ARG_BYTES = 16000; function validateAcceptedActions(actions) { const errors = []; diff --git a/tests/backlog-triage/triage-apply.test.js b/tests/backlog-triage/triage-apply.test.js index 261b75c..9426291 100644 --- a/tests/backlog-triage/triage-apply.test.js +++ b/tests/backlog-triage/triage-apply.test.js @@ -520,7 +520,7 @@ describe("execute", () => { '', '', '', - `` + `` )); const fixture = writeGhFixture(tempDir); const logPath = resolveLogPath("2026-04-18", repoRoot); @@ -539,7 +539,7 @@ describe("execute", () => { assert.match(result.stderr, /reason is required/); assert.match(result.stderr, /line 28: .*issue number must be a positive safe integer/); assert.match(result.stderr, /line 31: .*milestone must not contain a NUL character/); - assert.match(result.stderr, /line 34: .*reason must be at most 60000 bytes/); + assert.match(result.stderr, /line 34: .*reason must be at most 16000 bytes/); assert.equal(fixture.calls().length, 0); assert.equal(fs.readFileSync(logPath, "utf-8"), "existing log\n"); } From 3d5d24c1d3f705f06b2a75353924b002f94818c2 Mon Sep 17 00:00:00 2001 From: SJ Lee Date: Tue, 29 Sep 2026 23:23:57 +0900 Subject: [PATCH 13/13] docs: name the milestone alias in the triage dedup key (#505 review) Grok round 7 P3: apply.md and backlog-triage SKILL.md described the key as (verb, issueNumber, normalizedArgs) only; they now say a non-conflicting legacy `name=` is keyed as `milestone=`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X9gZYk7FcYSLAZBBe7JXvZ --- skills/backlog-triage/SKILL.md | 2 +- skills/backlog-triage/references/apply.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/backlog-triage/SKILL.md b/skills/backlog-triage/SKILL.md index a5b2eba..b290f18 100644 --- a/skills/backlog-triage/SKILL.md +++ b/skills/backlog-triage/SKILL.md @@ -29,7 +29,7 @@ Done when the report exists with every section present, every proposal anchored, ## Apply Goal: only accepted actions reach GitHub. -Rail: after a human flips `[ ]` → `[x]` next to an anchor, `node /scripts/triage-apply.js ` prints the plan (dry-run); the same with `--apply --yes` validates every accepted known action and the GitHub authority before any write, then executes accepted actions, dedupes by `(verb, issueNumber, normalizedArgs)`, skips unknown verbs, logs `already-applied` on re-runs, and writes an audit log beside the report. +Rail: after a human flips `[ ]` → `[x]` next to an anchor, `node /scripts/triage-apply.js ` prints the plan (dry-run); the same with `--apply --yes` validates every accepted known action and the GitHub authority before any write, then executes accepted actions, dedupes by `(verb, issueNumber, normalizedArgs)` (legacy `name=` keyed as `milestone=`), skips unknown verbs, logs `already-applied` on re-runs, and writes an audit log beside the report. Done when every accepted action is applied or logged `already-applied` and nothing unaccepted was touched. Scripts resolve from the installed `backlog-triage` skill directory; run from the target project root. Sprint files, milestones, and Issue AC belong to `dev-backlog`. diff --git a/skills/backlog-triage/references/apply.md b/skills/backlog-triage/references/apply.md index 59047fc..79e1d24 100644 --- a/skills/backlog-triage/references/apply.md +++ b/skills/backlog-triage/references/apply.md @@ -142,6 +142,6 @@ Every proposal emitted in a source section is re-emitted in `## Apply Checklist` ### Deduplication rule for the apply step -Because the same action anchor appears in both its source section and the Apply Checklist, `triage-apply.js` deduplicates by `(verb, issueNumber, normalizedArgs)`, where `normalizedArgs` means sorted keys with trimmed string values before stable serialization. +Because the same action anchor appears in both its source section and the Apply Checklist, `triage-apply.js` deduplicates by `(verb, issueNumber, normalizedArgs)`, where `normalizedArgs` means sorted keys with trimmed string values before stable serialization; for `assign-milestone`, a legacy `name=` that does not disagree with `milestone=` is rewritten to `milestone=` first, so the alias pair is one action in the report and in the apply log. A user may check the box in either location to accept the action; the deduped action is treated as accepted when *any* occurrence of that anchor is checked. Unknown verbs still parse, but `triage-apply.js` logs and skips them during execution.