declare-cc 0.6.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -87,18 +87,24 @@ let editFormError = null;
87
87
  /** @type {string|null} ID of declaration showing delete confirmation */
88
88
  let deleteConfirmId = null;
89
89
 
90
- /** @type {string | null} Active derivation session ID */
90
+ /** @type {string | null} Active derivation session ID (legacy single-session) */
91
91
  let derivationSessionId = null;
92
92
  /** @type {Array<{title: string, realizes: string, reason: string}> | null} */
93
93
  let derivationProposals = null;
94
94
 
95
- /** @type {string | null} Active action derivation session ID */
95
+ /** @type {Map<string, { sessionId: string, status: string }>} Tracks active derivations per declaration */
96
+ const activeDeriveMap = new Map();
97
+
98
+ /** @type {string | null} Active action derivation session ID (for the drilled-into milestone) */
96
99
  let actionDerivationSessionId = null;
97
- /** @type {string | null} Milestone ID for the current action derivation */
100
+ /** @type {string | null} Milestone ID for the current action derivation (drilled-into) */
98
101
  let actionDerivationMilestoneId = null;
99
102
  /** @type {Array<{title: string, produces: string, reason: string}> | null} */
100
103
  let actionDerivationProposals = null;
101
104
 
105
+ /** @type {Set<string>} Node IDs with in-flight planning (immediate visual feedback before agent registry updates) */
106
+ const pendingDerivations = new Set();
107
+
102
108
  /** @type {number|null} Line number currently being annotated */
103
109
  let annotatingLine = null;
104
110
  /** @type {string|null} Node ID of the currently displayed annotation panel */
@@ -135,6 +141,20 @@ let execCompletedActions = 0;
135
141
  let execFailedActions = 0;
136
142
 
137
143
 
144
+ // ─── Onboarding flow state ─────────────────────────────────────────────────────
145
+ /** @type {'idle'|'questions'|'proposals'|'approving'} Current onboard phase */
146
+ let onboardPhase = 'idle';
147
+ /** @type {string|null} The original vision prompt */
148
+ let onboardPrompt = null;
149
+ /** @type {Array<{question: string, context: string, options?: string[]}>|null} */
150
+ let onboardQuestions = null;
151
+ /** @type {Array<{title: string, statement: string, reasoning: string, approvedId?: string}>|null} */
152
+ let onboardProposals = null;
153
+ /** @type {number} Current index being approved */
154
+ let onboardApproveIndex = 0;
155
+ /** @type {string} Streaming text buffer */
156
+ let onboardStreamText = '';
157
+
138
158
  // ─── DOM refs ─────────────────────────────────────────────────────────────────
139
159
 
140
160
  const $overlay = document.getElementById('overlay');
@@ -151,7 +171,7 @@ const $healthBadge = document.getElementById('health-badge');
151
171
  const $lastUpdated = document.getElementById('last-updated');
152
172
  const $refreshBtn = document.getElementById('refresh-btn');
153
173
  const $executeMainBtn = document.getElementById('execute-main-btn');
154
- const $activityPinned = document.getElementById('activity-pinned');
174
+ // $activityPinned removed — replaced by agent cards panel (A-124)
155
175
  const $statusBreadcrumb = document.getElementById('status-breadcrumb');
156
176
 
157
177
  // Project name click → go back to declarations (home)
@@ -227,6 +247,20 @@ const $wfActionBtn = document.getElementById('wf-action-btn');
227
247
  /** @type {{ state: string, nextStep: { label: string, action: string, targetId?: string }, progress: { percentage: number, declarations: number, milestones: number, actions: number, actionsDone: number } } | null} */
228
248
  let workflowState = null;
229
249
 
250
+ /** @type {{ stages: Record<string, Array<{id:string,title:string,type:string,status:string,reviewState?:string,stage:string}>>, nextAction: {action:string,label:string,targetId?:string,targetType?:string}|null, progress: {total:number,done:number,percentage:number} } | null} */
251
+ let lifecycleData = null;
252
+
253
+ /** @type {string|null} Active lifecycle stage filter — null means show all stages */
254
+ let lifecycleFilter = null;
255
+
256
+ /** @type {boolean} Whether Done section is collapsed */
257
+ let lifecycleDoneCollapsed = true;
258
+
259
+ /** @type {string|null} Lifecycle filter for level 2 (milestones) */
260
+ let lifecycleFilterL2 = null;
261
+ /** @type {string|null} Lifecycle filter for level 3 (actions) */
262
+ let lifecycleFilterL3 = null;
263
+
230
264
  // ─── Utilities ────────────────────────────────────────────────────────────────
231
265
 
232
266
  /**
@@ -339,6 +373,9 @@ async function loadData() {
339
373
  // Sync topbar with running operations
340
374
  syncTopbarFromRunning();
341
375
 
376
+ // Restore running derivation sessions after page refresh
377
+ await restoreRunningDerivations();
378
+
342
379
  hideOverlay();
343
380
  renderStatusBar();
344
381
  renderGraph();
@@ -347,6 +384,7 @@ async function loadData() {
347
384
  updateLastUpdated();
348
385
  checkProjectComplete(graph);
349
386
  loadWorkflowState();
387
+ loadLifecycleData();
350
388
 
351
389
  // Apply persisted view mode (shows correct container, hides the other)
352
390
  switchView(viewMode);
@@ -451,6 +489,7 @@ function renderStatusBar() {
451
489
  // ─── Node element builder ─────────────────────────────────────────────────────
452
490
 
453
491
  const COMPLETED = new Set(['DONE','KEPT','HONORED']);
492
+ const EXECUTING_STATUSES_SET = new Set(['EXECUTING', 'IN_PROGRESS', 'RUNNING']);
454
493
  const IN_PROGRESS_STORED = new Set(['ACTIVE']);
455
494
 
456
495
  /**
@@ -1109,6 +1148,9 @@ function saveDrillNavState() {
1109
1148
  function drillGoDeeper(newLevel) {
1110
1149
  saveDrillNavState();
1111
1150
  drillLevel = newLevel;
1151
+ // Reset level-specific lifecycle filters when navigating
1152
+ if (newLevel === 'milestones') lifecycleFilterL2 = null;
1153
+ if (newLevel === 'actions') lifecycleFilterL3 = null;
1112
1154
  pushDrillHash();
1113
1155
  }
1114
1156
 
@@ -1119,6 +1161,128 @@ function drillGoBack(newLevel) {
1119
1161
  pushDrillHash();
1120
1162
  }
1121
1163
 
1164
+ /**
1165
+ * Navigate the drill browser to the result of a completed agent.
1166
+ * Maps agent type + result metadata to the correct drill state.
1167
+ * @param {object} agent - AgentRecord from the registry
1168
+ */
1169
+ function navigateToResult(agent) {
1170
+ if (!graphData) return;
1171
+ const result = agent.result || {};
1172
+ const milestones = graphData.milestones || [];
1173
+
1174
+ switch (agent.type) {
1175
+ case 'execution': {
1176
+ // Navigate to the milestone's action list
1177
+ const mileId = result.milestoneId || agent.milestoneId;
1178
+ if (mileId) {
1179
+ const mile = milestones.find(m => m.id === mileId);
1180
+ if (mile && mile.realizes && mile.realizes.length) {
1181
+ drillDeclId = mile.realizes[0];
1182
+ }
1183
+ drillMileId = mileId;
1184
+ drillLevel = 'actions';
1185
+ }
1186
+ break;
1187
+ }
1188
+ case 'derivation': {
1189
+ // Navigate to the declaration's milestone list
1190
+ // The target is the declaration ID (e.g., "D-16") or "all"
1191
+ const declId = agent.target !== 'all' ? agent.target : null;
1192
+ if (declId) {
1193
+ drillDeclId = declId;
1194
+ drillLevel = 'milestones';
1195
+ } else {
1196
+ // "all" derivation — go to declarations list
1197
+ drillDeclId = null;
1198
+ drillMileId = null;
1199
+ drillLevel = 'declarations';
1200
+ }
1201
+ drillMileId = null;
1202
+ break;
1203
+ }
1204
+ case 'action-derivation': {
1205
+ // Navigate to the milestone's action list
1206
+ const mileId = result.milestoneId || agent.milestoneId || agent.target;
1207
+ if (mileId) {
1208
+ const mile = milestones.find(m => m.id === mileId);
1209
+ if (mile && mile.realizes && mile.realizes.length) {
1210
+ drillDeclId = mile.realizes[0];
1211
+ }
1212
+ drillMileId = mileId;
1213
+ drillLevel = 'actions';
1214
+ }
1215
+ break;
1216
+ }
1217
+ case 'revision': {
1218
+ // Navigate to the revised node — could be declaration or milestone
1219
+ const nodeId = result.nodeId || agent.target;
1220
+ if (nodeId && nodeId.startsWith('M-')) {
1221
+ // Milestone revision — navigate to its action list
1222
+ const mile = milestones.find(m => m.id === nodeId);
1223
+ if (mile && mile.realizes && mile.realizes.length) {
1224
+ drillDeclId = mile.realizes[0];
1225
+ }
1226
+ drillMileId = nodeId;
1227
+ drillLevel = 'actions';
1228
+ } else if (nodeId && nodeId.startsWith('D-')) {
1229
+ // Declaration revision — navigate to its milestone list
1230
+ drillDeclId = nodeId;
1231
+ drillMileId = null;
1232
+ drillLevel = 'milestones';
1233
+ }
1234
+ break;
1235
+ }
1236
+ case 'pipeline': {
1237
+ // Navigate to the milestone targeted by the pipeline
1238
+ const mileId = agent.milestoneId || agent.target;
1239
+ if (mileId && mileId.startsWith('M-')) {
1240
+ const mile = milestones.find(m => m.id === mileId);
1241
+ if (mile && mile.realizes && mile.realizes.length) {
1242
+ drillDeclId = mile.realizes[0];
1243
+ }
1244
+ drillMileId = mileId;
1245
+ drillLevel = 'actions';
1246
+ } else {
1247
+ drillDeclId = null;
1248
+ drillMileId = null;
1249
+ drillLevel = 'declarations';
1250
+ }
1251
+ break;
1252
+ }
1253
+ case 'refine':
1254
+ case 'discuss': {
1255
+ // Navigate to the refined/discussed node — same as revision
1256
+ const nodeId = result.nodeId || agent.target;
1257
+ if (nodeId && nodeId.startsWith('M-')) {
1258
+ const mile = milestones.find(m => m.id === nodeId);
1259
+ if (mile && mile.realizes && mile.realizes.length) {
1260
+ drillDeclId = mile.realizes[0];
1261
+ }
1262
+ drillMileId = nodeId;
1263
+ drillLevel = 'actions';
1264
+ } else if (nodeId && nodeId.startsWith('D-')) {
1265
+ drillDeclId = nodeId;
1266
+ drillMileId = null;
1267
+ drillLevel = 'milestones';
1268
+ }
1269
+ break;
1270
+ }
1271
+ default: {
1272
+ // Unknown agent type — fall back to declarations
1273
+ drillDeclId = null;
1274
+ drillMileId = null;
1275
+ drillLevel = 'declarations';
1276
+ break;
1277
+ }
1278
+ }
1279
+
1280
+ // Switch to columns view if not already there and render
1281
+ if (viewMode !== 'columns') switchView('columns');
1282
+ pushDrillHash();
1283
+ renderDrillView();
1284
+ }
1285
+
1122
1286
  /** Build the hash string for current drill state */
1123
1287
  function drillHashString() {
1124
1288
  let hash = '#/';
@@ -1177,6 +1341,13 @@ window.addEventListener('popstate', () => {
1177
1341
  */
1178
1342
  function renderDrillView() {
1179
1343
  if (!$drillBrowser || !graphData) return;
1344
+
1345
+ // If onboarding is active, render it instead
1346
+ if (onboardPhase !== 'idle') {
1347
+ renderOnboardUI();
1348
+ return;
1349
+ }
1350
+
1180
1351
  syncDrillHash(); // Keep URL in sync without creating history entries
1181
1352
 
1182
1353
  const { declarations, milestones, actions } = graphData;
@@ -1229,6 +1400,11 @@ function renderDrillView() {
1229
1400
  updateStatusBreadcrumb(enrichedDeclarations, enrichedMilestones);
1230
1401
  // Update next/execute button
1231
1402
  updateNextButton(enrichedDeclarations, enrichedMilestones, actions || []);
1403
+
1404
+ // Re-attach discuss container if a discuss session is active (survives re-renders)
1405
+ if (discussActiveNodeId && discussActiveContainer) {
1406
+ reattachDiscussContainer();
1407
+ }
1232
1408
  }
1233
1409
 
1234
1410
  /**
@@ -1276,47 +1452,100 @@ function updateStatusBreadcrumb(enrichedDeclarations, enrichedMilestones) {
1276
1452
 
1277
1453
  /**
1278
1454
  * Find the next unapproved node and update the main button.
1455
+ * Uses lifecycle nextAction when available for smarter guidance.
1279
1456
  */
1280
1457
  function updateNextButton(enrichedDeclarations, enrichedMilestones, actions) {
1281
1458
  if (!$executeMainBtn) return;
1282
1459
 
1283
- // Find first unapproved node across all levels
1284
- const unapprovedDecl = enrichedDeclarations.find(d => d.reviewState !== 'approved');
1285
- const unapprovedMile = enrichedMilestones.find(m => m.reviewState !== 'approved');
1286
- const unapprovedAction = (actions || []).find(a => a.reviewState !== 'approved' && !COMPLETED.has((a.status || '').toUpperCase()));
1287
-
1288
- const hasUnapproved = unapprovedDecl || unapprovedMile || unapprovedAction;
1460
+ // Use lifecycle nextAction if available
1461
+ const next = lifecycleData ? lifecycleData.nextAction : null;
1289
1462
 
1290
- if (hasUnapproved) {
1291
- $executeMainBtn.innerHTML = '<kbd>N</kbd> Next';
1292
- $executeMainBtn.className = 'btn-next';
1293
- $executeMainBtn.id = 'execute-main-btn';
1294
- $executeMainBtn.disabled = false;
1295
- $executeMainBtn.title = 'Go to next item needing approval';
1296
-
1297
- // Store target for click handler
1298
- $executeMainBtn._nextTarget = { decl: unapprovedDecl, mile: unapprovedMile, action: unapprovedAction };
1299
- $executeMainBtn._planMode = false;
1300
- } else {
1301
- // Check if any declarations still need milestones (PENDING = no milestones planned)
1302
- const needsPlanning = enrichedDeclarations.some(d => d.displayStatus === 'PENDING');
1303
-
1304
- if (needsPlanning) {
1305
- $executeMainBtn.innerHTML = '<kbd>P</kbd> Plan';
1463
+ if (next) {
1464
+ if (next.action === 'derive-milestones' || next.action === 'derive-actions') {
1465
+ $executeMainBtn.innerHTML = '<kbd>⌃⇧P</kbd> Plan';
1306
1466
  $executeMainBtn.className = 'btn-plan';
1307
1467
  $executeMainBtn.id = 'execute-main-btn';
1308
1468
  $executeMainBtn.disabled = false;
1309
- $executeMainBtn.title = 'Plan milestones for unplanned declarations';
1469
+ $executeMainBtn.title = next.label;
1310
1470
  $executeMainBtn._nextTarget = null;
1311
1471
  $executeMainBtn._planMode = true;
1312
- } else {
1472
+ $executeMainBtn._lifecycleAction = next;
1473
+ } else if (next.action === 'approve') {
1474
+ $executeMainBtn.innerHTML = '<kbd>N</kbd> Next';
1475
+ $executeMainBtn.className = 'btn-next';
1476
+ $executeMainBtn.id = 'execute-main-btn';
1477
+ $executeMainBtn.disabled = false;
1478
+ $executeMainBtn.title = next.label;
1479
+ $executeMainBtn._nextTarget = null;
1480
+ $executeMainBtn._planMode = false;
1481
+ $executeMainBtn._lifecycleAction = next;
1482
+ } else if (next.action === 'execute') {
1313
1483
  $executeMainBtn.innerHTML = '<kbd>E</kbd> Execute';
1314
1484
  $executeMainBtn.className = '';
1315
1485
  $executeMainBtn.id = 'execute-main-btn';
1316
1486
  $executeMainBtn.disabled = !canEnterExecution();
1317
- $executeMainBtn.title = canEnterExecution() ? 'Enter execution mode' : 'No actions to execute';
1487
+ $executeMainBtn.title = next.label;
1318
1488
  $executeMainBtn._nextTarget = null;
1319
1489
  $executeMainBtn._planMode = false;
1490
+ $executeMainBtn._lifecycleAction = next;
1491
+ } else if (next.action === 'complete') {
1492
+ $executeMainBtn.innerHTML = '\u2713 Done';
1493
+ $executeMainBtn.className = 'btn-done';
1494
+ $executeMainBtn.id = 'execute-main-btn';
1495
+ $executeMainBtn.disabled = true;
1496
+ $executeMainBtn.title = 'All items complete';
1497
+ $executeMainBtn._nextTarget = null;
1498
+ $executeMainBtn._planMode = false;
1499
+ $executeMainBtn._lifecycleAction = null;
1500
+ } else {
1501
+ // view-execution or other — show status
1502
+ $executeMainBtn.innerHTML = next.label;
1503
+ $executeMainBtn.className = '';
1504
+ $executeMainBtn.id = 'execute-main-btn';
1505
+ $executeMainBtn.disabled = true;
1506
+ $executeMainBtn.title = next.label;
1507
+ $executeMainBtn._nextTarget = null;
1508
+ $executeMainBtn._planMode = false;
1509
+ $executeMainBtn._lifecycleAction = null;
1510
+ }
1511
+ } else {
1512
+ // Fallback to original logic when lifecycle data not available
1513
+ const unapprovedDecl = enrichedDeclarations.find(d => d.reviewState !== 'approved');
1514
+ const unapprovedMile = enrichedMilestones.find(m => m.reviewState !== 'approved');
1515
+ const unapprovedAction = (actions || []).find(a => a.reviewState !== 'approved' && !COMPLETED.has((a.status || '').toUpperCase()));
1516
+
1517
+ const hasUnapproved = unapprovedDecl || unapprovedMile || unapprovedAction;
1518
+
1519
+ if (hasUnapproved) {
1520
+ $executeMainBtn.innerHTML = '<kbd>N</kbd> Next';
1521
+ $executeMainBtn.className = 'btn-next';
1522
+ $executeMainBtn.id = 'execute-main-btn';
1523
+ $executeMainBtn.disabled = false;
1524
+ $executeMainBtn.title = 'Go to next item needing approval';
1525
+ $executeMainBtn._nextTarget = { decl: unapprovedDecl, mile: unapprovedMile, action: unapprovedAction };
1526
+ $executeMainBtn._planMode = false;
1527
+ $executeMainBtn._lifecycleAction = null;
1528
+ } else {
1529
+ const needsPlanning = enrichedDeclarations.some(d => d.displayStatus === 'PENDING');
1530
+ if (needsPlanning) {
1531
+ $executeMainBtn.innerHTML = '<kbd>⌃⇧P</kbd> Plan';
1532
+ $executeMainBtn.className = 'btn-plan';
1533
+ $executeMainBtn.id = 'execute-main-btn';
1534
+ $executeMainBtn.disabled = false;
1535
+ $executeMainBtn.title = 'Plan milestones for unplanned declarations';
1536
+ $executeMainBtn._nextTarget = null;
1537
+ $executeMainBtn._planMode = true;
1538
+ $executeMainBtn._lifecycleAction = null;
1539
+ } else {
1540
+ $executeMainBtn.innerHTML = '<kbd>E</kbd> Execute';
1541
+ $executeMainBtn.className = '';
1542
+ $executeMainBtn.id = 'execute-main-btn';
1543
+ $executeMainBtn.disabled = !canEnterExecution();
1544
+ $executeMainBtn.title = canEnterExecution() ? 'Enter execution mode' : 'No actions to execute';
1545
+ $executeMainBtn._nextTarget = null;
1546
+ $executeMainBtn._planMode = false;
1547
+ $executeMainBtn._lifecycleAction = null;
1548
+ }
1320
1549
  }
1321
1550
  }
1322
1551
 
@@ -1358,19 +1587,52 @@ function renderProjectDetail() {
1358
1587
  const mCount = stats.milestones || 0;
1359
1588
  const aCount = stats.actions || 0;
1360
1589
 
1590
+ // Lifecycle progress if available
1591
+ let progressHtml = '';
1592
+ if (lifecycleData) {
1593
+ const s = lifecycleData.stages;
1594
+ const total = Object.values(s).reduce((sum, arr) => sum + arr.length, 0);
1595
+ if (total > 0) {
1596
+ const segments = [
1597
+ { count: (s['needs-planning'] || []).length, color: '#fbbf24' },
1598
+ { count: (s['needs-approval'] || []).length, color: '#f97316' },
1599
+ { count: (s['ready-to-execute'] || []).length, color: '#86efac' },
1600
+ { count: (s['in-execution'] || []).length, color: '#60a5fa' },
1601
+ { count: (s['done'] || []).length, color: '#6b7280' },
1602
+ ];
1603
+ progressHtml = `<div class="detail-section-label">Progress</div>
1604
+ <div class="lifecycle-progress">${segments.map(seg =>
1605
+ seg.count > 0 ? `<div class="lifecycle-progress-segment" style="width:${(seg.count / total * 100).toFixed(1)}%;background:${seg.color}"></div>` : ''
1606
+ ).join('')}</div>
1607
+ <div class="detail-meta">${lifecycleData.progress.done}/${lifecycleData.progress.total} done (${lifecycleData.progress.percentage}%)</div>`;
1608
+ }
1609
+ }
1610
+
1361
1611
  $drillDetail.innerHTML = `
1362
1612
  <div class="detail-id" style="color:var(--accent)">PROJECT</div>
1363
1613
  <div class="detail-title">${escHtml(title)}</div>
1364
1614
  ${desc ? `<div class="detail-desc">${escHtml(desc)}</div>` : '<div class="detail-desc" style="color:var(--text-dim);font-style:italic">No project description yet. Run /declare:new-project to set one up.</div>'}
1365
1615
  ${core ? `<div class="detail-section-label">Core Value</div><div class="detail-desc">${escHtml(core)}</div>` : ''}
1366
1616
  ${currentState ? `<div class="detail-section-label">Current State</div><div class="detail-meta">${escHtml(currentState)}</div>` : ''}
1617
+ ${progressHtml}
1367
1618
  <div class="detail-section-label">Graph</div>
1368
1619
  <div class="detail-meta">${dCount} declarations &middot; ${mCount} milestones &middot; ${aCount} actions</div>
1369
1620
  `;
1370
1621
  }
1371
1622
 
1623
+ // ─── Lifecycle stage display config ──────────────────────────────────────────
1624
+
1625
+ const LIFECYCLE_STAGES = [
1626
+ { key: 'needs-planning', label: 'Needs Planning', color: '#fbbf24', icon: '\u270E' },
1627
+ { key: 'needs-approval', label: 'Needs Approval', color: '#f97316', icon: '\u2691' },
1628
+ { key: 'ready-to-execute', label: 'Ready to Execute', color: '#86efac', icon: '\u25B6' },
1629
+ { key: 'in-execution', label: 'In Execution', color: '#60a5fa', icon: '\u2699' },
1630
+ { key: 'done', label: 'Done', color: '#6b7280', icon: '\u2713' },
1631
+ ];
1632
+
1633
+
1372
1634
  /**
1373
- * Level 1 — List all declarations as cards.
1635
+ * Level 1 — Lifecycle-grouped view: all items grouped by stage.
1374
1636
  */
1375
1637
  function renderDrillDeclarations(enrichedDeclarations, enrichedMilestones, actions) {
1376
1638
  $drillContext.innerHTML = '';
@@ -1380,77 +1642,499 @@ function renderDrillDeclarations(enrichedDeclarations, enrichedMilestones, actio
1380
1642
  $drillDetail.classList.add('visible');
1381
1643
  renderProjectDetail();
1382
1644
 
1645
+ // Handle empty project — delegate to onboarding (M-56)
1383
1646
  if (enrichedDeclarations.length === 0) {
1384
- $drillList.innerHTML = '<div class="col-empty">No declarations found</div>';
1385
- $drillPrompt.innerHTML = '';
1647
+ renderEmptyOnboarding();
1386
1648
  return;
1387
1649
  }
1388
1650
 
1651
+ // Classify ONLY declarations into lifecycle stages
1652
+ const stages = classifyDeclarationsToStages(enrichedDeclarations, enrichedMilestones, actions);
1653
+ const nextAction = lifecycleData ? lifecycleData.nextAction : null;
1654
+
1655
+ // Render filter chips
1656
+ renderLifecycleFilterChipsGeneric($drillContext, stages, lifecycleFilter, (f) => {
1657
+ lifecycleFilter = f;
1658
+ renderDrillView();
1659
+ });
1660
+
1661
+ // Build declaration cards grouped by lifecycle stage
1389
1662
  let firstUnapproved = null;
1390
- const container = document.createElement('div');
1391
- container.className = 'drill-cards';
1392
- container.dataset.nodeType = 'declaration';
1663
+ const wrapper = document.createElement('div');
1664
+ wrapper.className = 'lifecycle-stages';
1665
+
1666
+ LIFECYCLE_STAGES.forEach(stageDef => {
1667
+ const items = stages[stageDef.key] || [];
1668
+ if (lifecycleFilter && lifecycleFilter !== stageDef.key) return;
1669
+ if (items.length === 0 && !lifecycleFilter) return;
1670
+
1671
+ const isDone = stageDef.key === 'done';
1672
+ const isCollapsed = isDone && lifecycleDoneCollapsed && !lifecycleFilter;
1673
+
1674
+ const section = document.createElement('div');
1675
+ section.className = `lifecycle-section${isCollapsed ? ' collapsed' : ''}`;
1676
+ section.dataset.stage = stageDef.key;
1677
+
1678
+ // Stage header with action buttons
1679
+ const header = document.createElement('div');
1680
+ header.className = 'lifecycle-header';
1681
+
1682
+ let stageBtnHtml = '';
1683
+ if (stageDef.key === 'needs-planning' && items.length > 0) {
1684
+ stageBtnHtml = '<button class="lifecycle-stage-btn" data-stage-action="plan-next">Plan Next</button>';
1685
+ stageBtnHtml += `<button class="lifecycle-stage-btn" data-stage-action="plan-all">Plan All (${items.length})</button>`;
1686
+ } else if (stageDef.key === 'ready-to-execute' && items.length > 0) {
1687
+ stageBtnHtml = '<button class="lifecycle-stage-btn" data-stage-action="execute">Execute</button>';
1688
+ }
1689
+
1690
+ header.innerHTML = `
1691
+ <span class="lifecycle-icon" style="color:${stageDef.color}">${stageDef.icon}</span>
1692
+ <span class="lifecycle-label">${escHtml(stageDef.label)}</span>
1693
+ <span class="lifecycle-count" style="background:${stageDef.color}20;color:${stageDef.color}">${items.length}</span>
1694
+ ${isDone ? `<span class="lifecycle-toggle">${isCollapsed ? '\u25B6' : '\u25BC'}</span>` : ''}
1695
+ <span class="lifecycle-header-spacer"></span>
1696
+ ${stageBtnHtml}
1697
+ `;
1698
+ if (isDone) {
1699
+ header.style.cursor = 'pointer';
1700
+ header.addEventListener('click', (e) => {
1701
+ if (e.target.closest('.lifecycle-stage-btn')) return;
1702
+ lifecycleDoneCollapsed = !lifecycleDoneCollapsed;
1703
+ renderDrillView();
1704
+ });
1705
+ }
1393
1706
 
1394
- enrichedDeclarations.forEach(d => {
1395
- const title = d.title || d.statement || d.id;
1396
- const desc = d.statement || '';
1397
- const myMilestones = enrichedMilestones.filter(m => (d.milestones || []).includes(m.id));
1398
- const needsReview = d.reviewState !== 'approved';
1399
- const isCurrent = needsReview && !firstUnapproved;
1400
- if (needsReview && !firstUnapproved) firstUnapproved = d;
1707
+ // Wire stage action buttons
1708
+ const stageBtn = header.querySelector('.lifecycle-stage-btn');
1709
+ if (stageBtn) {
1710
+ stageBtn.addEventListener('click', (e) => {
1711
+ e.stopPropagation();
1712
+ const action = stageBtn.dataset.stageAction;
1713
+ if (action === 'plan-next') {
1714
+ // Navigate into the first declaration needing planning
1715
+ const first = items[0];
1716
+ if (first) {
1717
+ drillDeclId = first.id;
1718
+ drillGoDeeper('milestones');
1719
+ renderDrillView();
1720
+ }
1721
+ } else if (action === 'plan-all') {
1722
+ // Kick off concurrent derivation for all declarations needing planning
1723
+ triggerDeriveAll();
1724
+ } else if (action === 'execute') {
1725
+ switchView('execution');
1726
+ }
1727
+ });
1728
+ }
1401
1729
 
1402
- const reviewMiles = myMilestones.filter(m => m.reviewState !== 'approved').length;
1403
- const statusClass = d.displayStatus === 'DONE' ? 's-done' : d.displayStatus === 'EXECUTING' ? 's-executing' : 's-planned';
1730
+ section.appendChild(header);
1404
1731
 
1405
- let badgesHtml = `<span class="drill-status-pill ${statusClass}">${escHtml(d.displayStatus)}</span>`;
1406
- badgesHtml += reviewBadgeHtml(d.id, d.reviewState);
1407
- if (myMilestones.length > 0) {
1408
- badgesHtml += `<span class="drill-card-stat">${myMilestones.length} milestones`;
1409
- if (reviewMiles > 0) badgesHtml += ` <strong>(${reviewMiles} need review)</strong>`;
1410
- badgesHtml += '</span>';
1732
+ // Cards declaration cards with statement text
1733
+ if (!isCollapsed) {
1734
+ const cardsContainer = document.createElement('div');
1735
+ cardsContainer.className = 'drill-cards lifecycle-cards';
1736
+ cardsContainer.dataset.nodeType = 'declaration';
1737
+
1738
+ items.forEach(d => {
1739
+ const needsReview = d.reviewState !== 'approved';
1740
+ const isCurrent = needsReview && !firstUnapproved;
1741
+ if (needsReview && !firstUnapproved) firstUnapproved = d;
1742
+
1743
+ const stmt = d.statement || d.title || '';
1744
+ const myMiles = enrichedMilestones.filter(m => (m.realizes || []).includes(d.id));
1745
+ const mileCount = myMiles.length;
1746
+ const unapprovedMiles = myMiles.filter(m => m.reviewState !== 'approved').length;
1747
+
1748
+ const isDeriving = activeDeriveMap.has(d.id);
1749
+ const runningAgent = getRunningAgentForNode(d.id);
1750
+ const isPending = pendingDerivations.has(d.id);
1751
+ const hasActiveWork = isDeriving || runningAgent || isPending;
1752
+
1753
+ let statusLabel = d.displayStatus || d.status || 'PENDING';
1754
+ const statusClass = statusLabel === 'DONE' ? 's-done' : statusLabel === 'EXECUTING' ? 's-executing' : 's-planned';
1755
+
1756
+ let badgesHtml = `<span class="drill-status-pill ${statusClass}">${escHtml(statusLabel)}</span>`;
1757
+ badgesHtml += reviewBadgeHtml(d.id, d.reviewState);
1758
+ if (mileCount > 0) {
1759
+ badgesHtml += `<span class="drill-card-stat">${mileCount} milestones`;
1760
+ if (unapprovedMiles > 0) badgesHtml += ` <strong>(${unapprovedMiles} need review)</strong>`;
1761
+ badgesHtml += '</span>';
1762
+ }
1763
+
1764
+ const activeLabel = isPending ? 'Starting\u2026' : isDeriving ? 'Planning\u2026' : runningAgent ? (AGENT_TYPE_LABELS[runningAgent.type] || runningAgent.type) + '\u2026' : '';
1765
+
1766
+ const el = document.createElement('div');
1767
+ el.className = `drill-card${needsReview ? ' needs-review' : ''}${isCurrent ? ' current-review' : ''}${hasActiveWork ? ' drill-card-deriving' : ''}`;
1768
+ el.innerHTML = `
1769
+ <div class="drill-card-top">
1770
+ <span class="drill-card-id">${escHtml(d.id)}</span>
1771
+ <div class="drill-card-body">
1772
+ <div class="drill-card-title">${escHtml(d.title || d.statement || d.id)}</div>
1773
+ <div class="drill-card-desc">${stmt !== (d.title || '') ? escHtml(stmt) : ''}</div>
1774
+ <div class="drill-card-badges">${badgesHtml}</div>
1775
+ </div>
1776
+ </div>
1777
+ ${renderEntityActions(d.id, d.reviewState, undefined, hasActiveWork ? activeLabel : null)}
1778
+ `;
1779
+
1780
+ el.addEventListener('click', (e) => {
1781
+ if (e.target.closest('.drill-review-btn') || e.target.closest('.drill-action-btn') || e.target.closest('textarea') || e.target.closest('input') || e.target.closest('.refine-container') || e.target.closest('.discuss-container')) return;
1782
+ drillDeclId = d.id;
1783
+ drillGoDeeper('milestones');
1784
+ renderDrillView();
1785
+ });
1786
+
1787
+ cardsContainer.appendChild(el);
1788
+ });
1789
+
1790
+ section.appendChild(cardsContainer);
1791
+ wireInlineReviewButtons(cardsContainer);
1792
+ wireEntityMenus(cardsContainer);
1411
1793
  }
1412
1794
 
1413
- const el = document.createElement('div');
1414
- el.className = `drill-card${needsReview ? ' needs-review' : ''}${isCurrent ? ' current-review' : ''}`;
1415
- el.innerHTML = `
1416
- <div class="drill-card-top">
1417
- <span class="drill-card-id">${escHtml(d.id)}</span>
1418
- <div class="drill-card-body">
1419
- <div class="drill-card-title">${escHtml(title)}</div>
1420
- <div class="drill-card-desc">${desc !== title ? escHtml(desc) : ''}</div>
1421
- <div class="drill-card-badges">${badgesHtml}</div>
1422
- </div>
1423
- </div>
1424
- ${renderEntityActions(d.id, d.reviewState)}
1425
- `;
1795
+ wrapper.appendChild(section);
1796
+ });
1426
1797
 
1427
- el.addEventListener('click', (e) => {
1428
- if (e.target.closest('.drill-review-btn') || e.target.closest('.drill-action-btn')) return;
1429
- drillDeclId = d.id;
1430
- drillGoDeeper('milestones');
1431
- renderDrillView();
1798
+ $drillList.appendChild(wrapper);
1799
+
1800
+ $drillPrompt.innerHTML = '';
1801
+ }
1802
+
1803
+
1804
+ /**
1805
+ * Navigate into a lifecycle item by type.
1806
+ */
1807
+ function navigateToItem(item, enrichedDeclarations, enrichedMilestones, actions) {
1808
+ if (item.type === 'declaration') {
1809
+ drillDeclId = item.id;
1810
+ drillGoDeeper('milestones');
1811
+ renderDrillView();
1812
+ } else if (item.type === 'milestone') {
1813
+ // Find parent declaration
1814
+ const decl = enrichedDeclarations.find(d => (d.milestones || []).includes(item.id));
1815
+ if (decl) drillDeclId = decl.id;
1816
+ drillMileId = item.id;
1817
+ drillGoDeeper('actions');
1818
+ renderDrillView();
1819
+ } else if (item.type === 'action') {
1820
+ // Find parent milestone and declaration
1821
+ const action = (actions || []).find(a => a.id === item.id);
1822
+ if (action) {
1823
+ const mId = (action.causes || [])[0];
1824
+ if (mId) {
1825
+ const mile = enrichedMilestones.find(m => m.id === mId);
1826
+ if (mile && mile.realizes && mile.realizes.length) drillDeclId = mile.realizes[0];
1827
+ drillMileId = mId;
1828
+ }
1829
+ }
1830
+ drillGoDeeper('actions');
1831
+ renderDrillView();
1832
+ }
1833
+ }
1834
+
1835
+ /**
1836
+ * Render the prompt bar for lifecycle view using nextAction intelligence.
1837
+ */
1838
+ function renderLifecyclePrompt(nextAction, enrichedDeclarations, enrichedMilestones, actions) {
1839
+ if (!$drillPrompt) return;
1840
+
1841
+ if (!nextAction) {
1842
+ $drillPrompt.innerHTML = '';
1843
+ return;
1844
+ }
1845
+
1846
+ if (nextAction.action === 'complete') {
1847
+ $drillPrompt.innerHTML = `<span class="drill-prompt-complete">${escHtml(nextAction.label)}</span>`;
1848
+ return;
1849
+ }
1850
+
1851
+ // Count unapproved items across all types
1852
+ const allNodes = [...enrichedDeclarations, ...enrichedMilestones, ...(actions || [])];
1853
+ const unapprovedCount = allNodes.filter(n => n.reviewState !== 'approved' && !COMPLETED.has((n.status || '').toUpperCase())).length;
1854
+
1855
+ const labelHtml = `<span class="drill-prompt-text">\u2192 ${escHtml(nextAction.label)}</span>`;
1856
+
1857
+ let actionsHtml = '';
1858
+ if (nextAction.targetId) {
1859
+ actionsHtml += `<button class="drill-next-btn" id="drill-lifecycle-go"><kbd>N</kbd> Go</button>`;
1860
+ }
1861
+ if (unapprovedCount > 0) {
1862
+ actionsHtml += `<button class="drill-approve-all-btn" id="drill-approve-all"><kbd>⌃⇧A</kbd> Approve All (${unapprovedCount})</button>`;
1863
+ }
1864
+
1865
+ $drillPrompt.innerHTML = labelHtml + actionsHtml;
1866
+
1867
+ // Wire go button
1868
+ const goBtn = document.getElementById('drill-lifecycle-go');
1869
+ if (goBtn && nextAction.targetId) {
1870
+ goBtn.addEventListener('click', () => {
1871
+ if (nextAction.action === 'derive-milestones') {
1872
+ drillDeclId = nextAction.targetId;
1873
+ drillGoDeeper('milestones');
1874
+ renderDrillView();
1875
+ } else if (nextAction.action === 'derive-actions') {
1876
+ // Find parent declaration
1877
+ const mile = enrichedMilestones.find(m => m.id === nextAction.targetId);
1878
+ if (mile && mile.realizes && mile.realizes.length) drillDeclId = mile.realizes[0];
1879
+ drillMileId = nextAction.targetId;
1880
+ drillGoDeeper('actions');
1881
+ renderDrillView();
1882
+ } else if (nextAction.action === 'approve') {
1883
+ // Navigate to the node needing approval
1884
+ navigateToItem({
1885
+ id: nextAction.targetId,
1886
+ type: nextAction.targetType || 'declaration',
1887
+ }, enrichedDeclarations, enrichedMilestones, actions);
1888
+ } else if (nextAction.action === 'execute') {
1889
+ switchView('execution');
1890
+ }
1432
1891
  });
1892
+ }
1893
+
1894
+ // Wire approve-all
1895
+ const approveAllBtn = document.getElementById('drill-approve-all');
1896
+ if (approveAllBtn) {
1897
+ approveAllBtn.addEventListener('click', () => approveAllVisible());
1898
+ }
1899
+ }
1900
+
1901
+ /**
1902
+ * Classify declarations into lifecycle stages (declarations only, no mixing).
1903
+ */
1904
+ function classifyDeclarationsToStages(declarations, milestones, actions) {
1905
+ const stages = { 'needs-planning': [], 'needs-approval': [], 'ready-to-execute': [], 'in-execution': [], 'done': [] };
1906
+ for (const d of declarations) {
1907
+ const status = (d.status || '').toUpperCase();
1908
+ if (COMPLETED.has(status) || d.displayStatus === 'DONE') {
1909
+ stages['done'].push(d);
1910
+ continue;
1911
+ }
1912
+
1913
+ // A declaration's stage is determined by the worst stage of its children.
1914
+ // No milestones at all → needs planning.
1915
+ const myMiles = milestones.filter(m => (m.realizes || []).includes(d.id));
1916
+ if (myMiles.length === 0) {
1917
+ stages['needs-planning'].push(d);
1918
+ continue;
1919
+ }
1920
+
1921
+ // Check each milestone's stage and bubble up the worst one.
1922
+ // Priority (worst to best): needs-planning > needs-approval > ready-to-execute > in-execution > done
1923
+ let worstStage = 'done';
1924
+ const stageRank = { 'needs-planning': 0, 'needs-approval': 1, 'ready-to-execute': 2, 'in-execution': 3, 'done': 4 };
1925
+
1926
+ for (const m of myMiles) {
1927
+ const mStatus = (m.status || m.displayStatus || '').toUpperCase();
1928
+ let mStage;
1929
+ if (COMPLETED.has(mStatus) || m.displayStatus === 'DONE') {
1930
+ mStage = 'done';
1931
+ } else if (!(actions || []).some(a => (a.causes || []).includes(m.id))) {
1932
+ mStage = 'needs-planning';
1933
+ } else if (m.reviewState !== 'approved') {
1934
+ mStage = 'needs-approval';
1935
+ } else if (m.displayStatus === 'EXECUTING' || EXECUTING_STATUSES_SET.has(mStatus)) {
1936
+ mStage = 'in-execution';
1937
+ } else {
1938
+ mStage = 'ready-to-execute';
1939
+ }
1940
+ if (stageRank[mStage] < stageRank[worstStage]) {
1941
+ worstStage = mStage;
1942
+ }
1943
+ }
1944
+
1945
+ // Declaration's own review state can also pull it back
1946
+ if (d.reviewState !== 'approved' && stageRank['needs-approval'] < stageRank[worstStage]) {
1947
+ worstStage = 'needs-approval';
1948
+ }
1433
1949
 
1434
- container.appendChild(el);
1950
+ stages[worstStage].push(d);
1951
+ }
1952
+ return stages;
1953
+ }
1954
+
1955
+ /**
1956
+ * Classify a list of milestones into lifecycle stages.
1957
+ */
1958
+ function classifyMilestonesToStages(milestones, actions) {
1959
+ const stages = { 'needs-planning': [], 'needs-approval': [], 'ready-to-execute': [], 'in-execution': [], 'done': [] };
1960
+ for (const m of milestones) {
1961
+ const status = (m.status || m.displayStatus || '').toUpperCase();
1962
+ if (COMPLETED.has(status) || m.displayStatus === 'DONE') {
1963
+ stages['done'].push(m);
1964
+ } else {
1965
+ const myActions = (actions || []).filter(a => (a.causes || []).includes(m.id));
1966
+ const hasActions = myActions.length > 0;
1967
+ const hasNoActionPlan = !m.hasPlan && !hasActions;
1968
+ const allActionsApproved = hasActions && myActions.every(a => a.reviewState === 'approved' || COMPLETED.has((a.status || '').toUpperCase()));
1969
+ const hasUnapprovedActions = hasActions && !allActionsApproved;
1970
+ if (hasNoActionPlan) {
1971
+ stages['needs-planning'].push(m);
1972
+ } else if (m.reviewState !== 'approved' || hasUnapprovedActions) {
1973
+ stages['needs-approval'].push(m);
1974
+ } else if (m.displayStatus === 'EXECUTING' || EXECUTING_STATUSES_SET.has(status)) {
1975
+ stages['in-execution'].push(m);
1976
+ } else {
1977
+ stages['ready-to-execute'].push(m);
1978
+ }
1979
+ }
1980
+ }
1981
+ return stages;
1982
+ }
1983
+
1984
+ /**
1985
+ * Classify a list of actions into lifecycle stages.
1986
+ */
1987
+ function classifyActionsToStages(actionsArr) {
1988
+ const stages = { 'needs-planning': [], 'needs-approval': [], 'ready-to-execute': [], 'in-execution': [], 'done': [] };
1989
+ for (const a of actionsArr) {
1990
+ const status = (a.status || '').toUpperCase();
1991
+ if (COMPLETED.has(status)) {
1992
+ stages['done'].push(a);
1993
+ } else if (runningActions.has(a.id) || EXECUTING_STATUSES_SET.has(status)) {
1994
+ stages['in-execution'].push(a);
1995
+ } else if (a.reviewState !== 'approved') {
1996
+ stages['needs-approval'].push(a);
1997
+ } else {
1998
+ stages['ready-to-execute'].push(a);
1999
+ }
2000
+ }
2001
+ return stages;
2002
+ }
2003
+
2004
+ /**
2005
+ * Render lifecycle filter chips into a container for any level.
2006
+ * @param {HTMLElement} container - Element to render chips into
2007
+ * @param {Object} stages - Stage classification
2008
+ * @param {string|null} activeFilter - Current filter
2009
+ * @param {function} onFilter - Callback when filter changes
2010
+ */
2011
+ function renderLifecycleFilterChipsGeneric(container, stages, activeFilter, onFilter) {
2012
+ const chips = document.createElement('div');
2013
+ chips.className = 'lifecycle-filter-chips';
2014
+
2015
+ const allChip = document.createElement('button');
2016
+ allChip.className = `lifecycle-chip${!activeFilter ? ' active' : ''}`;
2017
+ allChip.textContent = 'All';
2018
+ allChip.addEventListener('click', () => onFilter(null));
2019
+ chips.appendChild(allChip);
2020
+
2021
+ LIFECYCLE_STAGES.forEach(stageDef => {
2022
+ const items = stages[stageDef.key] || [];
2023
+ if (items.length === 0) return;
2024
+ const chip = document.createElement('button');
2025
+ chip.className = `lifecycle-chip${activeFilter === stageDef.key ? ' active' : ''}`;
2026
+ chip.innerHTML = `${escHtml(stageDef.label)} <span class="chip-count">${items.length}</span>`;
2027
+ chip.style.setProperty('--chip-color', stageDef.color);
2028
+ chip.addEventListener('click', () => onFilter(activeFilter === stageDef.key ? null : stageDef.key));
2029
+ chips.appendChild(chip);
1435
2030
  });
1436
2031
 
1437
- $drillList.appendChild(container);
1438
- wireInlineReviewButtons(container);
1439
- wireEntityMenus(container);
1440
-
1441
- // Prompt
1442
- if (firstUnapproved) {
1443
- $drillPrompt.innerHTML = `<span class="drill-prompt-text">\u2192 Review <span class="drill-prompt-target" data-drill-target="${escHtml(firstUnapproved.id)}">${escHtml(firstUnapproved.id)}</span> next</span>`;
1444
- $drillPrompt.querySelector('.drill-prompt-target').addEventListener('click', () => {
1445
- drillDeclId = firstUnapproved.id;
1446
- drillLevel = 'milestones';
1447
- renderDrillView();
1448
- });
1449
- } else {
1450
- $drillPrompt.innerHTML = `<span class="drill-prompt-complete">All declarations reviewed</span>
1451
- <button class="drill-next-btn" id="drill-next-btn"><kbd>N</kbd> Next</button>`;
1452
- document.getElementById('drill-next-btn').addEventListener('click', () => drillAdvanceNext());
2032
+ container.innerHTML = '';
2033
+ container.appendChild(chips);
2034
+ }
2035
+
2036
+ /**
2037
+ * Render items grouped by lifecycle stage sections.
2038
+ * @param {HTMLElement} listEl - Element to render sections into
2039
+ * @param {Object} stages - Stage classification
2040
+ * @param {string|null} filter - Active filter
2041
+ * @param {function} buildCard - Function to build a card element for an item
2042
+ * @param {Object} [opts] - Options like doneCollapsed
2043
+ */
2044
+ function renderLifecycleSections(listEl, stages, filter, buildCard, opts) {
2045
+ const wrapper = document.createElement('div');
2046
+ wrapper.className = 'lifecycle-stages';
2047
+ const doneCollapsed = opts && opts.doneCollapsed;
2048
+
2049
+ LIFECYCLE_STAGES.forEach(stageDef => {
2050
+ const items = stages[stageDef.key] || [];
2051
+ if (filter && filter !== stageDef.key) return;
2052
+ if (items.length === 0 && !filter) return;
2053
+
2054
+ const isDone = stageDef.key === 'done';
2055
+ const isCollapsed = isDone && doneCollapsed && !filter;
2056
+
2057
+ const section = document.createElement('div');
2058
+ section.className = `lifecycle-section${isCollapsed ? ' collapsed' : ''}`;
2059
+ section.dataset.stage = stageDef.key;
2060
+
2061
+ const header = document.createElement('div');
2062
+ header.className = 'lifecycle-header';
2063
+ header.innerHTML = `
2064
+ <span class="lifecycle-icon" style="color:${stageDef.color}">${stageDef.icon}</span>
2065
+ <span class="lifecycle-label">${escHtml(stageDef.label)}</span>
2066
+ <span class="lifecycle-count" style="background:${stageDef.color}20;color:${stageDef.color}">${items.length}</span>
2067
+ ${isDone ? `<span class="lifecycle-toggle">${isCollapsed ? '\u25B6' : '\u25BC'}</span>` : ''}
2068
+ `;
2069
+ if (isDone) {
2070
+ header.style.cursor = 'pointer';
2071
+ header.addEventListener('click', () => {
2072
+ if (opts && opts.onToggleDone) opts.onToggleDone();
2073
+ });
2074
+ }
2075
+ section.appendChild(header);
2076
+
2077
+ if (!isCollapsed) {
2078
+ const cardsContainer = document.createElement('div');
2079
+ cardsContainer.className = 'drill-cards lifecycle-cards';
2080
+ if (opts && opts.nodeType) cardsContainer.dataset.nodeType = opts.nodeType;
2081
+
2082
+ items.forEach(item => {
2083
+ const el = buildCard(item);
2084
+ cardsContainer.appendChild(el);
2085
+ });
2086
+
2087
+ section.appendChild(cardsContainer);
2088
+ wireInlineReviewButtons(cardsContainer);
2089
+ wireEntityMenus(cardsContainer);
2090
+ }
2091
+
2092
+ wrapper.appendChild(section);
2093
+ });
2094
+
2095
+ listEl.appendChild(wrapper);
2096
+ }
2097
+
2098
+ /**
2099
+ * Empty state onboarding — shown when project has no declarations.
2100
+ * Renders centered project name, inline declaration input, and explanation.
2101
+ */
2102
+ function renderEmptyOnboarding() {
2103
+ const projectName = (projectInfo && (projectInfo.title || projectInfo.folder)) || 'Your Project';
2104
+
2105
+ $drillList.innerHTML = `
2106
+ <div class="onboarding-empty">
2107
+ <div class="onboarding-project">${escHtml(projectName)}</div>
2108
+ <div class="onboarding-heading">Describe your vision</div>
2109
+ <div class="onboarding-desc">Tell us what you're building. We'll ask clarifying questions, then break it down into concrete declarations with milestones.</div>
2110
+ <div class="onboarding-form">
2111
+ <textarea id="onboarding-vision" class="onboarding-textarea" placeholder="Describe what you're building, what problems it solves, key features, target users..." rows="10"></textarea>
2112
+ <button id="onboarding-submit" class="onboarding-btn">Let's go</button>
2113
+ <div id="onboarding-error" class="onboarding-error"></div>
2114
+ </div>
2115
+ </div>
2116
+ `;
2117
+
2118
+ $drillPrompt.innerHTML = '';
2119
+
2120
+ const $vision = document.getElementById('onboarding-vision');
2121
+ const $btn = document.getElementById('onboarding-submit');
2122
+ const $err = document.getElementById('onboarding-error');
2123
+
2124
+ function submitVision() {
2125
+ const text = ($vision.value || '').trim();
2126
+ if (!text) {
2127
+ $err.textContent = 'Describe your vision first.';
2128
+ return;
2129
+ }
2130
+ startOnboard(text);
1453
2131
  }
2132
+
2133
+ $btn.addEventListener('click', submitVision);
2134
+ $vision.addEventListener('keydown', (e) => {
2135
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submitVision();
2136
+ });
2137
+ requestAnimationFrame(() => $vision.focus());
1454
2138
  }
1455
2139
 
1456
2140
  /**
@@ -1487,58 +2171,77 @@ function renderDrillMilestones(enrichedDeclarations, enrichedMilestones, actions
1487
2171
  wireInlineReviewButtons($drillDetail);
1488
2172
  wireEntityMenus($drillDetail);
1489
2173
 
1490
- $drillContext.innerHTML = '';
1491
-
1492
2174
  const filtered = enrichedMilestones.filter(m => (m.realizes || []).includes(drillDeclId));
1493
2175
 
1494
2176
  $drillList.innerHTML = '';
1495
2177
  if (filtered.length === 0) {
2178
+ $drillContext.innerHTML = '';
1496
2179
  $drillList.innerHTML = `
1497
2180
  <div class="col-empty-invite">
1498
2181
  <div class="empty-invite-title">No milestones yet</div>
1499
2182
  <div class="empty-invite-desc">Plan milestones to break this declaration into achievable steps.</div>
1500
2183
  <textarea class="empty-invite-input" id="plan-guidance-input" placeholder="Optional: guide the direction..." rows="2"></textarea>
1501
2184
  <button class="empty-invite-btn" id="plan-milestones-btn"><kbd>P</kbd> Plan Milestones</button>
2185
+ <div id="derivation-progress" class="derivation-progress" style="display:none"></div>
1502
2186
  <div id="derivation-log" class="output-log" style="display:none; min-height:0"></div>
1503
- <div id="derivation-proposals" style="display:none"></div>
1504
2187
  </div>`;
1505
2188
  $drillPrompt.innerHTML = '';
1506
2189
 
1507
2190
  // Wire the Plan button to trigger derivation
1508
2191
  const planBtn = document.getElementById('plan-milestones-btn');
1509
2192
  if (planBtn) {
1510
- planBtn.addEventListener('click', () => {
2193
+ // If derivation is already running (triggered from declaration card or restored), show active state
2194
+ if (derivationSessionId) {
1511
2195
  planBtn.disabled = true;
1512
- const startTime = Date.now();
1513
- planBtn.innerHTML = '<span class="derive-spinner"></span> Deriving milestones\u2026 <span class="derive-elapsed">0s</span>';
2196
+ const startTime = derivationStartTime || Date.now();
2197
+ const elapsed0 = Math.floor((Date.now() - startTime) / 1000);
2198
+ planBtn.innerHTML = '<span class="derive-spinner"></span> Planning milestones\u2026 <span class="derive-elapsed">' + fmtElapsed(elapsed0) + '</span>';
1514
2199
  const elSpan = planBtn.querySelector('.derive-elapsed');
1515
2200
  const timer = setInterval(() => {
1516
2201
  if (!elSpan || !document.contains(elSpan)) { clearInterval(timer); return; }
1517
- const sec = Math.floor((Date.now() - startTime) / 1000);
1518
- elSpan.textContent = sec + 's';
2202
+ elSpan.textContent = fmtElapsed(Math.floor((Date.now() - startTime) / 1000));
1519
2203
  }, 1000);
1520
2204
  planBtn._deriveTimer = timer;
1521
- startDerivation(drillDeclId);
1522
- });
1523
- }
1524
- return;
1525
- }
2205
+ showDerivationProgress(startTime);
2206
+ }
1526
2207
 
1527
- let firstUnapproved = null;
1528
- const container = document.createElement('div');
1529
- container.className = 'drill-cards';
1530
- container.dataset.nodeType = 'milestone';
2208
+ planBtn.addEventListener('click', () => {
2209
+ planBtn.disabled = true;
2210
+ derivationStartTime = Date.now();
2211
+ const startTime = derivationStartTime;
2212
+ planBtn.innerHTML = '<span class="derive-spinner"></span> Planning milestones\u2026 <span class="derive-elapsed">0s</span>';
2213
+ const elSpan = planBtn.querySelector('.derive-elapsed');
2214
+ const timer = setInterval(() => {
2215
+ if (!elSpan || !document.contains(elSpan)) { clearInterval(timer); return; }
2216
+ elSpan.textContent = fmtElapsed(Math.floor((Date.now() - startTime) / 1000));
2217
+ }, 1000);
2218
+ planBtn._deriveTimer = timer;
2219
+ showDerivationProgress(startTime);
2220
+ startDerivation(drillDeclId);
2221
+ });
2222
+ }
2223
+ return;
2224
+ }
2225
+
2226
+ // Classify milestones into lifecycle stages
2227
+ const stages = classifyMilestonesToStages(filtered, actions);
2228
+
2229
+ // Render filter chips
2230
+ renderLifecycleFilterChipsGeneric($drillContext, stages, lifecycleFilterL2, (f) => {
2231
+ lifecycleFilterL2 = f;
2232
+ renderDrillView();
2233
+ });
1531
2234
 
1532
- filtered.forEach(m => {
2235
+ // Build cards grouped by lifecycle stage
2236
+ let firstUnapproved = null;
2237
+ renderLifecycleSections($drillList, stages, lifecycleFilterL2, (m) => {
1533
2238
  const mTitle = m.title || m.id;
1534
2239
  const needsReview = m.reviewState !== 'approved';
1535
2240
  const isCurrent = needsReview && !firstUnapproved;
1536
2241
  if (needsReview && !firstUnapproved) firstUnapproved = m;
1537
2242
 
1538
2243
  const myActions = (actions || []).filter(a => (a.causes || []).includes(m.id));
1539
-
1540
- // Build description: milestone produces, or summarize from child action produces
1541
- let mDesc = m.produces || '';
2244
+ let mDesc = m.description || m.produces || '';
1542
2245
  if (!mDesc && myActions.length > 0) {
1543
2246
  const actionDescs = myActions.map(a => a.produces).filter(Boolean);
1544
2247
  if (actionDescs.length > 0) mDesc = actionDescs.join(' \u00B7 ');
@@ -1562,6 +2265,26 @@ function renderDrillMilestones(enrichedDeclarations, enrichedMilestones, actions
1562
2265
  badgesHtml += '</span>';
1563
2266
  }
1564
2267
 
2268
+ // Inline action list for milestone cards
2269
+ let actionListHtml = '';
2270
+ if (myActions.length > 0) {
2271
+ actionListHtml = '<ul class="drill-card-action-list">' +
2272
+ myActions.map(a => {
2273
+ const aStatus = (a.status || 'PENDING').toUpperCase();
2274
+ const isDone = COMPLETED.has(aStatus);
2275
+ const isApproved = a.reviewState === 'approved';
2276
+ const icon = isDone ? '\u2713' : isApproved ? '\u25CB' : '\u00B7';
2277
+ const cls = isDone ? 'done' : isApproved ? 'planned' : 'unplanned';
2278
+ return `<li class="${cls}"><span class="al-icon">${icon}</span>${escHtml(a.title || a.id)}</li>`;
2279
+ }).join('') + '</ul>';
2280
+ } else {
2281
+ actionListHtml = '<div class="drill-card-no-actions">No actions yet</div>';
2282
+ }
2283
+
2284
+ const runningAgent = getRunningAgentForNode(m.id);
2285
+ const isPending = pendingDerivations.has(m.id);
2286
+ const hasActiveWork = runningAgent || isPending;
2287
+ const activeLabel = isPending ? 'Planning…' : runningAgent ? (AGENT_TYPE_LABELS[runningAgent.type] || runningAgent.type) + '…' : '';
1565
2288
  const el = document.createElement('div');
1566
2289
  el.className = `drill-card${needsReview ? ' needs-review' : ''}${isCurrent ? ' current-review' : ''}`;
1567
2290
  el.innerHTML = `
@@ -1571,38 +2294,23 @@ function renderDrillMilestones(enrichedDeclarations, enrichedMilestones, actions
1571
2294
  <div class="drill-card-title">${escHtml(mTitle)}</div>
1572
2295
  <div class="drill-card-desc">${mDesc !== mTitle ? escHtml(mDesc) : ''}</div>
1573
2296
  <div class="drill-card-badges">${badgesHtml}</div>
2297
+ ${actionListHtml}
1574
2298
  </div>
1575
2299
  </div>
1576
- ${renderEntityActions(m.id, m.reviewState)}
2300
+ ${renderEntityActions(m.id, m.reviewState, undefined, hasActiveWork ? activeLabel : null)}
1577
2301
  `;
1578
2302
 
1579
2303
  el.addEventListener('click', (e) => {
1580
- if (e.target.closest('.drill-review-btn') || e.target.closest('.drill-action-btn')) return;
2304
+ if (e.target.closest('.drill-review-btn') || e.target.closest('.drill-action-btn') || e.target.closest('textarea') || e.target.closest('input') || e.target.closest('.refine-container') || e.target.closest('.discuss-container')) return;
1581
2305
  drillMileId = m.id;
1582
2306
  drillGoDeeper('actions');
1583
2307
  renderDrillView();
1584
2308
  });
1585
2309
 
1586
- container.appendChild(el);
1587
- });
2310
+ return el;
2311
+ }, { nodeType: 'milestone', doneCollapsed: lifecycleDoneCollapsed, onToggleDone: () => { lifecycleDoneCollapsed = !lifecycleDoneCollapsed; renderDrillView(); } });
1588
2312
 
1589
- $drillList.appendChild(container);
1590
- wireInlineReviewButtons(container);
1591
- wireEntityMenus(container);
1592
-
1593
- // Prompt
1594
- if (firstUnapproved) {
1595
- $drillPrompt.innerHTML = `<span class="drill-prompt-text">\u2192 Review <span class="drill-prompt-target" data-drill-target="${escHtml(firstUnapproved.id)}">${escHtml(firstUnapproved.id)}</span> to continue</span>`;
1596
- $drillPrompt.querySelector('.drill-prompt-target').addEventListener('click', () => {
1597
- drillMileId = firstUnapproved.id;
1598
- drillLevel = 'actions';
1599
- renderDrillView();
1600
- });
1601
- } else {
1602
- $drillPrompt.innerHTML = `<span class="drill-prompt-complete">All milestones approved</span>
1603
- <button class="drill-next-btn" id="drill-next-btn"><kbd>N</kbd> Next</button>`;
1604
- document.getElementById('drill-next-btn').addEventListener('click', () => drillAdvanceNext());
1605
- }
2313
+ $drillPrompt.innerHTML = '';
1606
2314
  }
1607
2315
 
1608
2316
  /**
@@ -1621,7 +2329,7 @@ function renderDrillActions(enrichedDeclarations, enrichedMilestones, actions) {
1621
2329
  const filtered = (actions || []).filter(a => (a.causes || []).includes(drillMileId));
1622
2330
 
1623
2331
  // Left detail panel — milestone info + review controls
1624
- let produces = mile.produces || '';
2332
+ let produces = mile.description || mile.produces || '';
1625
2333
  if (!produces && filtered.length > 0) {
1626
2334
  const actionDescs = filtered.map(a => a.produces).filter(Boolean);
1627
2335
  if (actionDescs.length > 0) produces = actionDescs.join(' · ');
@@ -1638,7 +2346,14 @@ function renderDrillActions(enrichedDeclarations, enrichedMilestones, actions) {
1638
2346
 
1639
2347
  $drillDetail.classList.add('visible');
1640
2348
  $drillDetail.dataset.nodeType = 'milestone';
2349
+ const declDesc = decl.statement || decl.title || '';
1641
2350
  $drillDetail.innerHTML = `
2351
+ <div class="detail-parent-context">
2352
+ <div class="detail-id" style="opacity:0.6">${escHtml(decl.id)}</div>
2353
+ <div class="detail-title" style="font-size:14px;opacity:0.8">${escHtml(decl.title || '')}</div>
2354
+ ${decl.statement ? `<div class="detail-desc" style="font-size:12px;opacity:0.6">${escHtml(truncate(decl.statement, 200))}</div>` : ''}
2355
+ </div>
2356
+ <hr style="border:none;border-top:1px solid var(--bg-3);margin:10px 0">
1642
2357
  <div class="detail-id">${escHtml(mile.id)}</div>
1643
2358
  <div class="detail-title">${escHtml(mileTitle)}</div>
1644
2359
  <div class="detail-desc">${produces ? escHtml(produces) : ''}</div>
@@ -1646,29 +2361,61 @@ function renderDrillActions(enrichedDeclarations, enrichedMilestones, actions) {
1646
2361
  <span class="drill-status-pill ${mileStatusClass}">${escHtml(mileStatusLabel)}</span>
1647
2362
  ${reviewBadgeHtml(mile.id, mile.reviewState)}
1648
2363
  </div>
1649
- <div class="detail-section-label">Declaration</div>
1650
- <div class="detail-meta">${escHtml(decl.id)}: ${escHtml(truncate(decl.title || decl.statement || '', 60))}</div>
1651
2364
  ${renderEntityActions(mile.id, mile.reviewState, { shift: true })}
1652
2365
  <div class="refine-container" id="refine-area-${escHtml(mile.id)}"></div>
1653
2366
  `;
1654
2367
  wireInlineReviewButtons($drillDetail);
1655
2368
  wireEntityMenus($drillDetail);
1656
2369
 
1657
- $drillContext.innerHTML = '';
1658
-
1659
2370
  $drillList.innerHTML = '';
1660
2371
  if (filtered.length === 0) {
1661
- $drillList.innerHTML = '<div class="col-empty">No actions for this milestone</div>';
2372
+ $drillContext.innerHTML = '';
2373
+
2374
+ // Check if derivation is already running for this milestone
2375
+ const isDerivingHere = actionDerivationSessionId && actionDerivationMilestoneId === drillMileId;
2376
+
2377
+ $drillList.innerHTML = `
2378
+ <div class="col-empty-invite">
2379
+ <div class="empty-invite-title">No actions yet</div>
2380
+ <div class="empty-invite-desc">Plan actions to break this milestone into executable steps.</div>
2381
+ <textarea class="empty-invite-input" id="action-guidance-input" placeholder="Optional: guide the direction..." rows="2"></textarea>
2382
+ <button class="empty-invite-btn" id="action-derive-btn"${isDerivingHere ? ' disabled' : ''}>${isDerivingHere ? '<span class="derive-spinner"></span> Planning actions\u2026' : '<kbd>P</kbd> Plan Actions'}</button>
2383
+ <div id="action-derivation-log" class="output-log" style="${isDerivingHere ? '' : 'display:none;'} min-height:0"></div>
2384
+ <div id="action-derivation-proposals" style="display:none"></div>
2385
+ </div>`;
1662
2386
  $drillPrompt.innerHTML = '';
2387
+
2388
+ // If derivation already has proposals, render them
2389
+ if (actionDerivationProposals && actionDerivationMilestoneId === drillMileId) {
2390
+ renderActionProposals();
2391
+ }
2392
+
2393
+ // Wire the Plan button to trigger action derivation
2394
+ const deriveBtn = document.getElementById('action-derive-btn');
2395
+ if (deriveBtn && !isDerivingHere) {
2396
+ deriveBtn.addEventListener('click', () => {
2397
+ deriveBtn.disabled = true;
2398
+ deriveBtn.innerHTML = '<span class="derive-spinner"></span> Planning actions\u2026';
2399
+ const logEl = document.getElementById('action-derivation-log');
2400
+ if (logEl) logEl.style.display = '';
2401
+ startActionDerivation(drillMileId);
2402
+ });
2403
+ }
1663
2404
  return;
1664
2405
  }
1665
2406
 
1666
- let firstUnapproved = null;
1667
- const container = document.createElement('div');
1668
- container.className = 'drill-cards';
1669
- container.dataset.nodeType = 'action';
2407
+ // Classify actions into lifecycle stages
2408
+ const stages = classifyActionsToStages(filtered);
2409
+
2410
+ // Render filter chips
2411
+ renderLifecycleFilterChipsGeneric($drillContext, stages, lifecycleFilterL3, (f) => {
2412
+ lifecycleFilterL3 = f;
2413
+ renderDrillView();
2414
+ });
1670
2415
 
1671
- filtered.forEach(a => {
2416
+ // Build cards grouped by lifecycle stage
2417
+ let firstUnapproved = null;
2418
+ renderLifecycleSections($drillList, stages, lifecycleFilterL3, (a) => {
1672
2419
  const aTitle = a.title || a.id;
1673
2420
  const aDesc = a.produces || a.title || '';
1674
2421
  const status = a.status || 'PENDING';
@@ -1692,65 +2439,13 @@ function renderDrillActions(enrichedDeclarations, enrichedMilestones, actions) {
1692
2439
  <div class="drill-card-badges">${badgesHtml}</div>
1693
2440
  </div>
1694
2441
  </div>
1695
- <div class="drill-exec-plan-toggle" data-action-id="${escHtml(a.id)}">\u25B6 Show exec-plan</div>
1696
- <div class="drill-exec-plan-content" id="drill-plan-${escHtml(a.id)}"></div>
1697
2442
  ${renderEntityActions(a.id, a.reviewState)}
1698
2443
  `;
1699
2444
 
1700
- // Exec-plan toggle
1701
- el.querySelector('.drill-exec-plan-toggle').addEventListener('click', async (e) => {
1702
- e.stopPropagation();
1703
- const toggle = e.currentTarget;
1704
- const content = el.querySelector('.drill-exec-plan-content');
1705
- if (content.classList.contains('open')) {
1706
- content.classList.remove('open');
1707
- toggle.textContent = '\u25B6 Show exec-plan';
1708
- return;
1709
- }
1710
- toggle.textContent = '\u25BC Loading...';
1711
- try {
1712
- const res = await fetch(`/api/action/${encodeURIComponent(a.id)}`);
1713
- const data = await res.json();
1714
- if (data.error || !data.execPlan) {
1715
- content.innerHTML = '<span style="opacity:0.5">No exec-plan found</span>';
1716
- } else {
1717
- const ep = data.execPlan;
1718
- let planHtml = '';
1719
- if (ep.goal) planHtml += `<div style="margin-bottom:6px"><strong>Goal:</strong> ${escHtml(ep.goal)}</div>`;
1720
- if (ep.tasks && ep.tasks.length) {
1721
- planHtml += '<div style="margin-bottom:4px"><strong>Tasks:</strong></div><ol style="padding-left:16px;margin:0">';
1722
- ep.tasks.forEach(t => {
1723
- const taskTitle = typeof t === 'string' ? t : (t.title || t.description || JSON.stringify(t));
1724
- planHtml += `<li style="margin-bottom:2px">${escHtml(truncate(taskTitle, 120))}</li>`;
1725
- });
1726
- planHtml += '</ol>';
1727
- }
1728
- if (!planHtml) planHtml = '<span style="opacity:0.5">Exec-plan loaded (no details)</span>';
1729
- content.innerHTML = planHtml;
1730
- }
1731
- } catch (err) {
1732
- content.innerHTML = `<span style="color:var(--broken-color)">Error: ${escHtml(err.message)}</span>`;
1733
- }
1734
- content.classList.add('open');
1735
- toggle.textContent = '\u25BC Hide exec-plan';
1736
- });
1737
-
1738
- container.appendChild(el);
1739
- });
1740
-
1741
- $drillList.appendChild(container);
1742
- wireInlineReviewButtons(container);
1743
- wireEntityMenus(container);
2445
+ return el;
2446
+ }, { nodeType: 'action', doneCollapsed: lifecycleDoneCollapsed, onToggleDone: () => { lifecycleDoneCollapsed = !lifecycleDoneCollapsed; renderDrillView(); } });
1744
2447
 
1745
- // Prompt
1746
- const allApproved = filtered.every(a => a.reviewState === 'approved');
1747
- if (allApproved) {
1748
- $drillPrompt.innerHTML = `<span class="drill-prompt-complete">All actions approved</span>
1749
- <button class="drill-next-btn" id="drill-next-btn"><kbd>N</kbd> Next</button>`;
1750
- document.getElementById('drill-next-btn').addEventListener('click', () => drillAdvanceNext());
1751
- } else if (firstUnapproved) {
1752
- $drillPrompt.innerHTML = `<span class="drill-prompt-text">\u2192 Review <span class="drill-prompt-target">${escHtml(firstUnapproved.id)}</span> next</span>`;
1753
- }
2448
+ $drillPrompt.innerHTML = '';
1754
2449
  }
1755
2450
 
1756
2451
  // ─── Drill helper functions ──────────────────────────────────────────────────
@@ -1769,15 +2464,96 @@ function renderInlineReviewButtons(nodeId) {
1769
2464
  * Return HTML for the unified entity action menu.
1770
2465
  * Available on ALL entities regardless of review state.
1771
2466
  */
1772
- function renderEntityActions(nodeId, reviewState, opts) {
2467
+ function renderEntityActions(nodeId, reviewState, opts, activeLabel) {
1773
2468
  const shift = opts && opts.shift;
2469
+ const spinnerHtml = activeLabel ? `<span class="derive-card-status"><span class="derive-spinner"></span> ${escHtml(activeLabel)}</span>` : '';
1774
2470
  const k = (key) => shift ? `⇧${key}` : key;
1775
2471
  const needsApprove = reviewState !== 'approved';
2472
+ const prefix = nodeId.split('-')[0];
2473
+ const isDone = graphData && (() => {
2474
+ const allNodes = [...(graphData.declarations || []), ...(graphData.milestones || []), ...(graphData.actions || [])];
2475
+ const node = allNodes.find(n => n.id === nodeId);
2476
+ return node && COMPLETED.has((node.status || '').toUpperCase());
2477
+ })();
2478
+
2479
+ if (isDone) {
2480
+ // Done milestones show no actions; other done nodes show Review/Delete
2481
+ if (prefix === 'M') return `<div class="drill-card-actions"></div>`;
2482
+ return `<div class="drill-card-actions">
2483
+ <button class="drill-action-btn" data-action="refine" data-mode="outdated" data-node-id="${escHtml(nodeId)}"><kbd>${k('R')}</kbd> Review</button>
2484
+ <button class="drill-action-btn drill-action-danger" data-action="delete" data-node-id="${escHtml(nodeId)}"><kbd>${k('D')}</kbd> Delete</button>
2485
+ </div>`;
2486
+ }
2487
+
2488
+ // Primary action: what this node needs most right now
2489
+ let primaryBtn = '';
2490
+ if (prefix === 'A' && graphData) {
2491
+ const action = (graphData.actions || []).find(a => a.id === nodeId);
2492
+ if (action) {
2493
+ const isExecuting = runningActions.has(nodeId) || EXECUTING_STATUSES_SET.has((action.status || '').toUpperCase());
2494
+ if (isExecuting) {
2495
+ primaryBtn = `<button class="drill-action-btn drill-action-primary" disabled><kbd>${k('V')}</kbd> Running...</button>`;
2496
+ } else if (needsApprove) {
2497
+ // Approve is primary — shown below
2498
+ } else {
2499
+ // Approved, not executing — Execute is primary
2500
+ primaryBtn = `<button class="drill-action-btn drill-action-primary" data-action="execute" data-node-id="${escHtml(nodeId)}"><kbd>${k('E')}</kbd> Execute</button>`;
2501
+ }
2502
+ }
2503
+ } else if (prefix === 'M' && graphData) {
2504
+ const milestone = (graphData.milestones || []).find(m => m.id === nodeId);
2505
+ const myActions = (graphData.actions || []).filter(a => (a.causes || []).includes(nodeId));
2506
+ if (needsApprove) {
2507
+ // Unapproved milestone — no primary action, just Approve/Edit/Delete (shown below)
2508
+ } else if (myActions.length === 0) {
2509
+ // Approved, no actions — Plan Actions is primary
2510
+ primaryBtn = `<button class="drill-action-btn drill-action-primary" data-action="derive-actions" data-node-id="${escHtml(nodeId)}"><kbd>${k('P')}</kbd> Plan Actions</button>`;
2511
+ } else {
2512
+ const allApproved = myActions.every(a => a.reviewState === 'approved');
2513
+ const hasUnapproved = myActions.some(a => a.reviewState !== 'approved' && !COMPLETED.has((a.status || '').toUpperCase()));
2514
+ if (hasUnapproved) {
2515
+ // Has unapproved actions — primary is to navigate to review them
2516
+ } else if (allApproved) {
2517
+ // All actions approved — Execute is primary
2518
+ primaryBtn = `<button class="drill-action-btn drill-action-primary" data-action="execute-milestone" data-node-id="${escHtml(nodeId)}"><kbd>${k('E')}</kbd> Execute</button>`;
2519
+ }
2520
+ }
2521
+ } else if (prefix === 'D' && graphData) {
2522
+ const myMilestones = (graphData.milestones || []).filter(m => {
2523
+ const realizes = Array.isArray(m.realizes) ? m.realizes : [m.realizes];
2524
+ return realizes.includes(nodeId);
2525
+ });
2526
+ if (myMilestones.length === 0) {
2527
+ // No milestones — Derive Milestones is primary
2528
+ primaryBtn = `<button class="drill-action-btn drill-action-primary" data-action="derive-milestones" data-node-id="${escHtml(nodeId)}"><kbd>${k('P')}</kbd> Plan Milestones</button>`;
2529
+ } else {
2530
+ const hasUnapproved = myMilestones.some(m => m.reviewState !== 'approved' && !COMPLETED.has((m.status || '').toUpperCase()));
2531
+ if (hasUnapproved) {
2532
+ // Has unapproved milestones — no extra primary button, user can drill in
2533
+ }
2534
+ }
2535
+ }
2536
+
2537
+ // Milestones and Declarations: primary + approve (if needed) + edit + delete
2538
+ if (prefix === 'M' || prefix === 'D') {
2539
+ const editBtn = `<button class="drill-action-btn" data-action="refine" data-mode="write" data-node-id="${escHtml(nodeId)}"><kbd>${k('E')}</kbd> Edit</button>`;
2540
+ const deleteBtn = `<button class="drill-action-btn drill-action-danger" data-action="delete" data-node-id="${escHtml(nodeId)}"><kbd>${k('D')}</kbd> Delete</button>`;
2541
+ return `<div class="drill-card-actions">
2542
+ ${primaryBtn}
2543
+ ${needsApprove ? `<button class="drill-review-btn approve-btn" data-review-action="approved" data-node-id="${escHtml(nodeId)}"><kbd>${k('A')}</kbd> Approve</button>` : ''}
2544
+ ${editBtn}
2545
+ ${deleteBtn}
2546
+ ${spinnerHtml}
2547
+ </div>`;
2548
+ }
2549
+
1776
2550
  return `<div class="drill-card-actions">
2551
+ ${primaryBtn}
1777
2552
  ${needsApprove ? `<button class="drill-review-btn approve-btn" data-review-action="approved" data-node-id="${escHtml(nodeId)}"><kbd>${k('A')}</kbd> Approve</button>` : ''}
1778
2553
  <button class="drill-action-btn" data-action="refine" data-mode="outdated" data-node-id="${escHtml(nodeId)}"><kbd>${k('R')}</kbd> Review</button>
1779
2554
  <button class="drill-action-btn" data-action="refine" data-mode="write" data-node-id="${escHtml(nodeId)}"><kbd>${k('W')}</kbd> Write</button>
1780
2555
  <button class="drill-action-btn drill-action-danger" data-action="delete" data-node-id="${escHtml(nodeId)}"><kbd>${k('D')}</kbd> Delete</button>
2556
+ ${spinnerHtml}
1781
2557
  </div>`;
1782
2558
  }
1783
2559
 
@@ -1870,6 +2646,50 @@ function wireEntityMenus($container) {
1870
2646
  if (confirm(`Delete ${nodeId}? This cannot be undone.`)) {
1871
2647
  deleteNode(nodeId);
1872
2648
  }
2649
+ } else if (action === 'execute') {
2650
+ // Execute a single action
2651
+ fetch('/api/action/' + encodeURIComponent(nodeId) + '/execute', {
2652
+ method: 'POST',
2653
+ headers: { 'Content-Type': 'application/json' },
2654
+ }).then(r => r.json()).then(data => {
2655
+ if (data.error) {
2656
+ alert('Execute error: ' + data.error);
2657
+ } else {
2658
+ runningActions.add(nodeId);
2659
+ renderDrillView();
2660
+ }
2661
+ });
2662
+ } else if (action === 'execute-milestone') {
2663
+ // Execute all approved actions for this milestone
2664
+ const myActions = (graphData.actions || []).filter(a =>
2665
+ (a.causes || []).includes(nodeId) && a.reviewState === 'approved' && !COMPLETED.has((a.status || '').toUpperCase())
2666
+ );
2667
+ for (const a of myActions) {
2668
+ fetch('/api/action/' + encodeURIComponent(a.id) + '/execute', {
2669
+ method: 'POST',
2670
+ headers: { 'Content-Type': 'application/json' },
2671
+ }).then(r => r.json()).then(data => {
2672
+ if (!data.error) {
2673
+ runningActions.add(a.id);
2674
+ renderDrillView();
2675
+ }
2676
+ });
2677
+ }
2678
+ } else if (action === 'derive-actions') {
2679
+ // Navigate into milestone's actions view, then derive
2680
+ drillMileId = nodeId;
2681
+ drillGoDeeper('actions');
2682
+ renderDrillView();
2683
+ triggerDerivation(nodeId);
2684
+ } else if (action === 'derive-milestones') {
2685
+ // Navigate into milestones view, then start derivation
2686
+ drillDeclId = nodeId;
2687
+ drillLevel = 'milestones';
2688
+ renderDrillView();
2689
+ triggerDerivation(nodeId);
2690
+ } else if (action === 'discuss') {
2691
+ // Optional discuss phase before derivation
2692
+ startDiscuss(nodeId);
1873
2693
  }
1874
2694
  });
1875
2695
  });
@@ -1877,6 +2697,8 @@ function wireEntityMenus($container) {
1877
2697
 
1878
2698
  /** Currently active refine node */
1879
2699
  let refineActiveNodeId = null;
2700
+ /** @type {Set<string>} Active refine node IDs for concurrent support */
2701
+ const refineActiveNodes = new Set();
1880
2702
 
1881
2703
  /**
1882
2704
  * Start a refine session for a node.
@@ -1938,18 +2760,166 @@ function createRefineArea(nodeId) {
1938
2760
  // Fallback: append to detail panel
1939
2761
  const det = document.getElementById(`refine-area-${nodeId}`);
1940
2762
  if (det) return det;
2763
+ // Last resort: attach to drill-detail or drill-list so output is always visible
2764
+ const fallbackParent = document.getElementById('drill-detail') || document.getElementById('drill-list');
2765
+ if (fallbackParent) {
2766
+ const area = document.createElement('div');
2767
+ area.className = 'refine-container';
2768
+ area.id = `refine-area-${nodeId}`;
2769
+ fallbackParent.appendChild(area);
2770
+ return area;
2771
+ }
1941
2772
  return document.createElement('div'); // noop
1942
2773
  }
1943
2774
 
2775
+ /**
2776
+ * Trigger the appropriate derivation after discuss phase completes.
2777
+ * @param {string} nodeId
2778
+ */
2779
+ function triggerDerivation(nodeId) {
2780
+ const prefix = nodeId.split('-')[0];
2781
+ if (prefix === 'D') {
2782
+ // Immediate visual feedback via pendingDerivations
2783
+ pendingDerivations.add(nodeId);
2784
+ derivationSessionId = 'pending';
2785
+ derivationStartTime = Date.now();
2786
+ derivationProposals = null;
2787
+ renderDrillView();
2788
+ // Derive milestones — capture real sessionId for SSE handler
2789
+ fetch('/api/milestones/derive', {
2790
+ method: 'POST',
2791
+ headers: { 'Content-Type': 'application/json' },
2792
+ body: JSON.stringify({ declarationId: nodeId }),
2793
+ }).then(r => r.json()).then(data => {
2794
+ pendingDerivations.delete(nodeId);
2795
+ if (data.error) {
2796
+ console.error('Plan milestones error:', data.error);
2797
+ derivationSessionId = null;
2798
+ derivationStartTime = null;
2799
+ } else if (data.sessionId) {
2800
+ derivationSessionId = data.sessionId;
2801
+ }
2802
+ renderDrillView();
2803
+ }).catch(() => {
2804
+ pendingDerivations.delete(nodeId);
2805
+ derivationSessionId = null;
2806
+ derivationStartTime = null;
2807
+ renderDrillView();
2808
+ });
2809
+ } else if (prefix === 'M') {
2810
+ // Immediate visual feedback via pendingDerivations
2811
+ pendingDerivations.add(nodeId);
2812
+ actionDerivationSessionId = 'pending';
2813
+ actionDerivationMilestoneId = nodeId;
2814
+ actionDerivationProposals = null;
2815
+ renderDrillView();
2816
+ // Derive actions — capture real sessionId for SSE handler
2817
+ fetch('/api/milestones/' + encodeURIComponent(nodeId) + '/actions/derive', {
2818
+ method: 'POST',
2819
+ headers: { 'Content-Type': 'application/json' },
2820
+ }).then(r => r.json()).then(data => {
2821
+ pendingDerivations.delete(nodeId);
2822
+ if (data.error) {
2823
+ console.error('Plan actions error:', data.error);
2824
+ actionDerivationSessionId = null;
2825
+ actionDerivationMilestoneId = null;
2826
+ } else if (data.sessionId) {
2827
+ actionDerivationSessionId = data.sessionId;
2828
+ }
2829
+ renderDrillView();
2830
+ }).catch(() => {
2831
+ pendingDerivations.delete(nodeId);
2832
+ actionDerivationSessionId = null;
2833
+ actionDerivationMilestoneId = null;
2834
+ renderDrillView();
2835
+ });
2836
+ }
2837
+ }
2838
+
2839
+ /** @type {string|null} Node ID with an active discuss session */
2840
+ let discussActiveNodeId = null;
2841
+ /** @type {HTMLElement|null} Detached discuss container preserved across re-renders */
2842
+ let discussActiveContainer = null;
2843
+
2844
+ /**
2845
+ * Start a discuss (interview) phase for a node.
2846
+ * Shows a discuss area and kicks off the discuss agent.
2847
+ * @param {string} nodeId
2848
+ */
2849
+ function startDiscuss(nodeId) {
2850
+ discussActiveNodeId = nodeId;
2851
+
2852
+ // Create the discuss container
2853
+ const container = document.createElement('div');
2854
+ container.className = 'discuss-container';
2855
+ container.id = `discuss-area-${nodeId}`;
2856
+ discussActiveContainer = container;
2857
+
2858
+ // Attach to current card
2859
+ reattachDiscussContainer();
2860
+
2861
+ container.innerHTML = `<div class="discuss-loading">
2862
+ <pre class="discuss-streaming" id="discuss-output-${nodeId}">Thinking...</pre>
2863
+ <button class="drill-action-btn" id="discuss-skip-early-${nodeId}">Skip</button>
2864
+ </div>`;
2865
+ container.querySelector(`#discuss-skip-early-${nodeId}`).addEventListener('click', () => {
2866
+ clearDiscussState();
2867
+ triggerDerivation(nodeId);
2868
+ });
2869
+
2870
+ fetch(`/api/node/${encodeURIComponent(nodeId)}/discuss`, {
2871
+ method: 'POST',
2872
+ headers: { 'Content-Type': 'application/json' },
2873
+ }).then(r => r.json()).then(data => {
2874
+ if (data.error) {
2875
+ clearDiscussState();
2876
+ triggerDerivation(nodeId);
2877
+ }
2878
+ }).catch(() => {
2879
+ clearDiscussState();
2880
+ triggerDerivation(nodeId);
2881
+ });
2882
+ }
2883
+
2884
+ /** Clear discuss tracking state */
2885
+ function clearDiscussState() {
2886
+ if (discussActiveContainer && discussActiveContainer.parentNode) {
2887
+ discussActiveContainer.parentNode.removeChild(discussActiveContainer);
2888
+ }
2889
+ discussActiveNodeId = null;
2890
+ discussActiveContainer = null;
2891
+ }
2892
+
2893
+ /** Re-attach the discuss container to the card after a re-render */
2894
+ function reattachDiscussContainer() {
2895
+ if (!discussActiveNodeId || !discussActiveContainer) return;
2896
+ // Remove from old parent if still attached
2897
+ if (discussActiveContainer.parentNode) {
2898
+ discussActiveContainer.parentNode.removeChild(discussActiveContainer);
2899
+ }
2900
+ // Find the card for this node
2901
+ const card = document.querySelector(`.drill-card[data-node-id="${discussActiveNodeId}"]`)
2902
+ || document.querySelector(`.drill-action-btn[data-node-id="${discussActiveNodeId}"]`)?.closest('.drill-card');
2903
+ if (card) {
2904
+ card.appendChild(discussActiveContainer);
2905
+ } else {
2906
+ // Fallback: append to drill-list
2907
+ const parent = document.getElementById('drill-list');
2908
+ if (parent) parent.appendChild(discussActiveContainer);
2909
+ }
2910
+ }
2911
+
1944
2912
  async function doRefine(nodeId, mode, message) {
1945
2913
  refineActiveNodeId = nodeId;
2914
+ refineActiveNodes.add(nodeId);
1946
2915
  const area = document.getElementById(`refine-area-${nodeId}`) || createRefineArea(nodeId);
1947
2916
  area.innerHTML = `<div class="refine-area"><div class="refine-streaming">Thinking...</div>
1948
2917
  <div class="refine-actions"><button class="refine-discard" id="refine-cancel-${nodeId}">Cancel</button></div></div>`;
1949
2918
  area.querySelector(`#refine-cancel-${nodeId}`).addEventListener('click', async () => {
1950
2919
  try { await fetch('/api/refine/stop', { method: 'POST' }); } catch (_) {}
1951
2920
  area.innerHTML = '';
1952
- refineActiveNodeId = null;
2921
+ refineActiveNodes.delete(nodeId);
2922
+ if (refineActiveNodeId === nodeId) refineActiveNodeId = null;
1953
2923
  });
1954
2924
 
1955
2925
  try {
@@ -1961,12 +2931,14 @@ async function doRefine(nodeId, mode, message) {
1961
2931
  if (!resp.ok) {
1962
2932
  const err = await resp.json();
1963
2933
  area.innerHTML = `<div class="refine-area" style="border-color:var(--broken-color)">${escHtml(err.error || 'Failed')}</div>`;
1964
- refineActiveNodeId = null;
2934
+ refineActiveNodes.delete(nodeId);
2935
+ if (refineActiveNodeId === nodeId) refineActiveNodeId = null;
1965
2936
  }
1966
2937
  // Output will stream via SSE
1967
2938
  } catch (err) {
1968
2939
  area.innerHTML = `<div class="refine-area" style="border-color:var(--broken-color)">${escHtml(err.message)}</div>`;
1969
- refineActiveNodeId = null;
2940
+ refineActiveNodes.delete(nodeId);
2941
+ if (refineActiveNodeId === nodeId) refineActiveNodeId = null;
1970
2942
  }
1971
2943
  }
1972
2944
 
@@ -2015,6 +2987,45 @@ function updateLocalReviewState(nodeId, newState) {
2015
2987
  if (node) node.reviewState = newState;
2016
2988
  }
2017
2989
 
2990
+ /**
2991
+ * Approve all visible entities of a given type (or all types at current drill level).
2992
+ * Sends parallel PUT requests and updates local state for instant feedback.
2993
+ */
2994
+ async function approveAllVisible() {
2995
+ if (!graphData) return;
2996
+ let nodes;
2997
+ if (drillLevel === 'declarations') {
2998
+ nodes = (graphData.declarations || []).filter(n => n.reviewState !== 'approved');
2999
+ } else if (drillLevel === 'milestones') {
3000
+ const filtered = (graphData.milestones || []).filter(m =>
3001
+ (m.realizes || []).includes(drillDeclId)
3002
+ );
3003
+ nodes = filtered.filter(n => n.reviewState !== 'approved');
3004
+ } else if (drillLevel === 'actions') {
3005
+ const filtered = (graphData.actions || []).filter(a =>
3006
+ (a.causes || []).includes(drillMileId)
3007
+ );
3008
+ nodes = filtered.filter(n => n.reviewState !== 'approved');
3009
+ } else {
3010
+ return;
3011
+ }
3012
+ if (nodes.length === 0) return;
3013
+
3014
+ // Fire all approve requests in parallel
3015
+ const promises = nodes.map(n =>
3016
+ fetch(`/api/node/${encodeURIComponent(n.id)}/review-state`, {
3017
+ method: 'PUT',
3018
+ headers: { 'Content-Type': 'application/json' },
3019
+ body: JSON.stringify({ reviewState: 'approved' }),
3020
+ }).then(resp => {
3021
+ if (resp.ok) updateLocalReviewState(n.id, 'approved');
3022
+ }).catch(err => console.error(`Failed to approve ${n.id}:`, err))
3023
+ );
3024
+ await Promise.all(promises);
3025
+ renderDrillView();
3026
+ loadActivity();
3027
+ }
3028
+
2018
3029
  // ─── Column browser keyboard navigation ──────────────────────────────────────
2019
3030
 
2020
3031
  // Inject kb-focus CSS rule
@@ -2096,6 +3107,8 @@ function clearColumnBrowserKbFocus() {
2096
3107
  */
2097
3108
  function handleColumnKeydown(e) {
2098
3109
  if (!isColumnBrowserActive()) return;
3110
+ // Don't intercept keys when user is typing in an input/textarea
3111
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
2099
3112
 
2100
3113
  const key = e.key;
2101
3114
 
@@ -2152,6 +3165,21 @@ function getFocusedCard() {
2152
3165
  document.addEventListener('keydown', (e) => {
2153
3166
  if (viewMode !== 'columns') return;
2154
3167
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
3168
+
3169
+ // Ctrl+Shift+A = Approve All visible entities (Ctrl on all platforms, not Cmd)
3170
+ if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'a') {
3171
+ e.preventDefault();
3172
+ approveAllVisible();
3173
+ return;
3174
+ }
3175
+
3176
+ // Ctrl+Shift+P = Global Plan (top-right button)
3177
+ if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'p') {
3178
+ e.preventDefault();
3179
+ if ($executeMainBtn && !$executeMainBtn.disabled) $executeMainBtn.click();
3180
+ return;
3181
+ }
3182
+
2155
3183
  if (e.metaKey || e.ctrlKey || e.altKey) return;
2156
3184
 
2157
3185
  const key = e.key;
@@ -2197,16 +3225,42 @@ document.addEventListener('keydown', (e) => {
2197
3225
  return;
2198
3226
  }
2199
3227
 
2200
- // P = Plan milestones (empty state or main Plan button)
3228
+ // P = Plan / Derive trigger planning on focused/current card
2201
3229
  if (kl === 'p') {
3230
+ // 1. Empty-state Plan buttons (inside a view with no children)
2202
3231
  const planBtn = document.getElementById('plan-milestones-btn');
2203
- if (planBtn) { e.preventDefault(); planBtn.click(); return; }
2204
- if ($executeMainBtn && $executeMainBtn._planMode) { e.preventDefault(); $executeMainBtn.click(); return; }
3232
+ if (planBtn && !planBtn.disabled) { e.preventDefault(); planBtn.click(); return; }
3233
+ const actionDeriveBtn = document.getElementById('action-derive-btn');
3234
+ if (actionDeriveBtn && !actionDeriveBtn.disabled) { e.preventDefault(); actionDeriveBtn.click(); return; }
3235
+ // 2. Focused or current-review card with derive button
3236
+ const card = getFocusedCard();
3237
+ if (card) {
3238
+ const deriveBtn = card.querySelector('[data-action="derive-actions"], [data-action="derive-milestones"]');
3239
+ if (deriveBtn) {
3240
+ e.preventDefault();
3241
+ const nodeId = deriveBtn.dataset.nodeId;
3242
+ if (nodeId) triggerDerivation(nodeId);
3243
+ return;
3244
+ }
3245
+ }
3246
+ // 3. Fallback: any visible derive button
3247
+ const anyDeriveBtn = document.querySelector('[data-action="derive-actions"], [data-action="derive-milestones"]');
3248
+ if (anyDeriveBtn) {
3249
+ e.preventDefault();
3250
+ const nodeId = anyDeriveBtn.dataset.nodeId;
3251
+ if (nodeId) triggerDerivation(nodeId);
3252
+ return;
3253
+ }
2205
3254
  return;
2206
3255
  }
2207
3256
 
2208
- // E = Execute (main button when in execute mode)
3257
+ // E = Edit (click first Edit button on a card) or Execute (main button)
2209
3258
  if (kl === 'e') {
3259
+ // First try Edit on a card
3260
+ const editBtn = document.querySelector('.drill-card.current-review .drill-action-btn[data-mode="write"]')
3261
+ || document.querySelector('.drill-card .drill-action-btn[data-mode="write"]');
3262
+ if (editBtn) { e.preventDefault(); editBtn.click(); return; }
3263
+ // Fallback: Execute main button
2210
3264
  if ($executeMainBtn && !$executeMainBtn._nextTarget && !$executeMainBtn._planMode && !$executeMainBtn.disabled) {
2211
3265
  e.preventDefault(); $executeMainBtn.click(); return;
2212
3266
  }
@@ -2221,13 +3275,13 @@ document.addEventListener('keydown', (e) => {
2221
3275
  return;
2222
3276
  }
2223
3277
 
2224
- // Shift+R/W/D/A = act on detail panel entity
2225
- if (e.shiftKey && (kl === 'r' || kl === 'w' || kl === 'd' || kl === 'a')) {
3278
+ // Shift+R/E/W/D/A = act on detail panel entity
3279
+ if (e.shiftKey && (kl === 'r' || kl === 'e' || kl === 'w' || kl === 'd' || kl === 'a')) {
2226
3280
  const detail = document.getElementById('drill-detail');
2227
3281
  if (!detail) return;
2228
3282
  let btn;
2229
3283
  if (kl === 'r') btn = detail.querySelector('.drill-action-btn[data-mode="outdated"]');
2230
- else if (kl === 'w') btn = detail.querySelector('.drill-action-btn[data-mode="write"]');
3284
+ else if (kl === 'e' || kl === 'w') btn = detail.querySelector('.drill-action-btn[data-mode="write"]');
2231
3285
  else if (kl === 'd') btn = detail.querySelector('.drill-action-btn[data-action="delete"]');
2232
3286
  else if (kl === 'a') btn = detail.querySelector('.drill-review-btn[data-review-action="approved"]');
2233
3287
  if (btn) { e.preventDefault(); btn.click(); }
@@ -2261,12 +3315,18 @@ document.addEventListener('keydown', (e) => {
2261
3315
  return;
2262
3316
  }
2263
3317
 
2264
- // A = Approve focused card
3318
+ // A = Approve focused card (fall back to detail panel approve button)
2265
3319
  if (kl === 'a') {
2266
3320
  const card = getFocusedCard();
2267
- if (!card) return;
2268
- const btn = card.querySelector('.drill-review-btn[data-review-action="approved"]');
2269
- if (btn) { e.preventDefault(); btn.click(); }
3321
+ if (card) {
3322
+ const btn = card.querySelector('.drill-review-btn[data-review-action="approved"]');
3323
+ if (btn) { e.preventDefault(); btn.click(); return; }
3324
+ }
3325
+ const detail = document.getElementById('drill-detail');
3326
+ if (detail) {
3327
+ const btn = detail.querySelector('.drill-review-btn[data-review-action="approved"]');
3328
+ if (btn) { e.preventDefault(); btn.click(); }
3329
+ }
2270
3330
  return;
2271
3331
  }
2272
3332
  });
@@ -2294,10 +3354,10 @@ document.addEventListener('click', async (e) => {
2294
3354
  console.error('Failed to update review state:', err);
2295
3355
  return;
2296
3356
  }
2297
- // SSE will trigger a refresh, but also update immediately for responsiveness
2298
- badge.className = `review-badge review-${nextState}`;
2299
- badge.dataset.reviewState = nextState;
2300
- badge.textContent = REVIEW_DISPLAY[nextState] || nextState;
3357
+ // Update local data + re-render for instant feedback (don't rely solely on SSE)
3358
+ updateLocalReviewState(nodeId, nextState);
3359
+ renderDrillView();
3360
+ loadActivity();
2301
3361
  } catch (err) {
2302
3362
  console.error('Failed to update review state:', err);
2303
3363
  }
@@ -3557,7 +4617,7 @@ function renderPanelChain(item, type) {
3557
4617
 
3558
4618
  // Derivation panel — derive milestones for this declaration
3559
4619
  html += `<div class="derivation-panel" id="derivation-panel">`;
3560
- html += `<button class="derive-btn" id="derive-btn">Derive Milestones</button>`;
4620
+ html += `<button class="derive-btn" id="derive-btn">Plan Milestones</button>`;
3561
4621
  html += `<div id="derivation-log" class="output-log" style="display:none"></div>`;
3562
4622
  html += `<div id="derivation-proposals" style="display:none"></div>`;
3563
4623
  html += `</div>`;
@@ -3650,7 +4710,7 @@ function renderPanelChain(item, type) {
3650
4710
  if (s.type === 'milestone') {
3651
4711
  // Action derivation panel — derive actions for this milestone
3652
4712
  html += `<div class="derivation-panel" id="action-derivation-panel">`;
3653
- html += `<button class="derive-btn" id="action-derive-btn">Derive Actions</button>`;
4713
+ html += `<button class="derive-btn" id="action-derive-btn">Plan Actions</button>`;
3654
4714
  html += `<div id="action-derivation-log" class="output-log" style="display:none"></div>`;
3655
4715
  html += `<div id="action-derivation-proposals" style="display:none"></div>`;
3656
4716
  html += `</div>`;
@@ -3667,8 +4727,8 @@ function renderPanelChain(item, type) {
3667
4727
 
3668
4728
  if (s.type === 'action') {
3669
4729
  // Exec-plan placeholder — filled asynchronously after render
3670
- html += `<div id="exec-plan-detail" style="margin-top:16px">
3671
- <div class="detail-label" style="opacity:0.4">Loading exec-plan…</div>
4730
+ html += `<div id="plan-detail" style="margin-top:16px">
4731
+ <div class="detail-label" style="opacity:0.4">Loading plan…</div>
3672
4732
  </div>`;
3673
4733
  }
3674
4734
  }
@@ -3814,7 +4874,7 @@ function renderPanelChain(item, type) {
3814
4874
  }
3815
4875
  }
3816
4876
 
3817
- // If an action is focused, fetch and render its exec-plan
4877
+ // If an action is focused, fetch and render its plan
3818
4878
  if (focusSection && focusSection.type === 'action') {
3819
4879
  loadExecPlan(focusSection.item.id);
3820
4880
  }
@@ -4088,7 +5148,7 @@ async function renderWorkabilityPath(nodeId, nodeType) {
4088
5148
 
4089
5149
  html += `</div>`;
4090
5150
 
4091
- const execPlanEl = $panelBody.querySelector('#exec-plan-detail');
5151
+ const execPlanEl = $panelBody.querySelector('#plan-detail');
4092
5152
  const tempDiv = document.createElement('div');
4093
5153
  tempDiv.innerHTML = html;
4094
5154
  const wpEl = tempDiv.firstElementChild;
@@ -4156,11 +5216,11 @@ function extractProducedFiles(summaryContent) {
4156
5216
  }
4157
5217
 
4158
5218
  /**
4159
- * Fetch /api/action/:id and render the exec-plan into #exec-plan-detail.
5219
+ * Fetch /api/action/:id and render the plan into #plan-detail.
4160
5220
  * @param {string} actionId
4161
5221
  */
4162
5222
  async function loadExecPlan(actionId) {
4163
- const container = document.getElementById('exec-plan-detail');
5223
+ const container = document.getElementById('plan-detail');
4164
5224
  if (!container) return;
4165
5225
 
4166
5226
  try {
@@ -4168,7 +5228,7 @@ async function loadExecPlan(actionId) {
4168
5228
  const data = await res.json();
4169
5229
 
4170
5230
  if (data.error || !data.execPlan) {
4171
- container.innerHTML = `<div class="detail-label" style="opacity:0.4">No exec-plan found</div>`;
5231
+ container.innerHTML = `<div class="detail-label" style="opacity:0.4">No plan found</div>`;
4172
5232
  return;
4173
5233
  }
4174
5234
 
@@ -4325,7 +5385,7 @@ async function loadExecPlan(actionId) {
4325
5385
  // Output log panel (hidden by default, shown when executing)
4326
5386
  html += `<div id="output-log" class="output-log" style="display:none"></div>`;
4327
5387
 
4328
- container.innerHTML = html || `<div class="detail-label" style="opacity:0.4">No exec-plan details</div>`;
5388
+ container.innerHTML = html || `<div class="detail-label" style="opacity:0.4">No plan details</div>`;
4329
5389
 
4330
5390
  // Wire button click handlers
4331
5391
  const execBtn = document.getElementById('exec-action-btn');
@@ -4364,7 +5424,7 @@ async function loadExecPlan(actionId) {
4364
5424
  }
4365
5425
 
4366
5426
  } catch (e) {
4367
- if (container) container.innerHTML = `<div class="detail-label" style="opacity:0.4">Could not load exec-plan</div>`;
5427
+ if (container) container.innerHTML = `<div class="detail-label" style="opacity:0.4">Could not load plan</div>`;
4368
5428
  }
4369
5429
  }
4370
5430
 
@@ -4910,6 +5970,85 @@ function handlePlayComplete(e) {
4910
5970
 
4911
5971
  // ─── Derivation controls ──────────────────────────────────────────────────────
4912
5972
 
5973
+ /** @type {number|null} Server-reported start time for active derivation (epoch ms) */
5974
+ let derivationStartTime = null;
5975
+ /** @type {number|null} Interval ID for derivation progress animation */
5976
+ let derivationProgressTimer = null;
5977
+
5978
+ /** Format seconds as m:ss (e.g. 119 → "1:59") */
5979
+ function fmtElapsed(sec) {
5980
+ const m = Math.floor(sec / 60);
5981
+ const s = sec % 60;
5982
+ return m > 0 ? m + ':' + String(s).padStart(2, '0') : s + 's';
5983
+ }
5984
+
5985
+ const DERIVATION_PHASES = [
5986
+ { at: 0, icon: '\u2699', text: 'Spawning AI agent\u2026' },
5987
+ { at: 3, icon: '\uD83D\uDD0D', text: 'Analyzing declaration statement\u2026' },
5988
+ { at: 7, icon: '\uD83E\uDDE0', text: 'Reasoning about what must be true\u2026' },
5989
+ { at: 12, icon: '\uD83C\uDFAF', text: 'Identifying key milestones\u2026' },
5990
+ { at: 18, icon: '\u2696\uFE0F', text: 'Evaluating milestone boundaries\u2026' },
5991
+ { at: 25, icon: '\uD83D\uDCDD', text: 'Drafting milestone proposals\u2026' },
5992
+ { at: 35, icon: '\u2728', text: 'Refining and validating output\u2026' },
5993
+ { at: 50, icon: '\u23F3', text: 'Almost there\u2026' },
5994
+ { at: 75, icon: '\uD83D\uDD04', text: 'Still working\u2026 complex declaration' },
5995
+ ];
5996
+
5997
+ /**
5998
+ * Show animated progress phases in the derivation progress area.
5999
+ * @param {number} startTime - Epoch ms when derivation started
6000
+ */
6001
+ function showDerivationProgress(startTime) {
6002
+ if (derivationProgressTimer) clearInterval(derivationProgressTimer);
6003
+
6004
+ const el = document.getElementById('derivation-progress');
6005
+ if (!el) return;
6006
+ el.style.display = '';
6007
+
6008
+ let lastPhaseAt = -1;
6009
+ function update() {
6010
+ if (!document.contains(el)) { clearInterval(derivationProgressTimer); return; }
6011
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
6012
+ let phase = DERIVATION_PHASES[0];
6013
+ for (const p of DERIVATION_PHASES) {
6014
+ if (elapsed >= p.at) phase = p;
6015
+ }
6016
+ // Only update DOM when phase changes to avoid re-triggering animation
6017
+ if (phase.at !== lastPhaseAt) {
6018
+ lastPhaseAt = phase.at;
6019
+ el.innerHTML = `<div class="derive-phase">
6020
+ <span class="derive-phase-icon">${phase.icon}</span>
6021
+ <span class="derive-phase-text">${phase.text}</span>
6022
+ </div>`;
6023
+ }
6024
+ }
6025
+
6026
+ update();
6027
+ derivationProgressTimer = setInterval(update, 1000);
6028
+ }
6029
+
6030
+ /** Stop the derivation progress animation. */
6031
+ function stopDerivationProgress() {
6032
+ if (derivationProgressTimer) { clearInterval(derivationProgressTimer); derivationProgressTimer = null; }
6033
+ const el = document.getElementById('derivation-progress');
6034
+ if (el) el.style.display = 'none';
6035
+ }
6036
+
6037
+ /**
6038
+ * Restore running derivation state after page refresh.
6039
+ * Checks /api/derivation/running and restores derivationSessionId so SSE events reconnect.
6040
+ */
6041
+ async function restoreRunningDerivations() {
6042
+ try {
6043
+ const data = await fetchJson('/api/derivation/running');
6044
+ if (data && data.sessions && data.sessions.length > 0) {
6045
+ const first = data.sessions[0];
6046
+ derivationSessionId = first.sessionId;
6047
+ derivationStartTime = first.startTime || null;
6048
+ }
6049
+ } catch (_) {}
6050
+ }
6051
+
4913
6052
  /**
4914
6053
  * Trigger milestone derivation for a declaration via POST /api/milestones/derive.
4915
6054
  * @param {string} declarationId
@@ -4918,7 +6057,7 @@ async function startDerivation(declarationId) {
4918
6057
  const btn = document.getElementById('derive-btn') || document.getElementById('plan-milestones-btn');
4919
6058
  if (btn) {
4920
6059
  btn.disabled = true;
4921
- btn.textContent = 'Deriving...';
6060
+ btn.textContent = 'Planning...';
4922
6061
  }
4923
6062
 
4924
6063
  const logEl = document.getElementById('derivation-log');
@@ -4936,7 +6075,7 @@ async function startDerivation(declarationId) {
4936
6075
 
4937
6076
  if (!res.ok) {
4938
6077
  if (logEl) { logEl.style.display = ''; logEl.textContent = 'Error: ' + (data.error || 'Failed to start derivation'); }
4939
- if (btn) { btn.disabled = false; btn.textContent = btn.id === 'plan-milestones-btn' ? 'Plan Milestones' : 'Derive Milestones'; }
6078
+ if (btn) { btn.disabled = false; btn.textContent = btn.id === 'plan-milestones-btn' ? 'Plan Milestones' : 'Plan Milestones'; }
4940
6079
  return;
4941
6080
  }
4942
6081
 
@@ -4944,42 +6083,114 @@ async function startDerivation(declarationId) {
4944
6083
  derivationProposals = null;
4945
6084
  } catch (err) {
4946
6085
  if (logEl) { logEl.style.display = ''; logEl.textContent = 'Error: ' + err.message; }
4947
- if (btn) { btn.disabled = false; btn.textContent = btn.id === 'plan-milestones-btn' ? 'Plan Milestones' : 'Derive Milestones'; }
6086
+ if (btn) { btn.disabled = false; btn.textContent = btn.id === 'plan-milestones-btn' ? 'Plan Milestones' : 'Plan Milestones'; }
4948
6087
  }
4949
6088
  }
4950
6089
 
4951
6090
  /**
4952
6091
  * Handle incoming derivation-output SSE event.
6092
+ * Supports both single-session (detail panel) and concurrent (card-level) derivations.
4953
6093
  * @param {MessageEvent} e
4954
6094
  */
4955
6095
  function handleDerivationOutput(e) {
4956
6096
  try {
4957
- const { sessionId, text } = JSON.parse(e.data);
4958
- if (sessionId !== derivationSessionId) return;
4959
- const logEl = document.getElementById('derivation-log');
4960
- if (!logEl) return;
4961
- if (logEl.style.display === 'none') logEl.style.display = '';
4962
- logEl.appendChild(document.createTextNode(text + '\n'));
4963
- logEl.scrollTop = logEl.scrollHeight;
6097
+ const { sessionId, declarationId, text, stream } = JSON.parse(e.data);
6098
+
6099
+ // Concurrent derive-all mode: update activeDeriveMap
6100
+ if (declarationId && activeDeriveMap.has(declarationId)) {
6101
+ // Card-level derivation no log panel needed
6102
+ return;
6103
+ }
6104
+
6105
+ // Single-session mode: stream to progress area
6106
+ // Accept if pending (optimistic) or matching session
6107
+ if (derivationSessionId === 'pending') derivationSessionId = sessionId;
6108
+ if (derivationSessionId && sessionId !== derivationSessionId) return;
6109
+ if (!derivationSessionId) derivationSessionId = sessionId;
6110
+
6111
+ // Update progress display with real streaming content
6112
+ const progressEl = document.getElementById('derivation-progress');
6113
+ if (progressEl && stream !== 'stderr') {
6114
+ progressEl.style.display = '';
6115
+ // Stop synthetic phases — we have real output now
6116
+ if (derivationProgressTimer) { clearInterval(derivationProgressTimer); derivationProgressTimer = null; }
6117
+
6118
+ if (stream === 'status') {
6119
+ // Status messages (e.g. "Thinking...")
6120
+ progressEl.innerHTML = `<div class="derive-phase">
6121
+ <span class="derive-phase-icon">\u2699</span>
6122
+ <span class="derive-phase-text">${text}</span>
6123
+ </div>`;
6124
+ } else {
6125
+ // Streaming text — show accumulating output
6126
+ if (!progressEl._streamEl) {
6127
+ progressEl.innerHTML = `<div class="derive-phase">
6128
+ <span class="derive-phase-icon">\uD83D\uDCDD</span>
6129
+ <span class="derive-phase-text">Writing milestones\u2026</span>
6130
+ </div>
6131
+ <pre class="derive-stream-output"></pre>`;
6132
+ progressEl._streamEl = progressEl.querySelector('.derive-stream-output');
6133
+ }
6134
+ if (progressEl._streamEl) {
6135
+ progressEl._streamEl.textContent += text;
6136
+ progressEl._streamEl.scrollTop = progressEl._streamEl.scrollHeight;
6137
+ }
6138
+ }
6139
+ }
4964
6140
  } catch (_) {}
4965
6141
  }
4966
6142
 
4967
6143
  /**
4968
6144
  * Handle incoming derivation-complete SSE event.
6145
+ * Supports both single-session (detail panel) and concurrent (card-level) derivations.
4969
6146
  * @param {MessageEvent} e
4970
6147
  */
4971
6148
  function handleDerivationComplete(e) {
4972
6149
  try {
4973
- const { sessionId, exitCode, milestones } = JSON.parse(e.data);
4974
- if (sessionId !== derivationSessionId) return;
6150
+ const { sessionId, declarationId, exitCode, milestones } = JSON.parse(e.data);
6151
+
6152
+ // Concurrent derive-all mode: auto-accept milestones
6153
+ if (declarationId && activeDeriveMap.has(declarationId)) {
6154
+ activeDeriveMap.delete(declarationId);
6155
+
6156
+ if (exitCode === 0 && milestones && Array.isArray(milestones)) {
6157
+ // Auto-accept only if declaration doesn't already have milestones
6158
+ const existingMilestones = graphData ? (graphData.milestones || []).filter(m => {
6159
+ const realizes = Array.isArray(m.realizes) ? m.realizes : [m.realizes];
6160
+ return realizes.includes(declarationId);
6161
+ }) : [];
6162
+ if (existingMilestones.length === 0) {
6163
+ const toAccept = milestones.map(m => ({
6164
+ title: m.title || '',
6165
+ description: m.description || m.reason || '',
6166
+ realizes: m.realizes || declarationId || '',
6167
+ }));
6168
+ fetch('/api/milestones/derive/accept', {
6169
+ method: 'POST',
6170
+ headers: { 'Content-Type': 'application/json' },
6171
+ body: JSON.stringify({ milestones: toAccept }),
6172
+ }).catch(() => {});
6173
+ }
6174
+ }
6175
+
6176
+ // Re-render to update card state
6177
+ renderDrillView();
6178
+ return;
6179
+ }
6180
+
6181
+ // Single-session mode
6182
+ if (derivationSessionId === 'pending') derivationSessionId = sessionId;
6183
+ if (derivationSessionId && sessionId !== derivationSessionId) return;
4975
6184
 
4976
6185
  derivationSessionId = null;
6186
+ derivationStartTime = null;
6187
+ stopDerivationProgress();
4977
6188
 
4978
6189
  const btn = document.getElementById('derive-btn') || document.getElementById('plan-milestones-btn');
4979
6190
  if (btn) {
4980
6191
  if (btn._deriveTimer) { clearInterval(btn._deriveTimer); btn._deriveTimer = null; }
4981
6192
  btn.disabled = false;
4982
- btn.innerHTML = btn.id === 'plan-milestones-btn' ? '<kbd>P</kbd> Plan Milestones' : 'Derive Milestones';
6193
+ btn.innerHTML = btn.id === 'plan-milestones-btn' ? '<kbd>P</kbd> Plan Milestones' : 'Plan Milestones';
4983
6194
  }
4984
6195
 
4985
6196
  if (exitCode !== 0) {
@@ -4989,22 +6200,39 @@ function handleDerivationComplete(e) {
4989
6200
  const span = document.createElement('span');
4990
6201
  span.style.color = 'var(--broken-color)';
4991
6202
  span.style.fontWeight = '700';
4992
- span.textContent = '\nDerivation failed (exit code ' + exitCode + ')';
6203
+ span.textContent = '\nPlanning failed (exit code ' + exitCode + ')';
4993
6204
  logEl.appendChild(span);
4994
6205
  }
4995
6206
  return;
4996
6207
  }
4997
6208
 
4998
6209
  if (milestones && Array.isArray(milestones)) {
4999
- derivationProposals = milestones;
5000
- renderProposals();
6210
+ // Auto-accept milestones into the DAG — but only if this declaration doesn't already have milestones
6211
+ const declId = declarationId || drillDeclId || '';
6212
+ const existingMilestones = graphData ? (graphData.milestones || []).filter(m => {
6213
+ const realizes = Array.isArray(m.realizes) ? m.realizes : [m.realizes];
6214
+ return realizes.includes(declId);
6215
+ }) : [];
6216
+ if (existingMilestones.length === 0) {
6217
+ const toAccept = milestones.map(m => ({
6218
+ title: m.title || '',
6219
+ description: m.description || m.reason || '',
6220
+ realizes: m.realizes || declId || '',
6221
+ }));
6222
+ fetch('/api/milestones/derive/accept', {
6223
+ method: 'POST',
6224
+ headers: { 'Content-Type': 'application/json' },
6225
+ body: JSON.stringify({ milestones: toAccept }),
6226
+ }).catch(() => {});
6227
+ }
6228
+ // SSE change event will trigger graph reload with the new milestones as DRAFT cards
5001
6229
  } else {
5002
6230
  const logEl = document.getElementById('derivation-log');
5003
6231
  if (logEl) {
5004
6232
  const span = document.createElement('span');
5005
6233
  span.style.color = 'var(--integrity-partial)';
5006
6234
  span.style.fontWeight = '600';
5007
- span.textContent = '\nDerivation finished but output could not be parsed. Check the log above.';
6235
+ span.textContent = '\nPlanning finished but output could not be parsed. Check the log above.';
5008
6236
  logEl.appendChild(span);
5009
6237
  }
5010
6238
  }
@@ -5012,93 +6240,15 @@ function handleDerivationComplete(e) {
5012
6240
  }
5013
6241
 
5014
6242
  /**
5015
- * Render proposed milestones as an editable checklist.
6243
+ * Legacy renderProposals no longer needed since milestones are auto-accepted.
6244
+ * Kept as no-op for any remaining references.
5016
6245
  */
5017
- function renderProposals() {
5018
- const container = document.getElementById('derivation-proposals');
5019
- if (!container || !derivationProposals) return;
5020
- container.style.display = 'block';
5021
-
5022
- let html = '<h4 style="margin:8px 0;color:var(--text-bright);font-size:13px">Proposed Milestones</h4>';
5023
- html += '<ul class="derivation-checklist">';
5024
- derivationProposals.forEach((m, idx) => {
5025
- html += '<li>';
5026
- html += '<input type="checkbox" checked data-idx="' + idx + '">';
5027
- html += '<input type="text" value="' + escHtml(m.title || '') + '" data-idx="' + idx + '">';
5028
- html += '</li>';
5029
- if (m.reason) {
5030
- html += '<li style="border-bottom:none;padding:0"><span class="reason">' + escHtml(m.reason) + '</span></li>';
5031
- }
5032
- });
5033
- html += '</ul>';
5034
- html += '<div style="margin-top:12px">';
5035
- html += '<button class="derive-accept-btn" onclick="acceptDerivation()">Accept Selected</button>';
5036
- html += '<button class="derive-cancel-btn" onclick="cancelDerivation()">Cancel</button>';
5037
- html += '</div>';
5038
-
5039
- container.innerHTML = html;
5040
- }
6246
+ function renderProposals() {}
5041
6247
 
5042
6248
  /**
5043
- * Accept selected milestones from the derivation checklist.
5044
- * Gathers checked items, POSTs to /api/milestones/derive/accept.
6249
+ * Accept derivation — legacy no-op, milestones are now auto-accepted on completion.
5045
6250
  */
5046
- async function acceptDerivation() {
5047
- const container = document.getElementById('derivation-proposals');
5048
- if (!container || !derivationProposals) return;
5049
-
5050
- const checkboxes = container.querySelectorAll('input[type="checkbox"]');
5051
- const textInputs = container.querySelectorAll('input[type="text"]');
5052
-
5053
- /** @type {Array<{title: string, realizes: string}>} */
5054
- const selected = [];
5055
- checkboxes.forEach((cb) => {
5056
- if (cb.checked) {
5057
- const idx = parseInt(cb.dataset.idx, 10);
5058
- const titleInput = textInputs[idx];
5059
- const proposal = derivationProposals[idx];
5060
- if (proposal && titleInput) {
5061
- selected.push({
5062
- title: titleInput.value || proposal.title,
5063
- realizes: proposal.realizes || '',
5064
- });
5065
- }
5066
- }
5067
- });
5068
-
5069
- if (selected.length === 0) {
5070
- cancelDerivation();
5071
- return;
5072
- }
5073
-
5074
- try {
5075
- const res = await fetch('/api/milestones/derive/accept', {
5076
- method: 'POST',
5077
- headers: { 'Content-Type': 'application/json' },
5078
- body: JSON.stringify({ milestones: selected }),
5079
- });
5080
- const data = await res.json();
5081
-
5082
- if (!res.ok) {
5083
- const logEl = document.getElementById('derivation-log');
5084
- if (logEl) logEl.textContent += '\nError accepting: ' + (data.error || 'Unknown error');
5085
- return;
5086
- }
5087
-
5088
- // Clear derivation panel and show success
5089
- derivationProposals = null;
5090
- const panel = document.getElementById('derivation-panel');
5091
- if (panel) {
5092
- panel.innerHTML = '<div style="color:var(--act-color);font-weight:600;padding:8px 0">' +
5093
- selected.length + ' milestone' + (selected.length !== 1 ? 's' : '') + ' created</div>';
5094
- setTimeout(() => { if (panel) panel.innerHTML = ''; }, 3000);
5095
- }
5096
- // SSE change event will trigger graph reload automatically
5097
- } catch (err) {
5098
- const logEl = document.getElementById('derivation-log');
5099
- if (logEl) logEl.textContent += '\nError: ' + err.message;
5100
- }
5101
- }
6251
+ async function acceptDerivation() {}
5102
6252
 
5103
6253
  /**
5104
6254
  * Cancel derivation — stop if running, clear proposals and panel.
@@ -5112,15 +6262,29 @@ async function cancelDerivation() {
5112
6262
 
5113
6263
  derivationSessionId = null;
5114
6264
  derivationProposals = null;
6265
+ renderDrillView();
6266
+ }
5115
6267
 
5116
- const logEl = document.getElementById('derivation-log');
5117
- if (logEl) { logEl.style.display = 'none'; logEl.innerHTML = ''; }
6268
+ /**
6269
+ * Trigger concurrent derivation for all declarations without milestones.
6270
+ * Calls POST /api/declarations/derive-all and populates activeDeriveMap.
6271
+ */
6272
+ async function triggerDeriveAll() {
6273
+ try {
6274
+ const res = await fetch('/api/declarations/derive-all', { method: 'POST' });
6275
+ const data = await res.json();
6276
+ if (!res.ok || !data.ok) return;
5118
6277
 
5119
- const proposals = document.getElementById('derivation-proposals');
5120
- if (proposals) { proposals.style.display = 'none'; proposals.innerHTML = ''; }
6278
+ // Populate activeDeriveMap from response
6279
+ for (const s of (data.sessions || [])) {
6280
+ activeDeriveMap.set(s.declarationId, { sessionId: s.sessionId, status: 'running' });
6281
+ }
5121
6282
 
5122
- const btn = document.getElementById('derive-btn');
5123
- if (btn) { btn.disabled = false; btn.textContent = 'Derive Milestones'; }
6283
+ // Re-render to show spinners on cards
6284
+ renderDrillView();
6285
+ } catch (err) {
6286
+ console.error('derive-all failed:', err);
6287
+ }
5124
6288
  }
5125
6289
 
5126
6290
  /**
@@ -5194,7 +6358,7 @@ async function saveDependencies(milestoneId, dependsOn) {
5194
6358
 
5195
6359
  async function startActionDerivation(milestoneId) {
5196
6360
  const btn = document.getElementById('action-derive-btn');
5197
- if (btn) { btn.disabled = true; btn.textContent = 'Deriving...'; }
6361
+ if (btn) { btn.disabled = true; btn.textContent = 'Planning...'; }
5198
6362
  const logEl = document.getElementById('action-derivation-log');
5199
6363
  if (logEl) { logEl.style.display = ''; logEl.innerHTML = ''; }
5200
6364
  try {
@@ -5204,7 +6368,7 @@ async function startActionDerivation(milestoneId) {
5204
6368
  const data = await res.json();
5205
6369
  if (!res.ok) {
5206
6370
  if (logEl) logEl.textContent = 'Error: ' + (data.error || 'Failed to start action derivation');
5207
- if (btn) { btn.disabled = false; btn.textContent = 'Derive Actions'; }
6371
+ if (btn) { btn.disabled = false; btn.textContent = 'Plan Actions'; }
5208
6372
  return;
5209
6373
  }
5210
6374
  actionDerivationSessionId = data.sessionId;
@@ -5212,14 +6376,17 @@ async function startActionDerivation(milestoneId) {
5212
6376
  actionDerivationProposals = null;
5213
6377
  } catch (err) {
5214
6378
  if (logEl) logEl.textContent = 'Error: ' + err.message;
5215
- if (btn) { btn.disabled = false; btn.textContent = 'Derive Actions'; }
6379
+ if (btn) { btn.disabled = false; btn.textContent = 'Plan Actions'; }
5216
6380
  }
5217
6381
  }
5218
6382
 
5219
6383
  function handleActionDerivationOutput(e) {
5220
6384
  try {
5221
6385
  const { sessionId, text } = JSON.parse(e.data);
5222
- if (sessionId !== actionDerivationSessionId) return;
6386
+ // Accept output if session matches OR if we haven't captured the session ID yet
6387
+ if (actionDerivationSessionId && sessionId !== actionDerivationSessionId) return;
6388
+ // Capture session ID from first output event if we missed it
6389
+ if (!actionDerivationSessionId) actionDerivationSessionId = sessionId;
5223
6390
  const logEl = document.getElementById('action-derivation-log');
5224
6391
  if (!logEl) return;
5225
6392
  logEl.appendChild(document.createTextNode(text + '\n'));
@@ -5229,32 +6396,50 @@ function handleActionDerivationOutput(e) {
5229
6396
 
5230
6397
  function handleActionDerivationComplete(e) {
5231
6398
  try {
5232
- const { sessionId, exitCode, actions } = JSON.parse(e.data);
5233
- if (sessionId !== actionDerivationSessionId) return;
6399
+ const { sessionId, milestoneId, exitCode, actions } = JSON.parse(e.data);
6400
+ // Accept completion if session matches OR if we haven't captured the session ID yet
6401
+ if (actionDerivationSessionId && sessionId !== actionDerivationSessionId) return;
5234
6402
  actionDerivationSessionId = null;
5235
6403
  const btn = document.getElementById('action-derive-btn');
5236
- if (btn) { btn.disabled = false; btn.textContent = 'Derive Actions'; }
6404
+ if (btn) { btn.disabled = false; btn.textContent = 'Plan Actions'; }
5237
6405
  if (exitCode !== 0) {
5238
6406
  const logEl = document.getElementById('action-derivation-log');
5239
6407
  if (logEl) {
5240
6408
  const span = document.createElement('span');
5241
6409
  span.style.color = 'var(--broken-color)';
5242
6410
  span.style.fontWeight = '700';
5243
- span.textContent = '\nAction derivation failed (exit code ' + exitCode + ')';
6411
+ span.textContent = '\nAction planning failed (exit code ' + exitCode + ')';
5244
6412
  logEl.appendChild(span);
5245
6413
  }
5246
6414
  return;
5247
6415
  }
5248
6416
  if (actions && Array.isArray(actions)) {
5249
- actionDerivationProposals = actions;
5250
- renderActionProposals();
6417
+ // Auto-accept: persist actions immediately (same as milestones), then re-render as cards
6418
+ const targetMilestoneId = milestoneId || actionDerivationMilestoneId;
6419
+ if (targetMilestoneId) {
6420
+ const acceptPayload = actions.map(a => ({ title: a.title, produces: a.produces || '' }));
6421
+ fetch('/api/milestones/' + encodeURIComponent(targetMilestoneId) + '/actions/derive/accept', {
6422
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
6423
+ body: JSON.stringify({ actions: acceptPayload }),
6424
+ }).then(r => r.json()).then(data => {
6425
+ actionDerivationProposals = null;
6426
+ actionDerivationMilestoneId = null;
6427
+ if (!data.error) {
6428
+ // Reload graph so new actions appear as cards
6429
+ loadData();
6430
+ }
6431
+ }).catch(() => {
6432
+ actionDerivationProposals = null;
6433
+ actionDerivationMilestoneId = null;
6434
+ });
6435
+ }
5251
6436
  } else {
5252
6437
  const logEl = document.getElementById('action-derivation-log');
5253
6438
  if (logEl) {
5254
6439
  const span = document.createElement('span');
5255
6440
  span.style.color = 'var(--integrity-partial)';
5256
6441
  span.style.fontWeight = '600';
5257
- span.textContent = '\nDerivation finished but output could not be parsed. Check the log above.';
6442
+ span.textContent = '\nPlanning finished but output could not be parsed. Check the log above.';
5258
6443
  logEl.appendChild(span);
5259
6444
  }
5260
6445
  }
@@ -5343,7 +6528,7 @@ async function cancelActionDerivation() {
5343
6528
  const proposals = document.getElementById('action-derivation-proposals');
5344
6529
  if (proposals) { proposals.style.display = 'none'; proposals.innerHTML = ''; }
5345
6530
  const btn = document.getElementById('action-derive-btn');
5346
- if (btn) { btn.disabled = false; btn.textContent = 'Derive Actions'; }
6531
+ if (btn) { btn.disabled = false; btn.textContent = 'Plan Actions'; }
5347
6532
  }
5348
6533
 
5349
6534
  const $focusHint = document.getElementById('focus-hint');
@@ -6214,11 +7399,43 @@ if ($refreshBtn) $refreshBtn.addEventListener('click', () => { loadData(); });
6214
7399
  // Execute / Next main button
6215
7400
  if ($executeMainBtn) {
6216
7401
  $executeMainBtn.addEventListener('click', () => {
7402
+ // Lifecycle-aware navigation
7403
+ const lca = $executeMainBtn._lifecycleAction;
7404
+ if (lca) {
7405
+ if (lca.action === 'derive-milestones' && lca.targetId) {
7406
+ drillDeclId = lca.targetId;
7407
+ drillLevel = 'milestones';
7408
+ if (viewMode !== 'columns') switchView('columns');
7409
+ else renderDrillView();
7410
+ return;
7411
+ }
7412
+ if (lca.action === 'derive-actions' && lca.targetId && graphData) {
7413
+ const mile = (graphData.milestones || []).find(m => m.id === lca.targetId);
7414
+ if (mile && mile.realizes && mile.realizes.length) drillDeclId = mile.realizes[0];
7415
+ drillMileId = lca.targetId;
7416
+ drillLevel = 'actions';
7417
+ if (viewMode !== 'columns') switchView('columns');
7418
+ else renderDrillView();
7419
+ return;
7420
+ }
7421
+ if (lca.action === 'approve' && lca.targetId && graphData) {
7422
+ // Navigate to the node needing approval
7423
+ const { declarations, milestones, actions: acts } = graphData;
7424
+ const enrichedM = (milestones || []).map(m => ({ ...m, ...deriveMilestoneStatus(m, acts || []) }));
7425
+ const enrichedD = (declarations || []).map(d => ({ ...d, displayStatus: deriveDeclarationStatus(d, enrichedM) }));
7426
+ navigateToItem({ id: lca.targetId, type: lca.targetType || 'declaration' }, enrichedD, enrichedM, acts || []);
7427
+ return;
7428
+ }
7429
+ if (lca.action === 'execute') {
7430
+ if (canEnterExecution()) switchView('execution');
7431
+ return;
7432
+ }
7433
+ }
7434
+
7435
+ // Fallback: original logic
6217
7436
  const target = $executeMainBtn._nextTarget;
6218
7437
  if (target) {
6219
- // Navigate to first unapproved item
6220
7438
  if (target.action) {
6221
- // Find which milestone/declaration this action belongs to
6222
7439
  const action = target.action;
6223
7440
  const mileId = (action.causes || [])[0];
6224
7441
  if (mileId && graphData) {
@@ -6242,7 +7459,6 @@ if ($executeMainBtn) {
6242
7459
  if (viewMode !== 'columns') switchView('columns');
6243
7460
  else renderDrillView();
6244
7461
  } else if ($executeMainBtn._planMode) {
6245
- // Navigate to first unplanned declaration's milestones view
6246
7462
  if (graphData) {
6247
7463
  const { declarations, milestones, actions: acts } = graphData;
6248
7464
  const enrichedM = (milestones || []).map(m => ({ ...m, ...deriveMilestoneStatus(m, acts || []) }));
@@ -6409,6 +7625,273 @@ function fireConfetti() {
6409
7625
  setTimeout(() => { cancelAnimationFrame(frame); canvas.remove(); }, 8000);
6410
7626
  }
6411
7627
 
7628
+ // --- Agent activity cards ---
7629
+
7630
+ const AGENT_TYPE_ICONS = { executor: '\uD83E\uDD16', planner: '\uD83D\uDCCB', deriver: '\u26A1', researcher: '\uD83D\uDD0D', revision: '\uD83D\uDD04', command: '\u2328\uFE0F', refine: '\uD83D\uDD0D', derivation: '\u26A1', 'action-derivation': '\u26A1', default: '\u2699\uFE0F' };
7631
+ const AGENT_TYPE_LABELS = { derivation: 'Planning', 'action-derivation': 'Planning Actions', revision: 'Revision', execution: 'Execution', pipeline: 'Pipeline', refine: 'Refine', discuss: 'Discuss', command: 'Command' };
7632
+
7633
+ const AGENT_STATUS_LABELS = { running: 'Running', complete: 'Done', done: 'Done', failed: 'Failed', interrupted: 'Stopped' };
7634
+
7635
+ /**
7636
+ * Format elapsed time between two timestamps as a human-readable string.
7637
+ * @param {string|number} startedAt - ISO string or ms timestamp
7638
+ * @param {string|number|null} [completedAt] - ISO string, ms timestamp, or null (for running agents)
7639
+ * @returns {string} e.g. "0:05", "1:23", "1h 05m"
7640
+ */
7641
+ function formatElapsed(startedAt, completedAt) {
7642
+ const start = typeof startedAt === 'string' ? new Date(startedAt).getTime() : startedAt;
7643
+ const end = completedAt ? (typeof completedAt === 'string' ? new Date(completedAt).getTime() : completedAt) : Date.now();
7644
+ const diffSec = Math.max(0, Math.floor((end - start) / 1000));
7645
+ if (diffSec >= 3600) {
7646
+ const h = Math.floor(diffSec / 3600);
7647
+ const m = Math.floor((diffSec % 3600) / 60);
7648
+ return `${h}h ${String(m).padStart(2, '0')}m`;
7649
+ }
7650
+ const m = Math.floor(diffSec / 60);
7651
+ const s = diffSec % 60;
7652
+ return `${m}:${String(s).padStart(2, '0')}`;
7653
+ }
7654
+
7655
+ /**
7656
+ * Generate a human-readable completion summary for an agent.
7657
+ * @param {object} agent
7658
+ * @returns {string}
7659
+ */
7660
+ function getAgentCompletionSummary(agent) {
7661
+ const result = agent.result || {};
7662
+ switch (agent.type) {
7663
+ case 'execution':
7664
+ return 'Executed ' + (result.actionId || agent.target);
7665
+ case 'derivation': {
7666
+ const count = result.milestones ? result.milestones.length : 0;
7667
+ return count > 0 ? 'Planned ' + count + ' milestone' + (count !== 1 ? 's' : '') : 'Planning complete';
7668
+ }
7669
+ case 'action-derivation': {
7670
+ const count = result.actionCount;
7671
+ const mId = result.milestoneId || agent.target;
7672
+ return count != null ? 'Planned ' + count + ' action' + (count !== 1 ? 's' : '') + ' for ' + mId : 'Actions planned for ' + mId;
7673
+ }
7674
+ case 'revision':
7675
+ return 'Revised ' + (result.nodeId || agent.target);
7676
+ case 'pipeline': {
7677
+ const c = result.completed || 0;
7678
+ const f = result.failed || 0;
7679
+ return c + ' completed' + (f > 0 ? ', ' + f + ' failed' : '');
7680
+ }
7681
+ default:
7682
+ return 'Completed';
7683
+ }
7684
+ }
7685
+
7686
+ /**
7687
+ * Render a single agent activity card as an HTML string.
7688
+ * @param {{ id: string, type: string, target: string, milestoneId?: string, status: string, startedAt: string|number, updatedAt?: string|number, completedAt?: string|number, exitCode?: number, error?: string, result?: any }} agent
7689
+ * @returns {string} HTML string
7690
+ */
7691
+ function renderAgentCard(agent) {
7692
+ const icon = AGENT_TYPE_ICONS[agent.type] || AGENT_TYPE_ICONS.default;
7693
+ const statusLabel = AGENT_STATUS_LABELS[agent.status] || agent.status;
7694
+ const elapsed = formatElapsed(agent.startedAt, agent.completedAt || null);
7695
+ const completedAttr = agent.completedAt ? escHtml(String(agent.completedAt)) : '';
7696
+
7697
+ let errorHtml = '';
7698
+ if (agent.error) {
7699
+ const truncated = agent.error.length > 120 ? agent.error.slice(0, 117) + '...' : agent.error;
7700
+ errorHtml = `<div class="agent-card-error" title="${escHtml(agent.error)}">${escHtml(truncated)}</div>`;
7701
+ }
7702
+
7703
+ // Completion summary for done agents
7704
+ let summaryHtml = '';
7705
+ const isDone = agent.status === 'complete' || agent.status === 'done';
7706
+ if (isDone) {
7707
+ const summary = getAgentCompletionSummary(agent);
7708
+ summaryHtml = `<div class="agent-card-summary">${escHtml(summary)}</div>`;
7709
+ }
7710
+
7711
+ // "View Result" button for done agents only (not failed)
7712
+ let viewResultHtml = '';
7713
+ if (isDone) {
7714
+ viewResultHtml = `<button class="agent-card-view-result" data-agent-id="${escHtml(agent.id)}">View Result</button>`;
7715
+ }
7716
+
7717
+ // Timer class: final for completed/failed, ticking for running
7718
+ const timerClass = (isDone || agent.status === 'failed') ? 'agent-card-timer agent-timer-final' : 'agent-card-timer';
7719
+
7720
+ return `<div class="agent-card status-${escHtml(agent.status)}" data-agent-id="${escHtml(agent.id)}" data-target="${escHtml(agent.target || '')}" style="cursor:pointer">
7721
+ <div class="agent-card-header">
7722
+ <span class="agent-card-icon">${icon}</span>
7723
+ <span class="agent-card-target">${escHtml(agent.target || '')}</span>
7724
+ <span class="agent-card-badge badge-${escHtml(agent.status)}">${escHtml(statusLabel)}</span>
7725
+ </div>
7726
+ <div class="agent-card-meta">
7727
+ <span class="agent-card-type">${escHtml(AGENT_TYPE_LABELS[agent.type] || agent.type || '')}</span>
7728
+ <span class="${timerClass}" data-started="${escHtml(String(agent.startedAt))}" data-completed="${completedAttr}">${elapsed}</span>
7729
+ </div>
7730
+ ${errorHtml}
7731
+ ${summaryHtml}
7732
+ ${viewResultHtml}
7733
+ </div>`;
7734
+ }
7735
+
7736
+ let cardTimerInterval = null;
7737
+
7738
+ /** Start the 1-second interval that updates elapsed timers on running agent cards. */
7739
+ function startCardTimers() {
7740
+ if (cardTimerInterval) return;
7741
+ cardTimerInterval = setInterval(() => {
7742
+ const timers = document.querySelectorAll('.agent-card.status-running .agent-card-timer');
7743
+ timers.forEach(el => {
7744
+ const started = el.getAttribute('data-started');
7745
+ if (started) el.textContent = formatElapsed(started, null);
7746
+ });
7747
+ }, 1000);
7748
+ }
7749
+
7750
+ /** Stop the card timer interval. */
7751
+ function stopCardTimers() {
7752
+ if (cardTimerInterval) {
7753
+ clearInterval(cardTimerInterval);
7754
+ cardTimerInterval = null;
7755
+ }
7756
+ }
7757
+
7758
+ /** Map of active/recent agent states keyed by agent ID. */
7759
+ const agentCardState = new Map();
7760
+
7761
+ // DOM references for card containers (A-124)
7762
+ const $activityCards = document.getElementById('activity-cards');
7763
+ const $activityCardsActive = document.getElementById('activity-cards-active');
7764
+ const $activityCardsRecent = document.getElementById('activity-cards-recent');
7765
+
7766
+ /**
7767
+ * Re-render the agent cards panel.
7768
+ * Splits agents from agentCardState into active (running) and recent (done/failed),
7769
+ * renders them into #activity-cards-active and #activity-cards-recent.
7770
+ */
7771
+ function renderAgentPanel() {
7772
+ if (!$activityCardsActive) return;
7773
+
7774
+ const agents = Array.from(agentCardState.values());
7775
+ const active = agents
7776
+ .filter(a => a.status === 'running')
7777
+ .sort((a, b) => new Date(b.startedAt || 0).getTime() - new Date(a.startedAt || 0).getTime());
7778
+ const recent = agents
7779
+ .filter(a => a.status === 'complete' || a.status === 'done' || a.status === 'failed' || a.status === 'interrupted')
7780
+ .sort((a, b) => new Date(b.completedAt || b.updatedAt || 0).getTime() - new Date(a.completedAt || a.updatedAt || 0).getTime())
7781
+ .slice(0, 10);
7782
+
7783
+ if (active.length === 0 && recent.length === 0) {
7784
+ $activityCardsActive.innerHTML = '<div style="padding:16px;color:var(--text-muted);font-size:11px;text-align:center;">No active agents</div>';
7785
+ $activityCardsRecent.innerHTML = '';
7786
+ stopCardTimers();
7787
+ return;
7788
+ }
7789
+
7790
+ $activityCardsActive.innerHTML = active.length > 0
7791
+ ? active.map(renderAgentCard).join('')
7792
+ : '<div style="padding:16px;color:var(--text-muted);font-size:11px;text-align:center;">No active agents</div>';
7793
+ $activityCardsRecent.innerHTML = recent.map(renderAgentCard).join('');
7794
+
7795
+ if (active.length > 0) startCardTimers(); else stopCardTimers();
7796
+ }
7797
+
7798
+ // Delegated click handler for "View Result" buttons on agent cards (A-128)
7799
+ function handleViewResultClick(e) {
7800
+ const btn = e.target.closest('.agent-card-view-result');
7801
+ if (!btn) return;
7802
+ e.stopPropagation();
7803
+ const agentId = btn.getAttribute('data-agent-id');
7804
+ const agent = agentCardState.get(agentId);
7805
+ if (agent) navigateToResult(agent);
7806
+ }
7807
+ // Delegated click handler: clicking an agent card navigates to its target node
7808
+ function handleAgentCardClick(e) {
7809
+ // Don't navigate if clicking a button (View Result, etc.)
7810
+ if (e.target.closest('button')) return;
7811
+ const card = e.target.closest('.agent-card');
7812
+ if (!card) return;
7813
+ const target = card.getAttribute('data-target');
7814
+ if (!target || !graphData) return;
7815
+ const prefix = target.split('-')[0];
7816
+ if (prefix === 'D') {
7817
+ drillDeclId = target;
7818
+ drillLevel = 'milestones';
7819
+ drillMileId = null;
7820
+ pushDrillHash();
7821
+ renderDrillView();
7822
+ } else if (prefix === 'M') {
7823
+ const mile = (graphData.milestones || []).find(m => m.id === target);
7824
+ if (mile && mile.realizes && mile.realizes.length) drillDeclId = mile.realizes[0];
7825
+ drillMileId = target;
7826
+ drillLevel = 'actions';
7827
+ pushDrillHash();
7828
+ renderDrillView();
7829
+ } else if (prefix === 'A') {
7830
+ const action = (graphData.actions || []).find(a => a.id === target);
7831
+ if (action && action.causes && action.causes.length) {
7832
+ drillMileId = action.causes[0];
7833
+ const parentMile = (graphData.milestones || []).find(m => m.id === drillMileId);
7834
+ if (parentMile && parentMile.realizes && parentMile.realizes.length) drillDeclId = parentMile.realizes[0];
7835
+ drillLevel = 'actions';
7836
+ pushDrillHash();
7837
+ renderDrillView();
7838
+ }
7839
+ }
7840
+ }
7841
+ if ($activityCardsActive) $activityCardsActive.addEventListener('click', handleAgentCardClick);
7842
+ if ($activityCardsRecent) $activityCardsRecent.addEventListener('click', handleAgentCardClick);
7843
+
7844
+ if ($activityCardsActive) $activityCardsActive.addEventListener('click', handleViewResultClick);
7845
+ if ($activityCardsRecent) $activityCardsRecent.addEventListener('click', handleViewResultClick);
7846
+
7847
+ // Tab switching for Agents/Log tabs (A-124)
7848
+ document.querySelectorAll('.activity-tab').forEach(tab => {
7849
+ tab.addEventListener('click', () => {
7850
+ document.querySelectorAll('.activity-tab').forEach(t => t.classList.remove('active'));
7851
+ document.querySelectorAll('.activity-tab-content').forEach(c => c.classList.remove('active'));
7852
+ tab.classList.add('active');
7853
+ const target = tab.dataset.tab === 'cards' ? $activityCards : document.getElementById('activity-list');
7854
+ if (target) target.classList.add('active');
7855
+ });
7856
+ });
7857
+
7858
+ /**
7859
+ * Check if any agent is actively running for a given node ID.
7860
+ * @param {string} nodeId
7861
+ * @returns {{ type: string, id: string } | null}
7862
+ */
7863
+ function getRunningAgentForNode(nodeId) {
7864
+ for (const [, agent] of agentCardState) {
7865
+ if (agent.status === 'running' && agent.target === nodeId) return agent;
7866
+ }
7867
+ return null;
7868
+ }
7869
+
7870
+ /**
7871
+ * Fetch current agent state from server and populate card state.
7872
+ * Called on page load and SSE reconnect to ensure cards survive refresh.
7873
+ */
7874
+ async function loadAgentCards() {
7875
+ try {
7876
+ const res = await fetch('/api/agents');
7877
+ if (!res.ok) return;
7878
+ const data = await res.json();
7879
+ const agents = [].concat(data.active || [], data.recent || []);
7880
+
7881
+ // Replace entire state with server truth
7882
+ agentCardState.clear();
7883
+ for (const agent of agents) {
7884
+ agentCardState.set(agent.id, agent);
7885
+ }
7886
+ renderAgentPanel();
7887
+ if (agents.length > 0) {
7888
+ console.log('[agents]', agents.length, 'agents loaded, active:', agents.filter(a => a.status === 'running').length);
7889
+ }
7890
+ } catch (err) {
7891
+ console.error('[agents] loadAgentCards failed:', err);
7892
+ }
7893
+ }
7894
+
6412
7895
  // ─── Activity topbar ──────────────────────────────────────────────────────────
6413
7896
 
6414
7897
  /** @type {{ actionId: string, milestoneId?: string, startedAt: number } | null} */
@@ -6416,16 +7899,8 @@ let topbarActiveOp = null;
6416
7899
  /** @type {{ actionId: string, milestoneId?: string, completedAt: number } | null} */
6417
7900
  let topbarLastOp = null;
6418
7901
 
6419
- function updateTopbar() {
6420
- if (!$activityPinned) return;
6421
- if (topbarActiveOp) {
6422
- const aLabel = topbarActiveOp.actionId;
6423
- const mLabel = topbarActiveOp.milestoneId ? ' \u2014 ' + topbarActiveOp.milestoneId : '';
6424
- $activityPinned.innerHTML = '<div class="activity-pinned"><span class="pinned-spinner"></span> EXECUTING ' + escHtml(aLabel) + escHtml(mLabel) + '</div>';
6425
- } else {
6426
- $activityPinned.innerHTML = '';
6427
- }
6428
- }
7902
+ // updateTopbar() — no-op, replaced by agent cards panel (A-124)
7903
+ function updateTopbar() {}
6429
7904
 
6430
7905
  function formatTimeAgo(ts) {
6431
7906
  var diff = Math.floor((Date.now() - ts) / 1000);
@@ -6459,25 +7934,13 @@ function topbarOnActionComplete(actionId) {
6459
7934
  updateTopbar();
6460
7935
  }
6461
7936
 
6462
- async function topbarOnActivity() {
6463
- try {
6464
- var res = await fetch('/api/activity');
6465
- var data = await res.json();
6466
- var events = data.events || [];
6467
- var taskStart = events.find(function(ev) { return ev.tool === 'Task' && ev.phase === 'start'; });
6468
- if (taskStart) {
6469
- var actionMatch = (taskStart.desc || '').match(/A-\d+/i);
6470
- var mileMatch = (taskStart.desc || '').match(/M-\d+/i);
6471
- if (actionMatch) {
6472
- topbarActiveOp = {
6473
- actionId: actionMatch[0].toUpperCase(),
6474
- milestoneId: mileMatch ? mileMatch[0].toUpperCase() : '',
6475
- startedAt: new Date(taskStart.ts).getTime() || Date.now(),
6476
- };
6477
- updateTopbar();
6478
- }
6479
- }
6480
- } catch (_) {}
7937
+ // topbarOnActivity() — simplified to just pulse the activity indicator (A-124)
7938
+ function topbarOnActivity() {
7939
+ if ($activityPulse) {
7940
+ $activityPulse.classList.add('live');
7941
+ clearTimeout($activityPulse._topbarTimer);
7942
+ $activityPulse._topbarTimer = setTimeout(() => $activityPulse.classList.remove('live'), 3000);
7943
+ }
6481
7944
  }
6482
7945
 
6483
7946
  setInterval(function() { if (!topbarActiveOp && topbarLastOp) updateTopbar(); }, 30000);
@@ -6535,7 +7998,12 @@ async function loadActivity() {
6535
7998
  return '';
6536
7999
  }
6537
8000
 
6538
- return `<div class="activity-event">
8001
+ // Extract node ID from desc for navigation
8002
+ const nodeMatch = (desc || '').match(/\b([DMA]-\d+)\b/);
8003
+ const clickable = nodeMatch ? ' clickable' : '';
8004
+ const nodeAttr = nodeMatch ? ` data-nav-node="${nodeMatch[1]}"` : '';
8005
+
8006
+ return `<div class="activity-event${clickable}"${nodeAttr}>
6539
8007
  <div class="ae-top">
6540
8008
  <span class="ae-icon">${icon}</span>
6541
8009
  <span class="ae-label ${labelClass}">${escHtml(label)}</span>
@@ -6568,8 +8036,8 @@ const STATE_DISPLAY = {
6568
8036
 
6569
8037
  const STATE_BTN_LABEL = {
6570
8038
  'create-declaration': '+ Declaration',
6571
- 'derive-milestones': 'Derive Milestones',
6572
- 'derive-actions': 'Derive Actions',
8039
+ 'derive-milestones': 'Plan Milestones',
8040
+ 'derive-actions': 'Plan Actions',
6573
8041
  'execute-action': 'Execute',
6574
8042
  'plan-actions': 'Plan Actions',
6575
8043
  'view-execution': 'View Execution',
@@ -6590,6 +8058,18 @@ async function loadWorkflowState() {
6590
8058
  }
6591
8059
  }
6592
8060
 
8061
+ async function loadLifecycleData() {
8062
+ try {
8063
+ lifecycleData = await fetchJson('/api/lifecycle');
8064
+ // Re-render if we're at the declarations level (lifecycle view)
8065
+ if (drillLevel === 'declarations' && viewMode === 'columns') {
8066
+ renderDrillView();
8067
+ }
8068
+ } catch (_) {
8069
+ lifecycleData = null;
8070
+ }
8071
+ }
8072
+
6593
8073
  /**
6594
8074
  * Render the workflow next-step banner from current workflowState.
6595
8075
  */
@@ -6667,9 +8147,45 @@ if ($wfActionBtn) {
6667
8147
 
6668
8148
  function connectSSE() {
6669
8149
  const es = new EventSource('/events');
8150
+ es.addEventListener('open', function() {
8151
+ sseReconnectDelay = 1000;
8152
+ hideReconnectBanner();
8153
+ // Re-sync agent state on reconnect
8154
+ loadAgentCards();
8155
+ });
8156
+ let sseChangeTimer = null;
6670
8157
  es.addEventListener('change', () => {
6671
8158
  if (focusNodeId || focusCleanupTimer) return; // skip during animation
6672
- loadData();
8159
+ // Debounce rapid SSE change events (e.g. approve writes multiple files)
8160
+ clearTimeout(sseChangeTimer);
8161
+ sseChangeTimer = setTimeout(() => {
8162
+ // If a refine or discuss session is active, save and restore the area across re-renders
8163
+ if (refineActiveNodeId || refineActiveNodes.size > 0 || discussActiveNodeId) {
8164
+ // Save refine area content before re-render
8165
+ let savedRefineHtml = null;
8166
+ const savedRefineNodeId = refineActiveNodeId;
8167
+ if (savedRefineNodeId) {
8168
+ const existingArea = document.getElementById(`refine-area-${savedRefineNodeId}`);
8169
+ savedRefineHtml = existingArea ? existingArea.innerHTML : null;
8170
+ }
8171
+ // Save discuss container (detached element — just need to re-attach after render)
8172
+ const savedDiscussNodeId = discussActiveNodeId;
8173
+ loadData().then(() => {
8174
+ // Restore refine area
8175
+ if (savedRefineHtml && savedRefineNodeId) {
8176
+ const restoredArea = document.getElementById(`refine-area-${savedRefineNodeId}`);
8177
+ if (restoredArea) restoredArea.innerHTML = savedRefineHtml;
8178
+ }
8179
+ // Re-attach discuss container
8180
+ if (savedDiscussNodeId && discussActiveContainer) {
8181
+ reattachDiscussContainer();
8182
+ }
8183
+ });
8184
+ } else {
8185
+ loadData();
8186
+ }
8187
+ loadAgentCards(); // refresh agent cards on any .planning/ change
8188
+ }, 300);
6673
8189
  });
6674
8190
  es.addEventListener('activity', () => {
6675
8191
  loadActivity();
@@ -6741,8 +8257,10 @@ function connectSSE() {
6741
8257
  es.addEventListener('refine-output', function(e) {
6742
8258
  try {
6743
8259
  const data = JSON.parse(e.data);
6744
- if (!refineActiveNodeId || data.nodeId !== refineActiveNodeId.toUpperCase()) return;
6745
- const area = document.getElementById(`refine-area-${refineActiveNodeId}`);
8260
+ // Find matching active refine by nodeId
8261
+ const nId = [...refineActiveNodes].find(id => id.toUpperCase() === data.nodeId) || refineActiveNodeId;
8262
+ if (!nId) return;
8263
+ const area = document.getElementById(`refine-area-${nId}`);
6746
8264
  if (!area) return;
6747
8265
  const streaming = area.querySelector('.refine-streaming');
6748
8266
  if (streaming) {
@@ -6754,9 +8272,10 @@ function connectSSE() {
6754
8272
  es.addEventListener('refine-complete', function(e) {
6755
8273
  try {
6756
8274
  const data = JSON.parse(e.data);
6757
- if (!refineActiveNodeId || data.nodeId !== refineActiveNodeId.toUpperCase()) return;
6758
- const nId = refineActiveNodeId;
6759
- refineActiveNodeId = null;
8275
+ const nId = [...refineActiveNodes].find(id => id.toUpperCase() === data.nodeId) || refineActiveNodeId;
8276
+ if (!nId) return;
8277
+ refineActiveNodes.delete(nId);
8278
+ if (refineActiveNodeId === nId) refineActiveNodeId = null;
6760
8279
  const area = document.getElementById(`refine-area-${nId}`);
6761
8280
  if (!area) return;
6762
8281
 
@@ -6810,17 +8329,689 @@ function connectSSE() {
6810
8329
  }
6811
8330
  } catch (_) {}
6812
8331
  });
8332
+ // ─── Discuss (interview) SSE events ──────────────────────────────────────
8333
+ es.addEventListener('discuss-output', function(e) {
8334
+ try {
8335
+ const data = JSON.parse(e.data);
8336
+ const outputEl = document.getElementById(`discuss-output-${data.nodeId}`);
8337
+ if (outputEl) {
8338
+ if (outputEl.textContent === 'Thinking...') outputEl.textContent = '';
8339
+ outputEl.textContent += data.text;
8340
+ }
8341
+ } catch (_) {}
8342
+ });
8343
+ es.addEventListener('discuss-complete', function(e) {
8344
+ try {
8345
+ const data = JSON.parse(e.data);
8346
+ const container = document.getElementById(`discuss-area-${data.nodeId}`);
8347
+ if (!container) return;
8348
+ if (data.error || !Array.isArray(data.questions) || data.questions.length === 0) {
8349
+ container.innerHTML = `<div class="discuss-result">
8350
+ <div class="discuss-error">${escHtml(data.error || 'No questions generated. Proceed with derivation.')}</div>
8351
+ <button class="drill-action-btn" id="discuss-skip-${data.nodeId}">Proceed</button>
8352
+ </div>`;
8353
+ container.querySelector(`#discuss-skip-${data.nodeId}`).addEventListener('click', () => {
8354
+ clearDiscussState();
8355
+ triggerDerivation(data.nodeId);
8356
+ });
8357
+ return;
8358
+ }
8359
+ // Show questions as a form
8360
+ let html = '<div class="discuss-questions">';
8361
+ html += '<div class="discuss-header">Answer these questions to improve the plan:</div>';
8362
+ data.questions.forEach((q, i) => {
8363
+ html += `<div class="discuss-q">
8364
+ <label class="discuss-q-label">${escHtml(q.question)}</label>
8365
+ ${q.context ? `<div class="discuss-q-context">${escHtml(q.context)}</div>` : ''}
8366
+ ${q.options && q.options.length > 0
8367
+ ? `<div class="discuss-q-options">${q.options.map(opt =>
8368
+ `<button class="discuss-option-btn" data-q="${i}" data-opt="${escHtml(opt)}">${escHtml(opt)}</button>`
8369
+ ).join('')}</div>`
8370
+ : ''}
8371
+ <textarea class="discuss-q-input" data-q="${i}" rows="2" placeholder="Your answer..."></textarea>
8372
+ </div>`;
8373
+ });
8374
+ html += `<div class="discuss-actions">
8375
+ <button class="drill-action-btn drill-action-primary" id="discuss-submit-${data.nodeId}">Save &amp; Proceed</button>
8376
+ <button class="drill-action-btn" id="discuss-skip-${data.nodeId}">Skip</button>
8377
+ </div></div>`;
8378
+ container.innerHTML = html;
8379
+
8380
+ // Wire option buttons to fill textarea
8381
+ container.querySelectorAll('.discuss-option-btn').forEach(btn => {
8382
+ btn.addEventListener('click', () => {
8383
+ const idx = btn.dataset.q;
8384
+ const ta = container.querySelector(`.discuss-q-input[data-q="${idx}"]`);
8385
+ if (ta) ta.value = btn.dataset.opt;
8386
+ });
8387
+ });
8388
+
8389
+ // Wire submit
8390
+ container.querySelector(`#discuss-submit-${data.nodeId}`).addEventListener('click', async () => {
8391
+ const answers = [];
8392
+ data.questions.forEach((q, i) => {
8393
+ const ta = container.querySelector(`.discuss-q-input[data-q="${i}"]`);
8394
+ answers.push({ question: q.question, answer: ta ? ta.value.trim() : '' });
8395
+ });
8396
+ const filtered = answers.filter(a => a.answer);
8397
+ if (filtered.length > 0) {
8398
+ await fetch(`/api/node/${encodeURIComponent(data.nodeId)}/discuss/answer`, {
8399
+ method: 'POST',
8400
+ headers: { 'Content-Type': 'application/json' },
8401
+ body: JSON.stringify({ answers: filtered }),
8402
+ });
8403
+ }
8404
+ clearDiscussState();
8405
+ triggerDerivation(data.nodeId);
8406
+ });
8407
+
8408
+ // Wire skip
8409
+ container.querySelector(`#discuss-skip-${data.nodeId}`).addEventListener('click', () => {
8410
+ clearDiscussState();
8411
+ triggerDerivation(data.nodeId);
8412
+ });
8413
+ } catch (_) {}
8414
+ });
8415
+
8416
+ // ─── Agent lifecycle SSE events (M-44) ───────────────────────────────────
8417
+ es.addEventListener('agent-start', function(e) {
8418
+ try {
8419
+ const agent = JSON.parse(e.data);
8420
+ agentCardState.set(agent.id, agent);
8421
+ renderAgentPanel();
8422
+ // Flash pulse indicator
8423
+ if ($activityPulse) {
8424
+ $activityPulse.classList.add('live');
8425
+ clearTimeout($activityPulse._timer);
8426
+ $activityPulse._timer = setTimeout(() => $activityPulse.classList.remove('live'), 3000);
8427
+ }
8428
+ } catch (_) {}
8429
+ });
8430
+ es.addEventListener('agent-update', function(e) {
8431
+ try {
8432
+ const agent = JSON.parse(e.data);
8433
+ // Merge with existing state to preserve any client-side additions
8434
+ const existing = agentCardState.get(agent.id);
8435
+ agentCardState.set(agent.id, Object.assign({}, existing || {}, agent));
8436
+ renderAgentPanel();
8437
+ } catch (_) {}
8438
+ });
8439
+ es.addEventListener('agent-complete', function(e) {
8440
+ try {
8441
+ const agent = JSON.parse(e.data);
8442
+ const existing = agentCardState.get(agent.id);
8443
+ agentCardState.set(agent.id, Object.assign({}, existing || {}, agent));
8444
+ renderAgentPanel();
8445
+ // Flash pulse
8446
+ if ($activityPulse) {
8447
+ $activityPulse.classList.add('live');
8448
+ clearTimeout($activityPulse._timer);
8449
+ $activityPulse._timer = setTimeout(() => $activityPulse.classList.remove('live'), 3000);
8450
+ }
8451
+ } catch (_) {}
8452
+ });
8453
+
8454
+ // Command output — stream text into command output area
8455
+ es.addEventListener('command-output', function(e) {
8456
+ try {
8457
+ const data = JSON.parse(e.data);
8458
+ const outputEl = document.getElementById('command-output-stream');
8459
+ if (outputEl) {
8460
+ if (outputEl.textContent === 'Running...') outputEl.textContent = '';
8461
+ outputEl.textContent += data.text;
8462
+ outputEl.scrollTop = outputEl.scrollHeight;
8463
+ }
8464
+ } catch (_) {}
8465
+ });
8466
+
8467
+ // Command complete — reload graph to reflect changes
8468
+ es.addEventListener('command-complete', function(e) {
8469
+ try {
8470
+ const data = JSON.parse(e.data);
8471
+ // Update command card UI
8472
+ const outputEl = document.getElementById('command-output-stream');
8473
+ if (outputEl) {
8474
+ if (data.error) {
8475
+ outputEl.textContent += '\n\nError: ' + data.error;
8476
+ }
8477
+ outputEl.classList.remove('streaming');
8478
+ }
8479
+ const cmdCard = document.getElementById('command-card');
8480
+ if (cmdCard) { cmdCard.classList.remove('running'); cmdCard.classList.add('editing'); }
8481
+ if (data.exitCode === 0) {
8482
+ loadData().then(() => renderDrillView()); // refresh graph since command may have modified files
8483
+ loadActivity();
8484
+ }
8485
+ } catch (_) {}
8486
+ });
8487
+
8488
+ // Onboarding flow SSE events
8489
+ es.addEventListener('onboard-output', function(e) {
8490
+ try {
8491
+ const data = JSON.parse(e.data);
8492
+ onboardStreamText += data.text;
8493
+ const el = document.getElementById('onboard-stream');
8494
+ if (el) {
8495
+ el.textContent = onboardStreamText;
8496
+ el.scrollTop = el.scrollHeight;
8497
+ }
8498
+ } catch (_) {}
8499
+ });
8500
+
8501
+ es.addEventListener('onboard-questions-complete', function(e) {
8502
+ try {
8503
+ const data = JSON.parse(e.data);
8504
+ if (data.error) {
8505
+ onboardPhase = 'idle';
8506
+ renderDrillView();
8507
+ return;
8508
+ }
8509
+ onboardQuestions = data.questions;
8510
+ onboardStreamText = '';
8511
+ renderOnboardUI();
8512
+ } catch (_) {}
8513
+ });
8514
+
8515
+ es.addEventListener('onboard-proposals-complete', function(e) {
8516
+ try {
8517
+ const data = JSON.parse(e.data);
8518
+ if (data.error) {
8519
+ onboardPhase = 'idle';
8520
+ renderDrillView();
8521
+ return;
8522
+ }
8523
+ onboardProposals = data.proposals;
8524
+ onboardStreamText = '';
8525
+ renderOnboardUI();
8526
+ } catch (_) {}
8527
+ });
8528
+
6813
8529
  es.addEventListener('error', () => {
6814
- // Connection dropped — reconnect after 3s
6815
8530
  es.close();
6816
- setTimeout(connectSSE, 3000);
8531
+ showReconnectBanner();
8532
+ setTimeout(connectSSE, sseReconnectDelay);
8533
+ // Exponential backoff: 1s, 2s, 4s, 8s, max 30s
8534
+ sseReconnectDelay = Math.min(sseReconnectDelay * 2, 30000);
6817
8535
  });
6818
8536
  }
6819
8537
 
8538
+ /** @type {number} Current SSE reconnection delay (exponential backoff) */
8539
+ let sseReconnectDelay = 1000;
8540
+
8541
+ function showReconnectBanner() {
8542
+ let banner = document.getElementById('reconnect-banner');
8543
+ if (!banner) {
8544
+ banner = document.createElement('div');
8545
+ banner.id = 'reconnect-banner';
8546
+ banner.textContent = 'Reconnecting\u2026';
8547
+ document.body.appendChild(banner);
8548
+ }
8549
+ banner.classList.add('visible');
8550
+ }
8551
+
8552
+ function hideReconnectBanner() {
8553
+ const banner = document.getElementById('reconnect-banner');
8554
+ if (banner) banner.classList.remove('visible');
8555
+ }
8556
+
6820
8557
  connectSSE();
6821
8558
 
8559
+ // Prune completed agents older than 30 minutes to prevent unbounded growth
8560
+ setInterval(function() {
8561
+ const cutoff = Date.now() - 30 * 60 * 1000;
8562
+ for (const [id, agent] of agentCardState) {
8563
+ if (agent.status !== 'running' && agent.completedAt) {
8564
+ const completedTs = new Date(agent.completedAt).getTime();
8565
+ if (completedTs < cutoff) agentCardState.delete(id);
8566
+ }
8567
+ }
8568
+ }, 60000);
8569
+
8570
+ // ─── Command card ─────────────────────────────────────────────────────────────
8571
+
8572
+ const $commandCard = document.getElementById('command-card');
8573
+ const $commandInput = document.getElementById('command-card-input');
8574
+
8575
+ if ($commandCard && $commandInput) {
8576
+ // Click to expand
8577
+ $commandCard.addEventListener('click', function() {
8578
+ if (!$commandCard.classList.contains('editing')) {
8579
+ $commandCard.classList.add('editing');
8580
+ $commandInput.value = '';
8581
+ $commandInput.focus();
8582
+ }
8583
+ });
8584
+
8585
+ // Esc to blur — stop propagation to prevent drill-level back-navigation
8586
+ $commandInput.addEventListener('keydown', function(e) {
8587
+ if (e.key === 'Escape') {
8588
+ e.stopPropagation();
8589
+ $commandInput.value = '';
8590
+ $commandInput.blur();
8591
+ }
8592
+ // Enter to send (without shift)
8593
+ if (e.key === 'Enter' && !e.shiftKey) {
8594
+ e.preventDefault();
8595
+ const msg = $commandInput.value.trim();
8596
+ if (!msg) return;
8597
+ $commandInput.value = '';
8598
+ $commandInput.blur();
8599
+ sendCommand(msg);
8600
+ }
8601
+ });
8602
+
8603
+ // Global shortcut: C key (when not in input)
8604
+ document.addEventListener('keydown', function(e) {
8605
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
8606
+ if (e.key === 'c' && !e.metaKey && !e.ctrlKey && !e.altKey) {
8607
+ // Only if agents tab is visible
8608
+ const agentsTab = document.querySelector('.activity-tab[data-tab="cards"]');
8609
+ if (agentsTab && !agentsTab.classList.contains('active')) {
8610
+ agentsTab.click();
8611
+ }
8612
+ $commandCard.click();
8613
+ e.preventDefault();
8614
+ }
8615
+ });
8616
+ }
8617
+
8618
+ // ─── Onboarding flow functions ─────────────────────────────────────────────────
8619
+
8620
+ function loadOnboardState() {
8621
+ fetch('/api/onboard/state').then(r => r.json()).then(data => {
8622
+ if (!data.active) return;
8623
+ onboardPrompt = data.prompt;
8624
+ onboardQuestions = data.questions;
8625
+ onboardProposals = data.proposals;
8626
+ onboardApproveIndex = data.approveIndex || 0;
8627
+ onboardStreamText = '';
8628
+
8629
+ if (data.phase === 'approving' && data.proposals) {
8630
+ // Mark already-approved proposals
8631
+ for (let i = 0; i < onboardApproveIndex && i < onboardProposals.length; i++) {
8632
+ onboardProposals[i].approvedId = onboardProposals[i].approvedId || '?';
8633
+ }
8634
+ onboardPhase = 'approving';
8635
+ } else if (data.phase === 'proposals' && data.proposals) {
8636
+ onboardPhase = 'proposals';
8637
+ } else if (data.phase === 'questions' && data.questions) {
8638
+ onboardPhase = 'questions';
8639
+ } else {
8640
+ return; // Nothing useful to restore
8641
+ }
8642
+
8643
+ renderOnboardUI();
8644
+ }).catch(() => {});
8645
+ }
8646
+
8647
+ function startOnboard(message) {
8648
+ onboardPhase = 'questions';
8649
+ onboardPrompt = message;
8650
+ onboardQuestions = null;
8651
+ onboardProposals = null;
8652
+ onboardApproveIndex = 0;
8653
+ onboardStreamText = '';
8654
+ renderOnboardUI();
8655
+
8656
+ fetch('/api/onboard', {
8657
+ method: 'POST',
8658
+ headers: { 'Content-Type': 'application/json' },
8659
+ body: JSON.stringify({ message }),
8660
+ }).then(r => r.json()).then(data => {
8661
+ if (data.error) {
8662
+ onboardPhase = 'idle';
8663
+ renderDrillView();
8664
+ }
8665
+ }).catch(() => {
8666
+ onboardPhase = 'idle';
8667
+ renderDrillView();
8668
+ });
8669
+ }
8670
+
8671
+ function submitOnboardAnswers() {
8672
+ const textareas = document.querySelectorAll('.onboard-question textarea');
8673
+ const answers = [];
8674
+ if (onboardQuestions) {
8675
+ onboardQuestions.forEach((q, i) => {
8676
+ const ta = textareas[i];
8677
+ answers.push({ question: q.question, answer: ta ? ta.value.trim() : '' });
8678
+ });
8679
+ }
8680
+
8681
+ onboardPhase = 'proposals';
8682
+ onboardStreamText = '';
8683
+ renderOnboardUI();
8684
+
8685
+ fetch('/api/onboard/answer', {
8686
+ method: 'POST',
8687
+ headers: { 'Content-Type': 'application/json' },
8688
+ body: JSON.stringify({ answers }),
8689
+ }).then(r => r.json()).then(data => {
8690
+ if (data.error) {
8691
+ onboardPhase = 'idle';
8692
+ renderDrillView();
8693
+ }
8694
+ }).catch(() => {
8695
+ onboardPhase = 'idle';
8696
+ renderDrillView();
8697
+ });
8698
+ }
8699
+
8700
+ function skipOnboardQuestions() {
8701
+ onboardPhase = 'proposals';
8702
+ onboardStreamText = '';
8703
+ renderOnboardUI();
8704
+
8705
+ fetch('/api/onboard/answer', {
8706
+ method: 'POST',
8707
+ headers: { 'Content-Type': 'application/json' },
8708
+ body: JSON.stringify({ answers: [] }),
8709
+ }).then(r => r.json()).catch(() => {
8710
+ onboardPhase = 'idle';
8711
+ renderDrillView();
8712
+ });
8713
+ }
8714
+
8715
+ function cancelOnboard() {
8716
+ fetch('/api/onboard/cancel', { method: 'POST' }).catch(() => {});
8717
+ onboardPhase = 'idle';
8718
+ onboardPrompt = null;
8719
+ onboardQuestions = null;
8720
+ onboardProposals = null;
8721
+ renderDrillView();
8722
+ }
8723
+
8724
+ function startOnboardApproving(mode) {
8725
+ onboardPhase = 'approving';
8726
+ onboardApproveIndex = 0;
8727
+ if (mode === 'all') {
8728
+ approveAllOnboard();
8729
+ } else {
8730
+ renderOnboardUI();
8731
+ }
8732
+ }
8733
+
8734
+ async function approveCurrentOnboard() {
8735
+ if (!onboardProposals || onboardApproveIndex >= onboardProposals.length) return;
8736
+
8737
+ const proposal = onboardProposals[onboardApproveIndex];
8738
+ // Read potentially edited values from inputs
8739
+ const titleInput = document.querySelector('.onboard-proposal.current .onboard-title');
8740
+ const stmtInput = document.querySelector('.onboard-proposal.current .onboard-statement');
8741
+ const title = titleInput ? titleInput.value.trim() : proposal.title;
8742
+ const statement = stmtInput ? stmtInput.value.trim() : proposal.statement;
8743
+
8744
+ try {
8745
+ const resp = await fetch('/api/onboard/approve', {
8746
+ method: 'POST',
8747
+ headers: { 'Content-Type': 'application/json' },
8748
+ body: JSON.stringify({ title, statement }),
8749
+ });
8750
+ const data = await resp.json();
8751
+ if (data.id) {
8752
+ proposal.approvedId = data.id;
8753
+ }
8754
+ } catch (_) {}
8755
+
8756
+ onboardApproveIndex++;
8757
+ if (onboardApproveIndex >= onboardProposals.length) {
8758
+ // All done — reset and refresh
8759
+ onboardPhase = 'idle';
8760
+ onboardPrompt = null;
8761
+ onboardQuestions = null;
8762
+ onboardProposals = null;
8763
+ fetch('/api/onboard/complete', { method: 'POST' }).catch(() => {});
8764
+ loadData().then(() => renderDrillView());
8765
+ loadActivity();
8766
+ } else {
8767
+ renderOnboardUI();
8768
+ }
8769
+ }
8770
+
8771
+ async function approveAllOnboard() {
8772
+ if (!onboardProposals) return;
8773
+
8774
+ for (let i = 0; i < onboardProposals.length; i++) {
8775
+ onboardApproveIndex = i;
8776
+ renderOnboardUI();
8777
+ const proposal = onboardProposals[i];
8778
+ try {
8779
+ const resp = await fetch('/api/onboard/approve', {
8780
+ method: 'POST',
8781
+ headers: { 'Content-Type': 'application/json' },
8782
+ body: JSON.stringify({ title: proposal.title, statement: proposal.statement }),
8783
+ });
8784
+ const data = await resp.json();
8785
+ if (data.id) proposal.approvedId = data.id;
8786
+ } catch (_) {}
8787
+ }
8788
+
8789
+ onboardPhase = 'idle';
8790
+ onboardPrompt = null;
8791
+ onboardQuestions = null;
8792
+ onboardProposals = null;
8793
+ fetch('/api/onboard/complete', { method: 'POST' }).catch(() => {});
8794
+ loadData().then(() => renderDrillView());
8795
+ loadActivity();
8796
+ }
8797
+
8798
+ function renderOnboardUI() {
8799
+ if (!$drillList) return;
8800
+ $drillList.innerHTML = '';
8801
+
8802
+ const container = document.createElement('div');
8803
+ container.className = 'onboard-container';
8804
+
8805
+ if (onboardPhase === 'questions') {
8806
+ if (!onboardQuestions) {
8807
+ // Still streaming
8808
+ container.innerHTML = `
8809
+ <div class="onboard-phase-label">Analyzing your vision...</div>
8810
+ <pre class="onboard-stream streaming" id="onboard-stream">${escHtml(onboardStreamText) || 'Thinking...'}</pre>
8811
+ `;
8812
+ } else {
8813
+ // Show question form
8814
+ let html = '<div class="onboard-phase-label">Clarification Questions</div>';
8815
+ html += '<div class="onboard-questions">';
8816
+ onboardQuestions.forEach((q, i) => {
8817
+ html += `<div class="onboard-question">
8818
+ <span class="oq-number">Q${i + 1}</span>
8819
+ <label>${escHtml(q.question)}</label>
8820
+ ${q.context ? `<div class="oq-context">${escHtml(q.context)}</div>` : ''}
8821
+ ${q.options && q.options.length > 0 ? `<div class="oq-options">${q.options.map(o => `<span class="oq-option" data-qi="${i}" data-val="${escHtml(o)}">${escHtml(o)}</span>`).join('')}</div>` : ''}
8822
+ <textarea placeholder="Your answer..." rows="2"></textarea>
8823
+ </div>`;
8824
+ });
8825
+ html += '</div>';
8826
+ html += `<div class="onboard-actions">
8827
+ <button class="onboard-btn-primary" onclick="submitOnboardAnswers()">Submit Answers</button>
8828
+ <button class="onboard-btn-secondary" onclick="skipOnboardQuestions()">Skip</button>
8829
+ <button class="onboard-btn-secondary" onclick="cancelOnboard()">Cancel</button>
8830
+ </div>`;
8831
+ container.innerHTML = html;
8832
+
8833
+ // Attach option click handlers after DOM insertion
8834
+ setTimeout(() => {
8835
+ container.querySelectorAll('.oq-option').forEach(opt => {
8836
+ opt.addEventListener('click', () => {
8837
+ const qi = parseInt(opt.dataset.qi);
8838
+ const val = opt.dataset.val;
8839
+ const ta = container.querySelectorAll('.onboard-question textarea')[qi];
8840
+ if (ta) {
8841
+ ta.value = ta.value ? ta.value + ', ' + val : val;
8842
+ }
8843
+ opt.classList.toggle('selected');
8844
+ });
8845
+ });
8846
+ }, 0);
8847
+ }
8848
+ } else if (onboardPhase === 'proposals') {
8849
+ if (!onboardProposals) {
8850
+ container.innerHTML = `
8851
+ <div class="onboard-phase-label">Generating declarations...</div>
8852
+ <pre class="onboard-stream streaming" id="onboard-stream">${escHtml(onboardStreamText) || 'Thinking...'}</pre>
8853
+ `;
8854
+ } else {
8855
+ let html = '<div class="onboard-phase-label">Proposed Declarations</div>';
8856
+ onboardProposals.forEach((p, i) => {
8857
+ html += `<div class="onboard-proposal">
8858
+ <div class="op-header">
8859
+ <span class="op-index">${i + 1}.</span>
8860
+ </div>
8861
+ <input class="onboard-title" value="${escHtml(p.title)}" />
8862
+ <textarea class="onboard-statement" rows="2">${escHtml(p.statement)}</textarea>
8863
+ <div class="onboard-reason">${escHtml(p.reasoning || '')}</div>
8864
+ </div>`;
8865
+ });
8866
+ html += `<div class="onboard-actions">
8867
+ <button class="onboard-btn-primary" onclick="startOnboardApproving('all')">Approve All</button>
8868
+ <button class="onboard-btn-secondary" onclick="startOnboardApproving('one')">One by One</button>
8869
+ <button class="onboard-btn-secondary" onclick="cancelOnboard()">Cancel</button>
8870
+ </div>`;
8871
+ container.innerHTML = html;
8872
+ }
8873
+ } else if (onboardPhase === 'approving') {
8874
+ if (!onboardProposals) return;
8875
+ let html = `<div class="onboard-phase-label">Approving Declarations</div>`;
8876
+ html += `<div class="onboard-progress">${onboardApproveIndex} of ${onboardProposals.length} approved</div>`;
8877
+ onboardProposals.forEach((p, i) => {
8878
+ const isApproved = i < onboardApproveIndex || p.approvedId;
8879
+ const isCurrent = i === onboardApproveIndex && !p.approvedId;
8880
+ const cls = isApproved ? 'approved' : isCurrent ? 'current' : '';
8881
+ html += `<div class="onboard-proposal ${cls}">
8882
+ <div class="op-header">
8883
+ ${isApproved ? '<span class="op-check">\u2713</span>' : `<span class="op-index">${i + 1}.</span>`}
8884
+ ${p.approvedId ? `<span class="op-id">${escHtml(p.approvedId)}</span>` : ''}
8885
+ </div>
8886
+ ${isCurrent ? `
8887
+ <input class="onboard-title" value="${escHtml(p.title)}" />
8888
+ <textarea class="onboard-statement" rows="2">${escHtml(p.statement)}</textarea>
8889
+ ` : `
8890
+ <div style="font-weight:600;font-size:13px">${escHtml(p.title)}</div>
8891
+ <div style="font-size:12px;color:var(--text-dim);margin-top:4px">${escHtml(p.statement)}</div>
8892
+ `}
8893
+ <div class="onboard-reason">${escHtml(p.reasoning || '')}</div>
8894
+ </div>`;
8895
+ });
8896
+ if (onboardApproveIndex < onboardProposals.length) {
8897
+ html += `<div class="onboard-actions">
8898
+ <button class="onboard-btn-primary" onclick="approveCurrentOnboard()">Approve &amp; Next</button>
8899
+ <button class="onboard-btn-secondary" onclick="cancelOnboard()">Cancel</button>
8900
+ </div>`;
8901
+ }
8902
+ container.innerHTML = html;
8903
+ }
8904
+
8905
+ $drillList.appendChild(container);
8906
+ }
8907
+
8908
+ function sendCommand(message) {
8909
+ // Route to onboarding when at declarations level with few declarations or long message
8910
+ if (drillLevel === 'declarations' && graphData) {
8911
+ const declCount = (graphData.declarations || []).length;
8912
+ if (declCount < 3 || message.length > 150) {
8913
+ startOnboard(message);
8914
+ return;
8915
+ }
8916
+ }
8917
+
8918
+ // Gather context from current view
8919
+ const context = {};
8920
+ if (drillLevel === 'declarations') {
8921
+ context.viewDescription = 'Declaration list (lifecycle view)';
8922
+ const cards = document.querySelectorAll('#drill-list .drill-card');
8923
+ context.nodeIds = Array.from(cards).map(c => c.dataset.nodeId).filter(Boolean);
8924
+ } else if (drillLevel === 'milestones' && drillDeclId) {
8925
+ context.nodeId = drillDeclId;
8926
+ context.viewDescription = 'Milestones for ' + drillDeclId;
8927
+ const cards = document.querySelectorAll('#drill-list .drill-card');
8928
+ context.nodeIds = Array.from(cards).map(c => c.dataset.nodeId).filter(Boolean);
8929
+ } else if (drillLevel === 'actions' && drillMileId) {
8930
+ context.nodeId = drillMileId;
8931
+ context.viewDescription = 'Actions for ' + drillMileId;
8932
+ const cards = document.querySelectorAll('#drill-list .drill-card');
8933
+ context.nodeIds = Array.from(cards).map(c => c.dataset.nodeId).filter(Boolean);
8934
+ }
8935
+
8936
+ // Show streaming output area in command card
8937
+ const cmdCard = document.getElementById('command-card');
8938
+ if (cmdCard) {
8939
+ cmdCard.classList.add('running');
8940
+ cmdCard.classList.remove('editing');
8941
+ // Create or reuse output area
8942
+ let outputEl = document.getElementById('command-output-stream');
8943
+ if (!outputEl) {
8944
+ outputEl = document.createElement('pre');
8945
+ outputEl.id = 'command-output-stream';
8946
+ outputEl.className = 'command-output-stream streaming';
8947
+ cmdCard.appendChild(outputEl);
8948
+ } else {
8949
+ outputEl.className = 'command-output-stream streaming';
8950
+ }
8951
+ outputEl.textContent = 'Running...';
8952
+ }
8953
+
8954
+ fetch('/api/command', {
8955
+ method: 'POST',
8956
+ headers: { 'Content-Type': 'application/json' },
8957
+ body: JSON.stringify({ message, context }),
8958
+ }).then(r => r.json()).then(data => {
8959
+ if (data.error) {
8960
+ console.error('Command error:', data.error);
8961
+ const outputEl = document.getElementById('command-output-stream');
8962
+ if (outputEl) outputEl.textContent = 'Error: ' + data.error;
8963
+ }
8964
+ }).catch(err => {
8965
+ console.error('Command failed:', err);
8966
+ const outputEl = document.getElementById('command-output-stream');
8967
+ if (outputEl) outputEl.textContent = 'Error: ' + err.message;
8968
+ });
8969
+ }
8970
+
8971
+ // ─── Clickable activity log items ─────────────────────────────────────────────
8972
+
8973
+ if ($activityList) {
8974
+ $activityList.addEventListener('click', function(e) {
8975
+ const item = e.target.closest('.activity-event');
8976
+ if (!item) return;
8977
+ const desc = item.querySelector('.ae-desc');
8978
+ if (!desc) return;
8979
+ // Extract node ID from description like "Review of A-117 complete"
8980
+ const match = desc.textContent.match(/\b([DMA]-\d+)\b/);
8981
+ if (match) {
8982
+ const nodeId = match[1];
8983
+ // Navigate to that node
8984
+ const prefix = nodeId.split('-')[0];
8985
+ if (prefix === 'D') {
8986
+ renderDeclarations(graphData); // back to declarations
8987
+ } else if (prefix === 'M') {
8988
+ // Find parent declaration
8989
+ const milestone = (graphData.milestones || []).find(m => m.id === nodeId);
8990
+ if (milestone && milestone.realizes) {
8991
+ const declId = Array.isArray(milestone.realizes) ? milestone.realizes[0] : milestone.realizes;
8992
+ renderMilestones(declId, graphData);
8993
+ }
8994
+ } else if (prefix === 'A') {
8995
+ // Find parent milestone
8996
+ const action = (graphData.actions || []).find(a => a.id === nodeId);
8997
+ if (action && action.causes && action.causes.length > 0) {
8998
+ renderActions(action.causes[0], graphData);
8999
+ }
9000
+ }
9001
+ }
9002
+ });
9003
+ }
9004
+
6822
9005
  // ─── Bootstrap ───────────────────────────────────────────────────────────────
6823
9006
 
6824
9007
  showLoading();
6825
- loadData().then(() => restoreExecState());
9008
+ loadData().then(() => {
9009
+ restoreExecState();
9010
+ // Restore onboard session from server
9011
+ loadOnboardState();
9012
+ });
6826
9013
  loadActivity();
9014
+ loadAgentCards();
9015
+
9016
+ // Poll agent cards every 3s as fallback if SSE agent events are missed
9017
+ setInterval(loadAgentCards, 3000);