From 5a4878ddd2804a800d5497b3250a7647cf966fe2 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 15 May 2026 20:46:08 +0200 Subject: [PATCH] Add per-repo TTL override display --- apps/cli/src/commands/config.ts | 40 ++++++ apps/cli/test/config.test.ts | 48 ++++++- .../.openspec.yaml | 2 + .../proposal.md | 23 +++ .../specs/per-repo-ttl-override-file/spec.md | 20 +++ .../tasks.md | 38 +++++ packages/config/src/index.ts | 11 ++ packages/config/src/ttl-override.ts | 131 ++++++++++++++++++ packages/config/test/ttl-override.test.ts | 73 ++++++++++ 9 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 openspec/changes/agent-codex-per-repo-ttl-override-file-2026-05-15-20-39/.openspec.yaml create mode 100644 openspec/changes/agent-codex-per-repo-ttl-override-file-2026-05-15-20-39/proposal.md create mode 100644 openspec/changes/agent-codex-per-repo-ttl-override-file-2026-05-15-20-39/specs/per-repo-ttl-override-file/spec.md create mode 100644 openspec/changes/agent-codex-per-repo-ttl-override-file-2026-05-15-20-39/tasks.md create mode 100644 packages/config/src/ttl-override.ts create mode 100644 packages/config/test/ttl-override.test.ts diff --git a/apps/cli/src/commands/config.ts b/apps/cli/src/commands/config.ts index 35f3571..99648f1 100644 --- a/apps/cli/src/commands/config.ts +++ b/apps/cli/src/commands/config.ts @@ -4,10 +4,12 @@ import { type SettingDoc, SettingsSchema, defaultSettings, + effectiveTtlConfig, loadSettings, saveSettings, settingsDocs, settingsPath, + type TtlOverrideKey, } from '@colony/config'; import type { Command } from 'commander'; import kleur from 'kleur'; @@ -187,6 +189,12 @@ function printDocs(docs: SettingDoc[]): void { } } +const TTL_LABELS: Record = { + fileHeatHalfLifeMinutes: 'File heat half-life', + claimStaleMinutes: 'Claim stale TTL', + coordinationSweepIntervalMinutes: 'Coordination sweep interval', +}; + export function registerConfigCommand(program: Command): void { const cfg = program.command('config').description('View or edit colony settings'); @@ -205,6 +213,38 @@ export function registerConfigCommand(program: Command): void { process.stdout.write(`${settingsPath()}\n`); }); + cfg + .command('ttl') + .description('Show effective TTL settings, including per-repo .colony/ttl.yaml overrides') + .option('--cwd ', 'Directory used to discover the repo override file') + .option('--json', 'Emit structured JSON') + .action((opts: { cwd?: string; json?: boolean }) => { + const effective = effectiveTtlConfig(loadSettings(), opts.cwd ?? process.cwd()); + if (opts.json === true) { + process.stdout.write(`${JSON.stringify(effective, null, 2)}\n`); + return; + } + + process.stdout.write(`${kleur.bold('TTL config')}\n`); + const sourceLabel = + effective.source.path === null + ? 'no git repo found' + : effective.source.present + ? effective.source.path + : `${effective.source.path} (not found)`; + process.stdout.write(`${kleur.dim('override file:')} ${sourceLabel}\n\n`); + for (const [key, value] of Object.entries(effective.values) as Array< + [TtlOverrideKey, number] + >) { + const source = effective.overriddenKeys.includes(key) ? 'override' : 'settings'; + process.stdout.write( + `${kleur.cyan(key.padEnd(36))} ${String(value).padStart(6)} min ${kleur.dim( + source, + )} ${TTL_LABELS[key]}\n`, + ); + } + }); + cfg .command('get ') .description('Get one setting by dotted path (e.g. embedding.provider)') diff --git a/apps/cli/test/config.test.ts b/apps/cli/test/config.test.ts index fad607d..be0d418 100644 --- a/apps/cli/test/config.test.ts +++ b/apps/cli/test/config.test.ts @@ -1,6 +1,10 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { SettingsSchema } from '@colony/config'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { coerceForPath, leafSchema } from '../src/commands/config.js'; +import { createProgram } from '../src/index.js'; describe('coerceForPath (schema-directed)', () => { it('parses numeric settings as numbers even when the string looks like a version', () => { @@ -55,3 +59,45 @@ describe('leafSchema', () => { expect(leafSchema(SettingsSchema, 'bogus.path')).toBeUndefined(); }); }); + +describe('config ttl command', () => { + it('prints effective TTL config with per-repo .colony/ttl.yaml overrides', async () => { + const dir = mkdtempSync(join(tmpdir(), 'colony-cli-ttl-')); + const repo = join(dir, 'repo'); + mkdirSync(join(repo, '.git'), { recursive: true }); + mkdirSync(join(repo, '.colony'), { recursive: true }); + writeFileSync( + join(repo, '.colony', 'ttl.yaml'), + ['claimStaleMinutes: 77', 'coordinationSweepIntervalMinutes: 0'].join('\n'), + ); + let output = ''; + const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { + output += String(chunk); + return true; + }); + + try { + await createProgram().parseAsync( + ['node', 'test', 'config', 'ttl', '--cwd', repo, '--json'], + { from: 'node' }, + ); + } finally { + write.mockRestore(); + rmSync(dir, { recursive: true, force: true }); + } + + const payload = JSON.parse(output) as { + values: { claimStaleMinutes: number; coordinationSweepIntervalMinutes: number }; + source: { present: boolean; path: string }; + overriddenKeys: string[]; + }; + expect(payload.source.present).toBe(true); + expect(payload.source.path).toBe(join(repo, '.colony', 'ttl.yaml')); + expect(payload.values.claimStaleMinutes).toBe(77); + expect(payload.values.coordinationSweepIntervalMinutes).toBe(0); + expect(payload.overriddenKeys).toEqual([ + 'claimStaleMinutes', + 'coordinationSweepIntervalMinutes', + ]); + }); +}); diff --git a/openspec/changes/agent-codex-per-repo-ttl-override-file-2026-05-15-20-39/.openspec.yaml b/openspec/changes/agent-codex-per-repo-ttl-override-file-2026-05-15-20-39/.openspec.yaml new file mode 100644 index 0000000..9f70866 --- /dev/null +++ b/openspec/changes/agent-codex-per-repo-ttl-override-file-2026-05-15-20-39/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-15 diff --git a/openspec/changes/agent-codex-per-repo-ttl-override-file-2026-05-15-20-39/proposal.md b/openspec/changes/agent-codex-per-repo-ttl-override-file-2026-05-15-20-39/proposal.md new file mode 100644 index 0000000..f2018f2 --- /dev/null +++ b/openspec/changes/agent-codex-per-repo-ttl-override-file-2026-05-15-20-39/proposal.md @@ -0,0 +1,23 @@ +## Why + +Repo teams need short, reviewable coordination TTL overrides without changing +global `settings.json` or expanding the existing settings schema. A checked-in +`.colony/ttl.yaml` lets a repo document claim stale windows and sweep cadence +next to project policy, while the CLI can show the effective values before a +human or daemon relies on them. + +## What Changes + +- Add `packages/config/src/ttl-override.ts` to discover and parse + `.colony/ttl.yaml` from the nearest git repo root. +- Merge supported TTL override keys over existing settings for display only. +- Add `colony config ttl` with `--cwd` and `--json` to print effective TTL + values and their override source. + +## Impact + +- No `SettingsSchema` changes. +- Supported override keys are `fileHeatHalfLifeMinutes`, `claimStaleMinutes`, + and `coordinationSweepIntervalMinutes` with snake-case and kebab-case aliases. +- This change exposes effective TTL config; consumers can adopt the loader in a + later behavior change. diff --git a/openspec/changes/agent-codex-per-repo-ttl-override-file-2026-05-15-20-39/specs/per-repo-ttl-override-file/spec.md b/openspec/changes/agent-codex-per-repo-ttl-override-file-2026-05-15-20-39/specs/per-repo-ttl-override-file/spec.md new file mode 100644 index 0000000..89a5b9d --- /dev/null +++ b/openspec/changes/agent-codex-per-repo-ttl-override-file-2026-05-15-20-39/specs/per-repo-ttl-override-file/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Per-repo TTL override discovery and display +The system SHALL discover a checked-in `.colony/ttl.yaml` file from the nearest +git repo root and expose effective TTL values without modifying the global +settings schema. + +#### Scenario: Effective TTL config merges repo overrides over settings +- **WHEN** `.colony/ttl.yaml` contains supported TTL keys +- **THEN** the loader returns the repo override path and parsed values +- **AND** effective TTL config uses repo values for overridden keys and settings values for all others. + +#### Scenario: CLI displays effective TTL config +- **WHEN** `colony config ttl --cwd --json` runs for a repo with `.colony/ttl.yaml` +- **THEN** the JSON payload includes effective TTL values +- **AND** the payload identifies the override file and overridden keys. + +#### Scenario: Settings schema remains unchanged +- **WHEN** per-repo TTL overrides are added +- **THEN** `SettingsSchema` is not extended with TTL override-file fields. diff --git a/openspec/changes/agent-codex-per-repo-ttl-override-file-2026-05-15-20-39/tasks.md b/openspec/changes/agent-codex-per-repo-ttl-override-file-2026-05-15-20-39/tasks.md new file mode 100644 index 0000000..2e745d1 --- /dev/null +++ b/openspec/changes/agent-codex-per-repo-ttl-override-file-2026-05-15-20-39/tasks.md @@ -0,0 +1,38 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +## Handoff + +- Handoff: change=`agent-codex-per-repo-ttl-override-file-2026-05-15-20-39`; branch=`agent/codex/per-repo-ttl-override-file-2026-05-15-20-39`; scope=`packages/config/src/ttl-override.ts, apps/cli/src/commands/config.ts`; action=`finish PR handoff after verification`. +- Copy prompt: Continue `agent-codex-per-repo-ttl-override-file-2026-05-15-20-39` on branch `agent/codex/per-repo-ttl-override-file-2026-05-15-20-39`. Work inside the existing sandbox, review `openspec/changes/agent-codex-per-repo-ttl-override-file-2026-05-15-20-39/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/per-repo-ttl-override-file-2026-05-15-20-39 --base main --via-pr --cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-per-repo-ttl-override-file-2026-05-15-20-39`. +- [x] 1.2 Define normative requirements in `specs/per-repo-ttl-override-file/spec.md`. + +## 2. Implementation + +- [x] 2.1 Implement scoped behavior changes. +- [x] 2.2 Add/update focused regression coverage. + +## 3. Verification + +- [x] 3.1 Run targeted project verification commands. + - `pnpm --filter @colony/config test -- ttl-override.test.ts` + - `pnpm --filter colonyq test -- config.test.ts` + - `pnpm --filter @colony/config typecheck` + - `pnpm --filter colonyq typecheck` +- [x] 3.2 Run `openspec validate agent-codex-per-repo-ttl-override-file-2026-05-15-20-39 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch). diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 90e4d55..4374f6d 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -15,5 +15,16 @@ export { resolveDataDir, settingsPath, } from './loader.js'; +export { + TTL_OVERRIDE_RELATIVE_PATH, + effectiveTtlConfig, + loadTtlOverride, + parseTtlOverride, + ttlOverridePathForCwd, + type EffectiveTtlConfig, + type TtlOverrideKey, + type TtlOverrideSource, + type TtlOverrideValues, +} from './ttl-override.js'; export { settingsDocs, type SettingDoc } from './docs.js'; export { quotaSafeOperatingContract, quotaSafeOperatingContractCompact } from './instructions.js'; diff --git a/packages/config/src/ttl-override.ts b/packages/config/src/ttl-override.ts new file mode 100644 index 0000000..0eda342 --- /dev/null +++ b/packages/config/src/ttl-override.ts @@ -0,0 +1,131 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, parse, resolve } from 'node:path'; +import type { Settings } from './schema.js'; + +export const TTL_OVERRIDE_RELATIVE_PATH = join('.colony', 'ttl.yaml'); + +const TTL_KEYS = [ + 'fileHeatHalfLifeMinutes', + 'claimStaleMinutes', + 'coordinationSweepIntervalMinutes', +] as const; + +export type TtlOverrideKey = (typeof TTL_KEYS)[number]; +export type TtlOverrideValues = Partial>; + +export interface TtlOverrideSource { + repoRoot: string | null; + path: string | null; + present: boolean; + values: TtlOverrideValues; +} + +export interface EffectiveTtlConfig { + values: Record; + source: TtlOverrideSource; + overriddenKeys: TtlOverrideKey[]; +} + +const KEY_ALIASES: Record = { + fileHeatHalfLifeMinutes: 'fileHeatHalfLifeMinutes', + file_heat_half_life_minutes: 'fileHeatHalfLifeMinutes', + 'file-heat-half-life-minutes': 'fileHeatHalfLifeMinutes', + claimStaleMinutes: 'claimStaleMinutes', + claim_stale_minutes: 'claimStaleMinutes', + 'claim-stale-minutes': 'claimStaleMinutes', + coordinationSweepIntervalMinutes: 'coordinationSweepIntervalMinutes', + coordination_sweep_interval_minutes: 'coordinationSweepIntervalMinutes', + 'coordination-sweep-interval-minutes': 'coordinationSweepIntervalMinutes', +}; + +const MIN_BY_KEY: Record = { + fileHeatHalfLifeMinutes: 1, + claimStaleMinutes: 1, + coordinationSweepIntervalMinutes: 0, +}; + +export function ttlOverridePathForCwd( + cwd = process.cwd(), +): { repoRoot: string; path: string } | null { + const repoRoot = findRepoRoot(cwd); + if (repoRoot === null) return null; + return { repoRoot, path: join(repoRoot, TTL_OVERRIDE_RELATIVE_PATH) }; +} + +export function loadTtlOverride(cwd = process.cwd()): TtlOverrideSource { + const resolved = ttlOverridePathForCwd(cwd); + if (resolved === null) { + return { repoRoot: null, path: null, present: false, values: {} }; + } + if (!existsSync(resolved.path)) { + return { repoRoot: resolved.repoRoot, path: resolved.path, present: false, values: {} }; + } + return { + repoRoot: resolved.repoRoot, + path: resolved.path, + present: true, + values: parseTtlOverride(readFileSync(resolved.path, 'utf8'), resolved.path), + }; +} + +export function effectiveTtlConfig(settings: Settings, cwd = process.cwd()): EffectiveTtlConfig { + const source = loadTtlOverride(cwd); + const values: Record = { + fileHeatHalfLifeMinutes: settings.fileHeatHalfLifeMinutes, + claimStaleMinutes: settings.claimStaleMinutes, + coordinationSweepIntervalMinutes: settings.coordinationSweepIntervalMinutes, + ...source.values, + }; + return { + values, + source, + overriddenKeys: TTL_KEYS.filter((key) => source.values[key] !== undefined), + }; +} + +export function parseTtlOverride(raw: string, path = TTL_OVERRIDE_RELATIVE_PATH): TtlOverrideValues { + const values: TtlOverrideValues = {}; + const errors: string[] = []; + + raw.split(/\r?\n/).forEach((line, index) => { + const trimmed = stripComment(line).trim(); + if (trimmed === '') return; + const match = trimmed.match(/^([A-Za-z0-9_-]+)\s*:\s*(.+)$/); + if (!match) { + errors.push(`line ${index + 1}: expected "key: minutes"`); + return; + } + const rawKey = match[1] ?? ''; + const key = KEY_ALIASES[rawKey]; + if (key === undefined) { + errors.push(`line ${index + 1}: unknown TTL key "${rawKey}"`); + return; + } + const parsed = Number(match[2]); + if (!Number.isInteger(parsed) || parsed < MIN_BY_KEY[key]) { + errors.push(`line ${index + 1}: ${rawKey} must be an integer >= ${MIN_BY_KEY[key]}`); + return; + } + values[key] = parsed; + }); + + if (errors.length > 0) { + throw new Error(`Invalid TTL override at ${path}: ${errors.join('; ')}`); + } + return values; +} + +function stripComment(line: string): string { + const index = line.indexOf('#'); + return index === -1 ? line : line.slice(0, index); +} + +function findRepoRoot(start: string): string | null { + let current = resolve(start); + const root = parse(current).root; + while (true) { + if (existsSync(join(current, '.git'))) return current; + if (current === root) return null; + current = dirname(current); + } +} diff --git a/packages/config/test/ttl-override.test.ts b/packages/config/test/ttl-override.test.ts new file mode 100644 index 0000000..de2dee3 --- /dev/null +++ b/packages/config/test/ttl-override.test.ts @@ -0,0 +1,73 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { defaultSettings } from '../src/defaults.js'; +import { effectiveTtlConfig, loadTtlOverride, parseTtlOverride } from '../src/ttl-override.js'; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'colony-ttl-')); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('ttl override loader', () => { + it('parses supported YAML aliases into canonical TTL keys', () => { + expect( + parseTtlOverride(` + file-heat-half-life-minutes: 10 + claim_stale_minutes: 90 + coordinationSweepIntervalMinutes: 0 + `), + ).toEqual({ + fileHeatHalfLifeMinutes: 10, + claimStaleMinutes: 90, + coordinationSweepIntervalMinutes: 0, + }); + }); + + it('rejects unknown keys and invalid minute values', () => { + expect(() => + parseTtlOverride(` + claimStaleMinutes: 0 + mystery: 12 + `), + ).toThrow(/claimStaleMinutes must be an integer >= 1/); + }); + + it('loads .colony/ttl.yaml from the repo root and merges it over settings', () => { + const repo = join(dir, 'repo'); + mkdirSync(join(repo, '.git'), { recursive: true }); + mkdirSync(join(repo, '.colony'), { recursive: true }); + writeFileSync( + join(repo, '.colony', 'ttl.yaml'), + ['claimStaleMinutes: 45', 'coordination-sweep-interval-minutes: 5'].join('\n'), + ); + + const source = loadTtlOverride(join(repo, 'nested')); + expect(source).toMatchObject({ + repoRoot: repo, + path: join(repo, '.colony', 'ttl.yaml'), + present: true, + values: { + claimStaleMinutes: 45, + coordinationSweepIntervalMinutes: 5, + }, + }); + + const effective = effectiveTtlConfig(defaultSettings, join(repo, 'nested')); + expect(effective.values).toEqual({ + fileHeatHalfLifeMinutes: defaultSettings.fileHeatHalfLifeMinutes, + claimStaleMinutes: 45, + coordinationSweepIntervalMinutes: 5, + }); + expect(effective.overriddenKeys).toEqual([ + 'claimStaleMinutes', + 'coordinationSweepIntervalMinutes', + ]); + }); +});