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
3 changes: 2 additions & 1 deletion backlog/sprints/2026-07-multi-track-sprints.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Replace the global single-active-sprint invariant with a component-partitioned m
- [x] #291 Phase 1a: scope: frontmatter + portfolio-aware read resolvers → PR #300 (merged)

### Batch 3 — Behavior (parallel-safe: disjoint files)
- [ ] #292 Phase 1b: sprint lifecycle track-awareness (init/close/mirror)
- [x] #292 Phase 1b: sprint lifecycle track-awareness (init/close/mirror) → PR #307
- [x] #293 Phase 1c: backlog-doctor active_sprint → scope-disjointness check → PR #305 (merged)

### Batch 4 — Spec amendment (HUMAN-GATED)
Expand All @@ -43,3 +43,4 @@ Replace the global single-active-sprint invariant with a component-partitioned m
- 2026-07-11: sprint planned. Epic #289 + children #290–#295 filed; dev-relay epic #954 + #955–#957 filed. Design doc landed in docs/ (uncommitted).
- 2026-07-12: Batches 1–2 landed while the sprint file was held: #290 RED gate (PR #297), #291 Phase 1a resolvers (PR #300) — sprint-state schema 2, portfolio + --track/--component, scopesOverlap in lib.js.
- 2026-07-12: #293 Phase 1c landed (PR #305) — doctor scope-disjointness with per-track fan-out; GATE_MT_DISJOINT/GATE_MT_OVERLAP enforced, smoke 172/172, 0 xfail; single-active output verified byte-identical. Sprint file re-added as sole active.
- 2026-07-12: #292 Phase 1b — sprint-init refuses only on scope overlap (--scope flag, D2 explicit), sprint-close/--track, sprint-mirror/--track; init/close single-track G4 verified byte-identical (text + exit codes + written files); fixed the cwd-dependent sprint-init.test.js #13 rot. Smoke 187/187.
66 changes: 66 additions & 0 deletions skills/dev-backlog/scripts/smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1285,6 +1285,72 @@ if (!c || c.status !== "warn" || c.informational !== true) process.exit(1);
if (!/cannot prove disjoint/.test(c.detail.summary)) process.exit(1);
'

# --- Phase 1b (#292) ENFORCED: lifecycle track-awareness (init/close) ---
# init: a disjoint-scope second track is created without refusal; an
# overlapping scope is refused naming the conflicting track. Scope is always
# explicit via --scope (D2) — never inferred from touched paths.
MT_LIFE_DIR="$TEST_DIR/mt-lifecycle"
mkdir -p "$MT_LIFE_DIR/backlog/sprints"
mt_write_sprint "$MT_LIFE_DIR/backlog/sprints/2026-07-auth.md" "Auth" 1 '["src/auth/**"]'
set +e
OUT=$(cd "$MT_LIFE_DIR" && node "$SCRIPT_DIR/sprint-init.js" "billing" --scope "src/billing/**" --json 2>/dev/null)
STATUS=$?
set -e
assert_equals "multi-track #292: disjoint second-track init exit code" "$STATUS" "0"
assert_json_eval "multi-track #292: disjoint init creates with scope frontmatter, no warnings" "$OUT" '
const j = JSON.parse(require("fs").readFileSync(0, "utf8"));
if (j.created !== true) process.exit(1);
if (!Array.isArray(j.warnings) || j.warnings.length !== 0) process.exit(1);
if (!/^scope: \["src\/billing\/\*\*"\]$/m.test(j.content)) process.exit(1);
'

set +e
OUT=$(cd "$MT_LIFE_DIR" && node "$SCRIPT_DIR/sprint-init.js" "auth-two" --scope "src/auth/api/**" --dry-run 2>&1)
STATUS=$?
set -e
assert_equals "multi-track #292: overlapping-scope init exit code" "$STATUS" "1"
assert_contains "multi-track #292: overlapping init names the conflicting track" "$OUT" "Active track overlaps on scope: 2026-07-auth.md"

# G4: first-sprint init (no active sprint yet) keeps today's text and exit code.
MT_INIT_SOLO_DIR="$TEST_DIR/mt-init-solo"
mkdir -p "$MT_INIT_SOLO_DIR/backlog/sprints"
set +e
OUT=$(cd "$MT_INIT_SOLO_DIR" && node "$SCRIPT_DIR/sprint-init.js" "solo-probe" --dry-run 2>/dev/null)
STATUS=$?
set -e
assert_equals "multi-track G4 #292: single-track init dry-run exit code" "$STATUS" "0"
assert_contains "multi-track G4 #292: single-track init dry-run text" "$OUT" "[dry-run] Would create:"

# close: --track picks one track out of a portfolio; ambiguous close still
# refuses (now with a --track hint); a no-match selector fails loud.
MT_CLOSE_DIR="$TEST_DIR/mt-close"
mkdir -p "$MT_CLOSE_DIR/backlog/sprints"
mt_write_sprint "$MT_CLOSE_DIR/backlog/sprints/2026-07-auth.md" "Auth" 1 '["src/auth/**"]'
mt_write_sprint "$MT_CLOSE_DIR/backlog/sprints/2026-07-billing.md" "Billing" 2 '["src/billing/**"]'

set +e
OUT=$(bash "$SCRIPT_DIR/sprint-close.sh" "$MT_CLOSE_DIR/backlog" 2>&1)
STATUS=$?
set -e
assert_equals "multi-track #292: ambiguous close exit code" "$STATUS" "1"
assert_contains "multi-track #292: ambiguous close refuses" "$OUT" "Refusing to close an ambiguous sprint"
assert_contains "multi-track #292: ambiguous close suggests --track" "$OUT" "Pass --track <slug> to close one track."

OUT=$(bash "$SCRIPT_DIR/sprint-close.sh" "$MT_CLOSE_DIR/backlog" --track 2026-07-billing --dry-run 2>&1)
assert_contains "multi-track #292: close --track dry-run targets the selected track" "$OUT" "Would set status: completed in $MT_CLOSE_DIR/backlog/sprints/2026-07-billing.md"
assert_contains "multi-track #292: close --track dry-run leaves files untouched" "$(grep '^status:' "$MT_CLOSE_DIR/backlog/sprints/2026-07-billing.md")" "active"

OUT=$(bash "$SCRIPT_DIR/sprint-close.sh" "$MT_CLOSE_DIR/backlog" --track 2026-07-billing 2>&1)
assert_contains "multi-track #292: close --track closes the selected track" "$(grep '^status:' "$MT_CLOSE_DIR/backlog/sprints/2026-07-billing.md")" "completed"
assert_contains "multi-track #292: close --track leaves the other track active" "$(grep '^status:' "$MT_CLOSE_DIR/backlog/sprints/2026-07-auth.md")" "active"

set +e
OUT=$(bash "$SCRIPT_DIR/sprint-close.sh" "$MT_CLOSE_DIR/backlog" --track bogus 2>&1)
STATUS=$?
set -e
assert_equals "multi-track #292: close --track no-match exit code" "$STATUS" "1"
assert_contains "multi-track #292: close --track no-match message" "$OUT" "No active track matches 'bogus'"

# (3) Back-compat (ENFORCED, GATE=1 — must stay GREEN on HEAD and after Phase 1).
# A single active track behaves exactly as today; this is the G4 text anchor
# (text output only — never snapshot --json, which changes by design).
Expand Down
63 changes: 45 additions & 18 deletions skills/dev-backlog/scripts/sprint-close.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
set -uo pipefail
# Close the active sprint: mark completed, move tasks, remind about context.
#
# Usage: bash scripts/sprint-close.sh [backlog-dir] [--dry-run] [--close-milestone]
# Usage: bash scripts/sprint-close.sh [backlog-dir] [--track slug] [--dry-run] [--close-milestone]
#
# With multiple active tracks, --track <slug> picks which one to close;
# without it the close refuses as ambiguous (a single active needs no flag).
#
# Steps:
# 1. Run backlog-doctor pre-close and compute the text-only reassess signal
Expand All @@ -18,24 +21,35 @@ BACKLOG_DIR="backlog"
DRY_RUN=false
CLOSE_MILESTONE=false
BACKLOG_DIR_SET=false
TRACK=""

for arg in "$@"; do
case "$arg" in
while [ "$#" -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN=true ;;
--close-milestone) CLOSE_MILESTONE=true ;;
--track)
shift
TRACK="${1:-}"
if [ -z "$TRACK" ]; then
echo "Missing value for --track"
exit 1
fi
;;
--track=*) TRACK="${1#--track=}" ;;

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 Reject empty --track= selectors

When a caller supplies --track= from an empty shell variable, this branch sets TRACK to an empty string and the later [ -n "$TRACK" ] check treats the command as if no selector was provided. In a single-active-sprint repo that means sprint-close.sh backlog --track= proceeds to close the only active sprint instead of failing like the --track form does for a missing value, so an invalid selector can still mutate the sprint file.

Useful? React with 👍 / 👎.

--*)
echo "Unknown argument: $arg"
echo "Unknown argument: $1"
exit 1
;;
*)
if $BACKLOG_DIR_SET; then
echo "Unexpected argument: $arg"
echo "Unexpected argument: $1"
exit 1
fi
BACKLOG_DIR="$arg"
BACKLOG_DIR="$1"
BACKLOG_DIR_SET=true
;;
esac
shift
done

# Optional provider mutations must be authorized before local sprint mutation.
Expand All @@ -54,19 +68,32 @@ if [ ! -d "$SPRINTS_DIR" ]; then
exit 1
fi

ACTIVE=$(find_active_sprint "$SPRINTS_DIR" 2>/dev/null)
ACTIVE_STATUS=$?
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
echo " - $(basename "$sprint")"
done
exit 1
fi
if [ -n "$TRACK" ]; then
ACTIVE=$(resolve_track "$SPRINTS_DIR" "$TRACK")
if [ -z "$ACTIVE" ]; then
echo "No active track matches '$TRACK'. Active tracks:"
find_active_sprints "$SPRINTS_DIR" | while IFS= read -r sprint; do
[ -z "$sprint" ] && continue
echo " - $(basename "$sprint" .md)"
done
exit 1
fi
else
ACTIVE=$(find_active_sprint "$SPRINTS_DIR" 2>/dev/null)
ACTIVE_STATUS=$?
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
echo " - $(basename "$sprint")"
done
echo "Pass --track <slug> to close one track."
exit 1
fi

if [ "$ACTIVE_STATUS" -ne 0 ]; then
echo "No active sprint to close."
exit 0
if [ "$ACTIVE_STATUS" -ne 0 ]; then
echo "No active sprint to close."
exit 0
fi
fi

SPRINT_NAME=$(basename "$ACTIVE" .md)
Expand Down
Loading
Loading