nexus-prime 7.9.17 → 7.9.19

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.
@@ -292,8 +292,9 @@ export function buildMcpToolDefinitions() {
292
292
  properties: {
293
293
  goal: { type: 'string', description: 'What you are trying to accomplish' },
294
294
  task: { type: 'string', description: 'Alias for goal (deprecated, use goal)' },
295
- files: { type: 'array', items: { type: 'string' }, description: 'File paths to analyze. If omitted, auto-scans src/' },
296
- budget: { type: 'number', description: 'Token budget override' }
295
+ files: { type: 'array', items: { type: 'string' }, description: 'File paths to analyze. If omitted, auto-scans a capped source shortlist instead of the whole repo.' },
296
+ budget: { type: 'number', description: 'Token budget override' },
297
+ maxFiles: { type: 'number', description: 'Maximum files to include when files are omitted (default 80, max 200)' }
297
298
  },
298
299
  required: [],
299
300
  },
@@ -14,12 +14,28 @@ import { GhostPass, summarizeExecution } from '../../../../phantom/index.js';
14
14
  import { applyExecutionPreset, resolveExecutionPreset } from '../../../../engines/execution-presets.js';
15
15
  import { pLimit } from '../../../../engines/util/p-limit.js';
16
16
  import { promises as fsPromises } from 'fs';
17
+ import * as path from 'path';
17
18
  import { getSharedLicenseManager } from '../../../../licensing/index.js';
18
19
  import { SessionDNAManager } from '../../../../engines/session-dna.js';
19
20
  import { getSharedTelemetry } from '../../../../engines/telemetry-remote.js';
20
21
  import { requireRuntime } from '../util/require-runtime.js';
21
22
  import { ensureCrGraphBuilt } from '../../../../engines/code-review-graph-client.js';
22
23
  import { recordFirstBootstrap } from '../../../../engines/telemetry.js';
24
+ function escapeMarkdownTableCell(value) {
25
+ return String(value ?? '')
26
+ .replace(/\r?\n/g, ' ')
27
+ .replace(/\|/g, '\\|')
28
+ .trim();
29
+ }
30
+ function markdownTable(headers, rows) {
31
+ if (rows.length === 0)
32
+ return '';
33
+ return [
34
+ `| ${headers.map(escapeMarkdownTableCell).join(' | ')} |`,
35
+ `| ${headers.map(() => '---').join(' | ')} |`,
36
+ ...rows.map(row => `| ${row.map(escapeMarkdownTableCell).join(' | ')} |`),
37
+ ].join('\n');
38
+ }
23
39
  export function extractSkillSelectorsFromPrompt(prompt) {
24
40
  const selectors = new Set();
25
41
  const add = (value) => {
@@ -282,6 +298,9 @@ export async function handleOrchestrationGroup(toolName, hctx, request, args, ct
282
298
  `Memory stats: prefrontal ${bootstrap.memoryStats?.prefrontal ?? 0} · hippocampus ${bootstrap.memoryStats?.hippocampus ?? 0} · cortex ${bootstrap.memoryStats?.cortex ?? 0}`,
283
299
  `Recommended next step: ${bootstrap.recommendedNextStep || 'nexus_orchestrate'}`,
284
300
  `Token optimization: ${bootstrap.tokenOptimization?.autoApplied ? `auto-applied — saved ${Number(bootstrap.tokenOptimization?.planMetrics?.savings ?? 0).toLocaleString()} tokens (${bootstrap.tokenOptimization?.planMetrics?.pct ?? 0}% reduction)` : (bootstrap.tokenOptimization?.required ? 'required before broad reading' : 'not required yet')}`,
301
+ bootstrap.tokenOptimization?.planMetrics
302
+ ? `Token budget: original ${Number(bootstrap.tokenOptimization.planMetrics.originalTokens ?? 0).toLocaleString()} → planned ${Number(bootstrap.tokenOptimization.planMetrics.compressedTokens ?? 0).toLocaleString()} across ${Number(bootstrap.tokenOptimization.planMetrics.files ?? 0).toLocaleString()} file action(s)`
303
+ : '',
285
304
  `Catalog health: ${bootstrap.catalogHealth?.overall || 'unknown'} · selected ${bootstrap.artifactSelectionAudit?.selected?.length || 0}`,
286
305
  (() => {
287
306
  try {
@@ -585,8 +604,13 @@ export async function handleOrchestrationGroup(toolName, hctx, request, args, ct
585
604
  const rawFiles = Array.isArray(request.params.arguments?.files)
586
605
  ? request.params.arguments.files.map(String)
587
606
  : null;
588
- const filePaths = rawFiles
589
- ?? await hctx.scanSourceFiles(hctx.getWorkspace(request.params.arguments ?? {}).repoRoot);
607
+ const workspaceRoot = hctx.getWorkspace(request.params.arguments ?? {}).repoRoot;
608
+ const scannedFilePaths = rawFiles
609
+ ?? await hctx.scanSourceFiles(workspaceRoot);
610
+ const requestedMaxFiles = Number(request.params.arguments?.maxFiles ?? request.params.arguments?.limit ?? 80);
611
+ const maxFiles = Math.max(1, Math.min(Number.isFinite(requestedMaxFiles) ? Math.floor(requestedMaxFiles) : 80, 200));
612
+ const filePaths = rawFiles ? scannedFilePaths : scannedFilePaths.slice(0, maxFiles);
613
+ const omittedFiles = rawFiles ? 0 : Math.max(0, scannedFilePaths.length - filePaths.length);
590
614
  const statLimit = pLimit(8);
591
615
  const files = await Promise.all(filePaths.map((p) => statLimit(async () => {
592
616
  const resolved = hctx.resolveToolPath(p, request.params.arguments ?? {});
@@ -660,10 +684,14 @@ export async function handleOrchestrationGroup(toolName, hctx, request, args, ct
660
684
  const dedupLine = dedupTokens > 0
661
685
  ? `\n ${dedupTokens.toLocaleString()} tok deduped (${delta.unchanged.length} unchanged files)`
662
686
  : '';
687
+ const scanLine = rawFiles
688
+ ? `\n explicit file set: ${filePaths.length.toLocaleString()} file(s)`
689
+ : `\n auto-scan capped: ${filePaths.length.toLocaleString()}/${scannedFilePaths.length.toLocaleString()} file(s)${omittedFiles ? ` (${omittedFiles.toLocaleString()} omitted; pass files or maxFiles to override)` : ''}`;
663
690
  const receipt = [
664
691
  '',
665
692
  `▸ Receipt ${plan.totalEstimatedTokens.toLocaleString()} tok in`,
666
693
  ` ${plan.savings.toLocaleString()} tok saved (${pct}% vs full read)${dedupLine}`,
694
+ scanLine.trimStart(),
667
695
  ` ~$${estimatedCostUsd.toFixed(4)} estimated · ~$${savedCostUsd.toFixed(4)} saved`,
668
696
  ].join('\n');
669
697
  return { content: [{ type: 'text', text: formatted + receipt + notification + nudge }] };
@@ -673,7 +701,9 @@ export async function handleOrchestrationGroup(toolName, hctx, request, args, ct
673
701
  const ctx = {
674
702
  action: String(args?.action ?? ''),
675
703
  tokenCount: args?.tokenCount,
676
- filesToModify: args?.filesToModify,
704
+ filesToModify: Array.isArray(args?.filesToModify)
705
+ ? args.filesToModify.map(String)
706
+ : (Array.isArray(args?.files) ? args.files.map(String) : undefined),
677
707
  isDestructive: args?.isDestructive,
678
708
  };
679
709
  const result = getGuardrailEngine().check(ctx);
@@ -685,15 +715,47 @@ export async function handleOrchestrationGroup(toolName, hctx, request, args, ct
685
715
  const nudge = result.passed
686
716
  ? hctx.telemetry.planningNudge('high_call_count', {})
687
717
  : hctx.telemetry.planningNudge('mindkit_fail', {});
718
+ const verdict = result.passed ? 'PASS' : 'FAIL';
719
+ const violationRows = result.violations.map((v) => {
720
+ const item = v;
721
+ return [
722
+ item.id ?? 'violation',
723
+ item.severity ?? 'high',
724
+ item.message ?? item.description ?? 'blocked by guardrail',
725
+ ];
726
+ });
727
+ const warningRows = result.warnings.map((w) => {
728
+ const item = w;
729
+ return [
730
+ item.id ?? 'warning',
731
+ item.severity ?? 'warn',
732
+ item.message ?? item.description ?? 'review recommended',
733
+ ];
734
+ });
735
+ const detailJson = JSON.stringify({
736
+ passed: result.passed,
737
+ score: Math.round(result.score * 100),
738
+ filesToModify: ctx.filesToModify ?? [],
739
+ violations: result.violations,
740
+ warnings: result.warnings,
741
+ summary: getGuardrailEngine().format(result)
742
+ }, null, 2);
688
743
  return {
689
744
  content: [{
690
- type: 'text', text: JSON.stringify({
691
- passed: result.passed,
692
- score: Math.round(result.score * 100),
693
- violations: result.violations,
694
- warnings: result.warnings,
695
- summary: getGuardrailEngine().format(result)
696
- }, null, 2) + nudge
745
+ type: 'text',
746
+ text: [
747
+ `Mindkit check: ${verdict}`,
748
+ formatBullets([
749
+ `Score: ${Math.round(result.score * 100)}/100`,
750
+ `Files: ${ctx.filesToModify?.length ? ctx.filesToModify.join(', ') : 'none declared'}`,
751
+ `Decision: ${result.passed ? 'proceed' : 'stop and resolve guardrail violations'}`,
752
+ `Summary: ${getGuardrailEngine().format(result)}`,
753
+ ]),
754
+ violationRows.length ? `Violations\n${markdownTable(['Rule', 'Severity', 'Message'], violationRows)}` : '',
755
+ warningRows.length ? `Warnings\n${markdownTable(['Rule', 'Severity', 'Message'], warningRows)}` : '',
756
+ `Details\n\`\`\`json\n${detailJson}\n\`\`\``,
757
+ nudge,
758
+ ].filter(Boolean).join('\n\n')
697
759
  }]
698
760
  };
699
761
  }
@@ -717,6 +779,17 @@ export async function handleOrchestrationGroup(toolName, hctx, request, args, ct
717
779
  hctx.nexusRef.storeMemory(`Ghost pass for "${goal.slice(0, 80)}": ${report.riskAreas.length} risks identified.`, 0.6, ['#ghost-pass']);
718
780
  nexusEventBus.emit('ghost.pass', { task: goal, risks: report.riskAreas.length, workers: report.workerAssignments.length });
719
781
  const ghostNudge = hctx.telemetry.planningNudge('ghost_pass', { risks: report.riskAreas.length });
782
+ const workerTable = markdownTable(['Worker', 'Approach', 'Token Budget', 'Files'], report.workerAssignments.map((worker, index) => [
783
+ `Worker ${index + 1}`,
784
+ worker.approach,
785
+ worker.tokenBudget.toLocaleString(),
786
+ worker.files.map(file => path.basename(file.path)).slice(0, 4).join(', ') || 'n/a',
787
+ ]));
788
+ const riskTable = markdownTable(['Risk', 'Severity', 'Mitigation'], report.riskAreas.map((risk) => [
789
+ risk,
790
+ 'review',
791
+ 'Use the reading plan and isolate edits before mutation',
792
+ ]));
720
793
  // Console ASCII UI
721
794
  const rCount = report.riskAreas.length;
722
795
  hctx.box('👻 GHOST PASS PRE-FLIGHT', [
@@ -735,10 +808,9 @@ export async function handleOrchestrationGroup(toolName, hctx, request, args, ct
735
808
  `Risks: ${report.riskAreas.length ? report.riskAreas.join(' | ') : 'none detected'}`,
736
809
  `Worker approaches: ${report.workerAssignments.length}`,
737
810
  ]),
811
+ riskTable ? `Risk table\n${riskTable}` : 'Risk table\nNo risks detected.',
812
+ workerTable ? `Worker table\n${workerTable}` : '',
738
813
  'Reading plan\n```txt\n' + formatReadingPlan(report.readingPlan) + '\n```',
739
- report.workerAssignments.length
740
- ? formatBullets(report.workerAssignments.map((worker, index) => `Worker ${index + 1}: ${worker.approach} · budget ${worker.tokenBudget.toLocaleString()} tokens`))
741
- : '',
742
814
  ghostNudge,
743
815
  ].filter(Boolean).join('\n\n'),
744
816
  }],
@@ -120,6 +120,13 @@ export class SessionTelemetry {
120
120
  this.noteFileIntent(args.files);
121
121
  return;
122
122
  }
123
+ if (toolName === 'nexus_spawn_workers') {
124
+ if (this.lifecyclePhase === 'bootstrapped' || this.lifecyclePhase === 'orchestrated') {
125
+ this.advancePhase('working');
126
+ }
127
+ this.noteFileIntent(args.files);
128
+ return;
129
+ }
123
130
  if (toolName === 'nexus_store_memory') {
124
131
  if (this.isPostOrchestratePhase()) {
125
132
  this.storeMemoryCalledPostOrchestrate = true;
@@ -136,6 +136,7 @@ const PRE_ORCHESTRATE_ALLOWED_TOOLS = new Set([
136
136
  'nexus_optimize_tokens',
137
137
  'nexus_mindkit_check',
138
138
  'nexus_ghost_pass',
139
+ 'nexus_spawn_workers',
139
140
  'nexus_token_report',
140
141
  'nexus_session_dna',
141
142
  'nexus_run_status',
@@ -545,6 +546,7 @@ export class MCPAdapter {
545
546
  code: 'orchestration-required',
546
547
  reason: 'nexus_orchestrate has not been called for this session',
547
548
  action: 'Call nexus_orchestrate(prompt="<your task>") before invoking mutation, worker, kernel, hook, automation, or low-level execution tools.',
549
+ nextTool: 'nexus_orchestrate',
548
550
  hint: 'Nexus Prime must own planning, token budgeting, memory recall, hooks, and review gates before work starts.',
549
551
  }, null, 2),
550
552
  }],
package/dist/cli/hook.js CHANGED
@@ -209,8 +209,9 @@ export async function runHookMindkit() {
209
209
  if (!lock)
210
210
  return; // no daemon — skip rather than block
211
211
  const resp = await callDaemonTool(lock, 'nexus_mindkit_check', {
212
- action: toolName,
213
- files,
212
+ action: `${toolName}: ${files.join(', ')}`,
213
+ filesToModify: files,
214
+ isDestructive: ['Write', 'Edit', 'MultiEdit'].includes(toolName),
214
215
  }, 6_000);
215
216
  // Parse the tool result text to check for a block verdict
216
217
  const text = extractResultText(resp);
package/dist/cli.js CHANGED
@@ -717,11 +717,11 @@ program
717
717
  .description('Start Nexus Prime daemon')
718
718
  .option('--force', 'Force restart if already running')
719
719
  .action(async (options) => {
720
- const workspaceContext = resolveWorkspaceContext({ workspaceRoot: getWorkspaceRoot() });
721
720
  try {
722
721
  const record = await ensureDaemonManaged({ force: options.force });
722
+ const dashboardPort = process.env.NEXUS_DASHBOARD_PORT ?? '3377';
723
723
  console.log(`Nexus Prime daemon running (pid ${record.pid}, ${formatDaemonAddress(record)})`);
724
- console.log(`Dashboard: http://localhost:3377`);
724
+ console.log(`Dashboard: http://localhost:${dashboardPort}`);
725
725
  }
726
726
  catch (err) {
727
727
  console.error(`Failed to start daemon: ${err.message}`);
@@ -395,6 +395,12 @@
395
395
  color: var(--text-main); margin-bottom: 4px;
396
396
  }
397
397
  .frh-sub { font-size: var(--text-sm); color: var(--text-dim); }
398
+ .frh-command {
399
+ display: grid;
400
+ grid-template-columns: minmax(220px, 1fr) auto auto;
401
+ gap: 8px;
402
+ margin-bottom: 14px;
403
+ }
398
404
  .frh-picks { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
399
405
  .frh-pick {
400
406
  background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius);
@@ -417,6 +423,8 @@
417
423
  }
418
424
  @media (max-width: 768px) {
419
425
  #kanban-board { grid-template-columns: repeat(2, 1fr); }
426
+ .frh-command,
427
+ .frh-picks { grid-template-columns: 1fr; }
420
428
  }
421
429
  @media (max-width: 480px) {
422
430
  #kanban-board { grid-template-columns: 1fr; }
@@ -76,12 +76,14 @@ function normalizeOperative(op) {
76
76
 
77
77
  /* ── Data loader ── */
78
78
  export async function load() {
79
- const [tok, life, op, sh, health] = await Promise.all([
79
+ const [tok, life, op, sh, health, memHealth, runs] = await Promise.all([
80
80
  api('/api/tokens/summary'),
81
81
  api('/api/tokens/lifetime', 15000),
82
82
  api('/api/dashboard/surface/operate', 5000),
83
83
  api('/api/synapse/health', 5000),
84
84
  api('/api/health', 15000),
85
+ api('/api/memory/health', 15000),
86
+ api('/api/runs?limit=12', 3000),
85
87
  ]);
86
88
  // Non-blocking: tool health data from ring buffer
87
89
  loadToolHealth();
@@ -91,6 +93,8 @@ export async function load() {
91
93
  S.synapseHealthRaw = sh;
92
94
  S.synapseHealth = (Array.isArray(sh) ? sh : (sh?.operatives||[])).map(normalizeOperative);
93
95
  S.healthData = health;
96
+ S.memHealth = memHealth;
97
+ S.runs = Array.isArray(runs) ? runs : [];
94
98
  notifyNotReady([sh]);
95
99
  // Prefetch curated specialists for first-run hero (non-blocking)
96
100
  if (!S.synapseHealth.length && !S.curatedSpecialists) {
@@ -274,6 +278,7 @@ function renderHero() {
274
278
  ?? lt?.totalSaved
275
279
  ?? lt?.lifetime?.saved
276
280
  ?? op?.tokenOptimization?.savedTokens
281
+ ?? op?.tokenOptimization?.estimatedSavings
277
282
  ?? t?.savedTokens
278
283
  ?? t?.saved
279
284
  ?? 0,
@@ -317,14 +322,18 @@ function getHireReadiness() {
317
322
  const synapseState = S.healthData?.runtimeEnvelope?.engines?.synapse ?? {};
318
323
  const memoryStorage = S.healthData?.memory?.storage ?? {};
319
324
  const rawReason = S.synapseHealthRaw?.reason;
325
+ const deferred = synapseState.deferred === true || /deferred\s*\(lazy mode\)/i.test(`${rawReason || ''} ${synapseState.reason || ''}`);
320
326
  const unavailable = Boolean(
321
- S.synapseHealthRaw?.notReady
322
- || synapseState.ready === false
323
- || synapseState.available === false,
327
+ (S.synapseHealthRaw?.notReady && !deferred)
328
+ || (synapseState.ready === false && !deferred)
329
+ || (synapseState.available === false && !deferred),
324
330
  );
325
331
  if (unavailable) {
326
332
  notes.push({ tone: 'bad', text: rawReason || synapseState.reason || 'Synapse is not ready for hires in this dashboard session.' });
327
333
  }
334
+ if (deferred && !unavailable) {
335
+ notes.push({ tone: 'warn', text: 'Synapse is in lazy mode and will warm up on the first hire.' });
336
+ }
328
337
  if (synapseState.fallbackApplied) {
329
338
  notes.push({ tone: 'warn', text: synapseState.reason || 'Synapse is running in fallback storage mode.' });
330
339
  }
@@ -349,13 +358,14 @@ function renderFirstRunHero() {
349
358
  if (!parent) return;
350
359
 
351
360
  const hasOps = S.synapseHealth.length > 0;
361
+ const hasRuns = (S.runs || []).length > 0;
352
362
  const alreadySeen = (() => { try { return !!localStorage.getItem(FIRST_RUN_KEY); } catch { return false; } })();
353
363
 
354
364
  // Remove any existing hero card
355
365
  const existing = $('first-run-hero');
356
366
  if (existing) existing.remove();
357
367
 
358
- if (hasOps || alreadySeen) return;
368
+ if (hasOps || (alreadySeen && hasRuns)) return;
359
369
 
360
370
  const specs = (S.curatedSpecialists || []).slice(0, 3);
361
371
  if (!specs.length) return; // Still loading — will re-render when prefetch resolves
@@ -371,10 +381,15 @@ function renderFirstRunHero() {
371
381
  card.className = 'first-run-hero card';
372
382
  card.innerHTML = `
373
383
  <div class="frh-header">
374
- <div class="frh-title">Your next hire</div>
375
- <div class="frh-sub">Hire a specialist to start running tasks autonomously.</div>
384
+ <div class="frh-title">${hasRuns ? 'Your next hire' : 'Start the first real run'}</div>
385
+ <div class="frh-sub">${hasRuns ? 'Hire a specialist to start running tasks autonomously.' : 'Run a goal from the dashboard or hire a specialist. The run will appear in Board and Context Log.'}</div>
376
386
  </div>
377
387
  ${noticesHtml}
388
+ <div class="frh-command">
389
+ <input id="frh-goal-input" class="form-input" type="text" placeholder="Inspect this repo and suggest the next fix" autocomplete="off">
390
+ <button class="btn btn-primary btn-sm" id="frh-run-btn">Run goal</button>
391
+ <button class="btn btn-sm" id="frh-context-btn">Open context</button>
392
+ </div>
378
393
  <div class="frh-picks">
379
394
  ${specs.map(s => `
380
395
  <div class="frh-pick" data-specid="${esc(s.specialistId)}" data-specname="${esc(s.name)}">
@@ -388,6 +403,33 @@ function renderFirstRunHero() {
388
403
  <button class="btn btn-ghost btn-sm frh-dismiss" style="margin-top:var(--space-3)">Dismiss</button>`;
389
404
 
390
405
  // Wire buttons before inserting
406
+ card.querySelector('#frh-run-btn')?.addEventListener('click', async () => {
407
+ const input = card.querySelector('#frh-goal-input');
408
+ const button = card.querySelector('#frh-run-btn');
409
+ const goal = (input?.value || `Inspect ${S.workspace?.repoName || 'this repo'} and report the next best action`).trim();
410
+ if (!goal) return;
411
+ if (button) {
412
+ button.disabled = true;
413
+ button.textContent = 'Queueing…';
414
+ }
415
+ setFirstRunStatus('Queueing dashboard run…');
416
+ const result = await post('/api/orchestrate', { goal, source: 'dashboard-onboarding' });
417
+ if (result.ok) {
418
+ setFirstRunStatus('Run queued. Board and Context Log will update as Nexus writes artifacts.');
419
+ bustCache('/api/runs?limit=12');
420
+ bustCache('/api/events');
421
+ setTimeout(load, 900);
422
+ } else {
423
+ setFirstRunStatus(result.error || 'Run failed to queue.', 'bad');
424
+ if (button) {
425
+ button.disabled = false;
426
+ button.textContent = 'Run goal';
427
+ }
428
+ }
429
+ });
430
+ card.querySelector('#frh-context-btn')?.addEventListener('click', () => {
431
+ window.location.hash = '#context-log';
432
+ });
391
433
  card.querySelectorAll('.frh-hire-btn').forEach(btn => {
392
434
  btn.addEventListener('click', async e => {
393
435
  e.stopPropagation();
@@ -401,6 +443,7 @@ function renderFirstRunHero() {
401
443
  specialistId: btn.dataset.specid,
402
444
  name: btn.dataset.specname,
403
445
  budgetCapUsd: 2,
446
+ fireFirstSortie: true,
404
447
  });
405
448
  if (result.ok) {
406
449
  setFirstRunStatus(readiness.notes.some(note => note.tone === 'warn')
@@ -446,18 +489,41 @@ function renderAgentsLiveStrip() {
446
489
  /* ── Kanban ── */
447
490
  function buildKanbanCols() {
448
491
  const cols={planning:[],hiring:[],running:[],ghostpass:[],done:[]};
449
- const op=S.operateSurface; if (!op) return cols;
450
- const pc=op.orchestration?.planningContext||op.planningContext;
451
- if (pc?.goal) cols.planning.push({id:'ctx',goal:pc.goal,status:'planning',tokens:null,time:pc.startedAt,role:null});
452
- const ws=op.orchestration?.workerPlan?.workers||op.workerPlan?.workers||[];
453
- for (const w of ws) {
454
- const st=String(w.status||'').toLowerCase();
455
- const sg=st==='active'||st==='running'?'running':st==='hiring'?'hiring':st==='complete'||st==='done'?'done':st==='reviewing'||st.includes('ghost')?'ghostpass':null;
456
- if (sg) cols[sg].push({id:w.id||w.workerId||w.goal,goal:w.goal||w.task||w.approach||'(worker)',status:st,tokens:w.tokensUsed||w.budget,time:w.startedAt||w.createdAt,role:w.role});
492
+ const op=S.operateSurface;
493
+ if (op) {
494
+ const pc=op.orchestration?.planningContext||op.planningContext;
495
+ if (pc?.goal) cols.planning.push({id:'ctx',goal:pc.goal,status:'planning',tokens:null,time:pc.startedAt,role:null});
496
+ const ws=op.orchestration?.workerPlan?.workers||op.workerPlan?.workers||[];
497
+ for (const w of ws) {
498
+ const st=String(w.status||'').toLowerCase();
499
+ const sg=st==='active'||st==='running'?'running':st==='hiring'?'hiring':st==='complete'||st==='done'?'done':st==='reviewing'||st.includes('ghost')?'ghostpass':null;
500
+ if (sg) cols[sg].push({id:w.id||w.workerId||w.goal,goal:w.goal||w.task||w.approach||'(worker)',status:st,tokens:w.tokensUsed||w.budget,time:w.startedAt||w.createdAt,role:w.role});
501
+ }
457
502
  }
458
- for (const r of (S.runs||[]).slice(0,6)) {
459
- if ((r.status==='complete'||r.status==='done')&&!cols.done.some(c=>c.id===r.id))
460
- cols.done.push({id:r.id,goal:r.goal||r.mandate||'(run)',status:'done',tokens:r.tokensUsed||r.tokenCount,time:r.completedAt||r.createdAt,role:null});
503
+ for (const r of (S.runs||[]).slice(0,8)) {
504
+ const runId = r.runId || r.id;
505
+ if (!runId) continue;
506
+ const status = String(r.status || r.state || '').toLowerCase();
507
+ const stage = String(r.stage || '').toLowerCase();
508
+ const lane = status.includes('complete') || status === 'done' || status === 'failed'
509
+ ? 'done'
510
+ : stage.includes('hire')
511
+ ? 'hiring'
512
+ : stage.includes('ghost') || stage.includes('review')
513
+ ? 'ghostpass'
514
+ : status === 'running' || stage.includes('orchestrat')
515
+ ? 'running'
516
+ : 'planning';
517
+ if (!cols[lane].some(c=>c.id===runId)) {
518
+ cols[lane].push({
519
+ id: runId,
520
+ goal: r.goal||r.mandate||'(run)',
521
+ status: status || stage || 'queued',
522
+ tokens:r.tokensUsed||r.tokenCount,
523
+ time:r.completedAt||r.updatedAt||r.createdAt||r.startedAt,
524
+ role:null,
525
+ });
526
+ }
461
527
  }
462
528
  return cols;
463
529
  }
@@ -89,17 +89,42 @@ function decisionRows(entries) {
89
89
  }
90
90
 
91
91
  function renderRunRail(runs) {
92
- if (!runs.length) return '<div class="empty"><div class="empty-title">No runs recorded</div></div>';
92
+ if (!runs.length) return `<div class="empty">
93
+ <div class="empty-title">No runs recorded</div>
94
+ <div class="empty-sub">Run a goal from the command bar and its context spine will appear here.</div>
95
+ </div>`;
93
96
  return runs.map(run => {
94
97
  const id = runIdOf(run);
95
98
  const active = id === S.contextLogSelectedRunId;
96
99
  return `<button class="context-log-run ${active ? 'active' : ''}" data-context-run="${esc(id)}">
97
100
  <span>${esc(String(id).slice(-10) || 'run')}</span>
98
- <small>${esc(run.state || run.status || 'queued')} · ${esc(fmtTs(run.createdAt))}</small>
101
+ <small>${esc(run.stage || run.state || run.status || 'queued')} · ${esc(fmtTs(run.createdAt || run.startedAt || run.updatedAt))}</small>
99
102
  </button>`;
100
103
  }).join('');
101
104
  }
102
105
 
106
+ function emptyContextMain() {
107
+ return `<div class="context-log-summary context-log-empty-state">
108
+ <div class="context-log-kpi"><span>Runs</span><strong>0</strong></div>
109
+ <div class="context-log-kpi"><span>Context</span><strong>—</strong></div>
110
+ <div class="context-log-kpi"><span>Decision</span><strong>—</strong></div>
111
+ <div class="context-log-selection">
112
+ <div>
113
+ <span>Start</span>
114
+ <button class="btn btn-sm" id="context-log-run-goal-btn">Run a goal</button>
115
+ </div>
116
+ <div>
117
+ <span>Expected here</span>
118
+ ${chips(['request brief', 'selection plan', 'context events', 'decisions'])}
119
+ </div>
120
+ <div>
121
+ <span>History</span>
122
+ <span class="context-log-empty">Previous runs persist after the dashboard restarts.</span>
123
+ </div>
124
+ </div>
125
+ </div>`;
126
+ }
127
+
103
128
  export async function load() {
104
129
  const runs = await api('/api/runs?limit=30', 3000);
105
130
  S.contextLogRuns = Array.isArray(runs) ? runs : [];
@@ -130,7 +155,7 @@ export function render() {
130
155
  ${renderRunRail(S.contextLogRuns || [])}
131
156
  </aside>
132
157
  <section class="context-log-main">
133
- ${spine ? summaryCard(spine) : '<div class="empty"><div class="empty-title">Select a run</div></div>'}
158
+ ${spine ? summaryCard(spine) : emptyContextMain()}
134
159
  <div class="context-log-grid">
135
160
  <div>
136
161
  <div class="shd">Context events</div>
@@ -150,6 +175,10 @@ export function render() {
150
175
  await load();
151
176
  });
152
177
  });
178
+ $('context-log-run-goal-btn')?.addEventListener('click', () => {
179
+ window.location.hash = '#board';
180
+ setTimeout(() => $('cmd-input')?.focus(), 100);
181
+ });
153
182
  $('context-log-refresh-btn')?.addEventListener('click', async () => {
154
183
  bustCache('/api/runs?limit=30');
155
184
  if (S.contextLogSelectedRunId) bustCache(spineUrl(S.contextLogSelectedRunId));