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