Skip to content
Open
12 changes: 6 additions & 6 deletions .task
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"taskId": "1282",
"taskId": "2123",
"phase": "execution",
"fenceToken": 3,
"sessionId": "c893aa20-a7b7-4112-9cc3-68c12a747bf1",
"journalPath": "/tmp/taskcore-worktrees/journal-T1282/tasks/T1282/",
"codeWorktree": "/tmp/taskcore-worktrees/code-T1282",
"claimedAt": 1773098423616,
"fenceToken": 10,
"sessionId": "7be3c6a7-358e-45c7-b5af-6c1c5a51b82e",
"journalPath": "/tmp/taskcore-worktrees/journal-T2123/tasks/T2123/",
"codeWorktree": "/tmp/taskcore-worktrees/code-T2123",
"claimedAt": 1773404837466,
"reviewNotes": []
}
258 changes: 258 additions & 0 deletions core/cli/plan-parse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
/**
* plan-parse.ts — parse markdown-ish plans into child task specs and allocate costs.
*
* Supports:
* - Checklist items: `- [ ] title` or `- [x] title`
* - Ordered items: `1. title`
* - Bullet items: `- title`
* - Headings: `# Section` — prefix subsequent items' titles with "Section: "
* - Continuation lines: indented (2+ spaces) lines after an item enrich its description
* - Trailing meta: `(cost: 15, assignee: coder, reviewer: overseer, skip-analysis: true)`
*/

export interface ParsedItem {
title: string;
description: string;
cost: number | undefined;
assignee: string | undefined;
reviewer: string | undefined;
skipAnalysis: boolean;
}

export interface AllocatedItem {
title: string;
description: string;
cost: number;
assignee: string | undefined;
reviewer: string | undefined;
skipAnalysis: boolean;
}

interface ItemMeta {
cost?: number;
assignee?: string;
reviewer?: string;
skipAnalysis?: boolean;
}

function extractMeta(raw: string): { baseTitle: string; meta: ItemMeta } {
// Match trailing parenthetical: `title (key: val, key: val)`
const parenMatch = raw.match(/^(.*?)\s*\(([^)]+)\)\s*$/);
if (!parenMatch) return { baseTitle: raw.trim(), meta: {} };

const baseTitle = parenMatch[1]!.trim();
const metaStr = parenMatch[2]!;
const meta: ItemMeta = {};

for (const pair of metaStr.split(",")) {
const colonIdx = pair.indexOf(":");
if (colonIdx < 0) continue;
const key = pair.slice(0, colonIdx).trim().toLowerCase();
const value = pair.slice(colonIdx + 1).trim();

switch (key) {
case "cost": {
const n = Number(value);
if (Number.isFinite(n) && n > 0) meta.cost = n;
break;
}
case "assignee":
if (value) meta.assignee = value;
break;
case "reviewer":
if (value) meta.reviewer = value;
break;
case "skip-analysis":
meta.skipAnalysis = value === "true" || value === "1" || value === "yes";
break;
}
}
return { baseTitle, meta };
}

interface PendingItem {
title: string;
rawTitle: string; // item text without heading prefix, used as default description
cost: number | undefined;
assignee: string | undefined;
reviewer: string | undefined;
skipAnalysis: boolean;
}

/**
* Parse a markdown-ish plan text into ParsedItem[].
*
* Item markers:
* - `- [ ] title` / `- [x] title` — checklist
* - `1. title` — ordered list
* - `- title` — plain bullet
*
* Headings (`# …`) set a context prefix applied to all following items until
* the next heading.
*
* Indented lines (2+ spaces) following an item are appended to its description.
*
* A trailing parenthetical `(key: value, …)` on any item line supplies metadata.
*/
export function parsePlan(text: string): ParsedItem[] {
const lines = text.split("\n");
const items: ParsedItem[] = [];
let headingContext = "";
let pending: PendingItem | null = null;
const descLines: string[] = [];

function flush(): void {
if (!pending) return;
const description = descLines.length > 0 ? descLines.join("\n") : pending.rawTitle;
items.push({
title: pending.title,
description,
cost: pending.cost,
assignee: pending.assignee,
reviewer: pending.reviewer,
skipAnalysis: pending.skipAnalysis,
});
pending = null;
descLines.length = 0;
}

for (const line of lines) {
const trimmed = line.trim();

// Blank lines — skip (don't flush; a blank between item and continuation is fine)
if (trimmed === "") continue;

// Heading: reset context prefix
const headingMatch = trimmed.match(/^#{1,6}\s+(.+)$/);
if (headingMatch) {
flush();
headingContext = headingMatch[1]!.trim();
continue;
}

// Checklist: `- [ ] title` or `- [x] title` (any char inside brackets)
const checklistMatch = trimmed.match(/^-\s+\[[^\]]*\]\s+(.+)$/);
// Ordered: `1. title` (must not have already matched checklist)
const orderedMatch = !checklistMatch ? trimmed.match(/^\d+\.\s+(.+)$/) : null;
// Bullet: `- title` (only when neither of the above matched)
const bulletMatch = !checklistMatch && !orderedMatch ? trimmed.match(/^-\s+(.+)$/) : null;

const itemText = checklistMatch?.[1] ?? orderedMatch?.[1] ?? bulletMatch?.[1];

if (itemText !== undefined) {
flush();
const { baseTitle, meta } = extractMeta(itemText);
const prefix = headingContext ? `${headingContext}: ` : "";
pending = {
title: prefix + baseTitle,
rawTitle: baseTitle,
cost: meta.cost,
assignee: meta.assignee,
reviewer: meta.reviewer,
skipAnalysis: meta.skipAnalysis ?? false,
};
continue;
}

// Continuation: indented line (2+ spaces or a tab) while we have a pending item
if (pending && /^[ \t]{2,}/.test(line)) {
descLines.push(trimmed);
continue;
}

// Unrecognised line — flush pending (don't silently swallow item boundaries)
flush();
}

flush();
return items;
}

/**
* Allocate costs to items. Items with an explicit `cost` keep it; items without
* share the remaining budget evenly. Uses integer arithmetic (cents) to avoid
* float drift.
*
* Returns an error string on failure, or the allocated items on success.
*/
export function allocateCosts(
items: ParsedItem[],
budgetRemainingDollars: number,
): { ok: true; items: AllocatedItem[] } | { ok: false; error: string } {
if (items.length === 0) {
return { ok: false, error: "plan contains no items" };
}

// Work in integer cents to avoid float drift
const budgetCents = Math.round(budgetRemainingDollars * 100);
if (budgetCents <= 0) {
return {
ok: false,
error: `no positive budget remaining (${budgetRemainingDollars.toFixed(2)})`,
};
}

let specifiedCents = 0;
let unspecifiedCount = 0;
for (const item of items) {
if (item.cost !== undefined) {
specifiedCents += Math.round(item.cost * 100);
} else {
unspecifiedCount++;
}
}

if (specifiedCents > budgetCents) {
return {
ok: false,
error:
`explicit costs (${(specifiedCents / 100).toFixed(2)}) exceed remaining budget` +
` (${budgetRemainingDollars.toFixed(2)})`,
};
}

const remainingCents = budgetCents - specifiedCents;
let perItemCents = 0;
let extraCents = 0;

if (unspecifiedCount > 0) {
if (remainingCents <= 0) {
return {
ok: false,
error: `no budget remains for ${unspecifiedCount} item(s) without explicit cost`,
};
}
perItemCents = Math.floor(remainingCents / unspecifiedCount);
extraCents = remainingCents % unspecifiedCount;
if (perItemCents === 0) {
return {
ok: false,
error:
`remaining budget (${(remainingCents / 100).toFixed(2)}) too small to distribute` +
` across ${unspecifiedCount} uncosted item(s)`,
};
}
}

// Distribute extra cents to the first items (deterministic, no float drift)
let extraGiven = 0;
const allocated: AllocatedItem[] = items.map((item) => {
let costCents: number;
if (item.cost !== undefined) {
costCents = Math.round(item.cost * 100);
} else {
costCents = perItemCents + (extraGiven < extraCents ? 1 : 0);
if (extraGiven < extraCents) extraGiven++;
}
return {
title: item.title,
description: item.description,
cost: costCents / 100,
assignee: item.assignee,
reviewer: item.reviewer,
skipAnalysis: item.skipAnalysis,
};
});

return { ok: true, items: allocated };
}
Loading
Loading