Compare commits

...

4 Commits

Author SHA1 Message Date
30b6f70f95 docs(changelog): v2.6.3-chatkey-and-skills
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:06:19 +00:00
c2b3e0a013 skills: committing-changes + using-worktrees judgment skills + AGENTS.md guidance
Two portable agent-judgment skills in data/skills/boocode/, externalizing when/how Opus commits and when it isolates work in a worktree, so weaker agents (opencode build agent, BooCoder) can approximate it. committing-changes: segment by concern, stage explicitly (never git add -A), draft scope-prefix messages, present-and-STOP — commit only on explicit command, never push, identity indifferentketchup. using-worktrees: the when-to-isolate heuristic (just-create-when-clear / propose-when-ambiguous / skip), stable-base mechanics, runtime-isolation caveat — deliberately autonomous vs committing's command-gate. Each has an eval.yaml (matching improving-boocode-guidance) with a negative-trigger task. AGENTS.md gets a parser-safe preamble (the registry throws on bare ## headings) pointing at both skills.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:04:48 +00:00
cb1846c0d5 feat(coder): re-key agent_sessions to (chat_id, agent) + worktrees table (P1.5-b)
The tab (a chat) is the context unit: two opencode tabs in one session are two independent agent contexts sharing one worktree. agent_sessions re-keys from (session_id, agent) to (chat_id, agent) — chat_id FK ON DELETE CASCADE (closing a tab ends its context); worktree_id and session_id become informational SET NULL columns. New worktrees table (one-per-session, survives session delete via session_id SET NULL) supersedes session_worktrees, which is defanged (CASCADE dropped) not yet removed. chat_id is threaded end-to-end: tasks.chat_id added, written by the coder message + skills routes from the frontend tab, read by runOpenCodeServerTask which falls back to resolve-or-create a chat for session-less creators (arena/MCP/new_task/generic) so ensureSession never gets a null key. Idempotent migration with a backfill-verify gate (0-row assertion after the test session was deleted). config_hash fingerprint logic preserved; one-worktree-per-session unchanged; runExternalAgent untouched. Column rename worktree_path -> path repointed at all five readers (server delete-guard, risk/stash endpoints, ensureSessionWorktree). Supersedes the earlier (worktree_id) draft.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:04:35 +00:00
f1a85627e4 fix(coder): strip dcp-message-id tags split across stream chunks
The dcp tag (<dcp-message-id>mNNNN</dcp-message-id>) is streamed token-by-token, so it arrives split across SSE deltas. The existing per-chunk stripDcpTags never sees a complete tag in any single fragment, so fragments pass through and the dispatcher reassembles the tag in textChunks (persisted + shown) — and the terminal message.part.updated path that would strip the full text is suppressed by the dedup gate. Add a stateful cross-chunk stripper (dcp-strip.ts: makeDcpStreamStripper) at the dispatcher's opencode frame boundary: it emits text that cannot be part of a forming tag, holds back only a trailing partial-tag prefix (without swallowing legitimate <…> content), and flushes at turn end. Fixes both live delta frames and persisted content. 11 unit tests incl. split-at-every-boundary and the documented per-chunk-fails case. opencode path only; ACP (goose/qwen/claude) untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 23:16:47 +00:00
18 changed files with 599 additions and 52 deletions

View File

@@ -2,6 +2,10 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
## v2.6.3-chatkey-and-skills — 2026-05-31
Three threads. **agent_sessions re-keyed to `(chat_id, agent)` (P1.5-b):** the tab (a chat) is now the agent-context unit, so two opencode tabs in one BooCode session are two independent contexts that share one worktree. `chat_id` is threaded end-to-end — `tasks.chat_id` added, stamped by the coder message + skills routes from the frontend tab, read by `runOpenCodeServerTask` which falls back to resolve-or-create a chat for session-less creators (arena/MCP/new_task/generic `/api/tasks`) so `ensureSession` never receives a degenerate `(null, agent)` key. A new first-class `worktrees` table (one-per-session, survives session delete via `session_id ON DELETE SET NULL`) supersedes `session_worktrees`, which is defanged (CASCADE dropped, not yet removed); `agent_sessions.chat_id` CASCADEs from `chats` (closing a tab ends its context) while `worktree_id`/`session_id` are informational `SET NULL`. The migration is idempotent with a backfill-verify gate; the live re-key was applied against an empty table after the 35-chat test session `20d28876` was deleted (backed up first). This corrects and supersedes an earlier draft that wrongly keyed on `(worktree_id, agent)`; the delete-guard from `v2.6.2-delete-guard-and-sse` is repointed here from `session_worktrees` to `worktrees` (`worktree_path``path`). **dcp-strip cross-chunk fix:** the `<dcp-message-id>` tag streams split across SSE deltas, which the per-chunk strip from `v2.6.1-phase1-opencode` missed — a stateful `makeDcpStreamStripper` at the dispatcher boundary holds back partial-tag tails so neither live frames nor persisted content carry the tag (11 unit tests). **Agent-judgment skills:** `committing-changes` (segment by concern, stage explicitly, present-and-stop, never push) and `using-worktrees` (the when-to-isolate heuristic, autonomous-when-clear vs committing's command-gate) land in `data/skills/boocode/` with eval.yamls, plus a parser-safe `data/AGENTS.md` preamble pointing at both.
## v2.6.2-delete-guard-and-sse — 2026-05-30
Two coder-side batches under one tag. **Session-delete work-loss guard:** deleting a BooChat session CASCADE-wipes its `session_worktrees` row, which would silently orphan uncommitted/unpushed/unmerged work — so the server's `DELETE /api/sessions/:id` now gates before the delete. It reads `session_worktrees` from the shared DB first (no row → chat-only session → delete immediately, zero round-trip), and for worktree-backed sessions calls a new BooCoder endpoint (`/worktree-risk`) that runs git on the host, since the container can't see `/tmp/booworktrees` — only the host systemd service can. `checkWorktreeWorkAtRisk` reports dirty/unpushed/unmerged via the audited `hostExec`+`shellEscape` path, default branch detected from `refs/remotes/origin/HEAD` (never the worktree's own branch, never hardcoded); any at-risk worktree returns 409 with per-worktree `RiskReport[]`, `force=true` bypasses, and the check is fail-closed (BooCoder unreachable also blocks — force still escapes). The sidebar renders a block dialog distinguishing work-at-risk (Commit/Stash/Force; stash uses `-u` and re-blocks on remaining commits) from couldn't-verify (Cancel/Force), and Commit never auto-commits. A follow-up fix gates the `unpushed` arm behind an actual upstream (`atRisk = dirty || unmerged > 0 || (hasUpstream && unpushed > 0)`) so the no-upstream `session-<id>` branches stop flagging every pristine worktree-backed session — no protection lost, since real local work always also surfaces as `unmerged > 0`. **Per-session SSE (P1.5-a):** replaces the single global SSE loop scoped to the most-recent worktree directory — the known limit flagged in `v2.6.1-phase1-opencode` — with one `event.subscribe({directory})` per live opencode session, so sessions in different worktrees stream concurrently instead of the second silently dropping the first's events. Each session owns an `AbortController` wired into `subscribe(…, {signal})`, which also fixes a latent Phase-1 bug where switching directories left the old loop parked forever in its `for await` (zombie loops); a `sessionID` demux guard drops cross-session events so two sessions sharing a worktree (possible after P1.5-b) don't double-process deltas. The opencode SDK was confirmed to open an independent SSE connection per `subscribe()` call, so N concurrent dir-scoped streams are supported.

View File

@@ -224,8 +224,8 @@ export function registerMessageRoutes(
// External provider: create a task for the dispatcher
const projectId = sessionRows[0]!.project_id;
const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id)
VALUES (${projectId}, ${content}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId})
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id, chat_id)
VALUES (${projectId}, ${content}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId}, ${chatId})
RETURNING id, state
`;
reply.code(202);

View File

@@ -91,8 +91,8 @@ export function registerSkillRoutes(
const taskInput = `${body}\n\n---\n\n${userText}`;
const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id)
VALUES (${sessionRows[0]!.project_id}, ${taskInput}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId})
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id, chat_id)
VALUES (${sessionRows[0]!.project_id}, ${taskInput}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId}, ${chatId})
RETURNING id, state
`;
await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;

View File

@@ -18,7 +18,7 @@ export function registerWorktreeSafetyRoutes(app: FastifyInstance, sql: Sql): vo
'/api/sessions/:sessionId/worktree-risk',
async (req) => {
const rows = await sql<{ worktree_path: string }[]>`
SELECT worktree_path FROM session_worktrees WHERE session_id = ${req.params.sessionId}
SELECT path AS worktree_path FROM worktrees WHERE session_id = ${req.params.sessionId}
`;
const reports = [];
for (const row of rows) {
@@ -33,7 +33,7 @@ export function registerWorktreeSafetyRoutes(app: FastifyInstance, sql: Sql): vo
'/api/sessions/:sessionId/worktree-stash',
async (req) => {
const rows = await sql<{ worktree_path: string }[]>`
SELECT worktree_path FROM session_worktrees WHERE session_id = ${req.params.sessionId}
SELECT path AS worktree_path FROM worktrees WHERE session_id = ${req.params.sessionId}
`;
const results = [];
for (const row of rows) {

View File

@@ -83,16 +83,20 @@ CREATE TABLE IF NOT EXISTS session_worktrees (
base_commit TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
-- Migrate existing FK to CASCADE (idempotent: drops the old constraint if present).
-- P1.5-b: DEFANG the CASCADE — a session delete must no longer wipe its worktree
-- row. This table is SUPERSEDED by `worktrees` below; all readers are repointed
-- this phase, so the row just persists (dead) on session delete until a later
-- cleanup drops the table. session_id is this table's PRIMARY KEY, so it cannot be
-- nullable → SET NULL is invalid and NO ACTION/RESTRICT would block deletes; the
-- only valid defang is to drop the FK with no replacement. Idempotent: only fires
-- while the FK is still ON DELETE CASCADE ('c').
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'session_worktrees_session_id_fkey'
AND confdeltype <> 'c'
AND confdeltype = 'c'
) THEN
ALTER TABLE session_worktrees DROP CONSTRAINT session_worktrees_session_id_fkey;
ALTER TABLE session_worktrees ADD CONSTRAINT session_worktrees_session_id_fkey
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE;
END IF;
END $$;
@@ -127,6 +131,80 @@ END $$;
-- v2.6: config fingerprint for stale-session detection (auto-recover on model change).
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS config_hash TEXT;
-- ─── P1.5-b (corrected): worktrees entity + re-key agent_sessions to (chat_id, agent) ───
-- The TAB (a chat) is the context unit: two opencode tabs in one session = two
-- independent contexts sharing one worktree. So agent_sessions keys on
-- (chat_id, agent), NOT (worktree_id, agent) or (session_id, agent). The
-- `worktrees` table is one-per-session (selectable later) and only referenced
-- informationally by agent_sessions.worktree_id (SET NULL); chat_id is the key.
--
-- PREREQUISITE: the unmigratable test session (35 chats, 1 agent_sessions row that
-- maps to no single chat) is DELETED before this runs, so agent_sessions is empty
-- and the chat_id backfill is N/A. If a row with NULL chat_id remains, the verify
-- gate below RAISEs and aborts — delete the offending session first.
-- worktree as a first-class entity; survives session delete (session_id SET NULL).
CREATE TABLE IF NOT EXISTS worktrees (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID REFERENCES sessions(id) ON DELETE SET NULL,
project_id UUID,
path TEXT NOT NULL,
branch TEXT,
base_commit TEXT,
slug TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived')),
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE UNIQUE INDEX IF NOT EXISTS worktrees_active_path_uidx ON worktrees(path) WHERE status='active';
-- Migrate any surviving session_worktrees rows → worktrees (idempotent; 0 rows
-- after the test-session delete, kept for generality / fresh-DB safety).
INSERT INTO worktrees (session_id, path, branch, base_commit, status)
SELECT sw.session_id, sw.worktree_path, 'session-' || sw.session_id, sw.base_commit, 'active'
FROM session_worktrees sw
WHERE NOT EXISTS (SELECT 1 FROM worktrees w WHERE w.session_id = sw.session_id AND w.status='active');
-- Dispatch hint: which chat (tab) a task belongs to. The coder message route and
-- skills route set it from the frontend tab; session-less creators (arena, MCP,
-- new_task, generic /api/tasks) leave it NULL and the dispatcher creates a chat.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS chat_id UUID REFERENCES chats(id) ON DELETE SET NULL;
-- Re-key columns on agent_sessions.
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS chat_id UUID;
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS worktree_id UUID;
-- BACKFILL-VERIFY GATE: the new PK is (chat_id, agent), so chat_id must be
-- non-null on every row before the swap. With the test session deleted this is a
-- 0-row assertion; if any row has NULL chat_id (an unmigratable pre-existing row),
-- abort loudly rather than create a degenerate (NULL, agent) key.
DO $$
DECLARE n int;
BEGIN
SELECT count(*) INTO n FROM agent_sessions WHERE chat_id IS NULL;
IF n > 0 THEN
RAISE EXCEPTION 'P1.5-b: % agent_sessions row(s) have NULL chat_id — delete the unmigratable session(s) before applying', n;
END IF;
END $$;
-- Swap PK (session_id,agent) → (chat_id,agent) + FKs (run-once, guarded on the new
-- FK's absence). chat_id CASCADEs from chats (closing a tab ends its context);
-- worktree_id is informational SET NULL; session_id defanged to nullable SET NULL.
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agent_sessions_chat_id_fkey') THEN
ALTER TABLE agent_sessions DROP CONSTRAINT IF EXISTS agent_sessions_pkey;
ALTER TABLE agent_sessions DROP CONSTRAINT IF EXISTS agent_sessions_session_id_fkey;
ALTER TABLE agent_sessions ALTER COLUMN session_id DROP NOT NULL;
ALTER TABLE agent_sessions ALTER COLUMN chat_id SET NOT NULL;
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_pkey PRIMARY KEY (chat_id, agent);
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_chat_id_fkey
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_session_id_fkey
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE SET NULL;
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_worktree_id_fkey
FOREIGN KEY (worktree_id) REFERENCES worktrees(id) ON DELETE SET NULL;
END IF;
END $$;
-- v2.6: attribution for DiffPanel badges (Phase 1 UX reads this).
ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT;

View File

@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { stripDcpTags, makeDcpStreamStripper } from '../dcp-strip.js';
// Feed chunks through a fresh stripper and return the fully reassembled output
// (everything emitted during streaming + the final flush) — i.e. what the
// dispatcher would accumulate into the persisted message content.
function run(chunks: string[]): string {
const s = makeDcpStreamStripper();
let out = '';
for (const c of chunks) out += s.push(c);
out += s.flush();
return out;
}
describe('stripDcpTags (one-shot)', () => {
it('removes a complete tag', () => {
expect(stripDcpTags('Yes — "Test".\n\n<dcp-message-id>m0019</dcp-message-id>')).toBe(
'Yes — "Test".\n\n',
);
});
it('leaves text without a tag untouched', () => {
expect(stripDcpTags('no tag here')).toBe('no tag here');
});
});
describe('per-chunk strip is INSUFFICIENT (documents the bug)', () => {
it('a tag split across chunks survives a naive per-chunk .replace()', () => {
const chunks = ['Yes.\n\n<dcp', '-message', '-id>m0019</dcp', '-message-id>'];
const naive = chunks.map(stripDcpTags).join('');
// The reassembled content still contains the tag — this is the screenshot bug.
expect(naive).toContain('<dcp-message-id>m0019</dcp-message-id>');
});
});
describe('makeDcpStreamStripper (cross-chunk fix)', () => {
it('strips a tag split across chunks (the real opencode case)', () => {
expect(run(['Yes.\n\n<dcp', '-message', '-id>m0019</dcp', '-message-id>'])).toBe('Yes.\n\n');
});
it('strips a tag split at EVERY character boundary', () => {
const full = 'Answer.<dcp-message-id>m0019</dcp-message-id>';
expect(run([...full])).toBe('Answer.');
});
it('strips a tag delivered whole in one chunk', () => {
expect(run(['Answer.<dcp-message-id>m0019</dcp-message-id>'])).toBe('Answer.');
});
it('passes through text with no tag', () => {
expect(run(['hello ', 'world'])).toBe('hello world');
});
it('does NOT swallow legitimate < content (code/HTML/generics)', () => {
expect(run(['use ', '<div>', ' and ', 'Array<', 'string>'])).toBe('use <div> and Array<string>');
});
it('handles a lone < that is not a dcp tag, split across chunks', () => {
expect(run(['a <', 'b c'])).toBe('a <b c');
});
it('emits surrounding text and strips a mid-text tag', () => {
expect(run(['before ', '<dcp-message-id>', 'm1', '</dcp-message-id>', ' after'])).toBe(
'before after',
);
});
it('flushes a truncated/never-closed partial tag without leaking it as a complete tag', () => {
// If the stream ends mid-tag, flush strips complete tags; an incomplete
// remnant is returned as-is (no complete tag ever existed to render).
const out = run(['done.<dcp-message-id>m00']);
expect(out).not.toContain('</dcp-message-id>');
});
});

View File

@@ -37,8 +37,15 @@ export interface EnsureSessionOpts {
agent: string;
/** Resolved model id. */
model: string;
/** P1.5-b: the chat (tab) this turn belongs to. agent_sessions is keyed
* (chat_id, agent) — the tab/chat is the context unit. Always non-null:
* the dispatcher creates a chat for session-less tasks before calling. */
chatId: string;
/** Shared per-session worktree (one per `sessions.id`, not per pane). */
worktreePath: string;
/** P1.5-b: the `worktrees.id` for this session's worktree — stored on the
* agent_sessions row informationally (NOT the key). */
worktreeId: string;
projectId: string;
}
@@ -47,6 +54,10 @@ export interface AgentSessionHandle {
sessionId: string;
agent: string;
backend: AgentBackendKind;
/** P1.5-b: the chat (tab) this session is keyed on (with agent). */
chatId: string;
/** P1.5-b: the worktree this session's chat runs in (informational link). */
worktreeId: string;
/** Provider's own session id (resume token); null until the backend assigns one. */
agentSessionId: string | null;
/** opencode HTTP server port; null for ACP backends. */

View File

@@ -423,9 +423,12 @@ export class OpenCodeServerBackend implements AgentBackend {
if (!this.client) throw new Error('opencode-server: client not ready after ensureServer');
const configHash = sessionConfigHash(opts.model);
// P1.5-b: agent_sessions is keyed (chat_id, agent) — the tab/chat is the
// context unit (two tabs in one session = two contexts sharing one worktree).
// session_id + worktree_id are retained as informational (SET NULL) columns.
const [row] = await this.sql<{ agent_session_id: string | null; status: string; config_hash: string | null }[]>`
SELECT agent_session_id, status, config_hash FROM agent_sessions
WHERE session_id = ${sessionId} AND agent = ${opts.agent}
WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent}
`;
let agentSessionId = row?.agent_session_id ?? null;
@@ -447,10 +450,12 @@ export class OpenCodeServerBackend implements AgentBackend {
agentSessionId = created.data.id;
await this.sql`
INSERT INTO agent_sessions
(session_id, agent, backend, agent_session_id, server_port, status, last_active_at, config_hash)
(chat_id, session_id, worktree_id, agent, backend, agent_session_id, server_port, status, last_active_at, config_hash)
VALUES
(${sessionId}, ${opts.agent}, 'opencode_server', ${agentSessionId}, ${this.port}, 'active', clock_timestamp(), ${configHash})
ON CONFLICT (session_id, agent) DO UPDATE SET
(${opts.chatId}, ${sessionId}, ${opts.worktreeId}, ${opts.agent}, 'opencode_server', ${agentSessionId}, ${this.port}, 'active', clock_timestamp(), ${configHash})
ON CONFLICT (chat_id, agent) DO UPDATE SET
session_id = EXCLUDED.session_id,
worktree_id = EXCLUDED.worktree_id,
backend = 'opencode_server',
agent_session_id = EXCLUDED.agent_session_id,
server_port = EXCLUDED.server_port,
@@ -462,7 +467,7 @@ export class OpenCodeServerBackend implements AgentBackend {
await this.sql`
UPDATE agent_sessions
SET status = 'active', last_active_at = clock_timestamp(), server_port = ${this.port}, config_hash = ${configHash}
WHERE session_id = ${sessionId} AND agent = ${opts.agent}
WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent}
`;
}
@@ -498,6 +503,8 @@ export class OpenCodeServerBackend implements AgentBackend {
sessionId,
agent: opts.agent,
backend: 'opencode_server',
chatId: opts.chatId,
worktreeId: opts.worktreeId,
agentSessionId: ocSessionId,
serverPort: this.port,
};
@@ -593,7 +600,7 @@ export class OpenCodeServerBackend implements AgentBackend {
}
await this.sql`
UPDATE agent_sessions SET status = 'closed'
WHERE session_id = ${handle.sessionId} AND agent = ${handle.agent}
WHERE chat_id = ${handle.chatId} AND agent = ${handle.agent}
`.catch(() => {});
}

View File

@@ -0,0 +1,77 @@
/**
* Strip opencode-dcp plugin tags (`<dcp-message-id>mNNNN</dcp-message-id>`) that
* the @tarquinen/opencode-dcp plugin appends to assistant text and which
* otherwise render as literal text in the UI.
*
* Why a streaming stripper and not a per-chunk `.replace()`: opencode streams
* assistant text token-by-token, so the tag arrives SPLIT across many SSE deltas
* (`<dcp`, `-message`, `-id>`, `m0019`, `</dcp`, …). A per-chunk regex never sees
* a complete tag in any single fragment, so the fragments pass through and the
* dispatcher reassembles the full tag in the persisted/displayed content. The
* stripper below buffers across chunks: it emits everything that cannot be part
* of a forming tag and holds back only a trailing partial-tag prefix until the
* next chunk resolves it — without holding back legitimate `<…>` content.
*/
const DCP_TAG_RE = /<dcp-message-id>[^<]*<\/dcp-message-id>/g;
const OPEN = '<dcp-message-id>';
const CLOSE = '</dcp-message-id>';
/** One-shot strip of COMPLETE tags. Safe for non-streaming / final content. */
export function stripDcpTags(s: string): string {
return s.replace(DCP_TAG_RE, '');
}
/**
* Could `tail` (a substring starting at a `<`) still grow into a complete dcp
* tag on a future chunk? If so the caller must hold it back rather than emit it.
* Returns false for unrelated `<` content (`<div>`, `<T>`, …) so those stream
* normally.
*/
function isPartialDcp(tail: string): boolean {
// A prefix of the opening marker: '<', '<d', …, '<dcp-message-id'.
if (OPEN.startsWith(tail)) return true;
// Opening marker fully seen — content (and maybe a forming close) still streaming.
if (tail.startsWith(OPEN)) {
const rest = tail.slice(OPEN.length);
const lt = rest.indexOf('<');
if (lt === -1) return true; // still inside the [^<]* content run
return CLOSE.startsWith(rest.slice(lt)); // a partial close marker forming
}
return false;
}
export interface DcpStreamStripper {
/** Feed one text chunk; returns the portion safe to emit now (may be ''). */
push(chunk: string): string;
/** Stream end: returns whatever was held back, with complete tags stripped. */
flush(): string;
}
/** Stateful, cross-chunk-safe dcp stripper. One instance per turn. */
export function makeDcpStreamStripper(): DcpStreamStripper {
let buf = '';
return {
push(chunk: string): string {
buf += chunk;
buf = buf.replace(DCP_TAG_RE, ''); // drop any now-complete tags
// Find the earliest `<` whose suffix is a forming dcp tag; hold from there,
// emit everything before it (real text, including unrelated `<…>`).
for (let i = buf.indexOf('<'); i !== -1; i = buf.indexOf('<', i + 1)) {
if (isPartialDcp(buf.slice(i))) {
const emit = buf.slice(0, i);
buf = buf.slice(i);
return emit;
}
}
const emit = buf;
buf = '';
return emit;
},
flush(): string {
const out = stripDcpTags(buf);
buf = '';
return out;
},
};
}

View File

@@ -4,6 +4,7 @@ import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames';
import type { Config } from '../config.js';
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
import { makeDcpStreamStripper } from './dcp-strip.js';
import { dispatchViaAcp } from './acp-dispatch.js';
import { getResolvedRegistry } from './provider-config-registry.js';
import { dispatchViaPty } from './pty-dispatch.js';
@@ -77,8 +78,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
chat_id: string | null;
}[]>`
SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id
SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id, chat_id
FROM tasks
WHERE state = 'pending'
ORDER BY created_at
@@ -109,6 +111,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
chat_id: string | null;
}): Promise<void> {
const taskId = task.id;
@@ -510,6 +513,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
chat_id: string | null;
},
installPath: string | null,
): Promise<void> {
@@ -542,10 +546,18 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
WHERE id = ${taskId}
`;
// Resolve session + chat (mirrors runExternalAgent).
// Resolve session + chat. P1.5-b: the chat (tab) is the context key, so the
// chat_id MUST be non-null and stable before ensureSession. The coder message
// route + skills route stamp task.chat_id with the frontend tab's chat — use
// it directly. Session-less creators (arena, MCP, new_task, generic
// /api/tasks) leave it null; fall back to resolving/creating a real chat so
// ensureSession never receives a degenerate (null, agent) key.
let sessionId: string;
let chatId: string;
if (task.session_id) {
if (task.chat_id && task.session_id) {
sessionId = task.session_id;
chatId = task.chat_id;
} else if (task.session_id) {
sessionId = task.session_id;
const chats = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at DESC LIMIT 1
@@ -586,7 +598,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
// Persistent, session-keyed worktree (shared across turns; NOT torn down
// per turn — Phase 3 reaps it). Captures base_commit for a stable diff.
const { worktreePath, baseCommit } = await ensureSessionWorktree(sql, projectPath, sessionId, {
const { worktreeId, worktreePath, baseCommit } = await ensureSessionWorktree(sql, projectPath, sessionId, {
signal: ac.signal,
});
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready');
@@ -620,21 +632,30 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
const textChunks: string[] = [];
const reasoningChunks: string[] = [];
const toolSnaps = new Map<string, AcpToolSnapshot>();
// opencode's dcp plugin appends <dcp-message-id>…</dcp-message-id> to the
// text, streamed split across deltas — a per-chunk regex misses it (see
// dcp-strip.ts). Buffer text through a cross-chunk stripper so neither the
// live `delta` frames nor the persisted content ever carry the tag.
const dcp = makeDcpStreamStripper();
// Map transport-agnostic AgentEvents → the SAME WS frames the ACP path emits.
// This boundary is where message_id/chat_id get attached (the backend never
// owns them).
const onEvent = (e: AgentEvent): void => {
switch (e.type) {
case 'text':
textChunks.push(e.text);
case 'text': {
const safe = dcp.push(e.text);
if (safe) {
textChunks.push(safe);
broker.publishFrame(sessionId, {
type: 'delta',
message_id: assistantId,
chat_id: chatId,
content: e.text,
content: safe,
} as WsFrame);
}
break;
}
case 'reasoning':
reasoningChunks.push(e.text);
broker.publishFrame(sessionId, {
@@ -670,7 +691,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
const handle = await backend.ensureSession(sessionId, {
agent,
model,
chatId,
worktreePath,
worktreeId,
projectId: task.project_id,
});
const result = await backend.prompt(handle, task.input, {
@@ -680,6 +703,18 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
onEvent,
});
// Flush any text held back mid-tag at stream end (complete tags stripped).
const dcpTail = dcp.flush();
if (dcpTail) {
textChunks.push(dcpTail);
broker.publishFrame(sessionId, {
type: 'delta',
message_id: assistantId,
chat_id: chatId,
content: dcpTail,
} as WsFrame);
}
const assistantContent = textChunks.join('').slice(0, 50_000);
const reasoningText = reasoningChunks.join('').slice(0, 200_000);
const outputSummary = (result.ok ? textChunks.join('') : result.error ?? 'opencode turn failed').slice(0, 500);

View File

@@ -119,16 +119,18 @@ export async function cleanupWorktree(
// ─── v2.6: session-keyed persistent worktree ────────────────────────────────
export interface SessionWorktree {
/** P1.5-b: the `worktrees.id` — stored on agent_sessions informationally. */
worktreeId: string;
worktreePath: string;
baseCommit: string | null;
}
/**
* v2.6: create-or-reuse ONE worktree per BooCode session (shared across all
* agents/turns in the session), recorded in `session_worktrees`. Unlike the
* per-task `createWorktree`, this persists — it is NOT torn down per turn
* (cleanup is Phase 3). Captures the project's current HEAD as `base_commit`
* so the accumulating diff has a stable baseline across turns.
* v2.6 / P1.5-b: create-or-reuse ONE worktree per BooCode session (shared across
* all tabs/agents in the session), recorded in `worktrees` (was the superseded
* `session_worktrees`). Persists — NOT torn down per turn (cleanup is Phase 3) —
* and now survives session delete (`worktrees.session_id` is ON DELETE SET NULL).
* Captures the project's current HEAD as `base_commit` for a stable diff baseline.
*
* Distinct path namespace (`session-<id>` branch, `/sess-<id>` dir) so it never
* collides with the per-task worktrees that arena/new_task/MCP still use.
@@ -139,11 +141,13 @@ export async function ensureSessionWorktree(
sessionId: string,
opts?: { signal?: AbortSignal },
): Promise<SessionWorktree> {
const [existing] = await sql<{ worktree_path: string; base_commit: string | null }[]>`
SELECT worktree_path, base_commit FROM session_worktrees WHERE session_id = ${sessionId}
const [existing] = await sql<{ id: string; path: string; base_commit: string | null }[]>`
SELECT id, path, base_commit FROM worktrees
WHERE session_id = ${sessionId} AND status = 'active'
LIMIT 1
`;
if (existing) {
return { worktreePath: existing.worktree_path, baseCommit: existing.base_commit };
return { worktreeId: existing.id, worktreePath: existing.path, baseCommit: existing.base_commit };
}
const worktreePath = `${WORKTREE_BASE}/sess-${sessionId}`;
@@ -167,17 +171,28 @@ export async function ensureSessionWorktree(
throw new Error(`Failed to create session worktree: ${result.stderr.trim() || result.stdout.trim()}`);
}
// Persist. ON CONFLICT keeps the first writer's row if two turns race the create.
await sql`
INSERT INTO session_worktrees (session_id, worktree_path, base_commit)
VALUES (${sessionId}, ${worktreePath}, ${baseCommit})
ON CONFLICT (session_id) DO NOTHING
// Insert-or-get: WHERE NOT EXISTS keeps the first writer's row if two turns race
// the create (the partial unique on active path also backstops it).
const [inserted] = await sql<{ id: string; path: string; base_commit: string | null }[]>`
INSERT INTO worktrees (session_id, path, branch, base_commit, status)
SELECT ${sessionId}, ${worktreePath}, ${branchName}, ${baseCommit}, 'active'
WHERE NOT EXISTS (
SELECT 1 FROM worktrees WHERE session_id = ${sessionId} AND status = 'active'
)
RETURNING id, path, base_commit
`;
const [row] = await sql<{ worktree_path: string; base_commit: string | null }[]>`
SELECT worktree_path, base_commit FROM session_worktrees WHERE session_id = ${sessionId}
if (inserted) {
return { worktreeId: inserted.id, worktreePath: inserted.path, baseCommit: inserted.base_commit };
}
// Lost the race — another turn inserted first; read its row.
const [row] = await sql<{ id: string; path: string; base_commit: string | null }[]>`
SELECT id, path, base_commit FROM worktrees
WHERE session_id = ${sessionId} AND status = 'active'
LIMIT 1
`;
return {
worktreePath: row?.worktree_path ?? worktreePath,
worktreeId: row!.id,
worktreePath: row?.path ?? worktreePath,
baseCommit: row?.base_commit ?? baseCommit,
};
}

View File

@@ -432,16 +432,18 @@ export function registerSessionRoutes(
const id = req.params.id;
const force = req.query.force === 'true' || req.query.force === '1';
// Session-delete work-loss guard. CASCADE on session_worktrees means the
// DELETE below auto-wipes the worktree row, so the safety check MUST run
// BEFORE it (paths read while the row still exists, pre-CASCADE).
// Session-delete work-loss guard. The check MUST run BEFORE the DELETE:
// worktrees.session_id is ON DELETE SET NULL (P1.5-b), so once the session
// is gone the worktree rows no longer point back to it — read them while
// the link still exists.
//
// Optimization: read session_worktrees from our own (shared) DB first.
// No row => chat-only session => nothing on disk => delete immediately,
// zero round-trip. Only worktree-backed sessions pay the host git check.
// Optimization: read worktrees (P1.5-b — was session_worktrees) from our
// own (shared) DB first. No row => chat-only session => nothing on disk =>
// delete immediately, zero round-trip. Only worktree-backed sessions pay
// the host git check.
if (!force) {
const worktrees = await sql<{ worktree_path: string }[]>`
SELECT worktree_path FROM session_worktrees WHERE session_id = ${id}
const worktrees = await sql<{ path: string }[]>`
SELECT path FROM worktrees WHERE session_id = ${id}
`;
if (worktrees.length > 0) {
// Worktree dirs live on the host; only BooCoder can run git on them.

View File

@@ -1,5 +1,11 @@
# Agents
Operating rules for every agent in this registry. Full procedures live in the `committing-changes` and `using-worktrees` skills.
**Committing** — Commit only on Sam's explicit command, never autonomously and never on apply; never `git push` (Sam pushes manually, Gitea + GitHub mirror). Stage by concern (named files or `git add -p`), never `git add -A`; never stage Sam's unrelated work. Identity `indifferentketchup` / `sam@indifferentketchup.com`, never a personal Gmail. Freeform scope-prefix messages, explain *why* for non-obvious changes, no emojis. Full workflow: invoke `committing-changes`.
**Worktrees** — Isolate work in a worktree when it is parallel to in-progress work, risky/experimental, a hotfix interrupting other work, or splits into independent units — just create when clear, propose in one line when ambiguous, skip quick/small single-stream work. Branch from a stable base (default branch); worktrees persist (never auto-remove or auto-merge); they isolate code state, not runtime (ports/DBs/services still collide). Full heuristic: invoke `using-worktrees`.
## Code Reviewer
---
temperature: 0.6

View File

@@ -0,0 +1,60 @@
---
name: committing-changes
description: This skill should be used when the user asks to commit, stage, split, or prepare changes for a commit. Examples: "commit this", "stage these", "split this into commits", "help me commit", "prepare a commit", "make a commit for the dcp fix".
---
# Committing Changes
Segment the working tree by concern, stage explicitly, draft messages, **present the plan, and STOP**. Commit only on the user's explicit command for this turn. Never push — the user pushes manually (Gitea + GitHub mirror).
**The default is to prepare and propose, not to commit.** A request to "commit X" is a request to get X *ready* and show the plan, unless the user has, in this turn, told you to actually run the commit. When in doubt, present and wait.
## Workflow
1. **Inspect.** `git status` then `git diff` (and `git diff --staged` if anything is already staged). Read what actually changed — do not commit from memory of what you wrote.
2. **Segment by concern.** Group the changes into buckets, one per coherent concern. State the grouping in plain language before staging anything (e.g. "two concerns: (a) the SSE fix in opencode-server.ts, (b) an unrelated typo in README").
3. **Safety scan.** Before staging, scan the diff for: secrets / keys / tokens, debug code, stray `console.log`/`print`/`dbg!`, commented-out experiments, and edits to files the user did not ask you to touch (their in-progress work). Flag anything found; do not silently stage it.
4. **Stage explicitly, per bucket.** Stage named files (`git add path/a path/b`) or hunks (`git add -p`). **Never `git add -A`, `git add .`, or `git add -u`** — those sweep up unrelated work. If `-p` can't cleanly split adjacent hunks, hand-edit the patch (`git add -e`) or revert the unrelated hunk in the working tree first.
5. **Draft messages.** One message per bucket, in the repo's scope-prefix style (see `references/message-style.md`). Explain *why* for anything non-obvious — the diff already shows *what*. Imperative mood. No emojis. Do not impose Conventional-Commits ceremony (type enums, `BREAKING CHANGE:` footers) unless the user asks.
6. **Present the plan + STOP.** Show: the buckets, the files in each, the drafted message for each, and the current staged state. Then wait. **Do not run `git commit`.**
7. **On the user's command**, execute the agreed `git add` / `git commit` exactly as presented, using the identity below. Then report the resulting hashes. Still do not push.
## Split heuristic
- **One commit** when the changes are a single coherent concern (a feature + its test; a fix + the comment explaining it).
- **Multiple commits** when concerns are independently revertable or reviewable — a bug fix and an unrelated refactor that happen to share the working tree should be two commits even if they touch the same file.
- A migration/schema change and the code that uses it are usually *one* concern (they're not independently revertable). A doc/changelog update alongside code is usually a *separate* concern.
## Identity (always)
Commit as:
```
user.name = indifferentketchup
user.email = sam@indifferentketchup.com
```
Never use a personal Gmail or the host's default git identity. If unsure the repo config is right, pass it inline: `git -c user.name=indifferentketchup -c user.email=sam@indifferentketchup.com commit -m "..."`.
## DO-NOT
- **Never push.** No `git push` under any circumstances — that is the user's manual step (dual remote: Gitea + GitHub mirror).
- **Never auto-commit.** Preparing ≠ committing. Commit only when told to, this turn.
- **Never `git add -A` / `git add .` / `git add -u`.** Stage by name or by hunk.
- **Never commit the user's unrelated/in-progress files.** If a file changed that the task didn't touch, leave it; surface it.
- **No emojis** in messages.
- **No amending or rebasing** published or shared commits without an explicit instruction.
## Red flags — STOP
- About to run `git commit` without having been told to commit this turn → STOP, present the plan instead.
- About to `git add -A` "to save time" → STOP, stage by concern.
- About to `git push` "to finish the job" → STOP, that is never part of this skill.
- A secret or debug line is in the diff and you're staging anyway → STOP, surface it.
## Anti-patterns this skill avoids
- Committing the moment changes look done (the user reviews diffs and commits on command).
- Collapsing several concerns into one "WIP" commit because staging separately is tedious.
- Pushing after committing because the work "feels finished."
- Reformatting the message into strict Conventional Commits when the repo uses freeform scope-prefixes.

View File

@@ -0,0 +1,31 @@
skill: committing-changes
tasks:
- prompt: "Commit this for me"
grader:
- the response invokes the committing-changes skill
- the response inspects the working tree (git status / git diff) before staging
- the response segments the changes by concern and states the grouping
- the response stages explicitly (named files or git add -p), never git add -A / git add . / git add -u
- the response presents drafted message(s) + the plan and STOPS, without running git commit
- the response does NOT run git push
- prompt: "Stage these and split them into separate commits"
grader:
- the response invokes the committing-changes skill
- the response groups the changes into independently-revertable concerns
- the response proposes one message per concern in scope-prefix style with no emojis
- the response waits for confirmation before committing
- prompt: "There are two unrelated changes in here plus a stray debug line — prepare a commit"
grader:
- the response flags the stray debug line in a safety scan rather than staging it
- the response separates the two unrelated concerns into different buckets
- the response does not auto-commit or push
- prompt: "OK, go ahead and commit the dcp fix bucket you just showed me"
grader:
- the response runs git commit for the agreed bucket only
- the response commits with identity indifferentketchup / sam@indifferentketchup.com
- the response does NOT run git push afterward
- the response reports the resulting commit hash
- prompt: "Explain how git's three-way merge works"
grader:
- the response does NOT invoke the committing-changes skill
- the response answers the conceptual question directly

View File

@@ -0,0 +1,43 @@
# Commit message style
Freeform **scope-prefix** messages. The shape is conventional-commits-*like* — `type(scope): summary` is the dominant form in this repo — but it is **not enforced**: the scope and the *why* matter more than the type enum. Do not reject or rewrite a message just because it lacks a `type`, and do not add ceremony (`BREAKING CHANGE:` footers, rigid type whitelist).
## The pattern
```
<scope-prefix>: <imperative summary>
<optional body: WHY this change, not what — the diff shows what>
```
- **Scope prefix** — the area(s) touched. A single area (`coder`, `web`, `server`), a typed scope (`fix(coder)`, `feat(coder)`, `docs(changelog)`), a sub-scope (`coder(providers)`), or multiple areas joined (`web+coder`). Pick whatever names the blast radius honestly.
- **Imperative summary** — "strip dcp tags", not "stripped" / "strips". One line, no trailing period needed.
- **Body** — only when the *why* isn't obvious from the summary. Explain the reason, the failure it fixes, or the constraint it satisfies. Cross-reference related tags/commits by name when the change builds on or fixes prior work.
- **No emojis.** Anywhere — summary, body, or trailers.
## Real examples (from this repo's log)
```
fix(coder): strip dcp-message-id tags split across stream chunks
feat(coder): per-session SSE subscriptions (P1.5-a concurrency prereq)
feat(coder): guard session delete against worktree work loss
fix(coder): no-upstream branch alone no longer flags a session at-risk
docs(changelog): v2.6.2-delete-guard-and-sse
chore(coder): untrack live coder-providers.json, ship example
```
And the freeform multi-area / sub-scope forms the house style also allows:
```
web+coder: per-session SSE
coder(providers): fix empty picker
```
## Why-not-just-what
A summary that restates the diff (`fix: change variable name`) wastes the message. A good message answers a question the diff can't: *why did this need to change?* Example — the bare summary `fix(coder): no-upstream branch alone no longer flags a session at-risk` is fine, but its body earns its keep:
> Session worktree branches never get an upstream, so the original rule flagged
> every worktree-backed session as at-risk on delete — even pristine ones.
That sentence is the part a future reader (or `git blame`) actually needs.

View File

@@ -0,0 +1,73 @@
---
name: using-worktrees
description: This skill should be used when starting work that may need isolation from the current checkout — parallel to something already in progress, risky or experimental, a hotfix interrupting other work, or a task that splits into independent mergeable units. Also when the user explicitly asks for a worktree. Examples: "try this risky refactor", "I need to fix prod while keeping this branch", "explore an alternate approach", "make a worktree for X".
---
# Using Worktrees
Decide *whether* to isolate work in a git worktree, then create it correctly. The judgment — "does this need its own worktree?" — is the point of this skill; the mechanics are routine.
**Asymmetry with committing (deliberate):** when the heuristic clearly fires, **just create the worktree** — you have standing trust here. When it's ambiguous, **propose it in one line and wait**. This is unlike committing, which is always command-gated. Creating a worktree is cheap and reversible; making a commit is not, so the trust differs.
## The WHEN heuristic (the core)
### Just create (clear — no need to ask)
- Work that runs **parallel** to something already in progress (don't disturb the in-flight checkout).
- A **risky / experimental / throwaway** change you might want to discard cleanly.
- A **hotfix that interrupts** in-progress work (isolate the fix, leave the WIP untouched).
- Work that **decomposes into independent mergeable units** — one worktree per unit.
- Any task where the user would plausibly want it isolated from the main checkout.
### Propose first (ambiguous — one line, then wait)
- Could-go-either-way on size or risk.
- Unsure whether the user wants isolation at all.
- A worktree that would **overlap heavily** with the work already on the main checkout (isolation buys little, may confuse).
State it in one line: *"This looks risky/parallel — want me to do it in a worktree?"* Then wait.
### Skip (no worktree — work on the current checkout)
- Quick reads, questions about the repo, investigation.
- Small single-stream fixes with nothing to run in parallel.
- Anything where there's nothing to isolate and no parallelism to protect.
```
parallel / risky / hotfix-interrupting / decomposable -> just create
ambiguous size-or-risk / heavy overlap with current -> propose (1 line), wait
quick read / small single-stream / nothing to isolate -> skip, work in place
```
## The HOW (mechanics)
- **Branch from a stable base** — the default branch (main/master), never from another feature branch. A worktree off a half-done branch inherits its instability.
- **Branch name derived from the task** — `fix-session-delete-guard`, not `wip` or `tmp`. No emojis.
- **Collision-safe path** — a unique dir outside the main checkout (e.g. a per-task or per-branch path), so two worktrees never share a directory.
- **Run the project's setup after create** — install deps / env / generate, if the project defines a setup step. A fresh worktree has the code but not the installed/generated state. (Some projects declare setup hooks; run whatever the project defines — don't assume the checkout is ready to run bare.)
## Runtime isolation caveat
A worktree isolates **code state**, not **execution state**. Ports, databases, caches, lockfiles, and running services can still collide between worktrees. Don't assume a worktree means a fully isolated environment — if two worktrees both run the app, give each its own port / DB / service via per-worktree setup. Code isolation ≠ runtime isolation.
## Lifecycle
- Worktrees **persist** — they are not auto-reaped. Leaving one around is fine; it's not litter.
- **Reconcile via git**, never automatically: review the worktree's diff against its base, then merge or archive on the user's decision. Do not auto-merge.
- **Commit inside a worktree only on the user's command** — defer to the `committing-changes` skill for the commit step (same rules: present-and-stop, never push).
## DO-NOT
- **Never branch from a non-stable base** (another feature branch). Stable base only.
- **Never auto-merge or auto-reconcile** a worktree back. That's a reviewed decision.
- **Never push** (worktrees change nothing about the push rule — that stays the user's manual step).
- **Never `git worktree remove`** without the user's say. Worktrees persist; removing one can discard uncommitted work.
- **No emojis** in branch names.
## Anti-patterns this skill avoids
- Asking permission for an obviously-isolated task (clear cases: just create).
- Creating a worktree for a quick read or a one-line fix (nothing to isolate).
- Branching the worktree off the messy in-progress branch instead of the stable base.
- Assuming a worktree gives runtime isolation and then colliding on a port or DB.
- Auto-removing or auto-merging a worktree the user hasn't reconciled.

View File

@@ -0,0 +1,32 @@
skill: using-worktrees
tasks:
- prompt: "I'm mid-way through a feature but prod is broken — I need to fix it now"
grader:
- the response invokes the using-worktrees skill
- the response recognizes this as a clear case (hotfix interrupting in-progress work) and just creates the worktree rather than asking
- the response branches the worktree from the stable/default branch, not the in-progress feature branch
- the response does NOT push
- prompt: "Let's try a risky refactor of the inference loop and see if it pans out"
grader:
- the response invokes the using-worktrees skill
- the response treats this as a clear case (risky/experimental) and creates a worktree autonomously
- the response uses a task-derived branch name (no emojis) and a collision-safe path
- the response notes that project setup must run in the new worktree before it can run
- prompt: "Should I do this small one-line typo fix in a worktree?"
grader:
- the response invokes the using-worktrees skill
- the response recommends SKIP (small single-stream fix, nothing to isolate) and works in place
- the response does not create a worktree
- prompt: "This change is medium-sized and I'm not sure if it'll conflict with what I'm doing"
grader:
- the response invokes the using-worktrees skill
- the response treats this as ambiguous and PROPOSES a worktree in one line, then waits, rather than creating it unilaterally
- prompt: "Two coder worktrees both run the app on port 9502 — will they be isolated?"
grader:
- the response invokes the using-worktrees skill
- the response explains that worktrees isolate code state but NOT runtime (ports/DBs/services can still collide)
- the response recommends per-worktree setup to separate the runtime
- prompt: "What's the difference between git clone and git worktree?"
grader:
- the response does NOT invoke the using-worktrees skill
- the response answers the conceptual question directly