feat(FN-5405): remove broad-scope triage heuristics and UI advisory chips/b
Removes the broad-scope detection feature end-to-end: the TaskCard chip and TaskDetailModal advisory banner are gone from the dashboard, the triage heuristic that flagged tasks as broad-scope has been deleted from the engine along with its associated run-audit events, and documentation references ha Fusion-Task-Id: FN-5405
This commit is contained in:
committed by
gsxdsm
parent
5a76a89071
commit
b8147dd3b1
@@ -55,7 +55,6 @@ Features:
|
||||
- PR/issue badges with live updates
|
||||
- GitHub provenance marker on task cards imported from GitHub (`sourceType: github_import`), shown alongside existing footer metadata like timers
|
||||
- Agent-created provenance badge in task card headers for agent-originated tasks (`sourceType: agent_heartbeat` or `sourceType: automation`, or legacy tasks with `sourceAgentId`), with labels preferring `sourceMetadata.agentName` over raw agent IDs
|
||||
- Broad-scope advisory surfacing from `task.sourceMetadata.broadScopeFlag`: TaskCard shows a warning-tinted `Broad scope` chip with score/reasons tooltip, and TaskDetailModal shows a read-only `Triage broad-scope advisory` banner with score, humanized reasons, and signal summary. This is informational only and does not alter lifecycle, scheduling, pause state, or merge behavior.
|
||||
- Column ordering semantics: `todo` mirrors scheduler pickup order (priority descending, then oldest `createdAt`, then task ID); `triage`, `in-progress`, `in-review`, and `archived` remain priority-first with task-ID tie-breaks; `done` is ordered by most recent completion first (`columnMovedAt`, then `updatedAt`, then `createdAt` fallback)
|
||||
|
||||

|
||||
|
||||
@@ -75,14 +75,6 @@ Direct-report stale decisions in `HeartbeatMonitor.buildReportsHealthSection()`
|
||||
- `heartbeatAgeMs` is the report's current heartbeat age at classification time
|
||||
- Healthy reports do not emit this diagnostic; only stale decisions do
|
||||
|
||||
## Broad-scope triage intake (`[triage]`)
|
||||
|
||||
- Trigger shape: `TriageProcessor.finalizeApprovedTask()` scores the prompt/description against `packages/engine/src/triage-broad-scope-heuristics.ts` and flags advisory decomposition risk when the score reaches `>= 3`.
|
||||
- Diagnostic: `[triage] <taskId>: broad-scope flag at triage — score=<n>, reasons=<csv>`.
|
||||
- Fail-soft diagnostic: `[triage] <taskId>: broad-scope heuristic failed open: <message>` when the helper throws; the task still proceeds to `todo`.
|
||||
- Audit event: `task:broad-scope-flagged-at-triage` with `{ score, reasons, signals, thresholds, version }`.
|
||||
- Task log side effect: `Broad-scope triage flag` advising operators to decompose via `fn_task_create` or set `breakIntoSubtasks=true` before execution.
|
||||
|
||||
## Resume instrumentation (FN-5389, Phase 1)
|
||||
|
||||
Dashboard Phase 1 resume instrumentation adds observation-only client/server traces for refetch/reconnect attribution. It does not change visibility/pageshow/SSE behavior; FN-5392 consumes this data for fixes.
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
BUILTIN_AGENT_PROMPTS,
|
||||
resolveAgentPrompt,
|
||||
@@ -252,6 +255,38 @@ describe("resolveAgentPrompt", () => {
|
||||
expect(result).toContain("task_document_write");
|
||||
});
|
||||
|
||||
it("triage prompt broad-scope decomposition block is present and identical in core and engine templates", () => {
|
||||
const corePrompt = resolveAgentPrompt("triage");
|
||||
const triageSource = readFileSync(
|
||||
resolve(fileURLToPath(new URL("..", import.meta.url)), "..", "..", "engine", "src", "triage.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const enginePromptMatch = triageSource.match(/export const TRIAGE_SYSTEM_PROMPT = `([\s\S]*?)`;/);
|
||||
expect(enginePromptMatch?.[1]).toBeTruthy();
|
||||
const enginePrompt = enginePromptMatch![1].replaceAll("\\`", "`");
|
||||
|
||||
for (const prompt of [corePrompt, enginePrompt]) {
|
||||
expect(prompt).toContain("**Broad-scope decomposition signals:**");
|
||||
expect(prompt).toContain("step count would reach 9 or more");
|
||||
expect(prompt).toContain("would reach 12 or more");
|
||||
expect(prompt).toContain("20 or more entries");
|
||||
expect(prompt).toContain("at or above 30 items");
|
||||
}
|
||||
|
||||
const marker = "**Broad-scope decomposition signals:**";
|
||||
const blockRegex = /\*\*Broad-scope decomposition signals:\*\*[\s\S]*?(?=\n\n(?:##|\*\*))/;
|
||||
const coreStart = corePrompt.indexOf(marker);
|
||||
const engineStart = enginePrompt.indexOf(marker);
|
||||
expect(coreStart).toBeGreaterThanOrEqual(0);
|
||||
expect(engineStart).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const coreBlock = corePrompt.slice(coreStart).match(blockRegex)?.[0];
|
||||
const engineBlock = enginePrompt.slice(engineStart).match(blockRegex)?.[0];
|
||||
expect(coreBlock).toBeTruthy();
|
||||
expect(engineBlock).toBeTruthy();
|
||||
expect(coreBlock).toBe(engineBlock);
|
||||
});
|
||||
|
||||
it("default role prompts include explicit heartbeat run guidance", () => {
|
||||
expect(resolveAgentPrompt("executor")).toContain("## Heartbeat Run Behavior");
|
||||
expect(resolveAgentPrompt("triage")).toContain("## Heartbeat Run Behavior");
|
||||
|
||||
@@ -371,6 +371,13 @@ For tasks you assess as Size M or L, proactively evaluate whether splitting into
|
||||
- Only keep a task as one unit if it genuinely has 5 or fewer focused steps with a clear scope
|
||||
- If you decide not to split an M/L task, proceed with a normal PROMPT.md specification
|
||||
|
||||
**Broad-scope decomposition signals:**
|
||||
- Size L tasks, especially when the planned step count would reach 9 or more.
|
||||
- Plans whose implementation-step count would reach 12 or more (additive signal — counts even when the surrounding "more than 7/10 steps" threshold above has not yet fired).
|
||||
- Tasks whose declared \`## File Scope\` would list 20 or more entries.
|
||||
- Descriptions that quantify large remediation batches (for example "47 failing tests", "30+ broken files") at or above 30 items — treat as a strong signal that the work should be partitioned by subsystem or file group before specifying.
|
||||
- When two or more of the signals above fire together, default to splitting via \`fn_task_create\`. If you still choose to keep the task as a single unit, justify the decision explicitly in the PROMPT.md \`## Mission\` paragraph.
|
||||
|
||||
## Triage tools
|
||||
You have these extra tools during triage:
|
||||
- \`fn_task_list\` — list existing active tasks
|
||||
|
||||
@@ -628,28 +628,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-broad-scope-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-warning);
|
||||
background: color-mix(in srgb, var(--color-warning) 18%, transparent);
|
||||
border: var(--btn-border-width) solid color-mix(in srgb, var(--color-warning) 35%, transparent);
|
||||
padding: calc(var(--space-xs) / 4) calc(var(--space-sm) - (var(--space-xs) / 4));
|
||||
border-radius: var(--radius-pill);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.card-broad-scope-chip-text {
|
||||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@container task-card (max-width: 240px) {
|
||||
.card-agent-badge-text {
|
||||
display: none;
|
||||
@@ -660,10 +638,6 @@
|
||||
.card-agent-badge-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card-broad-scope-chip-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.card-agent-badge--loading {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./TaskCard.css";
|
||||
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
|
||||
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest, AlertTriangle } from "lucide-react";
|
||||
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, PrInfo, IssueInfo, TaskPriority, GithubIssueAction } from "@fusion/core";
|
||||
import {
|
||||
COLUMN_LABELS,
|
||||
@@ -346,57 +346,6 @@ function getIssueUrlFromMetadata(metadata: Task["sourceMetadata"]): string | und
|
||||
return typeof issueUrl === "string" && issueUrl.length > 0 ? issueUrl : undefined;
|
||||
}
|
||||
|
||||
const BROAD_SCOPE_REASON_LABELS: Record<string, string> = {
|
||||
"size-l": "Size L",
|
||||
"steps-high": "many steps",
|
||||
"file-scope-high": "large file scope",
|
||||
"failing-file-mentions-high": "many failing files mentioned",
|
||||
"size-l-with-many-steps": "Size L + many steps",
|
||||
};
|
||||
|
||||
function getBroadScopeFlag(sourceMetadata: Task["sourceMetadata"]): {
|
||||
score: number;
|
||||
reasons: string[];
|
||||
signals?: {
|
||||
size?: string | null;
|
||||
stepCount?: number;
|
||||
fileScopeCount?: number;
|
||||
failingFileMentions?: number;
|
||||
};
|
||||
} | null {
|
||||
const candidate = sourceMetadata?.broadScopeFlag;
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const score = (candidate as { score?: unknown }).score;
|
||||
const reasons = (candidate as { reasons?: unknown }).reasons;
|
||||
const signals = (candidate as { signals?: unknown }).signals;
|
||||
|
||||
if (typeof score !== "number" || !Number.isFinite(score) || !Array.isArray(reasons) || !reasons.every((reason) => typeof reason === "string")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (signals != null && (typeof signals !== "object" || Array.isArray(signals))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
score,
|
||||
reasons,
|
||||
signals: signals as {
|
||||
size?: string | null;
|
||||
stepCount?: number;
|
||||
fileScopeCount?: number;
|
||||
failingFileMentions?: number;
|
||||
} | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function formatBroadScopeReasons(reasons: string[]): string {
|
||||
return reasons.map((reason) => BROAD_SCOPE_REASON_LABELS[reason] ?? reason).join(", ");
|
||||
}
|
||||
|
||||
function parseGithubIssueUrl(url?: string): { owner: string; repo: string; number: number } | null {
|
||||
if (!url) return null;
|
||||
const match = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)(?:$|[/?#])/i);
|
||||
@@ -543,7 +492,6 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
previousTask.sourceAgentId === nextTask.sourceAgentId &&
|
||||
previousTask.sourceMetadata?.issueUrl === nextTask.sourceMetadata?.issueUrl &&
|
||||
previousTask.sourceMetadata?.agentName === nextTask.sourceMetadata?.agentName &&
|
||||
previousTask.sourceMetadata?.broadScopeFlag === nextTask.sourceMetadata?.broadScopeFlag &&
|
||||
previousTask.stalledReview?.reason === nextTask.stalledReview?.reason &&
|
||||
previousTask.stalledReview?.heuristic === nextTask.stalledReview?.heuristic &&
|
||||
previousTask.stalledReview?.matchCount === nextTask.stalledReview?.matchCount &&
|
||||
@@ -899,10 +847,6 @@ function TaskCardComponent({
|
||||
const isAgentCreated = isAgentCreatedTask(task);
|
||||
const sourceAgentName = getSourceAgentName(task);
|
||||
const agentCreatedTitle = sourceAgentName ? `Created by agent: ${sourceAgentName}` : "Created by agent";
|
||||
const broadScopeFlag = getBroadScopeFlag(task.sourceMetadata);
|
||||
const broadScopeTitle = broadScopeFlag
|
||||
? `Broad-scope advisory (score ${broadScopeFlag.score}): ${formatBroadScopeReasons(broadScopeFlag.reasons)}`
|
||||
: null;
|
||||
const isAgentNameLoading = Boolean(task.assignedAgentId && agentName === null);
|
||||
const taskProviders = useMemo(() => {
|
||||
const providers: string[] = [];
|
||||
@@ -1778,13 +1722,6 @@ function TaskCardComponent({
|
||||
{abbreviateMissionTitle(missionTitle ?? task.missionId)}
|
||||
</span>
|
||||
)}
|
||||
{broadScopeFlag && broadScopeTitle && (
|
||||
<span className="card-broad-scope-chip" title={broadScopeTitle} aria-label={broadScopeTitle}>
|
||||
<AlertTriangle aria-hidden="true" />
|
||||
<span className="card-broad-scope-chip-text" aria-hidden="true">Broad scope</span>
|
||||
<span className="visually-hidden">{broadScopeTitle}</span>
|
||||
</span>
|
||||
)}
|
||||
<div className="card-header-actions">
|
||||
{canEdit && (
|
||||
<button
|
||||
|
||||
@@ -188,46 +188,7 @@
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.detail-broad-scope-banner {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
align-items: flex-start;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
margin-top: var(--space-sm);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||||
border: var(--btn-border-width) solid color-mix(in srgb, var(--color-warning) 32%, transparent);
|
||||
}
|
||||
|
||||
.detail-broad-scope-banner-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--space-xs) / 2);
|
||||
min-width: 0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-broad-scope-banner-heading {
|
||||
font-weight: 600;
|
||||
color: var(--color-warning);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.detail-broad-scope-banner-note {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.detail-broad-scope-banner {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-broad-scope-banner-content {
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
.detail-provenance {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./TaskDetailModal.css";
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle } from "lucide-react";
|
||||
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2 } from "lucide-react";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
@@ -375,57 +375,6 @@ function parseGithubIssueLabel(url: string): { label: string; href: string } | n
|
||||
};
|
||||
}
|
||||
|
||||
const BROAD_SCOPE_REASON_LABELS: Record<string, string> = {
|
||||
"size-l": "Size L",
|
||||
"steps-high": "many steps",
|
||||
"file-scope-high": "large file scope",
|
||||
"failing-file-mentions-high": "many failing files mentioned",
|
||||
"size-l-with-many-steps": "Size L + many steps",
|
||||
};
|
||||
|
||||
function getBroadScopeFlag(sourceMetadata: Task["sourceMetadata"]): {
|
||||
score: number;
|
||||
reasons: string[];
|
||||
signals?: {
|
||||
size?: string | null;
|
||||
stepCount?: number;
|
||||
fileScopeCount?: number;
|
||||
failingFileMentions?: number;
|
||||
};
|
||||
} | null {
|
||||
const candidate = sourceMetadata?.broadScopeFlag;
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const score = (candidate as { score?: unknown }).score;
|
||||
const reasons = (candidate as { reasons?: unknown }).reasons;
|
||||
const signals = (candidate as { signals?: unknown }).signals;
|
||||
|
||||
if (typeof score !== "number" || !Number.isFinite(score) || !Array.isArray(reasons) || !reasons.every((reason) => typeof reason === "string")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (signals != null && (typeof signals !== "object" || Array.isArray(signals))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
score,
|
||||
reasons,
|
||||
signals: signals as {
|
||||
size?: string | null;
|
||||
stepCount?: number;
|
||||
fileScopeCount?: number;
|
||||
failingFileMentions?: number;
|
||||
} | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function formatBroadScopeReasons(reasons: string[]): string {
|
||||
return reasons.map((reason) => BROAD_SCOPE_REASON_LABELS[reason] ?? reason).join(", ");
|
||||
}
|
||||
|
||||
function getResearchContextInfo(metadata: Task["sourceMetadata"]): string | undefined {
|
||||
const findingLabel = metadata?.findingLabel;
|
||||
if (typeof findingLabel === "string" && findingLabel.length > 0) {
|
||||
@@ -597,7 +546,6 @@ export function TaskDetailContent({
|
||||
const provenanceDisplay = getProvenanceLabel(workingTask, {
|
||||
sourceAgentName: sourceAgent?.name,
|
||||
});
|
||||
const broadScopeFlag = getBroadScopeFlag(workingTask.sourceMetadata);
|
||||
|
||||
// Sync activeTab when the caller changes initialTab (e.g. opening a different tab)
|
||||
useEffect(() => {
|
||||
@@ -2491,26 +2439,6 @@ export function TaskDetailContent({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{broadScopeFlag && (
|
||||
<div className="detail-broad-scope-banner" aria-label="Triage broad-scope advisory">
|
||||
<AlertTriangle aria-hidden="true" />
|
||||
<div className="detail-broad-scope-banner-content">
|
||||
<div className="detail-broad-scope-banner-heading">Triage broad-scope advisory</div>
|
||||
<div>{`Score ${broadScopeFlag.score} · ${formatBroadScopeReasons(broadScopeFlag.reasons)}`}</div>
|
||||
<div>
|
||||
{[
|
||||
broadScopeFlag.signals?.size ? `Size: ${broadScopeFlag.signals.size}` : null,
|
||||
typeof broadScopeFlag.signals?.stepCount === "number" ? `Steps: ${broadScopeFlag.signals.stepCount}` : null,
|
||||
typeof broadScopeFlag.signals?.fileScopeCount === "number" ? `File scope: ${broadScopeFlag.signals.fileScopeCount}` : null,
|
||||
typeof broadScopeFlag.signals?.failingFileMentions === "number"
|
||||
? `Failing-file mentions: ${broadScopeFlag.signals.failingFileMentions}`
|
||||
: null,
|
||||
].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
<div className="detail-broad-scope-banner-note">Advisory only — task lifecycle is unaffected.</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(task.prInfo?.number || task.mergeDetails?.prNumber) && (
|
||||
<div className="detail-provenance detail-pr-link-row">
|
||||
<GitBranch aria-hidden="true" />
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { TaskCard } from "../TaskCard";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
Link: () => <svg />,
|
||||
GitBranch: () => <svg />,
|
||||
Clock: () => <svg />,
|
||||
Pencil: () => <svg />,
|
||||
Layers: () => <svg />,
|
||||
ChevronDown: () => <svg />,
|
||||
Folder: () => <svg />,
|
||||
GitPullRequest: () => <svg />,
|
||||
CircleDot: () => <svg />,
|
||||
Target: () => <svg />,
|
||||
Bot: () => <svg />,
|
||||
Trash2: () => <svg />,
|
||||
RotateCw: () => <svg />,
|
||||
Zap: () => <svg />,
|
||||
AlertTriangle: () => <svg />,
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useTaskDiffStats", () => ({
|
||||
useTaskDiffStats: () => ({ stats: null, loading: false }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useBadgeWebSocket", () => ({
|
||||
useBadgeWebSocket: () => ({
|
||||
badgeUpdates: new Map(),
|
||||
isConnected: true,
|
||||
subscribeToBadge: vi.fn(),
|
||||
unsubscribeFromBadge: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useBatchBadgeFetch", () => ({
|
||||
getFreshBatchData: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchTaskDetail: vi.fn(),
|
||||
uploadAttachment: vi.fn(),
|
||||
fetchMission: vi.fn(),
|
||||
fetchAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useConfirm", () => ({
|
||||
useConfirm: () => ({ confirm: vi.fn(async () => true) }),
|
||||
}));
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-5314",
|
||||
title: "Broad scope test",
|
||||
description: "",
|
||||
column: "todo",
|
||||
status: undefined,
|
||||
steps: [],
|
||||
dependencies: [],
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function mountCss(): () => void {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = loadAllAppCss();
|
||||
document.head.appendChild(style);
|
||||
return () => style.remove();
|
||||
}
|
||||
|
||||
describe("TaskCard broad-scope advisory chip", () => {
|
||||
it("renders chip for well-formed broadScopeFlag", () => {
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
sourceMetadata: {
|
||||
broadScopeFlag: {
|
||||
score: 5,
|
||||
reasons: ["size-l", "steps-high"],
|
||||
signals: { size: "L", stepCount: 13, fileScopeCount: 22, failingFileMentions: 0 },
|
||||
},
|
||||
},
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const label = "Broad-scope advisory (score 5): Size L, many steps";
|
||||
const chip = screen.getByLabelText(label);
|
||||
expect(chip).toBeInTheDocument();
|
||||
expect(chip).toHaveAttribute("title", label);
|
||||
expect(chip.tagName).toBe("SPAN");
|
||||
});
|
||||
|
||||
it("uses warning color + pill radius tokenized styles", () => {
|
||||
const unmountCss = mountCss();
|
||||
document.documentElement.style.setProperty("--color-warning", "rgb(210, 120, 0)");
|
||||
document.documentElement.style.setProperty("--radius-pill", "9999px");
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
sourceMetadata: {
|
||||
broadScopeFlag: {
|
||||
score: 5,
|
||||
reasons: ["size-l"],
|
||||
},
|
||||
},
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const chip = screen.getByLabelText("Broad-scope advisory (score 5): Size L");
|
||||
const styles = window.getComputedStyle(chip);
|
||||
expect(styles.borderRadius).toBe("var(--radius-pill)");
|
||||
expect(styles.color).toBe("var(--color-warning)");
|
||||
|
||||
unmountCss();
|
||||
document.documentElement.style.removeProperty("--color-warning");
|
||||
document.documentElement.style.removeProperty("--radius-pill");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing sourceMetadata", undefined],
|
||||
["missing broadScopeFlag", {}],
|
||||
["non-numeric score", { broadScopeFlag: { score: "5", reasons: ["size-l"] } }],
|
||||
["non-array reasons", { broadScopeFlag: { score: 5, reasons: "size-l" } }],
|
||||
])("does not render chip when %s", (_label, sourceMetadata) => {
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ sourceMetadata: sourceMetadata as Task["sourceMetadata"] })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Broad scope")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { TaskDetailModal } from "../TaskDetailModal";
|
||||
import {
|
||||
makeTask,
|
||||
noop,
|
||||
noopDelete,
|
||||
noopMerge,
|
||||
noopMove,
|
||||
noopOpenDetail,
|
||||
setupTaskDetailModalHooks,
|
||||
} from "./TaskDetailModal.test-helpers";
|
||||
|
||||
setupTaskDetailModalHooks();
|
||||
|
||||
describe("TaskDetailModal broad-scope advisory banner", () => {
|
||||
it("renders banner details for well-formed broadScopeFlag and keeps provenance", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({
|
||||
sourceType: "dashboard_ui",
|
||||
sourceMetadata: {
|
||||
broadScopeFlag: {
|
||||
score: 4,
|
||||
reasons: ["size-l", "steps-high", "file-scope-high"],
|
||||
signals: {
|
||||
size: "L",
|
||||
stepCount: 13,
|
||||
fileScopeCount: 21,
|
||||
failingFileMentions: 32,
|
||||
},
|
||||
},
|
||||
},
|
||||
})}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Created via Dashboard")).toBeInTheDocument();
|
||||
|
||||
const banner = screen.getByLabelText("Triage broad-scope advisory");
|
||||
expect(banner.tagName).toBe("DIV");
|
||||
expect(within(banner).queryByRole("button")).not.toBeInTheDocument();
|
||||
expect(within(banner).getByText("Triage broad-scope advisory")).toBeInTheDocument();
|
||||
expect(within(banner).getByText("Score 4 · Size L, many steps, large file scope")).toBeInTheDocument();
|
||||
expect(within(banner).getByText("Size: L · Steps: 13 · File scope: 21 · Failing-file mentions: 32")).toBeInTheDocument();
|
||||
expect(within(banner).getByText("Advisory only — task lifecycle is unaffected.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing sourceMetadata", undefined],
|
||||
["missing broadScopeFlag", {}],
|
||||
["non-numeric score", { broadScopeFlag: { score: "4", reasons: ["size-l"] } }],
|
||||
["non-array reasons", { broadScopeFlag: { score: 4, reasons: "size-l" } }],
|
||||
])("does not render banner when %s", (_label, sourceMetadata) => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ sourceMetadata: sourceMetadata as any })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText("Triage broad-scope advisory")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,207 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { execSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { DEFAULT_SETTINGS, TaskStore } from "@fusion/core";
|
||||
|
||||
import * as broadScopeHeuristics from "../../triage-broad-scope-heuristics.js";
|
||||
import { TriageProcessor } from "../../triage.js";
|
||||
|
||||
function git(cwd: string, command: string): string {
|
||||
return execSync(command, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
||||
}
|
||||
|
||||
async function createFixture() {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "fusion-broad-scope-triage-"));
|
||||
git(rootDir, "git init -b main");
|
||||
git(rootDir, 'git config user.email "test@example.com"');
|
||||
git(rootDir, 'git config user.name "Test User"');
|
||||
git(rootDir, "git commit --allow-empty -m init");
|
||||
|
||||
const store = new TaskStore(rootDir, undefined, { inMemoryDb: true });
|
||||
await store.init();
|
||||
await store.updateSettings({ ...DEFAULT_SETTINGS, requirePlanApproval: false });
|
||||
const triage = new TriageProcessor(store, rootDir);
|
||||
|
||||
return {
|
||||
rootDir,
|
||||
store,
|
||||
triage,
|
||||
persistPrompt: async (taskId: string, prompt: string) => {
|
||||
await mkdir(join(rootDir, ".fusion", "tasks", taskId), { recursive: true });
|
||||
await writeFile(join(rootDir, ".fusion", "tasks", taskId, "PROMPT.md"), prompt, "utf-8");
|
||||
},
|
||||
cleanup: async () => {
|
||||
store.close();
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrompt({ size, stepCount, fileScopeCount }: { size: "S" | "M" | "L"; stepCount: number; fileScopeCount: number }): string {
|
||||
const steps = Array.from({ length: stepCount }, (_, index) => `### Step ${index + 1}: Step ${index + 1}\n- [ ] do work ${index + 1}`)
|
||||
.join("\n\n");
|
||||
const fileScope = Array.from({ length: fileScopeCount }, (_, index) => `- ` + "`" + `packages/engine/src/generated/file-${index + 1}.ts` + "`")
|
||||
.join("\n");
|
||||
|
||||
return `# Task: FN-1 - test\n\n**Size:** ${size}\n\n## Review Level: 1\n\n## File Scope\n${fileScope}\n\n## Steps\n\n${steps}\n`;
|
||||
}
|
||||
|
||||
describe("reliability interactions: broad-scope triage flag", () => {
|
||||
const fixtures: Array<Awaited<ReturnType<typeof createFixture>>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
while (fixtures.length) await fixtures.pop()!.cleanup();
|
||||
});
|
||||
|
||||
it("adds broadScopeFlag metadata and preserves intentSignature/fileScope composition", async () => {
|
||||
const fx = await createFixture();
|
||||
fixtures.push(fx);
|
||||
|
||||
const task = await fx.store.createTask({
|
||||
title: "Repair engine regression across generated files",
|
||||
description: "Touches /api/tasks/:id/pr/options, auth.ts, and 30 failing files across the triage pipeline.",
|
||||
});
|
||||
const prompt = buildPrompt({ size: "L", stepCount: 12, fileScopeCount: 25 });
|
||||
await fx.persistPrompt(task.id, prompt);
|
||||
|
||||
await (fx.triage as any).finalizeApprovedTask(task, prompt, await fx.store.getSettings(), {});
|
||||
|
||||
const updated = await fx.store.getTask(task.id);
|
||||
expect(updated.sourceMetadata?.broadScopeFlag).toMatchObject({
|
||||
score: 9,
|
||||
reasons: expect.arrayContaining(["size-l", "steps-high", "file-scope-high", "failing-file-mentions-high", "size-l-with-many-steps"]),
|
||||
signals: expect.objectContaining({
|
||||
size: "L",
|
||||
stepCount: 12,
|
||||
fileScopeCount: 25,
|
||||
failingFileMentions: 30,
|
||||
}),
|
||||
thresholds: expect.objectContaining({
|
||||
stepsHigh: 12,
|
||||
fileScopeHigh: 20,
|
||||
failingFileMentionsHigh: 30,
|
||||
sizeLStepsThreshold: 9,
|
||||
}),
|
||||
version: 1,
|
||||
flaggedAt: expect.any(String),
|
||||
});
|
||||
expect(updated.sourceMetadata?.intentSignature).toBeTruthy();
|
||||
expect(updated.sourceMetadata?.fileScope).toHaveLength(25);
|
||||
});
|
||||
|
||||
it("keeps flagged tasks in todo because the flag is advisory only", async () => {
|
||||
const fx = await createFixture();
|
||||
fixtures.push(fx);
|
||||
|
||||
const task = await fx.store.createTask({
|
||||
title: "Repair engine regression across generated files",
|
||||
description: "Touches /api/tasks/:id/pr/options, auth.ts, and 30 failing files across the triage pipeline.",
|
||||
});
|
||||
const prompt = buildPrompt({ size: "L", stepCount: 12, fileScopeCount: 25 });
|
||||
await fx.persistPrompt(task.id, prompt);
|
||||
|
||||
await (fx.triage as any).finalizeApprovedTask(task, prompt, await fx.store.getSettings(), {});
|
||||
|
||||
const updated = await fx.store.getTask(task.id);
|
||||
expect(updated.column).toBe("todo");
|
||||
});
|
||||
|
||||
it("emits a run-audit event with the broad-scope metadata payload", async () => {
|
||||
const fx = await createFixture();
|
||||
fixtures.push(fx);
|
||||
|
||||
const task = await fx.store.createTask({
|
||||
title: "Repair engine regression across generated files",
|
||||
description: "Touches /api/tasks/:id/pr/options, auth.ts, and 30 failing files across the triage pipeline.",
|
||||
});
|
||||
const prompt = buildPrompt({ size: "L", stepCount: 12, fileScopeCount: 25 });
|
||||
await fx.persistPrompt(task.id, prompt);
|
||||
|
||||
await (fx.triage as any).finalizeApprovedTask(task, prompt, await fx.store.getSettings(), {});
|
||||
|
||||
const audit = fx.store.getRunAuditEvents({ taskId: task.id, limit: 20 });
|
||||
expect(audit).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
mutationType: "task:broad-scope-flagged-at-triage",
|
||||
metadata: expect.objectContaining({
|
||||
score: 9,
|
||||
reasons: expect.arrayContaining(["size-l", "steps-high", "file-scope-high", "failing-file-mentions-high", "size-l-with-many-steps"]),
|
||||
signals: expect.objectContaining({ size: "L", stepCount: 12, fileScopeCount: 25, failingFileMentions: 30 }),
|
||||
thresholds: expect.objectContaining({
|
||||
stepsHigh: 12,
|
||||
fileScopeHigh: 20,
|
||||
failingFileMentionsHigh: 30,
|
||||
sizeLStepsThreshold: 9,
|
||||
}),
|
||||
version: 1,
|
||||
}),
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("appends an operator log entry when the flag fires", async () => {
|
||||
const fx = await createFixture();
|
||||
fixtures.push(fx);
|
||||
|
||||
const task = await fx.store.createTask({
|
||||
title: "Repair engine regression across generated files",
|
||||
description: "Touches /api/tasks/:id/pr/options, auth.ts, and 30 failing files across the triage pipeline.",
|
||||
});
|
||||
const prompt = buildPrompt({ size: "L", stepCount: 12, fileScopeCount: 25 });
|
||||
await fx.persistPrompt(task.id, prompt);
|
||||
|
||||
await (fx.triage as any).finalizeApprovedTask(task, prompt, await fx.store.getSettings(), {});
|
||||
|
||||
const updated = await fx.store.getTask(task.id);
|
||||
expect(updated.log.some((entry) => entry.action === "Broad-scope triage flag")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not add flag metadata, audit, or log entry for small narrow tasks", async () => {
|
||||
const fx = await createFixture();
|
||||
fixtures.push(fx);
|
||||
|
||||
const task = await fx.store.createTask({
|
||||
title: "Fix one narrow regression",
|
||||
description: "Touches auth.ts only.",
|
||||
});
|
||||
const prompt = buildPrompt({ size: "S", stepCount: 4, fileScopeCount: 3 });
|
||||
await fx.persistPrompt(task.id, prompt);
|
||||
|
||||
await (fx.triage as any).finalizeApprovedTask(task, prompt, await fx.store.getSettings(), {});
|
||||
|
||||
const updated = await fx.store.getTask(task.id);
|
||||
expect(updated.column).toBe("todo");
|
||||
expect(updated.sourceMetadata?.broadScopeFlag).toBeUndefined();
|
||||
expect(updated.log.some((entry) => entry.action === "Broad-scope triage flag")).toBe(false);
|
||||
const audit = fx.store.getRunAuditEvents({ taskId: task.id, limit: 20 });
|
||||
expect(audit.some((entry) => entry.mutationType === "task:broad-scope-flagged-at-triage")).toBe(false);
|
||||
});
|
||||
|
||||
it("fails open when signal extraction throws", async () => {
|
||||
const fx = await createFixture();
|
||||
fixtures.push(fx);
|
||||
|
||||
const task = await fx.store.createTask({
|
||||
title: "Repair engine regression across generated files",
|
||||
description: "Touches /api/tasks/:id/pr/options, auth.ts, and 30 failing files across the triage pipeline.",
|
||||
});
|
||||
const prompt = buildPrompt({ size: "L", stepCount: 12, fileScopeCount: 25 });
|
||||
await fx.persistPrompt(task.id, prompt);
|
||||
vi.spyOn(broadScopeHeuristics, "extractBroadScopeSignals").mockImplementation(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
|
||||
await (fx.triage as any).finalizeApprovedTask(task, prompt, await fx.store.getSettings(), {});
|
||||
|
||||
const updated = await fx.store.getTask(task.id);
|
||||
expect(updated.column).toBe("todo");
|
||||
expect(updated.sourceMetadata?.broadScopeFlag).toBeUndefined();
|
||||
expect(updated.log.some((entry) => entry.action === "Broad-scope triage flag")).toBe(false);
|
||||
const audit = fx.store.getRunAuditEvents({ taskId: task.id, limit: 20 });
|
||||
expect(audit.some((entry) => entry.mutationType === "task:broad-scope-flagged-at-triage")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BROAD_SCOPE_FLAG_VERSION,
|
||||
decideBroadScopeFlag,
|
||||
extractBroadScopeSignals,
|
||||
} from "../triage-broad-scope-heuristics.js";
|
||||
|
||||
describe("triage broad-scope heuristics", () => {
|
||||
describe("extractBroadScopeSignals", () => {
|
||||
it("uses the largest matching multi-digit failing-file mention", () => {
|
||||
const signals = extractBroadScopeSignals({
|
||||
size: "M",
|
||||
stepCount: 5,
|
||||
fileScopeCount: 4,
|
||||
descriptionText: "Touches 12 failing files, 30 broken tests, and 21 files overall. Ignore 7 failing files.",
|
||||
});
|
||||
|
||||
expect(signals).toMatchObject({
|
||||
size: "M",
|
||||
stepCount: 5,
|
||||
fileScopeCount: 4,
|
||||
failingFileMentions: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it("caps pathological counts at 9999", () => {
|
||||
const signals = extractBroadScopeSignals({
|
||||
size: "L",
|
||||
stepCount: 14,
|
||||
fileScopeCount: 24,
|
||||
descriptionText: "Spec mentions 12345 failing files across 200 broken tests.",
|
||||
});
|
||||
|
||||
expect(signals.failingFileMentions).toBe(9999);
|
||||
});
|
||||
|
||||
it("returns zero when there are no qualifying mentions", () => {
|
||||
const signals = extractBroadScopeSignals({
|
||||
size: "S",
|
||||
stepCount: 2,
|
||||
fileScopeCount: 1,
|
||||
descriptionText: "Only 9 failing files are listed, plus one broken test.",
|
||||
});
|
||||
|
||||
expect(signals.failingFileMentions).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decideBroadScopeFlag", () => {
|
||||
it("does not flag small low-scope tasks", () => {
|
||||
const decision = decideBroadScopeFlag({
|
||||
size: "S",
|
||||
stepCount: 4,
|
||||
fileScopeCount: 3,
|
||||
failingFileMentions: 0,
|
||||
});
|
||||
|
||||
expect(decision.flagged).toBe(false);
|
||||
expect(decision.score).toBe(0);
|
||||
expect(decision.reasons).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not flag size L alone", () => {
|
||||
const decision = decideBroadScopeFlag({
|
||||
size: "L",
|
||||
stepCount: 4,
|
||||
fileScopeCount: 3,
|
||||
failingFileMentions: 0,
|
||||
});
|
||||
|
||||
expect(decision.flagged).toBe(false);
|
||||
expect(decision.score).toBe(2);
|
||||
expect(decision.reasons).toEqual(["size-l"]);
|
||||
});
|
||||
|
||||
it("flags size L tasks with many steps", () => {
|
||||
const decision = decideBroadScopeFlag({
|
||||
size: "L",
|
||||
stepCount: 12,
|
||||
fileScopeCount: 3,
|
||||
failingFileMentions: 0,
|
||||
});
|
||||
|
||||
expect(decision.flagged).toBe(true);
|
||||
expect(decision.score).toBe(5);
|
||||
expect(decision.reasons).toEqual(["size-l", "steps-high", "size-l-with-many-steps"]);
|
||||
});
|
||||
|
||||
it("does not flag high file scope alone", () => {
|
||||
const decision = decideBroadScopeFlag({
|
||||
size: "M",
|
||||
stepCount: 5,
|
||||
fileScopeCount: 21,
|
||||
failingFileMentions: 0,
|
||||
});
|
||||
|
||||
expect(decision.flagged).toBe(false);
|
||||
expect(decision.score).toBe(2);
|
||||
expect(decision.reasons).toEqual(["file-scope-high"]);
|
||||
});
|
||||
|
||||
it("flags when multiple strong signals combine", () => {
|
||||
const decision = decideBroadScopeFlag({
|
||||
size: "M",
|
||||
stepCount: 5,
|
||||
fileScopeCount: 21,
|
||||
failingFileMentions: 30,
|
||||
});
|
||||
|
||||
expect(decision.flagged).toBe(true);
|
||||
expect(decision.score).toBe(4);
|
||||
expect(decision.reasons).toEqual(["file-scope-high", "failing-file-mentions-high"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("exports the initial heuristic version", () => {
|
||||
expect(BROAD_SCOPE_FLAG_VERSION).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -268,10 +268,8 @@ export type DatabaseMutationType =
|
||||
| "task:auto-recover-worktree-metadata-skipped-active"
|
||||
// task:auto-archived-ghost-bug metadata: { findings: Array<{ construct: { kind: string; raw: string; filePath?: string; line?: number }; matched: boolean; probeError?: string; output?: string }>; reason: string }
|
||||
// task:auto-archived-duplicate metadata: { siblingTaskIds: string[]; scores: Record<string, number> }
|
||||
// task:broad-scope-flagged-at-triage metadata: { score: number; reasons: string[]; signals: { size: "S"|"M"|"L"|null; stepCount: number; fileScopeCount: number; failingFileMentions: number }; thresholds: { stepsHigh: number; fileScopeHigh: number; failingFileMentionsHigh: number; sizeLStepsThreshold: number }; version: number }
|
||||
| "task:auto-archived-ghost-bug"
|
||||
| "task:auto-archived-duplicate"
|
||||
| "task:broad-scope-flagged-at-triage"
|
||||
| "task:auto-reconciled-self-defeating-dep"
|
||||
| "task:dependency-cycle-rejected"
|
||||
| "task:dependency-cycle-detected"
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
export const BROAD_SCOPE_FLAG_VERSION = 1;
|
||||
|
||||
export const DEFAULT_BROAD_SCOPE_THRESHOLDS = {
|
||||
stepsHigh: 12,
|
||||
fileScopeHigh: 20,
|
||||
failingFileMentionsHigh: 30,
|
||||
sizeLStepsThreshold: 9,
|
||||
} as const;
|
||||
|
||||
export interface BroadScopeSignals {
|
||||
size: "S" | "M" | "L" | null;
|
||||
stepCount: number;
|
||||
fileScopeCount: number;
|
||||
failingFileMentions: number;
|
||||
}
|
||||
|
||||
export interface BroadScopeFlagDecision {
|
||||
flagged: boolean;
|
||||
score: number;
|
||||
reasons: string[];
|
||||
signals: BroadScopeSignals;
|
||||
thresholds: typeof DEFAULT_BROAD_SCOPE_THRESHOLDS;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export function extractBroadScopeSignals(input: {
|
||||
size: "S" | "M" | "L" | null;
|
||||
stepCount: number;
|
||||
fileScopeCount: number;
|
||||
descriptionText: string;
|
||||
}): BroadScopeSignals {
|
||||
const matches = input.descriptionText.matchAll(/\b(\d{2,})\s+(failing|broken|test|file)s?\b/gi);
|
||||
let failingFileMentions = 0;
|
||||
|
||||
for (const match of matches) {
|
||||
const value = Number.parseInt(match[1] ?? "0", 10);
|
||||
if (Number.isFinite(value)) {
|
||||
failingFileMentions = Math.max(failingFileMentions, Math.min(value, 9999));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
size: input.size,
|
||||
stepCount: input.stepCount,
|
||||
fileScopeCount: input.fileScopeCount,
|
||||
failingFileMentions,
|
||||
};
|
||||
}
|
||||
|
||||
export function decideBroadScopeFlag(
|
||||
signals: BroadScopeSignals,
|
||||
thresholds: Partial<typeof DEFAULT_BROAD_SCOPE_THRESHOLDS> = {},
|
||||
): BroadScopeFlagDecision {
|
||||
const resolvedThresholds = {
|
||||
...DEFAULT_BROAD_SCOPE_THRESHOLDS,
|
||||
...thresholds,
|
||||
};
|
||||
const reasons: string[] = [];
|
||||
let score = 0;
|
||||
|
||||
if (signals.size === "L") {
|
||||
score += 2;
|
||||
reasons.push("size-l");
|
||||
}
|
||||
if (signals.stepCount >= resolvedThresholds.stepsHigh) {
|
||||
score += 2;
|
||||
reasons.push("steps-high");
|
||||
}
|
||||
if (signals.fileScopeCount >= resolvedThresholds.fileScopeHigh) {
|
||||
score += 2;
|
||||
reasons.push("file-scope-high");
|
||||
}
|
||||
if (signals.failingFileMentions >= resolvedThresholds.failingFileMentionsHigh) {
|
||||
score += 2;
|
||||
reasons.push("failing-file-mentions-high");
|
||||
}
|
||||
if (signals.size === "L" && signals.stepCount >= resolvedThresholds.sizeLStepsThreshold) {
|
||||
score += 1;
|
||||
reasons.push("size-l-with-many-steps");
|
||||
}
|
||||
|
||||
return {
|
||||
flagged: score >= 3,
|
||||
score,
|
||||
reasons,
|
||||
signals,
|
||||
thresholds: resolvedThresholds,
|
||||
version: BROAD_SCOPE_FLAG_VERSION,
|
||||
};
|
||||
}
|
||||
@@ -79,11 +79,6 @@ import {
|
||||
isResearchToolSurfaceEnabled,
|
||||
} from "./tool-availability.js";
|
||||
import { runGhostBugPreflight } from "./triage-preflight.js";
|
||||
import {
|
||||
BROAD_SCOPE_FLAG_VERSION,
|
||||
decideBroadScopeFlag,
|
||||
extractBroadScopeSignals,
|
||||
} from "./triage-broad-scope-heuristics.js";
|
||||
import { archiveAsGhostBug } from "./self-healing.js";
|
||||
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
|
||||
|
||||
@@ -266,6 +261,13 @@ For tasks you assess as Size M or L, consider whether splitting into 2-5 child t
|
||||
- Coordination overhead (worktrees, dependency wiring, merge sequencing) is real — only split when the parallelism or scope-clarity benefit clearly outweighs it
|
||||
- If you decide not to split an M/L task, proceed with a normal PROMPT.md specification
|
||||
|
||||
**Broad-scope decomposition signals:**
|
||||
- Size L tasks, especially when the planned step count would reach 9 or more.
|
||||
- Plans whose implementation-step count would reach 12 or more (additive signal — counts even when the surrounding "more than 7/10 steps" threshold above has not yet fired).
|
||||
- Tasks whose declared \`## File Scope\` would list 20 or more entries.
|
||||
- Descriptions that quantify large remediation batches (for example "47 failing tests", "30+ broken files") at or above 30 items — treat as a strong signal that the work should be partitioned by subsystem or file group before specifying.
|
||||
- When two or more of the signals above fire together, default to splitting via \`fn_task_create\`. If you still choose to keep the task as a single unit, justify the decision explicitly in the PROMPT.md \`## Mission\` paragraph.
|
||||
|
||||
## Triage tools
|
||||
You have these extra tools during triage:
|
||||
- \`fn_task_list\` — list existing active tasks
|
||||
@@ -2416,54 +2418,6 @@ export class TriageProcessor {
|
||||
} catch {
|
||||
// Fail open on persisted PROMPT.md parsing and keep using the in-memory parse.
|
||||
}
|
||||
type BroadScopeFlagRecord = {
|
||||
score: number;
|
||||
reasons: string[];
|
||||
signals: {
|
||||
size: "S" | "M" | "L" | null;
|
||||
stepCount: number;
|
||||
fileScopeCount: number;
|
||||
failingFileMentions: number;
|
||||
};
|
||||
thresholds: {
|
||||
stepsHigh: number;
|
||||
fileScopeHigh: number;
|
||||
failingFileMentionsHigh: number;
|
||||
sizeLStepsThreshold: number;
|
||||
};
|
||||
version: number;
|
||||
flaggedAt: string;
|
||||
};
|
||||
let broadScopeFlagRecord: BroadScopeFlagRecord | null = null;
|
||||
try {
|
||||
const broadScopeSignals = extractBroadScopeSignals({
|
||||
size: taskUpdates.size ?? task.size ?? null,
|
||||
stepCount: parsedSteps.length,
|
||||
fileScopeCount: parsedFileScope.length,
|
||||
descriptionText: task.description ?? "",
|
||||
});
|
||||
const broadScopeDecision = decideBroadScopeFlag(broadScopeSignals);
|
||||
if (broadScopeDecision.flagged) {
|
||||
broadScopeFlagRecord = {
|
||||
score: broadScopeDecision.score,
|
||||
reasons: broadScopeDecision.reasons,
|
||||
signals: broadScopeDecision.signals,
|
||||
thresholds: broadScopeDecision.thresholds,
|
||||
version: BROAD_SCOPE_FLAG_VERSION,
|
||||
flaggedAt: new Date().toISOString(),
|
||||
};
|
||||
taskUpdates.sourceMetadataPatch = {
|
||||
...(taskUpdates.sourceMetadataPatch ?? {}),
|
||||
broadScopeFlag: broadScopeFlagRecord,
|
||||
};
|
||||
planLog.warn(
|
||||
`${task.id}: broad-scope flag at triage — score=${broadScopeDecision.score}, reasons=${broadScopeDecision.reasons.join(",")}`,
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
planLog.warn(`${task.id}: broad-scope heuristic failed open: ${message}`);
|
||||
}
|
||||
let taskIntentSignature: ReturnType<typeof extractIntentSignature> = {
|
||||
routePaths: [],
|
||||
filePaths: [],
|
||||
@@ -2500,37 +2454,6 @@ export class TriageProcessor {
|
||||
|
||||
await this.store.updateTask(task.id, taskUpdates);
|
||||
|
||||
if (broadScopeFlagRecord) {
|
||||
try {
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
"Broad-scope triage flag",
|
||||
`Heuristics suggest this task may benefit from decomposition (score=${broadScopeFlagRecord.score}; signals: ${broadScopeFlagRecord.reasons.join(", ")}). Consider creating child tasks via fn_task_create or marking breakIntoSubtasks=true before execution.`,
|
||||
);
|
||||
const auditor = createRunAuditor(this.store, {
|
||||
taskId: task.id,
|
||||
agentId: task.assignedAgentId ?? "triage",
|
||||
runId: generateSyntheticRunId("triage", task.id),
|
||||
phase: "triage",
|
||||
source: "triage",
|
||||
});
|
||||
await auditor.database({
|
||||
type: "task:broad-scope-flagged-at-triage",
|
||||
target: task.id,
|
||||
metadata: {
|
||||
score: broadScopeFlagRecord.score,
|
||||
reasons: broadScopeFlagRecord.reasons,
|
||||
signals: broadScopeFlagRecord.signals,
|
||||
thresholds: broadScopeFlagRecord.thresholds,
|
||||
version: broadScopeFlagRecord.version,
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
planLog.warn(`${task.id}: broad-scope heuristic failed open: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const preflightDecision = await Promise.race([
|
||||
runGhostBugPreflight(
|
||||
|
||||
Reference in New Issue
Block a user