@yemi33/minions 0.1.1155 → 0.1.1157

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1157 (2026-04-18)
4
+
5
+ ### Features
6
+ - escapeHtml helper, hotspot XSS migration, regression gate (#1323)
7
+ - Prototype pollution guard in readBody (SEC-13) (#1300)
8
+ - redact ADO tokens and JWTs from engine/log.json writes (#1297)
9
+ - SEC-02 — replace curl shell-out in ado.js with adoFetch (#1296)
10
+ - validate project name and path on POST /api/projects/add (SEC-04, SEC-05) (#1298)
11
+ - seed realActivityMap at spawn time, stamp pid in live-output (#1200)
12
+
13
+ ### Fixes
14
+ - ado merge-commit mismatch preserves prior buildStatus (closes #1233) (#1326)
15
+ - scheduler substitutes {{date}} in title and description (#1275)
16
+ - prevent archived plans from re-triggering via orphaned .backup restore
17
+ - pinned context optimistic save (closes #1295) (#1316)
18
+ - re-check throttle state per-PR iteration to avoid stale fixThrottled
19
+ - preserve buildErrorLog through transient states, persist poll time (#1273)
20
+ - auto-fetch PR title on link-pr (closes #1283) (#1299)
21
+ - scheduler double-fire within same cron minute (#1277)
22
+ - gate auto-fix dispatch on throttle state to prevent stale-data spurious fixes
23
+ - preserve buildErrorLog through transient build states (#1232) (#1274)
24
+ - annotate fast-exit empty-output failures with diagnostic hint (#1276)
25
+ - invalidate PRD cache on pr-links.json change + guard aggregate PRs (#1220) (#1272)
26
+ - pass --add-dir for minions + ~/.claude to agents (#1271)
27
+ - preserve VERDICT marker by tail-slicing agent output (#1234) (#1270)
28
+ - PRD info cache staleness and aggregate PR bleed-through (#1222)
29
+ - avoid no-op work item writes
30
+ - resilient claude binary resolution + surface spawn errors
31
+ - resolve native claude.exe from npm wrapper on Windows
32
+ - cap temp-agent creation at maxConcurrent per tick (#1219)
33
+ - reassign pending items from unspawned temp agents to idle named agents (#1204) (#1212)
34
+
35
+ ### Other
36
+ - refactor: hoist fixThrottled before PR loop and drop underscore prefix
37
+ - docs(skill): add substitute-scheduler-template-vars (#1278)
38
+ - Clarify PR poll labels
39
+ - Prevent modal opens on text selection
40
+ - refactor: clarify settings page section structure and PR polling dependencies
41
+ - refactor: extract _probeClaudePackage helper, use shared.log for spawn errors
42
+ - Make work item descriptions scrollable
43
+ - Use PAT for publish merges
44
+ - test(queries): add unit tests for invalidateDispatchCache/getInbox/getAgentCharter (#1214)
45
+ - test(shared): add unit tests for truncateTextBytes/tailTextBytes/execSilent/trackReviewMetric/parseCanonicalPrId (#1215)
46
+ - Fix publish workflow merge
47
+ - chore: raise default meeting round timeout
48
+ - Harden prompt context handling
49
+ - Harden loop watch conversion
50
+ - Add watches sidebar activity badge
51
+ - test(cli): add unit tests for handleCommand, start, stop, kill, spawn (#1191)
52
+ - chore: untrack pipeline files — local config only
53
+ - restore: recover daily-arch-improvement and weekly-dead-code-cleanup pipelines
54
+ - Harden CC stream resilience
55
+
3
56
  ## 0.1.1155 (2026-04-18)
4
57
 
5
58
  ### Features
@@ -4,23 +4,23 @@ function renderAgents(agents) {
4
4
  agentData = agents;
5
5
  const grid = document.getElementById('agents-grid');
6
6
  grid.innerHTML = agents.map(a => `
7
- <div class="agent-card ${statusColor(a.status)}" onclick="if(shouldIgnoreSelectionClick(event))return;openAgentDetail('${escHtml(a.id)}')">
7
+ <div class="agent-card ${statusColor(a.status)}" onclick="if(shouldIgnoreSelectionClick(event))return;openAgentDetail('${escapeHtml(a.id)}')">
8
8
  <div class="agent-card-header">
9
- <span class="agent-name"><span class="agent-emoji">${escHtml(a.emoji)}</span>${escHtml(a.name)}</span>
10
- <span class="status-badge ${escHtml(a.status)}">${escHtml(a.status)}</span>
9
+ <span class="agent-name"><span class="agent-emoji">${escapeHtml(a.emoji)}</span>${escapeHtml(a.name)}</span>
10
+ <span class="status-badge ${escapeHtml(a.status)}">${escapeHtml(a.status)}</span>
11
11
  </div>
12
- <div class="agent-role">${escHtml(a.role)}</div>
13
- <div class="agent-action" title="${escHtml(a.lastAction)}">${escHtml(a.lastAction)}</div>
12
+ <div class="agent-role">${escapeHtml(a.role)}</div>
13
+ <div class="agent-action" title="${escapeHtml(a.lastAction)}">${escapeHtml(a.lastAction)}</div>
14
14
  ${(function() {
15
15
  var s = a.started_at, c = a.completed_at;
16
16
  if (s && c) { var d = new Date(c) - new Date(s); if (d > 0) { var sec = Math.floor(d/1000)%60, min = Math.floor(d/60000)%60, hr = Math.floor(d/3600000); return '<div style="font-size:9px;color:var(--muted)">Last run: ' + (hr > 0 ? hr + 'h ' : '') + min + 'm ' + sec + 's</div>'; } }
17
17
  if (s && a.status === 'working') return '<div class="agent-runtime-tick" data-started="' + s + '" style="font-size:9px;color:var(--yellow)"></div>';
18
18
  return '';
19
19
  })()}
20
- ${a._blockingToolCall ? `<div style="margin-top:4px;padding:4px 8px;background:rgba(130,160,210,0.13);border:1px solid rgba(130,160,210,0.3);border-radius:4px;font-size:10px;color:var(--muted)">&#x23F3; Blocking tool call (${escHtml(a._blockingToolCall.tool)}) &mdash; silent ${Math.round(a._blockingToolCall.silentMs/60000)}min, timeout in ${Math.round(a._blockingToolCall.remainingMs/60000)}min</div>` : ''}
21
- ${a._warning ? `<div style="margin-top:4px;padding:4px 8px;background:rgba(210,153,34,0.15);border:1px solid rgba(210,153,34,0.3);border-radius:4px;font-size:10px;color:var(--yellow)">&#x26A0; ${escHtml(a._warning)}</div>` : ''}
22
- ${a._permissionMode && a._permissionMode !== 'bypassPermissions' && !a._warning ? `<div style="margin-top:4px;font-size:9px;color:var(--muted)">Permission mode: ${escHtml(a._permissionMode)}</div>` : ''}
23
- ${a.resultSummary ? `<div class="agent-result" title="${escHtml(a.resultSummary)}">${renderMd(a.resultSummary.slice(0, 200))}${a.resultSummary.length > 200 ? '...' : ''}</div>` : ''}
20
+ ${a._blockingToolCall ? `<div style="margin-top:4px;padding:4px 8px;background:rgba(130,160,210,0.13);border:1px solid rgba(130,160,210,0.3);border-radius:4px;font-size:10px;color:var(--muted)">&#x23F3; Blocking tool call (${escapeHtml(a._blockingToolCall.tool)}) &mdash; silent ${Math.round(a._blockingToolCall.silentMs/60000)}min, timeout in ${Math.round(a._blockingToolCall.remainingMs/60000)}min</div>` : ''}
21
+ ${a._warning ? `<div style="margin-top:4px;padding:4px 8px;background:rgba(210,153,34,0.15);border:1px solid rgba(210,153,34,0.3);border-radius:4px;font-size:10px;color:var(--yellow)">&#x26A0; ${escapeHtml(a._warning)}</div>` : ''}
22
+ ${a._permissionMode && a._permissionMode !== 'bypassPermissions' && !a._warning ? `<div style="margin-top:4px;font-size:9px;color:var(--muted)">Permission mode: ${escapeHtml(a._permissionMode)}</div>` : ''}
23
+ ${a.resultSummary ? `<div class="agent-result" title="${escapeHtml(a.resultSummary)}">${renderMd(a.resultSummary.slice(0, 200))}${a.resultSummary.length > 200 ? '...' : ''}</div>` : ''}
24
24
  </div>
25
25
  `).join('');
26
26
  }
@@ -31,14 +31,23 @@ async function openAgentDetail(id) {
31
31
  currentAgentId = id;
32
32
  currentTab = (agent.status === 'working') ? 'live' : 'thought-process';
33
33
 
34
- document.getElementById('detail-agent-name').innerHTML =
35
- '<span style="font-size:22px">' + escHtml(agent.emoji) + '</span> ' + escHtml(agent.name) + ' ' + escHtml(agent.role);
34
+ // SEC-03 Phase A: Build the detail header via DOM + textContent instead of innerHTML.
35
+ // Emoji, name and role are all user-controlled fields; routing them through textContent
36
+ // guarantees no HTML interpretation even if the escape function were ever bypassed.
37
+ const nameEl = document.getElementById('detail-agent-name');
38
+ const emojiSpan = document.createElement('span');
39
+ emojiSpan.style.fontSize = '22px';
40
+ emojiSpan.textContent = agent.emoji || '';
41
+ nameEl.replaceChildren(
42
+ emojiSpan,
43
+ document.createTextNode(' ' + (agent.name || '') + ' \u2014 ' + (agent.role || ''))
44
+ );
36
45
 
37
46
  const badgeClass = agent.status;
38
47
  document.getElementById('detail-status-line').innerHTML =
39
48
  '<span class="status-badge ' + badgeClass + '">' + agent.status.toUpperCase() + '</span> ' +
40
- '<span style="color:var(--muted)">' + escHtml(agent.lastAction) + '</span>' +
41
- (agent._blockingToolCall ? '<div style="margin-top:4px;padding:4px 8px;background:rgba(130,160,210,0.13);border:1px solid rgba(130,160,210,0.3);border-radius:4px;font-size:11px;color:var(--muted)">&#x23F3; Blocking tool call (' + escHtml(agent._blockingToolCall.tool) + ') &mdash; silent ' + Math.round(agent._blockingToolCall.silentMs/60000) + 'min, timeout in ' + Math.round(agent._blockingToolCall.remainingMs/60000) + 'min</div>' : '') +
49
+ '<span style="color:var(--muted)">' + escapeHtml(agent.lastAction) + '</span>' +
50
+ (agent._blockingToolCall ? '<div style="margin-top:4px;padding:4px 8px;background:rgba(130,160,210,0.13);border:1px solid rgba(130,160,210,0.3);border-radius:4px;font-size:11px;color:var(--muted)">&#x23F3; Blocking tool call (' + escapeHtml(agent._blockingToolCall.tool) + ') &mdash; silent ' + Math.round(agent._blockingToolCall.silentMs/60000) + 'min, timeout in ' + Math.round(agent._blockingToolCall.remainingMs/60000) + 'min</div>' : '') +
42
51
  (agent.resultSummary ? '<div style="margin-top:4px;font-size:11px;color:var(--text);line-height:1.4">' + renderMd(agent.resultSummary.slice(0, 300)) + '</div>' : '');
43
52
 
44
53
  // Show panel immediately with loading state — don't wait for API
@@ -53,8 +62,8 @@ async function openAgentDetail(id) {
53
62
  } catch(e) {
54
63
  document.getElementById('detail-content').innerHTML =
55
64
  '<div style="padding:24px;text-align:center">' +
56
- '<div style="color:var(--red);margin-bottom:12px">Error loading agent detail: ' + escHtml(e.message) + '</div>' +
57
- '<button onclick="openAgentDetail(\'' + escHtml(id) + '\')" style="padding:6px 16px;background:var(--blue);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer;font-size:12px">Retry</button>' +
65
+ '<div style="color:var(--red);margin-bottom:12px">Error loading agent detail: ' + escapeHtml(e.message) + '</div>' +
66
+ '<button onclick="openAgentDetail(\'' + escapeHtml(id) + '\')" style="padding:6px 16px;background:var(--blue);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer;font-size:12px">Retry</button>' +
58
67
  ' <button onclick="closeDetail()" style="padding:6px 16px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;font-size:12px;color:var(--text)">Close</button>' +
59
68
  '</div>';
60
69
  }
@@ -29,16 +29,16 @@ function renderInbox(inbox) {
29
29
  const idx = inboxStart + i;
30
30
  const pk = inboxPinKey(item.name);
31
31
  const pinned = isPinned(pk);
32
- return `<div class="inbox-item${pinned ? ' item-pinned' : ''}" data-file="notes/inbox/${escHtml(item.name)}">
32
+ return `<div class="inbox-item${pinned ? ' item-pinned' : ''}" data-file="notes/inbox/${escapeHtml(item.name)}">
33
33
  <div class="inbox-name" onclick="openModal(${idx})" style="cursor:pointer">
34
- <span>${escHtml(item.name)}</span><span>${escHtml(item.age || '')}</span>
34
+ <span>${escapeHtml(item.name)}</span><span>${escapeHtml(item.age || '')}</span>
35
35
  </div>
36
- <div class="inbox-preview" onclick="openModal(${idx})" style="cursor:pointer">${escHtml(item.content.slice(0,200))}</div>
36
+ <div class="inbox-preview" onclick="openModal(${idx})" style="cursor:pointer">${escapeHtml(item.content.slice(0,200))}</div>
37
37
  <div style="display:flex;gap:6px;margin-top:6px;align-items:center">
38
- <button class="pr-pager-btn pin-btn${pinned ? ' pinned' : ''}" style="font-size:9px;padding:2px 8px" data-pin-key="${escHtml(pk)}" onclick="event.stopPropagation();_togglePinAndRefresh(this.dataset.pinKey,'inbox')">${pinned ? 'Unpin' : 'Pin'}</button>
39
- <button class="pr-pager-btn" style="font-size:9px;padding:2px 8px" data-inbox-name="${escHtml(item.name)}" onclick="event.stopPropagation();promoteToKB(this.dataset.inboxName)">Add to Knowledge Base</button>
40
- <button class="pr-pager-btn" style="font-size:9px;padding:2px 8px" data-inbox-name="${escHtml(item.name)}" onclick="event.stopPropagation();openInboxInExplorer(this.dataset.inboxName)">Open in Explorer</button>
41
- <button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--red)" data-inbox-name="${escHtml(item.name)}" onclick="event.stopPropagation();deleteInboxItem(this.dataset.inboxName)">Delete</button>
38
+ <button class="pr-pager-btn pin-btn${pinned ? ' pinned' : ''}" style="font-size:9px;padding:2px 8px" data-pin-key="${escapeHtml(pk)}" onclick="event.stopPropagation();_togglePinAndRefresh(this.dataset.pinKey,'inbox')">${pinned ? 'Unpin' : 'Pin'}</button>
39
+ <button class="pr-pager-btn" style="font-size:9px;padding:2px 8px" data-inbox-name="${escapeHtml(item.name)}" onclick="event.stopPropagation();promoteToKB(this.dataset.inboxName)">Add to Knowledge Base</button>
40
+ <button class="pr-pager-btn" style="font-size:9px;padding:2px 8px" data-inbox-name="${escapeHtml(item.name)}" onclick="event.stopPropagation();openInboxInExplorer(this.dataset.inboxName)">Open in Explorer</button>
41
+ <button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--red)" data-inbox-name="${escapeHtml(item.name)}" onclick="event.stopPropagation();deleteInboxItem(this.dataset.inboxName)">Delete</button>
42
42
  </div>
43
43
  </div>`;
44
44
  }).join('');
@@ -62,10 +62,10 @@ function promoteToKB(name) {
62
62
  { id: 'reviews', label: 'Reviews' },
63
63
  ];
64
64
  const picker = '<div style="padding:16px 20px">' +
65
- '<p style="font-size:13px;color:var(--text);margin-bottom:12px">Choose a category for <strong>' + escHtml(name) + '</strong>:</p>' +
65
+ '<p style="font-size:13px;color:var(--text);margin-bottom:12px">Choose a category for <strong>' + escapeHtml(name) + '</strong>:</p>' +
66
66
  '<div style="display:flex;flex-direction:column;gap:8px">' +
67
67
  categories.map(c =>
68
- '<button class="pr-pager-btn" style="font-size:12px;padding:8px 16px;text-align:left" onclick="doPromoteToKB(\'' + escHtml(name) + '\',\'' + c.id + '\')">' + c.label + '</button>'
68
+ '<button class="pr-pager-btn" style="font-size:12px;padding:8px 16px;text-align:left" onclick="doPromoteToKB(\'' + escapeHtml(name) + '\',\'' + c.id + '\')">' + c.label + '</button>'
69
69
  ).join('') +
70
70
  '</div></div>';
71
71
  document.getElementById('modal-title').textContent = 'Add to Knowledge Base';
@@ -112,18 +112,18 @@ function renderKnowledgeBase() {
112
112
  const label = KB_CAT_LABELS[item.category] || item.category;
113
113
  var pinKey = kbPinKey(item.category, item.file);
114
114
  var pinned = isPinned(pinKey);
115
- return '<div class="kb-item' + (pinned ? ' item-pinned' : '') + '" data-file="knowledge/' + escHtml(item.category) + '/' + escHtml(item.file) + '" onclick="kbOpenItem(\'' + escHtml(item.category) + '\', \'' + escHtml(item.file) + '\')">' +
115
+ return '<div class="kb-item' + (pinned ? ' item-pinned' : '') + '" data-file="knowledge/' + escapeHtml(item.category) + '/' + escapeHtml(item.file) + '" onclick="kbOpenItem(\'' + escapeHtml(item.category) + '\', \'' + escapeHtml(item.file) + '\')">' +
116
116
  '<div class="kb-item-body">' +
117
- '<div class="kb-item-title">' + icon + ' ' + escHtml(item.title) +
118
- ' <button class="pr-pager-btn pin-btn' + (pinned ? ' pinned' : '') + '" style="font-size:9px;padding:1px 6px;margin-left:6px;vertical-align:middle" data-pin-key="' + escHtml(pinKey) + '" onclick="event.stopPropagation();_togglePinAndRefresh(this.dataset.pinKey,\'kb\')">' + (pinned ? 'Unpin' : 'Pin') + '</button>' +
117
+ '<div class="kb-item-title">' + icon + ' ' + escapeHtml(item.title) +
118
+ ' <button class="pr-pager-btn pin-btn' + (pinned ? ' pinned' : '') + '" style="font-size:9px;padding:1px 6px;margin-left:6px;vertical-align:middle" data-pin-key="' + escapeHtml(pinKey) + '" onclick="event.stopPropagation();_togglePinAndRefresh(this.dataset.pinKey,\'kb\')">' + (pinned ? 'Unpin' : 'Pin') + '</button>' +
119
119
  '</div>' +
120
120
  '<div class="kb-item-meta">' +
121
121
  '<span>' + label + '</span>' +
122
- (item.agent ? '<span>' + escHtml(item.agent) + '</span>' : '') +
122
+ (item.agent ? '<span>' + escapeHtml(item.agent) + '</span>' : '') +
123
123
  '<span>' + (item.date || '') + '</span>' +
124
124
  '<span>' + Math.round(item.size / 1024) + 'KB</span>' +
125
125
  '</div>' +
126
- (item.preview ? '<div class="kb-item-preview">' + escHtml(item.preview) + '</div>' : '') +
126
+ (item.preview ? '<div class="kb-item-preview">' + escapeHtml(item.preview) + '</div>' : '') +
127
127
  '</div>' +
128
128
  '</div>';
129
129
  }).join('');
@@ -8,7 +8,7 @@ function _plansNext() { _plansPage++; refresh(); }
8
8
 
9
9
  function openCreatePlanModal() {
10
10
  const projOpts = (typeof cmdProjects !== 'undefined' ? cmdProjects : []).map(p =>
11
- '<option value="' + escHtml(p) + '">' + escHtml(p) + '</option>'
11
+ '<option value="' + escapeHtml(p) + '">' + escapeHtml(p) + '</option>'
12
12
  ).join('');
13
13
  const inputStyle = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit';
14
14
 
@@ -218,23 +218,23 @@ function renderPlans(plans) {
218
218
  // For awaiting-approval: show Execute (re-generate PRD from updated plan) + Approve (use current PRD as-is)
219
219
  if (effectiveStatus === 'awaiting-approval' && isDraft && prdFile) {
220
220
  actions = '<div class="plan-card-actions" onclick="event.stopPropagation()">' +
221
- '<button class="plan-btn approve" onclick="planApprove(\'' + escHtml(actionTarget) + '\')">Approve</button>' +
222
- '<button class="plan-btn approve" style="opacity:0.7" onclick="planExecute(\'' + escHtml(p.file) + '\',\'' + escHtml(p.project || '') + '\',this)">Re-execute</button>' +
223
- '<button class="plan-btn reject" onclick="planReject(\'' + escHtml(actionTarget) + '\')">Reject</button>' +
221
+ '<button class="plan-btn approve" onclick="planApprove(\'' + escapeHtml(actionTarget) + '\')">Approve</button>' +
222
+ '<button class="plan-btn approve" style="opacity:0.7" onclick="planExecute(\'' + escapeHtml(p.file) + '\',\'' + escapeHtml(p.project || '') + '\',this)">Re-execute</button>' +
223
+ '<button class="plan-btn reject" onclick="planReject(\'' + escapeHtml(actionTarget) + '\')">Reject</button>' +
224
224
  '</div>';
225
225
  } else {
226
226
  const actionLabel = effectiveStatus === 'paused' ? 'Resume' : 'Approve';
227
227
  actions = '<div class="plan-card-actions" onclick="event.stopPropagation()">' +
228
- '<button class="plan-btn approve" onclick="planApprove(\'' + escHtml(actionTarget) + '\')">' + actionLabel + '</button>' +
229
- '<button class="plan-btn reject" onclick="planReject(\'' + escHtml(actionTarget) + '\')">Reject</button>' +
228
+ '<button class="plan-btn approve" onclick="planApprove(\'' + escapeHtml(actionTarget) + '\')">' + actionLabel + '</button>' +
229
+ '<button class="plan-btn reject" onclick="planReject(\'' + escapeHtml(actionTarget) + '\')">Reject</button>' +
230
230
  '</div>';
231
231
  }
232
232
  } else if (isRevision) {
233
- actions = '<div class="plan-card-meta" style="margin-top:6px;color:var(--purple,#a855f7)">Revision in progress: ' + escHtml((p.revisionFeedback || '').slice(0, 100)) + '</div>';
233
+ actions = '<div class="plan-card-meta" style="margin-top:6px;color:var(--purple,#a855f7)">Revision in progress: ' + escapeHtml((p.revisionFeedback || '').slice(0, 100)) + '</div>';
234
234
  }
235
235
 
236
236
  const executeBtn = isDraft && (effectiveStatus === 'active' || effectiveStatus === 'draft') && !isArchived && !prdFile ? '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--green);font-weight:600" ' +
237
- 'onclick="event.stopPropagation();planExecute(\'' + escHtml(p.file) + '\',\'' + escHtml(p.project) + '\',this)">Execute</button>' : '';
237
+ 'onclick="event.stopPropagation();planExecute(\'' + escapeHtml(p.file) + '\',\'' + escapeHtml(p.project) + '\',this)">Execute</button>' : '';
238
238
  const showPause = effectiveStatus === 'dispatched' && prdFile && !isArchived;
239
239
  // Resume pill not needed — paused state is handled by the actions block above
240
240
  const showResume = false;
@@ -242,37 +242,37 @@ function renderPlans(plans) {
242
242
  const hasVerifyWi = !!verifyWi;
243
243
  const showVerify = effectiveStatus === 'completed' && prdFile && !isArchived && !hasVerifyWi;
244
244
  const pauseBtn = showPause ? '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--yellow)" ' +
245
- 'onclick="event.stopPropagation();planPause(\'' + escHtml(prdFile) + '\',this)">Pause</button>' : '';
245
+ 'onclick="event.stopPropagation();planPause(\'' + escapeHtml(prdFile) + '\',this)">Pause</button>' : '';
246
246
  const resumeBtn = showResume
247
247
  ? '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--green)" ' +
248
- 'onclick="event.stopPropagation();planApprove(\'' + escHtml(prdFile) + '\',this)">Resume</button>'
248
+ 'onclick="event.stopPropagation();planApprove(\'' + escapeHtml(prdFile) + '\',this)">Resume</button>'
249
249
  : '';
250
250
  const verifyBtn = showVerify ? '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--green)" ' +
251
- 'onclick="event.stopPropagation();triggerVerify(\'' + escHtml(prdFile) + '\',this)">Verify</button>' : '';
251
+ 'onclick="event.stopPropagation();triggerVerify(\'' + escapeHtml(prdFile) + '\',this)">Verify</button>' : '';
252
252
  const showArchive = !isArchived;
253
253
  const archiveFile = prdFile || p.file;
254
254
  const archiveReady = p.archiveReady && !isArchived;
255
255
  const archiveBtn = showArchive ? '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px' +
256
256
  (archiveReady ? ';color:var(--green);font-weight:600;border:1px solid var(--green)' : '') + '" ' +
257
- 'onclick="event.stopPropagation();planArchive(\'' + escHtml(archiveFile) + '\',this)">' +
257
+ 'onclick="event.stopPropagation();planArchive(\'' + escapeHtml(archiveFile) + '\',this)">' +
258
258
  (archiveReady ? '✓ Archive' : 'Archive') + '</button>' : '';
259
259
  const archiveReadyBadge = archiveReady ? '<span style="font-size:9px;font-weight:600;padding:1px 6px;border-radius:3px;background:rgba(63,185,80,0.15);color:var(--green);vertical-align:middle" title="Verification passed — ready to archive">Ready to archive</span>' : '';
260
260
  const deleteBtn = !isArchived ? '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--red)" ' +
261
- 'onclick="event.stopPropagation();planDelete(\'' + escHtml(p.file) + '\')">Delete</button>' : '';
261
+ 'onclick="event.stopPropagation();planDelete(\'' + escapeHtml(p.file) + '\')">Delete</button>' : '';
262
262
 
263
263
  const versionBadge = p.version ? ' <span style="font-size:9px;font-weight:700;padding:1px 5px;border-radius:3px;background:rgba(56,139,253,0.15);color:var(--blue);vertical-align:middle">v' + p.version + '</span>' : '';
264
264
  const statusColors = { 'completed': 'var(--green)', 'dispatched': 'var(--blue)', 'converting': 'var(--yellow)', 'paused': 'var(--muted)', 'awaiting-approval': 'var(--yellow)', 'approved': 'var(--green)', 'rejected': 'var(--red)', 'has-failures': 'var(--red)', 'revision-requested': 'var(--purple,#a855f7)', 'active': 'var(--muted)' };
265
265
  const cardClass = effectiveStatus === 'dispatched' || effectiveStatus === 'converting' ? 'working' : effectiveStatus === 'awaiting-approval' || effectiveStatus === 'paused' ? 'awaiting' : effectiveStatus;
266
- return '<div class="plan-card ' + cardClass + '" data-file="plans/' + escHtml(p.file) + '" style="cursor:pointer' + (isArchived ? ';opacity:0.7' : '') + '" onclick="if(shouldIgnoreSelectionClick(event))return;planView(\'' + escHtml(p.file) + '\')">' +
266
+ return '<div class="plan-card ' + cardClass + '" data-file="plans/' + escapeHtml(p.file) + '" style="cursor:pointer' + (isArchived ? ';opacity:0.7' : '') + '" onclick="if(shouldIgnoreSelectionClick(event))return;planView(\'' + escapeHtml(p.file) + '\')">' +
267
267
  '<div class="plan-card-header">' +
268
- '<div><div class="plan-card-title">' + escHtml(p.summary || p.file) + versionBadge + '</div>' +
268
+ '<div><div class="plan-card-title">' + escapeHtml(p.summary || p.file) + versionBadge + '</div>' +
269
269
  '<div class="plan-card-meta">' +
270
270
  '<span style="font-weight:600;color:' + (statusColors[effectiveStatus] || 'var(--muted)') + '">' + label + '</span>' +
271
- (p.project ? '<span>' + escHtml(p.project) + '</span>' : '') +
271
+ (p.project ? '<span>' + escapeHtml(p.project) + '</span>' : '') +
272
272
  '<span>' + p.itemCount + ' items</span>' +
273
273
  (p.updatedAt ? '<span title="Last updated: ' + p.updatedAt + '">Updated ' + timeAgo(p.updatedAt) + '</span>' : '') +
274
274
  (p.completedAt ? '<span>' + p.completedAt.slice(0, 10) + '</span>' : '') +
275
- (p.generatedBy ? '<span>by ' + escHtml(p.generatedBy) + '</span>' : '') +
275
+ (p.generatedBy ? '<span>by ' + escapeHtml(p.generatedBy) + '</span>' : '') +
276
276
  executeBtn + pauseBtn + resumeBtn + verifyBtn + (hasVerifyWi ? _renderVerifyBadge(verifyWi) : '') + archiveReadyBadge + archiveBtn + deleteBtn +
277
277
  '</div>' +
278
278
  '</div>' +
@@ -323,15 +323,15 @@ function openArchivedPlansModal() {
323
323
  const html = plans.map(p => {
324
324
  const itemCount = p.itemCount || 0;
325
325
  const completed = p.completedAt ? p.completedAt.slice(0, 10) : '';
326
- return '<div class="plan-card" data-file="plans/' + escHtml(p.file) + '" style="cursor:pointer;opacity:0.8" onclick="if(shouldIgnoreSelectionClick(event))return;planView(\'' + escHtml(p.file) + '\')">' +
326
+ return '<div class="plan-card" data-file="plans/' + escapeHtml(p.file) + '" style="cursor:pointer;opacity:0.8" onclick="if(shouldIgnoreSelectionClick(event))return;planView(\'' + escapeHtml(p.file) + '\')">' +
327
327
  '<div class="plan-card-header">' +
328
- '<div><div class="plan-card-title" style="font-size:13px">' + escHtml(p.summary || p.file) + '</div>' +
328
+ '<div><div class="plan-card-title" style="font-size:13px">' + escapeHtml(p.summary || p.file) + '</div>' +
329
329
  '<div class="plan-card-meta">' +
330
330
  '<span style="color:var(--green);font-weight:600">Completed</span>' +
331
- (p.project ? '<span>' + escHtml(p.project) + '</span>' : '') +
331
+ (p.project ? '<span>' + escapeHtml(p.project) + '</span>' : '') +
332
332
  '<span>' + itemCount + ' items</span>' +
333
333
  (completed ? '<span>' + completed + '</span>' : '') +
334
- (p.generatedBy ? '<span>by ' + escHtml(p.generatedBy) + '</span>' : '') +
334
+ (p.generatedBy ? '<span>by ' + escapeHtml(p.generatedBy) + '</span>' : '') +
335
335
  '</div>' +
336
336
  '</div>' +
337
337
  '</div>' +
@@ -417,7 +417,7 @@ function _renderPlanModal(normalizedFile, raw, lastMod) {
417
417
  if (normalizedFile.endsWith('.json')) {
418
418
  let plan;
419
419
  try { plan = JSON.parse(raw); } catch (e) {
420
- document.getElementById('modal-body').innerHTML = '<p style="color:var(--red)">Failed to parse plan JSON: ' + escHtml(e.message) + '</p><pre style="font-size:10px;max-height:200px;overflow:auto">' + escHtml((raw || '').slice(0, 500)) + '</pre>';
420
+ document.getElementById('modal-body').innerHTML = '<p style="color:var(--red)">Failed to parse plan JSON: ' + escapeHtml(e.message) + '</p><pre style="font-size:10px;max-height:200px;overflow:auto">' + escapeHtml((raw || '').slice(0, 500)) + '</pre>';
421
421
  return;
422
422
  }
423
423
  title = plan.plan_summary || normalizedFile;
@@ -466,20 +466,20 @@ function _renderPlanModal(normalizedFile, raw, lastMod) {
466
466
  if (isNeedsAction) {
467
467
  const target = prdFile || normalizedFile;
468
468
  const label = effectiveStatus === 'paused' ? 'Resume' : 'Approve';
469
- modalActions += '<button class="pr-pager-btn" style="' + bs + ';color:var(--green)" onclick="planApprove(\'' + escHtml(target) + '\',this)">' + label + '</button> ';
469
+ modalActions += '<button class="pr-pager-btn" style="' + bs + ';color:var(--green)" onclick="planApprove(\'' + escapeHtml(target) + '\',this)">' + label + '</button> ';
470
470
  // Re-execute: re-generate PRD from updated plan (only for .md plans with existing awaiting PRD)
471
471
  if (effectiveStatus === 'awaiting-approval' && isMdPlan && prdFile) {
472
- modalActions += '<button class="pr-pager-btn" style="' + bs + ';color:var(--green);opacity:0.7" onclick="planExecute(\'' + escHtml(normalizedFile) + '\',\'\',this)">Re-execute</button> ';
472
+ modalActions += '<button class="pr-pager-btn" style="' + bs + ';color:var(--green);opacity:0.7" onclick="planExecute(\'' + escapeHtml(normalizedFile) + '\',\'\',this)">Re-execute</button> ';
473
473
  }
474
- modalActions += '<button class="pr-pager-btn" style="' + bs + ';color:var(--red)" onclick="planReject(\'' + escHtml(target) + '\')">Reject</button> ';
474
+ modalActions += '<button class="pr-pager-btn" style="' + bs + ';color:var(--red)" onclick="planReject(\'' + escapeHtml(target) + '\')">Reject</button> ';
475
475
  }
476
476
  // Execute (draft .md without PRD)
477
477
  if (isDraft && (effectiveStatus === 'active' || effectiveStatus === 'draft') && !isArchived) {
478
- modalActions += '<button class="pr-pager-btn" style="' + bs + ';color:var(--green);font-weight:600" onclick="planExecute(\'' + escHtml(normalizedFile) + '\',\'\',this)">Execute</button> ';
478
+ modalActions += '<button class="pr-pager-btn" style="' + bs + ';color:var(--green);font-weight:600" onclick="planExecute(\'' + escapeHtml(normalizedFile) + '\',\'\',this)">Execute</button> ';
479
479
  }
480
480
  // Pause (active PRD, not completed)
481
481
  if (effectiveStatus === 'dispatched' && prdFile && !isArchived) {
482
- modalActions += '<button class="pr-pager-btn" style="' + bs + ';color:var(--yellow)" onclick="planPause(\'' + escHtml(prdFile) + '\',this)">Pause</button> ';
482
+ modalActions += '<button class="pr-pager-btn" style="' + bs + ';color:var(--yellow)" onclick="planPause(\'' + escapeHtml(prdFile) + '\',this)">Pause</button> ';
483
483
  }
484
484
  // Completed label
485
485
  if (isCompleted && !isArchived) {
@@ -492,19 +492,19 @@ function _renderPlanModal(normalizedFile, raw, lastMod) {
492
492
  // Verify / Verified badge
493
493
  const modalVerifyWi = (window._lastWorkItems || []).find(w => w.itemType === 'verify' && w.sourcePlan === (prdFile || normalizedFile));
494
494
  if (effectiveStatus === 'completed' && prdFile && !isArchived && !modalVerifyWi) {
495
- modalActions += '<button class="pr-pager-btn" style="' + bs + ';color:var(--green)" onclick="triggerVerify(\'' + escHtml(prdFile) + '\',this)">Verify</button> ';
495
+ modalActions += '<button class="pr-pager-btn" style="' + bs + ';color:var(--green)" onclick="triggerVerify(\'' + escapeHtml(prdFile) + '\',this)">Verify</button> ';
496
496
  }
497
497
  if (modalVerifyWi) modalActions += _renderVerifyBadge(modalVerifyWi);
498
498
  // Archive + Delete (always, unless archived)
499
499
  if (!isArchived) {
500
- modalActions += '<button class="pr-pager-btn" style="' + bs + ';color:var(--muted)" onclick="planArchive(\'' + escHtml(prdFile || normalizedFile) + '\')">Archive</button> ';
501
- modalActions += '<button class="pr-pager-btn" style="' + bs + ';color:var(--red)" onclick="planDelete(\'' + escHtml(normalizedFile) + '\')">Delete</button>';
500
+ modalActions += '<button class="pr-pager-btn" style="' + bs + ';color:var(--muted)" onclick="planArchive(\'' + escapeHtml(prdFile || normalizedFile) + '\')">Archive</button> ';
501
+ modalActions += '<button class="pr-pager-btn" style="' + bs + ';color:var(--red)" onclick="planDelete(\'' + escapeHtml(normalizedFile) + '\')">Delete</button>';
502
502
  }
503
503
 
504
504
  const lastModLabel = lastMod ? '<div style="font-size:10px;color:var(--muted);font-weight:400;margin-top:2px">Last updated: ' + new Date(lastMod).toLocaleString() + '</div>' : '';
505
505
  const actionBtns = '<div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:4px">' + modalActions + '</div>';
506
506
 
507
- document.getElementById('modal-title').innerHTML = escHtml(title) + (versionLabel ? ' <span style="font-size:11px;font-weight:700;padding:1px 6px;border-radius:3px;background:rgba(56,139,253,0.15);color:var(--blue)">' + escHtml(versionLabel) + '</span>' : '') + lastModLabel + actionBtns;
507
+ document.getElementById('modal-title').innerHTML = escapeHtml(title) + (versionLabel ? ' <span style="font-size:11px;font-weight:700;padding:1px 6px;border-radius:3px;background:rgba(56,139,253,0.15);color:var(--blue)">' + escapeHtml(versionLabel) + '</span>' : '') + lastModLabel + actionBtns;
508
508
  const modalBody = document.getElementById('modal-body');
509
509
  const scrollTop = modalBody.scrollTop;
510
510
  if (normalizedFile.endsWith('.json')) {
@@ -742,11 +742,11 @@ function _renderVerifyBadge(verifyWi) {
742
742
  const planFile = verifyWi.sourcePlan || '';
743
743
  const planSlug = planFile.replace('.json', '');
744
744
  const verifyPr = allPrs.find(pr => (pr.prdItems || []).includes(verifyWi.id) || (pr.branch && pr.branch.includes(planSlug) && (pr.title || '').includes('[E2E]')));
745
- const prLink = verifyPr?.url ? ' <a href="' + escHtml(verifyPr.url) + '" target="_blank" onclick="event.stopPropagation()" style="color:var(--blue);text-decoration:underline;font-size:9px">' + escHtml(verifyPr.id || 'E2E PR') + '</a>' : '';
745
+ const prLink = verifyPr?.url ? ' <a href="' + escapeHtml(verifyPr.url) + '" target="_blank" onclick="event.stopPropagation()" style="color:var(--blue);text-decoration:underline;font-size:9px">' + escapeHtml(verifyPr.id || 'E2E PR') + '</a>' : '';
746
746
  // Testing guide
747
747
  const guides = window._lastStatus?.verifyGuides || [];
748
748
  const guide = guides.find(g => g.planFile === planFile);
749
- const guideLink = guide ? ' <span onclick="event.stopPropagation();openVerifyGuide(\'' + escHtml(guide.file) + '\')" style="color:var(--green);cursor:pointer;text-decoration:underline;font-size:9px">Testing Guide</span>' : '';
749
+ const guideLink = guide ? ' <span onclick="event.stopPropagation();openVerifyGuide(\'' + escapeHtml(guide.file) + '\')" style="color:var(--green);cursor:pointer;text-decoration:underline;font-size:9px">Testing Guide</span>' : '';
750
750
  return '<span style="font-size:9px;font-weight:600;color:' + color + ';padding:0 4px">' + label + '</span>' + prLink + guideLink;
751
751
  }
752
752
 
@@ -19,16 +19,16 @@ function prRow(pr) {
19
19
  const url = pr.url || '#';
20
20
  const prId = pr.id || '—';
21
21
  return '<tr>' +
22
- '<td><span class="pr-id">' + escHtml(String(prId)) + '</span></td>' +
23
- '<td><a class="pr-title" href="' + escHtml(safeUrl(url)) + '" target="_blank" rel="noopener">' + escHtml(pr.title || 'Untitled') + '</a>' + (pr.description ? '<div class="pr-desc">' + escHtml(pr.description.length > 120 ? pr.description.slice(0, 120) + '...' : pr.description) + '</div>' : '') + '</td>' +
24
- '<td><span class="pr-agent">' + escHtml(pr.agent || '—') + '</span></td>' +
25
- '<td><span class="pr-branch">' + escHtml(pr.branch || '—') + '</span></td>' +
26
- '<td><span class="pr-badge ' + reviewClass + '">' + escHtml(reviewLabel) + '</span></td>' +
27
- '<td>' + (sq.reviewer && sq.status !== 'waiting' ? '<span class="pr-agent" title="' + escHtml(sq.note || '') + '">' + escHtml(sq.reviewer) + '</span>' : sq.reviewer && sq.status === 'waiting' ? '<span class="pr-agent" style="color:var(--muted)" title="Vote pending confirmation">' + escHtml(sq.reviewer) + '…</span>' : pr.reviewedBy && pr.reviewedBy.length ? '<span class="pr-agent">' + escHtml(pr.reviewedBy.join(', ')) + '</span>' : '<span style="color:var(--muted);font-size:11px">—</span>') + '</td>' +
28
- '<td><span class="pr-badge ' + buildClass + '">' + escHtml(buildLabel) + '</span></td>' +
29
- '<td><span class="pr-badge ' + statusClass + '">' + escHtml(statusLabel) + '</span></td>' +
30
- '<td><span class="pr-date">' + escHtml((pr.created || '—').slice(0, 16).replace('T', ' ')) + '</span></td>' +
31
- '<td><button class="pr-pager-btn" style="font-size:9px;padding:1px 5px;color:var(--red);border-color:var(--red)" data-pr-id="' + escHtml(String(prId)) + '" onclick="event.stopPropagation();unlinkPr(this.dataset.prId)" title="Remove from tracking">x</button></td>' +
22
+ '<td><span class="pr-id">' + escapeHtml(String(prId)) + '</span></td>' +
23
+ '<td><a class="pr-title" href="' + escapeHtml(safeUrl(url)) + '" target="_blank" rel="noopener">' + escapeHtml(pr.title || 'Untitled') + '</a>' + (pr.description ? '<div class="pr-desc">' + escapeHtml(pr.description.length > 120 ? pr.description.slice(0, 120) + '...' : pr.description) + '</div>' : '') + '</td>' +
24
+ '<td><span class="pr-agent">' + escapeHtml(pr.agent || '—') + '</span></td>' +
25
+ '<td><span class="pr-branch">' + escapeHtml(pr.branch || '—') + '</span></td>' +
26
+ '<td><span class="pr-badge ' + reviewClass + '">' + escapeHtml(reviewLabel) + '</span></td>' +
27
+ '<td>' + (sq.reviewer && sq.status !== 'waiting' ? '<span class="pr-agent" title="' + escapeHtml(sq.note || '') + '">' + escapeHtml(sq.reviewer) + '</span>' : sq.reviewer && sq.status === 'waiting' ? '<span class="pr-agent" style="color:var(--muted)" title="Vote pending confirmation">' + escapeHtml(sq.reviewer) + '…</span>' : pr.reviewedBy && pr.reviewedBy.length ? '<span class="pr-agent">' + escapeHtml(pr.reviewedBy.join(', ')) + '</span>' : '<span style="color:var(--muted);font-size:11px">—</span>') + '</td>' +
28
+ '<td><span class="pr-badge ' + buildClass + '">' + escapeHtml(buildLabel) + '</span></td>' +
29
+ '<td><span class="pr-badge ' + statusClass + '">' + escapeHtml(statusLabel) + '</span></td>' +
30
+ '<td><span class="pr-date">' + escapeHtml((pr.created || '—').slice(0, 16).replace('T', ' ')) + '</span></td>' +
31
+ '<td><button class="pr-pager-btn" style="font-size:9px;padding:1px 5px;color:var(--red);border-color:var(--red)" data-pr-id="' + escapeHtml(String(prId)) + '" onclick="event.stopPropagation();unlinkPr(this.dataset.prId)" title="Remove from tracking">x</button></td>' +
32
32
  '</tr>';
33
33
  }
34
34
 
@@ -93,7 +93,7 @@ function openModal(i) {
93
93
  if (!item) return;
94
94
  document.getElementById('modal-title').textContent = item.name;
95
95
  document.getElementById('modal-body').innerHTML =
96
- '<div style="margin-bottom:12px"><button class="pr-pager-btn" style="font-size:10px;padding:3px 10px" onclick="promoteToKB(\'' + escHtml(item.name) + '\')">Add to Knowledge Base</button></div>' +
96
+ '<div style="margin-bottom:12px"><button class="pr-pager-btn" style="font-size:10px;padding:3px 10px" onclick="promoteToKB(\'' + escapeHtml(item.name) + '\')">Add to Knowledge Base</button></div>' +
97
97
  '<div style="font-size:12px;line-height:1.7;color:var(--muted)">' + renderMd(item.content) + '</div>';
98
98
  _modalDocContext = { title: item.name, content: item.content, selection: '' };
99
99
  _modalFilePath = 'notes/inbox/' + item.name; showModalQa();
@@ -106,7 +106,7 @@ function openModal(i) {
106
106
  function openAddPrModal() {
107
107
  const projOpts = (typeof cmdProjects !== 'undefined' ? cmdProjects : []).map(p => {
108
108
  const name = typeof p === 'object' ? p.name : p;
109
- return '<option value="' + escHtml(name) + '">' + escHtml(name) + '</option>';
109
+ return '<option value="' + escapeHtml(name) + '">' + escapeHtml(name) + '</option>';
110
110
  }).join('');
111
111
  const inputStyle = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit';
112
112
 
@@ -23,55 +23,55 @@ function wiRetryBtn(item) {
23
23
  return '<span style="font-size:9px;padding:1px 6px;color:var(--green);border:1px solid rgba(63,185,80,0.35);background:rgba(63,185,80,0.1);border-radius:3px;margin-left:4px">Requeued</span>';
24
24
  }
25
25
  if (rs && rs.status === 'error') {
26
- return '<span style="font-size:9px;padding:1px 6px;color:var(--red);border:1px solid rgba(248,81,73,0.35);background:rgba(248,81,73,0.1);border-radius:3px;margin-left:4px;cursor:pointer" title="' + escHtml(rs.message || 'Retry failed') + ' — click to try again" onclick="event.stopPropagation();retryWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')">Retry failed</span>';
26
+ return '<span style="font-size:9px;padding:1px 6px;color:var(--red);border:1px solid rgba(248,81,73,0.35);background:rgba(248,81,73,0.1);border-radius:3px;margin-left:4px;cursor:pointer" title="' + escapeHtml(rs.message || 'Retry failed') + ' — click to try again" onclick="event.stopPropagation();retryWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')">Retry failed</span>';
27
27
  }
28
- return '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--yellow);border-color:var(--yellow);margin-left:4px" onclick="event.stopPropagation();retryWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')">Retry</button>';
28
+ return '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--yellow);border-color:var(--yellow);margin-left:4px" onclick="event.stopPropagation();retryWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')">Retry</button>';
29
29
  }
30
30
 
31
31
  function wiRow(item) {
32
32
  const statusBadge = (s) => {
33
33
  const cls = s === 'failed' ? 'rejected' : s === 'needs-human-review' ? 'needs-review' : s === 'dispatched' ? 'building' : s === 'pending' || s === 'queued' ? 'active' : s === 'done' ? 'approved' : s === 'decomposed' ? 'approved' : 'draft';
34
- return '<span class="pr-badge ' + cls + '">' + escHtml(s) + '</span>';
34
+ return '<span class="pr-badge ' + cls + '">' + escapeHtml(s) + '</span>';
35
35
  };
36
- const typeBadge = (t) => '<span class="dispatch-type ' + (t || 'implement') + '">' + escHtml(t || 'implement') + '</span>';
37
- const priBadge = (p) => '<span class="prd-item-priority ' + (p || '') + '">' + escHtml(p || 'medium') + '</span>';
36
+ const typeBadge = (t) => '<span class="dispatch-type ' + (t || 'implement') + '">' + escapeHtml(t || 'implement') + '</span>';
37
+ const priBadge = (p) => '<span class="prd-item-priority ' + (p || '') + '">' + escapeHtml(p || 'medium') + '</span>';
38
38
  const prLink = item._pr
39
- ? '<a class="pr-title" href="' + escHtml(item._prUrl || '#') + '" target="_blank" style="font-size:10px">' + escHtml(item._pr) + '</a>'
39
+ ? '<a class="pr-title" href="' + escapeHtml(item._prUrl || '#') + '" target="_blank" style="font-size:10px">' + escapeHtml(item._pr) + '</a>'
40
40
  : (item.branchStrategy === 'shared-branch' && item.status === 'done')
41
41
  ? '<span style="font-size:9px;color:var(--muted)" title="Part of shared branch — aggregate PR created at verify stage">shared branch</span>'
42
42
  : '<span style="color:var(--muted)">—</span>';
43
- return '<tr style="cursor:pointer" onclick="if(shouldIgnoreSelectionClick(event))return;openWorkItemDetail(\'' + escHtml(item.id) + '\')">' +
44
- '<td><span class="pr-id">' + escHtml(item.id || '') + '</span></td>' +
45
- '<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escHtml((item.title || '').slice(0, 200)) + '">' + escHtml(item.title || '') + '</td>' +
46
- '<td><span style="font-size:10px;color:var(--muted)">' + escHtml(item._source || '') + '</span>' +
43
+ return '<tr style="cursor:pointer" onclick="if(shouldIgnoreSelectionClick(event))return;openWorkItemDetail(\'' + escapeHtml(item.id) + '\')">' +
44
+ '<td><span class="pr-id">' + escapeHtml(item.id || '') + '</span></td>' +
45
+ '<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escapeHtml((item.title || '').slice(0, 200)) + '">' + escapeHtml(item.title || '') + '</td>' +
46
+ '<td><span style="font-size:10px;color:var(--muted)">' + escapeHtml(item._source || '') + '</span>' +
47
47
  (item.scope === 'fan-out' ? ' <span class="pr-badge ' + (item.status === 'done' || item.status === 'failed' ? 'draft' : 'building') + '" style="font-size:8px">fan-out</span>' : '') + '</td>' +
48
48
  '<td>' + typeBadge(item.type) + '</td>' +
49
49
  '<td>' + priBadge(item.priority) + '</td>' +
50
50
  '<td>' + statusBadge(item.status || 'pending') +
51
51
  (item._reopened ? ' <span style="font-size:9px;color:var(--purple);margin-left:4px" title="This item was reopened from a previously completed state">reopened</span>' : '') +
52
- (item._pendingReason && item.status === 'pending' && item._pendingReason !== 'already_dispatched' ? ' <span style="font-size:9px;color:var(--muted);margin-left:4px" title="Pending reason: ' + escHtml(item._pendingReason) + '">' + escHtml(item._pendingReason.replace(/_/g, ' ')) + '</span>' : '') +
52
+ (item._pendingReason && item.status === 'pending' && item._pendingReason !== 'already_dispatched' ? ' <span style="font-size:9px;color:var(--muted);margin-left:4px" title="Pending reason: ' + escapeHtml(item._pendingReason) + '">' + escapeHtml(item._pendingReason.replace(/_/g, ' ')) + '</span>' : '') +
53
53
  (item._pendingReason === 'already_dispatched' && item.status === 'pending' ? ' <span style="font-size:9px;color:var(--blue);margin-left:4px" title="In dispatch queue, waiting to be assigned">queued</span>' : '') +
54
- (item._skipReason && item.status === 'pending' ? ' <span style="font-size:9px;color:var(--yellow);margin-left:4px" title="Dispatch blocked: ' + escHtml(item._skipReason) + (item._blockedBy ? ' (by ' + escHtml(item._blockedBy) + ')' : '') + '">' + escHtml(item._skipReason.replace(/_/g, ' ')) + (item._blockedBy ? ' <span style="color:var(--muted)">(' + escHtml(item._blockedBy) + ')</span>' : '') + '</span>' : '') +
54
+ (item._skipReason && item.status === 'pending' ? ' <span style="font-size:9px;color:var(--yellow);margin-left:4px" title="Dispatch blocked: ' + escapeHtml(item._skipReason) + (item._blockedBy ? ' (by ' + escapeHtml(item._blockedBy) + ')' : '') + '">' + escapeHtml(item._skipReason.replace(/_/g, ' ')) + (item._blockedBy ? ' <span style="color:var(--muted)">(' + escapeHtml(item._blockedBy) + ')</span>' : '') + '</span>' : '') +
55
55
  (item.status === 'failed' ? ' ' + wiRetryBtn(item) : '') +
56
56
  '</td>' +
57
57
  '<td>' +
58
58
  (item.completedAgents && item.completedAgents.length > 0
59
- ? '<span class="pr-agent">' + escHtml(item.completedAgents.join(', ')) + '</span>'
60
- : '<span class="pr-agent">' + escHtml(item.dispatched_to || item.agent || '—') + '</span>') +
61
- (item.failReason ? '<span style="display:block;font-size:9px;color:var(--red)" title="' + escHtml(item.failReason) + '">' + escHtml(item.failReason.slice(0, 30)) + '</span>' : '') +
59
+ ? '<span class="pr-agent">' + escapeHtml(item.completedAgents.join(', ')) + '</span>'
60
+ : '<span class="pr-agent">' + escapeHtml(item.dispatched_to || item.agent || '—') + '</span>') +
61
+ (item.failReason ? '<span style="display:block;font-size:9px;color:var(--red)" title="' + escapeHtml(item.failReason) + '">' + escapeHtml(item.failReason.slice(0, 30)) + '</span>' : '') +
62
62
  '</td>' +
63
63
  '<td>' + prLink + '</td>' +
64
- '<td><span class="pr-date">' + escHtml((item.created || '').slice(0, 16).replace('T', ' ')) + '</span></td>' +
64
+ '<td><span class="pr-date">' + escapeHtml((item.created || '').slice(0, 16).replace('T', ' ')) + '</span></td>' +
65
65
  '<td style="white-space:nowrap;font-size:9px;color:var(--muted)">' +
66
66
  (item.references && item.references.length ? '<span title="' + item.references.length + ' reference(s)" style="margin-right:4px">&#x1F517;' + item.references.length + '</span>' : '') +
67
67
  (item.acceptanceCriteria && item.acceptanceCriteria.length ? '<span title="' + item.acceptanceCriteria.length + ' acceptance criteria">&#x2611;' + item.acceptanceCriteria.length + '</span>' : '') +
68
68
  '</td>' +
69
69
  '<td style="white-space:nowrap">' +
70
- ((item.status === 'pending' || item.status === 'failed' || item.status === 'needs-human-review') ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--blue);border-color:var(--blue);margin-right:4px" onclick="event.stopPropagation();editWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Edit work item">&#x270E;</button>' : '') +
71
- ((item.status === 'done' || item.status === 'failed' || item.status === 'needs-human-review') ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--muted);border-color:var(--border);margin-right:4px" onclick="event.stopPropagation();archiveWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Archive work item">&#x1F4E6;</button>' : '') +
72
- ((item.status === 'done' || item.status === 'failed' || item.status === 'needs-human-review') && !item._humanFeedback ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--green);border-color:var(--green);margin-right:4px" onclick="event.stopPropagation();feedbackWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Give feedback">&#x1F44D;&#x1F44E;</button>' : (item._humanFeedback ? '<span style="font-size:9px" title="Feedback given">' + (item._humanFeedback.rating === 'up' ? '&#x1F44D;' : '&#x1F44E;') + '</span> ' : '')) +
73
- ((item.status === 'pending' || item.status === 'dispatched' || item.status === 'queued' || item.status === 'failed' || item.status === 'needs-human-review') ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--orange);border-color:var(--orange);margin-right:4px" onclick="event.stopPropagation();cancelWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Cancel work item and kill agent">&#x1F6AB;</button>' : '') +
74
- '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--red);border-color:var(--red)" onclick="event.stopPropagation();deleteWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Delete work item and kill agent">&#x2715;</button>' +
70
+ ((item.status === 'pending' || item.status === 'failed' || item.status === 'needs-human-review') ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--blue);border-color:var(--blue);margin-right:4px" onclick="event.stopPropagation();editWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')" title="Edit work item">&#x270E;</button>' : '') +
71
+ ((item.status === 'done' || item.status === 'failed' || item.status === 'needs-human-review') ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--muted);border-color:var(--border);margin-right:4px" onclick="event.stopPropagation();archiveWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')" title="Archive work item">&#x1F4E6;</button>' : '') +
72
+ ((item.status === 'done' || item.status === 'failed' || item.status === 'needs-human-review') && !item._humanFeedback ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--green);border-color:var(--green);margin-right:4px" onclick="event.stopPropagation();feedbackWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')" title="Give feedback">&#x1F44D;&#x1F44E;</button>' : (item._humanFeedback ? '<span style="font-size:9px" title="Feedback given">' + (item._humanFeedback.rating === 'up' ? '&#x1F44D;' : '&#x1F44E;') + '</span> ' : '')) +
73
+ ((item.status === 'pending' || item.status === 'dispatched' || item.status === 'queued' || item.status === 'failed' || item.status === 'needs-human-review') ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--orange);border-color:var(--orange);margin-right:4px" onclick="event.stopPropagation();cancelWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')" title="Cancel work item and kill agent">&#x1F6AB;</button>' : '') +
74
+ '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--red);border-color:var(--red)" onclick="event.stopPropagation();deleteWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')" title="Delete work item and kill agent">&#x2715;</button>' +
75
75
  '</td>' +
76
76
  '</tr>';
77
77
  }
@@ -130,7 +130,7 @@ function editWorkItem(id, source) {
130
130
  if (!item) return;
131
131
  const types = ['implement', 'fix', 'review', 'plan', 'verify', 'decompose', 'meeting', 'investigate', 'refactor', 'test', 'explore', 'ask', 'docs'];
132
132
  const priorities = ['critical', 'high', 'medium', 'low'];
133
- const agentOpts = (cmdAgents || []).map(a => '<option value="' + escHtml(a.id) + '"' + (item.agent === a.id ? ' selected' : '') + '>' + escHtml(a.name) + '</option>').join('');
133
+ const agentOpts = (cmdAgents || []).map(a => '<option value="' + escapeHtml(a.id) + '"' + (item.agent === a.id ? ' selected' : '') + '>' + escapeHtml(a.name) + '</option>').join('');
134
134
  const typeOpts = types.map(t => '<option value="' + t + '"' + ((item.type || 'implement') === t ? ' selected' : '') + '>' + t + '</option>').join('');
135
135
  const priOpts = priorities.map(p => '<option value="' + p + '"' + ((item.priority || 'medium') === p ? ' selected' : '') + '>' + p + '</option>').join('');
136
136
 
@@ -139,10 +139,10 @@ function editWorkItem(id, source) {
139
139
  document.getElementById('modal-body').innerHTML =
140
140
  '<div style="display:flex;flex-direction:column;gap:12px;font-family:inherit">' +
141
141
  '<label style="color:var(--text);font-size:var(--text-md)">Title' +
142
- '<input id="wi-edit-title" value="' + escHtml(item.title || '') + '" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit">' +
142
+ '<input id="wi-edit-title" value="' + escapeHtml(item.title || '') + '" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit">' +
143
143
  '</label>' +
144
144
  '<label style="color:var(--text);font-size:var(--text-md)">Description' +
145
- '<textarea id="wi-edit-desc" rows="3" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit;resize:vertical">' + escHtml(item.description || '') + '</textarea>' +
145
+ '<textarea id="wi-edit-desc" rows="3" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit;resize:vertical">' + escapeHtml(item.description || '') + '</textarea>' +
146
146
  '</label>' +
147
147
  '<div style="display:flex;gap:12px">' +
148
148
  '<label style="color:var(--text);font-size:var(--text-md);flex:1">Type' +
@@ -156,14 +156,14 @@ function editWorkItem(id, source) {
156
156
  '</label>' +
157
157
  '</div>' +
158
158
  '<label style="color:var(--text);font-size:var(--text-md)">References (one per line: url | title | type)' +
159
- '<textarea id="wi-edit-refs" rows="3" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit;resize:vertical">' + escHtml((item.references || []).map(function(r) { return r.url + ' | ' + (r.title || '') + ' | ' + (r.type || 'link'); }).join('\n')) + '</textarea>' +
159
+ '<textarea id="wi-edit-refs" rows="3" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit;resize:vertical">' + escapeHtml((item.references || []).map(function(r) { return r.url + ' | ' + (r.title || '') + ' | ' + (r.type || 'link'); }).join('\n')) + '</textarea>' +
160
160
  '</label>' +
161
161
  '<label style="color:var(--text);font-size:var(--text-md)">Acceptance Criteria (one per line)' +
162
- '<textarea id="wi-edit-ac" rows="3" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit;resize:vertical">' + escHtml((item.acceptanceCriteria || []).join('\n')) + '</textarea>' +
162
+ '<textarea id="wi-edit-ac" rows="3" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit;resize:vertical">' + escapeHtml((item.acceptanceCriteria || []).join('\n')) + '</textarea>' +
163
163
  '</label>' +
164
164
  '<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:8px">' +
165
165
  '<button onclick="closeModal()" class="pr-pager-btn" style="padding:6px 16px;font-size:var(--text-md)">Cancel</button>' +
166
- '<button onclick="submitWorkItemEdit(\'' + escHtml(id) + '\',\'' + escHtml(source || '') + '\',event)" style="padding:6px 16px;font-size:var(--text-md);background:var(--blue);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer">Save</button>' +
166
+ '<button onclick="submitWorkItemEdit(\'' + escapeHtml(id) + '\',\'' + escapeHtml(source || '') + '\',event)" style="padding:6px 16px;font-size:var(--text-md);background:var(--blue);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer">Save</button>' +
167
167
  '</div>' +
168
168
  '</div>';
169
169
  document.getElementById('modal').classList.add('open');
@@ -253,11 +253,11 @@ async function toggleWorkItemArchive() {
253
253
  '<div class="pr-table-wrap"><table class="pr-table"><thead><tr><th>ID</th><th>Title</th><th>Type</th><th>Status</th><th>Agent</th><th>Archived</th></tr></thead><tbody>' +
254
254
  items.map(function(i) {
255
255
  return '<tr style="opacity:0.6">' +
256
- '<td><span class="pr-id">' + escHtml(i.id || '') + '</span></td>' +
257
- '<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(i.title || '') + '</td>' +
258
- '<td><span class="dispatch-type ' + (i.type || '') + '">' + escHtml(i.type || '') + '</span></td>' +
259
- '<td style="color:' + (i.status === 'done' ? 'var(--green)' : 'var(--red)') + '">' + escHtml(i.status || '') + '</td>' +
260
- '<td>' + escHtml(i.dispatched_to || '—') + '</td>' +
256
+ '<td><span class="pr-id">' + escapeHtml(i.id || '') + '</span></td>' +
257
+ '<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escapeHtml(i.title || '') + '</td>' +
258
+ '<td><span class="dispatch-type ' + (i.type || '') + '">' + escapeHtml(i.type || '') + '</span></td>' +
259
+ '<td style="color:' + (i.status === 'done' ? 'var(--green)' : 'var(--red)') + '">' + escapeHtml(i.status || '') + '</td>' +
260
+ '<td>' + escapeHtml(i.dispatched_to || '—') + '</td>' +
261
261
  '<td class="pr-date">' + shortTime(i.archivedAt) + '</td>' +
262
262
  '</tr>';
263
263
  }).join('') + '</tbody></table></div>';
@@ -309,7 +309,7 @@ function feedbackWorkItem(id, source) {
309
309
  '<textarea id="feedback-comment" rows="3" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-family:inherit;resize:vertical" placeholder="What was good or needs improvement?"></textarea>' +
310
310
  '<div style="display:flex;justify-content:flex-end;gap:8px">' +
311
311
  '<button onclick="closeModal()" class="pr-pager-btn">Cancel</button>' +
312
- '<button id="fb-send" onclick="submitFeedback(\'' + escHtml(id) + '\',\'' + escHtml(source) + '\')" style="padding:6px 16px;background:var(--surface2);color:var(--muted);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:not-allowed" disabled>Select rating first</button>' +
312
+ '<button id="fb-send" onclick="submitFeedback(\'' + escapeHtml(id) + '\',\'' + escapeHtml(source) + '\')" style="padding:6px 16px;background:var(--surface2);color:var(--muted);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:not-allowed" disabled>Select rating first</button>' +
313
313
  '</div>' +
314
314
  '</div>';
315
315
  document.getElementById('modal').classList.add('open');
@@ -352,10 +352,10 @@ function openCreateWorkItemModal() {
352
352
  '<option value="' + p + '"' + (p === 'medium' ? ' selected' : '') + '>' + p + '</option>'
353
353
  ).join('');
354
354
  const agentOpts = (typeof cmdAgents !== 'undefined' ? cmdAgents : []).map(a =>
355
- '<option value="' + escHtml(a.id) + '">' + escHtml(a.name) + '</option>'
355
+ '<option value="' + escapeHtml(a.id) + '">' + escapeHtml(a.name) + '</option>'
356
356
  ).join('');
357
357
  const projOpts = (typeof cmdProjects !== 'undefined' ? cmdProjects : []).map(p =>
358
- '<option value="' + escHtml(p) + '">' + escHtml(p) + '</option>'
358
+ '<option value="' + escapeHtml(p) + '">' + escapeHtml(p) + '</option>'
359
359
  ).join('');
360
360
  const inputStyle = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit';
361
361
 
@@ -443,41 +443,41 @@ function openWorkItemDetail(id) {
443
443
  if (!item) return;
444
444
 
445
445
  const field = (label, value) => value ? '<div style="margin-bottom:8px"><span style="color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:0.5px">' + label + '</span><div style="margin-top:2px">' + value + '</div></div>' : '';
446
- const badge = (cls, text) => '<span class="pr-badge ' + cls + '">' + escHtml(text) + '</span>';
446
+ const badge = (cls, text) => '<span class="pr-badge ' + cls + '">' + escapeHtml(text) + '</span>';
447
447
  const statusCls = item.status === 'failed' ? 'rejected' : item.status === 'dispatched' ? 'building' : item.status === 'done' ? 'approved' : 'active';
448
448
 
449
449
  let html = '<div style="display:flex;flex-direction:column;gap:4px;font-size:13px">';
450
450
  html += '<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">' +
451
451
  badge(statusCls, item.status || 'pending') +
452
452
  (item._reopened ? ' <span style="font-size:9px;color:var(--purple);margin-left:4px" title="This item was reopened from a previously completed state">reopened</span>' : '') + ' ' +
453
- '<span class="dispatch-type ' + (item.type || 'implement') + '">' + escHtml(item.type || 'implement') + '</span>' +
454
- '<span class="prd-item-priority ' + (item.priority || '') + '">' + escHtml(item.priority || 'medium') + '</span>' +
453
+ '<span class="dispatch-type ' + (item.type || 'implement') + '">' + escapeHtml(item.type || 'implement') + '</span>' +
454
+ '<span class="prd-item-priority ' + (item.priority || '') + '">' + escapeHtml(item.priority || 'medium') + '</span>' +
455
455
  '</div>';
456
456
  html += field('Description', '<div style="font-size:12px;max-height:320px;overflow-y:auto;padding:8px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm)">' + renderMd(item.description || item.title || '—') + '</div>');
457
- html += field('Agent', escHtml(item.dispatched_to || item.agent || 'Auto'));
458
- html += field('Source', escHtml(item._source || 'central'));
459
- if (item.created) html += field('Created', escHtml(new Date(item.created).toLocaleString()));
460
- if (item.dispatched_at) html += field('Dispatched', escHtml(new Date(item.dispatched_at).toLocaleString()) + ' to ' + escHtml(item.dispatched_to || '?'));
461
- if (item.completedAt) html += field('Completed', escHtml(new Date(item.completedAt).toLocaleString()));
462
- if (item.failReason) html += field('Failure Reason', '<span style="color:var(--red)">' + escHtml(item.failReason) + '</span>');
463
- if (item._pendingReason && item.status === 'pending') html += field('Pending Reason', item._pendingReason === 'already_dispatched' ? 'Queued — waiting for available agent slot' : escHtml(item._pendingReason.replace(/_/g, ' ')));
464
- if (item._skipReason && item.status === 'pending') html += field('Dispatch Blocked', '<span style="color:var(--yellow)">' + escHtml(item._skipReason.replace(/_/g, ' ')) + '</span>' + (item._blockedBy ? ' — blocked by <strong>' + escHtml(item._blockedBy) + '</strong>' : ''));
465
- if (item.depends_on?.length) html += field('Depends On', item.depends_on.map(d => '<code>' + escHtml(d) + '</code>').join(', '));
466
- if (item.acceptanceCriteria?.length) html += field('Acceptance Criteria', '<ul style="margin:0;padding-left:20px">' + item.acceptanceCriteria.map(c => '<li>' + escHtml(c) + '</li>').join('') + '</ul>');
467
- if (item.references?.length) html += field('References', item.references.map(r => '<a href="' + escHtml(r.url) + '" target="_blank" style="color:var(--blue)">' + escHtml(r.title || r.url) + '</a>' + (r.type ? ' <span style="color:var(--muted);font-size:10px">(' + escHtml(r.type) + ')</span>' : '')).join('<br>'));
468
- if (item._humanFeedback) html += field('Human Feedback', (item._humanFeedback.rating === 'up' ? '👍' : '👎') + (item._humanFeedback.comment ? ' — ' + escHtml(item._humanFeedback.comment) : ''));
469
- if (item._pr) html += field('Pull Request', '<a href="' + escHtml(item._prUrl || '#') + '" target="_blank" style="color:var(--blue)">' + escHtml(item._pr) + '</a>');
457
+ html += field('Agent', escapeHtml(item.dispatched_to || item.agent || 'Auto'));
458
+ html += field('Source', escapeHtml(item._source || 'central'));
459
+ if (item.created) html += field('Created', escapeHtml(new Date(item.created).toLocaleString()));
460
+ if (item.dispatched_at) html += field('Dispatched', escapeHtml(new Date(item.dispatched_at).toLocaleString()) + ' to ' + escapeHtml(item.dispatched_to || '?'));
461
+ if (item.completedAt) html += field('Completed', escapeHtml(new Date(item.completedAt).toLocaleString()));
462
+ if (item.failReason) html += field('Failure Reason', '<span style="color:var(--red)">' + escapeHtml(item.failReason) + '</span>');
463
+ if (item._pendingReason && item.status === 'pending') html += field('Pending Reason', item._pendingReason === 'already_dispatched' ? 'Queued — waiting for available agent slot' : escapeHtml(item._pendingReason.replace(/_/g, ' ')));
464
+ if (item._skipReason && item.status === 'pending') html += field('Dispatch Blocked', '<span style="color:var(--yellow)">' + escapeHtml(item._skipReason.replace(/_/g, ' ')) + '</span>' + (item._blockedBy ? ' — blocked by <strong>' + escapeHtml(item._blockedBy) + '</strong>' : ''));
465
+ if (item.depends_on?.length) html += field('Depends On', item.depends_on.map(d => '<code>' + escapeHtml(d) + '</code>').join(', '));
466
+ if (item.acceptanceCriteria?.length) html += field('Acceptance Criteria', '<ul style="margin:0;padding-left:20px">' + item.acceptanceCriteria.map(c => '<li>' + escapeHtml(c) + '</li>').join('') + '</ul>');
467
+ if (item.references?.length) html += field('References', item.references.map(r => '<a href="' + escapeHtml(r.url) + '" target="_blank" style="color:var(--blue)">' + escapeHtml(r.title || r.url) + '</a>' + (r.type ? ' <span style="color:var(--muted);font-size:10px">(' + escapeHtml(r.type) + ')</span>' : '')).join('<br>'));
468
+ if (item._humanFeedback) html += field('Human Feedback', (item._humanFeedback.rating === 'up' ? '👍' : '👎') + (item._humanFeedback.comment ? ' — ' + escapeHtml(item._humanFeedback.comment) : ''));
469
+ if (item._pr) html += field('Pull Request', '<a href="' + escapeHtml(item._prUrl || '#') + '" target="_blank" style="color:var(--blue)">' + escapeHtml(item._pr) + '</a>');
470
470
 
471
471
  // Artifacts — output log, branch, skills, etc.
472
472
  var arts = item._artifacts || {};
473
473
  var artPills = '';
474
474
  var pillStyle = 'display:inline-flex;align-items:center;gap:3px;padding:2px 8px;border-radius:10px;font-size:10px;cursor:pointer;background:var(--surface2);border:1px solid var(--border);color:var(--text)';
475
475
  // Output log pill removed — raw stream-json output is not human-readable
476
- var artBackFn = "pushModalBack(function(){openWorkItemDetail('" + escHtml(item.id) + "')});";
477
- if (arts.branch) artPills += '<span style="' + pillStyle + ';cursor:default">🌿 ' + escHtml(arts.branch) + '</span> ';
478
- if (arts.plan) artPills += '<span onclick="' + artBackFn + 'planView(\'' + escHtml(arts.plan) + '\')" style="' + pillStyle + '">📋 Plan</span> ';
479
- if (arts.prd) artPills += '<span onclick="' + artBackFn + 'planView(\'' + escHtml(arts.prd) + '\')" style="' + pillStyle + '">📄 PRD</span> ';
480
- if (arts.sourcePlan) artPills += '<span onclick="' + artBackFn + 'planView(\'' + escHtml(arts.sourcePlan) + '\')" style="' + pillStyle + '">📋 Source Plan</span> ';
476
+ var artBackFn = "pushModalBack(function(){openWorkItemDetail('" + escapeHtml(item.id) + "')});";
477
+ if (arts.branch) artPills += '<span style="' + pillStyle + ';cursor:default">🌿 ' + escapeHtml(arts.branch) + '</span> ';
478
+ if (arts.plan) artPills += '<span onclick="' + artBackFn + 'planView(\'' + escapeHtml(arts.plan) + '\')" style="' + pillStyle + '">📋 Plan</span> ';
479
+ if (arts.prd) artPills += '<span onclick="' + artBackFn + 'planView(\'' + escapeHtml(arts.prd) + '\')" style="' + pillStyle + '">📄 PRD</span> ';
480
+ if (arts.sourcePlan) artPills += '<span onclick="' + artBackFn + 'planView(\'' + escapeHtml(arts.sourcePlan) + '\')" style="' + pillStyle + '">📋 Source Plan</span> ';
481
481
  if (arts.notes && arts.notes.length > 0) {
482
482
  arts.notes.forEach(function(n) {
483
483
  var noteFile = (n && typeof n === 'object') ? (n.file || n) : String(n || '');
@@ -486,17 +486,17 @@ function openWorkItemDetail(id) {
486
486
  var kbCat = kbParts[0];
487
487
  var kbFile = kbParts.slice(1).join('/');
488
488
  var kbLabel = kbFile.replace(/\.md$/, '').slice(0, 30);
489
- artPills += '<span onclick="' + artBackFn + 'kbOpenItem(\'' + escHtml(kbCat) + '\',\'' + escHtml(kbFile) + '\')" style="' + pillStyle + '">📚 ' + escHtml(kbLabel) + '</span> ';
489
+ artPills += '<span onclick="' + artBackFn + 'kbOpenItem(\'' + escapeHtml(kbCat) + '\',\'' + escapeHtml(kbFile) + '\')" style="' + pillStyle + '">📚 ' + escapeHtml(kbLabel) + '</span> ';
490
490
  } else if (noteFile.startsWith('archive:')) {
491
491
  var archLabel = noteFile.slice(8).replace(/\.md$/, '').replace(/^\d{4}-\d{2}-\d{2}-/, '').slice(0, 30);
492
- artPills += '<span onclick="' + artBackFn + 'openInboxNote(\'' + escHtml(noteFile.slice(8)) + '\')" style="' + pillStyle + ';opacity:0.7">📄 ' + escHtml(archLabel) + ' <span style="font-size:8px">(archived)</span></span> ';
492
+ artPills += '<span onclick="' + artBackFn + 'openInboxNote(\'' + escapeHtml(noteFile.slice(8)) + '\')" style="' + pillStyle + ';opacity:0.7">📄 ' + escapeHtml(archLabel) + ' <span style="font-size:8px">(archived)</span></span> ';
493
493
  } else {
494
494
  var noteLabel = noteFile.replace(/\.md$/, '').slice(0, 30);
495
- artPills += '<span onclick="' + artBackFn + 'openInboxNote(\'' + escHtml(noteFile) + '\')" style="' + pillStyle + '">📝 ' + escHtml(noteLabel) + '</span> ';
495
+ artPills += '<span onclick="' + artBackFn + 'openInboxNote(\'' + escapeHtml(noteFile) + '\')" style="' + pillStyle + '">📝 ' + escapeHtml(noteLabel) + '</span> ';
496
496
  }
497
497
  });
498
498
  }
499
- if (arts.skills && arts.skills.length > 0) arts.skills.forEach(function(s) { artPills += '<span onclick="openSkill(\'' + escHtml(s) + '\',\'minions\',\'\')" style="' + pillStyle + '">⚙ ' + escHtml(s) + '</span> '; });
499
+ if (arts.skills && arts.skills.length > 0) arts.skills.forEach(function(s) { artPills += '<span onclick="openSkill(\'' + escapeHtml(s) + '\',\'minions\',\'\')" style="' + pillStyle + '">⚙ ' + escapeHtml(s) + '</span> '; });
500
500
  if (artPills) html += field('Artifacts', '<div style="display:flex;flex-wrap:wrap;gap:4px">' + artPills + '</div>');
501
501
 
502
502
  if (item._totalCostUsd != null) html += field('Cumulative Cost', '$' + Number(item._totalCostUsd).toFixed(4));
@@ -96,6 +96,25 @@ function toggleModalPin() {
96
96
  else if (_modalFilePath.startsWith('knowledge/')) renderKnowledgeBase();
97
97
  }
98
98
 
99
+ // Canonical HTML-escape helper (SEC-03). Use this in all new code and for any user-controlled
100
+ // field that reaches `.innerHTML` / a template literal. Escapes the 6 HTML metacharacters
101
+ // (& < > " ' /) — the `/` escape closes the `</script>` break-out path that a 5-char escape
102
+ // leaves open. Returns '' for null/undefined so missing fields never render the literal strings
103
+ // "null"/"undefined". Idempotent for non-metacharacter input (double-escaping only expands `&`).
104
+ function escapeHtml(str) {
105
+ if (str === null || str === undefined) return '';
106
+ return String(str)
107
+ .replace(/&/g, '&amp;')
108
+ .replace(/</g, '&lt;')
109
+ .replace(/>/g, '&gt;')
110
+ .replace(/"/g, '&quot;')
111
+ .replace(/'/g, '&#39;')
112
+ .replace(/\//g, '&#x2F;');
113
+ }
114
+
115
+ // Legacy 5-char escape. Kept as a backward-compat alias so files not yet migrated under
116
+ // SEC-03 Phase A still compile. Prefer `escapeHtml` in new code — it adds the `/` escape
117
+ // and null/undefined handling. Phase B will remove this once the remaining renderers move.
99
118
  function escHtml(s) {
100
119
  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
101
120
  }
@@ -482,4 +501,4 @@ async function submitBugReport() {
482
501
  }
483
502
  }
484
503
 
485
- window.MinionsUtils = { wakeEngine, escHtml, renderMd, normalizePlanFile, timeAgo, statusColor, shouldIgnoreSelectionClick, llmCopyBtn, copyLlmText, openBugReport, submitBugReport };
504
+ window.MinionsUtils = { wakeEngine, escapeHtml, escHtml, renderMd, normalizePlanFile, timeAgo, statusColor, shouldIgnoreSelectionClick, llmCopyBtn, copyLlmText, openBugReport, submitBugReport };
package/dashboard.js CHANGED
@@ -1292,7 +1292,20 @@ function readBody(req) {
1292
1292
  reject(new Error('Request body timeout after 30s'));
1293
1293
  }, 30000);
1294
1294
  req.on('data', chunk => { body += chunk; if (body.length > 1e6) { clearTimeout(timeout); reject(new Error('Too large')); } });
1295
- req.on('end', () => { clearTimeout(timeout); try { resolve(JSON.parse(body)); } catch(e) { reject(e); } });
1295
+ req.on('end', () => {
1296
+ clearTimeout(timeout);
1297
+ let parsed;
1298
+ try { parsed = JSON.parse(body); } catch(e) { reject(e); return; }
1299
+ // Belt-and-braces: reject payloads containing prototype-pollution attack keys
1300
+ // before they reach any downstream Object.assign / spread / deep-merge.
1301
+ if (shared.hasDangerousKey(parsed)) {
1302
+ const err = new Error('Request body contains forbidden key (__proto__, constructor, or prototype)');
1303
+ err.statusCode = 400; // honoured by handler catch blocks so response is 400 regardless of handler
1304
+ reject(err);
1305
+ return;
1306
+ }
1307
+ resolve(parsed);
1308
+ });
1296
1309
  req.on('error', (e) => { clearTimeout(timeout); reject(e); });
1297
1310
  });
1298
1311
  }
@@ -1519,7 +1532,7 @@ const server = http.createServer(async (req, res) => {
1519
1532
  }
1520
1533
  }
1521
1534
  return jsonReply(res, 200, { ok: true, message: 'Completion check ran but no verify task was needed' });
1522
- } catch (e) { return jsonReply(res, 500, { error: e.message }); }
1535
+ } catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
1523
1536
  }
1524
1537
 
1525
1538
  async function handleWorkItemsRetry(req, res) {
@@ -1847,7 +1860,7 @@ const server = http.createServer(async (req, res) => {
1847
1860
  if (content) { try { allArchived.push(...JSON.parse(content).map(i => ({ ...i, _source: project.name }))); } catch {} }
1848
1861
  }
1849
1862
  return jsonReply(res, 200, allArchived);
1850
- } catch (e) { console.error('Archive fetch error:', e.message); return jsonReply(res, 500, { error: e.message }); }
1863
+ } catch (e) { console.error('Archive fetch error:', e.message); return jsonReply(res, e.statusCode || 500, { error: e.message }); }
1851
1864
  }
1852
1865
 
1853
1866
  async function handleWorkItemsReopen(req, res) {
@@ -3472,7 +3485,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3472
3485
  }
3473
3486
  return jsonReply(res, 200, { ok: true, answer: answer + '\n\n(Read-only — changes not saved)', edited: false, actions });
3474
3487
  } finally { _docAbort = null; docChatInFlight.delete(docKey); }
3475
- } catch (e) { return jsonReply(res, 500, { error: e.message }); }
3488
+ } catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
3476
3489
  }
3477
3490
 
3478
3491
  async function handleInboxPersist(req, res) {
@@ -3660,7 +3673,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3660
3673
  }
3661
3674
  if (!selectedPath) return jsonReply(res, 200, { cancelled: true });
3662
3675
  return jsonReply(res, 200, { path: selectedPath.replace(/\\/g, '/') });
3663
- } catch (e) { return jsonReply(res, 500, { error: e.message }); }
3676
+ } catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
3664
3677
  }
3665
3678
 
3666
3679
  // ── Non-repo confirmation tokens (SEC-05) ───────────────────────────────
@@ -3857,7 +3870,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3857
3870
  });
3858
3871
 
3859
3872
  return jsonReply(res, 200, { repos: results });
3860
- } catch (e) { return jsonReply(res, 500, { error: e.message }); }
3873
+ } catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
3861
3874
  }
3862
3875
 
3863
3876
  async function handleFileBug(req, res) {
@@ -3992,7 +4005,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3992
4005
  } finally {
3993
4006
  _releaseCCTab(tabId);
3994
4007
  }
3995
- } catch (e) { _releaseCCTab(tabId); return jsonReply(res, 500, { error: e.message }); }
4008
+ } catch (e) { _releaseCCTab(tabId); return jsonReply(res, e.statusCode || 500, { error: e.message }); }
3996
4009
  }
3997
4010
 
3998
4011
  /** Build a lightweight input object for SSE tool events — keeps only the fields formatToolSummary needs, with truncated string values. */
@@ -4205,8 +4218,16 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4205
4218
  } catch (e) {
4206
4219
  stopCcHeartbeat();
4207
4220
  _releaseCCTab(tabId);
4208
- writeCcEvent({ type: 'error', error: e.message });
4209
- _ccStreamEnded = true; try { res.end(); } catch {}
4221
+ // If SSE headers haven't been sent yet (e.g. readBody guard fired), respond with the
4222
+ // intended HTTP status (400 for prototype-pollution rejection) instead of an SSE event.
4223
+ if (!res.headersSent) {
4224
+ res.statusCode = e.statusCode || 500;
4225
+ res.setHeader('Content-Type', 'application/json');
4226
+ try { res.end(JSON.stringify({ error: e.message })); } catch {}
4227
+ } else {
4228
+ writeCcEvent({ type: 'error', error: e.message });
4229
+ _ccStreamEnded = true; try { res.end(); } catch {}
4230
+ }
4210
4231
  }
4211
4232
  }
4212
4233
 
@@ -4360,7 +4381,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4360
4381
  try {
4361
4382
  const newPid = restartEngine();
4362
4383
  return jsonReply(res, 200, { ok: true, pid: newPid });
4363
- } catch (e) { return jsonReply(res, 500, { error: e.message }); }
4384
+ } catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
4364
4385
  }
4365
4386
 
4366
4387
  async function handleSettingsRead(req, res) {
@@ -4384,7 +4405,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4384
4405
  })),
4385
4406
  routing,
4386
4407
  });
4387
- } catch (e) { return jsonReply(res, 500, { error: e.message }); }
4408
+ } catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
4388
4409
  }
4389
4410
 
4390
4411
  async function handleSettingsUpdate(req, res) {
@@ -4526,7 +4547,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4526
4547
  ? 'Settings saved. Some values were adjusted: ' + _clamped.join('; ')
4527
4548
  : 'Settings saved. Engine picks up changes on next tick.';
4528
4549
  return jsonReply(res, 200, { ok: true, message: msg, clamped: _clamped });
4529
- } catch (e) { return jsonReply(res, 500, { error: e.message }); }
4550
+ } catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
4530
4551
  }
4531
4552
 
4532
4553
  async function handleSettingsRouting(req, res) {
@@ -4535,7 +4556,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4535
4556
  if (!body.content) return jsonReply(res, 400, { error: 'content required' });
4536
4557
  safeWrite(path.join(MINIONS_DIR, 'routing.md'), body.content);
4537
4558
  return jsonReply(res, 200, { ok: true });
4538
- } catch (e) { return jsonReply(res, 500, { error: e.message }); }
4559
+ } catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
4539
4560
  }
4540
4561
 
4541
4562
  async function handleSettingsReset(req, res) {
@@ -4548,7 +4569,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4548
4569
  reloadConfig();
4549
4570
  invalidateStatusCache();
4550
4571
  return jsonReply(res, 200, { ok: true });
4551
- } catch (e) { return jsonReply(res, 500, { error: e.message }); }
4572
+ } catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
4552
4573
  }
4553
4574
 
4554
4575
  async function handleHealth(req, res) {
package/engine/ado.js CHANGED
@@ -449,7 +449,8 @@ async function pollPrStatus(config) {
449
449
  const mergeRef = encodeURIComponent(`refs/pull/${prNumber}/merge`);
450
450
  const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${project.repositoryId}&repositoryType=TfsGit&$top=25&api-version=7.1`;
451
451
  const buildsData = await adoFetch(buildsUrl, token);
452
- const prBuilds = (buildsData?.value || []).filter(b => b.sourceVersion === mergeCommitId);
452
+ const allBuilds = buildsData?.value || [];
453
+ const prBuilds = allBuilds.filter(b => b.sourceVersion === mergeCommitId);
453
454
 
454
455
  if (prBuilds.length > 0) {
455
456
  buildStatus = classifyBuildStatus(prBuilds);
@@ -462,6 +463,17 @@ async function pollPrStatus(config) {
462
463
  _buildId: String(b.id),
463
464
  }));
464
465
  }
466
+ } else if (allBuilds.length > 0 && pr.buildStatus) {
467
+ // Stale merge-commit fallback — ADO returned builds for this PR's merge ref
468
+ // but none target the current `mergeCommitId`. Most likely the target branch
469
+ // moved, ADO recomputed the merge commit, but no new source-side changes
470
+ // triggered a rebuild. Preserve the previous `pr.buildStatus` so the tracker
471
+ // reflects the last known truth instead of flipping to a spurious 'none'.
472
+ // Also log a warn so stale states are detectable in engine logs. Issue #1233.
473
+ const sampleSv = (allBuilds[0]?.sourceVersion || '').slice(0, 8);
474
+ log('warn', `PR ${pr.id} build: merge-commit mismatch — ${allBuilds.length} build(s) on merge ref, none target current merge commit ${String(mergeCommitId).slice(0, 8)} (sample sourceVersion ${sampleSv}); preserving previous buildStatus '${pr.buildStatus}'`);
475
+ buildStatus = pr.buildStatus;
476
+ if (pr.buildFailReason) buildFailReason = pr.buildFailReason;
465
477
  }
466
478
  } catch (e) { log('warn', `ADO build query for ${pr.id}: ${e.message}`); }
467
479
  }
@@ -24,10 +24,29 @@
24
24
  const fs = require('fs');
25
25
  const path = require('path');
26
26
  const shared = require('./shared');
27
- const { safeJson, safeWrite, mutateJsonFileLocked, ts, WI_STATUS } = shared;
27
+ const { safeJson, safeWrite, mutateJsonFileLocked, ts, dateStamp, WI_STATUS } = shared;
28
28
 
29
29
  const SCHEDULE_RUNS_PATH = path.join(__dirname, 'schedule-runs.json');
30
30
 
31
+ /**
32
+ * Substitute schedule-time template variables in a string.
33
+ * Currently supports:
34
+ * {{date}} — today's date as YYYY-MM-DD (UTC, via dateStamp())
35
+ *
36
+ * Downstream playbook rendering (engine/playbook.js) is a single-pass replace,
37
+ * so any {{date}} embedded in a schedule's title/description would survive
38
+ * substitution of {{task_description}} and surface as an "unresolved template
39
+ * variables: date" warning plus a literal "{{date}}" in agent filenames.
40
+ * Resolve these fields at schedule time so the work item carries a concrete
41
+ * date string from the moment it's created.
42
+ *
43
+ * Safe on undefined/null/empty/non-string inputs — returns the input unchanged.
44
+ */
45
+ function resolveScheduleTemplateVars(str) {
46
+ if (typeof str !== 'string' || str.length === 0) return str;
47
+ return str.replace(/\{\{date\}\}/g, dateStamp());
48
+ }
49
+
31
50
  // Parse a single cron field into a matcher function.
32
51
  // field: e.g., "*", "5", "1,3,5", "*/15"
33
52
  // min/max: valid range (0-59 for minute, 0-23 for hour, 0-6 for dow)
@@ -141,13 +160,16 @@ function discoverScheduledWork(config) {
141
160
  const lastRun = typeof runEntry === 'string' ? runEntry : (runEntry?.lastRun || null);
142
161
  if (!shouldRunNow(sched, lastRun)) continue;
143
162
 
163
+ // Substitute schedule-time template vars (e.g. {{date}}) before the work
164
+ // item is written — single-pass playbook rendering can't reach placeholders
165
+ // embedded inside task_description, so they must be resolved up front.
144
166
  const workItemId = `sched-${sched.id}-${Date.now()}`;
145
167
  work.push({
146
168
  id: workItemId,
147
- title: sched.title,
169
+ title: resolveScheduleTemplateVars(sched.title),
148
170
  type: sched.type || 'implement',
149
171
  priority: sched.priority || 'medium',
150
- description: sched.description || sched.title,
172
+ description: resolveScheduleTemplateVars(sched.description || sched.title),
151
173
  status: WI_STATUS.PENDING,
152
174
  created: ts(),
153
175
  createdBy: 'scheduler',
@@ -170,4 +192,4 @@ function discoverScheduledWork(config) {
170
192
  return work;
171
193
  }
172
194
 
173
- module.exports = { parseCronExpr, parseCronField, shouldRunNow, discoverScheduledWork, SCHEDULE_RUNS_PATH };
195
+ module.exports = { parseCronExpr, parseCronField, shouldRunNow, discoverScheduledWork, resolveScheduleTemplateVars, SCHEDULE_RUNS_PATH };
package/engine/shared.js CHANGED
@@ -1058,6 +1058,61 @@ function sanitizePath(file, baseDir) {
1058
1058
  return resolved;
1059
1059
  }
1060
1060
 
1061
+ // ── Prototype Pollution Guard ────────────────────────────────────────────────
1062
+
1063
+ const _DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
1064
+
1065
+ /**
1066
+ * Detect the presence of prototype-pollution attack keys in a JSON-decoded payload.
1067
+ *
1068
+ * Belt-and-braces defence for endpoints that call `JSON.parse` on untrusted
1069
+ * request bodies and then feed the result into `Object.assign`, spread, or
1070
+ * deep-merge utilities. `JSON.parse` itself is safe — it installs `__proto__`
1071
+ * as a regular own data property and does not mutate the prototype chain —
1072
+ * but downstream code that shallow-merges the payload into a target object
1073
+ * CAN elevate it into a prototype write.
1074
+ *
1075
+ * Contract is **rejection, not sanitization**: we inspect the top level plus
1076
+ * one level deep and return a boolean. Deeper walks are intentionally skipped
1077
+ * to avoid their own DoS pathologies on adversarial inputs.
1078
+ *
1079
+ * - Null / undefined / primitives → false.
1080
+ * - Arrays are transparent: each element is checked at the same depth as the
1081
+ * array itself (an array does NOT consume a depth level).
1082
+ * - Max object nesting inspected: 1. Dangerous keys at object-depth 2+
1083
+ * are intentionally NOT flagged.
1084
+ * - Never mutates the input.
1085
+ *
1086
+ * @param {*} obj - any JSON-decoded value
1087
+ * @param {number} [_depth=0] - internal recursion counter; do not pass externally
1088
+ * @returns {boolean} true if any forbidden key is present at object-depth ≤ 1
1089
+ */
1090
+ function hasDangerousKey(obj, _depth = 0) {
1091
+ if (obj === null || obj === undefined || typeof obj !== 'object') return false;
1092
+
1093
+ // Arrays are transparent — preserve depth when recursing into elements.
1094
+ if (Array.isArray(obj)) {
1095
+ for (const elt of obj) {
1096
+ if (hasDangerousKey(elt, _depth)) return true;
1097
+ }
1098
+ return false;
1099
+ }
1100
+
1101
+ // Object: check own keys at the current depth.
1102
+ for (const key of Object.keys(obj)) {
1103
+ if (_DANGEROUS_KEYS.has(key)) return true;
1104
+ }
1105
+
1106
+ // Stop after one level of object nesting. Deeper recursion is an explicit
1107
+ // non-goal (see DoS note in the header).
1108
+ if (_depth >= 1) return false;
1109
+
1110
+ for (const v of Object.values(obj)) {
1111
+ if (hasDangerousKey(v, _depth + 1)) return true;
1112
+ }
1113
+ return false;
1114
+ }
1115
+
1061
1116
  /**
1062
1117
  * Validate that a PID value is a positive integer. Returns the numeric PID.
1063
1118
  * Throws if the value could be used for command injection.
@@ -1754,6 +1809,7 @@ module.exports = {
1754
1809
  getAdoOrgBase,
1755
1810
  sanitizePath,
1756
1811
  sanitizeBranch,
1812
+ hasDangerousKey,
1757
1813
  validateProjectName,
1758
1814
  validateProjectPath,
1759
1815
  validatePid,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1155",
3
+ "version": "0.1.1157",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"