Skip to content
Open
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: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -268,7 +268,7 @@ Idempotent: `<!-- aio-id: {workUnitId} -->` on the parent and `<!-- aio-id: {wor

`## Issue tracking` is attached on **every** agent start while a tree (or last issue) exists, so building stays on the board — not a one-shot addendum. Persist with `git add issues/`. Do not `gh issue create`.

Issues sync to the existing ktui board **Spectrum Web Co** via the `ktui` CLI. Agent tools come from MCP `ktui mcp --start-server` (tool `mcp__ktui_ktui`). This plugin's `.mcp.json` starts that server; no `--scope`.
Issues sync to the existing ktui board **Spectrum Web Co** via the `ktui` CLI. The plugin moves the current work-unit cards Ready → Doing when the agent starts and Doing → Done on terminal `agent_end` (`willContinue !== true`). Mid-run `turn_end` events do not complete the cards. Agent tools come from MCP `ktui mcp --start-server` (tool `mcp__ktui_ktui`). This plugin's `.mcp.json` starts that server; no `--scope`.

OMP shows a themed kanban widget above the editor plus a `/kanban` overlay. That overlay is **not** the real Textual TUI — run `ktui` in another terminal for that. See **TUI chrome**.

Expand Down
24 changes: 22 additions & 2 deletions src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,7 +30,7 @@ import {
import { resolveGithub } from "./issues/github.ts";
import { defaultKtuiRunner } from "./issues/kanban.ts";
import { ensureRepo, listIssues } from "./issues/tissue.ts";
import { refreshSnapshot, syncAllIssues, trackThoughtGraph, trackUpliftedPrompt } from "./issues/track.ts";
import { createBoardLaneController, isTerminalAgentEnd, refreshSnapshot, syncAllIssues, trackThoughtGraph, trackUpliftedPrompt } from "./issues/track.ts";
import { registerIssueTools } from "./issues/tools.ts";
import { importedSkillCount } from "./skills/import.ts";
import type { GraphSyncResult, IssueTrackState, SyncResult } from "./issues/types.ts";
Expand DownExpand Up@@ -171,10 +171,21 @@ export default function allInOne(pi: ExtensionAPI): void {
};
const issueState: IssueTrackState = { enabled: config.issues.enabled };
const run = defaultKtuiRunner(config.issues.ktuiBin);
let hudCtx: ExtensionContext | undefined;
let lastResult: UpliftResult | undefined;
let injectAddendum = false;
let injectIssueAddendum = false;
let issueTree: GraphSyncResult | undefined;
const boardLanes = createBoardLaneController({
run,
boardName: () => config.issues.boardName,
enabled: () => issueState.enabled,
tree: () => issueTree,
last: () => issueState.last,
onMoved: async () => {
if (hudCtx) await refreshHud(hudCtx);
},
});
let sessionCwd = process.cwd();
const thinkState = { enabled: config.think.enabled };
let lastGraph: ThoughtGraph | undefined;
Expand DownExpand Up@@ -804,6 +815,7 @@ export default function allInOne(pi: ExtensionAPI): void {
});

pi.on("session_start", async (event, ctx) => {
hudCtx = ctx;
sessionCwd = ctx.cwd;
applyFlag();
lsp.setCwd(ctx.cwd);
Expand DownExpand Up@@ -892,6 +904,7 @@ export default function allInOne(pi: ExtensionAPI): void {
});

pi.on("input", async (event, ctx) => {
hudCtx = ctx;
const idle = typeof ctx.isIdle === "function" ? ctx.isIdle() : true;
const decision = decideUplift(
{
Expand DownExpand Up@@ -1015,6 +1028,7 @@ export default function allInOne(pi: ExtensionAPI): void {
});

pi.on("before_agent_start", (event) => {
boardLanes.onAgentStart();
let lspDigest = "";
if (config.lsp.enabled) {
try {
Expand DownExpand Up@@ -1120,7 +1134,13 @@ export default function allInOne(pi: ExtensionAPI): void {
});


pi.on("turn_end", () => {
pi.on("agent_end", (event, ctx) => {
if (ctx) hudCtx = ctx;
if (isTerminalAgentEnd(event)) boardLanes.onAgentEnd();
});

pi.on("turn_end", (event, ctx) => {
if (ctx) hudCtx = ctx;
try {
const digest = lsp.shouldInjectParent();
if (digest) injectLspNote(pi, digest);
Expand Down
132 changes: 132 additions & 0 deletions src/issues/kanban.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,8 @@ import {
listBoards,
listCategories,
listTasks,
moveTasksToLane,
resolveLaneColumn,
syncIssue,
} from "./kanban.ts";

Expand DownExpand Up@@ -211,3 +213,133 @@ describe("boardSnapshot", () => {
expect(snapshot?.tasks).toHaveLength(3);
});
});

const NAMED_COLUMN_JSON = JSON.stringify([
{ column_id: 10, name: "Ready", visible: true, position: 1, board_id: 1 },
{ column_id: 20, name: "Doing", visible: true, position: 2, board_id: 1 },
{ column_id: 30, name: "Done", visible: true, position: 3, board_id: 1 },
]);

const POSITION_COLUMN_JSON = JSON.stringify([
{ column_id: 4, name: "Backlog", visible: true, position: 1, board_id: 1 },
{ column_id: 5, name: "Wip", visible: true, position: 2, board_id: 1 },
{ column_id: 6, name: "Complete", visible: true, position: 3, board_id: 1 },
]);

describe("resolveLaneColumn", () => {
test("resolves by name when ids are not 1/2/3", async () => {
const run = mockRunner((argv) => {
if (argv[0] === "column" && argv[1] === "list") return { stdout: NAMED_COLUMN_JSON };
throw new Error(`unexpected ${argv.join(" ")}`);
});
expect(await resolveLaneColumn(run, 1, "ready")).toBe(10);
expect(await resolveLaneColumn(run, 1, "doing")).toBe(20);
expect(await resolveLaneColumn(run, 1, "done")).toBe(30);
});

test("returns undefined when names are not Ready/Doing/Done", async () => {
const run = mockRunner((argv) => {
if (argv[0] === "column" && argv[1] === "list") return { stdout: POSITION_COLUMN_JSON };
throw new Error(`unexpected ${argv.join(" ")}`);
});
expect(await resolveLaneColumn(run, 1, "ready")).toBeUndefined();
expect(await resolveLaneColumn(run, 1, "doing")).toBeUndefined();
expect(await resolveLaneColumn(run, 1, "done")).toBeUndefined();
});

test("does not resolve done to Archive by position", async () => {
const run = mockRunner((argv) => {
if (argv[0] === "column" && argv[1] === "list") {
return {
stdout: JSON.stringify([
{ column_id: 1, name: "Backlog", visible: true, position: 1, board_id: 1 },
{ column_id: 2, name: "Wip", visible: true, position: 2, board_id: 1 },
{ column_id: 3, name: "Archive", visible: true, position: 3, board_id: 1 },
]),
};
}
throw new Error(`unexpected ${argv.join(" ")}`);
});
expect(await resolveLaneColumn(run, 1, "done")).toBeUndefined();
});
});

describe("moveTasksToLane", () => {
function laneRunner(opts: {
columns?: string;
tasks?: unknown;
moveCode?: number;
moveThrow?: boolean;
runThrow?: boolean;
} = {}) {
return mockRunner((argv) => {
if (opts.runThrow) throw new Error("ktui exploded");
if (argv[0] === "board" && argv[1] === "list") return { stdout: BOARD_JSON };
if (argv[0] === "board" && argv[1] === "activate") return {};
if (argv[0] === "column" && argv[1] === "list") {
return { stdout: opts.columns ?? COLUMN_JSON };
}
if (argv[0] === "task" && argv[1] === "list") {
const tasks = opts.tasks ?? [];
return { stdout: typeof tasks === "string" ? tasks : JSON.stringify(tasks) };
}
if (argv[0] === "task" && argv[1] === "move") {
if (opts.moveThrow) throw new Error("ktui exploded");
return { code: opts.moveCode ?? 0 };
}
throw new Error(`unexpected ${argv.join(" ")}`);
});
}

test("emits task move argv for Ready to Doing", async () => {
const run = laneRunner({
tasks: [{ task_id: 9, title: "A", column: 1, description: "tissue:a" }],
});
await expect(moveTasksToLane(run, [9], "doing")).resolves.toEqual({ moved: 1, skipped: 0 });
expect(run.calls).toContainEqual(["task", "move", "9", "2"]);
});

test("skips tasks already in the target lane", async () => {
const run = laneRunner({
tasks: [{ task_id: 9, title: "A", column: 2, description: "tissue:a" }],
});
await expect(moveTasksToLane(run, [9], "doing")).resolves.toEqual({ moved: 0, skipped: 1 });
expect(run.calls.some((argv) => argv[0] === "task" && argv[1] === "move")).toBe(false);
});

test("skips missing task ids", async () => {
const run = laneRunner({
tasks: [{ task_id: 9, title: "A", column: 1, description: "tissue:a" }],
});
await expect(moveTasksToLane(run, [99], "doing")).resolves.toEqual({ moved: 0, skipped: 1 });
expect(run.calls.some((argv) => argv[0] === "task" && argv[1] === "move")).toBe(false);
});

test("makes no ktui calls for empty ids", async () => {
const run = laneRunner({ runThrow: true });
await expect(moveTasksToLane(run, [], "doing")).resolves.toEqual({ moved: 0, skipped: 0 });
expect(run.calls).toHaveLength(0);
});

test("never throws when the runner throws or move is nonzero", async () => {
const throwing = laneRunner({ runThrow: true });
await expect(moveTasksToLane(throwing, [9], "doing")).resolves.toEqual({
moved: 0,
skipped: 1,
reason: "ktui exploded",
});

const failed = laneRunner({
tasks: [{ task_id: 9, title: "A", column: 1, description: "tissue:a" }],
moveCode: 2,
});
await expect(moveTasksToLane(failed, [9], "doing")).resolves.toEqual({ moved: 0, skipped: 1 });

const moveThrows = laneRunner({
tasks: [{ task_id: 9, title: "A", column: 1, description: "tissue:a" }],
moveThrow: true,
});
await expect(moveTasksToLane(moveThrows, [9], "doing")).resolves.toEqual({ moved: 0, skipped: 1 });
});
});

65 changes: 65 additions & 0 deletions src/issues/kanban.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -289,3 +289,68 @@ export async function boardSnapshot(
return undefined;
}
}

export type BoardLane = "ready" | "doing" | "done";

export async function resolveLaneColumn(
run: KtuiRunner,
boardId: number,
lane: BoardLane,
): Promise<number | undefined> {
const columns = await listColumns(run, boardId);
return columns.find((column) => column.name.trim().toLowerCase() === lane)?.column_id;
}

export async function moveTask(run: KtuiRunner, taskId: number, columnId: number): Promise<boolean> {
try {
const result = await run(["task", "move", String(taskId), String(columnId)]);
return result.code === 0;
} catch {
return false;
}
}

export async function moveTasksToLane(
run: KtuiRunner,
taskIds: number[],
lane: BoardLane,
boardName?: string,
): Promise<{ moved: number; skipped: number; reason?: string }> {
const ids: number[] = [];
const seen = new Set<number>();
for (const id of taskIds) {
if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
seen.add(id);
ids.push(id);
}
if (ids.length === 0) return { moved: 0, skipped: 0 };

try {
const { boardId } = await ensureBoard(run, boardName ?? DEFAULT_BOARD_NAME);
await run(["board", "activate", String(boardId)]).catch(() => undefined);

const columnId = await resolveLaneColumn(run, boardId, lane);
if (columnId === undefined) {
return { moved: 0, skipped: ids.length, reason: "lane column unresolved" };
}

const tasks = await listTasks(run, boardId);
const byId = new Map(tasks.map((task) => [task.task_id, task]));
let moved = 0;
let skipped = 0;
for (const id of ids) {
const task = byId.get(id);
if (!task || task.column === columnId) {
skipped += 1;
continue;
}
if (await moveTask(run, id, columnId)) moved += 1;
else skipped += 1;
}
return { moved, skipped };
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
return { moved: 0, skipped: ids.length, reason };
}
}

Loading