#!/usr/bin/env node import { readFile, readdir } from 'node:fs/promises' import { existsSync } from 'node:fs' import { execFileSync } from 'node:child_process' import path from 'node:path' const root = process.cwd() const failures = [] function fail(message) { failures.push(message) } async function readText(relativePath) { return readFile(path.join(root, relativePath), 'utf8') } function requireFile(relativePath) { if (!existsSync(path.join(root, relativePath))) { fail(`Missing required harness file: ${relativePath}`) } } function requireDir(relativePath) { if (!existsSync(path.join(root, relativePath))) { fail(`Missing required project directory: ${relativePath}`) } } function gitLines(args) { try { return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }) .split(/\r?\n/) .map(line => line.trim()) .filter(Boolean) } catch { return [] } } function changedFilesFromGit() { const files = new Set() for (const file of gitLines(['diff', '--name-only'])) files.add(file) for (const file of gitLines(['diff', '--name-only', '--cached'])) files.add(file) for (const file of gitLines(['ls-files', '--others', '--exclude-standard'])) files.add(file) const baseRef = process.env.GITHUB_BASE_REF if (baseRef) { const baseCandidates = [`origin/${baseRef}`, baseRef] let foundPrBase = false for (const base of baseCandidates) { const diff = gitLines(['diff', '--name-only', `${base}...HEAD`]) if (diff.length > 0) { foundPrBase = true for (const file of diff) files.add(file) break } } if (process.env.GITHUB_ACTIONS === 'true' && !foundPrBase && files.size === 0) { fail(`Unable to inspect PR diff against ${baseRef}; build checkout must fetch full history`) } } else { const upstream = gitLines(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])[0] if (upstream) { for (const file of gitLines(['diff', '--name-only', `${upstream}...HEAD`])) files.add(file) } } return [...files].sort() } function isChatSessionChainFile(file) { return file === 'packages/client/src/api/hermes/chat.ts' || file === 'packages/client/src/api/hermes/group-chat.ts' || file === 'packages/client/src/api/hermes/sessions.ts' || file === 'packages/client/src/stores/hermes/group-chat.ts' || file === 'packages/client/src/stores/hermes/chat.ts' || file === 'packages/server/src/controllers/hermes/sessions.ts' || file === 'packages/server/src/db/hermes/session-store.ts' || file === 'packages/server/src/routes/hermes/group-chat.ts' || file.startsWith('packages/client/src/components/hermes/group-chat/') || file.startsWith('packages/client/src/components/hermes/chat/') || file.startsWith('packages/server/src/lib/context-compressor/') || file.startsWith('packages/server/src/services/hermes/context-engine/') || file.startsWith('packages/server/src/services/hermes/group-chat/') || file.startsWith('packages/server/src/services/hermes/run-chat/') || file.startsWith('packages/server/src/services/hermes/agent-bridge/') } function isChatChainChangeFragment(file) { return file.startsWith('docs/chat-chain-changes/') && file.endsWith('.md') && path.basename(file) !== 'README.md' } for (const file of [ 'AGENTS.md', 'ARCHITECTURE.md', 'DEVELOPMENT.md', 'docs/harness/README.md', 'docs/harness/validation.md', 'docs/harness/worktree-runbook.md', 'docs/harness/pr-review.md', 'docs/chat-chain-changes/README.md', ]) { requireFile(file) } for (const dir of [ 'packages/client/src', 'packages/server/src', 'packages/skills', 'tests/client', 'tests/server', 'tests/e2e', '.github/workflows', 'docs/chat-chain-changes', ]) { requireDir(dir) } const agents = await readText('AGENTS.md') const agentLines = agents.trimEnd().split(/\r?\n/) if (agentLines.length > 120) { fail(`AGENTS.md should stay short; found ${agentLines.length} lines, expected <= 120`) } for (const requiredLink of [ 'DEVELOPMENT.md', 'ARCHITECTURE.md', 'docs/harness/README.md', 'docs/harness/validation.md', 'docs/harness/worktree-runbook.md', 'docs/harness/pr-review.md', ]) { if (!agents.includes(requiredLink)) { fail(`AGENTS.md must link to ${requiredLink}`) } } const packageJson = JSON.parse(await readText('package.json')) for (const scriptName of [ 'harness:check', 'test', 'test:coverage', 'test:e2e', 'build', ]) { if (!packageJson.scripts?.[scriptName]) { fail(`package.json is missing script: ${scriptName}`) } } const architecture = await readText('ARCHITECTURE.md') for (const phrase of [ 'packages/client/src', 'packages/server/src', 'packages/skills', 'HERMES_WEB_UI_HOME', 'agent-bridge/python/', ]) { if (!architecture.includes(phrase)) { fail(`ARCHITECTURE.md should document: ${phrase}`) } } const buildWorkflow = await readText('.github/workflows/build.yml') if (!buildWorkflow.includes('npm run harness:check')) { fail('Build workflow must run npm run harness:check') } if (!buildWorkflow.includes('fetch-depth: 0')) { fail('Build workflow checkout must use fetch-depth: 0 so harness:check can inspect PR diffs') } const chatSessionsDoc = await readText('docs/cli-chat-sessions.md') for (const phrase of [ '最后重建时间', '维护要求', '最近链路变更记录', 'docs/chat-chain-changes/', '每个 PR 一个变更片段', 'packages/server/src/services/hermes/agent-bridge/', 'packages/server/src/services/hermes/group-chat/', 'packages/server/src/lib/context-compressor/', '任何改动都算 Chat 链路改动', ]) { if (!chatSessionsDoc.includes(phrase)) { fail(`docs/cli-chat-sessions.md must document chat chain maintenance rule: ${phrase}`) } } const changedFiles = changedFilesFromGit() const changedChatChainFiles = changedFiles.filter( file => !isChatChainChangeFragment(file) && file !== 'docs/chat-chain-changes/README.md' && file !== 'docs/cli-chat-sessions.md' && isChatSessionChainFile(file), ) const changedChatChainFragments = changedFiles.filter(isChatChainChangeFragment) if (changedChatChainFiles.length > 0 && changedChatChainFragments.length === 0) { fail( [ 'Chat session chain changed without adding a docs/chat-chain-changes/*.md fragment.', 'Add one fragment with date, PR/commit, touched feature, and behavior impact.', `Changed chain files: ${changedChatChainFiles.join(', ')}`, ].join(' '), ) } for (const file of changedChatChainFragments) { if (!existsSync(path.join(root, file))) { fail(`Chat chain change fragment was removed instead of added/updated: ${file}`) continue } const fragment = await readText(file) for (const marker of ['date:', 'feature:', 'impact:']) { if (!fragment.includes(marker)) { fail(`Chat chain change fragment ${file} must include frontmatter field: ${marker}`) } } if (!fragment.includes('pr:') && !fragment.includes('commit:')) { fail(`Chat chain change fragment ${file} must include either pr: or commit:`) } } // --- Legacy brand scan ------------------------------------------------- // The project was renamed from ekko / hermes-web-ui to hermes-ui. A few // legacy identifiers are deliberately preserved so existing deployments keep // working; see ALLOWED_LEGACY_BRAND below for the reason each one survives. const LEGACY_BRAND_PATTERNS = [/\bekko\b/i, /hermes-web-ui/i] const SCAN_ROOTS = [ 'packages/client/src', 'packages/server/src', 'packages/skills', 'tests', 'scripts', 'bin', '.github', ] const SCAN_FILES = [ 'package.json', 'Dockerfile', 'docker-compose.yml', 'AGENTS.md', 'ARCHITECTURE.md', 'DEVELOPMENT.md', 'README.md', 'README_zh.md', ] const SCAN_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs', '.vue', '.json', '.yml', '.yaml', '.md', '.py']) const SCAN_SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'reference_code', '__pycache__']) // This file necessarily spells out the patterns and allow-list entries it checks. const SCAN_SKIP_FILES = new Set(['scripts/harness-check.mjs']) // Every entry must state why the legacy string is still allowed. const ALLOWED_LEGACY_BRAND = [ { file: 'packages/server/src/db/index.ts', reason: 'DB filenames predate the rename; renamed files would orphan data in ~/.hermes-web-ui', }, { file: 'bin/hermes-ui.mjs', reason: 'CLI reads the same pre-rename DB filename as packages/server/src/db/index.ts', }, { file: 'packages/server/src/config.ts', reason: 'HERMES_WEB_UI_HOME / ~/.hermes-web-ui state directory is a preserved public contract', }, { file: 'packages/server/src/middleware/user-auth.ts', reason: 'legacy JWT audience must stay accepted so upgrades do not force re-login', }, { file: 'packages/server/src/services/hermes/skill-injector.ts', reason: 'legacy manifest owner must stay recognized so managed skills are not re-injected', }, { file: 'packages/server/src/services/coding-agents.ts', reason: 'legacy MCP server/command names are detected to clean up old on-disk config', }, { file: 'packages/server/src/services/hermes/studio-mcp-autoinject.ts', reason: 'legacy MCP server/command names are detected to clean up old on-disk config', }, { file: 'tests/server/studio-mcp-autoinject.test.ts', reason: 'asserts legacy MCP entries are cleaned from existing on-disk config', }, { file: 'tests/server/coding-agents-launch.test.ts', reason: 'asserts legacy MCP entries are cleaned from existing on-disk config', }, { file: 'tests/client/markdown-rendering.test.ts', reason: 'fixture URL is arbitrary sample data, not project branding', }, { file: 'tests/server/cli-port-detection.test.ts', reason: 'temp dir names and the preserved hermes-web-ui.db filename', }, { file: 'tests/server/agent-bridge-python-concurrency.test.ts', reason: 'asserts preview socket path derived from the preview endpoint env var', }, ] // Files whose remaining 'ekko' hits are the in-house agent runtime that Phase // 003 deletes outright. They are allow-listed here only until then; Phase 003 // must delete these entries together with the files. const PHASE_003_EKKO_AGENT_FILES = [ 'packages/server/src/services/ekko-agent/manager.ts', 'packages/server/src/services/ekko-agent/mcp.ts', 'packages/server/src/services/hermes/run-chat/handle-ekko-agent-run.ts', 'packages/server/src/services/hermes/run-chat/index.ts', 'packages/server/src/services/hermes/run-chat/session-command.ts', 'packages/server/src/services/hermes/run-chat/types.ts', 'packages/client/src/api/coding-agents.ts', 'packages/client/src/api/hermes/chat.ts', 'packages/client/src/components/hermes/chat/ChatPanel.vue', 'packages/client/src/components/hermes/chat/MessageList.vue', 'packages/client/src/components/hermes/chat/SessionListItem.vue', 'packages/client/src/stores/hermes/chat.ts', 'packages/client/src/i18n/locales/en.ts', 'packages/client/src/i18n/locales/zh.ts', 'tests/ekko-agent', 'tests/server/ekko-agent-manager.test.ts', 'tests/server/ekko-agent-mcp.test.ts', 'tests/server/run-chat-ekko-context.test.ts', 'AGENTS.md', 'ARCHITECTURE.md', ] for (const target of PHASE_003_EKKO_AGENT_FILES) { ALLOWED_LEGACY_BRAND.push({ file: target, reason: 'ekko-agent runtime is removed in Phase 003; this entry must be deleted then', }) } // Legacy identifiers that stay valid everywhere, because they are public // deployment contracts rather than project branding. const GLOBAL_ALLOWED_PATTERNS = [ /HERMES_WEB_UI[A-Z0-9_]*/, /HERMES_WEBUI_STATE_DIR/, /\.hermes-web-ui/, /hermes_brightness/, /hermes_style/, /hermes_locale/, ] async function collectScanFiles(target, out) { const absolute = path.join(root, target) const stat = await readdir(absolute, { withFileTypes: true }).catch(() => null) if (!stat) return for (const entry of stat) { const relative = path.join(target, entry.name) if (entry.isDirectory()) { if (SCAN_SKIP_DIRS.has(entry.name)) continue await collectScanFiles(relative, out) } else if (SCAN_EXTENSIONS.has(path.extname(entry.name))) { out.push(relative) } } } function isAllowedLegacyBrand(relativePath, line) { if (GLOBAL_ALLOWED_PATTERNS.some(pattern => pattern.test(line))) return true const normalized = relativePath.split(path.sep).join('/') // An allow-list entry may name a directory, in which case it covers its files. return ALLOWED_LEGACY_BRAND.some( entry => normalized === entry.file || normalized.startsWith(`${entry.file}/`), ) } const scanFiles = [] for (const target of SCAN_ROOTS) await collectScanFiles(target, scanFiles) for (const file of SCAN_FILES) { if (existsSync(path.join(root, file))) scanFiles.push(file) } const brandHits = [] for (const relativePath of scanFiles) { if (SCAN_SKIP_FILES.has(relativePath.split(path.sep).join('/'))) continue const text = await readFile(path.join(root, relativePath), 'utf8') const lines = text.split(/\r?\n/) for (let index = 0; index < lines.length; index += 1) { const line = lines[index] if (!LEGACY_BRAND_PATTERNS.some(pattern => pattern.test(line))) continue if (isAllowedLegacyBrand(relativePath, line)) continue brandHits.push(`${relativePath}:${index + 1}: ${line.trim()}`) } } for (const hit of brandHits) { fail(`Legacy brand string must be removed (ekko / hermes-web-ui): ${hit}`) } if (failures.length > 0) { console.error('Harness check failed:') for (const failure of failures) { console.error(`- ${failure}`) } process.exit(1) } console.log('Harness check passed')