@weldr/runr 0.3.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/LICENSE +190 -0
  3. package/NOTICE +4 -0
  4. package/README.md +200 -0
  5. package/dist/cli.js +464 -0
  6. package/dist/commands/__tests__/report.test.js +202 -0
  7. package/dist/commands/compare.js +168 -0
  8. package/dist/commands/doctor.js +124 -0
  9. package/dist/commands/follow.js +251 -0
  10. package/dist/commands/gc.js +161 -0
  11. package/dist/commands/guards-only.js +89 -0
  12. package/dist/commands/metrics.js +441 -0
  13. package/dist/commands/orchestrate.js +800 -0
  14. package/dist/commands/paths.js +31 -0
  15. package/dist/commands/preflight.js +152 -0
  16. package/dist/commands/report.js +478 -0
  17. package/dist/commands/resume.js +149 -0
  18. package/dist/commands/run.js +538 -0
  19. package/dist/commands/status.js +189 -0
  20. package/dist/commands/summarize.js +220 -0
  21. package/dist/commands/version.js +82 -0
  22. package/dist/commands/wait.js +170 -0
  23. package/dist/config/__tests__/presets.test.js +104 -0
  24. package/dist/config/load.js +66 -0
  25. package/dist/config/schema.js +160 -0
  26. package/dist/context/__tests__/artifact.test.js +130 -0
  27. package/dist/context/__tests__/pack.test.js +191 -0
  28. package/dist/context/artifact.js +67 -0
  29. package/dist/context/index.js +2 -0
  30. package/dist/context/pack.js +273 -0
  31. package/dist/diagnosis/analyzer.js +678 -0
  32. package/dist/diagnosis/formatter.js +136 -0
  33. package/dist/diagnosis/index.js +6 -0
  34. package/dist/diagnosis/types.js +7 -0
  35. package/dist/env/__tests__/fingerprint.test.js +116 -0
  36. package/dist/env/fingerprint.js +111 -0
  37. package/dist/orchestrator/__tests__/policy.test.js +185 -0
  38. package/dist/orchestrator/__tests__/schema-version.test.js +65 -0
  39. package/dist/orchestrator/artifacts.js +405 -0
  40. package/dist/orchestrator/state-machine.js +646 -0
  41. package/dist/orchestrator/types.js +88 -0
  42. package/dist/ownership/normalize.js +45 -0
  43. package/dist/repo/context.js +90 -0
  44. package/dist/repo/git.js +13 -0
  45. package/dist/repo/worktree.js +239 -0
  46. package/dist/store/run-store.js +107 -0
  47. package/dist/store/run-utils.js +69 -0
  48. package/dist/store/runs-root.js +126 -0
  49. package/dist/supervisor/__tests__/evidence-gate.test.js +111 -0
  50. package/dist/supervisor/__tests__/ownership.test.js +103 -0
  51. package/dist/supervisor/__tests__/state-machine.test.js +290 -0
  52. package/dist/supervisor/collision.js +240 -0
  53. package/dist/supervisor/evidence-gate.js +98 -0
  54. package/dist/supervisor/planner.js +18 -0
  55. package/dist/supervisor/runner.js +1562 -0
  56. package/dist/supervisor/scope-guard.js +55 -0
  57. package/dist/supervisor/state-machine.js +121 -0
  58. package/dist/supervisor/verification-policy.js +64 -0
  59. package/dist/tasks/task-metadata.js +72 -0
  60. package/dist/types/schemas.js +1 -0
  61. package/dist/verification/engine.js +49 -0
  62. package/dist/workers/__tests__/claude.test.js +88 -0
  63. package/dist/workers/__tests__/codex.test.js +81 -0
  64. package/dist/workers/claude.js +119 -0
  65. package/dist/workers/codex.js +162 -0
  66. package/dist/workers/json.js +22 -0
  67. package/dist/workers/mock.js +193 -0
  68. package/dist/workers/prompts.js +98 -0
  69. package/dist/workers/schemas.js +39 -0
  70. package/package.json +47 -0
  71. package/templates/prompts/implementer.md +70 -0
  72. package/templates/prompts/planner.md +62 -0
  73. package/templates/prompts/reviewer.md +77 -0
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { SCOPE_PRESETS } from '../schema.js';
3
+ import { loadConfig } from '../load.js';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ describe('SCOPE_PRESETS', () => {
8
+ it('exports all expected presets', () => {
9
+ const expectedPresets = [
10
+ 'nextjs', 'react', 'drizzle', 'prisma',
11
+ 'vitest', 'jest', 'playwright',
12
+ 'typescript', 'tailwind', 'eslint', 'env'
13
+ ];
14
+ for (const preset of expectedPresets) {
15
+ expect(SCOPE_PRESETS[preset]).toBeDefined();
16
+ expect(Array.isArray(SCOPE_PRESETS[preset])).toBe(true);
17
+ expect(SCOPE_PRESETS[preset].length).toBeGreaterThan(0);
18
+ }
19
+ });
20
+ it('vitest preset includes expected patterns', () => {
21
+ const patterns = SCOPE_PRESETS.vitest;
22
+ expect(patterns).toContain('vitest.config.*');
23
+ expect(patterns).toContain('**/*.test.ts');
24
+ });
25
+ it('nextjs preset includes expected patterns', () => {
26
+ const patterns = SCOPE_PRESETS.nextjs;
27
+ expect(patterns).toContain('next.config.*');
28
+ expect(patterns).toContain('middleware.ts');
29
+ });
30
+ });
31
+ describe('preset expansion in loadConfig', () => {
32
+ it('expands presets into allowlist', () => {
33
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-config-test-'));
34
+ const configPath = path.join(tmpDir, 'agent.config.json');
35
+ const config = {
36
+ agent: { name: 'test', version: '1' },
37
+ scope: {
38
+ allowlist: ['src/**'],
39
+ denylist: [],
40
+ presets: ['vitest']
41
+ },
42
+ verification: { tier0: [], tier1: [], tier2: [] }
43
+ };
44
+ fs.writeFileSync(configPath, JSON.stringify(config));
45
+ try {
46
+ const loaded = loadConfig(configPath);
47
+ // Original patterns preserved
48
+ expect(loaded.scope.allowlist).toContain('src/**');
49
+ // Preset patterns expanded
50
+ expect(loaded.scope.allowlist).toContain('vitest.config.*');
51
+ expect(loaded.scope.allowlist).toContain('**/*.test.ts');
52
+ }
53
+ finally {
54
+ fs.rmSync(tmpDir, { recursive: true });
55
+ }
56
+ });
57
+ it('deduplicates patterns when preset overlaps with allowlist', () => {
58
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-config-test-'));
59
+ const configPath = path.join(tmpDir, 'agent.config.json');
60
+ const config = {
61
+ agent: { name: 'test', version: '1' },
62
+ scope: {
63
+ allowlist: ['src/**', 'vitest.config.*'], // Already has one pattern from vitest preset
64
+ denylist: [],
65
+ presets: ['vitest']
66
+ },
67
+ verification: { tier0: [], tier1: [], tier2: [] }
68
+ };
69
+ fs.writeFileSync(configPath, JSON.stringify(config));
70
+ try {
71
+ const loaded = loadConfig(configPath);
72
+ // Count occurrences of vitest.config.*
73
+ const count = loaded.scope.allowlist.filter(p => p === 'vitest.config.*').length;
74
+ expect(count).toBe(1); // Should be deduplicated
75
+ }
76
+ finally {
77
+ fs.rmSync(tmpDir, { recursive: true });
78
+ }
79
+ });
80
+ it('handles multiple presets', () => {
81
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-config-test-'));
82
+ const configPath = path.join(tmpDir, 'agent.config.json');
83
+ const config = {
84
+ agent: { name: 'test', version: '1' },
85
+ scope: {
86
+ allowlist: ['src/**'],
87
+ denylist: [],
88
+ presets: ['vitest', 'typescript', 'tailwind']
89
+ },
90
+ verification: { tier0: [], tier1: [], tier2: [] }
91
+ };
92
+ fs.writeFileSync(configPath, JSON.stringify(config));
93
+ try {
94
+ const loaded = loadConfig(configPath);
95
+ // All preset patterns should be present
96
+ expect(loaded.scope.allowlist).toContain('vitest.config.*');
97
+ expect(loaded.scope.allowlist).toContain('tsconfig*.json');
98
+ expect(loaded.scope.allowlist).toContain('tailwind.config.*');
99
+ }
100
+ finally {
101
+ fs.rmSync(tmpDir, { recursive: true });
102
+ }
103
+ });
104
+ });
@@ -0,0 +1,66 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { agentConfigSchema, SCOPE_PRESETS } from './schema.js';
4
+ export function resolveConfigPath(repoPath, configPath) {
5
+ if (configPath) {
6
+ return path.resolve(configPath);
7
+ }
8
+ // Check new location first: .runr/runr.config.json
9
+ const newConfigPath = path.resolve(repoPath, '.runr', 'runr.config.json');
10
+ if (fs.existsSync(newConfigPath)) {
11
+ return newConfigPath;
12
+ }
13
+ // Fall back to old location: .agent/agent.config.json
14
+ const oldConfigPath = path.resolve(repoPath, '.agent', 'agent.config.json');
15
+ if (fs.existsSync(oldConfigPath)) {
16
+ console.warn('\x1b[33m⚠ Deprecation: .agent/agent.config.json is deprecated.\x1b[0m');
17
+ console.warn('\x1b[33m Move to: .runr/runr.config.json\x1b[0m\n');
18
+ return oldConfigPath;
19
+ }
20
+ // Default to new path (will error if not found)
21
+ return newConfigPath;
22
+ }
23
+ /**
24
+ * Expand scope presets into allowlist patterns.
25
+ * Unknown presets are warned but not fatal.
26
+ */
27
+ function expandPresets(config) {
28
+ const presets = config.scope.presets ?? [];
29
+ if (presets.length === 0) {
30
+ return config;
31
+ }
32
+ const expandedPatterns = [];
33
+ const unknownPresets = [];
34
+ for (const preset of presets) {
35
+ const patterns = SCOPE_PRESETS[preset];
36
+ if (patterns) {
37
+ expandedPatterns.push(...patterns);
38
+ }
39
+ else {
40
+ unknownPresets.push(preset);
41
+ }
42
+ }
43
+ if (unknownPresets.length > 0) {
44
+ console.warn(`[config] Unknown scope presets (ignored): ${unknownPresets.join(', ')}`);
45
+ console.warn(`[config] Valid presets: ${Object.keys(SCOPE_PRESETS).join(', ')}`);
46
+ }
47
+ // Merge expanded patterns with existing allowlist (deduplicated)
48
+ const mergedAllowlist = [...new Set([...config.scope.allowlist, ...expandedPatterns])];
49
+ return {
50
+ ...config,
51
+ scope: {
52
+ ...config.scope,
53
+ allowlist: mergedAllowlist
54
+ }
55
+ };
56
+ }
57
+ export function loadConfig(configPath) {
58
+ if (!fs.existsSync(configPath)) {
59
+ throw new Error(`Config not found: ${configPath}`);
60
+ }
61
+ const raw = fs.readFileSync(configPath, 'utf-8');
62
+ const parsed = JSON.parse(raw);
63
+ const config = agentConfigSchema.parse(parsed);
64
+ // Expand presets into allowlist
65
+ return expandPresets(config);
66
+ }
@@ -0,0 +1,160 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Scope presets - common file patterns for popular frameworks/tools.
4
+ * These expand the allowlist to include config files that tasks commonly need.
5
+ */
6
+ export const SCOPE_PRESETS = {
7
+ // Framework presets
8
+ nextjs: [
9
+ 'next.config.*',
10
+ 'next-env.d.ts',
11
+ 'middleware.ts',
12
+ 'middleware.js',
13
+ ],
14
+ react: [
15
+ 'vite.config.*',
16
+ 'index.html',
17
+ ],
18
+ // Database presets
19
+ drizzle: [
20
+ 'drizzle.config.*',
21
+ 'drizzle/**',
22
+ ],
23
+ prisma: [
24
+ 'prisma/**',
25
+ ],
26
+ // Testing presets
27
+ vitest: [
28
+ 'vitest.config.*',
29
+ 'vite.config.*',
30
+ '**/*.test.ts',
31
+ '**/*.test.tsx',
32
+ '**/*.spec.ts',
33
+ '**/*.spec.tsx',
34
+ ],
35
+ jest: [
36
+ 'jest.config.*',
37
+ 'jest.setup.*',
38
+ '**/*.test.ts',
39
+ '**/*.test.tsx',
40
+ '**/*.spec.ts',
41
+ '**/*.spec.tsx',
42
+ ],
43
+ playwright: [
44
+ 'playwright.config.*',
45
+ 'e2e/**',
46
+ 'tests/**',
47
+ ],
48
+ // Build/config presets
49
+ typescript: [
50
+ 'tsconfig*.json',
51
+ ],
52
+ tailwind: [
53
+ 'tailwind.config.*',
54
+ 'postcss.config.*',
55
+ ],
56
+ eslint: [
57
+ 'eslint.config.*',
58
+ '.eslintrc*',
59
+ ],
60
+ // Environment presets
61
+ env: [
62
+ '.env.example',
63
+ '.env.local.example',
64
+ '.env.template',
65
+ ],
66
+ };
67
+ /** Valid preset names */
68
+ export const PRESET_NAMES = Object.keys(SCOPE_PRESETS);
69
+ const riskTriggerSchema = z.object({
70
+ name: z.string(),
71
+ patterns: z.array(z.string()),
72
+ tier: z.enum(['tier0', 'tier1', 'tier2'])
73
+ });
74
+ const verificationSchema = z.object({
75
+ cwd: z.string().optional(), // Working directory for verification commands (relative to repo root)
76
+ tier0: z.array(z.string()).default([]),
77
+ tier1: z.array(z.string()).default([]),
78
+ tier2: z.array(z.string()).default([]),
79
+ risk_triggers: z.array(riskTriggerSchema).default([]),
80
+ max_verify_time_per_milestone: z.number().int().positive().default(600)
81
+ });
82
+ const scopeSchema = z.object({
83
+ allowlist: z.array(z.string()).default([]),
84
+ denylist: z.array(z.string()).default([]),
85
+ lockfiles: z
86
+ .array(z.string())
87
+ .default(['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock']),
88
+ /** Scope presets to include (expands allowlist with common patterns) */
89
+ presets: z.array(z.string()).default([]),
90
+ /**
91
+ * Paths allowed to be dirty AND exempt from scope violations.
92
+ * These are "env state" - artifacts that don't affect code correctness.
93
+ * Matches both symlinks and directories (e.g., node_modules and node_modules/).
94
+ */
95
+ env_allowlist: z.array(z.string()).default([
96
+ 'node_modules',
97
+ 'node_modules/**',
98
+ '.next/**',
99
+ 'dist/**',
100
+ 'build/**',
101
+ '.turbo/**',
102
+ '.eslintcache',
103
+ 'coverage/**',
104
+ ]),
105
+ });
106
+ const agentSchema = z.object({
107
+ name: z.string().default('dual-llm-orchestrator'),
108
+ version: z.string().default('1')
109
+ });
110
+ const repoSchema = z.object({
111
+ default_branch: z.string().optional()
112
+ });
113
+ const workerConfigSchema = z.object({
114
+ bin: z.string(),
115
+ args: z.array(z.string()).default([]),
116
+ output: z.enum(['text', 'json', 'jsonl']).default('text')
117
+ });
118
+ const workersSchema = z.object({
119
+ codex: workerConfigSchema.default({
120
+ bin: 'codex',
121
+ args: ['exec', '--full-auto', '--json'],
122
+ output: 'jsonl'
123
+ }),
124
+ claude: workerConfigSchema.default({
125
+ bin: 'claude',
126
+ args: ['-p', '--output-format', 'json', '--dangerously-skip-permissions'],
127
+ output: 'json'
128
+ })
129
+ });
130
+ // Phase-to-worker mapping - allows configuring which worker handles each phase
131
+ const phasesSchema = z.object({
132
+ plan: z.enum(['claude', 'codex']).default('claude'),
133
+ implement: z.enum(['claude', 'codex']).default('codex'),
134
+ review: z.enum(['claude', 'codex']).default('claude')
135
+ });
136
+ // Resilience settings for auto-resume and failure recovery
137
+ const resilienceSchema = z.object({
138
+ /** Enable automatic resume on transient failures (stall, worker timeout) */
139
+ auto_resume: z.boolean().default(false),
140
+ /** Maximum number of auto-resumes per run (conservative default: 1) */
141
+ max_auto_resumes: z.number().int().nonnegative().default(1),
142
+ /** Backoff delays in ms between auto-resume attempts (must be positive integers) */
143
+ auto_resume_delays_ms: z
144
+ .array(z.number().int().positive())
145
+ .nonempty()
146
+ .default([30000, 120000, 300000]), // 30s, 2min, 5min
147
+ /** Hard cap on worker call duration in minutes (kills hung workers). Supports decimals for testing. */
148
+ max_worker_call_minutes: z.number().positive().default(45),
149
+ /** Maximum review rounds per milestone before stopping with review_loop_detected (default: 2) */
150
+ max_review_rounds: z.number().int().positive().default(2)
151
+ });
152
+ export const agentConfigSchema = z.object({
153
+ agent: agentSchema,
154
+ repo: repoSchema.default({}),
155
+ scope: scopeSchema,
156
+ verification: verificationSchema,
157
+ workers: workersSchema.default({}),
158
+ phases: phasesSchema.default({}),
159
+ resilience: resilienceSchema.default({})
160
+ });
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { writeContextPackArtifact, readContextPackArtifact, formatContextPackStatus } from '../artifact.js';
6
+ describe('writeContextPackArtifact', () => {
7
+ let tempDir;
8
+ beforeEach(() => {
9
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'artifact-test-'));
10
+ });
11
+ afterEach(() => {
12
+ fs.rmSync(tempDir, { recursive: true, force: true });
13
+ });
14
+ it('writes full pack when enabled', () => {
15
+ const pack = {
16
+ version: 1,
17
+ generated_at: '2025-12-24T01:00:00.000Z',
18
+ verification: {
19
+ tier0: ['pnpm lint'],
20
+ tier1: [],
21
+ tier2: []
22
+ },
23
+ reference_files: [],
24
+ scope: {
25
+ allowlist: ['src/**'],
26
+ denylist: []
27
+ },
28
+ patterns: {
29
+ tsconfig: null,
30
+ eslint: null,
31
+ package_json: null
32
+ },
33
+ blockers: {
34
+ scope_violations: [],
35
+ lockfile_restrictions: false,
36
+ common_errors: []
37
+ }
38
+ };
39
+ writeContextPackArtifact(tempDir, pack);
40
+ const artifactPath = path.join(tempDir, 'artifacts', 'context-pack.json');
41
+ expect(fs.existsSync(artifactPath)).toBe(true);
42
+ const content = JSON.parse(fs.readFileSync(artifactPath, 'utf-8'));
43
+ expect(content.enabled).toBe(true);
44
+ expect(content.pack_version).toBe(1);
45
+ expect(content.generated_at).toBe('2025-12-24T01:00:00.000Z');
46
+ expect(content.estimated_tokens).toBeGreaterThan(0);
47
+ expect(content.verification.tier0).toEqual(['pnpm lint']);
48
+ });
49
+ it('writes disabled stub when pack is null', () => {
50
+ writeContextPackArtifact(tempDir, null);
51
+ const artifactPath = path.join(tempDir, 'artifacts', 'context-pack.json');
52
+ expect(fs.existsSync(artifactPath)).toBe(true);
53
+ const content = JSON.parse(fs.readFileSync(artifactPath, 'utf-8'));
54
+ expect(content.enabled).toBe(false);
55
+ expect(content.pack_version).toBe(1);
56
+ expect(content.generated_at).toBeDefined();
57
+ expect(content.verification).toBeUndefined();
58
+ });
59
+ it('creates artifacts directory if missing', () => {
60
+ const artifactsDir = path.join(tempDir, 'artifacts');
61
+ expect(fs.existsSync(artifactsDir)).toBe(false);
62
+ writeContextPackArtifact(tempDir, null);
63
+ expect(fs.existsSync(artifactsDir)).toBe(true);
64
+ });
65
+ });
66
+ describe('readContextPackArtifact', () => {
67
+ let tempDir;
68
+ beforeEach(() => {
69
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'artifact-test-'));
70
+ });
71
+ afterEach(() => {
72
+ fs.rmSync(tempDir, { recursive: true, force: true });
73
+ });
74
+ it('returns null for missing file', () => {
75
+ const result = readContextPackArtifact(tempDir);
76
+ expect(result).toBeNull();
77
+ });
78
+ it('parses existing artifact', () => {
79
+ const artifactsDir = path.join(tempDir, 'artifacts');
80
+ fs.mkdirSync(artifactsDir, { recursive: true });
81
+ const artifact = {
82
+ enabled: true,
83
+ pack_version: 1,
84
+ generated_at: '2025-12-24T01:00:00.000Z',
85
+ estimated_tokens: 500
86
+ };
87
+ fs.writeFileSync(path.join(artifactsDir, 'context-pack.json'), JSON.stringify(artifact));
88
+ const result = readContextPackArtifact(tempDir);
89
+ expect(result).not.toBeNull();
90
+ expect(result?.enabled).toBe(true);
91
+ expect(result?.estimated_tokens).toBe(500);
92
+ });
93
+ it('returns null for invalid JSON', () => {
94
+ const artifactsDir = path.join(tempDir, 'artifacts');
95
+ fs.mkdirSync(artifactsDir, { recursive: true });
96
+ fs.writeFileSync(path.join(artifactsDir, 'context-pack.json'), 'not json');
97
+ const result = readContextPackArtifact(tempDir);
98
+ expect(result).toBeNull();
99
+ });
100
+ });
101
+ describe('formatContextPackStatus', () => {
102
+ it('formats null as not found', () => {
103
+ expect(formatContextPackStatus(null)).toBe('context_pack: (not found)');
104
+ });
105
+ it('formats disabled artifact', () => {
106
+ const artifact = {
107
+ enabled: false,
108
+ pack_version: 1,
109
+ generated_at: '2025-12-24T01:00:00.000Z'
110
+ };
111
+ expect(formatContextPackStatus(artifact)).toBe('context_pack: disabled');
112
+ });
113
+ it('formats enabled artifact with tokens', () => {
114
+ const artifact = {
115
+ enabled: true,
116
+ pack_version: 1,
117
+ generated_at: '2025-12-24T01:00:00.000Z',
118
+ estimated_tokens: 493
119
+ };
120
+ expect(formatContextPackStatus(artifact)).toBe('context_pack: present (493 tokens)');
121
+ });
122
+ it('handles missing token count', () => {
123
+ const artifact = {
124
+ enabled: true,
125
+ pack_version: 1,
126
+ generated_at: '2025-12-24T01:00:00.000Z'
127
+ };
128
+ expect(formatContextPackStatus(artifact)).toBe('context_pack: present (? tokens)');
129
+ });
130
+ });
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import path from 'node:path';
3
+ import { buildContextPack, formatContextPackForPrompt, estimatePackTokens } from '../pack.js';
4
+ // Use the actual repo root for integration-style tests
5
+ const REPO_ROOT = path.resolve(__dirname, '../../..');
6
+ describe('buildContextPack', () => {
7
+ describe('verification commands', () => {
8
+ it('extracts verification commands from config', () => {
9
+ const pack = buildContextPack({
10
+ repoRoot: REPO_ROOT,
11
+ targetRoot: 'apps/tactical-grid',
12
+ config: {
13
+ verification: {
14
+ tier0: ['pnpm lint', 'pnpm typecheck'],
15
+ tier1: ['pnpm test'],
16
+ tier2: []
17
+ }
18
+ }
19
+ });
20
+ expect(pack.verification.tier0).toEqual(['pnpm lint', 'pnpm typecheck']);
21
+ expect(pack.verification.tier1).toEqual(['pnpm test']);
22
+ expect(pack.verification.tier2).toEqual([]);
23
+ });
24
+ it('handles missing verification config', () => {
25
+ const pack = buildContextPack({
26
+ repoRoot: REPO_ROOT,
27
+ targetRoot: 'apps/tactical-grid',
28
+ config: {}
29
+ });
30
+ expect(pack.verification.tier0).toEqual([]);
31
+ expect(pack.verification.tier1).toEqual([]);
32
+ });
33
+ });
34
+ describe('reference files', () => {
35
+ it('resolves RNG pattern reference', () => {
36
+ const pack = buildContextPack({
37
+ repoRoot: REPO_ROOT,
38
+ targetRoot: 'apps/tactical-grid',
39
+ config: {},
40
+ references: [{ pattern: 'RNG pattern from deckbuilder' }]
41
+ });
42
+ expect(pack.reference_files.length).toBeGreaterThan(0);
43
+ const rngRef = pack.reference_files.find((r) => r.path.includes('rng.ts'));
44
+ expect(rngRef).toBeDefined();
45
+ expect(rngRef?.content).toContain('nextInt');
46
+ expect(rngRef?.content).toContain('1103515245'); // LCG constant
47
+ });
48
+ it('resolves explicit hint path', () => {
49
+ const pack = buildContextPack({
50
+ repoRoot: REPO_ROOT,
51
+ targetRoot: 'apps/tactical-grid',
52
+ config: {},
53
+ references: [
54
+ {
55
+ pattern: 'custom reference',
56
+ hint: 'apps/deckbuilder/src/engine/rng.ts'
57
+ }
58
+ ]
59
+ });
60
+ expect(pack.reference_files.length).toBe(1);
61
+ expect(pack.reference_files[0].path).toBe('apps/deckbuilder/src/engine/rng.ts');
62
+ expect(pack.reference_files[0].reason).toBe('custom reference');
63
+ });
64
+ it('handles unknown pattern gracefully', () => {
65
+ const pack = buildContextPack({
66
+ repoRoot: REPO_ROOT,
67
+ targetRoot: 'apps/tactical-grid',
68
+ config: {},
69
+ references: [{ pattern: 'nonexistent magic pattern' }]
70
+ });
71
+ expect(pack.reference_files).toEqual([]);
72
+ });
73
+ });
74
+ describe('scope constraints', () => {
75
+ it('extracts scope from config', () => {
76
+ const pack = buildContextPack({
77
+ repoRoot: REPO_ROOT,
78
+ targetRoot: 'apps/tactical-grid',
79
+ config: {
80
+ scope: {
81
+ allowlist: ['apps/tactical-grid/**'],
82
+ denylist: ['**/node_modules/**']
83
+ }
84
+ }
85
+ });
86
+ expect(pack.scope.allowlist).toEqual(['apps/tactical-grid/**']);
87
+ expect(pack.scope.denylist).toEqual(['**/node_modules/**']);
88
+ });
89
+ });
90
+ describe('config patterns', () => {
91
+ it('finds nearest tsconfig.json', () => {
92
+ const pack = buildContextPack({
93
+ repoRoot: REPO_ROOT,
94
+ targetRoot: 'apps/deckbuilder',
95
+ config: {}
96
+ });
97
+ expect(pack.patterns.tsconfig).not.toBeNull();
98
+ expect(pack.patterns.tsconfig?.path).toContain('tsconfig.json');
99
+ expect(pack.patterns.tsconfig?.content).toContain('compilerOptions');
100
+ });
101
+ it('finds nearest eslint config', () => {
102
+ const pack = buildContextPack({
103
+ repoRoot: REPO_ROOT,
104
+ targetRoot: 'apps/deckbuilder',
105
+ config: {}
106
+ });
107
+ expect(pack.patterns.eslint).not.toBeNull();
108
+ expect(pack.patterns.eslint?.path).toMatch(/eslint\.config/);
109
+ });
110
+ it('finds nearest package.json', () => {
111
+ const pack = buildContextPack({
112
+ repoRoot: REPO_ROOT,
113
+ targetRoot: 'apps/deckbuilder',
114
+ config: {}
115
+ });
116
+ expect(pack.patterns.package_json).not.toBeNull();
117
+ expect(pack.patterns.package_json?.content).toContain('scripts');
118
+ });
119
+ it('finds config even for nonexistent target (upward search or fallback)', () => {
120
+ const pack = buildContextPack({
121
+ repoRoot: REPO_ROOT,
122
+ targetRoot: 'apps/nonexistent-app',
123
+ config: {}
124
+ });
125
+ // Should find config via upward search (repo root) or fallback to deckbuilder
126
+ expect(pack.patterns.tsconfig).not.toBeNull();
127
+ expect(pack.patterns.tsconfig?.content).toContain('compilerOptions');
128
+ });
129
+ });
130
+ describe('version and metadata', () => {
131
+ it('includes version 1', () => {
132
+ const pack = buildContextPack({
133
+ repoRoot: REPO_ROOT,
134
+ targetRoot: 'apps/tactical-grid',
135
+ config: {}
136
+ });
137
+ expect(pack.version).toBe(1);
138
+ });
139
+ it('includes generated_at timestamp', () => {
140
+ const pack = buildContextPack({
141
+ repoRoot: REPO_ROOT,
142
+ targetRoot: 'apps/tactical-grid',
143
+ config: {}
144
+ });
145
+ expect(pack.generated_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
146
+ });
147
+ });
148
+ });
149
+ describe('formatContextPackForPrompt', () => {
150
+ it('formats verification commands', () => {
151
+ const pack = buildContextPack({
152
+ repoRoot: REPO_ROOT,
153
+ targetRoot: 'apps/tactical-grid',
154
+ config: {
155
+ verification: {
156
+ tier0: ['pnpm lint', 'pnpm typecheck'],
157
+ tier1: ['pnpm test']
158
+ }
159
+ }
160
+ });
161
+ const formatted = formatContextPackForPrompt(pack);
162
+ expect(formatted).toContain('Verification Commands');
163
+ expect(formatted).toContain('tier0: pnpm lint && pnpm typecheck');
164
+ expect(formatted).toContain('tier1: pnpm test');
165
+ });
166
+ it('includes reference file content', () => {
167
+ const pack = buildContextPack({
168
+ repoRoot: REPO_ROOT,
169
+ targetRoot: 'apps/tactical-grid',
170
+ config: {},
171
+ references: [{ pattern: 'RNG pattern' }]
172
+ });
173
+ const formatted = formatContextPackForPrompt(pack);
174
+ expect(formatted).toContain('Reference Files');
175
+ expect(formatted).toContain('nextInt');
176
+ });
177
+ });
178
+ describe('estimatePackTokens', () => {
179
+ it('estimates token count based on character length', () => {
180
+ const pack = buildContextPack({
181
+ repoRoot: REPO_ROOT,
182
+ targetRoot: 'apps/tactical-grid',
183
+ config: {
184
+ verification: { tier0: ['pnpm lint'] }
185
+ }
186
+ });
187
+ const tokens = estimatePackTokens(pack);
188
+ expect(tokens).toBeGreaterThan(0);
189
+ expect(tokens).toBeLessThan(10000); // Sanity check
190
+ });
191
+ });