import { existsSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { canonicalFactLabel } from "./prompt-policy.js"; import type { ActiveRun, KdFact, KdQuestion } from "./types.js"; const PHASE_ORDER = ["discuss", "spec", "plan", "execute", "verify", "ship"] as const; const MODE_PHASE_ORDER = { quick: ["discuss", "plan", "execute", "verify"], normal: PHASE_ORDER, } as const; export interface KdConsistencyIssue { code: string; message: string; } export interface KdConsistencyReport { runId?: string; checkedAt: string; errors: KdConsistencyIssue[]; warnings: KdConsistencyIssue[]; summary: { activeRunFile: "missing" | "invalid" | "present"; runStateFile: "missing" | "invalid" | "present" | "not-checked"; ledgerEvents: number; openBlockingQuestions: number; phase?: string; mode?: string; gatePassed?: boolean; }; } export function checkActiveRunConsistency(cwd: string, id?: string): KdConsistencyReport { const activePath = activeRunPath(cwd); const activeState = readJsonRecord(activePath); const activeId = stringValue(activeState.value?.id); const runId = id ?? activeId; const runState = runId ? readJsonRecord(runStatePath(cwd, runId)) : undefined; const run = runState?.status === "present" && runState.value ? recordToRun(runState.value) : undefined; return checkHarnessConsistency(cwd, run, { activeState, activeId, runId }); } export function checkHarnessConsistency( cwd: string, run: ActiveRun | undefined, input: { activeState?: ReadJsonResult; activeId?: string; runId?: string; } = {}, ): KdConsistencyReport { const activeState = input.activeState ?? readJsonRecord(activeRunPath(cwd)); const activeId = input.activeId ?? stringValue(activeState.value?.id); const runId = input.runId ?? run?.id ?? activeId; const runPath = runId ? runStatePath(cwd, runId) : undefined; const runState = runPath ? readJsonRecord(runPath) : undefined; const errors: KdConsistencyIssue[] = []; const warnings: KdConsistencyIssue[] = []; if (activeState.status === "invalid") { errors.push({ code: "active-run-invalid-json", message: `active-run.json 无法解析:${activeState.error}` }); } if (activeState.status === "present" && !activeId) { errors.push({ code: "active-run-missing-id", message: "active-run.json 缺少有效 id。" }); } if (!runId) { return buildReport({ run, activeState, runState, errors, warnings, ledgerEvents: 0, openBlockingQuestions: 0 }); } if (!runPath || !existsSync(runPath)) { errors.push({ code: "run-state-missing", message: `active run 指向的 RUN.json 不存在:${join(runsDir(cwd), runId, "RUN.json")}` }); } if (runState?.status === "invalid") { errors.push({ code: "run-state-invalid-json", message: `RUN.json 无法解析:${runState.error}` }); } const rawRunId = stringValue(runState?.value?.id); if (rawRunId && rawRunId !== runId) { errors.push({ code: "run-id-mismatch", message: `RUN.json id=${rawRunId} 与指针 id=${runId} 不一致。` }); } if (activeId && run?.id && activeId !== run.id) { errors.push({ code: "active-run-mismatch", message: `active-run.json id=${activeId} 与加载后的 run id=${run.id} 不一致。` }); } if (!run) { errors.push({ code: "run-state-unloadable", message: "active run 状态无法作为合法 Harness run 加载;坏状态必须修正或重新创建 run。" }); return buildReport({ run, activeState, runState, errors, warnings, ledgerEvents: 0, openBlockingQuestions: 0 }); } if (!modeLooksValid(run.mode)) { errors.push({ code: "run-mode-invalid", message: `Harness mode 非法:${String(run.mode)}。` }); } else if (!phaseIncludedInMode(run.mode, run.phase)) { errors.push({ code: "phase-not-in-mode", message: `阶段 ${run.phase} 不属于当前 Harness 模式 ${run.mode}。` }); } if (run.cwd && normalizePath(run.cwd) !== normalizePath(cwd)) { warnings.push({ code: "cwd-mismatch", message: `RUN.json cwd=${run.cwd} 与当前工作区 ${cwd} 不一致。` }); } const invalidLedgerLines = invalidLedgerLineNumbers(cwd, run.id); if (invalidLedgerLines.length > 0) { errors.push({ code: "ledger-invalid-lines", message: `ledger 存在无法作为 KdLedgerEvent 读取的行:${invalidLedgerLines.join(", ")}。` }); } const ledgerEvents = readLedgerEventRecords(cwd, run.id); for (const [index, event] of ledgerEvents.entries()) { if (numberValue(event.seq) !== index + 1) { errors.push({ code: "ledger-seq-gap", message: `ledger seq 不连续:第 ${index + 1} 条事件 seq=${event.seq}。` }); break; } if (event.runId !== run.id) { errors.push({ code: "ledger-run-mismatch", message: `ledger 事件 #${event.seq} runId=${event.runId} 与当前 run=${run.id} 不一致。` }); break; } } const openBlocking = openBlockingQuestions(run); if (openBlocking.length > 6) { warnings.push({ code: "many-open-blocking-questions", message: `存在 ${openBlocking.length} 个 open blocking question;建议先处理当前事实组,避免焦点分散。` }); } for (const question of openBlocking) { if (!question.contextSummary?.trim()) { errors.push({ code: "open-question-missing-context", message: `${question.id} 缺少 contextSummary;阻断问题必须保存提问前上下文和恢复点。` }); } } checkRawFacts(runState?.value ?? (run as unknown as Record), errors); checkFactConflicts(run.facts ?? [], errors); checkAnsweredQuestionFacts(run, errors); checkResumeSnapshot(run, errors, warnings); checkGateSnapshotShape(run, warnings); return buildReport({ run, activeState, runState, errors, warnings, ledgerEvents: ledgerEvents.length, openBlockingQuestions: openBlocking.length }); } export function formatConsistencyReport(report: KdConsistencyReport): string { const lines = [ "KCode Harness Consistency", `Run:${report.runId ?? "无 active run"}`, `Summary:active=${report.summary.activeRunFile} runState=${report.summary.runStateFile} phase=${report.summary.phase ?? "-"} mode=${report.summary.mode ?? "-"} ledger=${report.summary.ledgerEvents} openBlocking=${report.summary.openBlockingQuestions}`, ]; if (report.errors.length === 0 && report.warnings.length === 0) { lines.push("[OK] 未发现状态一致性问题。"); return lines.join("\n"); } for (const issue of report.errors) { lines.push(`[ERROR] ${issue.code}:${issue.message}`); } for (const issue of report.warnings) { lines.push(`[WARN] ${issue.code}:${issue.message}`); } return lines.join("\n"); } interface ReadJsonResult { status: "missing" | "invalid" | "present"; value?: Record; error?: string; } function buildReport(input: { run: ActiveRun | undefined; activeState: ReadJsonResult; runState?: ReadJsonResult; errors: KdConsistencyIssue[]; warnings: KdConsistencyIssue[]; ledgerEvents: number; openBlockingQuestions: number; }): KdConsistencyReport { return { runId: input.run?.id ?? stringValue(input.runState?.value?.id) ?? stringValue(input.activeState.value?.id), checkedAt: new Date().toISOString(), errors: input.errors, warnings: input.warnings, summary: { activeRunFile: input.activeState.status, runStateFile: input.runState?.status ?? "not-checked", ledgerEvents: input.ledgerEvents, openBlockingQuestions: input.openBlockingQuestions, phase: input.run?.phase, mode: input.run?.mode, gatePassed: input.run?.gate.passed, }, }; } function checkRawFacts(rawRun: Record | undefined, errors: KdConsistencyIssue[]): void { const rawFacts = Array.isArray(rawRun?.facts) ? rawRun.facts.filter(isRecord) : []; for (const [index, fact] of rawFacts.entries()) { const label = stringValue(fact.label); const value = stringValue(fact.value); if (!label || !canonicalFactLabel(label)) { errors.push({ code: "fact-label-invalid", message: `facts[${index}] 使用未知 factLabel:${label ?? ""}。` }); } if (!value || invalidFactValueReason(value)) { errors.push({ code: "fact-value-invalid", message: `facts[${index}] 不是可核验事实值:${value ?? ""}。` }); } } } function checkFactConflicts(facts: KdFact[], errors: KdConsistencyIssue[]): void { const current = facts.filter((fact) => fact.status === "current"); const byKey = new Map>(); for (const fact of current) { const key = factKey(fact.label); const values = byKey.get(key) ?? new Set(); values.add(fact.value); byKey.set(key, values); } for (const [key, values] of byKey) { if (values.size > 1) { errors.push({ code: "fact-conflict", message: `事实 ${key} 存在多个 current 值:${[...values].join(" | ")}。` }); } } } function checkAnsweredQuestionFacts(run: ActiveRun, errors: KdConsistencyIssue[]): void { const facts = run.facts ?? []; for (const question of run.questions ?? []) { if (question.status !== "answered" || !question.factLabel) continue; const expected = expectedFactFromQuestion(question); if (!expected) continue; const matching = facts.find((fact) => fact.sourceQuestionId === question.id && fact.key === expected.key && fact.value === expected.value); if (!matching) { errors.push({ code: "answered-question-fact-missing", message: `${question.id} 已回答 ${question.factLabel},但 run.facts 中缺少对应结构化事实。` }); } } } function checkResumeSnapshot(run: ActiveRun, errors: KdConsistencyIssue[], warnings: KdConsistencyIssue[]): void { const snapshot = run.resumeSnapshot; if (!snapshot) return; if (snapshot.status === "open") { const pending = snapshot.pendingQuestionId ? (run.questions ?? []).find((question) => question.id === snapshot.pendingQuestionId) : undefined; if (!pending) { errors.push({ code: "resume-question-missing", message: `resumeSnapshot 指向不存在的问题:${snapshot.pendingQuestionId ?? ""}。` }); return; } if (pending.status !== "open") { warnings.push({ code: "resume-snapshot-stale", message: `resumeSnapshot 仍为 open,但问题 ${pending.id} 状态为 ${pending.status}。` }); } } } function checkGateSnapshotShape(run: ActiveRun, warnings: KdConsistencyIssue[]): void { if (!run.gate || typeof run.gate.passed !== "boolean") { warnings.push({ code: "gate-snapshot-invalid", message: "run.gate 缺少有效 passed 状态;执行 gate refresh 后再推进。" }); } } function openBlockingQuestions(run: ActiveRun): KdQuestion[] { return (run.questions ?? []).filter((question) => question.status === "open" && question.blocking); } function recordToRun(record: Record): ActiveRun | undefined { const id = stringValue(record.id); const phase = stringValue(record.phase); const mode = stringValue(record.mode); if (!id || !phaseLooksValid(phase) || !modeLooksValid(mode)) return undefined; const gateRecord = isRecord(record.gate) ? record.gate : {}; return { id, goal: stringValue(record.goal), phase, mode, cwd: stringValue(record.cwd) ?? "", status: record.status === "paused" || record.status === "done" ? record.status : "active", createdAt: stringValue(record.createdAt), updatedAt: stringValue(record.updatedAt), product: stringValue(record.product) as ActiveRun["product"], version: stringValue(record.version), artifacts: isRecord(record.artifacts) ? Object.fromEntries(Object.entries(record.artifacts).filter((entry): entry is [string, string] => typeof entry[1] === "string")) : {}, gate: { passed: gateRecord.passed === true, reason: stringValue(gateRecord.reason), checkedAt: stringValue(gateRecord.checkedAt) ?? new Date().toISOString(), }, questions: readQuestions(record.questions), facts: readFacts(record.facts), contextEntries: [], resumeSnapshot: readResumeSnapshot(record.resumeSnapshot), }; } function readQuestions(value: unknown): KdQuestion[] { return Array.isArray(value) ? value.flatMap((item): KdQuestion[] => { if (!isRecord(item)) return []; const id = stringValue(item.id); const phase = stringValue(item.phase); const question = stringValue(item.question); if (!id || !phaseLooksValid(phase) || !question) return []; return [ { id, phase, question, reason: stringValue(item.reason), contextSummary: stringValue(item.contextSummary), sourceRefs: readStringArray(item.sourceRefs), factLabel: stringValue(item.factLabel), proposedFactValue: stringValue(item.proposedFactValue), options: readQuestionOptions(item.options), choices: readStringArray(item.choices), multiple: item.multiple === true, customAnswer: item.customAnswer !== false, blocking: item.blocking !== false, status: item.status === "answered" ? "answered" : "open", answer: stringValue(item.answer), createdAt: stringValue(item.createdAt) ?? new Date().toISOString(), answeredAt: stringValue(item.answeredAt), }, ]; }) : []; } function readFacts(value: unknown): KdFact[] { return Array.isArray(value) ? value.flatMap((item): KdFact[] => { if (!isRecord(item)) return []; const label = stringValue(item.label); const canonical = label ? canonicalFactLabel(label) : undefined; const factValue = stringValue(item.value); if (!canonical || !factValue) return []; const now = new Date().toISOString(); return [ { key: stringValue(item.key) ?? factKey(canonical), label: canonical, value: factValue, status: item.status === "superseded" || item.status === "rejected" ? item.status : "current", source: item.source === "manual" ? "manual" : "question", sourceQuestionId: stringValue(item.sourceQuestionId), reason: stringValue(item.reason), createdAt: stringValue(item.createdAt) ?? now, updatedAt: stringValue(item.updatedAt) ?? now, supersededBy: stringValue(item.supersededBy), }, ]; }) : []; } function readResumeSnapshot(value: unknown): ActiveRun["resumeSnapshot"] { if (!isRecord(value)) return undefined; const phase = stringValue(value.phase); const contextSummary = stringValue(value.contextSummary); const nextAction = stringValue(value.nextAction); if (!phaseLooksValid(phase) || !contextSummary || !nextAction) return undefined; return { phase, contextSummary, sourceRefs: readStringArray(value.sourceRefs), gateReason: stringValue(value.gateReason), nextAction, pendingQuestionId: stringValue(value.pendingQuestionId), status: value.status === "answered" || value.status === "archived" ? value.status : "open", updatedAt: stringValue(value.updatedAt) ?? new Date().toISOString(), }; } function readJsonRecord(path: string): ReadJsonResult { if (!existsSync(path)) return { status: "missing" }; try { const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; return isRecord(parsed) ? { status: "present", value: parsed } : { status: "invalid", error: "JSON root is not an object" }; } catch (error) { return { status: "invalid", error: error instanceof Error ? error.message : String(error) }; } } function invalidLedgerLineNumbers(cwd: string, runId: string): number[] { const path = runLedgerPath(cwd, runId); if (!existsSync(path)) return []; return readFileSync(path, "utf8") .split(/\r?\n/) .flatMap((line, index) => (line.trim() && !ledgerLineLooksValid(line, runId) ? [index + 1] : [])); } function ledgerLineLooksValid(line: string, runId: string): boolean { try { const parsed = JSON.parse(line) as unknown; if (!isRecord(parsed)) return false; return ( typeof parsed.seq === "number" && Number.isFinite(parsed.seq) && typeof parsed.type === "string" && typeof parsed.runId === "string" && parsed.runId === runId && typeof parsed.phase === "string" && typeof parsed.timestamp === "string" && typeof parsed.summary === "string" ); } catch { return false; } } function readLedgerEventRecords(cwd: string, runId: string): Record[] { const path = runLedgerPath(cwd, runId); if (!existsSync(path)) return []; return readFileSync(path, "utf8") .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .flatMap((line) => { try { const parsed = JSON.parse(line) as unknown; return isRecord(parsed) && ledgerLineLooksValid(line, runId) ? [parsed] : []; } catch { return []; } }); } function expectedFactFromQuestion(question: KdQuestion): Pick | undefined { if (question.status !== "answered" || !question.factLabel) return undefined; const label = canonicalFactLabel(question.factLabel); const answer = question.answer?.trim(); if (!label || !answer || invalidFactValueReason(answer)) return undefined; if (question.proposedFactValue) { const value = question.proposedFactValue.trim(); return value && isAffirmativeOnlyAnswer(answer) ? { key: factKey(label), value } : undefined; } return { key: factKey(label), value: answer }; } function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } function numberValue(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function normalizePath(path: string): string { return resolve(path).toLowerCase(); } function activeRunPath(cwd: string): string { return join(cwd, ".pi", "kd", "active-run.json"); } function runsDir(cwd: string): string { return join(cwd, ".pi", "kd", "runs"); } function runStatePath(cwd: string, runId: string): string { return join(runsDir(cwd), runId, "RUN.json"); } function runLedgerPath(cwd: string, runId: string): string { return join(runsDir(cwd), runId, "events", "ledger.jsonl"); } function phaseLooksValid(value: unknown): value is ActiveRun["phase"] { return typeof value === "string" && (PHASE_ORDER as readonly string[]).includes(value); } function modeLooksValid(value: unknown): value is ActiveRun["mode"] { return typeof value === "string" && Object.hasOwn(MODE_PHASE_ORDER, value); } function phaseIncludedInMode(mode: ActiveRun["mode"], phase: ActiveRun["phase"]): boolean { return (MODE_PHASE_ORDER[mode] as readonly string[]).includes(phase); } function readStringArray(value: unknown): string[] | undefined { if (!Array.isArray(value)) return undefined; const items = value.filter((item): item is string => typeof item === "string" && Boolean(item.trim())).map((item) => item.trim()); return items.length > 0 ? items : undefined; } function readQuestionOptions(value: unknown): Array<{ label: string; description?: string }> | undefined { if (!Array.isArray(value)) return undefined; const items = value.flatMap((item): Array<{ label: string; description?: string }> => { if (!isRecord(item) || typeof item.label !== "string" || !item.label.trim()) return []; return [ { label: item.label.trim(), description: typeof item.description === "string" && item.description.trim() ? item.description.trim() : undefined, }, ]; }); return items.length > 0 ? items : undefined; } function factKey(label: string): string { return label.trim().toLowerCase().replace(/\s+/g, ""); } function invalidFactValueReason(value: string): string | undefined { return isInvalidFactValue(value) ? "invalid fact value" : undefined; } function isAffirmativeOnlyAnswer(answer: string): boolean { return /^(是|是的|对|正确|确认|yes|true)$/i.test(answer.trim()); } function isNegativeAnswer(answer: string): boolean { return /^(否|不是|不对|错误|no|false)(\s|。|,|,|;|;|$)/i.test(answer.trim()); } function isPlaceholderAnswer(answer: string): boolean { return /^(待确认|未知|不清楚|未提供|无|没有|none|unknown|todo|tbd|n\/a|按实际|按实际环境|以后再说)(\s|。|,|,|;|;|$)/i.test(answer.trim()); } function isAcknowledgementOnlyAnswer(answer: string): boolean { return /^(可以|好的|好|行|嗯|嗯嗯|收到|明白|ok|okay|行的)(\s|。|,|,|;|;|$)/i.test(answer.trim()); } function isInvalidFactValue(answer: string): boolean { return isPlaceholderAnswer(answer) || isAffirmativeOnlyAnswer(answer) || isNegativeAnswer(answer) || isAcknowledgementOnlyAnswer(answer); }