Imported from bossanova-dev/bossanova (
services/boss/internal/skillinstall/skills/boss-plan/SKILL.md). Install upstream withnpx skills add bossanova-dev/bossanova --skill boss-plan. Copyright stays with the author.
boss-plan
Turn a vague, one-line tracker ticket into a fully-planned ticket (in the planned state) with an implementation-ready plan attached. Use when asked to "plan a tracker ticket", "plan the next ticket", "boss-plan", or given a ticket ID.
This skill is interactive by default — it may drive AskUserQuestion through a discovered
draft extension. Under BOSS_CRON=true it runs fully headless, dispatching a single awaited
subagent for recon + drafting (Phase 2), so it is safe to schedule unattended.
- Leave no local artifacts. At every terminal state, discard the scratch you created (gitignored dirs, seeded design docs,
mktempfiles) so the worktree is clean — in all modes, headless (BOSS_CRON=true) especially. - Dispatch zero-change work as such. Planning runs commit nothing, so a plain session finalizes
blockedbehind an empty draft PR. See Sessions that change nothing in thebossskill:create_sessiontakesquick_chat(no worktree or PR) ordefer_pr(worktree, no up-front PR).
Headless mode. If BOSS_CRON=true, no human can answer AskUserQuestion, so never call it —
in orchestrator/subagent, any phase. Path: preflight → select the
queue head → dispatch ONE general-purpose drafting subagent (toolbox/bs-dispatch-await.mjs;
Task/spawn_agent+wait_agent) → classify its sentinel → upload + write back to Linear. Use reasonable defaults,
discard local artifacts, and never block waiting for input.
On-demand references (read only when the mode calls for it)
Mode-exclusive prose lives in references/*.md, loaded only on the path that needs it. The
default headless orchestrator path reads neither — the resident body carries the whole skeleton.
| Reference | Read it when… |
|---|---|
references/interactive-mode.md |
Interactive /boss-plan only — Phase 1 confirm loop, design-doc seed, draft resolution |
references/headless-drafting-brief.md |
Passed (by path) to the Phase 2 drafting subagent — never read by the orchestrator |
references/extension-reviewers.md |
Phase 3.5 — repo-local boss-plan-* extension plan-reviewers (additive; no-op when none) |
Workspace facts (do not re-discover). Load the config once in Phase 0 —
loadSkillConfig({cwd}) → config; tc = trackerConfigFor(config) — and reference these role
names generically everywhere else:
- Reach the tracker only through the resolved tracker adapter; its server, team, team-key and
workspace come from
trackerConfigFor(config)(never inline them, and never pass aprojectfilter). - Statuses by role: the unplanned state (start) and the planned state (end), resolved from
trackerConfigFor(config).states.{unplanned,planned}(withinProgress/inReviewfor the active-backlog reads). - Pipeline label roles resolve through
labelName(config, '<role>'), whose keys are camelCase:agentFriendly,needsHuman,agentPlan,agentQuestion,epic— the display names they resolve to areagent-friendly,needs-human, and so on.labelNamefails closed — an unconfigured or misspelled role throws — so never hand it a display name. Never create labels.agentFriendlyandneedsHumanare mutually exclusive (every plan gets exactly one). - Content-taxonomy labels (
bug,feature,improvement,docs) are the tracker's own display names, not fixed pipeline roles. Read the issue's existing set with thereadLabelsop and merge — preserve what it returned, and add one only when it genuinely applies. Resolve each taxonomy name withoptionalLabelName(config, '<role>'), whose keys arebug,feature,improvement,docs; if it returnsnull, apply the literal display name. Never create labels. - Tracker priority numeric:
1=Urgent, 2=High, 3=Medium, 4=Low, 0=None. - Dependency links use the tracker's
blocks/blocked byrelations. A blocker is "cleared" only when its state type is completed or canceled (PR merged / work dropped) — theDEFAULT_CLEARED_STATE_TYPES/DEFAULT_CANCELED_STATE_TYPESrule intoolbox/plan-deps-lib.mjs. boss-build will not start a ticket blocked by an uncleared blocker. - Proof publishing remains independent of implementation-plan storage. Its configured publish
adapter and
publishConfigcontinue to govern proof artifacts only.
Phase 0 — Preflight
- Self-disable when this repo has no configured tracker. This runs in both interactive and
headless modes and precedes every tracker read/write. Probe the config seam and, when the repo
has no
.boss-skills.json/ no configured tracker, print exactly one line and exit 0 — a clean no-op, not an error (a/boss-planin an unrelated repo is a no-op; a non-zero exit would surface as a cron/agent error):
That firstBOSS_PLAN_ENV="${BOSS_SKILLS_HOME:-$HOME/.claude/skills}/boss-plan/toolbox/boss-plan-env.sh"; [ -f "$BOSS_PLAN_ENV" ] || BOSS_PLAN_ENV="$HOME/.claude/skills/boss-plan/toolbox/boss-plan-env.sh"; [ -f "$BOSS_PLAN_ENV" ] || BOSS_PLAN_ENV="$HOME/.codex/skills/boss-plan/toolbox/boss-plan-env.sh"; [ -f "$BOSS_PLAN_ENV" ] || { echo "BLOCKED: installed boss skills missing or stale - run 'boss skills install'"; exit 1; }; . "$BOSS_PLAN_ENV" CONFIGURED=$(node -e 'import(require("node:url").pathToFileURL(process.env.BOSS_PLAN_TOOLBOX+"/skill-config.mjs").href).then(m=>{const c=m.loadSkillConfig({cwd:process.cwd()});process.stdout.write(m.isConfiguredForPlanning(c)?"yes":"no")}).catch(e=>{process.stderr.write("boss-plan preflight: "+(e&&e.message||e)+"\n");process.stdout.write("error")})') # `isConfiguredForPlanning` requires the tracker identity AND the full state role map # (`states.{unplanned,planned,inProgress,inReview}`), so a repo configured only for a stateless # core self-disables cleanly ('no') instead of running with undefined state names. # Distinguish a loader failure (malformed/invalid .boss-skills.json → 'error' or empty) from a # valid "not planning-ready" ('no'): loadSkillConfig throws a `skill-config:` error on a present # but broken config, so a broken config must abort loudly, never skip silently as a clean no-op. if [ "$CONFIGURED" != "yes" ] && [ "$CONFIGURED" != "no" ]; then echo "boss-plan: .boss-skills.json is present but could not be loaded (see error above) — aborting instead of skipping." >&2 exit 1 fi if [ "$CONFIGURED" != "yes" ]; then echo "boss-plan: no configured tracker in .boss-skills.json for this repo — nothing to plan here; skipping." exit 0 fi # Amortized self-heal for regular plan-scratch files orphaned by runs that abort before cleanup. node "$BOSS_PLAN_TOOLBOX/plan-scratch-reap.mjs" .linear-plans || echo "warning: stale plan-scratch reap failed (non-fatal)" >&2.line is the toolbox preamble. Each Bash tool call is a fresh shell, so every command block that dereferences$BOSS_PLAN_TOOLBOXmust begin with it; an exported value never survives to the next block. It sourcestoolbox/boss-plan-env.sh, which is what actually resolvesBOSS_SKILLS_HOME(a pre-set value first, else the first of~/.claude/skillsand~/.codex/skillsthat carriesboss-plan/toolbox/boss-plan-env.sh— the helper file, not merely the directory, which a stale tree keeps long after it stops carrying the helper), sets and exportsBOSS_PLAN_TOOLBOX, and fails loudly when no tree carries it. The line probes those same candidates itself, with[ -f ]tests and||reassignments, because the helper cannot locate itself before it is read.~/.claude/skillsis named a second time on purpose:${BOSS_SKILLS_HOME:-…}supplies its default only when the variable is unset, so without the explicit candidate a pre-set value drops that tree out of the search entirely.loadSkillConfigis synchronous and takes an options object (loadSkillConfig({ cwd })); positional or awaited calls read as broken config. - Report installed skill drift before planning. If
bossresolves, run the read-onlyboss skills check --gatebefore any tracker write. It fails only on installed-vs-checkout drift that is notself-editedby this branch; non-zero is advisory — print awarning:line with the reported reinstall remedy and continue, because drift is bookkeeping and a stale tree still runs. The hard stop is the line before it: a missingboss-plan-env.shmeans the skills are not installed at all, and nothing downstream can execute. Withoutboss, keep the older warning probe so drift is visible rather than called clean. Re-derive the path first, since an unset guard is silent like a clean tree:
ABOSS_PLAN_ENV="${BOSS_SKILLS_HOME:-$HOME/.claude/skills}/boss-plan/toolbox/boss-plan-env.sh"; [ -f "$BOSS_PLAN_ENV" ] || BOSS_PLAN_ENV="$HOME/.claude/skills/boss-plan/toolbox/boss-plan-env.sh"; [ -f "$BOSS_PLAN_ENV" ] || BOSS_PLAN_ENV="$HOME/.codex/skills/boss-plan/toolbox/boss-plan-env.sh"; [ -f "$BOSS_PLAN_ENV" ] || { echo "BLOCKED: installed boss skills missing or stale - run 'boss skills install'"; exit 1; }; . "$BOSS_PLAN_ENV" if BOSS_BIN="$(command -v boss 2>/dev/null)"; then if O="$("$BOSS_BIN" skills check --gate 2>&1)"; then if [ -n "$O" ]; then printf '%s\n' "$O" >&2; fi else case "$O" in *--gate*) node "$BOSS_PLAN_TOOLBOX/toolbox-drift.mjs" --toolbox "$BOSS_PLAN_TOOLBOX" || true ;; *) printf '%s\n' "$O" >&2 R="$(printf '%s\n' "$O" | sed -n 's/^ run `\(.*\)`$/\1/p' | head -n 1)" if [ -n "$R" ]; then echo "warning: installed boss skills drift from checkout source; run: $R — bookkeeping only, work state unaffected" >&2 else echo "warning: installed boss skills drift from checkout source; see gate output above — bookkeeping only, work state unaffected" >&2 fi ;; esac fi elif [ -f "$BOSS_PLAN_TOOLBOX/toolbox-drift.mjs" ]; then node "$BOSS_PLAN_TOOLBOX/toolbox-drift.mjs" --toolbox "$BOSS_PLAN_TOOLBOX" || true else echo "boss-toolbox-drift: (drift helper not installed) — this install predates the check; drift is UNKNOWN, not clean." >&2 fiboss-toolbox-drift:line is the no-CLI fallback signal: warning-only, because that helper may itself be stale. Re-vendor and reinstall the skills to clear it. Either way drift never decides a terminal state. - Require the configured tracker's optional
preparePlanAttachment,finalizePlanAttachment,readPlanAttachment, anddeletePlanAttachmentoperations now. If any is absent, stop before drafting or tracker writes. These names are conventional tracker-adapter operations declared in the adapteroperationMap(OPTIONAL_TRACKER_OPERATIONSintracker/adapter-core.mjs), not toolbox exports or greppable helper symbols; for a tool-backed adapter, each op'stoolfield is the concrete capability to probe.deletePlanAttachmentis required here, not at its first use: every upload site reads its artifact back and deletes a confirmed-unreadable orphan (references/plan-storage.mdstep 5), so a missing op must fail with nothing written. Native tracker attachments are the only implementation-plan store and never change proof storage. - Confirm the tracker adapter is reachable with a cheap read (its status-list capability scoped to
trackerConfigFor(config).team).
Phase 1 — Select the issue
- If the user gave a ticket ID: call
get_issuewith it. Respect that choice regardless of status.- Interactive: if it is already in the planned/in-progress/
Done/Canceledstate, warn and confirm before re-planning (seereferences/interactive-mode.md). - Headless (
BOSS_CRON=true): do not ask. A cron job that names a ticket means to consider that ticket, but the idempotence precheck below still wins: an already-planned ticket with a valid description and canonical plan attachment exits cleanly without re-drafting. If the ticket isDone/Canceled, log a warning and stop (re-planning finished work unattended is almost never intended) rather than blocking.
- Interactive: if it is already in the planned/in-progress/
- Otherwise: list the team's unplanned issues via the tracker adapter's list/select capability —
scoped to
trackerConfigFor(config).teamand theunplannedstate,limit=250. Rank the whole queue by priority, reading the tracker's numbers correctly: Urgent(1) > High(2) > Medium(3)Low(4) > None(0). Tie-break by oldest
createdAtfirst. Keep this ranked list.- Interactive: show the head of the ranked queue and run the confirm loop (plan this one /
skip this one / pick a different one / cancel) — see
references/interactive-mode.md.skipwalks down the ranked list. - Headless (
BOSS_CRON=true): do not ask. Select the head of the ranked queue (highest priority, oldest tie-break) and proceed straight to Phase 2. If the unplanned queue is empty, report that and stop.
- Interactive: show the head of the ranked queue and run the confirm loop (plan this one /
skip this one / pick a different one / cancel) — see
Before Phase 2 in both modes, run the idempotence precheck. Write the selected issue payload from
the Phase 1 read to .linear-plans/<ISSUE-ID>.precheck.json and invoke the deterministic guard
(planIdempotencePrecheck(...) in $BOSS_PLAN_TOOLBOX/plan-run-guards.mjs):
BOSS_PLAN_ENV="${BOSS_SKILLS_HOME:-$HOME/.claude/skills}/boss-plan/toolbox/boss-plan-env.sh"; [ -f "$BOSS_PLAN_ENV" ] || BOSS_PLAN_ENV="$HOME/.claude/skills/boss-plan/toolbox/boss-plan-env.sh"; [ -f "$BOSS_PLAN_ENV" ] || BOSS_PLAN_ENV="$HOME/.codex/skills/boss-plan/toolbox/boss-plan-env.sh"; [ -f "$BOSS_PLAN_ENV" ] || { echo "BLOCKED: installed boss skills missing or stale - run 'boss skills install'"; exit 1; }; . "$BOSS_PLAN_ENV"
PRECHECK=".linear-plans/<ISSUE-ID>.precheck.json"
node "$BOSS_PLAN_TOOLBOX/plan-run-guards.mjs" idempotence "$PRECHECK"
If it prints action: "noop", delete the scratch file, print one line naming the ticket and the
satisfied conjuncts (planned state, valid description, canonical plan attachment), then exit 0
with zero tracker writes. If it prints action: "plan", log every reasons[] token and
continue. This precheck applies to explicitly-named tickets as well as queue-selected tickets; a
named ticket is not permission to destructively re-draft an already valid plan.
Phase 2 — Draft the plan
The plan itself — codebase recon, the review dimensions, and the polished write-up — is produced per the Phase 3 plan requirements (the shared contract for what a plan must contain). The two modes differ only in who drafts:
Draft-resolution (shared Fallback contract)
Resolve drafting by the Fallback contract: discovered boss-plan-* role: draft
extension → host built-in → inline prompt; tiers 2/3 suppressed only when a Tier-1 dispatch
succeeded, never merely because an extension exists. A dispatch succeeded only when its
result is valid AND the requested non-empty plan exists at the per-dispatch plan path that
dispatch alone was given, written by that dispatch — never at a path a peer could have written;
promote the first success to the real plan path. Record
extension <name>: skipped (<reason>) for every failed dispatch, including when a sibling
succeeded; when none succeeded, fall through to tier 2, then tier 3.
Interactive (default /boss-plan)
Resolve the draft/review step via the Fallback contract; the interactive
resolution and tier-3 inline drafting prompt live in references/interactive-mode.md. Then
continue to Phase 3.5 → Phase 4.
Headless (BOSS_CRON=true) — dispatch ONE awaited drafting subagent
Do not draft inline. Recon, drafting, and the self-review dimensions are bulk context; keeping them on the main thread is exactly the cost this mode avoids. Instead:
Bulk-output discipline (no raw bulk in the orchestrator). The drafting dispatch keeps its bulk material — the codebase recon and the drafted plan body — in the subagent's own context and returns only the plan-file path plus a bounded metadata object; the orchestrator never pastes the plan body or a subagent transcript back into its own context. It classifies the outcome from the run-file sentinel only (never from returned prose) and reads the finished plan file exactly once, for the Phase 4 secret gate.
-
Create the per-run sentinel context (the subagent writes its terminal decision here; the orchestrator classifies from the file only).
DISPATCH_FAILUREmust stay byte-identical to the module constant inbs-run-sentinel.mjs:BOSS_PLAN_ENV="${BOSS_SKILLS_HOME:-$HOME/.claude/skills}/boss-plan/toolbox/boss-plan-env.sh"; [ -f "$BOSS_PLAN_ENV" ] || BOSS_PLAN_ENV="$HOME/.claude/skills/boss-plan/toolbox/boss-plan-env.sh"; [ -f "$BOSS_PLAN_ENV" ] || BOSS_PLAN_ENV="$HOME/.codex/skills/boss-plan/toolbox/boss-plan-env.sh"; [ -f "$BOSS_PLAN_ENV" ] || { echo "BLOCKED: installed boss skills missing or stale - run 'boss skills install'"; exit 1; }; . "$BOSS_PLAN_ENV" RUN_SENTINEL="$BOSS_PLAN_TOOLBOX/bs-run-sentinel.mjs" test -f "$RUN_SENTINEL" || { echo "BLOCKED: bs-run-sentinel.mjs missing" >&2; exit 1; } DISPATCH_FAILURE="dispatch-failure" PLAN_PATH=".linear-plans/<ISSUE-ID>-<slug>.md" # compute the slug with plan-slug.mjs issueSlug RUN="$(node "$RUN_SENTINEL" make-ctx boss-plan)" RUN_ID="${RUN%%$'\t'*}"; RUN_DIR="${RUN#*$'\t'}" export RUN_SENTINEL DISPATCH_FAILURE PLAN_PATH RUN_ID RUN_DIR -
Before dispatch, write the byte copy of the Phase 1
get_issuedescription to.linear-plans/<ISSUE-ID>.image-guard-orig.md. This is the single raw-description snapshot for the whole run: Phase 4 reuses it, and the worker receives this path as its only description source. Do not let the worker re-read the tracker description; signed upload URLs can rotate and fail the parity gate. -
Dispatch ONE awaited
general-purposesubagent (subagent_type: general-purpose,never
run_in_background). Pass it the pathreferences/headless-drafting-brief.md(not its text), the ticketid/title, the description snapshot path, the targetPLAN_PATH, and the sentinel contextRUN_SENTINEL/RUN_DIR/RUN_ID. The brief tells it to recon, work the review dimensions, write the plan toPLAN_PATH, write the terminal sentinel with aplanPathpayload, and return only the bounded metadata object (planPath,labels,agentFriendly,estimate,priority,openQuestions,descriptionSummary) — never the plan file's content (returning content re-inflates the caller: codex fold).If the dispatch tool itself errors before the subagent starts, treat that as a dispatch failure: print one clear stderr line, clean up the sentinel context if it exists, make no Linear write, and exit non-zero. Do not draft inline in headless mode.
-
Classify from the run-file sentinel only, then re-verify (never trust the sentinel alone — epic D11): Measurement is orchestrator-owned. The orchestrator measures on-disk artifacts with
statorwc -c; reported size is never the input. Afterok, re-verify every orchestrator-consumed artifact:PLAN_PATH, guard, child-plan and epic-spec scratch. Epics require artifact manifests (guardScratchPaths,epicSpecPaths). Zero-byte original guard sources (.image-guard-orig.md/.attachment-guard-orig.md) are ok; others non-empty. Missing, empty, directory or wrong-path ⇒echo "$DISPATCH_FAILURE: sentinel ok but artifact missing/empty or wrong path (<path>) — no Linear write, aborting" >&2.READ="$(node "$RUN_SENTINEL" read "$RUN_DIR" "$RUN_ID" draft)" RC_STATUS="$(printf '%s' "$READ" | jq -r '.status')" if [ "$RC_STATUS" != "ok" ]; then # missing/stale sentinel: SAFE branch — NO Linear write, non-zero exit. echo "$DISPATCH_FAILURE: drafting subagent left no valid sentinel (status=$RC_STATUS) — no Linear write, aborting" >&2 node "$RUN_SENTINEL" cleanup "$RUN_DIR" # Abort skips Phase 5; delete the epic/guard/run-boundary scratch families now. CLEANUP_RC=0 rm -f .linear-plans/<ISSUE-ID>.{precheck,draft-metadata,premises,premise-states}.json || CLEANUP_RC=1 if [ -d .linear-plans ]; then find .linear-plans -maxdepth 1 -type f -name '<ISSUE-ID>-child-*.md' -delete || CLEANUP_RC=1; fi if [ -d .linear-plans ]; then find .linear-plans -maxdepth 1 -type f -name '<ISSUE-ID>-child-*.md.rejected' -delete || CLEANUP_RC=1; fi if [ -d .linear-plans ]; then find .linear-plans -maxdepth 1 -type f -name '<ISSUE-ID>*.image-guard-*.md' -delete || CLEANUP_RC=1; fi if [ -d .linear-plans ]; then find .linear-plans -maxdepth 1 -type f -name '<ISSUE-ID>*.attachment-guard-orig.md' -delete || CLEANUP_RC=1; fi if [ -d .linear-plans ]; then find .linear-plans -maxdepth 1 -type f -name '<ISSUE-ID>*.attachment-headers-*.json' -delete || CLEANUP_RC=1; fi if [ -d .linear-plans ]; then find .linear-plans -maxdepth 1 -type f -name '<ISSUE-ID>*.epic-spec.json' -delete || CLEANUP_RC=1; fi if [ -d .linear-plans ] && [ -n "$(find .linear-plans -maxdepth 1 -type f \( -name '<ISSUE-ID>-child-*.md' -o -name '<ISSUE-ID>-child-*.md.rejected' -o -name '<ISSUE-ID>*.image-guard-*.md' -o -name '<ISSUE-ID>*.attachment-guard-orig.md' -o -name '<ISSUE-ID>*.attachment-headers-*.json' -o -name '<ISSUE-ID>*.epic-spec.json' \) -print)" ]; then CLEANUP_RC=1; fi if [ "$CLEANUP_RC" != 0 ]; then echo "warning: scratch cleanup failed — .linear-plans may still hold plan text, tracker state or signed upload headers" >&2; fi exit 1 fi node -e 'const f=require("fs"),p=require("path"),[r,L,F]=process.argv.slice(1),x=JSON.parse(r).payload||{},T=c=>c?.trim?.(),B="epicSpecPaths",H="guardScratchPaths",P="childPlanPaths",K=["planPath",H,P,B,"attachmentHeaderPaths"],v=k=>{const q=x[k]||[];return k===P&&q&&!Array.isArray(q)&&typeof q=="object"?Object.values(q):[].concat(q)},g=s=>s.toLowerCase().replace(/[^a-z\d]+/g,"-").replace(/^-+|-+$/g,""),n=(id,t)=>id.toUpperCase()+"-"+g(t);let b=0,E=c=>{console.error(`${F}: sentinel ok but artifact missing/empty or wrong path (${c}) — no Linear write, aborting`);b=1},S=v(B).filter(T),G=v(H).filter(T),D=p.resolve(".linear-plans");if(x.epic){const I=v("childIds").filter(T),M=typeof x[P]=="object"&&!Array.isArray(x[P])?x[P]:{},C=I.map(id=>M[id]).filter(T),R=T(x.epicParentId),A=[],U=new Set,O=p.resolve(D,`${R}.epic-spec.json`);for(const k of[H,B])if(!Array.isArray(x[k]))E(k);if(!S.length)E(B);for(const s of S){if(p.resolve(s)!==O){E(B);continue}try{const q=JSON.parse(f.readFileSync(s));if(T(q.parentId)!==R)E(B);for(const c of q.children||[])if(T(c.key)&&T(c.title))A.push([c.key,c.title])}catch{E(s)}}if(!R||I.some(id=>"image-guard-orig attachment-guard-orig image-guard-new".split` `.some(w=>!G.some(c=>p.basename(c)===`${R}.child-${id}.${w}.md`))))E(H);if(!R||!I.length||A.length!==I.length||C.length!==I.length||new Set(C.map(c=>p.resolve(c))).size!==I.length)E(P);for(const id of I){const c=T(M[id]);if(!c){E(`${P}.${id}`);continue}const j=A.findIndex(y=>p.basename(c)===`${R}-child-${y[0]}-${n(id,y[1])}.md`);if(j<0||U.has(j))E(c);else U.add(j)}if(U.size!==A.length)E(P)}else if(!v("planPath").some(T))E("planPath");const P0=p.resolve(L);for(const k of K)for(const c of v(k))if(T(c)){const z=p.resolve(c),a=z===P0||p.dirname(z)===D,m=a&&f.existsSync(z)&&f.statSync(z),s=m&&m.isFile()&&(m.size||k===H&&/-guard-orig[.]md$/.test(z));if(!s)E(c)}process.exit(b)' "$READ" "$PLAN_PATH" "$DISPATCH_FAILURE" || { node "$RUN_SENTINEL" cleanup "$RUN_DIR" # Artifact verification failure also skips Phase 5; remove the same scratch families. CLEANUP_RC=0 rm -f .linear-plans/<ISSUE-ID>.{precheck,draft-metadata,premises,premise-states}.json || CLEANUP_RC=1 if [ -d .linear-plans ]; then find .linear-plans -maxdepth 1 -type f -name '<ISSUE-ID>-child-*.md' -delete || CLEANUP_RC=1; fi if [ -d .linear-plans ]; then find .linear-plans -maxdepth 1 -type f -name '<ISSUE-ID>-child-*.md.rejected' -delete || CLEANUP_RC=1; fi if [ -d .linear-plans ]; then find .linear-plans -maxdepth 1 -type f -name '<ISSUE-ID>*.image-guard-*.md' -delete || CLEANUP_RC=1; fi if [ -d .linear-plans ]; then find .linear-plans -maxdepth 1 -type f -name '<ISSUE-ID>*.attachment-guard-orig.md' -delete || CLEANUP_RC=1; fi if [ -d .linear-plans ]; then find .linear-plans -maxdepth 1 -type f -name '<ISSUE-ID>*.attachment-headers-*.json' -delete || CLEANUP_RC=1; fi if [ -d .linear-plans ]; then find .linear-plans -maxdepth 1 -type f -name '<ISSUE-ID>*.epic-spec.json' -delete || CLEANUP_RC=1; fi if [ -d .linear-plans ] && [ -n "$(find .linear-plans -maxdepth 1 -type f \( -name '<ISSUE-ID>-child-*.md' -o -name '<ISSUE-ID>-child-*.md.rejected' -o -name '<ISSUE-ID>*.image-guard-*.md' -o -name '<ISSUE-ID>*.attachment-guard-orig.md' -o -name '<ISSUE-ID>*.attachment-headers-*.json' -o -name '<ISSUE-ID>*.epic-spec.json' \) -print)" ]; then CLEANUP_RC=1; fi if [ "$CLEANUP_RC" != 0 ]; then echo "warning: scratch cleanup failed — .linear-plans may still hold plan text, tracker state or signed upload headers" >&2; fi exit 1 } EPIC="$(printf '%s' "$READ" | jq -r '.payload.epic // empty')" PREMISES="$(printf '%s' "$READ" | jq -c '.payload.premises // []')" if [ "$EPIC" = "true" ]; then # EPIC outcome: the subagent claims it performed ALL tracker writes itself (children # created + wired, parent repurposed with the parent-label exception, moved # unplanned → planned). BEFORE accepting, RE-VERIFY the epic against Linear (never trust # the sentinel alone — the subagent may have written `ok` too early / with partial # tracker writes, mirroring the single-ticket plan-file re-verify below): EPIC_PARENT="$(printf '%s' "$READ" | jq -r '.payload.epicParentId // empty')" # Run BOTH Linear MCP reads NOW and promote only from their actual results. EPIC_REVERIFIED=false # (a) get_issue "$EPIC_PARENT": parent planned, epic-labeled, not unplanned. # (b) list_issues parentId="$EPIC_PARENT" limit=250; hydrate each child with get_issue; require # payload `childIds` match, every child planned + canonical-plan attached, and # `reconcileEpicChildren(spec, hydratedLiveChildren)` passes. missing/empty childIds is a sentinel-shape failure, not a silent fallback. # Both true ⇒ EPIC_REVERIFIED=true; otherwise SAFE branch — NO success report. if [ "$EPIC_REVERIFIED" != "true" ]; then echo "$DISPATCH_FAILURE: epic sentinel ok but reverify failed (parent still unplanned, or children missing/short) — no success report, aborting" >&2 node "$RUN_SENTINEL" cleanup "$RUN_DIR" # Reverify-fail also skips Phase 5; remove the same scratch families. CLEANUP_RC=0 rm -f .linear-plans/<ISSUE-ID>.{precheck,draft-metadata,premises,premise-states}.json || CLEANUP_RC=1 if [ -d .linear-plans ]; then find .linear-plans -maxdepth 1 -type f -name '<ISSUE-ID>-child-*.md' -delete || CLEANUP_RC=1; fi if [ -d .linear-plans ]; then find .linear-plans -maxdepth 1 -type f -name '<ISSUE-ID>-child-*.md.rejected' -delete || CLEANUP_RC=1; fi if [ -d .linear-plans ]; then find .linear-plans -maxdepth 1 -type f -name '<ISSUE-ID>*.image-guard-*.md' -delete || CLEANUP_RC=1; fi if [ -d .linear-plans ]; then find .linear-plans -maxdepth 1 -type f -name '<ISSUE-ID>*.attachment-guard-orig.md' -delete || CLEANUP_RC=1; fi if [ -d .linear-plans ]; then find .linear-plans -maxdepth 1 -type f -name '<ISSUE-ID>*.attachment-headers-*.json' -delete || CLEANUP_RC=1; fi if [ -d .linear-plans ]; then find .linear-plans -maxdepth 1 -type f -name '<ISSUE-ID>*.epic-spec.json' -delete || CLEANUP_RC=1; fi if [ -d .linear-plans ] && [ -n "$(find .linear-plans -maxdepth 1 -type f \( -name '<ISSUE-ID>-child-*.md' -o -name '<ISSUE-ID>-child-*.md.rejected' -o -name '<ISSUE-ID>*.image-guard-*.md' -o -name '<ISSUE-ID>*.attachment-guard-orig.md' -o -name '<ISSUE-ID>*.attachment-headers-*.json' -o -name '<ISSUE-ID>*.epic-spec.json' \) -print)" ]; then CLEANUP_RC=1; fi if [ "$CLEANUP_RC" != 0 ]; then echo "warning: scratch cleanup failed — .linear-plans may still hold plan text, tracker state or signed upload headers" >&2; fi exit 1 fi # reverify PASSED: there is NO single-ticket plan file, and the single-ticket # metadata (labels/agentFriendly/estimate/…) does NOT apply. SKIP Phase 3.5 and # Phase 4 entirely and go straight to Phase 5 (cleanup) + Phase 6 (report), using # the bounded epic metadata (epicParentId, childIds) for the report. node "$RUN_SENTINEL" cleanup "$RUN_DIR" else PLAN_FILE_RAW="$(printf '%s' "$READ" | jq -r '.payload.planPath // empty')" # Normalize an equivalent absolute path; reject every path resolving elsewhere. PLAN_FILE="$(node -e 'const {resolve}=require("node:path");const [reportedPath,expectedPath]=process.argv.slice(1);if(!reportedPath||resolve(reportedPath)!==resolve(expectedPath))process.exit(1);process.stdout.write(expectedPath)' "$PLAN_FILE_RAW" "$PLAN_PATH")" # single-ticket `ok` sentinel → re-verify the expected plan file is non-empty. if [ "$PLAN_FILE" != "$PLAN_PATH" ] || [ ! -s "$PLAN_FILE" ]; then echo "$DISPATCH_FAILURE: sentinel ok but plan file missing/empty or wrong path ($PLAN_FILE_RAW) — no Linear write, aborting" >&2 node "$RUN_SENTINEL" cleanup "$RUN_DIR" exit 1 fi node "$RUN_SENTINEL" cleanup "$RUN_DIR" fiBranch on the
okpayload. An epic outcome (payload.epic == true, noplanPath) means the subagent already did every Phase 2.5 tracker write. Re-read Linear before accepting: its parent must be planned and its children must match requiredchildIds/parseEpicSpec; a sentinel that omitschildIdsis rejected separately from a child-reconciliation miss. Recovery is to decode the spec attachment body withnode "$BOSS_PLAN_TOOLBOX/plan-attachment.mjs" decode <in-file> <out-file>and re-run reconciliation, never to accept the sentinel alone. Otherwise safe-abort so the next sweep resumes it. On success skip Phase 3.5–4; re-running them would turn the parent into aboss-buildtarget. A single-ticketoksentinel proceeds only when its metadataplanPathresolves toPLAN_PATHand names a non-empty plan file. ItsdescriptionSummarybecomes the Linear description; read the plan file only for the secret gate.After an
oksentinel and the plan-file reverify pass, validate the returned bounded metadata before Phase 3.5. Write exactly the returned metadata object to.linear-plans/<ISSUE-ID>.draft-metadata.jsonand run:BOSS_PLAN_ENV="${BOSS_SKILLS_HOME:-$HOME/.claude/skills}/boss-plan/toolbox/boss-plan-env.sh"; [ -f "$BOSS_PLAN_ENV" ] || BOSS_PLAN_ENV="$HOME/.claude/skills/boss-plan/toolbox/boss-plan-env.sh"; [ -f "$BOSS_PLAN_ENV" ] || BOSS_PLAN_ENV="$HOME/.codex/skills/boss-plan/toolbox/boss-plan-env.sh"; [ -f "$BOSS_PLAN_ENV" ] || { echo "BLOCKED: installed boss skills missing or stale - run 'boss skills install'"; exit 1; }; . "$BOSS_PLAN_ENV" METADATA=".linear-plans/<ISSUE-ID>.draft-metadata.json" if ! node "$BOSS_PLAN_TOOLBOX/plan-run-guards.mjs" metadata "$METADATA"; then echo "$DISPATCH_FAILURE: draft metadata failed plan-run-guards.mjs metadata — no Linear write, aborting" >&2 node "$RUN_SENTINEL" cleanup "$RUN_DIR" rm -f "$METADATA" .linear-plans/<ISSUE-ID>.{precheck,premises,premise-states}.json exit 1 fiUnknown top-level keys, a missing
descriptionSummary, non-booleanagentFriendly, a non-single-ticket estimate, or an off-contractdescriptionSummaryare all the same SAFE branch:DISPATCH_FAILURE, no Phase 3.5, no tracker write. PreservePREMISESfrom the sentinel payload before cleanup; Phase 4 re-verifies those tracker premises immediately before writeback.
Phase 2.5 — Epic decomposition (triage = EPIC only)
When triage classifies the ticket EPIC — the honest estimate is ≥ 5, or the work spans
multiple independently-shippable
PRs with ≥ 2 genuinely separable PR-sized pieces (an honest ≤ 3 single-PR ticket is
SUBSTANTIAL, plan as one) — decompose it into a Linear parent + N fully-planned
children wired by an intra-epic blockedBy DAG, the exact shape boss-epic consumes.
Estimate is the forcing function: a single ticket may be estimated only 0/1/2/3; an honest 5
triages EPIC (unless genuinely atomic & un-splittable — then it survives as one ticket with a
recorded - Atomic-5: justification under ## Planning); an 8 is never a single-ticket estimate. The
interactive propose → confirm → create flow lives in references/interactive-mode.md; the headless
decompose-and-auto-create flow in references/headless-drafting-brief.md. The deterministic core —
validation, cycle safety, stable creation order, and the tracker-write plan — is the unit-tested
$BOSS_PLAN_TOOLBOX/plan-epic-lib.mjs (validateDecomposition, validateLayering, assertAcyclic,
topoOrderChildren, epicWiringPlan, epicParentEstimate, stableChildKey, serializeEpicSpec,
parseEpicSpec, validateSpecIdentity, specAttachmentFilename, specAttachmentTitle,
reconcileEpicChildren, EPIC_LABEL, EPIC_MIN_CHILDREN, EPIC_MAX_CHILDREN,
CHILD_MAX_ESTIMATE, SPEC_ATTACHMENT_MIME) plus this phase's own
$BOSS_PLAN_TOOLBOX/plan-epic-phase25.mjs (detectEpicParent, epicSpecRecoveryGate,
stalePlanAttachmentSweep, epicPhase25WritePlan);
never re-derive either inline.
Precondition — the source ticket MUST be unplanned and not already an epic child. The whole epic model depends on it:
parent-repurpose-last keeps the original in unplanned until the epic is fully built, and idempotent
resume re-picks a stranded partial epic via the headless unplanned sweep (list_issues state=unplanned). If the source carries parentId, record a single-ticket SUBSTANTIAL fallback:
decomposing a child mints grandchildren boss-epic never schedules. Phase 1 admits an
explicitly-named planned/in-progress source; if such a non-unplanned source triages EPIC, check
BOTH spec stores before falling back — one
get_issue(parent) already returns attachments[] and description, so checking both costs
zero extra calls. detectEpicParent(issue) is the whole classification, over that one payload:
it returns {isEpicParent, source, specAttachmentId, ambiguous, reasons} and owns the
store-specific presence rule, the attachment-wins-over-legacy ordering, and the two-or-more
Epic spec (…) attachments case (ambiguous: true ⇒ abort loudly per the contract's duplicate
policy, never guess which is current). An isEpicParent verdict means an existing epic parent (a
planned fully-built epic keeps its spec), so route to the idempotent resume/no-op path — read
attachment specs from the attachment body, never a description quote, and never fall back to
a buildable single-ticket plan. Attachment specs must pass validateSpecIdentity(spec, <ISSUE-ID>);
legacy inline specs are accepted by provenance because that read-only store predates
schemaVersion/parentId. Only a legacy parse failure reaches the gate below. Unreadable-spec recovery gate — when the spec cannot be read, or an
attachment-sourced spec fails validateSpecIdentity, the decision is
epicSpecRecoveryGate({parent, children, plannedState, epicLabel}), which owns the ALL-of conjunct
set — parent planned + epic-labelled; every child planned + plan artifact — and names every failure.
Feed it hydrated children: start from list_issues parentId=<parentId> limit=250, then get_issue
each child when the list omits attachments or full fields. Its action is only ever 'noop'
(enumerate + no-op) or 'abort':
falling through to the single-ticket path is forbidden — deliberately not even expressible in
that return type, because it would re-plan a finished or partial
epic as a normal buildable ticket. An unplanned parent can never satisfy the planned-parent
conjunct, so a corrupt spec attachment on one aborts every sweep until a human intervenes — that is deliberate
(re-decomposing would duplicate children), and the remediation is the same as the duplicate policy's:
delete the unreadable Epic spec (…) attachment, leaving the parent to re-decompose cleanly, or
repair its body. Accepted residual: the gate cannot detect a child deleted
outright — that failure is non-destructive (a partial epic is left alone, not corrupted). Only a
non-unplanned source with neither store present falls back to a single-ticket
SUBSTANTIAL plan (headless records the reason; interactive may re-ask). A non-unplanned parent
would sit in a non-queue state through the create→wire→expose window and, on a crash before the final
flip, be invisible to the unplanned sweep and stranded — recoverable only by manually re-running
that exact id. This precondition also means a well-formed epic parent never carries stale
agent-friendly/plan-link metadata; the strip in step 4 (below) is a defense-in-depth backstop, not
the primary guard.
The spec attachment contract. The decomposition spec is a native tracker attachment carrying plain JSON, never a description marker:
| Field | Value |
|---|---|
| filename | epic-spec.json (specAttachmentFilename()) |
| MIME type | application/json (SPEC_ATTACHMENT_MIME) |
| title | Epic spec (<ISSUE-ID>) (specAttachmentTitle(<ISSUE-ID>)) — must NOT start with Implementation plan |
| body | serializeEpicSpec(spec) — plain JSON { schemaVersion, parentId, parent, children } |
| read | readPlanAttachment (the Phase 0 attachment-read op), by attachment id from get_issue |
| duplicate policy | exactly one is valid; two or more ⇒ abort loudly, never guess — a human deletes all but one, then re-runs |
| identity | validateSpecIdentity(spec, <ISSUE-ID>) — schemaVersion + parentId must match, not title alone |
Upload it with the same prepare → PUT → finalize mechanism a plan artifact uses
(references/plan-storage.md steps 1–5, uploadRequest.headers scratch-file discipline and its
immediate deletion after the PUT included, and the step-5 read-back), substituting this contract's
filename, MIME type and title. Never hand-roll a second upload path, and never claim the plan artifact's text/markdown
MIME or its Implementation plan (…) title: bs-epic-lib.mjs's normalizeTicket recognizes a plan
by exactly that prefix, so a spec attachment titled that way is mistaken for the parent's plan
artifact. Title alone is not identity — a human can create an attachment with any name — so the
schemaVersion + parentId match is what makes it trustworthy.
The planner drafts a decomposition spec
{ parentId:"<ISSUE-ID>", parent:{title,goal,keyChanges[]}, children:[{key,title,goal,keyChanges[],blockedByKeys[],estimate,priority,agentFriendly,openQuestions[]}] }
(each key is a stable title-derived slug from stableChildKey, so a fresh-worktree retry
re-derives it identically and its resume marker still matches; parentId is the source ticket's own
id and is not optional — serializeEpicSpec omits an absent id rather than inventing one, and
validateSpecIdentity then refuses the attachment forever, so an unset parentId ships an
unbindable spec), then runs this ordering discipline —
validate everything locally BEFORE the first Linear write (the atomicity guard):
-
Validate the spec.
validateDecomposition+assertAcyclic. On failure: interactive re-asks / falls back to a singleSUBSTANTIALplan; headless falls back to a single-ticket plan and records the reason (never emit a broken epic). -
Fully plan every child locally to IDless scratch, each a Phase 3 plan, drafted with
allowEpic: false— the recursion guard: a child is never itself decomposed (depth cap = 1). The spec never carries plan bodies, so copy only each child plan's ownagentFriendlyverdict and itsopenQuestionslist onto its spec entry —serializeEpicSpecderives the child'sagentQuestion(⇒ theagent-questionlabel) from a non-emptyopenQuestions, so a child left blank here silently loses that queue signal on resume. Then re-runvalidateDecompositionon the completed spec before any write — step 1 validated the spec before those verdicts existed, so its non-boolean-agentFriendlyguard (a malformed"false"stringserializeEpicSpecwould coerce totrue) only bites when validation runs again after the copy. Run the Phase 4 secret and image-parity gates on every child plan before any write. -
Confirm (interactive only, via
AskUserQuestion: create this epic / plan as one ticket / cancel); headless auto-creates. -
Persist the FULL spec FIRST. The spec is an attachment now, so the old single atomic
save_issuebecomes an ordered write sequence, and that sequence isepicPhase25WritePlan({parentId, spec, unplannedState, staleAttachmentIds, labelsToStrip})(labelsToStrip= theagent-friendly/needs-humanexposure roles; it is parent-scoped, stage 1'sstripLabelsand nothing else): execute its ops in emitted order —label-strip, thenspec-upload, thenstale-delete, thencreate-children— minus any stage the preconditions below skip, and on a resume minus every childreconcileEpicChildrendoes NOT reportmissing(it emits onecreateChildper SPEC child, never per missing child; executing those unfiltered on a resume duplicates every child that already exists), exactly as step 5 executesepicWiringPlan. Each entry is{stage, op, args, runtimeArgs}, andargsis the statically-known subset only, under the adapter's own key names; the created-id map passed toepicWiringPlanmust include the reservedparententry beside every child id —runtimeArgsnames what only the executor can supply because it does not exist until the previous op ran (the prepare'ssize, the PUT'sfile/uploadURL/headers, the finalize'sassetUrl). It owns the ordering (in particular that every destructive delete comes strictly after the spec upload); never re-derive that inline. It does not own the stage preconditions below, which stay prose, and it emits no childlabelsfield — a child's label set is not derivable from the spec (serializeEpicSpecpersistsagentFriendly/agentQuestion, never alabelsarray), so the content labels +agent-questionunion below stays the caller's job:- Stage 1 — label strip only. The entry carries
stripLabelsOUTSIDEargs, becausesave_issuehas no "remove these labels" argument — itslabelsreplaces the whole set. So read the parent's current labels (opreadLabels), subtractstripLabels, and send the result aslabels; spreading aremoveLabelskey into the call would either error or silently send{id}alone, leaving the parent exposed for the whole create→wire→expose window. Cheap, atomic, reversible, and sufficient on its own:boss-buildselects a ticket that is planned andagent-friendlyand carries a plan artifact, so breaking one conjunct makes the parent non-selectable from the FIRST tracker mutation onward rather than through the create→wire→expose window or after a crash (step 7's strip then only reaffirms it). This is the safety write; it must not delete anything. - Stage 2 — upload the spec, exactly once. Read the parent's
attachments[]AND its description FIRST — BOTH stores, the same dual-store rule detection uses. Scoping this check to attachments would miss a legacy parent entirely, and the unplanned sweep that lands here is the primary resume route, not just the named-source branch above. Unlike the description marker it replaces — where a re-save simply overwrote the one marker — a finalize is not idempotent: it mints a NEW attachment row on every call, so a re-picked parent that already carries a spec must never upload a second one. Apply the same store-specific presence rule detection uses: anEpic spec (…)attachment counts when present; a description counts only whenparseEpicSpec(description)returns a spec, never on a bare quoted<!-- boss-plan-epic-spec:substring. Take these in order: two or moreEpic spec (…)attachments ⇒ abort loudly per the contract's duplicate policy; otherwise either store present ⇒ skip this stage entirely, and skip stage 3 with it (step 7's flip re-runs that same prefix-scoped strip); discard the spec just drafted and continue on the idempotent resume path below against the stored one (a crash after stage 2 leaves precisely this state, and the parent is still unplanned, so the sweep re-picks it here). A legacy-sourced resume writes no attachment — it keeps its inline marker, carried verbatim through step 6's save. Otherwise upload — first setspec.parentIdto this ticket's id, since only a bound spec can ever passvalidateSpecIdentity. The PUT takes a file, so writeserializeEpicSpec(spec)to.linear-plans/<ISSUE-ID>.epic-spec.json(Phase 5 deletes this scratch). Then verify those bytes BEFORE the PUT:validateSpecIdentity(parseEpicSpec(<the file's contents>), <ISSUE-ID>)must beok. Nothing else catches an unbound spec —serializeEpicSpecomits an unsetparentIdsilently rather than inventing one, andvalidateDecompositionnever inspects it — so without this check the attachment uploads clean and only turns fatal much later, when a resume cannot bind it.ok: false⇒ abort here, while zero children exist. Otherwise prepare → PUT → finalize theepic-spec.jsonattachment per the contract above; keep it forepicSpecPathsreverify when this stage ran. When this stage is skipped because a stored attachment or legacy marker already exists, write the stored spec to.linear-plans/<ISSUE-ID>.epic-spec.json, report it inepicSpecPaths, and do not upload. Then read the finalized spec back — still inside this stage, BEFORE any child is created (step 5 of that contract, one retry on a transport error): the read must return non-empty content andvalidateSpecIdentity(parseEpicSpec(<the returned body>), <ISSUE-ID>)must beok. The pre-PUT check validates the bytes on disk; only this one proves what the tracker stored, which is what every resume reads. On a confirmed-unreadable or unbindable read-back,deletePlanAttachmentthat orphaned row — safe only here, with zero children — then take the SAFE branch. Once children exist, a failed read-back aborts WITHOUT deleting the spec: that would leave the parent with neither store, the state step 6 warns re-decomposes into DUPLICATE children. Reading back before the first child create is what keeps the delete safe. Any failure takes the SAFE branch: abort with zero children created, the parent still unplanned, so the next unplanned sweep re-picks it. - Stage 3 — the deferred destructive strip. Its
staleAttachmentIdscome from the same prefix-scoped sweep step 7 cites, in its one-arg form — no parent-overview attachment exists yet, so there is nothing to keep; an unscoped sweep would destroy the spec just uploaded. Any stale single-ticketImplementation plan (…)link is dropped here too. Fortracker-attachment,deletePlanAttachmentwas already required before the FIRST epic write (this stage is skipped on every resume, so gating on its availability here would defer the check to step 7 — past child creation and exposure). A crash between stages 1 and 2 leaves only a removed label — recoverable, non-destructive, and resume just re-decomposes from scratch, nothing orphaned.
No stage moves the ticket out of unplanned, so parent-repurpose-last still holds. Crash-safety: the description is never rewritten here, so after stage 2 Linear holds the original notes + image URLs AND — unless stage 2 was skipped because a store already existed — the spec attachment, and a fresh retry recovers the spec from whichever store holds it and reconstructs the verbatim
## Original notes+ runs the image-parity gate against the still-present original source. This durable record — surviving a fresh cron worktree — carries the parent overview and every child's full metadata (key, title, goal, keyChanges, blockedByKeys, estimate, priority,agentFriendlycall, and itsagentQuestiondecision —openQuestionsnon-empty; it never carries plan bodies), so a retry completes the original epic from the parent alone rather than re-decomposing (a fresh LLM re-decomposition could build a different partial epic). PersistingagentFriendly/agentQuestionis what lets resume re-stamp the step-6 deferred-exposure label and re-applyagent-questionto an ALREADY-created child correctly (below). Then create children as unplanned, unexposed shells so each returned id can receive its native plan attachment before its planned-state write. Each child shell carriesparentId= original, each child spec's validatedestimateandpriority(soboss-epic, which orders ready/merge work by ticket priority, schedules children as the decomposition intended rather than by default/None), content labels plusagent-questionfor any child whose plan recorded non-emptyopenQuestions(the Phase 4 contract — union it into that child's labels at creation; it is independent of the agent-friendly/needs-human call and survives via the spec'sagentQuestion), and a child plan artifact titled exactlyImplementation plan (<child id>)(boss-epic'snormalizeTicketrecognizes a plan only via a link/attachment whose title starts withImplementation plan; a child linked or attached under any other title is exposedagent-friendlyyet silently skipped byboss-epicas missing a plan). On resume, inspect every adopted shell for that exact canonical attachment first. If absent, always redraft that child from its persisted spec metadata withallowEpic:false, re-run its secret and image-parity gates, and prepare, PUT and finalize the plan attachment before any planned-state or exposure write. Plan bodies are never persisted in the spec, so this unconditional redraft is the single documented path, not a size-triggered fallback; never expose an adopted child without its canonical plan artifact. Then save each child's contract description withepicChildMarker(key)embedded in that same write — canonical emitter only, never hand-written — but notagent-friendlyyet (deferred exposure, step 6), intopoOrderChildrenorder, recording each new id against itskey; later description saves must preserve that marker byte-for-byte. Fortracker-attachment, now prepare, PUT, finalize and read back that child's attachment (references/plan-storage.md; use the parent epic id as the signed-header scratch prefix) step 5), and only then move its shell to the planned state — otherwise an unwritten child plan reaches the planned flip and a consumer selects that child on a row whose bytes do not exist. Any attachment or read-back failure takes the SAFE branch before that child's planned-state or label exposure, and aborts the epic rather than skipping that child: siblings already created stay unexposed and are adopted on resume. A non-agent-friendlychild is notboss-build-selectable, so it cannot be picked up before its blockers exist. - Stage 1 — label strip only. The entry carries
-
Wire the intra-epic DAG. Execute
epicWiringPlan(spec, createdIdByKey): set each child's intra-epicblockedBy(append-only). These edges are internal to the epic — the children were all just created together and, on abort, stay unexposed together — so wiring them before the parent commit is safe. Defer the Phase 4 step-5 external conflict links to step 6, after the parent overview commits. Those outward edges mutate non-epic backlog tickets (a lower-priority active ticket savedblockedBya child); writing them here — before the step-6 parent gate — would strand that backlog work behind a child that a deterministic parent-gate failure leaves permanently unexposed/unbuildable. Intra-epic edges come exclusively fromepicWiringPlan. -
Commit the parent overview, THEN link external conflicts + expose the children (deferred exposure — makes an agent-friendly child
boss-build-eligible). Only now, after the intra-epic DAG wiring (step 5; the external links are deferred to here), commit the parent overview before any external edge is written or any child is exposed: after the children are created and moved to planned, re-assert the parent's configured unplanned state before composing the parent overview. Linear's sub-issue rollup can advance the parent on its own; without this re-assertion, parent-repurpose-last crash recovery silently stops working because the unplanned sweep can no longer find a partial epic. Then compose the parent overview, run step 7's three gates (secret + image-parity + plan-contract with--mode epic-parent), then attach it natively — reading the finalized parent overview back before the save (references/plan-storage.mdstep 5; deleting a confirmed-unreadable overview strands no spec store, which lives in its own attachment) — and save it onto the still-unplanned parent** (description-only; an attachment-sourced spec lives outside the description, so this description-replacing save cannot lose it — the old re-append-the-marker requirement is obsolete there, not dropped by accident. A LEGACY-sourced resume is the exception: that parent's spec is the inline<!-- boss-plan-epic-spec:… -->marker, so carry that marker substring verbatim into the composed overview — this save would otherwise destroy the only store and leave the parent with neither, which the next sweep re-decomposes into DUPLICATE children. Carry it; never migrate it to an attachment instead — this phase only ever sweeps unplanned tickets, so a self-heal-on-read path would almost never fire and is not worth the second write. The unplanned → planned flip stays last — step 7). On a gate or attachment/read-back/save failure take the SAFE branch — no external links, no exposure, no planned flip, abort. All three epic upload sites — the stage-2 spec, each child plan in step 4, and this parent overview — are read back before step 7's planned flip, so the flip never exposes an epic standing on bytes never written. Because the failure-prone plan-store + Linear parent save happen here, before any external edge or exposure, a deterministic parent-gate failure never leaves a childagent-friendly/buildable, nor a non-epic backlog ticket blocked behind an unbuildable child, while the parent aborts unplanned; an exposed child is always backed by an already-finalized parent overview, never one that later aborts unplanned. Only after the parent overview is saved, run the Phase 4 step-5 external conflict links for each child against the active planned/in-progress/in-review backlog — but exclude this epic's own child ids AND the epic parent id from that comparison/backlog set (the siblings were just created in planned, so without this exclusion the "external" pass would add extra priority-orientedblockedByedges between siblings on top of the intended intra-epic DAG, corrupting the decomposition order; the external linker only links each child against non-epic backlog tickets). Deferring these outward edges to here — past the parent commit — means a deterministic parent-gate abort writes zero external edges, so existing backlog work is never stranded behind a child that never becomes buildable. Then stamp each child with its own plan's agent-friendliness call (union, never clobber): a child whose plan concluded agent-friendly getsagent-friendly; a child whose plan concluded it needs a human (agentFriendly: false) getsneeds-humaninstead — neveragent-friendly— per the normal plan-contract convention.boss-epictreats a child as eligible only when it is planned andagent-friendlyand has a plan artifact and is notneeds-human, so honoring the per-child decision here keeps a human-blocked child from being handed toboss-build. By now every child already carries its blocker relations, soboss-build's "skip a candidate whose blocker relations already exist" keeps blocked children from starting out of DAG order while an agent-friendly root child (no blockers) is correctly buildable. Crash-safety: any crash before this step leaves the children unexposed (noagent-friendly/needs-human, unbuildable), so aboss-buildcron cannot pick a downstream child during the create→wire window; resume completes wiring and this exposure. On resume the per-child call comes from the recovered spec: an already-created-but-unexposed child adopted from the parent's spec has no.linear-plans/plan to re-read, so its persistedagentFriendly(step 4) is the authoritative source for whether resume stamps itagent-friendlyorneeds-human. -
Repurpose the parent (original-becomes-parent). The epic overview (goal + child checklist with plan artifacts + verbatim
## Original notes) was composed, gated, stored + saved onto the still-unplanned parent in step 6; the spec attachment is untouched by that description-replacing save (and a legacy parent's inline marker was carried through it verbatim), so idempotent resume still recovers the FULL original spec from a fully-built parent instead of re-decomposing into DUPLICATE children. The parent overview embeds
Truncated - read the full file at https://github.com/bossanova-dev/bossanova/blob/0137a00c86e9a153b757a7950793506615d0afb9/services/boss/internal/skillinstall/skills/boss-plan/SKILL.md.