feat(KB-139): parse dependencies, size, and review level from PROMPT.md after triage

- Add parseDependenciesFromPrompt to TaskStore to extract task IDs from ## Dependencies section
- Call dependency parser in specifyTask after PROMPT.md is written by triage agent
- Extract size (S/M/L) and review level from PROMPT.md front-matter into task metadata
- Extend updateTask to accept size and reviewLevel fields
- Add comprehensive tests for dependency parsing and triage integration
This commit is contained in:
Dustin Byrne
2026-03-28 00:11:19 -04:00
parent 8fd8b36f7d
commit 861179cbe7
4 changed files with 323 additions and 3 deletions

View File

@@ -1,4 +1,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync } from "node:fs";
import { mkdir, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { AgentSemaphore } from "./concurrency.js";
// Mock createKbAgent before importing TriageProcessor
@@ -31,6 +35,9 @@ function createMockStore(tasks: any[] = []) {
updateTask: vi.fn().mockResolvedValue({}),
moveTask: vi.fn().mockResolvedValue({}),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
logEntry: vi.fn().mockResolvedValue({}),
deleteTask: vi.fn().mockResolvedValue({}),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
@@ -666,3 +673,156 @@ describe("TriageProcessor agent log persistence", () => {
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-001", "hi", "text", undefined, "triage");
});
});
describe("TriageProcessor dependency parsing", () => {
let tmpDir: string;
beforeEach(() => {
vi.clearAllMocks();
tmpDir = mkdtempSync(join(tmpdir(), "kb-triage-dep-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
const makeTask = (id = "KB-001") => ({
id,
title: "Test",
description: "Test task",
column: "triage" as const,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
async function writePromptMd(rootDir: string, taskId: string, content: string) {
const dir = join(rootDir, ".kb", "tasks", taskId);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "PROMPT.md"), content);
}
it("calls parseDependenciesFromPrompt and persists deps via updateTask before moveTask", async () => {
const store = createMockStore();
store.parseDependenciesFromPrompt.mockResolvedValue(["KB-010", "KB-020"]);
const promptContent = `# KB-001: Test Task
**Size:** M
## Review Level: 2 (Plan and Code)
## Dependencies
- **Task:** KB-010 (first dep)
- **Task:** KB-020 (second dep)
## Steps
### Step 0: Preflight
`;
await writePromptMd(tmpDir, "KB-001", promptContent);
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
const triage = new TriageProcessor(store, tmpDir);
await triage.specifyTask(makeTask());
// Verify parseDependenciesFromPrompt was called
expect(store.parseDependenciesFromPrompt).toHaveBeenCalledWith("KB-001");
// Verify updateTask was called with dependencies, size, and reviewLevel
const updateCalls = store.updateTask.mock.calls;
// First call is { status: "specifying" }, second is the post-parse call
expect(updateCalls.length).toBeGreaterThanOrEqual(2);
const postParseCAll = updateCalls[1];
expect(postParseCAll[0]).toBe("KB-001");
expect(postParseCAll[1]).toMatchObject({
status: null,
dependencies: ["KB-010", "KB-020"],
size: "M",
reviewLevel: 2,
});
// Verify moveTask was called after updateTask
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
});
it("does not include dependencies in updateTask when parseDependenciesFromPrompt returns empty", async () => {
const store = createMockStore();
store.parseDependenciesFromPrompt.mockResolvedValue([]);
const promptContent = `# KB-001: Test Task
## Dependencies
- **None**
## Steps
`;
await writePromptMd(tmpDir, "KB-001", promptContent);
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
const triage = new TriageProcessor(store, tmpDir);
await triage.specifyTask(makeTask());
// The post-parse updateTask call should not include dependencies
const updateCalls = store.updateTask.mock.calls;
const postParseCall = updateCalls[1];
expect(postParseCall[1]).not.toHaveProperty("dependencies");
expect(postParseCall[1]).toHaveProperty("status", null);
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
});
it("extracts size and reviewLevel from PROMPT.md front-matter", async () => {
const store = createMockStore();
store.parseDependenciesFromPrompt.mockResolvedValue([]);
const promptContent = `# KB-001: Test Task
**Size:** L
## Review Level: 3 (Full)
## Dependencies
- **None**
## Steps
`;
await writePromptMd(tmpDir, "KB-001", promptContent);
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
const triage = new TriageProcessor(store, tmpDir);
await triage.specifyTask(makeTask());
const updateCalls = store.updateTask.mock.calls;
const postParseCall = updateCalls[1];
expect(postParseCall[1]).toMatchObject({
status: null,
size: "L",
reviewLevel: 3,
});
});
});

View File

@@ -307,7 +307,28 @@ export class TriageProcessor {
await this.store.logEntry(task.id, `Duplicate of ${dupId} — closed`);
await this.store.deleteTask(task.id);
} else {
await this.store.updateTask(task.id, { status: null });
// Parse dependencies, size, and review level from the generated PROMPT.md
const parsedDeps = await this.store.parseDependenciesFromPrompt(task.id);
const taskUpdates: Record<string, any> = { status: null };
if (parsedDeps.length > 0) {
taskUpdates.dependencies = parsedDeps;
triageLog.log(`${task.id} dependencies: ${parsedDeps.join(", ")}`);
}
// Extract size (S|M|L) from front-matter
const sizeMatch = written.match(/^\*\*Size:\*\*\s+(S|M|L)\b/m);
if (sizeMatch) {
taskUpdates.size = sizeMatch[1] as "S" | "M" | "L";
}
// Extract review level from heading
const reviewMatch = written.match(/^##\s+Review\s+Level:\s+(\d+)/m);
if (reviewMatch) {
taskUpdates.reviewLevel = parseInt(reviewMatch[1], 10);
}
await this.store.updateTask(task.id, taskUpdates);
await this.store.moveTask(task.id, "todo");
triageLog.log(`${task.id} specified and moved to todo`);
this.options.onSpecifyComplete?.(task);