sprint-accept
Close out a sprint by reading sprint-plan.md, dev-report.md, all VP review files, and Phase 3 evals from a sprint directory. CTO synthesizes a final accept/reject decision and writes it to the sprint
Files1
View on GitHubSKILL.md.claude/skills/sprint-accept/SKILL.md
GitHubSprint Accept: $ARGUMENTS
Reads the entire sprint dir, summarizes artifacts, and produces a CTO decision file.
Inventory the sprint dir
SPRINT_DIR="$ARGUMENTS"
# zsh aborts the whole script on an unmatched glob (e.g. cto-decision-*.md in a
# sprint dir that has none yet). no_nomatch makes an unmatched glob pass through
# literally, as bash does — the [ -f ] guards below then handle the no-match case.
[ -n "${ZSH_VERSION:-}" ] && setopt no_nomatch sh_word_split
# >>> CTO-HOME ANCHOR (canonical — sync with: bash scripts/cto/lint-skills.sh --apply) >>>
# Fleet state lives in the CTO home, and neither obvious anchor is reliable alone:
# `git rev-parse` returns whatever repo the SHELL sits in, so one lingering cd into a dev
# repo silently retargets the skill; and $CLAUDE_PROJECT_DIR was observed EMPTY in skill
# shells (2026-08-13) — /handoff then resolved the registry against a dev repo and refused
# with an error that blamed the registry rather than the anchoring. So: try every anchor,
# VALIDATE each candidate before accepting it, and when none holds either fail naming the
# real cause or fall back explicitly and say so.
# Skills that read the fleet registry set CTO_HOME_REQUIRED=1 before this block.
#
# NO POSITIONAL PARAMETERS IN THIS BLOCK — no dollar-1, no dollar-2, not even awk's dollar-1.
# (Spelled out rather than written literally, because the rewrite described next hits comments
# too: a literal example here would itself be replaced with argument text.)
#
# This block is embedded in SKILL.md, and the skill runtime rewrites every dollar-followed-by-
# digits token in the FILE with the invocation's arguments before the shell ever sees it. The
# rewrite is document-wide: no awareness of code fences, no escape (a backslash before it does
# not protect it — it renders empty), and it applies inside comments and string literals alike.
# A shell function's own positional parameter is therefore replaced by argument text, and when
# the skill is invoked with no arguments it is replaced by nothing at all.
#
# The first version of this block used positional parameters and was consequently broken from
# the day it shipped: _cto_is_home tested a garbage path on every candidate, so the anchor
# NEVER matched, and every skill carrying it silently fell back to the cwd repo — precisely the
# confident wrong answer it was written to prevent. Found 2026-09-01, six weeks after it
# shipped, because the fallback usually landed on the right repo by luck.
#
# Candidates are passed in named variables instead. Brace-wrapped positionals happen to survive
# the rewrite; do not use them either — the next person to write the bare form reintroduces the
# bug, and it fails silently. Guarded by lint CHECK E.
_CTO_REJECTED=""
_CTO_CAND=""
_cto_is_home() { # in: _CTO_CAND · appends to _CTO_REJECTED
[ -n "$_CTO_CAND" ] && [ -f "$_CTO_CAND/.cto/projects.yaml" ] || return 1
# The PUBLIC template ships a placeholder registry (/Users/yourname/repos/my-project).
# Accepting it resolves cleanly and then reports a fleet of repos that do not exist —
# a confident wrong answer, which is worse than the error it replaced. Observed live:
# four dev repos still point .cto-path at the template, so this is the real path.
if grep -q '/Users/yourname/' "$_CTO_CAND/.cto/projects.yaml" 2>/dev/null; then
_CTO_REJECTED="$_CTO_REJECTED $_CTO_CAND (placeholder registry — the unconfigured template)"
return 1
fi
return 0
}
# Sets _CTO_FOUND rather than printing: a $(…) capture runs in a SUBSHELL, so the rejected-
# candidate diagnostics collected by _cto_is_home would be discarded exactly when they are
# needed — on the failure path.
_CTO_FOUND=""
_cto_walk() { # in: _CTO_START · out: _CTO_FOUND
_d="$_CTO_START"
while [ -n "$_d" ] && [ "$_d" != "/" ]; do
_CTO_CAND="$_d"; _cto_is_home && { _CTO_FOUND="$_d"; return 0; }
if [ -f "$_d/.cto-path" ]; then
_p="$(tr -d '[:space:]' < "$_d/.cto-path")"
_CTO_CAND="$_p"; _cto_is_home && { _CTO_FOUND="$_p"; return 0; }
fi
# `--` because a mis-rendered candidate can begin with a dash, and `dirname --repos=x`
# exits with "illegal option" instead of walking. That message was the only outward sign
# of the rewrite bug above for two weeks, and it was read as cosmetic noise.
_d="$(dirname -- "$_d")"
done
return 1
}
ROOT=""
for _c in "${CTO_HOME:-}" "${CTO_REPO:-}" "${CLAUDE_PROJECT_DIR:-}"; do
[ -n "$_c" ] || continue
_CTO_CAND="$_c"; _cto_is_home && { ROOT="$_c"; break; }
done
if [ -z "$ROOT" ]; then _CTO_START="$PWD"; if _cto_walk; then ROOT="$_CTO_FOUND"; fi; fi
if [ -z "$ROOT" ] && [ -f "$HOME/.cto/home" ]; then
_p="$(tr -d '[:space:]' < "$HOME/.cto/home")"
_CTO_CAND="$_p"; _cto_is_home && ROOT="$_p"
fi
if [ -z "$ROOT" ]; then
if [ "${CTO_HOME_REQUIRED:-0}" = "1" ]; then
echo "ERROR: no CTO home found — no directory containing .cto/projects.yaml." >&2
echo " \$CTO_HOME='${CTO_HOME:-}' \$CTO_REPO='${CTO_REPO:-}' \$CLAUDE_PROJECT_DIR='${CLAUDE_PROJECT_DIR:-}'" >&2
echo " walked up from: $PWD" >&2
# Report the permanent anchor's CONTENT, not just that the remedy exists. When the anchor
# is set correctly and the skill still fails, the error must not keep recommending it —
# that loop cost a full diagnosis cycle on 2026-09-01.
if [ -f "$HOME/.cto/home" ]; then
echo " ~/.cto/home is SET and was tested: '$(tr -d '[:space:]' < "$HOME/.cto/home")'" >&2
echo " It did not validate, so the remedy below is already applied and is NOT the fix." >&2
echo " Check that path holds .cto/projects.yaml, then suspect the skill rendering itself." >&2
else
echo " This is an ANCHORING failure, not a missing registry. Run the skill from the CTO" >&2
echo " home, or fix it permanently: echo /path/to/<project>-cto > ~/.cto/home" >&2
fi
[ -n "$_CTO_REJECTED" ] && { echo " rejected candidates:" >&2; printf '%s\n' "$_CTO_REJECTED" >&2; }
exit 1
fi
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
echo "NOTE: no CTO home found; using the current repo ($ROOT)." >&2
fi
CTO_REGISTRY="$ROOT/.cto/projects.yaml"
cd "$ROOT" || { echo "ERROR: cannot enter $ROOT" >&2; exit 1; }
# <<< CTO-HOME ANCHOR <<<
# (placed after the anchor: it needs $ROOT)
# `--land` re-runs ONLY the landing for an already-decided sprint (ADR-003), e.g. after fixing the
# cause of `landing: INCOMPLETE`. Flags are split off here so SPRINT_DIR stays a path.
LAND_ONLY=0
case " $SPRINT_DIR " in *" --land "*) LAND_ONLY=1; SPRINT_DIR="$(printf '%s' "$SPRINT_DIR" | sed 's/ *--land *//')";; esac
if [ "$LAND_ONLY" = 1 ]; then
bash "$ROOT/scripts/agentic/land-lane.sh" "$SPRINT_DIR"; echo "LAND_ONLY=1 — report the landing result above; no new decision."; exit 0
fi
if [ -z "$SPRINT_DIR" ] || [ ! -d "$SPRINT_DIR" ]; then
echo "ERROR: provide a path to a sprint directory"
echo "Usage: /sprint-accept <path-to-sprint-XX/>"
exit 1
fi
echo "Sprint directory: $SPRINT_DIR"
echo ""
echo "=== Files present ==="
ls -la "$SPRINT_DIR" | tail -n +2
echo ""
echo "=== Artifact summary ==="
for f in scope.md sprint-plan.md dev-report.md test-results.md demo-output.md conformance.md \
qa-report.md product-review.md vp-eng-review.md security-review.md infra-review.md \
vp-datascience-review.md test-eval.md cto-decision-*.md approval.md rejection.md; do
for match in "$SPRINT_DIR"/$f; do
if [ -f "$match" ]; then
size=$(wc -c < "$match")
lines=$(wc -l < "$match")
printf " [present] %-40s %6d bytes / %d lines\n" "$(basename $match)" "$size" "$lines"
fi
done
done
echo ""
echo "=== Missing canonical artifacts ==="
MISSING=0
for f in sprint-plan.md dev-report.md; do
if [ ! -f "$SPRINT_DIR/$f" ]; then
echo " [missing] $f — required for accept"
MISSING=$((MISSING + 1))
fi
done
# CONFORMANCE GATE (process, not outcome): you cannot accept BLIND. conformance.md must
# EXIST (verification must have happened) — but it need NOT PASS. Accept stays a decision:
# shipping with NOT-MET/UNVERIFIED criteria as documented known-issues is a CTO/CEO call.
# This mirrors the QA-UX gate (report must exist + be reviewed; ship-with-defects is your call).
if [ ! -f "$SPRINT_DIR/conformance.md" ]; then
echo " [missing] conformance.md — REQUIRED for accept (objective verification precedes the decision)"
echo " Run: /sprint-verify $SPRINT_DIR (after tests + the QA drive), then re-run /sprint-accept."
echo " (Refusing only because verification is ABSENT — not because it failed. Accept never requires conformance to PASS.)"
MISSING=$((MISSING + 1))
fi
if [ "$MISSING" -gt 0 ]; then
echo ""
echo "CANNOT ACCEPT: missing $MISSING canonical artifact(s). Sprint is not ready to decide on yet."
exit 2
fi
Synthesize the decision
# >>> CTO-HOME ANCHOR (canonical — sync with: bash scripts/cto/lint-skills.sh --apply) >>>
# Fleet state lives in the CTO home, and neither obvious anchor is reliable alone:
# `git rev-parse` returns whatever repo the SHELL sits in, so one lingering cd into a dev
# repo silently retargets the skill; and $CLAUDE_PROJECT_DIR was observed EMPTY in skill
# shells (2026-08-13) — /handoff then resolved the registry against a dev repo and refused
# with an error that blamed the registry rather than the anchoring. So: try every anchor,
# VALIDATE each candidate before accepting it, and when none holds either fail naming the
# real cause or fall back explicitly and say so.
# Skills that read the fleet registry set CTO_HOME_REQUIRED=1 before this block.
#
# NO POSITIONAL PARAMETERS IN THIS BLOCK — no dollar-1, no dollar-2, not even awk's dollar-1.
# (Spelled out rather than written literally, because the rewrite described next hits comments
# too: a literal example here would itself be replaced with argument text.)
#
# This block is embedded in SKILL.md, and the skill runtime rewrites every dollar-followed-by-
# digits token in the FILE with the invocation's arguments before the shell ever sees it. The
# rewrite is document-wide: no awareness of code fences, no escape (a backslash before it does
# not protect it — it renders empty), and it applies inside comments and string literals alike.
# A shell function's own positional parameter is therefore replaced by argument text, and when
# the skill is invoked with no arguments it is replaced by nothing at all.
#
# The first version of this block used positional parameters and was consequently broken from
# the day it shipped: _cto_is_home tested a garbage path on every candidate, so the anchor
# NEVER matched, and every skill carrying it silently fell back to the cwd repo — precisely the
# confident wrong answer it was written to prevent. Found 2026-09-01, six weeks after it
# shipped, because the fallback usually landed on the right repo by luck.
#
# Candidates are passed in named variables instead. Brace-wrapped positionals happen to survive
# the rewrite; do not use them either — the next person to write the bare form reintroduces the
# bug, and it fails silently. Guarded by lint CHECK E.
_CTO_REJECTED=""
_CTO_CAND=""
_cto_is_home() { # in: _CTO_CAND · appends to _CTO_REJECTED
[ -n "$_CTO_CAND" ] && [ -f "$_CTO_CAND/.cto/projects.yaml" ] || return 1
# The PUBLIC template ships a placeholder registry (/Users/yourname/repos/my-project).
# Accepting it resolves cleanly and then reports a fleet of repos that do not exist —
# a confident wrong answer, which is worse than the error it replaced. Observed live:
# four dev repos still point .cto-path at the template, so this is the real path.
if grep -q '/Users/yourname/' "$_CTO_CAND/.cto/projects.yaml" 2>/dev/null; then
_CTO_REJECTED="$_CTO_REJECTED $_CTO_CAND (placeholder registry — the unconfigured template)"
return 1
fi
return 0
}
# Sets _CTO_FOUND rather than printing: a $(…) capture runs in a SUBSHELL, so the rejected-
# candidate diagnostics collected by _cto_is_home would be discarded exactly when they are
# needed — on the failure path.
_CTO_FOUND=""
_cto_walk() { # in: _CTO_START · out: _CTO_FOUND
_d="$_CTO_START"
while [ -n "$_d" ] && [ "$_d" != "/" ]; do
_CTO_CAND="$_d"; _cto_is_home && { _CTO_FOUND="$_d"; return 0; }
if [ -f "$_d/.cto-path" ]; then
_p="$(tr -d '[:space:]' < "$_d/.cto-path")"
_CTO_CAND="$_p"; _cto_is_home && { _CTO_FOUND="$_p"; return 0; }
fi
# `--` because a mis-rendered candidate can begin with a dash, and `dirname --repos=x`
# exits with "illegal option" instead of walking. That message was the only outward sign
# of the rewrite bug above for two weeks, and it was read as cosmetic noise.
_d="$(dirname -- "$_d")"
done
return 1
}
ROOT=""
for _c in "${CTO_HOME:-}" "${CTO_REPO:-}" "${CLAUDE_PROJECT_DIR:-}"; do
[ -n "$_c" ] || continue
_CTO_CAND="$_c"; _cto_is_home && { ROOT="$_c"; break; }
done
if [ -z "$ROOT" ]; then _CTO_START="$PWD"; if _cto_walk; then ROOT="$_CTO_FOUND"; fi; fi
if [ -z "$ROOT" ] && [ -f "$HOME/.cto/home" ]; then
_p="$(tr -d '[:space:]' < "$HOME/.cto/home")"
_CTO_CAND="$_p"; _cto_is_home && ROOT="$_p"
fi
if [ -z "$ROOT" ]; then
if [ "${CTO_HOME_REQUIRED:-0}" = "1" ]; then
echo "ERROR: no CTO home found — no directory containing .cto/projects.yaml." >&2
echo " \$CTO_HOME='${CTO_HOME:-}' \$CTO_REPO='${CTO_REPO:-}' \$CLAUDE_PROJECT_DIR='${CLAUDE_PROJECT_DIR:-}'" >&2
echo " walked up from: $PWD" >&2
# Report the permanent anchor's CONTENT, not just that the remedy exists. When the anchor
# is set correctly and the skill still fails, the error must not keep recommending it —
# that loop cost a full diagnosis cycle on 2026-09-01.
if [ -f "$HOME/.cto/home" ]; then
echo " ~/.cto/home is SET and was tested: '$(tr -d '[:space:]' < "$HOME/.cto/home")'" >&2
echo " It did not validate, so the remedy below is already applied and is NOT the fix." >&2
echo " Check that path holds .cto/projects.yaml, then suspect the skill rendering itself." >&2
else
echo " This is an ANCHORING failure, not a missing registry. Run the skill from the CTO" >&2
echo " home, or fix it permanently: echo /path/to/<project>-cto > ~/.cto/home" >&2
fi
[ -n "$_CTO_REJECTED" ] && { echo " rejected candidates:" >&2; printf '%s\n' "$_CTO_REJECTED" >&2; }
exit 1
fi
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
echo "NOTE: no CTO home found; using the current repo ($ROOT)." >&2
fi
CTO_REGISTRY="$ROOT/.cto/projects.yaml"
cd "$ROOT" || { echo "ERROR: cannot enter $ROOT" >&2; exit 1; }
# <<< CTO-HOME ANCHOR <<<
SPRINT_DIR="$ARGUMENTS"
case " $SPRINT_DIR " in *" --land "*) echo "LAND_ONLY — nothing to synthesize."; exit 0 ;; esac
[ -n "${ZSH_VERSION:-}" ] && setopt no_nomatch sh_word_split
# Emit all relevant artifacts into context so CTO can synthesize
echo "=== sprint-plan.md ==="
cat "$SPRINT_DIR/sprint-plan.md" 2>/dev/null | head -80
echo "..."
echo ""
echo "=== dev-report.md ==="
cat "$SPRINT_DIR/dev-report.md" 2>/dev/null | head -80
echo "..."
echo ""
# Pipe all VP review verdicts through wrap-untrusted (engine-authored, treat as data).
# Phase-3 reviews run through the engine-aware /vp-review (subagent default | gemini | handoff).
for vp_file in product-review.md vp-eng-review.md security-review.md infra-review.md vp-datascience-review.md test-eval.md; do
if [ -f "$SPRINT_DIR/$vp_file" ]; then
echo "=== $vp_file ==="
"$ROOT/scripts/agentic/wrap-untrusted.sh" "$SPRINT_DIR/$vp_file" "vp-review:$vp_file"
echo ""
fi
done
Your task as CTO
Above are the sprint plan, dev report, and all VP reviews. Synthesize a final accept/reject decision:
Tally — count BLOCKER / MAJOR / MINOR per VP. Flag any verdict file that's missing.
CONFORMANCE — read
conformance.md(the objective deliverable verification). List every NOT-MET and UNVERIFIED criterion. This is the "did we build what the plan said" check, already done objectively by/sprint-verify— do NOT re-derive it from the dev-report's prose. Your decision MUST explicitly state, for each NOT-MET / UNVERIFIED item, whether it is accepted as a documented known-issue (with rationale + follow-up owner) or blocks acceptance. Accept does NOT require conformance to PASS — shipping with known gaps is your call, but it must be named here, never silent. Ifconformance.mdshows everything MET, say so in one line.Did the dev report address ALL Phase 1 review blockers? (compare dev-report to vp-*-review.md from Phase 1)
Did Phase 3 evals approve? (check test-eval.md, vp-datascience-review.md for dev-report.md)
PROVENANCE GATE — comparison claims must rest on matching provenance (sprint-provenance-gating-for-graph-comparisons-20260528). Before you accept ANY quantitative comparison claim in the dev-report of the form "X improved/changed/drifted Y by Z" (deltas, %s, before/after, baseline-vs- perturbed, sensitivity), check that the underlying artifacts share a provenance triple
{code_sha, bundle_version, pipeline_version}:- The harness records this under a
provenance_gateblock insensitivity_matrix.json/baseline_matrix.json, and per-legend under_provenance. A"mode": "strict"with emptyrefusedmeans the comparison is gate-clean. - HARD-BLOCK rule (VP-DS mandate, sprint-provenance-gating-20260528):
a claim of model or pipeline improvement ("X improved Y by Z", "+N%
recall", "precision lift", baseline-vs-treatment) is REJECTED if any
contributing artifact (a) used the
--allow-cross-versionescape hatch, or (b) lacks a valid bundle content hash on thebundle_versionaxis (i.e. it ends in@unhashed). You cannot validate a lift when the baseline and treatment don't share a verifiable triple. Do not accept the sprint on the strength of such a claim; record it as REJECTED-FOR-REVISION or an explicit action item. - Longitudinal/drift framing is the only legitimate use of the escape
hatch: "how did behavior drift across this bundle change?" may cite a
cross-version comparison as drift, never as an improvement result, and
only with the documented
cross_version_reasonpresent. - If any artifact is legacy / unprovenanced and no documented reason is present, the comparison is not acceptable as stated — surface it as unverified and call it out as an action item.
- Date timestamps are NOT acceptable as a freshness/equivalence signal (that was the failure mode this gate exists to prevent).
- The harness records this under a
Cross-VP convergence on remaining issues — name them.
Decision — one of:
APPROVED— sprint shipped; step 9 lands itAPPROVED-WITH-CONDITIONS— minor follow-ups recorded but ship is OKREJECTED-FOR-REVISION— material blockers remain, sprint continuesESCALATE-TO-CEO— judgment call only CEO can make
Write decision file to
$SPRINT_DIR/cto-decision-$(date +%Y%m%d-%H%M%S).mdwith:- Decision verdict
- Reasoning citing specific VP review excerpts
- Conformance disposition — the NOT-MET/UNVERIFIED items being accepted as known-issues (with follow-up owners) vs. those that blocked
- Action items if APPROVED-WITH-CONDITIONS or REJECTED
- PRD status update guidance for VP Prod
LAND IT (APPROVED or APPROVED-WITH-CONDITIONS only) — ADR-003. The CEO typing
/sprint-acceptis the approval to ship; landing is part of accepting, not a later chore. Run, in this order:bash scripts/agentic/land-lane.sh <sprint-dir> --dry-run # show the plan bash scripts/agentic/land-lane.sh <sprint-dir> [--allow-red "<reason>"]It commits the sprint dir (decision included, scoped add), refuses a lane that lacks the latest base, waits for CI on the lane's head (per the repo's
.claude/land.yamlci:policy — by default red CI only WARNS until that repo's CI has passed once on a landing, then it refuses), fast-forwards the base, tagssprint/<slug>, deletes the lane branch, and archives + removes the lane worktree. Never use--allow-redon your own judgment: only with the CEO's stated reason, which it records. Run the CI wait in the background if CI is slow.landing: COMPLETE→ report it. Then offer the CEO a reap of anything else this lane left behind:bash scripts/agentic/reap-worktrees.sh <repo>(report-only;--reapremoves only REAPABLE rows, never anything a live lane uses).landing: INCOMPLETE at <step>→ the sprint is ACCEPTED but NOT LANDED. Say so plainly, give the step and reason, and fix the cause (e.g. merge the base into the lane). Re-run only the landing with/sprint-accept <sprint-dir> --land.- REJECTED / ESCALATE → nothing lands.
Present 5-line summary to CEO — including the landing line (COMPLETE, or INCOMPLETE at which step).
Sign as: — CTO