Skip to content

feat(stack): Add forward-merge carry and an OpenSpec rot sweep#66

Merged
thecodedrift merged 2 commits into
mainfrom
feat/stack-forward-carry
Jul 14, 2026
Merged

feat(stack): Add forward-merge carry and an OpenSpec rot sweep#66
thecodedrift merged 2 commits into
mainfrom
feat/stack-forward-carry

Conversation

@thecodedrift

Copy link
Copy Markdown
Member

Adds incremental-forward (root → tip) landing alongside the existing tip→root collapse, and a scheduled backstop for the OpenSpec debris that incremental-forward can leave on main. Requested by the taskless/taskless team, who want to land stacks slice-by-slice rather than only as an atomic collapse.

Forward-merge carry

carryBackward is the mirror of carryForward. When a stack lands incrementally, the bottom PR merges to the default branch first and the next PR up is retargeted onto it, becoming the redirected root. That surviving child absorbs the parent it was built on as # Built on top of #N, where the down-merge absorbs a child as # Contains #N. Either way the PR that finally reaches main holds the full stack legacy — read from the opposite end.

  • renderCarriedRegion takes a heading argument (default Contains). A region's heading records how a PR joined the body and is fixed at authoring time, so a region re-homed by a later merge in the other direction keeps its original heading — provenance is never rewritten.
  • The carry-backward job drives it on merge-into-default-branch. It finds the redirected root by live base (base.ref === merged.head.ref) or, once GitHub has auto-retargeted the child onto the default branch after branch deletion, by the breadcrumb marker recording the merged PR as its parent. A branching stack fans out — the parent is absorbed into every direct child. A merge with no such child (standalone PR, or the genuine last slice) is a clean no-op.

OpenSpec rot sweep

The direction-agnostic backstop the merge-time archive gate structurally can't provide. Incremental-forward lands a change directory on main unarchived and only archives it on the final slice — invariant I1: main ends clean, but transiently carries an in-flight change while the stack drains. A stalled or abandoned stack would strand that change indefinitely. The daily sweep of main fails on:

  • STALE — a live change (unchecked - [ ], or no tasks.md) with no git activity in STALE_DAYS (7, tunable). A stalled/abandoned stack rotting on main.
  • DONE — all tasks - [x] but never archived. Debris regardless of age → fails immediately. Defence in depth for an admin-merge or botched down-merge that slipped past the tip gate.

Age is read from git log (with fetch-depth: 0), never file mtime — a fresh actions/checkout stamps every file's mtime to checkout time. The job is named openspec: rot, deliberately not stack:-prefixed, so the breadcrumb reconcile's "trueing" logic won't re-run it. Cron is 15:17 UTC (early Pacific business day; fixed-UTC can't follow DST).

Notes

  • carryBackward has unit coverage (heading, re-home-keeps-heading, idempotence, canonical order); full suite is 66/66. Rot detection was dry-run against fixtures (stale-live + no-tasks → STALE, done → DONE, fresh-live → OK, archive/ skipped).
  • No changeset: this is all .github/ infra and doesn't touch the published @taskless/cli. Applying skip-changeset so the require-changeset gate passes.

Support incremental-forward (root->tip) landing alongside the existing
tip->root collapse, and add a scheduled backstop for the OpenSpec debris
that incremental-forward can leave on main.

carryBackward is the mirror of carryForward: on an incremental forward
land, the merged root's body is absorbed into the redirected root (the
child GitHub retargets onto the default branch) under a "Built on top of
#N" heading, so the PR that finally reaches main still holds the full
stack legacy — read from the opposite end. renderCarriedRegion takes a
heading argument; re-homed regions keep whatever heading they were first
carried under, so provenance is never rewritten. A new carry-backward job
drives it on merge-into-default-branch, finding the redirected root by
live base or recorded-parent marker.

The rot sweep is the direction-agnostic backstop the merge-time archive
gate structurally can't provide. Incremental-forward lands a change dir on
main unarchived and only archives it on the final slice (invariant I1:
main ends clean, transiently in-flight while draining); a stalled or
abandoned stack would strand it. The daily job fails on a STALE change
(live, no git activity in STALE_DAYS) and immediately on a DONE change
(all tasks checked, never archived). Age comes from git history, never
mtime, since a fresh checkout stamps mtime to checkout time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYWV5padNohKwVmWDGgNoe
Copilot AI review requested due to automatic review settings July 14, 2026 21:21
@thecodedrift thecodedrift added the skip-changeset PR intentionally ships no release note (bypasses the changeset requirement) label Jul 14, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds infrastructure to support incremental stack landing (root→tip) by carrying merged parent content into the redirected root PR, and introduces a scheduled “OpenSpec rot” sweep on main to catch unarchived/stale OpenSpec change directories.

Changes:

  • Add a carry-backward workflow job that, on merge into the default branch, finds redirected-root children and absorbs the merged parent PR body into them.
  • Extend stack-breadcrumb script with carryBackward and associated unit tests to preserve provenance headings and maintain canonical body order.
  • Add a scheduled openspec: rot workflow to fail on stale live changes or complete-but-unarchived OpenSpec change directories on main.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
.github/workflows/stack-breadcrumb.yml Adds carry-backward merge handler for incremental forward landing.
.github/workflows/openspec-rot.yml Adds daily scheduled rot check for stale/unarchived OpenSpec changes on main.
.github/scripts/stack-breadcrumb.test.cjs Adds unit tests for carryBackward behavior and ordering/idempotence.
.github/scripts/stack-breadcrumb.cjs Implements carryBackward and extends carried-region rendering to support headings.

Comment thread .github/workflows/openspec-rot.yml Outdated
New OpenSpec changes start with an empty tasks.md, which has no unchecked
`- [ ]` items — so the previous "live" test (has unchecked tasks, or no
tasks.md) classified an abandoned change with an empty or non-checkbox
tasks.md as not-live and never flagged it STALE, defeating the backstop
for exactly the abandoned-stack case it exists for.

Drop the special-cased live variable: anything unarchived that is not DONE
is live by definition, so staleness applies to every non-done change.
Measure age from the last commit touching ANY file in the change
directory rather than tasks.md alone, so real activity on proposal.md or
specs counts as "not abandoned".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYWV5padNohKwVmWDGgNoe
@thecodedrift
thecodedrift merged commit 6df09b7 into main Jul 14, 2026
4 checks passed
@thecodedrift
thecodedrift deleted the feat/stack-forward-carry branch July 14, 2026 21:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changeset PR intentionally ships no release note (bypasses the changeset requirement)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants