From 9733d70958f0d353508d64b51cf2e6f1ec362ecb Mon Sep 17 00:00:00 2001 From: Sameera Date: Wed, 29 Jul 2026 22:19:22 -0400 Subject: [PATCH 1/8] spec(analyze): pin tmp-only receipt placement as contract for issue-sourced epics (#171) The analyze receipt's home is now stated, not incidental: an issue-sourced epic's analyze-receipt.md lands under .nexus/tmp/epic-/ beside the materialized epic.md as ephemeral hand-off content; an old-contract committed entry keeps its committed placement unchanged; --pr mode keeps publishing a PR review and writes no receipt file. Refs #171 Co-Authored-By: Claude Fable 5 --- .claude/commands/nxs.analyze.md | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/.claude/commands/nxs.analyze.md b/.claude/commands/nxs.analyze.md index 33b4b02..04d3590 100644 --- a/.claude/commands/nxs.analyze.md +++ b/.claude/commands/nxs.analyze.md @@ -1,6 +1,6 @@ --- name: nxs.analyze -description: Implementation-conformance gate. Checks the implemented code against the epic's acceptance criteria, success metrics, and the decision record's invariants — does the build do what the planning said. Refuses to run while the epic's decision-record sub-issue is unapproved, and stamps which record it checked against. Reads the epic + the record issue body and the branch diff / closed story issues; reports inline conformance findings and writes a small analyze-receipt.md into the queue entry (/nxs.close gates on it). With `--pr ` it instead runs in a worktree against the PR (which may be open) and publishes the result as a PR review carrying a machine-readable receipt block. Run after the stories are implemented, before /nxs.close. Planning consistency is checked earlier, not here: story↔design coverage by /nxs.decision-record, AC quality by the nxs-epic-gate agent. +description: Implementation-conformance gate. Checks the implemented code against the epic's acceptance criteria, success metrics, and the decision record's invariants — does the build do what the planning said. Refuses to run while the epic's decision-record sub-issue is unapproved, and stamps which record it checked against. Reads the epic + the record issue body and the branch diff / closed story issues; reports inline conformance findings and writes a small analyze-receipt.md beside the resolved epic.md — under the gitignored .nexus/tmp/ for an issue-sourced epic, in the committed entry for an old-contract one (/nxs.close gates on it). With `--pr ` it instead runs in a worktree against the PR (which may be open) and publishes the result as a PR review carrying a machine-readable receipt block. Run after the stories are implemented, before /nxs.close. Planning consistency is checked earlier, not here: story↔design coverage by /nxs.decision-record, AC quality by the nxs-epic-gate agent. category: engineering model: inherit tools: Read, Grep, Glob, Bash, Write @@ -267,7 +267,19 @@ the epic; it reports so the user can gate. Then write the **receipt** — the proof this gate ran, which `/nxs.close` checks as a precondition. Write it to **`analyze-receipt.md`** beside the resolved `epic.md`, overwriting any previous receipt -(a re-run supersedes it). This is the command's only write: +(a re-run supersedes it). This is the command's only write. + +**Where the receipt lives is a contract, not an accident of where the epic resolved** (#171): + +- **Issue-sourced epic** (resolver-materialized, no committed entry — the #114 norm): the receipt is + written under **`.nexus/tmp/epic-/`**, beside the materialized `epic.md`. This placement is + intentional: the receipt is ephemeral hand-off content for the same-sitting `/nxs.close` → + `/nxs.distill` flow, and both commands depend on finding it there without re-deriving it. It is + never committed, never linked from an issue, and never described as committed on any surface + (record #176, invariant 1). +- **Old-contract entry** (an `epic.md` already committed under `.nexus/queue/`): the receipt is still + written into that committed directory, unchanged from before. +- **`--pr` mode**: no receipt file at all — the result is a published PR review (below), unchanged. ```markdown --- @@ -288,8 +300,9 @@ compares, so a design revised after this analysis is detectable and is named sep commit landing after it. Omit both keys entirely in downgraded mode — there is no record to name. Stamp the digest **in full**; no truncated form appears on any surface. -The receipt is ephemeral queue content: the distiller deletes it with the entry post-merge. Never -link it from an issue. +The receipt is ephemeral in both placements: a committed entry is deleted whole by the distiller +post-merge, and a `.nexus/tmp/` entry is hand-off content consumed by the drain. Never link it from +an issue. ## PR mode — publish a review, not a receipt file @@ -362,6 +375,11 @@ compare it for exact equality against the PR head. Re-running analyze publishes - **The record hash comes from the one digest program** (`nxs-record-digest`), computed over the body as fetched from GitHub, and is stamped in full on both the receipt and the PR machine block beside the analysed commit. Never re-derive it with a shell one-liner and never truncate it. +- **Receipt placement is contractual (#171).** Issue-sourced epic → `analyze-receipt.md` under + `.nexus/tmp/epic-/`, beside the materialized `epic.md`; old-contract committed entry → into + that committed directory, unchanged; `--pr` → PR review only, no receipt file. Downstream + commands (`/nxs.close`, `/nxs.distill`) rely on this placement — never write the receipt anywhere + else. - **No task analysis (0009).** There is no task layer: do not look for `TASK-*` files, `story_ref`, or task↔story traceability. - **Planning consistency is out of scope.** AC-quality-by-`story_type` belongs to the `nxs-epic-gate` From 3115a962bb809951d995b5eb085d785c77e4241c Mon Sep 17 00:00:00 2001 From: Sameera Date: Wed, 29 Jul 2026 22:22:19 -0400 Subject: [PATCH 2/8] spec(close): local close writes ephemeral tmp artifacts; close comment is the durable record (#172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An issue-sourced local close now resolves its epic into .nexus/tmp/epic-/ and writes close-record.md there as ephemeral hand-off content — never committed, never described as committed, no manual git step to hand off to /nxs.distill. Old-contract committed entries and --pr mode are unchanged. Per record #176: the epic issue's close comment becomes the single durable copy of a local close's rationale (invariant 4 — a failed post ends with ACTION REQUIRED and a preserved body, never a success report), and it gains the marker-anchored nexus:close-record machine block stamping the record reference + full hash, conformance verdict, and full-SHA landed range in every mode (invariant 5). Refs #172 Co-Authored-By: Claude Fable 5 --- .claude/commands/nxs.close.md | 126 +++++++++++++++++++++++++++------- 1 file changed, 103 insertions(+), 23 deletions(-) diff --git a/.claude/commands/nxs.close.md b/.claude/commands/nxs.close.md index 8efd8b4..aabb802 100644 --- a/.claude/commands/nxs.close.md +++ b/.claude/commands/nxs.close.md @@ -1,6 +1,6 @@ --- name: nxs.close -description: Close an epic. Emits a human-prose close record into the committed queue entry (key decisions + deferred-scope pointer + deviation rationale from a close-from-diff pass), appends deferred scope to the feature backlog, writes the process lesson as its own file, then — after a checkpoint — comments on and closes the epic GitHub issue. Preconditions — every sub-issue of the epic closed, story or decision record alike (hard block), and /nxs.analyze ran (its analyze-receipt.md present and current; missing/stale/blocking requires an explicit user waiver). With `--pr ` it runs post-merge in a worktree on a fresh distill branch (gated on the PR being merged), reads the analyze result from the PR review, commits and pushes the close artifacts, and hands off to /nxs.distill; single-repo and hub only. +description: Close an epic. Emits a human-prose close record beside the resolved epic.md — under the gitignored .nexus/tmp/ for an issue-sourced local close, in the committed entry for an old-contract one — (key decisions + deferred-scope pointer + deviation rationale from a close-from-diff pass), appends deferred scope to the feature backlog, writes the process lesson as its own file, then — after a checkpoint — posts the durable close comment (prose + machine block) on the epic GitHub issue and closes it. Preconditions — every sub-issue of the epic closed, story or decision record alike (hard block), and /nxs.analyze ran (its analyze-receipt.md present and current; missing/stale/blocking requires an explicit user waiver). With `--pr ` it runs post-merge in a worktree on a fresh distill branch (gated on the PR being merged), reads the analyze result from the PR review, commits and pushes the close artifacts, and hands off to /nxs.distill; single-repo and hub only. category: engineering tools: Read, Grep, Glob, Write, Edit, Bash, AskUserQuestion model: inherit @@ -15,10 +15,15 @@ issue. The close record is **human prose only** (0006): key decisions, a pointer to deferred scope, and the **deviation rationale** produced by the close-from-diff forcing function. There is **no `ConceptDelta` block, no `PIR.md`, and no task-file mining** — the task layer is gone (0009); decisions are mined from -the epic, the story issue comments, and the close review (C6). Durability is structural: the close -record is committed into `.nexus/queue/epic-/` — the entry's name is the epic issue number, -the same directory the capture rule wrote scratch to — and travels to main with the PR, where the -distiller consumes and deletes it. +the epic, the story issue comments, and the close review (C6). + +**Durability lives on the epic issue, not in the file** (record #176): the close comment posted in +Phase 8.2 is the single durable copy of a close's rationale, in every mode. The close-record *file* +is hand-off content. For an **issue-sourced local close** (#172) it lands under the gitignored +`.nexus/tmp/epic-/` beside the materialized `epic.md`, where the same-sitting +`/nxs.distill` consumes it — never committed, never described as committed on any surface. An +**old-contract entry** keeps its committed `.nexus/queue/` placement, travels to main with the PR, +and the distiller consumes and deletes it there — unchanged. # Interaction convention — actionable choice gate @@ -40,8 +45,20 @@ $ARGUMENTS 1. **`$ARGUMENTS` contains a file path** → use that `*epic.md` directly. 2. **A file is open in the editor** (passed as context) → use that file as the `*epic.md`. 3. **`--pr ` with no path** → the epic is resolved from the PR and **born at close** (see below). -4. **Otherwise** (no `--pr`, no path) → stop and ask the user to either open the `*epic.md` in their - editor and re-run, or pass the path: `/nxs.close path/to/epic.md`. +4. **No path, no `--pr`, but the epic issue number is resolvable** — an explicit `#` / `` in + `$ARGUMENTS`, else the current branch's linked issue → its parent epic — → resolve the epic into + the gitignored `.nexus/tmp/` (the issue-sourced local norm, #114 / #172): + + ```bash + tsx ./.claude/skills/nxs-epic-resolve/scripts/epic_resolve.ts --epic + ``` + + Use the printed `outPath`'s directory (`.nexus/tmp/epic-/`) as the entry. A same-sitting + `/nxs.analyze` already materialized this same directory and left `analyze-receipt.md` beside it + (#171); re-resolving is byte-identical against an unchanged issue graph. On a non-zero resolver + exit, report the diagnostic and stop. +5. **Otherwise** → stop and ask the user to either open the `*epic.md` in their editor and re-run, + pass the path (`/nxs.close path/to/epic.md`), or pass the epic issue number. If `$ARGUMENTS` also contains **`--pr `** (string-matched, like `/nxs.epic --resume`), close runs the **post-merge worktree flow** in Phase 0.5. Strip the `--pr ` token first. In `--pr` mode the @@ -79,7 +96,8 @@ now, as written. prefix, no `.`-named segment). - `complexity` — the story-size rollup (used for lesson framing) -2. Set `QDIR` = the directory containing `*epic.md` (the committed queue entry). +2. Set `QDIR` = the directory containing `*epic.md` — a committed queue entry, or the ephemeral + `.nexus/tmp/epic-/` materialization for an issue-sourced local close (#172). 3. **Validate `link`.** It MUST exist and contain an issue number. If missing, stop and report: @@ -465,8 +483,17 @@ Fill the seeded template and write it into the queue entry. is appended in Phase 5, not restated here). - **Process Lesson** — a **pointer only** to the lesson file written in Phase 6. -3. Write it to **`${QDIR}/close-record.md`** — in the committed queue entry, beside `epic.md`. Do - **not** emit a `ConceptDelta` block; the record is human prose only. +3. Write it to **`${QDIR}/close-record.md`**, beside `epic.md`. Placement follows the entry (#172): + - **Issue-sourced local close** (`QDIR` under `.nexus/tmp/`) — the close record lands there as + ephemeral hand-off content for the same-sitting `/nxs.distill`. No manual `git add` or + `git commit` is needed to hand off, and none is run: nothing durable depends on this file + surviving (the durable copy is the Phase 8.2 close comment). + - **Old-contract entry** (committed `epic.md` under `.nexus/queue/`) — the close record still + lands in that committed directory, unchanged from before. + - **`--pr` mode** — unchanged: Phase 0.5 / Phase 7.6 commit the born-at-close `epic.md` + + `close-record.md` onto the distill branch, because those artifacts have no feature PR to ride. + + Do **not** emit a `ConceptDelta` block; the record is human prose only. # Phase 5 — Append deferred scope to the feature backlog @@ -533,6 +560,9 @@ Ready to close epic "" (#). Written: 0. [born-at-close only] Materialized epic → ${QDIR}/epic.md (resolved from issue #) 1. Close record → ${QDIR}/close-record.md + [issue-sourced local: items 0–1 are ephemeral hand-off content under .nexus/tmp/ — + consumed by /nxs.distill in this sitting; the durable copy of the rationale is the + close comment posted in step 6] 2. Deferred scope → /backlog.md ( item(s)) 3. Process lesson → /delivery/lessons/-.md (in `--pr` mode all of these are inside the worktree ) @@ -554,7 +584,9 @@ About to: ``` In single-repo and hub mode without `--pr`, omit items 4–5b (and renumber) — the list reads exactly -as today. In `--pr` mode, omit items 4–5 (never migrated) but keep 5b. +as today. In `--pr` mode, omit items 4–5 (never migrated) but keep 5b. When `QDIR` is a `.nexus/tmp/` +materialization, the summary describes the entry's artifacts as **ephemeral hand-off content** — +never as "committed" (#172; record #176 invariant 1). Then ask via **`AskUserQuestion`** (not free text). Three options: @@ -668,10 +700,14 @@ approved; this comment is the correction, not a re-decision. ## 8.2 Post the close comment and close the epic issue -GitHub ops target the **epic issue** via `link`. The epic issue is a **durable** surface; the queue -`close-record.md` is **ephemeral** — the distiller deletes it post-merge. So the comment carries the -close record's **prose inline** (Key Decisions + Deviation Rationale); it must **never** link into -`.nexus/queue/`, or the link dangles the moment the distillation PR merges. Durable pointers — the +GitHub ops target the **epic issue** via `link`. The epic issue is a **durable** surface; the +close-record file is **ephemeral** in every placement — a committed entry drains post-merge, and a +`.nexus/tmp/` entry is hand-off only. **This comment is the single durable copy of the close's +rationale** (record #176, invariant 4) — for an issue-sourced local close, nothing else survives — +so nothing in this comment-writing step may be skipped or thinned because the file moved to +`.nexus/tmp/` (#172). The comment carries the close record's **prose inline** (Key Decisions + +Deviation Rationale, in full); it must **never** link into `.nexus/queue/` or `.nexus/tmp/`, or the +link dangles the moment the entry is consumed. Durable pointers — the feature backlog and the lesson file, both under the resolved docs root — may be included as bare paths (or absolute GitHub URLs via `nxs-abs-doc-path`); nothing in the queue may be linked. @@ -689,10 +725,10 @@ the repo close runs from, so both the comment and the close must carry it. The comment body has this shape: -```markdown +`````markdown ## Close Record -Epic closed. Durable record below — the queue `close-record.md` drains post-merge. +Epic closed. Durable record below — the ephemeral `close-record.md` is hand-off only. Decision record: # @ `` @@ -709,13 +745,45 @@ the durable surface must show the epic closed on a waiver --> ### Pointers (durable) - Deferred scope → /backlog.md - Process lesson → /delivery/lessons/-.md + + +```yaml +epic: "#" +date: +record: "#" # omit when the epic has no record +record_hash: # full digest, never truncated; omit with `record` +analyze: +range: + - repo: + base: + head: ``` +````` + +The marker-anchored fenced block is **mandatory in every mode** (record #176, invariant 5): it stamps +the facts the prose cannot recover — the record reference and its full approved-body hash, the +conformance verdict, and the **full-SHA landed range**, exactly the range Phase 3 diffed, never +recomputed later. It makes the epic issue a complete substitute for the close-record file, which is +what `/nxs.distill`'s GitHub recovery reads (#174). The shape mirrors the `nexus:analyze-receipt` +block `/nxs.analyze --pr` already publishes; the prose sections above it stay unchanged and in full. **Error handling:** - Epic issue already closed → report and continue to the completion summary. -- `gh` fails → report the error, preserve state (artifacts already written), and print the manual - commands above. +- `gh` fails on the **close comment** → **never report success as if the rationale were safe.** For + an issue-sourced local close this comment is the *only* durable copy (record #176, invariant 4): + preserve the composed body at its scratch path, do **not** close the epic issue, and end the run + with an explicit instruction — + + ``` + ACTION REQUIRED — the close comment did not post; the rationale has NO durable copy yet. + Body preserved at /close-comment.md. Post it, then close the issue: + gh issue comment $REPO_ARG --body-file "/close-comment.md" + gh issue close $REPO_ARG --reason completed + ``` + +- `gh` fails on the issue close (comment already posted) → report the error, preserve state, and + print the manual close command. # Phase 9 — Report completion @@ -727,7 +795,10 @@ Record amendment: # superseding decision(s) posted | none (implementation conformed) | NOT POSTED — ; superseding decision(s) stand in the close record's Deviation Rationale. Close not blocked. -Close record: ${QDIR}/close-record.md (committed; distiller consumes it post-merge) +Close record: ${QDIR}/close-record.md + (issue-sourced local: ephemeral hand-off under .nexus/tmp/ — /nxs.distill + consumes it; the durable copy is the epic issue's close comment) + | (old-contract: committed; distiller consumes it post-merge) Queue entry: [member mode] migrated → /.nexus/queue// (hub commit on ''); removed here (commit on '') Deferred scope: /backlog.md ( item(s)) @@ -839,8 +910,16 @@ state, but a closed epic with an open issue misreports the pipeline. else the close record alone. No flag, no mode switch, no migration: in-flight entries clear on their own. - **Never link an ephemeral queue file from the issue** — the close comment inlines the close-record - prose; the distiller deletes the queue entry post-merge. Link only durable targets (feature backlog, - lesson file, concept pages, anchors, other issues). + prose; the distiller deletes the queue entry post-merge, and a `.nexus/tmp/` path is machine-local. + Link only durable targets (feature backlog, lesson file, concept pages, anchors, other issues). +- **Artifact placement is contractual (#172).** Issue-sourced local close → `close-record.md` (and + the materialized `epic.md`) live under `.nexus/tmp/epic-/` as ephemeral hand-off content — + never committed, never described as committed, no manual git step to hand off to `/nxs.distill`. + Old-contract entry → committed `.nexus/queue/` placement unchanged. `--pr` mode → Phase 0.5 / + Phase 7.6 unchanged. The epic issue's close comment is the single durable copy of a local close's + rationale (record #176, invariant 4) and carries the full prose plus the marker-anchored machine + block (invariant 5) — nothing in the comment-writing step may be skipped or thinned because the + file is ephemeral. - Handle an already-closed epic issue gracefully. - **Every issue op targets the resolved issues-repo** — the epic and its story issues are filed into `github.issues-repo`, resolved once in Phase 1.0 **through the shared resolver** (never by parsing @@ -904,8 +983,9 @@ state, but a closed epic with an open issue misreports the pipeline. # Usage ``` -/nxs.close # epic from the open editor file +/nxs.close # epic from the open editor file, else the branch's linked epic issue /nxs.close path/to/epic.md # explicit epic path +/nxs.close 118 # issue-sourced local close: resolve epic #118 into .nexus/tmp/ /nxs.close --pr 123 # post-merge close of PR #123; epic born at close from the PR's linked issue /nxs.close --pr 123 path/to/epic.md # post-merge close of an old-contract epic whose entry rode the PR ``` From 470e199147aa664d707aec39a2c0f3f65aec999d Mon Sep 17 00:00:00 2001 From: Sameera Date: Wed, 29 Jul 2026 22:46:25 -0400 Subject: [PATCH 3/8] spec(distill): drain same-sitting .nexus/tmp entries with nothing to commit first (#173) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Input Resolution now scans .nexus/tmp/ alongside the committed queue: a tmp directory is a drainable entry only when it carries both epic.md and close-record.md (an epic-only materialization is resolver scratch), and consumption is derived, not marked — an ephemeral entry is consumed exactly when the concept store at the fetched trunk carries its provenance in a structured position, so a PR closed unmerged leaves it rediscoverable and an unconsumed entry is never auto-deleted. Per record #176: the merge precondition for an entry absent from the trunk is the two-test form — range-head reachability, else merged-PR resolution (squash-safe for a local close that stamped the pre-merge branch tip); an unresolvable range is the named hard error range-unresolvable. The consumed-entry removal is re-aimed, not skipped: the committed git rm targets the epic's per-user scratch directory, and no .nexus/tmp/ path is ever staged. The checkpoint digest names each entry's source and which deletions land with the merge. Ephemeral entries never enter drain-SLO accounting. Refs #173 Co-Authored-By: Claude Fable 5 --- .claude/commands/nxs.distill.md | 107 +++++++++++++++++++++++++++++--- 1 file changed, 99 insertions(+), 8 deletions(-) diff --git a/.claude/commands/nxs.distill.md b/.claude/commands/nxs.distill.md index 617a6b6..2132b83 100644 --- a/.claude/commands/nxs.distill.md +++ b/.claude/commands/nxs.distill.md @@ -1,6 +1,6 @@ --- name: nxs.distill -description: Drain the committed queue into the concept store via a reviewed distillation-PR. Reads each closed queue entry (epic + close record) plus the epic's decision record — fetched from its record sub-issue and verified against the hash stamped at close — plus the recomputed merged diff, synthesizes per-concept deltas, runs the deterministic steps (touches-reciprocity fan-out, code-anchor refresh, validator), then — after a checkpoint — opens the distillation-PR. Never writes .nexus/concepts/ on main; consumed queue entries are deleted only when that PR merges. +description: Drain the queue — committed entries and same-sitting ephemeral .nexus/tmp entries alike — into the concept store via a reviewed distillation-PR. Reads each closed queue entry (epic + close record) plus the epic's decision record — fetched from its record sub-issue and verified against the hash stamped at close — plus the recomputed merged diff, synthesizes per-concept deltas, runs the deterministic steps (touches-reciprocity fan-out, code-anchor refresh, validator), then — after a checkpoint — opens the distillation-PR. Never writes .nexus/concepts/ on main; consumed queue entries are deleted only when that PR merges. category: engineering tools: Read, Grep, Glob, Write, Edit, Bash, AskUserQuestion model: inherit @@ -8,9 +8,10 @@ model: inherit # Role -You are the **System B distiller** (0006/0007, slimmed per 0011 R5). You drain committed queue -entries — the human planning artifacts System A left behind — into `.nexus/concepts/`, the machine -knowledge store. The *what* comes from the merged git diff; the *why* comes from the decision and +You are the **System B distiller** (0006/0007, slimmed per 0011 R5). You drain queue entries — the +human planning artifacts System A left behind, whether committed under `.nexus/queue/` or written +ephemerally under the gitignored `.nexus/tmp/` by a same-sitting local close (#173) — into +`.nexus/concepts/`, the machine knowledge store. The *what* comes from the merged git diff; the *why* comes from the decision and close records. You infer the concept mapping yourself: System A emits nothing structured. The split is **judgment as prompt, mechanics as code** (0004 B0): @@ -54,6 +55,32 @@ $ARGUMENTS or a file inside one — resolve to the directory) → drain exactly that entry. 2. **No arguments** → scan `.nexus/queue/**` for entry directories (a directory containing an `epic.md`). **Presence = unconsumed** — there is no state file to consult. + + **Also scan `.nexus/tmp/`** for **ephemeral entries** (#173) in the same run: a directory + `.nexus/tmp/epic-/` is a drainable entry **only when it carries both `epic.md` and + `close-record.md`** (record #176, invariant 6). An epic-only materialization is resolver + scratch — never listed, never warned about, never aged. For an ephemeral candidate, + **presence alone is not the consumption signal** — nothing ever commits a deletion of a tmp + path, so derive consumption from the store (invariant 8): fetch the trunk + (`git fetch origin main`), then check whether the concept store **at the trunk** carries this + epic's provenance in a **structured provenance position** — a `last_updated_by:` frontmatter + value or a `### — …` Decision Log heading in `${TRUNK}:.nexus/concepts/**`, + matched on the whole provenance token (`#`, or the qualified `/#` form), + never on free prose. + - **Provenance present** → the entry is **consumed** — its distillation-PR merged. Do not + rediscover or re-drain it; delete the ephemeral directory (no commit — it is derived, + disposable content whose consumption is already durable at the trunk; invariant 12) and + report the cleanup. + - **Provenance absent** → the entry is **unconsumed**: drain it this run. It is never + auto-deleted, whatever its age (invariant 9) — a distillation-PR closed unmerged leaves it + here, rediscoverable, by design. + - **Accepted consequence** (record #176): a drain that produced zero concept deltas leaves no + provenance and is re-offered on the next run. Report that plainly and name the ephemeral + directory as safe to delete by hand — never delete it yourself. + + Ephemeral entries **never enter drain-SLO accounting** (invariant 7) — drain-SLO is a property + of durable queues, and a local tmp directory says nothing about any other machine. Never list + an ephemeral entry in the drain-SLO report, breach or otherwise. 3. For every candidate entry, require **`close-record.md`**. An entry without one is **not yet closed**: list it with a warning and skip it — never distill an unclosed epic, and **never delete it** (C12: undrained entries are never auto-deleted; an old undrained entry is a @@ -184,13 +211,36 @@ artifacts (a close just prepared it — the close record, backlog append, and le git merge-base --is-ancestor "$TRUNK" && echo merged || echo not-merged ``` - In the ordinary (non-continuation) drain, keep the `epic.md`-presence proxy: + In the ordinary (non-continuation) drain, keep the `epic.md`-presence proxy **for committed + entries only**: ```bash TRUNK="$(git rev-parse -q --verify origin/main || git rev-parse -q --verify main)" git cat-file -e "${TRUNK}:/epic.md" 2>/dev/null && echo merged || echo not-merged ``` + **For an ephemeral `.nexus/tmp/` entry — or any entry not present at the trunk — the proxy is + meaningless** (a file that never left `.nexus/tmp/` is absent from the trunk whatever the state + of the code) and the precondition is the **two-test form** (#173; record #176, invariant 10), + evaluated against the entry's recorded `range:` head: + + 1. **Reachability:** `git merge-base --is-ancestor "$TRUNK"` — satisfied for a + merge-commit landing. + 2. **Merged-PR resolution:** when reachability fails, resolve the head to its associated pull + request and test that PR merged — + `gh api "repos/{owner}/{repo}/commits//pulls" --jq '.[].merged_at'` (any + non-null `merged_at` passes). This is the same squash-and-rebase-safe resolution the + PR-worktree helper performs at close: a **local close stamps the pre-merge feature-branch + tip** as its range head, so a squash or rebase merge means that commit never becomes a trunk + ancestor — reachability alone would report every squash-merged local epic as not-merged, + firing the waiver gate on the normal path and training the operator to waive it. + + Only when **both** tests fail does the existing not-merged gate below fire, unchanged — never + silently. A recorded range head that **cannot be resolved locally at all** (the SHA is unknown + to this repo and no PR resolves it) is the named per-entry hard error + `range-unresolvable` (invariant 11): report it, drain nothing for that entry — never a silent + empty diff, never a partial one, never an invented range. + - **merged** → continue silently. Phase 1 and Phase 4 take their normal single-repo path (branch cut from the trunk, introducing-commit diff); continuation mode stays on its branch and derives from the recorded range. @@ -642,6 +692,25 @@ Run these for each entry, in order, before its commit: The entry leaves `.nexus/queue/**` only on this branch; main still holds it until the PR merges, and it stays recoverable via git history thereafter. + **For an ephemeral `.nexus/tmp/` entry the committed deletion is re-aimed, not skipped** (#173; + record #176, invariant 12). Nothing under `.nexus/tmp/` is tracked, so there is no entry dir to + `git rm` — but something committed usually is: the epic's **per-user scratch directory**, + `.nexus/queue/epic-/`, written during implementation by the capture rule. A literal skip + would leave every closed epic's scratch on the trunk with nothing to ever delete it. So: + + ```bash + # ephemeral entry — target the committed scratch home, when one exists: + git rm -r .nexus/queue/epic- # skip only if the directory does not exist or is untracked + git add .nexus/concepts .nexus/anchors + git commit + ``` + + Never `git rm` (or stage) any path under `.nexus/tmp/`. The ephemeral directory itself is + **not** deleted here — its consumption is derived from the trunk store after the PR merges + (Input Resolution 2), and the next run cleans it without a commit. This preserves scratch's + existing lifecycle exactly: deleted atomically with the page writes when the distillation-PR + merges. + # Phase 6 — Checkpoint (before any GitHub write) ## Phase 6.1 — Taxonomy gate (forced fits only; epic #94, STORY-94.01) @@ -708,7 +777,10 @@ PR body. **If Phase 2 found no registry, skip this step entirely** (byte-for-byt CHECKPOINT: Distillation-PR Drained entries: -- () +- () — source: + ↳ deletion landing with the merge: / | nothing committed to delete> + (a .nexus/tmp/ entry itself is NOT deleted by this PR — it is cleaned, uncommitted, by the + next run once its provenance is on the trunk) Concept deltas: - — log: "" @@ -786,6 +858,9 @@ Drained queue entries: `` (provenance: ) This PR already removes the drained entries on the branch, so the merge deletes them from main atomically with the page writes — **no manual post-merge step**: - `` (recoverable via git history) +- For an ephemeral `.nexus/tmp/` entry: the committed removal is the epic's scratch dir + `.nexus/queue/epic-/` (when one existed); the tmp copy is machine-local and is cleaned, + uncommitted, by the next run once this PR's provenance is on the trunk. ``` In continuation mode the entry's `close-record.md` was added by the close earlier on this same @@ -865,7 +940,21 @@ close worktree, so it cannot remove that worktree itself; the lead removes it on - **No machinery**: no recipe/template files, no state file, no retrieval index (0003 §7 — glob/rg is the index; the atlas (at the resolved docs root) is a derived human-orientation page regenerated by this phase's atlas-regeneration step, never a retrieval surface). - Idempotency is structural: entry presence = unconsumed. + Idempotency is structural: entry presence = unconsumed — and for an ephemeral entry, whose + presence nothing committed can end, consumption is **derived**: the entry is consumed exactly + when the concept store at the fetched trunk carries its provenance in a structured provenance + position (invariant 8). No marker file, no state file, no timing logic — the mark cannot exist + before the merge because it *is* the merge, and a PR closed unmerged leaves the entry + rediscoverable. +- **Ephemeral entries (#173):** a `.nexus/tmp/epic-/` directory is drainable only with both + `epic.md` and `close-record.md` (an epic-only materialization is resolver scratch — never + listed, warned about, or aged; invariant 6); it never enters drain-SLO accounting + (invariant 7); unconsumed, it is never auto-deleted whatever its age (invariant 9); its merge + precondition is the two-test form — range-head reachability, else merged-PR resolution + (invariant 10) — and an unresolvable recorded range is the named per-entry hard error + `range-unresolvable` (invariant 11); its committed removal targets the epic's scratch dir, never + a `.nexus/tmp/` path (invariant 12); and the checkpoint digest names its source and what is + actually deleted where (invariant 13). - **Every changed page gains exactly one Decision Log entry per queue entry**; prior entries are never edited, reordered, or deleted. - **Domain filing (epic #94, STORY-94.01) is gated on registry presence.** A registry present at @@ -896,8 +985,10 @@ close worktree, so it cannot remove that worktree itself; the lead removes it on # Usage ``` -/nxs.distill # drain every closed entry in .nexus/queue/** +/nxs.distill # drain every closed entry in .nexus/queue/** and + # every unconsumed ephemeral entry in .nexus/tmp/ /nxs.distill .nexus/queue/fe205650/ # drain one specific entry +/nxs.distill .nexus/tmp/epic-118/ # drain one specific ephemeral entry /nxs.distill # (on a close-prepared distill/* branch, inside the # close worktree) continuation mode — drains that # branch's one entry and opens its distillation-PR From 4d23143cdbc2517e5252165440961f5b45060f27 Mon Sep 17 00:00:00 2001 From: Sameera Date: Wed, 29 Jul 2026 22:47:21 -0400 Subject: [PATCH 4/8] spec(distill): explicit GitHub recovery for a wiped .nexus/tmp entry (#174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New --recover mode rebuilds a named entry from durable GitHub state: the epic re-derives through the resolver, and the rationale, record reference + full approved-body hash, conformance verdict, and full-SHA landed range come from the epic issue's close comment — the durable close record in every mode, local and --pr alike (record #176 rewrote this story's third AC: the no-GitHub-surface premise for a local close is false once the close comment carries the machine block). Where a PR exists, its published analyze review supplies the conformance verdict. Recovery is explicit and per-entry, never a discovery scan of closed epic issues (invariant 14). The genuinely unrecoverable cases are named per-entry hard blocks — no-close-comment and range-unresolvable — never "not yet closed", never a drain with fabricated or empty rationale. Refs #174 Co-Authored-By: Claude Fable 5 --- .claude/commands/nxs.distill.md | 53 +++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/.claude/commands/nxs.distill.md b/.claude/commands/nxs.distill.md index 2132b83..29c348f 100644 --- a/.claude/commands/nxs.distill.md +++ b/.claude/commands/nxs.distill.md @@ -98,7 +98,46 @@ $ARGUMENTS long the entry has been drainable in the hub queue. Drain-SLO is measured against the hub queue only — never scan member checkouts for closed-but-unmigrated entries (that is migration-lag, owned by close-entry-migration / workspace-status, not this report). -4. If nothing is drainable, report that and stop. +4. **`$ARGUMENTS` contains `--recover `** → **GitHub recovery mode** (#174): rebuild + that one entry from durable GitHub state when the local copy is gone — a different machine, a + cleared `.nexus/tmp/`, a drain days after the close. Recovery is an **explicit per-entry path, + never a discovery source** (record #176, invariant 14): the no-argument scan never queries + closed epic issues looking for undistilled closes — the lead in this scenario knows which epic + they are recovering, so an explicit invocation is sufficient and bounded. + + 1. **Re-derive the epic through the resolver** — + `tsx ./.claude/skills/nxs-epic-resolve/scripts/epic_resolve.ts --epic ` → the + materialized `epic.md` under `.nexus/tmp/epic-/`. A resolver failure is that diagnostic, + reported verbatim; stop. + 2. **Take the *why* and the *what*-facts from the epic issue's close comment** — the durable + close record in every mode, local and `--pr` alike (record #176, invariant 4/5). Fetch the + epic issue's comments, take the newest one containing the `` + marker that is authored by a maintainer (`authorAssociation` `OWNER`/`MEMBER`/ + `COLLABORATOR` — same trust rule as the analyze block; ignore untrusted bodies and bodies + that merely quote one). From it: + - the **rationale** — the Key Decisions + Deviation Rationale prose, verbatim; + - the **record reference and full approved-body hash**, the **conformance verdict**, and + the **full-SHA landed `range:`** — parsed from the marker-anchored machine block, never + recomputed (the stamped range is by contract the exact range the close diffed). + + Rebuild `close-record.md` from these at `.nexus/tmp/epic-/close-record.md`, beside the + re-derived `epic.md`. The rebuilt entry then flows through the ordinary pipeline unchanged — + Phase 0 hash-verifies the record against the recovered stamp, Phase 1 derives the diff from + the recovered range, Phase 5.6 re-aims the committed removal at the scratch dir. + 3. **Where the epic has a linked PR**, the analyze verdict can also be recovered from the PR's + published review — the existing `` machine block, same trust + rule — rather than treating conformance as unknown; the close comment's verdict and the + review must agree, and the review is the tie-breaker (it is the surface `/nxs.close --pr` + itself read). + 4. **The genuinely unrecoverable cases are named per-entry hard blocks** — reported precisely, + naming the entry and why it cannot be drained, never silently treated as "not yet closed" + and never drained with fabricated or empty rationale: + - `no-close-comment` — the epic issue has no trusted close comment (or none carrying the + machine block): there is no durable rationale anywhere. Nothing is written. + - `range-unresolvable` (invariant 11) — the recovered range cannot be resolved locally and + no PR resolves its head: never a silent empty diff, never a partial one, never an + invented range. +5. If nothing is drainable, report that and stop. All drainable entries in one run are batched into **one** distillation-PR (0007 batches naturally), applied entry-by-entry (Phase 4). @@ -777,7 +816,7 @@ PR body. **If Phase 2 found no registry, skip this step entirely** (byte-for-byt CHECKPOINT: Distillation-PR Drained entries: -- () — source: +- () — source: > ↳ deletion landing with the merge: / | nothing committed to delete> (a .nexus/tmp/ entry itself is NOT deleted by this PR — it is cleaned, uncommitted, by the next run once its provenance is on the trunk) @@ -955,6 +994,14 @@ close worktree, so it cannot remove that worktree itself; the lead removes it on `range-unresolvable` (invariant 11); its committed removal targets the epic's scratch dir, never a `.nexus/tmp/` path (invariant 12); and the checkpoint digest names its source and what is actually deleted where (invariant 13). +- **GitHub recovery (#174) is explicit and per-entry** (invariant 14): invoked as + `--recover ` for a named epic, never as a scan of closed epic issues on an ordinary + run. It re-derives the epic through the resolver and takes rationale, record reference, hash, + and range from the epic issue's close comment (the durable close record in every mode); a PR's + published analyze review supplies the conformance verdict where one exists. An epic issue with + no trusted close comment, or a recovered range that cannot be resolved locally, is a named + per-entry hard block (`no-close-comment` / `range-unresolvable`) — never "not yet closed", + never a drain with fabricated or empty rationale. - **Every changed page gains exactly one Decision Log entry per queue entry**; prior entries are never edited, reordered, or deleted. - **Domain filing (epic #94, STORY-94.01) is gated on registry presence.** A registry present at @@ -989,6 +1036,8 @@ close worktree, so it cannot remove that worktree itself; the lead removes it on # every unconsumed ephemeral entry in .nexus/tmp/ /nxs.distill .nexus/queue/fe205650/ # drain one specific entry /nxs.distill .nexus/tmp/epic-118/ # drain one specific ephemeral entry +/nxs.distill --recover 118 # rebuild epic #118's entry from its issue's close + # comment (local copy gone) and drain it /nxs.distill # (on a close-prepared distill/* branch, inside the # close worktree) continuation mode — drains that # branch's one entry and opens its distillation-PR From f5f53733104450c23c759754b7e4147886e67f37 Mon Sep 17 00:00:00 2001 From: Sameera Date: Thu, 30 Jul 2026 03:41:32 -0400 Subject: [PATCH 5/8] =?UTF-8?q?fix(close-migration):=20member=20close=20mi?= =?UTF-8?q?grates=20the=20epic=20=E2=80=94=20ephemeral=20artifacts=20plus?= =?UTF-8?q?=20committed=20scratch=20(#175)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration's unit is now the epic, with source-relative and destination-relative paths derived separately. For an ephemeral .nexus/tmp/epic- source (the tmp-first local close, #172) the hub entry is the union of the ephemeral artifacts and the epic's committed per-user scratch at .nexus/queue/epic-/ — one durable entry, verified byte-for-byte (the walk hashes filesystem content directly, so the gitignored source changes nothing) — and both local copies are removed after verification, so no tmp copy survives for a later /nxs.distill in the member checkout to rediscover as a second drainable entry. This also fixes the tracked-file-check defect the record names: the helper previously reused the destination-derived relative path for the code-repo removal, so an ephemeral source with same-named committed scratch would try to commit a removal of files it never deleted — failing after the irreversible hub commit had landed. Old committed-entry sources and single-repo/hub mode are unchanged. Specs updated: /nxs.close Phase 7.5 and the nxs-close-migration skill. Refs #175 Co-Authored-By: Claude Fable 5 --- .claude/commands/nxs.close.md | 12 +++ .claude/skills/nxs-close-migration/SKILL.md | 9 +++ libs/close-migration/src/git-fixtures.ts | 34 +++++++++ libs/close-migration/src/migrate.spec.ts | 85 ++++++++++++++++++++- libs/close-migration/src/migrate.ts | 48 +++++++++--- 5 files changed, 176 insertions(+), 12 deletions(-) diff --git a/.claude/commands/nxs.close.md b/.claude/commands/nxs.close.md index aabb802..1abd1ff 100644 --- a/.claude/commands/nxs.close.md +++ b/.claude/commands/nxs.close.md @@ -624,6 +624,18 @@ git** — the ordering is the no-data-loss invariant, and it lives in the helper tsx ./.claude/skills/nxs-close-migration/scripts/close_migration.ts migrate "${QDIR}" ``` +**A member close is always non-`--pr`, and its end state stays durable** (#175): "ephemeral" +describes where a member close *writes*, never where it *ends*. When `QDIR` is the +`.nexus/tmp/epic-/` materialization (#172), pass that path — the helper's **migration unit is +the epic**: it commits the **union** of the ephemeral artifacts and the epic's committed per-user +scratch (`.nexus/queue/epic-/`) into exactly one hub entry, verifies it byte-for-byte (the +gitignored source changes nothing — the helper walks the filesystem and hashes content directly), +and then removes **both** local copies, so no tmp copy is left behind for a later `/nxs.distill` +in this checkout to discover as a second, separately drainable entry. Drain-SLO attribution in +the hub is unchanged: the first `range:` entry's `repo` in `close-record.md`, aged from the +migration commit. In single-repo and hub mode no migration happens and #172's tmp-only behavior +applies unchanged. + - **exit 0** → the entry now exists in exactly one place: the hub queue. Record the printed hub commit SHA and hub branch for the Phase 9 report, then continue to Phase 8. - **exit non-zero** → **stop before any GitHub write.** Print the helper's diagnostic verbatim. diff --git a/.claude/skills/nxs-close-migration/SKILL.md b/.claude/skills/nxs-close-migration/SKILL.md index cf637c2..8cc2fea 100644 --- a/.claude/skills/nxs-close-migration/SKILL.md +++ b/.claude/skills/nxs-close-migration/SKILL.md @@ -35,8 +35,17 @@ Migrate — mutating, gated to member mode: ```bash tsx ./.claude/skills/nxs-close-migration/scripts/close_migration.ts migrate .nexus/queue/ +tsx ./.claude/skills/nxs-close-migration/scripts/close_migration.ts migrate .nexus/tmp/epic- ``` +The source may be a committed queue entry (the old contract) or the gitignored +`.nexus/tmp/epic-/` materialization a tmp-first local close writes (#172/#175). The +**migration unit is the epic**: for an ephemeral source, the hub entry is the union of the +ephemeral artifacts and the epic's committed per-user scratch at `.nexus/queue/epic-/`, and +both local copies are removed once the hub commit verifies — one entry, in one place, nothing +left for a later `/nxs.distill` in the member checkout to rediscover. Ignore status never affects +what is copied or verified: the helper walks the filesystem and hashes content directly. + ## What it reports **Member preflight** — migration armed: diff --git a/libs/close-migration/src/git-fixtures.ts b/libs/close-migration/src/git-fixtures.ts index ca59c0a..b938ba1 100644 --- a/libs/close-migration/src/git-fixtures.ts +++ b/libs/close-migration/src/git-fixtures.ts @@ -81,3 +81,37 @@ export function buildWorkspaceFixture(parent: string): WorkspaceFixture { fs.writeFileSync(path.join(entryDir, "analyze-receipt.md"), "# receipt\n"); return { parent, hubRoot, memberRoot, entryName, entryDir }; } + +export interface EphemeralEntryFixture { + entryName: string; + entryDir: string; // /.nexus/tmp/epic- — gitignored, all files untracked + scratchDir: string; // /.nexus/queue/epic- — the committed per-user scratch home +} + +/** + * Arrange an issue-sourced epic the way a tmp-first local close leaves it: the entry's + * artifacts under the gitignored .nexus/tmp/, and (optionally) committed per-user decision + * scratch under .nexus/queue/epic-/. + */ +export function addEphemeralEntry( + memberRoot: string, + epicIssue: number, + withScratch: boolean, +): EphemeralEntryFixture { + const entryName = `epic-${epicIssue}`; + fs.appendFileSync(path.join(memberRoot, ".gitignore"), ".nexus/tmp/\n"); + const scratchDir = path.join(memberRoot, ".nexus", "queue", entryName); + if (withScratch) { + const userDir = path.join(scratchDir, "sameera"); + fs.mkdirSync(userDir, { recursive: true }); + fs.writeFileSync(path.join(userDir, "decisions-feat-branch.md"), "## decision stub\n"); + } + commitAll(memberRoot, `ignore tmp; scratch for ${entryName}`); + + const entryDir = path.join(memberRoot, ".nexus", "tmp", entryName); + fs.mkdirSync(entryDir, { recursive: true }); + fs.writeFileSync(path.join(entryDir, "epic.md"), `# epic #${epicIssue}\n`); + fs.writeFileSync(path.join(entryDir, "close-record.md"), "# close (tmp)\n"); + fs.writeFileSync(path.join(entryDir, "analyze-receipt.md"), "# receipt (tmp)\n"); + return { entryName, entryDir, scratchDir }; +} diff --git a/libs/close-migration/src/migrate.spec.ts b/libs/close-migration/src/migrate.spec.ts index 94cf799..d908e6b 100644 --- a/libs/close-migration/src/migrate.spec.ts +++ b/libs/close-migration/src/migrate.spec.ts @@ -1,7 +1,14 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { buildWorkspaceFixture, commitAll, initRepo, makeParent, sh } from "./git-fixtures"; +import { + addEphemeralEntry, + buildWorkspaceFixture, + commitAll, + initRepo, + makeParent, + sh, +} from "./git-fixtures"; import { type MigrateResult, migrateEntry } from "./migrate"; import { type Runner, defaultRunner } from "./run"; @@ -322,6 +329,82 @@ describe("migrateEntry", () => { expect(sh(memberRoot, "git", "rev-list", "--count", "HEAD")).toBe(beforeCount); }); + // --- #175: ephemeral .nexus/tmp source — the migration's unit is the epic --- + + it("migrates an ephemeral .nexus/tmp entry into the hub queue byte-for-byte despite the gitignored source", () => { + const { hubRoot, memberRoot } = fixture(); + const { entryDir, entryName } = addEphemeralEntry(memberRoot, 170, false); + const closeSource = fs + .readFileSync(path.join(entryDir, "close-record.md"), "utf8") + .replace(/\n$/, ""); + + const outcome = asOk(migrateEntry(entryDir)); + + expect(outcome.entryName).toBe(entryName); + expect(outcome.hubCommit).toMatch(/^[0-9a-f]{40}$/); + const files = sh(hubRoot, "git", "show", "--name-only", "--format=", "HEAD") + .split("\n") + .filter(Boolean) + .sort(); + expect(files).toEqual( + ["analyze-receipt.md", "close-record.md", "epic.md"].map( + (f) => `.nexus/queue/${entryName}/${f}`, + ), + ); + const blob = sh(hubRoot, "git", "show", `HEAD:.nexus/queue/${entryName}/close-record.md`); + expect(blob).toBe(closeSource); + }); + + it("migrates the union — committed per-user scratch joins the ephemeral artifacts in one hub entry", () => { + const { hubRoot, memberRoot } = fixture(); + const { entryDir, entryName } = addEphemeralEntry(memberRoot, 170, true); + + asOk(migrateEntry(entryDir)); + + const files = sh(hubRoot, "git", "show", "--name-only", "--format=", "HEAD") + .split("\n") + .filter(Boolean) + .sort(); + expect(files).toEqual( + [ + "analyze-receipt.md", + "close-record.md", + "epic.md", + "sameera/decisions-feat-branch.md", + ].map((f) => `.nexus/queue/${entryName}/${f}`), + ); + }); + + it("ends with the entry in exactly one place: tmp copy gone, scratch removal committed in the member", () => { + const { memberRoot } = fixture(); + const { entryDir, entryName, scratchDir } = addEphemeralEntry(memberRoot, 170, true); + + const outcome = asOk(migrateEntry(entryDir)); + + expect(fs.existsSync(entryDir)).toBe(false); + expect(fs.existsSync(scratchDir)).toBe(false); + expect(outcome.removalCommit).toMatch(/^[0-9a-f]{40}$/); + expect( + sh(memberRoot, "git", "status", "--porcelain", "--", `.nexus/queue/${entryName}`), + ).toBe(""); + const removedFiles = sh(memberRoot, "git", "show", "--name-only", "--format=", "HEAD") + .split("\n") + .filter(Boolean); + expect(removedFiles).toEqual([`.nexus/queue/${entryName}/sameera/decisions-feat-branch.md`]); + }); + + it("removes an ephemeral entry with no scratch without creating a removal commit", () => { + const { memberRoot } = fixture(); + const { entryDir } = addEphemeralEntry(memberRoot, 171, false); + const beforeCount = sh(memberRoot, "git", "rev-list", "--count", "HEAD"); + + const outcome = asOk(migrateEntry(entryDir)); + + expect(outcome.removalCommit).toBeNull(); + expect(fs.existsSync(entryDir)).toBe(false); + expect(sh(memberRoot, "git", "rev-list", "--count", "HEAD")).toBe(beforeCount); + }); + // --- removal-failed: coverage for the last gated failure mode --------- it("reports removal-failed and names the safe hub commit when the member-side removal fails", () => { diff --git a/libs/close-migration/src/migrate.ts b/libs/close-migration/src/migrate.ts index 97a0ab8..8d14d00 100644 --- a/libs/close-migration/src/migrate.ts +++ b/libs/close-migration/src/migrate.ts @@ -26,24 +26,31 @@ export type MigrateResult = | { ok: true; outcome: MigrateOutcome } | { ok: false; error: MigrationDiagnostic }; -/** Sorted relative-path → git blob SHA for every file under entryDir (untracked included). */ -function sourceManifest(entryDir: string, run: Runner): Map { +/** + * Sorted relative-path → git blob SHA for every file under the source roots (untracked and + * gitignored included — the walk reads the filesystem and hashes content directly, so ignore + * status never affects what is copied or verified). Later roots win on a colliding relative + * path, matching the copy order in migrateEntry. + */ +function sourceManifest(sourceRoots: string[], run: Runner): Map { const manifest = new Map(); - const walk = (dir: string, relPrefix: string) => { + const walk = (root: string, dir: string, relPrefix: string) => { for (const name of fs.readdirSync(dir).sort()) { const abs = path.join(dir, name); const rel = relPrefix ? `${relPrefix}/${name}` : name; if (fs.statSync(abs).isDirectory()) { - walk(abs, rel); + walk(root, abs, rel); } else { - const sha = git(run, entryDir, "hash-object", abs); + const sha = git(run, root, "hash-object", abs); if (sha) { manifest.set(rel, sha); } } } }; - walk(entryDir, ""); + for (const root of sourceRoots) { + walk(root, root, ""); + } return manifest; } @@ -134,11 +141,22 @@ export function migrateEntry(entryDir: string, run: Runner = defaultRunner): Mig const hubRoot = preflight.hub.root; const hubBranch = preflight.hub.branch; const entryName = path.basename(entryDir); + // The migration's unit is the epic, and the source-relative and destination-relative paths + // are derived separately: `relPath` is the hub destination AND the code repo's committed + // home for this epic (`.nexus/queue/`), while the entry may live at an + // ephemeral, gitignored source path (`.nexus/tmp/epic-`, the tmp-first local close). + // For an ephemeral source, the hub entry is the UNION of the ephemeral artifacts and the + // committed per-user scratch at `relPath` — one entry, in one place, nothing stranded. const relPath = `.nexus/queue/${entryName}`; const dest = path.join(hubRoot, ".nexus", "queue", entryName); + const sourceRelPath = path.relative(codeRoot, entryDir).split(path.sep).join("/"); + const sourceIsCommittedHome = sourceRelPath === relPath; + const scratchDir = path.join(codeRoot, ".nexus", "queue", entryName); + const includeScratch = !sourceIsCommittedHome && fs.existsSync(scratchDir); - // --- 2. Source manifest ----------------------------------------------- - const manifest = sourceManifest(entryDir, run); + // --- 2. Source manifest (scratch first — entry artifacts win on a collision) --- + const sourceRoots = includeScratch ? [scratchDir, entryDir] : [entryDir]; + const manifest = sourceManifest(sourceRoots, run); let alreadyMigrated = false; let hubCommit: string; @@ -165,9 +183,11 @@ export function migrateEntry(entryDir: string, run: Runner = defaultRunner): Mig }; } } else { - // --- 4. Copy ------------------------------------------------------- + // --- 4. Copy (same order as the manifest: scratch first, entry over it) ----- fs.mkdirSync(path.join(hubRoot, ".nexus", "queue"), { recursive: true }); - fs.cpSync(entryDir, dest, { recursive: true }); + for (const root of sourceRoots) { + fs.cpSync(root, dest, { recursive: true }); + } const cleanup = () => { git(run, hubRoot, "reset", "-q", "--", relPath); @@ -225,9 +245,15 @@ export function migrateEntry(entryDir: string, run: Runner = defaultRunner): Mig // --- 7. Remove — only reached when step 6 verified, or on the alreadyMigrated path --- // `git add -A -- ` errors ("did not match any files") when the path has never been - // tracked, so only touch the index when the entry actually has tracked content to remove. + // tracked, so only touch the index when the epic actually has tracked content at its + // committed home. Both source locations go: the entry dir (ephemeral tmp copy, or the + // committed entry itself) and, for an ephemeral source, the committed scratch home — a + // later drain in this checkout must not rediscover either as a second drainable entry. const trackedBefore = git(run, codeRoot, "ls-files", "--", relPath); fs.rmSync(entryDir, { recursive: true, force: true }); + if (includeScratch) { + fs.rmSync(scratchDir, { recursive: true, force: true }); + } let removalCommit: string | null = null; if (trackedBefore) { From 480ca1fa50cd53ad10b1ebb36b7f6da52cf690f8 Mon Sep 17 00:00:00 2001 From: Sameera Date: Thu, 30 Jul 2026 03:41:45 -0400 Subject: [PATCH 6/8] chore: re-pin stale claude-components bundle fingerprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing on main: parity.spec.ts failed because the committed pin recorded 4422f3e9f4c1… while a fresh build hashes 13dec1e4708f…. Regenerated via the named remedy (pnpm nexus:vendor-tools; pin updated in place, no artifact copied). Unrelated to epic #170, fixed so the branch's full suite runs green. Co-Authored-By: Claude Fable 5 --- libs/portable-tools/bundle-fingerprint.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/portable-tools/bundle-fingerprint.json b/libs/portable-tools/bundle-fingerprint.json index 1776089..30b2715 100644 --- a/libs/portable-tools/bundle-fingerprint.json +++ b/libs/portable-tools/bundle-fingerprint.json @@ -5,5 +5,5 @@ "drift-advisory.mjs": "551c8f545c2948ef8994b737807da859a0290b39014c989cdeea5b995b241aed", "seed-registry.mjs": "3ea23a99afba3521ca3d9b0104086abb6e57ba4a55d74461293391d869a46ab9", "nexus.mjs": "18a5f981bdebb12e8740d92588d0dedb51d176936210683c0fc30eca3d26d1f2", - "claude-components": "4422f3e9f4c168b2d0f04c372aa349f0b2912ba24eb8fec0116e8565623e360d" + "claude-components": "13dec1e4708faca189e4a5012d9dbd09a245e41345a4cc643e8ebdd48f222a82" } From f949eb93861ed5ad36559a9d805ca9c033f6b7e4 Mon Sep 17 00:00:00 2001 From: Sameera Date: Thu, 30 Jul 2026 03:42:38 -0400 Subject: [PATCH 7/8] scratch: decision stubs for epic #170 implementation Co-Authored-By: Claude Fable 5 --- .../decisions-epic-170-tmp-first-artifacts.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .nexus/queue/epic-170/sameera/decisions-epic-170-tmp-first-artifacts.md diff --git a/.nexus/queue/epic-170/sameera/decisions-epic-170-tmp-first-artifacts.md b/.nexus/queue/epic-170/sameera/decisions-epic-170-tmp-first-artifacts.md new file mode 100644 index 0000000..b5ba61c --- /dev/null +++ b/.nexus/queue/epic-170/sameera/decisions-epic-170-tmp-first-artifacts.md @@ -0,0 +1,30 @@ +## 2026-07-30 — Recovery invocation syntax for /nxs.distill (#174) + +- **Choice:** an explicit `--recover ` flag, not a positional issue number. +- **Why:** record #176 requires recovery be "invoked explicitly for a named epic issue, never a + discovery source"; a bare positional argument already means "drain this entry path" and would + blur the explicit-recovery boundary the record draws. +- **Refuted alternative:** accept a bare `#` / `` positional argument and infer recovery + when no local entry exists — viable, but it turns recovery into an implicit fallback, which is + the shape the record refused. + +## 2026-07-30 — Collision precedence in the union migration (#175) + +- **Choice:** in `migrateEntry`, when an ephemeral entry file and a committed scratch file share a + relative path, the ephemeral entry's file wins (manifest and copy both walk scratch first, entry + last). +- **Why:** the entry artifacts are the close's authoritative output; scratch is pre-checkpoint + hints. A defined precedence keeps the byte-for-byte verify deterministic. +- **Refuted alternative:** hard-error on any colliding path — safer-looking, but the collision is + practically impossible (scratch lives under per-user subdirectories) and a hard error would turn + a cosmetic overlap into a blocked close. + +## 2026-07-30 — Consumed tmp entries are cleaned by the next run (#173) + +- **Choice:** a run that derives an ephemeral entry consumed (provenance at the trunk) deletes + that tmp directory itself, without a commit; only *unconsumed* entries are never auto-deleted. +- **Why:** record #176 invariant 12 says "the ephemeral directory is deleted without a commit", + and consumption is already durable at the trunk, so the local copy is disposable derived state. +- **Refuted alternative:** never delete, only report "safe to delete by hand" — matches one + sentence of the record's accepted-consequence prose, but that sentence governs the *unconsumed* + zero-delta case; leaving consumed dirs forever would make every drain report grow monotonically. From 6efb1e7147cc78a898a983aa7417430da83fb532 Mon Sep 17 00:00:00 2001 From: Sameera Date: Thu, 30 Jul 2026 06:54:46 -0400 Subject: [PATCH 8/8] util: Implement epic --- utils/implement-epic.sh | 169 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100755 utils/implement-epic.sh diff --git a/utils/implement-epic.sh b/utils/implement-epic.sh new file mode 100755 index 0000000..f098cb2 --- /dev/null +++ b/utils/implement-epic.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# +# implement-epic.sh — run the /goal epic-implementation loop headlessly, push +# the branch, open a draft PR, then /nxs.analyze in a fresh context. +# +# Usage: +# utils/implement-epic.sh [extra claude args...] +# +# Environment: +# TURNS turn cap in the goal condition (default 40) +# PERMISSION_MODE claude permission mode (default bypassPermissions — +# required for unattended runs; tool calls cannot be +# approved interactively in -p mode) +# ANALYZE set to 0 to skip the /nxs.analyze stage (default 1) +# BASE PR base branch (default main) +# +# Streams each assistant message, tool call, and tool result to the console +# as the run progresses, then prints a result summary per stage. + +set -euo pipefail + +if [[ $# -lt 1 || ! "$1" =~ ^[0-9]+$ ]]; then + echo "usage: $(basename "$0") [extra claude args...]" >&2 + exit 1 +fi + +N="$1" +shift +TURNS="${TURNS:-40}" +PERMISSION_MODE="${PERMISSION_MODE:-bypassPermissions}" +ANALYZE="${ANALYZE:-1}" +BASE="${BASE:-main}" + +FORMATTER=' +import readline from "node:readline"; + +const tty = process.stdout.isTTY; +const dim = (s) => (tty ? `\x1b[2m${s}\x1b[0m` : s); +const bold = (s) => (tty ? `\x1b[1m${s}\x1b[0m` : s); +const cyan = (s) => (tty ? `\x1b[36m${s}\x1b[0m` : s); + +const clip = (s, n) => { + s = String(s).replace(/\s+/g, " ").trim(); + return s.length > n ? s.slice(0, n) + "…" : s; +}; + +const toolLabel = (block) => { + const i = block.input ?? {}; + const detail = + i.command ?? i.file_path ?? i.path ?? i.pattern ?? i.url ?? i.skill ?? + i.description ?? ""; + return `${block.name}(${clip(detail, 120)})`; +}; + +const rl = readline.createInterface({ input: process.stdin }); +rl.on("line", (line) => { + let ev; + try { ev = JSON.parse(line); } catch { return; } + + switch (ev.type) { + case "system": + if (ev.subtype === "init") { + console.log(dim(`session ${ev.session_id} | model ${ev.model}`)); + } + break; + case "assistant": + for (const block of ev.message?.content ?? []) { + if (block.type === "text" && block.text.trim()) { + console.log("\n" + block.text.trim()); + } else if (block.type === "tool_use") { + console.log(cyan(` ● ${toolLabel(block)}`)); + } + } + break; + case "user": + for (const block of ev.message?.content ?? []) { + if (block.type !== "tool_result") continue; + const parts = Array.isArray(block.content) + ? block.content.filter((c) => c.type === "text").map((c) => c.text) + : [String(block.content ?? "")]; + const text = parts.join(" ").trim(); + if (text) { + console.log(dim(` ⎿ ${clip(text, 200)}`)); + } + } + break; + case "result": { + const mins = (ev.duration_ms / 60000).toFixed(1); + const cost = ev.total_cost_usd != null ? ` | $${ev.total_cost_usd.toFixed(2)}` : ""; + console.log(bold(`\n=== ${ev.subtype} | ${ev.num_turns} turns | ${mins} min${cost} ===`)); + if (ev.result) { + console.log(ev.result); + } + if (ev.is_error) { + process.exitCode = 1; + } + break; + } + } +}); +' + +# One claude -p invocation = one fresh session/context. pipefail propagates +# a failure from either claude or the formatter (is_error → exit 1). +run_claude() { + local prompt="$1" + shift + claude -p "$prompt" \ + --permission-mode "$PERMISSION_MODE" \ + --output-format stream-json \ + --verbose \ + "$@" \ + | node --input-type=module -e "$FORMATTER" +} + +GOAL="/goal Every story sub-issue of epic #${N} is implemented on a new branch — \ +one commit per story, in blocked_by order, each commit body ending with a line \ +reading exactly 'Closes #' — and the full test suite \ +passes. Prove it: git log shows one commit per story, each with its Closes line, \ +test command exits 0. Start by running /nxs-epic-resolve ${N}; the \ +decision-record sub-issue's invariants are binding; re-read the story's epic.md \ +section before starting each story. Do not push and do not open a PR — the \ +calling script does both. Stop after ${TURNS} turns." + +echo ">>> stage 1: implement epic #${N} | permission mode: ${PERMISSION_MODE} | turn cap: ${TURNS}" >&2 +run_claude "$GOAL" "$@" + +# The loop neither pushes nor opens a PR, so nothing has left the machine yet. +# Both steps below are needed for issue linkage: GitHub records a commit → issue +# reference only once the commit reaches the default branch or becomes part of a +# pull request, so a bare pushed branch still links nothing. The draft PR gives +# the story issues their commit references now; the Closes lines in the commit +# bodies close those issues when it merges into ${BASE}. +BRANCH="$(git rev-parse --abbrev-ref HEAD)" +if [[ "$BRANCH" == "HEAD" || "$BRANCH" == "$BASE" ]]; then + echo "!!! refusing to push from '${BRANCH}' — expected the loop's story branch" >&2 + exit 1 +fi +echo "" >&2 +echo ">>> pushing ${BRANCH} to origin" >&2 +git push -u origin "$BRANCH" + +PR_URL="$(gh pr list --head "$BRANCH" --state open --json url --jq '.[0].url // empty')" +if [[ -n "$PR_URL" ]]; then + echo ">>> PR already open: ${PR_URL}" >&2 +else + echo ">>> opening draft PR against ${BASE}" >&2 + EPIC_TITLE="$(gh issue view "$N" --json title --jq .title)" + gh pr create --draft \ + --base "$BASE" \ + --head "$BRANCH" \ + --title "epic #${N}: ${EPIC_TITLE}" \ + --body "$(cat <\` line, so merging this PR +into \`${BASE}\` closes the stories it implements. The epic itself closes through +\`/nxs.close\`, not by merge. + +Draft opened by \`utils/implement-epic.sh\`; \`/nxs.analyze\` runs against it next. +EOF +)" +fi + +if [[ "$ANALYZE" == "1" ]]; then + echo "" >&2 + echo ">>> stage 2: /nxs.analyze #${N} (fresh context)" >&2 + run_claude "/nxs.analyze ${N}" +fi