feat(FN-5171): add near-duplicate task guard to CLI create command
Adds a near-duplicate guard to the CLI task creation command (FN-5171), preventing creation of tasks with titles that are visually or semantically identical to existing ones, with 262 new test cases covering the detection logic and the standard changeset entry. Fusion-Task-Id: FN-5171
This commit is contained in:
committed by
gsxdsm
parent
f4039266e2
commit
b7ddfc9d20
@@ -28,7 +28,8 @@ vi.mock("node:child_process", async () => {
|
||||
});
|
||||
|
||||
// Mock @fusion/core before importing the module under test
|
||||
vi.mock("@fusion/core", () => {
|
||||
vi.mock("@fusion/core", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@fusion/core")>();
|
||||
const COLUMNS = ["triage", "specified", "in-progress", "review", "done"];
|
||||
const COLUMN_LABELS: Record<string, string> = {
|
||||
triage: "Triage",
|
||||
@@ -39,11 +40,14 @@ vi.mock("@fusion/core", () => {
|
||||
};
|
||||
|
||||
return {
|
||||
...actual,
|
||||
TaskStore: vi.fn(),
|
||||
COLUMNS,
|
||||
COLUMN_LABELS,
|
||||
runDeterministicDuplicateGuard: vi.fn(),
|
||||
reconcileDeterministicDuplicate: vi.fn(),
|
||||
extractIntentSignature: vi.fn(),
|
||||
findNearDuplicates: vi.fn(),
|
||||
getTaskDuplicateLineage: vi.fn((task: { sourceType?: string; sourceParentTaskId?: string; sourceMetadata?: any }) => {
|
||||
const ids: string[] = [];
|
||||
if (task.sourceType === "task_duplicate" && task.sourceParentTaskId) ids.push(task.sourceParentTaskId);
|
||||
@@ -115,7 +119,7 @@ vi.mock("../../project-context.js", () => ({
|
||||
}));
|
||||
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { TaskStore, CentralCore, runDeterministicDuplicateGuard, reconcileDeterministicDuplicate } from "@fusion/core";
|
||||
import { TaskStore, CentralCore, extractIntentSignature, findNearDuplicates, runDeterministicDuplicateGuard, reconcileDeterministicDuplicate } from "@fusion/core";
|
||||
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
|
||||
import { exec } from "node:child_process";
|
||||
import { runTaskShow, runTaskCreate, runTaskList, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskBranchRecovery, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "../task.js";
|
||||
@@ -157,6 +161,13 @@ beforeEach(() => {
|
||||
outcome: "kept",
|
||||
canonical: args.createdTask,
|
||||
}));
|
||||
vi.mocked(extractIntentSignature).mockReturnValue({
|
||||
routePaths: [],
|
||||
filePaths: [],
|
||||
identifiers: [],
|
||||
titleTokens: [],
|
||||
});
|
||||
vi.mocked(findNearDuplicates).mockReturnValue([]);
|
||||
});
|
||||
|
||||
describe("runTaskShow", () => {
|
||||
@@ -605,7 +616,164 @@ describe("project-aware task command behavior", () => {
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("runTaskCreate --no-dedup bypasses deterministic guard", async () => {
|
||||
it("runTaskCreate proceeds when no high-signal tokens are present", async () => {
|
||||
const mockCreateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-007", description: "plain task" }));
|
||||
const listTasks = vi.fn();
|
||||
|
||||
vi.mocked(resolveProject).mockResolvedValue({
|
||||
projectId: "proj_test",
|
||||
projectPath: "/test",
|
||||
projectName: "demo-project",
|
||||
isRegistered: true,
|
||||
store: { createTask: mockCreateTask, listTasks, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
|
||||
});
|
||||
|
||||
await runTaskCreate("plain task");
|
||||
|
||||
expect(findNearDuplicates).not.toHaveBeenCalled();
|
||||
expect(listTasks).not.toHaveBeenCalled();
|
||||
expect(mockCreateTask).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("runTaskCreate blocks near-duplicates in non-interactive mode", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((((code?: number) => {
|
||||
throw new Error(`exit:${code}`);
|
||||
}) as unknown) as (code?: string | number | null | undefined) => never);
|
||||
const originalInTTY = process.stdin.isTTY;
|
||||
const originalOutTTY = process.stdout.isTTY;
|
||||
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false });
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false });
|
||||
|
||||
const mockCreateTask = vi.fn();
|
||||
const listTasks = vi.fn().mockResolvedValue([
|
||||
makeTask({ id: "FN-9001", title: "Add /pr/options preflight flow", description: "touch /pr/options and /pr/preflight", column: "todo" }),
|
||||
]);
|
||||
vi.mocked(extractIntentSignature).mockReturnValue({
|
||||
routePaths: ["/pr/options", "/pr/preflight"],
|
||||
filePaths: [],
|
||||
identifiers: [],
|
||||
titleTokens: [],
|
||||
});
|
||||
vi.mocked(findNearDuplicates).mockReturnValue([
|
||||
{ id: "FN-9001", score: 0.7, sharedTokens: ["/pr/options", "/pr/preflight"], titleScore: 0.5, reason: "near-duplicate-intent" },
|
||||
]);
|
||||
|
||||
vi.mocked(resolveProject).mockResolvedValue({
|
||||
projectId: "proj_test",
|
||||
projectPath: "/test",
|
||||
projectName: "demo-project",
|
||||
isRegistered: true,
|
||||
store: { createTask: mockCreateTask, listTasks, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
|
||||
});
|
||||
|
||||
await expect(runTaskCreate("Investigate /pr/options /pr/preflight flow")).rejects.toThrow("exit:1");
|
||||
|
||||
expect(mockCreateTask).not.toHaveBeenCalled();
|
||||
const errorOutput = errorSpy.mock.calls.map((call) => call.join(" ")).join("\n");
|
||||
expect(errorOutput).toContain("FN-9001");
|
||||
expect(errorOutput).toContain("/pr/options");
|
||||
expect(errorOutput).toContain("--no-dedup");
|
||||
|
||||
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalInTTY });
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalOutTTY });
|
||||
errorSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("runTaskCreate proceeds on TTY when user confirms near-duplicate creation", async () => {
|
||||
const originalInTTY = process.stdin.isTTY;
|
||||
const originalOutTTY = process.stdout.isTTY;
|
||||
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true });
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
|
||||
|
||||
const mockCreateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-008", description: "same task" }));
|
||||
const listTasks = vi.fn().mockResolvedValue([
|
||||
makeTask({ id: "FN-9001", title: "Add /pr/options preflight flow", description: "touch /pr/options and /pr/preflight", column: "todo" }),
|
||||
]);
|
||||
const close = vi.fn();
|
||||
vi.mocked(createInterface).mockReturnValue({
|
||||
question: vi.fn().mockResolvedValue("y"),
|
||||
close,
|
||||
} as never);
|
||||
vi.mocked(extractIntentSignature).mockReturnValue({
|
||||
routePaths: ["/pr/options", "/pr/preflight"],
|
||||
filePaths: [],
|
||||
identifiers: [],
|
||||
titleTokens: [],
|
||||
});
|
||||
vi.mocked(findNearDuplicates).mockReturnValue([
|
||||
{ id: "FN-9001", score: 0.7, sharedTokens: ["/pr/options", "/pr/preflight"], titleScore: 0.5, reason: "near-duplicate-intent" },
|
||||
]);
|
||||
vi.mocked(reconcileDeterministicDuplicate).mockResolvedValue({ outcome: "kept", canonical: makeTask({ id: "FN-008", description: "same task" }) });
|
||||
|
||||
vi.mocked(resolveProject).mockResolvedValue({
|
||||
projectId: "proj_test",
|
||||
projectPath: "/test",
|
||||
projectName: "demo-project",
|
||||
isRegistered: true,
|
||||
store: { createTask: mockCreateTask, listTasks, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
|
||||
});
|
||||
|
||||
await runTaskCreate("Investigate /pr/options /pr/preflight flow");
|
||||
|
||||
expect(mockCreateTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
source: expect.objectContaining({
|
||||
sourceMetadata: expect.objectContaining({
|
||||
intentSignature: expect.objectContaining({ routePaths: ["/pr/options", "/pr/preflight"] }),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
expect(close).toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalInTTY });
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalOutTTY });
|
||||
});
|
||||
|
||||
it("runTaskCreate cancels on TTY when user declines near-duplicate creation", async () => {
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((((code?: number) => {
|
||||
throw new Error(`exit:${code}`);
|
||||
}) as unknown) as (code?: string | number | null | undefined) => never);
|
||||
const originalInTTY = process.stdin.isTTY;
|
||||
const originalOutTTY = process.stdout.isTTY;
|
||||
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true });
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
|
||||
|
||||
const mockCreateTask = vi.fn();
|
||||
const listTasks = vi.fn().mockResolvedValue([
|
||||
makeTask({ id: "FN-9001", title: "Add /pr/options preflight flow", description: "touch /pr/options and /pr/preflight", column: "todo" }),
|
||||
]);
|
||||
vi.mocked(createInterface).mockReturnValue({
|
||||
question: vi.fn().mockResolvedValue(""),
|
||||
close: vi.fn(),
|
||||
} as never);
|
||||
vi.mocked(extractIntentSignature).mockReturnValue({
|
||||
routePaths: ["/pr/options", "/pr/preflight"],
|
||||
filePaths: [],
|
||||
identifiers: [],
|
||||
titleTokens: [],
|
||||
});
|
||||
vi.mocked(findNearDuplicates).mockReturnValue([
|
||||
{ id: "FN-9001", score: 0.7, sharedTokens: ["/pr/options", "/pr/preflight"], titleScore: 0.5, reason: "near-duplicate-intent" },
|
||||
]);
|
||||
|
||||
vi.mocked(resolveProject).mockResolvedValue({
|
||||
projectId: "proj_test",
|
||||
projectPath: "/test",
|
||||
projectName: "demo-project",
|
||||
isRegistered: true,
|
||||
store: { createTask: mockCreateTask, listTasks, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
|
||||
});
|
||||
|
||||
await expect(runTaskCreate("Investigate /pr/options /pr/preflight flow")).rejects.toThrow("exit:0");
|
||||
expect(mockCreateTask).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalInTTY });
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalOutTTY });
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("runTaskCreate --no-dedup bypasses deterministic and near-duplicate guards but still stamps signature", async () => {
|
||||
const mockCreateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-008", description: "same task" }));
|
||||
|
||||
vi.mocked(runDeterministicDuplicateGuard).mockResolvedValue({
|
||||
@@ -613,6 +781,12 @@ describe("project-aware task command behavior", () => {
|
||||
fingerprint: "fp-no-dedup",
|
||||
releaseLock: vi.fn(),
|
||||
});
|
||||
vi.mocked(extractIntentSignature).mockReturnValue({
|
||||
routePaths: ["/pr/options", "/pr/preflight"],
|
||||
filePaths: [],
|
||||
identifiers: [],
|
||||
titleTokens: [],
|
||||
});
|
||||
vi.mocked(reconcileDeterministicDuplicate).mockResolvedValue({ outcome: "kept", canonical: makeTask({ id: "FN-008", description: "same task" }) });
|
||||
|
||||
vi.mocked(resolveProject).mockResolvedValue({
|
||||
@@ -620,13 +794,93 @@ describe("project-aware task command behavior", () => {
|
||||
projectPath: "/test",
|
||||
projectName: "demo-project",
|
||||
isRegistered: true,
|
||||
store: { createTask: mockCreateTask, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
|
||||
store: { createTask: mockCreateTask, listTasks: vi.fn(), addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
|
||||
});
|
||||
|
||||
await runTaskCreate("same task", undefined, undefined, undefined, undefined, true);
|
||||
|
||||
expect(runDeterministicDuplicateGuard).toHaveBeenCalledWith(expect.anything(), { description: "same task" }, expect.objectContaining({ bypass: true }));
|
||||
expect(findNearDuplicates).not.toHaveBeenCalled();
|
||||
expect(extractIntentSignature).toHaveBeenCalledWith({ description: "same task" });
|
||||
expect(mockCreateTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
source: expect.objectContaining({
|
||||
sourceMetadata: expect.objectContaining({
|
||||
contentFingerprint: "fp-no-dedup",
|
||||
intentSignature: expect.objectContaining({ routePaths: ["/pr/options", "/pr/preflight"] }),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("runTaskCreate fails open when listTasks throws during near-duplicate checking", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const mockCreateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-008", description: "same task" }));
|
||||
vi.mocked(extractIntentSignature).mockReturnValue({
|
||||
routePaths: ["/pr/options", "/pr/preflight"],
|
||||
filePaths: [],
|
||||
identifiers: [],
|
||||
titleTokens: [],
|
||||
});
|
||||
|
||||
vi.mocked(resolveProject).mockResolvedValue({
|
||||
projectId: "proj_test",
|
||||
projectPath: "/test",
|
||||
projectName: "demo-project",
|
||||
isRegistered: true,
|
||||
store: { createTask: mockCreateTask, listTasks: vi.fn().mockRejectedValue(new Error("list boom")), addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
|
||||
});
|
||||
|
||||
await runTaskCreate("Investigate /pr/options /pr/preflight flow");
|
||||
|
||||
expect(errorSpy.mock.calls.map((call) => call.join(" ")).join("\n")).toContain("near-duplicate check failed (list boom)");
|
||||
expect(mockCreateTask).toHaveBeenCalledOnce();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("runTaskCreate fails open when intent extraction throws", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const mockCreateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-008", description: "same task" }));
|
||||
vi.mocked(extractIntentSignature).mockImplementation(() => {
|
||||
throw new Error("extract boom");
|
||||
});
|
||||
|
||||
vi.mocked(resolveProject).mockResolvedValue({
|
||||
projectId: "proj_test",
|
||||
projectPath: "/test",
|
||||
projectName: "demo-project",
|
||||
isRegistered: true,
|
||||
store: { createTask: mockCreateTask, listTasks: vi.fn(), addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
|
||||
});
|
||||
|
||||
await runTaskCreate("Investigate /pr/options /pr/preflight flow");
|
||||
|
||||
expect(errorSpy.mock.calls.map((call) => call.join(" ")).join("\n")).toContain("near-duplicate check failed (extract boom)");
|
||||
expect(mockCreateTask).toHaveBeenCalledOnce();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("runTaskCreate keeps deterministic duplicate short-circuit ahead of near-duplicate checks", async () => {
|
||||
const existing = makeTask({ id: "FN-777", description: "same task", column: "todo" });
|
||||
|
||||
vi.mocked(runDeterministicDuplicateGuard).mockResolvedValue({
|
||||
action: "duplicate",
|
||||
fingerprint: "fp-dupe",
|
||||
existing,
|
||||
releaseLock: vi.fn(),
|
||||
});
|
||||
|
||||
vi.mocked(resolveProject).mockResolvedValue({
|
||||
projectId: "proj_test",
|
||||
projectPath: "/test",
|
||||
projectName: "demo-project",
|
||||
isRegistered: true,
|
||||
store: { createTask: vi.fn(), listTasks: vi.fn(), addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
|
||||
});
|
||||
|
||||
await runTaskCreate("same task");
|
||||
|
||||
expect(extractIntentSignature).not.toHaveBeenCalled();
|
||||
expect(findNearDuplicates).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runTaskCreate creates separate tasks when description differs", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry } from "@fusion/core";
|
||||
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch } from "@fusion/core";
|
||||
import { aiMergeTask, listBranchRecoveryCandidates, type BranchRecoveryCandidate } from "@fusion/engine";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
@@ -288,6 +288,109 @@ async function resolveNodeByNameOrId(nodeNameOrId: string): Promise<{ id: string
|
||||
}
|
||||
}
|
||||
|
||||
function truncateNearDuplicateLabel(value: string): string {
|
||||
const normalized = value.replace(/\s+/g, " ").trim();
|
||||
if (!normalized) return "(untitled)";
|
||||
return normalized.length > 60 ? `${normalized.slice(0, 60)}…` : normalized;
|
||||
}
|
||||
|
||||
function formatNearDuplicateMatch(match: NearDuplicateMatch, candidates: Map<string, NearDuplicateCandidate>): string[] {
|
||||
const candidate = candidates.get(match.id);
|
||||
const labelSource = candidate?.title?.trim() || candidate?.description?.trim() || "(untitled)";
|
||||
const shared = match.sharedTokens.slice(0, 6).join(", ");
|
||||
return [
|
||||
` ${match.id} (${candidate?.column ?? "unknown"}) ${truncateNearDuplicateLabel(labelSource)}`,
|
||||
` score: ${match.score.toFixed(2)} shared: ${shared}`,
|
||||
];
|
||||
}
|
||||
|
||||
interface CliNearDuplicateOutcome {
|
||||
signature?: IntentSignature;
|
||||
}
|
||||
|
||||
function hasIntentSignal(signature?: IntentSignature): signature is IntentSignature {
|
||||
return !!signature && (signature.routePaths.length + signature.filePaths.length + signature.identifiers.length) > 0;
|
||||
}
|
||||
|
||||
async function runCliNearDuplicateCheck(args: {
|
||||
store: TaskStore;
|
||||
description: string;
|
||||
bypass: boolean;
|
||||
}): Promise<CliNearDuplicateOutcome> {
|
||||
let signature: IntentSignature | undefined;
|
||||
let matches: NearDuplicateMatch[] = [];
|
||||
const candidates = new Map<string, NearDuplicateCandidate>();
|
||||
|
||||
try {
|
||||
signature = extractIntentSignature({ description: args.description });
|
||||
const signalCount = signature.routePaths.length + signature.filePaths.length + signature.identifiers.length;
|
||||
if (signalCount === 0) {
|
||||
return { signature };
|
||||
}
|
||||
if (!args.bypass) {
|
||||
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
const taskCandidates = (await args.store.listTasks({ slim: false, includeArchived: false }))
|
||||
.filter((task) => task.column !== "done")
|
||||
.filter((task) => {
|
||||
const createdAtMs = Date.parse(task.createdAt);
|
||||
return Number.isFinite(createdAtMs) && createdAtMs >= cutoff;
|
||||
})
|
||||
.slice(0, 200)
|
||||
.map((task) => ({
|
||||
id: task.id,
|
||||
title: task.title ?? "",
|
||||
description: task.description,
|
||||
column: task.column,
|
||||
fileScope: Array.isArray(task.source?.sourceMetadata?.fileScope)
|
||||
? task.source.sourceMetadata.fileScope.filter((entry): entry is string => typeof entry === "string")
|
||||
: undefined,
|
||||
createdAt: Date.parse(task.createdAt),
|
||||
} satisfies NearDuplicateCandidate));
|
||||
for (const candidate of taskCandidates) {
|
||||
candidates.set(candidate.id, candidate);
|
||||
}
|
||||
matches = findNearDuplicates(
|
||||
{ description: args.description },
|
||||
taskCandidates,
|
||||
{ windowMs: 7 * 24 * 60 * 60 * 1000 },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Warning: near-duplicate check failed (${error instanceof Error ? error.message : String(error)}); proceeding.`);
|
||||
return { signature: undefined };
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return { signature };
|
||||
}
|
||||
|
||||
console.error("Possible near-duplicate of existing task(s):");
|
||||
for (const match of matches) {
|
||||
for (const line of formatNearDuplicateMatch(match, candidates)) {
|
||||
console.error(line);
|
||||
}
|
||||
}
|
||||
console.error("Pass --no-dedup to create anyway.");
|
||||
|
||||
if (!(process.stdin.isTTY && process.stdout.isTTY)) {
|
||||
console.error("Refusing to create near-duplicate task in non-interactive mode. Re-run with --no-dedup to override.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
const answer = (await rl.question("Create anyway? [y/N]: ")).trim().toLowerCase();
|
||||
if (answer === "y" || answer === "yes") {
|
||||
return { signature };
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
|
||||
console.error("Task creation cancelled. Use --no-dedup to bypass.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export async function runTaskCreate(descriptionArg?: string, attachFiles?: string[], depends?: string[], projectName?: string, nodeName?: string, noDedup = false) {
|
||||
let description = descriptionArg;
|
||||
const projectContext = await getProjectContext(projectName);
|
||||
@@ -321,12 +424,23 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
|
||||
task = guard.existing;
|
||||
linkedExisting = true;
|
||||
} else {
|
||||
// FN-5171: create ordering remains deterministic duplicate (FN-4918) -> near-duplicate intent.
|
||||
// Mirrors the dashboard FN-5152 guard while keeping CLI create fail-open.
|
||||
const nearDuplicate = await runCliNearDuplicateCheck({
|
||||
store,
|
||||
description: description.trim(),
|
||||
bypass: noDedup,
|
||||
});
|
||||
const sourceMetadata = {
|
||||
...(guard.fingerprint ? { contentFingerprint: guard.fingerprint } : {}),
|
||||
...(hasIntentSignal(nearDuplicate.signature) ? { intentSignature: nearDuplicate.signature } : {}),
|
||||
};
|
||||
const created = await store.createTask({
|
||||
description: description.trim(),
|
||||
dependencies: depends,
|
||||
source: {
|
||||
sourceType: "cli",
|
||||
sourceMetadata: guard.fingerprint ? { contentFingerprint: guard.fingerprint } : undefined,
|
||||
sourceMetadata: Object.keys(sourceMetadata).length > 0 ? sourceMetadata : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user