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 1ae68ed25f
commit 5e4ee94424
4 changed files with 323 additions and 3 deletions

View File

@@ -658,6 +658,105 @@ describe("TaskStore", () => {
});
});
describe("parseDependenciesFromPrompt", () => {
it("returns single dependency from PROMPT.md", async () => {
const task = await store.createTask({ description: "Task with dep" });
const dir = join(rootDir, ".kb", "tasks", task.id);
await writeFile(
join(dir, "PROMPT.md"),
`# ${task.id}: Task with dep
## Dependencies
- **Task:** KB-001 (must be complete first)
## Steps
### Step 0: Preflight
- [ ] Check things
`,
);
const deps = await store.parseDependenciesFromPrompt(task.id);
expect(deps).toEqual(["KB-001"]);
});
it("returns multiple dependencies in order", async () => {
const task = await store.createTask({ description: "Task with deps" });
const dir = join(rootDir, ".kb", "tasks", task.id);
await writeFile(
join(dir, "PROMPT.md"),
`# ${task.id}: Task with deps
## Dependencies
- **Task:** KB-010 (first dep)
- **Task:** KB-020 (second dep)
- **Task:** PROJ-003 (third dep)
## Steps
### Step 0: Preflight
- [ ] Check things
`,
);
const deps = await store.parseDependenciesFromPrompt(task.id);
expect(deps).toEqual(["KB-010", "KB-020", "PROJ-003"]);
});
it("returns empty array when dependencies section says None", async () => {
const task = await store.createTask({ description: "No deps" });
const dir = join(rootDir, ".kb", "tasks", task.id);
await writeFile(
join(dir, "PROMPT.md"),
`# ${task.id}: No deps
## Dependencies
- **None**
## Steps
### Step 0: Preflight
- [ ] Check things
`,
);
const deps = await store.parseDependenciesFromPrompt(task.id);
expect(deps).toEqual([]);
});
it("returns empty array when no Dependencies section exists", async () => {
const task = await store.createTask({ description: "No section" });
const dir = join(rootDir, ".kb", "tasks", task.id);
await writeFile(
join(dir, "PROMPT.md"),
`# ${task.id}: No section
## Steps
### Step 0: Preflight
- [ ] Check things
`,
);
const deps = await store.parseDependenciesFromPrompt(task.id);
expect(deps).toEqual([]);
});
it("returns empty array when task has no PROMPT.md file", async () => {
const task = await store.createTask({ description: "No prompt" });
const dir = join(rootDir, ".kb", "tasks", task.id);
// Delete the PROMPT.md that createTask generates
const { unlink } = await import("node:fs/promises");
await unlink(join(dir, "PROMPT.md"));
const deps = await store.parseDependenciesFromPrompt(task.id);
expect(deps).toEqual([]);
});
});
describe("columnMovedAt", () => {
it("createTask sets columnMovedAt", async () => {
const before = new Date().toISOString();

View File

@@ -287,7 +287,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
id: string,
updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string },
updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string; size?: "S" | "M" | "L"; reviewLevel?: number },
): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
@@ -309,6 +309,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
if (updates.paused !== undefined) task.paused = updates.paused || undefined;
if (updates.baseBranch !== undefined) task.baseBranch = updates.baseBranch;
if (updates.size !== undefined) task.size = updates.size;
if (updates.reviewLevel !== undefined) task.reviewLevel = updates.reviewLevel;
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
@@ -448,6 +450,44 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return steps;
}
/**
* Parse the `## Dependencies` section from a task's PROMPT.md and extract
* task IDs from lines matching `- **Task:** {ID}` (where ID is `[A-Z]+-\d+`).
*
* Returns an empty array if the section says `- **None**`, has no task
* references, or if the section/file doesn't exist.
*
* @param id - The task ID whose PROMPT.md to parse
* @returns Array of dependency task IDs (e.g. `["KB-001", "KB-002"]`)
*/
async parseDependenciesFromPrompt(id: string): Promise<string[]> {
const dir = this.taskDir(id);
const promptPath = join(dir, "PROMPT.md");
if (!existsSync(promptPath)) return [];
const content = await readFile(promptPath, "utf-8");
// Find the ## Dependencies section.
// We locate the heading then slice to the next heading (or end of file)
// to avoid multiline `$` anchor issues with lazy quantifiers.
const headingMatch = content.match(/^##\s+Dependencies\s*$/m);
if (!headingMatch) return [];
const startIdx = headingMatch.index! + headingMatch[0].length;
const rest = content.slice(startIdx);
const nextHeading = rest.search(/\n##?\s/);
const section = nextHeading === -1 ? rest : rest.slice(0, nextHeading);
const ids: string[] = [];
const taskIdRegex = /^-\s+\*\*Task:\*\*\s+([A-Z]+-\d+)/gm;
let match;
while ((match = taskIdRegex.exec(section)) !== null) {
ids.push(match[1]);
}
return ids;
}
/**
* Parse the `## File Scope` section from a task's PROMPT.md and extract
* backtick-quoted file paths. Glob patterns ending in `/*` are stored

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);