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:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user