nexus-prime 7.9.29 → 7.9.30

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.
@@ -134,7 +134,7 @@
134
134
  .kanban-meta { font-size: 0.75rem; color: var(--text-dim); margin-bottom: 8px; padding: 0 4px; }
135
135
  .kanban-scroll { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 8px; }
136
136
  .kanban-lane {
137
- min-width: 160px; max-width: 200px; flex-shrink: 0;
137
+ min-width: 220px; max-width: 280px; flex-shrink: 0;
138
138
  background: var(--bg-panel); border: 1px solid var(--border);
139
139
  border-radius: var(--radius); display: flex; flex-direction: column;
140
140
  }
@@ -170,6 +170,56 @@
170
170
  }
171
171
  .kc-id { font-size: 0.65rem; color: var(--text-dim); font-family: var(--font-mono); }
172
172
  .kc-title { font-size: 0.74rem; color: var(--text-main); line-height: 1.35; word-break: break-word; }
173
+ .kc-goal {
174
+ margin-top: 5px;
175
+ color: var(--text-main);
176
+ font-size: 0.72rem;
177
+ line-height: 1.35;
178
+ word-break: break-word;
179
+ }
180
+ .kc-flow {
181
+ margin-top: 7px;
182
+ display: grid;
183
+ gap: 5px;
184
+ }
185
+ .kc-flow div {
186
+ border-left: 2px solid rgba(255,255,255,0.08);
187
+ padding-left: 7px;
188
+ }
189
+ .kc-flow span {
190
+ display: block;
191
+ margin-bottom: 1px;
192
+ color: var(--text-dim);
193
+ font-size: 0.58rem;
194
+ font-family: var(--font-mono);
195
+ text-transform: uppercase;
196
+ }
197
+ .kc-flow p {
198
+ margin: 0;
199
+ color: var(--text-muted);
200
+ font-size: 0.66rem;
201
+ line-height: 1.3;
202
+ word-break: break-word;
203
+ }
204
+ .kc-meta {
205
+ display: flex;
206
+ flex-wrap: wrap;
207
+ gap: 4px;
208
+ margin-top: 7px;
209
+ }
210
+ .kc-meta span {
211
+ max-width: 100%;
212
+ overflow: hidden;
213
+ text-overflow: ellipsis;
214
+ white-space: nowrap;
215
+ color: var(--text-dim);
216
+ background: var(--bg-panel);
217
+ border: 1px solid rgba(255,255,255,0.06);
218
+ border-radius: 3px;
219
+ padding: 1px 5px;
220
+ font-size: 0.6rem;
221
+ font-family: var(--font-mono);
222
+ }
173
223
  .kc-worker { font-size: 0.65rem; color: var(--text-dim); font-family: var(--font-mono); margin-top: 3px; display: block; }
174
224
  .kanban-overflow { font-size: 0.7rem; color: var(--text-dim); text-align: center; padding: 4px; }
175
225
  .kanban-empty { color: var(--text-dim); font-size: 0.8rem; padding: 20px; text-align: center; }
@@ -524,16 +524,19 @@ function renderFirstRunHero() {
524
524
  }
525
525
  btn.disabled = true; btn.textContent = 'Hiring…';
526
526
  setFirstRunStatus('Submitting hire request…');
527
+ const input = card.querySelector('#frh-goal-input');
528
+ const goal = (input?.value || `Use ${btn.dataset.specname || 'this specialist'} to inspect ${S.workspace?.repoName || 'this repo'} and report the next useful task`).trim();
527
529
  const result = await post('/api/synapse/hire', {
528
530
  specialistId: btn.dataset.specid,
529
531
  name: btn.dataset.specname,
530
532
  budgetCapUsd: 2,
531
533
  fireFirstSortie: true,
534
+ goal,
532
535
  });
533
536
  if (result.ok) {
534
537
  setFirstRunStatus(readiness.notes.some(note => note.tone === 'warn')
535
- ? 'Hired successfully. Fallback storage warning is still active.'
536
- : 'Hired successfully.');
538
+ ? 'Hired and first goal queued. Fallback storage warning is still active.'
539
+ : 'Hired and first goal queued.');
537
540
  try { localStorage.setItem(FIRST_RUN_KEY, '1'); } catch { /* ignore */ }
538
541
  bustCache('/api/synapse/health');
539
542
  setTimeout(load, 800);
@@ -26,6 +26,25 @@ function statusChip(s) {
26
26
  if (['failed','error','blocked','zombie','dead'].includes(v)) return chipHtml(s,'bad');
27
27
  return chipHtml(s);
28
28
  }
29
+ function firstText(...values) {
30
+ for (const value of values) {
31
+ if (typeof value === 'string' && value.trim()) return value.trim();
32
+ }
33
+ return '';
34
+ }
35
+ function clipText(value, max=140) {
36
+ const text = String(value ?? '').trim();
37
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
38
+ }
39
+ function lifecycleLabel(card, payload) {
40
+ const status = String(card?.status ?? payload?.status ?? '').toLowerCase();
41
+ if (status === 'done') return 'Completed';
42
+ if (status === 'failed') return 'Failed';
43
+ if (status === 'blocked') return 'Blocked';
44
+ if (status === 'review') return 'In review';
45
+ if (status === 'wip' || status === 'claimed') return 'Running';
46
+ return 'Queued';
47
+ }
29
48
 
30
49
  /* ── Active-dispatch state (push-mode) ── */
31
50
  // Keyed by runId. Entries are created on dispatch.started and removed on complete/failed/cancelled.
@@ -202,7 +221,7 @@ function renderKanban() {
202
221
  if (total === 0) {
203
222
  el.innerHTML = `<div class="kanban-empty kanban-empty-panel">
204
223
  <div class="kanban-empty-title">No workforce jobs yet</div>
205
- <div class="kanban-empty-sub">Run a goal or hire a specialist; the board will populate when work is actually dispatched.</div>
224
+ <div class="kanban-empty-sub">Run a dashboard goal or hire a specialist with a first goal; this board only shows real assigned work.</div>
206
225
  </div>`;
207
226
  return;
208
227
  }
@@ -214,7 +233,7 @@ function renderKanban() {
214
233
  const MAX_VISIBLE = 8;
215
234
  const visible = cards.slice(0, MAX_VISIBLE);
216
235
  const overflow = cards.length - MAX_VISIBLE;
217
- const cardsHtml = visible.map(c => _buildKanbanCard(c)).join('');
236
+ const cardsHtml = visible.map(c => _buildKanbanCard(c, lane)).join('');
218
237
  const overflowHtml = overflow > 0
219
238
  ? `<div class="kanban-overflow">+${overflow} more</div>`
220
239
  : '';
@@ -231,14 +250,31 @@ function renderKanban() {
231
250
  <div class="kanban-scroll">${lanesHtml || '<div class="kanban-empty">No visible lanes for the selected filters.</div>'}</div>`;
232
251
  }
233
252
 
234
- function _buildKanbanCard(c) {
253
+ function _buildKanbanCard(c, lane) {
254
+ const payload = c.payload && typeof c.payload === 'object' ? c.payload : {};
235
255
  const shortId = String(c.id ?? '').slice(0, 8);
236
256
  const title = esc(String(c.title ?? shortId).slice(0, 60));
257
+ const goal = clipText(firstText(payload.goal, c.title), 120);
258
+ const expected = clipText(firstText(payload.expectedBehavior, 'Worker owns this dashboard job and closes it with proof.'), 132);
259
+ const actual = clipText(firstText(payload.actualStatus, lifecycleLabel({ status: lane }, payload)), 132);
260
+ const runId = firstText(payload.runId, payload.completedRunId);
261
+ const mode = firstText(payload.mode, c.client);
237
262
  const wid = c.workerId ? `<span class="kc-worker">→ ${esc(String(c.workerId).slice(0, 8))}</span>` : '';
238
263
  const pri = c.priority != null ? `<span class="kc-pri">${c.priority}</span>` : '';
239
- return `<div class="kanban-card" title="${title}">
264
+ const meta = [
265
+ mode ? `<span>${esc(mode)}</span>` : '',
266
+ runId ? `<span>${esc(String(runId).slice(0, 12))}</span>` : '',
267
+ c.tokensUsed ? `<span>${fmtNum(c.tokensUsed)}t</span>` : '',
268
+ ].filter(Boolean).join('');
269
+ return `<div class="kanban-card" title="${esc([title, goal, actual].filter(Boolean).join(' · '))}">
240
270
  <div class="kc-top">${pri}<span class="kc-id">${shortId}</span></div>
241
271
  <div class="kc-title">${title}</div>
272
+ ${goal ? `<div class="kc-goal">${esc(goal)}</div>` : ''}
273
+ <div class="kc-flow">
274
+ <div><span>Expected</span><p>${esc(expected)}</p></div>
275
+ <div><span>Actual</span><p>${esc(actual)}</p></div>
276
+ </div>
277
+ ${meta ? `<div class="kc-meta">${meta}</div>` : ''}
242
278
  ${wid}
243
279
  </div>`;
244
280
  }
@@ -470,6 +506,7 @@ function _formatHireFailure(result) {
470
506
  function _showHireSheet(specialistId, name) {
471
507
  const { opOptions, teamOptions } = _buildHireSelectors();
472
508
  const readiness = _getHireReadiness();
509
+ const defaultGoal = `Use ${name} to inspect ${S.workspace?.repoName || 'this repo'} and identify the next useful task.`;
473
510
  const noticesHtml = readiness.notes.length
474
511
  ? `<div class="dsec" style="margin-bottom:var(--space-4)">
475
512
  ${readiness.notes.map(note => `<div style="padding:8px 10px;border:1px solid ${note.tone === 'bad' ? '#ff5f5733' : '#ffd14d33'};background:${note.tone === 'bad' ? '#ff5f5712' : '#ffd14d12'};color:${note.tone === 'bad' ? 'var(--bad)' : '#ffd14d'};border-radius:8px;font-size:var(--text-sm);margin-bottom:8px">${esc(note.text)}</div>`).join('')}
@@ -485,7 +522,10 @@ function _showHireSheet(specialistId, name) {
485
522
  <label style="display:block;margin-bottom:var(--space-2);font-size:var(--text-sm);color:var(--text-muted)">Budget cap (USD)</label>
486
523
  <input type="number" id="hire-budget" value="2.00" step="0.50" min="0.50" max="50"
487
524
  style="width:100%;background:var(--bg-panel);border:1px solid var(--border);border-radius:var(--radius);padding:6px 10px;font-size:var(--text-sm);color:var(--text-main);margin-bottom:var(--space-4)">
488
- <button class="btn btn-primary" id="hire-confirm-btn" data-specid="${esc(specialistId)}" data-specname="${esc(name)}" ${readiness.blocked ? 'disabled title="Synapse is not ready"' : ''}>Confirm Hire</button>
525
+ <label style="display:block;margin-bottom:var(--space-2);font-size:var(--text-sm);color:var(--text-muted)">First goal</label>
526
+ <textarea id="hire-goal" rows="3"
527
+ style="width:100%;background:var(--bg-panel);border:1px solid var(--border);border-radius:var(--radius);padding:8px 10px;font-size:var(--text-sm);color:var(--text-main);margin-bottom:var(--space-4);resize:vertical">${esc(defaultGoal)}</textarea>
528
+ <button class="btn btn-primary" id="hire-confirm-btn" data-specid="${esc(specialistId)}" data-specname="${esc(name)}" ${readiness.blocked ? 'disabled title="Synapse is not ready"' : ''}>Hire and Run Goal</button>
489
529
  <div id="hire-inline-status" style="margin-top:var(--space-3);font-size:var(--text-sm);color:${readiness.blocked ? 'var(--bad)' : 'var(--text-muted)'}">${readiness.blocked ? esc(readiness.notes[0]?.text || 'Synapse is not ready for hires.') : ''}</div>
490
530
  </div>` });
491
531
 
@@ -500,6 +540,7 @@ function _showHireSheet(specialistId, name) {
500
540
  const budget = parseFloat(document.getElementById('hire-budget')?.value||'2');
501
541
  const reportsToVal = (document.getElementById('hire-reports-to')?.value||'').trim()||null;
502
542
  const teamVal = (document.getElementById('hire-team')?.value||'').trim()||null;
543
+ const goal = (document.getElementById('hire-goal')?.value || defaultGoal).trim() || defaultGoal;
503
544
  const inlineStatus = document.getElementById('hire-inline-status');
504
545
  if (inlineStatus) {
505
546
  inlineStatus.textContent = readiness.notes.some(note => note.tone === 'warn')
@@ -518,6 +559,7 @@ function _showHireSheet(specialistId, name) {
518
559
  reportsToOperativeId: reportsToVal,
519
560
  strikeTeamId: teamVal,
520
561
  fireFirstSortie: true,
562
+ goal,
521
563
  }, { optimistic: optimisticRecord });
522
564
 
523
565
  // Immediately open drawer with pending state.
@@ -529,6 +571,7 @@ function _showHireSheet(specialistId, name) {
529
571
  <div class="drow"><span class="drow-k">Reports to</span><span class="drow-v">${esc(reportsToVal || 'team lead')}</span></div>
530
572
  <div class="drow"><span class="drow-k">Strike team</span><span class="drow-v">${esc(teamVal || 'solo')}</span></div>
531
573
  <div class="drow"><span class="drow-k">Budget cap</span><span class="drow-v">$${budget.toFixed(2)}</span></div>
574
+ <div class="drow"><span class="drow-k">Goal</span><span class="drow-v">${esc(clipText(goal, 120))}</span></div>
532
575
  </div>` });
533
576
 
534
577
  // Reconcile when real response arrives.
@@ -537,8 +580,8 @@ function _showHireSheet(specialistId, name) {
537
580
  if (real.ok) {
538
581
  if (msg) {
539
582
  msg.textContent = readiness.notes.some(note => note.tone === 'warn')
540
- ? 'Hired successfully. Fallback storage warning is still active.'
541
- : 'Hired successfully.';
583
+ ? 'Hired and first goal queued. Fallback storage warning is still active.'
584
+ : 'Hired and first goal queued.';
542
585
  }
543
586
  const idRow = document.getElementById('hire-id-row');
544
587
  const idVal = document.getElementById('hire-id-val');
@@ -564,7 +607,7 @@ function _showHireSheet(specialistId, name) {
564
607
  drawerBody.appendChild(stripDiv);
565
608
  }
566
609
  const warmupKey = `__warmup__:${operativeId}`;
567
- const warmupRun = { runId: warmupKey, operativeId, status: 'queued', tokens: 0, costUsd: 0, messages: ['Adapter pending; waiting for dispatch.started…'], filesChanged: [], pendingAdapter: true };
610
+ const warmupRun = { runId: warmupKey, operativeId, status: 'queued', tokens: 0, costUsd: 0, messages: [`Goal queued: ${goal}`, 'Adapter pending; waiting for dispatch.started…'], filesChanged: [], pendingAdapter: true };
568
611
  _dispatches.set(warmupKey, warmupRun);
569
612
  stripDiv.innerHTML = _buildDispatchStrip(warmupRun);
570
613
  }
@@ -78,6 +78,9 @@ export const handleGovernanceRoutes = async (ctx, req, res, url) => {
78
78
  const specialistId = typeof body.specialistId === 'string'
79
79
  ? body.specialistId
80
80
  : (typeof body.specialist === 'string' ? body.specialist : undefined);
81
+ const requestedGoal = typeof body.goal === 'string' && body.goal.trim()
82
+ ? body.goal.trim()
83
+ : null;
81
84
  if (!name && !specialistId) {
82
85
  ctx.respondJson(res, { error: 'name-or-specialistId-required' }, 400);
83
86
  return true;
@@ -117,13 +120,23 @@ export const handleGovernanceRoutes = async (ctx, req, res, url) => {
117
120
  // Non-blocking: the dashboard gets an immediate hire response while the
118
121
  // Workforce job and run lifecycle continue through SSE and kanban state.
119
122
  if (body.fireFirstSortie === true && operativeId) {
120
- firstDispatch = { queued: true, operativeId, mode: 'pending' };
123
+ const displayName = result?.name ?? specialistProfile?.name ?? name ?? specialistId ?? operativeId;
124
+ const repoName = path.basename(ctx.repoRoot ?? process.cwd());
125
+ const firstGoal = requestedGoal
126
+ ?? `Use ${displayName} to inspect ${repoName}, report what is working, and name the next useful task.`;
127
+ firstDispatch = {
128
+ queued: true,
129
+ operativeId,
130
+ mode: 'pending',
131
+ goal: firstGoal,
132
+ expectedBehavior: 'Hire creates an operative, assigns this goal as a Workforce job, and moves the job through dispatch or orchestrator fallback.',
133
+ };
121
134
  runDashboardAgentControl(ctx, new URL(`${baseUrl}/api/workforce/agents/${encodeURIComponent(operativeId)}/dispatch`), {
122
135
  operativeId,
123
136
  specialistId: result?.specialistId ?? specialistId ?? null,
124
137
  budgetCapUsd: result?.budgetCapUsd ?? 0.50,
125
- title: `First sortie: ${result?.name ?? specialistProfile?.name ?? operativeId}`,
126
- goal: 'Introduce yourself to the codebase. Read the README and two source files, then report the repo shape and next useful task.',
138
+ title: requestedGoal ? `Dashboard goal: ${firstGoal.slice(0, 64)}` : `First sortie: ${displayName}`,
139
+ goal: firstGoal,
127
140
  }).then(r => {
128
141
  nexusEventBus.emit('dashboard.action', {
129
142
  action: 'synapse.first-sortie.dispatched',
@@ -77,6 +77,11 @@ export async function runDashboardAgentControl(ctx, url, input) {
77
77
  operativeId,
78
78
  specialistId: input.specialistId ?? worker.role,
79
79
  source: 'dashboard-agent-control',
80
+ expectedBehavior: 'A real Workforce job is owned by this operative, routed through dispatch when available, then moved to done or failed with proof.',
81
+ actualStatus: 'Job enqueued and claimed; waiting for dispatch adapter.',
82
+ lifecycle: ['worker-materialized', 'job-enqueued', 'claim-acquired'],
83
+ mode: input.preferredRuntime ? `preferred:${input.preferredRuntime}` : 'dispatch',
84
+ assignedAt: Date.now(),
80
85
  },
81
86
  });
82
87
  const claim = wf.claimJob(job.id, worker.id, 15 * 60_000);
@@ -105,12 +110,29 @@ export async function runDashboardAgentControl(ctx, url, input) {
105
110
  crGraphContext,
106
111
  storeMemory: async (content, priority, tags) => { memory?.store?.(content, priority, tags); },
107
112
  });
113
+ wf.patchJobPayload(job.id, {
114
+ runId: dispatch.runId,
115
+ invoker: dispatch.invoker,
116
+ mode: 'dispatch',
117
+ actualStatus: 'Dispatch accepted; waiting for runtime completion.',
118
+ lifecycle: ['worker-materialized', 'job-enqueued', 'claim-acquired', 'dispatch-accepted'],
119
+ });
108
120
  const cleanup = [
109
121
  nexusEventBus.on('dispatch.complete', (payload) => {
110
122
  if (payload.runId !== dispatch.runId)
111
123
  return;
112
124
  wf.recordTokens(job.id, worker.id, Number(payload.tokensUsed ?? 0));
113
- if (payload.success === false)
125
+ const ok = payload.success !== false;
126
+ wf.patchJobPayload(job.id, {
127
+ actualStatus: ok ? 'Completed through dispatch runtime.' : 'Dispatch runtime reported failure.',
128
+ completedRunId: dispatch.runId,
129
+ tokensUsed: Number(payload.tokensUsed ?? 0),
130
+ filesChanged: Array.isArray(payload.filesChanged) ? payload.filesChanged : [],
131
+ lifecycle: ok
132
+ ? ['worker-materialized', 'job-enqueued', 'claim-acquired', 'dispatch-accepted', 'dispatch-complete']
133
+ : ['worker-materialized', 'job-enqueued', 'claim-acquired', 'dispatch-accepted', 'dispatch-failed'],
134
+ });
135
+ if (!ok)
114
136
  wf.failJob(job.id, worker.id);
115
137
  else
116
138
  wf.completeJob(job.id, worker.id);
@@ -119,6 +141,12 @@ export async function runDashboardAgentControl(ctx, url, input) {
119
141
  nexusEventBus.on('dispatch.failed', (payload) => {
120
142
  if (payload.runId !== dispatch.runId)
121
143
  return;
144
+ wf.patchJobPayload(job.id, {
145
+ actualStatus: 'Dispatch failed before completion.',
146
+ runId: dispatch.runId,
147
+ failureReason: payload.error ?? 'dispatch.failed',
148
+ lifecycle: ['worker-materialized', 'job-enqueued', 'claim-acquired', 'dispatch-accepted', 'dispatch-failed'],
149
+ });
122
150
  wf.failJob(job.id, worker.id);
123
151
  cleanup.forEach((unsubscribe) => unsubscribe());
124
152
  }),
@@ -127,6 +155,12 @@ export async function runDashboardAgentControl(ctx, url, input) {
127
155
  }
128
156
  catch (dispatchError) {
129
157
  if (!orchestrator) {
158
+ wf.patchJobPayload(job.id, {
159
+ actualStatus: 'Dispatch failed and no local orchestrator fallback was available.',
160
+ failureReason: dispatchError?.message ?? String(dispatchError),
161
+ mode: 'dispatch-unavailable',
162
+ lifecycle: ['worker-materialized', 'job-enqueued', 'claim-acquired', 'dispatch-failed'],
163
+ });
130
164
  wf.failJob(job.id, worker.id);
131
165
  throw dispatchError;
132
166
  }
@@ -137,6 +171,18 @@ export async function runDashboardAgentControl(ctx, url, input) {
137
171
  specialistId: input.specialistId ?? worker.role,
138
172
  operativeId,
139
173
  });
174
+ wf.patchJobPayload(job.id, {
175
+ runId: run.runId,
176
+ mode: 'orchestrator-fallback',
177
+ invoker: 'nexus-orchestrator',
178
+ fallbackReason: dispatchError?.message ?? String(dispatchError),
179
+ actualStatus: run.state === 'failed'
180
+ ? 'Local orchestrator fallback failed.'
181
+ : 'Completed through local orchestrator fallback.',
182
+ lifecycle: run.state === 'failed'
183
+ ? ['worker-materialized', 'job-enqueued', 'claim-acquired', 'dispatch-failed', 'orchestrator-fallback-failed']
184
+ : ['worker-materialized', 'job-enqueued', 'claim-acquired', 'dispatch-failed', 'orchestrator-fallback-complete'],
185
+ });
140
186
  if (run.state === 'failed')
141
187
  wf.failJob(job.id, worker.id);
142
188
  else
@@ -5,5 +5,5 @@
5
5
  * (no DROP, no rename). Migration copies live data on first init.
6
6
  */
7
7
  import type { WorkforceDb } from '../types.js';
8
- export declare const WORKFORCE_SCHEMA = "\nCREATE TABLE IF NOT EXISTS workforce_workers (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL UNIQUE,\n role TEXT,\n client TEXT NOT NULL DEFAULT 'unknown',\n status TEXT NOT NULL DEFAULT 'idle'\n CHECK(status IN ('idle','active','stale','retired')),\n budget_cap_usd REAL NOT NULL DEFAULT 10.0,\n spent_usd REAL NOT NULL DEFAULT 0.0,\n score REAL NOT NULL DEFAULT 0.0,\n last_heartbeat INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')*1000)\n);\n\nCREATE TABLE IF NOT EXISTS workforce_jobs (\n id TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n tier TEXT NOT NULL DEFAULT 'tactical'\n CHECK(tier IN ('strategy','tactical')),\n status TEXT NOT NULL DEFAULT 'ready'\n CHECK(status IN ('backlog','ready','claimed','wip','blocked','review','done','failed','cancelled')),\n priority INTEGER NOT NULL DEFAULT 5,\n worker_id TEXT,\n parent_id TEXT,\n depends_on TEXT NOT NULL DEFAULT '[]',\n budget_cap_usd REAL,\n tokens_used INTEGER NOT NULL DEFAULT 0,\n payload TEXT NOT NULL DEFAULT '{}',\n client TEXT NOT NULL DEFAULT 'unknown',\n claimed_at INTEGER,\n completed_at INTEGER,\n created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')*1000)\n);\n\nCREATE TABLE IF NOT EXISTS workforce_claims (\n id TEXT PRIMARY KEY,\n job_id TEXT NOT NULL REFERENCES workforce_jobs(id) ON DELETE CASCADE,\n worker_id TEXT NOT NULL,\n acquired_at INTEGER NOT NULL DEFAULT (strftime('%s','now')*1000),\n released_at INTEGER,\n lease_ms INTEGER NOT NULL DEFAULT 300000,\n expires_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_wf_jobs_status ON workforce_jobs(status, priority);\nCREATE INDEX IF NOT EXISTS idx_wf_jobs_worker ON workforce_jobs(worker_id, status);\nCREATE INDEX IF NOT EXISTS idx_wf_jobs_parent ON workforce_jobs(parent_id);\nCREATE INDEX IF NOT EXISTS idx_wf_workers_status ON workforce_workers(status, last_heartbeat);\nCREATE INDEX IF NOT EXISTS idx_wf_claims_job ON workforce_claims(job_id, released_at);\nCREATE INDEX IF NOT EXISTS idx_wf_claims_expiry ON workforce_claims(job_id, expires_at, released_at);\n\nCREATE VIEW IF NOT EXISTS vw_kanban AS\n SELECT\n status AS lane,\n id, title, worker_id, priority, tokens_used,\n budget_cap_usd, client, created_at, claimed_at\n FROM workforce_jobs\n WHERE\n -- Active lanes: always show\n status NOT IN ('done', 'failed', 'cancelled')\n OR\n -- Terminal lanes: only show last 7 days to prevent unbounded growth\n completed_at > (strftime('%s','now') - 7*86400) * 1000\n ORDER BY\n CASE status\n WHEN 'backlog' THEN 1\n WHEN 'ready' THEN 2\n WHEN 'claimed' THEN 3\n WHEN 'wip' THEN 4\n WHEN 'blocked' THEN 5\n WHEN 'review' THEN 6\n WHEN 'done' THEN 7\n WHEN 'failed' THEN 8\n WHEN 'cancelled' THEN 9\n ELSE 10\n END,\n priority,\n created_at;\n\nCREATE VIEW IF NOT EXISTS vw_worker_load AS\n SELECT\n w.id, w.name, w.role, w.status, w.client,\n COUNT(j.id) AS active_jobs,\n COALESCE(SUM(j.tokens_used), 0) AS total_tokens,\n w.spent_usd,\n w.budget_cap_usd,\n w.last_heartbeat\n FROM workforce_workers w\n LEFT JOIN workforce_jobs j\n ON j.worker_id = w.id AND j.status IN ('claimed','wip','review')\n GROUP BY w.id;\n";
8
+ export declare const WORKFORCE_SCHEMA = "\nCREATE TABLE IF NOT EXISTS workforce_workers (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL UNIQUE,\n role TEXT,\n client TEXT NOT NULL DEFAULT 'unknown',\n status TEXT NOT NULL DEFAULT 'idle'\n CHECK(status IN ('idle','active','stale','retired')),\n budget_cap_usd REAL NOT NULL DEFAULT 10.0,\n spent_usd REAL NOT NULL DEFAULT 0.0,\n score REAL NOT NULL DEFAULT 0.0,\n last_heartbeat INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')*1000)\n);\n\nCREATE TABLE IF NOT EXISTS workforce_jobs (\n id TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n tier TEXT NOT NULL DEFAULT 'tactical'\n CHECK(tier IN ('strategy','tactical')),\n status TEXT NOT NULL DEFAULT 'ready'\n CHECK(status IN ('backlog','ready','claimed','wip','blocked','review','done','failed','cancelled')),\n priority INTEGER NOT NULL DEFAULT 5,\n worker_id TEXT,\n parent_id TEXT,\n depends_on TEXT NOT NULL DEFAULT '[]',\n budget_cap_usd REAL,\n tokens_used INTEGER NOT NULL DEFAULT 0,\n payload TEXT NOT NULL DEFAULT '{}',\n client TEXT NOT NULL DEFAULT 'unknown',\n claimed_at INTEGER,\n completed_at INTEGER,\n created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')*1000)\n);\n\nCREATE TABLE IF NOT EXISTS workforce_claims (\n id TEXT PRIMARY KEY,\n job_id TEXT NOT NULL REFERENCES workforce_jobs(id) ON DELETE CASCADE,\n worker_id TEXT NOT NULL,\n acquired_at INTEGER NOT NULL DEFAULT (strftime('%s','now')*1000),\n released_at INTEGER,\n lease_ms INTEGER NOT NULL DEFAULT 300000,\n expires_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_wf_jobs_status ON workforce_jobs(status, priority);\nCREATE INDEX IF NOT EXISTS idx_wf_jobs_worker ON workforce_jobs(worker_id, status);\nCREATE INDEX IF NOT EXISTS idx_wf_jobs_parent ON workforce_jobs(parent_id);\nCREATE INDEX IF NOT EXISTS idx_wf_workers_status ON workforce_workers(status, last_heartbeat);\nCREATE INDEX IF NOT EXISTS idx_wf_claims_job ON workforce_claims(job_id, released_at);\nCREATE INDEX IF NOT EXISTS idx_wf_claims_expiry ON workforce_claims(job_id, expires_at, released_at);\n\nDROP VIEW IF EXISTS vw_kanban;\n\nCREATE VIEW vw_kanban AS\n SELECT\n status AS lane,\n id, title, worker_id, priority, tokens_used,\n budget_cap_usd, payload, client, created_at, claimed_at, completed_at\n FROM workforce_jobs\n WHERE\n -- Active lanes: always show\n status NOT IN ('done', 'failed', 'cancelled')\n OR\n -- Terminal lanes: only show last 7 days to prevent unbounded growth\n completed_at > (strftime('%s','now') - 7*86400) * 1000\n ORDER BY\n CASE status\n WHEN 'backlog' THEN 1\n WHEN 'ready' THEN 2\n WHEN 'claimed' THEN 3\n WHEN 'wip' THEN 4\n WHEN 'blocked' THEN 5\n WHEN 'review' THEN 6\n WHEN 'done' THEN 7\n WHEN 'failed' THEN 8\n WHEN 'cancelled' THEN 9\n ELSE 10\n END,\n priority,\n created_at;\n\nCREATE VIEW IF NOT EXISTS vw_worker_load AS\n SELECT\n w.id, w.name, w.role, w.status, w.client,\n COUNT(j.id) AS active_jobs,\n COALESCE(SUM(j.tokens_used), 0) AS total_tokens,\n w.spent_usd,\n w.budget_cap_usd,\n w.last_heartbeat\n FROM workforce_workers w\n LEFT JOIN workforce_jobs j\n ON j.worker_id = w.id AND j.status IN ('claimed','wip','review')\n GROUP BY w.id;\n";
9
9
  export declare function initWorkforceSchema(db: WorkforceDb): void;
@@ -56,11 +56,13 @@ CREATE INDEX IF NOT EXISTS idx_wf_workers_status ON workforce_workers(status
56
56
  CREATE INDEX IF NOT EXISTS idx_wf_claims_job ON workforce_claims(job_id, released_at);
57
57
  CREATE INDEX IF NOT EXISTS idx_wf_claims_expiry ON workforce_claims(job_id, expires_at, released_at);
58
58
 
59
- CREATE VIEW IF NOT EXISTS vw_kanban AS
59
+ DROP VIEW IF EXISTS vw_kanban;
60
+
61
+ CREATE VIEW vw_kanban AS
60
62
  SELECT
61
63
  status AS lane,
62
64
  id, title, worker_id, priority, tokens_used,
63
- budget_cap_usd, client, created_at, claimed_at
65
+ budget_cap_usd, payload, client, created_at, claimed_at, completed_at
64
66
  FROM workforce_jobs
65
67
  WHERE
66
68
  -- Active lanes: always show
@@ -5,14 +5,14 @@
5
5
  * All MCP tools go through getWorkforce(root).
6
6
  */
7
7
  import { registerWorker, getWorker, listWorkers, listCompanies, heartbeat, retireWorker, markStaleWorkers, recordSpend, adjustScore } from './workers.js';
8
- import { enqueueJob, getJob, listJobs, claimNextReady, advanceToWip, advanceToReview, recordTokens, completeJob, failJob, blockJob, cancelJob } from './jobs.js';
8
+ import { enqueueJob, getJob, listJobs, claimNextReady, advanceToWip, advanceToReview, recordTokens, completeJob, failJob, blockJob, cancelJob, patchJobPayload } from './jobs.js';
9
9
  import { claimJob, renewClaim, releaseClaim, reclaimExpiredLocks } from './claim-lock.js';
10
10
  import { getKanbanBoard, getLaneJobs } from './kanban-view.js';
11
11
  import { reconcileDag } from './dag.js';
12
12
  import type { WorkforceDb, Worker, Job, Claim, KanbanBoard, KanbanCard, JobStatus, RegisterWorkerInput, EnqueueJobInput } from './types.js';
13
13
  export type { Worker, Job, Claim, KanbanBoard, KanbanCard, JobStatus, WorkforceDb };
14
14
  export { registerWorker, getWorker, listWorkers, listCompanies, heartbeat, retireWorker, markStaleWorkers, recordSpend, adjustScore };
15
- export { enqueueJob, getJob, listJobs, claimNextReady, advanceToWip, advanceToReview, recordTokens, completeJob, failJob, blockJob, cancelJob };
15
+ export { enqueueJob, getJob, listJobs, claimNextReady, advanceToWip, advanceToReview, recordTokens, completeJob, failJob, blockJob, cancelJob, patchJobPayload };
16
16
  export { claimJob, renewClaim, releaseClaim, reclaimExpiredLocks };
17
17
  export { getKanbanBoard, getLaneJobs };
18
18
  export { reconcileDag };
@@ -37,6 +37,7 @@ export declare class WorkforceRuntime {
37
37
  advanceToWip(jobId: string, workerId: string): void;
38
38
  advanceToReview(jobId: string, workerId: string): void;
39
39
  recordTokens(jobId: string, workerId: string, tokens: number): void;
40
+ patchJobPayload(jobId: string, patch: Record<string, unknown>): Job | null;
40
41
  completeJob(jobId: string, workerId: string): Job | null;
41
42
  failJob(jobId: string, workerId: string): Job | null;
42
43
  blockJob(jobId: string, workerId: string): Job | null;
@@ -7,12 +7,12 @@
7
7
  import path from 'path';
8
8
  import { openWorkforceDb } from './db/client.js';
9
9
  import { registerWorker, getWorker, listWorkers, listCompanies, heartbeat, retireWorker, markStaleWorkers, recordSpend, adjustScore } from './workers.js';
10
- import { enqueueJob, getJob, listJobs, claimNextReady, advanceToWip, advanceToReview, recordTokens, completeJob, failJob, blockJob, cancelJob } from './jobs.js';
10
+ import { enqueueJob, getJob, listJobs, claimNextReady, advanceToWip, advanceToReview, recordTokens, completeJob, failJob, blockJob, cancelJob, patchJobPayload } from './jobs.js';
11
11
  import { claimJob, renewClaim, releaseClaim, reclaimExpiredLocks } from './claim-lock.js';
12
12
  import { getKanbanBoard, getLaneJobs } from './kanban-view.js';
13
13
  import { reconcileDag } from './dag.js';
14
14
  export { registerWorker, getWorker, listWorkers, listCompanies, heartbeat, retireWorker, markStaleWorkers, recordSpend, adjustScore };
15
- export { enqueueJob, getJob, listJobs, claimNextReady, advanceToWip, advanceToReview, recordTokens, completeJob, failJob, blockJob, cancelJob };
15
+ export { enqueueJob, getJob, listJobs, claimNextReady, advanceToWip, advanceToReview, recordTokens, completeJob, failJob, blockJob, cancelJob, patchJobPayload };
16
16
  export { claimJob, renewClaim, releaseClaim, reclaimExpiredLocks };
17
17
  export { getKanbanBoard, getLaneJobs };
18
18
  export { reconcileDag };
@@ -46,6 +46,7 @@ export class WorkforceRuntime {
46
46
  advanceToWip(jobId, workerId) { advanceToWip(this.db, jobId, workerId); }
47
47
  advanceToReview(jobId, workerId) { advanceToReview(this.db, jobId, workerId); }
48
48
  recordTokens(jobId, workerId, tokens) { recordTokens(this.db, jobId, workerId, tokens); }
49
+ patchJobPayload(jobId, patch) { return patchJobPayload(this.db, jobId, patch); }
49
50
  completeJob(jobId, workerId) { return completeJob(this.db, jobId, workerId); }
50
51
  failJob(jobId, workerId) { return failJob(this.db, jobId, workerId); }
51
52
  blockJob(jobId, workerId) { return blockJob(this.db, jobId, workerId); }
@@ -7,6 +7,7 @@
7
7
  import type { WorkforceDb, Job, JobStatus, EnqueueJobInput } from './types.js';
8
8
  import type { Claim } from './types.js';
9
9
  export declare function enqueueJob(db: WorkforceDb, input: EnqueueJobInput): Job;
10
+ export declare function patchJobPayload(db: WorkforceDb, jobId: string, patch: Record<string, unknown>): Job | null;
10
11
  export declare function getJob(db: WorkforceDb, id: string): Job | null;
11
12
  export declare function listJobs(db: WorkforceDb, status?: JobStatus, limit?: number, companyId?: string): Job[];
12
13
  export declare function claimNextReady(db: WorkforceDb, workerId: string, leaseMs?: number): Claim | null;
@@ -55,6 +55,13 @@ export function enqueueJob(db, input) {
55
55
  reconcileDag(db);
56
56
  return getJob(db, id);
57
57
  }
58
+ export function patchJobPayload(db, jobId, patch) {
59
+ const job = getJob(db, jobId);
60
+ if (!job)
61
+ return null;
62
+ db.prepare(`UPDATE workforce_jobs SET payload = ? WHERE id = ?`).run(JSON.stringify({ ...(job.payload ?? {}), ...patch }), jobId);
63
+ return getJob(db, jobId);
64
+ }
58
65
  // ─── Read ─────────────────────────────────────────────────────────────────────
59
66
  export function getJob(db, id) {
60
67
  const row = db.prepare('SELECT * FROM workforce_jobs WHERE id = ?').get(id);
@@ -3,6 +3,17 @@
3
3
  * Reads from vw_kanban SQL view; no business logic here.
4
4
  */
5
5
  const ALL_LANES = ['backlog', 'ready', 'claimed', 'wip', 'blocked', 'review', 'done', 'failed', 'cancelled'];
6
+ function parsePayload(raw) {
7
+ if (!raw)
8
+ return {};
9
+ try {
10
+ const parsed = JSON.parse(raw);
11
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
12
+ }
13
+ catch {
14
+ return {};
15
+ }
16
+ }
6
17
  export function getKanbanBoard(db, client) {
7
18
  const rows = client
8
19
  ? db.prepare(`SELECT * FROM vw_kanban WHERE client = ?`).all(client)
@@ -18,9 +29,11 @@ export function getKanbanBoard(db, client) {
18
29
  priority: r.priority,
19
30
  tokensUsed: r.tokens_used,
20
31
  budgetCapUsd: r.budget_cap_usd,
32
+ payload: parsePayload(r.payload),
21
33
  client: r.client,
22
34
  createdAt: r.created_at,
23
35
  claimedAt: r.claimed_at,
36
+ completedAt: r.completed_at,
24
37
  });
25
38
  }
26
39
  return {
@@ -38,8 +51,10 @@ export function getLaneJobs(db, lane) {
38
51
  priority: r.priority,
39
52
  tokensUsed: r.tokens_used,
40
53
  budgetCapUsd: r.budget_cap_usd,
54
+ payload: parsePayload(r.payload),
41
55
  client: r.client,
42
56
  createdAt: r.created_at,
43
57
  claimedAt: r.claimed_at,
58
+ completedAt: r.completed_at,
44
59
  }));
45
60
  }
@@ -78,9 +78,11 @@ export interface KanbanCard {
78
78
  priority: number;
79
79
  tokensUsed: number;
80
80
  budgetCapUsd: number | null;
81
+ payload: Record<string, unknown>;
81
82
  client: string;
82
83
  createdAt: number;
83
84
  claimedAt: number | null;
85
+ completedAt: number | null;
84
86
  }
85
87
  export interface KanbanBoard {
86
88
  lanes: Record<JobStatus, KanbanCard[]>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexus-prime",
3
- "version": "7.9.29",
3
+ "version": "7.9.30",
4
4
  "description": "Local-first MCP control plane for coding agents with bootstrap-orchestrate execution, memory fabric, token budgeting, and worktree-backed swarms",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",