feat(FN-4194): add reconciliation script for identity resolution

Adds a reconciliation script for FN-3909 identity issues, includes comprehensive tests verifying the workflow, and documents the process in the storage guide.

Fusion-Task-Id: FN-4194
This commit is contained in:
Fusion
2026-05-12 15:56:26 -07:00
committed by gsxdsm
parent be5e1fbd97
commit 51e8313419
3 changed files with 432 additions and 0 deletions

View File

@@ -0,0 +1,234 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import {
main,
RECONCILIATION_ACTION,
} from "../reconcile-fn-3909-identity.mjs";
const STALE_TITLE = "Implement heartbeat scope discipline for default agent promp";
const STALE_DESCRIPTION = "Pick up FN-3884 (Heartbeat scope discipline for default agent prompts) — it's fully unblocked, spec-approved, size S. Update HEARTBEAT_PROCEDURE and HEARTBEAT_NO_TASK_PROCEDURE constants with scope-discipline guidance.";
const CANONICAL_HEADING = "# Task: FN-3909 - Restore icons + width-aware labels on agent card buttons\n";
const cloneValue = globalThis.structuredClone
? (value) => globalThis.structuredClone(value)
: (value) => JSON.parse(JSON.stringify(value));
function createTempDir() {
return mkdtempSync(path.join(os.tmpdir(), "fn-4194-"));
}
function writeJson(filePath, value) {
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
}
function readJson(filePath) {
return JSON.parse(readFileSync(filePath, "utf8"));
}
function createTaskFixture() {
return {
id: "FN-3909",
title: STALE_TITLE,
description: STALE_DESCRIPTION,
column: "done",
log: [{ timestamp: "2026-05-10T05:40:52.625Z", action: "Task created" }],
steps: [{ name: "Preflight", status: "done" }],
mergeDetails: {
commitSha: "3cdc2d5a5d7525a2e142f351dbdc6353892be2f4",
mergeCommitMessage: "Added a details button icon to agent cards in the AgentsView with responsive container query styling for action labels, accompanied by regression tests.",
mergeConfirmed: true,
},
modifiedFiles: ["packages/dashboard/app/components/AgentsView.tsx"],
createdAt: "2026-05-10T05:40:52.625Z",
updatedAt: "2026-05-12T02:28:16.959Z",
};
}
function createFixture({ promptHeading = CANONICAL_HEADING, rawPrompt, task = createTaskFixture() } = {}) {
const root = createTempDir();
const fusionDir = path.join(root, ".fusion");
const taskDir = path.join(fusionDir, "tasks", "FN-3909");
mkdirSync(taskDir, { recursive: true });
writeFileSync(
path.join(taskDir, "PROMPT.md"),
rawPrompt ?? `${promptHeading}\n## Mission\n\nFix two related defects in AgentsView.\n`,
);
writeJson(path.join(taskDir, "task.json"), task);
const unrelatedTask = {
id: "FN-9999",
title: "Unrelated task",
description: "Should stay untouched",
column: "done",
log: [{ timestamp: "2026-05-12T00:00:00.000Z", action: "Task created" }],
createdAt: "2026-05-12T00:00:00.000Z",
updatedAt: "2026-05-12T00:00:00.000Z",
};
const tasks = new Map([
[task.id, cloneValue(task)],
[unrelatedTask.id, cloneValue(unrelatedTask)],
]);
const calls = { updateTask: 0, logEntry: 0 };
const store = {
calls,
async getTask(id) {
const taskState = tasks.get(id);
return taskState ? cloneValue(taskState) : null;
},
async updateTask(id, updates) {
calls.updateTask += 1;
const taskState = tasks.get(id);
assert.ok(taskState, `missing task ${id}`);
Object.assign(taskState, updates, { updatedAt: "2026-05-12T12:00:00.000Z" });
if (id === "FN-3909") {
writeJson(path.join(taskDir, "task.json"), taskState);
}
return cloneValue(taskState);
},
async logEntry(id, action, outcome) {
calls.logEntry += 1;
const taskState = tasks.get(id);
assert.ok(taskState, `missing task ${id}`);
taskState.log.push({ timestamp: "2026-05-12T12:00:01.000Z", action, outcome });
taskState.updatedAt = "2026-05-12T12:00:01.000Z";
if (id === "FN-3909") {
writeJson(path.join(taskDir, "task.json"), taskState);
}
return cloneValue(taskState);
},
};
return {
root,
taskDir,
taskPath: path.join(taskDir, "task.json"),
promptPath: path.join(taskDir, "PROMPT.md"),
store,
calls,
tasks,
cleanup() {
rmSync(root, { recursive: true, force: true });
},
};
}
function comparableTask(task) {
const clone = cloneValue(task);
delete clone.updatedAt;
return clone;
}
test("dry-run reports the diff without mutating the task", async () => {
const fixture = createFixture();
try {
const beforeTaskJson = readFileSync(fixture.taskPath, "utf8");
const unrelatedBefore = comparableTask(fixture.tasks.get("FN-9999"));
const result = await main(["--project-root", fixture.root], { store: fixture.store });
assert.equal(result.status, "would-apply");
assert.equal(result.dryRun, true);
assert.equal(result.diff.before.title, STALE_TITLE);
assert.match(result.diff.after.title, /Restore icons/);
assert.equal(fixture.calls.updateTask, 0);
assert.equal(fixture.calls.logEntry, 0);
assert.equal(readFileSync(fixture.taskPath, "utf8"), beforeTaskJson);
assert.deepEqual(comparableTask(fixture.tasks.get("FN-9999")), unrelatedBefore);
} finally {
fixture.cleanup();
}
});
test("apply updates only title, description, and log on FN-3909", async () => {
const fixture = createFixture();
try {
const before = readJson(fixture.taskPath);
const unrelatedBefore = comparableTask(fixture.tasks.get("FN-9999"));
const result = await main(["--project-root", fixture.root, "--apply"], { store: fixture.store });
const after = readJson(fixture.taskPath);
assert.equal(result.status, "applied");
assert.equal(fixture.calls.updateTask, 1);
assert.equal(fixture.calls.logEntry, 1);
assert.equal(after.title, "Restore icons + width-aware labels on agent card buttons");
assert.match(after.description, /AgentsView card action buttons/);
assert.equal(after.log.at(-1).action, RECONCILIATION_ACTION);
assert.match(after.log.at(-1).outcome, /FN-4194/);
const beforeComparable = comparableTask(before);
const afterComparable = comparableTask(after);
beforeComparable.title = afterComparable.title;
beforeComparable.description = afterComparable.description;
beforeComparable.log = afterComparable.log;
assert.deepEqual(afterComparable, beforeComparable);
assert.deepEqual(comparableTask(fixture.tasks.get("FN-9999")), unrelatedBefore);
} finally {
fixture.cleanup();
}
});
test("second apply is a no-op once reconciliation marker exists", async () => {
const fixture = createFixture();
try {
await main(["--project-root", fixture.root, "--apply"], { store: fixture.store });
const afterFirstApply = readFileSync(fixture.taskPath, "utf8");
const result = await main(["--project-root", fixture.root, "--apply"], { store: fixture.store });
assert.equal(result.status, "noop");
assert.equal(fixture.calls.updateTask, 1);
assert.equal(fixture.calls.logEntry, 1);
assert.equal(readFileSync(fixture.taskPath, "utf8"), afterFirstApply);
} finally {
fixture.cleanup();
}
});
test("refuses when the row already matches the prompt heading without reconciliation marker", async () => {
const task = createTaskFixture();
task.title = "Restore icons + width-aware labels on agent card buttons";
task.description = "Already canonical but not reconciled.";
const fixture = createFixture({ task });
try {
await assert.rejects(
main(["--project-root", fixture.root, "--apply"], { store: fixture.store }),
/already matches PROMPT heading but no FN-4194 reconciliation marker is present/,
);
assert.equal(fixture.calls.updateTask, 0);
assert.equal(fixture.calls.logEntry, 0);
} finally {
fixture.cleanup();
}
});
test("refuses when PROMPT heading is missing or unexpected", async (t) => {
await t.test("missing heading", async () => {
const fixture = createFixture({ rawPrompt: "No markdown heading here.\n\nJust body text.\n" });
try {
await assert.rejects(
main(["--project-root", fixture.root, "--apply"], { store: fixture.store }),
/PROMPT.md is missing a first heading/,
);
} finally {
fixture.cleanup();
}
});
await t.test("unexpected heading", async () => {
const fixture = createFixture({ promptHeading: "# Task: FN-3909 - Unexpected heading\n" });
try {
await assert.rejects(
main(["--project-root", fixture.root, "--apply"], { store: fixture.store }),
/unexpected PROMPT heading/,
);
} finally {
fixture.cleanup();
}
});
});

View File

@@ -0,0 +1,183 @@
#!/usr/bin/env node
import { readFileSync } from "node:fs";
import path from "node:path";
import process from "node:process";
export const TASK_ID = "FN-3909";
export const SOURCE_TASK_ID = "FN-4194";
export const CANONICAL_TITLE = "Restore icons + width-aware labels on agent card buttons";
export const CANONICAL_DESCRIPTION = "Fix the AgentsView card action buttons so the Details button restores its icon and split-sidebar action labels only hide when the available width is too narrow to fit them.";
export const EXPECTED_STALE_TITLE = "Implement heartbeat scope discipline for default agent promp";
export const EXPECTED_STALE_DESCRIPTION_FRAGMENT = "Pick up FN-3884 (Heartbeat scope discipline for default agent prompts)";
export const EXPECTED_PROMPT_HEADING = `Task: ${TASK_ID} - ${CANONICAL_TITLE}`;
export const RECONCILIATION_ACTION = `${SOURCE_TASK_ID} reconciliation`;
export const RECONCILIATION_OUTCOME = `${SOURCE_TASK_ID}: reconciled ${TASK_ID} title/description to the canonical merged UI-fix identity documented in task postmortem and canonical-mapping notes.`;
function readFlagValue(argv, flag) {
const index = argv.indexOf(flag);
if (index < 0) return undefined;
return argv[index + 1];
}
function normalizeTitle(text) {
return String(text ?? "")
.replace(/^#\s+/, "")
.replace(/^Task:\s*/i, "")
.replace(/^[A-Z]+-\d+\s*[:-]\s*/i, "")
.replace(/\s*\[via:[^\]]+\]\s*$/i, "")
.replace(/[“”]/g, '"')
.replace(/[]/g, "'")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
function firstHeading(promptText) {
const line = String(promptText ?? "").split(/\r?\n/).find((entry) => entry.trim().startsWith("#"));
if (!line) return null;
return line.replace(/^#+\s*/, "").trim();
}
function readPromptHeading(projectRoot) {
const promptPath = path.join(projectRoot, ".fusion", "tasks", TASK_ID, "PROMPT.md");
const prompt = readFileSync(promptPath, "utf8");
const heading = firstHeading(prompt);
if (!heading) {
throw new Error(`Refusing to reconcile ${TASK_ID}: PROMPT.md is missing a first heading at ${promptPath}`);
}
if (normalizeTitle(heading) !== normalizeTitle(EXPECTED_PROMPT_HEADING)) {
throw new Error(
`Refusing to reconcile ${TASK_ID}: unexpected PROMPT heading "${heading}" (expected "${EXPECTED_PROMPT_HEADING}")`,
);
}
return { promptPath, heading };
}
function summarizeDiff(task, heading) {
return {
id: task.id,
promptHeading: heading,
before: {
title: task.title,
description: task.description,
},
after: {
title: CANONICAL_TITLE,
description: CANONICAL_DESCRIPTION,
},
};
}
function hasReconciliationLog(task) {
return Array.isArray(task?.log)
&& task.log.some((entry) => entry?.action === RECONCILIATION_ACTION);
}
export function assessTaskState(task, heading) {
if (!task || task.id !== TASK_ID) {
throw new Error(`Refusing to reconcile: expected task ${TASK_ID}`);
}
const normalizedCurrentTitle = normalizeTitle(task.title);
const normalizedCanonicalTitle = normalizeTitle(CANONICAL_TITLE);
const alreadyCanonical = normalizedCurrentTitle === normalizedCanonicalTitle;
const alreadyLogged = hasReconciliationLog(task);
if (alreadyCanonical && alreadyLogged) {
return {
status: "noop",
reason: `${TASK_ID} already matches the canonical title and carries the ${SOURCE_TASK_ID} reconciliation log entry`,
diff: summarizeDiff(task, heading),
};
}
if (alreadyCanonical) {
throw new Error(
`Refusing to reconcile ${TASK_ID}: row title already matches PROMPT heading but no ${SOURCE_TASK_ID} reconciliation marker is present`,
);
}
if (task.title !== EXPECTED_STALE_TITLE) {
throw new Error(
`Refusing to reconcile ${TASK_ID}: unexpected stale title "${task.title}" (expected "${EXPECTED_STALE_TITLE}")`,
);
}
if (!String(task.description ?? "").includes(EXPECTED_STALE_DESCRIPTION_FRAGMENT)) {
throw new Error(`Refusing to reconcile ${TASK_ID}: current description does not match the expected heartbeat-scope stale text`);
}
return {
status: "needs-reconciliation",
diff: summarizeDiff(task, heading),
};
}
export async function runReconciliation({ store, projectRoot, dryRun = true } = {}) {
if (!store) {
throw new Error("runReconciliation requires a store instance");
}
const resolvedProjectRoot = path.resolve(projectRoot ?? process.cwd());
const { heading, promptPath } = readPromptHeading(resolvedProjectRoot);
const task = await store.getTask(TASK_ID);
const assessment = assessTaskState(task, heading);
if (assessment.status === "noop") {
return {
dryRun,
status: "noop",
promptPath,
...assessment,
};
}
if (dryRun) {
return {
dryRun,
status: "would-apply",
promptPath,
diff: assessment.diff,
};
}
await store.updateTask(TASK_ID, {
title: CANONICAL_TITLE,
description: CANONICAL_DESCRIPTION,
});
await store.logEntry(TASK_ID, RECONCILIATION_ACTION, RECONCILIATION_OUTCOME);
const updated = await store.getTask(TASK_ID);
return {
dryRun,
status: "applied",
promptPath,
diff: summarizeDiff(task, heading),
updated: {
title: updated.title,
description: updated.description,
reconciliationLogPresent: hasReconciliationLog(updated),
},
};
}
export async function main(argv = process.argv.slice(2), deps = {}) {
const dryRun = !argv.includes("--apply");
const projectRoot = path.resolve(readFlagValue(argv, "--project-root") ?? process.cwd());
const store = deps.store ?? (await (async () => {
const { TaskStore } = await import("../packages/core/dist/index.js");
const taskStore = new TaskStore(projectRoot);
await taskStore.init();
return taskStore;
})());
const result = await runReconciliation({ store, projectRoot, dryRun });
console.log(JSON.stringify(result, null, 2));
return result;
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}