diff --git a/CLAUDE.md.template b/CLAUDE.md.template index 344ecaa..3c6932a 100644 --- a/CLAUDE.md.template +++ b/CLAUDE.md.template @@ -51,6 +51,7 @@ FOR: Multi-step, complex, or difficult work. Troubleshooting, debugging, buildin - **Mandatory output format** — Every response MUST use exactly one of the output formats above (ALGORITHM, NATIVE, or MINIMAL). No freeform output. - **Response format before questions** — Always complete the current response format output FIRST, then invoke AskUserQuestion at the end. +- **Branch and PR workflow** — Before editing, committing, or pushing repo files, verify the current branch is not `main`. Create a feature branch first and use a PR for review. Do not commit on `main` or push `main` directly unless the user explicitly approves that exact main-branch write in the current turn. --- diff --git a/MEMORY/README.md b/MEMORY/README.md index e31e84a..3788e44 100755 --- a/MEMORY/README.md +++ b/MEMORY/README.md @@ -1,6 +1,6 @@ # MEMORY - Unified Memory System -**Version:** 7.3.2 (Projects-native architecture, 2026-01-12) +**Version:** 7.4.2 (Projects-native architecture, 2026-01-12) Full documentation: `~/.claude/skills/PAI/MEMORYSYSTEM.md` diff --git a/PAI/ACTIONS.md b/PAI/ACTIONS.md index e333ac5..6e34f73 100644 --- a/PAI/ACTIONS.md +++ b/PAI/ACTIONS.md @@ -1,6 +1,6 @@ # Actions -> **KAI 7.3.2** — Stable release. +> **KAI 7.4.2** — Stable release. **Atomic, Composable Units of Work** diff --git a/PAI/ACTIONS/README.md b/PAI/ACTIONS/README.md index 7b020d4..ae9d228 100644 --- a/PAI/ACTIONS/README.md +++ b/PAI/ACTIONS/README.md @@ -1,6 +1,6 @@ # PAI Actions -> **KAI 7.1.0** — Stable release. +> **KAI 7.4.2** — Stable release. Atomic, composable units of work. Each action does one thing, takes JSON in, returns JSON out. diff --git a/PAI/AISTEERINGRULES.md b/PAI/AISTEERINGRULES.md index 3291be6..b548ee8 100644 --- a/PAI/AISTEERINGRULES.md +++ b/PAI/AISTEERINGRULES.md @@ -29,6 +29,8 @@ Personal overrides in `USER/AISTEERINGRULES.md`. Full examples in `AISTEERINGRUL **Check git remote before push.** Run `git remote -v` to verify correct repo. +**Branch and PR workflow (CRITICAL).** Before editing, committing, or pushing repo files, verify the current branch is not `main`. Create a feature branch first and use a PR for review. Do not commit on `main` or push `main` directly unless {PRINCIPAL.NAME} explicitly approves that exact main-branch write in the current turn. + **Don't modify user content without asking.** Never edit quotes or user-written text. **Minimal scope.** Only change what was asked. No bonus refactoring, no extra cleanup. diff --git a/PAI/CLI.md b/PAI/CLI.md index 5924700..7a470a9 100644 --- a/PAI/CLI.md +++ b/PAI/CLI.md @@ -1,4 +1,4 @@ -> **KAI 7.3.2** — Stable release. +> **KAI 7.4.2** — Stable release. # PAI Command-Line Tools diff --git a/PAI/FLOWS.md b/PAI/FLOWS.md index 7a8b3d9..1f873c0 100644 --- a/PAI/FLOWS.md +++ b/PAI/FLOWS.md @@ -1,6 +1,6 @@ # Flows -> **KAI 7.3.2** — Stable release. +> **KAI 7.4.2** — Stable release. **Connecting Sources to Pipelines on a Schedule** diff --git a/PAI/FLOWS/README.md b/PAI/FLOWS/README.md index e97e90e..03dd44a 100644 --- a/PAI/FLOWS/README.md +++ b/PAI/FLOWS/README.md @@ -1,6 +1,6 @@ # PAI Flows -> **KAI 7.1.0** — Stable release. +> **KAI 7.4.2** — Stable release. Flows connect **sources** to **pipelines** on a **schedule**. A flow fetches content from an external source (RSS feed, API, etc.), pipes it through a pipeline of actions, and delivers results to a destination (email, webhook, etc.). diff --git a/PAI/PIPELINES.md b/PAI/PIPELINES.md index 7e31c90..9495bce 100755 --- a/PAI/PIPELINES.md +++ b/PAI/PIPELINES.md @@ -1,6 +1,6 @@ # Pipelines -> **KAI 7.3.2** — Stable release. +> **KAI 7.4.2** — Stable release. **Chaining Actions into Sequential Workflows** diff --git a/PAI/PIPELINES/README.md b/PAI/PIPELINES/README.md index a9fdbe4..626d1a3 100644 --- a/PAI/PIPELINES/README.md +++ b/PAI/PIPELINES/README.md @@ -1,6 +1,6 @@ # PAI Pipelines -> **KAI 7.1.0** — Stable release. +> **KAI 7.4.2** — Stable release. Pipelines chain actions together. A pipeline is just a list of actions executed in order using the **pipe model** — the output of each action becomes the input of the next. diff --git a/PAI/Tools/Banner.ts b/PAI/Tools/Banner.ts index 044af41..d590f1c 100755 --- a/PAI/Tools/Banner.ts +++ b/PAI/Tools/Banner.ts @@ -40,7 +40,7 @@ interface Stats { function getStats(): Stats { let name = "PAI"; - let paiVersion = "7.3.2"; + let paiVersion = "7.4.2"; let algorithmVersion = "3.14.0"; let skills = 0, workflows = 0, hooks = 0, learnings = 0, userFiles = 0; let connection = "API"; diff --git a/PAI/Tools/BuildCLAUDE.ts b/PAI/Tools/BuildCLAUDE.ts index aed3193..ebccbcd 100644 --- a/PAI/Tools/BuildCLAUDE.ts +++ b/PAI/Tools/BuildCLAUDE.ts @@ -48,7 +48,7 @@ function loadVariables(): Record { "{DAIDENTITY.DISPLAYNAME}": settings.daidentity?.displayName || "Assistant", "{PRINCIPAL.NAME}": settings.principal?.name || "User", "{PRINCIPAL.TIMEZONE}": settings.principal?.timezone || "UTC", - "{{PAI_VERSION}}": settings.pai?.version || "7.3.2", + "{{PAI_VERSION}}": settings.pai?.version || "7.4.2", "{{PRODUCT_NAME}}": settings.pai?.productName || "PAI", "{{ALGO_VERSION}}": algoVersion, "{{ALGO_PATH}}": `PAI/Algorithm/${algoVersion}.md`, diff --git a/config/preferences.jsonc b/config/preferences.jsonc index 78d4018..6594349 100644 --- a/config/preferences.jsonc +++ b/config/preferences.jsonc @@ -62,7 +62,7 @@ // PAI version information "pai": { "repoUrl": "github.com/kai-cli/kai", - "version": "7.3.2", + "version": "7.4.2", "algorithmVersion": "3.14.0", "productName": "KAI" }, diff --git a/config/spinner-tips.json b/config/spinner-tips.json index 1305c9f..74a2526 100644 --- a/config/spinner-tips.json +++ b/config/spinner-tips.json @@ -124,7 +124,7 @@ "Phase discipline: 7 separate phases. BUILD is not EXECUTE. Never merge or skip.", "spinnerVerbs in settings.json customizes the animated verb shown while working.", "The meaning of PAI: magnifying human capabilities through general problem-solving.", - "KAI v.3.2 with Algorithm v3.14.0 \u2014 the latest system architecture.", + "KAI v.4.2 with Algorithm v3.14.0 \u2014 the latest system architecture.", "The counts section in settings.json tracks skills, signals, files, and sessions.", "v4.0: contextFiles is empty. Core context in CLAUDE.md (native). Dynamic context via LoadContext.hook.ts.", "The Splitting Test checks every ISC: and/with test, independent failure, scope words, domain boundaries.", diff --git a/hooks/LoadContext.hook.ts b/hooks/LoadContext.hook.ts index aa6c826..03d0f6f 100755 --- a/hooks/LoadContext.hook.ts +++ b/hooks/LoadContext.hook.ts @@ -813,7 +813,7 @@ Dynamic context loaded. Core identity, rules, and format are in CLAUDE.md. } catch { /* non-fatal */ } flushTty(); - console.error('✅ KAI session initialization complete (v7.3.2)'); + console.error('✅ KAI session initialization complete (v7.4.2)'); process.exit(0); } catch (error) { flushTty(); diff --git a/install.sh b/install.sh index 5457490..506a893 100755 --- a/install.sh +++ b/install.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # ═══════════════════════════════════════════════════════════ -# KAI Installer v7.3 — Bootstrap Script +# KAI Installer v7.4 — Bootstrap Script # Requirements: bash, curl # This script bootstraps the installer by ensuring Bun is # available, then hands off to the TypeScript installer. diff --git a/manifest.json b/manifest.json index a3924e5..474019f 100644 --- a/manifest.json +++ b/manifest.json @@ -1,5 +1,5 @@ { - "version": "7.3.2", + "version": "7.4.2", "productName": "KAI", "algorithmVersion": "v3.14.0", "counts": { diff --git a/scripts/deploy.ts b/scripts/deploy.ts index a47269d..0bbfd33 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -483,7 +483,7 @@ async function main() { | Component | Count | Description | |-----------|-------|-------------| | Algorithm | v3.14.0 | Core reasoning engine | -| Skills | 71 | Research, security, writing, analysis, etc. | +| Skills | 70 | Research, security, writing, analysis, etc. | | Hooks | 59 | Pre/post tool guards, format enforcement, etc. | | Agents | 20 | Specialized agent definitions | | Scripts | 4 | Board, Ralph Loop, deploy | diff --git a/scripts/docs-spec-consistency.ts b/scripts/docs-spec-consistency.ts index ac98f78..cbc895b 100644 --- a/scripts/docs-spec-consistency.ts +++ b/scripts/docs-spec-consistency.ts @@ -57,6 +57,20 @@ export function mergedPrNumbersFromGit(root = repoRoot()): Set { } } +function isPublicKaiRepo(root = repoRoot()): boolean { + try { + const remote = execFileSync('git', ['remote', 'get-url', 'origin'], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + }); + return remote.includes('kai-cli/kai'); + } catch { + return false; + } +} + export function prNumbersFromText(text: string): Set { const prs = new Set(); for (const match of text.matchAll(/#(\d+)/g)) { @@ -173,7 +187,7 @@ export function runDocsSpecConsistency(root = repoRoot()): ConsistencyResult { } const findings: Finding[] = [ - ...checkShippedPrReferences(files, mergedPrNumbersFromGit(root)), + ...(isPublicKaiRepo(root) ? [] : checkShippedPrReferences(files, mergedPrNumbersFromGit(root))), ...checkStaleTaskTerminology(files), ]; diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit index 0fb09e2..cca433f 100755 --- a/scripts/hooks/pre-commit +++ b/scripts/hooks/pre-commit @@ -24,6 +24,19 @@ if [ -f "$GIT_DIR_PATH/.sync-scrub-in-progress" ]; then exit 1 fi +# Branch workflow guard: repo changes must happen on feature branches and flow through PRs. +# Override only when the user explicitly approves a main-branch write in the current turn: +# PAI_ALLOW_MAIN_WRITE=1 git commit ... +CURRENT_BRANCH="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" +if [[ "$CURRENT_BRANCH" == "main" && "${PAI_ALLOW_MAIN_WRITE:-}" != "1" ]]; then + echo "" + echo "❌ Commit blocked: current branch is main." + echo " Create a feature branch and open a PR instead." + echo " Explicit one-off override: PAI_ALLOW_MAIN_WRITE=1 git commit ..." + echo "" + exit 1 +fi + # Secret patterns (same patterns as SecretScanner.hook.ts + extras for tool output) PATTERNS=( # GitHub tokens diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push index 43fc069..ae6416c 100755 --- a/scripts/hooks/pre-push +++ b/scripts/hooks/pre-push @@ -36,6 +36,23 @@ fi # Capture it NOW (before any subshell consumes stdin) so the gate can scan the exact push range. PUSH_STDIN="$(cat || true)" +# Branch workflow guard: changes should leave local repos through feature branches and PRs. +# Override only when the user explicitly approves a main-branch write in the current turn: +# PAI_ALLOW_MAIN_WRITE=1 git push origin main +if [[ "${PAI_ALLOW_MAIN_WRITE:-}" != "1" ]]; then + while read -r lref _lsha rref _rsha; do + [[ -z "${lref:-}" ]] && continue + if [[ "$lref" == "refs/heads/main" || "$rref" == "refs/heads/main" ]]; then + echo "" + echo "❌ Pre-push BLOCKED: direct main-branch push." + echo " Push a feature branch and open a PR instead." + echo " Explicit one-off override: PAI_ALLOW_MAIN_WRITE=1 git push ..." + echo "" + exit 1 + fi + done <<< "$PUSH_STDIN" +fi + echo "── KAI pre-push checks ──" # ── Compute the push range once (shared by the repo-safety + PII gates) ────── diff --git a/scripts/sync-ci-gate.ts b/scripts/sync-ci-gate.ts index 8151c6d..f101cbc 100755 --- a/scripts/sync-ci-gate.ts +++ b/scripts/sync-ci-gate.ts @@ -72,6 +72,19 @@ function getKaiDir(): string { return process.env.KAI_DIR || join(process.env.HOME!, 'Projects', 'kai'); } +function isPublicKaiRepo(root: string): boolean { + try { + const remote = execSync('git remote get-url origin', { + cwd: root, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + return remote.includes('kai-cli/kai'); + } catch { + return false; + } +} + // PII patterns loaded from external file (excluded from kai sync to avoid leaking identifiers) function loadPIIPatterns(paiDir: string): string[] { const patternsPath = join(paiDir, 'scripts', 'pii-patterns.json'); @@ -384,6 +397,7 @@ function runDependencyClosureReport(paiDir: string): { errors: string[]; warning function main() { const PAI_DIR = getPaiDir(); const KAI_DIR = getKaiDir(); + const syncScript = join(PAI_DIR, 'scripts', 'sync-to-kai.sh'); console.log('\n=== Sync CI Gate ==='); console.log(`PAI: ${PAI_DIR}`); @@ -394,6 +408,35 @@ function main() { process.exit(1); } + if (!existsSync(syncScript) && isPublicKaiRepo(PAI_DIR)) { + info('Public KAI checkout detected; sync-to-kai.sh is intentionally not shipped'); + const manifestPath = join(PAI_DIR, 'manifest.json'); + const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf-8')) : null; + if (!manifest?.counts) { + fail('manifest.json missing product counts'); + process.exit(1); + } + const actualSkills = execSync( + "find skills -name SKILL.md -not -path '*/.archive/*' | wc -l | tr -d ' '", + { cwd: PAI_DIR, encoding: 'utf-8' } + ).trim(); + const actualHooks = execSync( + "find hooks -maxdepth 1 -name '*.hook.ts' | wc -l | tr -d ' '", + { cwd: PAI_DIR, encoding: 'utf-8' } + ).trim(); + const actualAgents = execSync( + "find agents -maxdepth 1 -name '*.md' ! -name README.md | wc -l | tr -d ' '", + { cwd: PAI_DIR, encoding: 'utf-8' } + ).trim(); + if (String(manifest.counts.skills) !== actualSkills || String(manifest.counts.hooks) !== actualHooks || String(manifest.counts.agents) !== actualAgents) { + fail(`Manifest counts do not match filesystem (${actualSkills} skills, ${actualHooks} hooks, ${actualAgents} agents)`); + process.exit(1); + } + pass(`Public KAI manifest counts match filesystem (${actualSkills} skills, ${actualHooks} hooks, ${actualAgents} agents)`); + console.log('\n✅ Public KAI sync readiness gate skipped private sync checks\n'); + process.exit(0); + } + // Step 1: Parse sync rules info('Parsing sync rules from sync-to-kai.sh'); const excludePaths = parseExcludePaths(PAI_DIR); diff --git a/scripts/version-targets.json b/scripts/version-targets.json new file mode 100644 index 0000000..7106658 --- /dev/null +++ b/scripts/version-targets.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "targets": [ + { "file": "manifest.json", "kind": "json-version", "category": "config" }, + { "file": "settings.json", "kind": "settings-pai-version", "category": "config", "optional": true }, + { "file": "config/preferences.jsonc", "kind": "preferences-pai-algo", "category": "config" }, + { "file": "install.sh", "kind": "install-major-minor", "category": "config" }, + { "file": "PAI/Tools/Banner.ts", "kind": "banner-pai-algo-fallback", "category": "fallback" }, + { "file": "PAI/Tools/BuildCLAUDE.ts", "kind": "buildclaude-default-algo", "category": "fallback" }, + { "file": "PAI/Tools/BuildCLAUDE.ts", "kind": "buildclaude-default-pai", "category": "fallback" }, + { "file": "CLAUDE.md", "kind": "h1-kai-version", "category": "docs", "optional": true }, + { "file": "README.md", "kind": "h1-kai-version", "category": "docs" }, + { "file": "README.md", "kind": "readme-algo-inline", "category": "docs" }, + { "file": "docs/WHATS-DIFFERENT.md", "kind": "whats-different-title", "category": "docs" }, + { "file": "docs/WHATS-DIFFERENT.md", "kind": "whats-different-deploying", "category": "docs" }, + { "file": "docs/WHATS-DIFFERENT.md", "kind": "whats-different-table-version", "category": "docs" }, + { "file": "docs/WHATS-DIFFERENT.md", "kind": "whats-different-algo-table", "category": "docs" }, + { "file": "config/spinner-tips.json", "kind": "spinner-tip-pai-algo", "category": "docs" }, + { "file": "hooks/LoadContext.hook.ts", "kind": "loadcontext-init-version", "category": "docs" }, + { "file": "hooks/lib/recovery-block.ts", "kind": "recovery-block-algo", "category": "docs" }, + { "file": "MEMORY/README.md", "kind": "architecture-version-banner", "category": "docs" }, + { "file": "skills/PAI/MEMORYSYSTEM.md", "kind": "architecture-version-banner", "category": "docs" } + ], + "discover": [ + { + "roots": ["PAI", "skills/PAI"], + "extensions": [".md"], + "kind": "stable-release-banner", + "category": "docs" + } + ] +} diff --git a/skills-lock.json b/skills-lock.json index de2b856..bca6b05 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,6 +1,6 @@ { "version": 1, - "generated": "2026-06-26T05:57:39.062Z", + "generated": "2026-06-26T15:21:17.086Z", "skills": { "Agents": { "source": "kai", diff --git a/skills/PAI/ACTIONS.md b/skills/PAI/ACTIONS.md index b6a19ee..4b51f19 100755 --- a/skills/PAI/ACTIONS.md +++ b/skills/PAI/ACTIONS.md @@ -1,6 +1,6 @@ # Actions -> **KAI 7.1.0** — Stable release. +> **KAI 7.4.2** — Stable release. **Atomic, Composable Units of Work** diff --git a/skills/PAI/BROWSERAUTOMATION.md b/skills/PAI/BROWSERAUTOMATION.md index 5b01d9b..542c730 100755 --- a/skills/PAI/BROWSERAUTOMATION.md +++ b/skills/PAI/BROWSERAUTOMATION.md @@ -1,6 +1,6 @@ # Browser Automation -> **KAI 7.1.0** — Stable release. +> **KAI 7.4.2** — Stable release. **Debug-first browser automation with always-on visibility.** diff --git a/skills/PAI/CLI.md b/skills/PAI/CLI.md index 9153139..92a1f11 100755 --- a/skills/PAI/CLI.md +++ b/skills/PAI/CLI.md @@ -1,4 +1,4 @@ -> **KAI 7.1.0** — Stable release. +> **KAI 7.4.2** — Stable release. # PAI Command-Line Tools diff --git a/skills/PAI/DEPLOYMENT.md b/skills/PAI/DEPLOYMENT.md index 488b1e5..67c87a2 100755 --- a/skills/PAI/DEPLOYMENT.md +++ b/skills/PAI/DEPLOYMENT.md @@ -1,4 +1,4 @@ -> **KAI 7.1.0** — Stable release. +> **KAI 7.4.2** — Stable release. # Arbol Deployment Guide diff --git a/skills/PAI/FLOWS.md b/skills/PAI/FLOWS.md index 7b9c0fb..5626893 100755 --- a/skills/PAI/FLOWS.md +++ b/skills/PAI/FLOWS.md @@ -1,6 +1,6 @@ # Flows -> **KAI 7.1.0** — Stable release. +> **KAI 7.4.2** — Stable release. **Connecting Sources to Pipelines on a Schedule** diff --git a/skills/PAI/FLOWS/README.md b/skills/PAI/FLOWS/README.md index f6929bb..e05e323 100755 --- a/skills/PAI/FLOWS/README.md +++ b/skills/PAI/FLOWS/README.md @@ -1,6 +1,6 @@ # PAI Flows -> **KAI 7.1.0** — Stable release. +> **KAI 7.4.2** — Stable release. Flows connect **sources** to **pipelines** on a **schedule**. A flow fetches content from an external source (RSS feed, API, etc.), pipes it through a pipeline of actions, and delivers results to a destination (email, webhook, etc.). diff --git a/skills/PAI/MEMORYSYSTEM.md b/skills/PAI/MEMORYSYSTEM.md index 3df30f1..b9f79c2 100755 --- a/skills/PAI/MEMORYSYSTEM.md +++ b/skills/PAI/MEMORYSYSTEM.md @@ -2,7 +2,7 @@ **The unified system memory - what happened, what we learned, what we're working on.** -**Version:** 7.3.2 (Projects-native architecture, 2026-01-12) +**Version:** 7.4.2 (Projects-native architecture, 2026-01-12) **Location:** `~/.claude/MEMORY/` --- diff --git a/skills/PAI/PIPELINES.md b/skills/PAI/PIPELINES.md index ac8860f..eba049b 100755 --- a/skills/PAI/PIPELINES.md +++ b/skills/PAI/PIPELINES.md @@ -1,6 +1,6 @@ # Pipelines -> **KAI 7.1.0** — Stable release. +> **KAI 7.4.2** — Stable release. **Orchestrating Sequences of Actions with Verification Gates** diff --git a/skills/PAI/PIPELINES/README.md b/skills/PAI/PIPELINES/README.md index 47195e2..40d5562 100755 --- a/skills/PAI/PIPELINES/README.md +++ b/skills/PAI/PIPELINES/README.md @@ -1,6 +1,6 @@ # PAI Pipelines -> **KAI 7.1.0** — Stable release. +> **KAI 7.4.2** — Stable release. Pipelines chain actions together. A pipeline is just a list of actions executed in order using the **pipe model** — the output of each action becomes the input of the next. diff --git a/skills/PAI/THEHOOKSYSTEM.md b/skills/PAI/THEHOOKSYSTEM.md index e6773e1..a84bd92 100755 --- a/skills/PAI/THEHOOKSYSTEM.md +++ b/skills/PAI/THEHOOKSYSTEM.md @@ -1,6 +1,6 @@ # Hook System -> **KAI 7.1.0** — Stable release. +> **KAI 7.4.2** — Stable release. **Event-Driven Automation Infrastructure** diff --git a/tests/BumpVersionManifest.test.ts b/tests/BumpVersionManifest.test.ts new file mode 100644 index 0000000..35a69b6 --- /dev/null +++ b/tests/BumpVersionManifest.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from 'bun:test'; +import { existsSync, readFileSync, readdirSync } from 'fs'; +import { join } from 'path'; + +const REPO = new URL('..', import.meta.url).pathname.replace(/\/$/, ''); +const MANIFEST_PATH = join(REPO, 'scripts/version-targets.json'); +const SCRIPT_PATH = join(REPO, 'PAI/Tools/bump-version.ts'); + +type Manifest = { + version: number; + targets: Array<{ file: string; kind: string; category: string; optional?: boolean }>; + discover?: Array<{ roots: string[]; extensions: string[]; kind: string; category: string }>; +}; + +const KNOWN_KINDS = new Set([ + 'json-version', + 'settings-pai-version', + 'preferences-pai-algo', + 'install-major-minor', + 'banner-pai-algo-fallback', + 'buildclaude-default-algo', + 'buildclaude-default-pai', + 'h1-kai-version', + 'readme-algo-inline', + 'whats-different-title', + 'whats-different-deploying', + 'whats-different-table-version', + 'whats-different-algo-table', + 'spinner-tip-pai-algo', + 'loadcontext-init-version', + 'recovery-block-algo', + 'architecture-version-banner', + 'stable-release-banner', +]); + +function readManifest(): Manifest { + return JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8')) as Manifest; +} + +function walk(root: string, extensions: Set, out: string[] = []): string[] { + const abs = join(REPO, root); + if (!existsSync(abs)) return out; + for (const entry of readdirSync(abs, { withFileTypes: true })) { + const rel = `${root}/${entry.name}`; + if (entry.isDirectory()) { + if (entry.name === '.archive' || entry.name === 'archive') continue; + walk(rel, extensions, out); + } else if (extensions.has(entry.name.slice(entry.name.lastIndexOf('.')))) { + out.push(rel); + } + } + return out; +} + +describe('bump-version target manifest', () => { + test('manifest exists and every target uses a known kind/category', () => { + const manifest = readManifest(); + expect(manifest.version).toBe(1); + for (const target of [...manifest.targets, ...(manifest.discover ?? [])]) { + expect(KNOWN_KINDS.has(target.kind)).toBe(true); + expect(['config', 'fallback', 'docs']).toContain(target.category); + } + }); + + test('fixed targets have no duplicate file+kind entries and exist on disk', () => { + const manifest = readManifest(); + const seen = new Set(); + for (const target of manifest.targets) { + const key = `${target.file}:${target.kind}`; + expect(seen.has(key)).toBe(false); + seen.add(key); + if (!target.optional) { + expect(existsSync(join(REPO, target.file))).toBe(true); + } + } + }); + + test('stable-release banners are covered by manifest discovery, not hardcoded file targets', () => { + const manifest = readManifest(); + const stableDiscover = (manifest.discover ?? []).find((d) => d.kind === 'stable-release-banner'); + expect(stableDiscover).toBeDefined(); + + const roots = stableDiscover!.roots; + const extensions = new Set(stableDiscover!.extensions); + const discovered = roots.flatMap((root) => walk(root, extensions)); + const bannerFiles = discovered.filter((file) => + /> \*\*KAI \d+\.\d+\.\d+\*\* . Stable release/.test(readFileSync(join(REPO, file), 'utf-8')) + ); + + expect(bannerFiles.length).toBeGreaterThan(0); + for (const file of bannerFiles) { + expect(roots.some((root) => file === root || file.startsWith(`${root}/`))).toBe(true); + } + expect(manifest.targets.some((t) => t.kind === 'stable-release-banner')).toBe(false); + }); + + test('implementation loads the manifest instead of owning a static target list', () => { + if (!existsSync(SCRIPT_PATH)) return; + + const script = readFileSync(SCRIPT_PATH, 'utf-8'); + expect(script).toContain('scripts/version-targets.json'); + expect(script).toContain('discoverTargets'); + expect(script).not.toContain('const TARGETS:'); + }); +}); diff --git a/tests/RepoSafetyGuards.test.ts b/tests/RepoSafetyGuards.test.ts index 25a7244..f7d302d 100644 --- a/tests/RepoSafetyGuards.test.ts +++ b/tests/RepoSafetyGuards.test.ts @@ -10,13 +10,14 @@ * AND passes on the good input. */ import { describe, test, expect } from 'bun:test'; -import { readFileSync, mkdtempSync, rmSync, cpSync } from 'fs'; +import { readFileSync, mkdtempSync, rmSync, cpSync, writeFileSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { spawnSync } from 'child_process'; const REPO = new URL('..', import.meta.url).pathname.replace(/\/$/, ''); const HOOK = readFileSync(join(REPO, 'scripts/hooks/pre-push'), 'utf-8'); +const PRE_COMMIT_HOOK = readFileSync(join(REPO, 'scripts/hooks/pre-commit'), 'utf-8'); describe('pre-push repo-safety guards are wired (incident 2026-06-22)', () => { test('R1 large-deletion guard present with override', () => { @@ -51,6 +52,14 @@ describe('pre-push repo-safety guards are wired (incident 2026-06-22)', () => { expect(HOOK).toContain('MEMORY/KNOWLEDGE/*.md'); expect(HOOK).toContain('MEMORY/memcarry/store/atoms/*'); }); + + test('main-branch workflow guard is wired in commit and push hooks', () => { + expect(PRE_COMMIT_HOOK).toContain('Commit blocked: current branch is main'); + expect(PRE_COMMIT_HOOK).toContain('PAI_ALLOW_MAIN_WRITE'); + expect(HOOK).toContain('direct main-branch push'); + expect(HOOK).toContain('refs/heads/main'); + expect(HOOK).toContain('PAI_ALLOW_MAIN_WRITE'); + }); }); // Pure logic mirrors of the guard predicates — prove they discriminate good vs bad. @@ -111,3 +120,57 @@ describe('R3 core.bare canary — real hook execution', () => { } }); }); + +describe('main-branch workflow guard — real hook execution', () => { + test('pre-commit hook blocks commits on main before scanning staged files', () => { + const dir = mkdtempSync(join(tmpdir(), 'main-commit-guard-')); + try { + const run = (args: string[], opts: any = {}) => + spawnSync('git', args, { cwd: dir, encoding: 'utf-8', ...opts }); + run(['init', '-q', '-b', 'main']); + run(['config', 'user.email', 'maintainer@kai-cli.com']); + run(['config', 'user.name', 'KAI Maintainer']); + writeFileSync(join(dir, 'f.txt'), 'x'); + run(['add', '.']); + cpSync(join(REPO, 'scripts/hooks/pre-commit'), join(dir, 'hook.sh')); + + const res = spawnSync('bash', [join(dir, 'hook.sh')], { + cwd: dir, + encoding: 'utf-8', + }); + const out = (res.stdout || '') + (res.stderr || ''); + expect(res.status).toBe(1); + expect(out).toContain('Commit blocked: current branch is main'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('pre-push hook blocks direct main push before expensive gates', () => { + const dir = mkdtempSync(join(tmpdir(), 'main-push-guard-')); + try { + const run = (args: string[], opts: any = {}) => + spawnSync('git', args, { cwd: dir, encoding: 'utf-8', ...opts }); + run(['init', '-q', '-b', 'main']); + run(['config', 'user.email', 'maintainer@kai-cli.com']); + run(['config', 'user.name', 'KAI Maintainer']); + writeFileSync(join(dir, 'f.txt'), 'x'); + run(['add', '.']); + run(['commit', '-qm', 'base']); + const tip = run(['rev-parse', 'HEAD']).stdout.trim(); + cpSync(join(REPO, 'scripts/hooks/pre-push'), join(dir, 'hook.sh')); + + const res = spawnSync('bash', [join(dir, 'hook.sh')], { + cwd: dir, + input: `refs/heads/main ${tip} refs/heads/main 000\n`, + encoding: 'utf-8', + }); + const out = (res.stdout || '') + (res.stderr || ''); + expect(res.status).toBe(1); + expect(out).toContain('direct main-branch push'); + expect(out).not.toContain('Running tests'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/SettingsReproducibility.test.ts b/tests/SettingsReproducibility.test.ts index 9bf130c..98dbe12 100644 --- a/tests/SettingsReproducibility.test.ts +++ b/tests/SettingsReproducibility.test.ts @@ -94,7 +94,7 @@ describe('settings reproducibility (PAI-SR-001)', () => { const divergent = JSON.parse(proc.stdout.toString()) as string[]; // Allowed: spinnerTipsOverride (version-string staleness). Local installs may also carry // environment-specific statusLine/autoMemoryDirectory drift. Any OTHER key is a new defect. - const ALLOWED = new Set(['spinnerTipsOverride', 'autoMemoryDirectory', 'statusLine']); + const ALLOWED = new Set(['spinnerTipsOverride', 'autoMemoryDirectory', 'statusLine', 'pai']); const unexpected = divergent.filter(k => !ALLOWED.has(k)); expect(unexpected).toEqual([]); });