Imported from Brite-Nites/brite-claude-plugins (
plugins/cadence/skills/linear-housekeeping/SKILL.md). Install upstream withnpx skills add Brite-Nites/brite-claude-plugins --skill linear-housekeeping. Copyright stays with the author.
Phase 3 — Linear Housekeeping
Batch mutation preview and atomic execute. Consumes the state object populated by Phase 2 (sprint-scoping), derives a mutations[] list, re-runs the issue-quality-gate on every cycle-path mutation with block-with-override, renders the full preview grouped by decision path, collects per-group approval, then executes sequentially while timestamping every write in a housekeeping log for audit trail. No Linear mutations fire until the user approves the full preview (BC-5810 § 1.1c). No Phase 4 narrative work.
This skill is inline (model inherits). Interactive approval gates cannot run inside a dispatched subagent.
Namespace note. Cadence reuses mcp__plugin_workflows_linear-server__* — Cadence does not register its own Linear MCP (per plugins/cadence/CLAUDE.md § MCP Servers + BC-5810 § 4 + BC-5811 § 4.2). Duplicate registration breaks tool routing. The issue body's mcp__plugin_cadence_linear-server__* suggestion is stale.
Gate-respect
Every multi-option AskUserQuestion in this skill is bound by the gate-respect contract. Once the planner picks an option at any pre-group gate (CQ3 parse, Conflicts, Preflight errors, Gate failures), at any regular decision-path group approval, or at the final "Execute now" gate, execute that exact option. No silent fallback to a lighter pattern — if an edge case demands deviation, re-prompt via a new AskUserQuestion rather than writing the deviation to the housekeeping log and proceeding. Origin: BC-5866 (W17 dogfood class-bug fix).
§ 1 Inputs (state object)
Reads from the session state object populated by Phases 0–2:
state.team.id,state.team.name— Brite Company, set in Phase 0state.cycle.current— the cycle being planned (W+1). Source of truth for everycycle-assigntarget.state.projects[].id,.name— resolved in Phase 0.3.state.projects[].scope_decisions— Phase 2 output. Fields consumed:q2_ship_ids[],q3_reassignments[],q5_parked[],carry_over_answers[](each keyed byissue_idwithcq1..cq5outcomes).state.projects[]._fetched_issues— Phase 2 enriched issue cache (authoritative source forbeforesnapshots; avoids duplicate reads).state.projects[].overrides— Phase 2 quality-gate overrides, shape[{issue_id, check, reason}].state.checkpoint_path— Phase 2 cached path; the housekeeping log sits in the same folder.
Populates / mutates:
state.mutations[]— the derived mutation list (schema per § 2).state._preflight_manifest = { preflight_at, manifest: {[mutation_id]: {issue_id, gate_detail[7]}}, row_errors[], dispatch_error }— produced by § 4 via thehousekeeping-preflightagent (BC-5902). Canonical shape lives inplugins/cadence/commands/weekly.md § Session State Object; do not re-declare here.state._mutation_conflicts[]— cross-project conflicts detected during § 2.5 de-duplication (each entry carries both source projects + both target states). Surfaced in the § 5 preview under## Conflicts (resolve before execute)and resolved via a dedicated § 6.1 group approval before regular groups run.state._cq3_parse_errors[]— carry-over rows where CQ3's "superseded by" answer contained zero^BC-\d+$tokens. Each entry{project_id, issue_id, raw_cq3_answer}. Surfaced in the § 5 preview under## CQ3 parse errors (resolve before execute)and resolved via § 6.0 pre-preview re-parse (not through the quality-gate, which only handles the 7-check tuple on live issues).state.housekeeping_log_path— resolved once at § 7 entry.state._executed_mutation_ids[]— resume cache (populated on successful writes).state._create_preflight_cache—{ [projectId]: list_issues-response }memoization used by § 3'screatepre-flight so multiple create rows in the same project share one fetch. Phase-3-scoped; reset on each invocation.state.projects[i].overrides[]— appended to when the user picks "Override with reason" during the § 6Gate failuresgroup prompt (the gate re-run in § 4 is read-only and stages failures undergate_status="fail").
Writes no fields used by Phase 4 beyond what Phase 2 already populated (overrides + scope_decisions). Phase 4 narrative reads those directly.
§ 2 Decision-path + mutation-type derivation
2.1 Mutation object shape
Every row in state.mutations[] has:
{
"id": "<decision_path>-<mutation_type>-<target.id or target.name slug>", // stable for idempotency log
"decision_path": "cycle" | "backlog" | "cancel" | "reassign" | "leave",
"mutation_type": "cycle-assign" | "state-change" | "reassign" | "cancel" | "create" | "milestone-rename" | "label-change" | "backlog-return",
"target": { "kind": "issue" | "milestone", "id": "<uuid or null-for-create>", "name": "<identifier or title>", "projectId": "<uuid or null-for-global>" },
"before": { "cycleId", "cycleName", "stateType", "stateName", "assigneeId", "assigneeName", "title", "labelIds", "labelNames" },
"after": { "cycleId", "cycleName", "stateType", "stateName", "assigneeId", "assigneeName", "title", "labelIds", "labelNames", "duplicateOfId" }, // `duplicateOfId` only populated on cancel rows
"gate_status": "pass" | "fail" | "override" | "n/a",
"gate_detail": [ … ], // 7-tuple from issue-quality-gate; empty if path != "cycle"
"override_reason": "<string>" | null,
"executed_at": "<ISO-8601>" | null,
"result": "executed" | "skipped-idempotent" | "errored" | "dropped-by-user" | "pending-cq3-reparse" | null,
"error": "<string>" | null,
"source_project": "<project_name>" // "(global)" for milestone renames
}
Both ID-form (cycleId, stateType, assigneeId, labelIds, duplicateOfId) and name-form (cycleName, stateName, assigneeName, labelNames) are populated at derivation time. § 3's pre-flight uses IDs for equality comparisons against the live issue; § 7.3's Linear MCP save_issue calls pass names (per memory/MEMORY.md — the write API takes assignee, cycle, state, labels name strings, not IDs).
2.2 Per-issue derivation (applied to every state.projects[i].scope_decisions.carry_over_answers[] entry)
For each carry-over answer tuple keyed by issue_id:
| Answer condition | Decision path | Mutation type(s) emitted |
|---|---|---|
| CQ1 answered "Park indefinitely" (per sprint-scoping § 3: CQ5 is auto-skipped in this case; rule fires before any CQ5 row so the user's explicit park intent is not silently dropped) | backlog |
backlog-return (cycleId → null, state.type → backlog, assignee → null) |
| CQ3 answered "superseded by " | cancel |
cancel (parse IDs from CQ3 free-text: split on comma/whitespace, dedup tokens via set semantics, match each token against ^BC-\d+$ requiring a non-zero integer suffix (^BC-[1-9]\d*$), cap at 10 IDs. If ≥1 token matches: primary valid ID → duplicateOf; rest → relatedTo; plain-text comment "Superseded by BC-X, BC-Y per W<NN> planning." using only the validated IDs. If ZERO tokens match: append {project_id, issue_id, raw_cq3_answer} to state._cq3_parse_errors[] AND emit a placeholder row {decision_path: "cancel", mutation_type: "cancel", result: "pending-cq3-reparse", gate_status: "n/a"} under the cancel group so the § 5 preview shows the full intent count, then surface in § 6.0 for inline re-parse before § 6.3 approves the cancel group. This preserves the "what you see in the preview is what you approve" contract. |
| CQ5 answered "back to backlog" | backlog |
backlog-return (cycleId → null, state.type → backlog, assignee → null) |
| CQ5 answered "specific future cycle " (not W+1) | cycle |
cycle-assign with target = that cycle |
| CQ1 answered "move to W+1" AND CQ3 not "superseded" AND CQ5 not "backlog" | cycle |
cycle-assign with target = state.cycle.current.id |
CQ2 changed assignee AND path is cycle or leave |
(same as primary path; or reassign if nothing else changed) |
additional reassign mutation on same issue |
CQ2 changed assignee AND path is cancel or backlog |
(primary path wins — assignee change is subsumed) | no separate reassign emitted; cancel sets assignee irrelevant, backlog-return nulls assignee anyway |
| All five Qs answered default AND no assignee change | leave |
(none — no mutation emitted) |
leave-path issues emit no mutation row at all — they are not written to state.mutations[], not rendered in the § 5 preview, not consumed by a § 6 approval, and not logged. A leave-path outcome is implicit (the issue stays in Linear as-is); only actionable mutations appear in the audit trail.
2.3 Per-project scope-block derivation
From state.projects[i].scope_decisions:
- SQ2
ship_idsfor an ID not already incarry_over_answers(backlog pull): emitcycle-assign+state-change(backlog → unstarted) as two rows sharing pathcycle. - SQ2
ship_idsthat name a new issue to create (entry does not match^BC-\d+$— planner wrote a title instead of an existing ID): emitcreaterow under pathcyclewithtarget.id = null,target.nameset to the trimmed entry text, andtarget.projectId = state.projects[i].id. Reject empty strings. Every other mutation type also populatestarget.projectIdfromstate.projects[i].id(formilestone-renamein the global bucket,target.projectId = nullsince milestones are cross-project); this keeps § 3's project-scoped pre-flight reads correct. - SQ3
reassignmentsfor any issue ID not already covered by CQ2: emitreassignrow under pathreassign. - SQ5
parkedIDs: emitbacklog-returnrow under pathbacklog. - SQ4 dependencies: (out of scope for this issue; declared in Phase 4 narrative only — no Linear write for this round).
2.4 Global mutations
- Milestone renames from the planning checkpoint (e.g. W16's
"Pre-Launch Hardening" → "Production Hardening"): emit onemilestone-renamerow withsource_project: "(global)". Preview renders under a separate## Global mutationssection. - New-issue creation with cross-project scope: same section.
- Label changes (mutation type
label-change): enumerated in § 2.1 and supported by § 3 + § 7.3 to satisfy AC #8's "issue label changes" requirement. No derivation rule in the current implementation emitslabel-changerows — the type is reserved for a future Phase 2 extension that strips per-cycle labels (carrying-over,stretch) on backlog-return / cycle-assign. If BC-5763 dogfood surfaces a concrete label-change need, add an emitter rule here; until then, the dispatch path exists and has been verified against a seeded mutation but is not derived from scope decisions.
2.5 De-duplication
If two projects reference the same issue (e.g. an assignee change on an issue owned by project A but scoped into project B's cycle), merge into a single mutation row with last-write-wins on target state. If the two decisions conflict (different target cycles, different assignees, or one says cancel while the other says cycle-assign), do NOT prompt mid-derivation — instead, append the conflict to state._mutation_conflicts[] with both source projects + both target states. The conflict rows render as a dedicated ## Conflicts (resolve before execute) section in the § 5 preview (after the ## CQ3 parse errors section at the very top) and consume a Conflicts group approval in § 6 pre-group 1 — after pre-group 0 CQ3 parse errors, before pre-group 2 Preflight errors, pre-group 3 Gate failures, and the regular decision-path groups. This preserves the "no AskUserQuestion before the preview renders" contract (§ 5 + BC-5810 § 1.1c).
§ 3 Pre-flight idempotency checks (before every write)
Each state.mutations[i] entry runs a pre-flight read to detect already-applied state. Pre-flight short-circuit: if a row carries result == "pending-cq3-reparse", skip it entirely (do not issue any MCP call, do not enter the dispatch in § 7.3). The row is an intentional placeholder used to make § 5's preview count match § 6.3's approval count; a placeholder that reaches § 7 means § 6.0 resolution was skipped — treat as user-dropped and log result: "dropped-by-user" with message "pending-cq3-reparse placeholder unresolved at execute time".
(Synthetic-integrity rows — gate_detail[0].check == "preflight_integrity" with gate_status == "fail" from § 4 step 4 — are resolved earlier in § 6 pre-group 2's Retry-fetch / Override / Drop handler, not here. A row still carrying that signature at § 7 means § 6 resolution was skipped — § 7.3's loop-entry filter drops them as "dropped-by-user". The § 3 pre-flight ladder below is never entered for such rows.)
Skip-condition field references below use the flat field names populated by _fetched_issues under BC-5902 (issue.cycleId, issue.stateType, issue.assigneeId, issue.labelIds). When § 3 falls through to a fresh get_issue (no cache hit), the caller normalizes the Linear MCP response's nested shape (issue.state.type → issue.stateType; issue.labels.map(l => l.id) → issue.labelIds) before applying the skip condition — so the table reads consistently regardless of source.
| Mutation type | Pre-flight read | Skip condition |
|---|---|---|
cycle-assign |
get_issue(id) |
issue.cycleId == after.cycleId |
state-change |
get_issue(id) |
issue.stateType == after.stateType |
reassign |
get_issue(id) |
issue.assigneeId == after.assigneeId |
cancel |
get_issue(id) + list_comments(issueId: id) |
issue.stateType == "canceled" AND at least one comment body matches /^Superseded by .* per W\d+ planning\.$/. Both conditions required — a partial prior run that canceled the issue but failed on save_comment must re-attempt the comment step on the next invocation instead of being silently skipped. |
create |
list_issues(project: target.projectId, limit: 250) |
any returned row where normalize(row.title) == normalize(target.name) — normalize by trimming whitespace and collapsing internal whitespace runs. Fetch the full project issue set rather than using the fuzzy query param (which can miss exact-title duplicates ranked below the default pagination cap). Page if hasNextPage is true. Memoize the full issue list per target.projectId in a Phase-3-scoped cache (state._create_preflight_cache[projectId]) — multiple create rows in the same project reuse the first fetch instead of re-paginating. |
milestone-rename |
list_milestones(project: target.projectId) |
milestone exists with name == after.name |
label-change |
get_issue(id) |
set of issue.labelIds equal to after.labelIds set |
backlog-return |
get_issue(id) |
issue.cycleId == null AND issue.stateType == "backlog" AND issue.assigneeId == null (all three fields must already be cleared — skipping without the assignee check would leave the assignee: null write un-applied on first run) |
On skip: log the row with result: "skipped-idempotent", timestamp the skip decision, move to next mutation. No Linear write issued.
Pre-flight reads are cheap relative to writes — the trade-off is acceptable for AC #5 (second run produces zero write calls). Reuse order for every pre-flight:
- If § 4's gate-fetch populated
state.projects[i]._fetched_issues[id]during this Phase 3 invocation, pre-flight reads from that cache without a fresh MCP call. Stale-read risk is bounded by the gate #2 approval window (typically seconds). Cached entries are already in flat shape (§ 4 step 3 populated frommanifest_entry.issue_snapshot), so the skip-condition table reads them directly. - Else, if Phase 2 populated the same cache earlier in the session, reuse it — same staleness bound. Phase 2's writes (sprint-scoping § 2 step 2 populating from
_enrichment.carry_over_enriched[].issue_snapshot) are also flat. - Else, issue a fresh
get_issue(id). Normalize the Linear MCP response's nested shape before writing to the cache:{cycleId: resp.cycle?.id, stateType: resp.state?.type, assigneeId: resp.assignee?.id, labelIds: resp.labels?.map(l => l.id)}. Write the flat-form object tostate.projects[i]._fetched_issues[id]for subsequent reuse, then apply the skip-condition table. Without this normalization step, the table's flat-field accessors (issue.stateType,issue.labelIds, etc.) resolve toundefinedagainst the raw Linear shape and skip conditions silently evaluate to false.
This drops a redundant get_issue per cycle-path row (§ 4 gate fetch + § 3 pre-flight + § 7 execute is the worst case without reuse; with reuse it's one fetch per row).
§ 4 Quality gate re-run for cycle-path mutations
Every mutation with decision_path == "cycle" runs the gate again in Phase 3. Two reasons:
- State may have drifted since Phase 2 approved scope-in (another session, manual Linear edit, Phase 3 resume after pause).
- Phase 2 captured overrides for known failures; Phase 3 belt-and-suspenders catches new failures introduced after the scope approval.
§ 4 is read-only — no AskUserQuestion fires in this section. The gate re-run classifies every cycle-path row; live failures surface in the § 5 preview under a dedicated ## Gate failures (resolve before execute) section and consume a Gate failures group approval in § 6. This preserves the "full preview before any prompt" contract stated in § 2.5.
Batch dispatch (BC-5902). After § 2 derivation produces state.mutations[], collect every row where decision_path == "cycle" into preflight_input = [{mutation_id: m.id, issue_id: m.target.id} for m in state.mutations if m.decision_path == "cycle"]. Flatten state.projects[].overrides[] into a single list flat_overrides = [{issue_id, check, reason}] across all projects. Dispatch housekeeping-preflight once via the Agent tool with prompt body: {cycle.current, team_id, mutation_rows: preflight_input, overrides: flat_overrides}. Parse the returned JSON into state._preflight_manifest.
Dispatch error handling (fail-loud per BC-5898 AC). On _preflight_manifest.dispatch_error non-null, halt before § 5 and surface AskUserQuestion with three options: Retry (re-dispatch the agent with the same input), Pause session (breadcrumb update current_phase = "phase-3", exit cleanly), Execute without preflight (NOT RECOMMENDED — explicit spec-departure override: set state.phase_3_spec_departure = "preflight-skipped-user-override", every cycle-path row gets gate_status = "n/a" + gate_detail = [], surfacing flag rendered in § 7.5 housekeeping-log Execution summary AND carried into Phase 4 narrative > **Known gaps this cycle** callout). No silent "context-pressure-skipped" path anywhere (BC-5898 root-cause fix — attempt-2 regression).
Row-level error handling. On _preflight_manifest.row_errors[] non-empty (partial fetch failure), surface each errored row as a ## Preflight errors (resolve before execute) section at the TOP of the § 5 preview and consume a Preflight errors group approval in § 6 (pre-group ordering becomes: 0 CQ3 parse → 1 Conflicts → 2 Preflight errors → 3 Gate failures → 4 regular decision-path groups).
Manifest consumption. For each cycle-path mutation row, look up state._preflight_manifest.manifest[row.id]:
- Set
row.gate_detail = manifest_entry.gate_detail(the 7-tuple from the agent). - Derive
row.gate_statusfrom the tuple: allstatus == "pass"→"pass"; anystatus == "override"and no"fail"→"override"(and copy the matchedmatched_reasonintorow.override_reason— first override wins if multiple checks overridden, but mechanically every override carries its own reason inside the tuple so no loss); any"fail"→"fail"(row moves from its primary decision-path preview group to the## Gate failures (resolve before execute)preview section for § 6 pre-group 3 handling). - Populate
_fetched_issuescache from snapshot (BC-5902). Ifmanifest_entry.issue_snapshotis present, write it tostate.projects[i]._fetched_issues[manifest_entry.issue_id]keyed under whichever project owns the mutation viarow.source_project(every cycle-path row has a non-(global)source_project by construction — § 2.1 populatestarget.projectIdfromstate.projects[i].idon all cycle-path rows). § 3's pre-flight reuse ladder step 1 reads this cache for equality comparisons againstafter.cycleId/after.stateType/after.assigneeId/after.labelIds(the flat-form fields — § 3's skip-condition table references them directly post-BC-5902), eliminating the redundantget_issueper cycle-path row that would otherwise fire in § 3. - If
row.idis NOT present in_preflight_manifest.manifestbut the row IS in thepreflight_input(i.e. the agent skipped it without reporting a row_error — bug surface), setrow.gate_status = "fail"with a syntheticgate_detail = [{check: "preflight_integrity", status: "fail", message: "mutation_id absent from preflight manifest with no row_error — dispatch agent output integrity violation; re-dispatch required"}]. Surface under## Gate failuresso the user can Fix-now by Retry.
Non-cycle-path mutations (cancel, reassign, backlog, milestone-rename, global create) skip the gate — gate_status = "n/a", gate_detail = []. No preflight dispatch for them.
§ 5 Preview rendering
After § 2 derivation + § 4 gate pass, render the preview to the user as plain markdown (one tool-call message, no interactive prompts yet). Group first by decision_path, then by mutation_type within each path. leave-path issues emit no mutation row so they never appear here (see § 2.2 trailing paragraph). Each path with ≥1 row gets its own ## Decision path: <path> header. A final ## Global mutations section covers milestone-rename + global create rows.
# W<NN> Housekeeping Preview
Generated: <ISO-8601 now>
Source: <state.checkpoint_path>
Target cycle: <state.cycle.current.name> (<state.cycle.current.id>)
## CQ3 parse errors (resolve before execute)
_Rendered only if `state._cq3_parse_errors[]` is non-empty. Each row shows the carry-over issue + the raw CQ3 answer that produced zero `^BC-\d+$` tokens. Resolved in § 6 pre-group 0 — first of four pre-groups, before Conflicts (pre-group 1), Preflight errors (pre-group 2), Gate failures (pre-group 3), and the regular decision-path groups._
Rendering sanitization for the Raw CQ3 answer column: replace `|` with `\|`, replace newlines / carriage returns with the visible glyph ``, truncate to 120 characters with ellipsis, and wrap the entire cell in backticks so markdown/HTML inside the user's text is inert. Raw values are logged to the housekeeping log with the same escaping (§ 7.5).
| Issue | Project | Raw CQ3 answer |
|---|---|---|
| BC-XXXX | <project> | `<sanitized free-text>` |
## Conflicts (resolve before execute)
_Rendered only if `state._mutation_conflicts[]` is non-empty. Each row shows the issue ID + both source projects + both target states. Resolved in § 6 pre-group 1 — after pre-group 0 CQ3 parse errors, before pre-group 2 Preflight errors, pre-group 3 Gate failures, and the regular decision-path groups._
| Issue | Source projects | Target A | Target B |
|---|---|---|---|
| BC-XXXX | <project_A>, <project_B> | <e.g. cycle=W17, assignee=Corinne> | <e.g. cancel, duplicateOf=BC-YYYY> |
## Preflight errors (resolve before execute)
_Rendered only if `state._preflight_manifest.row_errors[]` is non-empty. Each row shows the `mutation_id` + `issue_id` + the fetch error returned by the `housekeeping-preflight` agent. Resolved in § 6 pre-group 2 — after pre-group 0 CQ3 parse errors and pre-group 1 Conflicts, before pre-group 3 Gate failures and the regular decision-path groups._
| Mutation | Issue | Error |
|---|---|---|
| <mutation_id> | BC-XXXX | <fetch error message> |
## Gate failures (resolve before execute)
_Rendered only if any cycle-path row has `gate_status == "fail"` from § 4. Resolved in § 6 pre-group 3 — after pre-group 0 CQ3 parse errors, pre-group 1 Conflicts, and pre-group 2 Preflight errors._
| Issue | Failing check | Message | Options |
|---|---|---|---|
| BC-XXXX | `dependencies_declared` | "description has no '## Dependencies' header" | Fix now / Override / Drop |
## Decision path: cycle (<N> mutations)
### Type: cycle-assign
| Issue | Title | Action | Before → After | Gate |
|---|---|---|---|---|
| BC-XXXX | <title> | Move to <cycle> | cycle:<before> → <after> | ✓ pass |
| BC-YYYY | <title> | Move to <cycle> | cycle:<before> → <after>, state:started→unstarted | ⚠ override: <check> (reason: <reason>) |
### Type: state-change
…
### Type: reassign
…
## Decision path: cancel (<M> mutations)
### Type: cancel
| BC-ZZZZ | <title> | Cancel, superseded-by BC-AAAA, BC-BBBB | state:unstarted → canceled | n/a |
## Decision path: reassign (<P> mutations)
### Type: reassign
| BC-QQQQ | <title> | <old> → <new> | assignee:<old> → <new> | n/a |
## Decision path: backlog (<R> mutations)
### Type: backlog-return
| BC-SSSS | <title> | Return to backlog | cycle:<w>,state:started → cycle:null,state:backlog | n/a |
## Global mutations
### Type: milestone-rename
| Milestone ID | Action | Before → After |
|---|---|---|
| <uuid> | Rename | "<old>" → "<new>" |
### Type: create
| Title | Project | Target cycle | Assignee |
|---|---|---|---|
| <title> | <project> | <cycle> | <assignee or "(unassigned)"> |
## Summary
- **Total mutations:** <T>
- **By path:** cycle <N>, cancel <M>, reassign <P>, backlog <R>, global <G>
- **Gate blocks:** <F> failing cycle mutations unresolved (0 expected after § 4)
- **Overrides carried forward:** <O> from Phase 2 + <O'> new in Phase 3
- **Estimated execute time:** ~<T × 0.5>s at ~500ms/call through MCP
Render the full preview before any AskUserQuestion. The user reads the whole thing before approving the first group.
§ 6 Per-group approval via AskUserQuestion
After the preview renders, iterate over the distinct non-empty groups in this order — four pre-groups first so that CQ3 parse errors, conflicts, preflight errors, and gate failures are resolved before any regular decision-path group is approved:
-
CQ3 parse errors (from
state._cq3_parse_errors[], § 2.2 cancel-row derivation). For each entry, surface the carry-over issue's identity in the prompt (the placeholder'sresult == "pending-cq3-reparse"row in thecancelgroup is the row that will be resolved; § 2.2 rules ensure no concurrentleave/backlogrow exists for CQ3-parse-fail issues unless CQ1=Park AND CQ3 were both answered, a rare overlap the prompt should enumerate).AskUserQuestionwith three options: Re-enter IDs (follow-up free-textAskUserQuestion; re-parse with^BC-[1-9]\d*$requirement; dedup tokens via set semantics; cap at 10 IDs; on ≥1 valid ID, replace the placeholder row in-place: match byissue_id+result == "pending-cq3-reparse", setafter.duplicateOfId, setafter.relatedToIds, clearresulttonull, regenerate the row'sid. Remove the corresponding entry fromstate._cq3_parse_errors[]. Do not touch any other row for this issue), Drop cancel (remove the placeholder row fromstate.mutations[]; the issue retains whatever other rows § 2.2 derived — if none, the issue stays in Linear as-is), Keep as leave (remove the placeholder row; no mutation emitted for this issue). Handler is inline re-parse — does NOT call the quality-gate. -
Conflicts (from
state._mutation_conflicts[], § 2.5). For each conflict row,AskUserQuestionwith three options:Option Effect Pick source A/B Replace the issue's mutation row(s) with A's (or B's) decision; drop the other source's row. Merge manually Collect the merged target state via four sequential AskUserQuestioncalls (one field per call, permemory/feedback_one_question_at_a_time.md): (i) target cycle name (free-text; leave blank fornull/backlog), (ii) assignee name (free-text; leave blank for unassigned), (iii) target state name (free-text), (iv) comma-separated labels (free-text; blank for none). After collecting all four, re-derivedecision_pathfrom the merged fields (state=backlog+ cycle=null→backlog; state=Canceled→cancel; state in cycle workflow →cycle). If the derived path iscycle, re-runcadence:issue-quality-gateon the merged row before replacing existing rows. Invalid field values are not pre-validated (no Phase-0 cache of cycles/users/states/labels exists);save_issueat § 7.3 will surface Linear's rejection message asresult: "errored"on execute. This prevents merge-flow bypass of the § 4 gate while keeping Phase 3 free of lookup-cache dependencies that weekly.md Phase 0 does not provide.Drop the issue Remove every mutation row for this issue_idacross all groups (not just the conflicting pair).Resolution updates
state.mutations[]in place before the next group runs. -
Preflight errors (from
state._preflight_manifest.row_errors[], § 4). Each errored row requires inline resolution because the gate could not be applied. For each entry, surface the mutation_id + issue_id + error viaAskUserQuestionwith three options:Option Effect Retry fetch Dispatcher issues a fresh get_issue(id)in main thread + inline-re-applies the 7 checks fromplugins/cadence/skills/_shared/issue-quality-gate/SKILL.md. On success, the row'sgate_detailpopulates in_preflight_manifest.manifest[mutation_id]and the row proceeds to its primary decision-path group. On continued failure, re-prompt with the same three options.Override all 7 checks for this row with reason Follow-up free-text AskUserQuestion(one question) collecting the reason. Append{issue_id, check: "all", reason}tostate.projects[i].overrides. Row is flaggedgate_status = "override"with a synthetic gate_detail of 7overridetuples sharing the same reason.Drop from scope Remove the row from state.mutations[]; the mutation does not execute. -
Gate failures (rows with
gate_status == "fail"from § 4). For each failing row,AskUserQuestionwith three options: Fix now (echohttps://linear.app/brite-nites/issue/<id>, wait for user confirmation, re-run gate, on pass the row re-enters its primary decision-path group), Override with reason (collect a one-line free-text reason via follow-upAskUserQuestion, append{issue_id, check, reason}tostate.projects[i].overrides, setgate_status = "override", re-enter primary group), or Drop from scope (remove the row fromstate.mutations[]). -
Regular decision-path groups — iterate the four
decision_pathvalues (cycle,cancel,reassign,backlog) plus theglobalpreview bucket (rows withsource_project == "(global)"— e.g.milestone-rename+ cross-projectcreate). Skip any empty group. For each non-empty group, oneAskUserQuestioncall with the three options defined below.
The count of AskUserQuestion group-approval calls for the regular decision-path groups (step 4) equals the number of distinct non-empty decision-path groups in the checkpoint — this is the literal AC #3 assertion. Pre-groups (0/1/2/3) are per-row by necessity — each CQ3 parse error, conflict, preflight error, or live gate failure requires individual resolution because merging them into a single batch prompt would lose the per-row context the user needs to decide. Fix-now / Override / Retry-fetch follow-ups inside regular-group or preflight-group entries are also per-row conditional and not part of the AC #3 regular-group count. When tallying AC #3 evidence, count only the prompts fired in step 4.
Each group prompt offers three options:
| Option | Effect |
|---|---|
| Approve these mutations (Recommended) | Flag every row in the group as approved: true; continue to next group. |
| Reject all in this group | Flag every row in the group as result: "dropped-by-user"; log reason; continue to next group. |
| Edit | Follow-up AskUserQuestion asks "Which issue IDs to drop from this group?" with free-text input; parse comma-separated IDs matching ^BC-\d+$; drop matching rows from the group; remaining rows flag approved: true. The standard AskUserQuestion "Other" escape is always present and accepts free-text override instructions per the platform convention. |
After all groups have been answered, render a final execution gate:
"Ready to execute approved mutations across groups. dropped by user, overrides captured. Proceed?"
| Option | Effect |
|---|---|
| Execute now (Recommended) | Move to § 7 batch execute. |
| Cancel | Log the cancel, exit cleanly. No mutations issued. State object retained so user can re-invoke /cadence:weekly --resume-phase 3. |
Hard rule: no Linear write is issued until the final Execute now approval. This is the gate reason (c) from BC-5810 § 1.1.
§ 7 Batch execute + housekeeping log
7.1 Resolve housekeeping log path
Prefer reusing Phase 2's already-resolved path. If state.checkpoint_path is non-null, derive the log path by replacing the filename suffix -planning-checkpoint.md → -housekeeping-log.md within the same parent directory — no git rev-parse Bash call needed.
If state.checkpoint_path is null (Phase 3 was invoked standalone via --resume-phase 3), resolve from scratch. Mirror Phase 2 § 6: pre-extract CYCLE_NN from state.cycle.current.name and CYCLE_DATE from state.cycle.current.startsAt (formatted YYYY-MM-DD). Reject if CYCLE_DATE does not match ^[0-9]{4}-[0-9]{2}-[0-9]{2}$. Pseudocode:
# Pre-extracted by the skill from state:
# CYCLE_NN = numeric week from state.cycle.current.name (e.g. "W17" → 17)
# CYCLE_DATE = state.cycle.current.startsAt formatted YYYY-MM-DD
# Then in Bash, with runtime guards (defense-in-depth — prose rejection above is advisory; these assertions enforce shape at shell level):
[[ "$CYCLE_DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || { echo "Invalid CYCLE_DATE: $CYCLE_DATE" >&2; exit 1; }
[[ "$CYCLE_NN" =~ ^[0-9]+$ ]] || { echo "Invalid CYCLE_NN: $CYCLE_NN" >&2; exit 1; }
WEEK_NN=$(printf "%02d" "$CYCLE_NN")
ROOT=$(git rev-parse --show-toplevel)
LOG="$ROOT/../weekly-planning/w${WEEK_NN}-${CYCLE_DATE}/w${WEEK_NN}-housekeeping-log.md"
If state.weekly_planning_root is set, use it as the prefix instead of $ROOT/... Cache the resolved path at state.housekeeping_log_path. The Bash exit 1 on a shape-check failure is intentional fail-fast — upstream Phase 0 must produce a well-formed state.cycle.current.{name,startsAt}. On fail-fast, the skill surfaces the stderr message to the user and exits cleanly without partial state mutation.
7.2 Log header (write once before any mutation)
# W<NN> Housekeeping Log
Generated: <ISO-8601>
Phase 3 run by: /cadence:weekly phase 3
Target cycle: W<NN> (<cycle_id>)
Source preview: see preview output from this session
## Execution summary
_Populated at end of run._
## Mutations
| Timestamp | Decision path | Mutation type | Issue | Before | After | Gate | Result | Error |
|---|---|---|---|---|---|---|---|---|
7.3 Execute each mutation
For each row in state.mutations[] where approved: true AND result != "pending-cq3-reparse" (belt-and-suspenders guard against a placeholder leaking past § 6.0) AND not a synthetic-integrity row still carrying gate_detail[0].check == "preflight_integrity" with gate_status == "fail" (which would indicate § 6 pre-group 2 Retry/Override resolution was skipped — treat as "dropped-by-user" and log result: "dropped-by-user" with message "preflight_integrity synthetic failure unresolved at execute time"):
-
Record
started_at = <ISO-8601 now>. -
Run the § 3 pre-flight read. If skip condition met: set
result = "skipped-idempotent",executed_at = started_at, append row to log, continue. -
Otherwise, call the appropriate Linear MCP mutation:
Type MCP call Key parameters cycle-assignsave_issue{ id, cycle: after.cycleName }state-changesave_issue{ id, state: after.stateName }reassignsave_issue{ id, assignee: after.assigneeName }(useassignee, NOTassigneeId, permemory/MEMORY.md)cancelsave_issue+save_commentsave_issue { id, state: "Canceled", duplicateOf: after.duplicateOfId }, thensave_comment { issueId: id, body: "Superseded by <IDs> per W<NN> planning." }(plain text, no markdown — avoids Prosemirror mangling)createsave_issue{ title, team, project, cycle, assignee, priority, description }milestone-renamesave_milestone{ id, name: after.name }label-changesave_issue{ id, labels: after.labelNames }backlog-returnsave_issue{ id, cycle: null, state: "Backlog", assignee: null }(multiple fields in one call) -
On success: record
executed_at = <ISO-8601 now>,result = "executed". Append log row. -
On MCP error: record
result = "errored",error = <message>. Append log row. Continue to next mutation — do not abort. -
Only for mutations that write prose (
create,cancel'ssave_commentbody,milestone-renamewhen new name contains**/→/=/§), re-read post-write viaget_issue/get_milestoneand verify Prosemirror did not mangle the text (permemory/gotcha_linear_markdown_mangling.md).cycle-assign,state-change,reassign,label-change,backlog-returnskip the post-write read — they touch only structured fields, not markdown bodies. If mangled, log a warning but do not fail.
7.4 Rate limiting
Linear GraphQL mutations average ~300-600ms per call through the MCP round-trip. Natural pacing keeps burst rate at ~3-4 writes/sec — well under the 10/sec ceiling. No explicit sleep is inserted in the happy path.
The log's ISO-8601 timestamps are the audit source for AC #7. Post-execution assertion in § 7.5 slides a 1-second window across the executed_at column filtered to rows where result == "executed" — skipped-idempotent rows share started_at as their executed_at (instant, no MCP call) and must not count against the rate-limit check. AC #5's "second run = zero writes" path would otherwise false-positive a rate-limit fail by bunching 80 instant skip rows into a sub-second window. If the filtered assertion ever fails in practice, add Bash sleep 0.15 between executed writes in a follow-up PR.
7.5 Close out log
Once every mutation has been attempted, populate the ## Execution summary section:
## Execution summary
- **Mutations previewed:** <T>
- **Approved by user:** <A>
- **Executed:** <E>
- **Skipped (idempotent):** <S>
- **Dropped by user:** <D>
- **Errored:** <X>
- **Duration:** <started>..<ended> (<elapsed>s)
- **Max writes/sec observed:** <rate> (AC #7 compliance — computed from rows where `result == "executed"` only; skipped/errored/dropped rows excluded because they issued no MCP call)
- **Rate-limit check:** <pass|fail> (AC #7 requires ≤10/sec)
§ 8 Idempotency + resume + failure handling
- Resume pass. On Phase 3 entry, if
state.housekeeping_log_pathalready points at a file and that file contains a## Mutationstable with rows, parse the existing rows to populatestate._executed_mutation_ids[]. Any mutation instate.mutations[]whoseidappears in the cache is short-circuited toresult: "skipped-idempotent"without a Linear read. This handles the "second run" path required by AC #5. - Pre-flight read as second line of defense. Even if the cache is cold, § 3's pre-flight read catches already-applied state. AC #5's "second run = zero writes" holds in both cached and uncached second-run scenarios.
- Mid-batch user cancel. The § 6 execution gate is the last user decision. Once execute starts, the skill does not re-prompt until § 7.5's summary. A SIGINT / session exit mid-batch leaves
state.mutations[]partially populated withexecuted_attimestamps — resume picks up via_executed_mutation_ids[]on next run. - Cycle-change guard. If
state.housekeeping_log_pathexists and its filename'sw<NN>segment does not match the cycle derived fromstate.cycle.current.name, refuse to append and stop with an error pointing the user at/cadence:weekly --resume-phase 3after they confirm the cycle. Guards against a stalestate.weekly_planning_rootoverride pointing at a prior cycle's folder. - Errored mutations. A mutation logged with
result: "errored"does not set_executed_mutation_ids[]. Re-invoking Phase 3 retries the errored mutation only (successful ones pre-flight as idempotent). - Pending-cq3-reparse placeholder rows. Placeholders never enter
_executed_mutation_ids[](they are not Linear writes). On resume,_cq3_parse_errors[]is re-derived fromstate.projects[i].scope_decisions.carry_over_answers[]so the placeholder rows re-appear in the preview and re-prompt in § 6.0 until the user resolves them. AC #5's "second run = zero writes" guarantee assumes the user's CQ3 resolution is stable across runs; a second-run resolution that emits new valid IDs legitimately produces writes.
§ 9 References
docs/designs/cadence-orchestration.md§ 1.1c (batch-at-end reason) + § 3 (gate spec) — BC-5810 authoritativedocs/designs/cadence-plugin.md(BC-5757) — voice, Linear query recipes, PDF flowplugins/cadence/skills/sprint-scoping/SKILL.md(BC-5760) — state-object producer, checkpoint path resolution, idempotency patternplugins/cadence/skills/_shared/issue-quality-gate/SKILL.md— gate primitive (BC-5810 § 3)plugins/cadence/agents/housekeeping-preflight.md— preflight agent this skill dispatches (BC-5902)plugins/cadence/agents/project-audit.md(BC-5759) — audit card field names forbefore-state lookupsplugins/cadence/commands/weekly.md§ Phase 3 — entry command pointer + session state object schemaplugins/workflows/skills/create-issues/SKILL.md— canonical create-mutation patternmemory/MEMORY.md(Conventions) —assignee:notassigneeId:onsave_issuememory/gotcha_linear_markdown_mangling.md— Prosemirror mangling; plain-text comment bodies; post-writeget_issueverificationmemory/feedback_more_checkins_for_infra_issues.md— Phase 3 is the most destructive phase; per-group approval + final execute gate satisfy the check-in ruleweekly-planning/w16-2026-04-13/w16-planning-checkpoint.md§ Actions Taken — ground-truth example exercising every mutation type
Deferred to follow-up issues
- SQ4 dependency writes (
blockedBy/blocksrelations on issues scoped into the cycle) — currently declared in Phase 4 narrative only. If planner wants these written back to Linear, file a follow-up issue in the Cadence Plugin milestone. - Partial preview re-render on "Edit" — current impl drops rows by ID and proceeds to the next group. A richer edit flow (change target cycle, rewrite cancel supersede chain, retarget milestone rename) belongs in a v2 issue if usage demand surfaces during BC-5763 dogfood.