@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 +50 -55
- package/frontend/index.html +25 -25
- package/frontend/style.css +102 -13
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/onemancompany/agents/product_tools.py +42 -0
- package/src/onemancompany/api/routes.py +9 -73
- package/src/onemancompany/core/product.py +61 -4
- package/src/onemancompany/core/product_triggers.py +147 -70
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
|
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
|
|
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) {
|
|
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()) {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7400
|
-
|
|
7401
|
-
|
|
7402
|
-
|
|
7403
|
-
|
|
7404
|
-
|
|
7405
|
-
|
|
7406
|
-
|
|
7407
|
-
|
|
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
|
-
|
|
7729
|
-
|
|
7730
|
-
|
|
7731
|
-
|
|
7732
|
-
|
|
7733
|
-
|
|
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
|
-
|
|
8224
|
-
|
|
8225
|
-
|
|
8226
|
-
|
|
8227
|
-
|
|
8228
|
-
|
|
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
|
-
|
|
9071
|
-
if (rev.owner)
|
|
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
|
package/frontend/index.html
CHANGED
|
@@ -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
|
-
<
|
|
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">▼</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">↑</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">↑</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
|
-
</
|
|
26
|
+
</aside>
|
|
27
27
|
|
|
28
28
|
<!-- Left bottom: Activity Log -->
|
|
29
|
-
<
|
|
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">▼</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
|
-
</
|
|
37
|
+
</aside>
|
|
38
38
|
|
|
39
39
|
<!-- Center top: Office Canvas -->
|
|
40
|
-
<
|
|
40
|
+
<main id="office-panel">
|
|
41
41
|
<div class="panel-header">
|
|
42
42
|
<h1 class="pixel-title">🏢 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()">🔧 0</span>
|
|
46
46
|
<span class="status-item" id="room-count">🏢 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">📜</button>
|
|
49
|
-
<button id="company-culture-toolbar-btn" class="toolbar-icon-btn" title="Company Culture">🏴</button>
|
|
50
|
-
<button id="company-direction-toolbar-btn" class="toolbar-icon-btn" title="Company Direction">🎯</button>
|
|
51
|
-
<button id="dashboard-toolbar-btn" class="toolbar-icon-btn" title="Dashboard">📊</button>
|
|
52
|
-
<button id="announcements-toolbar-btn" class="toolbar-icon-btn" title="Announcements" style="position:relative;">🔔<span id="announcements-badge" class="announcement-badge hidden"></span></button>
|
|
53
|
-
<button id="settings-toolbar-btn" class="toolbar-icon-btn" title="Settings">⚙</button>
|
|
54
|
-
<span class="status-item" id="connection-status">● OFFLINE</span>
|
|
48
|
+
<button id="ex-employee-toolbar-btn" class="toolbar-icon-btn" title="Ex-Employee Wall" aria-label="Ex-Employee Wall">📜</button>
|
|
49
|
+
<button id="company-culture-toolbar-btn" class="toolbar-icon-btn" title="Company Culture" aria-label="Company Culture">🏴</button>
|
|
50
|
+
<button id="company-direction-toolbar-btn" class="toolbar-icon-btn" title="Company Direction" aria-label="Company Direction">🎯</button>
|
|
51
|
+
<button id="dashboard-toolbar-btn" class="toolbar-icon-btn" title="Dashboard" aria-label="Dashboard">📊</button>
|
|
52
|
+
<button id="announcements-toolbar-btn" class="toolbar-icon-btn" title="Announcements" aria-label="Announcements" style="position:relative;">🔔<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">⚙</button>
|
|
54
|
+
<span class="status-item" id="connection-status" role="status" aria-live="polite">● 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
|
-
</
|
|
60
|
+
</main>
|
|
61
61
|
|
|
62
62
|
<!-- Right top: Team Roster -->
|
|
63
|
-
<
|
|
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">▼</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
|
-
</
|
|
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">👑 YOU</span>
|
|
88
|
-
<button id="dnd-toggle-btn" class="toolbar-icon-btn" title="Do Not Disturb">🌙</button>
|
|
89
|
-
<button id="bg-tasks-toolbar-btn" class="toolbar-icon-btn" title="Background Tasks">▩</button>
|
|
90
|
-
<button id="screenshot-toolbar-btn" class="toolbar-icon-btn" title="Export SVG Screenshot">📷</button>
|
|
91
|
-
<button id="abort-all-toolbar-btn" class="toolbar-icon-btn" title="Stop All Tasks" style="color: #ff4444;">⚠</button>
|
|
92
|
-
<button id="reload-toolbar-btn" class="reload-btn" title="Force reload all data from disk">🔄</button>
|
|
88
|
+
<button id="dnd-toggle-btn" class="toolbar-icon-btn" title="Do Not Disturb" aria-label="Do Not Disturb">🌙</button>
|
|
89
|
+
<button id="bg-tasks-toolbar-btn" class="toolbar-icon-btn" title="Background Tasks" aria-label="Background Tasks">▩</button>
|
|
90
|
+
<button id="screenshot-toolbar-btn" class="toolbar-icon-btn" title="Export SVG Screenshot" aria-label="Export SVG Screenshot">📷</button>
|
|
91
|
+
<button id="abort-all-toolbar-btn" class="toolbar-icon-btn" title="Stop All Tasks" aria-label="Stop All Tasks" style="color: #ff4444;">⚠</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">🔄</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>
|
package/frontend/style.css
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
package/pyproject.toml
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1006
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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) >=
|
|
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) >=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|