@1mancompany/onemancompany 0.7.73 → 0.7.76

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/frontend/app.js CHANGED
@@ -32,6 +32,8 @@ class AppController {
32
32
  this._viewingBoardProjectId = null;
33
33
  // Unread message counts per channel (channelId → count)
34
34
  this._unreadCounts = {};
35
+ // Cached company defaults for candidate hire warnings/choices
36
+ this._companyLlmDefaults = null;
35
37
  // Initialize plugin system before connecting
36
38
  window.pluginLoader.init().then(() => {
37
39
  this.connect();
@@ -3903,7 +3905,7 @@ class AppController {
3903
3905
  for (const [batchId, batch] of Object.entries(batches)) {
3904
3906
  const candidates = batch.candidates || [];
3905
3907
  if (candidates.length > 0) {
3906
- this.showCandidateSelection({
3908
+ await this.showCandidateSelection({
3907
3909
  batch_id: batchId,
3908
3910
  candidates,
3909
3911
  roles: batch.roles || [],
@@ -3916,11 +3918,63 @@ class AppController {
3916
3918
  }
3917
3919
  }
3918
3920
 
3919
- showCandidateSelection(payload) {
3921
+ async _loadCompanyLlmDefaults(force = false) {
3922
+ if (this._companyLlmDefaults && !force) return this._companyLlmDefaults;
3923
+ try {
3924
+ const data = await fetch('/api/settings/api').then(r => r.json());
3925
+ this._companyLlmDefaults = {
3926
+ provider: data.default_provider || 'openrouter',
3927
+ model: data.default_model || '',
3928
+ };
3929
+ } catch (e) {
3930
+ console.warn('Failed to load company LLM defaults for hiring UI:', e);
3931
+ this._companyLlmDefaults = { provider: 'openrouter', model: '' };
3932
+ }
3933
+ return this._companyLlmDefaults;
3934
+ }
3935
+
3936
+ _getCandidateLlmWarning(candidate) {
3937
+ const defaults = this._companyLlmDefaults;
3938
+ if (!defaults) return null;
3939
+ const hosting = candidate.hosting || 'company';
3940
+ if (hosting === 'self' || hosting === 'remote') return null;
3941
+ const talentProvider = candidate.api_provider || 'openrouter';
3942
+ const talentModel = candidate.llm_model || '';
3943
+ const providerDiffers = talentProvider !== defaults.provider;
3944
+ const modelDiffers = Boolean(defaults.model && talentModel && talentModel !== defaults.model);
3945
+ if (!providerDiffers && !modelDiffers) return null;
3946
+ return {
3947
+ talentProvider,
3948
+ talentModel,
3949
+ companyProvider: defaults.provider,
3950
+ companyModel: defaults.model,
3951
+ };
3952
+ }
3953
+
3954
+ _getCandidateConfigChoice(candidateId) {
3955
+ return Boolean(this._candidateHireConfigChoices?.get(candidateId));
3956
+ }
3957
+
3958
+ _setCandidateConfigChoice(candidateId, enabled) {
3959
+ if (!this._candidateHireConfigChoices) this._candidateHireConfigChoices = new Map();
3960
+ this._candidateHireConfigChoices.set(candidateId, Boolean(enabled));
3961
+ if (this._selectedCandidates?.has(candidateId)) {
3962
+ const selected = this._selectedCandidates.get(candidateId);
3963
+ this._selectedCandidates.set(candidateId, {
3964
+ ...selected,
3965
+ use_talent_llm_config: Boolean(enabled),
3966
+ });
3967
+ this._updateBatchBar();
3968
+ }
3969
+ }
3970
+
3971
+ async showCandidateSelection(payload) {
3972
+ await this._loadCompanyLlmDefaults(true);
3920
3973
  this._candidateBatchId = payload.batch_id;
3921
3974
  this._candidateList = payload.candidates || [];
3922
3975
  this._candidateRoles = payload.roles || [];
3923
3976
  this._selectedCandidates = new Map(); // candidateId -> {candidate, role}
3977
+ this._candidateHireConfigChoices = new Map(); // candidateId -> bool
3924
3978
  this._interviewingCandidate = null;
3925
3979
 
3926
3980
  // If no roles structure, wrap flat candidates into a single role group
@@ -3994,6 +4048,10 @@ class AppController {
3994
4048
  const familyLabels = { company: '🧠 LangChain', self: '🤖 Claude', openclaw: '🦞 OpenClaw' };
3995
4049
  const hostingLabel = esc(familyLabels[hosting] || hosting);
3996
4050
  const authLabel = esc(c.auth_method === 'oauth' ? 'OAuth' : 'API Key');
4051
+ const llmWarning = this._getCandidateLlmWarning(c);
4052
+ const warningBadge = llmWarning
4053
+ ? `<div class="card-config-warning" title="${esc(`Company default: ${llmWarning.companyProvider}${llmWarning.companyModel ? ` / ${llmWarning.companyModel}` : ''}\nTalent prefers: ${llmWarning.talentProvider}${llmWarning.talentModel ? ` / ${llmWarning.talentModel}` : ''}`)}">⚠ Different company defaults</div>`
4054
+ : '';
3997
4055
 
3998
4056
  card.innerHTML = `
3999
4057
  <div class="card-inner">
@@ -4009,6 +4067,7 @@ class AppController {
4009
4067
  <span class="score-label">${scorePct}%</span>
4010
4068
  </div>
4011
4069
  ${reasoning ? `<div class="card-reasoning" title="${esc(reasoning)}">${esc(reasoning.substring(0, 40))}${reasoning.length > 40 ? '...' : ''}</div>` : ''}
4070
+ ${warningBadge}
4012
4071
  <div class="card-cost">${costPer1m} | ${hiringFee}</div>
4013
4072
  <div class="card-hosting">${hostingLabel}</div>
4014
4073
  </div>
@@ -4063,7 +4122,11 @@ class AppController {
4063
4122
  this._selectedCandidates.delete(candidateId);
4064
4123
  cardEl.classList.remove('selected');
4065
4124
  } else {
4066
- this._selectedCandidates.set(candidateId, { candidate, role });
4125
+ this._selectedCandidates.set(candidateId, {
4126
+ candidate,
4127
+ role,
4128
+ use_talent_llm_config: this._getCandidateConfigChoice(candidateId),
4129
+ });
4067
4130
  cardEl.classList.add('selected');
4068
4131
  }
4069
4132
  this._updateBatchBar();
@@ -4100,6 +4163,21 @@ class AppController {
4100
4163
  const hostingLabel = esc(familyLabels[hosting] || hosting);
4101
4164
  const authLabel = esc(c.auth_method === 'oauth' ? 'OAuth' : 'API Key');
4102
4165
  const reasoning = c.reasoning || '';
4166
+ const llmWarning = this._getCandidateLlmWarning(c);
4167
+ const useTalentConfig = this._getCandidateConfigChoice(candidateId);
4168
+ const warningSection = llmWarning ? `
4169
+ <div class="detail-section">
4170
+ <div class="detail-label">Config Warning</div>
4171
+ <div class="detail-config-warning-box">
4172
+ <div>Company default: <strong>${esc(llmWarning.companyProvider)}${llmWarning.companyModel ? ` / ${esc(llmWarning.companyModel)}` : ''}</strong></div>
4173
+ <div>Talent prefers: <strong>${esc(llmWarning.talentProvider)}${llmWarning.talentModel ? ` / ${esc(llmWarning.talentModel)}` : ''}</strong></div>
4174
+ <div class="detail-config-warning-note">By default this hire keeps the company setting. Enable the toggle below to hire with the talent's preferred provider/model.</div>
4175
+ </div>
4176
+ <label class="detail-config-toggle">
4177
+ <input id="detail-use-talent-config" type="checkbox" ${useTalentConfig ? 'checked' : ''} />
4178
+ Use talent preferred provider/model for this hire
4179
+ </label>
4180
+ </div>` : '';
4103
4181
 
4104
4182
  content.innerHTML = `
4105
4183
  <div class="detail-header">
@@ -4117,6 +4195,7 @@ class AppController {
4117
4195
  ${tags ? `<div class="detail-section"><div class="detail-label">Personality</div><div class="detail-tags-list">${tags}</div></div>` : ''}
4118
4196
  <div class="detail-section"><div class="detail-label">Skills</div><div class="detail-skills-list">${skills || '<em>N/A</em>'}</div></div>
4119
4197
  ${tools ? `<div class="detail-section"><div class="detail-label">Tools</div><div class="detail-tools-list">${tools}</div></div>` : ''}
4198
+ ${warningSection}
4120
4199
  <div class="detail-section detail-grid">
4121
4200
  <div><div class="detail-label">LLM Model</div><div class="detail-text">🤖 ${esc(llmModel)}</div></div>
4122
4201
  <div><div class="detail-label">Provider</div><div class="detail-text">${esc(c.api_provider || 'openrouter')}</div></div>
@@ -4144,6 +4223,7 @@ class AppController {
4144
4223
  const newInterviewBtn = document.getElementById('detail-interview-btn');
4145
4224
  const newSelectBtn = document.getElementById('detail-select-btn');
4146
4225
  const newCloseBtn = document.getElementById('detail-panel-close');
4226
+ const useTalentConfigCheckbox = document.getElementById('detail-use-talent-config');
4147
4227
  newSelectBtn.textContent = isSelected ? '✗ Deselect' : '✔ Select';
4148
4228
  newSelectBtn.className = isSelected ? 'pixel-btn danger' : 'pixel-btn secondary';
4149
4229
  // Only remote (self-hosted) candidates support interview
@@ -4167,6 +4247,11 @@ class AppController {
4167
4247
  newSelectBtn.textContent = nowSelected ? '✗ Deselect' : '✔ Select';
4168
4248
  newSelectBtn.className = nowSelected ? 'pixel-btn danger' : 'pixel-btn secondary';
4169
4249
  });
4250
+ if (useTalentConfigCheckbox) {
4251
+ useTalentConfigCheckbox.addEventListener('change', () => {
4252
+ this._setCandidateConfigChoice(candidateId, useTalentConfigCheckbox.checked);
4253
+ });
4254
+ }
4170
4255
  newCloseBtn.addEventListener('click', () => {
4171
4256
  panel.classList.add('hidden');
4172
4257
  cardEl.classList.remove('detail-active');
@@ -4183,7 +4268,11 @@ class AppController {
4183
4268
 
4184
4269
  if (count > 0) {
4185
4270
  bar.classList.remove('hidden');
4186
- countEl.textContent = `${count} selected`;
4271
+ const talentConfigCount = [...this._selectedCandidates.values()]
4272
+ .filter(sel => sel.use_talent_llm_config).length;
4273
+ countEl.textContent = talentConfigCount > 0
4274
+ ? `${count} selected · ${talentConfigCount} using talent config`
4275
+ : `${count} selected`;
4187
4276
  btn.textContent = `RECRUIT PARTY (${count})`;
4188
4277
  btn.disabled = false;
4189
4278
  } else {
@@ -4196,8 +4285,12 @@ class AppController {
4196
4285
 
4197
4286
  batchHireCandidates() {
4198
4287
  const selections = [];
4199
- for (const [candidateId, { candidate, role }] of this._selectedCandidates) {
4200
- selections.push({ candidate_id: candidateId, role });
4288
+ for (const [candidateId, { role }] of this._selectedCandidates) {
4289
+ selections.push({
4290
+ candidate_id: candidateId,
4291
+ role,
4292
+ use_talent_llm_config: this._getCandidateConfigChoice(candidateId),
4293
+ });
4201
4294
  }
4202
4295
 
4203
4296
  if (!selections.length) return;
@@ -4402,6 +4495,7 @@ class AppController {
4402
4495
 
4403
4496
  this._interviewingCandidate = null;
4404
4497
  this._selectedCandidates = new Map();
4498
+ this._candidateHireConfigChoices = new Map();
4405
4499
 
4406
4500
  // Reset detail panel state so it doesn't flash stale content on reopen
4407
4501
  const detailPanel = document.getElementById('candidate-detail-panel');
@@ -4421,6 +4515,7 @@ class AppController {
4421
4515
  body: JSON.stringify({
4422
4516
  batch_id: this._candidateBatchId,
4423
4517
  candidate_id: candidate.id,
4518
+ use_talent_llm_config: this._getCandidateConfigChoice(candidate.talent_id || candidate.id),
4424
4519
  }),
4425
4520
  })
4426
4521
  .then(r => r.json())
@@ -2259,6 +2259,15 @@ h3.pixel-title { font-size: var(--text-label); }
2259
2259
  width: 100%;
2260
2260
  max-width: 105px;
2261
2261
  }
2262
+ .card-config-warning {
2263
+ margin-top: 4px;
2264
+ padding: 2px 4px;
2265
+ font-size: var(--text-micro);
2266
+ color: #000;
2267
+ background: var(--pixel-yellow);
2268
+ border: 1px solid #000;
2269
+ text-align: center;
2270
+ }
2262
2271
 
2263
2272
  /* ===== Candidate Detail Panel ===== */
2264
2273
  .candidate-detail-panel.hidden { display: none; }
@@ -2297,6 +2306,30 @@ h3.pixel-title { font-size: var(--text-label); }
2297
2306
  flex: 1;
2298
2307
  min-height: 0;
2299
2308
  }
2309
+ .detail-config-warning-box {
2310
+ margin-top: 4px;
2311
+ padding: 6px;
2312
+ border: 1px solid var(--pixel-yellow);
2313
+ background: rgba(255, 204, 0, 0.08);
2314
+ color: var(--pixel-white);
2315
+ font-size: var(--text-body);
2316
+ line-height: 1.5;
2317
+ }
2318
+ .detail-config-warning-note {
2319
+ margin-top: 6px;
2320
+ color: var(--text-dim);
2321
+ }
2322
+ .detail-config-toggle {
2323
+ display: flex;
2324
+ align-items: center;
2325
+ gap: 6px;
2326
+ margin-top: 8px;
2327
+ color: var(--pixel-yellow);
2328
+ font-size: var(--text-body);
2329
+ }
2330
+ .detail-config-toggle input {
2331
+ margin: 0;
2332
+ }
2300
2333
  .detail-header {
2301
2334
  display: flex;
2302
2335
  align-items: center;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1mancompany/onemancompany",
3
- "version": "0.7.73",
3
+ "version": "0.7.76",
4
4
  "description": "The AI Operating System for One-Person Companies",
5
5
  "bin": {
6
6
  "onemancompany": "bin/cli.js"
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "onemancompany"
3
- version = "0.7.73"
3
+ version = "0.7.76"
4
4
  description = "A one-man company simulation with pixel art visualization and LangChain AI agents"
5
5
  requires-python = ">=3.12"
6
6
  dependencies = [
@@ -45,7 +45,7 @@ from onemancompany.core.config import (
45
45
  load_employee_skills,
46
46
  read_text_utf,
47
47
  )
48
- from onemancompany.core.models import AuthMethod
48
+ from onemancompany.core.models import AuthMethod, HostingMode
49
49
  from onemancompany.core.events import CompanyEvent, event_bus
50
50
  from onemancompany.core.state import company_state
51
51
  from onemancompany.agents.prompt_builder import PromptBuilder
@@ -60,6 +60,7 @@ _TC_ATTR = "tool_calls" # AIMessage attribute name
60
60
  _UNKNOWN_TOOL = "unknown"
61
61
  _NO_OUTPUT = "(no output)"
62
62
  _MISSING_API_KEY_SENTINEL = "missing-api-key"
63
+ _COMPANY_HOSTING = HostingMode.COMPANY.value
63
64
 
64
65
 
65
66
  def _extract_text(content) -> str:
@@ -160,6 +161,7 @@ def make_llm(employee_id: str = "", temperature: float | None = None) -> BaseCha
160
161
  effective_temp = 0.7
161
162
  api_provider = settings.default_api_provider or "openrouter"
162
163
  api_key = ""
164
+ auth_method = ""
163
165
 
164
166
  if employee_id and employee_id in employee_configs:
165
167
  cfg = employee_configs[employee_id]
@@ -168,10 +170,29 @@ def make_llm(employee_id: str = "", temperature: float | None = None) -> BaseCha
168
170
  effective_temp = cfg.temperature
169
171
  api_provider = cfg.api_provider
170
172
  api_key = cfg.api_key
173
+ auth_method = cfg.auth_method
171
174
 
172
175
  if temperature is not None:
173
176
  effective_temp = temperature
174
177
 
178
+ original_auth_method = auth_method
179
+ llm_profile = {
180
+ "hosting": _COMPANY_HOSTING,
181
+ "api_provider": api_provider,
182
+ "api_key": api_key,
183
+ "llm_model": model,
184
+ "auth_method": auth_method,
185
+ }
186
+ _cfg.normalize_llm_profile_defaults(
187
+ llm_profile,
188
+ reason=f"employee {employee_id}" if employee_id else "company default",
189
+ )
190
+ api_provider = llm_profile.get("api_provider", api_provider)
191
+ model = llm_profile.get("llm_model", model)
192
+ auth_method = llm_profile.get("auth_method", auth_method)
193
+ if not original_auth_method and api_provider == "anthropic":
194
+ auth_method = settings.anthropic_auth_method
195
+
175
196
  prov = get_provider(api_provider)
176
197
 
177
198
  # For custom provider, override chat_class from runtime settings
@@ -183,9 +204,6 @@ def make_llm(employee_id: str = "", temperature: float | None = None) -> BaseCha
183
204
  if prov and effective_chat_class == CHAT_CLASS_ANTHROPIC:
184
205
  from langchain_anthropic import ChatAnthropic
185
206
 
186
- auth_method = ""
187
- if employee_id and employee_id in employee_configs:
188
- auth_method = employee_configs[employee_id].auth_method
189
207
  if not auth_method:
190
208
  auth_method = settings.anthropic_auth_method
191
209
 
@@ -42,6 +42,7 @@ from onemancompany.core.config import (
42
42
  WORKSPACE_DIR_NAME,
43
43
  EmployeeConfig,
44
44
  ensure_employee_dir,
45
+ normalize_llm_profile_defaults,
45
46
  settings,
46
47
  read_text_utf,
47
48
  write_text_utf,
@@ -927,11 +928,16 @@ async def execute_hire(
927
928
  department, DEFAULT_TOOL_PERMISSIONS_FALLBACK
928
929
  ))
929
930
 
930
- # Default model from settings if not specified
931
- if not llm_model:
932
- from onemancompany.core.config import load_app_config
933
- _settings = load_app_config()
934
- llm_model = _settings.get("default_llm_model", "") if isinstance(_settings, dict) else getattr(_settings, "default_llm_model", "")
931
+ llm_profile = {
932
+ "hosting": hosting,
933
+ "api_provider": api_provider,
934
+ "llm_model": llm_model,
935
+ "auth_method": auth_method,
936
+ }
937
+ normalize_llm_profile_defaults(llm_profile, reason=f"new hire {name}")
938
+ api_provider = llm_profile.get("api_provider", "")
939
+ llm_model = llm_profile.get("llm_model", "")
940
+ auth_method = llm_profile.get("auth_method", "api_key")
935
941
 
936
942
  # Salary
937
943
  salary = compute_salary(llm_model) if llm_model else 0.0
@@ -76,6 +76,10 @@ class HireRequest(BaseModel):
76
76
  batch_id: str = Field(description="Batch ID from the shortlist")
77
77
  candidate_id: str = Field(description="ID of the selected candidate")
78
78
  nickname: str = Field(default="", description="Optional nickname")
79
+ use_talent_llm_config: bool = Field(
80
+ default=False,
81
+ description="When true, preserve the talent's provider/model instead of company defaults",
82
+ )
79
83
 
80
84
 
81
85
  class InterviewRequest(BaseModel):
@@ -4217,7 +4217,14 @@ async def hire_candidate(body: HireRequest) -> dict:
4217
4217
 
4218
4218
  # Launch onboarding as background task
4219
4219
  spawn_background(
4220
- _do_hire_single(body.batch_id, body.candidate_id, body.nickname, candidate, coo_ctx)
4220
+ _do_hire_single(
4221
+ body.batch_id,
4222
+ body.candidate_id,
4223
+ body.nickname,
4224
+ candidate,
4225
+ coo_ctx,
4226
+ use_talent_llm_config=body.use_talent_llm_config,
4227
+ )
4221
4228
  )
4222
4229
 
4223
4230
  return {
@@ -4228,22 +4235,45 @@ async def hire_candidate(body: HireRequest) -> dict:
4228
4235
  }
4229
4236
 
4230
4237
 
4231
- def _fill_talent_defaults(talent_data: dict) -> None:
4232
- """Fill missing LLM config fields with company defaults.
4238
+ def _fill_talent_defaults(talent_data: dict, *, use_talent_llm_config: bool = False) -> None:
4239
+ """Normalize Talent Market LLM config against company defaults.
4233
4240
 
4234
- Non-self-hosted talents that lack llm_model, api_provider, or auth_method
4235
- get the company's default values instead of failing validation.
4241
+ Company-hosted hires use the company's provider/model by default so newly
4242
+ hired employees run on the configured company stack. When the CEO
4243
+ explicitly opts in, preserve the talent's preferred provider/model where
4244
+ possible while still repairing missing fields.
4236
4245
  """
4237
4246
  hosting = talent_data.get("hosting", "")
4238
- if hosting in ("self", HostingMode.SELF):
4247
+ if hosting in ("self", "remote", HostingMode.SELF, HostingMode.REMOTE):
4248
+ return
4249
+
4250
+ from onemancompany.core.config import normalize_llm_profile_defaults, settings as _settings
4251
+
4252
+ if use_talent_llm_config:
4253
+ label = talent_data.get("talent_id") or talent_data.get("id") or talent_data.get("name") or "talent"
4254
+ normalize_llm_profile_defaults(talent_data, reason=f"talent {label}")
4239
4255
  return
4240
- from onemancompany.core.config import settings as _settings
4241
- if not talent_data.get("llm_model"):
4242
- talent_data["llm_model"] = _settings.default_llm_model
4243
- logger.info("[hiring] Talent missing llm_model — using company default: {}", _settings.default_llm_model)
4244
- if not talent_data.get("api_provider"):
4245
- talent_data["api_provider"] = _settings.default_api_provider or "openrouter"
4246
- logger.info("[hiring] Talent missing api_provider — using default: {}", talent_data["api_provider"])
4256
+
4257
+ company_model = _settings.default_llm_model
4258
+ company_provider = _settings.default_api_provider or "openrouter"
4259
+
4260
+ if company_model:
4261
+ if talent_data.get("llm_model") != company_model:
4262
+ logger.info(
4263
+ "[hiring] Overriding talent llm_model '{}' with company default '{}'",
4264
+ talent_data.get("llm_model", ""),
4265
+ company_model,
4266
+ )
4267
+ talent_data["llm_model"] = company_model
4268
+
4269
+ if talent_data.get("api_provider") != company_provider:
4270
+ logger.info(
4271
+ "[hiring] Overriding talent api_provider '{}' with company default '{}'",
4272
+ talent_data.get("api_provider", ""),
4273
+ company_provider,
4274
+ )
4275
+ talent_data["api_provider"] = company_provider
4276
+
4247
4277
  if not talent_data.get("auth_method"):
4248
4278
  talent_data["auth_method"] = "api_key"
4249
4279
 
@@ -4330,6 +4360,7 @@ async def _cleanup_single_hire_failure(
4330
4360
  async def _do_hire_single(
4331
4361
  batch_id: str, candidate_id: str, nickname: str,
4332
4362
  candidate: dict, coo_ctx: dict,
4363
+ *, use_talent_llm_config: bool = False,
4333
4364
  ) -> None:
4334
4365
  """Background task: execute hire + post-hire notifications."""
4335
4366
  from pathlib import Path
@@ -4378,8 +4409,8 @@ async def _do_hire_single(
4378
4409
  logger.debug("[hiring] No local profile for talent {}, using candidate data", talent_id)
4379
4410
  talent_data = candidate
4380
4411
 
4381
- # Fill missing LLM config with company defaults
4382
- _fill_talent_defaults(talent_data)
4412
+ # Default to company config unless the hirer explicitly opts in.
4413
+ _fill_talent_defaults(talent_data, use_talent_llm_config=use_talent_llm_config)
4383
4414
 
4384
4415
  # Validate required fields
4385
4416
  missing = _check_talent_required_fields(talent_data)
@@ -4810,8 +4841,11 @@ async def _do_batch_hire(
4810
4841
  logger.debug("[batch-hire] No local profile for talent {}, using candidate data", talent_id)
4811
4842
  talent_data = candidate
4812
4843
 
4813
- # Fill missing LLM config with company defaults
4814
- _fill_talent_defaults(talent_data)
4844
+ # Default to company config unless the hirer explicitly opts in.
4845
+ _fill_talent_defaults(
4846
+ talent_data,
4847
+ use_talent_llm_config=bool(sel.get("use_talent_llm_config", False)),
4848
+ )
4815
4849
 
4816
4850
  # Validate required fields
4817
4851
  missing = _check_talent_required_fields(talent_data)
@@ -406,13 +406,11 @@ ENGINEERING_DEPT = "Engineering"
406
406
  # Default tool permissions by department (set during hiring)
407
407
  # Note: read, ls, write, edit are now BASE_TOOLS (always available, no permission needed).
408
408
  # Only gated tools need to be listed here.
409
- DEFAULT_TOOL_PERMISSIONS: dict[str, list[str]] = {
410
- "Engineering": [
411
- "bash", "use_tool",
412
- ],
413
- "Design": ["use_tool"],
414
- "Analytics": ["use_tool"],
415
- "Marketing": ["use_tool"],
409
+ DEFAULT_TOOL_PERMISSIONS = {
410
+ "Engineering": ["bash", "use_tool"],
411
+ "Design": ["bash", "use_tool"],
412
+ "Analytics": ["bash", "use_tool"],
413
+ "Marketing": ["bash", "use_tool"],
416
414
  "General": [],
417
415
  }
418
416
  DEFAULT_TOOL_PERMISSIONS_FALLBACK: list[str] = []
@@ -655,6 +653,98 @@ def sync_founding_defaults(provider: str, model: str) -> int:
655
653
  return synced
656
654
 
657
655
 
656
+ def resolve_provider_key(provider_name: str, employee_api_key: str = "") -> str:
657
+ """Resolve an API key from employee override first, then company settings."""
658
+ if employee_api_key:
659
+ return employee_api_key
660
+ provider_name = (provider_name or "").strip()
661
+ if not provider_name:
662
+ return ""
663
+ prov = get_provider(provider_name)
664
+ if prov and prov.env_key:
665
+ return getattr(settings, prov.env_key, "")
666
+ return ""
667
+
668
+
669
+ def provider_has_credentials(provider_name: str, employee_api_key: str = "") -> bool:
670
+ """Return whether a provider can be used by this employee/company."""
671
+ return bool(resolve_provider_key(provider_name, employee_api_key))
672
+
673
+
674
+ def normalize_llm_profile_defaults(profile: dict, *, reason: str = "employee") -> bool:
675
+ """Normalize company-hosted LLM settings in a profile-like dict.
676
+
677
+ Imported talents can carry provider-specific defaults such as OpenRouter.
678
+ If that provider has no credentials in this company, use the configured
679
+ company default provider/model instead of creating an employee that fails
680
+ during startup.
681
+ """
682
+ hosting = profile.get("hosting", "")
683
+ if hosting in ("self", "remote"):
684
+ return False
685
+
686
+ changed = False
687
+ default_provider = settings.default_api_provider or PROVIDER_OPENROUTER
688
+ default_model = settings.default_llm_model
689
+
690
+ provider = (profile.get("api_provider") or "").strip()
691
+ employee_key = (profile.get("api_key") or "").strip()
692
+ provider_missing_key = provider and not provider_has_credentials(provider, employee_key)
693
+ default_has_key = provider_has_credentials(default_provider)
694
+
695
+ if not profile.get("api_provider"):
696
+ profile["api_provider"] = default_provider
697
+ provider = default_provider
698
+ changed = True
699
+ logger.info("[llm-config] {} missing api_provider — using company default: {}", reason, default_provider)
700
+ elif provider != default_provider and provider_missing_key and default_has_key:
701
+ logger.info(
702
+ "[llm-config] {} provider '{}' has no credentials — using company default '{}'",
703
+ reason,
704
+ provider,
705
+ default_provider,
706
+ )
707
+ profile["api_provider"] = default_provider
708
+ provider = default_provider
709
+ changed = True
710
+ # Provider-specific model names are not always portable across gateways.
711
+ if default_model and profile.get("llm_model") != default_model:
712
+ profile["llm_model"] = default_model
713
+ changed = True
714
+
715
+ if not profile.get("llm_model") and default_model:
716
+ profile["llm_model"] = default_model
717
+ changed = True
718
+ logger.info("[llm-config] {} missing llm_model — using company default: {}", reason, default_model)
719
+
720
+ if not profile.get("auth_method"):
721
+ profile["auth_method"] = "api_key"
722
+ changed = True
723
+
724
+ return changed
725
+
726
+
727
+ def sync_company_hosted_llm_defaults() -> int:
728
+ """Repair existing company-hosted employee profiles with unusable LLM config."""
729
+ synced = 0
730
+ if not EMPLOYEES_DIR.exists():
731
+ return synced
732
+
733
+ for emp_dir in sorted(EMPLOYEES_DIR.iterdir()):
734
+ if not emp_dir.is_dir():
735
+ continue
736
+ profile_path = emp_dir / PROFILE_FILENAME
737
+ if not profile_path.exists():
738
+ continue
739
+ data = yaml.safe_load(read_text_utf(profile_path)) or {}
740
+ if data.get(PF_REMOTE):
741
+ continue
742
+ if normalize_llm_profile_defaults(data, reason=f"employee {emp_dir.name}"):
743
+ write_text_utf(profile_path, yaml.dump(data, default_flow_style=False, allow_unicode=True))
744
+ synced += 1
745
+ return synced
746
+
747
+
658
748
  # ---------------------------------------------------------------------------
659
749
  # Application config (config.yaml at project root)
660
750
  # ---------------------------------------------------------------------------
@@ -16,17 +16,60 @@ import yaml
16
16
  from onemancompany.core.config import EMPLOYEES_DIR, PROJECT_YAML_FILENAME, TASK_TREE_FILENAME, read_text_utf
17
17
  from onemancompany.core.task_lifecycle import RESOLVED, TaskPhase, NodeType
18
18
 
19
+ _CLOSED_PROJECT_STATUSES = {"archived", "completed", "failed", "cancelled"}
20
+
21
+
22
+ def _project_metadata_paths(tree_path: Path) -> list[Path]:
23
+ """Return possible project/iteration metadata files for a task tree."""
24
+ project_dir = tree_path.parent
25
+ candidates = [
26
+ project_dir / PROJECT_YAML_FILENAME,
27
+ ]
28
+ if project_dir.parent.name == "iterations":
29
+ candidates.extend([
30
+ project_dir.parent / f"{project_dir.name}.yaml",
31
+ project_dir.parent.parent / PROJECT_YAML_FILENAME,
32
+ ])
33
+ else:
34
+ candidates.extend([
35
+ project_dir.parent / PROJECT_YAML_FILENAME,
36
+ project_dir.parent.parent / PROJECT_YAML_FILENAME,
37
+ ])
38
+
39
+ seen: set[Path] = set()
40
+ unique: list[Path] = []
41
+ for candidate in candidates:
42
+ if candidate not in seen:
43
+ seen.add(candidate)
44
+ unique.append(candidate)
45
+ return unique
46
+
47
+
48
+ def _project_statuses(tree_path: Path) -> list[str]:
49
+ """Load all known status values for the tree's project/iteration."""
50
+ statuses: list[str] = []
51
+ for metadata_path in _project_metadata_paths(tree_path):
52
+ if not metadata_path.exists():
53
+ continue
54
+ try:
55
+ doc = yaml.safe_load(read_text_utf(metadata_path)) or {}
56
+ except Exception as exc:
57
+ logger.debug("Skipping unreadable project metadata {}: {}", metadata_path, exc)
58
+ continue
59
+ status = doc.get("status")
60
+ if status:
61
+ statuses.append(str(status))
62
+ return statuses
63
+
19
64
 
20
65
  def _is_project_archived(tree_path: Path) -> bool:
21
66
  """Check if the project containing this tree file is archived."""
22
- project_yaml = tree_path.parent / PROJECT_YAML_FILENAME
23
- if not project_yaml.exists():
24
- return False
25
- try:
26
- doc = yaml.safe_load(read_text_utf(project_yaml)) or {}
27
- return doc.get("status") == "archived"
28
- except Exception:
29
- return False
67
+ return "archived" in _project_statuses(tree_path)
68
+
69
+
70
+ def _is_project_closed(tree_path: Path) -> bool:
71
+ """Check if the project/iteration is already final and should not recover work."""
72
+ return any(status in _CLOSED_PROJECT_STATUSES for status in _project_statuses(tree_path))
30
73
 
31
74
 
32
75
  # ---------------------------------------------------------------------------
@@ -59,9 +102,9 @@ def recover_schedule_from_trees(
59
102
  # 1. Scan all task_tree.yaml files under projects_dir
60
103
  if projects_dir.exists():
61
104
  for tree_path in projects_dir.rglob(TASK_TREE_FILENAME):
62
- # Skip archived projects — no need to restore tasks
63
- if _is_project_archived(tree_path):
64
- logger.debug("Skipping archived project tree: {}", tree_path)
105
+ # Skip closed projects — no need to restore tasks or stale handlers.
106
+ if _is_project_closed(tree_path):
107
+ logger.debug("Skipping closed project tree: {}", tree_path)
65
108
  continue
66
109
  try:
67
110
  tree = get_tree(tree_path)
@@ -95,6 +95,15 @@ class TaskNode:
95
95
  # (agent promised action but didn't call tools). Capped at MAX_STALL_RETRIES.
96
96
  stall_retry_count: int = 0
97
97
 
98
+ # Child failure event ids already surfaced to this parent. This makes
99
+ # failure notifications edge-triggered instead of repeatedly firing while a
100
+ # failed child remains in the tree.
101
+ handled_child_failure_ids: list[str] = field(default_factory=list)
102
+
103
+ # Stable key for system-generated notification nodes so callers can dedupe
104
+ # retries/recovery without relying on prompt text.
105
+ event_key: str = ""
106
+
98
107
  # --- Content externalization tracking (not part of equality/repr) ---
99
108
  _content_dirty: bool = field(default=False, init=False, repr=False, compare=False)
100
109
  _content_loaded: bool = field(default=False, init=False, repr=False, compare=False)
@@ -215,6 +224,8 @@ class TaskNode:
215
224
  "hold_started_at": self.hold_started_at,
216
225
  "retry_count": self.retry_count,
217
226
  "stall_retry_count": self.stall_retry_count,
227
+ "handled_child_failure_ids": list(self.handled_child_failure_ids),
228
+ "event_key": self.event_key,
218
229
  "directives_count": len(self.directives),
219
230
  }
220
231
 
@@ -1300,6 +1300,107 @@ class EmployeeManager:
1300
1300
 
1301
1301
  return count
1302
1302
 
1303
+ def quiesce_project(self, project_id: str, tree_path: str = "", reason: str = "Project completed") -> int:
1304
+ """Stop stale scheduled/system work for a project that has reached a final state.
1305
+
1306
+ Work results remain in the tree for audit. Only active system nodes
1307
+ (watchdog nudges, reviews, CEO requests, adhoc/system handlers) are
1308
+ cancelled, and all schedule entries for the project are removed so
1309
+ recovery or in-memory dispatch cannot keep old handlers alive.
1310
+ """
1311
+ from onemancompany.core.task_lifecycle import safe_cancel
1312
+ from onemancompany.core.task_tree import get_tree, save_tree_async
1313
+
1314
+ def _matches(candidate: str, tree_project_id: str = "") -> bool:
1315
+ ids = {p for p in (candidate, tree_project_id) if p}
1316
+ for existing in ids:
1317
+ if (
1318
+ existing == project_id
1319
+ or existing.startswith(f"{project_id}/")
1320
+ or project_id.startswith(f"{existing}/")
1321
+ ):
1322
+ return True
1323
+ return False
1324
+
1325
+ touched_tree_paths: set[str] = {tree_path} if tree_path else set()
1326
+ removed_entries = 0
1327
+
1328
+ for emp_id, entries in list(self._schedule.items()):
1329
+ keep: list[ScheduleEntry] = []
1330
+ for entry in entries:
1331
+ remove_entry = False
1332
+ try:
1333
+ tree = get_tree(entry.tree_path)
1334
+ node = tree.get_node(entry.node_id)
1335
+ if node and _matches(node.project_id, tree.project_id):
1336
+ remove_entry = True
1337
+ touched_tree_paths.add(entry.tree_path)
1338
+ except Exception as e:
1339
+ logger.debug("[quiesce_project] Could not inspect scheduled node {}: {}", entry.node_id, e)
1340
+ if remove_entry:
1341
+ removed_entries += 1
1342
+ else:
1343
+ keep.append(entry)
1344
+ self._schedule[emp_id] = keep
1345
+
1346
+ for emp_id, entry in list(self._current_entries.items()):
1347
+ try:
1348
+ tree = get_tree(entry.tree_path)
1349
+ node = tree.get_node(entry.node_id)
1350
+ if not node or not _matches(node.project_id, tree.project_id):
1351
+ continue
1352
+ touched_tree_paths.add(entry.tree_path)
1353
+ if node.node_type in SYSTEM_NODE_TYPES and TaskPhase(node.status) not in TERMINAL:
1354
+ running = self._running_tasks.get(emp_id)
1355
+ if running and not running.done():
1356
+ running.cancel()
1357
+ logger.info(
1358
+ "[quiesce_project] Cancelled running stale system task {} for project {}",
1359
+ entry.node_id, project_id,
1360
+ )
1361
+ except Exception as e:
1362
+ logger.debug("[quiesce_project] Could not inspect running node {}: {}", entry.node_id, e)
1363
+
1364
+ cancelled_nodes = 0
1365
+ for tp in touched_tree_paths:
1366
+ if not tp:
1367
+ continue
1368
+ try:
1369
+ tree = get_tree(tp)
1370
+ except Exception as e:
1371
+ logger.debug("[quiesce_project] Could not load tree {}: {}", tp, e)
1372
+ continue
1373
+
1374
+ dirty = False
1375
+ for node in tree.all_nodes():
1376
+ if not _matches(node.project_id, tree.project_id):
1377
+ continue
1378
+ if node.node_type not in SYSTEM_NODE_TYPES:
1379
+ continue
1380
+ if TaskPhase(node.status) in TERMINAL:
1381
+ continue
1382
+ if safe_cancel(node):
1383
+ node.result = reason
1384
+ node.completed_at = node.completed_at or datetime.now().isoformat()
1385
+ cancelled_nodes += 1
1386
+ dirty = True
1387
+ for cron_name in (f"reply_{node.id}", f"holding_{node.id}"):
1388
+ try:
1389
+ stop_cron(node.employee_id, cron_name)
1390
+ except Exception as exc:
1391
+ logger.debug("[quiesce_project] Could not stop cron {}/{}: {}", node.employee_id, cron_name, exc)
1392
+ self._publish_node_update(node.employee_id, node)
1393
+ if dirty:
1394
+ save_tree_async(tp)
1395
+
1396
+ total = removed_entries + cancelled_nodes
1397
+ if total:
1398
+ logger.info(
1399
+ "[quiesce_project] Quieted project {}: {} schedule entries removed, {} system nodes cancelled",
1400
+ project_id, removed_entries, cancelled_nodes,
1401
+ )
1402
+ return total
1403
+
1303
1404
  def abort_employee(self, employee_id: str) -> int:
1304
1405
  """Cancel all tasks for an employee. Returns count cancelled."""
1305
1406
  from onemancompany.core.task_tree import get_tree, save_tree_async
@@ -2621,11 +2722,14 @@ class EmployeeManager:
2621
2722
  if c.node_type == NodeType.REVIEW
2622
2723
  and c.status in (TaskPhase.PENDING.value, TaskPhase.PROCESSING.value)
2623
2724
  )
2624
- # Check for failed children when parent is HOLDING — resume parent
2625
- # so it can react (retry via reject_child, reassign, or escalate).
2626
- has_failed_child = any(
2627
- c for c in non_review_children
2628
- if c.status == TaskPhase.FAILED.value
2725
+ # Check whether the CURRENT completing node is a failed
2726
+ # substantive child. Failure notifications must be edge-triggered
2727
+ # per child id; otherwise completed watchdog/review nodes keep
2728
+ # re-notifying about old failed siblings.
2729
+ current_failed_child = (
2730
+ node.status == TaskPhase.FAILED.value
2731
+ and node.node_type not in SYSTEM_NODE_TYPES
2732
+ and node.parent_id == parent_node.id
2629
2733
  )
2630
2734
  # Check if the CURRENT completing node was cancelled — resume parent
2631
2735
  # so it can reassess (e.g. cancelled CEO_REQUEST should unblock parent).
@@ -2633,15 +2737,22 @@ class EmployeeManager:
2633
2737
  # siblings — otherwise WATCHDOG_NUDGE completions re-trigger the cancelled
2634
2738
  # branch in an infinite loop.
2635
2739
  has_cancelled_child = node.status == TaskPhase.CANCELLED.value
2636
- if has_failed_child and parent_node.status in (TaskPhase.HOLDING.value, TaskPhase.PROCESSING.value):
2637
- failed_children = [
2638
- c for c in non_review_children
2639
- if c.status == TaskPhase.FAILED.value
2640
- ]
2641
- failure_summary = "; ".join(
2642
- f"[{c.employee_id}] {c.description_preview}: {c.result or 'no details'}"
2643
- for c in failed_children
2740
+ handled_failures = getattr(parent_node, "handled_child_failure_ids", []) or []
2741
+ failure_event_key = f"child_failure:{parent_node.id}:{node.id}"
2742
+ failure_already_handled = node.id in handled_failures
2743
+ if current_failed_child and failure_already_handled:
2744
+ logger.debug(
2745
+ "[ON_CHILD_COMPLETE] child {} failure already handled for parent {} — skipping duplicate notification",
2746
+ node.id, parent_node.id,
2644
2747
  )
2748
+
2749
+ if (
2750
+ current_failed_child
2751
+ and not failure_already_handled
2752
+ and parent_node.status in (TaskPhase.HOLDING.value, TaskPhase.PROCESSING.value)
2753
+ ):
2754
+ parent_node.handled_child_failure_ids = [*handled_failures, node.id]
2755
+ failure_summary = f"[{node.employee_id}] {node.description_preview}: {node.result or 'no details'}"
2645
2756
  resume_desc = (
2646
2757
  f"[Child Task Failure] The following child tasks have failed:\n\n"
2647
2758
  f"{failure_summary}\n\n"
@@ -2668,19 +2779,39 @@ class EmployeeManager:
2668
2779
  logger.debug("[TASK LIFECYCLE] parent={} → PROCESSING (child failed, resuming)", parent_node.id)
2669
2780
  else:
2670
2781
  logger.debug("[TASK LIFECYCLE] parent={} already PROCESSING (child failed, re-dispatching)", parent_node.id)
2671
- # Inject failure context into parent's description for re-execution
2672
- notify_node = tree.add_child(
2673
- parent_id=parent_node.id,
2674
- employee_id=parent_node.employee_id,
2675
- description=resume_desc,
2676
- acceptance_criteria=[],
2782
+ # Inject failure context into a short-lived system node. The
2783
+ # event_key protects against duplicate creation if recovery
2784
+ # or a concurrent callback sees the same failure event.
2785
+ notify_node = next(
2786
+ (
2787
+ c for c in children
2788
+ if c.node_type == NodeType.WATCHDOG_NUDGE
2789
+ and getattr(c, "event_key", "") == failure_event_key
2790
+ and TaskPhase(c.status) not in TERMINAL
2791
+ ),
2792
+ None,
2677
2793
  )
2678
- notify_node.node_type = NodeType.WATCHDOG_NUDGE
2679
- notify_node.project_id = project_id
2680
- notify_node.project_dir = parent_node.project_dir or str(Path(entry.tree_path).parent)
2794
+ if notify_node:
2795
+ logger.debug(
2796
+ "[ON_CHILD_COMPLETE] failure notification {} already exists for event {}",
2797
+ notify_node.id, failure_event_key,
2798
+ )
2799
+ else:
2800
+ notify_node = tree.add_child(
2801
+ parent_id=parent_node.id,
2802
+ employee_id=parent_node.employee_id,
2803
+ description=resume_desc,
2804
+ acceptance_criteria=[],
2805
+ timeout_seconds=180,
2806
+ )
2807
+ notify_node.node_type = NodeType.WATCHDOG_NUDGE
2808
+ notify_node.event_key = failure_event_key
2809
+ notify_node.project_id = project_id
2810
+ notify_node.project_dir = parent_node.project_dir or str(Path(entry.tree_path).parent)
2681
2811
  save_tree_async(entry.tree_path)
2682
- self.schedule_node(parent_node.employee_id, notify_node.id, entry.tree_path)
2683
- self._schedule_next(parent_node.employee_id)
2812
+ if notify_node.status == TaskPhase.PENDING.value:
2813
+ self.schedule_node(parent_node.employee_id, notify_node.id, entry.tree_path)
2814
+ self._schedule_next(parent_node.employee_id)
2684
2815
 
2685
2816
  elif has_cancelled_child and parent_node.status in (TaskPhase.HOLDING.value, TaskPhase.PROCESSING.value):
2686
2817
  cancelled_children = [
@@ -3204,6 +3335,9 @@ class EmployeeManager:
3204
3335
  await _store.save_project_status(project_id, ITER_STATUS_FAILED)
3205
3336
  else:
3206
3337
  complete_project(project_id, label)
3338
+ tree_path = str(Path(node.project_dir) / TASK_TREE_FILENAME) if node.project_dir else ""
3339
+ quiet_reason = "Superseded: project failed" if agent_error else "Superseded: project completed"
3340
+ self.quiesce_project(project_id, tree_path=tree_path, reason=quiet_reason)
3207
3341
 
3208
3342
  # --- Resource cleanup: evict tree cache, task logs, Claude sessions ---
3209
3343
  self._release_project_resources(employee_id, node, project_id)
@@ -489,7 +489,15 @@ async def lifespan(app: FastAPI):
489
489
  logger.warning("Talent Market connection failed (configure in Settings): {}", e)
490
490
 
491
491
  from onemancompany.core.vessel_config import load_vessel_config
492
- from onemancompany.core.config import EMPLOYEES_DIR as _EMPLOYEES_DIR, employee_configs as _emp_cfgs
492
+ from onemancompany.core.config import (
493
+ EMPLOYEES_DIR as _EMPLOYEES_DIR,
494
+ employee_configs as _emp_cfgs,
495
+ sync_company_hosted_llm_defaults,
496
+ )
497
+
498
+ _synced_llm_profiles = sync_company_hosted_llm_defaults()
499
+ if _synced_llm_profiles:
500
+ logger.info("[startup] Repaired {} employee LLM profile(s) to use available company defaults", _synced_llm_profiles)
493
501
 
494
502
  # Founding employees — hosting-aware registration
495
503
  from onemancompany.core.vessel import register_founding_employee