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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ docs/*
!docs/agent-coordination-plan.md
!docs/agent-handoff-plan.md
!docs/evidence-packages.md
!docs/kanban.md

# Sandbox bind-mount artifacts from user home (not project files).
# Root-anchored so legitimate nested files with these names are still tracked.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ When you're happy with the result, merge the branch back to main from the sideba
- **Understanding tours** — **Take Tour** beside **Review Plan** (and in the plan viewer's header) explains the plan; **Understand** on a changed-file row explains that file. Both open a short card tour: a gist, then 3–7 cards with **Ask** and **Go deeper**. Plan, document and agent tours read like slides: a claim, a few short points, and collapsed notes. Every answer becomes a card under the card it was asked on; ← → move along one level, ↓ ↑ go into and out of answers, and an outline of every card sits beside the card on wide windows. **Save tour** writes the tour with its answers to a `.tour.md` file in the worktree, which opens again from the canvas with **Open Tour**; **Reference in chat** puts the card on screen into the agent's prompt. See [docs/tour-outline.md](docs/tour-outline.md). They use the same code Q&A provider setting as the change tour and inline Q&A, and run without tools: a plan tour sends the plan markdown, a file tour sends the file plus its direct imports (size-capped; anything omitted is listed on the gist card). Unsaved tours live in memory for as long as the task panel is open and are discarded when you switch tasks. A reopened file tour whose file has changed shows a **Regenerate** notice instead of silently going stale. Free-form subsystem questions ("how does X work?") are not supported yet.
- **Task brief** — after each agent turn, a model rewrites a short brief from the turn's prompts, the agent's last message and the branch diff (the task-naming model, or Claude Sonnet when that is Claude): claims the diff does not show, what is still open, decisions the agent made on its own, changes beyond the goal, and what changed. A card above the agent's output shows what to do next, marks a new brief with a dot, and is tinted when a claim is not in the diff; click it for the full brief, where you can keep a decision or send the agent a correction. Off by default; turn it on under Settings → **Write task briefs**
- **Confidence ratings** — **Rate confidence** on the brief card (shown while Write task briefs is on) asks the agent to rate its work and publish it with the `confidence_publish` MCP tool. Scores are named by whoever rates (one plain score or your own dimensions), low ones are highlighted, and a rating is marked out of date once the code changes after it. Point the prompt at your own rating skill in Settings
- **Project Kanban board** — the board icon on a project row opens a board kept as plain Markdown in `KANBAN.md` (Obsidian Kanban compatible). Start a task from any card; the card follows its task to In progress, Review and Done, shows when the agent needs input, and links to the task. Cards can carry a checklist (acceptance criteria), dependencies and a GitHub issue, added from the board or the GitHub page. **Plan with agent** lets an agent break a goal into cards. See [docs/kanban.md](docs/kanban.md)
- **Notes panel per task** — jot ideas, then send the notes straight to the agent as a prompt
- **Canvas per task** — a Markdown file from the worktree rendered live as the agent writes it; edit it in place, or select a passage and send it to the agent with your question
- **Browser preview in the canvas** — run a local app in the task shell, open **+ → Browser**, and pick elements to reference in your prompt ([usage and limits](docs/browser-preview.md))
Expand Down
104 changes: 104 additions & 0 deletions docs/kanban.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Project Kanban board

Each code project can have a Kanban board, opened from the board icon on the
project's row in the sidebar (next to the GitHub icon). The board is a plain
Markdown file, `KANBAN.md`, in the project root: edit it on the board, in any
editor, or let an agent write it. The board follows changes on disk within a
couple of seconds.

## File format

<!-- prettier-ignore -->
```markdown
# Offline sync

Optional notes about the board.

## Backlog

- [ ] Conflict resolution UI
Show both versions side by side and let the user pick.
- [ ] Diff view for text fields
depends: Sync engine
issue: https://github.com/acme/app/issues/12

## In progress

- [ ] Sync engine
branch: `task/sync-engine`

## Done

- [x] Spike: evaluate CRDT libraries
```

- `##` headings are columns; top-level list items are cards; `- [x]` marks a card done.
- Lines indented under a card are its description (Markdown). `- [ ]` items in
it are the card's checklist, shown with progress on the card and given to a
task as acceptance criteria.
- `depends: <card title>` marks the card blocked until that card is done.
- `issue: <github.com URL>` links a GitHub issue or pull request.
- `` branch: `name` `` is written by the app when a task is started from the card.
- Anything else in a column (paragraphs, sub-headings, comments, code) is kept
where it is. Obsidian Kanban's front matter and settings block are kept too,
as are the file's line endings and byte-order mark. The parser follows
CommonMark for code fences, HTML comments and list items, so text inside them
is never read as board structure.
- Column names decide automation: the first "In progress"/"Doing" column is
where started cards go, "Review"/"QA" where finished ones go, "Done" where
merged ones go, and "Archive" is collapsed by default.

## Working with tasks

- **Start task** on a card (or `s`) opens the new-task form prefilled from the
card; nothing is sent until you create the task. Card text comes from a file
in the repository, so control and invisible characters are removed from the
prompt.
- Once a worktree task exists, its branch is recorded under the card and the
card moves to In progress. Direct-mode tasks share the base branch and are
not linked.
- The card then shows the task's state (working, needs input, failed, ready
for review), one line of agent activity and the PR checks. Only needs input
and failures are coloured; **Answer** jumps to the waiting agent pane.
- A card moves forward on its own, never backwards: to Review when the agent
signals done or opens a PR, to Done when the branch is merged from the app,
the task lands, or its PR merges on GitHub.
- Merging is not blocked by the board. Unstaged edits to `KANBAN.md` are set
aside before a merge and written back afterwards, whatever the merge does;
if the task's branch changed the board too, the local board wins and the
merged version stays in the commit. Staged board changes still block a
merge, like any other uncommitted change.

## Planning agent

**Plan with agent** opens a separate hidden agent session (not the GitHub
page's) in the project checkout, with a planning prompt in its prompt box. It
edits `KANBAN.md` directly and the board highlights the cards that changed.
An unsent draft in its prompt box is never replaced.

## GitHub issues

- **Add from GitHub** on the board picks open issues and pull requests; each
becomes a card with an `issue:` line. Only the title is copied: the issue
body stays on GitHub, where a task started from the card reads it.
- On the GitHub page, **Add to board** adds the open issue (or the selected
batch); an issue already on the board shows its column instead.
- A task started from an issue card is linked to the issue like one started
from the GitHub page, and its prompt asks for "Fixes #N" in commits and the PR.
- Nothing is written to GitHub automatically.

## Safety

- The main process reads and writes only `KANBAN.md` in the project's real
root, refuses symlinks, FIFOs, files over 1 MB and files that are not valid
UTF-8, and writes atomically.
- Every write names the text it expects to replace. The board re-applies the
user's change to the file as it is on disk when another writer (an editor,
the planning agent, the automation) got there first, so edits are not lost.

## Code

- `src/lib/kanban.ts`: parser, serializer and pure board operations.
- `src/store/kanban.ts`: file access, task linking, automation, GitHub helpers.
- `src/components/KanbanPage.tsx`, `KanbanCard.tsx`, `kanban.css`: the page.
- `electron/ipc/kanban.ts`: the main-process file access.
4 changes: 4 additions & 0 deletions electron/github/gh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ describe('describeGhError', () => {
expect(describeGhError(err)).toBe('spawn /bin/sh ENOENT');
});

it('explains a project without a GitHub remote', () => {
expect(describeGhError({ stderr: 'no git remotes found' })).toMatch(/no GitHub remote/);
});

it('explains missing auth', () => {
expect(describeGhError({ stderr: 'You are not logged into any GitHub hosts.' })).toMatch(
/not logged in/,
Expand Down
5 changes: 5 additions & 0 deletions electron/github/gh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ export function describeGhError(err: unknown): string {
if (/gh repo set-default/i.test(stderr)) {
return 'GitHub CLI does not know which repository to use. Run "gh repo set-default" in the project folder.';
}
if (
/no git remotes found|none of the git remotes .* point to a known github host/i.test(stderr)
) {
return 'This project has no GitHub remote, so there are no GitHub issues to show.';
}
if (/rate limit/i.test(stderr)) {
return 'GitHub API rate limit reached. Try again in a few minutes.';
}
Expand Down
2 changes: 2 additions & 0 deletions electron/ipc/channel-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@
"ReadFileText": "read_file_text",
"ReadFileTourContext": "read_file_tour_context",
"SaveTourFile": "save_tour_file",
"ReadKanbanBoard": "read_kanban_board",
"WriteKanbanBoard": "write_kanban_board",
"ListThinkingTours": "list_thinking_tours",
"ResolveClipboardPaste": "resolve_clipboard_paste",
"SaveDroppedImage": "save_dropped_image",
Expand Down
18 changes: 18 additions & 0 deletions electron/ipc/git-merge-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { blockingStatusLines } from './git.js';

describe('merge clean-tree guard', () => {
it('lets the Kanban board alone through, but nothing else', () => {
expect(blockingStatusLines(' M KANBAN.md\n')).toEqual([]);
expect(blockingStatusLines('?? KANBAN.md\n')).toEqual([]);
expect(blockingStatusLines(' M KANBAN.md\n M src/a.ts\n')).toEqual([' M src/a.ts']);
expect(blockingStatusLines(' M docs/KANBAN.md\n')).toEqual([' M docs/KANBAN.md']);
expect(blockingStatusLines('')).toEqual([]);
// Staged or conflicted board changes are not the app's doing and still block.
expect(blockingStatusLines('M KANBAN.md\n')).toEqual(['M KANBAN.md']);
expect(blockingStatusLines('MM KANBAN.md\n')).toEqual(['MM KANBAN.md']);
expect(blockingStatusLines('A KANBAN.md\n')).toEqual(['A KANBAN.md']);
expect(blockingStatusLines('UU KANBAN.md\n')).toEqual(['UU KANBAN.md']);
expect(blockingStatusLines('R a.md -> KANBAN.md\n')).toEqual(['R a.md -> KANBAN.md']);
});
});
100 changes: 100 additions & 0 deletions electron/ipc/git-merge-kanban.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Integration test against real git: unstaged edits to the project's Kanban
// board (KANBAN.md), which the app makes as tasks move, survive every merge
// outcome, including the `reset --hard` that recovers a failed squash merge.
import { it, expect, beforeAll, afterAll } from 'vitest';
import { execFileSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { mergeTask } from './git.js';

let base: string;

function run(cwd: string, args: string[]): string {
return execFileSync('git', args, { cwd, encoding: 'utf8' });
}

beforeAll(() => {
base = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-kanban-it-'));
});

afterAll(() => {
fs.rmSync(base, { recursive: true, force: true });
});

/** A repo with KANBAN.md committed on main and a task branch. */
function repo(): string {
const root = fs.mkdtempSync(path.join(base, 'repo-'));
run(root, ['init', '-b', 'main']);
run(root, ['config', 'user.email', 'test@test.local']);
run(root, ['config', 'user.name', 'Test']);
run(root, ['config', 'commit.gpgsign', 'false']);
fs.writeFileSync(path.join(root, 'README.md'), 'hello\n');
fs.writeFileSync(path.join(root, 'KANBAN.md'), '# Board\n\n## Todo\n');
run(root, ['add', '.']);
run(root, ['commit', '-m', 'initial']);
run(root, ['branch', 'task/a']);
return root;
}

const LOCAL = '# Board\n\n## Todo\n\n- [ ] Edited on the board\n';
const board = (root: string) => fs.readFileSync(path.join(root, 'KANBAN.md'), 'utf8');

it('merges with unstaged board edits and keeps them', async () => {
const root = repo();
run(root, ['checkout', 'task/a']);
fs.writeFileSync(path.join(root, 'feature.txt'), 'work\n');
run(root, ['add', '.']);
run(root, ['commit', '-m', 'work']);
run(root, ['checkout', 'main']);
fs.writeFileSync(path.join(root, 'KANBAN.md'), LOCAL);
await mergeTask(root, 'task/a', true, 'Squash', false, 'main');
expect(board(root)).toBe(LOCAL);
expect(fs.existsSync(path.join(root, 'feature.txt'))).toBe(true);
});

it('keeps board edits when a squash merge fails and is reset', async () => {
const root = repo();
// Nothing new on the branch: the squash commit fails and the merge resets hard.
fs.writeFileSync(path.join(root, 'KANBAN.md'), LOCAL);
await expect(mergeTask(root, 'task/a', true, 'Squash', false, 'main')).rejects.toThrow();
expect(board(root)).toBe(LOCAL);
});

it('keeps board edits when the task branch changed the board too', async () => {
const root = repo();
run(root, ['checkout', 'task/a']);
fs.writeFileSync(path.join(root, 'KANBAN.md'), '# Board\n\n## Todo\n\n- [ ] From the task\n');
run(root, ['commit', '-am', 'task edits board']);
run(root, ['checkout', 'main']);
fs.writeFileSync(path.join(root, 'KANBAN.md'), LOCAL);
await mergeTask(root, 'task/a', true, 'Squash', false, 'main');
// The local board wins; the task's version is in the commit.
expect(board(root)).toBe(LOCAL);
expect(run(root, ['show', 'HEAD:KANBAN.md'])).toContain('From the task');
});

it('keeps an untracked board through a merge', async () => {
const root = repo();
run(root, ['rm', '-q', 'KANBAN.md']);
run(root, ['commit', '-m', 'no board']);
run(root, ['branch', '-f', 'task/a']);
run(root, ['checkout', 'task/a']);
fs.writeFileSync(path.join(root, 'feature.txt'), 'work\n');
run(root, ['add', '.']);
run(root, ['commit', '-m', 'work']);
run(root, ['checkout', 'main']);
fs.writeFileSync(path.join(root, 'KANBAN.md'), LOCAL);
await mergeTask(root, 'task/a', false, null, false, 'main');
expect(board(root)).toBe(LOCAL);
});

it('still refuses to merge with staged board changes', async () => {
const root = repo();
fs.writeFileSync(path.join(root, 'KANBAN.md'), LOCAL);
run(root, ['add', 'KANBAN.md']);
await expect(mergeTask(root, 'task/a', true, 'Squash', false, 'main')).rejects.toThrow(
/uncommitted changes/,
);
expect(board(root)).toBe(LOCAL);
});
Loading
Loading