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
184 lines
6.0 KiB
JavaScript
184 lines
6.0 KiB
JavaScript
#!/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;
|
||
});
|
||
}
|