Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .dev-backlog/sprints/_context.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
- One declared task authority: `.dev-backlog/.tracker` is one line the session reads (absent = `github`; `backlog` for the Backlog.md CLI, `files` as its legacy spelling; `gitlab`), parsed by `readTaskAuthority` in `scripts/lib.js` and by a mirrored guard in `scripts/sprint-close.sh`. The rails are the SKILL.md Task Authority table; there is no adapter in code (charter rev 20 Non-Goal), so adding an authority is one table row plus one allow-list entry and needs a measured consumer (GitLab is the stated exception). Scripts never call `gh`/`backlog`/`glab` for task state; milestones, comments, PR links, and closing are calls the session makes, with exactly two scripted exceptions kept by `references/authority-contract.md`: `sprint-close.sh --close-milestone` (GitHub-only, closes the milestone before any local mutation) and `triage-apply --apply` (accepted triage actions behind a human gate).
- The Work rail is the authority's Read verb (`gh issue view N --json body,comments` by default): on GitHub the newest `## Agent Brief` comment overrides the body, a `spec_ref:` line in the task body overrides the body in every authority (and the Agent Brief on GitHub), and a failed read is fail-closed — never fall back to sprint text or a local copy.
- 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.
- Active tracks partition by scope through the ONE `scopesOverlap()` in `scripts/lib.js` — never re-implement it. `component:` equality or explicit `scope:` directory prefixes (`dir`, `dir/`, `dir/*`, `dir/**`) decide; disjoint tracks coexist as a portfolio and overlap fails loud; a component-vs-scope pair or an unsupported scope entry is "cannot prove disjoint" (warn), never "disjoint". 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.
- Spec rev 21 (#494) leads the runtime: `spec/*` states the epic #504 target (checklist-only triage anchors, session-edited sprint close with no close script; the scope checks and waiting next batch landed with #496), while SKILL.md and the scripts describe current behavior until issues #497–#502 land. Execute from SKILL.md and the scripts; grade future work against `spec/*`; retire this line when the last of those issues merges.

## Conventions

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Each entry links the GitHub issue (the canonical spec) and the merge PR (the shi

### Fixed

- Sprint readers use frontmatter-only active status, reject ambiguous track selectors and unsupported `--scope` patterns, warn when scopes cannot be proven disjoint, and show later batches waiting on earlier in-flight refs; `sprint-close.sh` closes every form the reader treats as active. Closes [#496](https://github.com/sungjunlee/dev-backlog/issues/496), PR [#507](https://github.com/sungjunlee/dev-backlog/pull/507).
- `triage-apply.js` validates every accepted action before any GitHub call or apply-log write (dry-run included) — issue numbers, strict `key=value` argument grammar, required and bounded values, canonical `milestone=` with the legacy `name=` fallback, one priority / milestone / closing action per issue — and fails with report line numbers (rules in `skills/backlog-triage/references/apply.md`); apply refuses a non-GitHub or unreadable task authority. Closes [#495](https://github.com/sungjunlee/dev-backlog/issues/495), PR [#505](https://github.com/sungjunlee/dev-backlog/pull/505).

## [0.16.0] — 2026-09-20
Expand Down
4 changes: 2 additions & 2 deletions skills/dev-backlog/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Each active sprint file (one per track) in `.dev-backlog/sprints/YYYY-MM-<topic>
| `status: active` | Marks an active track | `sprint-init.js` refuses a track whose scope overlaps another active track; disjoint tracks coexist as a portfolio. |
| `objectives: [O1]` | Optional human-authored charter Objective IDs; not checked | Optional; no resolution check. |
| `component: "slug"` | Free track-scope string; by convention a `spec/capabilities.md` heading | Optional; compared by `scopesOverlap`. |
| `scope: ["glob"]` | Path-glob track scope when no component axis fits (one axis per track) | Optional; declared explicitly via `sprint-init.js --scope`, not inferred. |
| `scope: ["dir/**"]` | Directory-prefix track scope when no component axis fits (one axis per track) | Optional; `sprint-init.js --scope` accepts `dir`, `dir/`, `dir/*`, or `dir/**`, never inferred. |
| `## Goal` | Sprint-level success statement | One sentence describing done state. |
| `## Plan` | Ordered batches with normalized task refs and estimates | Every planned task has a checkbox and a complete `#N` ref. |
| `## Running Context` | Decisions/gotchas affecting later tasks | Updated when work reveals reusable context. |
Expand Down Expand Up @@ -106,7 +106,7 @@ added to the active Plan.
### Plan

Goal: one sprint file that is the track's execution hub, admitted per Sprint Admission.
Rail: when `.dev-backlog/` is missing, `setup-dev-backlog.js` creates it first, migrating a legacy `backlog/` skill layout (sprints, config, triage) and leaving `backlog/tasks/`, `docs/`, `completed/` in place (`references/file-format.md`). `sprint-init.js "topic" [--milestone "Name"] [--component "slug" | --scope "glob[,glob]"]` creates the sprint file and refuses an overlapping track. You write the Goal, ordered Plan batches (items in one batch are parallel-safe; dependents go in a later batch), and estimates.
Rail: when `.dev-backlog/` is missing, `setup-dev-backlog.js` creates it first, migrating a legacy `backlog/` skill layout (sprints, config, triage) and leaving `backlog/tasks/`, `docs/`, `completed/` in place (`references/file-format.md`). `sprint-init.js "topic" [--milestone "Name"] [--component "slug" | --scope "dir/**[,dir/**]"]` creates the sprint file and refuses an overlapping track. You write the Goal, ordered Plan batches (items in one batch are parallel-safe; dependents go in a later batch), and estimates.
Done when the sprint file is the track's execution hub and each planned issue has a clear batch position.

### Work
Expand Down
24 changes: 18 additions & 6 deletions skills/dev-backlog/scripts/backlog-doctor.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,14 @@ function listSprintFiles(sprintsDir) {
return fs.readdirSync(sprintsDir)
.filter((file) => file.endsWith(".md") && file !== "_context.md")
.map((file) => path.join(sprintsDir, file))
.filter((filePath) => {
try {
return fs.statSync(filePath).isFile();
} catch (error) {
if (error.code === "ENOENT") return false;
throw error;
}
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.sort();
}

Expand Down Expand Up @@ -170,14 +178,18 @@ function checkActiveSprint({ repoRoot, sprintsDir, tracks = loadActiveTracks(spr
});
}

const scopeless = tracks.filter((track) => sprintScopeKey(track.frontmatter).kind === "none");
if (scopeless.length >= 1) {
// informational: a scopeless track cannot be proven disjoint, but
// cannot be proven overlapping either.
const scopelessFiles = scopeless.map((track) => displayPath(repoRoot, track.path));
const unprovable = tracks.filter((track) => {
const key = sprintScopeKey(track.frontmatter);
return key.kind === "none" || key.kind === "unknown" || key.unknown;
});
Comment on lines +181 to +184

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Treat secondary unsupported scopes as unprovable

When an active file has both a nonempty component: and an unsupported scope: entry, sprintScopeKey returns the component before examining the scope, so this filter never marks the track unprovable. With another distinct-component track, the doctor therefore reports “scopes disjoint” even though the malformed or legacy track contains scope data the reader cannot evaluate; inspect both raw fields before issuing a pass, or warn whenever both axes are present.

AGENTS.md reference: AGENTS.md:L36-L38

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 4a44ef5: sprintScopeKey flags a file that declares both component: and scope: as unknown (component still used for overlap), so the doctor warns "cannot prove disjoint" instead of passing. Test added.

const kinds = new Set(tracks.map((track) => sprintScopeKey(track.frontmatter).kind));
if (unprovable.length > 0 || (kinds.has("component") && kinds.has("scope"))) {
const scopelessFiles = tracks
.filter((track) => sprintScopeKey(track.frontmatter).kind === "none")
.map((track) => displayPath(repoRoot, track.path));
return {
...verdict("active_sprint", "warn", {
summary: `${tracks.length} active tracks; cannot prove disjoint. Without component:/scope: (${scopelessFiles.join(", ")}). Declare component: or scope: on every active track.`,
summary: `${tracks.length} active tracks; cannot prove disjoint: ${displayActive.join(", ")}. Check scope axes and supported directory prefixes.`,
active_files: displayActive,
scopeless_files: scopelessFiles,
sprint_count: sprintFiles.length,
Expand Down
38 changes: 28 additions & 10 deletions skills/dev-backlog/scripts/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,33 +216,50 @@ function parseSimpleYaml(raw) {
function sprintScopeKey(frontmatter) {
const fm = frontmatter || {};
const component = typeof fm.component === "string" ? fm.component.trim() : "";
if (component) return { kind: "component", value: component };
// Both axes, or a non-string component, break the one-axis rule: the
// readable axis still decides overlap, but the track is never provably
// disjoint (`unknown`).
const malformedComponent = fm.component !== undefined && fm.component !== null
&& typeof fm.component !== "string";
const hasScope = Array.isArray(fm.scope) ? fm.scope.length > 0 : fm.scope !== undefined;
if (component) {
return hasScope
? { kind: "component", value: component, unknown: true }
: { kind: "component", value: component };
}
const scope = Array.isArray(fm.scope)
? fm.scope.map((glob) => String(glob).trim()).filter(Boolean)
? fm.scope.map((glob) => typeof glob === "string" ? glob.trim() : null)
: [];
if (scope.length) return { kind: "scope", globs: scope };
return { kind: "none" };
if (scope.length) {
const globs = scope.filter((glob) => normalizeScopePrefix(glob) !== null);
if (globs.length === 0) return { kind: "unknown" };
return { kind: "scope", globs, unknown: globs.length !== scope.length || malformedComponent };
}
return malformedComponent ? { kind: "unknown" } : { kind: "none" };
}

// Path glob -> directory prefix: "src/auth/**" / "src/auth/*" / "src/auth/" -> "src/auth".
// Only directory prefixes are supported; other glob syntax has unknown overlap.
function normalizeScopePrefix(glob) {
return String(glob).replace(/\/+\**$/, "").replace(/\/+$/, "");
if (typeof glob !== "string") return null;
const prefix = glob.replace(/\/(?:\*{1,2})?$/, "");
if (!prefix || prefix.startsWith("/") || /[?*\[\]{}!\\"',\r\n]/.test(prefix)) return null;
if (prefix.split("/").some((segment) => !segment || segment === "." || segment === "..")) return null;
return prefix;
}

function globsOverlap(a, b) {
const na = normalizeScopePrefix(a);
const nb = normalizeScopePrefix(b);
if (na === "" || nb === "") return true; // a root scope overlaps anything
if (na === null || nb === null) return false;
if (na === nb) return true;
return na.startsWith(`${nb}/`) || nb.startsWith(`${na}/`); // nested paths overlap
}

/**
* Do two sprints' scopes overlap? The single shared predicate consumed by
* sprint-state (OVERLAPPING_TRACKS), sprint-init (refuse), and backlog-doctor.
* component: exact equality; scope: globs: normalized path-prefix containment.
* Cross-axis or scopeless pairs return false — "cannot prove overlap" — and the
* doctor separately warns when a multi-track portfolio has a scopeless track.
* component: exact equality; supported scope: directory-prefix containment.
* Cross-axis, scopeless, and unsupported scope pairs return false (unknown).
*/
function scopesOverlap(frontmatterA, frontmatterB) {
const a = sprintScopeKey(frontmatterA);
Expand All @@ -259,6 +276,7 @@ module.exports = {
parseSimpleYaml,
sprintScopeKey,
scopesOverlap,
normalizeScopePrefix,
ISSUE_REF_RE,
parseIssueRef,
parsePlanCheckbox,
Expand Down
47 changes: 33 additions & 14 deletions skills/dev-backlog/scripts/lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

# Keep in sync with execution-root.js DEFAULT_BACKLOG_DIR.
DEFAULT_BACKLOG_DIR=".dev-backlog"
_lib_source="${BASH_SOURCE[0]//\\//}"
_lib_dir="${_lib_source%/*}"
[ "$_lib_dir" = "$_lib_source" ] && _lib_dir="."
LIB_SCRIPT_DIR="$(cd "$_lib_dir" && pwd)"

# Checkbox regex aliases shared by the shell rails and the smoke test.
RE_CB_ANY='^\- \[.\] #'
Expand All @@ -28,22 +32,32 @@ checkbox_lines() {
# Usage: find_active_sprints "$SPRINTS_DIR"
find_active_sprints() {
local sprints_dir="$1"
find "$sprints_dir" -maxdepth 1 -name "*.md" ! -name "_context.md" \
-exec grep -l "^status: active" {} + 2>/dev/null | sort
node -e '
const reader = require(process.argv[1]);
const { basename } = require("node:path");
const files = reader.findActiveSprintFiles(process.argv[2]);
if (files.length) console.log(files.map((file) => basename(file)).join("\n"));
' "$LIB_SCRIPT_DIR/sprint-state.js" "$sprints_dir" | while IFS= read -r name; do
# Print basenames from node and rejoin them here so callers keep the
# directory spelling they passed (node would return C:\... on Windows).
printf '%s/%s\n' "$sprints_dir" "$name"
done
return "${PIPESTATUS[0]}"
}

# Find the active sprint file.
# Return codes:
# 0: exactly one active sprint, printed to stdout
# 1: no active sprint
# 2: multiple active sprints, printed to stderr
# 3: the sprint files could not be read (reader error on stderr)
# Usage: ACTIVE=$(find_active_sprint "$SPRINTS_DIR")
find_active_sprint() {
local sprints_dir="$1"
local active
local count

active=$(find_active_sprints "$sprints_dir")
active=$(find_active_sprints "$sprints_dir") || return 3
count=$(printf "%s\n" "$active" | grep -c . || true)

if [ "$count" -eq 0 ]; then
Expand All @@ -65,17 +79,22 @@ find_active_sprint() {
# Return 0 and print the path when found; return 1 when no active track matches.
# Usage: SPRINT=$(resolve_track "$SPRINTS_DIR" "$TRACK")
resolve_track() {
local sprints_dir="$1" track="$2" f slug component
while IFS= read -r f; do
[ -z "$f" ] && continue
slug=$(basename "$f" .md)
component=$(awk -F': *' '/^component:/{gsub(/["'"'"']/,"",$2); print $2; exit}' "$f")
if [ "$slug" = "$track" ] || [ "$component" = "$track" ]; then
printf '%s\n' "$f"
return 0
fi
done < <(find_active_sprints "$sprints_dir")
return 1
local name
name=$(node -e '
const reader = require(process.argv[1]);
try {
const state = reader.readSprintState({
backlogDir: require("node:path").dirname(process.argv[2]),
track: process.argv[3],
});
if (!state.active_sprint) process.exitCode = 1;
else console.log(require("node:path").basename(state.active_sprint.path));
} catch (error) {
console.error(error.message);
process.exitCode = 2;
}
' "$LIB_SCRIPT_DIR/sprint-state.js" "$1" "$2") || return
printf '%s/%s\n' "$1" "$name"
}

# Count checkbox states in a sprint file through the shared ref grammar.
Expand Down
Loading
Loading