trackops 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +292 -272
  2. package/bin/trackops.js +108 -50
  3. package/lib/config.js +267 -38
  4. package/lib/control.js +534 -480
  5. package/lib/env.js +244 -0
  6. package/lib/i18n.js +61 -53
  7. package/lib/init.js +170 -47
  8. package/lib/locale.js +63 -0
  9. package/lib/opera-bootstrap.js +1075 -0
  10. package/lib/opera.js +524 -125
  11. package/lib/preferences.js +74 -0
  12. package/lib/registry.js +27 -13
  13. package/lib/release.js +56 -0
  14. package/lib/resources.js +42 -0
  15. package/lib/runtime-state.js +144 -0
  16. package/lib/server.js +1004 -521
  17. package/lib/skills.js +148 -124
  18. package/lib/workspace.js +260 -0
  19. package/locales/en.json +418 -132
  20. package/locales/es.json +418 -132
  21. package/package.json +8 -9
  22. package/scripts/postinstall-locale.js +21 -0
  23. package/scripts/skills-marketplace-smoke.js +124 -0
  24. package/scripts/smoke-tests.js +570 -0
  25. package/scripts/sync-skill-version.js +21 -0
  26. package/scripts/validate-skill.js +89 -0
  27. package/skills/trackops/SKILL.md +89 -0
  28. package/skills/trackops/agents/openai.yaml +3 -0
  29. package/skills/trackops/references/activation.md +73 -0
  30. package/skills/trackops/references/troubleshooting.md +49 -0
  31. package/skills/trackops/references/workflow.md +26 -0
  32. package/skills/trackops/scripts/bootstrap-trackops.js +203 -0
  33. package/skills/trackops/skill.json +29 -0
  34. package/templates/opera/agent.md +10 -9
  35. package/templates/opera/architecture/dependency-graph.md +24 -0
  36. package/templates/opera/architecture/runtime-automation.md +24 -0
  37. package/templates/opera/architecture/runtime-operations.md +34 -0
  38. package/templates/opera/en/agent.md +27 -0
  39. package/templates/opera/en/architecture/dependency-graph.md +24 -0
  40. package/templates/opera/en/architecture/runtime-automation.md +24 -0
  41. package/templates/opera/en/architecture/runtime-operations.md +34 -0
  42. package/templates/opera/en/genesis.md +79 -0
  43. package/templates/opera/en/references/autonomy-and-recovery.md +23 -0
  44. package/templates/opera/en/references/opera-cycle.md +62 -0
  45. package/templates/opera/en/registry.md +28 -0
  46. package/templates/opera/en/reviews/delivery-audit.md +18 -0
  47. package/templates/opera/en/reviews/integration-audit.md +18 -0
  48. package/templates/opera/en/router.md +49 -0
  49. package/templates/opera/genesis.md +79 -94
  50. package/templates/opera/reviews/delivery-audit.md +18 -0
  51. package/templates/opera/reviews/integration-audit.md +18 -0
  52. package/templates/opera/router.md +15 -5
  53. package/templates/skills/changelog-updater/locales/en/SKILL.md +11 -0
  54. package/templates/skills/commiter/locales/en/SKILL.md +11 -0
  55. package/templates/skills/opera-contract-auditor/SKILL.md +38 -0
  56. package/templates/skills/opera-contract-auditor/locales/en/SKILL.md +38 -0
  57. package/templates/skills/opera-policy-guard/SKILL.md +26 -0
  58. package/templates/skills/opera-policy-guard/locales/en/SKILL.md +26 -0
  59. package/templates/skills/project-starter-skill/SKILL.md +89 -164
  60. package/templates/skills/project-starter-skill/locales/en/SKILL.md +104 -0
  61. package/ui/css/panels.css +956 -953
  62. package/ui/index.html +1 -1
  63. package/ui/js/api.js +211 -194
  64. package/ui/js/app.js +200 -199
  65. package/ui/js/i18n.js +14 -0
  66. package/ui/js/onboarding.js +439 -437
  67. package/ui/js/state.js +130 -129
  68. package/ui/js/utils.js +175 -172
  69. package/ui/js/views/board.js +255 -254
  70. package/ui/js/views/execution.js +256 -256
  71. package/ui/js/views/insights.js +340 -339
  72. package/ui/js/views/overview.js +366 -361
  73. package/ui/js/views/settings.js +340 -202
  74. package/ui/js/views/sidebar.js +131 -132
  75. package/ui/js/views/skills.js +163 -162
  76. package/ui/js/views/tasks.js +406 -405
  77. package/ui/js/views/topbar.js +239 -183
  78. package/templates/etapa/agent.md +0 -26
  79. package/templates/etapa/genesis.md +0 -94
  80. package/templates/etapa/references/autonomy-and-recovery.md +0 -117
  81. package/templates/etapa/references/etapa-cycle.md +0 -193
  82. package/templates/etapa/registry.md +0 -28
  83. package/templates/etapa/router.md +0 -39
@@ -1,254 +1,255 @@
1
- /**
2
- * board.js — Tablero Kanban con drag & drop
3
- */
4
-
5
- import { icon } from '../icons.js';
6
- import * as state from '../state.js';
7
- import * as api from '../api.js';
8
- import * as router from '../router.js';
9
- import { flash } from './flash.js';
10
- import { esc, debounce } from '../utils.js';
11
-
12
- const COLUMNS = [
13
- { id: 'pending', label: 'Pendiente' },
14
- { id: 'in_progress', label: 'En Progreso' },
15
- { id: 'in_review', label: 'En Revisión' },
16
- { id: 'blocked', label: 'Bloqueado' },
17
- { id: 'completed', label: 'Completado' },
18
- ];
19
-
20
- let _dragTaskId = null;
21
-
22
- export async function render() {
23
- const payload = state.getPayload();
24
- if (!payload) return '<div class="empty-state" style="margin:3rem">Sin datos del proyecto.</div>';
25
-
26
- const tasks = _filterTasks(payload.derived.tasks);
27
- const hasCancelled = tasks.some(t => t.status === 'cancelled');
28
- const columns = hasCancelled
29
- ? [...COLUMNS, { id: 'cancelled', label: 'Cancelado' }]
30
- : COLUMNS;
31
-
32
- const html = `
33
- <div class="view-enter">
34
- <div class="section-header">
35
- <div class="section-header-left">
36
- <p class="eyebrow">Tablero</p>
37
- <h2>Tablero Operativo</h2>
38
- </div>
39
- <div style="display:flex;gap:var(--space-2)">
40
- <button class="btn btn-ghost btn-sm" id="board-filter-done" type="button">
41
- ${icon('check', 14)} ${_showCompleted() ? 'Ocultar completadas' : 'Mostrar completadas'}
42
- </button>
43
- <button class="btn btn-primary btn-sm" id="new-task-btn" type="button" aria-label="Crear nueva tarea">
44
- ${icon('plus', 14)} Nueva tarea
45
- </button>
46
- </div>
47
- </div>
48
-
49
- <div class="board-grid" id="board" aria-label="Tablero de tareas por estado" role="region">
50
- ${columns.map(col => {
51
- const colTasks = tasks.filter(t => t.status === col.id);
52
- return _renderColumn(col, colTasks);
53
- }).join('')}
54
- </div>
55
- </div>
56
- `;
57
-
58
- setTimeout(() => _bindEvents(), 0);
59
- return html;
60
- }
61
-
62
- function _filterTasks(tasks) {
63
- let list = tasks;
64
- const query = state.get('searchQuery')?.toLowerCase();
65
- if (query) {
66
- list = list.filter(t =>
67
- t.title.toLowerCase().includes(query) ||
68
- t.id.toLowerCase().includes(query) ||
69
- (t.summary || '').toLowerCase().includes(query)
70
- );
71
- }
72
- if (!_showCompleted()) {
73
- list = list.filter(t => t.status !== 'completed' && t.status !== 'cancelled');
74
- }
75
- return list;
76
- }
77
-
78
- function _showCompleted() {
79
- return sessionStorage.getItem('board-show-completed') === 'true';
80
- }
81
-
82
- function _renderColumn(col, tasks) {
83
- return `
84
- <section class="board-column col-${col.id}" data-status="${col.id}" aria-label="Columna ${col.label}">
85
- <div class="board-column-header">
86
- <h3 class="board-column-title" id="col-${col.id}">
87
- <span class="board-column-dot" aria-hidden="true"></span>
88
- ${esc(col.label)}
89
- </h3>
90
- <span class="board-column-count" aria-label="${tasks.length} tareas">${tasks.length}</span>
91
- </div>
92
- <div class="board-column-body" aria-labelledby="col-${col.id}" role="list">
93
- ${tasks.map(t => _renderCard(t)).join('')}
94
- ${tasks.length === 0
95
- ? `<div class="empty-state" style="padding:var(--space-5);min-height:80px;border-style:dashed">Sin tareas</div>`
96
- : ''}
97
- </div>
98
- </section>
99
- `;
100
- }
101
-
102
- function _renderCard(task) {
103
- const isSelected = task.id === state.get('selectedTaskId');
104
- const statusLabels = state.getStatusLabels();
105
- const phases = state.getPhases();
106
- const phaseInfo = phases.find(p => p.id === task.phase);
107
-
108
- const priorityVariant = { P0: 'danger', P1: 'warning', P2: 'accent', P3: 'muted' };
109
-
110
- return `
111
- <article
112
- class="task-card ${isSelected ? 'is-selected' : ''}"
113
- data-task-id="${esc(task.id)}"
114
- data-status="${esc(task.status)}"
115
- draggable="true"
116
- role="listitem"
117
- tabindex="0"
118
- aria-label="${esc(task.title)}, ${statusLabels[task.status] || task.status}, prioridad ${task.priority}"
119
- aria-selected="${isSelected}"
120
- >
121
- <strong class="task-card-title">${esc(task.title)}</strong>
122
- <span class="task-card-id">${esc(task.id)}</span>
123
- <p class="task-card-summary">${esc(task.summary || 'Sin descripción.')}</p>
124
- <div class="task-card-meta">
125
- <span class="badge badge-${priorityVariant[task.priority] || 'muted'}">${esc(task.priority)}</span>
126
- <span class="badge badge-muted">${esc(phaseInfo?.label || task.phase)}</span>
127
- ${task.stream ? `<span class="badge badge-muted">${esc(task.stream)}</span>` : ''}
128
- ${task.blocker ? `<span class="badge badge-danger" title="${esc(task.blocker)}">${icon('alertTriangle', 10)} Bloqueada</span>` : ''}
129
- </div>
130
- </article>
131
- `;
132
- }
133
-
134
- function _bindEvents() {
135
- const board = document.getElementById('board');
136
- if (!board) return;
137
-
138
- // Clic en task card → seleccionar y navegar a tasks
139
- board.addEventListener('click', e => {
140
- const card = e.target.closest('.task-card');
141
- if (!card) return;
142
-
143
- const id = card.dataset.taskId;
144
- state.update('selectedTaskId', id);
145
-
146
- // Navegar al editor si hubo doble clic; si un clic simple, solo marcar
147
- if (e.detail === 2) {
148
- router.navigate('tasks');
149
- } else {
150
- // Resaltar la card seleccionada sin re-render completo
151
- board.querySelectorAll('.task-card').forEach(c => {
152
- c.classList.toggle('is-selected', c.dataset.taskId === id);
153
- c.setAttribute('aria-selected', c.dataset.taskId === id ? 'true' : 'false');
154
- });
155
- }
156
- });
157
-
158
- // Teclado en cards (Enter = seleccionar, Space = toggle)
159
- board.addEventListener('keydown', e => {
160
- const card = e.target.closest('.task-card');
161
- if (!card) return;
162
- if (e.key === 'Enter') {
163
- state.update('selectedTaskId', card.dataset.taskId);
164
- router.navigate('tasks');
165
- }
166
- });
167
-
168
- // Toggle mostrar completadas
169
- document.getElementById('board-filter-done')?.addEventListener('click', () => {
170
- const current = _showCompleted();
171
- sessionStorage.setItem('board-show-completed', current ? 'false' : 'true');
172
- router.refresh();
173
- });
174
-
175
- // Nueva tarea
176
- document.getElementById('new-task-btn')?.addEventListener('click', () => {
177
- state.update('selectedTaskId', null);
178
- router.navigate('tasks');
179
- });
180
-
181
- // Drag & drop
182
- _bindDragDrop(board);
183
- }
184
-
185
- function _bindDragDrop(board) {
186
- // Dragstart
187
- board.addEventListener('dragstart', e => {
188
- const card = e.target.closest('.task-card');
189
- if (!card) return;
190
- _dragTaskId = card.dataset.taskId;
191
- card.classList.add('is-dragging');
192
- e.dataTransfer.setData('text/plain', _dragTaskId);
193
- e.dataTransfer.effectAllowed = 'move';
194
- });
195
-
196
- board.addEventListener('dragend', e => {
197
- const card = e.target.closest('.task-card');
198
- card?.classList.remove('is-dragging');
199
- _dragTaskId = null;
200
- board.querySelectorAll('.board-column').forEach(col => col.classList.remove('is-drop-target'));
201
- });
202
-
203
- board.addEventListener('dragover', e => {
204
- e.preventDefault();
205
- const col = e.target.closest('.board-column');
206
- if (!col) return;
207
- e.dataTransfer.dropEffect = 'move';
208
- board.querySelectorAll('.board-column').forEach(c => c.classList.remove('is-drop-target'));
209
- col.classList.add('is-drop-target');
210
- });
211
-
212
- board.addEventListener('dragleave', e => {
213
- const col = e.target.closest('.board-column');
214
- if (!col) return;
215
- // Solo eliminar si salimos de la columna, no de un hijo
216
- if (!col.contains(e.relatedTarget)) {
217
- col.classList.remove('is-drop-target');
218
- }
219
- });
220
-
221
- board.addEventListener('drop', async e => {
222
- e.preventDefault();
223
- const col = e.target.closest('.board-column');
224
- if (!col) return;
225
- col.classList.remove('is-drop-target');
226
-
227
- const taskId = e.dataTransfer.getData('text/plain') || _dragTaskId;
228
- if (!taskId) return;
229
-
230
- const newStatus = col.dataset.status;
231
- const task = state.getPayload()?.derived?.tasks?.find(t => t.id === taskId);
232
- if (!task || task.status === newStatus) return;
233
-
234
- const statusToAction = {
235
- pending: 'pending',
236
- in_progress: 'start',
237
- in_review: 'review',
238
- blocked: 'block',
239
- completed: 'complete',
240
- cancelled: 'cancel',
241
- };
242
-
243
- const action = statusToAction[newStatus];
244
- if (!action) return;
245
-
246
- try {
247
- await api.taskAction(taskId, action, `Movida a ${newStatus} desde el tablero.`);
248
- flash(`Tarea movida a ${newStatus}.`, 'success');
249
- window.dispatchEvent(new CustomEvent('ops:refresh'));
250
- } catch (err) {
251
- flash(err.message, 'error');
252
- }
253
- });
254
- }
1
+ /**
2
+ * board.js — Tablero Kanban con drag & drop
3
+ */
4
+
5
+ import { icon } from '../icons.js';
6
+ import * as state from '../state.js';
7
+ import * as api from '../api.js';
8
+ import * as router from '../router.js';
9
+ import { flash } from './flash.js';
10
+ import { esc, debounce } from '../utils.js';
11
+ import { t } from '../i18n.js';
12
+
13
+ const COLUMNS = [
14
+ { id: 'pending', label: 'Pending' },
15
+ { id: 'in_progress', label: 'In Progress' },
16
+ { id: 'in_review', label: 'In Review' },
17
+ { id: 'blocked', label: 'Blocked' },
18
+ { id: 'completed', label: 'Completed' },
19
+ ];
20
+
21
+ let _dragTaskId = null;
22
+
23
+ export async function render() {
24
+ const payload = state.getPayload();
25
+ if (!payload) return `<div class="empty-state" style="margin:3rem">${t('ui.board.noData', {}, 'No project data.')}</div>`;
26
+
27
+ const tasks = _filterTasks(payload.derived.tasks);
28
+ const hasCancelled = tasks.some(t => t.status === 'cancelled');
29
+ const columns = hasCancelled
30
+ ? [...COLUMNS, { id: 'cancelled', label: t('status.cancelled', {}, 'Cancelled') }]
31
+ : COLUMNS.map(col => ({ ...col, label: t(`status.${col.id}`, {}, col.label) }));
32
+
33
+ const html = `
34
+ <div class="view-enter">
35
+ <div class="section-header">
36
+ <div class="section-header-left">
37
+ <p class="eyebrow">${t('ui.board.eyebrow', {}, 'Board')}</p>
38
+ <h2>${t('ui.board.title', {}, 'Operational board')}</h2>
39
+ </div>
40
+ <div style="display:flex;gap:var(--space-2)">
41
+ <button class="btn btn-ghost btn-sm" id="board-filter-done" type="button">
42
+ ${icon('check', 14)} ${_showCompleted() ? t('ui.board.hideCompleted', {}, 'Hide completed') : t('ui.board.showCompleted', {}, 'Show completed')}
43
+ </button>
44
+ <button class="btn btn-primary btn-sm" id="new-task-btn" type="button" aria-label="${t('ui.board.newTask', {}, 'Create new task')}">
45
+ ${icon('plus', 14)} ${t('ui.tasks.new', {}, 'New task')}
46
+ </button>
47
+ </div>
48
+ </div>
49
+
50
+ <div class="board-grid" id="board" aria-label="${t('ui.board.aria', {}, 'Task board by status')}" role="region">
51
+ ${columns.map(col => {
52
+ const colTasks = tasks.filter(t => t.status === col.id);
53
+ return _renderColumn(col, colTasks);
54
+ }).join('')}
55
+ </div>
56
+ </div>
57
+ `;
58
+
59
+ setTimeout(() => _bindEvents(), 0);
60
+ return html;
61
+ }
62
+
63
+ function _filterTasks(tasks) {
64
+ let list = tasks;
65
+ const query = state.get('searchQuery')?.toLowerCase();
66
+ if (query) {
67
+ list = list.filter(t =>
68
+ t.title.toLowerCase().includes(query) ||
69
+ t.id.toLowerCase().includes(query) ||
70
+ (t.summary || '').toLowerCase().includes(query)
71
+ );
72
+ }
73
+ if (!_showCompleted()) {
74
+ list = list.filter(t => t.status !== 'completed' && t.status !== 'cancelled');
75
+ }
76
+ return list;
77
+ }
78
+
79
+ function _showCompleted() {
80
+ return sessionStorage.getItem('board-show-completed') === 'true';
81
+ }
82
+
83
+ function _renderColumn(col, tasks) {
84
+ return `
85
+ <section class="board-column col-${col.id}" data-status="${col.id}" aria-label="${t('ui.board.column', { label: col.label }, `Column ${col.label}`)}">
86
+ <div class="board-column-header">
87
+ <h3 class="board-column-title" id="col-${col.id}">
88
+ <span class="board-column-dot" aria-hidden="true"></span>
89
+ ${esc(col.label)}
90
+ </h3>
91
+ <span class="board-column-count" aria-label="${t('ui.board.tasksCount', { count: tasks.length }, `${tasks.length} tasks`)}">${tasks.length}</span>
92
+ </div>
93
+ <div class="board-column-body" aria-labelledby="col-${col.id}" role="list">
94
+ ${tasks.map(t => _renderCard(t)).join('')}
95
+ ${tasks.length === 0
96
+ ? `<div class="empty-state" style="padding:var(--space-5);min-height:80px;border-style:dashed">${t('ui.board.noTasks', {}, 'No tasks')}</div>`
97
+ : ''}
98
+ </div>
99
+ </section>
100
+ `;
101
+ }
102
+
103
+ function _renderCard(task) {
104
+ const isSelected = task.id === state.get('selectedTaskId');
105
+ const statusLabels = state.getStatusLabels();
106
+ const phases = state.getPhases();
107
+ const phaseInfo = phases.find(p => p.id === task.phase);
108
+
109
+ const priorityVariant = { P0: 'danger', P1: 'warning', P2: 'accent', P3: 'muted' };
110
+
111
+ return `
112
+ <article
113
+ class="task-card ${isSelected ? 'is-selected' : ''}"
114
+ data-task-id="${esc(task.id)}"
115
+ data-status="${esc(task.status)}"
116
+ draggable="true"
117
+ role="listitem"
118
+ tabindex="0"
119
+ aria-label="${esc(task.title)}, ${statusLabels[task.status] || task.status}, ${t('ui.board.priority', { priority: task.priority }, `priority ${task.priority}`)}"
120
+ aria-selected="${isSelected}"
121
+ >
122
+ <strong class="task-card-title">${esc(task.title)}</strong>
123
+ <span class="task-card-id">${esc(task.id)}</span>
124
+ <p class="task-card-summary">${esc(task.summary || t('ui.board.noDescription', {}, 'No description.'))}</p>
125
+ <div class="task-card-meta">
126
+ <span class="badge badge-${priorityVariant[task.priority] || 'muted'}">${esc(task.priority)}</span>
127
+ <span class="badge badge-muted">${esc(phaseInfo?.label || task.phase)}</span>
128
+ ${task.stream ? `<span class="badge badge-muted">${esc(task.stream)}</span>` : ''}
129
+ ${task.blocker ? `<span class="badge badge-danger" title="${esc(task.blocker)}">${icon('alertTriangle', 10)} ${t('status.blocked', {}, 'Blocked')}</span>` : ''}
130
+ </div>
131
+ </article>
132
+ `;
133
+ }
134
+
135
+ function _bindEvents() {
136
+ const board = document.getElementById('board');
137
+ if (!board) return;
138
+
139
+ // Clic en task card → seleccionar y navegar a tasks
140
+ board.addEventListener('click', e => {
141
+ const card = e.target.closest('.task-card');
142
+ if (!card) return;
143
+
144
+ const id = card.dataset.taskId;
145
+ state.update('selectedTaskId', id);
146
+
147
+ // Navegar al editor si hubo doble clic; si un clic simple, solo marcar
148
+ if (e.detail === 2) {
149
+ router.navigate('tasks');
150
+ } else {
151
+ // Resaltar la card seleccionada sin re-render completo
152
+ board.querySelectorAll('.task-card').forEach(c => {
153
+ c.classList.toggle('is-selected', c.dataset.taskId === id);
154
+ c.setAttribute('aria-selected', c.dataset.taskId === id ? 'true' : 'false');
155
+ });
156
+ }
157
+ });
158
+
159
+ // Teclado en cards (Enter = seleccionar, Space = toggle)
160
+ board.addEventListener('keydown', e => {
161
+ const card = e.target.closest('.task-card');
162
+ if (!card) return;
163
+ if (e.key === 'Enter') {
164
+ state.update('selectedTaskId', card.dataset.taskId);
165
+ router.navigate('tasks');
166
+ }
167
+ });
168
+
169
+ // Toggle mostrar completadas
170
+ document.getElementById('board-filter-done')?.addEventListener('click', () => {
171
+ const current = _showCompleted();
172
+ sessionStorage.setItem('board-show-completed', current ? 'false' : 'true');
173
+ router.refresh();
174
+ });
175
+
176
+ // Nueva tarea
177
+ document.getElementById('new-task-btn')?.addEventListener('click', () => {
178
+ state.update('selectedTaskId', null);
179
+ router.navigate('tasks');
180
+ });
181
+
182
+ // Drag & drop
183
+ _bindDragDrop(board);
184
+ }
185
+
186
+ function _bindDragDrop(board) {
187
+ // Dragstart
188
+ board.addEventListener('dragstart', e => {
189
+ const card = e.target.closest('.task-card');
190
+ if (!card) return;
191
+ _dragTaskId = card.dataset.taskId;
192
+ card.classList.add('is-dragging');
193
+ e.dataTransfer.setData('text/plain', _dragTaskId);
194
+ e.dataTransfer.effectAllowed = 'move';
195
+ });
196
+
197
+ board.addEventListener('dragend', e => {
198
+ const card = e.target.closest('.task-card');
199
+ card?.classList.remove('is-dragging');
200
+ _dragTaskId = null;
201
+ board.querySelectorAll('.board-column').forEach(col => col.classList.remove('is-drop-target'));
202
+ });
203
+
204
+ board.addEventListener('dragover', e => {
205
+ e.preventDefault();
206
+ const col = e.target.closest('.board-column');
207
+ if (!col) return;
208
+ e.dataTransfer.dropEffect = 'move';
209
+ board.querySelectorAll('.board-column').forEach(c => c.classList.remove('is-drop-target'));
210
+ col.classList.add('is-drop-target');
211
+ });
212
+
213
+ board.addEventListener('dragleave', e => {
214
+ const col = e.target.closest('.board-column');
215
+ if (!col) return;
216
+ // Solo eliminar si salimos de la columna, no de un hijo
217
+ if (!col.contains(e.relatedTarget)) {
218
+ col.classList.remove('is-drop-target');
219
+ }
220
+ });
221
+
222
+ board.addEventListener('drop', async e => {
223
+ e.preventDefault();
224
+ const col = e.target.closest('.board-column');
225
+ if (!col) return;
226
+ col.classList.remove('is-drop-target');
227
+
228
+ const taskId = e.dataTransfer.getData('text/plain') || _dragTaskId;
229
+ if (!taskId) return;
230
+
231
+ const newStatus = col.dataset.status;
232
+ const task = state.getPayload()?.derived?.tasks?.find(t => t.id === taskId);
233
+ if (!task || task.status === newStatus) return;
234
+
235
+ const statusToAction = {
236
+ pending: 'pending',
237
+ in_progress: 'start',
238
+ in_review: 'review',
239
+ blocked: 'block',
240
+ completed: 'complete',
241
+ cancelled: 'cancel',
242
+ };
243
+
244
+ const action = statusToAction[newStatus];
245
+ if (!action) return;
246
+
247
+ try {
248
+ await api.taskAction(taskId, action, t('ui.board.movedFromBoard', { status: newStatus }, `Moved to ${newStatus} from the board.`));
249
+ flash(t('ui.board.movedSuccess', { status: newStatus }, `Task moved to ${newStatus}.`), 'success');
250
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
251
+ } catch (err) {
252
+ flash(err.message, 'error');
253
+ }
254
+ });
255
+ }