Merge pull request #1194 from plarson/feat/task-dependency-mutation

Add safe task dependency mutation
This commit is contained in:
gsxdsm
2026-05-30 16:33:13 -07:00
committed by GitHub
7 changed files with 433 additions and 5 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add safe `fn task deps` commands for audited task dependency mutations.

View File

@@ -14,6 +14,7 @@ const commandMocks = vi.hoisted(() => ({
runTaskMove: vi.fn(),
runTaskMerge: vi.fn(),
runTaskUpdate: vi.fn(),
runTaskDeps: vi.fn(),
runTaskLog: vi.fn(),
runTaskLogs: vi.fn(),
runTaskShow: vi.fn(),
@@ -122,6 +123,7 @@ vi.mock("../commands/task.js", () => ({
runTaskMove: commandMocks.runTaskMove,
runTaskMerge: commandMocks.runTaskMerge,
runTaskUpdate: commandMocks.runTaskUpdate,
runTaskDeps: commandMocks.runTaskDeps,
runTaskLog: commandMocks.runTaskLog,
runTaskLogs: commandMocks.runTaskLogs,
runTaskShow: commandMocks.runTaskShow,
@@ -401,6 +403,32 @@ describe("bin command routing and fallbacks", () => {
);
});
it("routes task dependency replacement", async () => {
await runBin(["task", "deps", "replace", "FN-155", "FN-191", "FN-195", "--project", "atlas-notes"]);
expect(commandMocks.runTaskDeps).toHaveBeenCalledWith(
"replace",
"FN-155",
["FN-191", "FN-195"],
"atlas-notes",
);
});
it("routes task dependency add", async () => {
await runBin(["task", "deps", "add", "FN-155", "FN-191", "--project", "atlas-notes"]);
expect(commandMocks.runTaskDeps).toHaveBeenCalledWith(
"add",
"FN-155",
["FN-191"],
"atlas-notes",
);
});
it("errors for task deps missing operation", async () => {
await expect(runBin(["task", "deps"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Usage: fn task deps add <id> <dependency>");
});
it("errors for task move missing arguments", async () => {
await expect(runBin(["task", "move"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Usage: fn task move <id> <column>");

View File

@@ -119,7 +119,7 @@ async function loadCommandHandlers() {
const { runServe } = await import("./commands/serve.js");
const { runDaemon } = await import("./commands/daemon.js");
const { runDesktop } = await import("./commands/desktop.js");
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskPrCreate } = await import("./commands/task.js");
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskDeps, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskPrCreate } = await import("./commands/task.js");
const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js");
const { runSettingsExport } = await import("./commands/settings-export.js");
const { runSettingsImport } = await import("./commands/settings-import.js");
@@ -153,6 +153,7 @@ async function loadCommandHandlers() {
runTaskMove,
runTaskMerge,
runTaskUpdate,
runTaskDeps,
runTaskLog,
runTaskLogs,
runTaskShow,
@@ -274,6 +275,7 @@ Usage:
Show task agent execution logs
fn task move <id> <col> Move a task to a column
fn task update <id> <step> <status> Update step status (pending|in-progress|done|skipped)
fn task deps <op> <id> ... Add/remove/replace/set task dependencies
fn task log <id> <message> Add a log entry
fn task merge <id> Merge an in-review task and close it
fn task duplicate <id> Duplicate a task (creates copy in triage)
@@ -562,6 +564,7 @@ async function main() {
runTaskMove,
runTaskMerge,
runTaskUpdate,
runTaskDeps,
runTaskLog,
runTaskLogs,
runTaskShow,
@@ -1088,6 +1091,20 @@ async function main() {
await runTaskUpdate(id, step, status, projectName);
break;
}
case "deps": {
const operation = args[2];
const id = args[3];
const dependencyArgs = args.slice(4);
if (!operation || !id || !["add", "remove", "replace", "set"].includes(operation)) {
console.error("Usage: fn task deps add <id> <dependency>");
console.error(" fn task deps remove <id> <dependency>");
console.error(" fn task deps replace <id> <old> <new>");
console.error(" fn task deps set <id> [dependency ...]");
process.exit(1);
}
await runTaskDeps(operation as "add" | "remove" | "replace" | "set", id, dependencyArgs, projectName);
break;
}
case "log": {
const id = args[2], message = args.slice(3).join(" ");
if (!id || !message) { console.error("Usage: fn task log <id> <message>"); process.exit(1); }

View File

@@ -1,4 +1,4 @@
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildManualRetryResetPatch, 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 { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core";
import { aiMergeTask } from "@fusion/engine";
import { createInterface } from "node:readline/promises";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
@@ -519,6 +519,43 @@ export async function runTaskUpdate(id: string, stepStr: string, status: string,
console.log();
}
export async function runTaskDeps(
operation: "add" | "remove" | "replace" | "set",
id: string,
dependencyArgs: string[],
projectName?: string,
) {
const store = await getStore(projectName);
let mutation: TaskDependencyMutation;
switch (operation) {
case "add":
if (dependencyArgs.length !== 1) throw new Error("Usage: fn task deps add <task> <dependency>");
mutation = { operation, dependency: dependencyArgs[0] };
break;
case "remove":
if (dependencyArgs.length !== 1) throw new Error("Usage: fn task deps remove <task> <dependency>");
mutation = { operation, dependency: dependencyArgs[0] };
break;
case "replace":
if (dependencyArgs.length !== 2) throw new Error("Usage: fn task deps replace <task> <old> <new>");
mutation = { operation, from: dependencyArgs[0], to: dependencyArgs[1] };
break;
case "set":
mutation = { operation, dependencies: dependencyArgs };
break;
}
const task = await store.updateTaskDependencies(id, mutation);
console.log();
console.log(`${task.id}: dependencies → ${task.dependencies.length ? task.dependencies.join(", ") : "none"}`);
if (task.blockedBy) {
console.log(` Blocked by: ${task.blockedBy}`);
} else {
console.log(" Blocked by: none");
}
console.log();
}
export async function runTaskLog(id: string, message: string, outcome?: string, projectName?: string) {
const store = await getStore(projectName);
await store.logEntry(id, message, outcome);
@@ -601,6 +638,7 @@ function filterEntries(entries: AgentLogEntry[], options: LogsOptions): AgentLog
return result;
}
export async function runTaskLogs(id: string, options: LogsOptions = {}, projectName?: string) {
const projectContext = await getProjectContext(projectName);
const store = projectContext?.store ?? await getStore(projectName);

View File

@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
import type { TaskStore } from "../store.js";
describe("TaskStore dependency mutations", () => {
const harness = createTaskStoreTestHarness();
let store: TaskStore;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
});
afterEach(harness.afterEach);
it("replaces an obsolete dependency and clears stale blockers when the replacement is done", async () => {
const obsolete = await store.createTask({ description: "obsolete prerequisite" });
const canonical = await store.createTask({ description: "canonical prerequisite", column: "done" });
const dependent = await store.createTask({
description: "dependent task",
column: "todo",
dependencies: [obsolete.id],
});
await store.updateTask(dependent.id, { status: "queued", blockedBy: obsolete.id });
const movedEvents: Array<{ from: string; to: string; task: { id: string } }> = [];
store.on("task:moved", (event) => movedEvents.push(event));
const updated = await store.updateTaskDependencies(dependent.id, {
operation: "replace",
from: obsolete.id,
to: canonical.id,
});
expect(updated.dependencies).toEqual([canonical.id]);
expect(updated.blockedBy).toBeUndefined();
expect(updated.status).toBeUndefined();
expect(updated.column).toBe("triage");
expect(updated.log.at(-2)?.action).toBe("Moved to triage for re-specification — new dependency added");
expect(updated.log.at(-1)?.action).toContain(`Replaced dependency ${obsolete.id} with ${canonical.id}`);
expect(movedEvents).toHaveLength(1);
expect(movedEvents[0]).toMatchObject({ from: "todo", to: "triage", task: { id: dependent.id } });
const reloaded = await store.getTask(dependent.id);
expect(reloaded.dependencies).toEqual([canonical.id]);
expect(reloaded.blockedBy).toBeUndefined();
const taskJson = JSON.parse(
await readFile(join(harness.rootDir(), ".fusion", "tasks", dependent.id, "task.json"), "utf-8"),
) as { dependencies: string[]; blockedBy?: string; column: string; status?: string };
expect(taskJson.dependencies).toEqual([canonical.id]);
expect(taskJson.blockedBy).toBeUndefined();
expect(taskJson.column).toBe("triage");
expect(taskJson.status).toBeUndefined();
});
it("repoints stale blockedBy when the current blocker is resolved but still a dependency", async () => {
const resolved = await store.createTask({ description: "resolved prerequisite", column: "done" });
const unresolved = await store.createTask({ description: "unresolved prerequisite" });
const dependent = await store.createTask({
description: "dependent task",
column: "todo",
dependencies: [resolved.id],
});
await store.updateTask(dependent.id, { blockedBy: resolved.id });
const updated = await store.updateTaskDependencies(dependent.id, {
operation: "add",
dependency: unresolved.id,
});
expect(updated.dependencies).toEqual([resolved.id, unresolved.id]);
expect(updated.blockedBy).toBe(unresolved.id);
expect(updated.column).toBe("triage");
const taskJson = JSON.parse(
await readFile(join(harness.rootDir(), ".fusion", "tasks", dependent.id, "task.json"), "utf-8"),
) as { dependencies: string[]; blockedBy?: string; column: string };
expect(taskJson.dependencies).toEqual([resolved.id, unresolved.id]);
expect(taskJson.blockedBy).toBe(unresolved.id);
expect(taskJson.column).toBe("triage");
});
it("removes dependencies and recomputes stale blockers", async () => {
const active = await store.createTask({ description: "active prerequisite" });
const resolved = await store.createTask({ description: "resolved prerequisite", column: "done" });
const dependent = await store.createTask({
description: "dependent task",
dependencies: [active.id, resolved.id],
});
await store.updateTask(dependent.id, { blockedBy: active.id });
await expect(
store.updateTaskDependencies(dependent.id, { operation: "remove", dependency: "FN-404" }),
).rejects.toThrow(/does not depend on/);
const updated = await store.updateTaskDependencies(dependent.id, {
operation: "remove",
dependency: active.id,
});
expect(updated.dependencies).toEqual([resolved.id]);
expect(updated.blockedBy).toBeUndefined();
const reloaded = await store.getTask(dependent.id);
expect(reloaded.dependencies).toEqual([resolved.id]);
expect(reloaded.blockedBy).toBeUndefined();
});
it("sets dependencies with validation and blocker recomputation", async () => {
const original = await store.createTask({ description: "original prerequisite" });
const replacement = await store.createTask({ description: "replacement prerequisite", column: "done" });
const dependent = await store.createTask({
description: "dependent task",
column: "todo",
dependencies: [original.id],
});
const cycle = await store.createTask({ description: "cycle prerequisite", dependencies: [dependent.id] });
await store.updateTask(dependent.id, { blockedBy: original.id });
await expect(
store.updateTaskDependencies(dependent.id, { operation: "set", dependencies: [replacement.id, replacement.id] }),
).rejects.toThrow(/already depends on/);
await expect(
store.updateTaskDependencies(dependent.id, { operation: "set", dependencies: [dependent.id] }),
).rejects.toThrow(/cannot depend on itself/);
await expect(
store.updateTaskDependencies(dependent.id, { operation: "set", dependencies: [cycle.id] }),
).rejects.toThrow(/Dependency cycle detected/);
const updated = await store.updateTaskDependencies(dependent.id, {
operation: "set",
dependencies: [replacement.id],
});
expect(updated.dependencies).toEqual([replacement.id]);
expect(updated.blockedBy).toBeUndefined();
expect(updated.column).toBe("triage");
});
it("rejects missing replacements, duplicates, self dependencies, and cycles", async () => {
const a = await store.createTask({ description: "a" });
const b = await store.createTask({ description: "b", dependencies: [a.id] });
const c = await store.createTask({ description: "c", dependencies: [a.id] });
await expect(
store.updateTaskDependencies(c.id, { operation: "replace", from: b.id, to: a.id }),
).rejects.toThrow(/does not depend on/);
await expect(
store.updateTaskDependencies(c.id, { operation: "add", dependency: a.id }),
).rejects.toThrow(/already depends on/);
await expect(
store.updateTaskDependencies(c.id, { operation: "add", dependency: c.id }),
).rejects.toThrow(/cannot depend on itself/);
await expect(
store.updateTaskDependencies(a.id, { operation: "add", dependency: c.id }),
).rejects.toThrow(/Dependency cycle detected/);
});
});

View File

@@ -174,6 +174,7 @@ export {
type DeterministicGuardOptions,
type DeterministicGuardOutcome,
} from "./duplicate-guard.js";
export type { TaskDependencyMutation } from "./store.js";
export {
findSameAgentDuplicates,
archiveAsSameAgentDuplicate,

View File

@@ -646,6 +646,13 @@ export interface TaskStoreEvents {
* references *before* deleting the parent — otherwise the dependents
* would be permanently blocked by a nonexistent id.
*/
export type TaskDependencyMutation =
| { operation: "add"; dependency: string }
| { operation: "remove"; dependency: string }
| { operation: "replace"; from: string; to: string }
| { operation: "set"; dependencies: string[] };
export class TaskHasDependentsError extends Error {
readonly taskId: string;
readonly dependentIds: string[];
@@ -5573,6 +5580,173 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
}
async updateTaskDependencies(
id: string,
mutation: TaskDependencyMutation,
runContext?: RunMutationContext,
): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
const previousDependencies = [...(task.dependencies ?? [])];
const normalizedCurrent = previousDependencies.map((dependency) => dependency.trim()).filter(Boolean);
let nextDependencies: string[];
let action: string;
const assertNotSelf = (dependencyId: string) => {
if (dependencyId === id) {
throw new Error(`Task ${id} cannot depend on itself`);
}
};
const assertTaskExists = (dependencyId: string) => {
if (!this.readTaskFromDb(dependencyId)) {
throw new Error(`Dependency task ${dependencyId} not found`);
}
};
const assertUnique = (dependencies: readonly string[]) => {
const seen = new Set<string>();
for (const dependencyId of dependencies) {
if (seen.has(dependencyId)) {
throw new Error(`Task ${id} already depends on ${dependencyId}`);
}
seen.add(dependencyId);
}
};
const normalizeDependency = (dependencyId: string, label = "dependency") => {
const normalized = dependencyId.trim();
if (!normalized) {
throw new Error(`${label} is required`);
}
assertNotSelf(normalized);
assertTaskExists(normalized);
return normalized;
};
switch (mutation.operation) {
case "add": {
const dependency = normalizeDependency(mutation.dependency);
if (normalizedCurrent.includes(dependency)) {
throw new Error(`Task ${id} already depends on ${dependency}`);
}
nextDependencies = [...normalizedCurrent, dependency];
action = `Added dependency ${dependency}`;
break;
}
case "remove": {
const dependency = mutation.dependency.trim();
if (!dependency) {
throw new Error("dependency is required");
}
if (!normalizedCurrent.includes(dependency)) {
throw new Error(`Task ${id} does not depend on ${dependency}`);
}
nextDependencies = normalizedCurrent.filter((candidate) => candidate !== dependency);
action = `Removed dependency ${dependency}`;
break;
}
case "replace": {
const from = mutation.from.trim();
if (!from) {
throw new Error("from dependency is required");
}
const to = normalizeDependency(mutation.to, "replacement dependency");
if (!normalizedCurrent.includes(from)) {
throw new Error(`Task ${id} does not depend on ${from}`);
}
if (from !== to && normalizedCurrent.includes(to)) {
throw new Error(`Task ${id} already depends on ${to}`);
}
nextDependencies = normalizedCurrent.map((dependency) => dependency === from ? to : dependency);
action = `Replaced dependency ${from} with ${to}`;
break;
}
case "set": {
nextDependencies = mutation.dependencies.map((dependency) => normalizeDependency(dependency));
assertUnique(nextDependencies);
action = nextDependencies.length > 0
? `Set dependencies to ${nextDependencies.join(", ")}`
: "Cleared dependencies";
break;
}
}
const selfDefeatingDep = detectSelfDefeatingDependency(task.title, nextDependencies);
if (selfDefeatingDep) {
throw new SelfDefeatingDependencyError(
task.title?.trim() ?? "",
selfDefeatingDep.matchedVerb,
selfDefeatingDep.operandTaskId,
);
}
await this.assertNoDependencyCycle(
id,
nextDependencies,
"updateTask",
new Map([[id, nextDependencies]]),
);
const previousDependencySet = new Set(normalizedCurrent);
const hasNewDependencies = nextDependencies.some((dependencyId) => !previousDependencySet.has(dependencyId));
task.dependencies = nextDependencies;
const unresolvedDependency = nextDependencies.find((dependencyId) => {
const dependency = this.readTaskFromDb(dependencyId);
return dependency?.column !== "done" && dependency?.column !== "archived";
});
if (unresolvedDependency) {
const currentBlocker = task.blockedBy ? this.readTaskFromDb(task.blockedBy) : undefined;
const currentBlockerResolved = currentBlocker?.column === "done" || currentBlocker?.column === "archived";
if (!task.blockedBy || !nextDependencies.includes(task.blockedBy) || !currentBlocker || currentBlockerResolved) {
task.blockedBy = unresolvedDependency;
}
} else {
task.blockedBy = undefined;
}
task.updatedAt = new Date().toISOString();
task.log ??= [];
let movedToTriage = false;
if (hasNewDependencies && task.column === "todo") {
task.column = "triage";
movedToTriage = true;
task.status = undefined;
task.columnMovedAt = task.updatedAt;
task.log.push({
timestamp: task.updatedAt,
action: "Moved to triage for re-specification — new dependency added",
...(runContext ? { runContext } : {}),
});
}
task.log.push({
timestamp: task.updatedAt,
action,
...(runContext ? { runContext } : {}),
});
const auditEvent: RunAuditEventInput = {
taskId: id,
agentId: runContext?.agentId ?? "manual",
runId: runContext?.runId ?? "manual",
domain: "database",
mutationType: "task:dependencies:update",
target: id,
metadata: {
mutation,
previousDependencies,
dependencies: nextDependencies,
blockedBy: task.blockedBy ?? null,
},
};
await this.atomicWriteTaskJsonWithAudit(dir, task, auditEvent);
if (movedToTriage) {
this.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column, source: "engine" });
}
this.emit("task:updated", task);
return task;
});
}
async updateTask(
id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
@@ -5651,9 +5825,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Detect new dependencies being added to a todo task → auto-move to triage
let movedToTriage = false;
if (updates.dependencies !== undefined) {
const oldDeps = new Set(task.dependencies);
const hasNewDeps = updates.dependencies.some((d) => !oldDeps.has(d));
task.dependencies = updates.dependencies;
const oldDeps = new Set((task.dependencies ?? []).map((dependency) => dependency.trim()).filter(Boolean));
const normalizedDependencies = updates.dependencies.map((dependency) => dependency.trim()).filter(Boolean);
const hasNewDeps = normalizedDependencies.some((d) => !oldDeps.has(d));
task.dependencies = normalizedDependencies;
if (hasNewDeps && task.column === "todo") {
task.column = "triage";