@1mancompany/onemancompany 0.7.39 → 0.7.47

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
@@ -95,6 +95,7 @@ class AppController {
95
95
  const { employees, tasks, rooms, tools, activity_log, version, office_layout } = data;
96
96
  console.debug(`[bootstrap] /api/bootstrap took ${(performance.now() - t0).toFixed(0)}ms`);
97
97
 
98
+ this._cachedEmployees = employees || [];
98
99
  this.updateRoster(employees);
99
100
  this.updateOneononeDropdown(employees);
100
101
  this.updateProjectsPanel();
@@ -201,6 +202,7 @@ class AppController {
201
202
 
202
203
  async _fetchAndRenderRoster() {
203
204
  const employees = await fetch('/api/employees').then(r => r.json());
205
+ this._cachedEmployees = employees || [];
204
206
  this.updateRoster(employees);
205
207
  this.updateOneononeDropdown(employees);
206
208
  if (window.officeRenderer) {
@@ -1090,11 +1092,11 @@ class AppController {
1090
1092
  if (data.status === 'ok') {
1091
1093
  console.log('Abort all result:', data);
1092
1094
  } else {
1093
- alert(data.detail || data.message || 'Failed to abort all tasks');
1095
+ this._showToast(data.detail || data.message || 'Failed to abort all tasks', 'error');
1094
1096
  }
1095
1097
  } catch (e) {
1096
1098
  console.error('Abort all failed:', e);
1097
- alert('Failed to abort all tasks');
1099
+ this._showToast('Failed to abort all tasks', 'error');
1098
1100
  }
1099
1101
  });
1100
1102
 
@@ -1259,7 +1261,7 @@ class AppController {
1259
1261
  startBtn.disabled = false;
1260
1262
 
1261
1263
  if (res.error) {
1262
- alert(res.error);
1264
+ this._showToast(res.error, 'error');
1263
1265
  return;
1264
1266
  }
1265
1267
 
@@ -1752,7 +1754,7 @@ class AppController {
1752
1754
  })
1753
1755
  .then(data => {
1754
1756
  if (data.error) {
1755
- alert(`Cannot dismiss: ${data.error}`);
1757
+ this._showToast(`Cannot dismiss: ${data.error}`, 'error');
1756
1758
  } else {
1757
1759
  this.closeEmployeeDetail();
1758
1760
  this.addLog(`Dismissed ${data.name} (${data.nickname}) — ${data.reason}`);
@@ -1761,7 +1763,7 @@ class AppController {
1761
1763
  })
1762
1764
  .catch(err => {
1763
1765
  console.error('Fire employee error:', err);
1764
- alert('Failed to dismiss employee. See console for details.');
1766
+ this._showToast('Failed to dismiss employee', 'error');
1765
1767
  });
1766
1768
  }
1767
1769
 
@@ -2027,11 +2029,11 @@ class AppController {
2027
2029
  if (data.status === 'ok') {
2028
2030
  this._fetchCronList(empId);
2029
2031
  } else {
2030
- alert(data.detail || data.message || 'Failed to stop cron');
2032
+ this._showToast(data.detail || data.message || 'Failed to stop cron', 'error');
2031
2033
  }
2032
2034
  } catch (err) {
2033
2035
  console.error('Failed to cancel cron:', err);
2034
- alert('Failed to stop cron job');
2036
+ this._showToast('Failed to stop cron job', 'error');
2035
2037
  }
2036
2038
  }
2037
2039
 
@@ -2045,11 +2047,11 @@ class AppController {
2045
2047
  if (data.status === 'ok') {
2046
2048
  this._fetchCronList(empId);
2047
2049
  } else {
2048
- alert(data.detail || data.message || 'Failed to stop all crons');
2050
+ this._showToast(data.detail || data.message || 'Failed to stop all crons', 'error');
2049
2051
  }
2050
2052
  } catch (err) {
2051
2053
  console.error('Failed to stop all crons:', err);
2052
- alert('Failed to stop all cron jobs');
2054
+ this._showToast('Failed to stop all cron jobs', 'error');
2053
2055
  }
2054
2056
  }
2055
2057
 
@@ -2737,7 +2739,7 @@ class AppController {
2737
2739
  fetch('/api/ceo/dnd').then(r => r.json()).then(data => {
2738
2740
  dndBtn.classList.toggle('active', data.dnd);
2739
2741
  if (data.dnd) dndBtn.title = 'Do Not Disturb (ON)';
2740
- }).catch(() => {});
2742
+ }).catch(err => console.warn('[dnd] state load failed:', err));
2741
2743
  }
2742
2744
 
2743
2745
  // ===== @Mention Autocomplete ===== //
@@ -5898,7 +5900,7 @@ class AppController {
5898
5900
  });
5899
5901
  const result = await resp.json();
5900
5902
  if (result.status === 'error') {
5901
- alert(result.message);
5903
+ this._showToast(result.message, 'error');
5902
5904
  } else {
5903
5905
  this._renderSystemCrons();
5904
5906
  }
@@ -6679,7 +6681,7 @@ class AppController {
6679
6681
  window.open(data.auth_url, '_blank', 'width=600,height=700');
6680
6682
  setTimeout(() => this.openToolDetail(toolId), 5000);
6681
6683
  } else {
6682
- alert(data.message || 'OAuth login failed');
6684
+ this._showToast(data.message || 'OAuth login failed', 'error');
6683
6685
  }
6684
6686
  break;
6685
6687
  }
@@ -6692,7 +6694,7 @@ class AppController {
6692
6694
  case 'credentials': {
6693
6695
  const clientId = document.getElementById('tool-oauth-client-id')?.value || '';
6694
6696
  const clientSecret = document.getElementById('tool-oauth-client-secret')?.value || '';
6695
- if (!clientId || !clientSecret) { alert('Both Client ID and Client Secret required'); return; }
6697
+ if (!clientId || !clientSecret) { this._showToast('Both Client ID and Client Secret required', 'error'); return; }
6696
6698
  const res = await fetch(`/api/tools/${esc}/oauth/credentials`, {
6697
6699
  method: 'POST',
6698
6700
  headers: { 'Content-Type': 'application/json' },
@@ -6700,7 +6702,7 @@ class AppController {
6700
6702
  });
6701
6703
  const data = await res.json();
6702
6704
  if (data.status === 'ok') this.openToolDetail(toolId);
6703
- else alert(data.message || 'Failed');
6705
+ else this._showToast(data.message || 'Failed', 'error');
6704
6706
  break;
6705
6707
  }
6706
6708
  case 'save_env': {
@@ -6714,7 +6716,7 @@ class AppController {
6714
6716
  });
6715
6717
  const data = await res.json();
6716
6718
  if (data.status === 'ok') this.openToolDetail(toolId);
6717
- else alert(data.message || 'Failed');
6719
+ else this._showToast(data.message || 'Failed', 'error');
6718
6720
  break;
6719
6721
  }
6720
6722
  }
@@ -6725,7 +6727,7 @@ class AppController {
6725
6727
  async _templateOpen(toolId, filename) {
6726
6728
  const esc = encodeURIComponent;
6727
6729
  const res = await fetch(`/api/tools/${esc(toolId)}/templates/${esc(filename)}`);
6728
- if (!res.ok) { alert('Failed to load template'); return; }
6730
+ if (!res.ok) { this._showToast('Failed to load template', 'error'); return; }
6729
6731
  const data = await res.json();
6730
6732
  const body = document.getElementById('tool-list-body');
6731
6733
  const escH = (t) => this._escapeHtml(t);
@@ -6743,7 +6745,7 @@ class AppController {
6743
6745
 
6744
6746
  async _templateSave(toolId, filename) {
6745
6747
  const content = document.getElementById('template-editor')?.value || '';
6746
- if (!content.trim()) { alert('Template cannot be empty'); return; }
6748
+ if (!content.trim()) { this._showToast('Template cannot be empty', 'error'); return; }
6747
6749
  const esc = encodeURIComponent;
6748
6750
  const res = await fetch(`/api/tools/${esc(toolId)}/templates/${esc(filename)}`, {
6749
6751
  method: 'PUT',
@@ -6752,7 +6754,7 @@ class AppController {
6752
6754
  });
6753
6755
  const data = await res.json();
6754
6756
  if (data.status === 'ok') this.openToolDetail(toolId);
6755
- else alert(data.message || 'Save failed');
6757
+ else this._showToast(data.message || 'Save failed', 'error');
6756
6758
  }
6757
6759
 
6758
6760
  async _templateDelete(toolId, filename) {
@@ -6761,7 +6763,7 @@ class AppController {
6761
6763
  const res = await fetch(`/api/tools/${esc(toolId)}/templates/${esc(filename)}`, { method: 'DELETE' });
6762
6764
  const data = await res.json();
6763
6765
  if (data.status === 'ok') this.openToolDetail(toolId);
6764
- else alert(data.message || 'Delete failed');
6766
+ else this._showToast(data.message || 'Delete failed', 'error');
6765
6767
  }
6766
6768
 
6767
6769
  _templateNew(toolId, templatesDir) {
@@ -6961,7 +6963,7 @@ class AppController {
6961
6963
  }
6962
6964
  } catch (err) {
6963
6965
  this._chatPanel.showTyping(false);
6964
- alert(`Failed to send message: ${err.message}`);
6966
+ this._showToast(`Failed to send message: ${err.message}`, 'error');
6965
6967
  }
6966
6968
  // Reply arrives via WebSocket conversation_message event
6967
6969
  }
@@ -6987,7 +6989,7 @@ class AppController {
6987
6989
  const empName = this._resolveEmployeeName(data.employee_id || '');
6988
6990
  this.logEntry('SYSTEM', `🧹 Cleared 1-on-1 history for ${empName}.`, 'system');
6989
6991
  } catch (err) {
6990
- alert(`Failed to clear history: ${err.message}`);
6992
+ this._showToast(`Failed to clear history: ${err.message}`, 'error');
6991
6993
  }
6992
6994
  }
6993
6995
 
@@ -7099,6 +7101,7 @@ class AppController {
7099
7101
  }
7100
7102
 
7101
7103
  _escapeHtml(text) {
7104
+ if (text == null) return '';
7102
7105
  const div = document.createElement('div');
7103
7106
  div.textContent = text;
7104
7107
  return div.innerHTML;
@@ -7396,20 +7399,15 @@ class AppController {
7396
7399
  list.appendChild(row);
7397
7400
  }
7398
7401
 
7399
- async _populateProductOwnerDropdown() {
7400
- try {
7401
- const data = await fetch('/api/bootstrap').then(r => r.json());
7402
- const sel = document.getElementById('create-product-owner');
7403
- if (!sel) return;
7404
- sel.innerHTML = '<option value="">Select owner...</option>';
7405
- for (const emp of (data.employees || [])) {
7406
- const opt = document.createElement('option');
7407
- opt.value = emp.id;
7408
- opt.textContent = `${emp.name || emp.id} (${emp.role || ''})`;
7409
- sel.appendChild(opt);
7410
- }
7411
- } catch (e) {
7412
- console.debug('Failed to populate owner dropdown:', e);
7402
+ _populateProductOwnerDropdown() {
7403
+ const sel = document.getElementById('create-product-owner');
7404
+ if (!sel) return;
7405
+ sel.innerHTML = '<option value="">Select owner...</option>';
7406
+ for (const emp of (this._cachedEmployees || [])) {
7407
+ const opt = document.createElement('option');
7408
+ opt.value = emp.id;
7409
+ opt.textContent = `${emp.name || emp.id} (${emp.role || ''})`;
7410
+ sel.appendChild(opt);
7413
7411
  }
7414
7412
  }
7415
7413
 
@@ -7725,15 +7723,13 @@ class AppController {
7725
7723
  ownerEl.className = 'form-input';
7726
7724
  ownerEl.style.width = 'auto';
7727
7725
  ownerEl.innerHTML = '<option value="">Unassigned</option>';
7728
- fetch('/api/bootstrap').then(r => r.json()).then(d => {
7729
- for (const emp of (d.employees || [])) {
7730
- const opt = document.createElement('option');
7731
- opt.value = emp.id;
7732
- opt.textContent = `${emp.name || emp.id}`;
7733
- ownerEl.appendChild(opt);
7734
- }
7735
- ownerEl.value = product.owner_id || '';
7736
- });
7726
+ for (const emp of (this._cachedEmployees || [])) {
7727
+ const opt = document.createElement('option');
7728
+ opt.value = emp.id;
7729
+ opt.textContent = `${emp.name || emp.id}`;
7730
+ ownerEl.appendChild(opt);
7731
+ }
7732
+ ownerEl.value = product.owner_id || '';
7737
7733
  ownerEl.addEventListener('change', () => {
7738
7734
  fetch(`/api/product/${encodeURIComponent(slug)}`, {
7739
7735
  method: 'PUT',
@@ -8220,15 +8216,13 @@ class AppController {
8220
8216
  assignSel.style.width = 'auto';
8221
8217
  assignSel.style.display = 'inline';
8222
8218
  assignSel.innerHTML = '<option value="">Unassigned</option>';
8223
- fetch('/api/bootstrap').then(r => r.json()).then(d => {
8224
- for (const emp of (d.employees || [])) {
8225
- const opt = document.createElement('option');
8226
- opt.value = emp.id;
8227
- opt.textContent = emp.name || emp.id;
8228
- assignSel.appendChild(opt);
8229
- }
8230
- assignSel.value = issue.assignee_id || '';
8231
- });
8219
+ for (const emp of (this._cachedEmployees || [])) {
8220
+ const opt = document.createElement('option');
8221
+ opt.value = emp.id;
8222
+ opt.textContent = emp.name || emp.id;
8223
+ assignSel.appendChild(opt);
8224
+ }
8225
+ assignSel.value = issue.assignee_id || '';
8232
8226
  assignSel.addEventListener('click', (e) => e.stopPropagation());
8233
8227
  assignSel.addEventListener('change', () => {
8234
8228
  fetch(`/api/product/${encodeURIComponent(slug)}/issue/${encodeURIComponent(issue.id)}`, {
@@ -9067,8 +9061,9 @@ class AppController {
9067
9061
  header.className = 'review-card-header';
9068
9062
  const trigger = rev.trigger || 'manual';
9069
9063
  const dateStr = rev.created_at ? new Date(rev.created_at).toLocaleDateString() : '';
9070
- header.innerHTML = `<span class="review-trigger">${this._escHtml(trigger)}</span> <span class="review-date">${dateStr}</span>`;
9071
- if (rev.owner) header.innerHTML += ` <span class="review-owner">Owner: ${this._escHtml(rev.owner)}</span>`;
9064
+ let headerHtml = `<span class="review-trigger">${this._escHtml(trigger)}</span> <span class="review-date">${dateStr}</span>`;
9065
+ if (rev.owner) headerHtml += ` <span class="review-owner">Owner: ${this._escHtml(rev.owner)}</span>`;
9066
+ header.innerHTML = headerHtml;
9072
9067
  card.appendChild(header);
9073
9068
 
9074
9069
  // Checklist items
@@ -11,22 +11,22 @@
11
11
  <script defer src="https://cloud.umami.is/script.js" data-website-id="e72ced7e-c551-40a9-b60e-18bb7db0592f"></script>
12
12
  </head>
13
13
  <body>
14
- <div id="app">
14
+ <div id="app" role="application" aria-label="One Man Company CEO Console">
15
15
  <!-- Left top: Products & Projects -->
16
- <div id="left-top-panel">
16
+ <aside id="left-top-panel" aria-label="Products">
17
17
  <div class="collapsible-header" data-target="projects-panel-body">
18
18
  <span class="collapse-arrow">&#9660;</span>
19
19
  <h3 class="pixel-title">PRODUCTS</h3>
20
- <button id="create-product-btn" class="panel-add-btn" title="New Product">+</button>
21
- <button id="import-product-btn" class="panel-add-btn" title="Import Product">&#8593;</button>
20
+ <button id="create-product-btn" class="panel-add-btn" title="New Product" aria-label="New Product">+</button>
21
+ <button id="import-product-btn" class="panel-add-btn" title="Import Product" aria-label="Import Product">&#8593;</button>
22
22
  </div>
23
23
  <div id="projects-panel-body" class="collapsible-body">
24
24
  <div id="projects-panel-list"></div>
25
25
  </div>
26
- </div>
26
+ </aside>
27
27
 
28
28
  <!-- Left bottom: Activity Log -->
29
- <div id="left-bottom-panel">
29
+ <aside id="left-bottom-panel" aria-label="Activity Log">
30
30
  <div class="collapsible-header" data-target="activity-body">
31
31
  <span class="collapse-arrow">&#9660;</span>
32
32
  <h3 class="pixel-title">ACTIVITY LOG</h3>
@@ -34,10 +34,10 @@
34
34
  <div id="activity-body" class="collapsible-body collapsible-flex">
35
35
  <div id="activity-log" style="background:#0a0a0a;flex:1"></div>
36
36
  </div>
37
- </div>
37
+ </aside>
38
38
 
39
39
  <!-- Center top: Office Canvas -->
40
- <div id="office-panel">
40
+ <main id="office-panel">
41
41
  <div class="panel-header">
42
42
  <h1 class="pixel-title">&#127970; ONE MAN COMPANY <span id="app-version" class="version-badge"></span></h1>
43
43
  <div id="status-bar">
@@ -45,22 +45,22 @@
45
45
  <span class="status-item status-clickable" id="tool-count" onclick="window.app.openToolList()">&#128295; 0</span>
46
46
  <span class="status-item" id="room-count">&#127970; 0/0</span>
47
47
  <!-- Meeting moved to /meeting slash command in CEO console -->
48
- <button id="ex-employee-toolbar-btn" class="toolbar-icon-btn" title="Ex-Employee Wall">&#128220;</button>
49
- <button id="company-culture-toolbar-btn" class="toolbar-icon-btn" title="Company Culture">&#127988;</button>
50
- <button id="company-direction-toolbar-btn" class="toolbar-icon-btn" title="Company Direction">&#127919;</button>
51
- <button id="dashboard-toolbar-btn" class="toolbar-icon-btn" title="Dashboard">&#128202;</button>
52
- <button id="announcements-toolbar-btn" class="toolbar-icon-btn" title="Announcements" style="position:relative;">&#128276;<span id="announcements-badge" class="announcement-badge hidden"></span></button>
53
- <button id="settings-toolbar-btn" class="toolbar-icon-btn" title="Settings">&#9881;</button>
54
- <span class="status-item" id="connection-status">&#9679; OFFLINE</span>
48
+ <button id="ex-employee-toolbar-btn" class="toolbar-icon-btn" title="Ex-Employee Wall" aria-label="Ex-Employee Wall">&#128220;</button>
49
+ <button id="company-culture-toolbar-btn" class="toolbar-icon-btn" title="Company Culture" aria-label="Company Culture">&#127988;</button>
50
+ <button id="company-direction-toolbar-btn" class="toolbar-icon-btn" title="Company Direction" aria-label="Company Direction">&#127919;</button>
51
+ <button id="dashboard-toolbar-btn" class="toolbar-icon-btn" title="Dashboard" aria-label="Dashboard">&#128202;</button>
52
+ <button id="announcements-toolbar-btn" class="toolbar-icon-btn" title="Announcements" aria-label="Announcements" style="position:relative;">&#128276;<span id="announcements-badge" class="announcement-badge hidden"></span></button>
53
+ <button id="settings-toolbar-btn" class="toolbar-icon-btn" title="Settings" aria-label="Settings">&#9881;</button>
54
+ <span class="status-item" id="connection-status" role="status" aria-live="polite">&#9679; OFFLINE</span>
55
55
  <span class="status-item" id="last-sync-time"></span>
56
56
  </div>
57
57
  </div>
58
- <canvas id="office-canvas" width="640" height="480"></canvas>
58
+ <canvas id="office-canvas" width="640" height="480" aria-label="Office floor plan" role="img"></canvas>
59
59
  <div id="tooltip" class="tooltip hidden"></div>
60
- </div>
60
+ </main>
61
61
 
62
62
  <!-- Right top: Team Roster -->
63
- <div id="roster-panel">
63
+ <aside id="roster-panel" aria-label="Team Roster">
64
64
  <div class="collapsible-header" data-target="roster-body">
65
65
  <span class="collapse-arrow">&#9660;</span>
66
66
  <h3 class="pixel-title">TEAM ROSTER</h3>
@@ -73,7 +73,7 @@
73
73
  </div>
74
74
  <div id="roster-list"></div>
75
75
  </div>
76
- </div>
76
+ </aside>
77
77
 
78
78
  <!-- Bottom spanning center+right: CEO Console -->
79
79
  <div id="console-panel">
@@ -85,11 +85,11 @@
85
85
  <div id="ceo-avatar-area">
86
86
  <img id="ceo-avatar" width="32" height="32" src="/api/employees/00001/avatar" onerror="this.style.display='none'" />
87
87
  <span class="ceo-label">&#128081; YOU</span>
88
- <button id="dnd-toggle-btn" class="toolbar-icon-btn" title="Do Not Disturb">&#127769;</button>
89
- <button id="bg-tasks-toolbar-btn" class="toolbar-icon-btn" title="Background Tasks">&#9641;</button>
90
- <button id="screenshot-toolbar-btn" class="toolbar-icon-btn" title="Export SVG Screenshot">&#128247;</button>
91
- <button id="abort-all-toolbar-btn" class="toolbar-icon-btn" title="Stop All Tasks" style="color: #ff4444;">&#9888;</button>
92
- <button id="reload-toolbar-btn" class="reload-btn" title="Force reload all data from disk">&#128260;</button>
88
+ <button id="dnd-toggle-btn" class="toolbar-icon-btn" title="Do Not Disturb" aria-label="Do Not Disturb">&#127769;</button>
89
+ <button id="bg-tasks-toolbar-btn" class="toolbar-icon-btn" title="Background Tasks" aria-label="Background Tasks">&#9641;</button>
90
+ <button id="screenshot-toolbar-btn" class="toolbar-icon-btn" title="Export SVG Screenshot" aria-label="Export SVG Screenshot">&#128247;</button>
91
+ <button id="abort-all-toolbar-btn" class="toolbar-icon-btn" title="Stop All Tasks" aria-label="Stop All Tasks" style="color: #ff4444;">&#9888;</button>
92
+ <button id="reload-toolbar-btn" class="reload-btn" title="Force reload all data from disk" aria-label="Force reload all data from disk">&#128260;</button>
93
93
  </div>
94
94
  <div id="ceo-split">
95
95
  <div id="ceo-project-list">
@@ -118,7 +118,7 @@
118
118
  </select>
119
119
  </div>
120
120
  <div id="ceo-conv-input-row">
121
- <textarea id="ceo-conv-input" placeholder="$ Type message, / for commands (Enter to send)" rows="1"></textarea>
121
+ <textarea id="ceo-conv-input" placeholder="$ Type message, / for commands (Enter to send)" rows="1" aria-label="CEO message input"></textarea>
122
122
  <input type="file" id="ceo-file-input" multiple hidden />
123
123
  </div>
124
124
  <div id="ceo-slash-menu" class="hidden"></div>
@@ -15,7 +15,22 @@
15
15
  --pixel-white: #e0e0f0;
16
16
  --pixel-gray: #888899;
17
17
  --text-dim: #6666aa;
18
+ --text-primary: #e0e0f0;
19
+ --font-pixel: 'Press Start 2P', monospace;
20
+ --font-mono: 'Courier New', Courier, monospace;
21
+ --bg-card: #16162a;
18
22
  --font-boost: 2px; /* Text size boost: 0px (small), 2px (medium, default), 4px (large) */
23
+
24
+ /* z-index scale */
25
+ --z-base: 2;
26
+ --z-dropdown: 10;
27
+ --z-sticky: 100;
28
+ --z-overlay: 200;
29
+ --z-floating: 300;
30
+ --z-modal: 900;
31
+ --z-toast-backdrop: 9998;
32
+ --z-toast: 9999;
33
+ --z-critical: 99999;
19
34
  }
20
35
 
21
36
  * {
@@ -24,6 +39,18 @@
24
39
  image-rendering: crisp-edges;
25
40
  }
26
41
 
42
+ :focus-visible {
43
+ outline: 2px solid var(--pixel-cyan);
44
+ outline-offset: 2px;
45
+ }
46
+
47
+ input:focus-visible,
48
+ textarea:focus-visible,
49
+ select:focus-visible {
50
+ outline: 2px solid var(--pixel-cyan);
51
+ outline-offset: 0;
52
+ }
53
+
27
54
  body {
28
55
  background: var(--bg-dark);
29
56
  color: var(--pixel-white);
@@ -224,7 +251,7 @@ body.resize-dragging {
224
251
  font-size: calc(7px + var(--font-boost));
225
252
  line-height: 1.8;
226
253
  pointer-events: none;
227
- z-index: 10;
254
+ z-index: var(--z-dropdown);
228
255
  max-width: 200px;
229
256
  white-space: pre-wrap;
230
257
  }
@@ -258,7 +285,7 @@ body.resize-dragging {
258
285
  /* ===== Floating Panel (Settings dropdown etc.) ===== */
259
286
  .floating-panel {
260
287
  position: fixed;
261
- z-index: 300;
288
+ z-index: var(--z-floating);
262
289
  background: var(--bg-panel, #1a1a2e);
263
290
  border: 2px solid var(--pixel-cyan, #0ff);
264
291
  box-shadow: 0 4px 20px rgba(0, 255, 255, 0.2);
@@ -361,7 +388,7 @@ body.resize-dragging {
361
388
  border-radius: 2px;
362
389
  white-space: nowrap;
363
390
  pointer-events: none;
364
- z-index: 100;
391
+ z-index: var(--z-sticky);
365
392
  }
366
393
 
367
394
  .reload-btn:disabled {
@@ -908,7 +935,7 @@ body.resize-dragging {
908
935
  width: 100vw;
909
936
  height: 100vh;
910
937
  background: rgba(0, 0, 0, 0.75);
911
- z-index: 100;
938
+ z-index: var(--z-sticky);
912
939
  display: flex;
913
940
  align-items: center;
914
941
  justify-content: center;
@@ -1794,7 +1821,7 @@ body.resize-dragging {
1794
1821
  border: 1px solid var(--pixel-green);
1795
1822
  background: var(--pixel-green);
1796
1823
  opacity: 0;
1797
- z-index: 2;
1824
+ z-index: var(--z-base);
1798
1825
  display: flex;
1799
1826
  align-items: center;
1800
1827
  justify-content: center;
@@ -2017,7 +2044,7 @@ body.resize-dragging {
2017
2044
  background: var(--bg-panel);
2018
2045
  border: 1px solid var(--pixel-cyan);
2019
2046
  box-shadow: 0 0 20px rgba(0, 221, 255, 0.2);
2020
- z-index: 900;
2047
+ z-index: var(--z-modal);
2021
2048
  font-family: var(--font-pixel);
2022
2049
  overflow: hidden;
2023
2050
  transition: max-height 0.3s ease;
@@ -3567,7 +3594,7 @@ body.resize-dragging {
3567
3594
  top: 0;
3568
3595
  left: 0;
3569
3596
  right: 0;
3570
- z-index: 9998;
3597
+ z-index: var(--z-toast-backdrop);
3571
3598
  display: flex;
3572
3599
  align-items: center;
3573
3600
  justify-content: center;
@@ -3608,7 +3635,7 @@ body.resize-dragging {
3608
3635
  top: 0;
3609
3636
  left: 0;
3610
3637
  right: 0;
3611
- z-index: 9999;
3638
+ z-index: var(--z-toast);
3612
3639
  display: flex;
3613
3640
  justify-content: center;
3614
3641
  padding: 8px;
@@ -4600,7 +4627,7 @@ body.resize-dragging {
4600
4627
  padding: 16px;
4601
4628
  background: #111;
4602
4629
  border-left: 1px solid var(--pixel-green-dim, #1a3a2a);
4603
- z-index: 10;
4630
+ z-index: var(--z-dropdown);
4604
4631
  }
4605
4632
 
4606
4633
  .project-tree-drawer.hidden {
@@ -4677,7 +4704,7 @@ body.resize-dragging {
4677
4704
  top: 0; left: 0;
4678
4705
  width: 100vw; height: 100vh;
4679
4706
  background: rgba(0, 0, 0, 0.8);
4680
- z-index: 200;
4707
+ z-index: var(--z-overlay);
4681
4708
  display: flex;
4682
4709
  align-items: center;
4683
4710
  justify-content: center;
@@ -4949,7 +4976,7 @@ body.resize-dragging {
4949
4976
  position: fixed;
4950
4977
  top: 0; left: 0; right: 0; bottom: 0;
4951
4978
  background: rgba(0, 0, 0, 0.7);
4952
- z-index: 200;
4979
+ z-index: var(--z-overlay);
4953
4980
  display: flex;
4954
4981
  align-items: center;
4955
4982
  justify-content: center;
@@ -5602,7 +5629,7 @@ body.resize-dragging {
5602
5629
  border-radius: 4px;
5603
5630
  max-height: 180px;
5604
5631
  overflow-y: auto;
5605
- z-index: 100;
5632
+ z-index: var(--z-sticky);
5606
5633
  width: 220px;
5607
5634
  }
5608
5635
  .mention-dropdown.hidden { display: none; }
@@ -6548,7 +6575,7 @@ body.resize-dragging {
6548
6575
  position: fixed;
6549
6576
  top: 12px;
6550
6577
  right: 12px;
6551
- z-index: 99999;
6578
+ z-index: var(--z-critical);
6552
6579
  display: flex;
6553
6580
  flex-direction: column;
6554
6581
  gap: 6px;
@@ -6836,3 +6863,65 @@ body.resize-dragging {
6836
6863
  .release-form {
6837
6864
  margin-top: 8px;
6838
6865
  }
6866
+
6867
+ /* ===== Responsive Breakpoints ===== */
6868
+
6869
+ /* Tablet: stack left panels above, roster collapses */
6870
+ @media (max-width: 1024px) {
6871
+ #app {
6872
+ grid-template-columns: 200px 1fr;
6873
+ grid-template-rows: auto 1fr auto;
6874
+ grid-template-areas:
6875
+ "left-top canvas"
6876
+ "left-bottom canvas"
6877
+ "console console";
6878
+ }
6879
+ #roster-panel {
6880
+ display: none;
6881
+ }
6882
+ .floating-panel {
6883
+ width: 280px !important;
6884
+ }
6885
+ }
6886
+
6887
+ /* Mobile: single column stack */
6888
+ @media (max-width: 768px) {
6889
+ #app {
6890
+ grid-template-columns: 1fr;
6891
+ grid-template-rows: auto auto 200px auto;
6892
+ grid-template-areas:
6893
+ "canvas"
6894
+ "console"
6895
+ "left-top"
6896
+ "left-bottom";
6897
+ height: auto;
6898
+ min-height: 100vh;
6899
+ }
6900
+ #roster-panel {
6901
+ display: none;
6902
+ }
6903
+ #office-panel .panel-header {
6904
+ flex-wrap: wrap;
6905
+ }
6906
+ #status-bar {
6907
+ flex-wrap: wrap;
6908
+ gap: 4px;
6909
+ }
6910
+ .floating-panel {
6911
+ position: fixed !important;
6912
+ left: 0 !important;
6913
+ right: 0 !important;
6914
+ top: auto !important;
6915
+ bottom: 0 !important;
6916
+ width: 100% !important;
6917
+ max-height: 60vh;
6918
+ border-radius: 8px 8px 0 0;
6919
+ }
6920
+ #ceo-split {
6921
+ flex-direction: column;
6922
+ }
6923
+ #ceo-project-list {
6924
+ max-height: 120px;
6925
+ overflow-y: auto;
6926
+ }
6927
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1mancompany/onemancompany",
3
- "version": "0.7.39",
3
+ "version": "0.7.47",
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.39"
3
+ version = "0.7.47"
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 = [
@@ -743,6 +743,46 @@ async def delete_product_tool(product_slug: str) -> str:
743
743
  return f"Error: {e}"
744
744
 
745
745
 
746
+ @tool
747
+ async def assign_issue_tool(
748
+ product_slug: str,
749
+ issue_id: str,
750
+ assignee_id: str,
751
+ ) -> str:
752
+ """Assign (or reassign) an issue to an employee.
753
+
754
+ Args:
755
+ product_slug: The product slug
756
+ issue_id: The issue ID
757
+ assignee_id: Employee ID to assign
758
+ """
759
+ try:
760
+ issue = prod.update_issue(product_slug, issue_id, assignee_id=assignee_id)
761
+ return f"Issue {issue_id} assigned to {assignee_id}"
762
+ except (ValueError, FileNotFoundError) as e:
763
+ return f"Error: {e}"
764
+
765
+
766
+ @tool
767
+ async def transfer_product_ownership_tool(
768
+ product_slug: str,
769
+ new_owner_id: str,
770
+ ) -> str:
771
+ """Transfer product ownership to a different employee.
772
+
773
+ Args:
774
+ product_slug: The product slug
775
+ new_owner_id: Employee ID of the new owner
776
+ """
777
+ try:
778
+ result = prod.update_product(product_slug, owner_id=new_owner_id)
779
+ if result is None:
780
+ return f"Error: product '{product_slug}' not found"
781
+ return f"Product '{product_slug}' ownership transferred to {new_owner_id}"
782
+ except (ValueError, FileNotFoundError) as e:
783
+ return f"Error: {e}"
784
+
785
+
746
786
  # ---------------------------------------------------------------------------
747
787
  # Export
748
788
  # ---------------------------------------------------------------------------
@@ -770,4 +810,6 @@ PRODUCT_TOOLS = [
770
810
  version_management_tool,
771
811
  update_product_tool,
772
812
  delete_product_tool,
813
+ assign_issue_tool,
814
+ transfer_product_ownership_tool,
773
815
  ]
@@ -2659,9 +2659,10 @@ async def delete_employee_session(employee_id: str, project_id: str) -> dict:
2659
2659
  # ===== Company Culture =====
2660
2660
 
2661
2661
  @router.get("/api/company-culture")
2662
- async def get_company_culture() -> dict:
2662
+ async def get_company_culture(limit: int = 100, offset: int = 0) -> dict:
2663
2663
  """Get all company culture items."""
2664
- return {"items": _store.load_culture()}
2664
+ items = _store.load_culture()
2665
+ return {"items": items[offset:offset + limit], "total": len(items)}
2665
2666
 
2666
2667
 
2667
2668
  @router.post("/api/company-culture")
@@ -2846,10 +2847,11 @@ async def get_dashboard_costs() -> dict:
2846
2847
 
2847
2848
 
2848
2849
  @router.get("/api/projects")
2849
- async def get_projects() -> dict:
2850
+ async def get_projects(limit: int = 100, offset: int = 0) -> dict:
2850
2851
  """List all projects (v1 + v2 summary view for the project wall)."""
2851
2852
  from onemancompany.core.project_archive import list_projects
2852
- return {"projects": list_projects()}
2853
+ all_projects = list_projects()
2854
+ return {"projects": all_projects[offset:offset + limit], "total": len(all_projects)}
2853
2855
 
2854
2856
 
2855
2857
  @router.post("/api/projects")
@@ -3792,44 +3794,6 @@ async def get_project_file(project_id: str, file_path: str):
3792
3794
  return Response(content=content, media_type=media)
3793
3795
 
3794
3796
 
3795
- @router.get("/api/projects/{project_id}/download")
3796
- async def download_project_files(project_id: str):
3797
- """Download all project workspace files as a zip archive."""
3798
- import io
3799
- import zipfile
3800
- from pathlib import Path
3801
-
3802
- from fastapi.responses import StreamingResponse
3803
-
3804
- from onemancompany.core.project_archive import get_project_dir, list_project_files
3805
-
3806
- workspace = Path(get_project_dir(project_id))
3807
- if not workspace.exists():
3808
- raise HTTPException(status_code=404, detail="Project workspace not found")
3809
-
3810
- files = list_project_files(project_id)
3811
- if not files:
3812
- raise HTTPException(status_code=404, detail="No files to download")
3813
-
3814
- buf = io.BytesIO()
3815
- with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
3816
- for rel_path in files:
3817
- abs_path = workspace / rel_path
3818
- if abs_path.is_file():
3819
- zf.write(abs_path, rel_path)
3820
- buf.seek(0)
3821
-
3822
- slug = project_id.split("/")[0]
3823
- from urllib.parse import quote as _quote_url
3824
- _safe = slug.encode("ascii", "ignore").decode() or "project"
3825
- _enc = _quote_url(f"{slug}-files.zip", safe="")
3826
- return StreamingResponse(
3827
- buf,
3828
- media_type="application/zip",
3829
- headers={"Content-Disposition": f'attachment; filename="{_safe}-files.zip"; filename*=UTF-8\'\'{_enc}'},
3830
- )
3831
-
3832
-
3833
3797
  # ===== Employee Workspace =====
3834
3798
 
3835
3799
  @router.get("/api/employee/{employee_id}/workspace")
@@ -3970,10 +3934,11 @@ async def download_project_workspace(project_id: str):
3970
3934
  # ===== Ex-Employees =====
3971
3935
 
3972
3936
  @router.get("/api/ex-employees")
3973
- async def get_ex_employees() -> dict:
3937
+ async def get_ex_employees(limit: int = 100, offset: int = 0) -> dict:
3974
3938
  """List all ex-employees."""
3975
3939
  ex_emps = _store.load_ex_employees()
3976
- return {"ex_employees": list(ex_emps.values())}
3940
+ all_ex = list(ex_emps.values())
3941
+ return {"ex_employees": all_ex[offset:offset + limit], "total": len(all_ex)}
3977
3942
 
3978
3943
 
3979
3944
  @router.post("/api/ex-employees/{employee_id}/rehire")
@@ -6062,35 +6027,6 @@ async def get_room_chat(room_id: str):
6062
6027
  return load_room_chat(room_id)
6063
6028
 
6064
6029
 
6065
- @router.get("/api/rooms/{room_id}/minutes")
6066
- async def get_room_minutes(room_id: str):
6067
- """List archived meeting minutes for a room."""
6068
- from onemancompany.core.store import load_meeting_minutes
6069
- minutes = load_meeting_minutes(room_id)
6070
- # Return lightweight list (exclude full messages)
6071
- return [
6072
- {
6073
- "minute_id": m.get("minute_id", ""),
6074
- "topic": m.get("topic", ""),
6075
- "room_name": m.get("room_name", ""),
6076
- "participants": m.get("participants", []),
6077
- "summary": (m.get("summary", "") or "")[:200],
6078
- "message_count": len(m.get("messages", [])),
6079
- }
6080
- for m in minutes
6081
- ]
6082
-
6083
-
6084
- @router.get("/api/meeting-minutes/{minute_id}")
6085
- async def get_meeting_minute(minute_id: str):
6086
- """Get full content of a single meeting minute."""
6087
- from onemancompany.core.store import load_meeting_minute
6088
- data = load_meeting_minute(minute_id)
6089
- if not data:
6090
- raise HTTPException(status_code=404, detail="Meeting minute not found")
6091
- return data
6092
-
6093
-
6094
6030
  @router.post("/api/rooms/{room_id}/chat")
6095
6031
  async def post_room_chat(room_id: str, body: dict):
6096
6032
  """CEO sends a message to a meeting room chat.
@@ -19,6 +19,7 @@ from loguru import logger
19
19
 
20
20
  from onemancompany.core.config import (
21
21
  ACTIVITY_LOG_DIR_NAME,
22
+ EMPLOYEES_DIR,
22
23
  ISSUES_DIR_NAME,
23
24
  PRODUCT_YAML_FILENAME,
24
25
  PRODUCTS_DIR,
@@ -99,6 +100,17 @@ def _gen_id(prefix: str) -> str:
99
100
  return f"{prefix}{uuid.uuid4().hex[:8]}"
100
101
 
101
102
 
103
+ def _validate_employee_id(emp_id: str, label: str = "Employee") -> None:
104
+ """Raise ValueError if emp_id does not correspond to a valid employee directory.
105
+
106
+ Empty string is allowed (means "no owner/assignee assigned").
107
+ """
108
+ if not emp_id:
109
+ return # empty = unassigned, valid
110
+ if not (EMPLOYEES_DIR / emp_id).is_dir():
111
+ raise ValueError(f"{label} '{emp_id}' not found in employee registry")
112
+
113
+
102
114
  # ---------------------------------------------------------------------------
103
115
  # Product CRUD
104
116
  # ---------------------------------------------------------------------------
@@ -112,6 +124,7 @@ def create_product(
112
124
  current_version: str = "0.1.0",
113
125
  ) -> dict:
114
126
  """Create a new product. Returns the product dict."""
127
+ _validate_employee_id(owner_id, label="Owner")
115
128
  slug = _dedup_slug(_slugify(name))
116
129
  product_id = _gen_id("prod_")
117
130
  now = datetime.now().isoformat()
@@ -165,6 +178,8 @@ def list_products() -> list[dict]:
165
178
 
166
179
  def update_product(slug: str, **fields) -> dict | None:
167
180
  """Update product fields. Returns updated dict or None if not found."""
181
+ if "owner_id" in fields and fields["owner_id"] is not None:
182
+ _validate_employee_id(fields["owner_id"], label="Owner")
168
183
  with _get_slug_lock(slug):
169
184
  path = _product_yaml_path(slug)
170
185
  data = _read_yaml(path)
@@ -314,6 +329,8 @@ def create_issue(
314
329
  product = load_product(slug)
315
330
  if not product:
316
331
  raise ValueError(f"Product '{slug}' not found")
332
+ if assignee_id:
333
+ _validate_employee_id(assignee_id, label="Assignee")
317
334
  issue_id = _gen_id("issue_")
318
335
  product_id = product["id"]
319
336
  now = datetime.now().isoformat()
@@ -424,6 +441,9 @@ def update_issue(slug: str, issue_id: str, *, _skip_transition_check: bool = Fal
424
441
  _skip_transition_check: internal flag for system-derived status updates
425
442
  that may jump non-adjacent states (e.g. sync_issue_statuses).
426
443
  """
444
+ new_assignee = fields.get("assignee_id")
445
+ if new_assignee is not None and new_assignee != "":
446
+ _validate_employee_id(new_assignee, label="Assignee")
427
447
  with _get_slug_lock(slug):
428
448
  path = _issues_dir(slug) / f"{issue_id}.yaml"
429
449
  data = _read_yaml(path)
@@ -441,6 +461,11 @@ def update_issue(slug: str, issue_id: str, *, _skip_transition_check: bool = Fal
441
461
  if old_value != value:
442
462
  _append_history(data, key, old_value, value, changed_by="system")
443
463
  data[key] = value
464
+ # Auto-set closed_at and resolution when status transitions to DONE
465
+ if new_status == IssueStatus.DONE.value and not data.get("closed_at"):
466
+ data["closed_at"] = datetime.now().isoformat()
467
+ if not data.get("resolution"):
468
+ data["resolution"] = IssueResolution.FIXED.value
444
469
  _write_yaml(path, data)
445
470
  mark_dirty(DirtyCategory.PRODUCTS)
446
471
  return data
@@ -999,12 +1024,25 @@ def release_version(
999
1024
  product["current_version"] = new_version
1000
1025
  _write_yaml(_product_yaml_path(product_slug), product)
1001
1026
 
1002
- # Mark resolved issues as released (bypass validation release is a system operation)
1027
+ # Mark resolved issues as released — only DONE issues are eligible
1028
+ skipped_issues: list[str] = []
1003
1029
  for issue_id in resolved_issue_ids:
1004
1030
  issue = load_issue(product_slug, issue_id)
1005
- if issue and issue.get("status") != IssueStatus.RELEASED.value:
1006
- update_issue(product_slug, issue_id, _skip_transition_check=True, status=IssueStatus.RELEASED.value)
1031
+ if not issue:
1032
+ skipped_issues.append(issue_id)
1033
+ continue
1034
+ if issue.get("status") == IssueStatus.RELEASED.value:
1035
+ continue # already released
1036
+ if issue.get("status") != IssueStatus.DONE.value:
1037
+ skipped_issues.append(issue_id)
1038
+ logger.warning(
1039
+ "[VERSION] Skipping issue {} — status '{}' is not DONE",
1040
+ issue_id, issue.get("status"),
1041
+ )
1042
+ continue
1043
+ update_issue(product_slug, issue_id, _skip_transition_check=True, status=IssueStatus.RELEASED.value)
1007
1044
 
1045
+ version_record["skipped_issues"] = skipped_issues
1008
1046
  mark_dirty(DirtyCategory.PRODUCTS)
1009
1047
  logger.info("[VERSION] Released {} for product '{}'", new_version, product_slug)
1010
1048
  return version_record
@@ -1355,6 +1393,24 @@ def create_sprint(
1355
1393
  f"End date '{end_date}' must be after start date '{start_date}'"
1356
1394
  )
1357
1395
 
1396
+ # Check for date overlap with non-closed sprints
1397
+ existing_sprints = list_sprints(slug)
1398
+ for existing in existing_sprints:
1399
+ if existing.get("status") == SprintStatus.CLOSED.value:
1400
+ continue
1401
+ try:
1402
+ ex_sd = datetime.strptime(existing["start_date"], "%Y-%m-%d")
1403
+ ex_ed = datetime.strptime(existing["end_date"], "%Y-%m-%d")
1404
+ except (ValueError, KeyError):
1405
+ logger.debug("Skipping overlap check for sprint with invalid dates: {}", existing.get("id"))
1406
+ continue
1407
+ # Overlap: ranges overlap if start < other_end AND other_start < end
1408
+ if sd < ex_ed and ex_sd < ed:
1409
+ raise ValueError(
1410
+ f"Sprint dates {start_date}..{end_date} overlap with "
1411
+ f"'{existing['name']}' ({existing['start_date']}..{existing['end_date']})"
1412
+ )
1413
+
1358
1414
  sprint_id = _gen_id("sprint_")
1359
1415
  now = datetime.now().isoformat()
1360
1416
 
@@ -1478,8 +1534,9 @@ def close_sprint(slug: str, sprint_id: str) -> dict:
1478
1534
  total_count = len(all_issues)
1479
1535
  unfinished = [i for i in all_issues if i.get("status") not in _DONE_STATUSES]
1480
1536
 
1481
- # 3. Carry-over: find next planning sprint
1537
+ # 3. Carry-over: find next planning sprint (sorted by start_date, earliest first)
1482
1538
  planning_sprints = list_sprints(slug, status=SprintStatus.PLANNING.value)
1539
+ planning_sprints.sort(key=lambda s: s.get("start_date", ""))
1483
1540
  next_sprint = planning_sprints[0] if planning_sprints else None
1484
1541
 
1485
1542
  for issue in unfinished:
@@ -34,6 +34,12 @@ STALE_REVIEW_HOURS: int = 24 # Hours before an open review is conside
34
34
  BLOCKED_DAYS_THRESHOLD: int = 7 # Days before a blocked issue is flagged
35
35
  UNHANDLED_BACKLOG_THRESHOLD: int = 2 # Unhandled backlog issues before alert
36
36
 
37
+ def _get_threshold(product: dict, key: str, default: int) -> int:
38
+ """Read per-product config threshold, falling back to module-level default."""
39
+ config = product.get("config") or {}
40
+ return config.get(key, default)
41
+
42
+
37
43
  # ---------------------------------------------------------------------------
38
44
  # Trigger handlers
39
45
  # ---------------------------------------------------------------------------
@@ -148,6 +154,76 @@ async def _create_project_for_issue(slug: str, issue: dict) -> str:
148
154
  return ""
149
155
 
150
156
 
157
+ async def _create_review_project(product_slug: str, reason: str) -> str:
158
+ """Create a standalone review project for the product owner.
159
+
160
+ Unlike _create_project_for_issue, this doesn't take an issue dict —
161
+ it constructs a proper review-scoped project.
162
+ Returns project_id or empty string.
163
+ """
164
+ from pathlib import Path
165
+ from onemancompany.core.config import CEO_ID, EA_ID, TASK_TREE_FILENAME
166
+ from onemancompany.core.project_archive import async_create_project_from_task, get_project_dir
167
+ from onemancompany.core.task_lifecycle import NodeType, TaskPhase
168
+
169
+ product = prod.load_product(product_slug)
170
+ if not product:
171
+ return ""
172
+ product_id = product["id"]
173
+ owner_id = product.get("owner_id", "")
174
+ task_description = f"Product review for '{product['name']}': {reason}"
175
+
176
+ try:
177
+ project_id, iter_id = await async_create_project_from_task(
178
+ task_description,
179
+ product_id=product_id,
180
+ )
181
+ pdir = get_project_dir(project_id)
182
+ ctx_id = f"{project_id}/{iter_id}" if iter_id else project_id
183
+
184
+ product_ctx = prod.build_product_context(product_slug)
185
+ review_task = (
186
+ f"Product review needed: {reason}\n\n"
187
+ f"{product_ctx}\n\n"
188
+ f"[Project ID: {ctx_id}] [Project workspace: {pdir}]"
189
+ )
190
+
191
+ from onemancompany.core.task_tree import TaskTree
192
+ from onemancompany.core.vessel import _save_project_tree
193
+
194
+ tree = TaskTree(project_id=ctx_id, mode="standard")
195
+ ceo_root = tree.create_root(employee_id=CEO_ID, description=task_description)
196
+ ceo_root.node_type = NodeType.CEO_PROMPT.value
197
+ ceo_root.set_status(TaskPhase.PROCESSING)
198
+
199
+ owner_node = tree.add_child(
200
+ parent_id=ceo_root.id,
201
+ employee_id=owner_id or EA_ID,
202
+ description=review_task,
203
+ acceptance_criteria=[],
204
+ title=f"Product review: {reason[:50]}",
205
+ )
206
+ _save_project_tree(pdir, tree)
207
+
208
+ from onemancompany.core.agent_loop import employee_manager
209
+ target_id = owner_id or EA_ID
210
+ tree_path = str(Path(pdir) / TASK_TREE_FILENAME)
211
+ employee_manager.schedule_node(target_id, owner_node.id, tree_path)
212
+ employee_manager._schedule_next(target_id)
213
+
214
+ logger.info(
215
+ "[PRODUCT_TRIGGER] Created review project {} for product '{}' (reason: {})",
216
+ project_id, product_slug, reason,
217
+ )
218
+ return project_id
219
+ except Exception:
220
+ logger.exception(
221
+ "[PRODUCT_TRIGGER] Failed to create review project for '{}'",
222
+ product_slug,
223
+ )
224
+ return ""
225
+
226
+
151
227
  async def handle_project_complete(event: CompanyEvent) -> None:
152
228
  """When a project with product context completes, close issues + release version."""
153
229
  slug = event.payload.get("product_slug", "")
@@ -236,78 +312,69 @@ async def notify_owner(product_slug: str, reason: str = "") -> bool:
236
312
  f"[skill: product-review]"
237
313
  )
238
314
 
239
- try:
240
- from pathlib import Path
241
- from onemancompany.core.config import CEO_ID, TASK_TREE_FILENAME
242
- from onemancompany.core.project_archive import list_projects, get_project_dir
243
- from onemancompany.core.task_tree import get_tree
244
- from onemancompany.core.vessel import _save_project_tree
315
+ from pathlib import Path
316
+ from onemancompany.core.config import CEO_ID, TASK_TREE_FILENAME
317
+ from onemancompany.core.project_archive import list_projects, get_project_dir
318
+ from onemancompany.core.task_tree import get_tree
319
+ from onemancompany.core.vessel import _save_project_tree
245
320
 
246
- # Find existing active project for this product
247
- all_projects = list_projects()
248
- active_product_projects = [
249
- p for p in all_projects
250
- if p.get("product_id") == product["id"] and p.get("status") == "active"
251
- ]
321
+ # Find existing active project for this product
322
+ all_projects = list_projects()
323
+ active_product_projects = [
324
+ p for p in all_projects
325
+ if p.get("product_id") == product["id"] and p.get("status") == "active"
326
+ ]
252
327
 
253
- if active_product_projects:
254
- # Add task to existing project's tree
255
- proj = active_product_projects[0]
256
- pdir = get_project_dir(proj["project_id"])
257
- tree_path = Path(pdir) / TASK_TREE_FILENAME
258
- if not tree_path.exists():
259
- logger.debug("[PRODUCT_TRIGGER] Tree not found for project {}", proj["project_id"])
328
+ if active_product_projects:
329
+ # Add task to existing project's tree
330
+ proj = active_product_projects[0]
331
+ pdir = get_project_dir(proj["project_id"])
332
+ tree_path = Path(pdir) / TASK_TREE_FILENAME
333
+ if not tree_path.exists():
334
+ logger.debug("[PRODUCT_TRIGGER] Tree not found for project {}", proj["project_id"])
335
+ return False
336
+
337
+ tree = get_tree(str(tree_path))
338
+
339
+ # Check if owner already has a pending/processing review task — skip if so
340
+ from onemancompany.core.task_lifecycle import TaskPhase
341
+ for node in tree.all_nodes():
342
+ if (node.employee_id == owner_id
343
+ and node.status in (TaskPhase.PENDING.value, TaskPhase.PROCESSING.value)
344
+ and "review" in (node.title or node.description or "").lower()):
345
+ logger.debug("[PRODUCT_TRIGGER] Owner {} already has pending review task {}, skip",
346
+ owner_id, node.id)
260
347
  return False
261
348
 
262
- tree = get_tree(str(tree_path))
263
-
264
- # Check if owner already has a pending/processing review task — skip if so
265
- from onemancompany.core.task_lifecycle import TaskPhase
266
- for node in tree.all_nodes():
267
- if (node.employee_id == owner_id
268
- and node.status in (TaskPhase.PENDING.value, TaskPhase.PROCESSING.value)
269
- and "review" in (node.title or node.description or "").lower()):
270
- logger.debug("[PRODUCT_TRIGGER] Owner {} already has pending review task {}, skip",
271
- owner_id, node.id)
272
- return False
273
-
274
- # Find a suitable parent (EA node or root)
275
- ea_node = tree.get_ea_node()
276
- parent_id = ea_node.id if ea_node else tree.root_id
277
-
278
- child = tree.add_child(
279
- parent_id=parent_id,
280
- employee_id=owner_id,
281
- description=task_desc,
282
- acceptance_criteria=[],
283
- title=f"Product review: {reason[:50]}",
284
- )
285
- _save_project_tree(pdir, tree)
349
+ # Find a suitable parent (EA node or root)
350
+ ea_node = tree.get_ea_node()
351
+ parent_id = ea_node.id if ea_node else tree.root_id
286
352
 
287
- # Schedule owner to execute
288
- from onemancompany.core.agent_loop import employee_manager
289
- employee_manager.schedule_node(owner_id, child.id, str(tree_path))
290
- employee_manager._schedule_next(owner_id)
353
+ child = tree.add_child(
354
+ parent_id=parent_id,
355
+ employee_id=owner_id,
356
+ description=task_desc,
357
+ acceptance_criteria=[],
358
+ title=f"Product review: {reason[:50]}",
359
+ )
360
+ _save_project_tree(pdir, tree)
291
361
 
292
- logger.info("[PRODUCT_TRIGGER] Pushed review task to owner {} on project {} (reason: {})",
293
- owner_id, proj["project_id"], reason)
294
- else:
295
- # No active project — create one
296
- project_id = await _create_project_for_issue(product_slug, {
297
- "id": f"review_{product_slug}",
298
- "title": f"Product review: {product['name']}",
299
- "description": task_desc,
300
- "priority": IssuePriority.P2.value,
301
- })
302
- if not project_id:
303
- return False
304
- logger.info("[PRODUCT_TRIGGER] Created review project {} for owner {} (reason: {})",
305
- project_id, owner_id, reason)
362
+ # Schedule owner to execute
363
+ from onemancompany.core.agent_loop import employee_manager
364
+ employee_manager.schedule_node(owner_id, child.id, str(tree_path))
365
+ employee_manager._schedule_next(owner_id)
306
366
 
307
- return True
308
- except Exception:
309
- logger.exception("[PRODUCT_TRIGGER] Failed to push review task for '{}'", product_slug)
310
- return False
367
+ logger.info("[PRODUCT_TRIGGER] Pushed review task to owner {} on project {} (reason: {})",
368
+ owner_id, proj["project_id"], reason)
369
+ else:
370
+ # No active project — create a dedicated review project
371
+ project_id = await _create_review_project(product_slug, reason)
372
+ if not project_id:
373
+ return False
374
+ logger.info("[PRODUCT_TRIGGER] Created review project {} for owner {} (reason: {})",
375
+ project_id, owner_id, reason)
376
+
377
+ return True
311
378
 
312
379
 
313
380
  def sync_issue_statuses(product_slug: str) -> list[dict]:
@@ -398,6 +465,8 @@ async def run_product_check(product_slug: str) -> dict:
398
465
  if not owner_id:
399
466
  return {"skipped": True, "reason": "no owner"}
400
467
 
468
+ max_active = _get_threshold(product, "max_active_projects", MAX_ACTIVE_PROJECTS)
469
+
401
470
  from onemancompany.core.project_archive import list_projects
402
471
  all_projects = list_projects()
403
472
  active_for_product = [
@@ -425,7 +494,7 @@ async def run_product_check(product_slug: str) -> dict:
425
494
 
426
495
  # High priority + no active project → create project
427
496
  if priority in _AUTO_PROJECT_PRIORITIES and not linked:
428
- if len(active_for_product) >= MAX_ACTIVE_PROJECTS:
497
+ if len(active_for_product) >= max_active:
429
498
  logger.debug("[PRODUCT_CHECK] Skipping project for issue {} — 3+ active projects", issue["id"])
430
499
  continue
431
500
  project_id = await _create_project_for_issue(product_slug, issue)
@@ -440,7 +509,7 @@ async def run_product_check(product_slug: str) -> dict:
440
509
 
441
510
  # Has assignee but no project → create project
442
511
  elif issue.get("assignee_id") and not linked:
443
- if len(active_for_product) >= MAX_ACTIVE_PROJECTS:
512
+ if len(active_for_product) >= max_active:
444
513
  continue
445
514
  project_id = await _create_project_for_issue(product_slug, issue)
446
515
  if project_id:
@@ -460,10 +529,12 @@ async def run_product_check(product_slug: str) -> dict:
460
529
  if target <= 0 or current >= target:
461
530
  continue # met or invalid
462
531
 
532
+ kr_id = kr.get("id", "")
463
533
  kr_title = kr.get("title", "")
464
- # Check if any open issue is related to this KR (by title match)
534
+ kr_label = f"kr:{kr_id}"
535
+ # Check if any open issue is already tracking this KR (by kr_id label)
465
536
  has_issue = any(
466
- kr_title in i.get("title", "") or kr.get("id", "") in i.get("title", "")
537
+ kr_label in i.get("labels", [])
467
538
  for i in all_issues
468
539
  if i.get("status") not in (IssueStatus.DONE.value, IssueStatus.RELEASED.value)
469
540
  )
@@ -475,7 +546,7 @@ async def run_product_check(product_slug: str) -> dict:
475
546
  description=f"Key result '{kr_title}' is at {current}/{target}. Create and execute work to advance this metric.",
476
547
  priority=IssuePriority.P2,
477
548
  created_by="system",
478
- labels=["kr-tracking", "auto-created"],
549
+ labels=["kr-tracking", "auto-created", kr_label],
479
550
  )
480
551
  actions_taken.append(f"Created issue for KR: {kr_title}")
481
552
  all_issues.append(issue) # prevent duplicate creation in same cycle
@@ -665,6 +736,12 @@ async def handle_issue_assigned(event: CompanyEvent) -> None:
665
736
  logger.debug("[PRODUCT_TRIGGER] Issue {} already has linked tasks {}, skip", issue_id, linked)
666
737
  return
667
738
 
739
+ # Re-read to guard against race with handle_issue_created
740
+ fresh_issue = prod.load_issue(slug, issue_id)
741
+ if fresh_issue and fresh_issue.get("linked_task_ids"):
742
+ logger.debug("[PRODUCT_TRIGGER] Race guard: issue {} got linked_task_ids before project creation", issue_id)
743
+ return
744
+
668
745
  logger.info("[PRODUCT_TRIGGER] Issue {} assigned to {} — creating project", issue_id, assignee_id)
669
746
  project_id = await _create_project_for_issue(slug, issue)
670
747