From 44d63ffda862e929383207838870138646334410 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 13:39:11 -0800 Subject: [PATCH 1/8] chore(quest): claim quest/m0/token-waste/child-lists.md Quest-Claim: 9871801a1f7ea323 From f681f0779444e55de9d3a190c32f464f0b62ea6f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 13:41:09 -0800 Subject: [PATCH 2/8] feat!: quest check rejects epics listing their children Restore the stripping #518 staged out: remove() drops every epic's Required link to a quest it holds, so quest check --fix, quest rm, and the land daemon's base-merge prune migrate an old tree, and --fix runs with no history. quest check now errors on "requires X, which this epic holds". Migrate this tree with quest check --fix, and the land fixer no longer adds the flaky quest's link to quest/m0/flaky/README.md. BREAKING CHANGE: repositories migrate with quest check --fix on their pin bump past this commit. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 ++-- assets/land-fix.md | 5 ++- quest/README.md | 6 ---- quest/m0/README.md | 17 --------- quest/m0/kix/README.md | 8 ----- quest/m0/kix/sandbox/README.md | 4 --- quest/m0/model-routing/README.md | 6 ---- quest/m0/model-routing/pins.md | 6 ++-- quest/m0/queue-watcher/README.md | 12 ------- quest/m0/queue-watcher/flaky-quest.md | 9 +++-- quest/m0/token-waste/README.md | 7 ---- quest/m0/token-waste/child-lists.md | 44 ----------------------- quest/m0/user-dev-shell/README.md | 5 --- quest/m1/README.md | 13 ------- quest/m1/kix/README.md | 5 --- quest/m1/kix/sandbox/README.md | 5 --- quest/m1/theme/README.md | 4 --- quest/m2/README.md | 13 ------- quest/m2/automation/README.md | 6 ---- quest/m2/cloud/README.md | 7 ---- src/cli/main.ts | 13 +++---- src/cli/move.ts | 45 +++++++++++++----------- src/core/doc.ts | 11 ++---- src/core/edit.ts | 25 ++++++++++---- src/core/index.ts | 1 + src/core/ready.ts | 5 +-- src/core/rules.ts | 15 +++++--- src/core/unblocked.ts | 9 ++--- test/move.test.ts | 50 +++++++++++++++++++-------- test/tree.test.ts | 38 +++++++++----------- test/unblocked.test.ts | 6 ---- 31 files changed, 132 insertions(+), 275 deletions(-) delete mode 100644 quest/m0/token-waste/child-lists.md diff --git a/README.md b/README.md index a4e69103..d2940a7b 100644 --- a/README.md +++ b/README.md @@ -284,7 +284,7 @@ quest rm -r quest/m1/drain under a moved epic. `rm` drops each `Required`, `Related`, or `Closes` entry that links to what it deleted, and the heading if that was the last entry. It also drops every `Required` link to a finished milestone, including one it -just finished, but keeps the root's listing of the milestone. +just finished, and every epic's `Required` link to a quest in its own folder. Both stage the change, print one line per file (`R from -> to`, `D path`, or `M path`), and report on stderr any other mention of the path, such as prose or files outside `quest/`, without editing it. They run `quest check` on the @@ -331,7 +331,10 @@ A clean merge can still leave this branch's list links to quests the base finished, which `quest check` then flags. `quest check --fix` drops every `Required`, `Related`, or `Closes` entry linking to a quest deleted in the history of `HEAD`, and of `MERGE_HEAD` during a merge, that the working tree -lacks, and prints `M path` for each edited document. It leaves the edits +lacks, and every epic's `Required` entry naming a quest in its own folder, +which the epic already waits for, and prints `M path` for each edited +document. That last part also migrates a tree from before epics stopped +listing their children. It leaves the edits unstaged and refuses while a quest file has unresolved conflicts. A quest moved rather than deleted, a link that never named a quest, and a link in prose stay for `quest check` to report. diff --git a/assets/land-fix.md b/assets/land-fix.md index 8e4066bf..4940c260 100644 --- a/assets/land-fix.md +++ b/assets/land-fix.md @@ -169,9 +169,8 @@ commit on it only - that follow-up quest, `quest/m0/flaky/.md`, with `` the test's name in lowercase kebab-case, whose Goal says to fix the test or delete it and links each listed job and this failure's, which the failure log names; -- its link in the `Required` list of `quest/m0/flaky/README.md`, the epic - holding one quest per disabled test, created with a link in - `quest/m0/README.md`'s `Required` if it is missing. +- `quest/m0/flaky/README.md`, the epic holding one quest per disabled test, + if it is missing. Follow the repository's quest format, and run `quest check` when the repository has it. A disabled test that is another quest's acceptance test diff --git a/quest/README.md b/quest/README.md index 05a03a75..228412cf 100644 --- a/quest/README.md +++ b/quest/README.md @@ -10,9 +10,3 @@ versioned Markdown, with a standalone CLI and a reversible adoption path. `m0` is adoption on moq-dev's own repositories, `m1` the public release, and `m2` later work. Add later horizons when concrete work warrants them, keeping milestone numbers stable as earlier milestones complete. - -## Required - -- [M0: moq-dev adoption](/quest/m0/README.md) - kix at quest.moq.dev, used daily on moq-dev/moq, moq-dev/quest, and moq-dev/moq.pro, beside the standalone CLI -- [M1: Public release](/quest/m1/README.md) - kix.quest open to any maintainer, with onboarding, contributor sandboxing, the themed site, and launch material -- [M2: Later work](/quest/m2/README.md) - cloud runners, a native mobile app, shared runners, and donated reviews and CI diff --git a/quest/m0/README.md b/quest/m0/README.md index 7bfc763e..c6f66c69 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -91,20 +91,3 @@ ownership-manifest design: limits). Prefer offline, deterministic commands, and keep GitHub calls in the CLI rather than skill prose. - Repository-specific rules stay in the repository's own root instructions. - -## Required - -- [Land through a serial merge queue with an async fixer](/quest/m0/queue-watcher/README.md) - GitHub's merge queue, or the watcher's own where GitHub has none, lands one PR at a time, and a host watcher fixes and re-enqueues each PR it drops -- [Review sessions keyed by upstream branch](/quest/m0/review-session-key.md) - a round from a local branch tracking the PR branch continues that branch's review session -- [Kix decision UI](/quest/m0/kix/README.md) - planning and implementation through prompts and summaries, run on the maintainer's own runners -- [Route quests to models by size](/quest/m0/model-routing/README.md) - `/quest-spawn` starts each quest on the first model of its size's ranked list that can run, reviews rank the same way, and PRs record size, implementer, and reviewer -- [Repositories and hosts follow quest's main](/quest/m0/pin-sync.md) - every push to main reaches each enrolled repository and host through a bot-queued pin bump -- [Cut agent token waste](/quest/m0/token-waste/README.md) - sessions stop paying for loading rules, a second review pass, polling, oversized coordinators, and merge churn -- [quest check rejects a misleveled Waiting heading](/quest/m0/waiting-heading-level.md) - a `### Waiting` (or any non-`##` level) fails `quest check` with its file and line, so no wait drops out of `quest waiting` -- [Model check](/quest/m0/model-check.md) - recurring: the first audit of the routed PRs retunes the built-in size lists -- [Codex cloud off](/quest/m0/codex-cloud-off.md) - automatic Codex cloud reviews stop once no open PR relies on them -- [Codex project-config skip](/quest/m0/codex-project-config.md) - once Codex keeps a supported way to skip a project's `.codex/` layer, reviews keep the user's config -- [Agents get the dev shell from user settings](/quest/m0/user-dev-shell/README.md) - maintainers' own Claude and Codex settings load the dev shell; repositories drop dev-shell hooks, Quest worktree hooks, and `nix develop` instructions, and `quest init` writes no hooks -- [Session checkouts released](/quest/m0/session-checkouts.md) - a Claude session's own checkout is released when the session ends and reclaimed on resume -- [claude rm upstream report](/quest/m0/claude-rm-upstream.md) - posting approved: ask Claude Code to run `WorktreeRemove` instead of advising an unlock -- [Three generic rules in the shared context](/quest/m0/context-rules.md) - `quest context` adds matching conventions, never loosening a flaky test, and short names under a namespace diff --git a/quest/m0/kix/README.md b/quest/m0/kix/README.md index 56e1a35c..ba76f415 100644 --- a/quest/m0/kix/README.md +++ b/quest/m0/kix/README.md @@ -136,11 +136,3 @@ fixes what it finds: [moq-dev/quest#209](https://github.com/moq-dev/quest/pull/209) assumes is the comparison GitHub makes. Try it with a real PR that edits a workflow (decided 2026-10-24, user). - -## Required - -- [Operator setup](/quest/m0/kix/operator-setup.md) - pilot installations and a verified test PR on moq-dev/moq and moq-dev/quest -- [Private repositories](/quest/m0/kix/private.md) - moq-dev/moq.pro enrolls, visible only to signed-in users who can read it -- [Job sandbox](/quest/m0/kix/sandbox/README.md) - head approval for kix-pushed heads, and opencode sandboxed -- [Runner account pool](/quest/m0/kix/cliproxy.md) - the runner routes every Claude and Codex job and review through its own CLIProxyAPI pool -- [Web execution](/quest/m0/kix/execution.md) - spawn and iterate through the UI, ending in contribution PRs diff --git a/quest/m0/kix/sandbox/README.md b/quest/m0/kix/sandbox/README.md index 9798eee9..7c79e0cb 100644 --- a/quest/m0/kix/sandbox/README.md +++ b/quest/m0/kix/sandbox/README.md @@ -91,7 +91,3 @@ Shipped in [moq-dev/quest#212](https://github.com/moq-dev/quest/pull/212) - Sources: [Claude Code sandboxing](https://code.claude.com/docs/en/sandboxing) and [sandbox-runtime](https://www.npmjs.com/package/@anthropic-ai/sandbox-runtime). - -## Required - -- [Per-job opencode saved output](/quest/m0/kix/sandbox/opencode-data.md) - each opencode job saves its tool output and shell captures beside its checkout, so no job reads another's, while sessions and the credential stay in the account's database diff --git a/quest/m0/model-routing/README.md b/quest/m0/model-routing/README.md index a5f83f07..73ca040a 100644 --- a/quest/m0/model-routing/README.md +++ b/quest/m0/model-routing/README.md @@ -91,9 +91,3 @@ are mostly judgment. [Route by size](/quest/m0/model-routing/models.md). - Which milestone? ✅ m0 (rejected: m1). It ranks first among m0's work after Kix, by the 2026-11-01 order: land work, then Kix, then the rest. - -## Required - -- [Route by size](/quest/m0/model-routing/models.md) - `/quest-spawn` and iterate dispatches start on the first entry of the size's ranked list that can run, an iterate sized by its remaining work -- [kix records its follow-up sessions](/quest/m0/model-routing/kix-sessions.md) - kix `iterate` and `merge` jobs that change an existing PR record their `Implementer:` line in a PR comment -- [moq-dev pins](/quest/m0/model-routing/pins.md) - moq-dev/moq and moq-dev/moq.pro bump their `quest` pin to pick up the routing diff --git a/quest/m0/model-routing/pins.md b/quest/m0/model-routing/pins.md index 00440584..67147f4f 100644 --- a/quest/m0/model-routing/pins.md +++ b/quest/m0/model-routing/pins.md @@ -18,11 +18,9 @@ repository uses the same settings, so nothing in them changes but the pin. - Neither repository's `AGENTS.md` overrides the tables. - Each bump runs `quest check --fix` with the new `quest` and commits the result, since a `quest` past - [Epics list no children](/quest/m0/token-waste/child-lists.md) rejects + [moq-dev/quest#554](https://github.com/moq-dev/quest/pull/554) rejects epic READMEs that still list their children, and its `--fix` strips them - (coordinator, 2026-11-05, follow-up of - [moq-dev/quest#518](https://github.com/moq-dev/quest/pull/518), which - accepts both formats; [Pin sync](/quest/m0/pin-sync.md) does the same for + (coordinator, 2026-11-05; [Pin sync](/quest/m0/pin-sync.md) does the same for its automated bumps). ## Required diff --git a/quest/m0/queue-watcher/README.md b/quest/m0/queue-watcher/README.md index 43b65641..798b46a7 100644 --- a/quest/m0/queue-watcher/README.md +++ b/quest/m0/queue-watcher/README.md @@ -144,15 +144,3 @@ Shared shape, for the children to follow: class (`QUEST_GATE_PRIORITY=1`, which waits for memory but never for load) lets them admit ahead of agent load, set from moq's workflow or the runner's environment. - -## Required - -- [The other repositories follow](/quest/m0/queue-watcher/follow.md) - quest, rind and rust2 are cut over and landing; waits for a fork PR to land through one of their queues -- [Harden the queue runners](/quest/m0/queue-watcher/runners.md) - each queue runner runs only its repository's queue workflow, and every outside fork PR needs approval -- [The watcher writes flaky quests](/quest/m0/queue-watcher/flaky-quest.md) - a disable's follow-up quest comes from a template in `quest land watch`, so the headless land fixer writes no quest -- [Daemons prune their logs](/quest/m0/queue-watcher/log-retention.md) - the watcher and gate delete their finished logs after a window, so state stays bounded (4.1 GiB on nixos on 2026-11-04) -- [The watcher runs its own serial queue](/quest/m0/queue-watcher/own-queue.md) - `quest land watch` lands a base without GitHub's merge queue, testing one pick at a time in the repository's queue workflow -- [moq.pro lands main through the own queue](/quest/m0/queue-watcher/pro-cutover.md) - moq.pro's `main` leaves the batching gate for the watcher's own queue -- [The watcher runs one instance per base branch](/quest/m0/queue-watcher/per-base.md) - each base of a repository gets its own watcher, clone, locks, state and `--method`, so moq.pro's `release` can follow `main` -- [Retire the batching gate](/quest/m0/queue-watcher/retire.md) - once no repository lands through `quest land serve`, its batching code and host units go -- [kix.quest shows each repository's landing queue](/quest/m0/queue-watcher/queue-page.md) - one page per repository with its queue order, current build, and recent drops, in both modes diff --git a/quest/m0/queue-watcher/flaky-quest.md b/quest/m0/queue-watcher/flaky-quest.md index 4d3c9f57..a7889666 100644 --- a/quest/m0/queue-watcher/flaky-quest.md +++ b/quest/m0/queue-watcher/flaky-quest.md @@ -15,8 +15,8 @@ only by a coordinator (`/quest-complete`, `/quest-spawn`) on Opus, never by a sub-agent. The skills already follow it: only `/quest-merge` and `/quest-complete` plan follow-ups, and `/quest-plan` refuses to run in an agent that cannot prompt. The land fixer, a headless Codex agent, is the one -exception: `assets/land-fix.md` has it write the flaky quest and its -`quest/m0/flaky/README.md` link in the disable commit, and +exception: `assets/land-fix.md` has it write the flaky quest, and the +`quest/m0/flaky/README.md` epic when missing, in the disable commit, and `src/cli/land-watch-flaky.ts` requires exactly one added quest file. Decision prompts, chosen options marked ✅: @@ -37,7 +37,6 @@ Recommendations for the implementer: - The template's Goal says to fix the test or delete it and links each job. Its size is a fixed guess, such as `[S]`, for the next coordinator to correct. -- [Epics list no children](/quest/m0/token-waste/child-lists.md) is in - flight and drops the epic README links; follow whichever format the base - has when this lands. +- Epic READMEs list no children, so the quest file is the only tree edit + besides creating the epic's README when missing. - `validateFix` then rejects a disable commit that touches `quest/` at all. diff --git a/quest/m0/token-waste/README.md b/quest/m0/token-waste/README.md index a081e38a..ea5fa14b 100644 --- a/quest/m0/token-waste/README.md +++ b/quest/m0/token-waste/README.md @@ -66,13 +66,6 @@ Decision prompts, chosen options marked ✅: semver-breaking features) is moq-dev/moq's own plan: ✅ re-plan moq separately (rejected: make it quest's convention; not now). -## Required - -- [Epics list no children](/quest/m0/token-waste/child-lists.md) - an epic's children are its directory, so finishing a quest edits no README -- [Mechanical rules become checks](/quest/m0/token-waste/rule-checks.md) - hooks enforce em dashes, push refspecs, no rebase or force-push, the footer, and `.tmp/`, and their prose leaves the always-loaded rules -- [quest usage](/quest/m0/token-waste/usage.md) - per quest and PR: which models and efforts worked on it, their tokens and API-equal cost, and how the work went; the epic's re-measure runs it -- [Point to token-max](/quest/m0/token-waste/token-max.md) - `SETUP.md` recommends the standalone settings doctor, and the re-measure runs it - ## Related - [Model check](/quest/m0/model-check.md) - audits the size tables the review quest changes diff --git a/quest/m0/token-waste/child-lists.md b/quest/m0/token-waste/child-lists.md deleted file mode 100644 index 8f059249..00000000 --- a/quest/m0/token-waste/child-lists.md +++ /dev/null @@ -1,44 +0,0 @@ -# [S] Epics list no children - -## Goal - -This repository's epic and milestone READMEs no longer list their children, -and `quest check` rejects a README `Required` entry naming a quest in its own -directory. Finishing a quest then deletes its file and edits no README, so -PRs that finish sibling quests stop conflicting there. - -## Plan - -Decided 2026-11-05 (user, /quest-plan, the -[token waste](/quest/m0/token-waste/README.md) audit): an epic's children are -the quests in its directory, its `Required` lists only what blocks it, and -path order replaces list order. - -Measured, moq-dev PRs created 2026-10-06 to 11-05: - -- `quest/m1/README.md` was touched by 33% of merged moq-dev/moq PRs, and - milestone READMEs as a group by 45%; in moq-dev/rust2, `quest/m3/README.md` - by 290 of 585. -- GitHub's merge queue cannot run quest's merge driver and ejected 723 PRs - for conflicts. - -Decided 2026-11-07 (user): ✅ stage it in two PRs (rejected: admin-bypass -merge once; skip; delete). The merge queue checks a PR with the CLI built -from its base, which rejected the migrated tree. -[moq-dev/quest#518](https://github.com/moq-dev/quest/pull/518) shipped -directory membership with a validator that accepts both formats: an epic's -`Required` link to a quest it holds is allowed and counts once, as a child. -This quest is the second PR: - -- Make `remove()` (`src/core/edit.ts`) drop every epic's `Required` link to a - quest it holds, so `quest check --fix`, `quest rm`, and the land daemon's - base-merge prune strip old child lists, and let `quest check --fix` run with - no history to read. #518's commit caa32fc3 has both, with tests. -- Run `quest check --fix` on this tree and on prose that describes the lists. -- Turn `requires X, which this epic holds` into a `quest check` error. -- `assets/land-fix.md` stops adding the flaky quest's link to - `quest/m0/flaky/README.md`, and - [the flaky quest](/quest/m0/queue-watcher/flaky-quest.md) says so. -- The PR is breaking: other repositories migrate with `quest check --fix` on - their pin bump past it, as - [moq-dev pins](/quest/m0/model-routing/pins.md) describes. diff --git a/quest/m0/user-dev-shell/README.md b/quest/m0/user-dev-shell/README.md index d1801ca7..360d8d0e 100644 --- a/quest/m0/user-dev-shell/README.md +++ b/quest/m0/user-dev-shell/README.md @@ -70,8 +70,3 @@ The user-level hooks live in [kixelated/dotfiles](https://github.com/kixelated/d installed into each Claude profile's `settings.json` and Codex's user config, and generalize this repository's `.claude/hooks/direnv.sh` and moq-dev/moq's `.codex/hooks/direnv.sh`. - -## Required - -- [.worktrees/ stays out of git status](/quest/m0/user-dev-shell/worktrees-exclude.md) - `quest worktree` adds `/.worktrees/` to the clone's local exclude unless something ignores it -- [moq.pro drops its Codex dev-shell config](/quest/m0/user-dev-shell/repo-config.md) - once a fresh Codex session there loads the dev shell without the repo hooks diff --git a/quest/m1/README.md b/quest/m1/README.md index 44f33f48..3a837559 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -19,16 +19,3 @@ earlier "Later work" milestone became m2. - ✅ Move to kix.quest in m1 - Stay on quest.moq.dev - Decide later - -## Required - -- [User state in their RunnerHub](/quest/m1/user-state.md) - each user's runners, jobs, inbox, and push subscriptions live in their Durable Object, and D1 keeps repository data and other cross-user state -- [Open kix.quest](/quest/m1/open.md) - the Worker moves to kix.quest and any owner may enroll -- [Release with worktree hooks](/quest/m1/release.md) - tag a release that has `quest worktree hook`, so SETUP.md's release routes work -- [Kix for any repository](/quest/m1/kix/README.md) - onboarding, read-only CI checks, contributor sandboxing, and the maintainer guide -- [Quest log theme](/quest/m1/theme/README.md) - an MMO-style identity, copy, board, landing page, and CLI output for the demo and launch -- [Launch material](/quest/m1/launch.md) - README, quickstart, demo, comparison, and launch-post drafts -- [Gate CPU weight](/quest/m1/gate-cpu-weight.md) - gated commands run at a lower CPU weight than the interactive session, so concurrent gates no longer starve the desktop -- [Retire Decisions needed](/quest/m1/decisions-needed.md) - only `## Waiting` hands a draft back, once no open draft uses the old heading -- [Bun EFAULT report](/quest/m1/bun-efault.md) - report concurrent `rmSync` `EFAULT` on Windows, and drop the workaround once Bun fixes it -- [Model check](/quest/m1/model-check.md) - recurring each milestone: the built-in implementation and review tables use the newest models at efforts retuned from outcomes, and the live sandbox test passes diff --git a/quest/m1/kix/README.md b/quest/m1/kix/README.md index 9a3ca15e..65b84813 100644 --- a/quest/m1/kix/README.md +++ b/quest/m1/kix/README.md @@ -12,8 +12,3 @@ unconfined on their own machines. Moved from m0 on 2026-10-30 (user, /quest-plan): moq-dev's own repositories already carry Quest, are enrolled by the operator, and are worked on by their maintainers, so they need none of this first. - -## Required - -- [Contributor sandboxing](/quest/m1/kix/sandbox/README.md) - contributors' PR heads build in a VM, on stock Ubuntu and verified on macOS -- [Onboarding guide](/quest/m1/kix/guide.md) - a complete maintainer path, from runner setup through a reviewed PR diff --git a/quest/m1/kix/sandbox/README.md b/quest/m1/kix/sandbox/README.md index f66fc35d..43e961f3 100644 --- a/quest/m1/kix/sandbox/README.md +++ b/quest/m1/kix/sandbox/README.md @@ -13,8 +13,3 @@ Split from [the m0 sandbox epic](/quest/m0/kix/sandbox/README.md) on 2026-10-30 (user, /quest-plan): moq-dev's maintainers run their jobs on trusted or approved heads, so untrusted heads stay refused until m1. The design decisions for the whole sandbox stay in the m0 epic's Plan. - -## Required - -- [macOS sandbox](/quest/m1/kix/sandbox/macos.md) - the boundary verified under Seatbelt, then the answer-approval gate removed -- [Untrusted devShells](/quest/m1/kix/sandbox/vm.md) - untrusted PR heads build their devShell in a per-job VM diff --git a/quest/m1/theme/README.md b/quest/m1/theme/README.md index da40cc8a..dc04f193 100644 --- a/quest/m1/theme/README.md +++ b/quest/m1/theme/README.md @@ -110,10 +110,6 @@ Decided while planning on 2026-10-05, amended 2026-10-14 where noted: uses these terms. A blocked waypoint's tooltip lists what it needs under "Required:". -## Required - -- [Landing page](/quest/m1/theme/landing.md) - the home page's calls to action lead to sign-in, onboarding, and the setup paste - ## Related - [Launch material](/quest/m1/launch.md) - posts, quickstart, demo, and comparison written in the same voice diff --git a/quest/m2/README.md b/quest/m2/README.md index df3f891a..7c9eca1c 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -5,16 +5,3 @@ Extend kix.quest with cloud runners, a native mobile app, runners shared with other projects, donated reviews and CI, stronger isolation, and additional login providers. This milestone does not gate the public release. - -## Required - -- [Cloud runners](/quest/m2/cloud/README.md) - one-click free cloud runners, API-key and donor funding, then paid compute -- [Native mobile app](/quest/m2/mobile-app.md) - push notifications with quick replies to decisions -- [Shared runners](/quest/m2/donor-runner.md) - offer your runner and accounts to other projects -- [Review and check automation](/quest/m2/automation/README.md) - donated reviews, CI, and a shared Nix cache -- [opencode reviewers](/quest/m2/review-harnesses.md) - `quest review` runs opencode read-only, for the council's DeepSeek member -- [Review council](/quest/m2/council.md) - `/quest-council` reviews a PR with one blind model per maker and merges their findings with agreement counts -- [Donor isolation](/quest/m2/donor-isolation.md) - stronger boundaries for untrusted shared jobs -- [ChatGPT hosted runs](/quest/m2/chatgpt-hosted.md) - conditional on provider approval and current terms -- [ChatGPT approval](/quest/m2/chatgpt-waitlist.md) - resolve hosted-use approval before implementation -- [Worktree hook PATH error](/quest/m2/hook-path-error.md) - a worktree hook without `quest` on `PATH` says so and names the dev-shell launch route diff --git a/quest/m2/automation/README.md b/quest/m2/automation/README.md index 1c33ccb7..dd6ed835 100644 --- a/quest/m2/automation/README.md +++ b/quest/m2/automation/README.md @@ -10,9 +10,3 @@ authorized agent reviews and CI capacity using ordinary GitHub interfaces. Decided 2026-10-13: C1 first, then C2. Existing repository checks remain part of implementation; this epic adds independent donated review and CI jobs. No custom review database or git-note approval protocol is introduced. - -## Required - -- [Agent reviews](/quest/m2/automation/review.md) - attributed, advisory GitHub reviews -- [Donated checks](/quest/m2/automation/checks.md) - repository-defined check execution with explicit trust -- [Nix cache](/quest/m2/automation/cache.md) - optional main-only signed build cache diff --git a/quest/m2/cloud/README.md b/quest/m2/cloud/README.md index cf1fabc2..6c50de33 100644 --- a/quest/m2/cloud/README.md +++ b/quest/m2/cloud/README.md @@ -26,10 +26,3 @@ Decided 2026-10-15 in the decisions-not-implementations interview: for the cloud runners that depend on it. - Funding setup and operating guidance stay in existing service documentation; each implementation updates the docs it changes. - -## Required - -- [API funding](/quest/m2/cloud/funding.md) - maintainer API keys, the credential proxy, and durable usage accounting -- [Hosted runners](/quest/m2/cloud/runners.md) - free, capped cloud runners enabled with one click -- [Donor allocations](/quest/m2/cloud/donors.md) - shared API budgets after maintainer-funded cloud runners work -- [Paid compute](/quest/m2/cloud/billing.md) - charge for cloud runner compute once free capacity is not enough diff --git a/src/cli/main.ts b/src/cli/main.ts index 094e850d..94159eaa 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -170,14 +170,15 @@ export function main(argv: string[], out: Output): number | Promise { "first: a stale base can predate the quest and skip it.\n\n" + "With --fix, first drop every Required, Related, or Closes entry that links to a quest deleted in " + "the history of HEAD, and of MERGE_HEAD during a merge, that the working tree lacks, as quest rm " + - "does, such as the quests a merged base finished, with any heading left empty. It prints M for each " + - "edited document and leaves the edits unstaged. A quest moved rather than deleted, a link that never " + - "named a quest, and a link in prose stay for the check to report. Refuses outside a git work tree " + - "and while a quest file has unresolved conflicts.", + "does, such as the quests a merged base finished, and every epic's Required entry naming a quest in " + + "its own directory, which the epic already waits for, with any heading left empty. It prints M for " + + "each edited document and leaves the edits unstaged. A quest moved rather than deleted, a link that " + + "never named a quest, and a link in prose stay for the check to report. Refuses outside a git work " + + "tree and while a quest file has unresolved conflicts.", ) .option("--base ", "Also check the quest branch's diff from its merge base with this ref") .option("--branch ", "The PR's head branch, when it differs from the current branch (needs --base)") - .option("--fix", "First drop list links to quests the history deleted") + .option("--fix", "First drop list links to quests the history deleted, and epics' links to their own quests") .action((options: { base?: string; branch?: string; fix?: boolean }) => { if (options.branch !== undefined && options.base === undefined) { throw new UsageError("--branch requires --base"); @@ -1242,7 +1243,7 @@ export function main(argv: string[], out: Output): number | Promise { "Delete a quest, or an epic and the quests under it, with git rm, and drop every Required, Related, " + "or Closes entry that links to it, with any heading left empty. It also drops every Required link to a " + "finished milestone, one whose directory holds no other quest and whose README has no Required left, " + - "except the root's own listing. The change is staged.\n\n" + + "and every epic's Required entry naming a quest in its own directory. The change is staged.\n\n" + "Refuses outside a git work tree. An epic is its directory or README; removing one that holds other " + "quests requires -r. The root epic and milestones are permanent.\n\n" + "Prints D for each deleted quest and M for each edited document. Mentions that are not list entries, " + diff --git a/src/cli/move.ts b/src/cli/move.ts index 52b74402..9283e41c 100644 --- a/src/cli/move.ts +++ b/src/cli/move.ts @@ -76,7 +76,8 @@ export async function moveQuest( /** * Delete a quest, or an epic with the quests under it, and drop every list - * entry that links to what was deleted. The root and milestones are permanent. + * entry that links to what was deleted, as `remove` does. The root and + * milestones are permanent. */ export function removeQuest(root: string, arg: string, recursive: boolean): Report { workTree(root); @@ -113,10 +114,11 @@ export function removeQuest(root: string, arg: string, recursive: boolean): Repo /** * Drop every list entry that links to a quest deleted in the history of `HEAD`, and of * `MERGE_HEAD` during a merge, that the working tree lacks, as `quest rm` drops the links to a - * quest it deletes. Merging the base brings in the quests it finished as deletions, which leaves - * this branch's links to them dangling. A quest the history moved is a rename, not a deletion, and - * a link to a path that never held a quest is a mistake; both stay for `quest check` to report, as - * does a link in prose. Writes the edited documents without staging them, and refuses while a + * quest it deletes, and every epic's `Required` entry naming a quest in its own directory, which + * the epic already waits for. Merging the base brings in the quests it finished as deletions, which + * leaves this branch's links to them dangling. A quest the history moved is a rename, not a deletion, + * and a link to a path that never held a quest is a mistake; both stay for `quest check` to report, + * as does a link in prose. Writes the edited documents without staging them, and refuses while a * quest file is unmerged, since its conflict markers are not yet a document. Returns `M path` for * each. */ @@ -130,23 +132,24 @@ export function fixLinks(root: string): string[] { throw new Error(`resolve the merge conflicts in ${[...new Set(unmerged)].join(", ")} before quest check --fix`); } const heads = ["HEAD", "MERGE_HEAD"].filter((ref) => verified(root, ref)); - if (heads.length === 0) return []; const deleted = new Set( - // `--relative` names paths from `root`, as the tree's links do, when the tree is nested in its repository. - git(root, [ - "log", - "--relative", - "--diff-filter=D", - "--find-renames", - "--name-only", - "--format=", - "-z", - ...heads, - "--", - "quest", - ]) - .split("\0") - .filter(isQuest), + heads.length === 0 + ? [] + : // `--relative` names paths from `root`, as the tree's links do, when the tree is nested in its repository. + git(root, [ + "log", + "--relative", + "--diff-filter=D", + "--find-renames", + "--name-only", + "--format=", + "-z", + ...heads, + "--", + "quest", + ]) + .split("\0") + .filter(isQuest), ); const edited = remove(sources(root), (path) => deleted.has(path) && !exists(root, path)); for (const [path, text] of edited) writeFileSync(join(root, path), text); diff --git a/src/core/doc.ts b/src/core/doc.ts index bba071d5..109946f0 100644 --- a/src/core/doc.ts +++ b/src/core/doc.ts @@ -204,17 +204,12 @@ export function isEpic(docs: readonly Doc[], doc: Doc): boolean { return permanent(doc.path) || docs.some((d) => holds(doc.path, d.path)); } -/** - * What must finish before `doc`: its `Required` quests, then, for an epic, its - * children. An epic's `Required` link to one of its children, left from a tree - * that listed them, counts once. - */ +/** What must finish before `doc`: its `Required` quests, then, for an epic, its children. */ export function requires(docs: readonly Doc[], doc: Doc): string[] { - const kids = children(docs, doc); const required = entries(doc, "Required") .map((entry) => (entry.target === null ? null : rooted(entry.target))) - .filter((target): target is string => target !== null && !kids.includes(target)); - return [...required, ...kids]; + .filter((target): target is string => target !== null); + return [...required, ...children(docs, doc)]; } /** diff --git a/src/core/edit.ts b/src/core/edit.ts index 8e9c0e3a..3c7f94cc 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -5,7 +5,7 @@ // dependency graph. Anything else that mentions a path is prose, which the // caller reports rather than rewriting. -import { type Doc, type Entry, parse, rooted, withoutFragment } from "./doc"; +import { type Doc, type Entry, holds, parse, rooted, withoutFragment } from "./doc"; import { LIST_SECTIONS, finishedMilestone } from "./rules"; /** One quest document as loaded: its repository-relative path and source text. */ @@ -71,24 +71,35 @@ export function move(sources: Source[], from: string, to: string): Moved { * Drop every list entry, in a list section, that opens with a link to a * deleted document, and a section heading left with no entries. Then drop * every `Required` link to a finished milestone, whether this removal emptied - * it or the link was already stale, repeating until nothing changes so the - * result passes `quest check`. Returns the new text of every surviving + * it or the link was already stale, and every epic's `Required` link to a + * quest it holds, which it already waits for, repeating until nothing changes + * so the result passes `quest check`. Returns the new text of every surviving * document that changed. */ export function remove(sources: Source[], deleted: (path: string) => boolean): Map { const texts = new Map(sources.filter((s) => !deleted(s.path)).map((s) => [s.path, s.text])); const changed = new Set(); + const target = (entry: Entry) => (entry.target === null ? null : rooted(entry.target)); const linksDeleted = (entry: Entry) => { - if (entry.section === null || !LIST_SECTIONS.includes(entry.section) || entry.target === null) return false; - const target = rooted(entry.target); - return target !== null && deleted(target); + if (entry.section === null || !LIST_SECTIONS.includes(entry.section)) return false; + const path = target(entry); + return path !== null && deleted(path); + }; + // Only a quest that exists is held: a mistyped link stays for `quest check` to report. + const held = (doc: Doc, entry: Entry) => { + const path = target(entry); + return entry.section === "Required" && path !== null && texts.has(path) && holds(doc.path, path); }; for (let progress = true; progress;) { progress = false; const docs = [...texts].map(([path, text]) => parse(path, text)); const finished = finishedMilestone(docs); for (const doc of docs) { - const text = drop(texts.get(doc.path)!, doc, (entry) => linksDeleted(entry) || finished(doc, entry)); + const text = drop( + texts.get(doc.path)!, + doc, + (entry) => linksDeleted(entry) || finished(doc, entry) || held(doc, entry), + ); if (text === null) continue; texts.set(doc.path, text); changed.add(doc.path); diff --git a/src/core/index.ts b/src/core/index.ts index 2647ca94..66ba9ef1 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -16,6 +16,7 @@ export { children, entries, has, + holds, isQuest, isEpic, owner, diff --git a/src/core/ready.ts b/src/core/ready.ts index 8d8e4d8c..75a31be5 100644 --- a/src/core/ready.ts +++ b/src/core/ready.ts @@ -84,7 +84,6 @@ function expand(docs: Doc[], byPath: Map, doc: Doc, stack: string[] // everything that greps for it, including `quest check`, which reports it. // Calling it ready here would make this the one tool that disagrees. const required = entries(doc, "Required"); - const kids = children(docs, doc); if (has(doc, "Required") && required.length === 0) { found.push({ path: null, @@ -96,11 +95,9 @@ function expand(docs: Doc[], byPath: Map, doc: Doc, stack: string[] // A bullet that does not open with a link into the tree is invalid, and // nothing here can clear it, so it is a blocker with nothing under it. const target = entry.target === null ? null : rooted(entry.target); - // An old child list's entry is listed below, with the children. - if (target !== null && kids.includes(target)) continue; found.push(blocker(docs, byPath, target !== null && byPath.has(target) ? target : null, entry.text, stack)); } - for (const child of kids) found.push(blocker(docs, byPath, child, child, stack)); + for (const child of children(docs, doc)) found.push(blocker(docs, byPath, child, child, stack)); return found; } diff --git a/src/core/rules.ts b/src/core/rules.ts index 45d0dded..ff4d1369 100644 --- a/src/core/rules.ts +++ b/src/core/rules.ts @@ -210,8 +210,8 @@ function links(found: Finding[], exists: (path: string) => boolean, known: Set, docs: Doc[]) { found.push({ path: doc.path, line: entry.line, message: `requires ${entry.target} twice` }); } seen.add(target); + if (holds(doc.path, target)) { + found.push({ + path: doc.path, + line: entry.line, + message: `requires ${entry.target}, which this epic holds; an epic waits for everything in its directory, so remove the entry (quest check --fix does)`, + }); + } } } } @@ -256,8 +263,8 @@ function membership(found: Finding[], known: Set, docs: Doc[]) { * other quest and its README has no `## Required`. * * Readiness stays a grep for the heading; this rule is what keeps that answer - * true once the milestone empties. The root's own listing of its milestones is - * membership, not a blocker, so it is exempt. + * true once the milestone empties. A root entry naming a milestone is the + * membership rule's finding, not this one's. */ function milestones(found: Finding[], docs: Doc[]) { const finished = finishedMilestone(docs); diff --git a/src/core/unblocked.ts b/src/core/unblocked.ts index a45f18cd..5a5fd606 100644 --- a/src/core/unblocked.ts +++ b/src/core/unblocked.ts @@ -37,13 +37,8 @@ export function ranking(docs: Doc[]): Ranking { reverse.set(target, dependents); }; for (const doc of docs) { - const kids = children(docs, doc); - for (const entry of entries(doc, "Required")) { - const target = rooted(entry.target!)!; - // An old child list's entry is membership, which the children below record. - if (!kids.includes(target)) depend(target, doc.path, false); - } - for (const child of kids) depend(child, doc.path, true); + for (const entry of entries(doc, "Required")) depend(rooted(entry.target!)!, doc.path, false); + for (const child of children(docs, doc)) depend(child, doc.path, true); } // Assign the root's subtrees their priority before scoring. In particular, diff --git a/test/move.test.ts b/test/move.test.ts index 793e21be..715abd2d 100644 --- a/test/move.test.ts +++ b/test/move.test.ts @@ -10,7 +10,7 @@ import { move, remove } from "../src/core"; import { openPullRequests } from "../src/cli/claims"; import { GitHub, type GitHubDependencies } from "../src/cli/github"; import { main } from "../src/cli/main"; -import { EPIC_README, Tree, initRepository, tempDir } from "./fixture"; +import { EPIC_README, M0_README, ROOT_README, Tree, initRepository, tempDir } from "./fixture"; // Each test spawns git up to a dozen times, which a busy Windows runner can stretch past 5 s. vi.setConfig({ testTimeout: 30_000 }); @@ -120,11 +120,9 @@ describe("remove", () => { "# [S] A\n\n## Goal\n\nAfter [B](/quest/b.md).\n\n- [B](/quest/b.md) in a goal list\n\n## Related\n\nContext.\n", ); }); - test("drops links to finished milestones until nothing changes, keeping the root's listing", () => { - const root = - "# Quests\n\n## Goal\n\nG.\n\n## Required\n\n- [M0](/quest/m0/README.md)\n- [M1](/quest/m1/README.md)\n"; + test("drops links to finished milestones until nothing changes", () => { const sources = [ - { path: "quest/README.md", text: root }, + { path: "quest/README.md", text: "# Quests\n\n## Goal\n\nG.\n" }, // m0 finishes once b goes, which finishes m1, which only waited on m0. { path: "quest/m0/README.md", text: "# M0\n\n## Goal\n\nG.\n\n## Required\n\n- [B](/quest/b.md)\n" }, { @@ -147,6 +145,32 @@ describe("remove", () => { expect(texts.get("quest/m1/README.md")).toBe("# M1\n\n## Goal\n\nG.\n"); expect(texts.get("quest/a.md")).toBe("# [S] A\n\n## Goal\n\nG.\n\n## Related\n\n- [M0](/quest/m0/README.md)\n"); }); + + // An epic waits for everything in its directory, so its links to those quests + // are the child lists this format retired. A sibling's link is a real blocker, + // and a link to no quest is a mistake for the check to report. + test("drops an epic's Required links to quests it holds, at any depth", () => { + const readme = + "# Epic\n\n## Goal\n\nG.\n\n## Required\n\n- [A](/quest/m0/epic/a.md)\n" + + "- [Outside](/quest/m0/outside.md)\n- [Deep](/quest/m0/epic/sub/deep.md)\n- [Typo](/quest/m0/epic/typo.md)\n\n" + + "## Related\n\n- [A](/quest/m0/epic/a.md)\n"; + const sibling = "# [S] B\n\n## Goal\n\nG.\n\n## Required\n\n- [A](/quest/m0/epic/a.md)\n"; + const quest = (title: string) => `# [S] ${title}\n\n## Goal\n\nG.\n`; + const texts = remove( + [ + { path: "quest/m0/epic/README.md", text: readme }, + { path: "quest/m0/epic/a.md", text: quest("A") }, + { path: "quest/m0/epic/b.md", text: sibling }, + { path: "quest/m0/epic/sub/deep.md", text: quest("Deep") }, + ], + () => false, + ); + expect([...texts.keys()]).toEqual(["quest/m0/epic/README.md"]); + expect(texts.get("quest/m0/epic/README.md")).toBe( + "# Epic\n\n## Goal\n\nG.\n\n## Required\n\n- [Outside](/quest/m0/outside.md)\n- [Typo](/quest/m0/epic/typo.md)\n\n" + + "## Related\n\n- [A](/quest/m0/epic/a.md)\n", + ); + }); }); describe("quest mv", () => { @@ -432,9 +456,8 @@ describe("quest check --fix", () => { expect(tree.read("quest/m0/epic/three.md")).toBe(before); }); - // Trees from before directory membership keep their child lists until migrated: - // finishing a quest drops only its own entry. - test("drops only the finished quest's entry from a child list", async () => { + // Trees from before epics stopped listing their children migrate with one run. + test("strips the child lists of the root, milestones, and epics", async () => { const tree = Tree.baseline() .append("quest/README.md", "\n## Required\n\n- [M0](/quest/m0/README.md)\n") .append("quest/m0/README.md", "\n## Required\n\n- [Epic](/quest/m0/epic/README.md)\n") @@ -443,15 +466,14 @@ describe("quest check --fix", () => { "\n## Required\n\n- [One](/quest/m0/epic/one.md)\n- [Two](/quest/m0/epic/two.md)\n", ); committed(tree); - expect(await quest(tree, "rm", "quest/m0/epic/one.md")).toEqual({ + expect(await quest(tree, "check", "--fix")).toEqual({ code: 0, - stdout: "D quest/m0/epic/one.md\nM quest/m0/epic/README.md\nM quest/m0/epic/two.md\n", + stdout: "M quest/README.md\nM quest/m0/README.md\nM quest/m0/epic/README.md\nquest: 5 documents ok\n", stderr: "", }); - expect(tree.read("quest/m0/epic/README.md")).toBe( - `${EPIC_README}\n## Required\n\n- [Two](/quest/m0/epic/two.md)\n`, - ); - expect(await quest(tree, "check", "--fix")).toEqual({ code: 0, stdout: "quest: 4 documents ok\n", stderr: "" }); + expect(tree.read("quest/README.md")).toBe(ROOT_README); + expect(tree.read("quest/m0/README.md")).toBe(M0_README); + expect(tree.read("quest/m0/epic/README.md")).toBe(EPIC_README); }); test("fails outside a git work tree", async () => { diff --git a/test/tree.test.ts b/test/tree.test.ts index a33ca185..54a067c5 100644 --- a/test/tree.test.ts +++ b/test/tree.test.ts @@ -293,27 +293,19 @@ describe("epics", () => { .rejects("quest/m0/loose/deeper/README.md: sits in quest/m0/loose, which has no README.md"); }); - // Trees from before directory membership still list each epic's children. - // Such a link is membership, so it counts once. + // An epic already waits for everything in its directory, so a link to any of + // it is the child list this format retired; `quest check --fix` drops it. test.each([ ["quest/README.md", "/quest/m0/README.md"], ["quest/m0/README.md", "/quest/m0/epic/README.md"], + ["quest/m0/README.md", "/quest/m0/epic/one.md"], ["quest/m0/epic/README.md", "/quest/m0/epic/one.md"], - ])("%s requiring its child %s", (readme, target) => { - const listed = tree().append(readme, `\n## Required\n\n- [Held](${target})\n`); - listed.accepts(); - expect(listed.ready()).toEqual(tree().ready()); - expect(listed.blockers(readme)).toEqual(tree().blockers(readme)); - }); - - // Only a child stands in for a link: a deeper quest, or a link to no quest, - // stays a blocker, as on a tree that listed children. - test.each([ - ["/quest/m0/epic/one.md", "quest/m0/epic/one.md"], - ["/quest/m0/gone.md", "Gone"], - ])("quest/m0/README.md requiring %s, which is not its child", (target, rendered) => { - const t = tree().append("quest/m0/README.md", `\n## Required\n\n- [Gone](${target})\n`); - expect(t.blockers("quest/m0/README.md")[0]).toBe(rendered); + ])("%s requiring %s, which it holds", (readme, target) => { + const t = tree().append(readme, `\n## Required\n\n- [Held](${target})\n`); + expect(t.findings()).toHaveLength(1); + t.rejects( + `requires ${target}, which this epic holds; an epic waits for everything in its directory, so remove the entry (quest check --fix does)`, + ); }); test("a quest required twice", () => { @@ -394,12 +386,14 @@ describe("finished milestones", () => { .accepts(); }); - // The root's listing of its milestones is membership, not a blocker. - test("the root's link to a finished milestone is allowed", () => { - tree() + // The root holds every milestone, so its link to one is that finding alone. + test("the root's link to a finished milestone is one finding", () => { + const t = tree() .append("quest/README.md", "\n## Required\n\n- [M0](/quest/m0/README.md)\n") - .remove("quest/m0/epic") - .accepts(); + .remove("quest/m0/epic"); + expect(t.findings()).toEqual([ + "quest/README.md:9: requires /quest/m0/README.md, which this epic holds; an epic waits for everything in its directory, so remove the entry (quest check --fix does)", + ]); }); test("removing the link readies the quest", () => { diff --git a/test/unblocked.test.ts b/test/unblocked.test.ts index 817b3b6b..5a3cfef5 100644 --- a/test/unblocked.test.ts +++ b/test/unblocked.test.ts @@ -47,12 +47,6 @@ test("deduplicates diamonds and counts work that still has another prerequisite" ]); }); -test("an epic's list of its own children is membership, not dependents", () => { - const spec = { "m0/epic/a.md": [], "m0/epic/b.md": ["m0/epic/a.md"], "m0/zero.md": [] }; - const listed = graph({ ...spec, "m0/epic/README.md": ["m0/epic/a.md", "m0/epic/b.md"] }); - expect(unblocked(listed)).toEqual(unblocked(graph(spec))); -}); - test("does not score membership, but reaches real dependents of nested epics", () => { const docs = graph({ "m0/epic/nested/a.md": [], From 3e64948bf47c5fc18db54eedc774da798bc7e1e4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 13:56:38 -0800 Subject: [PATCH 3/8] fix: dedupe unmigrated epic blockers; let the flaky fixer satisfy an older quest check --- assets/land-fix.md | 6 +++++- src/core/ready.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/assets/land-fix.md b/assets/land-fix.md index 4940c260..5a22adeb 100644 --- a/assets/land-fix.md +++ b/assets/land-fix.md @@ -170,7 +170,11 @@ commit on it only name in lowercase kebab-case, whose Goal says to fix the test or delete it and links each listed job and this failure's, which the failure log names; - `quest/m0/flaky/README.md`, the epic holding one quest per disabled test, - if it is missing. + if it is missing; +- only when the repository's own `quest check` demands it (an older Quest + that still requires epics to list their children), the link to the new + quest in that epic and, for a new epic, the epic's link in + `quest/m0/README.md`. Follow the repository's quest format, and run `quest check` when the repository has it. A disabled test that is another quest's acceptance test diff --git a/src/core/ready.ts b/src/core/ready.ts index 75a31be5..e9d9d3f2 100644 --- a/src/core/ready.ts +++ b/src/core/ready.ts @@ -97,7 +97,11 @@ function expand(docs: Doc[], byPath: Map, doc: Doc, stack: string[] const target = entry.target === null ? null : rooted(entry.target); found.push(blocker(docs, byPath, target !== null && byPath.has(target) ? target : null, entry.text, stack)); } - for (const child of children(docs, doc)) found.push(blocker(docs, byPath, child, child, stack)); + // An unmigrated epic may still list a child under `Required`; name it once. + const listed = new Set(found.map((b) => b.path)); + for (const child of children(docs, doc)) { + if (!listed.has(child)) found.push(blocker(docs, byPath, child, child, stack)); + } return found; } From e735ae9794390ed464ef22c8dc3cbda14bd52798 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 14:00:02 -0800 Subject: [PATCH 4/8] quest: strip m2's child list after merging main --- quest/m2/README.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/quest/m2/README.md b/quest/m2/README.md index 7e3130b7..7c9eca1c 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -5,15 +5,3 @@ Extend kix.quest with cloud runners, a native mobile app, runners shared with other projects, donated reviews and CI, stronger isolation, and additional login providers. This milestone does not gate the public release. - -## Required - -- [Cloud runners](/quest/m2/cloud/README.md) - one-click free cloud runners, API-key and donor funding, then paid compute -- [Native mobile app](/quest/m2/mobile-app.md) - push notifications with quick replies to decisions -- [Shared runners](/quest/m2/donor-runner.md) - offer your runner and accounts to other projects -- [Review and check automation](/quest/m2/automation/README.md) - donated reviews, CI, and a shared Nix cache -- [opencode reviewers](/quest/m2/review-harnesses.md) - `quest review` runs opencode read-only, for the council's DeepSeek member -- [Review council](/quest/m2/council.md) - `/quest-council` reviews a PR with one blind model per maker and merges their findings with agreement counts -- [Donor isolation](/quest/m2/donor-isolation.md) - stronger boundaries for untrusted shared jobs -- [ChatGPT hosted runs](/quest/m2/chatgpt-hosted.md) - conditional on provider approval and current terms -- [ChatGPT approval](/quest/m2/chatgpt-waitlist.md) - resolve hosted-use approval before implementation From 4bcc55f6b8f1c7ee6bb23bcdce6e5bd9123abf4d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 14:40:21 -0800 Subject: [PATCH 5/8] fix(check): drop held links after promoting headings and after mv Co-Authored-By: Claude Opus 5.5 --- src/cli/main.ts | 3 ++- src/cli/move.ts | 5 ++++- test/move.test.ts | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/cli/main.ts b/src/cli/main.ts index 6503cd1c..b74e9249 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -189,7 +189,8 @@ export function main(argv: string[], out: Output): number | Promise { throw new UsageError("--branch requires --base"); } if (options.fix) { - const edited = new Set([...fixLinks(root()), ...fixHeadings(root())]); + // Promote first: a promoted `Required` can hold links the cleanup then drops. + const edited = new Set([...fixHeadings(root()), ...fixLinks(root())]); for (const line of [...edited].sort()) print(line); } const tree = checkout(root(), warn); diff --git a/src/cli/move.ts b/src/cli/move.ts index 7906bb91..f5b7abe2 100644 --- a/src/cli/move.ts +++ b/src/cli/move.ts @@ -74,12 +74,15 @@ export async function moveQuest( mkdirSync(join(root, posix.dirname(to)), { recursive: true }); git(root, ["mv", "--", from, to]); write(root, edited.texts); + // The move can put a required quest inside the epic requiring it, which then holds it. + const holding = remove(sources(root), () => false); + write(root, holding); const warnings = edited.missed.map( (link) => `${relocate(link.path, from, to) ?? link.path}:${link.line}: links ${link.target} in a form mv cannot rewrite; update it by hand`, ); return { - changes: [`R ${from} -> ${to}`, ...modified(edited.texts)], + changes: [`R ${from} -> ${to}`, ...modified(new Map([...edited.texts, ...holding]))], warnings: [...warnings, ...mentions(root, from)], findings: findings(root), }; diff --git a/test/move.test.ts b/test/move.test.ts index bf2c7019..e1c3423e 100644 --- a/test/move.test.ts +++ b/test/move.test.ts @@ -187,6 +187,20 @@ describe("quest mv", () => { tree.accepts(); }); + test("drops a Required link the move puts inside the requiring epic", async () => { + const tree = committed( + Tree.baseline() + .write("quest/m0/a.md", "# [S] A\n\n## Goal\n\nG.\n") + .write("quest/m0/epic/README.md", `${EPIC_README.trimEnd()}\n\n## Required\n\n- [A](/quest/m0/a.md)\n`), + ); + const out = await quest(tree, "mv", "quest/m0/a.md", "quest/m0/epic/a.md"); + expect(out.code).toBe(0); + expect(out.stdout).toBe("R quest/m0/a.md -> quest/m0/epic/a.md\nM quest/m0/epic/README.md\n"); + expect(tree.read("quest/m0/epic/README.md")).not.toContain("a.md"); + expect(staged(tree)).toEqual(["M quest/m0/epic/README.md", "R quest/m0/a.md -> quest/m0/epic/a.md"]); + tree.accepts(); + }); + test("moves an epic into a new directory", async () => { const tree = Tree.committed(); const out = await quest(tree, "mv", "quest/m0/epic", "quest/m1/later/epic"); From 9dbdfae05640a0e23789a762243a89618a49b9b5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 14:44:39 -0800 Subject: [PATCH 6/8] fix(check): check repair preconditions before promoting headings Co-Authored-By: Claude Opus 5.5 --- src/cli/main.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cli/main.ts b/src/cli/main.ts index b74e9249..00d4a489 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -189,8 +189,9 @@ export function main(argv: string[], out: Output): number | Promise { throw new UsageError("--branch requires --base"); } if (options.fix) { - // Promote first: a promoted `Required` can hold links the cleanup then drops. - const edited = new Set([...fixHeadings(root()), ...fixLinks(root())]); + // fixLinks checks the repair preconditions before anything is written; it runs again + // after promotion, since a promoted `Required` can hold links it then drops. + const edited = new Set([...fixLinks(root()), ...fixHeadings(root()), ...fixLinks(root())]); for (const line of [...edited].sort()) print(line); } const tree = checkout(root(), warn); From 1f65a8b35558bf59173a3a393e1d265468c8e31a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 15:10:14 -0800 Subject: [PATCH 7/8] quest: keep main's sandbox PR-trigger notes Co-Authored-By: Claude Opus 5.5 --- quest/m0/kix/sandbox/README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/quest/m0/kix/sandbox/README.md b/quest/m0/kix/sandbox/README.md index 7c79e0cb..06a4b126 100644 --- a/quest/m0/kix/sandbox/README.md +++ b/quest/m0/kix/sandbox/README.md @@ -78,10 +78,11 @@ Shipped in [moq-dev/quest#212](https://github.com/moq-dev/quest/pull/212) opencode and srt and a scripted model, and `test/live/runner-sandbox-claude.test.ts` checks Claude Code's the same way, its scripted model set as the account's endpoint in its settings. It runs in `just runner-sandbox` - and a nightly GitHub-hosted workflow (`.github/workflows/sandbox.yml`), - not in `just test` or the merge queue, whose container can't mount - `/proc` for bwrap, so a bump that weakens the boundary fails the next - nightly run. Decided 2026-11-03 (user, /quest-complete), after the merge + and a GitHub-hosted workflow (`.github/workflows/sandbox.yml`), nightly + and on PRs that touch the flake, `nix/`, the dependencies, the runner, or + these tests, not in `just test` or the merge queue, whose container can't + mount `/proc` for bwrap, so a bump that weakens the boundary fails on its + own PR. Decided 2026-11-03 (user, /quest-complete), after the merge queue dropped [moq-dev/quest#404](https://github.com/moq-dev/quest/pull/404): its own CI, nightly, so landing never blocks on it. - Decided 2026-10-24 (user) in `/quest-plan`: split the remaining work into From e1a88cec87653e6d73c60a0a35c730fe00635dfc Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 16:45:16 -0800 Subject: [PATCH 8/8] Keep main's queue-watcher README text, dropping only its child list --- quest/m0/queue-watcher/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/quest/m0/queue-watcher/README.md b/quest/m0/queue-watcher/README.md index 37c8273f..11201104 100644 --- a/quest/m0/queue-watcher/README.md +++ b/quest/m0/queue-watcher/README.md @@ -126,8 +126,8 @@ recorded in [moq.pro's cutover](/quest/m0/queue-watcher/pro-cutover.md) and Shared shape, for the children to follow: -- The watcher is a long-running host daemon, one per repository (one per - base once [per-base instances](/quest/m0/queue-watcher/per-base.md) land), that reuses +- The watcher is a long-running host daemon, one per base branch of a + repository, each with its own clone, locks and state, that reuses the host gate's fixer infrastructure rather than a new one: moq-bot's App key through systemd `LoadCredential`, the ephemeral nspawn fixer container and its launcher, the detached fixer supervisor and its collection in