diff --git a/.claude/commands/nxs.decision-record.md b/.claude/commands/nxs.decision-record.md index 7fb18a4..7b9ddb7 100644 --- a/.claude/commands/nxs.decision-record.md +++ b/.claude/commands/nxs.decision-record.md @@ -49,10 +49,11 @@ before resolving the entry path from the rest of `$ARGUMENTS`. **which filing path Phase 4 takes — it does not select where the run starts.** Run Phase 0.2's **resolution** steps 1–2 as usual (they establish `$REPO_ARG` and the label names every later `gh` call needs) but skip its step-3 **gate** — that gate answers "does this epic warrant a record", and a -revision already presupposes the answer. Then run **Phases 1–3** to produce the new body, and file it -through **Phase 4.5**, which reopens the record, records what is being superseded, updates the body, -and re-closes it. Strip the token before resolving the entry path. Without an existing closed record -there is nothing to revise — say so and run the normal path instead. +revision already presupposes the answer. Then run **Phase 0.4** (it runs on every path, revision +included) and **Phases 1–3** to produce the new body, and file it through **Phase 4.5**, which reopens +the record, records what is being superseded, updates the body, and re-closes it. Strip the token +before resolving the entry path. Without an existing closed record there is nothing to revise — say so +and run the normal path instead. ## Interaction convention — actionable choice gates @@ -182,6 +183,40 @@ That is what makes an epic filed by hand outside Nexus — no label, no record This is the only deliberate way to reach "this epic legitimately has no record" — which is also the only state in which `/nxs.analyze` may run in its degraded no-invariant mode. +## Phase 0.4 — Read the discovery gists off the epic issue + +An epic that came from a `/nxs.discover` discovery carries the decisions that discovery resolved, as +comments on the epic issue written when the discovery graduated. Read them **before** analysing, so +the record is designed on top of what was already settled instead of re-deriving it. + +This is the **only** change discovery makes to this command. It runs on every path (normal, import, +and revision) and needs no flag — the epic issue either carries marked comments or it does not. + +1. Fetch the epic issue's comments and keep **only** the ones whose body contains the marker + ``: + + ```bash + gh issue view $REPO_ARG --json comments \ + --jq '[.comments[] | select(.body | contains("")) | .body]' + ``` + + `$REPO_ARG` is the resolved issues-repo from Phase 0.2 — the epic issue may not live in the repo + this command runs from. + +2. **No marked comment** → this epic did not come from a discovery. Continue exactly as today: no + new prompt, no new question, and **no empty section** anywhere in the record. Skip the rest of + this phase. + +3. Otherwise keep the collected gists as **`DISCOVERY_GISTS`** for Phase 1. + +**Only marked comments are read.** An epic issue accumulates ordinary discussion, and feeding all of +it to the architect degrades the input. Capturing an out-of-band decision comment in the general case +is worth solving on its own terms and is not solved here. + +**This command never edits or removes those comments.** It reads them and nothing else. They are the +copy that survived promotion rewriting the stub body, and they are the only durable carrier of the +discovery's reasoning once the discovery folder is gone. + ## Phase 0.5 — Load the design doc (import mode only) **Skip without `--from`.** With `--from `, read the design doc at `` (it lives outside @@ -218,12 +253,30 @@ a *why*, any choice it made without recording the viable alternative it beat, or needs human ratification becomes an **Open Clarification** (the Phase 2 gate) — import never silently invents a rationale the doc did not contain. +**Discovery gists (`DISCOVERY_GISTS` from Phase 0.4, when present):** pass them as an +**authoritative input** alongside the epic and its stories, and tell the architect that these +decisions are **already settled** — its job is to design on top of them, not to re-decide them. + +They **do not replace the analysis**. The architect still designs the epic from scratch and the +coverage requirement below still applies to every story. This is why the gists do **not** go through +`--from`: import mode treats its document as *the* design and derives the record from it, whereas a +gist decides *what* to build and at what scope. A gist settles almost nothing about how the epic is +built and carries no invariants, which is the part of a record the conformance gate later checks +against — so routing them through import mode would skip the design work on the grounds that the +scope work was done. + +A gist that **states a decision without its reasoning** becomes an **Open Clarification** for the +human, exactly as an unexplained decision in an imported design doc does today. Never invent the +missing *why*. + ``` Invoke: nxs-architect Topic: Decision record for epic "" Resolved docs root: # every doc path below is under this; on a repo-root hub it is `.` Inputs to read: - # import mode ONLY (--from): the authoritative design doc — primary why source +- DISCOVERY_GISTS # when present (Phase 0.4): decisions the discovery already settled — + # authoritative, already decided, NOT a substitute for this analysis - ${QDIR}/epic.md # the epic and ALL its user stories — authoritative scope - /product/context.md # personas, strategy (reference, don't re-tabulate) - /system/stack.md # technology stack @@ -563,6 +616,11 @@ Report concisely: label, or a status anywhere, and never infer approval from any other signal. Nexus applies no permission check of its own: whoever can close the sub-issue is the approver, and the timeline records who and when. +- **Discovery gists are an input, never a substitute.** The marked comments on the epic issue are + read as authoritative decisions the discovery already settled, and the command still runs its own + architectural analysis and still checks story coverage. It **never edits or removes** those + comments, and it reads **only** the marked ones. An epic with no marked comment behaves exactly as + it did before: no new prompt, no empty section. - **`--from` imports a design doc; it does not copy it.** The doc is the authoritative *why* source, but the record it produces is still abstracted domain prose (no code / file paths / type names) covering every story, and every decision still carries its *why* — a doc that states a diff --git a/.claude/commands/nxs.discover.md b/.claude/commands/nxs.discover.md new file mode 100644 index 0000000..243bcd8 --- /dev/null +++ b/.claude/commands/nxs.discover.md @@ -0,0 +1,636 @@ +--- +name: nxs.discover +description: Run pre-epic discovery on a foggy initiative as a multi-session loop over a committed store. Starts a discovery by naming its destination and writing one decision ticket per open decision, resumes it one decision at a time, and closes it when the resolutions conclude that no build follows. Writes nothing to GitHub — a discovery that does conclude a build graduates through /nxs.epic --discovery. +category: planning +tools: Read, Write, Edit, Glob, Grep, Bash, Task, Skill, AskUserQuestion +model: inherit +--- + +# Role + +Act as a delivery lead running discovery. Turn an underspecified initiative into a set of open +decisions, resolve them one at a time, and stop when every functional goal is sharp enough to be +filed as a backlog stub. You resolve decisions; you do not plan work, size epics, or file issues. +Issues appear later, when `/nxs.epic` consumes the finished discovery. + +# User Input + +```text +$ARGUMENTS +``` + +The flag selects the action; it is never inferred from the shape of the argument: + +- **intent text** — a natural-language description of a foggy initiative → **start** a discovery. +- **`--resume `** — work one open decision of an existing discovery. +- **`--close `** — end a discovery whose resolutions concluded that no build follows. + +Empty input is an error: ask for an initiative description, or for `--resume` / `--close` with a +discovery folder, and stop. + +# What this command does (read once) + +- **Discovery is the stage before the epic stage.** Nexus's right-size gate measures size only. An + initiative can be *oversized* (big but clear — `/nxs.epic` decomposes it) or *underspecified* + (foggy — the split itself hangs on decisions nobody has made). Pre-slicing a foggy initiative into + work-shaped stubs is speculative over-generation. This command answers the foggy case instead. +- **The destination is fixed and it is Nexus's own contract.** A discovery is done when every + functional goal is sharp enough to be stated as a backlog stub of size M or smaller. Nothing else + ends it. The destination is immutable for the life of a discovery — if it changes, close the + discovery and start another. +- **The unit is the decision ticket.** A ticket is a question whose resolution is a decision. It is + never a slice of build work. One decision is resolved per session. +- **The store is committed, in a directory of its own outside the queue.** Committing is what makes + a discovery shareable: push it to a fork, hand it to a domain expert, pull their work back. Sharing needs no + machinery beyond ordinary git operations — no review gate, no approval command, and no rule about + who may start, resume, or graduate a discovery. +- **This command writes nothing to GitHub.** No issue, no comment, no label, at any point in a + discovery's life. Every GitHub write for a discovery happens in `/nxs.epic`, which is also where + graduation lives. That keeps one code path emitting every stub in the system. +- **It commits, and it never pushes.** Each session commits its own work and reports the commit. + Pushing, opening a pull request, and merging are the user's, so this command stays out of each + repository's branch-protection and review policy. + +## Interaction convention — actionable choice gates + +Every explicit-choice point in this command — the destination confirmation, the feature +confirmation, and the resume-or-new choice when a discovery already exists — is presented through +the **`AskUserQuestion`** tool, **not** as a free-text prompt the user has to read and type a reply +to. Render any context first as ordinary markdown, then call `AskUserQuestion` with **one option per +choice**, using the canonical verb named at that gate as the option label and putting the action's +effect in the option description. The user can always pick "Other" to give a custom answer. + +## Prose convention — human-facing artifacts + +Before drafting the first human-facing artifact of the run, invoke the **`nxs-prose-style`** skill +(Skill tool) once; its authoring rules stay in force for the rest of the run. It governs the +discovery doc, every ticket file, and the session report. The structural rules in this command +(the file shapes, the frontmatter keys, the index line format) take precedence over it. + +## Vocabulary + +Every artifact this command writes — discovery docs, ticket files, prompts, reports — uses Nexus +vocabulary only. The nouns are **discovery**, **destination**, **decision ticket**, **resolution**, +**functional goal**, and **backlog stub**. Do not write "map", "frontier", "charting", or +"wayfinding" anywhere. The fog sharpness test may be described as fog in prose — a suspicion is +foggy until it can be stated precisely — but fog is a description, not a named artifact. + +--- + +# The store + +A discovery lives in one committed folder: + +``` +.nexus/discovery/discover--/ + discovery.md # the discovery doc — one per folder + ticket--.md # one file per decision ticket +``` + +- `` is kebab-case, derived from the intent. +- `` is a short unique key — 8 lowercase hex characters, the shape existing queue entries + already use. It exists because a discovery has no issue number to be named by (nothing is filed at + start), and because two contributors may independently start on the same intent; a slug alone + would silently merge two different discoveries into one folder. +- **Discovery folders live under `.nexus/discovery/`, never under `.nexus/queue/`.** The queue is a + close-time drain buffer holding only closed, drainable entries; a discovery runs before the epic + stage, is never closed, and is never drained. Location alone is what keeps a discovery out of the + distiller's scan, the `/nxs.analyze` resolution, and the `/nxs.decision-record` resolution — those + stages read the queue, and a discovery is not in it. +- **`.nexus/discovery/**` is excluded from the distiller's behavioral diff analysis**, alongside + `.nexus/queue/**`, so discovery prose can never become a concept delta. That exclusion is + load-bearing, not incidental. + +**Nothing durable may link into this folder.** No issue body, comment, document, concept page, or +report may carry a path into it, because the folder is removed when the discovery ends and the link +would break at exactly the moment a reader needs it. Anything that must outlive the discovery is +**copied in full** into a durable artifact — a reference is never sufficient. + +The store lives in the checkout the command runs in, and it never migrates. Nothing drains a +discovery folder, so there is no hub migration for one. Hub and multi-repository workspaces are +untested this iteration — not blocked. + +--- + +# Action: start — open a discovery on a foggy initiative + +Run the phases in order. **Nothing is created on disk before Phase 4**, so a user who backs out at +any gate leaves no trace. + +## Phase 0 — Resolve the action and the docs root + +1. If `$ARGUMENTS` contains `--resume` or `--close` (string-matched), this is not a start — run that + action instead. Otherwise the whole of `$ARGUMENTS` is the **intent**: the natural-language + description of the initiative. +2. Resolve the docs root exactly as `/nxs.epic` does — the single-value view over the workspace + resolver: + + ```bash + tsx ./.claude/skills/nxs-workspace-status/scripts/docs_root.ts + ``` + + In a checkout with no in-repo Node toolchain, use `node /nexus.mjs workspace + docs-root`. Capture the printed line as **``**. **On a non-zero exit, stop and report + the diagnostic** — never fall back to a literal `docs/`. Apply the empty-prefix rule when + building a path under it: on a hub whose docs root is `.`, the taxonomy hangs off the repo root + (`features//…`), and no path ever carries a `./` prefix or a segment named `.`. +3. Read `/product/context.md` if present, to calibrate the destination against the + product's actual strategy and personas. + +## Phase 1 — Name the destination (MANDATORY STOP) + +The destination is named **first**, before any folder, ticket, or file exists. It is what every later +ruling is relative to: a question is in scope because answering it moves the initiative toward the +destination, and work is out of scope because it lies beyond it. + +The destination is pinned to Nexus's contract and is **not** open-ended. State it in this shape: + +```markdown +## Destination + +This discovery is done when every functional goal of **** can be stated as a backlog +stub of size M or smaller — a one-line goal, an S or M estimate, and candidate story titles. + +In scope for that judgement: +Beyond it: +``` + +Render that as markdown, then ask via **`AskUserQuestion`**: + +| Option | Action | +|--------|--------| +| **accept** | Take this destination and continue. It is immutable for the life of the discovery. | +| **revise** | Give a different destination (use "Other"), then re-present it. | + +**Do not create anything without an explicit `accept`.** Loop on `revise` until accepted. + +## Phase 2 — Confirm the feature (MANDATORY STOP) + +Every stub this discovery eventually produces belongs to a feature. Confirm it **once**, here, and +record it in the discovery doc. It is the default for every stub at graduation, overridable per stub +there. + +Derive a feature **name** (Title Case) and **slug** (kebab-case) from the intent, and let +`` be `/features/` (empty-prefix rule applied). If the user already +referenced a feature container, use that one. Then present one confirmation through +`AskUserQuestion` — "This discovery's goals will land under feature **** (`/`). +Accept, or give a different name?" — and take the user's correction if any. + +Do **not** create the feature directory. Discovery writes nothing outside its own folder, and +`/nxs.epic` creates the container when it files. + +## Phase 3 — Check for an existing discovery (MANDATORY STOP when one exists) + +Derive the kebab-case `` from the intent, then list the committed store: + +```bash +ls -d .nexus/discovery/discover--* 2>/dev/null +``` + +Because the store is committed, this also sees discoveries other people started and shared — so a +second contributor typing the same intent is offered the existing one instead of silently forking a +parallel discovery. + +- **No match** → continue to Phase 4. +- **One or more matches** → read each one's `## Destination`, render them, and ask via + `AskUserQuestion` **before creating anything**: + + | Option | Action | + |--------|--------| + | **resume** | Work the existing discovery instead. Hand off to the resume action against that folder. | + | **new** | Start a separate discovery under a fresh key. Both folders then exist side by side. | + +## Phase 4 — Create the folder and the discovery doc + +Generate `` — 8 lowercase hex characters — and create the folder: + +```bash +mkdir -p .nexus/discovery/discover-- +``` + +Write `discovery.md` in the shape given under "Store file shapes" below. At creation it carries the +accepted destination, the confirmed feature, an **empty** resolved-decisions index, a **"Not yet +specified"** section holding the in-scope fog, and an **"Out of scope"** section. + +**No open ticket is listed in the doc.** Open tickets are found by listing the ticket files, so the +doc never carries a second copy of the ticket set that could fall out of step with it. + +**Write the folder under `.nexus/discovery/`, never under `.nexus/queue/`** — see "The store". +Location is what keeps a discovery out of reach of the rest of the pipeline. + +## Phase 5 — Write the decision tickets + +Split the initiative's unknowns by the one test that matters: **can the question be stated precisely +now?** That is not the same as whether it can be answered now — a question can be sharp and +completely open. + +1. **A question that can be stated precisely** becomes its own ticket file beside the discovery doc, + in the shape given below. Assign its **type** from what would actually resolve it: + + | Type | Resolves through | Use when | + |------|------------------|----------| + | `research` | The `Explore` and `nxs-architect` agents | The answer is discoverable from the code, the docs, or the field — away from the keyboard. | + | `interview` | `nxs-pm` and the `nxs-setup` interview pattern | Only a human holds the answer. | + | `council` | The two perspective agents, synthesised under the council's mandate | The trade-off is genuinely contested between product and architecture. | + | `task` | Ordinary work in the session | Unblocking legwork stands between you and a statable question. | + + There is no `prototype` type. A prototype-shaped question routes to an `interview` or a + `council` ticket. + +2. **A suspicion that cannot yet be phrased sharply** goes into the discovery doc's **"Not yet + specified"** section, and **no ticket is created for it**. It graduates into a ticket later, when + a resolution makes it precisely statable. + +3. **Work already ruled beyond the destination** goes into **"Out of scope"**. Entries there never + graduate. + +4. **Wire the blocking edges in a second pass**, once every ticket file exists and has a name to be + referred to. Set each ticket's frontmatter `blocked_by` to the list of ticket filenames that must + resolve first, or `none`. Do not invent ordering to look tidy — a ticket is blocked only when its + question cannot be *stated* or *answered* until another resolves. + +Prefer the fewest tickets that cover the fog. A ticket per paragraph of the intent is padding. + +## Phase 6 — Commit + +Commit the folder — the discovery doc and every ticket file — as one commit, and report it: + +```bash +git add .nexus/discovery/discover-- +git commit -m "discover: open discovery" +``` + +**Never push, open a pull request, or merge.** Report the commit and tell the user that sharing this +discovery is an ordinary `git push`. + +## Phase 7 — Fire the research agents, then stop + +For each `research`-typed ticket that is unblocked, fire its agent now (`Explore` for locating and +reading, `nxs-architect` for feasibility and trade-off analysis) so the work happens while the lead +is away from the keyboard. + +**Start resolves no ticket.** An agent's output is evidence, recorded on the ticket by the session +that reads it. It is never a resolution: a fact is not a decision, and only a session marks a ticket +resolved. Firing the agents is the last thing start does. + +Report: + +- The destination, in the accepted words. +- The feature this discovery's goals will land under. +- The ticket count by type, naming each ticket **by title** — never by a bare filename. +- What went to "Not yet specified" and what went to "Out of scope". +- The commit, and that nothing was pushed and nothing was written to GitHub. +- Next step: `/nxs.discover --resume ` to work one decision. + +--- + +# Action: resume — work one open decision + +A resume session claims exactly one open decision ticket, resolves it, records the resolution, +graduates whatever fog the resolution made sharp, commits, and stops. **One decision is resolved per +session.** That is the granularity a reader wants, because each commit is then one decision and its +reasoning, and the commit history reads as the decision history. + +Run the phases in order. + +## Phase R0 — Resolve the discovery and the docs root + +1. `--resume ` takes the discovery folder. If the argument is omitted and exactly one + discovery folder exists under `.nexus/discovery/`, use it. If several exist, render each one's + destination and ask which via `AskUserQuestion`. If none exists, say so and stop — there is + nothing to resume. +2. Read `discovery.md`. A discovery whose `status` is `closed` cannot be resumed; report that and + stop. +3. Resolve `` exactly as Phase 0 does, and by the same rules on failure. + +## Phase R1 — Select and claim one ticket (before any work begins) + +Read the frontmatter of every `ticket-*.md` in the folder. A ticket is **claimable** when all three +hold: + +- its `status` is `open`; +- it is **unblocked** — every ticket named in its `blocked_by` has `status: resolved`; +- it is **unclaimed**, or its `claimed_at` is older than the staleness threshold of **24 hours** + while its `status` is still `open`. + +Select one claimable ticket — prefer the one that unblocks the most others, then the oldest. If the +user named a ticket, use that one; **it is claimed the same way**, and a user-named ticket that is +blocked or freshly claimed by someone else is refused with the reason, not taken anyway. + +Then write the claim into that ticket's frontmatter **before any work begins**: + +```yaml +claimed_by: +claimed_at: +``` + +The claim is **not a boolean** — it records who and when, because both are what the next contributor +needs. Resolve the owner the way the in-flight decision-stub rule resolves it: + +```bash +gh api user --jq .login # fall back to a slug of `git config user.name` +``` + +**Taking over a stale claim** is allowed and is **recorded**, so the trail survives: overwrite +`claimed_by` / `claimed_at` and append one line to the ticket body under a `## Claim history` +heading — `Taken over from (claimed ) on .` + +**The claim's scope is one working tree.** It exists because parallel agent sessions can work one +discovery in one tree, where git gives them no protection at all. It does not coordinate people: two +contributors working in two clones never see each other's claims, and a **merge conflict**, not a +claim, is what tells them they collided. Staleness matters for the same reason — a claim can arrive +in a pull someone else made and simply sit there. + +If nothing is claimable, report why (all resolved, or every open ticket blocked or freshly claimed), +name the blocking tickets **by title**, and stop. + +## Phase R2 — Resolve it through existing machinery + +Route by the ticket's `type`. Every route is machinery that already exists — this stage adds no +agent and no skill. + +- **`research`** → invoke `Explore` for locating and reading, and `nxs-architect` for feasibility and + trade-off analysis. Give each the question verbatim and the destination as its boundary. +- **`interview`** → invoke `nxs-pm` for the framing and the questions worth asking, then run the + exchange with the human using the `nxs-setup` interview pattern: at most a handful of strategic + questions, one at a time, through `AskUserQuestion`. **An interview ticket resolves only through + the live exchange. Never supply the human's side of it** — not as a guess, not as a "likely + answer", not as a default the human is invited to correct. If the human is not available, leave the + ticket claimed, say so, and stop. +- **`council`** → run the two perspective agents **yourself**, `nxs-pm` and `nxs-architect`, and + synthesise their output under the council's synthesis mandate: lead with the decision, add value + beyond summarising, and name what each perspective gave up. Do **not** hand off to `/nxs.council` — + a slash command cannot invoke another slash command, and the handoff would leave the ticket claimed + across a session boundary with the outcome pasted back by hand. +- **`task`** → do the unblocking legwork in this session, then state the question it made statable. + +**An agent's output is evidence, never a resolution.** Record it on the ticket under an +`## Evidence` heading, attributed to the agent that produced it. Only the session marks a ticket +resolved and writes its index gist, because a fact is not a decision and closing a ticket on evidence +alone would record as decided something nobody decided. + +## Phase R3 — Record the resolution + +Append to the claimed ticket file: + +```markdown +## Resolution + +- **Decided:** +- **Why:** +- **Refuted alternative:** +- **Resolved by:** on +``` + +Set the ticket's `status: resolved` in frontmatter. + +Then append **exactly one** line to `discovery.md`'s `## Resolved decisions` index: + +```markdown +- **** — . Detail: `ticket--.md` +``` + +The index is **append-only and order-insensitive**: append at the end, never sort it, never rewrite +an existing line. That is what lets two clones appending different resolutions merge cleanly. It must +stay **reconstructible from the ticket files**, so a botched merge costs a rebuild and nothing more — +which is exactly why the line is a gist and the ticket file remains the only store of the detail +until graduation copies it onto the stubs. + +## Phase R4 — Graduate the fog the resolution sharpened + +Re-read `## Not yet specified` against the resolution just recorded. + +1. **Fog the resolution made precisely statable** graduates: write a new typed ticket file for it, + then — in a second pass, once every new file exists — wire its `blocked_by`, and **remove the + entry from "Not yet specified"**. The test is unchanged: can the question be stated precisely + now, not can it be answered now. +2. **Work the resolution ruled beyond the destination** moves to `## Out of scope`. Entries there + **never graduate**. +3. Everything else stays where it is. + +If the resolution sharpened nothing, this phase writes nothing. That is a normal outcome. + +## Phase R5 — Commit one decision, report, and stop + +Commit the claim, the resolution, the index line, and any graduated tickets as **one commit**: + +```bash +git add .nexus/discovery/discover-- +git commit -m "discover: resolve " +``` + +**Never push, open a pull request, or merge.** + +Then **stop**. One decision is resolved per session. Research-typed tickets fired earlier may still +be running in parallel — that is fine, they resolve nothing. + +Report: + +- The ticket resolved, **by title**, and the decision in one sentence. +- Any takeover that was recorded. +- What graduated out of "Not yet specified" and what moved to "Out of scope", each **by title**. +- What remains open, **by title**, and what is still blocked and by which ticket. +- The commit, and that nothing was pushed. +- Next step: `/nxs.discover --resume ` again while open tickets remain. When none remain and + "Not yet specified" is empty, the discovery is done — graduate it with `/nxs.epic`, or end it with + `/nxs.discover --close ` if the resolutions concluded that no build follows. + +--- + +# Action: close — end a discovery that concluded no build follows + +This is the **terminal act for one outcome only**: every decision is resolved, and the resolutions +concluded that **no build follows**. It is the outcome `/nxs.epic` never sees, because no epic and no +stub is ever filed, so without this action it would have no home at all — and there are no stubs on +this path to carry the reasoning. + +**A discovery that concluded a build does follow is not closed here.** It graduates: +`/nxs.epic --discovery `. Do not force a stub to make the discovery closable. + +## Phase C0 — Confirm the outcome (MANDATORY STOP) + +1. `--close ` takes the discovery folder. Read `discovery.md` and every ticket file. +2. Every ticket must have `status: resolved`. If any is open, report it **by title** and stop — an + unresolved question is not a no-build conclusion. +3. Render the destination, the full resolved-decisions index, and the conclusion you have drawn from + the resolutions in one or two sentences: **why no build follows**. Then ask via + `AskUserQuestion`: + + | Option | Action | + |--------|--------| + | **close** | Write the lessons note and remove the folder, in one commit. Irreversible in the tree, recoverable from the log. | + | **graduate** | Stop instead, and run `/nxs.epic --discovery ` — a build does follow. | + + **Do not remove anything without an explicit `close`.** + +## Phase C1 — Write the lessons note + +Resolve `` as Phase 0 does, then write **one** note: + +``` +/delivery/lessons/-.md +``` + +That folder already holds dated outcome notes written by a pipeline stage, so this introduces no new +convention. The note is the **only durable carrier** of everything this discovery learned, so it +carries all three of: + +```markdown +# — discovery closed, no build + +## Destination + + + +## Resolved decisions + +### + +- **Decided:** +- **Why:** +- **Refuted alternative:** + + + +## Conclusion + + +``` + +Carry the index **in full** — one entry per index line, none dropped, none merged — and **drop the +`Detail:` clause**: the ticket file it names stops existing in the next step, so copying it would +leave the only durable artifact pointing at nothing. This is why each entry carries the ticket's +**Why** and **refuted alternative** as well: the index line alone is a gist that leans on a ticket +file to hold the reasoning, and there is no ticket file after this commit. It is the same full gist +form `/nxs.epic` copies onto a stub at graduation, for the same reason — anything that must outlive +the discovery is copied in full into a durable artifact, and a reference is never sufficient. + +## Phase C2 — Mark the doc closed, remove the folder, commit + +In one commit: set `status: closed` in `discovery.md`'s frontmatter with the conclusion recorded +under the destination, then remove the folder and add the note. + +```bash +git rm -r .nexus/discovery/discover-- +git add /delivery/lessons/-.md +git commit -m "discover: close — no build follows" +``` + +Writing the note and removing the folder in the **same commit** makes the trade atomic on merge, so +the record cannot be deleted without its replacement landing. + +**Never push.** Report the note's path, the conclusion, and the commit. + +--- + +# Store file shapes + +## The discovery doc — `discovery.md` + +```markdown +--- +destination_accepted: +feature: "" +feature_path: +status: open | closed +--- + +# Discovery: + +## Destination + + + +## Resolved decisions + + + +## Not yet specified + +- + +## Out of scope + +- +``` + +The **resolved-decisions index** is append-only and order-insensitive, so two clones appending +different resolutions merge cleanly. It must stay reconstructible from the ticket files, so a botched +merge costs a rebuild and nothing more. Each resolved ticket contributes **exactly one** line to it; +the ticket file remains the only store of the detail until graduation copies it onto the stubs. + +## A decision ticket — `ticket--.md` + +```markdown +--- +title: "" +type: research | interview | council | task +status: open | resolved +blocked_by: [ticket--.md, ...] | none +claimed_by: # a GitHub login, set when a session claims it +claimed_at: # ISO-8601, set with claimed_by +--- + +## Question + + + +## Why it blocks + + + +## Evidence + +## Claim history + +## Resolution +``` + +The frontmatter is the **entire control surface**: the type, the blocking edges, the claim, and the +status. The body holds the question and, later, its resolution. Each field has a one-to-one GitHub +equivalent — the type is a label, the blocking edges are native dependency edges, the claim is an +assignee, the status is open or closed — so migrating this store to an issue-backed one later is a +translation rather than a redesign. + +One file per ticket is also the **merge unit**. Two clones resolving two different tickets touch two +different files and conflict nowhere except the shared index, where both sides are appending. + +--- + +# Usage + +``` +/nxs.discover # start a discovery on a foggy initiative +/nxs.discover --resume # claim and resolve one open decision, then stop +/nxs.discover --close # end a discovery whose resolutions concluded no build follows +``` + +A discovery whose resolutions **do** conclude a build graduates instead, through the one command +that files issues: `/nxs.epic --discovery `. + +# Constraints + +- **No GitHub write, ever.** This command creates no issue, comment, or label at any point in a + discovery's life. If a step seems to need one, it belongs in `/nxs.epic`. +- **Never write a discovery under `.nexus/queue/`.** The queue holds only closed, drainable entries. + A discovery lives under `.nexus/discovery/`, which is what keeps it out of reach of every stage + that reads the queue. +- **No durable link into the store.** Copy in full instead; the folder is removed when the discovery + ends. +- **A decision ticket is a question whose resolution is a decision** — never a slice of build work. + If a ticket reads like something an engineer would implement, it is not a ticket. +- **The destination is immutable.** Changing it invalidates rulings that were made against the old + boundary and never re-validated. Close the discovery and start another instead. +- **The command commits and never pushes.** Sharing is the user's `git push`. +- **No new agents or skills.** Every ticket type routes to machinery that already exists. +- **One decision per session, one commit.** Research agents may run in parallel to it, and they + resolve nothing. +- **A ticket is claimed before any work begins**, and the claim's owner is a GitHub login. The only + takeable ticket is one that is unresolved, unblocked, and either unclaimed or claimed past the + staleness threshold. A takeover is recorded. +- **An interview-typed ticket resolves only through live human exchange.** The agent never supplies + the human's side of it. +- **The folder is removed by exactly two acts:** closing a discovery with no build, or a human + removing it after graduation. No stage is taught to drain it, and `/nxs.epic` never removes it. +- **Closing with no build writes its lessons note in the same commit that removes the folder.** +- **Human-facing output names a ticket by its title**, never by a bare filename. diff --git a/.claude/commands/nxs.distill.md b/.claude/commands/nxs.distill.md index 9d4b5c6..5132a61 100644 --- a/.claude/commands/nxs.distill.md +++ b/.claude/commands/nxs.distill.md @@ -366,9 +366,9 @@ argument its own quoted token — never a shell-interpolated string: full SHAs), resolves each named repo to its sibling member checkout through the workspace resolver (the hub's own entries resolve to the hub checkout), verifies both SHAs are reachable, and emits **one diff per repo** — each computed as `git diff ...` - inside that repo's own checkout with `.nexus/queue/**` excluded, so no path is ever - attributed to the wrong repo. It reads only: it never clones, fetches, or mutates a member - checkout. + inside that repo's own checkout with `.nexus/queue/**` and `.nexus/discovery/**` excluded, so + no path is ever attributed to the wrong repo. It reads only: it never clones, fetches, or + mutates a member checkout. - **Exit 0:** stdout carries a `=== repo checkout range ... ===` header per repo followed by that repo's diff. Analyze each repo's diff against its own @@ -411,6 +411,11 @@ a fallback for legacy entries with no usable range. For an entry the Phase 0.4 g In both modes, exclude `.nexus/queue/**` paths from the behavioral analysis — the entry's own artifacts are input, not the *what*. +**Exclude `.nexus/discovery/**` in both modes too.** A discovery folder holds ungated, in-flight +reasoning that no human gate has passed. It is never a queue entry and is never drained, so a +branch that carries both discovery prose and code must not feed that prose into concept-delta +synthesis. This exclusion is load-bearing (record #235, invariant 2), not a tidiness rule. + # Phase 2 — Survey the concept store Before synthesizing, know what exists (0003 §5 retrieval — glob/rg is *your* index; the diff --git a/.claude/commands/nxs.epic.md b/.claude/commands/nxs.epic.md index fbba9ae..8e08f8f 100644 --- a/.claude/commands/nxs.epic.md +++ b/.claude/commands/nxs.epic.md @@ -24,12 +24,16 @@ The text after the slash command is either: - **`--from #`** — pull an epic that is **already filed** as GitHub issues (by Nexus or by hand) into a materialized `epic.md`, so downstream stages can run against an epic not planned in this session. This is a read-only wrapper over the resolver — it plans nothing and commits nothing - (handled up front in Phase 0; the planning phases below do not run). + (handled up front in Phase 0; the planning phases below do not run), or +- **`--discovery `** — **consume a finished discovery** produced by `/nxs.discover`. The + discovery doc is the intent. This is the one path by which a discovery becomes GitHub issues: + `/nxs.discover` writes nothing to GitHub, so graduation happens here, through the emission path + this command already owns. The flag selects the operation; it is never inferred from the shape of the argument. A bare number -always means "plan this epic"; `--from` always means "load this already-planned epic". **Any other -input is a capability description** — there is no slug lookup, because a stub's issue number is its -only identifier. +always means "plan this epic"; `--from` always means "load this already-planned epic"; +`--discovery` always means "consume this finished discovery". **Any other input is a capability +description** — there is no slug lookup, because a stub's issue number is its only identifier. Empty input is an error: ask the user for a capability description (or a stub's issue number) and stop. @@ -37,7 +41,15 @@ Empty input is an error: ask the user for a capability description (or a stub's - **No feature brief precondition.** It takes intent directly. The feature container is an _output_: if one is not already in context, infer a name, confirm it once, and scaffold it. No human pre-authors a brief before planning. - **Nothing is committed at planning — GitHub issues are the source of truth (#114).** The epic is drafted only to **session scratch**; the epic gate runs on that draft; and at approval the epic and its story issues are **filed**, committing **nothing** to `.nexus/queue/`. The queue entry is no longer born here — it is born at close (`/nxs.close`), so the queue holds only closed, drainable entries. Every later stage reconstructs the epic from its issue number via the resolver (`nxs-epic-resolve`), not from a committed planning file. The feature folder under `/features//` (the docs root resolved in Phase 0) still holds the durable nav index. It holds no backlog file: deferred scope is an open issue carrying the unplanned label (#185), so the feature tree carries no re-triage queue at all. +- **Underspecified scope is referred to discovery, not answered with stubs.** The right-size phase + tests sharpness before it measures size. An intent whose functional goals cannot be stated stops + there and recommends `/nxs.discover`, with an explicit override. Big-but-clear is a different + problem and keeps the decomposition path below. - **Oversized scope decomposes to stubs.** The right-sizing gate is kept. A `> M` scope, with consent, files one **stub issue** per functional goal — an epic identified but not yet planned, carrying the epic classification plus the unplanned label; the full epic for each is deferred to a later `/nxs.epic ` promotion. +- **A finished discovery graduates here.** `/nxs.discover` resolves decisions and writes nothing to + GitHub; `--discovery ` turns those resolved decisions into issues through the very same + emission path everything else uses. That is what makes "a discovery-produced stub is accepted + unchanged by promotion" true by construction rather than by a third copy of the stub contract. - **A stub is an epic issue, so every epic query filters it out.** The whole cross-feature backlog is one query — open issues carrying the unplanned label — and its exclusion is one negated filter. Any query here or downstream that enumerates epics for **planned** work carries that negation; ask for it (`delivery_config.py backlog-query --form exclude`) rather than writing the label by hand. This is the accepted price of a stub keeping its issue number through promotion. ## Interaction convention — actionable choice gates @@ -135,6 +147,31 @@ In a checkout with no in-repo Node toolchain (a docs-only hub), use the portable ``` The issue must exist, be **open**, and carry the resolved unplanned label. If it does not — closed, no such issue, or already planned — report **why** it is not promotable, name `--from #` as the way to load an already-planned epic instead, and **file nothing**. Otherwise seed Phase 3 from the stub's body: the functional goal, the estimate, and the candidate story-group titles. Read `feature`/`feature_path` from the body's meta block. Skip the right-sizing gate — the stub was already sized ≤ M when it was decomposed. Record `PROMOTE = `. + + **Promotion is unchanged by discovery.** A stub filed from a discovery is promoted with no + manual edit: its body carries the decision gists in a `## Decisions this goal hangs on` + section, which seeds Phase 3 like the rest of the body and is then rewritten with it. + Promotion **neither reads nor moves** the marked gist comment — that comment is the copy that + survives the rewrite, and `/nxs.decision-record` is what consumes it later. + - `$ARGUMENTS` contains **`--discovery `** (string-matched, like `--from`) → **discovery + mode**. Record `DISCOVERY = ` and resolve it as follows: + + 1. Read `/discovery.md`. If it does not exist, ERROR — that is not a discovery + folder. If its `status` is `closed`, ERROR: a discovery closed with no build has nothing + to graduate. + 2. **Precondition — the discovery is finished.** Every `ticket-*.md` in the folder must have + `status: resolved`, and `## Not yet specified` must be empty. If either fails, stop and + report what is still open **by title**, pointing at `/nxs.discover --resume `. + File nothing. + 3. The discovery doc is the **intent**: its destination and its resolved-decisions index are + the capability description this run sizes. Read every ticket file too — the full + reasoning lives there, and it is copied onto what gets filed. + 4. Read `feature` / `feature_path` from the discovery doc's frontmatter. That is the + **default** feature for everything filed from this discovery, overridable per stub in + Phase 2b. + 5. **Skip the sharpness precondition** of the right-size phase — discovery is the thing that + precondition refers people to, so firing it here would deadlock the work discovery itself + produced. Then run the Phase 2 right-size gate **unchanged**. - **Anything else** → **intent mode**. The text is the capability description. A word that looks like a slug is intent, not a lookup key. **When a promoted stub proves oversized.** If Phase 3's rollup shows the stub cannot become a single epic, run the Phase 2 gate after all and emit fresh stub issues (Phase 2b) — then close the original **as not planned** with a comment naming its successors. Never close it as completed: nothing was delivered. @@ -149,8 +186,11 @@ gh issue close --reason "not planned" The container must exist before writing: the feature nav index (written at filing, Phase 6) links the epic issue from it (0006 §4). The draft records the feature it belongs to in its `feature`/`feature_path` frontmatter — carried onto the epic issue's meta block at filing, so the resolver recovers it. 1. **Promotion mode** → already resolved: the `feature_path` recorded in the stub issue's meta block. Create the directory if it does not exist (a stub writes nothing to the tree, so a feature whose first epic is a promotion has no container yet). Continue. -2. **Intent already inside a feature** → if the user referenced a `/features//` path or has a file open under one, use that feature. -3. **Otherwise infer and confirm once**: +2. **Discovery mode** → already resolved: the `feature_path` recorded in the discovery doc, confirmed + once when the discovery was started. Create the directory if it does not exist. A per-stub override + is offered in Phase 2b; nothing is re-confirmed here. +3. **Intent already inside a feature** → if the user referenced a `/features//` path or has a file open under one, use that feature. +4. **Otherwise infer and confirm once**: - Derive a feature **name** (Title Case) and **slug** (kebab-case) from the intent. - Let **``** be the resolved container: `/features/` (empty-prefix rule: `features/` on a repo-root hub). This exact string is what you record in `feature_path` and derive `README.md` from. - Present a single confirmation: _"I'll plan this under feature **** (`/`). Accept, or give a different name?"_ — one prompt, cheap. Accept the user's correction if any. @@ -162,6 +202,61 @@ Before generating any epic content, assess the scope yourself using the rubric b judgment step — read `/product/context.md` and `/system/stack.md` (the `` resolved in Phase 0) if present to calibrate against existing patterns. +### Sharpness precondition — is this intent underspecified? (intent mode only) + +**This runs before sizing**, because sizing an intent nobody can state is a guess dressed as a +measurement. It distinguishes two different problems that the size rubric alone cannot tell apart: + +- **Oversized** — big but clear. The split is knowable now. The L/XL/XXL path below handles it. +- **Underspecified** — foggy. The split itself hangs on decisions nobody has made, so pre-slicing it + into work-shaped stubs is speculative over-generation. + +**The test is the stub shape itself.** Attempt the decomposition below and ask: can **each** +functional goal be stated as a one-line goal, with an S or M estimate, and with candidate story +titles? If decomposition cannot produce that shape, the intent is **underspecified**, not merely +oversized. The test adds no new machinery, because that shape is already the output the decomposition +step must produce. + +**When does this fire.** Intent mode only. In **promotion mode** and **discovery mode** the gate does +**not** fire: a promoted stub was already discovered, and a consumed discovery is the output of the +very thing this gate refers people to, so firing there would deadlock the work discovery itself +produced. It lives inside this phase rather than as a phase of its own precisely so it inherits that +skip rule instead of needing its own exemption. + +**If the goals are sharp** — every one of them — this precondition adds **no interaction at all**. +Say nothing about it and continue to the sizing rubric below. A sharp, right-sized intent must see +exactly the run it sees today. + +**Sharpness gate (MANDATORY STOP).** If they are not, stop before sizing. Render the assessment, then +ask via `AskUserQuestion`: + +```markdown +## ⚠️ Underspecified — the split isn't knowable yet + +This intent cannot be decomposed into stated goals. What is missing is not size, it is decisions: + +| Functional goal (attempted) | What blocks stating it | +|---|---| +| … | … | + +Nexus answers this with discovery, not with stubs. `/nxs.discover` resolves the open decisions one +at a time and ends when every goal is sharp enough to file — then `/nxs.epic --discovery` files them. + +**Options** (asked via `AskUserQuestion` — see the interaction convention): + +| Option | Action | +|--------|--------| +| **discover** | (recommended) Stop here and run `/nxs.discover `. Nothing is filed. | +| **override** | Size and plan this intent anyway, on the existing path below. | +``` + +- **discover** → stop. **File nothing.** Report the command to run. +- **override** → continue to the sizing rubric below, unchanged. + +**Nothing is filed before the lead chooses.** The override exists because the sharpness call is a +judgement the lead owns: a hard refusal would make a false positive unrecoverable, leaving the lead +to reword the intent until the model relented. + ### Sizing rubric | Size | Duration | Characteristics | @@ -295,13 +390,43 @@ The `stubs` choice at the Phase 2 gate is the consent for this filing; nothing i - **estimate:** S | M - **candidate stories:** ; ; … - **source:** decomposition of "" () + + ## Decisions this goal hangs on + + ``` Each stub must be ≤ M. If the decomposer returns a sub-goal still > M, record `estimate: M` and say so in the goal line — it is re-decomposed when promoted. Write the goal line and every Meta value **unwrapped** — one line each, per "Line breaks" under the epic document structure. -3. **File the batch** through the shared filer, classified as an **epic** rather than a story: + In **discovery mode** the `feature` value defaults to the one recorded in the discovery doc; ask + for a per-stub override only where a goal plainly belongs to a different feature. The `source` + line reads `discovery of "" ()` instead of `decomposition of …`, and + it names the destination — **never a path into the discovery folder**, which is removed once the + discovery ends. + +3. **Copy the decision gists onto each stub (discovery mode only).** Each stub carries the resolved + decisions **that goal hangs on**, in **full gist form** — the decision **plus its reasoning**, + copied from the ticket files, not the one-line index entry: + + ```markdown + ### + + - **Decided:** + - **Why:** + - **Refuted alternative:** + ``` + + Each gist **names its originating ticket by title only**. Nothing durable may carry a path into + the discovery folder, because the folder is removed once the discovery ends and the link would + break at exactly the moment a reader needs it. A decision that more than one goal hangs on is + copied onto each of them in full; a reference to another stub is not sufficient. + + Hold this text as `GIST_BODY_` — step 5 writes **the same text**, unedited, a second time as + a comment. + +4. **File the batch** through the shared filer, classified as an **epic** rather than a story: ```bash python ./.claude/skills/nxs-gh-create-story/scripts/create_gh_issues.py "" \ @@ -317,10 +442,40 @@ The `stubs` choice at the Phase 2 gate is the consent for this filing; nothing i Discard the transient files only after a `✅ Complete` run. +5. **Post the marked gist comment (discovery mode only).** This is the **one** addition the discovery + entry mode makes to the emission path above — that path files issues and writes no comments. + For each stub, write `GIST_BODY_` — **the same text already in the body, unedited** — to a + scratch file under the marker, and post it: + + ```markdown + ## Decisions this goal hangs on + + + + + ``` + + ```bash + gh issue comment --body-file "/gist-.md" + ``` + + The gist is written twice because the two copies do different jobs. The **body** copy is the one + promotion consumes, because promotion seeds its draft from the stub's body. The **comment** copy + is the one that **survives**, because promotion rewrites that body wholesale — anything left only + in the body is destroyed at exactly the moment the reasoning matters most. The duplication cannot + drift, because **neither copy is ever edited again**. The hidden marker is what turns the + surviving copy from an archive into an input: `/nxs.decision-record` finds it by that marker when + it later designs the promoted epic. + Then **stop**. Report the created issue numbers with their goals, and tell the user to promote one with `/nxs.epic `. Do **not** create a queue entry, a feature `README.md`, or a full epic issue this run. +**In discovery mode**, also report that `` has been **consumed** and can now be removed, +and that removing it is a plain commit the user makes — this command does not delete the folder and +**commits nothing**, which is its contract on every path. Name the folder in the report only; never +write it into an issue body or a comment. + Close the report with the **cross-feature backlog query** — the whole backlog, this batch included, in one query. Ask for it rather than spelling the label out: @@ -331,7 +486,9 @@ python ./.claude/skills/nxs-gh-shared/delivery_config.py backlog-query ## Phase 3 — Generate the epic 1. Read `/product/context.md` if present — personas and strategy are canonical there. **Reference** them; do not re-tabulate. -2. Parse the capability description (or, in promotion mode, the stub's goal + candidate story titles): +2. Parse the capability description (in promotion mode, the stub's goal + candidate story titles; in + discovery mode, the discovery doc's destination plus the resolved decisions in full — the epic is + built **on top of** those decisions, not by re-deriving them): - Extract actors, goals, actions, data, constraints, business value. - Decompose into **3–8 user stories**, each independently deliverable (INVEST). - **Size each story `S` or `M`** (story-scale rubric) and **split any story that would exceed M** @@ -614,6 +771,19 @@ story becomes one GitHub issue, child of the epic issue. a per-feature backlog view would be a text search over issue bodies — brittle, and several links all meaning the same thing. The backlog is linked once, from `/features/README.md`. +6b. **Post the marked gist comment on the epic issue (discovery mode only).** A discovery that + right-sized to M or smaller is planned here as one epic and files **no stub**, so there is no + stub body to carry its reasoning. Write the resolved decisions onto the epic issue in the same + full gist form and under the same marker Phase 2b step 5 uses, so the reasoning still outlives + the folder and still reaches `/nxs.decision-record`: + + ```bash + gh issue comment --body-file "/gist-epic.md" + ``` + + Anything that must outlive a discovery is copied in full into a durable artifact; a reference is + never sufficient, because the folder is removed once the discovery ends. + 7. **Add the new feature to the features index** when Phase 1 created the container. Append a row to the table in `/features/README.md` linking `/README.md` and its one-line capability statement. An existing feature needs nothing here. @@ -630,8 +800,12 @@ Report: - Epic title, complexity rating, and story count (with `story_type` breakdown). - **In promotion mode**: that epic issue `#` is the same issue the stub was filed under — no second issue was created, and the unplanned label was removed. +- **In discovery mode**: that the discovery folder has been **consumed** and can be removed, and + that removing it is a plain commit the user makes. This command does not delete it. Name it in the + report only — never in an issue body or comment. - **Nothing committed to `.nexus/queue/`** — the epic lives on GitHub issues; the queue entry is - born at close. The `epic.md` draft stayed in session scratch. + born at close. The `epic.md` draft stayed in session scratch. This holds in discovery mode too: a + consumed discovery folder is left exactly as it was found. - **If the creation scripts printed "Seeded github config … — review and commit"** (STORY-121.07 write-back): a repo with no `github:` block had its resolved publishing decisions (classification mode, discovered project or `none`) persisted into `.nexus/config/settings.yml`. This is a diff --git a/.claude/commands/nxs.setup.md b/.claude/commands/nxs.setup.md index 23aba67..84c6cfd 100644 --- a/.claude/commands/nxs.setup.md +++ b/.claude/commands/nxs.setup.md @@ -230,8 +230,9 @@ Use `.nexus/config/templates/standard.template.md` for structural guidance; adap live in the hub manifest; this per-repo seed still only fills keys absent locally. 3. **`/delivery/lessons/`** — create the folder plus a `README.md` documenting the one-file-per-lesson convention (`-.md`, source-epic in frontmatter). This is the home `/nxs.close` writes process/delivery lessons to. 4. **`.nexus/queue/`** — this surface is **committed, not gitignored**. Do **not** add a `.nexus/` ignore rule for it. -5. **Templates** — do **not** seed `.nexus/config/templates/` here. The install/update script seeds the tool-agnostic templates; setup only seeds project-generated config (above). -6. **Decision scratch is committed under `.nexus/queue/`, not gitignored.** Do **not** add a +5. **`.nexus/discovery/`** — the pre-epic discovery store (`/nxs.discover`), also **committed, not gitignored**. It is deliberately a sibling of the queue rather than a folder inside it: the queue holds only closed, drainable entries, and a discovery is never closed and never drained. Do not create it at setup — `/nxs.discover` creates it on first use — and do not add an ignore rule for it. +6. **Templates** — do **not** seed `.nexus/config/templates/` here. The install/update script seeds the tool-agnostic templates; setup only seeds project-generated config (above). +7. **Decision scratch is committed under `.nexus/queue/`, not gitignored.** Do **not** add a committed-path ignore for `.nexus/queue/**` — the whole point is that the per-user decision scratch (`.nexus/queue/epic-//`) rides ordinary commits. Keep any pre-existing `.nexus/plans/` line as **retired** (it covers residual local scratch during migration); diff --git a/.nexus/queue/epic-228/sameera/decisions-feat-pre-epic-discovery-228.md b/.nexus/queue/epic-228/sameera/decisions-feat-pre-epic-discovery-228.md new file mode 100644 index 0000000..7c06ca4 --- /dev/null +++ b/.nexus/queue/epic-228/sameera/decisions-feat-pre-epic-discovery-228.md @@ -0,0 +1,56 @@ +## 2026-08-08 — Discovery store filenames and the unique key's shape + +- **Choice:** The discovery doc is `discovery.md`; a ticket is `ticket--.md`; the folder key is 8 lowercase hex characters. +- **Why:** `discovery.md` is a name no stage scans for, the numbered ticket prefix gives a stable listing order without encoding dependency order, and 8 hex matches the key shape the existing queue entries already use. +- **Refuted alternative:** Name the doc after the slug (`.md`) so the folder reads self-describing — rejected because every session would have to derive the doc's name from the folder's name before it could read it. + +## 2026-08-08 — Staleness threshold for a ticket claim is 24 hours + +- **Choice:** A claim on an unresolved ticket may be taken over once it is older than 24 hours. +- **Why:** A claim has to outlive one working session so a parallel same-machine session cannot steal an in-flight ticket, and has to expire fast enough that a claim arriving in someone else's pull does not park the ticket for a week. +- **Refuted alternative:** No fixed threshold, deciding staleness by asking the user each time — rejected because the takeover is the one path a session must be able to take unattended. + +## 2026-08-08 — The direct-plan path also gets the marked gist comment + +- **Choice:** When a discovery right-sizes to M or smaller and is planned as one epic, `/nxs.epic` posts the same marked gist comment on the epic issue that the stub path posts on each stub. +- **Why:** The record's invariant 5 requires anything outliving a discovery to be copied in full into a durable artifact, and on this path there is no stub body to carry it — the folder is removed and the reasoning would be lost outright. +- **Refuted alternative:** Read the acceptance criterion literally and write gists only onto stubs — rejected because it silently drops every decision of a small discovery on the floor. + +## 2026-08-09 — The no-build lessons note carries full gists, not the index verbatim + +- **Choice:** Phase C1 writes one full gist per index line — Decided, Why, refuted alternative — and drops the index line's `Detail:` clause, instead of copying the resolved-decisions index verbatim. +- **Why:** The index line is a gist that leans on a ticket file to hold the reasoning, and the same commit removes every ticket file, so a verbatim copy would ship the only durable artifact on the no-build path with pointers that are dead on arrival (record invariants 4 and 24). +- **Refuted alternative:** Copy the index verbatim and merely strip the `Detail:` clause — rejected because it satisfies the invariant while still losing the reasoning outright; the graduation path copies full gists onto stubs for this same reason, and the no-build path has less carrying it, not more. + +## 2026-08-08 — The component fingerprint pin rides the last commit that touches `.claude/` + +- **Choice:** `libs/portable-tools/bundle-fingerprint.json` is re-pinned once, in the final story commit that changes `.claude/commands/`, rather than once per story commit. +- **Why:** The pin is a derived hash of the whole component tree, so a per-commit re-pin would rewrite the same line five times and conflict on every replay, while buying nothing — only the branch tip is ever vendored. +- **Refuted alternative:** Re-pin in each story commit so every commit passes the parity test on its own — rejected as churn on a single derived line that no consumer reads at an intermediate commit. + +## 2026-08-09 — `verify.ts`'s GitHub-file-list cross-check keeps excluding only the queue + +- **Choice:** `changedFileSet` (the git-diff-based helper) now excludes `.nexus/discovery` alongside + `.nexus/queue`, matching the distiller's own exclusion (record #235 invariant 2). `prChangedFiles` + and `verifyRange`'s `ghFileSetsEqual` cross-check — which filter GitHub's own `--json files` list by + string prefix, not git pathspec — still filter only `.nexus/queue`. +- **Why:** The analyze-pass finding named only the git-diff exclusion list (`range.ts`, `verify.ts`'s + `changedFileSet`, `range.spec.ts`) as load-bearing; the GitHub-file-list filters are a separate + mechanism serving a cross-check, not the distiller-facing gate itself, and extending them was outside + the finding's stated scope. +- **Refuted alternative:** Extend the same `.nexus/discovery` filter to `prChangedFiles` and + `ghFileSetsEqual` for consistency — a real PR touching `.nexus/discovery` would otherwise fail the + optional GitHub cross-check even though the range itself is now correctly derived. Left as a follow-up + rather than folded into this fix, since it widens the diff beyond the named finding. + +## 2026-08-09 — Sharpness sub-gate restates MANDATORY-STOP as an inline sub-gate label, not a heading suffix + +- **Choice:** Added `**Sharpness gate (MANDATORY STOP).**` as the lead-in to the stop sentence inside + `### Sharpness precondition`, mirroring Phase 5's `**Open questions gate (MANDATORY STOP).**` + sub-gate label, rather than appending `(MANDATORY STOP)` to the `###` heading itself. +- **Why:** The heading covers both the sharp-goals (no-stop) and underspecified (stop) branches; only + the second is a mandatory stop, so marking the heading would misstate the sharp-goals branch. The + inline sub-gate label is the file's existing convention for a conditional stop nested inside an + already-marked phase (Phase 5 heading + Open-questions sub-gate label). +- **Refuted alternative:** Append `(MANDATORY STOP)` to the `### Sharpness precondition` heading — + rejected because it would fire on every intent-mode run, not only the underspecified branch. diff --git a/.nexus/queue/epic-228/sameera/notes-feat-pre-epic-discovery-228.md b/.nexus/queue/epic-228/sameera/notes-feat-pre-epic-discovery-228.md new file mode 100644 index 0000000..09afa97 --- /dev/null +++ b/.nexus/queue/epic-228/sameera/notes-feat-pre-epic-discovery-228.md @@ -0,0 +1,155 @@ +# Analyze-pass walkthroughs — epic #228 + +Record #235 Risks 4 and 5 make five manual walkthroughs a required mitigation at the analyze pass, +because the three changed commands are prompt documents with no automated coverage. The +2026-08-08 analyze receipt found no evidence that any of them ran. This file is that evidence. + +Run 2026-08-09 against `feat/pre-epic-discovery-228`. The fixture and the verifier live in the +session scratch directory, not in the tree — nothing here is a committed test. + +## (a) A foggy intent — the gate fires + +Intent: "Make Nexus usable by a team whose issues do not live in GitHub." + +Applied the gate's own test: attempt the decomposition, and ask whether **each** functional goal can +be stated as a one-line goal with an S or M estimate and candidate story titles. + +| Functional goal (attempted) | What blocks stating it | +|---|---| +| A stage files a work item through a provider other than GitHub | Nothing has decided where the provider seam sits — the `gh` call sites, the publishing skills, or a new adapter surface. Each is a different set of stories. | +| Ordering between work items survives on a provider with no dependency edges | Undecided whether ordering degrades to a body convention or rules that provider out. | +| The record hash keeps working when the record is not an issue | Approval is the close of the record sub-issue today. Nothing has decided what the record's durable home and approval act are without one. | + +Not one goal reaches the stub shape, so the intent is underspecified rather than oversized. Expected +behaviour: stop before sizing, render the `⚠️ Underspecified` block, ask discover-or-override through +`AskUserQuestion`, file nothing. That is what the gate text at `nxs.epic.md` Phase 2 specifies, and it +is the behaviour the walkthrough produced. **Pass.** + +## (b) A big-but-clear intent — the gate stays silent and the oversized split still runs + +Intent: "Add a `--dry-run` flag to every Nexus command that writes: discover, epic, decision-record, +close, distill." + +| Functional goal | Estimate | Candidate stories | +|---|---|---| +| `/nxs.discover` honours `--dry-run` | M | Start reports the folder it would create; resume reports the resolution it would append; close reports the note and the removal | +| `/nxs.epic` honours `--dry-run` | M | The digest renders; the issue bodies render; nothing is filed | +| `/nxs.decision-record` honours `--dry-run` | S | The record body renders; no sub-issue is filed | +| `/nxs.close` honours `--dry-run` | M | The close record renders; the epic issue is not commented or closed | +| `/nxs.distill` honours `--dry-run` | M | Per-concept deltas render; no distillation pull request opens | + +Every goal reaches the stub shape, so the sharpness precondition adds no interaction and says nothing. +Sizing then runs unchanged: five command surfaces is XL, which is the existing decomposition path, and +it emits five stubs. Big but clear still splits. **Pass.** + +## (c) A sharp, right-sized intent — the run is unchanged + +Intent: "Add `--json` to `nexus workspace status` so a script can read the workspace read-out." + +One functional goal, estimate S, candidate stories: the read-out emits machine-readable output under +the flag; human output is unchanged without it. Sharp, so the precondition adds no interaction at all, +and sizing sends it straight to Phase 3. A sharp intent sees exactly the run it saw before this epic. +**Pass.** + +## (d) Graduation from a real discovery folder — executed with artifacts + +Built a real finished discovery in the scratch fixture: `discover-agent-run-history-7f3ac1b2`, with a +discovery doc and three resolved tickets (council, task, interview), an empty "Not yet specified", and +one out-of-scope entry. Then drove `/nxs.epic --discovery` through Phase 0 discovery mode, Phase 1, and +Phase 2b, emitting two real stub work-items with their gists and the two marked comment bodies. A +verifier checked the result against the contracts rather than against my reading of them. + +30 of 30 checks passed, covering: + +- **Preconditions** — doc status open, every ticket resolved, "Not yet specified" empty, feature path + readable, index carrying exactly one line per ticket. +- **Filer contract** — every stub parses through the filer's own `parse_frontmatter`, carries + `ref`/`title`/`labels`, carries **no** `parent` key, carries only the resolved unplanned label + (`backlog`, resolved live from `delivery_config.py`), estimates S or M, and sources itself to the + destination rather than to a folder path. +- **Gist fidelity** — each gist in a stub body is byte-identical to the Decided / Why / Refuted lines + of the ticket it came from, so nothing was paraphrased on the way out. +- **Invariants 4 and 24** — no stub carries `.nexus/discovery`, no stub carries a bare + `ticket--.md` filename, and every gist heading is a ticket **title**. +- **The surviving copy** — each comment body is the stub's gist section unedited plus the marker. + +The run stopped before the filer. Nothing was filed, and no GitHub write of any kind was made. + +## (e) The full path — executed live on 2026-08-09 (see (f)) + +The mechanical half ran. Built a four-comment payload — two marked gist comments and two ordinary +discussion comments — and ran the reader's exact query from `nxs.decision-record.md` Phase 0.4 +against it. It selected the two marked comments and dropped the discussion. The marker string the +writer emits (`nxs.epic.md` Phase 2b step 5 and Phase 6b) is the same string the reader selects on; +the verifier asserts that against both files rather than trusting a copy-paste. So the handoff from +graduation to the record is verified end to end **as a mechanism**. + +**The live half is no longer outstanding.** It ran on 2026-08-09 against a throwaway hosted +repository, the way `nxs-pr-acceptance` does it for the `--pr` flow. Section (f) records it. + +## (f) The live run — AC6, executed against real GitHub on 2026-08-09 + +Provisioned `sameera/nexus-pr-acceptance-scratch` through the `nxs-pr-acceptance` harness, carrying +the toolchain tree at `ca50c17`, and committed a finished discovery into the clone: +`discover-agent-run-history-7f3ac1b2`, three resolved tickets (council, task, interview), empty +"Not yet specified". The repository was deleted at teardown; the numbers below no longer resolve and +are recorded for the audit trail, not as links. + +**Graduation.** `/nxs.epic --discovery` sized the destination L, took the stubs path, and filed two +stub issues — one per functional goal. Both carried the gists in the body and the same text again as +a comment closed by the marker. Verified against the ticket files by diff, not by eye: the +Decided / Why / Refuted lines are byte-identical. Neither stub was a sub-issue of anything, both +carried the resolved unplanned label, and neither body nor comment contained `.nexus/discovery` or a +bare ticket filename. The decision both goals hang on was copied onto both stubs in full, which is +what Phase 2b step 3 requires. + +**Promotion.** Promoting the first stub rewrote that issue's body wholesale into a planned epic with +three story sub-issues. The gists were gone from the body; the marked comment survived with +`updatedAt` still null, so GitHub itself never saw an edit. That is the double-write earning its +keep, measured rather than argued. + +**The record.** `/nxs.decision-record` ran against the promoted epic. Phase 0.4's marker query +selected exactly one comment out of the issue's real comment set. The filed record was designed on +top of the settled decisions rather than re-deriving them — its invariants restate them as +constraints ("exactly one record exists per top-level invocation…", "the write path performs no +network calls of any kind…"). + +**Why the first pass was not enough.** The architect the record delegates to read the discovery +folder off disk, which still existed. The record then cited a decision whose gist was never on that +epic's comment. Nothing was contradicted, but the provenance was confounded — the run could not +prove the comment carried the reasoning, because the folder was also available. + +**The clean pass.** Removed the discovery folder from the clone and committed the removal, which is +the real lifecycle anyway, then promoted the second stub and ran the record against it under an +explicit prohibition on recovering the folder from git history, restated in the architect's own +prompt. That record carries the second stub's own decision as a constraint — "the history root +always resolves to the list. No path, redirect, or default selection may land a maintainer on a +run's detail first" — and a grep for the other stub's gist vocabulary returns nothing. With the +folder gone, the marked comment is the only path that text could have travelled. **AC6 is met**, and +success metric 4 moves from NOT MEASURED to measured. + +**Findings the run produced, none of them fixed here.** + +1. The architect reads beyond what Phase 0.4 hands it. In the first pass it read the discovery + folder directly. In a real run the folder is usually gone by then, so this rarely bites — but the + input discipline is a genuine gap, and it is the reason the second pass exists. Worth its own + issue. +2. `/nxs.epic` Phase 6 leaves the feature nav index dirty in the working tree with no commit step + named at that point. Both live runs hit it. It is consistent with "nothing is committed at + planning", so it may be intended — but the command document does not say so, and a reader cannot + tell intent from omission. +3. The command documents invoke a bare `tsx`. It was not on `PATH` in either run; both fell back to + `./node_modules/.bin/tsx`. Environment gap, not a contract defect. + +The one thing a live run still cannot buy: both approval gates were resolved from pre-authorized +operator answers rather than a human clicking through `AskUserQuestion`, because the runs were +scripted. The gates fired and stopped where the documents say they do; what went unexercised is the +UI, not the control flow. + +## Observation, not a defect + +The stub body's meta block writes the feature **path** under a key named `feature`, while promotion +(Phase 0) says it reads `feature`/`feature_path` from that block. The two names refer to one value. +This predates discovery — the decomposition path writes the same template — and discovery inherits it +unchanged, which is exactly why a discovery stub promotes with no manual edit. Worth tidying on its +own, not here. diff --git a/CLAUDE.md b/CLAUDE.md index 51070f0..ab35a26 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,9 @@ **Core Philosophy:** Generation is cheap. Judgment is not. Nexus's failure mode to guard against is speculative over-generation: heavy artifacts (sprawling HLDs, per-task plans, prose reports) produced ahead of validated scope, burying the human decisions that matter. The rule: every artifact must force a human decision, or it gets cut. -**Pipeline:** `setup → epic → hld → analyze → close`, and then the distiller drains the closed queue into the concept store. In the PR-driven flow the lead runs `analyze --pr` against the (possibly open) PR, merges, then runs `close --pr` and `distill` post-merge in one shared worktree. The user story, not the technical task, is the terminal planning unit and the GitHub-issue granularity — Nexus stops decomposing once a story is small enough to ship and verify on its own. Implementation itself stays the engineer's job — Nexus plans and gates the work, it does not write the code. +**Pipeline:** `setup → (discover when foggy) → epic → decision-record → analyze → close → distill`. Implementation sits between the decision record and analyze and belongs to engineers, and the distiller drains the closed queue into the concept store. In the PR-driven flow the lead runs `analyze --pr` against the (possibly open) PR, merges, then runs `close --pr` and `distill` post-merge in one shared worktree. The user story, not the technical task, is the terminal planning unit and the GitHub-issue granularity — Nexus stops decomposing once a story is small enough to ship and verify on its own. Implementation itself stays the engineer's job — Nexus plans and gates the work, it does not write the code. + +**Discovery (`/nxs.discover`)** is the pre-epic stage, and it runs only when an initiative is *underspecified* — foggy, so that the split itself hangs on unmade decisions. That is distinct from *oversized* — big but clear, which `/nxs.epic` still decomposes into backlog stubs. Its durable contract: discovery is a multi-session loop, its unit is the decision ticket (a question whose resolution is a decision, never a slice of build work), its output is functional goals that `/nxs.epic` files, and a discovery is shared by ordinary git operations. It writes nothing to GitHub — the issues appear when `/nxs.epic --discovery` consumes the finished discovery. *First iteration:* the store is a committed folder under `.nexus/discovery/` holding a discovery doc and one file per ticket. ## Code Conventions diff --git a/README.md b/README.md index 7b2a02d..8891b2b 100644 --- a/README.md +++ b/README.md @@ -66,33 +66,44 @@ That division of labor is deliberate: ## The Pipeline ``` -setup → epic → decision record → (implementation) → analyze → close → distill +setup → (discover when foggy) → epic → decision-record → analyze → close → distill ``` +Implementation sits between the decision record and analyze; engineers own it. + 1. **Setup** (`/nxs.setup`) - One-time bootstrap: detect the stack, generate the system standards, interview for product context. - Five questions, not fifty. Judgment applied once, up front. -2. **Epic** (`/nxs.epic`) +2. **Discover** (`/nxs.discover`) — only when the initiative is foggy + - **Oversized is not underspecified.** Big but clear is `/nxs.epic`'s job: it decomposes the scope into backlog stubs. Foggy is different — the split itself hangs on decisions nobody has made, so slicing it into work-shaped stubs would be a guess. + - A **multi-session loop**. Its unit is the **decision ticket**: a question whose resolution is a decision, never a slice of build work. One decision is resolved per session. + - Its output is **functional goals sharp enough for `/nxs.epic` to file** — a one-line goal, a small estimate, candidate story titles. That is the whole destination; nothing else ends a discovery. + - A discovery can be **shared by ordinary git operations**. Push it to a fork, hand it to the domain expert who can answer a question, pull their resolution back. No review gate, no approval command, no rule about who may work it. + - **Discovery writes nothing to GitHub.** No issue, no comment, no label, at any point. The issues appear when `/nxs.epic` consumes the finished discovery, through the same emission path every other stub goes through. + - *First iteration:* the store is a committed folder under `.nexus/discovery/`, holding a discovery doc and one file per ticket. + +3. **Epic** (`/nxs.epic`) - Natural-language intent in; a right-sized epic with user stories and acceptance criteria out. - Approval happens at a decision-grade digest — you approve the decisions, not a wall of prose. - On approval, the epic and one issue per story are filed together. Oversized scope is cut into backlog stubs instead of inflating the epic. + - If the intent turns out to be underspecified rather than oversized, `/nxs.epic` stops and refers you to `/nxs.discover` instead of filing work-shaped stubs. -3. **Decision record** (`/nxs.decision-record`) +4. **Decision record** (`/nxs.decision-record`) - The focused "why": key decisions with refuted alternatives, invariants, risks. Tiered by complexity — a simple epic gets a short record. - Filed as a sub-issue of the epic. Approval is closing that issue. No shadow copies. -4. **Implementation** - engineers build the stories. Nexus stays out of the way. +5. **Implementation** - engineers build the stories. Nexus stays out of the way. -5. **Analyze** (`/nxs.analyze`) +6. **Analyze** (`/nxs.analyze`) - The conformance gate: does the build do what the planning said? - Checks the implemented code against acceptance criteria, success metrics, and the decision record's invariants — and refuses to run against an unapproved record. -6. **Close** (`/nxs.close`) +7. **Close** (`/nxs.close`) - A human-prose close record: what was decided, what deviated and why, what was deferred. - Deferred scope becomes backlog issues, not a forgotten section in a document. The epic issue gets a durable close comment and is closed. -7. **Distill** (`/nxs.distill`) +8. **Distill** (`/nxs.distill`) - Drains closed epics into the concept store via a reviewed PR. - Per-concept pages are updated with the epic's validated decisions; the temporary planning artifacts are deleted. The record that survives is the one you'll actually read. diff --git a/how-to-nexus.md b/how-to-nexus.md index 8bfc04e..54cc22b 100644 --- a/how-to-nexus.md +++ b/how-to-nexus.md @@ -24,14 +24,15 @@ The long-term memory is the **concept store**: one distilled page per concept, h ## The Pipeline in Plain English ``` -setup → epic → decision record → (implementation) → analyze → close → distill +setup → (discover when foggy) → epic → decision-record → analyze → close → distill ``` -The planning and gating stages are run by product / the lead. Implementation in the middle belongs to engineers. Each stage is a separate conversation, and that separation is the feature: you look at fewer decisions at a time. +The planning and gating stages are run by product / the lead. Implementation sits between the decision record and analyze and belongs to engineers. Each stage is a separate conversation, and that separation is the feature: you look at fewer decisions at a time. | Stage | Command | The human decision it forces | | ----- | ------- | ---------------------------- | | Setup | `/nxs.setup` | What is this product, and what are its standards? (once) | +| Discover | `/nxs.discover` | What has to be decided before this can even be scoped? (only when foggy) | | Epic | `/nxs.epic` | Is this the right scope, cut into the right stories? | | Decision record | `/nxs.decision-record` | Can we live with this design? | | Analyze | `/nxs.analyze` | Does the build match what we promised? | @@ -44,6 +45,25 @@ The planning and gating stages are run by product / the lead. Implementation in One-time project bootstrap. It auto-detects the stack, generates the system docs and standards, then runs a short interview — at most five strategic questions — to build the product context. Judgment applied once, up front, so every later stage has something real to ground in. +## Step 0.5: `/nxs.discover` — Only When the Initiative Is Foggy + +Skip this stage unless you need it. Most intent goes straight to `/nxs.epic`. + +You need it when the initiative is **underspecified**, which is not the same as **oversized**: + +- **Oversized** — big but clear. You could list the goals today; there are just too many for one epic. `/nxs.epic` handles this: it cuts the scope into backlog stubs. +- **Underspecified** — foggy. The split itself hangs on decisions nobody has made. Slicing it into work-shaped stubs now would be a guess dressed up as a plan. + +Discovery is a **multi-session loop**, and its unit is the **decision ticket** — a question whose resolution is a decision, never a slice of build work. Each session claims one open ticket, resolves it through the machinery Nexus already has (research agents, a PM interview, a council on a contested trade-off), records the resolution, and stops. One decision per session, one commit per decision, so the commit history reads as the decision history. + +The loop ends at one destination and no other: **every functional goal is sharp enough for `/nxs.epic` to file** — a one-line goal, a small estimate, candidate story titles. A suspicion you can't yet phrase as a question waits in "Not yet specified" until some resolution makes it statable. Work ruled beyond the destination goes to "Out of scope" and never comes back. + +A discovery is **shared by ordinary git operations**. Push it to a fork, hand it to the domain expert who can actually answer question three, pull their resolution back. There is no review gate, no approval command, and no rule about who may start, resume, or graduate one. That matters because discovery is the stage most likely to need more than one person. + +**Discovery writes nothing to GitHub** — no issue, no comment, no label, at any point in its life. The issues appear when `/nxs.epic --discovery ` consumes the finished discovery and files its goals through the same path every other stub goes through. A discovery whose resolutions conclude that *no build follows* ends with `/nxs.discover --close`, which writes a dated lessons note and removes the folder in one commit. + +*First iteration:* the store is a committed folder under `.nexus/discovery/`, holding a discovery doc and one file per decision ticket. + ## Step 1: `/nxs.epic` — Stop and Define the Problem You bring natural-language intent: "We need audit logging for compliance." No feature brief required. @@ -55,6 +75,7 @@ The output is a right-sized epic with user stories and acceptance criteria. Appr Two things keep the epic honest: - **Oversized scope gets cut, not carried.** Anything that doesn't belong in this epic becomes a backlog stub issue instead of inflating the scope. +- **Underspecified scope gets referred, not sliced.** Before it sizes anything, `/nxs.epic` checks whether each functional goal can actually be stated. If they can't, it stops, recommends `/nxs.discover`, and files nothing — with an override, because that call is yours. - **Nothing is committed to the repo at planning.** The issues are the source of truth. Any stage that later needs the epic as a file reconstructs it deterministically from the issue number. ### The backlog is one query diff --git a/libs/portable-tools/bundle-fingerprint.json b/libs/portable-tools/bundle-fingerprint.json index 23e91d0..8a0c0d0 100644 --- a/libs/portable-tools/bundle-fingerprint.json +++ b/libs/portable-tools/bundle-fingerprint.json @@ -1,9 +1,9 @@ { "generate-atlas.mjs": "ddbfc4a1823a1c16267994876e61bc75e88d0211f42070b1b0e8c3a56067a478", "validate-concepts.mjs": "89868118965bf0b67e8bdb585e92eb797eb1bf9dca4937a6d5841efeb19e9166", - "derive-entry-diff.mjs": "37a8e1055fbb9fddfaf4595f8860f207dd6c90358c70281e05e740edbf400943", + "derive-entry-diff.mjs": "1b25a8ac45d2a75c5a2d29f89a7b8f400d3264567c481549edad352ae3a9a97f", "drift-advisory.mjs": "f36973b70e4c603879fb0965adb4e4840bcb1dc23ed4b7affd4eeb11d2320710", "seed-registry.mjs": "89b40214b6cfad560bb9475d5a38c454216d953e9e79a99f9e4d59f11b25f3ce", "nexus.mjs": "134f97e0e44d85db89b5c481720ca22f1645f196b572a7dfb12f0bef343f13d9", - "claude-components": "d052fe62f078f0e787b5f9a08a186c9dd2b198dc4aa9f83eaf387f2064cc8036" + "claude-components": "3b8107cd448a5b390e054779fc3494bbd479802d001597cf0856639b0a47ce7d" } diff --git a/libs/portable-tools/src/derive-entry-diff.spec.ts b/libs/portable-tools/src/derive-entry-diff.spec.ts index d2de84f..ede9495 100644 --- a/libs/portable-tools/src/derive-entry-diff.spec.ts +++ b/libs/portable-tools/src/derive-entry-diff.spec.ts @@ -63,6 +63,8 @@ function buildHubFixture(parent: string): HubFixture { const webBase = commitAll(webRoot, "base"); write(webRoot, "src/app.ts", "export const v = 2;\n"); write(webRoot, ".nexus/queue/some-entry/epic.md", "# queued epic\n"); // must be excluded + // Ungated discovery reasoning riding the same branch as the code — must be excluded too. + write(webRoot, ".nexus/discovery/foggy-thing-ab12cd34/ticket-01-pick-a-store.md", "# undecided reasoning\n"); const webHead = commitAll(webRoot, "head"); const apiRoot = path.join(parent, "api"); @@ -86,6 +88,19 @@ function writeEntry(hubRoot: string, items: Array<{ repo: string; base: string; } describe("deriveEntryDiff — happy paths", () => { + it("excludes .nexus/discovery from the emitted diff (record #235, invariant 2)", () => { + const parent = makeParent(); + const { hubRoot, web } = buildHubFixture(parent); + const entryDir = writeEntry(hubRoot, [{ repo: "github.com/acme/web-app", base: web.base, head: web.head }]); + + const result = deriveEntryDiff(entryDir, hubRoot); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.diffs[0].diff).toContain("src/app.ts"); + expect(result.diffs[0].diff).not.toContain(".nexus/discovery"); + expect(result.diffs[0].diff).not.toContain("undecided reasoning"); + }); + it("AC1: single-repo range, identical diff, queue excluded", () => { const parent = makeParent(); const { hubRoot, web } = buildHubFixture(parent); @@ -98,7 +113,7 @@ describe("deriveEntryDiff — happy paths", () => { expect(result.diffs[0].repo).toBe("github.com/acme/web-app"); expect(result.diffs[0].checkout).toBe(web.root); - const expectedDiff = execFileSync("git", ["diff", `${web.base}...${web.head}`, "--", ".", ":(exclude).nexus/queue"], { cwd: web.root, encoding: "utf8" }); + const expectedDiff = execFileSync("git", ["diff", `${web.base}...${web.head}`, "--", ".", ":(exclude).nexus/queue", ":(exclude).nexus/discovery"], { cwd: web.root, encoding: "utf8" }); expect(result.diffs[0].diff).toBe(expectedDiff); expect(result.diffs[0].diff).toContain("src/app.ts"); expect(result.diffs[0].diff).not.toContain(".nexus/queue"); diff --git a/libs/portable-tools/src/derive-entry-diff.ts b/libs/portable-tools/src/derive-entry-diff.ts index 413ec70..78d3b41 100644 --- a/libs/portable-tools/src/derive-entry-diff.ts +++ b/libs/portable-tools/src/derive-entry-diff.ts @@ -6,7 +6,9 @@ * recorded SHAs resolve only inside the named member's sibling checkout. This tool resolves each * range item to its checkout via the workspace resolver (never re-deriving workspace shape), * verifies both recorded SHAs are reachable, and emits one diff per repo with `.nexus/queue/**` - * excluded. All items must resolve before any diff is emitted — a missing checkout, an + * and `.nexus/discovery/**` excluded — the queue entry's own artifacts are input rather than the + * *what*, and a discovery folder holds ungated in-flight reasoning no human gate has passed + * (record #235, invariant 2). All items must resolve before any diff is emitted — a missing checkout, an * unreachable SHA, or a missing/malformed stamp is a hard per-entry error. It never falls back * to the hub, never fabricates an empty or partial diff, and never clones, fetches, or * writes — it reads only. @@ -155,10 +157,10 @@ export function deriveEntryDiff(entryDir: string, hubDir: string, run: Runner = } if (errors.length > 0) return { ok: false, errors }; - // Pass 2 — emit one diff per repo, queue folder excluded, read-only. + // Pass 2 — emit one diff per repo, queue + discovery folders excluded, read-only. const diffs: RepoDiff[] = []; for (const item of plan) { - const r = run("git", ["diff", `${item.base}...${item.head}`, "--", ".", ":(exclude).nexus/queue"], { cwd: item.checkout }); + const r = run("git", ["diff", `${item.base}...${item.head}`, "--", ".", ":(exclude).nexus/queue", ":(exclude).nexus/discovery"], { cwd: item.checkout }); if (r.status !== 0) { errors.push({ entry, problem: "git-diff-failed", message: `git diff ${item.base}...${item.head} failed in ${item.checkout}: ${r.stderr.trim()}` }); diff --git a/libs/pr-acceptance/src/verify.spec.ts b/libs/pr-acceptance/src/verify.spec.ts index ea910d5..0d22479 100644 --- a/libs/pr-acceptance/src/verify.spec.ts +++ b/libs/pr-acceptance/src/verify.spec.ts @@ -58,6 +58,17 @@ describe("changedFileSet", () => { const s = squashed(); expect(changedFileSet(defaultRunner, s.repo, "0".repeat(40), s.mergeCommit)).toBeNull(); }); + + it("excludes the discovery path, which the distiller's own diff also excludes (record #235, invariant 2)", () => { + const s = squashed(); + sh(s.repo, "git", "checkout", "-q", "-b", "tmp2"); + fs.mkdirSync(path.join(s.repo, ".nexus/discovery/foggy-thing-ab12cd34"), { recursive: true }); + fs.writeFileSync(path.join(s.repo, ".nexus/discovery/foggy-thing-ab12cd34/ticket-01-x.md"), "x\n"); + fs.writeFileSync(path.join(s.repo, "docs/f3.md"), "f3\n"); + const tip = writeCommit(s.repo, "docs/f4.md", "f4\n", "more"); + const set = changedFileSet(defaultRunner, s.repo, s.mergeCommit, tip); + expect(set).toEqual(["docs/f3.md", "docs/f4.md"]); + }); }); describe("verifyRange", () => { diff --git a/libs/pr-acceptance/src/verify.ts b/libs/pr-acceptance/src/verify.ts index 6a89438..2cd45c2 100644 --- a/libs/pr-acceptance/src/verify.ts +++ b/libs/pr-acceptance/src/verify.ts @@ -21,9 +21,9 @@ import { type Runner, git } from "./run.js"; const FULL_SHA = /^[0-9a-f]{40}$/; -/** The exact three-dot diff the distiller recomputes later, queue excluded. */ +/** The exact three-dot diff the distiller recomputes later, queue and discovery excluded. */ export function changedFileSet(run: Runner, cwd: string, base: string, head: string): string[] | null { - const out = git(run, cwd, "diff", "--name-only", `${base}...${head}`, "--", ".", ":(exclude).nexus/queue"); + const out = git(run, cwd, "diff", "--name-only", `${base}...${head}`, "--", ".", ":(exclude).nexus/queue", ":(exclude).nexus/discovery"); if (out === null) return null; return [...new Set(out.split("\n").map((l) => l.trim()).filter((l) => l.length > 0))].sort(); } diff --git a/libs/pr-worktree/src/range.spec.ts b/libs/pr-worktree/src/range.spec.ts index 14e6470..153f721 100644 --- a/libs/pr-worktree/src/range.spec.ts +++ b/libs/pr-worktree/src/range.spec.ts @@ -1,9 +1,10 @@ import * as fs from "node:fs"; +import * as path from "node:path"; import { afterAll, describe, expect, it } from "vitest"; import { deriveRange } from "./range.js"; import { type PrInfo } from "./pr.js"; import { defaultRunner, git } from "./run.js"; -import { buildMergeCommit, buildRebase, buildSquash, makeParent, type Topology } from "./git-fixtures.js"; +import { buildMergeCommit, buildRebase, buildSquash, initRepo, makeParent, sh, writeCommit, type Topology } from "./git-fixtures.js"; const tracked: string[] = []; afterAll(() => { @@ -27,7 +28,7 @@ function prFor(t: Topology): PrInfo { } function changedFiles(repo: string, base: string, head: string): string[] { - const out = git(defaultRunner, repo, "diff", "--name-only", `${base}...${head}`, "--", ".", ":(exclude).nexus/queue") ?? ""; + const out = git(defaultRunner, repo, "diff", "--name-only", `${base}...${head}`, "--", ".", ":(exclude).nexus/queue", ":(exclude).nexus/discovery") ?? ""; return out.split("\n").filter(Boolean).sort(); } @@ -78,4 +79,27 @@ describe("deriveRange", () => { if (r.ok) return; expect(r.error.problem).toBe("pr-no-merge-commit"); }); + + it("treats a range whose only non-queue content is under .nexus/discovery as an empty diff (record #235, invariant 2)", () => { + const parent = makeParent(tracked); + const repo = path.join(parent, "discovery-only-repo"); + initRepo(repo); + const c0 = writeCommit(repo, "base.txt", "base\n", "C0"); + sh(repo, "git", "checkout", "-q", "-b", "feature", c0); + const prHead = writeCommit( + repo, + ".nexus/discovery/foggy-thing-ab12cd34/ticket-01-pick-a-store.md", + "# undecided reasoning\n", + "F1", + ); + sh(repo, "git", "checkout", "-q", "main"); + sh(repo, "git", "merge", "--no-ff", "-q", "-m", "Merge feature", "feature"); + const mergeCommit = sh(repo, "git", "rev-parse", "HEAD"); + const t: Topology = { repo, mergeCommit, prHead, baseRefOid: c0, prCommitCount: 1, expectedFiles: [] }; + + const r = deriveRange(defaultRunner, t.repo, prFor(t)); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.error.problem).toBe("range-empty-diff"); + }); }); diff --git a/libs/pr-worktree/src/range.ts b/libs/pr-worktree/src/range.ts index 39b5b75..f32e672 100644 --- a/libs/pr-worktree/src/range.ts +++ b/libs/pr-worktree/src/range.ts @@ -20,7 +20,7 @@ * `base...mergeCommit` file set equals it. With no PR head to verify against we * refuse rather than guess — a wrong range would distill the wrong pages weeks * later. Two final gates always run: base must be an ancestor of head, and the - * exact three-dot diff the distiller runs (queue excluded) must be non-empty. + * exact three-dot diff the distiller runs (queue and discovery excluded) must be non-empty. */ import { type PrInfo } from "./pr.js"; @@ -36,9 +36,9 @@ export type DeriveRangeResult = | { ok: true; range: Range } | { ok: false; error: PrWorktreeDiagnostic }; -/** Sorted, unique, non-empty changed paths for `base...head`, queue excluded; null on git error. */ +/** Sorted, unique, non-empty changed paths for `base...head`, queue and discovery excluded; null on git error. */ function diffNameSet(run: Runner, cwd: string, base: string, head: string): string[] | null { - const out = git(run, cwd, "diff", "--name-only", `${base}...${head}`, "--", ".", ":(exclude).nexus/queue"); + const out = git(run, cwd, "diff", "--name-only", `${base}...${head}`, "--", ".", ":(exclude).nexus/queue", ":(exclude).nexus/discovery"); if (out === null) return null; return [...new Set(out.split("\n").map((l) => l.trim()).filter((l) => l.length > 0))].sort(); } @@ -149,7 +149,7 @@ export function deriveRange( }; } - const diff = git(run, cwd, "diff", `${base}...${head}`, "--", ".", ":(exclude).nexus/queue"); + const diff = git(run, cwd, "diff", `${base}...${head}`, "--", ".", ":(exclude).nexus/queue", ":(exclude).nexus/discovery"); if (diff === null) { return { ok: false, @@ -161,7 +161,7 @@ export function deriveRange( ok: false, error: { problem: "range-empty-diff", - message: `derived range ${base}...${head} has an empty diff (excluding .nexus/queue); refusing to stamp a range that would distill nothing.`, + message: `derived range ${base}...${head} has an empty diff (excluding .nexus/queue and .nexus/discovery); refusing to stamp a range that would distill nothing.`, }, }; }