takt: add-persona-quality-gates (#472)
This commit is contained in:
parent
7bfc7954aa
commit
a69e9f4fb3
@ -37,6 +37,7 @@ import {
|
|||||||
isVerboseMode,
|
isVerboseMode,
|
||||||
resolveConfigValue,
|
resolveConfigValue,
|
||||||
invalidateGlobalConfigCache,
|
invalidateGlobalConfigCache,
|
||||||
|
invalidateAllResolvedConfigCache,
|
||||||
} from '../infra/config/index.js';
|
} from '../infra/config/index.js';
|
||||||
|
|
||||||
let isolatedGlobalConfigDir: string;
|
let isolatedGlobalConfigDir: string;
|
||||||
@ -270,6 +271,351 @@ describe('loadPiece (builtin fallback)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('loadPiece piece_overrides.personas integration', () => {
|
||||||
|
let testDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
||||||
|
mkdirSync(join(testDir, '.takt', 'pieces'), { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
invalidateGlobalConfigCache();
|
||||||
|
invalidateAllResolvedConfigCache();
|
||||||
|
if (existsSync(testDir)) {
|
||||||
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply persona quality gates from global then project configs', () => {
|
||||||
|
// Given: global/project persona overrides and piece yaml quality gates
|
||||||
|
writeFileSync(
|
||||||
|
join(isolatedGlobalConfigDir, 'config.yaml'),
|
||||||
|
[
|
||||||
|
'language: en',
|
||||||
|
'piece_overrides:',
|
||||||
|
' personas:',
|
||||||
|
' coder:',
|
||||||
|
' quality_gates:',
|
||||||
|
' - "Global persona gate"',
|
||||||
|
].join('\n'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(testDir, '.takt', 'config.yaml'),
|
||||||
|
[
|
||||||
|
'piece_overrides:',
|
||||||
|
' personas:',
|
||||||
|
' coder:',
|
||||||
|
' quality_gates:',
|
||||||
|
' - "Project persona gate"',
|
||||||
|
].join('\n'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(testDir, '.takt', 'pieces', 'persona-gates.yaml'),
|
||||||
|
[
|
||||||
|
'name: persona-gates',
|
||||||
|
'description: Persona quality gates integration test',
|
||||||
|
'max_movements: 3',
|
||||||
|
'initial_movement: implement',
|
||||||
|
'movements:',
|
||||||
|
' - name: implement',
|
||||||
|
' persona: coder',
|
||||||
|
' edit: true',
|
||||||
|
' quality_gates:',
|
||||||
|
' - "YAML gate"',
|
||||||
|
' rules:',
|
||||||
|
' - condition: Done',
|
||||||
|
' next: COMPLETE',
|
||||||
|
' instruction: "{task}"',
|
||||||
|
].join('\n'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
invalidateGlobalConfigCache();
|
||||||
|
invalidateAllResolvedConfigCache();
|
||||||
|
|
||||||
|
// When: loading the piece through normal config pipeline
|
||||||
|
const piece = loadPiece('persona-gates', testDir);
|
||||||
|
|
||||||
|
// Then: persona gates are merged in global -> project -> yaml order
|
||||||
|
const movement = piece?.movements.find((step) => step.name === 'implement');
|
||||||
|
expect(movement?.qualityGates).toEqual([
|
||||||
|
'Global persona gate',
|
||||||
|
'Project persona gate',
|
||||||
|
'YAML gate',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply persona quality gates when movement persona uses personas section alias key', () => {
|
||||||
|
// Given: piece persona alias key differs from mapped persona filename
|
||||||
|
writeFileSync(
|
||||||
|
join(isolatedGlobalConfigDir, 'config.yaml'),
|
||||||
|
[
|
||||||
|
'language: en',
|
||||||
|
'piece_overrides:',
|
||||||
|
' personas:',
|
||||||
|
' coder:',
|
||||||
|
' quality_gates:',
|
||||||
|
' - "Alias key gate"',
|
||||||
|
].join('\n'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
mkdirSync(join(testDir, '.takt', 'pieces', 'personas'), { recursive: true });
|
||||||
|
writeFileSync(join(testDir, '.takt', 'pieces', 'personas', 'implementer.md'), 'Implementer persona', 'utf-8');
|
||||||
|
writeFileSync(
|
||||||
|
join(testDir, '.takt', 'pieces', 'persona-alias-key.yaml'),
|
||||||
|
[
|
||||||
|
'name: persona-alias-key',
|
||||||
|
'description: personas alias key should drive override matching',
|
||||||
|
'max_movements: 3',
|
||||||
|
'initial_movement: implement',
|
||||||
|
'personas:',
|
||||||
|
' coder: ./personas/implementer.md',
|
||||||
|
'movements:',
|
||||||
|
' - name: implement',
|
||||||
|
' persona: coder',
|
||||||
|
' quality_gates:',
|
||||||
|
' - "YAML gate"',
|
||||||
|
' rules:',
|
||||||
|
' - condition: Done',
|
||||||
|
' next: COMPLETE',
|
||||||
|
' instruction: "{task}"',
|
||||||
|
].join('\n'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
invalidateGlobalConfigCache();
|
||||||
|
invalidateAllResolvedConfigCache();
|
||||||
|
|
||||||
|
// When: loading piece with section alias persona reference
|
||||||
|
const piece = loadPiece('persona-alias-key', testDir);
|
||||||
|
|
||||||
|
// Then: override key is alias key ("coder"), not mapped filename ("implementer")
|
||||||
|
const movement = piece?.movements.find((step) => step.name === 'implement');
|
||||||
|
expect(movement?.qualityGates).toEqual(['Alias key gate', 'YAML gate']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply persona quality gates for path personas using basename key', () => {
|
||||||
|
// Given: movement persona is a path and override key uses its basename
|
||||||
|
writeFileSync(
|
||||||
|
join(isolatedGlobalConfigDir, 'config.yaml'),
|
||||||
|
[
|
||||||
|
'language: en',
|
||||||
|
'piece_overrides:',
|
||||||
|
' personas:',
|
||||||
|
' implementer:',
|
||||||
|
' quality_gates:',
|
||||||
|
' - "Path basename gate"',
|
||||||
|
].join('\n'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
mkdirSync(join(testDir, '.takt', 'pieces', 'personas'), { recursive: true });
|
||||||
|
writeFileSync(join(testDir, '.takt', 'pieces', 'personas', 'implementer.md'), 'Implementer persona', 'utf-8');
|
||||||
|
writeFileSync(
|
||||||
|
join(testDir, '.takt', 'pieces', 'persona-path-key.yaml'),
|
||||||
|
[
|
||||||
|
'name: persona-path-key',
|
||||||
|
'description: path personas should match overrides by basename',
|
||||||
|
'max_movements: 3',
|
||||||
|
'initial_movement: implement',
|
||||||
|
'movements:',
|
||||||
|
' - name: implement',
|
||||||
|
' persona: ./personas/implementer.md',
|
||||||
|
' quality_gates:',
|
||||||
|
' - "YAML gate"',
|
||||||
|
' rules:',
|
||||||
|
' - condition: Done',
|
||||||
|
' next: COMPLETE',
|
||||||
|
' instruction: "{task}"',
|
||||||
|
].join('\n'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
invalidateGlobalConfigCache();
|
||||||
|
invalidateAllResolvedConfigCache();
|
||||||
|
|
||||||
|
// When: loading piece with path-like persona reference
|
||||||
|
const piece = loadPiece('persona-path-key', testDir);
|
||||||
|
|
||||||
|
// Then: override key resolves from path basename ("implementer")
|
||||||
|
const movement = piece?.movements.find((step) => step.name === 'implement');
|
||||||
|
expect(movement?.qualityGates).toEqual(['Path basename gate', 'YAML gate']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not apply persona quality gates when persona does not match', () => {
|
||||||
|
// Given: persona overrides exist only for reviewer
|
||||||
|
writeFileSync(
|
||||||
|
join(isolatedGlobalConfigDir, 'config.yaml'),
|
||||||
|
[
|
||||||
|
'language: en',
|
||||||
|
'piece_overrides:',
|
||||||
|
' personas:',
|
||||||
|
' reviewer:',
|
||||||
|
' quality_gates:',
|
||||||
|
' - "Reviewer gate"',
|
||||||
|
].join('\n'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(testDir, '.takt', 'pieces', 'persona-mismatch.yaml'),
|
||||||
|
[
|
||||||
|
'name: persona-mismatch',
|
||||||
|
'description: Persona mismatch integration test',
|
||||||
|
'max_movements: 3',
|
||||||
|
'initial_movement: implement',
|
||||||
|
'movements:',
|
||||||
|
' - name: implement',
|
||||||
|
' persona: coder',
|
||||||
|
' quality_gates:',
|
||||||
|
' - "YAML gate"',
|
||||||
|
' rules:',
|
||||||
|
' - condition: Done',
|
||||||
|
' next: COMPLETE',
|
||||||
|
' instruction: "{task}"',
|
||||||
|
].join('\n'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
invalidateGlobalConfigCache();
|
||||||
|
invalidateAllResolvedConfigCache();
|
||||||
|
|
||||||
|
// When: loading piece with different persona
|
||||||
|
const piece = loadPiece('persona-mismatch', testDir);
|
||||||
|
|
||||||
|
// Then: only YAML gates are applied
|
||||||
|
const movement = piece?.movements.find((step) => step.name === 'implement');
|
||||||
|
expect(movement?.qualityGates).toEqual(['YAML gate']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not apply persona quality gates when movement has no persona', () => {
|
||||||
|
writeFileSync(
|
||||||
|
join(isolatedGlobalConfigDir, 'config.yaml'),
|
||||||
|
[
|
||||||
|
'language: en',
|
||||||
|
'piece_overrides:',
|
||||||
|
' personas:',
|
||||||
|
' reviewer:',
|
||||||
|
' quality_gates:',
|
||||||
|
' - "Reviewer gate"',
|
||||||
|
].join('\n'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(testDir, '.takt', 'pieces', 'no-persona-reviewer.yaml'),
|
||||||
|
[
|
||||||
|
'name: no-persona-reviewer',
|
||||||
|
'description: No persona movement should not match persona overrides',
|
||||||
|
'max_movements: 3',
|
||||||
|
'initial_movement: reviewer',
|
||||||
|
'movements:',
|
||||||
|
' - name: reviewer',
|
||||||
|
' quality_gates:',
|
||||||
|
' - "YAML gate"',
|
||||||
|
' rules:',
|
||||||
|
' - condition: Done',
|
||||||
|
' next: COMPLETE',
|
||||||
|
' instruction: "{task}"',
|
||||||
|
].join('\n'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
invalidateGlobalConfigCache();
|
||||||
|
invalidateAllResolvedConfigCache();
|
||||||
|
|
||||||
|
const piece = loadPiece('no-persona-reviewer', testDir);
|
||||||
|
|
||||||
|
const movement = piece?.movements.find((step) => step.name === 'reviewer');
|
||||||
|
expect(movement?.qualityGates).toEqual(['YAML gate']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not apply persona quality gates from persona_name without persona', () => {
|
||||||
|
writeFileSync(
|
||||||
|
join(isolatedGlobalConfigDir, 'config.yaml'),
|
||||||
|
[
|
||||||
|
'language: en',
|
||||||
|
'piece_overrides:',
|
||||||
|
' personas:',
|
||||||
|
' reviewer:',
|
||||||
|
' quality_gates:',
|
||||||
|
' - "Reviewer gate"',
|
||||||
|
].join('\n'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(testDir, '.takt', 'pieces', 'persona-name-only.yaml'),
|
||||||
|
[
|
||||||
|
'name: persona-name-only',
|
||||||
|
'description: persona_name should be display-only for persona overrides',
|
||||||
|
'max_movements: 3',
|
||||||
|
'initial_movement: review',
|
||||||
|
'movements:',
|
||||||
|
' - name: review',
|
||||||
|
' persona_name: reviewer',
|
||||||
|
' quality_gates:',
|
||||||
|
' - "YAML gate"',
|
||||||
|
' rules:',
|
||||||
|
' - condition: Done',
|
||||||
|
' next: COMPLETE',
|
||||||
|
' instruction: "{task}"',
|
||||||
|
].join('\n'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
invalidateGlobalConfigCache();
|
||||||
|
invalidateAllResolvedConfigCache();
|
||||||
|
|
||||||
|
const piece = loadPiece('persona-name-only', testDir);
|
||||||
|
|
||||||
|
const movement = piece?.movements.find((step) => step.name === 'review');
|
||||||
|
expect(movement?.qualityGates).toEqual(['YAML gate']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when movement persona is an empty string', () => {
|
||||||
|
writeFileSync(
|
||||||
|
join(testDir, '.takt', 'pieces', 'empty-persona.yaml'),
|
||||||
|
[
|
||||||
|
'name: empty-persona',
|
||||||
|
'description: Empty persona should fail fast',
|
||||||
|
'max_movements: 3',
|
||||||
|
'initial_movement: implement',
|
||||||
|
'movements:',
|
||||||
|
' - name: implement',
|
||||||
|
' persona: " "',
|
||||||
|
' rules:',
|
||||||
|
' - condition: Done',
|
||||||
|
' next: COMPLETE',
|
||||||
|
' instruction: "{task}"',
|
||||||
|
].join('\n'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
invalidateGlobalConfigCache();
|
||||||
|
invalidateAllResolvedConfigCache();
|
||||||
|
|
||||||
|
expect(() => loadPiece('empty-persona', testDir)).toThrow('Movement "implement" has an empty persona value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when movement persona_name is an empty string', () => {
|
||||||
|
writeFileSync(
|
||||||
|
join(testDir, '.takt', 'pieces', 'empty-persona-name.yaml'),
|
||||||
|
[
|
||||||
|
'name: empty-persona-name',
|
||||||
|
'description: Empty persona_name should fail fast',
|
||||||
|
'max_movements: 3',
|
||||||
|
'initial_movement: implement',
|
||||||
|
'movements:',
|
||||||
|
' - name: implement',
|
||||||
|
' persona: coder',
|
||||||
|
' persona_name: " "',
|
||||||
|
' rules:',
|
||||||
|
' - condition: Done',
|
||||||
|
' next: COMPLETE',
|
||||||
|
' instruction: "{task}"',
|
||||||
|
].join('\n'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
invalidateGlobalConfigCache();
|
||||||
|
invalidateAllResolvedConfigCache();
|
||||||
|
|
||||||
|
expect(() => loadPiece('empty-persona-name', testDir)).toThrow('Movement "implement" has an empty persona_name value');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('listPieces (builtin fallback)', () => {
|
describe('listPieces (builtin fallback)', () => {
|
||||||
let testDir: string;
|
let testDir: string;
|
||||||
|
|
||||||
|
|||||||
@ -117,6 +117,61 @@ piece_overrides:
|
|||||||
|
|
||||||
expect(reloaded.pieceOverrides?.qualityGates).toEqual(['Test 1', 'Test 2']);
|
expect(reloaded.pieceOverrides?.qualityGates).toEqual(['Test 1', 'Test 2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should preserve personas quality_gates in save/load cycle', () => {
|
||||||
|
const configContent = `
|
||||||
|
piece_overrides:
|
||||||
|
personas:
|
||||||
|
coder:
|
||||||
|
quality_gates:
|
||||||
|
- "Global persona gate"
|
||||||
|
`;
|
||||||
|
writeFileSync(testConfigPath, configContent, 'utf-8');
|
||||||
|
|
||||||
|
const manager = GlobalConfigManager.getInstance();
|
||||||
|
const loaded = manager.load();
|
||||||
|
const loadedPieceOverrides = loaded.pieceOverrides as unknown as {
|
||||||
|
personas?: Record<string, { qualityGates?: string[] }>;
|
||||||
|
};
|
||||||
|
expect(loadedPieceOverrides.personas?.coder?.qualityGates).toEqual(['Global persona gate']);
|
||||||
|
|
||||||
|
manager.save(loaded);
|
||||||
|
|
||||||
|
GlobalConfigManager.resetInstance();
|
||||||
|
const reloadedManager = GlobalConfigManager.getInstance();
|
||||||
|
const reloaded = reloadedManager.load();
|
||||||
|
const reloadedPieceOverrides = reloaded.pieceOverrides as unknown as {
|
||||||
|
personas?: Record<string, { qualityGates?: string[] }>;
|
||||||
|
};
|
||||||
|
expect(reloadedPieceOverrides.personas?.coder?.qualityGates).toEqual(['Global persona gate']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve empty quality_gates array in personas', () => {
|
||||||
|
const configContent = `
|
||||||
|
piece_overrides:
|
||||||
|
personas:
|
||||||
|
coder:
|
||||||
|
quality_gates: []
|
||||||
|
`;
|
||||||
|
writeFileSync(testConfigPath, configContent, 'utf-8');
|
||||||
|
|
||||||
|
const manager = GlobalConfigManager.getInstance();
|
||||||
|
const loaded = manager.load();
|
||||||
|
const loadedPieceOverrides = loaded.pieceOverrides as unknown as {
|
||||||
|
personas?: Record<string, { qualityGates?: string[] }>;
|
||||||
|
};
|
||||||
|
expect(loadedPieceOverrides.personas?.coder?.qualityGates).toEqual([]);
|
||||||
|
|
||||||
|
manager.save(loaded);
|
||||||
|
|
||||||
|
GlobalConfigManager.resetInstance();
|
||||||
|
const reloadedManager = GlobalConfigManager.getInstance();
|
||||||
|
const reloaded = reloadedManager.load();
|
||||||
|
const reloadedPieceOverrides = reloaded.pieceOverrides as unknown as {
|
||||||
|
personas?: Record<string, { qualityGates?: string[] }>;
|
||||||
|
};
|
||||||
|
expect(reloadedPieceOverrides.personas?.coder?.qualityGates).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('security hardening', () => {
|
describe('security hardening', () => {
|
||||||
|
|||||||
@ -95,6 +95,57 @@ piece_overrides:
|
|||||||
|
|
||||||
expect(reloaded.pieceOverrides?.qualityGates).toEqual(['Test 1', 'Test 2']);
|
expect(reloaded.pieceOverrides?.qualityGates).toEqual(['Test 1', 'Test 2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should preserve personas quality_gates in save/load cycle', () => {
|
||||||
|
const configPath = join(testDir, '.takt', 'config.yaml');
|
||||||
|
const configContent = `
|
||||||
|
piece_overrides:
|
||||||
|
personas:
|
||||||
|
coder:
|
||||||
|
quality_gates:
|
||||||
|
- "Project persona gate"
|
||||||
|
`;
|
||||||
|
writeFileSync(configPath, configContent, 'utf-8');
|
||||||
|
|
||||||
|
const loaded = loadProjectConfig(testDir);
|
||||||
|
const loadedPieceOverrides = loaded.pieceOverrides as unknown as {
|
||||||
|
personas?: Record<string, { qualityGates?: string[] }>;
|
||||||
|
};
|
||||||
|
expect(loadedPieceOverrides.personas?.coder?.qualityGates).toEqual(['Project persona gate']);
|
||||||
|
|
||||||
|
saveProjectConfig(testDir, loaded);
|
||||||
|
|
||||||
|
const reloaded = loadProjectConfig(testDir);
|
||||||
|
const reloadedPieceOverrides = reloaded.pieceOverrides as unknown as {
|
||||||
|
personas?: Record<string, { qualityGates?: string[] }>;
|
||||||
|
};
|
||||||
|
expect(reloadedPieceOverrides.personas?.coder?.qualityGates).toEqual(['Project persona gate']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve empty quality_gates array in personas', () => {
|
||||||
|
const configPath = join(testDir, '.takt', 'config.yaml');
|
||||||
|
const configContent = `
|
||||||
|
piece_overrides:
|
||||||
|
personas:
|
||||||
|
coder:
|
||||||
|
quality_gates: []
|
||||||
|
`;
|
||||||
|
writeFileSync(configPath, configContent, 'utf-8');
|
||||||
|
|
||||||
|
const loaded = loadProjectConfig(testDir);
|
||||||
|
const loadedPieceOverrides = loaded.pieceOverrides as unknown as {
|
||||||
|
personas?: Record<string, { qualityGates?: string[] }>;
|
||||||
|
};
|
||||||
|
expect(loadedPieceOverrides.personas?.coder?.qualityGates).toEqual([]);
|
||||||
|
|
||||||
|
saveProjectConfig(testDir, loaded);
|
||||||
|
|
||||||
|
const reloaded = loadProjectConfig(testDir);
|
||||||
|
const reloadedPieceOverrides = reloaded.pieceOverrides as unknown as {
|
||||||
|
personas?: Record<string, { qualityGates?: string[] }>;
|
||||||
|
};
|
||||||
|
expect(reloadedPieceOverrides.personas?.coder?.qualityGates).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('migrated project-local fields', () => {
|
describe('migrated project-local fields', () => {
|
||||||
@ -170,6 +221,35 @@ piece_overrides:
|
|||||||
expect(raw).not.toContain('verbose: false');
|
expect(raw).not.toContain('verbose: false');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not persist empty pipeline object on save', () => {
|
||||||
|
// Given: empty pipeline object
|
||||||
|
const config = {
|
||||||
|
pipeline: {},
|
||||||
|
} as ProjectLocalConfig;
|
||||||
|
|
||||||
|
// When: project config is saved
|
||||||
|
saveProjectConfig(testDir, config);
|
||||||
|
|
||||||
|
// Then: pipeline key is not serialized
|
||||||
|
const raw = readFileSync(join(testDir, '.takt', 'config.yaml'), 'utf-8');
|
||||||
|
expect(raw).not.toContain('pipeline:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not persist empty personaProviders object on save', () => {
|
||||||
|
// Given: empty personaProviders object
|
||||||
|
const config = {
|
||||||
|
personaProviders: {},
|
||||||
|
} as ProjectLocalConfig;
|
||||||
|
|
||||||
|
// When: project config is saved
|
||||||
|
saveProjectConfig(testDir, config);
|
||||||
|
|
||||||
|
// Then: persona_providers key is not serialized
|
||||||
|
const raw = readFileSync(join(testDir, '.takt', 'config.yaml'), 'utf-8');
|
||||||
|
expect(raw).not.toContain('persona_providers:');
|
||||||
|
expect(raw).not.toContain('personaProviders:');
|
||||||
|
});
|
||||||
|
|
||||||
it('should not persist schema-injected default values on save', () => {
|
it('should not persist schema-injected default values on save', () => {
|
||||||
const loaded = loadProjectConfig(testDir);
|
const loaded = loadProjectConfig(testDir);
|
||||||
saveProjectConfig(testDir, loaded);
|
saveProjectConfig(testDir, loaded);
|
||||||
|
|||||||
@ -6,21 +6,34 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { applyQualityGateOverrides } from '../infra/config/loaders/qualityGateOverrides.js';
|
import { applyQualityGateOverrides } from '../infra/config/loaders/qualityGateOverrides.js';
|
||||||
import type { PieceOverrides } from '../core/models/persisted-global-config.js';
|
import type { PieceOverrides } from '../core/models/persisted-global-config.js';
|
||||||
|
|
||||||
|
type ApplyOverridesArgs = [
|
||||||
|
string,
|
||||||
|
string[] | undefined,
|
||||||
|
boolean | undefined,
|
||||||
|
string | undefined,
|
||||||
|
PieceOverrides | undefined,
|
||||||
|
PieceOverrides | undefined,
|
||||||
|
];
|
||||||
|
|
||||||
|
function applyOverrides(...args: ApplyOverridesArgs): string[] | undefined {
|
||||||
|
return applyQualityGateOverrides(...args);
|
||||||
|
}
|
||||||
|
|
||||||
describe('applyQualityGateOverrides', () => {
|
describe('applyQualityGateOverrides', () => {
|
||||||
it('returns undefined when no gates are defined', () => {
|
it('returns undefined when no gates are defined', () => {
|
||||||
const result = applyQualityGateOverrides('implement', undefined, true, undefined, undefined);
|
const result = applyOverrides('implement', undefined, true, undefined, undefined, undefined);
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns YAML gates when no overrides are defined', () => {
|
it('returns YAML gates when no overrides are defined', () => {
|
||||||
const yamlGates = ['Test passes'];
|
const yamlGates = ['Test passes'];
|
||||||
const result = applyQualityGateOverrides('implement', yamlGates, true, undefined, undefined);
|
const result = applyOverrides('implement', yamlGates, true, undefined, undefined, undefined);
|
||||||
expect(result).toEqual(['Test passes']);
|
expect(result).toEqual(['Test passes']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty array when yamlGates is empty array and no overrides', () => {
|
it('returns empty array when yamlGates is empty array and no overrides', () => {
|
||||||
const yamlGates: string[] = [];
|
const yamlGates: string[] = [];
|
||||||
const result = applyQualityGateOverrides('implement', yamlGates, true, undefined, undefined);
|
const result = applyOverrides('implement', yamlGates, true, undefined, undefined, undefined);
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -29,7 +42,7 @@ describe('applyQualityGateOverrides', () => {
|
|||||||
const globalOverrides: PieceOverrides = {
|
const globalOverrides: PieceOverrides = {
|
||||||
qualityGates: ['E2E tests pass'],
|
qualityGates: ['E2E tests pass'],
|
||||||
};
|
};
|
||||||
const result = applyQualityGateOverrides('implement', yamlGates, true, undefined, globalOverrides);
|
const result = applyOverrides('implement', yamlGates, true, undefined, undefined, globalOverrides);
|
||||||
expect(result).toEqual(['E2E tests pass', 'Unit tests pass']);
|
expect(result).toEqual(['E2E tests pass', 'Unit tests pass']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -43,7 +56,7 @@ describe('applyQualityGateOverrides', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const result = applyQualityGateOverrides('implement', yamlGates, true, undefined, globalOverrides);
|
const result = applyOverrides('implement', yamlGates, true, undefined, undefined, globalOverrides);
|
||||||
expect(result).toEqual(['Global gate', 'Movement-specific gate', 'Unit tests pass']);
|
expect(result).toEqual(['Global gate', 'Movement-specific gate', 'Unit tests pass']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -55,7 +68,7 @@ describe('applyQualityGateOverrides', () => {
|
|||||||
const projectOverrides: PieceOverrides = {
|
const projectOverrides: PieceOverrides = {
|
||||||
qualityGates: ['Project gate'],
|
qualityGates: ['Project gate'],
|
||||||
};
|
};
|
||||||
const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, globalOverrides);
|
const result = applyOverrides('implement', yamlGates, true, undefined, projectOverrides, globalOverrides);
|
||||||
expect(result).toEqual(['Global gate', 'Project gate', 'YAML gate']);
|
expect(result).toEqual(['Global gate', 'Project gate', 'YAML gate']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -68,7 +81,7 @@ describe('applyQualityGateOverrides', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, undefined);
|
const result = applyOverrides('implement', yamlGates, true, undefined, projectOverrides, undefined);
|
||||||
expect(result).toEqual(['Project movement gate', 'YAML gate']);
|
expect(result).toEqual(['Project movement gate', 'YAML gate']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -78,7 +91,7 @@ describe('applyQualityGateOverrides', () => {
|
|||||||
qualityGates: ['Global gate'],
|
qualityGates: ['Global gate'],
|
||||||
qualityGatesEditOnly: true,
|
qualityGatesEditOnly: true,
|
||||||
};
|
};
|
||||||
const result = applyQualityGateOverrides('review', yamlGates, false, undefined, globalOverrides);
|
const result = applyOverrides('review', yamlGates, false, undefined, undefined, globalOverrides);
|
||||||
expect(result).toEqual(['YAML gate']); // Global gate excluded because edit=false
|
expect(result).toEqual(['YAML gate']); // Global gate excluded because edit=false
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -88,7 +101,7 @@ describe('applyQualityGateOverrides', () => {
|
|||||||
qualityGates: ['Global gate'],
|
qualityGates: ['Global gate'],
|
||||||
qualityGatesEditOnly: true,
|
qualityGatesEditOnly: true,
|
||||||
};
|
};
|
||||||
const result = applyQualityGateOverrides('implement', yamlGates, true, undefined, globalOverrides);
|
const result = applyOverrides('implement', yamlGates, true, undefined, undefined, globalOverrides);
|
||||||
expect(result).toEqual(['Global gate', 'YAML gate']);
|
expect(result).toEqual(['Global gate', 'YAML gate']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -98,7 +111,7 @@ describe('applyQualityGateOverrides', () => {
|
|||||||
qualityGates: ['Project gate'],
|
qualityGates: ['Project gate'],
|
||||||
qualityGatesEditOnly: true,
|
qualityGatesEditOnly: true,
|
||||||
};
|
};
|
||||||
const result = applyQualityGateOverrides('review', yamlGates, false, projectOverrides, undefined);
|
const result = applyOverrides('review', yamlGates, false, undefined, projectOverrides, undefined);
|
||||||
expect(result).toEqual(['YAML gate']); // Project gate excluded because edit=false
|
expect(result).toEqual(['YAML gate']); // Project gate excluded because edit=false
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -113,7 +126,7 @@ describe('applyQualityGateOverrides', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const result = applyQualityGateOverrides('review', yamlGates, false, projectOverrides, undefined);
|
const result = applyOverrides('review', yamlGates, false, undefined, projectOverrides, undefined);
|
||||||
// Project global gate excluded (edit=false), but movement-specific gate included
|
// Project global gate excluded (edit=false), but movement-specific gate included
|
||||||
expect(result).toEqual(['Review-specific gate', 'YAML gate']);
|
expect(result).toEqual(['Review-specific gate', 'YAML gate']);
|
||||||
});
|
});
|
||||||
@ -136,7 +149,7 @@ describe('applyQualityGateOverrides', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, globalOverrides);
|
const result = applyOverrides('implement', yamlGates, true, undefined, projectOverrides, globalOverrides);
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
'Global gate',
|
'Global gate',
|
||||||
'Global movement gate',
|
'Global movement gate',
|
||||||
@ -155,10 +168,104 @@ describe('applyQualityGateOverrides', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, undefined);
|
const result = applyOverrides('implement', yamlGates, true, undefined, projectOverrides, undefined);
|
||||||
expect(result).toEqual(['YAML gate']); // No override for 'implement', only for 'review'
|
expect(result).toEqual(['YAML gate']); // No override for 'implement', only for 'review'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('persona overrides', () => {
|
||||||
|
it('applies persona-specific gates from global and project configs in order', () => {
|
||||||
|
// Given: both global and project configs define gates for the same persona
|
||||||
|
const yamlGates = ['YAML gate'];
|
||||||
|
const globalOverrides = {
|
||||||
|
personas: {
|
||||||
|
coder: {
|
||||||
|
qualityGates: ['Global persona gate'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as PieceOverrides;
|
||||||
|
const projectOverrides = {
|
||||||
|
personas: {
|
||||||
|
coder: {
|
||||||
|
qualityGates: ['Project persona gate'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as PieceOverrides;
|
||||||
|
|
||||||
|
// When: the movement is executed with the matching persona
|
||||||
|
const result = applyOverrides('implement', yamlGates, true, 'coder', projectOverrides, globalOverrides);
|
||||||
|
|
||||||
|
// Then: gates are additive with global persona gates before project persona gates
|
||||||
|
expect(result).toEqual(['Global persona gate', 'Project persona gate', 'YAML gate']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not apply persona-specific gates when persona does not match', () => {
|
||||||
|
// Given: config defines gates for reviewer persona only
|
||||||
|
const yamlGates = ['YAML gate'];
|
||||||
|
const projectOverrides = {
|
||||||
|
personas: {
|
||||||
|
reviewer: {
|
||||||
|
qualityGates: ['Reviewer persona gate'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as PieceOverrides;
|
||||||
|
|
||||||
|
// When: movement persona is coder
|
||||||
|
const result = applyOverrides('implement', yamlGates, true, 'coder', projectOverrides, undefined);
|
||||||
|
|
||||||
|
// Then: only YAML gates remain
|
||||||
|
expect(result).toEqual(['YAML gate']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates gates across movement, persona, and YAML sources', () => {
|
||||||
|
// Given: same gate appears in multiple override layers
|
||||||
|
const yamlGates = ['Shared gate', 'YAML only'];
|
||||||
|
const globalOverrides = {
|
||||||
|
movements: {
|
||||||
|
implement: {
|
||||||
|
qualityGates: ['Shared gate', 'Global movement only'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
personas: {
|
||||||
|
coder: {
|
||||||
|
qualityGates: ['Shared gate', 'Global persona only'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as PieceOverrides;
|
||||||
|
const projectOverrides = {
|
||||||
|
personas: {
|
||||||
|
coder: {
|
||||||
|
qualityGates: ['Shared gate', 'Project persona only'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as PieceOverrides;
|
||||||
|
|
||||||
|
// When: overrides are merged for matching movement + persona
|
||||||
|
const result = applyOverrides('implement', yamlGates, true, 'coder', projectOverrides, globalOverrides);
|
||||||
|
|
||||||
|
// Then: duplicates are removed, first appearance order is preserved
|
||||||
|
expect(result).toEqual([
|
||||||
|
'Shared gate',
|
||||||
|
'Global movement only',
|
||||||
|
'Global persona only',
|
||||||
|
'Project persona only',
|
||||||
|
'YAML only',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when personaName is empty', () => {
|
||||||
|
const projectOverrides = {
|
||||||
|
personas: {
|
||||||
|
coder: {
|
||||||
|
qualityGates: ['Project persona gate'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as PieceOverrides;
|
||||||
|
expect(() =>
|
||||||
|
applyOverrides('implement', ['YAML gate'], true, ' ', projectOverrides, undefined)
|
||||||
|
).toThrow('Invalid persona name for movement "implement": empty value');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('deduplication', () => {
|
describe('deduplication', () => {
|
||||||
it('removes duplicate gates from multiple sources', () => {
|
it('removes duplicate gates from multiple sources', () => {
|
||||||
const yamlGates = ['Test 1', 'Test 2'];
|
const yamlGates = ['Test 1', 'Test 2'];
|
||||||
@ -168,7 +275,7 @@ describe('applyQualityGateOverrides', () => {
|
|||||||
const projectOverrides: PieceOverrides = {
|
const projectOverrides: PieceOverrides = {
|
||||||
qualityGates: ['Test 1', 'Test 4'],
|
qualityGates: ['Test 1', 'Test 4'],
|
||||||
};
|
};
|
||||||
const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, globalOverrides);
|
const result = applyOverrides('implement', yamlGates, true, undefined, projectOverrides, globalOverrides);
|
||||||
// Duplicates removed: Test 1, Test 2 appear only once
|
// Duplicates removed: Test 1, Test 2 appear only once
|
||||||
expect(result).toEqual(['Test 2', 'Test 3', 'Test 1', 'Test 4']);
|
expect(result).toEqual(['Test 2', 'Test 3', 'Test 1', 'Test 4']);
|
||||||
});
|
});
|
||||||
@ -177,7 +284,7 @@ describe('applyQualityGateOverrides', () => {
|
|||||||
const projectOverrides: PieceOverrides = {
|
const projectOverrides: PieceOverrides = {
|
||||||
qualityGates: ['Test 1', 'Test 2', 'Test 1', 'Test 3', 'Test 2'],
|
qualityGates: ['Test 1', 'Test 2', 'Test 1', 'Test 3', 'Test 2'],
|
||||||
};
|
};
|
||||||
const result = applyQualityGateOverrides('implement', undefined, true, projectOverrides, undefined);
|
const result = applyOverrides('implement', undefined, true, undefined, projectOverrides, undefined);
|
||||||
expect(result).toEqual(['Test 1', 'Test 2', 'Test 3']);
|
expect(result).toEqual(['Test 1', 'Test 2', 'Test 3']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -186,7 +293,7 @@ describe('applyQualityGateOverrides', () => {
|
|||||||
const projectOverrides: PieceOverrides = {
|
const projectOverrides: PieceOverrides = {
|
||||||
qualityGates: ['npm run test', 'npm run build'],
|
qualityGates: ['npm run test', 'npm run build'],
|
||||||
};
|
};
|
||||||
const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, undefined);
|
const result = applyOverrides('implement', yamlGates, true, undefined, projectOverrides, undefined);
|
||||||
// 'npm run test' appears only once
|
// 'npm run test' appears only once
|
||||||
expect(result).toEqual(['npm run test', 'npm run build', 'npm run lint']);
|
expect(result).toEqual(['npm run test', 'npm run build', 'npm run lint']);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -23,6 +23,8 @@ export interface PieceOverrides {
|
|||||||
qualityGatesEditOnly?: boolean;
|
qualityGatesEditOnly?: boolean;
|
||||||
/** Movement-specific quality gates overrides */
|
/** Movement-specific quality gates overrides */
|
||||||
movements?: Record<string, MovementQualityGatesOverride>;
|
movements?: Record<string, MovementQualityGatesOverride>;
|
||||||
|
/** Persona-specific quality gates overrides */
|
||||||
|
personas?: Record<string, MovementQualityGatesOverride>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Custom agent configuration */
|
/** Custom agent configuration */
|
||||||
|
|||||||
@ -211,6 +211,8 @@ export const PieceOverridesSchema = z.object({
|
|||||||
quality_gates_edit_only: z.boolean().optional(),
|
quality_gates_edit_only: z.boolean().optional(),
|
||||||
/** Movement-specific quality gates overrides */
|
/** Movement-specific quality gates overrides */
|
||||||
movements: z.record(z.string(), MovementQualityGatesOverrideSchema).optional(),
|
movements: z.record(z.string(), MovementQualityGatesOverrideSchema).optional(),
|
||||||
|
/** Persona-specific quality gates overrides */
|
||||||
|
personas: z.record(z.string(), MovementQualityGatesOverrideSchema).optional(),
|
||||||
}).optional();
|
}).optional();
|
||||||
|
|
||||||
/** Rule-based transition schema (new unified format) */
|
/** Rule-based transition schema (new unified format) */
|
||||||
|
|||||||
@ -41,7 +41,12 @@ export function denormalizeProviderProfiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizePieceOverrides(
|
export function normalizePieceOverrides(
|
||||||
raw: { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | undefined,
|
raw: {
|
||||||
|
quality_gates?: string[];
|
||||||
|
quality_gates_edit_only?: boolean;
|
||||||
|
movements?: Record<string, { quality_gates?: string[] }>;
|
||||||
|
personas?: Record<string, { quality_gates?: string[] }>;
|
||||||
|
} | undefined,
|
||||||
): PieceOverrides | undefined {
|
): PieceOverrides | undefined {
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
return {
|
return {
|
||||||
@ -55,14 +60,32 @@ export function normalizePieceOverrides(
|
|||||||
])
|
])
|
||||||
)
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
personas: raw.personas
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(raw.personas).map(([name, override]) => [
|
||||||
|
name,
|
||||||
|
{ qualityGates: override.quality_gates },
|
||||||
|
])
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function denormalizePieceOverrides(
|
export function denormalizePieceOverrides(
|
||||||
overrides: PieceOverrides | undefined,
|
overrides: PieceOverrides | undefined,
|
||||||
): { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | undefined {
|
): {
|
||||||
|
quality_gates?: string[];
|
||||||
|
quality_gates_edit_only?: boolean;
|
||||||
|
movements?: Record<string, { quality_gates?: string[] }>;
|
||||||
|
personas?: Record<string, { quality_gates?: string[] }>;
|
||||||
|
} | undefined {
|
||||||
if (!overrides) return undefined;
|
if (!overrides) return undefined;
|
||||||
const result: { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } = {};
|
const result: {
|
||||||
|
quality_gates?: string[];
|
||||||
|
quality_gates_edit_only?: boolean;
|
||||||
|
movements?: Record<string, { quality_gates?: string[] }>;
|
||||||
|
personas?: Record<string, { quality_gates?: string[] }>;
|
||||||
|
} = {};
|
||||||
if (overrides.qualityGates !== undefined) {
|
if (overrides.qualityGates !== undefined) {
|
||||||
result.quality_gates = overrides.qualityGates;
|
result.quality_gates = overrides.qualityGates;
|
||||||
}
|
}
|
||||||
@ -80,6 +103,17 @@ export function denormalizePieceOverrides(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (overrides.personas) {
|
||||||
|
result.personas = Object.fromEntries(
|
||||||
|
Object.entries(overrides.personas).map(([name, override]) => {
|
||||||
|
const personaOverride: { quality_gates?: string[] } = {};
|
||||||
|
if (override.qualityGates !== undefined) {
|
||||||
|
personaOverride.quality_gates = override.qualityGates;
|
||||||
|
}
|
||||||
|
return [name, personaOverride];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
return Object.keys(result).length > 0 ? result : undefined;
|
return Object.keys(result).length > 0 ? result : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -140,7 +140,14 @@ export class GlobalConfigManager {
|
|||||||
} : undefined,
|
} : undefined,
|
||||||
autoFetch: parsed.auto_fetch,
|
autoFetch: parsed.auto_fetch,
|
||||||
baseBranch: parsed.base_branch,
|
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),
|
pieceOverrides: normalizePieceOverrides(
|
||||||
|
parsed.piece_overrides as {
|
||||||
|
quality_gates?: string[];
|
||||||
|
quality_gates_edit_only?: boolean;
|
||||||
|
movements?: Record<string, { quality_gates?: string[] }>;
|
||||||
|
personas?: Record<string, { quality_gates?: string[] }>;
|
||||||
|
} | undefined
|
||||||
|
),
|
||||||
};
|
};
|
||||||
validateProviderModelCompatibility(config.provider, config.model);
|
validateProviderModelCompatibility(config.provider, config.model);
|
||||||
this.cachedConfig = config;
|
this.cachedConfig = config;
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
resolveRefList,
|
resolveRefList,
|
||||||
resolveSectionMap,
|
resolveSectionMap,
|
||||||
extractPersonaDisplayName,
|
extractPersonaDisplayName,
|
||||||
|
isResourcePath,
|
||||||
resolvePersona,
|
resolvePersona,
|
||||||
} from './resource-resolver.js';
|
} from './resource-resolver.js';
|
||||||
|
|
||||||
@ -244,10 +245,22 @@ function normalizeStepFromRaw(
|
|||||||
const rules: PieceRule[] | undefined = step.rules?.map(normalizeRule);
|
const rules: PieceRule[] | undefined = step.rules?.map(normalizeRule);
|
||||||
|
|
||||||
const rawPersona = (step as Record<string, unknown>).persona as string | undefined;
|
const rawPersona = (step as Record<string, unknown>).persona as string | undefined;
|
||||||
|
if (rawPersona !== undefined && rawPersona.trim().length === 0) {
|
||||||
|
throw new Error(`Movement "${step.name}" has an empty persona value`);
|
||||||
|
}
|
||||||
const { personaSpec, personaPath } = resolvePersona(rawPersona, sections, pieceDir, context);
|
const { personaSpec, personaPath } = resolvePersona(rawPersona, sections, pieceDir, context);
|
||||||
|
|
||||||
const displayName: string | undefined = (step as Record<string, unknown>).persona_name as string
|
const displayNameRaw = (step as Record<string, unknown>).persona_name as string | undefined;
|
||||||
|| undefined;
|
if (displayNameRaw !== undefined && displayNameRaw.trim().length === 0) {
|
||||||
|
throw new Error(`Movement "${step.name}" has an empty persona_name value`);
|
||||||
|
}
|
||||||
|
const displayName = displayNameRaw || undefined;
|
||||||
|
const derivedPersonaName = personaSpec ? extractPersonaDisplayName(personaSpec) : undefined;
|
||||||
|
const resolvedPersonaDisplayName = displayName || derivedPersonaName || step.name;
|
||||||
|
const normalizedRawPersona = rawPersona?.trim();
|
||||||
|
const personaOverrideKey = normalizedRawPersona
|
||||||
|
? (isResourcePath(normalizedRawPersona) ? extractPersonaDisplayName(normalizedRawPersona) : normalizedRawPersona)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const policyRef = (step as Record<string, unknown>).policy as string | string[] | undefined;
|
const policyRef = (step as Record<string, unknown>).policy as string | string[] | undefined;
|
||||||
const policyContents = resolveRefList(policyRef, sections.resolvedPolicies, pieceDir, 'policies', context);
|
const policyContents = resolveRefList(policyRef, sections.resolvedPolicies, pieceDir, 'policies', context);
|
||||||
@ -265,7 +278,7 @@ function normalizeStepFromRaw(
|
|||||||
description: step.description,
|
description: step.description,
|
||||||
persona: personaSpec,
|
persona: personaSpec,
|
||||||
session: step.session,
|
session: step.session,
|
||||||
personaDisplayName: displayName || (personaSpec ? extractPersonaDisplayName(personaSpec) : step.name),
|
personaDisplayName: resolvedPersonaDisplayName,
|
||||||
personaPath,
|
personaPath,
|
||||||
mcpServers: step.mcp_servers,
|
mcpServers: step.mcp_servers,
|
||||||
provider: normalizedProvider.provider ?? inheritedProvider,
|
provider: normalizedProvider.provider ?? inheritedProvider,
|
||||||
@ -282,6 +295,7 @@ function normalizeStepFromRaw(
|
|||||||
step.name,
|
step.name,
|
||||||
step.quality_gates,
|
step.quality_gates,
|
||||||
step.edit,
|
step.edit,
|
||||||
|
personaOverrideKey,
|
||||||
projectOverrides,
|
projectOverrides,
|
||||||
globalOverrides,
|
globalOverrides,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -17,15 +17,18 @@ import type { PieceOverrides } from '../../../core/models/persisted-global-confi
|
|||||||
* Merge order (gates are added in this sequence):
|
* Merge order (gates are added in this sequence):
|
||||||
* 1. Global override in global config (filtered by edit flag if qualityGatesEditOnly=true)
|
* 1. Global override in global config (filtered by edit flag if qualityGatesEditOnly=true)
|
||||||
* 2. Movement-specific override in global config
|
* 2. Movement-specific override in global config
|
||||||
* 3. Global override in project config (filtered by edit flag if qualityGatesEditOnly=true)
|
* 3. Persona-specific override in global config
|
||||||
* 4. Movement-specific override in project config
|
* 4. Global override in project config (filtered by edit flag if qualityGatesEditOnly=true)
|
||||||
* 5. Piece YAML quality_gates
|
* 5. Movement-specific override in project config
|
||||||
|
* 6. Persona-specific override in project config
|
||||||
|
* 7. Piece YAML quality_gates
|
||||||
*
|
*
|
||||||
* Merge strategy: Additive merge (all gates are combined, no overriding)
|
* Merge strategy: Additive merge (all gates are combined, no overriding)
|
||||||
*
|
*
|
||||||
* @param movementName - Name of the movement
|
* @param movementName - Name of the movement
|
||||||
* @param yamlGates - Quality gates from piece YAML
|
* @param yamlGates - Quality gates from piece YAML
|
||||||
* @param editFlag - Whether the movement has edit: true
|
* @param editFlag - Whether the movement has edit: true
|
||||||
|
* @param personaName - Persona name used by the movement
|
||||||
* @param projectOverrides - Project-level piece_overrides (from .takt/config.yaml)
|
* @param projectOverrides - Project-level piece_overrides (from .takt/config.yaml)
|
||||||
* @param globalOverrides - Global-level piece_overrides (from ~/.takt/config.yaml)
|
* @param globalOverrides - Global-level piece_overrides (from ~/.takt/config.yaml)
|
||||||
* @returns Merged quality gates array
|
* @returns Merged quality gates array
|
||||||
@ -34,9 +37,15 @@ export function applyQualityGateOverrides(
|
|||||||
movementName: string,
|
movementName: string,
|
||||||
yamlGates: string[] | undefined,
|
yamlGates: string[] | undefined,
|
||||||
editFlag: boolean | undefined,
|
editFlag: boolean | undefined,
|
||||||
|
personaName: string | undefined,
|
||||||
projectOverrides: PieceOverrides | undefined,
|
projectOverrides: PieceOverrides | undefined,
|
||||||
globalOverrides: PieceOverrides | undefined,
|
globalOverrides: PieceOverrides | undefined,
|
||||||
): string[] | undefined {
|
): string[] | undefined {
|
||||||
|
if (personaName !== undefined && personaName.trim().length === 0) {
|
||||||
|
throw new Error(`Invalid persona name for movement "${movementName}": empty value`);
|
||||||
|
}
|
||||||
|
const normalizedPersonaName = personaName?.trim();
|
||||||
|
|
||||||
// Track whether yamlGates was explicitly defined (even if empty)
|
// Track whether yamlGates was explicitly defined (even if empty)
|
||||||
const hasYamlGates = yamlGates !== undefined;
|
const hasYamlGates = yamlGates !== undefined;
|
||||||
const gates: string[] = [];
|
const gates: string[] = [];
|
||||||
@ -54,6 +63,14 @@ export function applyQualityGateOverrides(
|
|||||||
gates.push(...globalMovementGates);
|
gates.push(...globalMovementGates);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect persona-specific gates from global config
|
||||||
|
const globalPersonaGates = normalizedPersonaName
|
||||||
|
? globalOverrides?.personas?.[normalizedPersonaName]?.qualityGates
|
||||||
|
: undefined;
|
||||||
|
if (globalPersonaGates) {
|
||||||
|
gates.push(...globalPersonaGates);
|
||||||
|
}
|
||||||
|
|
||||||
// Collect global gates from project config
|
// Collect global gates from project config
|
||||||
const projectGlobalGates = projectOverrides?.qualityGates;
|
const projectGlobalGates = projectOverrides?.qualityGates;
|
||||||
const projectEditOnly = projectOverrides?.qualityGatesEditOnly ?? false;
|
const projectEditOnly = projectOverrides?.qualityGatesEditOnly ?? false;
|
||||||
@ -67,6 +84,14 @@ export function applyQualityGateOverrides(
|
|||||||
gates.push(...projectMovementGates);
|
gates.push(...projectMovementGates);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect persona-specific gates from project config
|
||||||
|
const projectPersonaGates = normalizedPersonaName
|
||||||
|
? projectOverrides?.personas?.[normalizedPersonaName]?.qualityGates
|
||||||
|
: undefined;
|
||||||
|
if (projectPersonaGates) {
|
||||||
|
gates.push(...projectPersonaGates);
|
||||||
|
}
|
||||||
|
|
||||||
// Add YAML gates (lowest priority)
|
// Add YAML gates (lowest priority)
|
||||||
if (yamlGates) {
|
if (yamlGates) {
|
||||||
gates.push(...yamlGates);
|
gates.push(...yamlGates);
|
||||||
|
|||||||
@ -51,7 +51,6 @@ type RawProviderReference = ConfigProviderReference<ProviderType>;
|
|||||||
*/
|
*/
|
||||||
export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
|
export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
|
||||||
const configPath = getProjectConfigPath(projectDir);
|
const configPath = getProjectConfigPath(projectDir);
|
||||||
|
|
||||||
const rawConfig: Record<string, unknown> = {};
|
const rawConfig: Record<string, unknown> = {};
|
||||||
if (existsSync(configPath)) {
|
if (existsSync(configPath)) {
|
||||||
const content = readFileSync(configPath, 'utf-8');
|
const content = readFileSync(configPath, 'utf-8');
|
||||||
@ -110,7 +109,6 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
|
|||||||
model as string | undefined,
|
model as string | undefined,
|
||||||
provider_options as Record<string, unknown> | undefined,
|
provider_options as Record<string, unknown> | undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const normalizedSubmodules = normalizeSubmodules(submodules);
|
const normalizedSubmodules = normalizeSubmodules(submodules);
|
||||||
const normalizedWithSubmodules = normalizeWithSubmodules(with_submodules);
|
const normalizedWithSubmodules = normalizeWithSubmodules(with_submodules);
|
||||||
const effectiveWithSubmodules = normalizedSubmodules === undefined ? normalizedWithSubmodules : undefined;
|
const effectiveWithSubmodules = normalizedSubmodules === undefined ? normalizedWithSubmodules : undefined;
|
||||||
@ -142,7 +140,14 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
|
|||||||
model: normalizedProvider.model,
|
model: normalizedProvider.model,
|
||||||
providerOptions: normalizedProvider.providerOptions,
|
providerOptions: normalizedProvider.providerOptions,
|
||||||
providerProfiles: normalizeProviderProfiles(provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | 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),
|
pieceOverrides: normalizePieceOverrides(
|
||||||
|
piece_overrides as {
|
||||||
|
quality_gates?: string[];
|
||||||
|
quality_gates_edit_only?: boolean;
|
||||||
|
movements?: Record<string, { quality_gates?: string[] }>;
|
||||||
|
personas?: Record<string, { quality_gates?: string[] }>;
|
||||||
|
} | undefined
|
||||||
|
),
|
||||||
runtime: normalizeRuntime(runtime),
|
runtime: normalizeRuntime(runtime),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -153,11 +158,9 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
|
|||||||
export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig): void {
|
export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig): void {
|
||||||
const configDir = getProjectConfigDir(projectDir);
|
const configDir = getProjectConfigDir(projectDir);
|
||||||
const configPath = getProjectConfigPath(projectDir);
|
const configPath = getProjectConfigPath(projectDir);
|
||||||
|
|
||||||
if (!existsSync(configDir)) {
|
if (!existsSync(configDir)) {
|
||||||
mkdirSync(configDir, { recursive: true });
|
mkdirSync(configDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
copyProjectResourcesToDir(configDir);
|
copyProjectResourcesToDir(configDir);
|
||||||
|
|
||||||
const savePayload: Record<string, unknown> = { ...config };
|
const savePayload: Record<string, unknown> = { ...config };
|
||||||
@ -243,6 +246,8 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
|
|||||||
}
|
}
|
||||||
if (config.personaProviders && Object.keys(config.personaProviders).length > 0) {
|
if (config.personaProviders && Object.keys(config.personaProviders).length > 0) {
|
||||||
savePayload.persona_providers = config.personaProviders;
|
savePayload.persona_providers = config.personaProviders;
|
||||||
|
} else {
|
||||||
|
delete savePayload.persona_providers;
|
||||||
}
|
}
|
||||||
if (normalizedSubmodules !== undefined) {
|
if (normalizedSubmodules !== undefined) {
|
||||||
savePayload.submodules = normalizedSubmodules;
|
savePayload.submodules = normalizedSubmodules;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user