@helmiq/crew 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/defaults/personas/architect.persona.yaml +72 -0
- package/defaults/personas/engineer.persona.yaml +137 -0
- package/defaults/personas/persona-spec.schema.yaml +149 -0
- package/defaults/personas/reviewer.persona.yaml +47 -0
- package/defaults/rubrics/adr.rubric.yaml +48 -0
- package/defaults/rubrics/code-review.rubric.yaml +39 -0
- package/defaults/rubrics/pull-request.rubric.yaml +40 -0
- package/dist/actions/actions.test.d.ts +2 -0
- package/dist/actions/actions.test.d.ts.map +1 -0
- package/dist/actions/actions.test.js +158 -0
- package/dist/actions/direct-dispatcher.d.ts +10 -0
- package/dist/actions/direct-dispatcher.d.ts.map +1 -0
- package/dist/actions/direct-dispatcher.js +27 -0
- package/dist/actions/dispatcher.d.ts +11 -0
- package/dist/actions/dispatcher.d.ts.map +1 -0
- package/dist/actions/dispatcher.js +1 -0
- package/dist/actions/index.d.ts +7 -0
- package/dist/actions/index.d.ts.map +1 -0
- package/dist/actions/index.js +3 -0
- package/dist/actions/registry.d.ts +13 -0
- package/dist/actions/registry.d.ts.map +1 -0
- package/dist/actions/registry.js +40 -0
- package/dist/actions/resolver.d.ts +47 -0
- package/dist/actions/resolver.d.ts.map +1 -0
- package/dist/actions/resolver.js +43 -0
- package/dist/cli/cli.test.d.ts +2 -0
- package/dist/cli/cli.test.d.ts.map +1 -0
- package/dist/cli/cli.test.js +392 -0
- package/dist/cli/run.d.ts +45 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +236 -0
- package/dist/common/errors.d.ts +76 -0
- package/dist/common/errors.d.ts.map +1 -0
- package/dist/common/errors.js +74 -0
- package/dist/config/config.test.d.ts +2 -0
- package/dist/config/config.test.d.ts.map +1 -0
- package/dist/config/config.test.js +691 -0
- package/dist/config/index.d.ts +7 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +4 -0
- package/dist/config/loader.d.ts +16 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +56 -0
- package/dist/config/model-resolver.d.ts +24 -0
- package/dist/config/model-resolver.d.ts.map +1 -0
- package/dist/config/model-resolver.js +39 -0
- package/dist/config/resolver.d.ts +22 -0
- package/dist/config/resolver.d.ts.map +1 -0
- package/dist/config/resolver.js +115 -0
- package/dist/config/schemas.d.ts +266 -0
- package/dist/config/schemas.d.ts.map +1 -0
- package/dist/config/schemas.js +115 -0
- package/dist/context/artifact-reader.d.ts +12 -0
- package/dist/context/artifact-reader.d.ts.map +1 -0
- package/dist/context/artifact-reader.js +92 -0
- package/dist/context/assembler.d.ts +22 -0
- package/dist/context/assembler.d.ts.map +1 -0
- package/dist/context/assembler.js +126 -0
- package/dist/context/code-reader.d.ts +14 -0
- package/dist/context/code-reader.d.ts.map +1 -0
- package/dist/context/code-reader.js +56 -0
- package/dist/context/context.test.d.ts +2 -0
- package/dist/context/context.test.d.ts.map +1 -0
- package/dist/context/context.test.js +260 -0
- package/dist/context/index.d.ts +9 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +5 -0
- package/dist/context/section-extractor.d.ts +9 -0
- package/dist/context/section-extractor.d.ts.map +1 -0
- package/dist/context/section-extractor.js +32 -0
- package/dist/context/token-budget.d.ts +11 -0
- package/dist/context/token-budget.d.ts.map +1 -0
- package/dist/context/token-budget.js +22 -0
- package/dist/control/control.test.d.ts +2 -0
- package/dist/control/control.test.d.ts.map +1 -0
- package/dist/control/control.test.js +137 -0
- package/dist/control/id-generator.d.ts +12 -0
- package/dist/control/id-generator.d.ts.map +1 -0
- package/dist/control/id-generator.js +20 -0
- package/dist/control/index.d.ts +5 -0
- package/dist/control/index.d.ts.map +1 -0
- package/dist/control/index.js +3 -0
- package/dist/control/lock-manager.d.ts +13 -0
- package/dist/control/lock-manager.d.ts.map +1 -0
- package/dist/control/lock-manager.js +72 -0
- package/dist/control/run-state.d.ts +16 -0
- package/dist/control/run-state.d.ts.map +1 -0
- package/dist/control/run-state.js +55 -0
- package/dist/engine/composite.d.ts +34 -0
- package/dist/engine/composite.d.ts.map +1 -0
- package/dist/engine/composite.js +192 -0
- package/dist/engine/composite.test.d.ts +2 -0
- package/dist/engine/composite.test.d.ts.map +1 -0
- package/dist/engine/composite.test.js +1947 -0
- package/dist/engine/engine.test.d.ts +2 -0
- package/dist/engine/engine.test.d.ts.map +1 -0
- package/dist/engine/engine.test.js +334 -0
- package/dist/engine/index.d.ts +10 -0
- package/dist/engine/index.d.ts.map +1 -0
- package/dist/engine/index.js +5 -0
- package/dist/engine/llm-client.d.ts +27 -0
- package/dist/engine/llm-client.d.ts.map +1 -0
- package/dist/engine/llm-client.js +46 -0
- package/dist/engine/simple.d.ts +21 -0
- package/dist/engine/simple.d.ts.map +1 -0
- package/dist/engine/simple.js +59 -0
- package/dist/engine/tool-dispatch.d.ts +37 -0
- package/dist/engine/tool-dispatch.d.ts.map +1 -0
- package/dist/engine/tool-dispatch.js +146 -0
- package/dist/engine/tool-dispatch.test.d.ts +2 -0
- package/dist/engine/tool-dispatch.test.d.ts.map +1 -0
- package/dist/engine/tool-dispatch.test.js +348 -0
- package/dist/engine/tool-filter.d.ts +13 -0
- package/dist/engine/tool-filter.d.ts.map +1 -0
- package/dist/engine/tool-filter.js +25 -0
- package/dist/evaluation/evaluation.test.d.ts +2 -0
- package/dist/evaluation/evaluation.test.d.ts.map +1 -0
- package/dist/evaluation/evaluation.test.js +490 -0
- package/dist/evaluation/evaluator.d.ts +19 -0
- package/dist/evaluation/evaluator.d.ts.map +1 -0
- package/dist/evaluation/evaluator.js +78 -0
- package/dist/evaluation/index.d.ts +4 -0
- package/dist/evaluation/index.d.ts.map +1 -0
- package/dist/evaluation/index.js +2 -0
- package/dist/evaluation/scorer.d.ts +38 -0
- package/dist/evaluation/scorer.d.ts.map +1 -0
- package/dist/evaluation/scorer.js +94 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/providers/index.d.ts +2 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +1 -0
- package/dist/providers/provider-factory.d.ts +11 -0
- package/dist/providers/provider-factory.d.ts.map +1 -0
- package/dist/providers/provider-factory.js +30 -0
- package/dist/publication/frontmatter.d.ts +21 -0
- package/dist/publication/frontmatter.d.ts.map +1 -0
- package/dist/publication/frontmatter.js +15 -0
- package/dist/publication/git-ops.d.ts +18 -0
- package/dist/publication/git-ops.d.ts.map +1 -0
- package/dist/publication/git-ops.js +74 -0
- package/dist/publication/index.d.ts +9 -0
- package/dist/publication/index.d.ts.map +1 -0
- package/dist/publication/index.js +5 -0
- package/dist/publication/provenance-writer.d.ts +27 -0
- package/dist/publication/provenance-writer.d.ts.map +1 -0
- package/dist/publication/provenance-writer.js +21 -0
- package/dist/publication/publication.test.d.ts +2 -0
- package/dist/publication/publication.test.d.ts.map +1 -0
- package/dist/publication/publication.test.js +235 -0
- package/dist/publication/publisher.d.ts +32 -0
- package/dist/publication/publisher.d.ts.map +1 -0
- package/dist/publication/publisher.js +113 -0
- package/dist/publication/secret-scanner.d.ts +6 -0
- package/dist/publication/secret-scanner.d.ts.map +1 -0
- package/dist/publication/secret-scanner.js +19 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +2 -0
- package/dist/tools/registry.d.ts +15 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +288 -0
- package/dist/tools/registry.test.d.ts +2 -0
- package/dist/tools/registry.test.d.ts.map +1 -0
- package/dist/tools/registry.test.js +131 -0
- package/dist/tools/tool-groups.d.ts +20 -0
- package/dist/tools/tool-groups.d.ts.map +1 -0
- package/dist/tools/tool-groups.js +48 -0
- package/dist/tools/tool-groups.test.d.ts +2 -0
- package/dist/tools/tool-groups.test.d.ts.map +1 -0
- package/dist/tools/tool-groups.test.js +127 -0
- package/dist/types/artifact-store.d.ts +33 -0
- package/dist/types/artifact-store.d.ts.map +1 -0
- package/dist/types/artifact-store.js +9 -0
- package/dist/types/evaluation-rubric.d.ts +18 -0
- package/dist/types/evaluation-rubric.d.ts.map +1 -0
- package/dist/types/evaluation-rubric.js +1 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/llm-provider.d.ts +47 -0
- package/dist/types/llm-provider.d.ts.map +1 -0
- package/dist/types/llm-provider.js +8 -0
- package/dist/types/persona-spec.d.ts +79 -0
- package/dist/types/persona-spec.d.ts.map +1 -0
- package/dist/types/persona-spec.js +1 -0
- package/dist/types/project-config.d.ts +28 -0
- package/dist/types/project-config.d.ts.map +1 -0
- package/dist/types/project-config.js +1 -0
- package/dist/types/provenance.d.ts +67 -0
- package/dist/types/provenance.d.ts.map +1 -0
- package/dist/types/provenance.js +1 -0
- package/dist/types/run-state.d.ts +11 -0
- package/dist/types/run-state.d.ts.map +1 -0
- package/dist/types/run-state.js +1 -0
- package/dist/types/tool-runtime.d.ts +43 -0
- package/dist/types/tool-runtime.d.ts.map +1 -0
- package/dist/types/tool-runtime.js +30 -0
- package/dist/workspace/detect.d.ts +11 -0
- package/dist/workspace/detect.d.ts.map +1 -0
- package/dist/workspace/detect.js +28 -0
- package/dist/workspace/detect.test.d.ts +2 -0
- package/dist/workspace/detect.test.d.ts.map +1 -0
- package/dist/workspace/detect.test.js +53 -0
- package/dist/workspace/index.d.ts +2 -0
- package/dist/workspace/index.d.ts.map +1 -0
- package/dist/workspace/index.js +1 -0
- package/package.json +51 -0
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { loadConfig } from './resolver.js';
|
|
5
|
+
import { loadYaml } from './loader.js';
|
|
6
|
+
import { ProjectConfigSchema, PersonaSpecSchema, EvaluationRubricSchema } from './schemas.js';
|
|
7
|
+
import { resolveModel, resolveExecutionModel, resolveEvalModel } from './model-resolver.js';
|
|
8
|
+
import { ConfigNotFoundError, PersonaNotFoundError, RubricNotFoundError, SkillNotFoundError, SchemaValidationError, ModelNotFoundError, ModelNotAllowedError, } from '../common/errors.js';
|
|
9
|
+
const testDir = join(fileURLToPath(import.meta.url), '..', '..', '..');
|
|
10
|
+
const fixturesPath = join(testDir, 'test', 'fixtures');
|
|
11
|
+
const defaultsPath = join(testDir, 'test', 'fixtures-defaults');
|
|
12
|
+
const crewRepoPath = testDir;
|
|
13
|
+
describe('T-01-001a: valid config load', () => {
|
|
14
|
+
it('loads and validates a well-formed project config', async () => {
|
|
15
|
+
const configPath = join(fixturesPath, '.crew', 'config');
|
|
16
|
+
const config = await loadYaml(configPath, ProjectConfigSchema, 'Project config');
|
|
17
|
+
expect(config.project.name).toBe('Test Project');
|
|
18
|
+
expect(config.project.key).toBe('TEST');
|
|
19
|
+
expect(config.llm.default_model).toBe('claude-sonnet');
|
|
20
|
+
expect(config.llm.providers['anthropic']?.api_key_env).toBe('ANTHROPIC_API_KEY');
|
|
21
|
+
});
|
|
22
|
+
it('loads and validates a well-formed persona spec', async () => {
|
|
23
|
+
const specPath = join(fixturesPath, '.crew', 'agents', 'engineer', 'persona.yaml');
|
|
24
|
+
const spec = await loadYaml(specPath, PersonaSpecSchema, 'Persona spec');
|
|
25
|
+
expect(spec.persona.name).toBe('engineer');
|
|
26
|
+
expect(spec.persona.identity.role).toBe('Senior Software Engineer');
|
|
27
|
+
expect(spec.persona.tasks['implement-story']?.mode).toBe('composite');
|
|
28
|
+
});
|
|
29
|
+
it('loads and validates a well-formed rubric', async () => {
|
|
30
|
+
const rubricPath = join(fixturesPath, '.crew', 'rubrics', 'rubrics', 'pull-request.rubric.yaml');
|
|
31
|
+
const rubric = await loadYaml(rubricPath, EvaluationRubricSchema, 'Rubric');
|
|
32
|
+
expect(rubric.rubric.artifact_type).toBe('pull-request');
|
|
33
|
+
expect(rubric.rubric.pass_threshold).toBe(7);
|
|
34
|
+
expect(rubric.rubric.criteria.length).toBe(3);
|
|
35
|
+
expect(rubric.rubric.criteria[0]?.weight).toBe('blocking');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
describe('T-01-001b: override resolution', () => {
|
|
39
|
+
it('resolves persona from project override when present', async () => {
|
|
40
|
+
const result = await loadConfig({
|
|
41
|
+
workspacePath: fixturesPath,
|
|
42
|
+
personaName: 'engineer',
|
|
43
|
+
crewRepoPath: defaultsPath,
|
|
44
|
+
});
|
|
45
|
+
expect(result.persona.persona.name).toBe('engineer');
|
|
46
|
+
expect(result.persona.persona.identity.role).toBe('Senior Software Engineer');
|
|
47
|
+
});
|
|
48
|
+
it('falls back to defaults when no project override exists', async () => {
|
|
49
|
+
const result = await loadConfig({
|
|
50
|
+
workspacePath: fixturesPath,
|
|
51
|
+
personaName: 'reviewer',
|
|
52
|
+
crewRepoPath: defaultsPath,
|
|
53
|
+
});
|
|
54
|
+
expect(result.persona.persona.name).toBe('reviewer');
|
|
55
|
+
expect(result.persona.persona.identity.role).toBe('Senior Code Reviewer');
|
|
56
|
+
});
|
|
57
|
+
it('resolves rubric from project override when present', async () => {
|
|
58
|
+
const result = await loadConfig({
|
|
59
|
+
workspacePath: fixturesPath,
|
|
60
|
+
personaName: 'engineer',
|
|
61
|
+
crewRepoPath: defaultsPath,
|
|
62
|
+
});
|
|
63
|
+
expect(result.rubric.rubric.artifact_type).toBe('pull-request');
|
|
64
|
+
});
|
|
65
|
+
it('falls back to default rubric when no project override exists', async () => {
|
|
66
|
+
const result = await loadConfig({
|
|
67
|
+
workspacePath: fixturesPath,
|
|
68
|
+
personaName: 'reviewer',
|
|
69
|
+
crewRepoPath: defaultsPath,
|
|
70
|
+
});
|
|
71
|
+
expect(result.rubric.rubric.artifact_type).toBe('code-review');
|
|
72
|
+
});
|
|
73
|
+
it('returns typed ProjectConfig, PersonaSpec, and EvaluationRubric', async () => {
|
|
74
|
+
const result = await loadConfig({
|
|
75
|
+
workspacePath: fixturesPath,
|
|
76
|
+
personaName: 'engineer',
|
|
77
|
+
crewRepoPath: defaultsPath,
|
|
78
|
+
});
|
|
79
|
+
expect(result.project.project.name).toBe('Test Project');
|
|
80
|
+
expect(result.persona.persona.name).toBe('engineer');
|
|
81
|
+
expect(result.rubric.rubric.artifact_type).toBe('pull-request');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
describe('T-01-001c: schema validation errors', () => {
|
|
85
|
+
it('throws ConfigNotFoundError for missing config file', async () => {
|
|
86
|
+
await expect(loadYaml('/nonexistent/path/config', ProjectConfigSchema, 'Project config')).rejects.toThrow(ConfigNotFoundError);
|
|
87
|
+
});
|
|
88
|
+
it('throws SchemaValidationError for invalid YAML syntax', async () => {
|
|
89
|
+
const { writeFile, mkdir } = await import('node:fs/promises');
|
|
90
|
+
const tmpDir = join(testDir, 'tmp-invalid-yaml');
|
|
91
|
+
await mkdir(tmpDir, { recursive: true });
|
|
92
|
+
const badPath = join(tmpDir, 'bad.yaml');
|
|
93
|
+
await writeFile(badPath, ' :\n - invalid: [unclosed', 'utf-8');
|
|
94
|
+
try {
|
|
95
|
+
await expect(loadYaml(badPath, ProjectConfigSchema, 'Bad YAML')).rejects.toThrow(SchemaValidationError);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
const { rm } = await import('node:fs/promises');
|
|
99
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
it('throws SchemaValidationError with details for schema violations', async () => {
|
|
103
|
+
const { writeFile, mkdir } = await import('node:fs/promises');
|
|
104
|
+
const tmpDir = join(testDir, 'tmp-invalid-schema');
|
|
105
|
+
await mkdir(tmpDir, { recursive: true });
|
|
106
|
+
const badPath = join(tmpDir, 'incomplete.yaml');
|
|
107
|
+
await writeFile(badPath, 'project:\n name: "Missing fields"\n', 'utf-8');
|
|
108
|
+
try {
|
|
109
|
+
await expect(loadYaml(badPath, ProjectConfigSchema, 'Incomplete config')).rejects.toThrow(SchemaValidationError);
|
|
110
|
+
try {
|
|
111
|
+
await loadYaml(badPath, ProjectConfigSchema, 'Incomplete config');
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
expect(err).toBeInstanceOf(SchemaValidationError);
|
|
115
|
+
expect(err.message).toContain('failed schema validation');
|
|
116
|
+
expect(err.details).toBeDefined();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
const { rm } = await import('node:fs/promises');
|
|
121
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
it('throws PersonaNotFoundError when persona is missing everywhere', async () => {
|
|
125
|
+
await expect(loadConfig({
|
|
126
|
+
workspacePath: fixturesPath,
|
|
127
|
+
personaName: 'nonexistent-persona',
|
|
128
|
+
crewRepoPath: defaultsPath,
|
|
129
|
+
})).rejects.toThrow(PersonaNotFoundError);
|
|
130
|
+
});
|
|
131
|
+
it('throws RubricNotFoundError when rubric is missing everywhere', async () => {
|
|
132
|
+
const { writeFile, mkdir } = await import('node:fs/promises');
|
|
133
|
+
const tmpDir = join(testDir, 'tmp-missing-rubric');
|
|
134
|
+
const agentDir = join(tmpDir, '.crew', 'agents', 'badrubric');
|
|
135
|
+
await mkdir(agentDir, { recursive: true });
|
|
136
|
+
const crewDir = join(tmpDir, '.crew');
|
|
137
|
+
await writeFile(join(crewDir, 'config'), `project:
|
|
138
|
+
name: Test
|
|
139
|
+
key: T
|
|
140
|
+
workspace:
|
|
141
|
+
path: .
|
|
142
|
+
work: work/
|
|
143
|
+
runs: runs/
|
|
144
|
+
source:
|
|
145
|
+
repo: github:test/repo
|
|
146
|
+
path: ../t
|
|
147
|
+
llm:
|
|
148
|
+
default_model: claude-sonnet
|
|
149
|
+
providers:
|
|
150
|
+
anthropic:
|
|
151
|
+
api_key_env: ANTHROPIC_API_KEY
|
|
152
|
+
models:
|
|
153
|
+
claude-sonnet: claude-sonnet-4-20250514
|
|
154
|
+
`, 'utf-8');
|
|
155
|
+
const skillDir = join(tmpDir, '.crew', 'skills', 'test-skill');
|
|
156
|
+
await mkdir(skillDir, { recursive: true });
|
|
157
|
+
await writeFile(join(skillDir, 'test-skill.prompt.md'), 'Test skill prompt', 'utf-8');
|
|
158
|
+
await writeFile(join(agentDir, 'persona.yaml'), `persona:
|
|
159
|
+
name: badrubric
|
|
160
|
+
identity:
|
|
161
|
+
role: Test
|
|
162
|
+
skills:
|
|
163
|
+
- test-skill
|
|
164
|
+
perception:
|
|
165
|
+
per_task:
|
|
166
|
+
do-thing:
|
|
167
|
+
- artifact: test
|
|
168
|
+
tasks:
|
|
169
|
+
do-thing:
|
|
170
|
+
mode: simple
|
|
171
|
+
trigger: [manual]
|
|
172
|
+
skill: test-skill
|
|
173
|
+
produces: output
|
|
174
|
+
tools:
|
|
175
|
+
permitted: [read-artifact]
|
|
176
|
+
denied: []
|
|
177
|
+
cadence: {}
|
|
178
|
+
evaluation:
|
|
179
|
+
rubric: nonexistent/rubric.yaml
|
|
180
|
+
`, 'utf-8');
|
|
181
|
+
try {
|
|
182
|
+
await expect(loadConfig({
|
|
183
|
+
workspacePath: tmpDir,
|
|
184
|
+
personaName: 'badrubric',
|
|
185
|
+
crewRepoPath: defaultsPath,
|
|
186
|
+
})).rejects.toThrow(RubricNotFoundError);
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
const { rm } = await import('node:fs/promises');
|
|
190
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
describe('T-01-009a: model alias resolution', () => {
|
|
195
|
+
const llmConfig = {
|
|
196
|
+
default_model: 'claude-sonnet',
|
|
197
|
+
allowed_models: ['claude-sonnet', 'claude-haiku'],
|
|
198
|
+
providers: {
|
|
199
|
+
anthropic: {
|
|
200
|
+
api_key_env: 'ANTHROPIC_API_KEY',
|
|
201
|
+
models: {
|
|
202
|
+
'claude-sonnet': 'claude-sonnet-4-20250514',
|
|
203
|
+
'claude-haiku': 'claude-haiku-3-20250414',
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
it('resolves a valid alias to its concrete model', () => {
|
|
209
|
+
const result = resolveModel(llmConfig, 'claude-sonnet');
|
|
210
|
+
expect(result.alias).toBe('claude-sonnet');
|
|
211
|
+
expect(result.provider).toBe('anthropic');
|
|
212
|
+
expect(result.concreteModel).toBe('claude-sonnet-4-20250514');
|
|
213
|
+
expect(result.apiKeyEnv).toBe('ANTHROPIC_API_KEY');
|
|
214
|
+
});
|
|
215
|
+
it('resolves the execution model using project default', () => {
|
|
216
|
+
const result = resolveExecutionModel(llmConfig);
|
|
217
|
+
expect(result.alias).toBe('claude-sonnet');
|
|
218
|
+
expect(result.concreteModel).toBe('claude-sonnet-4-20250514');
|
|
219
|
+
});
|
|
220
|
+
it('resolves eval model with persona override', () => {
|
|
221
|
+
const result = resolveEvalModel(llmConfig, 'claude-haiku');
|
|
222
|
+
expect(result.alias).toBe('claude-haiku');
|
|
223
|
+
expect(result.concreteModel).toBe('claude-haiku-3-20250414');
|
|
224
|
+
});
|
|
225
|
+
it('resolves eval model falling back to project default', () => {
|
|
226
|
+
const result = resolveEvalModel(llmConfig, undefined);
|
|
227
|
+
expect(result.alias).toBe('claude-sonnet');
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
describe('T-01-009b: persona override within allowed set', () => {
|
|
231
|
+
it('allows a model that is in the allowed list', () => {
|
|
232
|
+
const llmConfig = {
|
|
233
|
+
default_model: 'claude-sonnet',
|
|
234
|
+
allowed_models: ['claude-sonnet', 'claude-haiku'],
|
|
235
|
+
providers: {
|
|
236
|
+
anthropic: {
|
|
237
|
+
api_key_env: 'ANTHROPIC_API_KEY',
|
|
238
|
+
models: {
|
|
239
|
+
'claude-sonnet': 'claude-sonnet-4-20250514',
|
|
240
|
+
'claude-haiku': 'claude-haiku-3-20250414',
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
expect(() => resolveModel(llmConfig, 'claude-haiku')).not.toThrow();
|
|
246
|
+
});
|
|
247
|
+
it('allows any model when allowed_models is not set', () => {
|
|
248
|
+
const llmConfig = {
|
|
249
|
+
default_model: 'claude-sonnet',
|
|
250
|
+
providers: {
|
|
251
|
+
anthropic: {
|
|
252
|
+
api_key_env: 'ANTHROPIC_API_KEY',
|
|
253
|
+
models: {
|
|
254
|
+
'claude-sonnet': 'claude-sonnet-4-20250514',
|
|
255
|
+
'claude-haiku': 'claude-haiku-3-20250414',
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
expect(() => resolveModel(llmConfig, 'claude-haiku')).not.toThrow();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
describe('T-01-009c: rejection of disallowed model', () => {
|
|
264
|
+
it('throws ModelNotAllowedError for a model not in allowed_models', () => {
|
|
265
|
+
const llmConfig = {
|
|
266
|
+
default_model: 'claude-sonnet',
|
|
267
|
+
allowed_models: ['claude-sonnet'],
|
|
268
|
+
providers: {
|
|
269
|
+
anthropic: {
|
|
270
|
+
api_key_env: 'ANTHROPIC_API_KEY',
|
|
271
|
+
models: {
|
|
272
|
+
'claude-sonnet': 'claude-sonnet-4-20250514',
|
|
273
|
+
'claude-haiku': 'claude-haiku-3-20250414',
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
expect(() => resolveModel(llmConfig, 'claude-haiku')).toThrow(ModelNotAllowedError);
|
|
279
|
+
});
|
|
280
|
+
it('throws ModelNotFoundError for a completely unknown alias', () => {
|
|
281
|
+
const llmConfig = {
|
|
282
|
+
default_model: 'claude-sonnet',
|
|
283
|
+
providers: {
|
|
284
|
+
anthropic: {
|
|
285
|
+
api_key_env: 'ANTHROPIC_API_KEY',
|
|
286
|
+
models: { 'claude-sonnet': 'claude-sonnet-4-20250514' },
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
expect(() => resolveModel(llmConfig, 'gpt-4o')).toThrow(ModelNotFoundError);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
describe('T-FR-01-1: default Engineer persona spec schema validation', () => {
|
|
294
|
+
let spec;
|
|
295
|
+
beforeAll(async () => {
|
|
296
|
+
const specPath = join(crewRepoPath, 'defaults', 'personas', 'engineer.persona.yaml');
|
|
297
|
+
spec = await loadYaml(specPath, PersonaSpecSchema, 'Engineer persona spec');
|
|
298
|
+
});
|
|
299
|
+
it('loads and validates the default engineer persona spec', () => {
|
|
300
|
+
expect(spec.persona.name).toBe('engineer');
|
|
301
|
+
});
|
|
302
|
+
it('defines all persona dimensions: identity, skills, perception, tasks, tools, cadence, evaluation', () => {
|
|
303
|
+
expect(spec.persona.identity).toBeDefined();
|
|
304
|
+
expect(spec.persona.identity.role).toBe('Senior Software Engineer');
|
|
305
|
+
expect(spec.persona.skills).toBeDefined();
|
|
306
|
+
expect(spec.persona.skills.length).toBeGreaterThan(0);
|
|
307
|
+
expect(spec.persona.perception).toBeDefined();
|
|
308
|
+
expect(spec.persona.perception.per_task).toBeDefined();
|
|
309
|
+
expect(spec.persona.tasks).toBeDefined();
|
|
310
|
+
expect(Object.keys(spec.persona.tasks).length).toBeGreaterThan(0);
|
|
311
|
+
expect(spec.persona.tools).toBeDefined();
|
|
312
|
+
expect(spec.persona.tools.permitted).toBeDefined();
|
|
313
|
+
expect(spec.persona.tools.denied).toBeDefined();
|
|
314
|
+
expect(spec.persona.cadence).toBeDefined();
|
|
315
|
+
expect(spec.persona.evaluation).toBeDefined();
|
|
316
|
+
expect(spec.persona.evaluation.rubric).toBeTruthy();
|
|
317
|
+
});
|
|
318
|
+
it('declares all required skills', () => {
|
|
319
|
+
expect(spec.persona.skills).toContain('feature-implement');
|
|
320
|
+
expect(spec.persona.skills).toContain('code-implement');
|
|
321
|
+
expect(spec.persona.skills).toContain('implementation-plan');
|
|
322
|
+
expect(spec.persona.skills).toContain('code-review');
|
|
323
|
+
expect(spec.persona.skills).toContain('quality-check');
|
|
324
|
+
expect(spec.persona.skills).toContain('test-write');
|
|
325
|
+
expect(spec.persona.skills).toContain('self-review');
|
|
326
|
+
expect(spec.persona.skills).toContain('pr-author');
|
|
327
|
+
});
|
|
328
|
+
it('defines implement-story as a composite task with six sub-agents', () => {
|
|
329
|
+
const task = spec.persona.tasks['implement-story'];
|
|
330
|
+
expect(task).toBeDefined();
|
|
331
|
+
expect(task.mode).toBe('composite');
|
|
332
|
+
const composite = task;
|
|
333
|
+
expect(composite.sub_agents).toHaveLength(6);
|
|
334
|
+
const names = composite.sub_agents.map((a) => a.name);
|
|
335
|
+
expect(names).toEqual([
|
|
336
|
+
'planner',
|
|
337
|
+
'implementer',
|
|
338
|
+
'test-writer',
|
|
339
|
+
'quality-checker',
|
|
340
|
+
'self-reviewer',
|
|
341
|
+
'pr-author',
|
|
342
|
+
]);
|
|
343
|
+
});
|
|
344
|
+
it('maps sub-agents to skills', () => {
|
|
345
|
+
const task = spec.persona.tasks['implement-story'];
|
|
346
|
+
const byName = Object.fromEntries(task.sub_agents.map((a) => [a.name, a.skill]));
|
|
347
|
+
expect(byName['planner']).toBe('implementation-plan');
|
|
348
|
+
expect(byName['implementer']).toBe('code-implement');
|
|
349
|
+
expect(byName['test-writer']).toBe('test-write');
|
|
350
|
+
expect(byName['quality-checker']).toBe('quality-check');
|
|
351
|
+
expect(byName['self-reviewer']).toBe('self-review');
|
|
352
|
+
expect(byName['pr-author']).toBe('pr-author');
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
describe('T-FR-01-2: skill resolution', () => {
|
|
356
|
+
it('resolves skills to prompt content at load time', async () => {
|
|
357
|
+
const result = await loadConfig({
|
|
358
|
+
workspacePath: fixturesPath,
|
|
359
|
+
personaName: 'engineer',
|
|
360
|
+
crewRepoPath: defaultsPath,
|
|
361
|
+
});
|
|
362
|
+
expect(result.skills['feature-implementation']).toBeDefined();
|
|
363
|
+
expect(result.skills['feature-implementation']).toContain('Senior Software Engineer');
|
|
364
|
+
});
|
|
365
|
+
it('prefers workspace .crew/skills/ over default skills/', async () => {
|
|
366
|
+
const result = await loadConfig({
|
|
367
|
+
workspacePath: fixturesPath,
|
|
368
|
+
personaName: 'engineer',
|
|
369
|
+
crewRepoPath: defaultsPath,
|
|
370
|
+
});
|
|
371
|
+
expect(result.skills['feature-implementation']).toContain('workspace override');
|
|
372
|
+
});
|
|
373
|
+
it('falls back to default skills/ when workspace override absent', async () => {
|
|
374
|
+
const result = await loadConfig({
|
|
375
|
+
workspacePath: fixturesPath,
|
|
376
|
+
personaName: 'reviewer',
|
|
377
|
+
crewRepoPath: defaultsPath,
|
|
378
|
+
});
|
|
379
|
+
expect(result.skills['code-review']).toContain('ANALYZING code changes');
|
|
380
|
+
});
|
|
381
|
+
it('resolves implementation-planner skill from defaults', async () => {
|
|
382
|
+
const result = await loadConfig({
|
|
383
|
+
workspacePath: fixturesPath,
|
|
384
|
+
personaName: 'engineer',
|
|
385
|
+
crewRepoPath: defaultsPath,
|
|
386
|
+
});
|
|
387
|
+
expect(result.skills['implementation-planner']).toBeDefined();
|
|
388
|
+
expect(result.skills['implementation-planner']).toContain('ANALYZING requirements');
|
|
389
|
+
});
|
|
390
|
+
it('resolves self-review skill from defaults', async () => {
|
|
391
|
+
const result = await loadConfig({
|
|
392
|
+
workspacePath: fixturesPath,
|
|
393
|
+
personaName: 'engineer',
|
|
394
|
+
crewRepoPath: defaultsPath,
|
|
395
|
+
});
|
|
396
|
+
expect(result.skills['self-review']).toBeDefined();
|
|
397
|
+
expect(result.skills['self-review']).toContain('self-reviewer');
|
|
398
|
+
});
|
|
399
|
+
it('resolves pr-authoring skill from defaults', async () => {
|
|
400
|
+
const result = await loadConfig({
|
|
401
|
+
workspacePath: fixturesPath,
|
|
402
|
+
personaName: 'engineer',
|
|
403
|
+
crewRepoPath: defaultsPath,
|
|
404
|
+
});
|
|
405
|
+
expect(result.skills['pr-authoring']).toBeDefined();
|
|
406
|
+
expect(result.skills['pr-authoring']).toContain('CREATING structured pull requests');
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
describe('T-FR-01-3: Engineer tool permissions', () => {
|
|
410
|
+
let spec;
|
|
411
|
+
beforeAll(async () => {
|
|
412
|
+
const specPath = join(crewRepoPath, 'defaults', 'personas', 'engineer.persona.yaml');
|
|
413
|
+
spec = await loadYaml(specPath, PersonaSpecSchema, 'Engineer persona spec');
|
|
414
|
+
});
|
|
415
|
+
it('permits required tools', () => {
|
|
416
|
+
const required = ['read-artifact', 'write-artifact', 'code', 'git-operations', 'shell'];
|
|
417
|
+
for (const tool of required) {
|
|
418
|
+
expect(spec.persona.tools.permitted).toContain(tool);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
it('denies restricted tools', () => {
|
|
422
|
+
const denied = ['write-strategy-artifacts', 'write-standards'];
|
|
423
|
+
for (const tool of denied) {
|
|
424
|
+
expect(spec.persona.tools.denied).toContain(tool);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
it('does not permit any denied tools', () => {
|
|
428
|
+
const overlap = spec.persona.tools.permitted.filter((t) => spec.persona.tools.denied.includes(t));
|
|
429
|
+
expect(overlap).toEqual([]);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
describe('T-FR-01-4: missing skill error', () => {
|
|
433
|
+
it('throws SkillNotFoundError for a missing skill', async () => {
|
|
434
|
+
const { writeFile, mkdir } = await import('node:fs/promises');
|
|
435
|
+
const tmpDir = join(testDir, 'tmp-missing-skill');
|
|
436
|
+
const agentDir = join(tmpDir, '.crew', 'agents', 'badskill');
|
|
437
|
+
await mkdir(agentDir, { recursive: true });
|
|
438
|
+
const crewDir = join(tmpDir, '.crew');
|
|
439
|
+
await writeFile(join(crewDir, 'config'), `project:
|
|
440
|
+
name: Test
|
|
441
|
+
key: T
|
|
442
|
+
workspace:
|
|
443
|
+
path: .
|
|
444
|
+
work: work/
|
|
445
|
+
runs: runs/
|
|
446
|
+
source:
|
|
447
|
+
repo: github:test/repo
|
|
448
|
+
path: ../t
|
|
449
|
+
llm:
|
|
450
|
+
default_model: claude-sonnet
|
|
451
|
+
providers:
|
|
452
|
+
anthropic:
|
|
453
|
+
api_key_env: ANTHROPIC_API_KEY
|
|
454
|
+
models:
|
|
455
|
+
claude-sonnet: claude-sonnet-4-20250514
|
|
456
|
+
`, 'utf-8');
|
|
457
|
+
await writeFile(join(agentDir, 'persona.yaml'), `persona:
|
|
458
|
+
name: badskill
|
|
459
|
+
identity:
|
|
460
|
+
role: Test
|
|
461
|
+
skills:
|
|
462
|
+
- nonexistent-skill
|
|
463
|
+
perception:
|
|
464
|
+
per_task:
|
|
465
|
+
do-thing:
|
|
466
|
+
- artifact: test
|
|
467
|
+
tasks:
|
|
468
|
+
do-thing:
|
|
469
|
+
mode: simple
|
|
470
|
+
trigger: [manual]
|
|
471
|
+
skill: nonexistent-skill
|
|
472
|
+
produces: output
|
|
473
|
+
tools:
|
|
474
|
+
permitted: [read-artifact]
|
|
475
|
+
denied: []
|
|
476
|
+
cadence: {}
|
|
477
|
+
evaluation:
|
|
478
|
+
rubric: rubrics/pull-request.rubric.yaml
|
|
479
|
+
`, 'utf-8');
|
|
480
|
+
try {
|
|
481
|
+
await expect(loadConfig({
|
|
482
|
+
workspacePath: tmpDir,
|
|
483
|
+
personaName: 'badskill',
|
|
484
|
+
crewRepoPath: defaultsPath,
|
|
485
|
+
})).rejects.toThrow(SkillNotFoundError);
|
|
486
|
+
}
|
|
487
|
+
finally {
|
|
488
|
+
const { rm } = await import('node:fs/promises');
|
|
489
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
describe('T-CREW-10-001: test-writer sub-agent has git tools', () => {
|
|
494
|
+
let spec;
|
|
495
|
+
beforeAll(async () => {
|
|
496
|
+
const specPath = join(crewRepoPath, 'defaults', 'personas', 'engineer.persona.yaml');
|
|
497
|
+
spec = await loadYaml(specPath, PersonaSpecSchema, 'Engineer persona spec');
|
|
498
|
+
});
|
|
499
|
+
it('test-writer has code, git, and shell tools', () => {
|
|
500
|
+
const task = spec.persona.tasks['implement-story'];
|
|
501
|
+
const tw = task.sub_agents.find((a) => a.name === 'test-writer');
|
|
502
|
+
expect(tw).toBeDefined();
|
|
503
|
+
expect(tw.tools).toContain('code');
|
|
504
|
+
expect(tw.tools).toContain('git');
|
|
505
|
+
expect(tw.tools).toContain('shell');
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
describe('T-FR-05: quality-checker sub-agent configuration', () => {
|
|
509
|
+
let spec;
|
|
510
|
+
beforeAll(async () => {
|
|
511
|
+
const specPath = join(crewRepoPath, 'defaults', 'personas', 'engineer.persona.yaml');
|
|
512
|
+
spec = await loadYaml(specPath, PersonaSpecSchema, 'Engineer persona spec');
|
|
513
|
+
});
|
|
514
|
+
it('quality-checker references the quality-check skill', () => {
|
|
515
|
+
const task = spec.persona.tasks['implement-story'];
|
|
516
|
+
const qc = task.sub_agents.find((a) => a.name === 'quality-checker');
|
|
517
|
+
expect(qc).toBeDefined();
|
|
518
|
+
expect(qc.skill).toBe('quality-check');
|
|
519
|
+
});
|
|
520
|
+
it('quality-checker has shell, code, and git tools for running checks, fixing failures, and committing', () => {
|
|
521
|
+
const task = spec.persona.tasks['implement-story'];
|
|
522
|
+
const qc = task.sub_agents.find((a) => a.name === 'quality-checker');
|
|
523
|
+
expect(qc.tools).toContain('shell');
|
|
524
|
+
expect(qc.tools).toContain('code');
|
|
525
|
+
expect(qc.tools).toContain('git');
|
|
526
|
+
});
|
|
527
|
+
it('quality-checker has max_iterations of 3', () => {
|
|
528
|
+
const task = spec.persona.tasks['implement-story'];
|
|
529
|
+
const qc = task.sub_agents.find((a) => a.name === 'quality-checker');
|
|
530
|
+
expect(qc.max_iterations).toBe(3);
|
|
531
|
+
});
|
|
532
|
+
it('quality-checker produces quality-report', () => {
|
|
533
|
+
const task = spec.persona.tasks['implement-story'];
|
|
534
|
+
const qc = task.sub_agents.find((a) => a.name === 'quality-checker');
|
|
535
|
+
expect(qc.produces).toBe('quality-report');
|
|
536
|
+
});
|
|
537
|
+
it('address-feedback task also uses quality-check skill for its quality-checker', () => {
|
|
538
|
+
const task = spec.persona.tasks['address-feedback'];
|
|
539
|
+
const qc = task.sub_agents.find((a) => a.name === 'quality-checker');
|
|
540
|
+
expect(qc).toBeDefined();
|
|
541
|
+
expect(qc.skill).toBe('quality-check');
|
|
542
|
+
expect(qc.tools).toContain('shell');
|
|
543
|
+
expect(qc.tools).toContain('code');
|
|
544
|
+
expect(qc.tools).toContain('git');
|
|
545
|
+
expect(qc.max_iterations).toBe(3);
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
describe('T-FR-01 (CREW-05): Architect persona spec and ADR rubric validation', () => {
|
|
549
|
+
let spec;
|
|
550
|
+
beforeAll(async () => {
|
|
551
|
+
const specPath = join(crewRepoPath, 'defaults', 'personas', 'architect.persona.yaml');
|
|
552
|
+
spec = await loadYaml(specPath, PersonaSpecSchema, 'Architect persona spec');
|
|
553
|
+
});
|
|
554
|
+
it('loads and validates the default architect persona spec', () => {
|
|
555
|
+
expect(spec.persona.name).toBe('architect');
|
|
556
|
+
expect(spec.persona.identity.role).toBe('Senior Solution Architect');
|
|
557
|
+
});
|
|
558
|
+
it('declares adr-plan, adr-write, adr-review skills', () => {
|
|
559
|
+
expect(spec.persona.skills).toContain('adr-plan');
|
|
560
|
+
expect(spec.persona.skills).toContain('adr-write');
|
|
561
|
+
expect(spec.persona.skills).toContain('adr-review');
|
|
562
|
+
});
|
|
563
|
+
it('defines adr-workflow as a composite task with three sub-agents', () => {
|
|
564
|
+
const task = spec.persona.tasks['adr-workflow'];
|
|
565
|
+
expect(task).toBeDefined();
|
|
566
|
+
expect(task.mode).toBe('composite');
|
|
567
|
+
const composite = task;
|
|
568
|
+
expect(composite.sub_agents).toHaveLength(3);
|
|
569
|
+
const names = composite.sub_agents.map((a) => a.name);
|
|
570
|
+
expect(names).toEqual(['planner', 'writer', 'reviewer']);
|
|
571
|
+
});
|
|
572
|
+
it('maps sub-agents to skills', () => {
|
|
573
|
+
const task = spec.persona.tasks['adr-workflow'];
|
|
574
|
+
const byName = Object.fromEntries(task.sub_agents.map((a) => [a.name, a.skill]));
|
|
575
|
+
expect(byName['planner']).toBe('adr-plan');
|
|
576
|
+
expect(byName['writer']).toBe('adr-write');
|
|
577
|
+
expect(byName['reviewer']).toBe('adr-review');
|
|
578
|
+
});
|
|
579
|
+
it('reviewer sub-agent has gate with on_fail targeting writer', () => {
|
|
580
|
+
const task = spec.persona.tasks['adr-workflow'];
|
|
581
|
+
const reviewer = task.sub_agents.find((a) => a.name === 'reviewer');
|
|
582
|
+
expect(reviewer.gate).toBeDefined();
|
|
583
|
+
expect(reviewer.on_fail).toBe('writer');
|
|
584
|
+
expect(reviewer.max_loops).toBe(2);
|
|
585
|
+
});
|
|
586
|
+
it('permits read-artifact, write-artifact, and read-code', () => {
|
|
587
|
+
expect(spec.persona.tools.permitted).toContain('read-artifact');
|
|
588
|
+
expect(spec.persona.tools.permitted).toContain('write-artifact');
|
|
589
|
+
expect(spec.persona.tools.permitted).toContain('read-code');
|
|
590
|
+
});
|
|
591
|
+
it('denies write-code, git-write, git-operations, and shell', () => {
|
|
592
|
+
expect(spec.persona.tools.denied).toContain('write-code');
|
|
593
|
+
expect(spec.persona.tools.denied).toContain('git-write');
|
|
594
|
+
expect(spec.persona.tools.denied).toContain('git-operations');
|
|
595
|
+
expect(spec.persona.tools.denied).toContain('shell');
|
|
596
|
+
});
|
|
597
|
+
it('does not permit any denied tools (no overlap after expansion)', async () => {
|
|
598
|
+
const { expandToolNames } = await import('../tools/tool-groups.js');
|
|
599
|
+
const permitted = expandToolNames(spec.persona.tools.permitted);
|
|
600
|
+
const denied = expandToolNames(spec.persona.tools.denied);
|
|
601
|
+
const overlap = [...permitted].filter((t) => denied.has(t));
|
|
602
|
+
expect(overlap).toEqual([]);
|
|
603
|
+
});
|
|
604
|
+
it('declares writable_paths for architecture/decisions/', () => {
|
|
605
|
+
expect(spec.persona.tools.writable_paths).toBeDefined();
|
|
606
|
+
expect(spec.persona.tools.writable_paths).toContain('architecture/decisions/');
|
|
607
|
+
});
|
|
608
|
+
it('references rubrics/adr.rubric.yaml', () => {
|
|
609
|
+
expect(spec.persona.evaluation.rubric).toBe('rubrics/adr.rubric.yaml');
|
|
610
|
+
});
|
|
611
|
+
it('loads and validates the ADR evaluation rubric', async () => {
|
|
612
|
+
const rubricPath = join(crewRepoPath, 'defaults', 'rubrics', 'adr.rubric.yaml');
|
|
613
|
+
const rubric = await loadYaml(rubricPath, EvaluationRubricSchema, 'ADR rubric');
|
|
614
|
+
expect(rubric.rubric.artifact_type).toBe('adr-summary');
|
|
615
|
+
expect(rubric.rubric.scoring_scale).toBe(10);
|
|
616
|
+
expect(rubric.rubric.pass_threshold).toBe(7);
|
|
617
|
+
});
|
|
618
|
+
it('ADR rubric has 3 blocking and 2 important criteria', async () => {
|
|
619
|
+
const rubricPath = join(crewRepoPath, 'defaults', 'rubrics', 'adr.rubric.yaml');
|
|
620
|
+
const rubric = await loadYaml(rubricPath, EvaluationRubricSchema, 'ADR rubric');
|
|
621
|
+
expect(rubric.rubric.criteria).toHaveLength(5);
|
|
622
|
+
const blocking = rubric.rubric.criteria.filter((c) => c.weight === 'blocking');
|
|
623
|
+
const important = rubric.rubric.criteria.filter((c) => c.weight === 'important');
|
|
624
|
+
expect(blocking).toHaveLength(3);
|
|
625
|
+
expect(important).toHaveLength(2);
|
|
626
|
+
});
|
|
627
|
+
it('ADR rubric criteria cover template, balance, rationale, consequences, and contradictions', async () => {
|
|
628
|
+
const rubricPath = join(crewRepoPath, 'defaults', 'rubrics', 'adr.rubric.yaml');
|
|
629
|
+
const rubric = await loadYaml(rubricPath, EvaluationRubricSchema, 'ADR rubric');
|
|
630
|
+
const names = rubric.rubric.criteria.map((c) => c.name);
|
|
631
|
+
expect(names).toContain('ADRs follow project template');
|
|
632
|
+
expect(names).toContain('Options analysis is balanced');
|
|
633
|
+
expect(names).toContain('Rationale is justified by analysis');
|
|
634
|
+
expect(names).toContain('Consequences are documented');
|
|
635
|
+
expect(names).toContain('No contradictions with existing ADRs');
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
describe('T-FR-01 (CREW-04): Reviewer persona spec validation', () => {
|
|
639
|
+
let spec;
|
|
640
|
+
beforeAll(async () => {
|
|
641
|
+
const specPath = join(crewRepoPath, 'defaults', 'personas', 'reviewer.persona.yaml');
|
|
642
|
+
spec = await loadYaml(specPath, PersonaSpecSchema, 'Reviewer persona spec');
|
|
643
|
+
});
|
|
644
|
+
it('loads and validates the default reviewer persona spec', () => {
|
|
645
|
+
expect(spec.persona.name).toBe('reviewer');
|
|
646
|
+
expect(spec.persona.identity.role).toBe('Senior Code Reviewer');
|
|
647
|
+
});
|
|
648
|
+
it('declares code-review skill', () => {
|
|
649
|
+
expect(spec.persona.skills).toContain('code-review');
|
|
650
|
+
});
|
|
651
|
+
it('defines review-pr as a simple task', () => {
|
|
652
|
+
const task = spec.persona.tasks['review-pr'];
|
|
653
|
+
expect(task).toBeDefined();
|
|
654
|
+
expect(task.mode).toBe('simple');
|
|
655
|
+
});
|
|
656
|
+
it('permits read-only code and git tools plus shell', () => {
|
|
657
|
+
expect(spec.persona.tools.permitted).toContain('read-artifact');
|
|
658
|
+
expect(spec.persona.tools.permitted).toContain('write-artifact');
|
|
659
|
+
expect(spec.persona.tools.permitted).toContain('read-code');
|
|
660
|
+
expect(spec.persona.tools.permitted).toContain('git-read');
|
|
661
|
+
expect(spec.persona.tools.permitted).toContain('shell');
|
|
662
|
+
});
|
|
663
|
+
it('denies write-code and git-write', () => {
|
|
664
|
+
expect(spec.persona.tools.denied).toContain('write-code');
|
|
665
|
+
expect(spec.persona.tools.denied).toContain('git-write');
|
|
666
|
+
});
|
|
667
|
+
it('does not permit any denied tools (no overlap after expansion)', async () => {
|
|
668
|
+
const { expandToolNames } = await import('../tools/tool-groups.js');
|
|
669
|
+
const permitted = expandToolNames(spec.persona.tools.permitted);
|
|
670
|
+
const denied = expandToolNames(spec.persona.tools.denied);
|
|
671
|
+
const overlap = [...permitted].filter((t) => denied.has(t));
|
|
672
|
+
expect(overlap).toEqual([]);
|
|
673
|
+
});
|
|
674
|
+
it('has perception for standards, pr-diff, design, requirements, and prior review', () => {
|
|
675
|
+
expect(spec.persona.perception.always_read).toBeDefined();
|
|
676
|
+
expect(spec.persona.perception.always_read.length).toBeGreaterThan(0);
|
|
677
|
+
expect(spec.persona.perception.always_read[0].artifact).toBe('standards');
|
|
678
|
+
const reviewPrRefs = spec.persona.perception.per_task['review-pr'];
|
|
679
|
+
expect(reviewPrRefs).toBeDefined();
|
|
680
|
+
const artifactTypes = reviewPrRefs.map((r) => r.artifact);
|
|
681
|
+
expect(artifactTypes).toContain('pr-diff');
|
|
682
|
+
expect(artifactTypes).toContain('design');
|
|
683
|
+
expect(artifactTypes).toContain('requirements');
|
|
684
|
+
expect(artifactTypes).toContain('review');
|
|
685
|
+
});
|
|
686
|
+
it('uses self-eval quality gate with code-review rubric', () => {
|
|
687
|
+
const task = spec.persona.tasks['review-pr'];
|
|
688
|
+
expect(task.quality_gate).toBe('self-eval');
|
|
689
|
+
expect(spec.persona.evaluation.rubric).toBe('rubrics/code-review.rubric.yaml');
|
|
690
|
+
});
|
|
691
|
+
});
|