Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
# Keep bundled Markdown frontmatter and workflow scripts stable on every host.
* text=auto eol=lf

quest/** merge=quest
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,23 @@ branch or open PR on `origin`, or on the repository a GitHub fork came from,
since the branch would no longer name it.
Neither command touches branches or PRs.

## Merge quest lists

Concurrent PRs edit the same link lists: the base branch deletes finished
quests' lines while a branch inserts a new quest beside them. `quest init`
routes `quest/**` through a merge driver in `.gitattributes` and registers it
in the clone's git config, so a local `git merge` resolves those conflicts.

Inside a conflicting `Required`, `Related`, or `Closes` list, `quest resolve`
keeps the entries neither side removed and adds the ones either side
inserted, ours before theirs at the same place, matching entries by their
opening link. Prose, headings, an entry both sides rewrote, or one side
removed while the other edited, keep the usual conflict markers. Any `quest`
command registers the driver in a fresh clone or worktree whose
`.gitattributes` names it. Without `quest` on the merge's PATH, the driver
falls back to `git merge-file`, and GitHub's merges ignore drivers, so a PR
shows as conflicting until a local merge from its base is pushed.

## Wait for pull request activity

`quest wait` blocks until a GitHub PR has a review, comment, review-thread
Expand Down
9 changes: 6 additions & 3 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ quest init
Init writes a stub per skill under both `.claude/skills/` and `.agents/skills/`
without creating symbolic links (existing linked layouts continue to work), creates `quest/README.md` when missing, and appends a line
starting with `Quests: ` to the root `AGENTS.md`, or `CLAUDE.md` when only that
exists. It prints each path it changed and is safe to rerun.
exists. It also appends `quest/** merge=quest` to `.gitattributes` and sets
`merge.quest.driver` in the clone's git config, so local merges resolve
concurrent edits to quest link lists. It prints each path it changed and is
safe to rerun.

If init refuses, it names the conflicting path. Report it and ask the user how
to proceed; never delete or rename their files to get past it.
Expand Down Expand Up @@ -82,8 +85,8 @@ work or `/quest-import` to bring in open GitHub issues.

1. Run `quest skill export` and follow it, so active quests become GitHub
issues. Skip this only if the user says so.
2. Run `quest uninstall`. It removes only unmodified stubs and the `Quests: `
line, and never deletes the quest tree.
2. Run `quest uninstall`. It removes only unmodified stubs, the `Quests: `
line, and the merge driver, and never deletes the quest tree.
3. Remove the pin added above: the mise entry, or the flake input and dev
shell package. For an unpinned install, delete the `quest` binary the
installer placed (`~/.local/bin/quest` by default, or `quest.exe` in the
Expand Down
4 changes: 3 additions & 1 deletion assets/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ Quests are linked with root-absolute paths.
Find/create/update any references by searching for the path.

Finished quests are deleted; git history keeps them.
Merge conflicts are expected; resolve them by aligning quests.
Merge conflicts are expected.
A local merge resolves conflicting edits to link lists itself when `quest` is on PATH; GitHub's cannot, so merge the base branch locally and push.
Resolve the rest, such as prose, by aligning quests.

## Format

Expand Down
1 change: 0 additions & 1 deletion quest/m0/helpers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ commands from Sep-Oct. Session counts in each child come from that window.

- [Status holds](/quest/m0/helpers/status-holds.md) - `quest status` shows a PR's `quest:*` hold
- [Headless agents](/quest/m0/helpers/headless-agents.md) - `/quest-spawn api` launches, resumes, and stops billed agents through one command auto mode allows
- [Resolve](/quest/m0/helpers/resolve.md) - a local merge resolves concurrent link-list edits in quest files, set up by `quest init`

## Related

Expand Down
56 changes: 0 additions & 56 deletions quest/m0/helpers/resolve.md

This file was deleted.

10 changes: 10 additions & 0 deletions scripts/lifecycle-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ try {
execFileSync("git", ["-C", repository, "worktree", "add", "--quiet", "--detach", root]);
run(root, "init");
assert.equal(run(root, "init"), "");
// The merge driver probes for `resolve` with `quest help resolve`, which fails for an unknown command.
assertFile(root, ".gitattributes", "quest/** merge=quest\n");
assert.match(
execFileSync("git", ["-C", root, "config", "merge.quest.driver"], { encoding: "utf8" }),
/resolve/,
);
run(root, "help", "resolve");
assert.equal(spawnSync(command, [...prefix, "help", "unknown"]).status, 2);
for (const dir of destinations) {
assert(lstatSync(join(root, dir)).isDirectory(), `${dir} must not require symlink privileges`);
for (const skill of skills)
Expand Down Expand Up @@ -106,6 +114,8 @@ try {
assertFile(root, ".claude/skills/quest-plan/SKILL.md", edited);
assertFile(root, ".agents/skills/quest-start/notes.md", "User notes.\n");
assert(!existsSync(join(root, ".agents/skills/quest-start/SKILL.md")));
assert(!existsSync(join(root, ".gitattributes")));
assert.equal(spawnSync("git", ["-C", root, "config", "merge.quest.driver"]).status, 1);
for (const dir of originalDirs) assertFile(root, `${dir}/custom/SKILL.md`, `# Custom ${dir} workflow\n`);
run(root, "check");
assert.equal(run(root, "uninstall"), "");
Expand Down
34 changes: 31 additions & 3 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ import { gitHubDependencies, GitHubInputError, mergedHead, pullRequest, remoteUr
import { type Hold, type State, finish, land, release, take } from "./hold";
import { landed } from "./landed";
import { type Report, moveQuest, removeQuest } from "./move";
import { init, uninstall } from "./setup";
import { resolveFile } from "./resolve";
import { heal, init, uninstall } from "./setup";
import { Refusal, describe, notUpstream, releaseQuest, startQuest } from "./start";
import { GUIDE, SKILLS, skill } from "./skills";
import { type Terminal, Theme } from "./theme";
Expand Down Expand Up @@ -810,6 +811,28 @@ export function main(argv: string[], out: Output): number | Promise<number> {
code = report(removeQuest(root(), path, options.recursive ?? false));
});

program
.command("resolve")
.summary("Merge driver: resolve concurrent edits to a quest's link lists")
.description(
"Merge driver: merge <theirs> into <ours> from <base> by lines, as git merge-file does, and write the " +
"result to <ours>. A conflict that only edits the entries of one Required, Related, or Closes list is " +
"resolved: entries either side removed stay removed, entries either side added are kept in their " +
"place, ours before theirs, and an entry only one side changed takes that change. Entries match by " +
"their opening link. Any other conflict, including prose, a heading, an entry both sides changed, or a " +
"reordered list, keeps standard conflict markers, showing the base when merge.conflictStyle is diff3 " +
"or zdiff3. Exits 1 when a conflict remains.\n\n" +
"quest init registers it for quest/** as the merge.quest driver. Any other command registers the driver " +
"in the clone's config when .gitattributes names merge=quest and no config sets it. Without quest on " +
"the merge's PATH, the driver falls back to git merge-file. GitHub's merges ignore merge drivers.",
)
.argument("<ours>", "Our version, overwritten with the result (git's %A)")
.argument("<base>", "The common ancestor (git's %O)")
.argument("<theirs>", "Their version (git's %B)")
.action((ours: string, base: string, theirs: string) => {
if (resolveFile(ours, base, theirs) > 0) code = 1;
});

program
.command("guide")
.description("Print the quest guide: the format, workflow, and rules agents follow")
Expand Down Expand Up @@ -838,20 +861,25 @@ export function main(argv: string[], out: Output): number | Promise<number> {

program
.command("init")
.description("Install skill stubs, a quest root, and agent pointers for this repository")
.description("Install skill stubs, a quest root, agent pointers, and the merge driver for this repository")
.action(() => {
const changes = init(root());
for (const line of theme ? theme.init(changes) : changes) print(line);
});

program
.command("uninstall")
.description("Remove Quest stubs and markers installed by `quest init`")
.description("Remove Quest stubs, markers, and the merge driver installed by `quest init`")
.action(() => {
const changes = uninstall(root());
for (const line of theme ? theme.uninstall(changes) : changes) print(line);
});

// Fresh clones and worktrees get the merge driver from whichever command runs first.
program.hook("preAction", (_program, action) => {
if (!["init", "uninstall"].includes(action.name())) heal(root());
});

try {
program.parse(gateBoundary(argv), { from: "user" });
return pending ?? code;
Expand Down
70 changes: 70 additions & 0 deletions src/cli/resolve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// `quest resolve`: the merge driver for quest files. Git merges by line, then
// the core resolves the conflicts that only edit a link list.

import { spawnSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";

import { LABELS, chunks, render, resolveLists } from "../core";

/** Git's default conflict marker length. */
const MARKER_SIZE = 7;

/**
* The command `merge.quest.driver` runs. Git substitutes its temporary file
* names for `%A`, `%O`, and `%B` and runs it with a shell. A `quest` missing
* from the merge's PATH, or one without `resolve`, falls back to git's own
* merge with conflict markers: `quest help <unknown>` exits non-zero, while
* `quest <unknown> --help` prints the top-level help and exits zero.
*/
export const DRIVER =
"if quest help resolve >/dev/null 2>&1; " +
`then quest resolve %A %O %B; ` +
`else git merge-file -L ${LABELS.ours} -L ${LABELS.base} -L ${LABELS.theirs} %A %O %B; fi`;

/**
* Merge `theirs` into `ours` from their common `base`, as `git merge-file`
* does, writing the result to `ours`. Returns how many conflicts remain.
*/
export function resolveFile(ours: string, base: string, theirs: string): number {
// Markers longer than any run of marker characters in the inputs cannot be mistaken for content.
const texts = [ours, base, theirs].map((file) => readFileSync(file, "utf8"));
const longest = Math.max(
...texts.flatMap((text) => text.split("\n").map((line) => /^([<|=>])\1*/.exec(line)?.[0].length ?? 0)),
);
const size = Math.max(MARKER_SIZE, longest + 1);

const merged = spawnSync(
"git",
[
"merge-file",
"-p",
"--diff3",
`--marker-size=${size}`,
"-L",
LABELS.ours,
"-L",
LABELS.base,
"-L",
LABELS.theirs,
ours,
base,
theirs,
],
{ encoding: "utf8", maxBuffer: 1 << 30 },
);
if (merged.error) throw merged.error;
// git merge-file exits with the number of conflicts, capped at 127, or above that on failure.
if (merged.status === null || merged.status > 127) {
throw new Error(`git merge-file failed: ${merged.stderr.trim()}`);
}

const resolved = resolveLists(ours, chunks(merged.stdout, size));
writeFileSync(ours, render(resolved, MARKER_SIZE, diff3()));
return resolved.filter((chunk) => !Array.isArray(chunk)).length;
}

/** Whether unresolved conflicts show the base too, as `merge.conflictStyle` says. */
function diff3(): boolean {
const style = spawnSync("git", ["config", "--get", "merge.conflictStyle"], { encoding: "utf8" });
return ["diff3", "zdiff3"].includes(style.stdout?.trim() ?? "");
}
55 changes: 53 additions & 2 deletions src/cli/setup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// `quest init` and `quest uninstall`: install skill stubs and reverse it.
//
// Init touches only stubs, one reference line, and an empty `quest/README.md`
// when missing. Uninstall never deletes the quest tree.
// Init touches only stubs, one reference line, one `.gitattributes` line, an
// empty `quest/README.md` when missing, and the clone's merge driver config.
// Uninstall never deletes the quest tree.

import {
existsSync,
Expand All @@ -18,6 +19,8 @@ import {
} from "node:fs";
import { dirname, join, resolve } from "node:path";

import { gitOutput } from "./claims";
import { DRIVER } from "./resolve";
import { SKILLS } from "./skills";

/**
Expand All @@ -32,6 +35,9 @@ export const REFERENCE_LINE = "Quests: when work mentions a quest, run `quest gu
/** The skill directories Claude Code and Codex read; new installs use real directories. */
const SKILL_DIRS = [".claude/skills", ".agents/skills"] as const;

/** Routes quest files through the `quest` merge driver; uninstall removes lines starting with it. */
export const ATTRIBUTES_LINE = "quest/** merge=quest";

const QUEST_ROOT_README = "# Quests\n\n## Goal\n\nWhat this project is working toward.\n";

/**
Expand All @@ -58,6 +64,7 @@ export function init(root: string): string[] {
checkPath(root, readme, "file");
const instructions = ["AGENTS.md", "CLAUDE.md"].find((name) => isFile(join(root, name))) ?? "AGENTS.md";
checkPath(root, instructions, "file");
checkPath(root, ".gitattributes", "file");

const changes: string[] = [];
for (const { path, stub } of missing) {
Expand All @@ -73,9 +80,51 @@ export function init(root: string): string[] {
}

if (appendLine(root, instructions, REFERENCE_MARKER, REFERENCE_LINE)) changes.push(instructions);
if (appendLine(root, ".gitattributes", ATTRIBUTES_LINE, ATTRIBUTES_LINE)) changes.push(".gitattributes");
register(root);
return changes;
}

/**
* Register the merge driver when `root`'s `.gitattributes` names it, so fresh
* clones and worktrees get it from any command. Never fails.
*/
export function heal(root: string): void {
let named = false;
try {
named = lines(read(root, ".gitattributes")).some((line) => /(^|\s)merge=quest(\s|$)/.test(line));
} catch {
// No attributes, so no driver to register.
}
if (named) register(root);
}

/**
* Set `merge.quest.driver` in the clone's config when no config sets it, and
* only inside a git work tree: git ignores a driver named only in
* `.gitattributes`. A failed write is ignored, leaving git's own merge.
*/
function register(root: string): void {
try {
if (gitOutput(root, ["config", "--get", "merge.quest.driver"]) !== "") return;
if (gitOutput(root, ["rev-parse", "--is-inside-work-tree"]) !== "true") return;
gitOutput(root, ["config", "--local", "merge.quest.driver", DRIVER]);
} catch {
// Not a repository, or its config is not writable.
}
}

/** Remove the merge driver from the clone's config, inside a git work tree. */
function unregister(root: string): void {
try {
if (gitOutput(root, ["rev-parse", "--is-inside-work-tree"]) !== "true") return;
if (gitOutput(root, ["config", "--local", "--get", "merge.quest.driver"]) === "") return;
gitOutput(root, ["config", "--local", "--remove-section", "merge.quest"]);
} catch {
// Not a repository, or its config is not writable.
}
}

/** Check an install destination and its parents before any writes, following valid symlinks. */
function checkPath(root: string, path: string, expected: "directory" | "file"): void {
const parent = dirname(path);
Expand Down Expand Up @@ -126,6 +175,8 @@ export function uninstall(root: string): string[] {
for (const path of ["AGENTS.md", "CLAUDE.md"]) {
if (removeLine(root, path, REFERENCE_MARKER)) changes.push(path);
}
if (removeLine(root, ".gitattributes", ATTRIBUTES_LINE)) changes.push(".gitattributes");
unregister(root);
return changes;
}

Expand Down
1 change: 1 addition & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ export { comparePaths, normalize } from "./path";
export { type Blocker, blockers, label, lookup, ready, renderBlocker } from "./ready";
export { type Finding, ROOT, check, formatFinding } from "./rules";
export { type RankedQuest, unblocked } from "./unblocked";
export { type Chunk, type Clean, type Conflict, LABELS, chunks, render, resolveLists } from "./merge";
Loading
Loading