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
|
||||
|
||||
Reference in New Issue
Block a user