diff --git a/.changeset/open-loop-framework-yml.md b/.changeset/open-loop-framework-yml.md new file mode 100644 index 0000000..293e380 --- /dev/null +++ b/.changeset/open-loop-framework-yml.md @@ -0,0 +1,20 @@ +--- +'@gemstack/framework': minor +--- + +Read `the-framework.yml` for per-repo Open Loop defaults (#258). + +A project can now carry its own domain preset + modes, so you do not retype the +flags each run: + +```yaml +preset: software-development +autopilot: true +``` + +The CLI reads it from the run's workspace and merges it with the flags: `--preset` +wins over the file's `preset`; `--autopilot` / `--technical` OR with the file's +booleans (a flag only ever enables a mode). A missing file is a no-op and a +malformed one is a warning, never a failed run. New exports: `loadFrameworkConfig`, +`parseFrameworkConfig`, `mergeRunConfig`, `FRAMEWORK_CONFIG_FILES`, +`FrameworkFileConfig`. diff --git a/packages/framework/package.json b/packages/framework/package.json index c85aafc..73df969 100644 --- a/packages/framework/package.json +++ b/packages/framework/package.json @@ -49,7 +49,8 @@ "clean": "rm -rf dist dist-test" }, "dependencies": { - "@gemstack/ai-autopilot": "workspace:^" + "@gemstack/ai-autopilot": "workspace:^", + "yaml": "^2.5.0" }, "devDependencies": { "@types/node": "^20.0.0", diff --git a/packages/framework/src/cli.test.ts b/packages/framework/src/cli.test.ts index 95031b8..8127d9e 100644 --- a/packages/framework/src/cli.test.ts +++ b/packages/framework/src/cli.test.ts @@ -6,6 +6,7 @@ import { chooseSessionLink, claudeDriverOptions, CLAUDE_CODE_SESSION_LIST, + mergeRunConfig, parseArgs, resolveDomainPreset, runCli, @@ -77,6 +78,25 @@ test('activeModes maps the mode flags to Open Loop mode names', () => { assert.deepEqual(activeModes({ autopilot: true, technical: true }), ['autopilot', 'technical']) }) +test('mergeRunConfig: the-framework.yml supplies defaults, flags override (#258)', () => { + const flags = { preset: undefined, autopilot: false, technical: false } + // file-only: the repo config drives the run + assert.deepEqual(mergeRunConfig(flags, { preset: 'software-development', autopilot: true }), { + presetName: 'software-development', + autopilot: true, + technical: false, + }) + // a --preset flag wins over the file's preset + assert.equal(mergeRunConfig({ ...flags, preset: 'web-dev' }, { preset: 'software-development' }).presetName, 'web-dev') + // modes OR together: a flag can only enable a mode + assert.deepEqual(mergeRunConfig({ ...flags, technical: true }, { autopilot: true }), { + autopilot: true, + technical: true, + }) + // nothing set anywhere: no preset, no modes + assert.deepEqual(mergeRunConfig(flags, {}), { autopilot: false, technical: false }) +}) + test('resolveDomainPreset resolves a shipped preset by name (#254/#256)', async () => { const none = await resolveDomainPreset(undefined, []) assert.deepEqual(none, {}) @@ -104,7 +124,7 @@ test('runCli notes mode flags given without a preset', async () => { // --fake so the note fires before any real run; unknown-preset path is not hit. const code = await runCli(['--fake', '--no-dashboard', '--autopilot'], io) assert.equal(code, 0) - assert.ok(err.some(l => /have no effect without --preset/.test(l))) + assert.ok(err.some(l => /have no effect without a preset/.test(l))) }) test('chooseSessionLink defaults a live run to the claude.ai/code session list (#212)', () => { diff --git a/packages/framework/src/cli.ts b/packages/framework/src/cli.ts index 791d7dc..cf8cc0c 100644 --- a/packages/framework/src/cli.ts +++ b/packages/framework/src/cli.ts @@ -23,6 +23,7 @@ import { } from './run.js' import { FAKE_DEPLOY, FAKE_INTENT, FAKE_SIGNALS, fakeDriver } from './fake-script.js' import { discoverExtensions, readProjectSignals } from './extensions.js' +import { loadFrameworkConfig, type FrameworkFileConfig } from './config.js' import { preflight } from './preflight.js' import { RunStore } from './store/index.js' @@ -74,6 +75,8 @@ Options: + skills frame the build), e.g. software-development. --autopilot Activate the preset's Autopilot mode variants. --technical Activate the preset's Technical mode variants. + (--preset / --autopilot / --technical can also be set per + repo in the-framework.yml; these flags override it.) --compose-extensions Opt the built-in capability extensions in (auth, data, rbac, crud, shell) so the agent composes them instead of hand-rolling. Vike-only; installed framework-* extensions @@ -312,6 +315,32 @@ export function activeModes(opts: Pick): return modes } +/** + * Merge CLI flags over a project's `the-framework.yml` defaults (#258). A `--preset` + * flag wins over the file's `preset`; the mode flags OR with the file's booleans + * (a flag can only *enable* a mode, so there is nothing to override the other way). + */ +export function mergeRunConfig( + opts: Pick, + file: FrameworkFileConfig, +): { presetName?: string; autopilot: boolean; technical: boolean } { + const presetName = opts.preset ?? file.preset + return { + ...(presetName ? { presetName } : {}), + autopilot: opts.autopilot || file.autopilot === true, + technical: opts.technical || file.technical === true, + } +} + +/** A short summary of what the-framework.yml contributed and is in effect, or `''` for nothing to report. */ +function describeConfigSource(opts: Pick, file: FrameworkFileConfig): string { + const parts: string[] = [] + if (file.preset && !opts.preset) parts.push(`preset=${file.preset}`) // a --preset flag would override it + if (file.autopilot) parts.push('autopilot') + if (file.technical) parts.push('technical') + return parts.join(', ') +} + /** * Resolve `--preset ` to a shipped {@link DomainPreset}, loaded with the * active `modes` so its conditions variants are selected (#254, #256). Returns @@ -371,18 +400,29 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise io.err(msg)) + const fromFile = describeConfigSource(opts, fileConfig) + if (fromFile) io.out(`◆ the-framework.yml: ${fromFile}`) + // Resolve an Open Loop domain preset by name (#256), loaded with the active mode // variants. A bad name is a usage error before we do any work. The mode flags // only act on a preset, so note when they are given without one. - const modes = activeModes(opts) - const { preset: domainPreset, error: presetError } = await resolveDomainPreset(opts.preset, modes) + const merged = mergeRunConfig(opts, fileConfig) + const modes = activeModes(merged) + const { preset: domainPreset, error: presetError } = await resolveDomainPreset(merged.presetName, modes) if (presetError) { io.err(presetError) io.err('Run `framework --help` for usage.') return 2 } if (modes.length && !domainPreset) { - io.err(`note: ${modes.join(' + ')} mode(s) have no effect without --preset.`) + io.err(`note: ${modes.join(' + ')} mode(s) have no effect without a preset.`) } // Fail early and clearly if a live run's prerequisites are missing. @@ -397,7 +437,6 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise { + assert.deepEqual(parseFrameworkConfig('preset: software-development\nautopilot: true\ntechnical: false\n'), { + preset: 'software-development', + autopilot: true, + technical: false, + }) +}) + +test('parseFrameworkConfig treats an empty document as {}', () => { + assert.deepEqual(parseFrameworkConfig(''), {}) + assert.deepEqual(parseFrameworkConfig('# just a comment\n'), {}) +}) + +test('parseFrameworkConfig rejects a non-map document and mistyped fields', () => { + assert.throws(() => parseFrameworkConfig('- a\n- b\n'), /must be a YAML map/) + assert.throws(() => parseFrameworkConfig('preset: 3\n'), /"preset" must be a string/) + assert.throws(() => parseFrameworkConfig('autopilot: yep\n'), /"autopilot" must be a boolean/) +}) + +test('loadFrameworkConfig reads the-framework.yml from a directory', async () => { + const dir = await mkdtemp(join(tmpdir(), 'framework-cfg-')) + try { + await writeFile(join(dir, 'the-framework.yml'), 'preset: software-development\nautopilot: true\n') + assert.deepEqual(await loadFrameworkConfig(dir), { preset: 'software-development', autopilot: true }) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('loadFrameworkConfig yields {} when no config file is present', async () => { + const dir = await mkdtemp(join(tmpdir(), 'framework-cfg-empty-')) + try { + assert.deepEqual(await loadFrameworkConfig(dir), {}) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('loadFrameworkConfig warns and returns {} on a malformed file', async () => { + const dir = await mkdtemp(join(tmpdir(), 'framework-cfg-bad-')) + try { + await writeFile(join(dir, 'the-framework.yml'), 'preset: 3\n') + const warnings: string[] = [] + assert.deepEqual(await loadFrameworkConfig(dir, m => warnings.push(m)), {}) + assert.ok(warnings.some(w => /ignoring the-framework\.yml/.test(w))) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) diff --git a/packages/framework/src/config.ts b/packages/framework/src/config.ts new file mode 100644 index 0000000..f6de4a3 --- /dev/null +++ b/packages/framework/src/config.ts @@ -0,0 +1,73 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { parse as parseYaml } from 'yaml' + +/** + * The per-repo run defaults persisted in `the-framework.yml` (#204): which Open + * Loop domain preset and modes a project builds under, so its config travels with + * the code instead of being retyped as flags each run. + */ +export interface FrameworkFileConfig { + /** Domain preset to run under, by name (e.g. `software-development`). */ + preset?: string + /** Activate the preset's Autopilot mode variants. */ + autopilot?: boolean + /** Activate the preset's Technical mode variants. */ + technical?: boolean +} + +/** Config file names read from the workspace root, in precedence order. */ +export const FRAMEWORK_CONFIG_FILES = ['the-framework.yml', 'the-framework.yaml'] as const + +/** + * Read `the-framework.yml` (or `.yaml`) from a directory. A missing file yields + * `{}`. Best-effort: a malformed file is reported via `onWarn` and treated as + * empty, never a failed run. CLI flags override whatever this returns. + */ +export async function loadFrameworkConfig( + dir: string, + onWarn?: (message: string) => void, +): Promise { + for (const name of FRAMEWORK_CONFIG_FILES) { + let raw: string + try { + raw = await readFile(join(dir, name), 'utf8') + } catch { + continue // not this name; try the next + } + try { + return parseFrameworkConfig(raw, name) + } catch (err) { + // parseFrameworkConfig already prefixes the file name in its message. + onWarn?.(`ignoring ${err instanceof Error ? err.message : String(err)}`) + return {} + } + } + return {} +} + +/** + * Parse and validate a `the-framework.yml` body into a {@link FrameworkFileConfig}. + * An empty document is `{}`. Throws on a non-map document or a mistyped field so + * {@link loadFrameworkConfig} can surface it as a warning. + */ +export function parseFrameworkConfig(raw: string, source = 'the-framework.yml'): FrameworkFileConfig { + const data = parseYaml(raw) as unknown + if (data == null) return {} + if (typeof data !== 'object' || Array.isArray(data)) { + throw new Error(`${source} must be a YAML map of settings`) + } + const obj = data as Record + const config: FrameworkFileConfig = {} + if (obj['preset'] !== undefined) { + if (typeof obj['preset'] !== 'string') throw new Error(`${source}: "preset" must be a string`) + config.preset = obj['preset'] + } + for (const key of ['autopilot', 'technical'] as const) { + if (obj[key] !== undefined) { + if (typeof obj[key] !== 'boolean') throw new Error(`${source}: "${key}" must be a boolean`) + config[key] = obj[key] as boolean + } + } + return config +} diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index ebdc250..e9294c5 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -86,6 +86,12 @@ export { type OpenStoreOptions, } from './store/index.js' export { runCli, parseArgs, buildDeployTarget, type CliIO, type CliOptions } from './cli.js' +export { + loadFrameworkConfig, + parseFrameworkConfig, + FRAMEWORK_CONFIG_FILES, + type FrameworkFileConfig, +} from './config.js' export { preflight, type PreflightResult, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d61fdb3..30424ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,6 +277,9 @@ importers: '@gemstack/ai-autopilot': specifier: workspace:^ version: link:../ai-autopilot + yaml: + specifier: ^2.5.0 + version: 2.9.0 devDependencies: '@types/node': specifier: ^20.0.0