From f3dfa58d68a30277031bfdd3c2f33666f222093c Mon Sep 17 00:00:00 2001 From: Louise Lau Date: Thu, 2 Jul 2026 09:04:26 +0800 Subject: [PATCH] feat: signed maintain history + per-role model picker + recall explorer in the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three roadmap items, all small and orthogonal: 1. SIGNED MAINTAIN HISTORY (enterprise): maintain-export --sign embeds an audit block in the snapshot β€” the hash-chain head over the runs + HMAC-SHA256 signature (QODEX_AUDIT_KEY env, never stored; non-secret keyId recorded). Unsigned exports still carry the integrity head. maintain-import verifies BEFORE reporting/merging: a tampered or wrongly-signed snapshot is refused with exit 1; the report shows "Audit: βœ“ integrity intact Β· πŸ” signature valid". PURE: historyHead / verifyHistoryAudit reuse the maintain-audit chain primitives. 2. DASHBOARD β€” MODELS PER ROLE: the single default-model select becomes a "Models β€” per role" panel: main / sub-agent / vision selects plus "one model for everything". model.set gains a role param (main|subagent|vision|all; default main β†’ backward compatible) writing defaults.model / roles..{model,provider} (provider inferred). Vision awareness via looksVisionCapable: a vision-capable main model gets a πŸ‘ badge and the vision row says "optional β€” main already sees images"; picking a non-vision model FOR the vision role warns. 3. DASHBOARD β€” RECALL EXPLORER: ask "how did we do X before?" in the dashboard. recall.query action runs the same rankApproaches β†’ renderApproachDiffs pipeline as the recall_approach tool (best match + how other attempts differed + stable core), rendered in-place in a
   without a page reload.
---
 src/cli/dashboard-control.ts  | 34 ++++++++++++++++++++--
 src/cli/dashboard.ts          | 48 ++++++++++++++++++++++++++----
 src/cli/maintain-history.ts   | 55 ++++++++++++++++++++++++++++++++---
 src/index.ts                  | 29 ++++++++++++++----
 test/dashboard.test.ts        |  9 ++++++
 test/maintain-history.test.ts | 26 ++++++++++++++++-
 6 files changed, 183 insertions(+), 18 deletions(-)

diff --git a/src/cli/dashboard-control.ts b/src/cli/dashboard-control.ts
index 33233f7..a592bd7 100644
--- a/src/cli/dashboard-control.ts
+++ b/src/cli/dashboard-control.ts
@@ -85,10 +85,40 @@ export async function dispatchAction(name: string, params: any, cwd: string): Pr
         return { ok: true, message: `Set ${params.key} = ${v.coerced}. Takes effect on the next run.` };
       }
       case 'model.set': {
+        // role: main (default) | subagent | vision | all β€” "all" points every role at one model,
+        // which is all you need when that model has vision (no separate vision model required).
         const model = String(params?.model ?? '').trim();
+        const role = String(params?.role ?? 'main').trim();
         if (!model) return { ok: false, message: 'Pick a model.' };
-        await writeConfigKnob('defaults.model', model);
-        return { ok: true, message: `Default model β†’ ${model}. Takes effect on the next run.` };
+        if (!['main', 'subagent', 'vision', 'all'].includes(role)) return { ok: false, message: `Unknown role "${role}".` };
+        const { inferProvider } = await import('../llm/role-resolver.js');
+        const { looksVisionCapable } = await import('../setup/model-detector.js');
+        const provider = inferProvider(model);
+        const seesImages = looksVisionCapable(model);
+        if (role === 'main' || role === 'all') await writeConfigKnob('defaults.model', model);
+        if (role === 'subagent' || role === 'all') { await writeConfigKnob('roles.subagent.model', model); await writeConfigKnob('roles.subagent.provider', provider); }
+        if (role === 'vision' || role === 'all') { await writeConfigKnob('roles.vision.model', model); await writeConfigKnob('roles.vision.provider', provider); }
+        const visionNote = role === 'vision' && !seesImages
+          ? ' ⚠️ this model does not look vision-capable β€” screenshots may fail.'
+          : seesImages && role !== 'subagent' ? ' πŸ‘ has vision β€” no separate vision model needed.' : '';
+        const label = role === 'all' ? 'ALL roles (main + subagent + vision)' : role === 'main' ? 'Default model' : `${role} model`;
+        return { ok: true, message: `${label} β†’ ${model}.${visionNote} Takes effect on the next run.` };
+      }
+      case 'recall.query': {
+        const q = String(params?.query ?? '').trim();
+        if (!q) return { ok: false, message: 'Type what to recall.' };
+        const { rankApproaches } = await import('../context/approach-recall.js');
+        const { renderApproachDiffs } = await import('../context/approach-diff.js');
+        const { getSessionStore } = await import('../session/store.js');
+        const store = getSessionStore();
+        const worklog = (() => { try { return store.getWorklog(cwd, 100).map((w: any) => ({ kind: 'worklog' as const, text: w.entry, when: String(w.created_at ?? '').slice(0, 10), at: w.created_at, detail: w.kind })); } catch { return []; } })();
+        const episodes = await (async () => {
+          try { const { readEpisodes } = await import('../context/episodic-memory.js'); return (await readEpisodes(cwd)).map((e: any) => ({ kind: 'episode' as const, text: `${e.prompt} ${e.summary}`, when: String(e.ts ?? '').slice(0, 10), at: e.ts, files: e.filesChanged, detail: e.summary })); }
+          catch { return []; }
+        })();
+        const facts = (() => { try { return store.getFactsForCwd(cwd, 200).map((f: string) => ({ kind: 'fact' as const, text: f, when: '', detail: 'fact' })); } catch { return []; } })();
+        const matches = rankApproaches(q, [...episodes, ...worklog, ...facts], { topK: 4, nowMs: Date.now(), diversity: 0.35 });
+        return { ok: true, message: renderApproachDiffs(q, matches) };
       }
       case 'memory.add': {
         const fact = String(params?.fact ?? '').trim();
diff --git a/src/cli/dashboard.ts b/src/cli/dashboard.ts
index 113ba54..903b1ce 100644
--- a/src/cli/dashboard.ts
+++ b/src/cli/dashboard.ts
@@ -33,6 +33,7 @@ export interface DashboardData {
   maintainProjection?: { cleanupsPerMonth: number; minutesPerMonth: number };
   maintainForecast?: import('./maintain-stats.js').MaintainForecast;
   extractMetrics?: import('../tools/web/extract-metrics.js').ExtractCounts;
+  roleModels?: { subagent?: string; vision?: string; mainHasVision: boolean };
   totals: { sessions: number; tokens: number; cost: number; facts: number; episodes: number; skills: number };
 }
 
@@ -120,6 +121,12 @@ export function buildDashboardHtml(d: DashboardData, opts: { token?: string } =
   const healthBadges = d.health.map(h => `${h.ok ? 'βœ“' : '!'} ${esc(h.label)}: ${esc(h.detail)}`).join('');
   const healthPanel = `

Health

${live ? `` : ''}
${healthBadges}
`; const logsPanel = d.logs.length ? `

Recent log

${d.logs.map(esc).join('\n')}
` : ''; + // Recall explorer β€” ask "how did we do X before?" right here: best match + how other attempts + // differed (the same visual diff recall_approach returns), rendered in-place without a reload. + const recallPanel = live ? `

Recall β€” how did we do it before?

+
+ +
` : ''; // Web-extract quality: how often an oversized page was trimmed SEMANTICALLY (query-aware) vs a // positional head+tail window. A high semantic rate = agents pass a query and get the relevant part. const em = d.extractMetrics; @@ -138,11 +145,18 @@ export function buildDashboardHtml(d: DashboardData, opts: { token?: string } = um.preferences.length ? `
    ${um.preferences.map(p => `
  • ${esc(p)}
  • `).join('')}
` : '

No stated preferences yet β€” tell me with β€œRemember” above.

' }${um.recentThemes.length ? `

Recent focus: ${um.recentThemes.map(esc).join(' Β· ')}

` : ''}${um.favoriteAreas.length ? `

Works mostly in: ${um.favoriteAreas.map(esc).join(', ')}

` : ''}`; - // Model switcher (live: a dropdown of known models; read-only: just the current one). - const modelCtl = live && d.models.length - ? `` - : `${esc(d.model)}`; - const modelPanel = `
Default model${modelCtl}
`; + // Model switcher β€” per ROLE (main / subagent / vision) plus a one-model-for-everything setter. + // When the main model has vision, say so: a separate vision model is optional, not required. + const rm = d.roleModels; + const modelSelect = (role: string, current: string | undefined, extraFirst?: string) => live && d.models.length + ? `` + : `${esc(current ?? 'β€”')}`; + const modelPanel = `

Models β€” per role

+
Main (default)${rm?.mainHasVision ? ' πŸ‘ has vision' : ''}${modelSelect('main', d.model)}
+
Sub-agent${modelSelect('subagent', rm?.subagent ?? d.model)}
+
Vision${rm?.mainHasVision ? ' (optional β€” main already sees images)' : ''}${modelSelect('vision', rm?.vision ?? (rm?.mainHasVision ? d.model : undefined))}
+ ${live && d.models.length ? `
One model for everything${modelSelect('all', undefined, 'pick a model…')}
` : ''} +
`; // Quarantined skill candidates β€” promote (independent-judge-passed) or reject from here. const candRows = d.candidates.length ? d.candidates.map(c => `
  • ${esc(c.name)}${c.confidence != null ? ` conf ${c.confidence}` : ''} β€” ${esc(c.description)}${live ? ` ` : ''}
  • `).join('') : '
  • No candidates in quarantine.
  • '; @@ -236,6 +250,7 @@ export function buildDashboardHtml(d: DashboardData, opts: { token?: string } = ${umPanel}

    Skills

      ${skillList}
    ${candidatePanel} + ${recallPanel} ${extractPanel} ${logsPanel}

    Generated by qodex dashboard β€” all data is local, under ~/.qodex/.

    @@ -256,6 +271,17 @@ ${live ? ` if(j.ok) setTimeout(()=>location.reload(), 700); }catch(e){ toast(String(e), false); } } + async function recallRun(){ + const q = document.getElementById('recall-q').value.trim(); + if(!q) return; + const out = document.getElementById('recall-out'); + out.style.display = 'block'; out.textContent = 'Searching your history…'; + try{ + const r = await fetch('/api/action?k='+encodeURIComponent(TOKEN),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'recall.query',params:{query:q}})}); + const j = await r.json(); + out.textContent = j.message || (j.ok ? '(no result)' : 'Failed'); + }catch(e){ out.textContent = String(e); } + } ` : ''} `; } @@ -270,6 +296,16 @@ export async function gatherDashboardData(cwd: string): Promise { const project = (() => { try { return store.getProject(cwd)?.name || path.basename(cwd); } catch { return path.basename(cwd); } })(); const defModel = config?.defaults?.model ?? '(unset)'; const defProvider = config?.defaults?.provider; + const roleModels = await (async () => { + try { + const { looksVisionCapable } = await import('../setup/model-detector.js'); + return { + subagent: config?.roles?.subagent?.model as string | undefined, + vision: config?.roles?.vision?.model as string | undefined, + mainHasVision: typeof defModel === 'string' && looksVisionCapable(defModel), + }; + } catch { return { subagent: undefined, vision: undefined, mainHasVision: false }; } + })(); // Providers: built-ins present in config + user customs. const providers: DashboardData['providers'] = []; @@ -406,7 +442,7 @@ export async function gatherDashboardData(cwd: string): Promise { providers, sessions, facts, episodes, skills, controls, schedules, models, candidates, runs, bot, health, logs, userModel, maintainStats, maintainWeekly: maintain?.weekly, maintainNext: maintain?.next ?? undefined, maintainTrend: maintain?.trend, maintainProjection: maintain?.projection, maintainForecast: maintain?.forecast, - extractMetrics, + extractMetrics, roleModels, totals: { sessions: sessions.length, tokens: sessions.reduce((a, s) => a + s.tokens, 0), cost: sessions.reduce((a, s) => a + s.cost, 0), facts: facts.length, episodes: episodes.length, skills: skills.length, diff --git a/src/cli/maintain-history.ts b/src/cli/maintain-history.ts index f474328..b3b53ca 100644 --- a/src/cli/maintain-history.ts +++ b/src/cli/maintain-history.ts @@ -7,42 +7,89 @@ * All PURE (string/array in, string/array out) so the round-trip + merge are unit-tested. */ import type { MaintainRun } from './maintain-stats.js'; +import { buildAuditChain, chainHead, signChainHead, verifyChainSignature, keyIdFor, type AuditableRun } from './maintain-audit.js'; export const MAINTAIN_HISTORY_VERSION = 1; +export interface HistoryAudit { + algo: 'sha256-chain' | 'sha256-chain+hmac-sha256'; + head: string; + keyId?: string; + signature?: string; +} + export interface MaintainHistoryFile { kind: 'qodex-maintain-history'; version: number; exportedAt: string; count: number; runs: MaintainRun[]; + /** Tamper-evidence for the snapshot itself: the audit-chain head over `runs` (+ optional HMAC). */ + audit?: HistoryAudit; +} + +const toAuditable = (r: MaintainRun): AuditableRun => + ({ at: r.at ?? '', scope: r.scope, status: r.status, filesChanged: r.filesChanged }); + +/** The audit-chain head over a run list (order-independent β€” the chain sorts chronologically). PURE. */ +export function historyHead(runs: MaintainRun[]): string { + return chainHead(buildAuditChain(runs.map(toAuditable))); } -/** Serialize maintain runs into a portable snapshot. PURE (pass `nowIso`). */ -export function serializeMaintainHistory(runs: MaintainRun[], nowIso: string): string { +/** + * Serialize maintain runs into a portable snapshot. PURE (pass `nowIso`). With `opts.key`, the + * snapshot carries an `audit` block: the hash-chain head over the runs plus an HMAC signature β€” so + * the receiver can prove the history wasn't altered in transit and WHO exported it. Without a key + * it still carries the unsigned head (integrity check, no authenticity). + */ +export function serializeMaintainHistory(runs: MaintainRun[], nowIso: string, opts: { key?: string } = {}): string { const clean = runs.map(normalizeRun); + const head = historyHead(clean); + const audit: HistoryAudit = opts.key + ? { algo: 'sha256-chain+hmac-sha256', head, keyId: keyIdFor(opts.key), signature: signChainHead(head, opts.key) } + : { algo: 'sha256-chain', head }; const file: MaintainHistoryFile = { kind: 'qodex-maintain-history', version: MAINTAIN_HISTORY_VERSION, exportedAt: nowIso, count: clean.length, runs: clean, + audit, }; return JSON.stringify(file, null, 2); } +export interface HistoryAuditVerdict { + present: boolean; + headMatches?: boolean; + signaturePresent?: boolean; + signatureValid?: boolean; // undefined when unsigned or no key supplied + ok: boolean; // everything checkable passed (an unsigned-but-intact snapshot is ok) +} + +/** Verify a snapshot's audit block against its own runs. PURE. */ +export function verifyHistoryAudit(runs: MaintainRun[], audit: HistoryAudit | undefined, key?: string): HistoryAuditVerdict { + if (!audit) return { present: false, ok: true }; // legacy snapshot β€” nothing to check + const headMatches = historyHead(runs.map(normalizeRun)) === audit.head; + const signaturePresent = !!audit.signature; + let signatureValid: boolean | undefined; + if (signaturePresent && key) signatureValid = verifyChainSignature(audit.head, audit.signature!, key); + return { present: true, headMatches, signaturePresent, signatureValid, ok: headMatches && (!signaturePresent || signatureValid !== false) }; +} + /** * Parse a history snapshot back into runs. Tolerant of extra fields and either the wrapped file * shape or a bare runs array; throws only when the input is not usable JSON or has no runs field. * PURE. */ -export function deserializeMaintainHistory(text: string): { runs: MaintainRun[]; exportedAt?: string; version?: number } { +export function deserializeMaintainHistory(text: string): { runs: MaintainRun[]; exportedAt?: string; version?: number; audit?: HistoryAudit } { let obj: any; try { obj = JSON.parse(text); } catch { throw new Error('not valid JSON'); } const rawRuns = Array.isArray(obj) ? obj : obj?.runs; if (!Array.isArray(rawRuns)) throw new Error('no maintain runs found in the file'); const runs = rawRuns.filter(isRunLike).map(normalizeRun); - return { runs, exportedAt: typeof obj?.exportedAt === 'string' ? obj.exportedAt : undefined, version: typeof obj?.version === 'number' ? obj.version : undefined }; + const audit = obj?.audit && typeof obj.audit === 'object' && typeof obj.audit.head === 'string' ? obj.audit as HistoryAudit : undefined; + return { runs, exportedAt: typeof obj?.exportedAt === 'string' ? obj.exportedAt : undefined, version: typeof obj?.version === 'number' ? obj.version : undefined, audit }; } /** A stable identity for a run, so re-importing the same snapshot doesn't double-count. */ diff --git a/src/index.ts b/src/index.ts index 8583762..ee5d373 100644 --- a/src/index.ts +++ b/src/index.ts @@ -782,16 +782,22 @@ async function gatherMaintainRuns(): Promise', 'write to this file instead of stdout') - .action(async (opts: { out?: string }) => { + .option('--sign', 'sign the snapshot with HMAC-SHA256 using the QODEX_AUDIT_KEY env var (never stored)') + .action(async (opts: { out?: string; sign?: boolean }) => { const { serializeMaintainHistory } = await import('./cli/maintain-history.js'); + let key: string | undefined; + if (opts.sign) { + key = process.env.QODEX_AUDIT_KEY; + if (!key) { console.error('\nβœ— --sign needs a key: set QODEX_AUDIT_KEY in your environment (it is never stored).\n'); process.exit(1); } + } const runs = await gatherMaintainRuns(); - const json = serializeMaintainHistory(runs, new Date().toISOString()); + const json = serializeMaintainHistory(runs, new Date().toISOString(), { key }); if (opts.out) { const { promises: fs } = await import('fs'); await fs.writeFile(opts.out, json); - console.log(`\nπŸ“¦ Exported ${runs.length} maintain run(s) β†’ ${opts.out}\n`); + console.log(`\nπŸ“¦ Exported ${runs.length} maintain run(s) β†’ ${opts.out}${key ? ' πŸ” signed' : ''}\n`); } else { console.log(json); } @@ -804,12 +810,19 @@ program .option('--merge', 'merge the snapshot with local history and report the combined analytics') .action(async (file: string, opts: { merge?: boolean }) => { const { promises: fs } = await import('fs'); - const { deserializeMaintainHistory, mergeRuns } = await import('./cli/maintain-history.js'); + const { deserializeMaintainHistory, mergeRuns, verifyHistoryAudit } = await import('./cli/maintain-history.js'); const { buildMaintainStats, forecastTrend } = await import('./cli/maintain-stats.js'); let parsed; try { parsed = deserializeMaintainHistory(await fs.readFile(file, 'utf-8')); } catch (e: any) { console.error(`\nβœ— Could not read snapshot: ${e?.message ?? e}\n`); process.exit(1); } let runs = parsed!.runs; + // Tamper check BEFORE merging: verify the snapshot's audit head (and signature, if a key is set). + const audit = verifyHistoryAudit(runs, parsed!.audit, process.env.QODEX_AUDIT_KEY); + if (audit.present && !audit.ok) { + const why = audit.headMatches === false ? 'runs do not match the audit head (snapshot was altered)' : 'signature is INVALID (wrong key or forged)'; + console.error(`\n❌ Snapshot failed its audit check: ${why}. Refusing to report on it.\n`); + process.exit(1); + } let label = `snapshot (${runs.length} run(s)${parsed!.exportedAt ? `, exported ${parsed!.exportedAt.slice(0, 10)}` : ''})`; if (opts.merge) { const local = await gatherMaintainRuns(); @@ -822,6 +835,12 @@ program const fc = forecastTrend(runs, now); const arrow = fc.direction === 'rising' ? 'rising ↑' : fc.direction === 'falling' ? 'cooling ↓' : 'steady β†’'; console.log(`\nπŸ“₯ Maintain history β€” ${label}\n`); + if (audit.present) { + const sig = !audit.signaturePresent ? 'unsigned' + : audit.signatureValid === undefined ? 'signed (set QODEX_AUDIT_KEY to verify)' + : audit.signatureValid ? 'πŸ” signature valid (authentic)' : 'signature INVALID'; + console.log(` Audit: βœ“ integrity intact Β· ${sig}`); + } if (stats.totalRuns === 0) { console.log(' No runs in the snapshot.\n'); process.exit(0); } console.log(` Totals: ${stats.opened} cleanup PR(s) Β· ${stats.blocked} safely blocked Β· ${stats.filesCleaned} files cleaned Β· ~${stats.estMinutesSaved} min saved`); console.log(` Forecast: ${arrow} Β· avg ~${fc.weeklyAvg}/wk Β· next week β‰ˆ ${fc.nextWeek}`); diff --git a/test/dashboard.test.ts b/test/dashboard.test.ts index 0db4c49..f8262a2 100644 --- a/test/dashboard.test.ts +++ b/test/dashboard.test.ts @@ -28,6 +28,7 @@ const data: DashboardData = { maintainProjection: { cleanupsPerMonth: 6, minutesPerMonth: 30 }, maintainForecast: { weeklyAvg: 1.5, slope: 0.4, direction: 'rising', nextWeek: 3, weeks: 8 }, extractMetrics: { semantic: 7, headTail: 3, truncated: 10, semanticRate: 0.7 }, + roleModels: { subagent: 'qwen3-coder', vision: undefined, mainHasVision: true }, totals: { sessions: 1, tokens: 42000, cost: 0.12, facts: 1, episodes: 1, skills: 1 }, }; @@ -90,6 +91,14 @@ describe('qodex dashboard (pure render)', () => { expect(live).toContain('next week β‰ˆ 3'); // prediction rendered expect(live).toContain('Web extract β€” semantic vs positional'); // extract hit-rate panel expect(live).toContain('70%'); // semantic hit-rate rendered + expect(live).toContain('Models β€” per role'); // role-aware model panel + expect(live).toContain("role:'subagent'"); // per-role setters wired + expect(live).toContain("role:'vision'"); + expect(live).toContain("role:'all'"); // one-model-for-everything + expect(live).toContain('πŸ‘ has vision'); // main model vision badge + expect(live).toContain('optional β€” main already sees images'); // vision role marked optional + expect(live).toContain('Recall β€” how did we do it before?'); // recall explorer panel + expect(live).toContain("action:'recall.query'"); // in-place recall fetch (no reload) }); // Exercises the REAL gather chain (config + store + skills, read-only) for an empty cwd β€” proves it diff --git a/test/maintain-history.test.ts b/test/maintain-history.test.ts index 6982392..45ab23a 100644 --- a/test/maintain-history.test.ts +++ b/test/maintain-history.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { serializeMaintainHistory, deserializeMaintainHistory, mergeRuns, MAINTAIN_HISTORY_VERSION } from '../src/cli/maintain-history.ts'; +import { serializeMaintainHistory, deserializeMaintainHistory, mergeRuns, verifyHistoryAudit, historyHead, MAINTAIN_HISTORY_VERSION } from '../src/cli/maintain-history.ts'; import type { MaintainRun } from '../src/cli/maintain-stats.ts'; const RUNS: MaintainRun[] = [ @@ -34,6 +34,30 @@ describe('maintain history export/import', () => { expect(() => deserializeMaintainHistory('{"foo":1}')).toThrow(/no maintain runs/); }); + it('signed snapshot: audit block verifies end-to-end; wrong key and tampering are caught', () => { + const KEY = 'audit-key'; + const parsed = deserializeMaintainHistory(serializeMaintainHistory(RUNS, '2026-07-02T00:00:00Z', { key: KEY })); + expect(parsed.audit!.algo).toBe('sha256-chain+hmac-sha256'); + expect(parsed.audit!.keyId).toHaveLength(12); + + // Right key β†’ fully ok. + expect(verifyHistoryAudit(parsed.runs, parsed.audit, KEY)).toMatchObject({ present: true, headMatches: true, signatureValid: true, ok: true }); + // Wrong key β†’ signature invalid β†’ not ok. + expect(verifyHistoryAudit(parsed.runs, parsed.audit, 'wrong').ok).toBe(false); + // Tampered runs (forge filesChanged) β†’ head mismatch β†’ not ok, even with the right key. + const forged = parsed.runs.map((r, i) => i === 0 ? { ...r, filesChanged: 99 } : r); + expect(verifyHistoryAudit(forged, parsed.audit, KEY)).toMatchObject({ headMatches: false, ok: false }); + }); + + it('unsigned snapshot still carries an integrity head; legacy (no audit) stays ok', () => { + const parsed = deserializeMaintainHistory(serializeMaintainHistory(RUNS, 'x')); + expect(parsed.audit!.algo).toBe('sha256-chain'); + expect(parsed.audit!.signature).toBeUndefined(); + expect(verifyHistoryAudit(parsed.runs, parsed.audit)).toMatchObject({ present: true, headMatches: true, ok: true }); + expect(verifyHistoryAudit(RUNS, undefined)).toEqual({ present: false, ok: true }); // legacy file + expect(historyHead(RUNS)).toBe(parsed.audit!.head); // deterministic + }); + it('mergeRuns dedups identical runs and sorts newest-first', () => { const imported = deserializeMaintainHistory(serializeMaintainHistory(RUNS, 'x')).runs; const local: MaintainRun[] = [