takt: github-issue-382-feat-purojeku (#384)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
nrs 2026-03-03 00:10:02 +09:00 committed by GitHub
parent 4a92ba2012
commit f838a0e656
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 669 additions and 7 deletions

View File

@ -0,0 +1,121 @@
/**
* Global config tests.
*
* Tests global config loading and saving with piece_overrides,
* including empty array round-trip behavior.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { PersistedGlobalConfig } from '../core/models/persisted-global-config.js';
// Mock the getGlobalConfigPath to use a test directory
let testConfigPath: string;
vi.mock('../infra/config/paths.js', () => ({
getGlobalConfigPath: () => testConfigPath,
getGlobalTaktDir: () => join(testConfigPath, '..'),
getProjectTaktDir: vi.fn(),
getProjectCwd: vi.fn(),
}));
import { GlobalConfigManager } from '../infra/config/global/globalConfig.js';
describe('globalConfig', () => {
let testDir: string;
beforeEach(() => {
testDir = mkdtempSync(join(tmpdir(), 'takt-test-global-config-'));
mkdirSync(testDir, { recursive: true });
testConfigPath = join(testDir, 'config.yaml');
GlobalConfigManager.resetInstance();
});
afterEach(() => {
GlobalConfigManager.resetInstance();
if (testDir) {
rmSync(testDir, { recursive: true, force: true });
}
});
describe('piece_overrides empty array round-trip', () => {
it('should preserve empty quality_gates array in save/load cycle', () => {
// Write config with empty quality_gates array
const configContent = `
piece_overrides:
quality_gates: []
`;
writeFileSync(testConfigPath, configContent, 'utf-8');
// Load config
const manager = GlobalConfigManager.getInstance();
const loaded = manager.load();
expect(loaded.pieceOverrides?.qualityGates).toEqual([]);
// Save config
manager.save(loaded);
// Reset and reload to verify empty array is preserved
GlobalConfigManager.resetInstance();
const reloadedManager = GlobalConfigManager.getInstance();
const reloaded = reloadedManager.load();
expect(reloaded.pieceOverrides?.qualityGates).toEqual([]);
});
it('should preserve empty quality_gates in movements', () => {
const configContent = `
piece_overrides:
movements:
implement:
quality_gates: []
`;
writeFileSync(testConfigPath, configContent, 'utf-8');
const manager = GlobalConfigManager.getInstance();
const loaded = manager.load();
expect(loaded.pieceOverrides?.movements?.implement?.qualityGates).toEqual([]);
manager.save(loaded);
GlobalConfigManager.resetInstance();
const reloadedManager = GlobalConfigManager.getInstance();
const reloaded = reloadedManager.load();
expect(reloaded.pieceOverrides?.movements?.implement?.qualityGates).toEqual([]);
});
it('should distinguish undefined from empty array', () => {
// Test with undefined (not specified)
writeFileSync(testConfigPath, 'piece_overrides: {}\n', 'utf-8');
const manager1 = GlobalConfigManager.getInstance();
const loaded1 = manager1.load();
expect(loaded1.pieceOverrides?.qualityGates).toBeUndefined();
// Test with empty array (explicitly disabled)
GlobalConfigManager.resetInstance();
writeFileSync(testConfigPath, 'piece_overrides:\n quality_gates: []\n', 'utf-8');
const manager2 = GlobalConfigManager.getInstance();
const loaded2 = manager2.load();
expect(loaded2.pieceOverrides?.qualityGates).toEqual([]);
});
it('should preserve non-empty quality_gates array', () => {
const config: PersistedGlobalConfig = {
pieceOverrides: {
qualityGates: ['Test 1', 'Test 2'],
},
};
const manager = GlobalConfigManager.getInstance();
manager.save(config);
GlobalConfigManager.resetInstance();
const reloadedManager = GlobalConfigManager.getInstance();
const reloaded = reloadedManager.load();
expect(reloaded.pieceOverrides?.qualityGates).toEqual(['Test 1', 'Test 2']);
});
});
});

View File

@ -0,0 +1,99 @@
/**
* Project config tests.
*
* Tests project config loading and saving with piece_overrides,
* including empty array round-trip behavior.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { loadProjectConfig, saveProjectConfig } from '../infra/config/project/projectConfig.js';
import type { ProjectLocalConfig } from '../infra/config/types.js';
describe('projectConfig', () => {
let testDir: string;
beforeEach(() => {
testDir = mkdtempSync(join(tmpdir(), 'takt-test-project-config-'));
mkdirSync(join(testDir, '.takt'), { recursive: true });
});
afterEach(() => {
if (testDir) {
rmSync(testDir, { recursive: true, force: true });
}
});
describe('piece_overrides empty array round-trip', () => {
it('should preserve empty quality_gates array in save/load cycle', () => {
// Write config with empty quality_gates array
const configPath = join(testDir, '.takt', 'config.yaml');
const configContent = `
piece_overrides:
quality_gates: []
`;
writeFileSync(configPath, configContent, 'utf-8');
// Load config
const loaded = loadProjectConfig(testDir);
expect(loaded.pieceOverrides?.qualityGates).toEqual([]);
// Save config
saveProjectConfig(testDir, loaded);
// Reload and verify empty array is preserved
const reloaded = loadProjectConfig(testDir);
expect(reloaded.pieceOverrides?.qualityGates).toEqual([]);
});
it('should preserve empty quality_gates in movements', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
const configContent = `
piece_overrides:
movements:
implement:
quality_gates: []
`;
writeFileSync(configPath, configContent, 'utf-8');
const loaded = loadProjectConfig(testDir);
expect(loaded.pieceOverrides?.movements?.implement?.qualityGates).toEqual([]);
saveProjectConfig(testDir, loaded);
const reloaded = loadProjectConfig(testDir);
expect(reloaded.pieceOverrides?.movements?.implement?.qualityGates).toEqual([]);
});
it('should distinguish undefined from empty array', () => {
// Test with undefined (not specified)
const configPath1 = join(testDir, '.takt', 'config.yaml');
writeFileSync(configPath1, 'piece_overrides: {}\n', 'utf-8');
const loaded1 = loadProjectConfig(testDir);
expect(loaded1.pieceOverrides?.qualityGates).toBeUndefined();
// Test with empty array (explicitly disabled)
const configPath2 = join(testDir, '.takt', 'config.yaml');
writeFileSync(configPath2, 'piece_overrides:\n quality_gates: []\n', 'utf-8');
const loaded2 = loadProjectConfig(testDir);
expect(loaded2.pieceOverrides?.qualityGates).toEqual([]);
});
it('should preserve non-empty quality_gates array', () => {
const config: ProjectLocalConfig = {
pieceOverrides: {
qualityGates: ['Test 1', 'Test 2'],
},
};
saveProjectConfig(testDir, config);
const reloaded = loadProjectConfig(testDir);
expect(reloaded.pieceOverrides?.qualityGates).toEqual(['Test 1', 'Test 2']);
});
});
});

View File

@ -0,0 +1,194 @@
/**
* Tests for quality gate override logic
*/
import { describe, it, expect } from 'vitest';
import { applyQualityGateOverrides } from '../infra/config/loaders/qualityGateOverrides.js';
import type { PieceOverrides } from '../core/models/persisted-global-config.js';
describe('applyQualityGateOverrides', () => {
it('returns undefined when no gates are defined', () => {
const result = applyQualityGateOverrides('implement', undefined, true, undefined, undefined);
expect(result).toBeUndefined();
});
it('returns YAML gates when no overrides are defined', () => {
const yamlGates = ['Test passes'];
const result = applyQualityGateOverrides('implement', yamlGates, true, undefined, undefined);
expect(result).toEqual(['Test passes']);
});
it('returns empty array when yamlGates is empty array and no overrides', () => {
const yamlGates: string[] = [];
const result = applyQualityGateOverrides('implement', yamlGates, true, undefined, undefined);
expect(result).toEqual([]);
});
it('merges global override gates with YAML gates (additive)', () => {
const yamlGates = ['Unit tests pass'];
const globalOverrides: PieceOverrides = {
qualityGates: ['E2E tests pass'],
};
const result = applyQualityGateOverrides('implement', yamlGates, true, undefined, globalOverrides);
expect(result).toEqual(['E2E tests pass', 'Unit tests pass']);
});
it('applies movement-specific override from global config', () => {
const yamlGates = ['Unit tests pass'];
const globalOverrides: PieceOverrides = {
qualityGates: ['Global gate'],
movements: {
implement: {
qualityGates: ['Movement-specific gate'],
},
},
};
const result = applyQualityGateOverrides('implement', yamlGates, true, undefined, globalOverrides);
expect(result).toEqual(['Global gate', 'Movement-specific gate', 'Unit tests pass']);
});
it('applies project overrides with higher priority than global', () => {
const yamlGates = ['YAML gate'];
const globalOverrides: PieceOverrides = {
qualityGates: ['Global gate'],
};
const projectOverrides: PieceOverrides = {
qualityGates: ['Project gate'],
};
const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, globalOverrides);
expect(result).toEqual(['Global gate', 'Project gate', 'YAML gate']);
});
it('applies movement-specific override from project config', () => {
const yamlGates = ['YAML gate'];
const projectOverrides: PieceOverrides = {
movements: {
implement: {
qualityGates: ['Project movement gate'],
},
},
};
const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, undefined);
expect(result).toEqual(['Project movement gate', 'YAML gate']);
});
it('filters global gates when qualityGatesEditOnly=true and edit=false', () => {
const yamlGates = ['YAML gate'];
const globalOverrides: PieceOverrides = {
qualityGates: ['Global gate'],
qualityGatesEditOnly: true,
};
const result = applyQualityGateOverrides('review', yamlGates, false, undefined, globalOverrides);
expect(result).toEqual(['YAML gate']); // Global gate excluded because edit=false
});
it('includes global gates when qualityGatesEditOnly=true and edit=true', () => {
const yamlGates = ['YAML gate'];
const globalOverrides: PieceOverrides = {
qualityGates: ['Global gate'],
qualityGatesEditOnly: true,
};
const result = applyQualityGateOverrides('implement', yamlGates, true, undefined, globalOverrides);
expect(result).toEqual(['Global gate', 'YAML gate']);
});
it('filters project global gates when qualityGatesEditOnly=true and edit=false', () => {
const yamlGates = ['YAML gate'];
const projectOverrides: PieceOverrides = {
qualityGates: ['Project gate'],
qualityGatesEditOnly: true,
};
const result = applyQualityGateOverrides('review', yamlGates, false, projectOverrides, undefined);
expect(result).toEqual(['YAML gate']); // Project gate excluded because edit=false
});
it('applies movement-specific gates regardless of qualityGatesEditOnly flag', () => {
const yamlGates = ['YAML gate'];
const projectOverrides: PieceOverrides = {
qualityGates: ['Project global gate'],
qualityGatesEditOnly: true,
movements: {
review: {
qualityGates: ['Review-specific gate'],
},
},
};
const result = applyQualityGateOverrides('review', yamlGates, false, projectOverrides, undefined);
// Project global gate excluded (edit=false), but movement-specific gate included
expect(result).toEqual(['Review-specific gate', 'YAML gate']);
});
it('handles complex priority scenario with all override types', () => {
const yamlGates = ['YAML gate'];
const globalOverrides: PieceOverrides = {
qualityGates: ['Global gate'],
movements: {
implement: {
qualityGates: ['Global movement gate'],
},
},
};
const projectOverrides: PieceOverrides = {
qualityGates: ['Project gate'],
movements: {
implement: {
qualityGates: ['Project movement gate'],
},
},
};
const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, globalOverrides);
expect(result).toEqual([
'Global gate',
'Global movement gate',
'Project gate',
'Project movement gate',
'YAML gate',
]);
});
it('returns YAML gates only when other movements are specified in overrides', () => {
const yamlGates = ['YAML gate'];
const projectOverrides: PieceOverrides = {
movements: {
review: {
qualityGates: ['Review gate'],
},
},
};
const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, undefined);
expect(result).toEqual(['YAML gate']); // No override for 'implement', only for 'review'
});
describe('deduplication', () => {
it('removes duplicate gates from multiple sources', () => {
const yamlGates = ['Test 1', 'Test 2'];
const globalOverrides: PieceOverrides = {
qualityGates: ['Test 2', 'Test 3'],
};
const projectOverrides: PieceOverrides = {
qualityGates: ['Test 1', 'Test 4'],
};
const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, globalOverrides);
// Duplicates removed: Test 1, Test 2 appear only once
expect(result).toEqual(['Test 2', 'Test 3', 'Test 1', 'Test 4']);
});
it('removes duplicate gates from single source', () => {
const projectOverrides: PieceOverrides = {
qualityGates: ['Test 1', 'Test 2', 'Test 1', 'Test 3', 'Test 2'],
};
const result = applyQualityGateOverrides('implement', undefined, true, projectOverrides, undefined);
expect(result).toEqual(['Test 1', 'Test 2', 'Test 3']);
});
it('removes duplicate gates from YAML and overrides', () => {
const yamlGates = ['npm run test', 'npm run lint'];
const projectOverrides: PieceOverrides = {
qualityGates: ['npm run test', 'npm run build'],
};
const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, undefined);
// 'npm run test' appears only once
expect(result).toEqual(['npm run test', 'npm run build', 'npm run lint']);
});
});
});

View File

@ -10,6 +10,21 @@ export interface PersonaProviderEntry {
model?: string;
}
/** Movement-specific quality gates override */
export interface MovementQualityGatesOverride {
qualityGates?: string[];
}
/** Piece-level overrides (quality_gates, etc.) */
export interface PieceOverrides {
/** Global quality gates applied to all movements */
qualityGates?: string[];
/** Whether to apply quality_gates only to edit: true movements */
qualityGatesEditOnly?: boolean;
/** Movement-specific quality gates overrides */
movements?: Record<string, MovementQualityGatesOverride>;
}
/** Custom agent configuration */
export interface CustomAgentConfig {
name: string;
@ -138,6 +153,8 @@ export interface PersistedGlobalConfig {
autoFetch: boolean;
/** Base branch to clone from (default: current branch) */
baseBranch?: string;
/** Piece-level overrides (quality_gates, etc.) */
pieceOverrides?: PieceOverrides;
}
/** Project-level configuration */
@ -152,6 +169,8 @@ export interface ProjectConfig {
concurrency?: number;
/** Base branch to clone from (overrides global baseBranch) */
baseBranch?: string;
/** Piece-level overrides (quality_gates, etc.) */
pieceOverrides?: PieceOverrides;
/** Compatibility flag for full submodule acquisition when submodules is unset */
withSubmodules?: boolean;
/** Submodule acquisition mode (all or explicit path list) */

View File

@ -154,6 +154,21 @@ export const OutputContractsFieldSchema = z.object({
/** Quality gates schema - AI directives for movement completion (string array) */
export const QualityGatesSchema = z.array(z.string()).optional();
/** Movement-specific quality gates override schema */
export const MovementQualityGatesOverrideSchema = z.object({
quality_gates: QualityGatesSchema,
}).optional();
/** Piece overrides schema for config-level overrides */
export const PieceOverridesSchema = z.object({
/** Global quality gates applied to all movements */
quality_gates: QualityGatesSchema,
/** Whether to apply quality_gates only to edit: true movements */
quality_gates_edit_only: z.boolean().optional(),
/** Movement-specific quality gates overrides */
movements: z.record(z.string(), MovementQualityGatesOverrideSchema).optional(),
}).optional();
/** Rule-based transition schema (new unified format) */
export const PieceRuleSchema = z.object({
/** Human-readable condition text */
@ -504,6 +519,8 @@ export const GlobalConfigSchema = z.object({
auto_fetch: z.boolean().optional().default(false),
/** Base branch to clone from (default: current branch) */
base_branch: z.string().optional(),
/** Piece-level overrides (quality_gates, etc.) */
piece_overrides: PieceOverridesSchema,
});
/** Project config schema */
@ -517,6 +534,8 @@ export const ProjectConfigSchema = z.object({
concurrency: z.number().int().min(1).max(10).optional(),
/** Base branch to clone from (overrides global base_branch) */
base_branch: z.string().optional(),
/** Piece-level overrides (quality_gates, etc.) */
piece_overrides: PieceOverridesSchema,
/** Submodule acquisition mode (all or explicit path list) */
submodules: z.union([
z.string().refine((value) => value.trim().toLowerCase() === 'all', {

View File

@ -10,7 +10,7 @@ import { isAbsolute } from 'node:path';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { GlobalConfigSchema } from '../../../core/models/index.js';
import type { Language } from '../../../core/models/index.js';
import type { PersistedGlobalConfig, PersonaProviderEntry } from '../../../core/models/persisted-global-config.js';
import type { PersistedGlobalConfig, PersonaProviderEntry, PieceOverrides } from '../../../core/models/persisted-global-config.js';
import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js';
import { normalizeProviderOptions } from '../loaders/pieceParser.js';
import { getGlobalConfigPath } from '../paths.js';
@ -123,6 +123,51 @@ function denormalizeProviderProfiles(
}])) as Record<string, { default_permission_mode: string; movement_permission_overrides?: Record<string, string> }>;
}
/** Normalize piece_overrides from snake_case (YAML) to camelCase (internal) */
function normalizePieceOverrides(
raw: { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | undefined,
): PieceOverrides | undefined {
if (!raw) return undefined;
return {
qualityGates: raw.quality_gates,
qualityGatesEditOnly: raw.quality_gates_edit_only,
movements: raw.movements
? Object.fromEntries(
Object.entries(raw.movements).map(([name, override]) => [
name,
{ qualityGates: override.quality_gates },
])
)
: undefined,
};
}
/** Denormalize piece_overrides from camelCase (internal) to snake_case (YAML) */
function denormalizePieceOverrides(
overrides: PieceOverrides | undefined,
): { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | undefined {
if (!overrides) return undefined;
const result: { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } = {};
if (overrides.qualityGates !== undefined) {
result.quality_gates = overrides.qualityGates;
}
if (overrides.qualityGatesEditOnly !== undefined) {
result.quality_gates_edit_only = overrides.qualityGatesEditOnly;
}
if (overrides.movements) {
result.movements = Object.fromEntries(
Object.entries(overrides.movements).map(([name, override]) => {
const movementOverride: { quality_gates?: string[] } = {};
if (override.qualityGates !== undefined) {
movementOverride.quality_gates = override.qualityGates;
}
return [name, movementOverride];
})
);
}
return Object.keys(result).length > 0 ? result : undefined;
}
/**
* Manages global configuration loading and caching.
* Singleton use GlobalConfigManager.getInstance().
@ -226,6 +271,7 @@ export class GlobalConfigManager {
taskPollIntervalMs: parsed.task_poll_interval_ms,
autoFetch: parsed.auto_fetch,
baseBranch: parsed.base_branch,
pieceOverrides: normalizePieceOverrides(parsed.piece_overrides as { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | undefined),
};
validateProviderModelCompatibility(config.provider, config.model);
this.cachedConfig = config;
@ -371,6 +417,10 @@ export class GlobalConfigManager {
if (config.baseBranch) {
raw.base_branch = config.baseBranch;
}
const denormalizedPieceOverrides = denormalizePieceOverrides(config.pieceOverrides);
if (denormalizedPieceOverrides) {
raw.piece_overrides = denormalizedPieceOverrides;
}
writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
this.invalidateCache();
invalidateAllResolvedConfigCache();

View File

@ -28,6 +28,10 @@ type RawPiece = z.output<typeof PieceConfigRawSchema>;
import type { MovementProviderOptions } from '../../../core/models/piece-types.js';
import type { PieceRuntimeConfig } from '../../../core/models/piece-types.js';
import type { PieceOverrides } from '../../../core/models/persisted-global-config.js';
import { applyQualityGateOverrides } from './qualityGateOverrides.js';
import { loadProjectConfig } from '../project/projectConfig.js';
import { loadGlobalConfig } from '../global/globalConfig.js';
/** Convert raw YAML provider_options (snake_case) to internal format (camelCase). */
export function normalizeProviderOptions(
@ -278,6 +282,8 @@ function normalizeStepFromRaw(
sections: PieceSections,
inheritedProviderOptions?: PieceMovement['providerOptions'],
context?: FacetResolutionContext,
projectOverrides?: PieceOverrides,
globalOverrides?: PieceOverrides,
): PieceMovement {
const rules: PieceRule[] | undefined = step.rules?.map(normalizeRule);
@ -316,7 +322,13 @@ function normalizeStepFromRaw(
: undefined) || expandedInstruction || '{task}',
rules,
outputContracts: normalizeOutputContracts(step.output_contracts, pieceDir, sections.resolvedReportFormats, context),
qualityGates: step.quality_gates,
qualityGates: applyQualityGateOverrides(
step.name,
step.quality_gates,
step.edit,
projectOverrides,
globalOverrides,
),
passPreviousResponse: step.pass_previous_response ?? true,
policyContents,
knowledgeContents,
@ -324,7 +336,7 @@ function normalizeStepFromRaw(
if (step.parallel && step.parallel.length > 0) {
result.parallel = step.parallel.map((sub: RawStep) =>
normalizeStepFromRaw(sub, pieceDir, sections, inheritedProviderOptions, context),
normalizeStepFromRaw(sub, pieceDir, sections, inheritedProviderOptions, context, projectOverrides, globalOverrides),
);
}
@ -382,6 +394,8 @@ export function normalizePieceConfig(
raw: unknown,
pieceDir: string,
context?: FacetResolutionContext,
projectOverrides?: PieceOverrides,
globalOverrides?: PieceOverrides,
): PieceConfig {
const parsed = PieceConfigRawSchema.parse(raw);
@ -402,7 +416,7 @@ export function normalizePieceConfig(
const pieceRuntime = normalizeRuntimeConfig(parsed.piece_config);
const movements: PieceMovement[] = parsed.movements.map((step) =>
normalizeStepFromRaw(step, pieceDir, sections, pieceProviderOptions, context),
normalizeStepFromRaw(step, pieceDir, sections, pieceProviderOptions, context, projectOverrides, globalOverrides),
);
// Schema guarantees movements.min(1)
@ -447,5 +461,11 @@ export function loadPieceFromFile(filePath: string, projectDir: string): PieceCo
repertoireDir: getRepertoireDir(),
};
return normalizePieceConfig(raw, pieceDir, context);
// Load config overrides from project and global configs
const projectConfig = loadProjectConfig(projectDir);
const globalConfig = loadGlobalConfig();
const projectOverrides = projectConfig.pieceOverrides;
const globalOverrides = globalConfig.pieceOverrides;
return normalizePieceConfig(raw, pieceDir, context, projectOverrides, globalOverrides);
}

View File

@ -0,0 +1,84 @@
/**
* Quality gate override application logic
*
* Resolves quality gates from config overrides with 3-layer priority:
* 1. Project .takt/config.yaml piece_overrides
* 2. Global ~/.takt/config.yaml piece_overrides
* 3. Piece YAML quality_gates
*
* Merge strategy: Additive (config gates + YAML gates)
*/
import type { PieceOverrides } from '../../../core/models/persisted-global-config.js';
/**
* Apply quality gate overrides to a movement.
*
* Merge order (gates are added in this sequence):
* 1. Global override in global config (filtered by edit flag if qualityGatesEditOnly=true)
* 2. Movement-specific override in global config
* 3. Global override in project config (filtered by edit flag if qualityGatesEditOnly=true)
* 4. Movement-specific override in project config
* 5. Piece YAML quality_gates
*
* Merge strategy: Additive merge (all gates are combined, no overriding)
*
* @param movementName - Name of the movement
* @param yamlGates - Quality gates from piece YAML
* @param editFlag - Whether the movement has edit: true
* @param projectOverrides - Project-level piece_overrides (from .takt/config.yaml)
* @param globalOverrides - Global-level piece_overrides (from ~/.takt/config.yaml)
* @returns Merged quality gates array
*/
export function applyQualityGateOverrides(
movementName: string,
yamlGates: string[] | undefined,
editFlag: boolean | undefined,
projectOverrides: PieceOverrides | undefined,
globalOverrides: PieceOverrides | undefined,
): string[] | undefined {
// Track whether yamlGates was explicitly defined (even if empty)
const hasYamlGates = yamlGates !== undefined;
const gates: string[] = [];
// Collect global gates from global config
const globalGlobalGates = globalOverrides?.qualityGates;
const globalEditOnly = globalOverrides?.qualityGatesEditOnly ?? false;
if (globalGlobalGates && (!globalEditOnly || editFlag === true)) {
gates.push(...globalGlobalGates);
}
// Collect movement-specific gates from global config
const globalMovementGates = globalOverrides?.movements?.[movementName]?.qualityGates;
if (globalMovementGates) {
gates.push(...globalMovementGates);
}
// Collect global gates from project config
const projectGlobalGates = projectOverrides?.qualityGates;
const projectEditOnly = projectOverrides?.qualityGatesEditOnly ?? false;
if (projectGlobalGates && (!projectEditOnly || editFlag === true)) {
gates.push(...projectGlobalGates);
}
// Collect movement-specific gates from project config
const projectMovementGates = projectOverrides?.movements?.[movementName]?.qualityGates;
if (projectMovementGates) {
gates.push(...projectMovementGates);
}
// Add YAML gates (lowest priority)
if (yamlGates) {
gates.push(...yamlGates);
}
// Deduplicate gates (same text = same gate)
const uniqueGates = Array.from(new Set(gates));
// Return undefined only if no gates were defined anywhere
// If yamlGates was explicitly set (even if empty), return the merged array
if (uniqueGates.length > 0) {
return uniqueGates;
}
return hasYamlGates ? [] : undefined;
}

View File

@ -10,7 +10,7 @@ import { parse, stringify } from 'yaml';
import { copyProjectResourcesToDir } from '../../resources/index.js';
import type { ProjectLocalConfig } from '../types.js';
import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js';
import type { AnalyticsConfig, SubmoduleSelection } from '../../../core/models/persisted-global-config.js';
import type { AnalyticsConfig, PieceOverrides, SubmoduleSelection } from '../../../core/models/persisted-global-config.js';
import { applyProjectConfigEnvOverrides } from '../env/config-env-overrides.js';
import { normalizeProviderOptions } from '../loaders/pieceParser.js';
import { invalidateResolvedConfigCache } from '../resolutionCache.js';
@ -129,6 +129,51 @@ function denormalizeAnalytics(config: AnalyticsConfig | undefined): Record<strin
return Object.keys(raw).length > 0 ? raw : undefined;
}
/** Normalize piece_overrides from snake_case (YAML) to camelCase (internal) */
function normalizePieceOverrides(
raw: { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | undefined,
): PieceOverrides | undefined {
if (!raw) return undefined;
return {
qualityGates: raw.quality_gates,
qualityGatesEditOnly: raw.quality_gates_edit_only,
movements: raw.movements
? Object.fromEntries(
Object.entries(raw.movements).map(([name, override]) => [
name,
{ qualityGates: override.quality_gates },
])
)
: undefined,
};
}
/** Denormalize piece_overrides from camelCase (internal) to snake_case (YAML) */
function denormalizePieceOverrides(
overrides: PieceOverrides | undefined,
): { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | undefined {
if (!overrides) return undefined;
const result: { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } = {};
if (overrides.qualityGates !== undefined) {
result.quality_gates = overrides.qualityGates;
}
if (overrides.qualityGatesEditOnly !== undefined) {
result.quality_gates_edit_only = overrides.qualityGatesEditOnly;
}
if (overrides.movements) {
result.movements = Object.fromEntries(
Object.entries(overrides.movements).map(([name, override]) => {
const movementOverride: { quality_gates?: string[] } = {};
if (override.qualityGates !== undefined) {
movementOverride.quality_gates = override.qualityGates;
}
return [name, movementOverride];
})
);
}
return Object.keys(result).length > 0 ? result : undefined;
}
/**
* Load project configuration from .takt/config.yaml
*/
@ -157,6 +202,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
provider_options,
provider_profiles,
analytics,
piece_overrides,
claude_cli_path,
codex_cli_path,
cursor_cli_path,
@ -188,6 +234,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
};
} | undefined),
providerProfiles: normalizeProviderProfiles(provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined),
pieceOverrides: normalizePieceOverrides(piece_overrides as { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | undefined),
claudeCliPath: claude_cli_path as string | undefined,
codexCliPath: codex_cli_path as string | undefined,
cursorCliPath: cursor_cli_path as string | undefined,
@ -246,6 +293,12 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
delete savePayload.baseBranch;
delete savePayload.withSubmodules;
const rawPieceOverrides = denormalizePieceOverrides(config.pieceOverrides);
if (rawPieceOverrides) {
savePayload.piece_overrides = rawPieceOverrides;
}
delete savePayload.pieceOverrides;
const content = stringify(savePayload, { indent: 2 });
writeFileSync(configPath, content, 'utf-8');
invalidateResolvedConfigCache(projectDir);

View File

@ -79,6 +79,7 @@ const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K
verbose: { layers: ['local', 'global'] },
autoFetch: { layers: ['global'] },
baseBranch: { layers: ['local', 'global'] },
pieceOverrides: { layers: ['local', 'global'] },
};
function resolveAnalyticsMerged(

View File

@ -4,7 +4,7 @@
import type { MovementProviderOptions } from '../../core/models/piece-types.js';
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js';
import type { AnalyticsConfig, SubmoduleSelection } from '../../core/models/persisted-global-config.js';
import type { AnalyticsConfig, PieceOverrides, SubmoduleSelection } from '../../core/models/persisted-global-config.js';
/** Project configuration stored in .takt/config.yaml */
export interface ProjectLocalConfig {
@ -34,6 +34,8 @@ export interface ProjectLocalConfig {
providerOptions?: MovementProviderOptions;
/** Provider-specific permission profiles (project-level override) */
providerProfiles?: ProviderPermissionProfiles;
/** Piece-level overrides (quality_gates, etc.) */
pieceOverrides?: PieceOverrides;
/** Claude Code CLI path override (project-level) */
claudeCliPath?: string;
/** Codex CLI path override (project-level) */