@1mancompany/onemancompany 0.7.32 → 0.7.39

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
@@ -7555,6 +7555,7 @@ class AppController {
7555
7555
  { id: 'issues', label: `Issues (${issues.length})` },
7556
7556
  { id: 'kanban', label: 'Kanban' },
7557
7557
  { id: 'roadmap', label: 'Roadmap' },
7558
+ { id: 'reviews', label: `Reviews (${(data.reviews || []).length})` },
7558
7559
  { id: 'activity', label: 'Activity' },
7559
7560
  { id: 'projects', label: `Projects (${projects.length})` },
7560
7561
  ];
@@ -7591,6 +7592,8 @@ class AppController {
7591
7592
  this._renderProductKanban(slug, container, data);
7592
7593
  } else if (tabId === 'roadmap') {
7593
7594
  this._renderProductRoadmap(slug, container);
7595
+ } else if (tabId === 'reviews') {
7596
+ this._renderProductReviews(data.reviews || [], slug, container);
7594
7597
  } else if (tabId === 'activity') {
7595
7598
  this._renderProductActivity(slug, container);
7596
7599
  } else if (tabId === 'projects') {
@@ -7783,6 +7786,21 @@ class AppController {
7783
7786
  unitEl.textContent = unit;
7784
7787
  this._makeKrFieldEditable(unitEl, slug, kr.id, 'unit');
7785
7788
 
7789
+ // Delete KR button
7790
+ const delKrBtn = document.createElement('button');
7791
+ delKrBtn.className = 'kr-remove-btn';
7792
+ delKrBtn.innerHTML = '×';
7793
+ delKrBtn.title = 'Delete KR';
7794
+ delKrBtn.addEventListener('click', async () => {
7795
+ if (!confirm(`Delete KR "${kr.title}"?`)) return;
7796
+ try {
7797
+ const r = await fetch(`/api/product/${encodeURIComponent(slug)}/kr/${encodeURIComponent(kr.id)}`, { method: 'DELETE' });
7798
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
7799
+ this._showToast('KR deleted', 'success');
7800
+ this._openProductDetail(slug);
7801
+ } catch (err) { this._showToast(`Failed: ${err.message}`, 'error'); }
7802
+ });
7803
+
7786
7804
  krRow.appendChild(titleEl);
7787
7805
  krRow.appendChild(document.createTextNode(': '));
7788
7806
  krRow.appendChild(currentEl);
@@ -7791,6 +7809,7 @@ class AppController {
7791
7809
  krRow.appendChild(unitEl);
7792
7810
  krRow.appendChild(document.createTextNode(` (${pct.toFixed(0)}%)`));
7793
7811
  krRow.appendChild(progTrack);
7812
+ krRow.appendChild(delKrBtn);
7794
7813
  krList.appendChild(krRow);
7795
7814
  }
7796
7815
 
@@ -7803,11 +7822,12 @@ class AppController {
7803
7822
  container.appendChild(krList);
7804
7823
 
7805
7824
  // Version History
7825
+ const verLabel = document.createElement('div');
7826
+ verLabel.className = 'product-section-label';
7827
+ verLabel.textContent = 'Version History';
7828
+ container.appendChild(verLabel);
7829
+
7806
7830
  if (versions.length > 0) {
7807
- const verLabel = document.createElement('div');
7808
- verLabel.className = 'product-section-label';
7809
- verLabel.textContent = 'Version History';
7810
- container.appendChild(verLabel);
7811
7831
  const verList = document.createElement('div');
7812
7832
  verList.className = 'product-version-list';
7813
7833
  for (const v of versions) {
@@ -7826,6 +7846,13 @@ class AppController {
7826
7846
  }
7827
7847
  container.appendChild(verList);
7828
7848
  }
7849
+
7850
+ // Release Version button
7851
+ const releaseBtn = document.createElement('button');
7852
+ releaseBtn.className = 'btn-small';
7853
+ releaseBtn.textContent = '+ Release Version';
7854
+ releaseBtn.addEventListener('click', () => this._showReleaseVersionForm(container, slug));
7855
+ container.appendChild(releaseBtn);
7829
7856
  }
7830
7857
 
7831
7858
  _makeEditable(el, fieldName, slug) {
@@ -8152,10 +8179,40 @@ class AppController {
8152
8179
  ${labels ? `<div>Labels: ${labels}</div>` : ''}
8153
8180
  ${issue.created_by ? `<div>Created by: ${this._escHtml(issue.created_by)}</div>` : ''}
8154
8181
  ${issue.resolution ? `<div>Resolution: ${this._escHtml(issue.resolution)}</div>` : ''}
8155
- ${issue.sprint ? `<div>Sprint: ${this._escHtml(issue.sprint)}</div>` : ''}
8156
8182
  `;
8157
8183
  body.appendChild(metaEl);
8158
8184
 
8185
+ // Sprint picker (dropdown)
8186
+ const sprintRow = document.createElement('div');
8187
+ sprintRow.textContent = 'Sprint: ';
8188
+ const sprintSel = document.createElement('select');
8189
+ sprintSel.className = 'form-input';
8190
+ sprintSel.style.width = 'auto';
8191
+ sprintSel.style.display = 'inline';
8192
+ sprintSel.innerHTML = '<option value="">No Sprint</option>';
8193
+ fetch(`/api/product/${encodeURIComponent(slug)}/sprints`)
8194
+ .then(r => r.json())
8195
+ .then(sprints => {
8196
+ for (const s of sprints.filter(s => s.status !== 'closed')) {
8197
+ const opt = document.createElement('option');
8198
+ opt.value = s.id;
8199
+ opt.textContent = `${s.name}${s.status === 'active' ? ' (active)' : ''}`;
8200
+ sprintSel.appendChild(opt);
8201
+ }
8202
+ sprintSel.value = issue.sprint || '';
8203
+ })
8204
+ .catch(err => console.warn('Failed to load sprints:', err));
8205
+ sprintSel.addEventListener('change', () => {
8206
+ fetch(`/api/product/${encodeURIComponent(slug)}/issue/${encodeURIComponent(issue.id)}`, {
8207
+ method: 'PUT',
8208
+ headers: { 'Content-Type': 'application/json' },
8209
+ body: JSON.stringify({ sprint: sprintSel.value || '' }),
8210
+ }).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); })
8211
+ .catch(err => this._showToast(`Failed: ${err.message}`, 'error'));
8212
+ });
8213
+ sprintRow.appendChild(sprintSel);
8214
+ body.appendChild(sprintRow);
8215
+
8159
8216
  const assignRow = document.createElement('div');
8160
8217
  assignRow.textContent = 'Assignee: ';
8161
8218
  const assignSel = document.createElement('select');
@@ -8402,11 +8459,26 @@ class AppController {
8402
8459
  <option value="P2" selected>P2</option><option value="P3">P3</option>
8403
8460
  </select>
8404
8461
  <input type="number" class="form-input issue-new-sp" placeholder="Story pts" style="width:60px" />
8405
- <input type="text" class="form-input issue-new-sprint" placeholder="Sprint" style="width:80px" />
8462
+ <select class="form-input issue-new-sprint" style="width:auto">
8463
+ <option value="">No Sprint</option>
8464
+ </select>
8406
8465
  <button class="btn-small issue-new-save">Create</button>
8407
8466
  <button class="kr-remove-btn issue-new-cancel">&times;</button>
8408
8467
  </div>
8409
8468
  `;
8469
+ // Populate sprint picker from available sprints
8470
+ const sprintSel = row.querySelector('.issue-new-sprint');
8471
+ fetch(`/api/product/${encodeURIComponent(slug)}/sprints`)
8472
+ .then(r => r.json())
8473
+ .then(sprints => {
8474
+ for (const s of sprints.filter(s => s.status !== 'closed')) {
8475
+ const opt = document.createElement('option');
8476
+ opt.value = s.id;
8477
+ opt.textContent = `${s.name}${s.status === 'active' ? ' (active)' : ''}`;
8478
+ sprintSel.appendChild(opt);
8479
+ }
8480
+ })
8481
+ .catch(err => console.warn('Failed to load sprints:', err));
8410
8482
  row.querySelector('.issue-new-cancel').addEventListener('click', () => row.remove());
8411
8483
  row.querySelector('.issue-new-save').addEventListener('click', async () => {
8412
8484
  const title = row.querySelector('.issue-new-title').value.trim();
@@ -8607,6 +8679,11 @@ class AppController {
8607
8679
  const actions = document.createElement('div');
8608
8680
  actions.className = 'sprint-actions';
8609
8681
  if (s.status === 'planning') {
8682
+ const editBtn = document.createElement('button');
8683
+ editBtn.className = 'sprint-action-btn';
8684
+ editBtn.textContent = 'Edit';
8685
+ editBtn.addEventListener('click', () => this._showEditSprintForm(bar, slug, s));
8686
+ actions.appendChild(editBtn);
8610
8687
  const startBtn = document.createElement('button');
8611
8688
  startBtn.className = 'sprint-action-btn';
8612
8689
  startBtn.textContent = 'Start';
@@ -8758,10 +8835,26 @@ class AppController {
8758
8835
  </div>
8759
8836
  <div class="sprint-form-row">
8760
8837
  <input type="number" class="form-input sprint-new-capacity" placeholder="Capacity (pts)" style="width:80px" />
8838
+ <span class="sprint-suggested-capacity" style="color:var(--text-dim);font-size:calc(5px + var(--font-boost));margin-left:4px"></span>
8761
8839
  <button class="btn-small sprint-save-btn">Create</button>
8762
8840
  <button class="kr-remove-btn sprint-cancel-btn">&times;</button>
8763
8841
  </div>
8764
8842
  `;
8843
+ // Show suggested capacity if available
8844
+ fetch(`/api/product/${encodeURIComponent(slug)}/sprint/suggest-capacity`)
8845
+ .then(r => r.json())
8846
+ .then(d => {
8847
+ if (d.suggested_capacity != null) {
8848
+ const hint = form.querySelector('.sprint-suggested-capacity');
8849
+ hint.textContent = `(suggested: ${d.suggested_capacity} pts)`;
8850
+ hint.style.cursor = 'pointer';
8851
+ hint.title = 'Click to use suggested capacity';
8852
+ hint.addEventListener('click', () => {
8853
+ form.querySelector('.sprint-new-capacity').value = d.suggested_capacity;
8854
+ });
8855
+ }
8856
+ })
8857
+ .catch(err => console.warn('Failed to load suggested capacity:', err));
8765
8858
  // Default dates: today → +14 days
8766
8859
  const today = new Date();
8767
8860
  const end = new Date(today);
@@ -8796,6 +8889,51 @@ class AppController {
8796
8889
  form.querySelector('.sprint-new-name').focus();
8797
8890
  }
8798
8891
 
8892
+ _showEditSprintForm(barEl, slug, sprint) {
8893
+ if (barEl.querySelector('.sprint-inline-add')) return;
8894
+ const form = document.createElement('div');
8895
+ form.className = 'sprint-inline-add';
8896
+ form.innerHTML = `
8897
+ <input type="text" class="form-input sprint-edit-name" value="${this._escHtml(sprint.name)}" />
8898
+ <input type="text" class="form-input sprint-edit-goal" value="${this._escHtml(sprint.goal || '')}" placeholder="Goal" />
8899
+ <div class="sprint-form-row">
8900
+ <label style="color:var(--text-dim);font-size:calc(5px + var(--font-boost))">Start:</label>
8901
+ <input type="date" class="form-input sprint-edit-start" value="${sprint.start_date || ''}" style="width:auto" />
8902
+ <label style="color:var(--text-dim);font-size:calc(5px + var(--font-boost))">End:</label>
8903
+ <input type="date" class="form-input sprint-edit-end" value="${sprint.end_date || ''}" style="width:auto" />
8904
+ </div>
8905
+ <div class="sprint-form-row">
8906
+ <input type="number" class="form-input sprint-edit-capacity" value="${sprint.capacity || ''}" placeholder="Capacity" style="width:80px" />
8907
+ <button class="btn-small sprint-edit-save">Save</button>
8908
+ <button class="kr-remove-btn sprint-edit-cancel">&times;</button>
8909
+ </div>
8910
+ `;
8911
+ form.querySelector('.sprint-edit-cancel').addEventListener('click', () => form.remove());
8912
+ form.querySelector('.sprint-edit-save').addEventListener('click', async () => {
8913
+ const updates = {
8914
+ name: form.querySelector('.sprint-edit-name').value.trim(),
8915
+ goal: form.querySelector('.sprint-edit-goal').value.trim(),
8916
+ start_date: form.querySelector('.sprint-edit-start').value,
8917
+ end_date: form.querySelector('.sprint-edit-end').value,
8918
+ };
8919
+ const cap = parseInt(form.querySelector('.sprint-edit-capacity').value);
8920
+ if (!isNaN(cap)) updates.capacity = cap;
8921
+ if (!updates.name) { this._showToast('Sprint name is required', 'warning'); return; }
8922
+ try {
8923
+ const r = await fetch(`/api/product/${encodeURIComponent(slug)}/sprint/${encodeURIComponent(sprint.id)}`, {
8924
+ method: 'PUT',
8925
+ headers: { 'Content-Type': 'application/json' },
8926
+ body: JSON.stringify(updates),
8927
+ });
8928
+ if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
8929
+ this._showToast('Sprint updated', 'success');
8930
+ this._openProductDetail(slug);
8931
+ } catch (err) { this._showToast(`Update failed: ${err.message}`, 'error'); }
8932
+ });
8933
+ barEl.appendChild(form);
8934
+ form.querySelector('.sprint-edit-name').focus();
8935
+ }
8936
+
8799
8937
  // ---------------------------------------------------------------------------
8800
8938
  // Activity Feed Tab
8801
8939
  // ---------------------------------------------------------------------------
@@ -8874,6 +9012,207 @@ class AppController {
8874
9012
  .catch(err => { container.innerHTML = `<div class="error-text">Failed to load activity: ${err.message}</div>`; });
8875
9013
  }
8876
9014
 
9015
+ // ---------------------------------------------------------------------------
9016
+ // Reviews Tab
9017
+ // ---------------------------------------------------------------------------
9018
+
9019
+ _renderProductReviews(reviews, slug, container) {
9020
+ container.innerHTML = '';
9021
+
9022
+ // Create Review button
9023
+ const toolbar = document.createElement('div');
9024
+ toolbar.className = 'issue-toolbar';
9025
+ const createBtn = document.createElement('button');
9026
+ createBtn.className = 'btn-small';
9027
+ createBtn.textContent = '+ Create Review';
9028
+ createBtn.addEventListener('click', async () => {
9029
+ try {
9030
+ const r = await fetch(`/api/product/${encodeURIComponent(slug)}/review`, {
9031
+ method: 'POST',
9032
+ headers: { 'Content-Type': 'application/json' },
9033
+ body: JSON.stringify({ trigger: 'manual', owner: '' }),
9034
+ });
9035
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
9036
+ this._showToast('Review created', 'success');
9037
+ this._openProductDetail(slug);
9038
+ } catch (err) { this._showToast(`Failed: ${err.message}`, 'error'); }
9039
+ });
9040
+ toolbar.appendChild(createBtn);
9041
+ container.appendChild(toolbar);
9042
+
9043
+ if (!reviews.length) {
9044
+ const emptyMsg = document.createElement('div');
9045
+ emptyMsg.className = 'task-empty';
9046
+ emptyMsg.textContent = 'No reviews yet.';
9047
+ container.appendChild(emptyMsg);
9048
+ return;
9049
+ }
9050
+
9051
+ // Group: open first, then completed
9052
+ const open = reviews.filter(r => r.status === 'open');
9053
+ const completed = reviews.filter(r => r.status === 'completed');
9054
+
9055
+ for (const group of [{ label: 'Open', items: open }, { label: 'Completed', items: completed }]) {
9056
+ if (!group.items.length) continue;
9057
+ const heading = document.createElement('div');
9058
+ heading.className = 'product-section-label';
9059
+ heading.textContent = `${group.label} (${group.items.length})`;
9060
+ container.appendChild(heading);
9061
+
9062
+ for (const rev of group.items) {
9063
+ const card = document.createElement('div');
9064
+ card.className = `review-card ${rev.status === 'completed' ? 'review-completed' : ''}`;
9065
+
9066
+ const header = document.createElement('div');
9067
+ header.className = 'review-card-header';
9068
+ const trigger = rev.trigger || 'manual';
9069
+ 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>`;
9072
+ card.appendChild(header);
9073
+
9074
+ // Checklist items
9075
+ const itemsList = document.createElement('div');
9076
+ itemsList.className = 'review-items';
9077
+ for (const item of (rev.items || [])) {
9078
+ const row = document.createElement('div');
9079
+ row.className = 'review-item-row';
9080
+ const checkbox = document.createElement('input');
9081
+ checkbox.type = 'checkbox';
9082
+ checkbox.checked = !!item.checked;
9083
+ checkbox.disabled = rev.status === 'completed';
9084
+ checkbox.addEventListener('change', async () => {
9085
+ try {
9086
+ const r = await fetch(`/api/product/${encodeURIComponent(slug)}/review/${encodeURIComponent(rev.id)}/item/${encodeURIComponent(item.key)}`, {
9087
+ method: 'PUT',
9088
+ headers: { 'Content-Type': 'application/json' },
9089
+ body: JSON.stringify({ checked: checkbox.checked }),
9090
+ });
9091
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
9092
+ } catch (err) {
9093
+ checkbox.checked = !checkbox.checked;
9094
+ this._showToast(`Failed: ${err.message}`, 'error');
9095
+ }
9096
+ });
9097
+ const label = document.createElement('span');
9098
+ label.className = item.checked ? 'review-item-checked' : '';
9099
+ label.textContent = item.label || item.key;
9100
+ row.appendChild(checkbox);
9101
+ row.appendChild(label);
9102
+ itemsList.appendChild(row);
9103
+ }
9104
+ card.appendChild(itemsList);
9105
+
9106
+ // Complete button for open reviews
9107
+ if (rev.status === 'open') {
9108
+ const completeBtn = document.createElement('button');
9109
+ completeBtn.className = 'btn-small';
9110
+ completeBtn.textContent = 'Complete Review';
9111
+ completeBtn.addEventListener('click', async () => {
9112
+ try {
9113
+ const r = await fetch(`/api/product/${encodeURIComponent(slug)}/review/${encodeURIComponent(rev.id)}/complete`, { method: 'POST' });
9114
+ if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
9115
+ this._showToast('Review completed', 'success');
9116
+ this._openProductDetail(slug);
9117
+ } catch (err) { this._showToast(`Failed: ${err.message}`, 'error'); }
9118
+ });
9119
+ card.appendChild(completeBtn);
9120
+ }
9121
+
9122
+ container.appendChild(card);
9123
+ }
9124
+ }
9125
+ }
9126
+
9127
+ // ---------------------------------------------------------------------------
9128
+ // Release Version Form
9129
+ // ---------------------------------------------------------------------------
9130
+
9131
+ _showReleaseVersionForm(container, slug) {
9132
+ if (container.querySelector('.release-form')) return;
9133
+ const form = document.createElement('div');
9134
+ form.className = 'release-form sprint-inline-add';
9135
+
9136
+ // Show done issues that can be released
9137
+ fetch(`/api/product/${encodeURIComponent(slug)}/detail`)
9138
+ .then(r => r.json())
9139
+ .then(data => {
9140
+ const doneIssues = (data.issues || []).filter(i => i.status === 'done');
9141
+ if (!doneIssues.length) {
9142
+ form.innerHTML = '<div class="task-empty">No issues in DONE status to release.</div>';
9143
+ const closeBtn = document.createElement('button');
9144
+ closeBtn.className = 'kr-remove-btn';
9145
+ closeBtn.innerHTML = '&times;';
9146
+ closeBtn.addEventListener('click', () => form.remove());
9147
+ form.appendChild(closeBtn);
9148
+ return;
9149
+ }
9150
+
9151
+ const label = document.createElement('div');
9152
+ label.style.cssText = 'color:var(--text-dim);font-size:calc(5px + var(--font-boost));margin-bottom:4px';
9153
+ label.textContent = `Select issues to include in release (${doneIssues.length} done):`;
9154
+ form.appendChild(label);
9155
+
9156
+ const checkboxes = [];
9157
+ for (const issue of doneIssues) {
9158
+ const row = document.createElement('div');
9159
+ row.className = 'review-item-row';
9160
+ const cb = document.createElement('input');
9161
+ cb.type = 'checkbox';
9162
+ cb.checked = true;
9163
+ cb.dataset.issueId = issue.id;
9164
+ checkboxes.push(cb);
9165
+ const text = document.createElement('span');
9166
+ text.textContent = `[${issue.priority || 'P2'}] ${issue.title}`;
9167
+ row.appendChild(cb);
9168
+ row.appendChild(text);
9169
+ form.appendChild(row);
9170
+ }
9171
+
9172
+ const bumpRow = document.createElement('div');
9173
+ bumpRow.className = 'sprint-form-row';
9174
+ bumpRow.style.marginTop = '6px';
9175
+ const bumpLabel = document.createElement('label');
9176
+ bumpLabel.style.cssText = 'color:var(--text-dim);font-size:calc(5px + var(--font-boost))';
9177
+ bumpLabel.textContent = 'Bump:';
9178
+ const bumpSel = document.createElement('select');
9179
+ bumpSel.className = 'form-input';
9180
+ bumpSel.style.width = 'auto';
9181
+ bumpSel.innerHTML = '<option value="patch">Patch</option><option value="minor">Minor</option><option value="major">Major</option>';
9182
+ bumpRow.appendChild(bumpLabel);
9183
+ bumpRow.appendChild(bumpSel);
9184
+
9185
+ const releaseBtn = document.createElement('button');
9186
+ releaseBtn.className = 'btn-small';
9187
+ releaseBtn.textContent = 'Release';
9188
+ releaseBtn.addEventListener('click', async () => {
9189
+ const selectedIds = checkboxes.filter(cb => cb.checked).map(cb => cb.dataset.issueId);
9190
+ if (!selectedIds.length) { this._showToast('Select at least one issue', 'warning'); return; }
9191
+ try {
9192
+ const r = await fetch(`/api/product/${encodeURIComponent(slug)}/release`, {
9193
+ method: 'POST',
9194
+ headers: { 'Content-Type': 'application/json' },
9195
+ body: JSON.stringify({ resolved_issue_ids: selectedIds, bump: bumpSel.value }),
9196
+ });
9197
+ if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
9198
+ const result = await r.json();
9199
+ this._showToast(`Released v${result.version}`, 'success');
9200
+ this._openProductDetail(slug);
9201
+ } catch (err) { this._showToast(`Release failed: ${err.message}`, 'error'); }
9202
+ });
9203
+ bumpRow.appendChild(releaseBtn);
9204
+
9205
+ const cancelBtn = document.createElement('button');
9206
+ cancelBtn.className = 'kr-remove-btn';
9207
+ cancelBtn.innerHTML = '&times;';
9208
+ cancelBtn.addEventListener('click', () => form.remove());
9209
+ bumpRow.appendChild(cancelBtn);
9210
+ form.appendChild(bumpRow);
9211
+ });
9212
+
9213
+ container.appendChild(form);
9214
+ }
9215
+
8877
9216
  _doUpdateProjectsPanel() {
8878
9217
  const panel = document.getElementById('projects-panel-list');
8879
9218
  if (!panel) return;
@@ -6786,3 +6786,53 @@ body.resize-dragging {
6786
6786
  border-color: #ff4444;
6787
6787
  color: #ff4444;
6788
6788
  }
6789
+
6790
+ /* Review Cards */
6791
+ .review-card {
6792
+ background: rgba(0,255,255,0.03);
6793
+ border: 1px solid rgba(0,255,255,0.1);
6794
+ border-radius: 4px;
6795
+ padding: 8px 10px;
6796
+ margin-bottom: 6px;
6797
+ }
6798
+ .review-card.review-completed {
6799
+ opacity: 0.7;
6800
+ border-color: rgba(0,255,0,0.15);
6801
+ }
6802
+ .review-card-header {
6803
+ display: flex;
6804
+ gap: 8px;
6805
+ align-items: center;
6806
+ margin-bottom: 6px;
6807
+ font-size: calc(5px + var(--font-boost));
6808
+ }
6809
+ .review-trigger {
6810
+ color: var(--accent);
6811
+ font-weight: bold;
6812
+ text-transform: uppercase;
6813
+ font-size: calc(5px + var(--font-boost));
6814
+ }
6815
+ .review-date { color: var(--text-dim); }
6816
+ .review-owner { color: var(--text-dim); }
6817
+ .review-items { margin: 4px 0; }
6818
+ .review-item-row {
6819
+ display: flex;
6820
+ align-items: center;
6821
+ gap: 6px;
6822
+ padding: 2px 0;
6823
+ font-size: calc(6px + var(--font-boost));
6824
+ }
6825
+ .review-item-row input[type="checkbox"] {
6826
+ width: 14px;
6827
+ height: 14px;
6828
+ accent-color: var(--accent);
6829
+ }
6830
+ .review-item-checked {
6831
+ text-decoration: line-through;
6832
+ color: var(--text-dim);
6833
+ }
6834
+
6835
+ /* Release Form */
6836
+ .release-form {
6837
+ margin-top: 8px;
6838
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1mancompany/onemancompany",
3
- "version": "0.7.32",
3
+ "version": "0.7.39",
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.32"
3
+ version = "0.7.39"
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 = [
@@ -691,6 +691,58 @@ def version_management_tool(
691
691
  return f"Error: {e}"
692
692
 
693
693
 
694
+ @tool
695
+ async def update_product_tool(
696
+ product_slug: str,
697
+ name: str = "",
698
+ description: str = "",
699
+ objective: str = "",
700
+ ) -> str:
701
+ """Update a product's name, description, or objective.
702
+
703
+ Args:
704
+ product_slug: Product slug identifier
705
+ name: New product name (leave empty to keep current)
706
+ description: New description (leave empty to keep current)
707
+ objective: New objective (leave empty to keep current)
708
+ """
709
+ fields: dict = {}
710
+ if name:
711
+ fields["name"] = name
712
+ if description:
713
+ fields["description"] = description
714
+ if objective:
715
+ fields["objective"] = objective
716
+ if not fields:
717
+ return "Error: no fields to update. Provide name, description, or objective."
718
+ try:
719
+ result = prod.update_product(product_slug, **fields)
720
+ if result is None:
721
+ return f"Error: product '{product_slug}' not found"
722
+ return f"Updated product '{product_slug}': {', '.join(fields.keys())}"
723
+ except (ValueError, FileNotFoundError) as e:
724
+ return f"Error: {e}"
725
+
726
+
727
+ @tool
728
+ async def delete_product_tool(product_slug: str) -> str:
729
+ """Delete a product and all its issues, versions, and linked projects.
730
+
731
+ Args:
732
+ product_slug: Product slug identifier
733
+ """
734
+ try:
735
+ summary = prod.delete_product(product_slug)
736
+ return (
737
+ f"Deleted product '{product_slug}'. "
738
+ f"Removed {summary.get('issues', 0)} issues, "
739
+ f"{summary.get('versions', 0)} versions, "
740
+ f"{summary.get('projects', 0)} linked projects."
741
+ )
742
+ except (ValueError, FileNotFoundError) as e:
743
+ return f"Error: {e}"
744
+
745
+
694
746
  # ---------------------------------------------------------------------------
695
747
  # Export
696
748
  # ---------------------------------------------------------------------------
@@ -716,4 +768,6 @@ PRODUCT_TOOLS = [
716
768
  delete_sprint_tool,
717
769
  sprint_analytics_tool,
718
770
  version_management_tool,
771
+ update_product_tool,
772
+ delete_product_tool,
719
773
  ]
@@ -7141,6 +7141,18 @@ async def api_update_kr(slug: str, kr_id: str, request: Request) -> dict:
7141
7141
  return result
7142
7142
 
7143
7143
 
7144
+ @router.delete("/api/product/{slug}/kr/{kr_id}")
7145
+ async def api_delete_kr(slug: str, kr_id: str) -> dict:
7146
+ """Delete a key result from a product."""
7147
+ from onemancompany.core import product as prod
7148
+
7149
+ try:
7150
+ prod.delete_key_result(slug, kr_id)
7151
+ except ValueError as exc:
7152
+ raise HTTPException(status_code=404, detail=str(exc))
7153
+ return {"ok": True}
7154
+
7155
+
7144
7156
  # ── Issues ──────────────────────────────────────────────────────────────────
7145
7157
 
7146
7158
 
@@ -7483,6 +7495,13 @@ async def api_create_sprint(slug: str, request: Request) -> dict:
7483
7495
  )
7484
7496
  except ValueError as exc:
7485
7497
  raise HTTPException(status_code=404, detail=str(exc))
7498
+ await event_bus.publish(
7499
+ CompanyEvent(
7500
+ type=EventType.SPRINT_CREATED,
7501
+ payload={"product_slug": slug, "sprint_id": result["id"]},
7502
+ agent=SYSTEM_AGENT,
7503
+ )
7504
+ )
7486
7505
  return result
7487
7506
 
7488
7507
 
@@ -7531,6 +7550,13 @@ async def api_close_sprint(slug: str, sprint_id: str) -> dict:
7531
7550
  result = prod.close_sprint(slug, sprint_id)
7532
7551
  except ValueError as exc:
7533
7552
  raise HTTPException(status_code=400, detail=str(exc))
7553
+ await event_bus.publish(
7554
+ CompanyEvent(
7555
+ type=EventType.SPRINT_CLOSED,
7556
+ payload={"product_slug": slug, "sprint_id": sprint_id},
7557
+ agent=SYSTEM_AGENT,
7558
+ )
7559
+ )
7534
7560
  return result
7535
7561
 
7536
7562
 
@@ -7543,6 +7569,13 @@ async def api_start_sprint(slug: str, sprint_id: str) -> dict:
7543
7569
  result = prod.start_sprint(slug, sprint_id)
7544
7570
  except ValueError as exc:
7545
7571
  raise HTTPException(status_code=400, detail=str(exc))
7572
+ await event_bus.publish(
7573
+ CompanyEvent(
7574
+ type=EventType.SPRINT_STARTED,
7575
+ payload={"product_slug": slug, "sprint_id": sprint_id},
7576
+ agent=SYSTEM_AGENT,
7577
+ )
7578
+ )
7546
7579
  return result
7547
7580
 
7548
7581
 
@@ -7649,6 +7682,13 @@ async def api_create_review(slug: str, request: Request) -> dict:
7649
7682
  trigger_ref=trigger_ref,
7650
7683
  owner=owner,
7651
7684
  )
7685
+ await event_bus.publish(
7686
+ CompanyEvent(
7687
+ type=EventType.REVIEW_CREATED,
7688
+ payload={"product_slug": slug, "review_id": review["id"]},
7689
+ agent=SYSTEM_AGENT,
7690
+ )
7691
+ )
7652
7692
  return review
7653
7693
 
7654
7694
 
@@ -7691,9 +7731,17 @@ async def api_complete_review(slug: str, review_id: str) -> dict:
7691
7731
  from onemancompany.core import product as prod
7692
7732
 
7693
7733
  try:
7694
- return prod.complete_review(slug, review_id)
7734
+ result = prod.complete_review(slug, review_id)
7695
7735
  except ValueError as exc:
7696
7736
  raise HTTPException(status_code=400, detail=str(exc))
7737
+ await event_bus.publish(
7738
+ CompanyEvent(
7739
+ type=EventType.REVIEW_COMPLETED,
7740
+ payload={"product_slug": slug, "review_id": review_id},
7741
+ agent=SYSTEM_AGENT,
7742
+ )
7743
+ )
7744
+ return result
7697
7745
 
7698
7746
 
7699
7747
  # ---------------------------------------------------------------------------
@@ -163,6 +163,7 @@ class EventType(str, Enum):
163
163
  KR_UPDATED = "kr_updated"
164
164
  VERSION_RELEASED = "version_released"
165
165
  SPRINT_CREATED = "sprint_created"
166
+ SPRINT_STARTED = "sprint_started"
166
167
  SPRINT_CLOSED = "sprint_closed"
167
168
  REVIEW_CREATED = "review_created"
168
169
  REVIEW_COMPLETED = "review_completed"
@@ -37,6 +37,12 @@ from onemancompany.core.models import (
37
37
  )
38
38
  from onemancompany.core.store import _read_yaml, _write_yaml, mark_dirty
39
39
 
40
+ # ---------------------------------------------------------------------------
41
+ # Configurable constants
42
+ # ---------------------------------------------------------------------------
43
+
44
+ HISTORY_MAX_ENTRIES: int = 100
45
+
40
46
  # ---------------------------------------------------------------------------
41
47
  # Per-slug threading locks (same pattern as project_archive.py)
42
48
  # ---------------------------------------------------------------------------
@@ -230,6 +236,24 @@ def update_kr_progress(slug: str, kr_id: str, *, current: float) -> dict:
230
236
  raise ValueError(f"KR '{kr_id}' not found in product '{slug}'")
231
237
 
232
238
 
239
+ def delete_key_result(slug: str, kr_id: str) -> None:
240
+ """Delete a key result from a product. Raises ValueError if not found."""
241
+ with _get_slug_lock(slug):
242
+ path = _product_yaml_path(slug)
243
+ data = _read_yaml(path)
244
+ if not data:
245
+ raise ValueError(f"Product '{slug}' not found")
246
+ krs = data.get("key_results", [])
247
+ original_len = len(krs)
248
+ data["key_results"] = [kr for kr in krs if kr["id"] != kr_id]
249
+ if len(data["key_results"]) == original_len:
250
+ raise ValueError(f"KR '{kr_id}' not found in product '{slug}'")
251
+ data["updated_at"] = datetime.now().isoformat()
252
+ _write_yaml(path, data)
253
+ mark_dirty(DirtyCategory.PRODUCTS)
254
+ logger.debug("Deleted KR {} from product {}", kr_id, slug)
255
+
256
+
233
257
  def update_kr_fields(slug: str, kr_id: str, **fields) -> dict:
234
258
  """Update arbitrary fields on a key result. Returns updated KR dict.
235
259
 
@@ -269,8 +293,8 @@ def _append_history(data: dict, field: str, old_value, new_value, changed_by: st
269
293
  "new_value": str(new_value) if new_value is not None else None,
270
294
  "changed_by": changed_by,
271
295
  })
272
- if len(data["history"]) > 100:
273
- data["history"] = data["history"][-100:]
296
+ if len(data["history"]) > HISTORY_MAX_ENTRIES:
297
+ data["history"] = data["history"][-HISTORY_MAX_ENTRIES:]
274
298
 
275
299
 
276
300
  def create_issue(
@@ -355,8 +379,12 @@ def list_issues(
355
379
  priority: IssuePriority | None = None,
356
380
  labels: list[str] | None = None,
357
381
  sprint: str | None = None,
382
+ assignee_id: str | None = None,
358
383
  ) -> list[dict]:
359
- """List issues for a product, optionally filtered."""
384
+ """List issues for a product, optionally filtered.
385
+
386
+ assignee_id: filter by assignee. Empty string "" means unassigned.
387
+ """
360
388
  issues_path = _issues_dir(slug)
361
389
  if not issues_path.exists():
362
390
  return []
@@ -378,12 +406,24 @@ def list_issues(
378
406
  continue
379
407
  if sprint is not None and data.get("sprint") != sprint:
380
408
  continue
409
+ if assignee_id is not None:
410
+ issue_assignee = data.get("assignee_id") or ""
411
+ if assignee_id == "":
412
+ # Filter for unassigned
413
+ if issue_assignee:
414
+ continue
415
+ elif issue_assignee != assignee_id:
416
+ continue
381
417
  results.append(data)
382
418
  return results
383
419
 
384
420
 
385
- def update_issue(slug: str, issue_id: str, **fields) -> dict:
386
- """Update issue fields. Returns updated dict. Raises ValueError if not found."""
421
+ def update_issue(slug: str, issue_id: str, *, _skip_transition_check: bool = False, **fields) -> dict:
422
+ """Update issue fields. Returns updated dict. Raises ValueError if not found.
423
+
424
+ _skip_transition_check: internal flag for system-derived status updates
425
+ that may jump non-adjacent states (e.g. sync_issue_statuses).
426
+ """
387
427
  with _get_slug_lock(slug):
388
428
  path = _issues_dir(slug) / f"{issue_id}.yaml"
389
429
  data = _read_yaml(path)
@@ -391,7 +431,7 @@ def update_issue(slug: str, issue_id: str, **fields) -> dict:
391
431
  raise ValueError(f"Issue '{issue_id}' not found in product '{slug}'")
392
432
  # Validate status transition if status is being changed
393
433
  new_status = fields.get("status")
394
- if new_status is not None:
434
+ if new_status is not None and not _skip_transition_check:
395
435
  current_status = data.get("status", IssueStatus.BACKLOG.value)
396
436
  if new_status != current_status:
397
437
  _validate_status_transition(current_status, new_status)
@@ -959,11 +999,11 @@ def release_version(
959
999
  product["current_version"] = new_version
960
1000
  _write_yaml(_product_yaml_path(product_slug), product)
961
1001
 
962
- # Mark resolved issues as released
1002
+ # Mark resolved issues as released (bypass validation — release is a system operation)
963
1003
  for issue_id in resolved_issue_ids:
964
1004
  issue = load_issue(product_slug, issue_id)
965
1005
  if issue and issue.get("status") != IssueStatus.RELEASED.value:
966
- update_issue(product_slug, issue_id, status=IssueStatus.RELEASED.value)
1006
+ update_issue(product_slug, issue_id, _skip_transition_check=True, status=IssueStatus.RELEASED.value)
967
1007
 
968
1008
  mark_dirty(DirtyCategory.PRODUCTS)
969
1009
  logger.info("[VERSION] Released {} for product '{}'", new_version, product_slug)
@@ -994,12 +1034,14 @@ def build_product_context(product_slug: str) -> str:
994
1034
  unit = kr.get("unit", "")
995
1035
  suffix = f" {unit}" if unit else ""
996
1036
  parts.append(f" - {kr['title']}: {current}/{target}{suffix} ({pct:.0f}%)")
997
- issues = list_issues(product_slug, status=IssueStatus.BACKLOG)
1037
+ _terminal = {IssueStatus.DONE.value, IssueStatus.RELEASED.value}
1038
+ issues = [i for i in list_issues(product_slug) if i.get("status") not in _terminal]
998
1039
  issues.sort(key=lambda i: i.get("priority", "P3"))
999
1040
  if issues:
1000
1041
  parts.append(f"\nActive Issues ({len(issues)}):")
1001
1042
  for issue in issues[:10]:
1002
- parts.append(f" - [{issue['priority']}] {issue['title']} ({issue['id']})")
1043
+ status_tag = issue.get("status", "backlog")
1044
+ parts.append(f" - [{issue['priority']}][{status_tag}] {issue['title']} ({issue['id']})")
1003
1045
  if len(issues) > 10:
1004
1046
  parts.append(f" ... and {len(issues) - 10} more")
1005
1047
  parts.append("=== End Product Context ===")
@@ -1270,7 +1312,7 @@ def sync_issue_statuses(slug: str) -> list[dict]:
1270
1312
  current = issue.get("status", IssueStatus.BACKLOG.value)
1271
1313
 
1272
1314
  if derived.value != current:
1273
- update_issue(slug, issue["id"], status=derived.value)
1315
+ update_issue(slug, issue["id"], _skip_transition_check=True, status=derived.value)
1274
1316
  changed.append({"issue_id": issue["id"], "old": current, "new": derived.value})
1275
1317
  logger.debug("[PRODUCT] Issue {} status derived: {} → {}", issue["id"], current, derived.value)
1276
1318
 
@@ -23,6 +23,16 @@ from onemancompany.core.system_cron import system_cron
23
23
  # Priorities that auto-trigger project creation
24
24
  _AUTO_PROJECT_PRIORITIES = {IssuePriority.P0.value, IssuePriority.P1.value}
25
25
 
26
+ # ---------------------------------------------------------------------------
27
+ # Configurable thresholds (B4 audit: extracted from inline magic numbers)
28
+ # ---------------------------------------------------------------------------
29
+
30
+ KR_LAGGING_THRESHOLD: int = 50 # KR progress % below which it's "lagging"
31
+ MAX_ACTIVE_PROJECTS: int = 3 # Max concurrent active projects per product
32
+ BACKLOG_GROOMING_THRESHOLD: int = 5 # P2/P3 unscheduled issues before grooming nudge
33
+ STALE_REVIEW_HOURS: int = 24 # Hours before an open review is considered stale
34
+ BLOCKED_DAYS_THRESHOLD: int = 7 # Days before a blocked issue is flagged
35
+ UNHANDLED_BACKLOG_THRESHOLD: int = 2 # Unhandled backlog issues before alert
26
36
 
27
37
  # ---------------------------------------------------------------------------
28
38
  # Trigger handlers
@@ -332,7 +342,7 @@ async def check_kr_progress(product_slug: str) -> list[dict]:
332
342
  if target <= 0:
333
343
  continue
334
344
  progress_pct = current / target * 100
335
- if progress_pct >= 50:
345
+ if progress_pct >= KR_LAGGING_THRESHOLD:
336
346
  continue
337
347
 
338
348
  # Check if an open issue already exists for this KR
@@ -415,7 +425,7 @@ async def run_product_check(product_slug: str) -> dict:
415
425
 
416
426
  # High priority + no active project → create project
417
427
  if priority in _AUTO_PROJECT_PRIORITIES and not linked:
418
- if len(active_for_product) >= 3:
428
+ if len(active_for_product) >= MAX_ACTIVE_PROJECTS:
419
429
  logger.debug("[PRODUCT_CHECK] Skipping project for issue {} — 3+ active projects", issue["id"])
420
430
  continue
421
431
  project_id = await _create_project_for_issue(product_slug, issue)
@@ -430,7 +440,7 @@ async def run_product_check(product_slug: str) -> dict:
430
440
 
431
441
  # Has assignee but no project → create project
432
442
  elif issue.get("assignee_id") and not linked:
433
- if len(active_for_product) >= 3:
443
+ if len(active_for_product) >= MAX_ACTIVE_PROJECTS:
434
444
  continue
435
445
  project_id = await _create_project_for_issue(product_slug, issue)
436
446
  if project_id:
@@ -484,34 +494,31 @@ async def run_product_check(product_slug: str) -> dict:
484
494
  logger.debug("[PRODUCT_CHECK] Invalid end_date '{}' on sprint {}", end_date_str, active_sprint.get("id"))
485
495
 
486
496
  # --- Step 4: Backlog grooming reminder ---
487
- _BACKLOG_GROOMING_THRESHOLD = 5
488
497
  unscheduled_low = [
489
498
  i for i in all_issues
490
499
  if i.get("priority") in (IssuePriority.P2.value, IssuePriority.P3.value)
491
500
  and not i.get("sprint")
492
501
  and i.get("status") not in (IssueStatus.DONE.value, IssueStatus.RELEASED.value)
493
502
  ]
494
- if len(unscheduled_low) >= _BACKLOG_GROOMING_THRESHOLD:
503
+ if len(unscheduled_low) >= BACKLOG_GROOMING_THRESHOLD:
495
504
  actions_taken.append(f"{len(unscheduled_low)} P2/P3 issues unscheduled — backlog grooming needed")
496
505
 
497
- # --- Step 5: Stale review check (open > 24h) ---
506
+ # --- Step 5: Stale review check ---
498
507
  from datetime import datetime as _datetime, timedelta as _timedelta
499
508
 
500
509
  open_reviews = prod.list_reviews(product_slug, status="open")
501
- _STALE_REVIEW_HOURS = 24
502
510
  stale_reviews = []
503
511
  for rev in open_reviews:
504
512
  try:
505
513
  created = _datetime.fromisoformat(rev.get("created_at", ""))
506
- if _datetime.now() - created > _timedelta(hours=_STALE_REVIEW_HOURS):
514
+ if _datetime.now() - created > _timedelta(hours=STALE_REVIEW_HOURS):
507
515
  stale_reviews.append(rev)
508
516
  except (ValueError, TypeError):
509
517
  logger.debug("[PRODUCT_CHECK] Invalid created_at on review {}", rev.get("id"))
510
518
  if stale_reviews:
511
- actions_taken.append(f"{len(stale_reviews)} stale review(s) open > {_STALE_REVIEW_HOURS}h")
519
+ actions_taken.append(f"{len(stale_reviews)} stale review(s) open > {STALE_REVIEW_HOURS}h")
512
520
 
513
- # --- Step 6: Blocked issue check (blocked > 7 days) ---
514
- _BLOCKED_DAYS_THRESHOLD = 7
521
+ # --- Step 6: Blocked issue check ---
515
522
  for issue in all_issues:
516
523
  if issue.get("status") in (IssueStatus.DONE.value, IssueStatus.RELEASED.value):
517
524
  continue
@@ -532,9 +539,9 @@ async def run_product_check(product_slug: str) -> dict:
532
539
  oldest_blocked_at = link_created
533
540
  except (ValueError, TypeError):
534
541
  logger.debug("[PRODUCT_CHECK] Invalid created_at on link in issue {}", issue.get("id"))
535
- if oldest_blocked_at and _datetime.now() - oldest_blocked_at > _timedelta(days=_BLOCKED_DAYS_THRESHOLD):
542
+ if oldest_blocked_at and _datetime.now() - oldest_blocked_at > _timedelta(days=BLOCKED_DAYS_THRESHOLD):
536
543
  actions_taken.append(
537
- f"Issue '{issue['title']}' blocked for >{_BLOCKED_DAYS_THRESHOLD} days"
544
+ f"Issue '{issue['title']}' blocked for >{BLOCKED_DAYS_THRESHOLD} days"
538
545
  )
539
546
 
540
547
  # --- Step 7: Check if owner review is needed ---
@@ -546,7 +553,7 @@ async def run_product_check(product_slug: str) -> dict:
546
553
  i for i in all_issues
547
554
  if i.get("status") == IssueStatus.BACKLOG.value and not i.get("linked_task_ids")
548
555
  ]
549
- if len(unhandled_backlog) > 2:
556
+ if len(unhandled_backlog) > UNHANDLED_BACKLOG_THRESHOLD:
550
557
  needs_review = True
551
558
  review_reasons.append(f"{len(unhandled_backlog)} unhandled backlog issues")
552
559
 
@@ -570,7 +577,7 @@ async def run_product_check(product_slug: str) -> dict:
570
577
  logger.debug("[PRODUCT_CHECK] Invalid end_date on sprint {} for review check", active_sprint.get("id"))
571
578
 
572
579
  # Backlog grooming threshold → needs owner review
573
- if len(unscheduled_low) >= _BACKLOG_GROOMING_THRESHOLD:
580
+ if len(unscheduled_low) >= BACKLOG_GROOMING_THRESHOLD:
574
581
  needs_review = True
575
582
  review_reasons.append(f"{len(unscheduled_low)} P2/P3 issues need sprint assignment")
576
583
 
@@ -755,6 +762,7 @@ def register_product_triggers() -> "asyncio.Task":
755
762
  EventType.ISSUE_CLOSED,
756
763
  EventType.ISSUE_ASSIGNED,
757
764
  EventType.SPRINT_CREATED,
765
+ EventType.SPRINT_STARTED,
758
766
  EventType.SPRINT_CLOSED,
759
767
  EventType.VERSION_RELEASED,
760
768
  EventType.REVIEW_CREATED,