trackops 2.0.5 → 2.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.
@@ -1,29 +1,29 @@
1
- {
2
- "name": "trackops",
3
- "shortDescription": "Global TrackOps skill that explains TrackOps, requires explicit runtime install, and guides per-repository activation.",
4
- "description": "Explains what TrackOps does, installs the global skill layer, requires explicit runtime installation with npm, supports Spanish and English, activates TrackOps repository by repository, and routes OPERA onboarding into either direct bootstrap or agent-led discovery.",
5
- "skillVersion": "2.0.5",
6
- "trackopsVersion": "2.0.5",
7
- "npmPackage": "trackops",
8
- "bootstrapPolicy": "explicit_install",
9
- "supportedAgentsV1": [
10
- "antigravity",
11
- "claude-code",
12
- "codex",
13
- "cursor",
14
- "gemini-cli",
15
- "github-copilot",
16
- "kiro-cli"
17
- ],
18
- "distribution": {
19
- "source": "Baxahaun/trackops",
20
- "skill": "trackops",
21
- "fullDepth": true
22
- },
23
- "repository": {
24
- "provider": "github",
25
- "owner": "Baxahaun",
26
- "repo": "trackops",
27
- "skillPath": "skills/trackops"
28
- }
29
- }
1
+ {
2
+ "name": "trackops",
3
+ "shortDescription": "Global TrackOps skill that explains TrackOps, requires explicit runtime install, and guides per-repository activation.",
4
+ "description": "Explains what TrackOps does, installs the global skill layer, requires explicit runtime installation with npm, supports Spanish and English, activates TrackOps repository by repository, and routes OPERA onboarding into either direct bootstrap or agent-led discovery.",
5
+ "skillVersion": "2.1.0",
6
+ "trackopsVersion": "2.1.0",
7
+ "npmPackage": "trackops",
8
+ "bootstrapPolicy": "explicit_install",
9
+ "supportedAgentsV1": [
10
+ "antigravity",
11
+ "claude-code",
12
+ "codex",
13
+ "cursor",
14
+ "gemini-cli",
15
+ "github-copilot",
16
+ "kiro-cli"
17
+ ],
18
+ "distribution": {
19
+ "source": "Baxahaun/trackops",
20
+ "skill": "trackops",
21
+ "fullDepth": true
22
+ },
23
+ "repository": {
24
+ "provider": "github",
25
+ "owner": "Baxahaun",
26
+ "repo": "trackops",
27
+ "skillPath": "skills/trackops"
28
+ }
29
+ }
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: "opera-quality-guard"
3
+ description: "Guardia local de calidad para proyectos OPERA. Obliga al agente a consultar estado de calidad, ejecutar verificaciones declaradas y comprobar readiness antes de release o recomendacion de despliegue."
4
+ metadata:
5
+ version: "1.0"
6
+ type: "project"
7
+ ---
8
+
9
+ # OPERA Quality Guard
10
+
11
+ ## Mision
12
+
13
+ Complementar OPERA con una capa de calidad continua y readiness de salida.
14
+
15
+ ## Reglas
16
+
17
+ - antes de cerrar una fase, ejecuta `trackops quality status`
18
+ - tras cambios relevantes en codigo, entorno, build o smoke, ejecuta `trackops quality verify`
19
+ - antes de `trackops release`, ejecuta `trackops quality release-readiness`
20
+ - antes de recomendar produccion, ejecuta `trackops quality promote-readiness --target production`
21
+ - si la readiness esta bloqueada, no inventes excepciones: explica el bloqueo real y los pasos para resolverlo
22
+ - solo usa waivers si existe una aprobacion humana explicita y con caducidad
23
+
24
+ ## Prioridad
25
+
26
+ La calidad complementa OPERA; no sustituye backlog, contrato ni politica.
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: "opera-quality-guard"
3
+ description: "Local quality guard for OPERA projects. Forces the agent to inspect quality status, run declared verification, and check readiness before release or deployment recommendations."
4
+ metadata:
5
+ version: "1.0"
6
+ type: "project"
7
+ ---
8
+
9
+ # OPERA Quality Guard
10
+
11
+ ## Mission
12
+
13
+ Complement OPERA with continuous quality and production-readiness checks.
14
+
15
+ ## Rules
16
+
17
+ - before closing a phase, run `trackops quality status`
18
+ - after relevant code, environment, build, or smoke changes, run `trackops quality verify`
19
+ - before `trackops release`, run `trackops quality release-readiness`
20
+ - before recommending production, run `trackops quality promote-readiness --target production`
21
+ - if readiness is blocked, report the real blocker and the concrete remediation steps
22
+ - only use waivers when there is explicit human approval and an expiry
23
+
24
+ ## Priority
25
+
26
+ Quality complements OPERA; it does not replace backlog, contract, or policy.
@@ -51,6 +51,12 @@ Antes de asumir nada, ejecuta:
51
51
  trackops status
52
52
  ```
53
53
 
54
+ Y antes de cerrar una fase, release o recomendacion de despliegue, ejecuta:
55
+
56
+ ```bash
57
+ trackops quality status
58
+ ```
59
+
54
60
  - si `trackops status` falla, no sigas
55
61
  - si no existe TrackOps activo, redirige a la skill global `trackops`
56
62
  - si OPERA no esta instalado, indica `trackops opera install`
@@ -202,6 +208,8 @@ Formalizar release, despliegue y automatizacion operativa con seguridad.
202
208
  - `trackops status`
203
209
  - `trackops workspace status`
204
210
  - `trackops env status`
211
+ - `trackops quality release-readiness`
212
+ - `trackops quality promote-readiness --target production`
205
213
  - `trackops sync`
206
214
 
207
215
  ### Skills delegables
@@ -51,6 +51,12 @@ Before assuming anything, run:
51
51
  trackops status
52
52
  ```
53
53
 
54
+ And before closing a phase, a release, or a deployment recommendation, run:
55
+
56
+ ```bash
57
+ trackops quality status
58
+ ```
59
+
54
60
  - if `trackops status` fails, stop
55
61
  - if there is no active TrackOps, redirect to the global `trackops` skill
56
62
  - if OPERA is not installed, indicate `trackops opera install`
@@ -202,6 +208,8 @@ Formalize release, deployment, and operational automation with safety.
202
208
  - `trackops status`
203
209
  - `trackops workspace status`
204
210
  - `trackops env status`
211
+ - `trackops quality release-readiness`
212
+ - `trackops quality promote-readiness --target production`
205
213
  - `trackops sync`
206
214
 
207
215
  ### Delegable skills
package/ui/js/api.js CHANGED
@@ -142,12 +142,12 @@ export async function createTask(payload) {
142
142
  * @param {string} taskId
143
143
  * @param {Object} payload
144
144
  */
145
- export async function updateTask(taskId, payload) {
146
- return call(`/api/tasks/${encodeURIComponent(taskId)}`, {
147
- method: 'PUT',
148
- body: JSON.stringify({ projectId: state.get('currentProjectId'), ...payload }),
149
- });
150
- }
145
+ export async function updateTask(taskId, payload, meta = {}) {
146
+ return call(`/api/tasks/${encodeURIComponent(taskId)}`, {
147
+ method: 'PUT',
148
+ body: JSON.stringify({ projectId: state.get('currentProjectId'), ...payload, ...meta }),
149
+ });
150
+ }
151
151
 
152
152
  /**
153
153
  * Ejecuta una acción sobre una tarea (start, review, complete, block, pending, cancel)
@@ -155,14 +155,81 @@ export async function updateTask(taskId, payload) {
155
155
  * @param {string} action
156
156
  * @param {string} [note]
157
157
  */
158
- export async function taskAction(taskId, action, note = '') {
159
- return call(`/api/tasks/${encodeURIComponent(taskId)}/action`, {
160
- method: 'POST',
161
- body: JSON.stringify({ projectId: state.get('currentProjectId'), action, note }),
162
- });
163
- }
164
-
165
- // ─────────────────────────────── SYNC ───────────────────────────────────────
158
+ export async function taskAction(taskId, action, note = '', meta = {}) {
159
+ return call(`/api/tasks/${encodeURIComponent(taskId)}/action`, {
160
+ method: 'POST',
161
+ body: JSON.stringify({ projectId: state.get('currentProjectId'), action, note, ...meta }),
162
+ });
163
+ }
164
+
165
+ // ─────────────────────────────── PLANS ──────────────────────────────────────
166
+
167
+ export async function getPlans() {
168
+ return call('/api/plans');
169
+ }
170
+
171
+ export async function getPlan(sourceId) {
172
+ return call(`/api/plans/${encodeURIComponent(sourceId)}`);
173
+ }
174
+
175
+ export async function scanPlans(scanPath = '') {
176
+ return call('/api/plans/scan', {
177
+ method: 'POST',
178
+ body: JSON.stringify({ projectId: state.get('currentProjectId'), path: scanPath || undefined }),
179
+ });
180
+ }
181
+
182
+ export async function importPlan(payload) {
183
+ return call('/api/plans/import', {
184
+ method: 'POST',
185
+ body: JSON.stringify({ projectId: state.get('currentProjectId'), ...payload }),
186
+ });
187
+ }
188
+
189
+ export async function applyPlan(sourceId, payload = {}) {
190
+ return call(`/api/plans/${encodeURIComponent(sourceId)}/apply`, {
191
+ method: 'POST',
192
+ body: JSON.stringify({ projectId: state.get('currentProjectId'), ...payload }),
193
+ });
194
+ }
195
+
196
+ export async function unlinkPlan(sourceId, payload = {}) {
197
+ return call(`/api/plans/${encodeURIComponent(sourceId)}/unlink`, {
198
+ method: 'POST',
199
+ body: JSON.stringify({ projectId: state.get('currentProjectId'), ...payload }),
200
+ });
201
+ }
202
+
203
+ // ─────────────────────────────── QUALITY ────────────────────────────────────
204
+
205
+ export async function getQuality() {
206
+ return call('/api/quality');
207
+ }
208
+
209
+ export async function verifyQuality(payload = {}) {
210
+ return call('/api/quality/verify', {
211
+ method: 'POST',
212
+ body: JSON.stringify({ projectId: state.get('currentProjectId'), ...payload }),
213
+ });
214
+ }
215
+
216
+ export async function getPhaseReadiness(phase = 'current') {
217
+ return call(`/api/quality/phase-readiness?phase=${encodeURIComponent(phase)}`);
218
+ }
219
+
220
+ export async function getReleaseReadiness() {
221
+ return call('/api/quality/release-readiness');
222
+ }
223
+
224
+ export async function getPromotionReadiness(target = 'production') {
225
+ return call(`/api/quality/promotion-readiness?target=${encodeURIComponent(target)}`);
226
+ }
227
+
228
+ export async function getQualityWaivers() {
229
+ return call('/api/quality/waivers');
230
+ }
231
+
232
+ // ─────────────────────────────── SYNC ───────────────────────────────────────
166
233
 
167
234
  /**
168
235
  * Sincroniza los docs del proyecto (task_plan.md, progress.md, findings.md)
@@ -180,12 +247,12 @@ export async function syncDocs() {
180
247
  * Ejecuta un comando en el shell del proyecto
181
248
  * @param {string} command
182
249
  */
183
- export async function runCommand(command) {
184
- return call('/api/commands', {
185
- method: 'POST',
186
- body: JSON.stringify({ projectId: state.get('currentProjectId'), command }),
187
- });
188
- }
250
+ export async function runCommand(command, meta = {}) {
251
+ return call('/api/commands', {
252
+ method: 'POST',
253
+ body: JSON.stringify({ projectId: state.get('currentProjectId'), command, ...meta }),
254
+ });
255
+ }
189
256
 
190
257
  /**
191
258
  * Crea un EventSource para hacer streaming de salida de una sesión
@@ -203,12 +270,12 @@ export function streamSession(sessionId) {
203
270
  * @param {string} taskId
204
271
  * @param {string} taskTitle
205
272
  */
206
- export async function startTimeEntry(taskId, taskTitle) {
207
- return call('/api/time/start', {
208
- method: 'POST',
209
- body: JSON.stringify({ projectId: state.get('currentProjectId'), taskId, taskTitle }),
210
- });
211
- }
273
+ export async function startTimeEntry(taskId, taskTitle, meta = {}) {
274
+ return call('/api/time/start', {
275
+ method: 'POST',
276
+ body: JSON.stringify({ projectId: state.get('currentProjectId'), taskId, taskTitle, ...meta }),
277
+ });
278
+ }
212
279
 
213
280
  /**
214
281
  * Detiene el time entry activo
package/ui/js/app.js CHANGED
@@ -15,8 +15,9 @@ import * as keyboard from './keyboard.js';
15
15
  // Vistas
16
16
  import { render as renderSidebar } from './views/sidebar.js';
17
17
  import { render as renderTopbar } from './views/topbar.js';
18
- import * as dashboardView from './views/dashboard.js';
19
- import * as tasksView from './views/tasks.js';
18
+ import * as dashboardView from './views/dashboard.js';
19
+ import * as plansView from './views/plans.js';
20
+ import * as tasksView from './views/tasks.js';
20
21
  import * as executionView from './views/execution.js';
21
22
  import * as projectsView from './views/projects.js';
22
23
  import { render as renderTimeline } from './views/timeline.js';
@@ -37,11 +38,16 @@ async function init() {
37
38
  setTimeout(() => dashboardView.bindEvents?.(), 50);
38
39
  return html;
39
40
  });
40
- router.register('tasks', async () => {
41
- const html = await tasksView.render();
42
- setTimeout(() => tasksView.bindEvents?.(), 50);
43
- return html;
44
- });
41
+ router.register('tasks', async () => {
42
+ const html = await tasksView.render();
43
+ setTimeout(() => tasksView.bindEvents?.(), 50);
44
+ return html;
45
+ });
46
+ router.register('plans', async () => {
47
+ const html = await plansView.render();
48
+ setTimeout(() => plansView.bindEvents?.(), 50);
49
+ return html;
50
+ });
45
51
  router.register('terminal', async () => {
46
52
  const html = await executionView.render();
47
53
  setTimeout(() => executionView.bindEvents(), 50);
package/ui/js/filters.js CHANGED
@@ -11,7 +11,7 @@ import { t } from './i18n.js';
11
11
  const STORAGE_PREFIX = 'ops-filters-';
12
12
 
13
13
  /** Claves de filtro soportadas */
14
- const FILTER_KEYS = ['status', 'priority', 'phase', 'stream', 'search'];
14
+ const FILTER_KEYS = ['status', 'priority', 'phase', 'stream', 'sourceId', 'rootId', 'search'];
15
15
 
16
16
  // ─────────────────────────────── PERSISTENCE ─────────────────────────────────
17
17
 
@@ -67,15 +67,17 @@ export function apply(tasks, filters) {
67
67
  return tasks.filter(task => {
68
68
  if (filters.status && task.status !== filters.status) return false;
69
69
  if (filters.priority && task.priority !== filters.priority) return false;
70
- if (filters.phase && task.phase !== filters.phase) return false;
71
- if (filters.stream && task.stream !== filters.stream) return false;
72
- if (filters.search) {
73
- const q = filters.search.toLowerCase();
74
- const haystack = `${task.title} ${task.id} ${task.summary || ''} ${task.stream || ''}`.toLowerCase();
75
- if (!haystack.includes(q)) return false;
76
- }
77
- return true;
78
- });
70
+ if (filters.phase && task.phase !== filters.phase) return false;
71
+ if (filters.stream && task.stream !== filters.stream) return false;
72
+ if (filters.sourceId && (task.sourceId || task.origin?.sourceId || '') !== filters.sourceId) return false;
73
+ if (filters.rootId && (task.rootId || task.id) !== filters.rootId) return false;
74
+ if (filters.search) {
75
+ const q = filters.search.toLowerCase();
76
+ const haystack = `${task.title} ${task.id} ${task.summary || ''} ${task.stream || ''} ${task.sourceId || task.origin?.sourceId || ''} ${task.rootTitle || ''}`.toLowerCase();
77
+ if (!haystack.includes(q)) return false;
78
+ }
79
+ return true;
80
+ });
79
81
  }
80
82
 
81
83
  // ─────────────────────────────── CONTEO ──────────────────────────────────────
@@ -99,16 +101,20 @@ export function count(filters) {
99
101
  * @param {Object} options — Opciones de renderizado
100
102
  * @param {Array} [options.statuses] — Estados disponibles
101
103
  * @param {Array} [options.priorities] — Prioridades disponibles
102
- * @param {Array} [options.phases] — Fases disponibles
103
- * @param {Array} [options.streams] — Streams disponibles
104
- * @returns {string} HTML
105
- */
106
- export function renderBar(viewId, filters, options = {}) {
107
- const statusLabels = state.getStatusLabels?.() || {};
108
- const phases = options.phases || state.getPhases?.() || [];
109
- const statuses = options.statuses || ['pending', 'in_progress', 'in_review', 'blocked', 'completed', 'cancelled'];
110
- const priorities = options.priorities || ['P0', 'P1', 'P2', 'P3'];
111
- const streams = options.streams || [];
104
+ * @param {Array} [options.phases] — Fases disponibles
105
+ * @param {Array} [options.streams] — Streams disponibles
106
+ * @param {Array} [options.sources] — Source ids disponibles
107
+ * @param {Array} [options.roots] — Root tasks disponibles
108
+ * @returns {string} HTML
109
+ */
110
+ export function renderBar(viewId, filters, options = {}) {
111
+ const statusLabels = state.getStatusLabels?.() || {};
112
+ const phases = options.phases || state.getPhases?.() || [];
113
+ const statuses = options.statuses || ['pending', 'in_progress', 'in_review', 'blocked', 'completed', 'cancelled'];
114
+ const priorities = options.priorities || ['P0', 'P1', 'P2', 'P3'];
115
+ const streams = options.streams || [];
116
+ const sources = options.sources || [];
117
+ const roots = options.roots || [];
112
118
 
113
119
  return `
114
120
  <div class="filter-bar" role="search" aria-label="${t('ui.filters.label', {}, 'Filter tasks')}">
@@ -125,9 +131,17 @@ export function renderBar(viewId, filters, options = {}) {
125
131
  value: p.id, label: `${p.id} · ${p.label}`
126
132
  })), t('ui.filters.phase', {}, 'Phase')) : ''}
127
133
 
128
- ${streams.length ? _renderSelect('stream', filters.stream, streams.map(s => ({
129
- value: s, label: s
130
- })), t('ui.filters.stream', {}, 'Stream')) : ''}
134
+ ${streams.length ? _renderSelect('stream', filters.stream, streams.map(s => ({
135
+ value: s, label: s
136
+ })), t('ui.filters.stream', {}, 'Stream')) : ''}
137
+
138
+ ${sources.length ? _renderSelect('sourceId', filters.sourceId, sources.map(source => ({
139
+ value: source, label: source
140
+ })), t('ui.filters.source', {}, 'Source')) : ''}
141
+
142
+ ${roots.length ? _renderSelect('rootId', filters.rootId, roots.map(root => ({
143
+ value: root.id, label: root.title
144
+ })), t('ui.filters.root', {}, 'Root')) : ''}
131
145
 
132
146
  <div class="filter-search">
133
147
  <input
@@ -177,12 +191,18 @@ function _renderActiveChips(filters, statusLabels, phases) {
177
191
  const phase = phases.find(p => p.id === filters.phase);
178
192
  chips.push({ key: 'phase', label: phase ? `${phase.id} · ${phase.label}` : filters.phase });
179
193
  }
180
- if (filters.stream) {
181
- chips.push({ key: 'stream', label: filters.stream });
182
- }
183
- if (filters.search) {
184
- chips.push({ key: 'search', label: `"${filters.search}"` });
185
- }
194
+ if (filters.stream) {
195
+ chips.push({ key: 'stream', label: filters.stream });
196
+ }
197
+ if (filters.sourceId) {
198
+ chips.push({ key: 'sourceId', label: filters.sourceId });
199
+ }
200
+ if (filters.rootId) {
201
+ chips.push({ key: 'rootId', label: filters.rootId });
202
+ }
203
+ if (filters.search) {
204
+ chips.push({ key: 'search', label: `"${filters.search}"` });
205
+ }
186
206
 
187
207
  return chips.map(c => `
188
208
  <span class="chip chip-active" data-filter-remove="${c.key}">
@@ -18,32 +18,40 @@ let _startMs = null;
18
18
  * @param {string} taskId
19
19
  * @param {string} taskTitle
20
20
  */
21
- export async function start(taskId, taskTitle) {
22
- // Si hay uno en curso, detenerlo primero
23
- if (state.get('activeEntry')) {
24
- await stop();
25
- }
21
+ export async function start(taskId, taskTitle) {
22
+ // Si hay uno en curso, detenerlo primero
23
+ if (state.get('activeEntry')) {
24
+ await stop();
25
+ }
26
26
 
27
- try {
28
- const result = await api.startTimeEntry(taskId, taskTitle);
27
+ try {
28
+ const result = await api.startTimeEntry(taskId, taskTitle, {
29
+ actor: 'user',
30
+ source: 'time_tracker',
31
+ });
29
32
  const entry = {
30
33
  id: result.entry?.id || `local-${Date.now()}`,
31
34
  taskId,
32
35
  taskTitle,
33
36
  startedAt: result.entry?.startedAt || new Date().toISOString(),
34
37
  };
35
- state.update('activeEntry', entry);
36
- _startMs = Date.now() - (result.entry?.elapsedMs || 0);
37
- _startInterval();
38
- _updateTopbarTimer();
39
- flash(`Timer iniciado: ${taskTitle}`, 'success');
40
- } catch (err) {
41
- // Fallback local si el backend no tiene el endpoint todavía
42
- const entry = {
43
- id: `local-${Date.now()}`,
44
- taskId,
45
- taskTitle,
46
- startedAt: new Date().toISOString(),
38
+ state.update('activeEntry', entry);
39
+ _startMs = Date.now() - (result.entry?.elapsedMs || 0);
40
+ _startInterval();
41
+ _updateTopbarTimer();
42
+ flash(`Timer iniciado: ${taskTitle}`, 'success');
43
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
44
+ } catch (err) {
45
+ if (!_shouldUseLocalFallback(err)) {
46
+ flash(err.message || 'No se pudo iniciar el timer.', 'error');
47
+ return;
48
+ }
49
+ // Fallback local si el backend no tiene el endpoint todavía
50
+ const entry = {
51
+ id: `local-${Date.now()}`,
52
+ taskId,
53
+ taskTitle,
54
+ startedAt: new Date().toISOString(),
47
55
  };
48
56
  state.update('activeEntry', entry);
49
57
  _startMs = Date.now();
@@ -72,9 +80,9 @@ export function resume() {
72
80
  /**
73
81
  * Detiene el timer y persiste en el backend
74
82
  */
75
- export async function stop() {
76
- const entry = state.get('activeEntry');
77
- if (!entry) return;
83
+ export async function stop() {
84
+ const entry = state.get('activeEntry');
85
+ if (!entry) return;
78
86
 
79
87
  _stopInterval();
80
88
  const elapsed = _startMs ? Date.now() - _startMs : 0;
@@ -99,10 +107,11 @@ export async function stop() {
99
107
  state.update('timeEntries', timeEntries.slice(0, 50));
100
108
  }
101
109
 
102
- state.update('activeEntry', null);
103
- _startMs = null;
104
- _updateTopbarTimer();
105
- }
110
+ state.update('activeEntry', null);
111
+ _startMs = null;
112
+ _updateTopbarTimer();
113
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
114
+ }
106
115
 
107
116
  /**
108
117
  * Obtiene el tiempo transcurrido actual en ms
@@ -144,13 +153,17 @@ function _startInterval() {
144
153
  }, 1000);
145
154
  }
146
155
 
147
- function _stopInterval() {
156
+ function _stopInterval() {
148
157
  if (_interval) {
149
158
  clearInterval(_interval);
150
159
  _interval = null;
151
160
  }
152
161
  state.update('timerInterval', null);
153
- }
162
+ }
163
+
164
+ function _shouldUseLocalFallback(err) {
165
+ return err?.status === 404 || err?.status === 405 || err?.status === 501;
166
+ }
154
167
 
155
168
  function _updateTimerDisplays() {
156
169
  const elapsed = getElapsed();
@@ -145,15 +145,20 @@ function _renderCard(task, phases) {
145
145
  <strong class="task-card-title">${esc(task.title)}</strong>
146
146
  <span class="task-card-id">${esc(task.id)}</span>
147
147
  <p class="task-card-summary">${esc(task.summary || t('ui.board.noDescription', {}, 'No description.'))}</p>
148
- <div class="task-card-meta">
149
- <span class="badge badge-${priorityVariant[task.priority] || 'muted'}">${esc(task.priority)}</span>
150
- <span class="badge badge-muted">${esc(phaseInfo?.label || task.phase)}</span>
151
- ${task.stream ? `<span class="badge badge-muted">${esc(task.stream)}</span>` : ''}
152
- ${task.blocker ? `<span class="badge badge-danger" title="${esc(task.blocker)}">${icon('alertTriangle', 10)} ${t('status.blocked', {}, 'Blocked')}</span>` : ''}
153
- </div>
154
- </article>
155
- `;
156
- }
148
+ <div class="task-card-meta">
149
+ <span class="badge badge-${priorityVariant[task.priority] || 'muted'}">${esc(task.priority)}</span>
150
+ <span class="badge badge-muted">${esc(phaseInfo?.label || task.phase)}</span>
151
+ ${task.stream ? `<span class="badge badge-muted">${esc(task.stream)}</span>` : ''}
152
+ ${task.executionOwner ? `<span class="badge badge-muted">${esc(`exec:${task.executionOwner}`)}</span>` : ''}
153
+ ${task.sourceId ? `<span class="badge badge-muted">${esc(`plan:${task.sourceId}`)}</span>` : ''}
154
+ ${task.rootId && task.rootId !== task.id ? `<span class="badge badge-muted">${esc(`root:${task.rootTitle}`)}</span>` : ''}
155
+ ${task.awaitingUserConfirmation ? `<span class="badge badge-warning">${t('ui.tasks.awaitingUser', {}, 'Awaiting user')}</span>` : ''}
156
+ ${task.verificationPending ? `<span class="badge badge-accent">${t('ui.tasks.verifyPending', {}, 'Verify state')}</span>` : ''}
157
+ ${task.blocker ? `<span class="badge badge-danger" title="${esc(task.blocker)}">${icon('alertTriangle', 10)} ${t('status.blocked', {}, 'Blocked')}</span>` : ''}
158
+ </div>
159
+ </article>
160
+ `;
161
+ }
157
162
 
158
163
  function _bindDragDrop(board) {
159
164
  board.addEventListener('dragstart', e => {
@@ -214,11 +219,14 @@ function _bindDragDrop(board) {
214
219
  const action = statusToAction[newStatus];
215
220
  if (!action) return;
216
221
 
217
- try {
218
- await api.taskAction(taskId, action, t('ui.board.movedFromBoard', { status: newStatus }, `Moved to ${newStatus} from the board.`));
219
- flash(t('ui.board.movedSuccess', { status: newStatus }, `Task moved to ${newStatus}.`), 'success');
220
- window.dispatchEvent(new CustomEvent('ops:refresh'));
221
- } catch (err) {
222
+ try {
223
+ await api.taskAction(taskId, action, t('ui.board.movedFromBoard', { status: newStatus }, `Moved to ${newStatus} from the board.`), {
224
+ actor: 'user',
225
+ source: 'board',
226
+ });
227
+ flash(t('ui.board.movedSuccess', { status: newStatus }, `Task moved to ${newStatus}.`), 'success');
228
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
229
+ } catch (err) {
222
230
  flash(err.message, 'error');
223
231
  }
224
232
  });