loreli 0.0.0 → 2.0.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 (104) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +710 -97
  3. package/bin/loreli.js +89 -0
  4. package/package.json +77 -14
  5. package/packages/README.md +101 -0
  6. package/packages/action/README.md +98 -0
  7. package/packages/action/prompts/action.md +172 -0
  8. package/packages/action/src/index.js +684 -0
  9. package/packages/agent/README.md +606 -0
  10. package/packages/agent/src/backends/claude.js +387 -0
  11. package/packages/agent/src/backends/codex.js +351 -0
  12. package/packages/agent/src/backends/cursor.js +371 -0
  13. package/packages/agent/src/backends/index.js +486 -0
  14. package/packages/agent/src/base.js +138 -0
  15. package/packages/agent/src/cli.js +275 -0
  16. package/packages/agent/src/discover.js +396 -0
  17. package/packages/agent/src/factory.js +124 -0
  18. package/packages/agent/src/index.js +12 -0
  19. package/packages/agent/src/models.js +159 -0
  20. package/packages/agent/src/output.js +62 -0
  21. package/packages/agent/src/session.js +162 -0
  22. package/packages/agent/src/trace.js +186 -0
  23. package/packages/classify/README.md +136 -0
  24. package/packages/classify/prompts/blocker.md +12 -0
  25. package/packages/classify/prompts/feedback.md +14 -0
  26. package/packages/classify/prompts/pane-state.md +20 -0
  27. package/packages/classify/src/index.js +81 -0
  28. package/packages/config/README.md +898 -0
  29. package/packages/config/src/defaults.js +145 -0
  30. package/packages/config/src/index.js +223 -0
  31. package/packages/config/src/schema.js +291 -0
  32. package/packages/config/src/validate.js +160 -0
  33. package/packages/context/README.md +165 -0
  34. package/packages/context/src/index.js +198 -0
  35. package/packages/hub/README.md +338 -0
  36. package/packages/hub/src/base.js +154 -0
  37. package/packages/hub/src/github.js +1597 -0
  38. package/packages/hub/src/index.js +79 -0
  39. package/packages/hub/src/labels.js +48 -0
  40. package/packages/identity/README.md +288 -0
  41. package/packages/identity/src/index.js +620 -0
  42. package/packages/identity/src/themes/avatar.js +217 -0
  43. package/packages/identity/src/themes/digimon.js +217 -0
  44. package/packages/identity/src/themes/dragonball.js +217 -0
  45. package/packages/identity/src/themes/lotr.js +217 -0
  46. package/packages/identity/src/themes/marvel.js +217 -0
  47. package/packages/identity/src/themes/pokemon.js +217 -0
  48. package/packages/identity/src/themes/starwars.js +217 -0
  49. package/packages/identity/src/themes/transformers.js +217 -0
  50. package/packages/identity/src/themes/zelda.js +217 -0
  51. package/packages/knowledge/README.md +217 -0
  52. package/packages/knowledge/src/index.js +243 -0
  53. package/packages/log/README.md +93 -0
  54. package/packages/log/src/index.js +252 -0
  55. package/packages/marker/README.md +200 -0
  56. package/packages/marker/src/index.js +184 -0
  57. package/packages/mcp/README.md +323 -0
  58. package/packages/mcp/instructions.md +126 -0
  59. package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
  60. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
  61. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
  62. package/packages/mcp/scaffolding/loreli.yml +491 -0
  63. package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +4 -0
  64. package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +14 -0
  65. package/packages/mcp/scaffolding/mcp-configs/.mcp.json +14 -0
  66. package/packages/mcp/scaffolding/pull-request.md +23 -0
  67. package/packages/mcp/src/index.js +600 -0
  68. package/packages/mcp/src/tools/agent-context.js +44 -0
  69. package/packages/mcp/src/tools/agents.js +450 -0
  70. package/packages/mcp/src/tools/context.js +200 -0
  71. package/packages/mcp/src/tools/github.js +1163 -0
  72. package/packages/mcp/src/tools/hitl.js +162 -0
  73. package/packages/mcp/src/tools/index.js +18 -0
  74. package/packages/mcp/src/tools/refactor.js +227 -0
  75. package/packages/mcp/src/tools/repo.js +44 -0
  76. package/packages/mcp/src/tools/start.js +904 -0
  77. package/packages/mcp/src/tools/status.js +149 -0
  78. package/packages/mcp/src/tools/work.js +134 -0
  79. package/packages/orchestrator/README.md +192 -0
  80. package/packages/orchestrator/src/index.js +1492 -0
  81. package/packages/planner/README.md +251 -0
  82. package/packages/planner/prompts/plan-reviewer.md +109 -0
  83. package/packages/planner/prompts/planner.md +191 -0
  84. package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
  85. package/packages/planner/src/index.js +1381 -0
  86. package/packages/review/README.md +129 -0
  87. package/packages/review/prompts/reviewer.md +158 -0
  88. package/packages/review/src/index.js +1403 -0
  89. package/packages/risk/README.md +178 -0
  90. package/packages/risk/prompts/risk.md +272 -0
  91. package/packages/risk/src/index.js +439 -0
  92. package/packages/session/README.md +165 -0
  93. package/packages/session/src/index.js +215 -0
  94. package/packages/test-utils/README.md +96 -0
  95. package/packages/test-utils/src/index.js +354 -0
  96. package/packages/tmux/README.md +261 -0
  97. package/packages/tmux/src/index.js +501 -0
  98. package/packages/workflow/README.md +317 -0
  99. package/packages/workflow/prompts/preamble.md +14 -0
  100. package/packages/workflow/src/index.js +660 -0
  101. package/packages/workflow/src/proof-of-life.js +74 -0
  102. package/packages/workspace/README.md +143 -0
  103. package/packages/workspace/src/index.js +1127 -0
  104. package/index.js +0 -8
@@ -0,0 +1,145 @@
1
+ import ms from 'ms';
2
+
3
+ /**
4
+ * Built-in defaults for every configurable value in Loreli.
5
+ * This is the lowest-priority layer in the resolution chain.
6
+ *
7
+ * Duration values use human-readable strings converted via `ms()`
8
+ * at module load time. All exported values are milliseconds.
9
+ *
10
+ * @type {object}
11
+ */
12
+ export default {
13
+ repo: undefined,
14
+ theme: 'transformers',
15
+ reviewers: [],
16
+ model: 'balanced',
17
+ merge: {
18
+ method: 'squash',
19
+ hitl: false,
20
+ base: 'main'
21
+ },
22
+ pr: {
23
+ validation: {
24
+ command: 'npm test'
25
+ },
26
+ selfReview: {
27
+ enabled: true
28
+ }
29
+ },
30
+ hitl: {
31
+ timeout: ms('3d')
32
+ },
33
+ timeouts: {
34
+ stall: ms('10m'),
35
+ shutdown: ms('1m'),
36
+ poll: ms('2s'),
37
+ rapidDeath: ms('15s'),
38
+ proxyDiscovery: ms('5s'),
39
+ nudge: true
40
+ },
41
+ log: {
42
+ level: 'info',
43
+ maxSize: 10 * 1024 * 1024,
44
+ maxFiles: 3
45
+ },
46
+ labels: {
47
+ track: true,
48
+ extra: []
49
+ },
50
+ watch: {
51
+ interval: ms('1m'),
52
+ maxRounds: 7,
53
+ maxClaims: 3
54
+ },
55
+ scaling: {
56
+ maxAgents: 8,
57
+ maxPerTick: 2,
58
+ cooldown: ms('30s')
59
+ },
60
+ tmux: {
61
+ session: 'loreli',
62
+ capture: 500
63
+ },
64
+ backends: {
65
+ claude: {
66
+ env: {
67
+ CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: '1'
68
+ },
69
+ models: {
70
+ fast: { anthropic: 'claude-haiku-4-5-20251001' },
71
+ balanced: { anthropic: 'claude-sonnet-4-5-20250929' },
72
+ powerful: { anthropic: 'claude-opus-4-5-20251101' }
73
+ }
74
+ },
75
+ codex: {
76
+ models: {
77
+ fast: { openai: 'gpt-5-mini' },
78
+ balanced: { openai: 'gpt-5.1-codex' },
79
+ powerful: { openai: 'gpt-5.2-pro' }
80
+ }
81
+ },
82
+ cursor: {
83
+ models: {
84
+ fast: { anthropic: 'sonnet-4.5', openai: 'gpt-5.3-codex-low' },
85
+ balanced: { anthropic: 'sonnet-4.5-thinking', openai: 'gpt-5.3-codex' },
86
+ powerful: { anthropic: 'opus-4.6-thinking', openai: 'gpt-5.1-codex-max' }
87
+ }
88
+ }
89
+ },
90
+ classify: {
91
+ model: 'fast',
92
+ maxLines: 100,
93
+ timeout: ms('30s'),
94
+ maxRetries: 5
95
+ },
96
+ trace: {
97
+ enabled: true,
98
+ includeOutput: true,
99
+ maxOutputChars: 8000
100
+ },
101
+ agents: {
102
+ disallowedTools: ['gh', 'curl']
103
+ },
104
+ proofOfLife: {
105
+ timeout: ms('5m')
106
+ },
107
+ workspace: {
108
+ cleanup: false
109
+ },
110
+ cleanup: {
111
+ retention: ms('12h'),
112
+ autoprune: true
113
+ },
114
+ workflows: {
115
+ action: {
116
+ model: 'balanced',
117
+ maxAgents: 3
118
+ },
119
+ reviewer: {
120
+ model: 'balanced',
121
+ maxAgents: 2,
122
+ trace: { enabled: true, maxOutputChars: 4000 }
123
+ },
124
+ risk: {
125
+ model: 'fast',
126
+ maxAgents: 3,
127
+ skip: false,
128
+ trace: { enabled: true, maxOutputChars: 2000 }
129
+ },
130
+ planner: {
131
+ model: 'powerful',
132
+ maxAgents: 1,
133
+ trace: { enabled: true, maxOutputChars: 4000 }
134
+ }
135
+ },
136
+ feedback: {
137
+ enabled: true,
138
+ threshold: 5,
139
+ categories: ['naming', 'architecture', 'testing', 'documentation', 'performance', 'security'],
140
+ hitl: false
141
+ },
142
+ github: {
143
+ token: undefined
144
+ }
145
+ };
@@ -0,0 +1,223 @@
1
+ import { resolve } from 'node:path';
2
+ import { readFileSync } from 'node:fs';
3
+ import { parse } from 'yaml';
4
+ import { logger } from 'loreli/log';
5
+ import defaults from './defaults.js';
6
+ import { validate } from './schema.js';
7
+
8
+ const log = logger('config');
9
+
10
+ export { defaults };
11
+ export { validate };
12
+ export * as check from './validate.js';
13
+
14
+ /**
15
+ * Load a `.env` file into `process.env` using Node's built-in parser.
16
+ * Variables already present in `process.env` are not overwritten.
17
+ *
18
+ * @param {string} [path='.env'] - Absolute or relative path to the env file.
19
+ * @returns {boolean} True if the file was loaded, false if it does not exist.
20
+ */
21
+ export function loadEnv(path = '.env') {
22
+ try {
23
+ process.loadEnvFile(resolve(path));
24
+ return true;
25
+ } catch (err) {
26
+ // ENOENT means the file simply doesn't exist — not an error worth surfacing
27
+ if (err?.code === 'ENOENT') return false;
28
+ throw err;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Centralized configuration for Loreli.
34
+ *
35
+ * Resolution order (highest priority wins):
36
+ * 1. Start tool params (via merge())
37
+ * 2. loreli.yml from target repo (via load())
38
+ * 3. Environment variables
39
+ * 4. Built-in defaults
40
+ *
41
+ * @example
42
+ * ```js
43
+ * const config = new Config();
44
+ * await config.load(hub, 'owner/repo');
45
+ * config.merge({ theme: 'pokemon' });
46
+ * config.get('theme'); // 'pokemon'
47
+ * config.get('timeouts.stall'); // 600000
48
+ * ```
49
+ */
50
+ export class Config {
51
+ constructor() {
52
+ /** @type {object} Values from loreli.yml (validated). */
53
+ this.file = {};
54
+
55
+ /** @type {object} Values from start params (overrides). */
56
+ this.overrides = {};
57
+
58
+ /** @type {boolean} Whether loreli.yml was found in the repo. */
59
+ this.found = false;
60
+ }
61
+
62
+ /**
63
+ * Load config from a target repo via hub.read().
64
+ * Returns gracefully if the file does not exist or is unparseable.
65
+ *
66
+ * @param {object} hub - Hub instance with a read() method.
67
+ * @param {string} repo - Repository in "owner/name" format.
68
+ * @param {string} [path='loreli.yml'] - File path within the repo.
69
+ * @returns {Promise<boolean>} True if config was found and parsed, false otherwise.
70
+ */
71
+ async load(hub, repo, path = 'loreli.yml') {
72
+ try {
73
+ const result = await hub.read(repo, path);
74
+ const raw = parse(result.content);
75
+ this.file = validate(raw);
76
+ this.found = true;
77
+ } catch (err) {
78
+ if (err?.status !== 404 && err?.code !== 'ENOENT') {
79
+ log.warn(`config load failed for ${repo}/${path}: ${err.message}`);
80
+ }
81
+ this.file = {};
82
+ this.found = false;
83
+ }
84
+ return this.found;
85
+ }
86
+
87
+ /**
88
+ * Load config from a local loreli.yml path.
89
+ * Returns gracefully if the file does not exist or is unparseable.
90
+ *
91
+ * @param {string} [path='loreli.yml'] - Absolute or relative file path.
92
+ * @returns {boolean} True if config was found and parsed, false otherwise.
93
+ */
94
+ loadLocal(path = 'loreli.yml') {
95
+ try {
96
+ const content = readFileSync(resolve(path), 'utf8');
97
+ const raw = parse(content);
98
+ this.file = validate(raw);
99
+ this.found = true;
100
+ } catch (err) {
101
+ if (err?.code !== 'ENOENT') {
102
+ log.warn(`config loadLocal failed for ${path}: ${err.message}`);
103
+ }
104
+ this.file = {};
105
+ this.found = false;
106
+ }
107
+
108
+ return this.found;
109
+ }
110
+
111
+ /**
112
+ * Merge start params or other overrides on top.
113
+ * Nested objects are shallow-merged one level deep.
114
+ *
115
+ * @param {object} overrides - Plain object of override values.
116
+ */
117
+ merge(overrides) {
118
+ if (!overrides || typeof overrides !== 'object') return;
119
+ this.overrides = validate(overrides);
120
+ }
121
+
122
+ /**
123
+ * Resolve a config value through all layers.
124
+ * Supports dot-notation for nested keys (e.g. 'merge.hitl').
125
+ *
126
+ * Resolution: overrides > file > env > defaults.
127
+ *
128
+ * @param {string} path - Dot-notation key path.
129
+ * @returns {*} The resolved value.
130
+ */
131
+ get(path) {
132
+ const parts = path.split('.');
133
+
134
+ // Check overrides first
135
+ const fromOverrides = dig(this.overrides, parts);
136
+ if (fromOverrides !== undefined) return fromOverrides;
137
+
138
+ // Check file layer
139
+ const fromFile = dig(this.file, parts);
140
+ if (fromFile !== undefined) return fromFile;
141
+
142
+ // Check environment variables
143
+ const fromEnv = env(path);
144
+ if (fromEnv !== undefined) return fromEnv;
145
+
146
+ // Fall through to defaults
147
+ return dig(defaults, parts);
148
+ }
149
+
150
+ /**
151
+ * Serialize the fully resolved config to a plain object.
152
+ * Walks every default key and resolves each through the layer chain.
153
+ *
154
+ * @returns {object} Fully resolved config.
155
+ */
156
+ toJSON() {
157
+ return walk(defaults, '', this);
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Recursively resolve all keys in a template object.
163
+ * Uses the default object as a schema to know which keys to resolve.
164
+ *
165
+ * @param {object} template - Defaults object defining the key structure.
166
+ * @param {string} prefix - Dot-notation prefix for nested keys.
167
+ * @param {Config} config - Config instance to resolve through.
168
+ * @returns {object} Resolved object.
169
+ */
170
+ function walk(template, prefix, config) {
171
+ const out = {};
172
+
173
+ for (const [key, value] of Object.entries(template)) {
174
+ const path = prefix ? `${prefix}.${key}` : key;
175
+
176
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
177
+ out[key] = walk(value, path, config);
178
+ } else {
179
+ out[key] = config.get(path);
180
+ }
181
+ }
182
+
183
+ return out;
184
+ }
185
+
186
+ /**
187
+ * Navigate into a nested object using an array of key segments.
188
+ *
189
+ * @param {object} obj - Object to navigate.
190
+ * @param {string[]} parts - Key segments.
191
+ * @returns {*} The value at the path, or undefined.
192
+ */
193
+ function dig(obj, parts) {
194
+ let current = obj;
195
+ for (const part of parts) {
196
+ if (current == null || typeof current !== 'object') return undefined;
197
+ current = current[part];
198
+ }
199
+ return current;
200
+ }
201
+
202
+ /**
203
+ * Map config paths to environment variable names.
204
+ * Only a subset of config values are env-overridable.
205
+ *
206
+ * @param {string} path - Config path (dot-notation).
207
+ * @returns {*} Parsed env value, or undefined.
208
+ */
209
+ function env(path) {
210
+ const mapping = {
211
+ repo: 'LORELI_REPO',
212
+ 'log.level': 'LORELI_LOG_LEVEL',
213
+ 'github.token': 'GITHUB_TOKEN'
214
+ };
215
+
216
+ const varName = mapping[path];
217
+ if (!varName) return undefined;
218
+
219
+ const value = process.env[varName];
220
+ if (value === undefined) return undefined;
221
+
222
+ return value;
223
+ }
@@ -0,0 +1,291 @@
1
+ import ms from 'ms';
2
+
3
+ /**
4
+ * Valid merge methods supported by GitHub.
5
+ * @type {Set<string>}
6
+ */
7
+ const MERGE_METHODS = new Set(['merge', 'squash', 'rebase']);
8
+
9
+ /**
10
+ * Pattern for valid "owner/name" repository format.
11
+ * @type {RegExp}
12
+ */
13
+ const REPO_RE = /^[^/]+\/[^/]+$/;
14
+
15
+ /**
16
+ * Validates and normalizes a raw config object parsed from loreli.yml.
17
+ * Ensures types are correct and unknown keys are stripped.
18
+ *
19
+ * Duration fields accept both human-readable strings ('10m', '3d')
20
+ * and raw millisecond numbers. All are normalized to milliseconds.
21
+ *
22
+ * @param {object} raw - Parsed YAML content.
23
+ * @returns {object} Normalized config object with only known keys.
24
+ */
25
+ export function validate(raw) {
26
+ if (!raw || typeof raw !== 'object') return {};
27
+
28
+ const out = {};
29
+
30
+ if (typeof raw.repo === 'string' && REPO_RE.test(raw.repo)) out.repo = raw.repo;
31
+
32
+ if (typeof raw.theme === 'string') {
33
+ out.theme = raw.theme;
34
+ } else if (Array.isArray(raw.theme)) {
35
+ const valid = raw.theme.filter(function isString(t) { return typeof t === 'string'; });
36
+ if (valid.length) out.theme = valid.length === 1 ? valid[0] : valid;
37
+ }
38
+
39
+ if (typeof raw.model === 'string') out.model = raw.model;
40
+
41
+ if (Array.isArray(raw.reviewers)) {
42
+ out.reviewers = raw.reviewers.filter(function isString(r) {
43
+ return typeof r === 'string';
44
+ });
45
+ }
46
+
47
+ if (raw.merge && typeof raw.merge === 'object') {
48
+ out.merge = {};
49
+ if (typeof raw.merge.method === 'string' && MERGE_METHODS.has(raw.merge.method)) out.merge.method = raw.merge.method;
50
+ if (typeof raw.merge.base === 'string') out.merge.base = raw.merge.base;
51
+
52
+ if (typeof raw.merge.hitl === 'boolean') {
53
+ out.merge.hitl = raw.merge.hitl;
54
+ } else if (raw.merge.gate === 'human') {
55
+ out.merge.hitl = true;
56
+ } else if (typeof raw.merge.gate === 'string') {
57
+ out.merge.hitl = false;
58
+ }
59
+ }
60
+
61
+ if (raw.pr && typeof raw.pr === 'object') {
62
+ const pr = {};
63
+
64
+ if (raw.pr.validation && typeof raw.pr.validation === 'object') {
65
+ const validation = {};
66
+ if (typeof raw.pr.validation.command === 'string') {
67
+ validation.command = raw.pr.validation.command;
68
+ }
69
+ if (Object.keys(validation).length) pr.validation = validation;
70
+ }
71
+
72
+ if (raw.pr.selfReview && typeof raw.pr.selfReview === 'object') {
73
+ const selfReview = {};
74
+ if (typeof raw.pr.selfReview.enabled === 'boolean') {
75
+ selfReview.enabled = raw.pr.selfReview.enabled;
76
+ }
77
+ if (Object.keys(selfReview).length) pr.selfReview = selfReview;
78
+ }
79
+
80
+ if (Object.keys(pr).length) out.pr = pr;
81
+ }
82
+
83
+ if (raw.hitl && typeof raw.hitl === 'object') {
84
+ out.hitl = {};
85
+ if (raw.hitl.timeout === null) {
86
+ out.hitl.timeout = null;
87
+ } else {
88
+ const v = raw.hitl.timeout;
89
+ try { out.hitl.timeout = typeof v === 'number' ? v : ms(v); } catch { /* invalid — falls to default */ }
90
+ }
91
+ }
92
+
93
+ if (raw.timeouts && typeof raw.timeouts === 'object') {
94
+ out.timeouts = {};
95
+ for (const key of ['stall', 'shutdown', 'poll', 'rapidDeath', 'proxyDiscovery']) {
96
+ const v = raw.timeouts[key];
97
+ if (v == null) continue;
98
+ try { out.timeouts[key] = typeof v === 'number' ? v : ms(v); } catch { /* invalid — falls to default */ }
99
+ }
100
+ if (typeof raw.timeouts.nudge === 'boolean') out.timeouts.nudge = raw.timeouts.nudge;
101
+ }
102
+
103
+ if (raw.log && typeof raw.log === 'object') {
104
+ out.log = {};
105
+ if (typeof raw.log.level === 'string') out.log.level = raw.log.level;
106
+ if (typeof raw.log.maxSize === 'number') out.log.maxSize = raw.log.maxSize;
107
+ if (typeof raw.log.maxFiles === 'number') out.log.maxFiles = raw.log.maxFiles;
108
+ }
109
+
110
+ if (raw.labels && typeof raw.labels === 'object') {
111
+ out.labels = {};
112
+ if (typeof raw.labels.track === 'boolean') out.labels.track = raw.labels.track;
113
+ if (Array.isArray(raw.labels.extra)) {
114
+ out.labels.extra = raw.labels.extra.filter(function isString(l) {
115
+ return typeof l === 'string';
116
+ });
117
+ }
118
+ }
119
+
120
+ if (raw.watch && typeof raw.watch === 'object') {
121
+ out.watch = {};
122
+ const v = raw.watch.interval;
123
+ if (v != null) {
124
+ try { out.watch.interval = typeof v === 'number' ? v : ms(v); } catch { /* invalid — falls to default */ }
125
+ }
126
+ if (typeof raw.watch.maxRounds === 'number') out.watch.maxRounds = raw.watch.maxRounds;
127
+ if (typeof raw.watch.maxClaims === 'number' && raw.watch.maxClaims > 0) out.watch.maxClaims = raw.watch.maxClaims;
128
+ }
129
+
130
+ if (raw.scaling && typeof raw.scaling === 'object') {
131
+ out.scaling = {};
132
+ if (typeof raw.scaling.maxAgents === 'number') out.scaling.maxAgents = raw.scaling.maxAgents;
133
+ if (typeof raw.scaling.maxPerTick === 'number') out.scaling.maxPerTick = raw.scaling.maxPerTick;
134
+
135
+ const cd = raw.scaling.cooldown;
136
+ if (cd != null) {
137
+ try { out.scaling.cooldown = typeof cd === 'number' ? cd : ms(cd); } catch { /* invalid — falls to default */ }
138
+ }
139
+
140
+ if (!Object.keys(out.scaling).length) delete out.scaling;
141
+ }
142
+
143
+ if (raw.tmux && typeof raw.tmux === 'object') {
144
+ out.tmux = {};
145
+ if (typeof raw.tmux.session === 'string') out.tmux.session = raw.tmux.session;
146
+ if (typeof raw.tmux.capture === 'number') out.tmux.capture = raw.tmux.capture;
147
+ }
148
+
149
+ if (raw.backends && typeof raw.backends === 'object') {
150
+ out.backends = {};
151
+ for (const [name, backend] of Object.entries(raw.backends)) {
152
+ if (!backend || typeof backend !== 'object') continue;
153
+
154
+ const entry = {};
155
+
156
+ // Validate models: three-level nested object (tier → provider → string)
157
+ if (backend.models && typeof backend.models === 'object') {
158
+ const models = {};
159
+ for (const [tier, providers] of Object.entries(backend.models)) {
160
+ if (!providers || typeof providers !== 'object') continue;
161
+
162
+ const validated = {};
163
+ for (const [provider, modelId] of Object.entries(providers)) {
164
+ if (typeof modelId === 'string') validated[provider] = modelId;
165
+ }
166
+
167
+ if (Object.keys(validated).length) models[tier] = validated;
168
+ }
169
+
170
+ if (Object.keys(models).length) entry.models = models;
171
+ }
172
+
173
+ // Validate env: flat string-to-string map (optional, user-configured only)
174
+ if (backend.env && typeof backend.env === 'object') {
175
+ const env = {};
176
+ for (const [key, val] of Object.entries(backend.env)) {
177
+ if (typeof val === 'string') env[key] = val;
178
+ }
179
+
180
+ if (Object.keys(env).length) entry.env = env;
181
+ }
182
+
183
+ if (Object.keys(entry).length) out.backends[name] = entry;
184
+ }
185
+
186
+ // Strip empty backends to avoid overriding defaults with nothing
187
+ if (!Object.keys(out.backends).length) delete out.backends;
188
+ }
189
+
190
+ if (raw.classify && typeof raw.classify === 'object') {
191
+ out.classify = {};
192
+ if (typeof raw.classify.model === 'string') out.classify.model = raw.classify.model;
193
+ if (typeof raw.classify.maxLines === 'number' && raw.classify.maxLines > 0) out.classify.maxLines = raw.classify.maxLines;
194
+ if (typeof raw.classify.maxRetries === 'number' && raw.classify.maxRetries > 0) out.classify.maxRetries = raw.classify.maxRetries;
195
+ const t = raw.classify.timeout;
196
+ if (t != null) {
197
+ try { out.classify.timeout = typeof t === 'number' ? t : ms(t); } catch { /* invalid — falls to default */ }
198
+ }
199
+ if (!Object.keys(out.classify).length) delete out.classify;
200
+ }
201
+
202
+ if (raw.trace && typeof raw.trace === 'object') {
203
+ out.trace = {};
204
+ if (typeof raw.trace.enabled === 'boolean') out.trace.enabled = raw.trace.enabled;
205
+ if (typeof raw.trace.includeOutput === 'boolean') out.trace.includeOutput = raw.trace.includeOutput;
206
+ if (typeof raw.trace.maxOutputChars === 'number') out.trace.maxOutputChars = raw.trace.maxOutputChars;
207
+ }
208
+
209
+ if (raw.agents && typeof raw.agents === 'object') {
210
+ const agents = {};
211
+ if (Array.isArray(raw.agents.disallowedTools)) {
212
+ agents.disallowedTools = raw.agents.disallowedTools.filter(
213
+ function isString(t) { return typeof t === 'string'; }
214
+ );
215
+ }
216
+ if (Object.keys(agents).length) out.agents = agents;
217
+ }
218
+
219
+ if (raw.proofOfLife && typeof raw.proofOfLife === 'object') {
220
+ out.proofOfLife = {};
221
+ const t = raw.proofOfLife.timeout;
222
+ if (t != null) {
223
+ try { out.proofOfLife.timeout = typeof t === 'number' ? t : ms(t); } catch { /* invalid — falls to default */ }
224
+ }
225
+ }
226
+
227
+ if (raw.feedback && typeof raw.feedback === 'object') {
228
+ out.feedback = {};
229
+ if (typeof raw.feedback.enabled === 'boolean') out.feedback.enabled = raw.feedback.enabled;
230
+ if (typeof raw.feedback.threshold === 'number' && raw.feedback.threshold > 0) {
231
+ out.feedback.threshold = raw.feedback.threshold;
232
+ }
233
+ if (Array.isArray(raw.feedback.categories)) {
234
+ out.feedback.categories = raw.feedback.categories.filter(
235
+ function isString(c) { return typeof c === 'string'; }
236
+ );
237
+ }
238
+ if (typeof raw.feedback.hitl === 'boolean') {
239
+ out.feedback.hitl = raw.feedback.hitl;
240
+ } else if (Array.isArray(raw.feedback.hitl)) {
241
+ out.feedback.hitl = raw.feedback.hitl.filter(
242
+ function isString(c) { return typeof c === 'string'; }
243
+ );
244
+ }
245
+ }
246
+
247
+ if (raw.workspace && typeof raw.workspace === 'object') {
248
+ out.workspace = {};
249
+ if (typeof raw.workspace.cleanup === 'boolean') out.workspace.cleanup = raw.workspace.cleanup;
250
+ }
251
+
252
+ if (raw.cleanup && typeof raw.cleanup === 'object') {
253
+ out.cleanup = {};
254
+ const ret = raw.cleanup.retention;
255
+ if (ret != null) {
256
+ try { out.cleanup.retention = typeof ret === 'number' ? ret : ms(ret); } catch { /* invalid — falls to default */ }
257
+ }
258
+ if (typeof raw.cleanup.autoprune === 'boolean') out.cleanup.autoprune = raw.cleanup.autoprune;
259
+ }
260
+
261
+ if (raw.workflows && typeof raw.workflows === 'object') {
262
+ const workflows = {};
263
+ for (const role of ['action', 'reviewer', 'risk', 'planner']) {
264
+ const wf = raw.workflows[role];
265
+ if (!wf || typeof wf !== 'object') continue;
266
+
267
+ const entry = {};
268
+ if (typeof wf.model === 'string') entry.model = wf.model;
269
+ if (typeof wf.maxAgents === 'number') entry.maxAgents = wf.maxAgents;
270
+ if (typeof wf.prompt === 'string') entry.prompt = wf.prompt;
271
+ if (typeof wf.skip === 'boolean') entry.skip = wf.skip;
272
+
273
+ if (wf.trace && typeof wf.trace === 'object') {
274
+ const trace = {};
275
+ if (typeof wf.trace.enabled === 'boolean') trace.enabled = wf.trace.enabled;
276
+ if (typeof wf.trace.maxOutputChars === 'number') trace.maxOutputChars = wf.trace.maxOutputChars;
277
+ if (Object.keys(trace).length) entry.trace = trace;
278
+ }
279
+
280
+ if (Object.keys(entry).length) workflows[role] = entry;
281
+ }
282
+ if (Object.keys(workflows).length) out.workflows = workflows;
283
+ }
284
+
285
+ if (raw.github && typeof raw.github === 'object') {
286
+ out.github = {};
287
+ if (typeof raw.github.token === 'string' && raw.github.token.length > 0) out.github.token = raw.github.token;
288
+ }
289
+
290
+ return out;
291
+ }