From 5a728070d724b01de89dc1b64aa763c11307de96 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 14 Jul 2026 14:20:46 -0700 Subject: [PATCH 1/2] feat(stack): Add forward-merge carry and an OpenSpec rot sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01SYWV5padNohKwVmWDGgNoe --- .github/scripts/stack-breadcrumb.cjs | 51 +++++++++- .github/scripts/stack-breadcrumb.test.cjs | 65 ++++++++++++ .github/workflows/openspec-rot.yml | 118 ++++++++++++++++++++++ .github/workflows/stack-breadcrumb.yml | 111 +++++++++++++++++++- 4 files changed, 338 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/openspec-rot.yml diff --git a/.github/scripts/stack-breadcrumb.cjs b/.github/scripts/stack-breadcrumb.cjs index f033a98..60ca830 100644 --- a/.github/scripts/stack-breadcrumb.cjs +++ b/.github/scripts/stack-breadcrumb.cjs @@ -220,11 +220,20 @@ function ownDescription(body) { .trimEnd(); } -/** Wrap a description as a carried region for `number_`. */ -function renderCarriedRegion(number_, description) { +/** + * Wrap a description as a carried region for `number_`, headed by `heading`. + * + * The heading records HOW this PR joined the body and is fixed at authoring + * time: a down-merge (tip→root) carries a child into its parent as + * `Contains #N` (the default); a forward-merge (root→tip) carries a parent into + * the redirected root as `Built on top of #N`. Re-homing an already-carried + * region never re-renders it, so its original heading — its provenance — + * survives regardless of the direction of a later merge. + */ +function renderCarriedRegion(number_, description, heading = "Contains") { return [ ``, - `# Contains #${number_}`, + `# ${heading} #${number_}`, "", description, ``, @@ -272,6 +281,41 @@ function carryForward(parentBody, childNumber, childBody) { return canonicalizeBody(body, getRegion(body) ?? ""); } +/** + * Carry a merged PARENT's body into `childBody` — the forward-merge (root→tip) + * 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 the + * default branch, becoming the new "redirected root". That surviving child + * absorbs the parent it was built on as `` headed + * `Built on top of #parent`, and re-homes the parent's own already-carried + * regions as flat top-level siblings — each upserted by key, so the child ends + * with a deduped, flat set of everything landed beneath it. + * + * Where `carryForward` keeps the surviving parent's descendants ("Contains"), + * `carryBackward` keeps the surviving child's ancestors ("Built on top of"), so + * the PR that finally reaches the default branch still holds the full legacy of + * the stack — read from the opposite end. Re-homed regions keep whatever heading + * they were first carried under (provenance is never rewritten); only the parent + * being freshly absorbed here is rendered with the forward heading. A final + * `canonicalizeBody` re-lays the accumulated body in canonical order (breadcrumb + * → description → carried), keeping the child's existing breadcrumb. + */ +function carryBackward(childBody, parentNumber, parentBody) { + let body = upsertCarriedRegion( + childBody, + parentNumber, + renderCarriedRegion( + parentNumber, + ownDescription(parentBody), + "Built on top of" + ) + ); + for (const { number, region } of extractCarriedRegions(parentBody)) { + body = upsertCarriedRegion(body, number, region); + } + return canonicalizeBody(body, getRegion(body) ?? ""); +} + // --------------------------------------------------------------------------- // Canonical body layout // @@ -669,6 +713,7 @@ module.exports = { ownDescription, upsertCarriedRegion, carryForward, + carryBackward, HERE_PREFIX, HERE_SUFFIX, STACK_HEADING, diff --git a/.github/scripts/stack-breadcrumb.test.cjs b/.github/scripts/stack-breadcrumb.test.cjs index bc84564..9177967 100644 --- a/.github/scripts/stack-breadcrumb.test.cjs +++ b/.github/scripts/stack-breadcrumb.test.cjs @@ -25,6 +25,7 @@ const { ownDescription, upsertCarriedRegion, carryForward, + carryBackward, } = require("./stack-breadcrumb.cjs"); const DEFAULT_BRANCH = "main"; @@ -656,3 +657,67 @@ test("carryForward: emits canonical order — breadcrumb, then description, then `${breadcrumb}\n\nParent 83 desc.\n\n\n# Contains #84\n\nGroup 84 work.\n` ); }); + +// ─── PR carry-backward (forward-merge, root→tip) ───────────────────────────── + +test("carryBackward: absorbs the merged parent under a 'Built on top of' heading", () => { + // #82 (the root) merged to the default branch; #83 is retargeted onto it and + // becomes the redirected root, absorbing #82. + const child83 = + "Group 83 work.\n\n\n(tree)\n"; + const result = carryBackward(child83, 82, "Root 82 desc."); + assert.match( + result, + /\n# Built on top of #82\n\nRoot 82 desc\.\n/ + ); +}); + +test("carryBackward: re-homes the merged parent's carried regions, keeping their headings", () => { + // #82 already carried #81 downward ("Contains"); when #82 merges forward into + // #83, #81 rides along as a flat sibling and KEEPS its original heading — + // provenance is never rewritten by the direction of a later merge. + const carried81 = [ + "", + "# Contains #81", + "", + "Earlier work.", + "", + ].join("\n"); + const parent82 = `Root 82 desc.\n\n${carried81}`; + const child83 = + "Group 83 work.\n\n\n(tree)\n"; + + const result = carryBackward(child83, 82, parent82); + + // Both ancestors are present, sorted by number below the tree. + assert.deepEqual( + extractCarriedRegions(result).map((r) => r.number), + [81, 82] + ); + // #82 is freshly absorbed with the forward heading … + assert.match(result, /# Built on top of #82/); + // … while #81 rides along with the heading it was first carried under. + assert.ok(result.includes(carried81)); +}); + +test("carryBackward: is idempotent — re-absorbing does not duplicate", () => { + const child = + "Child work.\n\n\n(t)\n"; + const once = carryBackward(child, 1, "Root work."); + const twice = carryBackward(once, 1, "Root work."); + assert.equal(once, twice); + assert.equal(extractCarriedRegions(twice).length, 1); +}); + +test("carryBackward: emits canonical order — breadcrumb, then description, then carried", () => { + const breadcrumb = + "\n(tree)\n"; + // Child authored description-first; absorbing the parent must still land the + // breadcrumb on top, the child's prose next, and the carried parent last. + const child83 = `Group 83 work.\n\n${breadcrumb}`; + const result = carryBackward(child83, 82, "Root 82 desc."); + assert.equal( + result, + `${breadcrumb}\n\nGroup 83 work.\n\n\n# Built on top of #82\n\nRoot 82 desc.\n` + ); +}); diff --git a/.github/workflows/openspec-rot.yml b/.github/workflows/openspec-rot.yml new file mode 100644 index 0000000..6c9df67 --- /dev/null +++ b/.github/workflows/openspec-rot.yml @@ -0,0 +1,118 @@ +name: OpenSpec Rot Check + +# A daily sweep of `main` for OpenSpec debris — the direction-agnostic backstop +# the merge-time archive gate structurally cannot provide. +# +# Incremental-forward stacks land the change directory on `main` UNARCHIVED and +# only archive it on the final slice (invariant I1: main ends clean, but may +# transiently carry an in-flight change while the stack drains). If that stack +# stalls or is abandoned part-way, the unarchived change sits on `main` +# indefinitely. This sweep catches two failure modes on `main`: +# +# STALE — a still-live change (tasks.md has unchecked `- [ ]` items, or there +# is no tasks.md at all) with NO git activity in STALE_DAYS. That is a +# stalled/abandoned stack rotting on main. Fails after the window. +# DONE — a change whose tasks are ALL checked but which was never moved under +# archive/. Debris regardless of age → fails immediately. The tip +# archive gate catches this at merge time; this is defence in depth +# for an admin-merge or botched down-merge that slipped past it. +# +# Age is read from git history, never file mtime: a fresh `actions/checkout` +# stamps every file's mtime to checkout time, so mtime would always look fresh. +on: + schedule: + # Daily near the start of the Pacific business day. Cron is fixed-UTC and + # can't follow DST, so 15:17 UTC lands at 8:17am PDT / 7:17am PST — early + # either way. The :17 is deliberately off the top of the hour (GitHub delays + # runs scheduled on the heavily-contended :00). + - cron: "17 15 * * *" + workflow_dispatch: + +permissions: + contents: read + +env: + STALE_DAYS: "7" + +jobs: + rot: + name: "openspec: rot" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 # full history — git log needs it to date each change dir + + - name: Fail on stale or complete-but-unarchived OpenSpec changes + run: | + set -euo pipefail + + if [ ! -d openspec/changes ]; then + echo "openspec/changes does not exist — nothing to check." + exit 0 + fi + + stale_days="${STALE_DAYS:-7}" + now=$(date +%s) + cutoff=$(( stale_days * 24 * 3600 )) + fail=0 + + while IFS= read -r dir; do + [ -z "$dir" ] && continue + name=$(basename "$dir") + tasks="$dir/tasks.md" + + # Age from git history (NOT mtime). Date tasks.md when present, else + # the whole change directory. + age_path="$dir" + [ -f "$tasks" ] && age_path="$tasks" + last=$(git log -1 --format=%ct -- "$age_path" || true) + + if [ -f "$tasks" ]; then + incomplete=$(grep -cE '^[[:space:]]*- \[ \]' "$tasks" || true) + complete=$(grep -cE '^[[:space:]]*- \[[xX]\]' "$tasks" || true) + else + incomplete=0 + complete=0 + fi + incomplete=${incomplete:-0} + complete=${complete:-0} + total=$(( incomplete + complete )) + + # DONE: every task checked but not archived. Debris now, not later. + if [ "$total" -gt 0 ] && [ "$incomplete" -eq 0 ]; then + echo "::error::openspec/changes/$name: all $total task(s) complete but the change is not archived. Move it under openspec/changes/archive/ (e.g. /openspec-archive-change $name)." + fail=1 + continue + fi + + # A change is "live" while it has unchecked tasks, or has no tasks.md + # to judge by (unarchived == still in flight). + live=0 + if [ -f "$tasks" ]; then + [ "$incomplete" -gt 0 ] && live=1 + else + live=1 + fi + + # STALE: live and untouched beyond the window. + if [ "$live" -eq 1 ]; then + if [ -z "$last" ]; then + echo "::warning::openspec/changes/$name: unarchived and live, but has no git history to date; cannot assess staleness." + continue + fi + age=$(( now - last )) + if [ "$age" -gt "$cutoff" ]; then + days=$(( age / 86400 )) + echo "::error::openspec/changes/$name: live but untouched for ${days}d (> ${stale_days}d). A stalled/abandoned stack is leaving OpenSpec debris on main — land or close it, then archive the change." + fail=1 + fi + fi + done < <(find openspec/changes -mindepth 1 -maxdepth 1 -type d ! -name archive) + + if [ "$fail" -ne 0 ]; then + echo "" + echo "OpenSpec rot detected — see the errors above." + exit 1 + fi + echo "No OpenSpec rot — OK." diff --git a/.github/workflows/stack-breadcrumb.yml b/.github/workflows/stack-breadcrumb.yml index 72d92b9..4dc6e67 100644 --- a/.github/workflows/stack-breadcrumb.yml +++ b/.github/workflows/stack-breadcrumb.yml @@ -16,10 +16,16 @@ name: Stack Breadcrumb # `stack`-prefixed check that a shape change left failing # on a PR that is no longer the tip. # -# A separate CARRY-FORWARD job runs on a member's MERGE: it embeds the merged -# PR's body into its parent PR as a keyed `` region below the tree, -# so an atomic collapse (tip→root) accumulates the full legacy on the PR that -# lands on the default branch. Incremental delivery has no parent → a no-op. +# Two CARRY jobs run on a member's MERGE, embedding the merged PR's body into a +# surviving PR as a keyed `` region below the tree: +# CARRY-FORWARD (merge into a NON-default base, tip→root) — an atomic collapse +# accumulates the merged CHILD into its parent ("Contains #N"). +# CARRY-BACKWARD (merge into the DEFAULT branch, root→tip) — an incremental +# land absorbs the merged PARENT into the redirected root (the +# child GitHub retargets onto the default branch), headed +# "Built on top of #N". +# Either way the PR that finally reaches the default branch holds the full stack +# legacy; a merge with no surviving stack neighbour is a clean no-op. # # Stage 1 fires the dispatch with the built-in GITHUB_TOKEN: although that token # normally suppresses recursive workflow runs, `repository_dispatch` is a @@ -199,6 +205,103 @@ jobs: await github.rest.pulls.update({ owner, repo, pull_number: parent.number, body: newBody }); core.info(`carried #${merged.number} into parent #${parent.number}`); + carry-backward: + name: Absorb a merged parent into the redirected root + # On a member's merge INTO THE DEFAULT BRANCH (an incremental forward land), + # embed the merged PR's body into the open child that was built on it — the + # PR GitHub retargets onto the default branch to become the new root — as a + # keyed `` region headed "Built on top of #N". A merge to the + # default branch with no such child (a standalone PR, or the genuine last + # slice) has nothing to absorb into → a clean no-op. + if: >- + github.event_name == 'pull_request' + && github.event.action == 'closed' + && github.event.pull_request.merged == true + && github.event.pull_request.base.ref == github.event.repository.default_branch + && github.event.pull_request.head.repo.full_name == github.repository + && github.event.sender.login != 'github-actions[bot]' + runs-on: ubuntu-latest + permissions: + contents: read # actions/checkout needs this (top-level permissions: {} grants no defaults) + pull-requests: write # edit the redirected-root PR body + concurrency: + # Serialize retries of THIS merge event; convergent upserts make it safe, + # and we must not cancel an absorb mid-write. + group: stack-carry-backward-${{ github.event.pull_request.number }} + cancel-in-progress: false + steps: + # Trusted script from the default branch — never PR-supplied code. + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + + - uses: actions/github-script@v7 + with: + script: | + const scriptPath = `${process.env.GITHUB_WORKSPACE}/.github/scripts/stack-breadcrumb.cjs`; + let stack; + try { + stack = require(scriptPath); + } catch (error) { + if (error.code === "MODULE_NOT_FOUND") { + core.info("stack-breadcrumb script not on the default branch yet; skipping until merged."); + return; + } + throw error; + } + const { owner, repo } = context.repo; + const merged = context.payload.pull_request; + const defaultBranch = context.payload.repository.default_branch; + + // The redirected root is the open child built on the merged PR. Find + // it two ways, because merging the root may have deleted its head + // branch and made GitHub retarget the child onto the default branch: + // 1. base still points at the merged root's head branch (branch + // not deleted, or the retarget hasn't landed yet); + // 2. otherwise the child's breadcrumb marker records the merged PR + // as its parent, and its base is now the default branch. + // A branching stack can have several such children (each becomes an + // independent root); absorb the parent into every one. + const rawPulls = await github.paginate(github.rest.pulls.list, { + owner, repo, state: "open", per_page: 100, + }); + const sameRepo = rawPulls.filter( + (pr) => pr.head.repo?.full_name === `${owner}/${repo}` + ); + const children = sameRepo.filter((pr) => { + if (pr.base.ref === merged.head.ref) return true; + if (pr.base.ref !== defaultBranch) return false; + const marker = stack.parseStackComment(pr.body ?? ""); + return marker !== undefined && marker.parents.get(pr.number) === merged.number; + }); + if (children.length === 0) { + core.info(`#${merged.number} merged into "${merged.base.ref}" with no open child built on it — nothing to absorb (standalone or last slice).`); + return; + } + + // Fetch the merged body fresh — the event payload can lag a + // last-moment edit. + const { data: mergedFresh } = await github.rest.pulls.get({ + owner, repo, pull_number: merged.number, + }); + + for (const child of children) { + const { data: childFresh } = await github.rest.pulls.get({ + owner, repo, pull_number: child.number, + }); + const newBody = stack.carryBackward( + childFresh.body ?? "", + merged.number, + mergedFresh.body ?? "" + ); + if (newBody === (childFresh.body ?? "")) { + core.info(`#${merged.number} already absorbed into #${child.number}; nothing to do.`); + continue; + } + await github.rest.pulls.update({ owner, repo, pull_number: child.number, body: newBody }); + core.info(`absorbed #${merged.number} into redirected root #${child.number}`); + } + reconcile: name: Reconcile breadcrumb across the stack if: github.event_name == 'repository_dispatch' || github.event_name == 'workflow_dispatch' From 310a0d85ef48065772ee13078abd0218b9496e2f Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 14 Jul 2026 14:31:14 -0700 Subject: [PATCH 2/2] fix(stack): Treat every non-done OpenSpec change as live for rot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01SYWV5padNohKwVmWDGgNoe --- .github/workflows/openspec-rot.yml | 54 ++++++++++++++---------------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/.github/workflows/openspec-rot.yml b/.github/workflows/openspec-rot.yml index 6c9df67..3d4a20c 100644 --- a/.github/workflows/openspec-rot.yml +++ b/.github/workflows/openspec-rot.yml @@ -9,13 +9,16 @@ name: OpenSpec Rot Check # stalls or is abandoned part-way, the unarchived change sits on `main` # indefinitely. This sweep catches two failure modes on `main`: # -# STALE — a still-live change (tasks.md has unchecked `- [ ]` items, or there -# is no tasks.md at all) with NO git activity in STALE_DAYS. That is a -# stalled/abandoned stack rotting on main. Fails after the window. # DONE — a change whose tasks are ALL checked but which was never moved under # archive/. Debris regardless of age → fails immediately. The tip # archive gate catches this at merge time; this is defence in depth # for an admin-merge or botched down-merge that slipped past it. +# STALE — any unarchived change that is NOT done (work unfinished, tasks.md +# empty/placeholder, or no tasks.md) with NO git activity in its +# directory for STALE_DAYS. A stalled/abandoned stack rotting on main. +# Fails after the window. Because every non-done change is treated as +# live, an abandoned change can't slip the net by having an empty +# tasks.md — the exact case this backstop exists for. # # Age is read from git history, never file mtime: a fresh `actions/checkout` # stamps every file's mtime to checkout time, so mtime would always look fresh. @@ -62,12 +65,6 @@ jobs: name=$(basename "$dir") tasks="$dir/tasks.md" - # Age from git history (NOT mtime). Date tasks.md when present, else - # the whole change directory. - age_path="$dir" - [ -f "$tasks" ] && age_path="$tasks" - last=$(git log -1 --format=%ct -- "$age_path" || true) - if [ -f "$tasks" ]; then incomplete=$(grep -cE '^[[:space:]]*- \[ \]' "$tasks" || true) complete=$(grep -cE '^[[:space:]]*- \[[xX]\]' "$tasks" || true) @@ -86,27 +83,26 @@ jobs: continue fi - # A change is "live" while it has unchecked tasks, or has no tasks.md - # to judge by (unarchived == still in flight). - live=0 - if [ -f "$tasks" ]; then - [ "$incomplete" -gt 0 ] && live=1 - else - live=1 + # Anything unarchived that is not DONE is LIVE by definition — its + # work is unfinished, its tasks.md is empty/placeholder (new changes + # start with an empty one), or it has no tasks.md at all. So there is + # no "not-live" branch that could let an abandoned change slip the + # net; staleness applies to every non-DONE change. + # + # Age is the last commit touching ANY file in the change directory + # (NOT mtime — a fresh checkout stamps mtime to checkout time). Dating + # the whole folder means real activity on proposal.md / specs counts + # as "not abandoned", not just edits to tasks.md. + last=$(git log -1 --format=%ct -- "$dir" || true) + if [ -z "$last" ]; then + echo "::warning::openspec/changes/$name: unarchived but has no git history to date; cannot assess staleness." + continue fi - - # STALE: live and untouched beyond the window. - if [ "$live" -eq 1 ]; then - if [ -z "$last" ]; then - echo "::warning::openspec/changes/$name: unarchived and live, but has no git history to date; cannot assess staleness." - continue - fi - age=$(( now - last )) - if [ "$age" -gt "$cutoff" ]; then - days=$(( age / 86400 )) - echo "::error::openspec/changes/$name: live but untouched for ${days}d (> ${stale_days}d). A stalled/abandoned stack is leaving OpenSpec debris on main — land or close it, then archive the change." - fail=1 - fi + age=$(( now - last )) + if [ "$age" -gt "$cutoff" ]; then + days=$(( age / 86400 )) + echo "::error::openspec/changes/$name: live but untouched for ${days}d (> ${stale_days}d). A stalled/abandoned stack is leaving OpenSpec debris on main — land or close it, then archive the change." + fail=1 fi done < <(find openspec/changes -mindepth 1 -maxdepth 1 -type d ! -name archive)