diff --git a/plugins/ms-ai-architect/scripts/kb-update/templates/README.md b/plugins/ms-ai-architect/scripts/kb-update/templates/README.md
new file mode 100644
index 0000000..4ac5d56
--- /dev/null
+++ b/plugins/ms-ai-architect/scripts/kb-update/templates/README.md
@@ -0,0 +1,62 @@
+# ms-ai-architect KB-update scheduling templates
+
+These templates are consumed by `scripts/install-kb-cron.mjs` (added in
+Wave 4 / Step 11) which substitutes the documented placeholders and
+hands off to the platform's native scheduler. Do not edit a generated
+file directly — re-run the installer instead so the source-of-truth
+stays in this directory.
+
+## Files
+
+| File | Platform | Scheduler |
+|------|----------|-----------|
+| `com.fromaitochitta.ms-ai-architect.kb-update.plist` | macOS (Intel + Apple Silicon) | `launchctl` (per-user LaunchAgent) |
+| `ms-ai-architect-kb-update.service` | Linux | `systemctl --user` |
+| `ms-ai-architect-kb-update.timer` | Linux | `systemctl --user` (paired with the .service) |
+| `ms-ai-architect-kb-update.ps1` | Windows 10/11 | Task Scheduler via `Register-ScheduledTask` |
+
+## Placeholders
+
+All four templates share the same canonical placeholder set. The
+installer fills them in at install-time and writes the rendered file
+under the platform's scheduler directory.
+
+| Placeholder | Filled with | Source |
+|-------------|-------------|--------|
+| `{{NODE_BIN}}` | Absolute path to the `node` binary that should run the cron | `which node` (POSIX) / `where node` (Windows) at install-time |
+| `{{PLUGIN_ROOT}}` | Absolute path to the `plugins/ms-ai-architect/` directory | Resolved by the installer relative to itself |
+| `{{LOG_FILE}}` | Absolute path to the rotated log file | `getLogDir('ms-ai-architect') + '/kb-update.log'` (per `lib/cross-platform-paths.mjs`) |
+| `{{SCHEDULE_HOUR}}` | Cron-hour, 0-23 | Default `4`; overridable via `--schedule-hour` |
+| `{{SCHEDULE_MINUTE}}` | Cron-minute, 0-59 | Default `23`; overridable via `--schedule-minute` |
+| `{{SCHEDULE_DAY_OF_WEEK}}` | launchd Weekday integer (0=Sunday … 3=Wednesday) | Default `3` (Wednesday) |
+
+The systemd `.timer` and Windows `.ps1` use a literal `Wed`/`Wednesday`
+day name rather than `{{SCHEDULE_DAY_OF_WEEK}}` because their respective
+schedulers expect day-name strings, and the installer currently locks
+the day to Wednesday (per the brief's "weekly Wed" cadence). Changing
+the day requires editing the template — the installer does not yet
+expose a `--schedule-day` flag.
+
+## Install / uninstall
+
+The full install/uninstall flow is implemented by
+`scripts/install-kb-cron.mjs` (Wave 4 / Step 11). Run with `--help` for
+the current option set. The contract for all three platforms is "fires
+while the user is logged in" — there is no system-wide / sudo install
+path because Claude Code's keychain-bound auth dies in unattended
+contexts.
+
+## Why these specific schedulers
+
+- **launchd** is the only first-class scheduler on macOS; cron is a
+ thin user-facing alias. `RunAtLoad` is `false` so loading the job at
+ boot does not trigger an immediate Claude Code session.
+- **systemd `--user` units** keep the symmetry of "user-context only"
+ with launchd's LoginItem and Windows' `InteractiveToken`. The
+ `Persistent=true` setting on the timer ensures a missed run (laptop
+ asleep on Wednesday) fires on next boot rather than being skipped.
+- **Windows Task Scheduler** with `InteractiveToken` is the only logon
+ type that keeps the keychain unlocked, which is required for
+ subscription-auth Claude Code sessions.
+
+See `research/01-cross-os-scheduling.md` for the full background.
diff --git a/plugins/ms-ai-architect/scripts/kb-update/templates/com.fromaitochitta.ms-ai-architect.kb-update.plist b/plugins/ms-ai-architect/scripts/kb-update/templates/com.fromaitochitta.ms-ai-architect.kb-update.plist
new file mode 100644
index 0000000..5bdf92f
--- /dev/null
+++ b/plugins/ms-ai-architect/scripts/kb-update/templates/com.fromaitochitta.ms-ai-architect.kb-update.plist
@@ -0,0 +1,55 @@
+
+
+
+
+
+ Label
+ com.fromaitochitta.ms-ai-architect.kb-update
+
+ ProgramArguments
+
+ {{NODE_BIN}}
+ {{PLUGIN_ROOT}}/scripts/kb-update/weekly-kb-cron.mjs
+
+
+ WorkingDirectory
+ {{PLUGIN_ROOT}}
+
+ StartCalendarInterval
+
+ Weekday
+ {{SCHEDULE_DAY_OF_WEEK}}
+ Hour
+ {{SCHEDULE_HOUR}}
+ Minute
+ {{SCHEDULE_MINUTE}}
+
+
+ RunAtLoad
+
+
+ StandardOutPath
+ {{LOG_FILE}}
+
+ StandardErrorPath
+ {{LOG_FILE}}
+
+ EnvironmentVariables
+
+ PATH
+ /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin
+
+
+ ProcessType
+ Background
+
+
diff --git a/plugins/ms-ai-architect/scripts/kb-update/templates/ms-ai-architect-kb-update.ps1 b/plugins/ms-ai-architect/scripts/kb-update/templates/ms-ai-architect-kb-update.ps1
new file mode 100644
index 0000000..95ba629
--- /dev/null
+++ b/plugins/ms-ai-architect/scripts/kb-update/templates/ms-ai-architect-kb-update.ps1
@@ -0,0 +1,48 @@
+# ms-ai-architect-kb-update.ps1
+# PowerShell installer fragment for Windows Task Scheduler. Filled in
+# by scripts/install-kb-cron.mjs at install-time and run elevated only
+# if the user requested system-wide install (default is per-user with
+# InteractiveToken so the task fires while the user is logged in).
+
+$TaskName = 'ms-ai-architect-kb-update'
+$NodeBin = '{{NODE_BIN}}'
+$PluginRoot = '{{PLUGIN_ROOT}}'
+$LogFile = '{{LOG_FILE}}'
+$ScheduleAt = '{{SCHEDULE_HOUR}}:{{SCHEDULE_MINUTE}}'
+
+$Trigger = New-ScheduledTaskTrigger `
+ -Weekly `
+ -DaysOfWeek Wednesday `
+ -At $ScheduleAt
+
+$Action = New-ScheduledTaskAction `
+ -Execute $NodeBin `
+ -Argument "$PluginRoot\scripts\kb-update\weekly-kb-cron.mjs" `
+ -WorkingDirectory $PluginRoot
+
+# InteractiveToken is the contract: the task only runs while the user is
+# logged in. This avoids the "OAuth dies in cron" failure-mode (claude
+# subscription auth is bound to the keychain, which is unlocked only when
+# the user is logged in). RunLevel Limited keeps the task at non-elevated
+# privileges; admin elevation is unnecessary for per-user scheduling.
+$Principal = New-ScheduledTaskPrincipal `
+ -UserId $env:USERNAME `
+ -LogonType InteractiveToken `
+ -RunLevel Limited
+
+$Settings = New-ScheduledTaskSettingsSet `
+ -AllowStartIfOnBatteries `
+ -DontStopIfGoingOnBatteries `
+ -StartWhenAvailable `
+ -ExecutionTimeLimit (New-TimeSpan -Hours 2)
+
+Register-ScheduledTask `
+ -TaskName $TaskName `
+ -Trigger $Trigger `
+ -Action $Action `
+ -Principal $Principal `
+ -Settings $Settings `
+ -Description 'Weekly Microsoft Learn KB freshness update for ms-ai-architect plugin' `
+ -Force | Out-Null
+
+Write-Host "Registered Windows scheduled task '$TaskName' (weekly Wed $ScheduleAt, log: $LogFile)"
diff --git a/plugins/ms-ai-architect/scripts/kb-update/templates/ms-ai-architect-kb-update.service b/plugins/ms-ai-architect/scripts/kb-update/templates/ms-ai-architect-kb-update.service
new file mode 100644
index 0000000..9a69bd3
--- /dev/null
+++ b/plugins/ms-ai-architect/scripts/kb-update/templates/ms-ai-architect-kb-update.service
@@ -0,0 +1,19 @@
+[Unit]
+Description=ms-ai-architect weekly KB-update (Microsoft Learn freshness)
+Documentation=file://{{PLUGIN_ROOT}}/scripts/kb-update/templates/README.md
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=oneshot
+ExecStart={{NODE_BIN}} {{PLUGIN_ROOT}}/scripts/kb-update/weekly-kb-cron.mjs
+WorkingDirectory={{PLUGIN_ROOT}}
+StandardOutput=append:{{LOG_FILE}}
+StandardError=append:{{LOG_FILE}}
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+# No User= here; the unit is installed under `systemctl --user` so it
+# inherits the invoking user's identity. Running under the user manager
+# keeps the contract "fires while user is logged in" symmetric across
+# the three platforms (launchd LoginItem, systemd --user, Windows
+# InteractiveToken). Switching to system-wide service+sudo would
+# diverge from that contract — do not do that here.
diff --git a/plugins/ms-ai-architect/scripts/kb-update/templates/ms-ai-architect-kb-update.timer b/plugins/ms-ai-architect/scripts/kb-update/templates/ms-ai-architect-kb-update.timer
new file mode 100644
index 0000000..e05bce6
--- /dev/null
+++ b/plugins/ms-ai-architect/scripts/kb-update/templates/ms-ai-architect-kb-update.timer
@@ -0,0 +1,13 @@
+[Unit]
+Description=Weekly trigger for ms-ai-architect KB-update
+
+[Timer]
+# Default cadence per the brief is Wednesday 04:23 local time. Editing
+# this file directly is fine for one-off schedule tweaks; for
+# reproducible installs prefer re-running scripts/install-kb-cron.mjs.
+OnCalendar=Wed *-*-* 04:23:00
+Persistent=true
+Unit=ms-ai-architect-kb-update.service
+
+[Install]
+WantedBy=timers.target
diff --git a/plugins/ms-ai-architect/tests/kb-update/test-template-generation.test.mjs b/plugins/ms-ai-architect/tests/kb-update/test-template-generation.test.mjs
new file mode 100644
index 0000000..034faf6
--- /dev/null
+++ b/plugins/ms-ai-architect/tests/kb-update/test-template-generation.test.mjs
@@ -0,0 +1,98 @@
+// tests/kb-update/test-template-generation.test.mjs
+// Structural-regex tests for scripts/kb-update/templates/* (Step 8).
+// Verifies that each template file exists, contains the documented sentinel
+// strings, and exposes the documented placeholder set. No template execution
+// or real scheduling occurs in this test — that lives in Wave 6 live-test.
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync, existsSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const TEMPLATES_DIR = join(__dirname, '..', '..', 'scripts', 'kb-update', 'templates');
+
+const PLIST = join(TEMPLATES_DIR, 'com.fromaitochitta.ms-ai-architect.kb-update.plist');
+const SERVICE = join(TEMPLATES_DIR, 'ms-ai-architect-kb-update.service');
+const TIMER = join(TEMPLATES_DIR, 'ms-ai-architect-kb-update.timer');
+const PS1 = join(TEMPLATES_DIR, 'ms-ai-architect-kb-update.ps1');
+const README = join(TEMPLATES_DIR, 'README.md');
+
+function readTpl(p) {
+ assert.equal(existsSync(p), true, `template missing: ${p}`);
+ return readFileSync(p, 'utf8');
+}
+
+test('plist — exists with required keys and placeholders', () => {
+ const content = readTpl(PLIST);
+ assert.match(content, /Label<\/key>/);
+ assert.match(content, /StartCalendarInterval<\/key>/);
+ assert.match(content, /ProgramArguments<\/key>/);
+ assert.match(content, /StandardOutPath<\/key>/);
+ assert.match(content, /StandardErrorPath<\/key>/);
+ assert.match(content, /EnvironmentVariables<\/key>/);
+ assert.match(content, /RunAtLoad<\/key>\s*/);
+ assert.match(content, /\{\{NODE_BIN\}\}/);
+ assert.match(content, /\{\{PLUGIN_ROOT\}\}/);
+ assert.match(content, /\{\{LOG_FILE\}\}/);
+ assert.match(content, /\{\{SCHEDULE_HOUR\}\}/);
+ assert.match(content, /\{\{SCHEDULE_MINUTE\}\}/);
+ assert.match(content, /\{\{SCHEDULE_DAY_OF_WEEK\}\}/);
+});
+
+test('systemd .timer — exists with OnCalendar and Persistent', () => {
+ const content = readTpl(TIMER);
+ assert.match(content, /\[Unit\]/);
+ assert.match(content, /\[Timer\]/);
+ assert.match(content, /\[Install\]/);
+ assert.match(content, /OnCalendar=Wed/);
+ assert.match(content, /Persistent=true/);
+ assert.match(content, /WantedBy=timers\.target/);
+});
+
+test('systemd .service — exists with [Unit], [Service] and ExecStart', () => {
+ const content = readTpl(SERVICE);
+ assert.match(content, /\[Unit\]/);
+ assert.match(content, /\[Service\]/);
+ assert.match(content, /ExecStart=/);
+ assert.match(content, /\{\{NODE_BIN\}\}/);
+ assert.match(content, /\{\{PLUGIN_ROOT\}\}/);
+});
+
+test('PowerShell ps1 — exists with Register-ScheduledTask and InteractiveToken', () => {
+ const content = readTpl(PS1);
+ assert.match(content, /Register-ScheduledTask/);
+ assert.match(content, /InteractiveToken/);
+ assert.match(content, /New-ScheduledTaskTrigger/);
+ assert.match(content, /-Weekly/);
+ assert.match(content, /-DaysOfWeek\s+Wednesday/);
+ assert.match(content, /\{\{NODE_BIN\}\}/);
+ assert.match(content, /\{\{PLUGIN_ROOT\}\}/);
+});
+
+test('README — exists and references each template by filename', () => {
+ const content = readTpl(README);
+ assert.match(content, /com\.fromaitochitta\.ms-ai-architect\.kb-update\.plist/);
+ assert.match(content, /ms-ai-architect-kb-update\.service/);
+ assert.match(content, /ms-ai-architect-kb-update\.timer/);
+ assert.match(content, /ms-ai-architect-kb-update\.ps1/);
+});
+
+test('plist + service + ps1 reference NODE_BIN and PLUGIN_ROOT', () => {
+ // The .timer is a pure trigger — it activates the .service, which is
+ // the only systemd unit that needs to know the binary + plugin root.
+ // launchd and Windows put the command directly in the trigger spec, so
+ // they need both placeholders themselves.
+ for (const tpl of [PLIST, SERVICE, PS1]) {
+ const content = readFileSync(tpl, 'utf8');
+ assert.match(content, /\{\{NODE_BIN\}\}/, `${tpl} missing NODE_BIN placeholder`);
+ assert.match(content, /\{\{PLUGIN_ROOT\}\}/, `${tpl} missing PLUGIN_ROOT placeholder`);
+ }
+});
+
+test('.timer is placeholder-free literal (Wed 04:23 hardcoded per plan)', () => {
+ const content = readFileSync(TIMER, 'utf8');
+ assert.match(content, /OnCalendar=Wed \*-\*-\* 04:23:00/);
+ assert.doesNotMatch(content, /\{\{[A-Z_]+\}\}/);
+});