Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/open-loop-framework-yml.md
Original file line number Diff line number Diff line change
@@ -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`.
3 changes: 2 additions & 1 deletion packages/framework/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 21 additions & 1 deletion packages/framework/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
chooseSessionLink,
claudeDriverOptions,
CLAUDE_CODE_SESSION_LIST,
mergeRunConfig,
parseArgs,
resolveDomainPreset,
runCli,
Expand Down Expand Up @@ -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, {})
Expand Down Expand Up @@ -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)', () => {
Expand Down
47 changes: 43 additions & 4 deletions packages/framework/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -312,6 +315,32 @@ export function activeModes(opts: Pick<CliOptions, 'autopilot' | 'technical'>):
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<CliOptions, 'preset' | 'autopilot' | 'technical'>,
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<CliOptions, 'preset'>, 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 <name>` to a shipped {@link DomainPreset}, loaded with the
* active `modes` so its conditions variants are selected (#254, #256). Returns
Expand Down Expand Up @@ -371,18 +400,29 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
return 2
}

const cwd = opts.cwd ?? (fake ? join(tmpdir(), 'framework-fake-workspace') : process.cwd())

// The project can carry its own Open Loop defaults in the-framework.yml (#258):
// which domain preset + modes to build under. CLI flags override the file; a bad
// file is a warning, never a failed run. Read from the run's own workspace, so a
// --fake demo (empty tmp cwd) stays deterministic unless pointed at a config dir.
const fileConfig = await loadFrameworkConfig(cwd, msg => 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.
Expand All @@ -397,7 +437,6 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num

const claudeOpts = claudeDriverOptions(opts)
const driver: Driver = fake ? fakeDriver() : new ClaudeCodeDriver(claudeOpts)
const cwd = opts.cwd ?? (fake ? join(tmpdir(), 'framework-fake-workspace') : process.cwd())
// The fake demo defaults to a Cloudflare deploy decision so the flow ends with
// a deploy phase; a live run only narrates deploy when asked.
const deploy: DeployDecision | undefined = opts.deploy
Expand Down
56 changes: 56 additions & 0 deletions packages/framework/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { loadFrameworkConfig, parseFrameworkConfig } from './config.js'

test('parseFrameworkConfig reads preset + mode booleans', () => {
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 })
}
})
73 changes: 73 additions & 0 deletions packages/framework/src/config.ts
Original file line number Diff line number Diff line change
@@ -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<FrameworkFileConfig> {
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<string, unknown>
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
}
6 changes: 6 additions & 0 deletions packages/framework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading