diff --git a/.dev-backlog/sprints/_context.md b/.dev-backlog/sprints/_context.md index 74c6469..9aefc28 100644 --- a/.dev-backlog/sprints/_context.md +++ b/.dev-backlog/sprints/_context.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index cde62be..17c084e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/skills/dev-backlog/SKILL.md b/skills/dev-backlog/SKILL.md index a91013d..775417b 100644 --- a/skills/dev-backlog/SKILL.md +++ b/skills/dev-backlog/SKILL.md @@ -70,7 +70,7 @@ Each active sprint file (one per track) in `.dev-backlog/sprints/YYYY-MM- | `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. | @@ -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 diff --git a/skills/dev-backlog/scripts/backlog-doctor.js b/skills/dev-backlog/scripts/backlog-doctor.js index 09289d8..eb460d8 100644 --- a/skills/dev-backlog/scripts/backlog-doctor.js +++ b/skills/dev-backlog/scripts/backlog-doctor.js @@ -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; + } + }) .sort(); } @@ -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; + }); + 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, diff --git a/skills/dev-backlog/scripts/lib.js b/skills/dev-backlog/scripts/lib.js index 9e8c48f..723f871 100644 --- a/skills/dev-backlog/scripts/lib.js +++ b/skills/dev-backlog/scripts/lib.js @@ -216,23 +216,41 @@ 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 } @@ -240,9 +258,8 @@ function globsOverlap(a, b) { /** * 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); @@ -259,6 +276,7 @@ module.exports = { parseSimpleYaml, sprintScopeKey, scopesOverlap, + normalizeScopePrefix, ISSUE_REF_RE, parseIssueRef, parsePlanCheckbox, diff --git a/skills/dev-backlog/scripts/lib.sh b/skills/dev-backlog/scripts/lib.sh index 07a56f6..93642e7 100644 --- a/skills/dev-backlog/scripts/lib.sh +++ b/skills/dev-backlog/scripts/lib.sh @@ -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='^\- \[.\] #' @@ -28,8 +32,17 @@ 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. @@ -37,13 +50,14 @@ find_active_sprints() { # 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 @@ -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. diff --git a/skills/dev-backlog/scripts/sprint-close.sh b/skills/dev-backlog/scripts/sprint-close.sh index f2b7218..a741222 100755 --- a/skills/dev-backlog/scripts/sprint-close.sh +++ b/skills/dev-backlog/scripts/sprint-close.sh @@ -85,6 +85,9 @@ fi if [ -n "$TRACK" ]; then ACTIVE=$(resolve_track "$SPRINTS_DIR" "$TRACK") + RESOLVE_STATUS=$? + # 2 = ambiguous selector; resolve_track already named the candidates. + [ "$RESOLVE_STATUS" -eq 2 ] && exit 1 if [ -z "$ACTIVE" ]; then echo "No active track matches '$TRACK'. Active tracks:" find_active_sprints "$SPRINTS_DIR" | while IFS= read -r sprint; do @@ -94,8 +97,16 @@ if [ -n "$TRACK" ]; then exit 1 fi else - ACTIVE=$(find_active_sprint "$SPRINTS_DIR" 2>/dev/null) + FIND_ERR=$(mktemp) + ACTIVE=$(find_active_sprint "$SPRINTS_DIR" 2>"$FIND_ERR") ACTIVE_STATUS=$? + if [ "$ACTIVE_STATUS" -eq 3 ]; then + cat "$FIND_ERR" >&2 + rm -f "$FIND_ERR" + echo "Could not read the sprint files in $SPRINTS_DIR." >&2 + exit 1 + fi + rm -f "$FIND_ERR" if [ "$ACTIVE_STATUS" -eq 2 ]; then echo "Multiple active sprints found. Refusing to close an ambiguous sprint:" find_active_sprints "$SPRINTS_DIR" | while IFS= read -r sprint; do @@ -120,6 +131,28 @@ if [ "$CB_TODO" -gt 0 ] || [ "$CB_IN_FLIGHT" -gt 0 ]; then echo "Warning: $CB_TODO todo, $CB_IN_FLIGHT in-flight items remaining" fi +# The status flip goes through the shared reader: every form it reads as +# active is rewritten, and a file it cannot close stops here, before Step 0 +# touches GitHub. Usage: complete_sprint_file check|write FILE +complete_sprint_file() { + node -e ' + const fs = require("node:fs"); + const { completedSprintContent } = require(process.argv[1]); + const next = completedSprintContent(fs.readFileSync(process.argv[3], "utf-8")); + if (next === null) process.exit(1); + if (process.argv[2] === "write") { + // Write beside the file, then rename, so a failed write never truncates it. + const tmp = `${process.argv[3]}.closing-${process.pid}`; + fs.writeFileSync(tmp, next); + fs.renameSync(tmp, process.argv[3]); + } + ' "$SCRIPT_DIR/sprint-state.js" "$1" "$2" +} +if ! complete_sprint_file check "$ACTIVE"; then + echo "Cannot set status: completed in $ACTIVE; edit its frontmatter by hand." >&2 + exit 1 +fi + # --- Step 0: Optional provider mutation runs BEFORE any local mutation --- # Fail-loud contract (#366): if the GitHub milestone cannot be closed, the # local sprint must remain active — never completed-with-open-milestone. @@ -143,8 +176,33 @@ if $CLOSE_MILESTONE; then exit 1 fi fi +# The milestone title comes from the shared frontmatter parser (read once, used +# by Step 0 and Step 4). An unquoted numeric or boolean title keeps its written +# text; a missing or structured value is no milestone. +if $CLOSE_MILESTONE; then + CLOSE_MILESTONE_NAME=$(node -e ' + const fs = require("node:fs"); + const { parseFrontmatter } = require(process.argv[1]); + const content = fs.readFileSync(process.argv[2], "utf-8"); + const milestone = parseFrontmatter(content).milestone; + if (typeof milestone === "string") { + console.log(milestone.trim()); + } else if (typeof milestone === "number" || typeof milestone === "boolean") { + // The root line is the one whose removal changes the parsed root value; + // a nested `milestone:` never qualifies. + const lines = content.split("\n"); + for (let index = lines.length - 1; index >= 0; index -= 1) { + if (!/^[ \t]*milestone:/.test(lines[index])) continue; + const without = lines.filter((_, lineIndex) => lineIndex !== index).join("\n"); + if (parseFrontmatter(without).milestone === milestone) continue; + // Unquoted (it parsed as a number or boolean), so a " #" starts a comment. + console.log(lines[index].replace(/^[ \t]*milestone:/, "").replace(/\s+#.*$/, "").trim()); + break; + } + } + ' "$SCRIPT_DIR/sprint-state.js" "$ACTIVE") +fi if $CLOSE_MILESTONE && ! $DRY_RUN; then - CLOSE_MILESTONE_NAME=$(grep '^milestone:' "$ACTIVE" | sed 's/^milestone: *//') if [ -z "$CLOSE_MILESTONE_NAME" ]; then echo "No milestone: frontmatter in $ACTIVE; cannot --close-milestone." exit 1 @@ -166,8 +224,10 @@ DOCTOR_STATUS=$? if $DRY_RUN; then echo "[dry-run] Would set status: completed in $ACTIVE" else - # Replace status: active with status: completed - sed -i.bak "s/^status: active$/status: completed/" "$ACTIVE" && rm -f "$ACTIVE.bak" + if ! complete_sprint_file write "$ACTIVE"; then + echo "Could not set status: completed in $ACTIVE; edit its frontmatter by hand." >&2 + exit 1 + fi # Append progress entry echo "- $TODAY: Sprint closed. $CB_DONE/$CB_TOTAL tasks completed." >> "$ACTIVE" echo "Set status: completed in $ACTIVE" @@ -185,7 +245,7 @@ fi # --- Step 4: Optionally close milestone --- if $CLOSE_MILESTONE; then - MILESTONE=$(grep '^milestone:' "$ACTIVE" | sed 's/^milestone: *//') + MILESTONE="$CLOSE_MILESTONE_NAME" if [ -n "$MILESTONE" ]; then if $DRY_RUN; then echo "[dry-run] Would close milestone: $MILESTONE" diff --git a/skills/dev-backlog/scripts/sprint-init.js b/skills/dev-backlog/scripts/sprint-init.js index e9fcd9a..c963588 100755 --- a/skills/dev-backlog/scripts/sprint-init.js +++ b/skills/dev-backlog/scripts/sprint-init.js @@ -14,11 +14,11 @@ const fs = require("fs"); const path = require("path"); -const { slugify, sprintScopeKey, scopesOverlap } = require("./lib"); +const { slugify, sprintScopeKey, scopesOverlap, normalizeScopePrefix } = require("./lib"); const { defaultSprintsDir } = require("./execution-root.js"); -const { parseFrontmatter } = require("./sprint-state.js"); +const { parseFrontmatter, findActiveSprintFiles } = require("./sprint-state.js"); -const USAGE = 'Usage: sprint-init.js "topic" [--milestone "Milestone Name"] [--component "slug" | --scope "glob[,glob]"]'; +const USAGE = 'Usage: sprint-init.js "topic" [--milestone "Milestone Name"] [--component "slug" | --scope "dir/**[,dir/**]"]'; function parseTrackAxis(args) { const componentIdx = args.indexOf("--component"); @@ -36,7 +36,11 @@ function parseTrackAxis(args) { } if (componentIdx !== -1) return { component }; if (scopeIdx !== -1) { - return { scope: rawScope.split(",").map((glob) => glob.trim()).filter(Boolean) }; + const scope = rawScope.split(",").map((glob) => glob.trim()).filter(Boolean); + if (scope.length === 0 || scope.some((glob) => normalizeScopePrefix(glob) === null)) { + return { error: "--scope accepts directory prefixes only: dir, dir/, dir/*, dir/**." }; + } + return { scope }; } return {}; } @@ -114,14 +118,7 @@ ${scopeLine}${componentLine}--- } function listActiveSprintFiles(sprintsDir) { - if (!fs.existsSync(sprintsDir)) return []; - return fs.readdirSync(sprintsDir) - .filter((file) => file.endsWith(".md") && file !== "_context.md") - .filter((file) => { - const content = fs.readFileSync(path.join(sprintsDir, file), "utf-8"); - return /^status: active$/m.test(content); - }) - .sort(); + return findActiveSprintFiles(sprintsDir).map((filePath) => path.basename(filePath)); } // Overlap is the one shared scopesOverlap predicate. A scopeless track in a @@ -145,16 +142,18 @@ function checkTrackDisjointness({ sprintsDir, component, scope, newTrackFile = " ); } - const scopelessAfterCreate = [ - ...activeTracks - .filter((track) => sprintScopeKey(track.frontmatter).kind === "none") - .map((track) => track.file), - ...(sprintScopeKey(newFrontmatter).kind === "none" ? [newTrackFile] : []), - ]; - if (activeTracks.length >= 1 && scopelessAfterCreate.length >= 1) { + const allTracks = [...activeTracks, { file: newTrackFile, frontmatter: newFrontmatter }]; + const unknown = allTracks.filter((track) => { + const key = sprintScopeKey(track.frontmatter); + return key.kind === "none" || key.kind === "unknown" || key.unknown; + }).map((track) => track.file); + const crossAxis = allTracks.some((track) => sprintScopeKey(track.frontmatter).kind === "component") + && allTracks.some((track) => sprintScopeKey(track.frontmatter).kind === "scope"); + if (activeTracks.length >= 1 && (unknown.length > 0 || crossAxis)) { + const namedFiles = unknown.length > 0 ? unknown : allTracks.map((track) => track.file); return [ - `Active track(s) without component:/scope: (${scopelessAfterCreate.join(", ")}); ` - + "cannot prove all active tracks are disjoint. Declare component: or scope: on every active track (backlog-doctor will warn).", + `Active tracks cannot prove disjoint: ${namedFiles.join(", ")}. ` + + "Use one supported scope axis across tracks (backlog-doctor will warn).", ]; } return []; @@ -171,6 +170,9 @@ function createSprintFile({ if (component && Array.isArray(scope) && scope.length) { throw new Error("--component and --scope cannot be used together; declare one track axis."); } + if (Array.isArray(scope) && scope.some((glob) => normalizeScopePrefix(glob) === null)) { + throw new Error("--scope accepts directory prefixes only: dir, dir/, dir/*, dir/**."); + } const datePrefix = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`; const started = today.toISOString().slice(0, 10); diff --git a/skills/dev-backlog/scripts/sprint-state.js b/skills/dev-backlog/scripts/sprint-state.js index d37335f..bd3658e 100644 --- a/skills/dev-backlog/scripts/sprint-state.js +++ b/skills/dev-backlog/scripts/sprint-state.js @@ -160,15 +160,26 @@ function findActiveSprintFiles(sprintsDir, { existsSync = fs.existsSync, readdirSync = fs.readdirSync, readFileSync = fs.readFileSync, + statSync = fs.statSync, } = {}) { if (!existsSync(sprintsDir)) return []; return readdirSync(sprintsDir) .filter((file) => file.endsWith(".md") && file !== "_context.md") .map((file) => path.join(sprintsDir, file)) + // A directory or dangling link named *.md is not a sprint; other read + // errors (permissions) still throw. + .filter((filePath) => { + try { + return statSync(filePath).isFile(); + } catch (error) { + if (error.code === "ENOENT") return false; + throw error; + } + }) .filter((filePath) => { const content = readFileSync(filePath, "utf-8"); - return /^status:\s*active\s*$/m.test(content); + return isActiveSprint(content); }) .sort(); } @@ -179,6 +190,38 @@ function parseFrontmatter(content) { return parseSimpleYaml(match[1]); } +function isActiveSprint(content) { + const status = parseFrontmatter(content).status; + return typeof status === "string" && status.trim() === "active"; +} + +// The same file with the root frontmatter `status:` set to completed, or null +// when no single line does that (including a duplicated root `status:`). Each `status:` line is tried in turn and kept +// only if the parser then sees the root status completed and nothing else +// changed, so a nested or block-text `status:` is never touched. +function completedSprintContent(content) { + const match = content.match(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/); + if (!match) return null; + const rest = content.slice(match[0].length); + const withoutStatus = (fm) => JSON.stringify({ ...fm, status: undefined }); + const before = withoutStatus(parseFrontmatter(content)); + const lines = match[0].split("\n"); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index].match(/^([ \t]*)status:.*?(\s+#.*?)?(\r?)$/); + if (!line) continue; + const candidate = [...lines]; + // Keep a trailing YAML comment the author wrote on the status line. + candidate[index] = `${line[1]}status: completed${line[2] || ""}${line[3]}`; + const next = candidate.join("\n") + rest; + const parsed = parseFrontmatter(next); + if (parsed.status !== "completed" || withoutStatus(parsed) !== before) continue; + // A second root `status: active` would survive in the file: stop instead. + const remaining = lines.filter((_, lineIndex) => lineIndex !== index).join("\n") + rest; + return isActiveSprint(remaining) ? null : next; + } + return null; +} + function extractSectionLines(content, section) { const lines = content.split(/\r?\n/); const sectionRe = new RegExp(`^## ${escapeRegExp(section)}[ \\t]*$`); @@ -225,15 +268,17 @@ function parseProgressEntries(progressLines) { function parsePlanItems(planLines) { const items = []; let batchHeading = null; + let batchIndex = null; for (const line of planLines) { if (/^### Batch/.test(line)) { batchHeading = line; + batchIndex = batchIndex === null ? 0 : batchIndex + 1; continue; } const item = parsePlanItem(line, batchHeading); - if (item) items.push(item); + if (item) items.push({ ...item, batch_index: batchIndex }); } return items; @@ -284,14 +329,25 @@ function findNextBatch(planItems) { return { heading: null, items: planItems.filter((item) => item.state === "todo"), + waiting_on: [], + actionable: true, }; } + // Batches are identified by position, not heading text, so a repeated + // heading cannot merge two batches. + const batch = firstTodo.batch_index; + const waitingOn = planItems.filter((item) => + item.state === "in_flight" && item.batch_index !== null && item.batch_index < batch + ).map((item) => item.ref); + return { heading: firstTodo.batch_heading, items: planItems.filter( - (item) => item.state === "todo" && item.batch_heading === firstTodo.batch_heading + (item) => item.state === "todo" && item.batch_index === batch ), + waiting_on: waitingOn, + actionable: waitingOn.length === 0, }; } @@ -424,6 +480,7 @@ function readSprintState({ existsSync = fs.existsSync, readdirSync = fs.readdirSync, readFileSync = fs.readFileSync, + statSync = fs.statSync, } = {}) { const sprintsDir = path.join(backlogDir, "sprints"); const authority = readTaskAuthority(backlogDir); // #476: read once per run @@ -431,6 +488,7 @@ function readSprintState({ existsSync, readdirSync, readFileSync, + statSync, }); if (activeFiles.length === 0) return emptyState(); @@ -448,6 +506,11 @@ function readSprintState({ if (track || component) { const matches = perSprints.filter((s) => matchesSelector(s, { track, component })); if (matches.length === 0) return emptyState(); + if (matches.length > 1) { + const selector = track || component; + throw new Error(`Ambiguous active track '${selector}':\n` + + matches.map((s) => ` ${s.active_sprint.path}`).join("\n")); + } return singleState(matches[0]); } @@ -519,8 +582,11 @@ function progressLine(counts) { function nextBatchLines(nextBatch) { const heading = nextBatch ? nextBatch.heading : null; const items = nextBatch ? nextBatch.items : []; + const waitingOn = nextBatch?.waiting_on || []; return [ - heading ? `Next: ${heading}` : "Next items:", + waitingOn.length > 0 + ? `Waiting: ${heading} (on ${waitingOn.join(", ")})` + : (heading ? `Next: ${heading}` : "Next items:"), ...items.map((item) => ` ${item.line}`), ]; } @@ -546,7 +612,11 @@ function nextTrackLines(perSprint) { const slug = sprintSlug(perSprint.active_sprint.path); const lines = [`${slug}: ${counts.done}/${counts.total} done${inFlight}`]; const todo = perSprint.plan_items.find((item) => item.state === "todo"); - if (todo) lines.push(` Next: ${todo.line.replace(/^- \[ \] /, "")}`); + if (todo) { + const waitingOn = perSprint.next_batch?.waiting_on || []; + const label = waitingOn.length ? `Waiting on ${waitingOn.join(", ")}` : "Next"; + lines.push(` ${label}: ${todo.line.replace(/^- \[ \] /, "")}`); + } return lines; } @@ -573,8 +643,15 @@ function statusSingleLines(perSprint) { const lines = [statusCountLine(perSprint, { unit: " tasks" })]; const inFlight = itemLines(perSprint.plan_items, "in_flight", 3); if (inFlight.length > 0) lines.push("", "In flight:", ...inFlight); - const todo = itemLines(perSprint.plan_items, "todo", 3); - if (todo.length > 0) lines.push("", "Next up:", ...todo); + const waitingOn = perSprint.next_batch?.waiting_on || []; + if (waitingOn.length > 0) { + // Only the blocked batch waits; later batches are not listed under it. + const waiting = perSprint.next_batch.items.slice(0, 3).map((item) => ` ${item.line}`); + lines.push("", `Waiting on ${waitingOn.join(", ")}:`, ...waiting); + } else { + const todo = itemLines(perSprint.plan_items, "todo", 3); + if (todo.length > 0) lines.push("", "Next up:", ...todo); + } if (isReadyToClose(counts)) lines.push("", ">> All items done — ready to close sprint"); return lines; } @@ -681,6 +758,8 @@ module.exports = { parseArgs, findActiveSprintFiles, parseFrontmatter, + isActiveSprint, + completedSprintContent, extractSectionLines, hasSection, parseProgressEntries, diff --git a/tests/dev-backlog/backlog-doctor.test.js b/tests/dev-backlog/backlog-doctor.test.js index 4fbf1d3..724d17c 100644 --- a/tests/dev-backlog/backlog-doctor.test.js +++ b/tests/dev-backlog/backlog-doctor.test.js @@ -285,6 +285,59 @@ describe("runDoctor", () => { assert.equal(exitCodeFor(report), 1); }); + it("warns when component and scope axes cannot prove disjoint (#496)", () => { + const sprintsDir = path.join(repoRoot, ".dev-backlog", "sprints"); + const first = path.join(sprintsDir, "2026-07-auth.md"); + const second = path.join(sprintsDir, "2026-07-other.md"); + write(first, sprintNoSpecFields({ component: "auth" })); + write(second, sprintNoSpecFields({ scope: '["src/auth/**"]' })); + const active = check(runDoctor({ repoRoot }), "active_sprint"); + assert.equal(active.status, "warn"); + assert.match(active.detail.summary, /cannot prove disjoint/); + assert.doesNotMatch(active.detail.summary, /scopes disjoint/); + assert.ok(active.detail.summary.includes(".dev-backlog/sprints/2026-07-auth.md")); + assert.ok(active.detail.summary.includes(".dev-backlog/sprints/2026-07-other.md")); + }); + + it("warns when one active track declares both component and scope, or a non-string component (#496)", () => { + const sprintsDir = path.join(repoRoot, ".dev-backlog", "sprints"); + write(path.join(sprintsDir, "2026-07-auth.md"), sprintNoSpecFields({ component: "auth", scope: '["src/**/*.ts"]' })); + write(path.join(sprintsDir, "2026-07-other.md"), sprintNoSpecFields({ component: "billing" })); + assert.equal(check(runDoctor({ repoRoot }), "active_sprint").status, "warn"); + + write(path.join(sprintsDir, "2026-07-auth.md"), sprintNoSpecFields({ scope: '["src/auth/**"]' }).replace("status: active", "status: active\ncomponent: [1]")); + write(path.join(sprintsDir, "2026-07-other.md"), sprintNoSpecFields({ scope: '["src/billing/**"]' })); + assert.equal(check(runDoctor({ repoRoot }), "active_sprint").status, "warn"); + + // The readable scope axis still decides a real overlap. + write(path.join(sprintsDir, "2026-07-other.md"), sprintNoSpecFields({ scope: '["src/auth/**"]' })); + assert.equal(check(runDoctor({ repoRoot }), "active_sprint").status, "fail"); + }); + + it("warns when existing scope entries use unsupported glob syntax (#496)", () => { + const sprintsDir = path.join(repoRoot, ".dev-backlog", "sprints"); + const first = path.join(sprintsDir, "2026-07-auth.md"); + const second = path.join(sprintsDir, "2026-07-other.md"); + for (const { firstTrack, scope } of [ + { firstTrack: { scope: '["src/billing/**"]' }, scope: '["src/**/*.ts"]' }, + { firstTrack: { scope: '["src/a.ts"]' }, scope: '["src/*.ts"]' }, + { firstTrack: { scope: '["src/authz/**"]' }, scope: '["src/auth*"]' }, + ]) { + write(first, sprintNoSpecFields(firstTrack)); + write(second, sprintNoSpecFields({ scope })); + const active = check(runDoctor({ repoRoot }), "active_sprint"); + assert.equal(active.status, "warn"); + assert.match(active.detail.summary, /cannot prove disjoint/); + assert.doesNotMatch(active.detail.summary, /scopes disjoint/); + assert.ok(active.detail.summary.includes(".dev-backlog/sprints/2026-07-auth.md")); + assert.ok(active.detail.summary.includes(".dev-backlog/sprints/2026-07-other.md")); + } + + write(first, sprintNoSpecFields({ scope: '["src/auth/api/**"]' })); + write(second, sprintNoSpecFields({ scope: '["src/auth/**", "src/**/*.ts"]' })); + assert.equal(check(runDoctor({ repoRoot }), "active_sprint").status, "fail"); + }); + it("warns informationally when one of two active tracks is scopeless (#337)", () => { write( path.join(repoRoot, ".dev-backlog", "sprints", "2026-07-declared.md"), diff --git a/tests/dev-backlog/sprint-close.test.js b/tests/dev-backlog/sprint-close.test.js new file mode 100644 index 0000000..dd93c75 --- /dev/null +++ b/tests/dev-backlog/sprint-close.test.js @@ -0,0 +1,69 @@ +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const { resolveBashExecutable, toBashArgs } = require(path.resolve(__dirname, "../tools/bash-runtime.js")); + +const SCRIPTS_DIR = path.resolve(__dirname, "../../skills/dev-backlog/scripts"); +const { isActiveSprint, completedSprintContent } = require(path.join(SCRIPTS_DIR, "sprint-state.js")); + +function closeSprint(t, statusLine, { extraDir, args = [] } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "sprint-close-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const sprintPath = path.join(root, ".dev-backlog", "sprints", "2026-09-close.md"); + fs.mkdirSync(path.dirname(sprintPath), { recursive: true }); + if (extraDir) fs.mkdirSync(path.join(path.dirname(sprintPath), extraDir)); + fs.writeFileSync(sprintPath, [ + "---", statusLine, "---", "", "## Goal", "Done.", "", "## Plan", "- [x] #1 One", "", + "## Running Context", "", "## Progress", "- 2026-09-29: started", + ].join("\n") + "\n"); + const result = spawnSync(resolveBashExecutable(), toBashArgs([path.join(SCRIPTS_DIR, "sprint-close.sh"), ...args]), { + cwd: root, + encoding: "utf8", + }); + return { result, content: fs.readFileSync(sprintPath, "utf-8") }; +} + +describe("sprint-close status flip (#496)", () => { + for (const statusLine of ["status: active", "status: active ", 'status: "active"', " status: active"]) { + it(`closes a sprint whose frontmatter reads ${JSON.stringify(statusLine)}`, (t) => { + const { result, content } = closeSprint(t, statusLine); + assert.equal(result.status, 0, result.stderr); + assert.equal(isActiveSprint(content), false); + assert.match(content, /^[ \t]*status: completed$/m); + }); + } + + it("changes only the root status, never a nested or block-text status", () => { + const content = [ + "---", "status: active", "deployment:", " status: active", "notes: |", " status: active", "---", "body", + ].join("\n"); + const next = completedSprintContent(content); + assert.equal(isActiveSprint(next), false); + assert.equal(next.split("\n").filter((line) => line === " status: active").length, 2); + assert.equal(completedSprintContent("---\nstatus: active\nmilestone: M\nstatus: active\n---\n"), null); + assert.match(completedSprintContent("---\nstatus: active # managed by hand\n---\n"), /^status: completed # managed by hand$/m); + }); + + for (const [frontmatter, title] of [ + ["status: active\nmilestone: 1.20", "1.20"], + [" status: active\n milestone: Sprint X", "Sprint X"], + ['status: active\nmilestone: "2026"', "2026"], + ["status: active\nmilestone: 2026\ndeployment:\n milestone: 123", "2026"], + ["status: active\nmilestone: 1.20 # quarterly", "1.20"], + ]) { + it(`reads the milestone title ${title} through the shared parser for --close-milestone`, (t) => { + const { result } = closeSprint(t, frontmatter, { args: ["--close-milestone", "--dry-run"] }); + assert.equal(result.status, 0, result.stderr); + assert.ok(result.stdout.split("\n").some((line) => line.includes("milestone") && line.endsWith(` ${title}`))); + }); + } + + it("skips a directory named *.md instead of reporting no active sprint", (t) => { + const { result, content } = closeSprint(t, "status: active", { extraDir: "not-a-sprint.md" }); + assert.equal(result.status, 0, result.stderr); + assert.equal(isActiveSprint(content), false); + }); +}); diff --git a/tests/dev-backlog/sprint-init.test.js b/tests/dev-backlog/sprint-init.test.js index 0e54fce..2a47c1e 100644 --- a/tests/dev-backlog/sprint-init.test.js +++ b/tests/dev-backlog/sprint-init.test.js @@ -5,7 +5,10 @@ const os = require("os"); const path = require("path"); const { spawnSync } = require("child_process"); const SKILL_SCRIPTS = path.resolve(__dirname, "../../skills/dev-backlog/scripts"); +const { spawnBashSync } = require(path.resolve(__dirname, "../tools/bash-runtime.js")); const { checkSprintShape } = require(path.join(SKILL_SCRIPTS, "backlog-doctor.js")); +const { runDoctor } = require(path.join(SKILL_SCRIPTS, "backlog-doctor.js")); +const { readSprintState } = require(path.join(SKILL_SCRIPTS, "sprint-state.js")); const { parseArgs, buildComponentFrontmatterLine, @@ -149,6 +152,49 @@ describe("createSprintFile", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + it("uses only trimmed frontmatter status across Node readers and the shell bridge (#496)", () => { + const backlogDir = path.join(tmpDir, ".dev-backlog"); + const sprintsDir = path.join(backlogDir, "sprints"); + fs.mkdirSync(sprintsDir, { recursive: true }); + const bodyOnly = path.join(sprintsDir, "body-only.md"); + const active = path.join(sprintsDir, "active.md"); + fs.writeFileSync(bodyOnly, "---\nstatus: completed\n---\n\nstatus: active\n"); + fs.writeFileSync(active, "---\nstatus: active \ncomponent: auth\n---\n"); + + assert.deepEqual(listActiveSprintFiles(sprintsDir), ["active.md"]); + assert.equal(readSprintState({ backlogDir }).active_sprint.path, active); + const doctor = runDoctor({ repoRoot: tmpDir }); + // Doctor display paths are repo-relative and portable (forward slashes). + assert.deepEqual(doctor.checks[0].detail.active_files, [".dev-backlog/sprints/active.md"]); + const shell = spawnBashSync(["-c", 'source "$1"; find_active_sprints "$2"', "bash", + path.join(SKILL_SCRIPTS, "lib.sh"), sprintsDir], { encoding: "utf8" }); + assert.equal(shell.status, 0); + // One file, whatever path spelling the Bash layer hands back on Windows. + assert.deepEqual(shell.stdout.trim().split(/\r?\n/).map((line) => path.basename(line.replace(/\\/g, "/"))), ["active.md"]); + assert.throws(() => createSprintFile({ + topic: "new", milestone: "M", component: "auth", sprintsDir, + today: new Date("2026-04-05T09:00:00Z"), + }), /active\.md/); + }); + + it("refuses unsupported scope syntax and enforces a supported parent prefix (#496)", () => { + const sprintsDir = path.join(tmpDir, ".dev-backlog", "sprints"); + fs.mkdirSync(sprintsDir, { recursive: true }); + const cli = path.join(SKILL_SCRIPTS, "sprint-init.js"); + const invoke = (topic, scope) => spawnSync(process.execPath, + [cli, topic, "--scope", scope], { cwd: tmpDir, encoding: "utf8" }); + const invalid = invoke("invalid", "src/**/*.ts"); + assert.equal(invalid.status, 1); + assert.deepEqual(fs.readdirSync(sprintsDir), []); + fs.writeFileSync(path.join(sprintsDir, "parent.md"), + '---\nstatus: active\nscope: ["src/**"]\n---\n'); + const blocked = invoke("nested", "src/auth/**"); + assert.equal(blocked.status, 1); + assert.match(blocked.stdout + blocked.stderr, /parent\.md/); + fs.rmSync(path.join(sprintsDir, "parent.md")); + assert.equal(invoke("nested", "src/auth/**").status, 0); + }); + it("writes sprint file and returns structured result", () => { const result = createSprintFile({ topic: "auth-system", @@ -293,14 +339,14 @@ describe("createSprintFile", () => { }, /Active track overlaps on scope: 2026-04-current\.md/); }); - it("refuses when a shared component: overlaps, regardless of scope globs (#292)", () => { + it("allows a scope track next to a component track with a cannot-prove-disjoint warning (#292, #496)", () => { fs.writeFileSync( path.join(tmpDir, "2026-04-current.md"), '---\nstatus: active\ncomponent: "auth-system"\n---\n', ); - // A scopeless new sprint next to a component-scoped track cannot be proven - // to overlap — but two tracks on the SAME component axis can, via frontmatter. + // A scope track next to a component track is cross-axis: allowed, with a + // cannot-prove-disjoint warning. const disjoint = createSprintFile({ topic: "billing", milestone: "Sprint W14", @@ -308,7 +354,8 @@ describe("createSprintFile", () => { sprintsDir: tmpDir, today: new Date("2026-04-05T09:00:00Z"), }); - assert.deepEqual(disjoint.warnings, []); + assert.equal(disjoint.warnings.length, 1); + assert.match(disjoint.warnings[0], /cannot prove disjoint/); }); it("refuses equal components and allows distinct ones without a scopeless warning (#331)", () => { diff --git a/tests/dev-backlog/sprint-state.test.js b/tests/dev-backlog/sprint-state.test.js index 467438f..9c148f8 100644 --- a/tests/dev-backlog/sprint-state.test.js +++ b/tests/dev-backlog/sprint-state.test.js @@ -3,7 +3,9 @@ const assert = require("node:assert/strict"); const fs = require("fs"); const os = require("os"); const path = require("path"); +const { spawnSync } = require("node:child_process"); const SKILL_SCRIPTS = path.resolve(__dirname, "../../skills/dev-backlog/scripts"); +const { spawnBashSync } = require(path.resolve(__dirname, "../tools/bash-runtime.js")); const { readSprintState, parsePlanItem, @@ -91,6 +93,7 @@ Expose actor-readable execution state. issue_number: 211, title: "Add JSON surfaces (~2hr)", batch_heading: "### Batch 2 - Active", + batch_index: 1, pr: { number: 224, state: "reviewing" }, branch: null, unmoored: false, @@ -182,6 +185,24 @@ Expose actor-readable execution state. assert.deepEqual(noMatch.active_sprints, []); }); + it("fails on a selector shared by a slug and another active component (#496)", () => { + const slugMatch = path.join(backlogDir, "sprints", "auth.md"); + const componentMatch = path.join(backlogDir, "sprints", "other.md"); + writeFile(slugMatch, '---\nstatus: active\ncomponent: billing\n---\n'); + writeFile(componentMatch, '---\nstatus: active\ncomponent: auth\n---\n'); + const result = spawnSync(process.execPath, [path.join(SKILL_SCRIPTS, "sprint-state.js"), + "--mode", "next", "--track", "auth", backlogDir], { encoding: "utf8" }); + assert.equal(result.status, 1); + assert.ok(result.stderr.includes(slugMatch)); + assert.ok(result.stderr.includes(componentMatch)); + const shell = spawnBashSync(["-c", 'source "$1"; resolve_track "$2" "$3"', "bash", + path.join(SKILL_SCRIPTS, "lib.sh"), path.join(backlogDir, "sprints"), "auth"], + { encoding: "utf8" }); + assert.notEqual(shell.status, 0); + assert.ok(shell.stderr.includes(slugMatch)); + assert.ok(shell.stderr.includes(componentMatch)); + }); + it("keeps the GitHub wire identity and ignores refs outside the #N grammar", () => { writeFile(path.join(backlogDir, "sprints", "mixed.md"), `--- status: active @@ -418,6 +439,53 @@ started: 2026-06-30 }); describe("Orient recovery rail (scenarios 1 and 8, #490)", () => { + it("marks a later todo batch waiting on earlier in-flight refs in JSON and next text (#496)", () => { + const content = `--- +status: active +--- + +## Plan +### Batch 1 +- [~] #1 Running [branch:work] + +### Batch 2 +- [ ] #2 Later +`; + const state = parseSprintContent({ sprintPath: "waiting.md", content }); + assert.equal(state.next_batch.actionable, false); + assert.deepEqual(state.next_batch.waiting_on, ["#1"]); + assert.deepEqual(state.next_batch.items.map((item) => item.ref), ["#2"]); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dev-backlog-waiting-")); + try { + const backlogDir = path.join(tmpDir, ".dev-backlog"); + writeFile(path.join(backlogDir, "sprints", "waiting.md"), content); + const report = textReport({ mode: "next", backlogDir }); + assert.equal(report.code, 0); + assert.ok(report.lines.some((line) => line.includes("### Batch 2") && line.includes("#1"))); + assert.ok(!report.lines.some((line) => line.startsWith("Next: ### Batch 2"))); + writeFile(path.join(backlogDir, "sprints", "waiting.md"), `${content}\n### Batch 3\n- [ ] #3 Later still\n`); + const status = textReport({ mode: "status", backlogDir }); + assert.ok(!status.lines.includes("Next up:")); + assert.ok(!status.lines.some((line) => line.includes("#3"))); + assert.ok(status.lines.some((line) => line.includes("#1") && !line.includes("[~]"))); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + + const sameBatch = parseSprintContent({ sprintPath: "same.md", content: content + .replace("### Batch 2\n", "") }); + assert.equal(sameBatch.next_batch.actionable, true); + assert.deepEqual(sameBatch.next_batch.waiting_on, []); + + // Batches are positional: a repeated heading is still a later batch. + const repeated = parseSprintContent({ sprintPath: "repeated.md", content: content + .replace("### Batch 2", "### Batch 1") }); + assert.equal(repeated.next_batch.actionable, false); + assert.deepEqual(repeated.next_batch.waiting_on, ["#1"]); + assert.deepEqual(repeated.next_batch.items.map((item) => item.ref), ["#2"]); + }); + it("selects the first [ ] batch as next_batch, distinct from [~] work, and reports latest Progress or its absence", () => { const state = parseSprintContent({ sprintPath: ".dev-backlog/sprints/scenario-1.md", @@ -567,7 +635,7 @@ describe("textReport", () => { "In flight:", " - [~] #2 Dispatch (~2hr) → PR #87 (reviewing)", "", - "Next: ### Batch 3 — Remaining", + "Waiting: ### Batch 3 — Remaining (on #2)", " - [ ] #3 Render text", " - [ ] #4 Delete the bash parser", "", @@ -576,7 +644,7 @@ describe("textReport", () => { }); }); - it("renders the status stanza with capped in-flight and next-up lists", () => { + it("renders the status stanza with capped in-flight and waiting lists", () => { writeFile(path.join(backlogDir, "sprints", "2026-09-text.md"), BATCHED_SPRINT); assert.deepEqual(textReport({ mode: "status", backlogDir }), { @@ -588,7 +656,7 @@ describe("textReport", () => { "In flight:", " - [~] #2 Dispatch (~2hr) → PR #87 (reviewing)", "", - "Next up:", + "Waiting on #2:", " - [ ] #3 Render text", " - [ ] #4 Delete the bash parser", ], diff --git a/tests/smoke/smoke-test.sh b/tests/smoke/smoke-test.sh index f4c2199..90f2445 100644 --- a/tests/smoke/smoke-test.sh +++ b/tests/smoke/smoke-test.sh @@ -442,7 +442,7 @@ assert_not_contains "next: does not show done items as next" "$OUT" "[ ] #1" OUT=$(bash "$SCRIPT_DIR/status.sh" "$TEST_DIR/.dev-backlog") assert_contains "status: shows in-flight" "$OUT" "1 in-flight" assert_contains "status: shows in-flight item" "$OUT" "[~] #3" -assert_contains "status: shows next up" "$OUT" "Next up:" +assert_contains "status: later batch waits on the in-flight ref (#496)" "$OUT" "Waiting on #3:" assert_contains "status: shows sprint name" "$OUT" "2026-03-test" assert_contains "status: shows percentage" "$OUT" "40%" @@ -577,8 +577,8 @@ status: active EOF OUT=$(bash "$SCRIPT_DIR/next.sh" "$TEST_DIR/.dev-backlog") -assert_contains "malformed: still finds active" "$OUT" "Sprint:" -assert_contains "malformed: counts tasks" "$OUT" "0/1 done" +assert_contains "malformed: no frontmatter is inactive" "$OUT" "No active sprint found." +assert_not_contains "malformed: does not count tasks" "$OUT" "0/1 done" # --- No active sprint --- rm "$TEST_DIR/.dev-backlog/sprints/2026-03-test.md"