trackops 1.0.1 → 1.1.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 (57) hide show
  1. package/README.md +326 -270
  2. package/bin/trackops.js +102 -70
  3. package/lib/config.js +260 -35
  4. package/lib/control.js +517 -475
  5. package/lib/env.js +227 -0
  6. package/lib/i18n.js +61 -53
  7. package/lib/init.js +135 -46
  8. package/lib/locale.js +63 -0
  9. package/lib/opera-bootstrap.js +523 -0
  10. package/lib/opera.js +319 -170
  11. package/lib/registry.js +27 -13
  12. package/lib/release.js +56 -0
  13. package/lib/resources.js +42 -0
  14. package/lib/server.js +907 -554
  15. package/lib/skills.js +148 -124
  16. package/lib/workspace.js +260 -0
  17. package/locales/en.json +331 -139
  18. package/locales/es.json +331 -139
  19. package/package.json +7 -9
  20. package/scripts/skills-marketplace-smoke.js +124 -0
  21. package/scripts/smoke-tests.js +445 -0
  22. package/scripts/sync-skill-version.js +21 -0
  23. package/scripts/validate-skill.js +88 -0
  24. package/skills/trackops/SKILL.md +64 -0
  25. package/skills/trackops/agents/openai.yaml +3 -0
  26. package/skills/trackops/references/activation.md +39 -0
  27. package/skills/trackops/references/troubleshooting.md +34 -0
  28. package/skills/trackops/references/workflow.md +20 -0
  29. package/skills/trackops/scripts/bootstrap-trackops.js +201 -0
  30. package/skills/trackops/skill.json +29 -0
  31. package/templates/opera/en/agent.md +26 -0
  32. package/templates/opera/en/genesis.md +79 -0
  33. package/templates/opera/en/references/autonomy-and-recovery.md +23 -0
  34. package/templates/opera/en/references/opera-cycle.md +62 -0
  35. package/templates/opera/en/registry.md +28 -0
  36. package/templates/opera/en/router.md +39 -0
  37. package/templates/opera/genesis.md +79 -94
  38. package/templates/skills/changelog-updater/locales/en/SKILL.md +11 -0
  39. package/templates/skills/commiter/locales/en/SKILL.md +11 -0
  40. package/templates/skills/project-starter-skill/locales/en/SKILL.md +24 -0
  41. package/ui/css/panels.css +956 -953
  42. package/ui/index.html +1 -1
  43. package/ui/js/api.js +211 -194
  44. package/ui/js/app.js +200 -199
  45. package/ui/js/i18n.js +14 -0
  46. package/ui/js/onboarding.js +439 -437
  47. package/ui/js/state.js +130 -129
  48. package/ui/js/utils.js +175 -172
  49. package/ui/js/views/board.js +255 -254
  50. package/ui/js/views/execution.js +256 -256
  51. package/ui/js/views/insights.js +340 -339
  52. package/ui/js/views/overview.js +365 -364
  53. package/ui/js/views/settings.js +340 -202
  54. package/ui/js/views/sidebar.js +131 -132
  55. package/ui/js/views/skills.js +163 -162
  56. package/ui/js/views/tasks.js +406 -405
  57. package/ui/js/views/topbar.js +239 -183
@@ -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
+ }