Client dispatch: In Codex, read
codex-runtimeandrefine/codex.md, execute that native procedure, and do not continue into the Claude procedure below.
Interaction rule: Every question, confirmation, or approval directed at the user — anywhere in this skill, including error recovery — MUST be asked with the
AskUserQuestiontool. Never ask in plain text. If an instruction says "ask the user" or "confirm", that meansAskUserQuestion.
Context
Read project-core and resolve neutral configuration before continuing.
Use the config returned by project-core; if none exists, stop with its client-appropriate setup guidance.
Parse $ARGUMENTS:
The first token is the ticket ID. Everything after it is optional user context (additional instructions or focus areas).
Split $ARGUMENTS into:
- Ticket ID: the first whitespace-delimited token, with any leading
#prefix stripped. For example:#1 focus on API→ ID1,7→ ID7. - User context: everything after the first token (may be empty).
For example:
42 focus on the API layer→ context isfocus on the API layer.
Ticket ID validation: Validate the parsed ticket ID against ^[0-9]+$. If it does not match, stop immediately — do not run gh, do not write any temp file — and tell the user:
"The ticket ID must be numeric (digits only) — got <value>. Re-run /cenci:refine <ticket-id> [additional context] with the ticket's numeric ID (e.g. /cenci:refine 350)."
Shell rules: Read the shell-rules skill before running any gh commands (covers heredoc temp-file pattern).
Fetch the ticket:
Extract owner/repo from git remote get-url origin (e.g. git@github.com:owner/repo.git → owner/repo).
owner/repo validation: Trim surrounding whitespace from the derived value (e.g. a trailing newline from command output), then validate it against ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ (exactly one /, each segment limited to letters, digits, ., _, -), additionally rejecting either segment being exactly . or .., or ending in .git (GitHub never allows a repo name to end in .git, so a trailing .git here always indicates a botched extraction, not a real repo name). If it does not match, stop immediately — do not run gh, do not write any temp file. Before echoing the value below, redact anything up through the last @ (keep only the text after it, or the whole value if no @ is present) so a malformed remote URL with embedded userinfo/credentials is never echoed back — then tell the user:
"The derived owner/repo value (<value>) does not look like a valid owner/repo. Check git remote get-url origin and verify the remote URL is correctly configured."
Once validated, run:
gh issue view <number> --repo <owner>/<repo> --json number,title,body,labels,state,assignees,milestone,comments,author
Each entry in comments now also carries its own author.login and authorAssociation — the bundle written in step 4 below passes these through per comment so the refiner's Design Coverage Check can restrict which comments' ticket-carried screen node references count toward coverage.
The ticket's own author association needs a second read: gh issue view --json exposes no top-level authorAssociation field (it exists only per comment, inside comments), so requesting it there makes the whole fetch above exit non-zero with Unknown JSON field: "authorAssociation". The REST issue endpoint exposes it as author_association:
gh api repos/<owner>/<repo>/issues/<number> --jq '.author_association'
If this call fails, treat the ticket's own association as unknown — it is then not one of the accepted values, so a ticket-body screen node reference does not count toward coverage. Never substitute a per-comment authorAssociation for it.
Split-child provenance detection: Determine whether this ticket is itself a child of an earlier split — mirrors agents/context-gatherer.md's parent-child detection. Primary source is the native sub-issue link:
gh issue view <number> --repo <owner>/<repo> --json parent --jq '.parent.number // empty'
If that returns a number, record isSplitChild: true and parentNumber: <that number>. Fallback (older tickets linked only by convention, or the primary command failing non-zero): if the first non-empty line of the fetched body matches Related to #<number>, record isSplitChild: true with that number as parentNumber. If neither yields a parent, record isSplitChild: false. A split child is presumed sized by its parent's refinement — split depth is one (docs/ticket-sizing.md) — so this flag drives the bundle's resolved flags (step 4), the Split-depth guard in step 9, and the Oversize split child escalation at the ## Confirmation Gate.
Attachments
Read the attachments reference skill and follow its 4-step procedure to discover, present, download, and load ticket attachments. If no attachments are found or the user selects none, proceed to Pre-flight Checks.
Store each image summary alongside its file reference for use during refinement.
Pre-flight Checks
After fetching the ticket, inspect its current state before proceeding:
Check the issue's labels array and state field.
If any of these conditions are true, warn the user and ask for confirmation using AskUserQuestion:
| Condition | Message |
|---|---|
| Ticket is closed/resolved | "This ticket is closed/resolved. Do you still want to refine it?" |
| Has "Refined" label/tag | "This ticket is already marked as Refined. Do you want to re-refine it?" |
| Has "Planned" label/tag | "This ticket already has a saved plan (Planned). Do you want to re-refine it?" |
| Has "Working" label/tag | "This ticket is currently being worked on. Do you want to re-refine it?" |
| Has "In Review" label/tag | "This ticket has an open PR (In Review). Do you want to re-refine it?" |
| Has "Implemented" label/tag | "This ticket is already marked as Implemented (its PR merged). Do you want to re-refine it?" |
If the user says no → stop. If yes → proceed normally.
Ownership Inspection (read-only)
Read the ticket-ownership reference skill's ## Inspect ownership (read-only)
section only, and follow it using the assignees from the ticket fetch above — this
performs zero GitHub writes. Record the result as ownershipState
(unowned | owned-by-caller | conflict) for the ## Confirmation Gate
manifest below. The claim itself is deferred to the write phase (## Update Ticket
below, #### Ownership claim (first write)) — it must not run before the gate's
confirmation. Never replace an existing assignee.
Your Role
You orchestrate backlog refinement. The judgment-heavy analysis — ambiguity hunting, question drafting, sizing, and the refined ticket proposal — is delegated to the refiner agent (spawned via the Task tool), whose model: opus frontmatter pin holds for its entire run; a skill-level pin only lasts the invoking turn, so it would silently degrade every follow-up turn of the Q&A loop to the session model. You run the interaction and the writes: relay the refiner's questions to the user, feed answers back, and perform every GitHub mutation yourself, all of them after the ## Confirmation Gate. Never perform the refiner's analysis inline, and never delegate a GitHub write or an AskUserQuestion to a subagent (see the subagent-safety skill).
Process
Summarize the fetched ticket for the user in 2-3 lines (title, current scope, state). Do not analyze it — that is the refiner's job.
Classify ticket type: Read the
frontend-classificationreference skill and apply its rule to determine if this ticket involves frontend/UI work. Record the result asisFrontendfor the bundle and the labeling steps.Design-only classification (if
isFrontendANDpencil.enabledistruein.cenci/config.json): determine whether the ticket's deliverable is the design itself — a.penfile plusDESIGN.mdspec, with no production code change (e.g., "Design the settings page", "Create mockups for the onboarding flow"). If the signals point that way, confirm viaAskUserQuestion:"This reads as a design-only ticket — the deliverable would be a design spec (
.pen+DESIGN.md) produced by/cenci:design, with no code change. Is that right?"Options: "Yes — design-only", "No — includes implementation"
If confirmed, set
isDesignTicket = true. Design-only tickets are routed to/cenci:design, not/cenci:implement: they skip the browser question (step 8) and theui:visual-checklabel (step 12), and receive theDesignlabel in step 11. The refiner focuses its analysis accordingly via the bundle'sisDesignTicketflag — design questions (visual direction, screens, states, design-system fit) instead of implementation-only items.The Design Coverage Check (
.pen/DESIGN.mdevaluation and thedesignNeededdetermination) is performed by the refiner agent, not here — its result arrives in the proposal's### Design Coveragesection (step 7); a conventions-only DESIGN.md is not itself a coverage gap, sodesignNeededonly goes true when there is genuinely no design to work from. Design always happens on a dedicated design ticket, never on the implementation ticket itself — whendesignNeededis true, the design ticket is created later, either as the first child of a split (see Design-first splits in step 9) or as a companion ticket (see Companion design ticket in the Update Ticket section).Create the per-run temp-file token. Run
mktemp -u ${TMPDIR:-/tmp}/cenci/issue-<number>-XXXXXXonce and capture the trailing random segment as<token>(the token is the random suffix only, e.g.a1b2c3— not the full mktemp basename). As with<ticket-id-or-slug>in the implement phases, carry the literal<token>value forward as text into every temp-file path for the rest of this run — do NOT re-derive it per Bash call, and do not use$$/shell state (it does not persist across separate Bash tool invocations).-uis a dry-run name generator — it only produces a unique-ish suffix, not an atomically-created file — which is why theWritetool is what actually creates each temp file in this run. The<token>is a collision-avoidance mechanism only — it reduces the chance of two concurrent runs picking the same temp-file basename — and is explicitly not an atomic reservation (a second run could theoretically generate the same suffix before either run'sWritecall lands) and not a security boundary (it provides no protection against a malicious or adversarial process targeting the same path).Write the context bundle. Use the
Writetool to create${TMPDIR:-/tmp}/cenci/issue-<number>-<token>-bundle.mdcontaining, in order:- The verbatim ticket title, body, labels, state, and comments from the fetch above — full text, never a digest or paraphrase (the refiner's decisions require source fidelity; see
docs/skill-authoring.md). Include the ticket's ownauthor.login(fromgh issue view) paired with itsauthor_association(from the REST issue-endpoint read) alongside the body, and each comment'sauthor.login/authorAssociationalongside its body, per the two fetches above. - Each attachment's summary alongside its downloaded file path (from the Attachments step).
- The user context parsed from
$ARGUMENTS, verbatim (orNone). - The resolved flags:
isFrontend,isDesignTicket,pencil.enabled,pencil.designPath(from the resolved config), andisSplitChildwith itsparentNumber(from the Split-child provenance detection in the Context section above;parentNumberis omitted whenisSplitChildis false).
If the
Writefails, retry once; if it still fails, STOP and report the error — the refiner must never run without the bundle.- The verbatim ticket title, body, labels, state, and comments from the fetch above — full text, never a digest or paraphrase (the refiner's decisions require source fidelity; see
Delegate to the refiner agent (
Tasktool, agentrefiner). The first invocation's prompt contains only: the bundle path, a note that this is round 1, and the instruction to read the bundle file in full before analyzing. Later rounds additionally carry the Q&A history (step 6) — do not re-paste ticket or bundle content into any prompt; the bundle path is the context.Q&A relay loop. Parse the refiner's
## Questionssection:- If it is
None.→ the same output contains the## Refined Ticket Proposal; continue to step 7. - Otherwise, present the questions to the user in the refiner's order. Ask exactly ONE question per
AskUserQuestioncall, and wait for the answer before the next call. Never combine multiple refiner questions into a singleAskUserQuestioncall, never merge them into one composite question, and never ask them as plain text. When the refiner supplied answer options for a question, map them onto theAskUserQuestionoptions using the refiner's wording (the user can always answer freeform via "Other"); otherwise offer sensible options. When the refiner marked one option recommended, map it onto the firstAskUserQuestionoption, appending(Recommended)to that option's label and carrying the rationale into its description; this is advisory: a question with no marked recommendation is relayed exactly as today and never triggers a re-invocation of the refiner. - When every question in the round is answered, re-invoke the refiner with the bundle path and the complete accumulated Q&A history — all rounds, as
Q:/A:pairs using the refiner's original question wording; do not re-paste ticket or bundle content. Route the new output through this step again. The refiner asks at most 4 questions per round and rounds continue until it returnsNone..
- If it is
Proposal received. When the refiner returns
None., its output contains the## Refined Ticket Proposal— the summary content adopted in step 9 and persisted in steps 10-12. ReaddesignNeededfrom its### Design Coveragesection (treat it as false when the section or field is absent).Before adopting the proposal, ask one final infrastructure question — but only when it can plausibly apply. Skip for design-only tickets (
isDesignTicketis true) — they never reach the implement pipeline; setbrowserRequired: false. Ask it if the ticket was classified frontend/UI in step 2, or the ticket/answers mention web scraping, browser automation, or manual browser testing. For pure backend/infrastructure/data tickets with none of those signals, skip the question and setbrowserRequired: false.Using
AskUserQuestion: "Does this story need interactive browser access during implementation? (e.g., for visual verification, form testing, or web scraping). If yes, the implementer should ensureplaywright-cliis installed (npm i -g @playwright/cli)."- If yes → note
browserRequired: truefor the labeling step - If no → proceed normally
- If yes → note
When refined, adopt the refiner's
## Refined Ticket Proposalverbatim as the summary content — its sections (### Updated Title(optional),### Updated Description,### Acceptance Criteria,### Assumptions (auto-adopted),### Decisions,### Technical Notes,### Design Coverage,### Design Direction,### Automation,### Size Estimate,### Suggested Splitwith#### Execution Order) map 1:1 onto the persistence steps below; the section formats themselves are specified inagents/refiner.md.### Assumptions (auto-adopted)and### Decisionspersist into the ticket body in step 10, same as### Acceptance Criteria;### Automationdrives the## Confirmation Gate's per-ticketautomerge:okverdict computation (parent and every child) and is never written into the body. Do not rewrite, summarize, or reorder the proposal's content. The proposal itself is rendered to the user at the## Confirmation Gatebelow, before any write — it is not held back until the final message; steps 10-13 persist the ticket update, any split or companion design ticket, and the labels only after that gate's Confirm branch (see the Final Message note at the end of the Update Ticket section).A
### Suggested Splitin the proposal means each child becomes its own numbered ticket and PR, linked to the parent as a native GitHub sub-issue, with dependency ordering captured in the child bodies (Pass 1/Pass 2 below).Split-depth guard (fail closed). When
isSplitChildis true and the adopted proposal nonetheless contains a### Suggested Split, STOP immediately — render no Confirmation Gate manifest, ask no confirmation, perform zero GitHub writes — and report that the refiner violated the no-resplit contract for split child #<number>(child of #<parentNumber>; seeagents/refiner.md's Split children are never split again rule), and that re-running/cenci:refine <number>is how to retry. Split depth is one and grandchild tickets are never created (docs/ticket-sizing.md); an L-sized split child is handled by the Oversize split child escalation at the## Confirmation Gatebelow, never by another split.Design-first splits (if frontend feature AND
pencil.enabledistrueANDdesignNeededis true): the proposal's split makes the first child a design-only ticket (e.g., "Design screens") that every UI implementation child depends on. It gets theDesignlabel in Pass 1, its body includes the### Design Directionsection from the proposal (that's where/cenci:designreads it from), it is executed via/cenci:design, and it produces a committed design spec rather than a PR (the one exception to "1 ticket = 1 PR"). When/cenci:designcompletes it, theDesignedlabel is propagated to the implementation children that depend on it, satisfying implement's Design gate.
Confirmation Gate
Placed here — between Process step 9 (the proposal is adopted) and ## Update Ticket below — this gate performs zero GitHub writes; it only classifies, asks, computes, and renders. No GitHub write of any kind occurs before its single confirmation — the ownership claim, the Working label, and every ticket/label/sub-issue mutation all wait for it. Steps 10-13 below then consume everything this gate computes; they never recompute a verdict or a label set. A split proposal must also clear the Coverage gate precondition immediately below before this gate renders anything.
Coverage gate — verify the split's acceptance-criteria partition
Before rendering a manifest for a proposal carrying a ### Suggested Split, run the structural completeness check first, the acceptance-criteria partition check second — both within this same gate, both gating the same pre-render stop point.
Structural completeness check. For every child block in the adopted ### Suggested Split, verify all five subsections are present: ### Goal, ### Decisions, ### Assumptions (auto-adopted), ### Acceptance criteria, and ### Dependencies. Each present subsection must also satisfy its emptiness rule: ### Goal must contain non-empty prose — never a placeholder or blank section; ### Dependencies must be non-empty ("None." is a valid value when the child truly has no dependencies); ### Decisions and ### Assumptions (auto-adopted) must each be non-empty or exactly "None."; ### Acceptance criteria may be empty only for a child the partition assigned zero criteria (e.g. a design-only first child). If any child is missing a subsection or violates its emptiness rule, STOP before any child creation — render no manifest, ask no confirmation, create no tickets, run no Pass 2 — and report the violating child's (K/N) title and the missing or empty section(s) so the split can be corrected in another refinement round. This check only confirms presence/absence and does not itself judge whether an empty ### Acceptance criteria section is legitimate — the acceptance-criteria partition check below is the sole verifier of correct assignment, and a child wrongly left empty will surface there as an unassigned criterion.
Acceptance-criteria partition check. Only after the structural completeness check passes for every child, verify the proposal partitions the parent's acceptance criteria per agents/refiner.md's Acceptance-criteria partition rule: every - [ ] item in the proposal's ### Acceptance Criteria must appear in exactly one child's ### Acceptance criteria checklist (scoped rewording is fine as long as the mapping is evident), and no criterion may appear under two children. If any criterion is unassigned or duplicated, STOP — render no manifest, ask no confirmation, create no tickets, run no Pass 2 — and report the violating criteria to the user so the split can be corrected in another refinement round. Nothing has been written at this point, so stopping here is free; proceeding would mint children whose closure can no longer prove the parent's criteria (#661), or, per the structural check above, whose Refined label is not truthful (#872).
Oversize split child escalation (before rendering). When isSplitChild is true and the adopted proposal's ### Size Estimate is L, surface the refiner's parent re-partition recommendation before rendering the manifest and ask, via AskUserQuestion:
"This ticket is a split child of #<parentNumber> and still sizes L (budget risk). Split depth is one, so it will not be split again — proceed with it as-is, or decline so parent #<parentNumber>'s partition can be redone?"
Options: "Proceed — keep the oversize child as-is" / "Decline — redo the parent's partition". Proceed ⇒ continue into the numbered steps below with the proposal unchanged (the L reasoning stays in the persisted body's ### Technical Notes-adjacent sections exactly as the refiner wrote it). Decline ⇒ zero GitHub writes have occurred and none will occur — jump to step 13's declined-cleanup branch, and report that re-running /cenci:refine <parentNumber> is how to re-partition the parent. Either way this escalation only asks — it never modifies the proposal, and it never splits: this is the human hand-off for an oversize child, replacing any automatic grandchild creation.
Per-child classification — only when the adopted proposal carries a
### Suggested Split. For each proposed child K, apply thefrontend-classificationreference skill to that child's own block text (its title,### Goal, and### Acceptance criteria) — never an inlined keyword list; that skill is the single source of truth. RecordchildIsFrontend(K)andchildVisualCheck(K)(the visual-check signal subset) per child.Per-child browser question — for each child where
childIsFrontend(K)is true, or whose block mentions web scraping, browser automation, or manual browser testing, ask step 8's question once, scoped to that child, as its ownAskUserQuestioncall: "Does child (K/N)<title>need interactive browser access during implementation? (e.g., for visual verification, form testing, or web scraping). If yes, the implementer should ensureplaywright-cliis installed (npm i -g @playwright/cli)." Ask them in child order (1/N … N/N); never combine two children into one call — mirrors step 6's one-question-per-call rule. Skip entirely for a design-only child — design children never reach the implement pipeline, exactly as step 8 skips forisDesignTicket, andchildBrowserRequired(K)is setfalse. A child with no frontend/browser signal is not asked at all and getschildBrowserRequired(K) = false.The parent's step-8 question is independent of every child. Step 8 stays where it is and is unchanged; its answer applies to the parent ticket only and is never propagated to any child — on a split the parent is a tracking epic, so a
browserRequired: trueparent answer must not forceBrowseror a withhold onto a child, and afalseparent answer must not clear a child's owntrue.Compute effective verdicts, per ticket T in {parent, child 1…N}:
effective grant(T) = ### Automation verdict for T is exactly "grant" AND NOT isDesignTicket AND NOT browserRequired AND NOT visual-check-signals-match(each override evaluated for T itself — the parent's ownisDesignTicket/browserRequired/visual-check match for the parent; for a child K,isDesignTicket(K)is true only when K is the design-only child produced by a Design-first split (the child seeded with["Refined","Design"]in Pass 1) — never derived from or equal tochildIsFrontend(K)— plus that child's ownchildBrowserRequired(K)andchildVisualCheck(K)). Fail-closed default preserved: an absent/empty### Automationentry for T, or any value other than exactlygrant, iswithhold.Compute the label set per ticket: the parent's set is computed per steps 11-12 below (unchanged in shape; steps 11-12 consume the gate-computed values rather than recomputing them); each child's set = inherited non-excluded parent labels +
Refined[+Designfor the design child] [+BrowserwhenchildBrowserRequired(K)] [+ui:visual-checkwhenchildVisualCheck(K)and the child is not the design child] [+automerge:okwhen its effective grant holds]. Record the gate-time parent snapshot — the parent's currentlabelsandmilestone, as fetched for this gate — as the basis for "inherited non-excluded parent labels" above; this is a preview, not a promise. It is reconfirmed at write time by## Update Ticket's Manifest revalidation sub-step (run before the first write), which re-fetches the parent and diffs it against this gate-time snapshot. That diff is split into two kinds: authorization-sensitive drift — a change inautomerge:ok,Browser, orui:visual-checkon the parent between the gate and the write — versus cosmetic drift — everything else (milestone,area:*, priority, team,Design, or any other label). The gate's ownautomerge:ok/Browser/ui:visual-checkcomputation above is authoritative for what this run computed and does not itself change; it is the parent's current remote state that may have moved, which is exactly what the revalidation sub-step exists to catch.Render the complete adopted proposal, followed by a manifest: one row per ticket to be updated or created, with its title, final label set, its milestone, its intended hierarchy/dependencies (
Related to/Depends on/Parallel withlines and native sub-issue links), andgrant/withholdplus a one-line rationale. Include a row for the parent's own pending mutations: the ownership claim (oralready owned by youwhenownershipStateisowned-by-caller) and theWorkinglabel transition. If the parent already carries sub-issues from a prior run, state in the manifest that those existing children will not be modified — and note that a child created before this gate existed may still carry a legacyautomerge:ok/Browser/ui:visual-checkgrant inherited under the old behavior; this run does not audit or revoke it, since the unmodified child is untouched by design.One confirmation. Ask, via
AskUserQuestion: "Apply this refinement as shown?" Options: "Confirm — apply as shown" / "Decline — make no changes". No adjust loop — a decline requires a fresh/cenci:refine <id>run to change anything.Decline ⇒ zero GitHub writes have occurred and none will occur — no ownership claim, no
Workinglabel, no ticket/label/sub-issue mutation of any kind — so no cleanup mutation is needed: title, body, labels, assignees, milestone, and native sub-issues are state-for-state exactly as they were when this run started. Jump straight to step 13's declined-cleanup branch below, and report that re-running/cenci:refine <id>is how to adjust the proposal.
Confirm ⇒ continue into ## Update Ticket below, where steps 10-13 apply every effective verdict and label set exactly as rendered in the manifest.
Update Ticket
CRITICAL: This section is mandatory after refinement. Do NOT skip it. No GitHub write of any kind occurs before the human confirms at the
## Confirmation Gateabove — that gate, not the Q&A loop (steps 6-8), is the pre-write authorization boundary. The Q&A loop only shapes the proposal's content; steps 10-13 below execute only after the gate's Confirm branch, and a Decline stops before any of them run (see step 13's declined branch).
Write-failure protocol: Every edit in this section (ticket body/title updates, parent tracking updates, label add/remove) MUST be verified by re-fetching the resource with
gh issue view ... --json ...and confirming the expected change is actually present — a command exiting 0 is not sufficient proof. Ticket creation is the one exception:--jq .numberon thegh api repos/...response returns the new issue number directly — a numeric value is the proof; empty output, non-numeric output, or a non-zero exit is a failed create, so no separate re-fetch is required there. A malformed JSON payload surfaces as an API 4xx parse error and is itself a failed write — handle it with the single documented retry below, not a hand-patch loop. This retry also covers the localWritetool call that authors the raw title/body files and thejq -n --rawfileinvocation that composes the JSON payload from them (see theshell-rulesskill's canonical snippet): if a raw-fileWritecall fails, or the subsequentjqinvocation exits non-zero, or the payload file is missing/empty/stale when thegh api repos/... --inputcommand is about to read it, retry the failed step (Writeorjq) once before (re-)invoking thatgh api repos/...command — do not assume a local Write or jq failure is instead an API-side rejection. If the write or the verification fails:
- Report the error to the user.
- Retry the write once, then verify again.
- If it still fails, STOP — do not proceed to the next step — and emit a partial-state report: what succeeded so far (with concrete issue/label numbers or names), what failed, and what the user needs to do manually to reconcile it. Each write point below states what belongs in that report.
Per-run temp-file token: The <token> used in every temp-file path of this section is the one created in Process step 3 — carry that same literal value forward; never mint a second token mid-run (two tokens in one run would orphan the earlier files from step 13's cleanup list).
Write order
The canonical, machine-verified order of every GitHub write this skill performs, reached only via the gate's Confirm branch — nothing before this point ever writes:
claim— the ownership claim, first write (#### Ownership claim (first write)below).working— theWorkinglabel ensure + add (#### Label "Working"below).parent-body— the parent ticket's title/body update (step 10).child-create:1immediately followed by its ownchild-link:1, thenchild-create:2immediately followed by its ownchild-link:2, immediately followed by its ownchild-blockers:2when child 2 has at least one blocking sibling (omitted entirely for a child with none), and so on for every split child in dependency order (Pass 1) — every child's create+link(+blockers) group completes before the next step. The blockers op is per-child optional, and the first child never carries one: children are created in dependency order, so child 1 has no already-created sibling to be blocked by. That is why this canonical illustration shows a blockers op on child 2 only — a blockers op on child 1 is not a shape this procedure can produce. (Stated without its op token here on purpose: this section is machine-extracted, and every backtick-quoted token inside it is read as part of the canonical sequence.)parent-exec-order— Pass 2's Execution Order note on the parent body when the split has real ordering (step 10 Pass 2(b)); when there is no split, this same slot instead covers the companion design ticket's create, its native--add-blocked-bylink plusblockedByverification, and the supplementaryDepends on #<D> (design)prose-line body write.refined— step 11'sRefinedlabel add /Workinglabel removal.visual-check— step 12'sui:visual-checklabel add (skipped whenisDesignTicket).
Manifest revalidation (read-only — before the first write)
Reached immediately after the gate's Confirm, before the ownership claim, before Working, before any other write. Fetch the parent's current milestone and labels to a file, as its own Bash call (a fetch failure then surfaces distinctly, before any write), mirroring the same pattern in implement/phases/phase-9-pr.md. This fetch is now unconditional — it runs on every Confirm, whether or not this run creates split children or a companion design ticket, because it also guards the parent's own label write (steps 11-12) against write-time drift, not only child/design-ticket inheritance. Do not reuse the labels/milestone already in context from the Context section's fetch or from the gate's own snapshot: both are stale by the time this runs, and externally-sourced label names must never be interpolated into a command line (docs/skill-authoring.md) — the file is consumed mechanically by jq --slurpfile below, never read back into shell text. The trailing || rm -f is load-bearing: it removes a partially-written file so the presence gate below can never mistake one for a good fetch.
gh issue view <number> --repo <owner>/<repo> --json milestone,labels > ${TMPDIR:-/tmp}/cenci/issue-<number>-<token>-parent-meta.json || rm -f ${TMPDIR:-/tmp}/cenci/issue-<number>-<token>-parent-meta.json
Presence gate — fail closed, zero writes (D1). Read the file back, then validate its content is actually usable — an exit-0 cat alone is not proof the fetch produced real data:
cat ${TMPDIR:-/tmp}/cenci/issue-<number>-<token>-parent-meta.json
jq -e 'has("labels")' ${TMPDIR:-/tmp}/cenci/issue-<number>-<token>-parent-meta.json > /dev/null
If the cat command errors (non-zero exit, e.g. No such file or directory), OR the file exists but the jq -e 'has("labels")' check fails (empty file, malformed JSON, or valid JSON missing the expected labels shape) — treat a present-but-empty-or-malformed file exactly the same as a missing one, never as a good fetch — retry the fetch once. If it errors again by either check — the parent cannot be read after one retry — STOP: zero writes have occurred anywhere in this run, so stopping here is free (D1; converges with the fail-closed precedent for the identical fetch at implement/phases/phase-9-pr.md:263 — that fetch's graceful-degrade rationale no longer applies here now that this fetch precedes every write instead of following one). Report that ticket #<number>'s metadata could not be fetched and that re-running /cenci:refine <number> is how to retry. Do not claim ownership, do not add Working, do not update the parent body, do not create children.
Drift classification — fail closed on authorization-sensitive drift (D2). If the fetch succeeds, diff the freshly fetched labels against the gate-time parent snapshot recorded at step 5 of the ## Confirmation Gate above:
- Authorization-sensitive drift — the parent's
automerge:ok,Browser, orui:visual-checklabels changed since the gate rendered its manifest: STOP — zero writes anywhere in this run — stop and ask for a fresh confirmation: report exactly what changed and tell the user to re-run/cenci:refine <number>from scratch. No in-session re-gate: the gate's single confirmation authorizes exactly the manifest it rendered, and re-rendering here would violate "steps 10-13 never recompute a verdict or a label set." - Cosmetic drift — any other label or milestone change (e.g.
area:*, priority, team,Design, milestone): proceed with the freshly fetched snapshot (not the gate-time one) for every inheritance decision below, and disclose the cosmetic label drift in the Final Message. - No drift: proceed with the freshly fetched snapshot as normal.
Ownership claim (first write)
The first GitHub write of the run. Read the ticket-ownership reference skill's ## Claim ownership (mutating) section and follow it — re-verify exclusive ownership as the first action after the gate's Confirm, using a fresh fetch: the ownershipState recorded in ## Ownership Inspection (read-only) above may be stale by the time Confirm is reached, since time has passed for the Q&A loop and the gate's questions. If a conflict is found (a different assignee, or multiple assignees since the inspection), STOP: a conflict stops here with zero other writes — no Working, no parent body edit, no child creation — and report the observed state exactly as ticket-ownership specifies. Otherwise the claim proceeds and this run is now the ticket's sole assignee.
Label "Working"
Immediately after the ownership claim succeeds, add the "Working" label to signal that the ticket is actively being worked on. gh issue edit --add-label fails when the label does not exist in the repository, so ensure it exists first — run each as its own Bash call (|| true swallows only the "already exists" error):
gh label create "Working" --repo <owner>/<repo> --color "FBCA04" --description "Actively being refined, designed, or implemented" 2>/dev/null || true
gh issue edit <number> --repo <owner>/<repo> --add-label "Working"
Apply the same ensure-then-add pattern to every label this skill applies later (Refined, Design, Browser, ui:visual-check, automerge:ok, …): before the first --add-label <name> of a label, run its gh label create <name> … || true with the color/description from the lifecycle table in /cenci:configure.
Ensure gate-decided labels exist, before Pass 1. Step 10's Pass 1 below may seed automerge:ok, Browser, and/or ui:visual-check directly into a child's create payload, and step 11 may add automerge:ok to the parent — gh issue edit --add-label fails when the label does not exist in the repository, so ensure all three exist now, before any child is created (the ensure-then-add rule already exists in #### Label "Working" above; this only relocates it ahead of the writes that need it, using the same colors/descriptions as the canonical table at configure/SKILL.md:715). Run each ensure as its own Bash call — idempotent (|| true), so running one this run doesn't need is harmless:
gh label create "automerge:ok" --repo <owner>/<repo> --color "006B75" --description "Human granted hands-off merge at refinement — babysit may merge this PR without review" 2>/dev/null || true
gh label create "Browser" --repo <owner>/<repo> --color "BFD4F2" --description "Implementation needs interactive browser access (Playwright CLI)" 2>/dev/null || true
gh label create "ui:visual-check" --repo <owner>/<repo> --color "FEF2C0" --description "Visual/layout change — verify in a browser before merge" 2>/dev/null || true
- Update the ticket description in the remote system.
IMPORTANT: Writing a temp file is NOT updating the ticket. You MUST execute the update command after writing the file. Never stop between writing the temp file and running the update command.
When the proposal adopted in step 9 includes an ### Updated Title, use the Write tool to create the raw title and body as plain text — ${TMPDIR:-/tmp}/cenci/issue-<number>-<token>-edit-title.txt (the updated title) and ${TMPDIR:-/tmp}/cenci/issue-<number>-<token>-edit-body.md (the updated description) — never a hand-escaped JSON literal (the title is free text and must never be interpolated directly into a command line; a title c
…(truncated)