Files
fusion/scripts/reconcile-fn-3909-identity.mjs
Fusion 0bca14fb72 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
2026-05-12 17:04:33 -07:00

184 lines
6.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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;
});
}