fixed regressed parsing, tool sanitation

This commit is contained in:
2026-06-08 20:34:16 -04:00
parent dc3993048e
commit 85123b7755
10 changed files with 3262 additions and 33 deletions

View File

@@ -24,7 +24,7 @@ export function getBlockedTasks(
for (const task of pendingTasks) {
if (blocked.has(task.id)) continue;
const deps = task.dependencies || [];
if (deps.some((dep) => failedTaskIds.has(dep))) {
if (deps.some((dep) => failedTaskIds.has(dep) || blocked.has(dep))) {
blocked.add(task.id);
changed = true;
}
@@ -46,9 +46,15 @@ export function buildExecutionPlan(
parallelGroup?: number,
failedTaskIds: Set<string> = new Set(),
): ExecutionPlan {
// Filter out already completed tasks
const pendingTasks = project.tasks.filter((t) => !completed.has(t.id));
const skippedTasks = project.tasks.filter((t) => completed.has(t.id));
// Filter out already completed AND failed tasks
// Failed tasks should not be re-scheduled — they're only re-attempted
// via the retry mechanism inside executeTask, not via the DAG.
const pendingTasks = project.tasks.filter(
(t) => !completed.has(t.id) && !failedTaskIds.has(t.id),
);
const skippedTasks = project.tasks.filter(
(t) => completed.has(t.id) || failedTaskIds.has(t.id),
);
// With explicitly declared parallel groups, all groups are independent.
// Since there are no cross-group dependencies by definition, standard

View File

@@ -866,6 +866,20 @@ function sleep(ms: number): Promise<void> {
// ─── Tool Call Formatting ────────────────────────────────────────────────
/**
* Strip control characters and newlines from a display label so it
* does not break TUI layout (tree branches, text width calculation).
*/
function sanitizeLabel(s: string): string {
// Replace newlines/carriage returns with spaces (multi-line commands
// must fit on a single tree-branch line), then strip ASCII control
// characters except \t (which is harmless) and keep printable chars.
return s
.replace(/\r?\n/g, " ")
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
.trim();
}
/**
* Format a tool call argument into a short label.
*/
@@ -873,21 +887,20 @@ function formatToolArg(name: string, args: unknown): string {
const a = args as Record<string, unknown>;
switch (name) {
case "bash":
return truncateMiddle(String(a.command ?? ""), 70);
return sanitizeLabel(truncateMiddle(String(a.command ?? ""), 70));
case "write":
case "read":
return truncateMiddle(String(a.path ?? ""), 60);
return sanitizeLabel(truncateMiddle(String(a.path ?? ""), 60));
case "edit":
return truncateMiddle(String(a.path ?? ""), 60);
return sanitizeLabel(truncateMiddle(String(a.path ?? ""), 60));
case "grep":
return `${a.pattern ?? "?"}${truncateMiddle(
String(a.path ?? ""),
40,
)}`;
return sanitizeLabel(
`${a.pattern ?? "?"}${truncateMiddle(String(a.path ?? ""), 40)}`,
);
case "find":
return `${a.path ?? "."}${a.glob ?? "*"}`;
return sanitizeLabel(`${a.path ?? "."}${a.glob ?? "*"}`);
case "ls":
return truncateMiddle(String(a.path ?? "."), 60);
return sanitizeLabel(truncateMiddle(String(a.path ?? "."), 60));
default:
return name;
}

View File

@@ -44,8 +44,27 @@ export function parseTaskFile(filePath: string): Project {
// ─── Fio Format Parser ───────────────────────────────────────────────────────
/** Match both markdown heading (## Dependencies) and plain heading (Dependencies). */
const DEP_HEADING_RE = /^(?:##\s+)?Dependencies\s*$/m;
/** Match both markdown heading (## Tasks) and plain heading (Tasks). */
const TASK_HEADING_RE = /^(?:##\s+)?Tasks\s*$/m;
/** Match other markdown headings (## Something). */
const ANY_MD_HEADING_RE = /^##\s/;
/**
* Detect a plain (non-markdown) section heading like "Exit criteria".
* A plain heading must:
* - Start with a letter
* - Contain only letters and spaces
* - Have no colons (avoids matching "Objective:" and "Status legend:")
* - Not be a task/dep line (doesn't start with "-")
*/
function isPlainSectionHeader(line: string): boolean {
const trimmed = line.trim();
return trimmed.length > 0 && /^[A-Za-z][A-Za-z\s]*$/.test(trimmed);
}
function hasDependenciesSection(content: string): boolean {
return /^##\s+Dependencies\s*$/m.test(content);
return DEP_HEADING_RE.test(content);
}
function parseFioFormat(
@@ -61,20 +80,21 @@ function parseFioFormat(
let inDeps = false;
for (const line of lines) {
if (/^##\s+Tasks\s*$/m.test(line)) {
if (TASK_HEADING_RE.test(line)) {
inTasks = true;
inDeps = false;
continue;
}
if (/^##\s+Dependencies\s*$/m.test(line)) {
if (DEP_HEADING_RE.test(line)) {
inTasks = false;
inDeps = true;
continue;
}
// Reset state on any other section heading — both ##-style and plain
if (
/^##\s/.test(line) &&
!/^##\s+Tasks/.test(line) &&
!/^##\s+Dependencies/.test(line)
(ANY_MD_HEADING_RE.test(line) || isPlainSectionHeader(line)) &&
!TASK_HEADING_RE.test(line) &&
!DEP_HEADING_RE.test(line)
) {
inTasks = false;
inDeps = false;
@@ -272,12 +292,14 @@ function parseFioFormat(
}
}
// Extract exit criteria
// Extract exit criteria — detect both ## Exit Criteria and plain Exit criteria
const exitCriteria: string[] = [];
const exitIdx = lines.findIndex((l) => /^##\s+Exit\s+Criteria/i.test(l));
const exitCriteriaRe = /^(?:##\s+)?Exit\s+Criteria/i;
const exitIdx = lines.findIndex((l) => exitCriteriaRe.test(l));
if (exitIdx >= 0) {
for (let i = exitIdx + 1; i < lines.length; i++) {
if (/^##\s/.test(lines[i])) break;
// Stop at any new section heading (##-style or plain)
if (/^##\s/.test(lines[i]) || isPlainSectionHeader(lines[i])) break;
const m = lines[i].match(/^-\s+(.+)$/);
if (m) exitCriteria.push(m[1].trim());
}
@@ -419,9 +441,14 @@ export function updateTaskInFile(
const char = statusToChar(status);
// Strategy 1: Fio numbered format — match by explicit task ID in the file
// Try both padded (01) and raw (1) variations
const rawId = parseInt(taskId, 10).toString();
const idPatterns = new Set([escapeRegex(taskId), escapeRegex(rawId)]);
// Try both padded (01) and raw (1) variations.
// When the task ID is already zero-padded (e.g., "01"), skip the raw ID
// to avoid partial matches ("1" matching the second digit of "01").
const idPatterns = new Set([escapeRegex(taskId)]);
if (!taskId.startsWith("0")) {
const rawId = parseInt(taskId, 10).toString();
idPatterns.add(escapeRegex(rawId));
}
for (const idPattern of idPatterns) {
const fioRegex = new RegExp(