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
40 changes: 40 additions & 0 deletions apps/cli/src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -187,6 +189,12 @@ function printDocs(docs: SettingDoc[]): void {
}
}

const TTL_LABELS: Record<TtlOverrideKey, string> = {
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');

Expand All @@ -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 <path>', '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 <key>')
.description('Get one setting by dotted path (e.g. embedding.provider)')
Expand Down
48 changes: 47 additions & 1 deletion apps/cli/test/config.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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',
]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-15
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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 <repo> --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.
Original file line number Diff line number Diff line change
@@ -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/<your-name>/<branch-slug> --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).
11 changes: 11 additions & 0 deletions packages/config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
131 changes: 131 additions & 0 deletions packages/config/src/ttl-override.ts
Original file line number Diff line number Diff line change
@@ -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<Record<TtlOverrideKey, number>>;

export interface TtlOverrideSource {
repoRoot: string | null;
path: string | null;
present: boolean;
values: TtlOverrideValues;
}

export interface EffectiveTtlConfig {
values: Record<TtlOverrideKey, number>;
source: TtlOverrideSource;
overriddenKeys: TtlOverrideKey[];
}

const KEY_ALIASES: Record<string, TtlOverrideKey> = {
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<TtlOverrideKey, number> = {
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<TtlOverrideKey, number> = {
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);
}
}
Loading
Loading