diff --git a/agents/onboarding-agent.md b/agents/onboarding-agent.md index 567aa81..d366334 100644 --- a/agents/onboarding-agent.md +++ b/agents/onboarding-agent.md @@ -135,6 +135,10 @@ last_updated: [YYYY-MM-DD] Sammendraget som injiseres ambient ved sesjonsstart kapper fri-konteksten til et lengde-budsjett; den fulle teksten leses av agenter direkte fra fila. Hold derfor det viktigste først i prosaen. +### Planlagt deteksjon — håndteres av orkestratoren (ikke av deg) + +Den valgfrie scheduler-innstillingen (`enabled` + `os_scheduler_cadence`) settes IKKE av deg. Den skrives til en **gated** bruker-config via en CLI med sikkerhetskopi + atomisk skriving, som krever Bash — et verktøy du ikke har. Orkestratoren (`/architect:onboard`) spør om dette og skriver configen ETTER at intervjuet ditt er ferdig. Du verken spør om eller skriver scheduler-configen; hold deg til org-filene over. + ## Completion Report After the 5 structured phases (and the optional Phase 6), present: diff --git a/commands/onboard.md b/commands/onboard.md index ce33767..a33f7e6 100644 --- a/commands/onboard.md +++ b/commands/onboard.md @@ -49,8 +49,16 @@ Hvis argumentet inneholder `--status`: **Fullført:** X/5 kategorier (XX%) — lagret i `$ORG_DIR`. Fri kontekst er valgfri og teller ikke mot prosenten. ``` -4. Hvis noen mangler, tilby å kjøre `/architect:onboard` for å fullføre -5. Avslutt etter statusvisning +4. **Planlagt deteksjon:** les `$HOME/.claude/ms-ai-architect/ms-ai-architect.local.md` hvis den finnes, og vis `scheduled_detection`-innstillingen, f.eks.: + +``` +**Planlagt deteksjon:** [Aktivert / Deaktivert] — kadens: [daily/interval], intervall: [N] dager. +``` + + Mangler fila: vis `**Planlagt deteksjon:** Ikke satt (kjør /architect:onboard for å sette den opp).` + +5. Hvis noen kategorier mangler, tilby å kjøre `/architect:onboard` for å fullføre +6. Avslutt etter statusvisning ## Ingen onboarding @@ -68,6 +76,7 @@ Prosessen tar ca. 5 minutter og dekker: 4. Arkitekturbeslutninger (plattform, integrasjoner, budsjett) 5. Forretningsreferanser (styringsmodell, dokumentformat) 6. Fri kontekst (valgfritt — alt annet du vil pluginen skal vite) +7. Planlagt deteksjon (valgfritt — automatisk sjekk om kunnskapsbasen er oppdatert) ``` Deretter start onboarding-agenten (se under). @@ -89,6 +98,31 @@ rekkefølge, hopp over kategorier som allerede har completed: true, og avslutt m Phase 6 (fri kontekst → free-context.md) hvis den ikke allerede finnes." ``` +## Planlagt deteksjon (etter intervjuet — orkestratoren håndterer dette) + +Når intervju-agenten er ferdig, sett opp den **valgfrie** planlagte deteksjonen. Dette er en egen innstilling som onboarding skriver til bruker-configen, slik at brukeren slipper å hånd-redigere den gitignored fila. (Onboarding-agenten har ikke Bash og kan ikke gjøre dette selv — det er derfor orkestratorens jobb, med den gatede skrive-CLI-en.) + +1. **Spør om aktivering** (AskUserQuestion): + > «Vil du aktivere planlagt deteksjon? Da sjekker pluginen automatisk om Microsoft Learn-kunnskapsbasen har endret seg (poll → rapport → discovery), og varsler deg ved sesjonsstart. Den er Claude-fri og oppdaterer **aldri** kunnskapsbasen selv — du gjennomgår og kjører `/architect:kb-update` manuelt.» + - Alternativer: **Ja, aktiver** / **Nei, ikke nå** (opt-in — default er av). + +2. **Hvis Ja, spør om kadens** (AskUserQuestion): + > «Hvor ofte skal den lokale OS-planleggeren (launchd/cron, hvis du installerer den senere) kjøre deteksjon?» + - Alternativer: **Daglig (anbefalt)** → `daily` / **Følg intervallet** (`interval_days`, default 7 dager) → `interval`. + +3. **Skriv configen** via den gatede CLI-en (sikkerhetskopierer eksisterende config + atomisk skriving — aldri rå fs-write): + + ```bash + # --enabled fra steg 1, --cadence fra steg 2 (daily hvis deaktivert) + node scripts/kb-update/write-schedule-config.mjs --enabled --cadence + ``` + + Skriptet skriver til `~/.claude/ms-ai-architect/ms-ai-architect.local.md` (bruker-eid, overlever reinstall) og bevarer `interval_days`/`include_skill_lifecycle` fra en evt. eksisterende config. Det skriver **aldri** til plugin-roten og kaller **aldri** Claude. + +4. **Rapportér kort:** bekreft valgt innstilling. Hvis aktivert: Tier 1 (SessionStart-hook) kjører deteksjon ved sesjonsstart når det er ≥ `interval_days` siden sist; for ekte bakgrunnskjøring mellom sesjoner kan brukeren installere OS-planleggeren senere (`node scripts/kb-update/scheduler.mjs install`, se `/architect:kb-update`). + +> **Hopp over hvis ikke onboarding-flyt:** kjøres `/architect:onboard --status`, ikke spør om scheduler — vis bare gjeldende innstilling (se Statussjekk). + ## Etter fullført onboarding Vis oppsummering og foreslå neste steg: diff --git a/docs/onboarding-redesign-plan.md b/docs/onboarding-redesign-plan.md index 8084bab..0c9209c 100644 --- a/docs/onboarding-redesign-plan.md +++ b/docs/onboarding-redesign-plan.md @@ -146,3 +146,16 @@ C2.1 er den bærende delsesjonen (etablerer lagring + resolver alle andre bygger - **Kapp-tradeoff (bevisst):** fri kontekst kappes til `maxValueLen` (160) i det ambiente teaser-et — K3 krever bare «kappet til budsjett»-synlighet, og agentene leser full fil via fallback. Egen, mer raus fri-kontekst-cap er en triviell oppfølging hvis 160 viser seg for kort i praksis (ikke gjort — unngår knott uten påvist behov). - **Verifisert:** user-data 25/25 (+9 fri-kontekst) · test-hooks 11/11 (+1: K3-ambient) · kb-update 200 · kb-eval 100 · validate PASSED (239/0/0) · kb-integrity 192/192 (220 baseline-warns) · discovery 13/13 · gitleaks 3 PRE-EKSISTERENDE (`playground/vendor/`). - **Neste:** C2.3 (`os_scheduler_cadence` + `enabled` skrives av onboarding; round-trip mot `parseScheduleConfig`). + +### C2.3 — RESULTAT (levert, TDD test FØR kode) + +**Mål nådd:** onboarding skriver `scheduled_detection:`-blokka (`enabled` + `os_scheduler_cadence`) til den bruker-eide configen, og `parseScheduleConfig` leser den tilbake til samme verdier → **K5 oppfylt** (round-trip bevist deterministisk). Brukeren slipper å hånd-redigere den gitignored fila. + +- **Deterministisk kjerne (K5):** ny **`serializeScheduleConfig(config)`** i `detection-schedule.mjs` — invers av `parseScheduleConfig`, lagt RETT VED parseren (én formatsannhet, ingen andre-parser). Skriver alle fire kjente nøkler normalisert slik `coerce()` leser dem, så `parseScheduleConfig(serializeScheduleConfig(c))` round-tripper (`daily`/`interval`/`enabled`/`interval_days`; garbage-kadens → `daily`). +- **Gated skrive-CLI:** ny **`scripts/kb-update/write-schedule-config.mjs`** (`--enabled` `--cadence` `[--home]`) — **MERGER** på eksisterende config (`parse → override KUN de to onboarding-eide nøklene → serialize`), så `interval_days`/`include_skill_lifecycle` satt andre steder overlever. Pure node, **Claude-fri** (kun ToS-rasjonale i kommentarer, ingen invokasjon); ikke en `DETECTION_STEP`. Skriver til `resolveConfigPath` (bruker-eid). +- **Gating-invariant (ufravikelig):** sikkerhetskopi via ny **`backupFile(filePath, backupRoot)`** i `lib/backup.mjs` (presis enkeltfil-backup — drar IKKE org/ inn i backup ved hver kadens-endring, slik `backupDir` ville) + **`atomicWriteSync`** (aldri rå fs-write). Backup er no-op ved første skriv (ingenting å bevare); backup tas før overskriving. +- **Designvalg som avvek fra plan-teksten (begrunnet):** scheduler-spørsmålet ligger i **orkestratoren (`onboard.md`)**, ikke i `onboarding-agent.md`. Agenten har kun `Write` (ingen Bash) og kan ikke kjøre den gatede CLI-en; orkestratoren har `Bash` + `AskUserQuestion`. Agenten fikk en note om at scheduler håndteres av orkestratoren etter intervjuet. (Speiler C2.2-mønsteret: dokumenter avviket, ikke følg plan-bokstaven når koden tilsier annet.) +- **`onboard.md`:** ny «Planlagt deteksjon»-seksjon (aktiver Ja/Nei opt-in → kadens daily/interval, daily anbefalt → gated CLI-kall + kort rapport); scheduler-rad i `--status`; «dekker»-liste utvidet med pkt. 7. **`onboarding-agent.md`:** note om at scheduler er orkestratorens jobb. +- **Kjent begrensning (pre-eksisterende, ikke C2.3-introdusert):** CLI-kallet i `onboard.md` bruker relativ `node scripts/...`-sti (antar cwd = plugin-rot), identisk konvensjon som `kb-update.md`. Org-/config-stiene er home-baserte (robuste). +- **Verifisert:** detection-schedule 33/33 (+10: serialize round-trip + writeScheduleConfig merge/backup/end-to-end) · backup-restore 15/15 (+3: `backupFile`) · kb-update 213 · kb-eval 100 · validate PASSED (239/0/0) · test-hooks 11/11 · kb-integrity 192/192 (220 baseline-warns) · discovery 13/13 · gitleaks 3 PRE-EKSISTERENDE (`playground/vendor/`). +- **Neste:** C2.4 (verifiser privat-sektor-paritet #4 + lukk evt. §167-residu). diff --git a/scripts/kb-update/lib/backup.mjs b/scripts/kb-update/lib/backup.mjs index 9c26534..7a55eee 100644 --- a/scripts/kb-update/lib/backup.mjs +++ b/scripts/kb-update/lib/backup.mjs @@ -15,7 +15,7 @@ import { unlinkSync, mkdirSync, } from 'node:fs'; -import { join } from 'node:path'; +import { join, basename } from 'node:path'; import { atomicWriteJson } from './atomic-write.mjs'; const META_FILENAME = '.backup-meta.json'; @@ -127,6 +127,36 @@ export function backupDir(srcDir, backupRoot, opts = {}) { return { backupPath, retentionDays, restore }; } +/** + * Back up a SINGLE file into backupRoot//. No-op (returns + * null) when the source file does not exist — a first-time write has nothing to + * preserve. Used to guard the C2.3 scheduler-config write: atomic-write handles + * partial-write safety; this keeps the prior version recoverable. Precise by + * design (does not copy the surrounding dir, so onboarding's org/ data is not + * dragged into a backup on every cadence change). + * + * @param {string} filePath — file to back up + * @param {string} backupRoot — parent dir for the timestamped backup subdir + * @param {object} [opts] + * @param {Date} [opts.now] — override clock for testing + * @returns {{backupPath: string}|null} + */ +export function backupFile(filePath, backupRoot, opts = {}) { + if (!filePath || typeof filePath !== 'string') { + throw new Error('backupFile: filePath is required'); + } + if (!backupRoot || typeof backupRoot !== 'string') { + throw new Error('backupFile: backupRoot is required'); + } + if (!existsSync(filePath)) return null; + const now = opts.now ?? new Date(); + const destDir = join(backupRoot, backupTimestamp(now)); + mkdirSync(destDir, { recursive: true }); + const backupPath = join(destDir, basename(filePath)); + cpSync(filePath, backupPath, { force: true, preserveTimestamps: true }); + return { backupPath }; +} + /** * True if a stale rollback sentinel exists at backupRoot. * @param {string} backupRoot diff --git a/scripts/kb-update/lib/detection-schedule.mjs b/scripts/kb-update/lib/detection-schedule.mjs index f6cfe95..ab48351 100644 --- a/scripts/kb-update/lib/detection-schedule.mjs +++ b/scripts/kb-update/lib/detection-schedule.mjs @@ -88,6 +88,41 @@ export function parseScheduleConfig(text) { return cfg; } +/** + * Serialize a schedule config to a full config-file text: a `---`-fenced + * frontmatter with the `scheduled_detection:` block (all four known keys) plus a + * short human header. The inverse of parseScheduleConfig — C2.3's onboarding + * write-path uses THIS (no second format definition): values are normalized + * exactly as coerce() would read them, so + * parseScheduleConfig(serializeScheduleConfig(c)) + * yields a DEFAULT-merged, normalized c (round-trip, acceptance K5). Pure. + * @param {Partial} [config] + * @returns {string} + */ +export function serializeScheduleConfig(config = {}) { + const c = { ...DEFAULT_SCHEDULE_CONFIG, ...config }; + const interval = + Number.isInteger(c.interval_days) && c.interval_days > 0 + ? c.interval_days + : DEFAULT_SCHEDULE_CONFIG.interval_days; + return [ + '---', + 'scheduled_detection:', + ` enabled: ${c.enabled === true}`, + ` interval_days: ${interval}`, + ` include_skill_lifecycle: ${c.include_skill_lifecycle !== false}`, + ` os_scheduler_cadence: ${c.os_scheduler_cadence === 'interval' ? 'interval' : 'daily'}`, + '---', + '', + '# ms-ai-architect — planlagt deteksjon', + '', + 'Skrevet av `/architect:onboard`. Styrer den opt-in, Claude-frie deteksjonen', + '(poll → rapport → discovery → skill-livssyklus). Endre `scheduled_detection`-', + 'blokka over, eller kjør `/architect:onboard` på nytt.', + '', + ].join('\n'); +} + /** * Load the opt-in schedule config. C2.1: the user-owned path * (~/.claude/ms-ai-architect/ms-ai-architect.local.md) takes precedence so the diff --git a/scripts/kb-update/write-schedule-config.mjs b/scripts/kb-update/write-schedule-config.mjs new file mode 100644 index 0000000..f2abe57 --- /dev/null +++ b/scripts/kb-update/write-schedule-config.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +// write-schedule-config.mjs — Spor C / C2.3: onboarding writes the opt-in +// scheduled-detection config to the USER-owned config path +// (~/.claude/ms-ai-architect/ms-ai-architect.local.md), so the user never has to +// hand-edit the gitignored file. Operator: "avgjøres i onboardingen, egen +// innstilling, daglig default". +// +// STRUCTURAL ToS GUARANTEE: pure node — it only reads/writes a local config +// file and NEVER contacts Anthropic/Claude (same boundary as the rest of the +// detection layer; see detection-schedule.mjs for the Consumer Terms §3.7 +// rationale). It is not a DETECTION_STEP — it is the onboarding write-path. +// +// GATED: it MERGES onto the existing config (parse → override only the two +// onboarding-owned keys: enabled + os_scheduler_cadence → serialize), backs up +// any prior file via lib/backup, and writes atomically via lib/atomic-write — +// never a raw fs write. interval_days / include_skill_lifecycle set elsewhere +// survive the rewrite. +// +// node scripts/kb-update/write-schedule-config.mjs --enabled true --cadence daily +// node scripts/kb-update/write-schedule-config.mjs --enabled false --cadence interval +// +// --home overrides the home dir (tests). Prints the resolved config path. + +import { existsSync, readFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { resolveConfigPath, resolveUserDataDir } from './lib/user-data.mjs'; +import { parseScheduleConfig, serializeScheduleConfig } from './lib/detection-schedule.mjs'; +import { atomicWriteSync } from './lib/atomic-write.mjs'; +import { backupFile } from './lib/backup.mjs'; + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--enabled') out.enabled = argv[++i]; + else if (argv[i] === '--cadence') out.cadence = argv[++i]; + else if (argv[i] === '--home') out.home = argv[++i]; + } + return out; +} + +/** + * Write (merge onto) the user-owned scheduler config from onboarding answers. + * @param {{enabled: boolean|string, cadence: string, home?: string}} args + * @returns {{configPath: string, config: object}} + */ +export function writeScheduleConfig({ enabled, cadence, home = homedir() }) { + const configPath = resolveConfigPath(home); + const existing = existsSync(configPath) ? readFileSync(configPath, 'utf8') : ''; + const parsed = parseScheduleConfig(existing); // existing values (or OFF defaults) + + // Only the two onboarding-owned keys are overridden; everything else is + // carried forward from `parsed`. coerce() in detection-schedule normalizes on + // read, so we just hand it values it understands. + const merged = { + ...parsed, + enabled: enabled === true || enabled === 'true', + os_scheduler_cadence: cadence === 'interval' ? 'interval' : 'daily', + }; + + mkdirSync(resolveUserDataDir(home), { recursive: true }); + backupFile(configPath, join(resolveUserDataDir(home), '.backups')); // no-op if absent + atomicWriteSync(configPath, serializeScheduleConfig(merged)); + return { configPath, config: merged }; +} + +// CLI entry — only when run directly (not when imported by tests). +if (import.meta.url === `file://${process.argv[1]}`) { + const { enabled, cadence, home } = parseArgs(process.argv.slice(2)); + const { configPath, config } = writeScheduleConfig({ enabled, cadence, home }); + console.log(configPath); + console.error( + `scheduled_detection: enabled=${config.enabled} os_scheduler_cadence=${config.os_scheduler_cadence} (interval_days=${config.interval_days})`, + ); +} diff --git a/tests/kb-update/test-backup-restore.test.mjs b/tests/kb-update/test-backup-restore.test.mjs index d0246a6..b5fa89a 100644 --- a/tests/kb-update/test-backup-restore.test.mjs +++ b/tests/kb-update/test-backup-restore.test.mjs @@ -17,6 +17,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { backupDir, + backupFile, detectStaleRollback, cleanupOldBackups, backupTimestamp, @@ -97,6 +98,34 @@ test('backupDir — copies content faithfully (deep equal)', () => { }); }); +// --- backupFile: precise single-file backup (C2.3 config-write guard) --- + +test('backupFile — copies one file into a timestamped subdir, preserving content', () => { + withTmp((tmp) => { + const file = join(tmp, 'ms-ai-architect.local.md'); + const root = join(tmp, '.backups'); + writeFileSync(file, 'PRIOR CONTENT', 'utf8'); + + const res = backupFile(file, root); + assert.ok(res && res.backupPath, 'returns the backup path'); + assert.match(res.backupPath, /\.backups\/\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\/ms-ai-architect\.local\.md$/); + assert.equal(readFileSync(res.backupPath, 'utf8'), 'PRIOR CONTENT'); + }); +}); + +test('backupFile — no-op (returns null) when the source file is absent', () => { + withTmp((tmp) => { + const res = backupFile(join(tmp, 'does-not-exist.md'), join(tmp, '.backups')); + assert.equal(res, null); + assert.equal(existsSync(join(tmp, '.backups')), false, 'no backup dir created when nothing to back up'); + }); +}); + +test('backupFile — throws on missing args', () => { + assert.throws(() => backupFile('', '/tmp/x'), /filePath/); + assert.throws(() => backupFile('/tmp/x', ''), /backupRoot/); +}); + test('backupDir — writes .backup-meta.json sentinel inside backup', () => { withTmp((tmp) => { const src = join(tmp, 'skills'); diff --git a/tests/kb-update/test-detection-schedule.test.mjs b/tests/kb-update/test-detection-schedule.test.mjs index 43251e6..3035b62 100644 --- a/tests/kb-update/test-detection-schedule.test.mjs +++ b/tests/kb-update/test-detection-schedule.test.mjs @@ -12,7 +12,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -20,11 +20,13 @@ import { DEFAULT_SCHEDULE_CONFIG, DETECTION_STEPS, parseScheduleConfig, + serializeScheduleConfig, loadScheduleConfig, shouldRunDetection, summarizeSkillLifecycle, daysSinceLastPoll, } from '../../scripts/kb-update/lib/detection-schedule.mjs'; +import { writeScheduleConfig } from '../../scripts/kb-update/write-schedule-config.mjs'; import { resolveConfigPath, resolveUserDataDir } from '../../scripts/kb-update/lib/user-data.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -92,6 +94,120 @@ test('parseScheduleConfig — unknown/garbage cadence falls back to daily', () = 'unspecified cadence => daily default'); }); +// =========================================================================== +// (A2) C2.3 — serializeScheduleConfig + writeScheduleConfig (onboarding writes +// the opt-in config; parseScheduleConfig round-trips it — acceptance K5) +// =========================================================================== + +test('serializeScheduleConfig — produces a parseable scheduled_detection: block', () => { + const text = serializeScheduleConfig({ enabled: true, os_scheduler_cadence: 'interval' }); + assert.match(text, /^---/, 'frontmatter-fenced config file'); + assert.match(text, /scheduled_detection:/); + assert.match(text, /\n {2}enabled: true/); + assert.match(text, /\n {2}os_scheduler_cadence: interval/); +}); + +test('serializeScheduleConfig — round-trips through parseScheduleConfig (daily)', () => { + const cfg = parseScheduleConfig(serializeScheduleConfig({ enabled: true, os_scheduler_cadence: 'daily' })); + assert.equal(cfg.enabled, true); + assert.equal(cfg.os_scheduler_cadence, 'daily'); + assert.equal(cfg.interval_days, 7, 'unspecified key serialized at its default'); + assert.equal(cfg.include_skill_lifecycle, true); +}); + +test('serializeScheduleConfig — round-trips interval cadence + disabled', () => { + const cfg = parseScheduleConfig(serializeScheduleConfig({ enabled: false, os_scheduler_cadence: 'interval' })); + assert.equal(cfg.enabled, false); + assert.equal(cfg.os_scheduler_cadence, 'interval'); +}); + +test('serializeScheduleConfig — preserves a non-default interval_days through the round-trip', () => { + const cfg = parseScheduleConfig(serializeScheduleConfig({ enabled: true, interval_days: 14, os_scheduler_cadence: 'daily' })); + assert.equal(cfg.interval_days, 14); +}); + +test('serializeScheduleConfig — garbage cadence is normalized to daily', () => { + const cfg = parseScheduleConfig(serializeScheduleConfig({ enabled: true, os_scheduler_cadence: 'weekly' })); + assert.equal(cfg.os_scheduler_cadence, 'daily'); +}); + +test('writeScheduleConfig — writes user config that parseScheduleConfig reads back (K5)', () => { + const home = mkdtempSync(join(tmpdir(), 'home-')); + try { + const { configPath } = writeScheduleConfig({ enabled: true, cadence: 'interval', home }); + assert.equal(configPath, resolveConfigPath(home), 'writes to the user-owned config path'); + const cfg = parseScheduleConfig(readFileSync(configPath, 'utf8')); + assert.equal(cfg.enabled, true); + assert.equal(cfg.os_scheduler_cadence, 'interval'); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test('writeScheduleConfig — coerces string flags (CLI args arrive as strings)', () => { + const home = mkdtempSync(join(tmpdir(), 'home-')); + try { + writeScheduleConfig({ enabled: 'true', cadence: 'daily', home }); + const cfg = parseScheduleConfig(readFileSync(resolveConfigPath(home), 'utf8')); + assert.equal(cfg.enabled, true, '"true" string => boolean true'); + assert.equal(cfg.os_scheduler_cadence, 'daily'); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test('writeScheduleConfig — merge preserves pre-existing keys (interval_days) it does not own', () => { + const home = mkdtempSync(join(tmpdir(), 'home-')); + try { + mkdirSync(resolveUserDataDir(home), { recursive: true }); + writeFileSync( + resolveConfigPath(home), + 'scheduled_detection:\n enabled: true\n interval_days: 21\n include_skill_lifecycle: false\n', + ); + writeScheduleConfig({ enabled: false, cadence: 'interval', home }); + const cfg = parseScheduleConfig(readFileSync(resolveConfigPath(home), 'utf8')); + assert.equal(cfg.enabled, false, 'onboarding-owned key updated'); + assert.equal(cfg.os_scheduler_cadence, 'interval', 'onboarding-owned key updated'); + assert.equal(cfg.interval_days, 21, 'non-owned key preserved across the rewrite'); + assert.equal(cfg.include_skill_lifecycle, false, 'non-owned key preserved across the rewrite'); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test('writeScheduleConfig — backs up an existing config before overwriting it', () => { + const home = mkdtempSync(join(tmpdir(), 'home-')); + try { + writeScheduleConfig({ enabled: true, cadence: 'daily', home }); // first write: nothing to back up + const backupRoot = join(resolveUserDataDir(home), '.backups'); + assert.equal(existsSync(backupRoot), false, 'no backup created on first write (no prior file)'); + writeScheduleConfig({ enabled: false, cadence: 'interval', home }); // second write: prior file exists + assert.equal(existsSync(backupRoot), true, 'prior config backed up before the second write'); + const stamps = readdirSync(backupRoot); + assert.equal(stamps.length, 1, 'exactly one backup generation'); + const backedUp = parseScheduleConfig( + readFileSync(join(backupRoot, stamps[0], 'ms-ai-architect.local.md'), 'utf8'), + ); + assert.equal(backedUp.enabled, true, 'backup holds the PRIOR (pre-overwrite) config'); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test('writeScheduleConfig — loadScheduleConfig picks up what onboarding wrote (end-to-end)', () => { + const root = mkdtempSync(join(tmpdir(), 'sched-')); + const home = mkdtempSync(join(tmpdir(), 'home-')); + try { + writeScheduleConfig({ enabled: true, cadence: 'interval', home }); + const cfg = loadScheduleConfig(root, home); + assert.equal(cfg.enabled, true, 'the scheduler loads the onboarding-written user config'); + assert.equal(cfg.os_scheduler_cadence, 'interval'); + } finally { + rmSync(root, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } +}); + test('daysSinceLastPoll — Infinity when report missing or last_poll absent/invalid', () => { const now = Date.UTC(2026, 5, 21); assert.equal(daysSinceLastPoll(null, now), Infinity);