mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
* Add version-matched bundled skill guides * Clarify skill freshness rollout PRs * Add canonical skills show alias * fix(skills): address guide review feedback * fix(skills): make guide commands cross-platform * fix(skills): apply the ORCA convention to the emulator guides Review follow-up: the emulator guides still instructed literal `orca emulator ...` in sh fences with no Linux disambiguation, so on unmanaged Linux they could launch the GNOME screen reader — the exact failure the executable-selection preamble prevents. Both emulator guides now carry the preamble and ORCA placeholder across fences, tables, and prose, and the cross-platform safety test covers all four converted guides. Also replaces computer-use's "unless a block names a shell" carve-out, which contradicted its own POSIX example, with the unconditional placeholder rule.
206 lines
6.6 KiB
JavaScript
206 lines
6.6 KiB
JavaScript
import { constants } from 'node:fs'
|
|
import { access, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import process from 'node:process'
|
|
import { parse } from 'yaml'
|
|
|
|
const SCRIPT_DIR = import.meta.dirname
|
|
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..')
|
|
|
|
const CANONICAL_GUIDE_NAMES = [
|
|
'computer-use',
|
|
'linear-tickets',
|
|
'orca-cli',
|
|
'orca-emulator',
|
|
'orca-emulator-android',
|
|
'orca-linear',
|
|
'orca-per-workspace-env',
|
|
'orchestration'
|
|
]
|
|
|
|
// Why: old discovery stubs can outlive a rename indefinitely, so aliases are
|
|
// a compatibility ledger: add entries for renames, but never remove them.
|
|
const GUIDE_ALIASES = {
|
|
'computer-use': [],
|
|
'linear-tickets': [],
|
|
'orca-cli': [],
|
|
'orca-emulator': [],
|
|
'orca-emulator-android': [],
|
|
'orca-linear': [],
|
|
'orca-per-workspace-env': [],
|
|
orchestration: []
|
|
}
|
|
|
|
function normalizeMarkdown(markdown) {
|
|
return markdown.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
}
|
|
|
|
function parseFrontmatter(markdown, sourcePath) {
|
|
const normalized = normalizeMarkdown(markdown)
|
|
const match = /^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/.exec(normalized)
|
|
if (!match) {
|
|
throw new Error(`Guide source has no YAML frontmatter: ${sourcePath}`)
|
|
}
|
|
let values
|
|
try {
|
|
values = parse(match[1])
|
|
} catch (error) {
|
|
throw new Error(
|
|
`Guide source has invalid YAML frontmatter: ${sourcePath}: ${error instanceof Error ? error.message : String(error)}`
|
|
)
|
|
}
|
|
if (
|
|
!values ||
|
|
typeof values !== 'object' ||
|
|
typeof values.name !== 'string' ||
|
|
typeof values.description !== 'string'
|
|
) {
|
|
throw new Error(`Guide source must declare name and description: ${sourcePath}`)
|
|
}
|
|
return {
|
|
name: values.name,
|
|
description: values.description.replace(/\s+/g, ' ').trim()
|
|
}
|
|
}
|
|
|
|
function constantName(name) {
|
|
return `${name.replace(/-/g, '_').toUpperCase()}_MARKDOWN`
|
|
}
|
|
|
|
function serializeEmbeddedModule(guides) {
|
|
const markdownConstants = guides
|
|
.map(
|
|
(guide) =>
|
|
`// oxfmt-ignore\nconst ${constantName(guide.name)} = ${JSON.stringify(guide.markdown)}`
|
|
)
|
|
.join('\n\n')
|
|
const guideEntries = guides
|
|
.map((guide) => {
|
|
const markdownConstant = constantName(guide.name)
|
|
return [
|
|
' {',
|
|
` name: ${JSON.stringify(guide.name)},`,
|
|
` description: ${JSON.stringify(guide.description)},`,
|
|
` markdown: ${markdownConstant},`,
|
|
` fullMarkdown: ${markdownConstant},`,
|
|
` aliases: ${JSON.stringify(guide.aliases)}`,
|
|
' }'
|
|
].join('\n')
|
|
})
|
|
.join(',\n')
|
|
|
|
return `// Generated by config/scripts/generate-bundled-skill-guides.mjs. Do not edit.\n\nexport type BundledSkillGuide = {\n readonly name: string\n readonly description: string\n readonly markdown: string\n readonly fullMarkdown: string\n readonly aliases: readonly string[]\n}\n\n${markdownConstants}\n\n// Why: no current guide has bundled reference documents, so --full is byte-identical for now.\n// oxfmt-ignore\nexport const BUNDLED_SKILL_GUIDES = [\n${guideEntries}\n] as const satisfies readonly BundledSkillGuide[]\n`
|
|
}
|
|
|
|
function assertAliasContract(guides) {
|
|
const canonicalNames = new Set(guides.map((guide) => guide.name))
|
|
const seenAliases = new Set()
|
|
for (const guide of guides) {
|
|
for (const alias of guide.aliases) {
|
|
if (canonicalNames.has(alias)) {
|
|
throw new Error(`Guide alias collides with canonical name: ${alias}`)
|
|
}
|
|
if (seenAliases.has(alias)) {
|
|
throw new Error(`Guide alias is assigned more than once: ${alias}`)
|
|
}
|
|
seenAliases.add(alias)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function buildArtifacts(repoRoot = REPO_ROOT) {
|
|
const guideRoot = path.join(repoRoot, 'skill-guides')
|
|
const sourceFiles = (await readdir(guideRoot, { withFileTypes: true }))
|
|
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
|
.map((entry) => entry.name.slice(0, -3))
|
|
.sort((left, right) => left.localeCompare(right, 'en'))
|
|
const expectedNames = [...CANONICAL_GUIDE_NAMES].sort((left, right) =>
|
|
left.localeCompare(right, 'en')
|
|
)
|
|
if (JSON.stringify(sourceFiles) !== JSON.stringify(expectedNames)) {
|
|
throw new Error(
|
|
`Guide sources must match the canonical topic list.\nExpected: ${expectedNames.join(', ')}\nFound: ${sourceFiles.join(', ')}`
|
|
)
|
|
}
|
|
|
|
const guides = []
|
|
const projections = []
|
|
for (const name of expectedNames) {
|
|
const sourcePath = path.join(guideRoot, `${name}.md`)
|
|
// Why: Git may render text with native EOLs despite repository policy; the
|
|
// embedded guide and generated projection must have one platform-neutral identity.
|
|
const markdown = normalizeMarkdown(await readFile(sourcePath, 'utf8'))
|
|
const frontmatter = parseFrontmatter(markdown, path.relative(repoRoot, sourcePath))
|
|
if (frontmatter.name !== name) {
|
|
throw new Error(`Guide source ${name}.md declares mismatched name ${frontmatter.name}`)
|
|
}
|
|
const aliases = GUIDE_ALIASES[name]
|
|
guides.push({ name, description: frontmatter.description, markdown, aliases })
|
|
projections.push({
|
|
path: path.join(repoRoot, 'skills', name, 'SKILL.md'),
|
|
content: markdown
|
|
})
|
|
}
|
|
assertAliasContract(guides)
|
|
|
|
return [
|
|
{
|
|
path: path.join(repoRoot, 'src', 'cli', 'bundled-skill-guides.ts'),
|
|
content: serializeEmbeddedModule(guides)
|
|
},
|
|
...projections
|
|
]
|
|
}
|
|
|
|
async function writeArtifacts(artifacts) {
|
|
for (const artifact of artifacts) {
|
|
await mkdir(path.dirname(artifact.path), { recursive: true })
|
|
await writeFile(artifact.path, artifact.content, 'utf8')
|
|
}
|
|
}
|
|
|
|
async function verifyArtifacts(artifacts, repoRoot = REPO_ROOT) {
|
|
const stale = []
|
|
for (const artifact of artifacts) {
|
|
try {
|
|
await access(artifact.path, constants.R_OK)
|
|
if ((await readFile(artifact.path, 'utf8')) !== artifact.content) {
|
|
stale.push(artifact.path)
|
|
}
|
|
} catch {
|
|
stale.push(artifact.path)
|
|
}
|
|
}
|
|
if (stale.length > 0) {
|
|
throw new Error(
|
|
`Generated bundled skill guides are stale:\n${stale
|
|
.map((filePath) => path.relative(repoRoot, filePath))
|
|
.join('\n')}\nRun node config/scripts/generate-bundled-skill-guides.mjs --write.`
|
|
)
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const artifacts = await buildArtifacts()
|
|
await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts)
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : error)
|
|
process.exitCode = 1
|
|
})
|
|
}
|
|
|
|
export {
|
|
CANONICAL_GUIDE_NAMES,
|
|
GUIDE_ALIASES,
|
|
assertAliasContract,
|
|
buildArtifacts,
|
|
normalizeMarkdown,
|
|
parseFrontmatter,
|
|
serializeEmbeddedModule,
|
|
verifyArtifacts,
|
|
writeArtifacts
|
|
}
|