codehere 0.7.1 → 1.0.1

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.
Files changed (139) hide show
  1. package/README.md +22 -13
  2. package/dist/application/services/verdict-service.js +154 -0
  3. package/dist/application/use-cases/assess-cross-vendor.js +126 -0
  4. package/dist/application/use-cases/build-cross-vendor-verdict.js +118 -0
  5. package/dist/application/use-cases/export-aat.js +107 -0
  6. package/dist/application/use-cases/generate-acceptance-criteria.js +223 -0
  7. package/dist/application/use-cases/generate-final-report.js +140 -15
  8. package/dist/application/use-cases/generate-narration.js +5 -3
  9. package/dist/application/use-cases/generate-thread-title.js +5 -2
  10. package/dist/application/use-cases/narrator-prompt-template.js +1 -1
  11. package/dist/application/use-cases/plan-auto-review.js +57 -0
  12. package/dist/application/use-cases/reconcile-audit.js +100 -0
  13. package/dist/application/use-cases/rerun-tests.js +226 -0
  14. package/dist/application/use-cases/search-sessions.js +70 -0
  15. package/dist/application/use-cases/should-auto-review.js +71 -0
  16. package/dist/application/use-cases/thread-export.js +90 -0
  17. package/dist/application/use-cases/verify-claim.js +187 -5
  18. package/dist/domain/audit.js +11 -0
  19. package/dist/domain/services/codebase-detector.js +0 -6
  20. package/dist/domain/task.js +91 -0
  21. package/dist/error-handler.js +3 -115
  22. package/dist/index.js +279 -31
  23. package/dist/infrastructure/completion/bash-completion.js +7 -3
  24. package/dist/infrastructure/completion/fish-completion.js +7 -12
  25. package/dist/infrastructure/completion/zsh-completion.js +7 -12
  26. package/dist/infrastructure/config/config-manager.js +4 -3
  27. package/dist/infrastructure/config/product-context.js +1 -19
  28. package/dist/infrastructure/context/adapters/figma-source.js +37 -2
  29. package/dist/infrastructure/context/adapters/github-issue-source.js +3 -2
  30. package/dist/infrastructure/context/github-api.js +110 -0
  31. package/dist/infrastructure/context/github-device-flow.js +65 -0
  32. package/dist/infrastructure/context/oauth-store.js +5 -2
  33. package/dist/infrastructure/context/registry-bootstrap.js +0 -4
  34. package/dist/infrastructure/cost/budget-config.js +4 -3
  35. package/dist/infrastructure/cost/cost-router.js +16 -23
  36. package/dist/infrastructure/cost/spend-aggregator.js +91 -16
  37. package/dist/infrastructure/cost/team-ledger.js +355 -0
  38. package/dist/infrastructure/discovery/discovery-log.js +85 -0
  39. package/dist/infrastructure/hooks/audit-tip-signature.js +336 -0
  40. package/dist/infrastructure/hooks/claude-sdk-hook.js +139 -3
  41. package/dist/infrastructure/hooks/hook-mode.js +20 -4
  42. package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +8 -2
  43. package/dist/infrastructure/hooks/pre-prompt-scan.js +3 -3
  44. package/dist/infrastructure/observability/execution-tracer.js +1 -1
  45. package/dist/infrastructure/orchestration/agents-config-seed.js +23 -11
  46. package/dist/infrastructure/orchestration/aider-adapter.js +188 -24
  47. package/dist/infrastructure/orchestration/artifacts-extractor.js +60 -2
  48. package/dist/infrastructure/orchestration/cassette-recorder.js +1 -1
  49. package/dist/infrastructure/orchestration/claude-code-adapter.js +117 -3
  50. package/dist/infrastructure/orchestration/codex-adapter.js +52 -16
  51. package/dist/infrastructure/orchestration/conductor-adapter.js +46 -7
  52. package/dist/infrastructure/orchestration/context-handoff.js +32 -14
  53. package/dist/infrastructure/orchestration/detect-ollama.js +75 -0
  54. package/dist/infrastructure/orchestration/failover-config.js +1 -11
  55. package/dist/infrastructure/orchestration/gemini-adapter.js +92 -12
  56. package/dist/infrastructure/orchestration/generic-cli-adapter.js +134 -3
  57. package/dist/infrastructure/orchestration/graphify-context.js +14 -59
  58. package/dist/infrastructure/orchestration/memory-injector.js +4 -1
  59. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +167 -9
  60. package/dist/infrastructure/orchestration/opencode-adapter.js +84 -14
  61. package/dist/infrastructure/orchestration/progress-aggregator.js +275 -23
  62. package/dist/infrastructure/orchestration/session-manager.js +593 -94
  63. package/dist/infrastructure/orchestration/session-persistence.js +177 -41
  64. package/dist/infrastructure/orchestration/thread-key-store.js +1 -1
  65. package/dist/infrastructure/orchestration/write-pass-store.js +0 -8
  66. package/dist/infrastructure/protocols/cloud-sync.js +2 -2
  67. package/dist/infrastructure/protocols/mcp-helpers.js +1 -1
  68. package/dist/infrastructure/protocols/mcp-server-impl.js +1498 -354
  69. package/dist/infrastructure/protocols/mcp-tools.js +401 -0
  70. package/dist/infrastructure/protocols/ui/app-script-artifacts.js +121 -0
  71. package/dist/infrastructure/protocols/ui/app-script-audit-footer.js +44 -0
  72. package/dist/infrastructure/protocols/ui/app-script-audit-verify.js +56 -0
  73. package/dist/infrastructure/protocols/ui/app-script-composer-cost.js +588 -0
  74. package/dist/infrastructure/protocols/ui/app-script-context-sources.js +168 -0
  75. package/dist/infrastructure/protocols/ui/app-script-conversation.js +67 -0
  76. package/dist/infrastructure/protocols/ui/app-script-drawer.js +115 -0
  77. package/dist/infrastructure/protocols/ui/app-script-failover.js +133 -0
  78. package/dist/infrastructure/protocols/ui/app-script-github.js +113 -0
  79. package/dist/infrastructure/protocols/ui/app-script-observer.js +104 -0
  80. package/dist/infrastructure/protocols/ui/app-script-onboarding.js +80 -0
  81. package/dist/infrastructure/protocols/ui/app-script-overlay.js +203 -0
  82. package/dist/infrastructure/protocols/ui/app-script-recovery.js +136 -0
  83. package/dist/infrastructure/protocols/ui/app-script-render.js +166 -0
  84. package/dist/infrastructure/protocols/ui/app-script-share.js +78 -0
  85. package/dist/infrastructure/protocols/ui/app-script-sidebar.js +194 -0
  86. package/dist/infrastructure/protocols/ui/app-script-tour.js +78 -0
  87. package/dist/infrastructure/protocols/ui/app-script-wpass.js +312 -0
  88. package/dist/infrastructure/protocols/ui/app-script.js +2998 -0
  89. package/dist/infrastructure/protocols/ui/font-faces.js +7 -0
  90. package/dist/infrastructure/protocols/ui/styles.js +2147 -0
  91. package/dist/infrastructure/protocols/ui/tokens.js +96 -0
  92. package/dist/infrastructure/protocols/ui-page-v2.js +116 -4126
  93. package/dist/infrastructure/protocols/verify-page.js +884 -148
  94. package/dist/infrastructure/security/fix-code.js +144 -0
  95. package/dist/infrastructure/security/fix-secret.js +89 -0
  96. package/dist/infrastructure/security/permissions-config.js +3 -2
  97. package/dist/infrastructure/security/scan-types.js +10 -0
  98. package/dist/infrastructure/security/ship-scan.js +195 -0
  99. package/dist/infrastructure/security/trust-scorer.js +70 -3
  100. package/dist/infrastructure/security/vibe-checks.js +99 -0
  101. package/dist/infrastructure/storage/atomic-write.js +86 -0
  102. package/dist/infrastructure/storage/project-thread-mirror.js +151 -4
  103. package/dist/infrastructure/storage/project-thread-reader.js +262 -25
  104. package/dist/infrastructure/storage/project-thread-title-backfill.js +1 -1
  105. package/dist/monitoring.js +2 -2
  106. package/dist/presentation/cli/commands/audit-command.js +79 -4
  107. package/dist/presentation/cli/commands/budget-command.js +2 -2
  108. package/dist/presentation/cli/commands/check-command.js +96 -0
  109. package/dist/presentation/cli/commands/context-source-command.js +20 -1
  110. package/dist/presentation/cli/commands/help-command.js +5 -5
  111. package/dist/presentation/cli/commands/hook-command.js +5 -5
  112. package/dist/presentation/cli/commands/init-command.js +89 -66
  113. package/dist/presentation/cli/commands/memory-command.js +28 -28
  114. package/dist/presentation/cli/commands/permission-command.js +1 -12
  115. package/dist/presentation/cli/commands/protocols-builder.js +2 -2
  116. package/dist/presentation/cli/commands/protocols-command.js +4 -4
  117. package/dist/presentation/cli/commands/repo-map-builder.js +1 -1
  118. package/dist/presentation/cli/commands/report-command.js +48 -32
  119. package/dist/presentation/cli/commands/scan-command.js +52 -0
  120. package/dist/presentation/cli/commands/scan-tool-command.js +24 -8
  121. package/dist/presentation/cli/commands/search-command.js +51 -0
  122. package/dist/presentation/cli/commands/setup-command.js +31 -8
  123. package/dist/presentation/cli/commands/status-command.js +39 -86
  124. package/dist/presentation/cli/commands/team-command.js +256 -0
  125. package/dist/presentation/cli/commands/thread-command.js +87 -5
  126. package/dist/presentation/cli/commands/trust-command.js +53 -31
  127. package/dist/presentation/cli/commands/verify-command.js +74 -9
  128. package/dist/presentation/cli/error-display.js +0 -8
  129. package/dist/presentation/cli/repl.js +15 -7
  130. package/dist/presentation/cli/task-deliverable.js +36 -10
  131. package/dist/testing/replay-adapter.js +0 -19
  132. package/dist/ui.js +51 -239
  133. package/dist/utils/version.js +0 -6
  134. package/package.json +30 -10
  135. package/dist/domain/entities/task.js +0 -61
  136. package/dist/infrastructure/protocols/ui-page.js +0 -2377
  137. package/dist/infrastructure/security/input-validator.js +0 -152
  138. package/dist/infrastructure/storage/embeddings-path.js +0 -37
  139. package/dist/infrastructure/storage/task-repository.js +0 -206
@@ -0,0 +1,2998 @@
1
+ import { PHASE_CLASSIFIER_SRC } from '../thread-phase.js';
2
+ import { RENDER_HELPERS } from './app-script-render.js';
3
+ import { CONVERSATION_TURN } from './app-script-conversation.js';
4
+ import { SIDEBAR } from './app-script-sidebar.js';
5
+ import { WPASS_MODAL } from './app-script-wpass.js';
6
+ import { OVERLAY_RENDERER } from './app-script-overlay.js';
7
+ import { CONTEXT_SOURCES } from './app-script-context-sources.js';
8
+ import { RECOVERY_ACTIONS } from './app-script-recovery.js';
9
+ import { SETTINGS_DRAWER } from './app-script-drawer.js';
10
+ import { AUDIT_CHAIN_VERIFY } from './app-script-audit-verify.js';
11
+ import { COMPOSER_COST } from './app-script-composer-cost.js';
12
+ import { SHARE_MODAL } from './app-script-share.js';
13
+ import { AUDIT_FOOTER } from './app-script-audit-footer.js';
14
+ import { FAILOVER_BANNER } from './app-script-failover.js';
15
+ import { GITHUB_INTEGRATION } from './app-script-github.js';
16
+ import { ONBOARDING } from './app-script-onboarding.js';
17
+ import { TOUR } from './app-script-tour.js';
18
+ import { OBSERVER_RAIL } from './app-script-observer.js';
19
+ import { ARTIFACTS } from './app-script-artifacts.js';
20
+ export const APP_SCRIPT = `
21
+ const _cached = {};
22
+ // Expose the live cache so /verify coverage can drive the REAL state-dependent
23
+ // updaters (upBud/upIdx/upWk) over a known state and observe the actual banner
24
+ // render — a const is not auto-attached to window, so the harness can't reach it
25
+ // otherwise. Read/written only by the coverage drives, which restore after.
26
+ try{ window._cached = _cached; }catch(e){}
27
+ let _filter = 'all', _detail = null, _room = null, _offline = false, _autoOpened = false;
28
+ // Client-side memo of answered agent-questions (sessionId|toolUseId -> labels)
29
+ // so the question card stays "answered" across the post-answer re-render (the
30
+ // step itself isn't marked server-side in v1).
31
+ var _answeredAsks = {};
32
+ var _convOpenByTask = {};
33
+ // The artifacts drawer is rendered INLINE in the detail HTML, so a poll-driven
34
+ // render() rebuilds it closed — clicking "N artifacts" opened it for under one
35
+ // poll interval, then it snapped shut (Ega: "i cant click", 2026-07-02). Persist
36
+ // the open state across re-render, same pattern as _convOpenByTask; reset when a
37
+ // different task detail opens (showD).
38
+ var _artOpen = false;
39
+ // Same render-wipe class for individual expanded step rows: expand a step to
40
+ // read its output, the poll re-renders and collapses it (3rd instance found by
41
+ // the systemic sweep). Keyed by task + step signature (startedAt+label), stable
42
+ // across renders. Toggling updates it via __rememberStepOpen.
43
+ var _stepOpen = {};
44
+ // DESIGN.md §4.3 room→receipts: the step wall (activityBlock) collapses into a
45
+ // one-line receipt inside the open conversation — evidence one click away, never
46
+ // always-on. Open state persisted per session (render-wipe-safe), reset on task
47
+ // switch. Toggling updates it via __rememberReceiptOpen.
48
+ var _receiptOpen = {};
49
+ var _glMoreOpen = {}; // glance turn's full-report <details> · same render-wipe guard as _receiptOpen
50
+ // Cross-vendor verdict cache, per session id. undefined = not fetched (renderD
51
+ // kicks the fetch); null = fetched, not a cross-vendor review; object = the
52
+ // receipt. Cached so the panel lives in renderD's output and survives
53
+ // writeDetail's morph-diff (a DOM-injected node would be culled).
54
+ var _crossVendorByTask = {};
55
+ // Project-memory state for the open room · null = unknown (fetch in flight),
56
+ // false = agents forget this project (show the nudge), true = memory already
57
+ // on (stay quiet), 'just-on' = turned on from the nudge this visit (confirm).
58
+ let _roomMemory = null;
59
+ // The FULL project thread (all clones, all-time) fetched from /api/projects/:slug when
60
+ // a room opens — so the conversation shows every turn, not just the recent /api/tasks subset.
61
+ let _roomThread = null;
62
+ // S19 glyph language: never emoji-as-icon — blocked/queued/paused carry their
63
+ // word on the surface; the glyph only accents it (see the token-block contract).
64
+ const ICONS = { queued:'\\u25CB', running:'\\u25CF', paused:'\\u00B7', blocked:'\\u2298', done:'\\u2713', failed:'\\u2715', crashed:'!', killed:'\\u2298' };
65
+ const STAT_ORDER = ['running','queued','paused','blocked','done','failed','crashed','killed'];
66
+ const FILTERS = { all: s=>true, running: s=>['running','queued','paused','blocked'].includes(s.status), done: s=>['done','completed'].includes(s.status), failed: s=>['failed','crashed','killed','error'].includes(s.status) };
67
+
68
+ function ms(t) { return t?'\\u2713':'\\u2715'; }
69
+ function dur(ms) { if(!ms&&ms!==0)return''; const s=Math.floor(ms/1000); if(s<60)return s+'s'; return Math.floor(s/60)+'m '+(s%60)+'s'; }
70
+ // S4 · a finished-or-dead task with no recorded end must not wear a live
71
+ // wall-clock (a crashed orphan with durationMs=0 AND completedAt=0 read
72
+ // "23,860m" — now−started). Live states still tick; unknown says so.
73
+ function taskDur(t){
74
+ if(t.durationMs) return dur(t.durationMs);
75
+ if(t.completedAt && t.startedAt) return dur(t.completedAt - t.startedAt);
76
+ if(['running','queued','paused','blocked'].includes(t.status) && t.startedAt) return dur(Date.now()-t.startedAt);
77
+ return '\u2014';
78
+ }
79
+ // Strip internal context-injection prefixes from a prompt before showing it to
80
+ // the user. Real spawns prepend "[graphify context: ...]\\n\\n" (and failover
81
+ // adds "[Previous session context — ...]") — functional for the agent, noise
82
+ // for the human. Found via real-execution dogfood #45: the prefix was becoming
83
+ // the thread title. Display-only; the actual spawn prompt keeps the prefix.
84
+ function displayPrompt(p) {
85
+ return String(p == null ? '' : p)
86
+ .replace(/^\\[graphify context:[\\s\\S]*?\\]\\s*\\n+/, '')
87
+ .replace(/^\\[Previous session context[\\s\\S]*?\\]\\s*\\n+/, '')
88
+ .replace(/^\\s+/, '');
89
+ }
90
+ function title(p) {
91
+ // Build a headline from whole CLAUSES (split on · — and sentence-enders),
92
+ // accumulating up to ~72 chars — so we never chop mid-phrase like the old
93
+ // "...200 rps · Lua INCR in". Trailing punctuation stripped per clause.
94
+ var s=(displayPrompt(p)||'Untitled').split('\\n')[0].trim();
95
+ var parts=s.split(/\\s*[\\u00B7\\u2014]\\s*|(?<=[.!?])\\s+/)
96
+ .map(function(x){return x.replace(/[.!?,;:\\s]+$/,'').trim();}).filter(Boolean);
97
+ var out='';
98
+ for(var i=0;i<parts.length;i++){
99
+ var nx=out?out+' \\u00B7 '+parts[i]:parts[i];
100
+ if(out && nx.length>72) break;
101
+ out=nx; if(out.length>=72) break;
102
+ }
103
+ out=(out||s).trim();
104
+ if(out.length>80) out=out.slice(0,80).replace(/\\s+\\S*$/,'')+'\\u2026';
105
+ return out||'Untitled';
106
+ }
107
+ // HTML-escaped title — use at any site that inserts a title into innerHTML, so a
108
+ // prompt like "<img onerror=...>" can never execute (module-level so it's in
109
+ // scope everywhere; the function-local escapers don't reach every render site).
110
+ function titleH(p){ return String(title(p)).replace(/[&<>"]/g,function(ch){return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[ch];}); }
111
+
112
+ // Per-agent identity badge — primitive #1 from AGENTIC_UX_FRAMEWORK.
113
+ // Returns a squircle with the agent's initials in its identity color.
114
+ // Recognized: claude-code, codex, aider, gemini, opencode, conductor, auto.
115
+ function agentKey(id) {
116
+ const s = String(id||'').toLowerCase();
117
+ if (s === 'claude-code' || s.startsWith('claude')) return 'claude';
118
+ if (s === 'codex' || s.startsWith('codex')) return 'codex';
119
+ if (s === 'aider' || s.startsWith('aider')) return 'aider';
120
+ if (s === 'gemini' || s.startsWith('gemini')) return 'gemini';
121
+ if (s === 'opencode' || s.startsWith('opencode')) return 'opencode';
122
+ if (s === 'cursor' || s.startsWith('cursor')) return 'cursor';
123
+ if (s === 'conductor') return 'conductor';
124
+ if (s === 'auto') return 'auto';
125
+ return 'auto';
126
+ }
127
+ function agentInitials(id) {
128
+ const k = agentKey(id);
129
+ if (k === 'claude') return 'CC';
130
+ if (k === 'codex') return 'CD';
131
+ if (k === 'aider') return 'AI';
132
+ if (k === 'gemini') return 'GM';
133
+ if (k === 'opencode') return 'OC';
134
+ if (k === 'cursor') return 'CU';
135
+ if (k === 'conductor') return 'CH';
136
+ return '?';
137
+ }
138
+ function ab(id, size) {
139
+ const k = agentKey(id);
140
+ const sz = size === 'sm' ? ' sm' : (size === 'lg' ? ' lg' : '');
141
+ return '<span class="ab ' + k + sz + '">' + agentInitials(id) + '</span>';
142
+ }
143
+ // Phase 3 · who's-involved. Overlapping avatars for the agents (and, when
144
+ // collaboration lands, people) involved in a room/task. Dedup by agent key,
145
+ // show up to 4, "+N" overflow, names in the tooltip. String-only (no regex:
146
+ // this runs inside a template literal where backslashes get mangled).
147
+ function partStack(agents) {
148
+ var list = (agents || []).filter(Boolean);
149
+ if (!list.length) return '';
150
+ var seen = {}, uniq = [];
151
+ for (var i = 0; i < list.length; i++) {
152
+ if (agentInitials(list[i]) === '?') continue; // skip unrecognized agents — no broken "?" chip
153
+ var k = agentKey(list[i]); if (!seen[k]) { seen[k] = 1; uniq.push(list[i]); }
154
+ }
155
+ if (!uniq.length) return '';
156
+ var show = uniq.slice(0, 4), more = uniq.length - show.length;
157
+ var names = show.map(function (a) { return agentLabel(a); }).join(', ') + (more > 0 ? ' +' + more + ' more' : '');
158
+ var h = '<span class="pstack" role="img" aria-label="involved: ' + names + '" title="' + names + '">';
159
+ // Distinct 2-char vendor codes (CC/CD/CH…) — single letters collapsed Claude,
160
+ // Codex and Conductor all to "C". Vendor-colored + lightly spaced (not crowded).
161
+ for (var j = 0; j < show.length; j++) { var _k = agentKey(show[j]); h += '<span class="ab stk ' + _k + '" title="' + agentLabel(show[j]) + '">' + agentInitials(show[j]) + '</span>'; }
162
+ if (more > 0) h += '<span class="ab stk more">+' + more + '</span>';
163
+ return h + '</span>';
164
+ }
165
+ function agentLabel(id) {
166
+ const k = agentKey(id);
167
+ if (k === 'claude') return 'Claude Code';
168
+ if (k === 'codex') return 'Codex';
169
+ if (k === 'aider') return 'Aider';
170
+ if (k === 'gemini') return 'Gemini';
171
+ if (k === 'opencode') return 'OpenCode';
172
+ if (k === 'cursor') return 'Cursor';
173
+ if (k === 'conductor') return 'Conductor';
174
+ if (k === 'auto') return 'Auto';
175
+ return id || '?';
176
+ }
177
+ // Cost signal pill — primitive #2.
178
+ ${RENDER_HELPERS}
179
+ function allS(d) { const sp=d.spawned||d.sessions||[], pe=d.persisted||[], seen=new Set(sp.map(s=>s.sessionId)); return [...sp,...pe.filter(p=>!seen.has(p.sessionId))]; }
180
+ function cardActions(s) {
181
+ // Only 'kill' is wired (POST /api/task/:id/kill). Pause/Resume/Restart had NO
182
+ // backend endpoint — they rendered as live buttons that silently no-op'd (R11
183
+ // honest-state lie, 2026-06-03). Removed until real process control lands; Kill
184
+ // and Cancel both map to the wired kill, so every rendered control here works.
185
+ const m={
186
+ running:'<button class="card-btn kill" onclick="event.stopPropagation();a(\\'kill\\',\\''+s+'\\')">Kill</button>',
187
+ paused:'<button class="card-btn kill" onclick="event.stopPropagation();a(\\'kill\\',\\''+s+'\\')">Kill</button>',
188
+ queued:'<button class="card-btn kill" onclick="event.stopPropagation();a(\\'kill\\',\\''+s+'\\')">Cancel</button>',
189
+ blocked:'<button class="card-btn kill" onclick="event.stopPropagation();a(\\'kill\\',\\''+s+'\\')">Cancel</button>',
190
+ };
191
+ return m[mapped(s)]||'';
192
+ }
193
+ function statIcon(s) { return '<span class="status-icon '+s+'"></span>'; }
194
+ function verB(v) { return v&&v!=='unverified'?'<span class="verify-badge '+v+'">'+v+'</span>':''; }
195
+ function tagHtml(t) { return t.map(x=>'<span style="font-size:10.5px;color:var(--fg-40);font-family:var(--mono)">'+x+'</span>').join(''); }
196
+
197
+ function st(s) {
198
+ if(s==='completed')return'done'; if(s==='error'||s==='killed')return'failed'; if(s==='paused')return'paused'; return s;
199
+ }
200
+ function aStat(s) { return s==='blocked'?'paused':s==='killed'?'failed':s==='crashed'?'failed':st(s); }
201
+ function mapped(s) { return s==='queued'?'running':s==='blocked'?'paused':aStat(s); }
202
+
203
+ function findS(id) {
204
+ const s=allS(_cached.tasks||{});
205
+ let hit=s.find(x=>x.sessionId===id); if(hit) return hit;
206
+ if(_roomThread&&_roomThread.tasks){ hit=_roomThread.tasks.find(x=>x.sessionId===id); if(hit) return hit; }
207
+ for(var k in _sbThreads){ const t=(_sbThreads[k]||[]).find(x=>x.sessionId===id); if(t) return t; }
208
+ return null;
209
+ }
210
+
211
+ // Crafted agent picker (composer). Replaces the native <select>; the chosen
212
+ // value lives in the hidden #agent-pick (unchanged read contract). Rows show the
213
+ // vendor badge + REAL readiness (agentS over _cached.readiness) — honest, never faked.
214
+ var AGENTS=['claude-code','codex','aider','gemini','opencode'];
215
+ var _AP_SPARK='<svg class="ap-spark" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l1.7 6.3L20 10l-6.3 1.7L12 18l-1.7-6.3L4 10l6.3-1.7z"/></svg>';
216
+ var _apInit=false;
217
+ function agentReadyState(id){ var ags=(_cached.readiness&&_cached.readiness.agents)||[]; var a=ags.find(function(x){return x&&(x.id===id||x.agent===id);}); return a?agentS(a.state):null; }
218
+ function _apTrigHtml(val){
219
+ if(!val||val==='auto'){ return _AP_SPARK+'<span class="ap-name">Auto</span>'; }
220
+ return '<span class="ab stk '+agentKey(val)+'">'+(String(agentInitials(val)).charAt(0)||'?')+'</span><span class="ap-name">'+String(agentLabel(val)).replace(/[<>&]/g,'')+'</span>';
221
+ }
222
+ function setAgentPick(val){
223
+ var inp=document.getElementById('agent-pick');
224
+ // Set the value AND fire 'change' so the existing updateCostEst listener (which
225
+ // the old <select> drove natively) recomputes the cost estimate on agent change.
226
+ if(inp){ inp.value=val; try{ inp.dispatchEvent(new Event('change',{bubbles:true})); }catch(e){} }
227
+ var cur=document.getElementById('agentpick-cur'); if(cur) cur.innerHTML=_apTrigHtml(val);
228
+ closeAgentMenu();
229
+ }
230
+ function renderAgentMenu(){
231
+ var m=document.getElementById('agentpick-menu'); if(!m) return;
232
+ var cur=(document.getElementById('agent-pick')||{}).value||'auto';
233
+ var html='<div class="ap-item'+(cur==='auto'?' sel':'')+'" role="option" tabindex="0" onclick="setAgentPick(\\'auto\\')" onkeydown="if(event.key===\\'Enter\\'){setAgentPick(\\'auto\\')}">'+_AP_SPARK+'<span class="ap-il"><span class="ap-in">Auto</span><span class="ap-isub">best agent for the task</span></span>'+(cur==='auto'?'<span class="ap-chk">\\u2713</span>':'')+'</div>';
234
+ AGENTS.forEach(function(id){
235
+ var st=agentReadyState(id);
236
+ var stw=st?'<span class="ap-st">'+st+'</span>':'';
237
+ var dot=st?'<span class="ap-dot '+st+'"></span>':'';
238
+ html+='<div class="ap-item'+(cur===id?' sel':'')+'" role="option" tabindex="0" onclick="setAgentPick(\\''+id+'\\')" onkeydown="if(event.key===\\'Enter\\'){setAgentPick(\\''+id+'\\')}"><span class="ab stk '+agentKey(id)+'">'+(String(agentInitials(id)).charAt(0)||'?')+'</span><span class="ap-il"><span class="ap-in">'+agentLabel(id)+'</span></span>'+stw+dot+(cur===id?'<span class="ap-chk">\\u2713</span>':'')+'</div>';
239
+ });
240
+ m.innerHTML=html;
241
+ }
242
+ function toggleAgentMenu(e){ if(e){e.stopPropagation();} var m=document.getElementById('agentpick-menu'),b=document.getElementById('agentpick-btn'); if(!m) return; if(m.hidden){ renderAgentMenu(); m.hidden=false; if(b)b.setAttribute('aria-expanded','true'); } else { closeAgentMenu(); } }
243
+ function closeAgentMenu(){ var m=document.getElementById('agentpick-menu'),b=document.getElementById('agentpick-btn'); if(m&&!m.hidden){m.hidden=true; if(b)b.setAttribute('aria-expanded','false');} }
244
+ function initAgentPick(){
245
+ if(_apInit) return; var cur=document.getElementById('agentpick-cur'); if(!cur) return; _apInit=true;
246
+ setAgentPick((document.getElementById('agent-pick')||{}).value||'auto');
247
+ document.addEventListener('click',function(e){ if(!(e.target&&e.target.closest&&e.target.closest('.agentpick-wrap'))) closeAgentMenu(); });
248
+ document.addEventListener('keydown',function(e){ if(e.key==='Escape') closeAgentMenu(); });
249
+ }
250
+ function render() {
251
+ if(_detail){ const t=findS(_detail); if(t){ const _sy=window.scrollY; renderD(t); if(_sy>0)window.scrollTo(0,_sy); } else{ _detail=null; if(_room)renderRoom(); else renderL(); } }
252
+ else if(_room) renderRoom();
253
+ else renderL();
254
+ upBud(); upWk(); upIdx(); upHero(); renderDecisions();
255
+ renderSidebar(); initAgentPick(); maybeStartOnboarding(); if(typeof initGhImport==='function') initGhImport();
256
+ }
257
+ // Cockpit shell · the persistent Projects sidebar, as a REAL tree.
258
+ // The caret toggles expand/collapse (animated via CSS, state in _sbExpanded,
259
+ // lazy-fetches a project's tasks on first expand); the name opens the project in
260
+ // the main pane. The active project auto-expands. Leaves open the run in the pane.
261
+ ${SIDEBAR}
262
+ // The goal room — one goal opened to its tasks as a chronological thread of
263
+ // agent turns. Renders into #task-list (same surface as the goals list).
264
+ function openRoom(enc){
265
+ _room=decodeURIComponent(enc||''); _detail=null;
266
+ try{ var _rh=_roomHash(); if(_rh && location.hash!==_rh) location.hash=_rh; }catch(e){} // room state → URL (not the reverse)
267
+ // Entering a room closes the Spend page (loop-6 verifier catch: without
268
+ // this the FULL spend hero stayed rendered below the room feed under a
269
+ // room hash — screen/hash drift, the exact thing the spine exists to kill).
270
+ var _sv0=document.getElementById('spend-view'); if(_sv0) _sv0.style.display='none';
271
+ document.getElementById('task-list-view').style.display='';
272
+ // Project-memory status for the nudge — fetched once per entry from the
273
+ // daemon (the SAME readiness the injector acts on, not a guess).
274
+ _roomMemory=null;
275
+ (function(forCwd){
276
+ fetch('/api/memory/status?cwd='+encodeURIComponent(forCwd)).then(x=>x.json()).then(j=>{
277
+ if(_room===forCwd){ _roomMemory=!!j.ready; render(); }
278
+ }).catch(()=>{});
279
+ })(_room);
280
+ // Pull the FULL project thread (all clones, all-time) so the room renders the complete
281
+ // conversation, not the recent /api/tasks subset (e.g. 405 turns, not 15).
282
+ _roomThread=null;
283
+ (function(forCwd){
284
+ var proj=(_cached.projects||[]).find(function(x){return projPaths(x).indexOf(normC(forCwd))>=0;});
285
+ var slug=proj&&proj.slug;
286
+ if(!slug) return;
287
+ fetch('/api/projects/'+encodeURIComponent(slug)).then(x=>x.json()).then(j=>{
288
+ if(_room===forCwd && j && j.thread){ _roomThread={cwd:forCwd, tasks:(j.thread.tasks||[])}; render(); }
289
+ }).catch(function(){});
290
+ })(_room);
291
+ document.getElementById('task-list-view').style.display='';
292
+ document.getElementById('task-detail-view').style.display='none';
293
+ document.body.classList.remove('detail-open');
294
+ // Hand-off affordance: inside a room the composer targets THIS goal (cwd is
295
+ // pinned in submit) and the picker defaults to whoever worked it last —
296
+ // continuing is zero clicks, handing to a different agent is one visible
297
+ // click. Set once on entry (not in renderRoom, which re-runs every poll and
298
+ // would stomp the user's choice).
299
+ const ta=document.getElementById('main-ta');
300
+ if(ta) ta.placeholder='Add the next task in this goal\\u2026 any agent picks up where the last one left off';
301
+ const ap=document.getElementById('agent-pick');
302
+ if(ap){
303
+ const turns=allS(_cached.tasks||{}).filter(x=>!/^demo-/.test(x.sessionId||'')&&!x.mockRun&&normC(x.cwd)===normC(_room)&&x.agent).sort((a,b)=>(a.startedAt||0)-(b.startedAt||0));
304
+ const raw=turns.length?String(turns[turns.length-1].agent):'';
305
+ ap.value=['claude-code','codex','aider','gemini','opencode'].indexOf(raw)>=0?raw:'auto';
306
+ }
307
+ render();
308
+ }
309
+ function closeRoom(){
310
+ _room=null; _roomMemory=null; _roomThread=null;
311
+ try{ if((location.hash||'').indexOf('#/room/')===0) history.replaceState(null,'',location.pathname+location.search); }catch(e){}
312
+ const ta=document.getElementById('main-ta');
313
+ if(ta) ta.placeholder='Describe a task or ask a question...';
314
+ const ap=document.getElementById('agent-pick'); if(ap){ if(typeof setAgentPick==='function') setAgentPick('auto'); else ap.value='auto'; }
315
+ render();
316
+ }
317
+ async function initRoomMemory(btn){
318
+ if(!_room) return;
319
+ if(btn){ btn.disabled=true; btn.textContent='turning on\\u2026'; }
320
+ try{
321
+ const r=await fetch('/api/memory/init',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cwd:_room})});
322
+ if(r.ok){ _roomMemory='just-on'; render(); }
323
+ else { const e=await r.json().catch(()=>({})); al('Could not turn on memory: '+(e.error||e.reason||'unknown')); if(btn){btn.disabled=false;btn.textContent='turn on project memory';} }
324
+ }catch(e){ al('Network error: '+e.message); if(btn){btn.disabled=false;btn.textContent='turn on project memory';} }
325
+ }
326
+ // Group turns into threads (continued conversations) within a room. Union-find
327
+ // over the persisted/live lineage edge (continuedFrom — set on reply turns +
328
+ // in-session hand-offs). A turn with no edge is its own single-turn thread.
329
+ // Returns an array of turn-arrays, each sorted oldest-first, groups ordered by
330
+ // their earliest turn. Sparse today (most threads = 1 turn); richer once
331
+ // cross-agent hand-off (Phase 3) persists more edges.
332
+ function threadGroups(items){
333
+ const byId={}; items.forEach(t=>{ byId[t.sessionId]=t; });
334
+ const parent={};
335
+ const find=x=>{ while(parent[x]!==undefined && parent[x]!==x){ parent[x]=parent[parent[x]]!==undefined?parent[parent[x]]:parent[x]; x=parent[x]; } return x; };
336
+ items.forEach(t=>{ if(parent[t.sessionId]===undefined) parent[t.sessionId]=t.sessionId; });
337
+ items.forEach(t=>{ const f=t.continuedFrom||t.parentSessionId; if(f&&byId[f]){ const ra=find(t.sessionId), rb=find(f); if(ra!==rb) parent[ra]=rb; } });
338
+ // Re-runs of the same task are one thread even without a lineage edge — the
339
+ // same rule the home card uses (title(prompt) equality, see the runs strip).
340
+ // Scoped to the same cwd so cross-project coincidences never merge.
341
+ const byTitle={};
342
+ items.forEach(t=>{
343
+ const k=title(t.prompt);
344
+ if(!k||k==='Untitled') return;
345
+ const key=String(t.cwd||'')+'\\u0000'+k;
346
+ if(byTitle[key]){ const ra=find(t.sessionId), rb=find(byTitle[key]); if(ra!==rb) parent[ra]=rb; }
347
+ else byTitle[key]=t.sessionId;
348
+ });
349
+ const groups={};
350
+ items.forEach(t=>{ const r=find(t.sessionId); (groups[r]=groups[r]||[]).push(t); });
351
+ const arr=Object.keys(groups).map(k=>groups[k].slice().sort((a,b)=>(a.startedAt||0)-(b.startedAt||0)));
352
+ arr.sort((a,b)=>(a[0].startedAt||0)-(b[0].startedAt||0));
353
+ return arr;
354
+ }
355
+
356
+ // Day Log (Direction A, Ega-picked 2026-07-06) — one line per task: rail hue =
357
+ // agent, title, glance fragment, receipt right. Same-title re-runs collapse to
358
+ // one line with a run count + total cost. Conversations/hand-offs (the wedge)
359
+ // keep the full turn treatment.
360
+ function dayLogGlance(t){
361
+ if(t.finalReport && typeof t.finalReport.summary==='string' && t.finalReport.summary.trim()){
362
+ var s=String(t.finalReport.summary).replace(/[#*>_~\\[\\]\\u0060]/g,'').replace(/\\s+/g,' ').trim();
363
+ s=s.replace(/^Aider v[\\d.]+\\s*/i,'');
364
+ var m=s.match(/^[\\s\\S]*?(?:[!?]|\\.(?!\\.))(?:\\s|$)/);
365
+ s=(m?m[0]:s).trim();
366
+ if(s.length>110) s=s.slice(0,110)+'\\u2026';
367
+ return s;
368
+ }
369
+ if(['running','queued','paused','blocked'].indexOf(t.status)>=0 && (t.steps||[]).length){
370
+ return 'working\\u2026 '+compactLabel((t.steps[t.steps.length-1]||{}).label||'');
371
+ }
372
+ var n=(t.steps||[]).length;
373
+ return n? n+' step'+(n===1?'':'s'):'';
374
+ }
375
+ // Day-divider label · the Day Log's calendar seams: 'today' / 'yesterday' /
376
+ // '30 Jun' (+ the year when it isn't this year). Machine fact -> mono (it
377
+ // reuses the .turns-label treatment; the static "Tasks in this project"
378
+ // header died for it — the first divider anchors the list).
379
+ function dayLabel(ts){
380
+ var d=new Date(ts), now=new Date();
381
+ var key=function(x){ return x.getFullYear()+'-'+x.getMonth()+'-'+x.getDate(); };
382
+ if(key(d)===key(now)) return 'today';
383
+ if(key(d)===key(new Date(now.getTime()-86400000))) return 'yesterday';
384
+ return d.getDate()+' '+['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][d.getMonth()]
385
+ +(d.getFullYear()!==now.getFullYear()?' '+d.getFullYear():'');
386
+ }
387
+ function dayLogRow(t, runCount, totalCents){
388
+ var esc=function(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];});};
389
+ var _verified=!t.mockRun && !(t.artifacts&&t.artifacts.noGitTracking) && !!(t.verdict&&(t.verdict.grade?t.verdict.grade.green:(t.verdict.claim&&t.verdict.claim.status==='verified')));
390
+ var live=['running','queued','paused','blocked'].indexOf(t.status)>=0;
391
+ var bad=['failed','error','crashed','killed'].indexOf(t.status)>=0;
392
+ var cents=(typeof totalCents==='number')?totalCents:((t.cost&&typeof t.cost.spent==='number')?t.cost.spent:null);
393
+ var co=(cents!==null)?'$'+(cents/100).toFixed(cents<10?4:2):'';
394
+ var meta=[(runCount>1?'':taskDur(t)),co].filter(Boolean).join(' \\u00b7 ')+(runCount>1&&co?' total':'');
395
+ var g=dayLogGlance(t);
396
+ return '<div class="dlog'+(live?' live':'')+'" tabindex="0" role="button" style="--rail:'+railHue(t.agent,t.agent,false)+'" onclick="showD(\\''+t.sessionId+'\\')" onkeydown="if(event.key===\\'Enter\\')showD(\\''+t.sessionId+'\\')">'
397
+ +'<span class="dlog-t" data-user-content>'+esc(title(t.prompt))+'</span>'
398
+ +(runCount>1?'<span class="dlog-x">\\u00d7'+runCount+'</span>':'')
399
+ +(_verified?'<span class="dlog-v">verified</span>':(bad?'<span class="dlog-b">stopped</span>':(live?'<span class="dlog-l">'+esc(String(t.status))+'</span>':'')))
400
+ +(g?'<span class="dlog-g" data-user-content>'+esc(g)+'</span>':'')
401
+ +(meta?'<span class="dlog-r">'+esc(meta)+'</span>':'')
402
+ +'</div>';
403
+ }
404
+ function renderRoom(){
405
+ const c=document.getElementById('task-list');
406
+ const _or=document.getElementById('observer-rail'); if(_or){ _or.style.display='none'; }
407
+ const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[ch]));
408
+ const cwd=_room;
409
+ const projects=_cached.projects||[];
410
+ // A room is a PROJECT (a repo), which may span several local clones. Find the
411
+ // project that owns this cwd (membership over its localPaths), then scope the
412
+ // room to every clone's turns. Falls back to the single cwd when no project
413
+ // record exists (e.g. mock daemon with no /api/projects).
414
+ const p=projects.find(x=>projPaths(x).indexOf(normC(cwd))>=0)||projects.find(x=>normC(x.cwd)===normC(cwd));
415
+ const paths=p?projPaths(p):[normC(cwd)];
416
+ // S18 · practice runs never sit among the room's real turns either — they
417
+ // collapse behind the same quiet practice row (still one click to detail).
418
+ // Base the room on the FULL project thread when loaded (all turns, all-time), then let
419
+ // live/recent tasks from the cache override by sessionId (fresher status/verdict/cost,
420
+ // plus just-spawned turns not yet mirrored). Falls back to the cache subset (mock daemon
421
+ // / pre-fetch) so the room is never empty.
422
+ let roomAll;
423
+ if(_roomThread && _roomThread.cwd===_room && _roomThread.tasks){
424
+ const byId=new Map();
425
+ for(const t of _roomThread.tasks){ if(!/^demo-/.test(t.sessionId||'')) byId.set(t.sessionId, t); }
426
+ for(const t of allS(_cached.tasks||{})){ if(!/^demo-/.test(t.sessionId||'') && paths.indexOf(normC(t.cwd))>=0){ var _prev=byId.get(t.sessionId); byId.set(t.sessionId, (_prev&&_prev.archived&&!('archived' in t))?Object.assign({},t,{archived:_prev.archived}):t); } }
427
+ roomAll=[...byId.values()];
428
+ } else {
429
+ roomAll=allS(_cached.tasks||{}).filter(x=>!/^demo-/.test(x.sessionId||'') && paths.indexOf(normC(x.cwd))>=0);
430
+ }
431
+ const practice=roomAll.filter(x=>x.mockRun);
432
+ const items=roomAll.filter(x=>!x.mockRun);
433
+ items.sort((a,b)=>(a.startedAt||0)-(b.startedAt||0)); // turns in the order they happened
434
+ // Fold zero-signal runs (terminal, no report, no cost, <2 steps) so abandoned
435
+ // junk doesn't sit among real work. An honest failure (with a report, cost, or
436
+ // steps) is signal and stays visible.
437
+ const _zeroSig=function(t){
438
+ if(t.batchId) return false; // batch fan-outs are real multi-agent work
439
+ if(['running','queued','paused','blocked','completed','done'].indexOf(t.status)>=0) return false;
440
+ const hasReport=!!(t.finalReport&&t.finalReport.summary&&String(t.finalReport.summary).trim());
441
+ const hasCost=!!(t.cost&&typeof t.cost.spent==='number'&&t.cost.spent>0);
442
+ return !hasReport && !hasCost && (t.steps||[]).length<2;
443
+ };
444
+ const archivedItems=items.filter(function(x){return x.archived;});
445
+ const folded=items.filter(function(x){return _zeroSig(x)&&!x.archived;});
446
+ const shown=items.filter(function(x){return !_zeroSig(x)&&!x.archived;});
447
+ window._roomSlug=(p&&p.slug)||''; // the archive handler needs the project slug
448
+ const repoName=p?projName(p):(cwd?(cwd.split('/').filter(Boolean).pop()||'Project'):'Project');
449
+ // Title = the project name (same as the breadcrumb + the home list). Using
450
+ // p.title directly diverged ("Agent" vs "muhammadegaa/codehere"); projName
451
+ // already prefers the remote name, so repoName is the one consistent identity.
452
+ const title=repoName;
453
+ const agents=[...new Set(items.map(x=>x.agent).filter(Boolean))];
454
+ // The "← Projects" back button died for the rail's Today row (nav option B,
455
+ // 2026-07-07) — the crumb keeps the place-label, the rail does the moving.
456
+ let html='<div class="room-head">';
457
+ html+='<div class="room-crumb" style="font-family:var(--mono);font-size:10.5px;color:var(--fg-40);margin:2px 0 4px">Projects / '+esc(repoName)+'</div>';
458
+ // Rename is offered ONLY for local projects (no remote): there projName returns
459
+ // p.title, so a rename shows immediately. Remote projects keep their canonical
460
+ // repo name (renaming the slug would be a lie), so no pencil.
461
+ var _canRename=!!(p&&p.slug&&!p.remote);
462
+ html+='<div class="room-title-row"><span class="room-title" id="room-title" data-user-content>'+esc(title)+'</span>'
463
+ +(_canRename?'<button class="room-rename" title="Rename project" aria-label="Rename project" onclick="renameRoom(\\''+esc(p.slug)+'\\')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg></button>':'')
464
+ +'</div>';
465
+ // Everyone sees the plain-English outcome; the cwd path rides along
466
+ // (S20: the persona CSS-hiding died — lenses recompose via templates).
467
+ const _done=shown.filter(x=>x.status==='completed'||x.status==='done').length;
468
+ const _outcome=shown.length?(_done===shown.length?('all '+shown.length+' done'):(_done+' of '+shown.length+' done')):'no tasks yet';
469
+ // Short trailing path, not the dev-y full /Users/... (full path on hover).
470
+ var _cwParts=String(cwd||'').split('/').filter(Boolean);
471
+ var _cwShort=_cwParts.length>3?('\\u2026/'+_cwParts.slice(-3).join('/')):(cwd||'');
472
+ html+='<div class="room-sub">'+esc(_outcome)+(agents.length?' \\u00b7 '+partStack(agents):'')+'<span class="room-cwd" data-user-content title="'+esc(cwd||'')+'"> \\u00b7 '+esc(_cwShort)+'</span></div></div>';
473
+ // Live-agents strip · the room shows who is working right now, at the top,
474
+ // before the task history (L2 mockup). Real state only (running/queued/paused/blocked).
475
+ const _live=items.filter(x=>['running','queued','paused','blocked'].indexOf(x.status)>=0);
476
+ if(_live.length){
477
+ html+='<div class="live-strip">'+_live.map(function(x){
478
+ return '<span class="live-a"><span class="live-dot"></span>'+esc(agentLabel(x.agent)||'agent')+' <span class="live-task" data-user-content>'+esc(String(x.prompt||'').slice(0,48))+'</span></span>';
479
+ }).join('')+'</div>';
480
+ }
481
+ if(shown.length){
482
+ // It-4 V1-MULTITASK + V1-FANOUT-ALL · batch grouping. Turns spawned by one
483
+ // /api/task/batch call share a batchId. When >=2 turns share one, render a
484
+ // single .project-rollup header (counts DERIVED LIVE every render — never a
485
+ // stored aggregate, which would lie after a crash) + the children as a
486
+ // side-by-side .fanout-all grid. Lone turns render as one-line rows.
487
+ const seenBatch={};
488
+ const renderTurn=(t,full)=>{
489
+ const bid=t.batchId;
490
+ if(bid){
491
+ if(seenBatch[bid]) return ''; // batch already rendered at its first turn
492
+ const sibs=items.filter(x=>x.batchId===bid);
493
+ if(sibs.length>=2){ seenBatch[bid]=1; return batchH(sibs); }
494
+ }
495
+ // Day Log (Direction A, Ega-picked 2026-07-06) — ONE row treatment for
496
+ // the whole feed. Depth lives in the detail view, not the feed. The
497
+ // cleanup pass (same day) removed the coexisting full-turn treatment:
498
+ // groups keep only their border + label as context.
499
+ return dayLogRow(t,1);
500
+ };
501
+ // Threads-as-groups · turns continued from one another (a hand-off or a
502
+ // follow-up) wrap into one conversation. Most threads are a single turn —
503
+ // those render exactly as before, no wrapper. Richer once Phase 3 persists
504
+ // cross-agent hand-off edges.
505
+ const groups=threadGroups(shown);
506
+ // Day dividers · the Day Log's calendar seams. Groups arrive sorted by
507
+ // first-turn startedAt, so one running lastDay marker is enough.
508
+ let _lastDay='';
509
+ for(const g of groups){
510
+ const _gts=g[0].startedAt||0;
511
+ if(_gts){ const _dk=new Date(_gts).toDateString(); if(_dk!==_lastDay){ _lastDay=_dk; html+='<div class="turns-label day-div">'+esc(dayLabel(_gts))+'</div>'; } }
512
+ if(g.length>1){
513
+ // A thread that crosses AGENTS is the wedge — make it pop. A same-agent
514
+ // follow-up stays quiet (the plain border); a cross-agent hand-off gets
515
+ // the accent border + an agent-chain header + a "handed to X" marker
516
+ // between the turns where the baton changes hands.
517
+ const ga=[...new Set(g.map(x=>x.agent).filter(Boolean))];
518
+ const crossAgent=ga.length>1;
519
+ const chain=ga.map(agentLabel);
520
+ const border=crossAgent?'2px solid var(--accent)':'2px solid var(--border)';
521
+ // A group can form two ways: lineage edges (a real conversation) or
522
+ // same-title re-runs (the home runs-strip rule). Label what it IS.
523
+ const ids={}; g.forEach(x=>{ ids[x.sessionId]=1; });
524
+ const hasEdge=g.some(x=>{ const f=x.continuedFrom||x.parentSessionId; return f&&ids[f]; });
525
+ // Same-title re-runs (no lineage, one agent) collapse to ONE Day-Log
526
+ // line: newest run's glance + a run count + the family's total cost.
527
+ // Six identical failed runs used to eat 30 lines; now they eat one.
528
+ if(!crossAgent && !hasEdge){
529
+ let cents=0, any=false;
530
+ for(const x of g){ if(x.cost&&typeof x.cost.spent==='number'){ cents+=x.cost.spent; any=true; } }
531
+ html+=dayLogRow(g[g.length-1], g.length, any?cents:undefined);
532
+ continue;
533
+ }
534
+ // A failover is not a chosen hand-off — the label and the baton mark
535
+ // say which one happened (quiet: same treatment, honest words + reason).
536
+ const anyFailover=g.some(x=>x.failoverFrom);
537
+ html+='<div class="thread-group'+(crossAgent?' handoff':'')+'" style="border-left:'+border+';padding-left:10px;margin:6px 0">'
538
+ +'<div class="turns-label" style="margin-top:0">'
539
+ +(crossAgent?(anyFailover?'failover':'hand-off')+' \\u00b7 '+esc(chain.join(' \\u203a ')):(hasEdge?'conversation \\u00b7 '+g.length+' turns':g.length+' runs of this task'))
540
+ +'</div>';
541
+ let prevAgent=null;
542
+ for(const t of g){
543
+ if(crossAgent&&prevAgent&&t.agent&&t.agent!==prevAgent){
544
+ const _foR=(t.failoverFrom&&t.failoverReason)?String(t.failoverReason).replace(/_/g,' '):'';
545
+ html+='<div class="handoff-mark" style="color:var(--accent);font-size:12px;margin:2px 0 2px 2px">\\u21b3 '+(t.failoverFrom?esc(agentLabel(t.agent))+' took over'+(_foR?' \\u00b7 '+esc(_foR):''):'handed to '+esc(agentLabel(t.agent)))+'</div>';
546
+ }
547
+ html+=renderTurn(t);
548
+ if(t.agent) prevAgent=t.agent;
549
+ }
550
+ html+='</div>';
551
+ } else {
552
+ html+=renderTurn(g[0]);
553
+ }
554
+ }
555
+ // One quiet door to the proof — for the moment someone actually doubts, not
556
+ // a standing billboard about our own honesty. The audit log is still there;
557
+ // it just doesn't narrate itself in the conversation.
558
+ } else if(!practice.length && !folded.length){
559
+ html+='<div class="empty-state"><h2>No tasks here yet.</h2><p style="color:var(--fg-50);font-size:13px;margin-top:6px">Describe a task below \\u00b7 any agent picks it up, and codehere records what it did and checks it.</p></div>';
560
+ }
561
+ if(folded.length){
562
+ html+='<details class="folded-runs"><summary>'+folded.length+' run'+(folded.length===1?'':'s')+' that changed nothing</summary><div class="reveal-wrap"><div class="reveal-body">'+folded.map(function(t){return dayLogRow(t,1);}).join('')+'</div></div></details>';
563
+ }
564
+ if(archivedItems.length){
565
+ html+='<details class="folded-runs archived-runs"><summary>'+archivedItems.length+' archived</summary><div class="reveal-wrap"><div class="reveal-body">'+archivedItems.map(function(t){return dayLogRow(t,1);}).join('')+'</div></div></details>';
566
+ }
567
+ practice.sort((a,b)=>(a.startedAt||0)-(b.startedAt||0));
568
+ html+=pracGroupHtml(practice);
569
+ // Project-memory nudge (stranger-walk finding 2026-06-06): web-only users
570
+ // never run codehere-init, so agents silently get NO memory of the project.
571
+ // First-run only (Ega 2026-06-17) — shown on a fresh project (<=1 turn), not
572
+ // re-nagged on every visit. The confirmation says "from the next task" because
573
+ // injection happens at spawn — no fake instant effect.
574
+ if(_roomMemory===false && items.length<=1){
575
+ html+='<div class="mem-nudge">agents forget this project between runs \\u00b7 <button class="mem-btn" onclick="initRoomMemory(this)">turn on project memory</button></div>';
576
+ } else if(_roomMemory==='just-on'){
577
+ html+='<div class="mem-nudge on">\\u2713 project memory is on \\u00b7 agents will remember this project from the next task</div>';
578
+ }
579
+ // S12 · same non-destructive morph as renderL (hover/scroll survive polls).
580
+ morphInto(c, html);
581
+ }
582
+ // Inline-rename a (local) project. Swaps the title to an input; Enter or blur
583
+ // saves via the existing POST /api/projects/:slug/rename, Esc restores. Same-origin
584
+ // POST (no token needed). On success, refetch + re-render so the new name shows
585
+ // everywhere (room title + crumb + home), consistently.
586
+ window.renameRoom = function(slug){
587
+ var el=document.getElementById('room-title'); if(!el) return;
588
+ var cur=el.textContent||'';
589
+ var inp=document.createElement('input');
590
+ inp.className='room-title-edit'; inp.value=cur; inp.setAttribute('aria-label','Project name');
591
+ el.replaceWith(inp); inp.focus(); inp.select();
592
+ var settled=false;
593
+ var finish=function(save){
594
+ if(settled) return; settled=true;
595
+ var v=(inp.value||'').trim();
596
+ if(save && v && v!==cur){
597
+ fetch('/api/projects/'+encodeURIComponent(slug)+'/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:v})})
598
+ .then(function(r){return r.json().catch(function(){return {};});})
599
+ .then(function(){ return (typeof fetchAll==='function'?fetchAll():Promise.resolve()); })
600
+ .then(function(){ if(window._room) renderRoom(); })
601
+ .catch(function(){ render(); });
602
+ } else { render(); }
603
+ };
604
+ inp.onkeydown=function(e){ if(e.key==='Enter'){e.preventDefault();finish(true);} else if(e.key==='Escape'){e.preventDefault();finish(false);} };
605
+ inp.onblur=function(){ finish(true); };
606
+ };
607
+ // Archive (soft-delete) or restore a task. POSTs the audit-safe archive event,
608
+ // then refetches the project thread so the archived flag is recomputed, and
609
+ // re-renders. The task is never deleted — it moves to the "archived" reveal.
610
+ window.archiveTask = function(sessionId, archived){
611
+ var slug=window._roomSlug; if(!slug) return;
612
+ fetch('/api/projects/'+encodeURIComponent(slug)+'/archive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sessionId:sessionId, archived:!!archived})})
613
+ .then(function(r){return r.json().catch(function(){return {};});})
614
+ .then(function(){
615
+ return fetch('/api/projects/'+encodeURIComponent(slug)).then(function(x){return x.json();}).then(function(j){
616
+ if(j&&j.thread&&window._room){ _roomThread={cwd:_room, tasks:(j.thread.tasks||[])}; }
617
+ }).catch(function(){});
618
+ })
619
+ .then(function(){ if(window._room) renderRoom(); })
620
+ .catch(function(){});
621
+ };
622
+
623
+
624
+ // C-01 · first-run indexing banner · auto-show when session count < 3 +
625
+ // only once per browser session (sessionStorage tracks the dismissal).
626
+ function upIdx(){
627
+ const banner=document.getElementById('indexing-banner');
628
+ if(!banner)return;
629
+ try{
630
+ if(sessionStorage.getItem('codehere_indexing_dismissed')==='1'){banner.style.display='none'; return;}
631
+ }catch{}
632
+ const all=allS(_cached.tasks||{});
633
+ banner.style.display = all.length<3 ? 'block' : 'none';
634
+ if(all.length>=3){
635
+ try{sessionStorage.setItem('codehere_indexing_dismissed','1');}catch{}
636
+ }
637
+ }
638
+
639
+ // F-02 · cross-worktree alert · scan for any running session whose cwd
640
+ // differs from the most-common cwd AND has been running > 5 minutes. Pulses
641
+ // in the squad row · click to jump to that session's detail.
642
+ function upWk(){
643
+ const all=allS(_cached.tasks||{});
644
+ const running=all.filter(x=>['running','queued','paused','blocked'].includes(x.status) && x.cwd);
645
+ const ae=document.getElementById('worktree-alert');
646
+ const at=document.getElementById('worktree-alert-text');
647
+ if(!ae||!at){return;}
648
+ if(running.length<2){ae.style.display='none'; window._wkTarget=null; return;}
649
+ // Pick the cwd that's most-common · everything else is "other"
650
+ const cwdCounts={};
651
+ for(const r of running) cwdCounts[normC(r.cwd)]=(cwdCounts[normC(r.cwd)]||0)+1;
652
+ let topCwd='', topN=0;
653
+ for(const k in cwdCounts) if(cwdCounts[k]>topN){topN=cwdCounts[k]; topCwd=k;}
654
+ const now=Date.now();
655
+ const oddOnes=running.filter(r=>normC(r.cwd)!==topCwd && (now-r.startedAt) > 5*60*1000);
656
+ if(!oddOnes.length){ae.style.display='none'; window._wkTarget=null; return;}
657
+ const t=oddOnes[0];
658
+ window._wkTarget=t.sessionId;
659
+ // Shorten cwd to its last path component for display
660
+ const short=String(t.cwd||'').split('/').filter(Boolean).slice(-1)[0] || t.cwd;
661
+ // S12 · taskDur honesty rule for the chip: a live minute counter ONLY for a
662
+ // session that is verifiably live (a heartbeat — its latest step or its
663
+ // spawn — within the last 30 minutes). A stuck/stalled record (the demo
664
+ // fixture frozen 16 days on "running") shows its stalled state, never a
665
+ // "running 23362m" lie.
666
+ const _steps=t.steps||[];
667
+ const _lastStep=_steps.length?_steps[_steps.length-1]:null;
668
+ const _beat=Math.max(t.startedAt||0, _lastStep&&(_lastStep.completedAt||_lastStep.startedAt)||0);
669
+ ae.style.display='inline-flex';
670
+ if(t.stalledReason || (now-_beat) > 30*60*1000){
671
+ at.textContent='stalled in '+short;
672
+ } else {
673
+ const mins=Math.floor((now-t.startedAt)/60000);
674
+ at.textContent='running '+mins+'m in '+short;
675
+ }
676
+ }
677
+
678
+ // ── S12 · A-home hero — the story of your day ──────────────────────────────
679
+ // Every number is COMPUTED from _cached.tasks, never hardcoded: today =
680
+ // startedAt within the LOCAL day · agents = distinct agents among today's
681
+ // non-demo tasks (demo/practice sessions never inflate the story). Zero tasks
682
+ // today -> the honest first-run hero, no fake numbers. data-verify-hero-*
683
+ // stamps carry the claim so the H13 probe can re-derive and catch a lie.
684
+ let _heroPrev={agents:0,verified:0,caught:0,stopped:0,running:0,spend:0};
685
+ let _heroSnap=null;
686
+ function heroCounts(){
687
+ // S18 · PAIRED with verify-page H13's expected-derivation (hReal filter):
688
+ // practice runs (mockRun) never feed the day-line — a practice agent never
689
+ // counts as "worked for you today". Change BOTH sides identically (the
690
+ // honest-rule update), never one side alone.
691
+ const all=allS(_cached.tasks||{}).filter(t=>!/^demo-/.test(t.sessionId||'')&&!t.mockRun);
692
+ const sod=new Date(); sod.setHours(0,0,0,0);
693
+ const today=all.filter(t=>(t.startedAt||0)>=sod.getTime());
694
+ const liveSt=['running','queued','paused','blocked'];
695
+ // Refuter C4 · a task still running that started BEFORE midnight is part of
696
+ // "now" — live counts ignore the day boundary; day-scoped counts don't.
697
+ const live=all.filter(t=>liveSt.includes(t.status));
698
+ // Refuter C1 · "verified" derives from the VERDICT (claims match recorded
699
+ // reality), never the dead t.verification field — and never a mock run, a
700
+ // failed run, or an untracked repo (the same gates the green block obeys).
701
+ // 2.2 · "verified" derives from the COMPOSITE GRADE (green only with an
702
+ // INDEPENDENT signal — observed on disk or adversarial cross-vendor), never
703
+ // self-report alone. Fallback to the legacy claim check for any verdict with
704
+ // no grade attached (real tasks always carry one via composeVerdict).
705
+ const isVerified=t=>!!(t.verdict&&(t.verdict.grade?t.verdict.grade.green:(t.verdict.claim&&t.verdict.claim.status==='verified')))
706
+ && !t.mockRun
707
+ && ['completed','done'].includes(t.status)
708
+ && !(t.artifacts&&t.artifacts.noGitTracking);
709
+ return {
710
+ today: today.length,
711
+ agents: [...new Set(today.concat(live).map(t=>String(t.agent||'')).filter(Boolean))].length,
712
+ verified: today.filter(isVerified).length,
713
+ // Refuter C3 · "caught" means the VERIFIER caught a contradiction —
714
+ // user-killed / crashed runs are honest "didn't finish", a separate word.
715
+ caught: today.filter(t=>{var v=t.verdict;return !!(v&&(v.grade?v.grade.headline==='broken':(v.claim&&v.claim.status==='broken')));}).length,
716
+ stopped: today.filter(t=>['failed','error','crashed','killed'].includes(t.status)).length,
717
+ running: live.length,
718
+ };
719
+ }
720
+ // S13 · with tasks today the hero element IS the TODAY day-line — same
721
+ // element, same data-verify-hero-* stamps, same heroCounts derivations
722
+ // (H13 unchanged). The big two-line hero renders ONLY first-run, where it
723
+ // keeps its teach copy. The S12 proof strip merged into the OSS footer
724
+ // (one audit truth line, not two).
725
+ function upHero(){
726
+ const el=document.getElementById('home-hero');
727
+ if(!el) return;
728
+ if(_room){ el.style.display='none'; document.body.classList.remove('first-run'); return; }
729
+ el.style.display='block';
730
+ // A scan/fix lives INSIDE this hero's innerHTML. While one is mid-flight, never rebuild the hero —
731
+ // it would wipe the result (and break the running fix timer's DOM refs) out from under the user.
732
+ if(window._scanBusy) return;
733
+ const c=heroCounts();
734
+ const p=_cached.pricing;
735
+ const spendC=p?(p.spentTodayCents||0):null;
736
+ const capC=p&&p.dailyBudgetCents?p.dailyBudgetCents:null;
737
+ // The claim, stamped for the H13 probe — re-derived independently there.
738
+ el.setAttribute('data-verify-hero-mode', c.today?'counts':'first-run');
739
+ el.setAttribute('data-verify-hero-agents', String(c.agents));
740
+ el.setAttribute('data-verify-hero-verified', String(c.verified));
741
+ el.setAttribute('data-verify-hero-caught', String(c.caught));
742
+ el.setAttribute('data-verify-hero-stopped', String(c.stopped));
743
+ el.setAttribute('data-verify-hero-running', String(c.running));
744
+ // The product home is never a landing page — always the compact day-line, never
745
+ // the big marketing hero (that pitch lives on codehere.uk + the onboarding tour).
746
+ document.body.classList.remove('first-run');
747
+ el.classList.add('day-line-mode');
748
+ const snap=JSON.stringify([c.today,c.agents,c.verified,c.caught,c.stopped,c.running,spendC,capC]);
749
+ if(snap===_heroSnap) return; // unchanged — leave the DOM (and any focus) alone
750
+ _heroSnap=snap;
751
+ if(!c.today){
752
+ // No activity today: a slim, product-y day-line. When there's earlier work
753
+ // (surfaced as "Recent" below), drop the "no tasks" note — it contradicts it.
754
+ const dt=new Date();
755
+ const DAYS=['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
756
+ const MONTHS=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
757
+ var _anyHist=allS(_cached.tasks||{}).some(function(x){return !/^demo-/.test(x.sessionId||'')&&!x.mockRun;});
758
+ el.innerHTML='<div class="day-line"><span class="dl-today">'+DAYS[dt.getDay()]+' \\u00b7 '+MONTHS[dt.getMonth()]+' '+dt.getDate()+'</span>'+(_anyHist?'':'<span>\\u00b7</span><span class="dl-quiet">no tasks yet today</span>')+'</div>';
759
+ _heroPrev={agents:0,verified:0,caught:0,stopped:0,running:0,spend:0};
760
+ return;
761
+ }
762
+ // Day-line · plain language, calm casing. "checked" splits into verified
763
+ // (the claim held) and caught (the claim broke) — a broken claim is never
764
+ // folded into a "verified" count. Every number still comes from heroCounts;
765
+ // the data-verify-hero-* stamps (set in upHero) are untouched.
766
+ el.innerHTML='<div class="day-line"><span class="dl-today">Today</span>'
767
+ +'<span>\\u00b7</span><span><b>'+c.agents+'</b> agent'+(c.agents===1?'':'s')+'</span>'
768
+ +(c.verified>0?'<span>\\u00b7</span><span><b>'+c.verified+'</b> verified</span>':'')
769
+ +(c.caught>0?'<span>\\u00b7</span><span><b>'+c.caught+'</b> caught</span>':'')
770
+ +(c.running>0?'<span>\\u00b7</span><span><b>'+c.running+'</b> running</span>':'')
771
+ +(spendC!=null?'<span>\\u00b7</span><span><b>$'+(spendC/100).toFixed(2)+'</b>'+(capC?' / $'+(capC/100).toFixed(2):'')+'</span>':'')
772
+ +'</div>';
773
+ _heroPrev={agents:c.agents,verified:c.verified,caught:c.caught,stopped:c.stopped,running:c.running,spend:spendC!=null?spendC/100:0};
774
+ }
775
+
776
+ function fmtGap(ms){
777
+ // Two-line label · big "4h 38m later" + small relative-time sub
778
+ // ("≈ 5 hours" · "≈ 2 days"). Demo v12 lines 425-426 ·
779
+ // .time-gap .label b / .time-gap .label .sub. R10 retro 2026-05-24:
780
+ // earlier adjective sub ("between sessions") was banked as a
781
+ // "numbers over adjectives" violation · replaced with a real
782
+ // approximate delta so the gap reads as a measurement, not a feeling.
783
+ let main;
784
+ const m=Math.floor(ms/60000);
785
+ if(m<60) main = m+'m later';
786
+ else {
787
+ const h=Math.floor(m/60); const rm=m%60;
788
+ if(h<24) main = rm ? h+'h '+rm+'m later' : h+'h later';
789
+ else main = Math.floor(h/24)+'d later';
790
+ }
791
+ let sub;
792
+ const hours = ms / 3600000;
793
+ if(hours < 1) {
794
+ const mins = Math.round(ms/60000);
795
+ sub = '\\u2248 ' + mins + ' minute' + (mins===1?'':'s');
796
+ } else if(hours < 24) {
797
+ const h = Math.round(hours);
798
+ sub = '\\u2248 ' + h + ' hour' + (h===1?'':'s');
799
+ } else if(hours < 24*30) {
800
+ const days = Math.round(hours/24);
801
+ sub = '\\u2248 ' + days + ' day' + (days===1?'':'s');
802
+ } else {
803
+ const wks = Math.round(hours/(24*7));
804
+ sub = '\\u2248 ' + wks + ' week' + (wks===1?'':'s');
805
+ }
806
+ return '<b>'+main+'</b><span class="sub">'+sub+'</span>';
807
+ }
808
+
809
+ // One goal = one room regardless of path SPELLING. New spawns canonicalize
810
+ // cwd server-side (realpath at the spawn boundary), but persisted records
811
+ // predate that: on macOS /tmp/x and /private/tmp/x are the same directory
812
+ // (/tmp -> /private/tmp symlink) and rendered as TWO goals for one repo
813
+ // (browser dogfood 2026-06-10). Merge the known-equivalent legacy spellings
814
+ // at every cwd comparison; display keeps the canonical (/private) form.
815
+ function normC(s){ var c=String(s==null?'':s); return c.replace(/^\\/(tmp|var)(\\/|$)/,'/private/$1$2'); }
816
+
817
+ // S12 · non-destructive list render. Build the new HTML into a detached
818
+ // container and morph children by index (the SAME reconcile writeDetail
819
+ // uses), so a poll never wipes hover / focus / scroll — every unchanged card
820
+ // keeps its exact DOM node. This is the renderL/renderRoom counterpart of
821
+ // writeDetail's morph; morphNode is hoisted (function declaration).
822
+ function morphInto(c, html){
823
+ if(!c.firstElementChild && !(c.textContent||'').trim()){ c.innerHTML=html; return; }
824
+ var tmp=document.createElement('div'); tmp.innerHTML=html;
825
+ var nw=Array.prototype.slice.call(tmp.childNodes);
826
+ for(var i=0;i<nw.length;i++){
827
+ var o=c.childNodes[i];
828
+ if(!o){ c.appendChild(nw[i]); continue; }
829
+ morphNode(o, nw[i]);
830
+ }
831
+ while(c.childNodes.length>nw.length){ c.removeChild(c.lastChild); }
832
+ }
833
+
834
+ // S12 · EARLIER chapter progressive disclosure — render the latest ~20, the
835
+ // rest behind an explicit expander (no unbounded DOM, no scroll hijack).
836
+ let _earlierShown = 20;
837
+ function showEarlier(){
838
+ _earlierShown = Number.MAX_SAFE_INTEGER;
839
+ const sc=document.querySelector('.content');
840
+ const y=sc?sc.scrollTop:0;
841
+ renderL();
842
+ if(sc) sc.scrollTop=y; // scroll preserved across expansion
843
+ }
844
+
845
+ // S13 · the history row — EARLIER + demo fixtures collapse behind ONE quiet
846
+ // row; expanding reuses the S12 scroll-preserving mechanics. The harness
847
+ // expands/collapses through this REAL render path (never DOM injection).
848
+ let _histOpen = false;
849
+ function toggleHist(){
850
+ const sc=document.querySelector('.content');
851
+ const y=sc?sc.scrollTop:0;
852
+ _histOpen=!_histOpen;
853
+ if(!_histOpen) _earlierShown=20;
854
+ renderL();
855
+ if(sc) sc.scrollTop=y; // scroll preserved across expansion
856
+ }
857
+ window.__histExpand=function(){ if(!_histOpen){ _histOpen=true; renderL(); } };
858
+ window.__histCollapse=function(){ if(_histOpen){ _histOpen=false; _earlierShown=20; renderL(); } };
859
+
860
+ // S18 · the practice expander — practice (CODEHERE_MOCK) runs collapse behind
861
+ // their own quiet row beside the history row. Same scroll-preserving
862
+ // mechanics as toggleHist; shared by the home and the room render.
863
+ let _pracOpen = false;
864
+ function togglePrac(){
865
+ const sc=document.querySelector('.content');
866
+ const y=sc?sc.scrollTop:0;
867
+ _pracOpen=!_pracOpen;
868
+ if(_room) renderRoom(); else renderL();
869
+ if(sc) sc.scrollTop=y;
870
+ }
871
+ window.__pracExpand=function(){ if(!_pracOpen){ _pracOpen=true; if(_room) renderRoom(); else renderL(); } };
872
+ window.__pracCollapse=function(){ if(_pracOpen){ _pracOpen=false; if(_room) renderRoom(); else renderL(); } };
873
+ // One quiet row + (when open) the one-line practice rows. Used by renderL
874
+ // and renderRoom so practice never sits among real work on either surface.
875
+ function pracGroupHtml(practice){
876
+ if(!practice.length) return '';
877
+ let html='<button class="hist-row prac-row" onclick="togglePrac()" aria-expanded="'+(_pracOpen?'true':'false')+'" data-verify-unit="practice" data-verify-practice-n="'+practice.length+'">practice runs \\u00b7 '+practice.length+(_pracOpen?' \\u00b7 collapse':' \\u00b7 expand \\u203A')+'</button>';
878
+ if(_pracOpen) html+='<div class="practice-rows">'+practice.map(rowH).join('')+'</div>';
879
+ return html;
880
+ }
881
+
882
+ // One goal group (head + its cards, with the >2h time-gap separators kept).
883
+ function goalGroupsHtml(items, titleFor, esc){
884
+ const groups=new Map();
885
+ for(const t of items){
886
+ const key=normC(t.cwd)||'ungrouped';
887
+ if(!groups.has(key)) groups.set(key,{cwd:t.cwd,items:[]});
888
+ groups.get(key).items.push(t);
889
+ }
890
+ const lastTs=g=>g.items.reduce((m,x)=>Math.max(m,x.startedAt||0),0);
891
+ const ordered=[...groups.values()].sort((a,b)=>lastTs(b)-lastTs(a));
892
+ let html='';
893
+ for(const g of ordered){
894
+ g.items.sort((a,b)=>(b.startedAt||0)-(a.startedAt||0));
895
+ const running=g.items.filter(x=>['running','queued','paused','blocked'].includes(x.status)).length;
896
+ const meta=g.items.length+' task'+(g.items.length===1?'':'s')+(running?' · '+running+' running':'');
897
+ html+='<div class="goal-group"><div class="goal-head" tabindex="0" role="button" data-goal-cwd="'+encodeURIComponent(g.cwd||'')+'" onclick="event.stopPropagation();openRoom(\\''+encodeURIComponent(g.cwd||'')+'\\')" onkeydown="if(event.key===\\'Enter\\'||event.key===\\' \\'){event.preventDefault();openRoom(\\''+encodeURIComponent(g.cwd||'')+'\\')}"><span class="goal-title" data-user-content>'+esc(titleFor(g.cwd))+'</span><span class="goal-meta">'+esc(meta)+' \\u00b7 open \\u203A</span></div>';
898
+ // Time-gap separator within a goal · a big gap between consecutive tasks
899
+ // in the same goal is meaningful ("picked this back up 3 days later").
900
+ let prevTs=null;
901
+ for(const runs of groupByTitle(g.items)){
902
+ const t=runs[0];
903
+ const ts=t.startedAt||0;
904
+ if(prevTs!==null && ts>0 && prevTs>0){
905
+ const gap=Math.abs(prevTs-ts);
906
+ if(gap>2*3600*1000){ html+='<div class="time-gap"><span class="line"></span><span class="label">'+fmtGap(gap)+'</span><span class="line"></span></div>'; }
907
+ }
908
+ html+=groupCardH(runs);
909
+ prevTs=ts;
910
+ }
911
+ html+='</div>';
912
+ }
913
+ return html;
914
+ }
915
+
916
+ // Project = a repository (git remote = identity; clones collapse server-side).
917
+ // Display name: org/repo from the remote when present, else the project title
918
+ // or the folder basename.
919
+ function projName(p){
920
+ if(p&&p.remote){ const parts=String(p.remote).split('/').filter(Boolean); return parts.length>=2?parts.slice(1).join('/'):p.remote; }
921
+ if(p&&p.title) return p.title;
922
+ const cwd=(p&&(p.cwd||(p.localPaths&&p.localPaths[0])))||'';
923
+ const seg=String(cwd).split('/').filter(Boolean); return seg.length?seg[seg.length-1]:'Project';
924
+ }
925
+ // Compact relative-age label for a project's last activity.
926
+ function projAgo(ts){
927
+ if(!ts) return '';
928
+ const s=Math.max(0,Math.floor((Date.now()-ts)/1000));
929
+ if(s<60) return 'just now';
930
+ const m=Math.floor(s/60); if(m<60) return m+'m ago';
931
+ const h=Math.floor(m/60); if(h<24) return h+'h ago';
932
+ const d=Math.floor(h/24); return d+'d ago';
933
+ }
934
+ // The set of normalized cwds that belong to a project (its clones).
935
+ function projPaths(p){ return ((p&&p.localPaths&&p.localPaths.length)?p.localPaths:[(p&&p.cwd)||'']).map(normC); }
936
+ // The project-first home body: one row per repository, newest-first. Live
937
+ // running counts + thread counts overlay from the live /api/tasks records
938
+ // (which carry the freshest status), keyed to the project via its localPaths.
939
+ function projectListHtml(esc){
940
+ const projects=_cached.projects||[];
941
+ if(!projects.length) return '';
942
+ const real=allS(_cached.tasks||{}).filter(x=>!/^demo-/.test(x.sessionId||'')&&!x.mockRun);
943
+ // normalized-cwd -> project.id
944
+ const cwdToId={};
945
+ projects.forEach(p=>{ projPaths(p).forEach(np=>{ if(np) cwdToId[np]=p.id; }); });
946
+ const tasksById={};
947
+ real.forEach(t=>{ const pid=cwdToId[normC(t.cwd)]; if(pid){ (tasksById[pid]=tasksById[pid]||[]).push(t); } });
948
+ const _live=t=>['running','queued','paused','blocked'].includes(t.status);
949
+ let html='<div class="section-label section-label-row">Projects<button class="add-gh" onclick="event.stopPropagation();openClone()" title="Clone a GitHub repo and open it as a project">+ from GitHub</button></div>';
950
+ for(const p of projects){
951
+ const mine=tasksById[p.id]||[];
952
+ const running=mine.filter(_live).length;
953
+ // turns = all-time count from the project thread.jsonl (p.taskCount). A "threads"
954
+ // count is intentionally NOT shown here: it was computed from _cached.tasks (a recent
955
+ // subset) while turns came from thread.jsonl (all-time) — incoherent. Threads-as-groups
956
+ // is shown IN the room where it's consistent; bring a count back (server-side, same
957
+ // source) once cross-agent hand-offs make multi-turn threads meaningful (Phase 3).
958
+ const turns=p.taskCount||mine.length||0;
959
+ const bits=[];
960
+ bits.push(turns+' turn'+(turns===1?'':'s'));
961
+ if(running) bits.push(running+' running');
962
+ if(p.totalCostCents>0) bits.push('$'+(p.totalCostCents/100).toFixed(2));
963
+ const ago=projAgo(p.lastActivityAt); if(ago) bits.push(ago);
964
+ const cwd=(p.cwd||(p.localPaths&&p.localPaths[0]))||'';
965
+ html+='<div class="goal-group'+(running?' running':'')+'"><div class="goal-head" tabindex="0" role="button" data-verify-unit="project" data-project-id="'+esc(p.id)+'" data-goal-cwd="'+encodeURIComponent(cwd)+'" onclick="event.stopPropagation();openRoom(\\''+encodeURIComponent(cwd)+'\\')" onkeydown="if(event.key===\\'Enter\\'||event.key===\\' \\'){event.preventDefault();openRoom(\\''+encodeURIComponent(cwd)+'\\')}">'
966
+ +'<span class="goal-title" data-user-content>'+esc(projName(p))+'</span>'
967
+ +'<span class="goal-meta">'+(partStack(p.recentAgents)?partStack(p.recentAgents)+' \\u00b7 ':'')+esc(bits.join(' \\u00b7 '))+' \\u00b7 open \\u203A</span></div></div>';
968
+ }
969
+ return html;
970
+ }
971
+
972
+ function renderL() {
973
+ const c=document.getElementById('task-list'), all=allS(_cached.tasks||{});
974
+ const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[ch]));
975
+ const fl=all.filter(FILTERS[_filter]||FILTERS.all);
976
+ let html='';
977
+ // Demo-shape sessions (id prefix 'demo-') render AFTER the real work —
978
+ // they demonstrate the product, but the home leads with YOUR story.
979
+ const demo=fl.filter(x=>/^demo-/.test(x.sessionId||''));
980
+ // S18 · practice runs (mockRun-stamped by the daemon under CODEHERE_MOCK)
981
+ // never sit among real work — they collapse behind their own quiet row
982
+ // beside the history expander. Same records, same showD detail: grouped,
983
+ // never deleted, never unreachable. Demo fixtures stay the FEATURED group.
984
+ const practice=fl.filter(x=>!/^demo-/.test(x.sessionId||'')&&x.mockRun);
985
+ const rest=fl.filter(x=>!/^demo-/.test(x.sessionId||'')&&!x.mockRun);
986
+ // cwd -> project title (from /api/projects); fall back to the folder name.
987
+ const projects=_cached.projects||[];
988
+ const titleFor=cwd=>{
989
+ if(!cwd) return 'Ungrouped';
990
+ const p=projects.find(x=>normC(x.cwd)===normC(cwd));
991
+ if(p&&p.title) return p.title;
992
+ const parts=String(cwd).split('/').filter(Boolean);
993
+ return parts.length?parts[parts.length-1]:'Ungrouped';
994
+ };
995
+ // S13 · TODAY = live tasks (any start day) + today's finished work, as
996
+ // ONE-LINE rows (direction E), live first then newest. EARLIER + the demo
997
+ // fixtures collapse behind the single history row; expanding keeps the S12
998
+ // goal groups (goal heads still open rooms, time-gaps + demo cards intact).
999
+ const _live=t=>['running','queued','paused','blocked'].includes(t.status);
1000
+ const _sod=new Date(); _sod.setHours(0,0,0,0);
1001
+ const _dayStart=_sod.getTime();
1002
+ const chToday=rest.filter(t=>_live(t)||(t.startedAt||0)>=_dayStart);
1003
+ chToday.sort((a,b)=>{ const la=_live(a)?1:0, lb=_live(b)?1:0; if(la!==lb) return lb-la; return (b.startedAt||0)-(a.startedAt||0); });
1004
+ let chEarlier=rest.filter(t=>!_live(t)&&(t.startedAt||0)<_dayStart);
1005
+ chEarlier.sort((a,b)=>(b.startedAt||0)-(a.startedAt||0));
1006
+ const histN=chEarlier.length+demo.length;
1007
+ if(chToday.length){
1008
+ html+='<div class="section-label">Recent</div><div class="today-rows">'+groupByTitle(chToday).map(groupRowH).join('')+'</div>';
1009
+ }
1010
+ // Projects live in the persistent sidebar now; on the home pane the list is a
1011
+ // duplicate (the confusion Ega flagged), so .home-projects is hidden on desktop
1012
+ // and kept only for mobile (sidebar collapses) + the verify probe (display:none
1013
+ // still matches querySelectorAll).
1014
+ var _projHtml=projectListHtml(esc); // '' when there are no projects (keeps the first-run empty-state trigger alive)
1015
+ if(_projHtml) html+='<div class="home-projects">'+_projHtml+'</div>';
1016
+ // A returning user whose last work was before today would otherwise land on an
1017
+ // EMPTY home — everything hidden behind this collapsed "earlier" row. So when
1018
+ // there's nothing today, surface the recent work DIRECTLY (grouped by project),
1019
+ // never a blank page. When there IS work today, keep "earlier" collapsible.
1020
+ const _todayEmpty=!chToday.length;
1021
+ if(histN){
1022
+ if(_todayEmpty){
1023
+ html+='<div class="section-label">Recent</div>';
1024
+ } else {
1025
+ html+='<button class="hist-row" onclick="toggleHist()" aria-expanded="'+(_histOpen?'true':'false')+'">earlier \\u00b7 '+histN+' run'+(histN===1?'':'s')+(_histOpen?' \\u00b7 collapse':' \\u00b7 expand \\u203A')+'</button>';
1026
+ }
1027
+ }
1028
+ if(_histOpen||_todayEmpty){
1029
+ const earlierTotal=chEarlier.length;
1030
+ const earlierHidden=Math.max(0, earlierTotal-_earlierShown);
1031
+ if(earlierHidden>0) chEarlier=chEarlier.slice(0,_earlierShown);
1032
+ if(chEarlier.length){
1033
+ html+=goalGroupsHtml(chEarlier, titleFor, esc);
1034
+ if(earlierHidden>0){
1035
+ html+='<button class="show-earlier" onclick="showEarlier()">show earlier work ('+earlierHidden+' more)</button>';
1036
+ }
1037
+ }
1038
+ if(demo.length){
1039
+ html+='<div class="section-label featured">Featured \\u00b7 demo</div>';
1040
+ for(const t of demo) html+=cardH(t);
1041
+ }
1042
+ }
1043
+ practice.sort((a,b)=>(b.startedAt||0)-(a.startedAt||0));
1044
+ html+=pracGroupHtml(practice);
1045
+ if(!html){
1046
+ if(_filter==='all'){
1047
+ // First-run onboarding: what codehere is, the coding agents detected on THIS
1048
+ // machine (the cross-vendor lineup is the whole point), and a one-click first
1049
+ // task. Genuine first run only (no tasks, no projects).
1050
+ html='<div class="empty-state onboarding">'
1051
+ +'<h2>Welcome to codehere</h2>'
1052
+ +'<p class="ob-lede">Running locally. No account, nothing leaves your machine. Two steps to your first verified task:</p>'
1053
+ +'<div class="ob-cta"><button class="ob-tourbtn" onclick="startTour()">Take the 30-second tour \\u2192</button></div>'
1054
+ +'<div class="ob-step"><span class="ob-num">1</span><div class="ob-b"><div class="ob-h">Your agents</div>'+obAgentsHtml()+'</div></div>'
1055
+ +'<div class="ob-step"><span class="ob-num">2</span><div class="ob-b"><div class="ob-h">Start a task</div>'
1056
+ +'<div class="ob-ex">'
1057
+ +'<button class="ex-prompt" data-ex="Review the code in this project for bugs and security issues, and tell me what to fix" onclick="exFill(this.dataset.ex)">Review my code for bugs</button>'
1058
+ +'<button class="ex-prompt" data-ex="Explain how this project is structured and what the main parts do" onclick="exFill(this.dataset.ex)">Explain this codebase</button>'
1059
+ +'<button class="ex-prompt" data-ex="Add a test for the most important function in this project and make it pass" onclick="exFill(this.dataset.ex)">Write a test</button>'
1060
+ +'</div>'
1061
+ +'<div class="ex-gh">Working on a repo on GitHub? <button class="add-gh" onclick="openClone()">+ from GitHub</button></div>'
1062
+ +'</div></div>'
1063
+ +'</div>';
1064
+ } else {
1065
+ const h={running:'No running tasks.',done:'No completed tasks.',failed:'No failed tasks.'};
1066
+ html='<div class="empty-state"><h2>'+(h[_filter]||'No tasks found.')+'</h2></div>';
1067
+ }
1068
+ }
1069
+ // S12 · morph, never wipe — zero hover/scroll/focus loss on the poll.
1070
+ morphInto(c, html);
1071
+ }
1072
+
1073
+ ${TOUR}
1074
+ ${ONBOARDING}
1075
+ ${GITHUB_INTEGRATION}
1076
+
1077
+ ${OBSERVER_RAIL}
1078
+
1079
+ // It-4 V1-MULTITASK + V1-FANOUT-ALL · render one batch (siblings sharing a
1080
+ // batchId) as a derived-live rollup header + a side-by-side fanout grid.
1081
+ // Counts are computed from the child records on EVERY render — never stored —
1082
+ // so a daemon crash mid-run reads "running", not a stale "done" (the plan's
1083
+ // honesty constraint). Each grid column reuses cardH so a column is a real
1084
+ // turn card labelled with the real adapter chip; a not-ready adapter is simply
1085
+ // absent (it never got a child), never a faked column.
1086
+ function batchH(sibs){
1087
+ const isDone=x=>x.status==='completed'||x.status==='done';
1088
+ const isRun=x=>['running','queued','paused','blocked'].includes(x.status);
1089
+ const isFail=x=>['error','killed','crashed','failed'].includes(x.status);
1090
+ const n=sibs.length, done=sibs.filter(isDone).length, run=sibs.filter(isRun).length, fail=sibs.filter(isFail).length;
1091
+ const parts=[done+' done'];
1092
+ if(run>0) parts.push(run+' running');
1093
+ if(fail>0) parts.push(fail+' failed');
1094
+ let html='<div class="project-rollup"><span class="rollup-icon">&#8801;</span>'
1095
+ +'<span class="rollup-count">'+n+' sub-task'+(n===1?'':'s')+'</span>'
1096
+ +'<span class="rollup-sep">\\u00b7</span><span class="rollup-stat">'+parts.join(' \\u00b7 ')+'</span>'
1097
+ +'<span class="rollup-note">sent to every ready agent</span></div>';
1098
+ // Fanout grid · one column per child, sorted by adapter for a stable order.
1099
+ const ordered=[...sibs].sort((a,b)=>String(a.agent||'').localeCompare(String(b.agent||'')));
1100
+ html+='<div class="fanout-all">';
1101
+ for(const t of ordered){
1102
+ html+='<div class="fanout-col"><div class="fanout-col-head">'+ab(t.agent,'sm')+agentLabel(t.agent)+'</div>'+cardH(t)+'</div>';
1103
+ }
1104
+ html+='</div>';
1105
+ return html;
1106
+ }
1107
+ // S12 · ONE story-arc line per card, derived from REAL data only — steps
1108
+ // count, verdict status, failover lineage, a caught contradiction when
1109
+ // tests.claimed disagrees with the observed counts. No real arc data -> ''
1110
+ // (the existing meta stays); we never invent narrative.
1111
+ function cardArc(t){
1112
+ const he=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
1113
+ const steps=t.steps||[];
1114
+ if(['running','queued','paused','blocked'].includes(t.status)){
1115
+ if(!steps.length) return '';
1116
+ const last=steps[steps.length-1];
1117
+ return '<div class="card-arc">Now: <span data-user-content>'+compactLabel(last.label||last.tool||'')+'</span> \\u00b7 step '+steps.length+'</div>';
1118
+ }
1119
+ const parts=[];
1120
+ if(t.failoverFrom&&t.failoverTo) parts.push('<span class="wr">recovered from '+he(agentLabel(t.failoverFrom))+'</span>');
1121
+ const tests=t.artifacts&&t.artifacts.tests;
1122
+ const vr=t.verdict&&t.verdict.claim;
1123
+ const passed=tests?(tests.passed||0):0, failed=tests?(tests.failed||0):0;
1124
+ const cl=tests&&tests.claimed;
1125
+ const caught=!!(cl&&((cl.passed!==passed)||(cl.failed!==failed)));
1126
+ const isFail=['failed','error','crashed','killed'].includes(t.status);
1127
+ if(caught){
1128
+ parts.push('<span class="wr">verifier caught a claimed '+cl.passed+'/'+(cl.passed+cl.failed)+'</span> \\u203A re-ran \\u203A '+passed+'/'+(passed+failed));
1129
+ } else if(isFail && failed>0){
1130
+ parts.push('<span class="wr">verifier found '+failed+' red test'+(failed===1?'':'s')+' \\u00b7 left unmerged</span>');
1131
+ } else if(vr&&vr.status==='broken'){
1132
+ parts.push('<span class="wr">claim contradicts reality \\u00b7 caught by the verifier</span>');
1133
+ } else if(vr&&vr.status==='verified'&&!isFail&&tests&&tests.observed){
1134
+ parts.push('<span class="ok">verifier re-ran the tests \\u203A '+passed+'/'+(passed+failed)+' \\u2713</span>');
1135
+ }
1136
+ if(!parts.length) return '';
1137
+ const lead=steps.length?steps.length+' step'+(steps.length===1?'':'s')+' \\u203A ':'';
1138
+ return '<div class="card-arc">'+lead+parts.join(' \\u00b7 ')+'</div>';
1139
+ }
1140
+ function cardH(t) {
1141
+ // Unified card (2026-06-20): every card — today, history, demo, fanout — uses
1142
+ // the SAME .tcard render as the home rows, so the product never shows two card
1143
+ // styles. Preserve the demo pin marker (the F-PIN-DEMO coverage selector
1144
+ // .task-card.tc-pinned).
1145
+ var h = rowH(t);
1146
+ if(/^demo-/.test(t.sessionId||'')) h = h.replace('class="task-card tcard', 'class="task-card tcard tc-pinned');
1147
+ return h;
1148
+ }
1149
+
1150
+ // S13 · ONE-line row (direction E): status dot+word · title · agent (mono,
1151
+ // no rainbow) · checks · cost. Keeps the task-card class + tabindex/Enter +
1152
+ // the stop control + the agent-* class — harness selectors + the morph rely
1153
+ // on them. Verified green ONLY through the same gate heroCounts uses (S12).
1154
+ // S20 fix · ONE verdict precedence for every row-level derivation. This is
1155
+ // the canonical detail verdictLine order (practice first, then the gated
1156
+ // verified branches, then broken) extended with the row-only states the
1157
+ // detail never renders (stalled, live, terminal-unclean, done). rowStatus,
1158
+ // rowClaims and outcomeSentence all read THIS state, never their own branch
1159
+ // order — before this, each hand-rolled its own precedence and the same
1160
+ // mock+broken record wore a different verdict word per lens.
1161
+ function verdictState(t){
1162
+ const claim=(t.verdict&&t.verdict.claim&&t.verdict.claim.status)||'';
1163
+ const live=['running','queued','paused','blocked'].includes(t.status);
1164
+ const unclean=['failed','error','crashed','killed'].includes(t.status);
1165
+ const doneSt=['completed','done'].includes(t.status);
1166
+ const noGit=!!(t.artifacts&&t.artifacts.noGitTracking);
1167
+ if(t.mockRun&&!live) return 'practice';
1168
+ if(claim==='verified'&&doneSt&&!noGit) return 'verified';
1169
+ if(claim==='verified'&&doneSt&&noGit) return 'partial';
1170
+ if(claim==='broken') return 'caught';
1171
+ if(t.stalledReason) return 'stalled';
1172
+ if(live) return 'live';
1173
+ if(unclean) return 'stopped';
1174
+ if(doneSt) return 'done';
1175
+ return 'unknown';
1176
+ }
1177
+ function rowStatus(t){
1178
+ switch(verdictState(t)){
1179
+ case 'verified': return {cls:'verified',word:'Verified'};
1180
+ case 'caught': return {cls:'caught',word:'Caught'};
1181
+ case 'stalled': return {cls:'stalled',word:'Stalled'};
1182
+ case 'live':
1183
+ if(t.status==='running') return {cls:'running',word:'Running'};
1184
+ if(t.status==='queued') return {cls:'running',word:'Queued'};
1185
+ return {cls:'running',word:'Paused'};
1186
+ case 'stopped': return {cls:'stopped',word:'Stopped'};
1187
+ case 'practice':
1188
+ // Default vocabulary stays run-status (the practice group already names
1189
+ // the mode; the detail's verdict word is Practice) — but a practice
1190
+ // verdict NEVER wears Caught/Verified: a mock run fabricated its own
1191
+ // artifacts, so a verdict word here would contradict the detail.
1192
+ if(t.stalledReason) return {cls:'stalled',word:'Stalled'};
1193
+ if(['failed','error','crashed','killed'].includes(t.status)) return {cls:'stopped',word:'Stopped'};
1194
+ return {cls:'done',word:'Done'};
1195
+ case 'partial': return {cls:'done',word:'Done'};
1196
+ case 'done': return {cls:'done',word:'Done'};
1197
+ default: return {cls:'done',word:String(t.status||'?')};
1198
+ }
1199
+ }
1200
+ function rowH(t){
1201
+ // S20 · Status lens: the row IS the outcome sentence (template branch).
1202
+ if(curLens()==='status') return statusRowH(t);
1203
+ const rs=rowStatus(t);
1204
+ const agentClass='agent-'+String(t.agent||'').toLowerCase().replace(/[^a-z0-9]/g,'-');
1205
+ const live=['running','queued','paused','blocked'].includes(t.status);
1206
+ const tests=t.artifacts&&t.artifacts.tests;
1207
+ const passed=tests?(tests.passed||0):0, failed=tests?(tests.failed||0):0, total=passed+failed;
1208
+ const co=(t.cost&&typeof t.cost.spent==='number')?'$'+(t.cost.spent/100).toFixed(2):'';
1209
+ const nf=(t.artifacts&&t.artifacts.files&&t.artifacts.files.length)||0;
1210
+ // the status stat-pill carries the HONEST verdict word (rowClaims) — green ✓
1211
+ // ONLY when verified, never a fake ✓ for a stopped/caught/partial run. Live
1212
+ // gets the run pulse. This keeps the H15 honesty contract across both lenses.
1213
+ const vs=verdictState(t);
1214
+ // 2.2 honesty gate — IDENTICAL to the detail headline + summary tile: a green
1215
+ // ✓ verified ONLY when the composite grade confirms an INDEPENDENT signal.
1216
+ // A self-reported run (grade present but not green) reads '○ reported', never
1217
+ // a green check. Legacy/seeded verdicts with no grade keep the claim check.
1218
+ const _graded=t.verdict&&t.verdict.grade;
1219
+ const _green=vs==='verified'&&(_graded?!!t.verdict.grade.green:true);
1220
+ var pill;
1221
+ if(live){ pill='<span class="tc-stat run"><span class="tc-pulse"></span>'+rs.word+'</span>'; }
1222
+ else if(_green){ pill='<span class="tc-stat ok">\\u2713 verified'+(total>0?' '+passed+'/'+total:'')+'</span>'; }
1223
+ else if(vs==='verified'){ pill='<span class="tc-stat neutral">\\u25CB reported</span>'; }
1224
+ else { var cl=rowClaims(t); var pc=(vs==='caught'?'fail':'neutral'); pill='<span class="tc-stat '+pc+'">'+cl.word+'</span>'; }
1225
+ // meta line: the vendor + what it touched (mockup: "Claude Code · 3 files")
1226
+ var meta='<span class="tc-vendor">'+agentLabel(t.agent)+'</span>'+(nf?' \\u00b7 '+nf+' file'+(nf===1?'':'s'):'');
1227
+ const stop=live?'<button class="card-stop" data-verify-action="stop" title="Stop this agent now" aria-label="Stop this agent now" onclick="stopTask(event,\\''+t.sessionId+'\\')">\\u25A0 stop</button>':'';
1228
+ return '<div class="task-card tcard '+agentClass+(live?' active':'')+'" tabindex="0" role="button" onclick="showD(\\''+t.sessionId+'\\')">'
1229
+ +'<span class="tc-av">'+ab(t.agent)+'</span>'
1230
+ +'<div class="tc-body"><div class="tc-title" data-user-content title="'+convHe(displayPrompt(t.prompt)).slice(0,400)+'">'+convHe(title(t.prompt))+'</div>'
1231
+ +'<div class="tc-meta">'+meta+'</div></div>'
1232
+ +'<div class="tc-glyphs">'+pill+(co?'<span class="tc-cost">'+co+'</span>':'')+stop+'<span class="tc-arrow">\\u203A</span></div>'
1233
+ +'</div>';
1234
+ }
1235
+
1236
+ // S21 · thread grouping — runs of the SAME task (same title) collapse into one
1237
+ // thread: the newest run is the head card, older runs nest below as compact rows.
1238
+ // Every run keeps its own showD(sessionId) target (one-click record, and the e2e
1239
+ // sid selectors still resolve). A single-run task renders exactly as before.
1240
+ function groupByTitle(items){
1241
+ const map=new Map();
1242
+ // Same guards as threadGroups (cold-review P2): Untitled tasks never merge
1243
+ // (each keys on its own sessionId) and the key is cwd-scoped so same-title
1244
+ // tasks from different projects stay separate threads.
1245
+ for(const t of items){ const tt=title(t.prompt); const k=(tt&&tt!=='Untitled')?(String(t.cwd||'')+'\\u0000'+tt):('sid:'+String(t.sessionId||'')); if(!map.has(k)) map.set(k,[]); map.get(k).push(t); }
1246
+ const groups=[];
1247
+ for(const pair of map){ const runs=pair[1]; runs.sort((a,b)=>(b.startedAt||0)-(a.startedAt||0)); groups.push(runs); }
1248
+ groups.sort((a,b)=>(b[0].startedAt||0)-(a[0].startedAt||0));
1249
+ return groups;
1250
+ }
1251
+ function _runRowH(r){ const rc=rowClaims(r); const co=(r.cost&&typeof r.cost.spent==='number')?'$'+(r.cost.spent/100).toFixed(2):'\\u2014';
1252
+ const sid=String(r.sessionId||'').replace(/[^A-Za-z0-9._:-]/g,'');
1253
+ return '<div class="task-run" tabindex="0" role="button" onclick="showD(\\''+sid+'\\')"><span class="tr-who">'+convHe(agentLabel(r.agent))+'</span><span class="tr-st st-'+rc.cls+'">'+convHe(rc.word)+'</span><span class="tr-c">'+co+'</span></div>'; }
1254
+ function _threadWrap(headHtml, runs){
1255
+ let cents=0; for(const r of runs){ if(r.cost&&typeof r.cost.spent==='number') cents+=r.cost.spent; }
1256
+ const tot=cents?'$'+(cents/100).toFixed(2):'';
1257
+ return '<div class="task-thread">'+headHtml
1258
+ +'<div class="task-runs"><div class="tr-more">'+runs.length+' runs \\u00b7 '+tot+'</div>'+runs.slice(1).map(_runRowH).join('')+'</div>'
1259
+ +'</div>';
1260
+ }
1261
+ function groupRowH(runs){ return runs.length===1?rowH(runs[0]):_threadWrap(rowH(runs[0]), runs); }
1262
+ function groupCardH(runs){ return runs.length===1?cardH(runs[0]):_threadWrap(cardH(runs[0]), runs); }
1263
+
1264
+ // ── S20 · lenses: claims-state word + honest outcome sentence ──────────────
1265
+ // rowClaims = the Review lead. outcomeSentence = the Status line, built ONLY
1266
+ // from recorded verdict/status/stall facts (the S13 story-arc rule — no real
1267
+ // data, no narrative). BOTH derive from verdictState above — one precedence,
1268
+ // one truth per record, only the vocabulary differs per lens. A stopped or
1269
+ // stalled run wears the same word family as the default lens (stopped, never
1270
+ // a failure word the default lens does not claim).
1271
+ function rowClaims(t){
1272
+ switch(verdictState(t)){
1273
+ case 'practice': return {cls:'done',word:'\\u25CB practice'};
1274
+ case 'verified': return {cls:'verified',word:'\\u2713 verified'};
1275
+ case 'partial': return {cls:'done',word:'\\u25CB partial'};
1276
+ case 'caught': return {cls:'caught',word:'\\u2715 caught'};
1277
+ case 'stalled': return {cls:'stopped',word:'\\u25A0 stalled'};
1278
+ case 'live': return {cls:'running',word:'\\u25CF unchecked'};
1279
+ case 'stopped': return {cls:'stopped',word:'\\u25A0 stopped'};
1280
+ default: return {cls:'done',word:'\\u25CB unverified'};
1281
+ }
1282
+ }
1283
+ function outcomeSentence(t){
1284
+ switch(verdictState(t)){
1285
+ case 'practice': return 'practice run \\u00b7 wiring only';
1286
+ case 'verified': return 'verified \\u00b7 claims match reality';
1287
+ case 'partial': return 'done \\u00b7 partly verified \\u00b7 not a git repo';
1288
+ case 'caught': return 'caught \\u00b7 the claim contradicted the record';
1289
+ case 'stalled': return 'did not finish \\u00b7 stopped for going silent';
1290
+ case 'live': return t.status==='running'?'in progress':String(t.status);
1291
+ case 'stopped': return 'did not finish \\u00b7 '+String(t.status);
1292
+ case 'done': return 'done \\u00b7 unverified';
1293
+ default: return String(t.status||'unknown');
1294
+ }
1295
+ }
1296
+ // Status-lens row: title + the outcome sentence + spend. Keeps the
1297
+ // .task-card/.trow contract (tabindex, Enter, agent-* class, stop, pin).
1298
+ function statusRowH(t){
1299
+ const agentClass='agent-'+String(t.agent||'').toLowerCase().replace(/[^a-z0-9]/g,'-');
1300
+ const pinClass=/^demo-/.test(t.sessionId||'')?' tc-pinned':'';
1301
+ const live=['running','queued','paused','blocked'].includes(t.status);
1302
+ const stop=live?'<button class="card-stop" data-verify-action="stop" title="Stop this agent now" aria-label="Stop this agent now" onclick="stopTask(event,\\''+t.sessionId+'\\')">\\u25A0 stop</button>':'';
1303
+ const co=(t.cost&&typeof t.cost.spent==='number')?'<span class="row-meta">$'+(t.cost.spent/100).toFixed(2)+'</span>':'';
1304
+ return '<div class="task-card trow srow '+agentClass+pinClass+'" tabindex="0" role="button" onclick="showD(\\''+t.sessionId+'\\')">'
1305
+ +'<span class="row-title" data-user-content title="'+displayPrompt(t.prompt).replace(/"/g,'&quot;').slice(0,400)+'">'+titleH(t.prompt)+'</span>'
1306
+ +'<span class="row-outcome">'+outcomeSentence(t)+'</span>'
1307
+ +co
1308
+ +stop
1309
+ +'</div>';
1310
+ }
1311
+
1312
+ // ── S13 · NEEDS YOUR DECISION — the only orange on the page ────────────────
1313
+ // Derived from REAL state on every render: (a) pending W-PASS intents (the
1314
+ // same /api/writepass/pending list the modal polls — window._wpPendingAll),
1315
+ // (b) tasks whose claim the verifier BROKE today, (c) today's failed/killed
1316
+ // runs with the original prompt recorded (Retry can respawn them). Empty ->
1317
+ // the section does not render at all; H14 pins absent <=> N===0.
1318
+ function decisionItems(){
1319
+ // S18 · PAIRED with verify-page H14's expected-derivation (dAll filter):
1320
+ // a practice (mockRun) failure never demands a real decision. Change BOTH
1321
+ // sides identically (the honest-rule update), never one side alone.
1322
+ const all=allS(_cached.tasks||{}).filter(t=>!/^demo-/.test(t.sessionId||'')&&!t.mockRun);
1323
+ const sod=new Date(); sod.setHours(0,0,0,0);
1324
+ const ds=sod.getTime();
1325
+ const today=t=>(t.startedAt||0)>=ds;
1326
+ const passes=window._wpPendingAll||[];
1327
+ const broken=all.filter(t=>today(t)&&t.verdict&&t.verdict.claim&&t.verdict.claim.status==='broken');
1328
+ const failed=all.filter(t=>today(t)&&['failed','error','crashed','killed'].includes(t.status)
1329
+ &&t.prompt&&!(t.verdict&&t.verdict.claim&&t.verdict.claim.status==='broken'));
1330
+ return {passes,broken,failed,n:passes.length+broken.length+failed.length};
1331
+ }
1332
+ function renderDecisions(){
1333
+ const c=document.getElementById('decision-queue');
1334
+ if(!c) return;
1335
+ const he=s=>String(s==null?'':s).replace(/[&<>"]/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[ch]));
1336
+ const d=decisionItems();
1337
+ if(_room||d.n===0){
1338
+ c.style.display='none'; c.innerHTML='';
1339
+ c.removeAttribute('data-verify-unit'); c.removeAttribute('data-verify-decisions-n');
1340
+ return;
1341
+ }
1342
+ c.style.display='block';
1343
+ c.setAttribute('data-verify-unit','decisions');
1344
+ c.setAttribute('data-verify-decisions-n', String(d.n));
1345
+ // Exactly ONE filled button on the screen: the first row's primary.
1346
+ let first=true;
1347
+ const btn=(act,label,kbd,onclick,fill)=>'<button class="dq-btn'+(fill?' fill':(act==='deny'||act==='retry')?' quiet':'')+'" data-act="'+act+'" onclick="'+onclick+'">'+label+' <span class="kbd">'+kbd+'</span></button>';
1348
+ let rows='';
1349
+ for(const p of d.passes){
1350
+ const it=p.intent||{};
1351
+ rows+='<div class="dq-row" tabindex="0" data-kind="pass">'
1352
+ +'<div class="dq-what"><div class="dq-t"><span class="dq-id">W-PASS</span>'+he(it.emittedBy||'an agent')+' wants to write: <span data-user-content>'+he((it.method||'')+' '+(it.target||''))+'</span></div>'
1353
+ +'<div class="dq-why">external write \\u00b7 gated by codehere'+(it.reason?' \\u00b7 <span data-user-content>'+he(String(it.reason).slice(0,140))+'</span>':'')+'</div>'
1354
+ +'<div class="dq-result" style="display:none"></div></div>'
1355
+ +'<div class="dq-acts">'
1356
+ +btn('approve','Approve','A',"wpQueueDecide('"+he(it.id)+"','approve',this)",first)
1357
+ +btn('deny','Deny','X',"wpQueueDecide('"+he(it.id)+"','cancel',this)",false)
1358
+ +'</div></div>';
1359
+ first=false;
1360
+ }
1361
+ for(const t of d.broken){
1362
+ rows+='<div class="dq-row" tabindex="0" data-kind="broken">'
1363
+ +'<div class="dq-what"><div class="dq-t">'+agentLabel(t.agent)+' says it\\u2019s done. The verifier disagrees.</div>'
1364
+ +'<div class="dq-why"><span data-user-content>'+titleH(t.prompt)+'</span> \\u00b7 the claim contradicts recorded reality \\u00b7 left for your call</div></div>'
1365
+ +'<div class="dq-acts">'+btn('review','Review','\\u23CE',"showD('"+he(t.sessionId)+"')",first)+'</div></div>';
1366
+ first=false;
1367
+ }
1368
+ // Collapse identical failed attempts (same prompt+cwd+agent) into ONE row — a
1369
+ // Retry that re-fails (e.g. a rate-limited agent) shouldn't stack duplicate
1370
+ // decision rows. One row per distinct failure, with the attempt count; the
1371
+ // latest attempt drives Review/Retry.
1372
+ const _fg=[], _fk={};
1373
+ for(const t of d.failed){
1374
+ const k=(t.prompt||'')+'|'+(t.cwd||'');
1375
+ if(_fk[k]!==undefined){ const g=_fg[_fk[k]]; g.count++; if(g.agents.indexOf(t.agent)<0) g.agents.push(t.agent); if((t.startedAt||0)>(g.t.startedAt||0)) g.t=t; }
1376
+ else { _fk[k]=_fg.length; _fg.push({t:t,count:1,agents:[t.agent]}); }
1377
+ }
1378
+ for(const g of _fg){ const t=g.t;
1379
+ const who=g.agents.map(a=>agentLabel(a)).join(', ');
1380
+ rows+='<div class="dq-row" tabindex="0" data-kind="failed">'
1381
+ +'<div class="dq-what"><div class="dq-t"><span data-user-content>'+titleH(t.prompt)+'</span> didn\\u2019t finish.</div>'
1382
+ +'<div class="dq-why">'+who+' \\u00b7 '+he(t.stalledReason||t.status)+(g.count>1?' \\u00b7 '+g.count+' attempts':'')+' \\u00b7 Retry respawns the same prompt fresh</div></div>'
1383
+ +'<div class="dq-acts">'+btn('review','Review','\\u23CE',"showD('"+he(t.sessionId)+"')",first)
1384
+ +btn('retry','Retry','R',"recoverRetry('"+he(t.sessionId)+"')",false)+'</div></div>';
1385
+ first=false;
1386
+ }
1387
+ // Kicker counts the actionable ROWS shown (failures collapsed by attempt), not
1388
+ // raw attempts — so "· N" always matches what's on screen. (The verify
1389
+ // attribute above keeps d.n, the raw count, for the H14 contract.)
1390
+ c.innerHTML='<div class="dq-kicker">Needs your decision <span class="cnt">\\u00b7 '+(d.passes.length+d.broken.length+_fg.length)+'</span></div>'+rows;
1391
+ }
1392
+
1393
+ function upBud() {
1394
+ const p=_cached.pricing||_cached.tasks?.pricing;
1395
+ const e=document.getElementById('budget');
1396
+ const banner=document.getElementById('budget-banner');
1397
+ const bwhat=document.getElementById('budget-banner-what');
1398
+ const bmsg=document.getElementById('budget-banner-msg');
1399
+ if(!p||!p.dailyBudgetCents){
1400
+ e.textContent='';e.className='budget';
1401
+ if(banner) banner.style.display='none';
1402
+ return;
1403
+ }
1404
+ const d=p.dailyBudgetCents,s=p.spentTodayCents||0;
1405
+ e.textContent=' \\u00b7 $'+(s/100).toFixed(2)+' / $'+(d/100).toFixed(2);
1406
+ e.className='budget'+(s>=d?' block':s>=d*0.8?' warn':' safe');
1407
+ const ib=document.querySelector('.input-bar');
1408
+ if(ib){ib.className='input-bar'+(s>=d?' block':s>=d*0.8?' warn':'');}
1409
+ // F-01 · budget threshold banner · only shows when crossing 80%
1410
+ if(banner && bwhat && bmsg){
1411
+ if(s>=d){
1412
+ banner.style.display='block';
1413
+ banner.className='budget-banner block';
1414
+ bwhat.textContent='budget exhausted';
1415
+ bmsg.textContent='daily cap $'+(d/100).toFixed(2)+' spent · paid spawns will block until tomorrow.';
1416
+ } else if(s>=d*0.8){
1417
+ banner.style.display='block';
1418
+ banner.className='budget-banner';
1419
+ bwhat.textContent='budget threshold';
1420
+ bmsg.textContent='$'+(s/100).toFixed(2)+' of $'+(d/100).toFixed(2)+' spent today · paid spawns require confirm.';
1421
+ } else {
1422
+ banner.style.display='none';
1423
+ }
1424
+ }
1425
+ }
1426
+
1427
+ // Map readiness state -> dot class. 'setup' is distinct from 'busy': busy means
1428
+ // actively working a task, setup means installed/known but can't run until the
1429
+ // user does something (auth, key, install). Conflating them was a screen lie —
1430
+ // a dead-without-setup agent looked like it was working (dogfood 2026-05-27 #4).
1431
+ // S13: the squad strip died; this readiness map feeds the ⌘K Agents command.
1432
+ function agentS(state){if(!state||state==='error')return'offline';if(state==='installed'||state==='ready')return'ready';if(state==='needs_install'||state==='needs_auth'||state==='billed_key_required')return'setup';return'busy';}
1433
+
1434
+ // S13 · filters live in ⌘K; the active filter shows as ONE removable token
1435
+ // above the list (never a tab bar).
1436
+ function setFilter(f){
1437
+ _filter=f;
1438
+ const tk=document.getElementById('filter-token');
1439
+ if(tk){
1440
+ if(f==='all'){ tk.style.display='none'; tk.innerHTML=''; }
1441
+ else { tk.style.display='block'; tk.innerHTML='<span class="ftoken">filter: '+f+' <span class="x" role="button" tabindex="0" aria-label="Clear filter" title="clear \\u00b7 back to everything" onclick="setFilter(\\'all\\')">\\u00d7</span></span>'; }
1442
+ }
1443
+ if(_cached.tasks)render();
1444
+ }
1445
+
1446
+ // URL-sync (eng-review 2026-07-03, D3) — hash deep-link so /#/task/:id is unique,
1447
+ // bookmarkable, and the back-button works. SINGLE-MACHINE by design: session ids
1448
+ // aren't portable across daemons (the record travels by signed bundle, not this
1449
+ // link), so this is DX + a foundation, not team-share. The URL is a PROJECTION:
1450
+ // written only in the mutators (showD/hideD), hydrated on load + hashchange.
1451
+ // render() NEVER reads it — reading it per-frame would re-introduce the
1452
+ // poll-render-wipe class the sweep fixed. String-only (no regex: template literal).
1453
+ function _hashForDetail(){ return _detail ? ('#/task/'+encodeURIComponent(_detail)) : ''; }
1454
+ // The URL spine (nav option A, Ega-approved 2026-07-06): rooms and the Spend
1455
+ // page get hashes too, so refresh / back / forward always land where you were.
1456
+ // Same projection rule as tasks: mutators write the hash, hydration reads it,
1457
+ // render() never touches it. Room ids prefer the project SLUG (short, stable);
1458
+ // a raw cwd is accepted on read as the fallback.
1459
+ function _roomHash(){
1460
+ if(!_room) return '';
1461
+ var p=(_cached.projects||[]).find(function(x){ return projPaths(x).indexOf(normC(_room))>=0; });
1462
+ return '#/room/'+encodeURIComponent(p&&p.slug?p.slug:_room);
1463
+ }
1464
+ function _spendVisible(){ var sv=document.getElementById('spend-view'); return !!(sv&&sv.style.display==='block'); }
1465
+ function _routeFromHash(){
1466
+ var h=location.hash||'', pfx='#/task/', rfx='#/room/';
1467
+ if(h.indexOf(pfx)===0){
1468
+ var id=decodeURIComponent(h.slice(pfx.length));
1469
+ if(!id || id===_detail) return;
1470
+ if(typeof findS==='function' && findS(id)) showD(id);
1471
+ // Unresolvable id → say so. Silently keeping the previous screen under a
1472
+ // hash that names a different task showed the WRONG task to anyone opening
1473
+ // a shared link (verify-distill loop-2 find, confirmed on both builds).
1474
+ else showTaskNotFound(id);
1475
+ } else if(h.indexOf(rfx)===0){
1476
+ var key=decodeURIComponent(h.slice(rfx.length));
1477
+ if(!key) return;
1478
+ var p=(_cached.projects||[]).find(function(x){ return x.slug===key; })
1479
+ ||(_cached.projects||[]).find(function(x){ return projPaths(x).indexOf(normC(key))>=0; });
1480
+ var cwd=p?(p.cwd||projPaths(p)[0]):(key.indexOf('/')===0?key:null);
1481
+ if(!cwd){
1482
+ // Unknown project id → say so and land home (leaving whatever screen was
1483
+ // up under an empty hash is the same state-drift the spine kills).
1484
+ al('No project with this id here.');
1485
+ try{ history.replaceState(null,'',location.pathname+location.search); }catch(e){}
1486
+ if(_detail||document.body.classList.contains('detail-open')) hideD();
1487
+ if(_spendVisible()){ document.getElementById('spend-view').style.display='none'; document.getElementById('task-list-view').style.display=''; }
1488
+ if(_room) closeRoom();
1489
+ return;
1490
+ }
1491
+ // Widened gates here + below (ship-review catch): the not-found screen
1492
+ // nulls _detail with the container visible — Back/Today must clear it too.
1493
+ if(_detail||document.body.classList.contains('detail-open')) hideD();
1494
+ if(normC(_room)!==normC(cwd)) openRoom(encodeURIComponent(cwd));
1495
+ } else if(h==='#/spend'){
1496
+ if(!_spendVisible()) showSpend();
1497
+ } else {
1498
+ if(_detail||document.body.classList.contains('detail-open')) hideD();
1499
+ if(_spendVisible()){ document.getElementById('spend-view').style.display='none'; document.getElementById('task-list-view').style.display=''; renderSidebar(); }
1500
+ if(_room) closeRoom();
1501
+ }
1502
+ }
1503
+ // Honest dead-link state (reuses the .empty-state treatment — no new class).
1504
+ // Plain about WHY the id can miss: this view loads recent + opened-room tasks
1505
+ // only, and session ids aren't portable across machines (the URL-sync design
1506
+ // note above). The one real affordance is the way back.
1507
+ function showTaskNotFound(id){
1508
+ _detail=null;
1509
+ if(_ctlPoll){ clearInterval(_ctlPoll); _ctlPoll=null; } _ctlId=null; // stop polling the task we navigated away from (ship-review catch)
1510
+ document.getElementById('task-list-view').style.display='none';
1511
+ document.getElementById('task-detail-view').style.display='block';
1512
+ var _sv=document.getElementById('spend-view'); if(_sv) _sv.style.display='none';
1513
+ document.body.classList.add('detail-open');
1514
+ var dv=document.getElementById('detail-view');
1515
+ dv.setAttribute('data-sid','');
1516
+ dv.innerHTML='<div class="detail-header"><span class="back-btn" onclick="hideD()">\\u2190 back \\u00b7 <span class="kbd">Esc</span></span></div>'
1517
+ +'<div class="empty-state" data-verify-unit="task-not-found"><h2>No task with this id here.</h2>'
1518
+ +'<p style="color:var(--fg-50);font-size:13px;margin-top:6px" data-user-content>'+String(id).slice(0,80).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];})
1519
+ +'</p><p style="color:var(--fg-40);font-size:12px;margin-top:10px">It may live in a project\\u2019s older history (open that project first), or on another machine \\u2014 task links don\\u2019t travel between daemons; the signed record does.</p></div>';
1520
+ }
1521
+ if(typeof window!=='undefined') window.addEventListener('hashchange', _routeFromHash);
1522
+ function showD(id){
1523
+ if(id!==_detail){ _artOpen=false; _stepOpen={}; _receiptOpen={}; _glMoreOpen={}; } // a different task starts with drawer closed + collapsed receipt + no step expansions carried over
1524
+ _detail=id;
1525
+ try{ var _h=_hashForDetail(); if(location.hash!==_h) location.hash=_h; }catch(e){} // project state → URL (not the reverse)
1526
+ const t=findS(id);
1527
+ if(!t){_detail=null;return;}
1528
+ document.getElementById('task-list-view').style.display='none';
1529
+ document.getElementById('task-detail-view').style.display='block';
1530
+ var _sv2=document.getElementById('spend-view'); if(_sv2) _sv2.style.display='none';
1531
+ // R10 fix · when detail-view opens, hide the global main composer so the
1532
+ // detail-view follow-up composer ("Ask a follow-up about this task...")
1533
+ // is the ONLY input visible. Pre-fix · BOTH composers rendered stacked,
1534
+ // both with "-> Auto · ~$0.02" chips · user couldn't tell which one
1535
+ // typed to which target. Surfaced 2026-05-24 by Ega.
1536
+ document.body.classList.add('detail-open');
1537
+ renderD(t);
1538
+ try{ updateCostEst(); }catch(e){}
1539
+ }
1540
+ function hideD(){
1541
+ _detail=null;
1542
+ // Leaving a task lands in the room (when one is open) — the URL says so too.
1543
+ try{
1544
+ var _h0=location.hash||'';
1545
+ if(_h0.indexOf('#/task/')===0||_h0==='#/spend'){
1546
+ var _rh=_roomHash();
1547
+ if(_rh){ if(_h0!==_rh) location.hash=_rh; }
1548
+ else history.replaceState(null,'',location.pathname+location.search);
1549
+ }
1550
+ }catch(e){}
1551
+ if(_ctlPoll){ clearInterval(_ctlPoll); _ctlPoll=null; } _ctlId=null; // stop control polling
1552
+ document.getElementById('task-list-view').style.display='';
1553
+ document.getElementById('task-detail-view').style.display='none';
1554
+ var _sv=document.getElementById('spend-view'); if(_sv) _sv.style.display='none';
1555
+ document.body.classList.remove('detail-open');
1556
+ render();
1557
+ }
1558
+ // Spend & Audit page: the team cost+audit ledger as its own destination (nav tab),
1559
+ // not a banner on the workspace. Loaded on demand from /api/team.
1560
+ function showSpend(){
1561
+ // Spend is a top-level place — entering it ENDS the room/detail context
1562
+ // (loop-7 verifier catch: without this the project row stayed .active
1563
+ // beside Spend's .here — two you-are-heres — and _detail went stale).
1564
+ if(_detail) hideD();
1565
+ if(_room) closeRoom();
1566
+ document.getElementById('task-list-view').style.display='none';
1567
+ document.getElementById('task-detail-view').style.display='none';
1568
+ document.body.classList.remove('detail-open');
1569
+ var sv=document.getElementById('spend-view'); if(sv) sv.style.display='block';
1570
+ try{ if(location.hash!=='#/spend') location.hash='#/spend'; }catch(e){}
1571
+ try{ renderSidebar(); }catch(e){} // the rail's you-are-here follows
1572
+ try{ renderTeamHero(); }catch(e){}
1573
+ }
1574
+
1575
+ // Open an observed host session as a THREAD · the meta-dogfood payoff: the
1576
+ // coding-agent session running in your own terminal, with every tool call
1577
+ // codehere scanned, in order, hash-chained.
1578
+ async function showHostSession(shortId){
1579
+ _detail='host:'+shortId;
1580
+ document.getElementById('task-list-view').style.display='none';
1581
+ document.getElementById('task-detail-view').style.display='block';
1582
+ document.body.classList.add('detail-open');
1583
+ const dv=document.getElementById('detail-view');
1584
+ dv.innerHTML='<div class="detail-header"><span class="back-btn" onclick="hideD()">\\u2190 Back</span></div><div style="color:var(--fg-40);font-size:12px;padding:20px 0">loading observed session...</div>';
1585
+ let d;
1586
+ try{ d=await fetch('/api/host-sessions/'+encodeURIComponent(shortId)).then(x=>x.json()); }
1587
+ catch(e){ dv.innerHTML='<div class="detail-header"><span class="back-btn" onclick="hideD()">\\u2190 Back</span></div><div style="color:var(--caught);padding:20px 0">Could not load session.</div>'; return; }
1588
+ if(!d||d.error){ dv.innerHTML='<div class="detail-header"><span class="back-btn" onclick="hideD()">\\u2190 Back</span></div><div style="color:var(--fg-40);padding:20px 0">'+he(d&&d.error||'No such session')+'</div>'; return; }
1589
+ function he(s){ return String(s).replace(/[&<>"]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
1590
+ function ago(ts){ const m=Math.floor((Date.now()-new Date(ts).getTime())/60000); if(m<1)return'just now'; if(m<60)return m+'m ago'; const h=Math.floor(m/60); if(h<24)return h+'h ago'; return Math.floor(h/24)+'d ago'; }
1591
+ function clock(ts){ try{ return new Date(ts).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'}); }catch{return'';} }
1592
+ const calls=d.calls||[];
1593
+ // Tool-call timeline · one row per observed call · time · tool · preview · state
1594
+ // No phase narrative here · raw terminal tool calls don't carry lifecycle
1595
+ // semantics (a dev session building codehere mentions "audit"/"build"/"test"
1596
+ // as domain words, so the classifier lies). Phases are a spawned-task concept.
1597
+ // Instead, group calls by tool so the timeline reads as activity, not noise.
1598
+ let timeline='';
1599
+ for(const c of calls){
1600
+ const tag = c.criticalFindings>0 ? '<span class="obs-call-tag crit">critical</span>'
1601
+ : c.blocked ? '<span class="obs-call-tag block">blocked</span>'
1602
+ : c.findings>0 ? '<span class="obs-call-tag warn">'+c.findings+' finding'+(c.findings===1?'':'s')+'</span>'
1603
+ : '<span class="obs-call-tag ok">scanned</span>';
1604
+ timeline+='<div class="obs-call'+(c.blocked?' is-block':'')+'">'
1605
+ +'<span class="obs-call-time">'+he(clock(c.timestamp))+'</span>'
1606
+ +'<span class="obs-call-tool">'+he(c.toolName||'')+'</span>'
1607
+ +'<span class="obs-call-prev">'+(c.preview?he(c.preview):'<span style="color:var(--fg-30)">\\u2014</span>')+'</span>'
1608
+ +tag
1609
+ +'</div>';
1610
+ }
1611
+ // Tool mix summary · gives the observed thread a real "outcome"-style header
1612
+ // (what KIND of work happened) without inventing phases that aren't there.
1613
+ const _mix={};
1614
+ for(const c of calls){ const k=c.toolName||'?'; _mix[k]=(_mix[k]||0)+1; }
1615
+ const _mixStr=Object.entries(_mix).sort((a,b)=>b[1]-a[1]).map(([k,v])=>he(k)+' '+v).join(' · ');
1616
+ // Three states, not two. A chain with no entries walks clean, so treating validity as a
1617
+ // boolean painted this green over a log that did not exist. It is not broken either — there
1618
+ // is simply nothing recorded, and DESIGN.md §1 reserves green for a measurement, which an
1619
+ // empty walk is not.
1620
+ const chainKnown=d.chainVerifiable!==false;
1621
+ const chainOk=chainKnown&&d.chainValid;
1622
+ // Chain-flow · the real audit chain as connected nodes (demo v12 .chain-flow,
1623
+ // gap 4.8). Condensed to KEY events: the first/last few + every blocked or
1624
+ // critical call (never hide those), with a gap marker for the elided middle.
1625
+ // Each node is a real chain entry — seq + tool + state — linked left->right.
1626
+ function chainFlow(cs){
1627
+ if(!cs.length) return '';
1628
+ function he(s){ return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
1629
+ function init(tool){ return (String(tool||'?').replace(/[^A-Za-z]/g,'')[0]||'?').toUpperCase(); }
1630
+ var keep=new Set();
1631
+ for(var i=0;i<cs.length;i++){ if(i<2||i>=cs.length-2||cs[i].blocked||cs[i].criticalFindings>0) keep.add(i); }
1632
+ var nodes='';
1633
+ var prevKept=-1;
1634
+ for(var j=0;j<cs.length;j++){
1635
+ if(!keep.has(j)) continue;
1636
+ if(prevKept>=0 && j-prevKept>1) nodes+='<span class="cf-gap" title="'+(j-prevKept-1)+' more scanned">···</span>';
1637
+ else if(prevKept>=0) nodes+='<span class="cf-link"></span>';
1638
+ var c=cs[j];
1639
+ var cls=c.criticalFindings>0?'crit':c.blocked?'block':'ok';
1640
+ var t=c.preview?he(c.preview).slice(0,60):he(c.toolName||'');
1641
+ nodes+='<span class="cf-node '+cls+'" title="'+(c.seq!=null?'#'+c.seq+' · ':'')+he(c.toolName||'')+' · '+t+'">'
1642
+ +'<span class="cf-seq">'+(c.seq!=null?'#'+c.seq:'')+'</span><span class="cf-tool">'+init(c.toolName)+'</span></span>';
1643
+ prevKept=j;
1644
+ }
1645
+ return '<div class="chain-flow-wrap"><div class="chain-flow">'+nodes+'</div>'
1646
+ +'<div class="cf-legend">spawn \\u203A latest · '+cs.length+' calls · each node is a hash-linked entry</div></div>';
1647
+ }
1648
+ const chainHtml='<div class="panel"><div class="panel-title">Audit chain</div>'
1649
+ +chainFlow(calls)
1650
+ +'<div class="verified-block" style="padding:14px 0">'
1651
+ +'<div class="badge" style="background:'+(!chainKnown?'var(--fg-30)':(chainOk?'var(--verified)':'var(--caught)'))+'">'+(!chainKnown?'\\u00b7':(chainOk?'\\u2713':'\\u2715'))+'</div>'
1652
+ +'<div class="k">'+(!chainKnown?'nothing recorded yet':(chainOk?'hash chain verified':'chain broken'))+'</div>'
1653
+ +'<div class="what">'+(!chainKnown
1654
+ ?'No audit entries on this machine. Install the hook to start recording: codehere hook install'
1655
+ :d.chainEntries+' entries · SHA-256 hash-chained · integrity-checked (catches edits after writing)')+'</div>'
1656
+ +'</div></div>';
1657
+ const oHtml='<div class="panel"><div class="panel-title">Observed</div>'
1658
+ +'<div class="outcome-item"><span class="check">'+ms(true)+'</span> '+d.totalCalls+' tool calls scanned by the hook</div>'
1659
+ +(d.blockedCalls>0?'<div class="outcome-item"><span class="cross">'+ms(false)+'</span> '+d.blockedCalls+' blocked before running</div>':'<div class="outcome-item"><span class="check">'+ms(true)+'</span> nothing blocked</div>')
1660
+ +(d.criticalCalls>0?'<div class="outcome-item"><span class="cross">'+ms(false)+'</span> '+d.criticalCalls+' critical finding'+(d.criticalCalls===1?'':'s')+'</div>':'')
1661
+ +(_mixStr?'<div class="outcome-item" style="color:var(--fg-50);font-family:var(--mono);font-size:12px"><span class="pending">\\u2022</span> '+_mixStr+'</div>':'')
1662
+ +'</div>';
1663
+ dv.innerHTML='<div class="detail-header">'
1664
+ +'<span class="back-btn" onclick="hideD()">\\u2190 Back</span>'
1665
+ +'<div class="detail-title-wrap">'
1666
+ +'<div class="detail-kicker">observed · runs in your terminal · read-only</div>'
1667
+ +'<h1 class="detail-title">'+he(d.sessionId)+'</h1>'
1668
+ +'</div></div>'
1669
+ +'<div class="detail-meta"><span class="status-icon done"></span> last activity '+he(ago(calls.length?calls[calls.length-1].timestamp:Date.now()))+' · '+d.totalCalls+' calls</div>'
1670
+ +oHtml+chainHtml
1671
+ +'<div class="panel"><div class="panel-title">Tool calls ('+calls.length+')</div><div class="obs-timeline">'+timeline+'</div></div>'
1672
+ // Honest interaction affordance · an observed session runs in the user's own
1673
+ // terminal; codehere watches it read-only via the hook and cannot inject a
1674
+ // prompt into it. So NO fake composer (that would be a screen-lie). Instead,
1675
+ // an honest note + a real action: fork the context into a managed agent the
1676
+ // user CAN drive from here.
1677
+ +'<div class="obs-readonly-note">'
1678
+ +'<div class="t">This session runs in your terminal. codehere observes it read-only · it can\\'t send a prompt back into your shell. Continue the work in a managed agent codehere CAN drive:</div>'
1679
+ +'<button class="obs-fork-btn" id="ctl-open-btn" onclick="controlManagedAgent(\\''+he(d.sessionId)+'\\')">Continue in a managed agent \\u203A</button>'
1680
+ +'<div id="control-panel"></div>'
1681
+ +'</div>';
1682
+ }
1683
+
1684
+ // Remote-control · open a codehere-managed agent (codex) and DRIVE it from here:
1685
+ // open a control session, then inject() messages into the running turn via the
1686
+ // daemon control endpoints. The observed terminal session stays read-only; this
1687
+ // is a real agent codehere owns end-to-end (turn/steer).
1688
+ let _ctlId=null, _ctlPoll=null;
1689
+ async function controlManagedAgent(sessionId){
1690
+ const btn=document.getElementById('ctl-open-btn');
1691
+ const panel=document.getElementById('control-panel');
1692
+ if(!panel)return;
1693
+ if(btn)btn.disabled=true, btn.textContent='opening managed agent…';
1694
+ try{
1695
+ const r=await fetch('/api/control/open',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent:'codex',prompt:'Continue the work from terminal session '+sessionId})}).then(x=>x.json());
1696
+ if(!r.controlId){ if(btn)btn.disabled=false,btn.textContent='Continue in a managed agent \\u203A'; al('Control open failed: '+(r.error||'unknown')); return; }
1697
+ _ctlId=r.controlId;
1698
+ if(btn)btn.style.display='none';
1699
+ // Honest "Remote Control · live" affordance — rendered ONLY here, after a
1700
+ // real channel opened. data-verify-channel="live" lets /verify assert the
1701
+ // badge can never exist without a live channel (anti-screen-lie invariant).
1702
+ panel.innerHTML='<div class="ctl-badge" data-verify-unit="remote-control" data-verify-channel="live"><span class="lv"></span> Remote Control \\u00b7 live</div>'
1703
+ +'<div class="ctl-head">managed agent \\u00b7 <span class="ag">codex</span> \\u00b7 thread <code>'+r.threadId+'</code> \\u00b7 steer it below</div>'
1704
+ +'<div class="ctl-steer">'
1705
+ + '<span class="chip" data-steer="continue">\\u203A continue</span>'
1706
+ + '<span class="chip" data-steer="run the tests">run the tests</span>'
1707
+ + '<span class="chip" data-steer="show me the diff">show me the diff</span>'
1708
+ + '<span class="chip stop" data-steer="__stop">\\u2715 stop steering</span>'
1709
+ +'</div>'
1710
+ +'<div class="ctl-feed" id="ctl-feed"></div>'
1711
+ +'<div class="ctl-composer"><input id="ctl-input" placeholder="Steer this live session\\u2026 (goes to the real running agent)"/><button id="ctl-send">Send \\u2191</button></div>'
1712
+ +'<div class="ctl-channel"><span class="lv"></span> <b>live channel attached</b> \\u00b7 steers recorded in audit chain \\u00b7 reachable from any device you\\'re signed in on</div>';
1713
+ document.getElementById('ctl-send').onclick=ctlInject;
1714
+ document.getElementById('ctl-input').addEventListener('keydown',e=>{ if(e.key==='Enter')ctlInject(); });
1715
+ panel.querySelectorAll('.ctl-steer .chip').forEach(function(c){ c.onclick=function(){ var s=c.getAttribute('data-steer'); if(s==='__stop'){ctlStop();return;} var inp=document.getElementById('ctl-input'); if(inp){ inp.value=s; ctlInject(); } }; });
1716
+ ctlPollEvents();
1717
+ _ctlPoll=setInterval(ctlPollEvents,1500);
1718
+ }catch(e){ if(btn)btn.disabled=false,btn.textContent='Continue in a managed agent \\u203A'; al('Network error: '+e.message); }
1719
+ }
1720
+ async function ctlInject(){
1721
+ const inp=document.getElementById('ctl-input'); if(!inp||!_ctlId)return;
1722
+ const msg=inp.value.trim(); if(!msg)return;
1723
+ inp.value='';
1724
+ try{ const r=await fetch('/api/control/'+_ctlId+'/inject',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:msg})}).then(x=>x.json());
1725
+ if(!r.accepted)al('Inject not accepted');
1726
+ setTimeout(ctlPollEvents,300);
1727
+ }catch(e){ al('Inject failed: '+e.message); }
1728
+ }
1729
+ // Stop steering from here. Honest scope: detaches the control channel (stops
1730
+ // polling + closes the panel). It does NOT claim to kill the remote process —
1731
+ // that would be an unverifiable overclaim. The badge disappears with the panel.
1732
+ function ctlStop(){
1733
+ if(_ctlPoll){ clearInterval(_ctlPoll); _ctlPoll=null; }
1734
+ _ctlId=null;
1735
+ const panel=document.getElementById('control-panel');
1736
+ if(panel)panel.innerHTML='<div class="ctl-evt dim">stopped steering \\u00b7 channel detached \\u00b7 the agent keeps running in its own session</div>';
1737
+ }
1738
+ async function ctlPollEvents(){
1739
+ if(!_ctlId)return;
1740
+ try{ const r=await fetch('/api/control/'+_ctlId+'/events').then(x=>x.json());
1741
+ const feed=document.getElementById('ctl-feed'); if(!feed)return;
1742
+ function he(s){ return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
1743
+ feed.innerHTML=(r.events||[]).map(e=>{ const it=(e.params||{}).item||{}; const t=it.text||it.command||it.type||e.method; return '<div class="ctl-evt">'+he(t)+'</div>'; }).join('')||'<div class="ctl-evt dim">agent working…</div>';
1744
+ }catch{}
1745
+ }
1746
+
1747
+ // ── Phase model · the demo v12 lifecycle spine ────────────────────────────
1748
+ // A thread reads as a phased narrative (research -> build -> ship -> audit -> live),
1749
+ // not a flat step list. Steps don't carry phase labels, so classify by the
1750
+ // dominant signal in the label/output. Order matters: test later phases first
1751
+ // so a "run tests" step lands in ship, not research.
1752
+ // Phase classifier · PHASE_ORDER / PHASE_META / stepPhase / classifySteps are
1753
+ // injected from the unit-tested module (thread-phase.ts) — one source of truth.
1754
+ // The \\bcode\\b bug that mislabeled read steps as BUILD is why this is no longer
1755
+ // inline-untested.
1756
+ ${PHASE_CLASSIFIER_SRC}
1757
+ // Chapter-card divider between phases · demo v12 .chapter-card pattern.
1758
+ function chapterCard(prevPhase,nextPhase){
1759
+ var prev=PHASE_META[prevPhase], next=PHASE_META[nextPhase];
1760
+ return '<div class="chapter-divider">'
1761
+ +(prev?'<span class="ch-prev">\\u2713 '+prev.done+'</span>':'<span class="ch-prev"></span>')
1762
+ +'<span class="ch-line"></span>'
1763
+ +'<span class="ch-next">Phase '+next.n+' of 5 \\u00B7 <b>'+next.label+'</b></span>'
1764
+ +'</div>';
1765
+ }
1766
+
1767
+ ${ARTIFACTS}
1768
+
1769
+ ${CONVERSATION_TURN}
1770
+ function __rememberConvOpen(el){
1771
+ try{
1772
+ var sid=el&&el.getAttribute('data-sid');
1773
+ if(sid) _convOpenByTask[sid]=!!el.open;
1774
+ }catch(e){}
1775
+ }
1776
+ function __rememberStepOpen(el){
1777
+ try{
1778
+ var k=el&&el.getAttribute('data-stepk');
1779
+ if(k) _stepOpen[decodeURIComponent(k)]=!!el.open;
1780
+ }catch(e){}
1781
+ }
1782
+ function __rememberReceiptOpen(el){
1783
+ try{
1784
+ var k=el&&el.getAttribute('data-rsid');
1785
+ if(k) _receiptOpen[decodeURIComponent(k)]=!!el.open;
1786
+ }catch(e){}
1787
+ }
1788
+ // Same render-wipe guard for the glance turn's collapsed full report — without
1789
+ // it the poll morph strips the open attribute and the report snaps shut mid-read.
1790
+ function __rememberGlOpen(el){
1791
+ try{
1792
+ var k=el&&el.getAttribute('data-glsid');
1793
+ if(k) _glMoreOpen[decodeURIComponent(k)]=!!el.open;
1794
+ }catch(e){}
1795
+ }
1796
+ // Unified timeline (task #51) · the conversation IS the spine, and the tool
1797
+ // activity is woven INTO it as inline, collapsed-by-default events (native
1798
+ // <details>, no JS), grouped by EMERGENT sections (segmentSteps — dynamic, not
1799
+ // a fixed pipeline). Turn timestamps are display strings and step times are
1800
+ // epoch (not clock-comparable), so we place the activity block right where
1801
+ // execution starts: after the first non-user turn (routing / first agent),
1802
+ // before that agent reports back. Reads correct for seeded dialogues and for
1803
+ // real single-agent runs (where turns are sparse and the steps dominate).
1804
+ // Internal tool ids leak into step labels ("mcp__plugin_context-mode_context-
1805
+ // mode__ctx_batch_execute", "Claude requested permissions to use mcp__...").
1806
+ // Humanize the SUMMARY line only — the expanded output keeps the raw evidence,
1807
+ // so the record stays honest while the conversation reads like a person did it.
1808
+ function humanizeStep(s){
1809
+ var x=String(s||'');
1810
+ x=x.replace(/mcp__([A-Za-z0-9_-]+)__([A-Za-z0-9_-]+)/g,function(_,srv,tool){
1811
+ srv=srv.replace(/^plugin[_-]/,'');
1812
+ var m=srv.match(/^(.+?)[_-]\\1$/); if(m) srv=m[1];
1813
+ return tool.replace(/_/g,' ')+' \\u00b7 '+srv;
1814
+ });
1815
+ x=x.replace(/Claude requested permissions? to use\\s+[^,.]+[^\\u00b7]*/i,'needed a permission that was not granted');
1816
+ // Bare internal tool names ("ToolSearch") read like code — split the camelCase.
1817
+ if(/^[A-Z][a-z]+(?:[A-Z][a-z]+)+$/.test(x)) x=x.replace(/([a-z])([A-Z])/g,'$1 $2').toLowerCase();
1818
+ return x;
1819
+ }
1820
+ function renderTimeline(t, wrapCtxUrls){
1821
+ var turns=Array.isArray(t.turns)?t.turns:[];
1822
+ var steps=(t.toolTrace||t.steps||[]);
1823
+ if(!turns.length && !steps.length) return '';
1824
+ function evtRow(z){
1825
+ var raw=String(z.label||'');
1826
+ // Write-pass · the one moment the human MUST act. Keep the rich primitive
1827
+ // (Approve+send opens the write-pass modal) rather than collapsing it to a
1828
+ // one-liner — collapsing would orphan the approval trigger.
1829
+ if(/^\\s*w-?pass\\b|^\\s*write-pass\\b/i.test(raw)){
1830
+ var parts=raw.split(/\\s*·\\s*/);
1831
+ var wtitle=(parts[1]||raw||'write awaits').replace(/</g,'&lt;');
1832
+ var wtail=(parts.slice(2).join(' · ')||(z.state==='active'?'awaiting approval':'')).replace(/</g,'&lt;');
1833
+ var pHtml=(z.output?String(z.output):'').replace(/</g,'&lt;')
1834
+ .replace(/(in-progress|pending|todo|backlog)/g,'<span class="from">$1</span>')
1835
+ .replace(/(\\u2192|->)/g,'<span class="arrow">$1</span>')
1836
+ .replace(/(done|approved|merged|shipped|sent)/g,'<span class="val">$1</span>');
1837
+ return '<div class="write-pass" style="margin-left:25px">'
1838
+ +'<div class="head"><span>write-pass</span><span class="tail" data-user-content style="color:var(--fg-50);letter-spacing:normal;text-transform:none">'+wtail+'</span></div>'
1839
+ +'<h4 data-user-content>'+wtitle+'</h4>'
1840
+ +(pHtml?'<div class="payload" data-user-content>'+pHtml+'</div>':'')
1841
+ +'<div class="actions"><button class="primary" onclick="if(window.openWpModal)openWpModal()">Approve + send</button></div>'
1842
+ +'</div>';
1843
+ }
1844
+ var lbl=wrapCtxUrls(humanizeStep(raw).replace(/</g,'&lt;'));
1845
+ var cls='allow', txt='L0';
1846
+ if(z.state==='failed'){ cls='block'; txt=/block|denied|refused|permission/i.test(String(z.output||z.errorText||z.label||''))?'BLOCKED':'FAILED'; }
1847
+ else if(z.state==='active'){ cls='clean'; txt='RUN'; }
1848
+ // Active step · the agent is working on this RIGHT NOW. Render the live
1849
+ // .thinking dots + streaming .repl-beat instead of a collapsed details row,
1850
+ // so the reasoning primitive renders on a real active step (not only in the
1851
+ // dead toolCardsHtml path). SSE refetch swaps it for a completed row when
1852
+ // the step finishes.
1853
+ if(z.state==='active'){
1854
+ var beat=z.output?ansiToHtml(String(z.output).slice(0,300)):lbl;
1855
+ return '<div class="tl-evt '+cls+' tl-active">'
1856
+ +'<div class="tl-lbl" data-user-content>'+lbl+'</div>'
1857
+ +'<div class="thinking"><span class="dots"><span></span><span></span><span></span></span>working</div>'
1858
+ +'<div class="repl-beat" data-user-content>'+beat+'<span class="cursor"></span></div>'
1859
+ +'</div>';
1860
+ }
1861
+ var dur=(z.completedAt&&z.startedAt)?'<span class="tl-dur">'+(Math.round((z.completedAt-z.startedAt)/100)/10)+'s</span>':'';
1862
+ // Lift the step's OUTCOME into the summary line so the conversation reads as
1863
+ // what HAPPENED ("Edit x · +59 lines · change applied"), not a hollow label.
1864
+ // The meaningless "L0" badge is dropped for normal completed steps; the badge
1865
+ // stays only for failed/blocked states, which are worth flagging.
1866
+ var oc=String(z.output==null?'':z.output).trim();
1867
+ var rawFirst=oc.split(/\\r?\\n/)[0]||'';
1868
+ var ocFirst=humanizeStep(rawFirst);
1869
+ var ocHtml=ocFirst?'<span class="tl-outcome" data-user-content>'+wrapCtxUrls(ocFirst.slice(0,72).replace(/</g,'&lt;'))+'</span>':'';
1870
+ // Compare raw-to-raw: humanizeStep shortens the first line, and comparing
1871
+ // the raw total against the shortened line faked a "more to expand" state.
1872
+ var more=(oc.length>rawFirst.length+2)||(rawFirst.length>72);
1873
+ var badge=(cls!=='allow')?'<span class="tl-badge '+cls+'">'+txt+'</span>':'';
1874
+ var out=more?renderStepOutput(z):'';
1875
+ // The raw label stays one hover away (title attr) — humanize rewrites the
1876
+ // summary, it must not erase the original evidence from the DOM.
1877
+ var rawTitle=(humanizeStep(raw)!==raw)?' title="'+raw.replace(/"/g,'&quot;').replace(/</g,'&lt;')+'"':'';
1878
+ // Persist expansion across the poll re-render (systemic render-wipe sweep):
1879
+ // key on task + step signature so an expanded step stays open. Only
1880
+ // expandable rows (more) get the key + ontoggle; flat rows have nothing.
1881
+ var _sk=(t.sessionId||'')+'|'+(z.startedAt||'')+'|'+raw;
1882
+ var _stepIsOpen=more && !!_stepOpen[_sk];
1883
+ var _stepAttrs=more?(' ontoggle="__rememberStepOpen(this)" data-stepk="'+encodeURIComponent(_sk)+'"'+(_stepIsOpen?' open':'')):'';
1884
+ return '<details class="tl-evt '+cls+(more?'':' tl-flat')+'"'+_stepAttrs+'><summary><span class="tl-caret">\\u25B8</span><span class="tl-lbl" data-user-content'+rawTitle+'>'+lbl+'</span>'+ocHtml+dur+badge+'</summary>'+out+'</details>';
1885
+ }
1886
+ // The agent's own words (kind:'say') — rendered as the agent speaking, with
1887
+ // its avatar, distinct from the collapsed tool events.
1888
+ function thinkRow(st){
1889
+ // The agent's interleaved reasoning — muted, with a "thinking" marker, full
1890
+ // markdown (so code in reasoning renders properly), no trim.
1891
+ return '<div class="tl-think" data-verify-unit="think"><span class="tl-think-tag">\\u29B8 thinking</span><div class="tl-think-text report-md" data-user-content>'+renderReportMd(st.label)+'</div></div>';
1892
+ }
1893
+ function askRow(st){
1894
+ // The agent is asking the user. Renders the question + options as a real
1895
+ // card; clicking an option (single-select) sends the answer, which continues
1896
+ // the SAME session via /api/task/:id/message (--resume). Already answered ->
1897
+ // shows the chosen answer, locked.
1898
+ var q=st.question||{questions:[]};
1899
+ var answered=st.answered||_answeredAsks[(t.sessionId||'')+'|'+(q.toolUseId||'')];
1900
+ var anyMulti=(q.questions||[]).some(function(x){return x.multiSelect;});
1901
+ var qs=(q.questions||[]).map(function(qq){
1902
+ var opts=(qq.options||[]).map(function(o){
1903
+ var picked=answered && (answered.indexOf?answered.indexOf(o.label)>=0:false);
1904
+ return '<div class="ask-opt'+(picked?' picked':'')+'" data-ask-label="'+convHe(o.label)+'"'+(answered?'':' onclick="chAnswerOpt(this)"')+'><span class="ask-opt-label" data-user-content>'+convHe(o.label)+'</span>'+(o.description?'<span class="ask-opt-desc" data-user-content>'+convHe(o.description)+'</span>':'')+'</div>';
1905
+ }).join('');
1906
+ return '<div class="ask-q">'+(qq.header?'<div class="ask-q-head" data-user-content>'+convHe(qq.header)+'</div>':'')+'<div class="ask-q-text" data-user-content>'+convHe(qq.question)+'</div><div class="ask-opts">'+opts+'</div></div>';
1907
+ }).join('');
1908
+ var foot = answered ? '<div class="ask-answered">\\u2713 answered: <span data-user-content>'+convHe((answered.join?answered.join(', '):String(answered)))+'</span></div>'
1909
+ : (anyMulti ? '<div class="ask-foot"><button class="ask-send" onclick="chAnswerSend(this)">Send answer</button></div>' : '<div class="ask-hint">click an option to answer \\u00b7 the agent continues from here</div>');
1910
+ return '<div class="tl-ask'+(answered?' answered':'')+'" data-verify-unit="ask"'+(anyMulti?' data-ask-multi="1"':'')+(q.toolUseId?' data-ask-tuid="'+convHe(q.toolUseId)+'"':'')+'><div class="tl-ask-head">'+ab(t.agent,'sm')+'<span class="tl-ask-title">The agent is asking you</span></div>'+qs+foot+'</div>';
1911
+ }
1912
+ function sayRow(st){
1913
+ return '<div class="tl-say" data-verify-unit="say" data-verify-textlen="'+String((st.label||'').trim().length)+'">'+ab(t.agent,'sm')+'<div class="tl-say-text report-md" data-user-content>'+renderReportMd(st.label)+'</div></div>';
1914
+ }
1915
+ // Single in-order pass so the agent's words + its tool actions interleave the
1916
+ // way they actually happened ("explains -> acts"). Dynamic ⧁ section dividers
1917
+ // appear when a RUN of tool actions shifts activity (segmentSteps logic,
1918
+ // inline); a 'say' breaks the run so the next tool reopens a section.
1919
+ function activityBlock(){
1920
+ if(!steps.length) return '';
1921
+ // Inline audit-line · the first beat of the timeline makes the local audit
1922
+ // chain VISIBLE in the flow (not just in the footer). ONLY state-derived
1923
+ // claims: the pre-prompt scan genuinely runs on every spawn
1924
+ // (session-manager scanPromptBeforeSpawn, all adapters), but its VERDICT
1925
+ // isn't plumbed to the task object — so say it ran, never that it was
1926
+ // "clean". "L0 hooks loaded" / "writes require approval" were cut by the
1927
+ // 2026-06-07 review: claude-only / opt-in respectively — false as blanket
1928
+ // claims for the other vendors and the default posture.
1929
+ var _aAgent=(agentLabel(t.agent)||'agent').toLowerCase();
1930
+ var _aFiles=((t.artifacts&&t.artifacts.files)||[]).length;
1931
+ var html='<div class="audit-line allow"><span class="seq">#0001</span>'
1932
+ +'pre-prompt scan ran \\u00b7 routed to <b>'+_aAgent+'</b>'
1933
+ +(_aFiles?' \\u00b7 context: <b>'+_aFiles+' file'+(_aFiles===1?'':'s')+'</b>':'')
1934
+ +'</div>';
1935
+ var curPhase=null, openActs=false;
1936
+ var close=function(){ if(openActs){ html+='</div>'; openActs=false; } };
1937
+ for(var i=0;i<steps.length;i++){
1938
+ var st=steps[i];
1939
+ if(st.kind==='say'){ close(); curPhase=null;
1940
+ // Don't repeat the distilled report here — it's already the top summary
1941
+ // bubble (renderD summHtml). Showing the exact same words twice is the
1942
+ // duplication the summary-bubble comment warns against.
1943
+ var _rep=(t.finalReport&&t.finalReport.summary)||'';
1944
+ if(_rep&&String(st.label||'').trim()===String(_rep).trim()){ continue; }
1945
+ html+=sayRow(st); continue; }
1946
+ if(st.kind==='think'){ close(); curPhase=null; html+=thinkRow(st); continue; }
1947
+ if(st.kind==='ask'){ close(); curPhase=null; html+=askRow(st); continue; }
1948
+ var ph=(typeof stepPhase==='function'?stepPhase(st):null)||curPhase||'research';
1949
+ // S12 · the existing phase dividers upgraded to the NUMBERED chapter
1950
+ // style (01 RESEARCH · 02 BUILD · …) — same classifier, same .tl-div
1951
+ // class (the F-THREAD-PHASED probe keeps matching), richer staging.
1952
+ if(ph!==curPhase){
1953
+ close();
1954
+ var _pm=(typeof PHASE_META!=='undefined'&&PHASE_META[ph])||null;
1955
+ var _plabel=(_pm&&_pm.label)||ph;
1956
+ // No canonical phase number prefix: steps render chronologically, so a
1957
+ // phase index (research=01…ship=03) reads out of order (03 · 01 · 02 · 03)
1958
+ // and looks broken. The phase word alone marks what kind of work it is.
1959
+ html+='<div class="tl-div"><span class="tl-verb">'+convHe(_plabel)+'</span><span class="tl-ln"></span></div>';
1960
+ curPhase=ph;
1961
+ }
1962
+ if(!openActs){ html+='<div class="tl-acts">'; openActs=true; }
1963
+ html+=evtRow(st);
1964
+ }
1965
+ close();
1966
+ return html;
1967
+ }
1968
+ // split point: just after the first non-user (routing / agent) turn
1969
+ var splitAt=0;
1970
+ for(var i=0;i<turns.length;i++){ var sp=turns[i].speaker||turns[i].role; if(isAgentSpeaker(sp)){ splitAt=i+1; break; } }
1971
+ if(!turns.length) splitAt=0;
1972
+ var head=turns.slice(0,splitAt).map(renderTurn).join('');
1973
+ var tail=turns.slice(splitAt).map(renderTurn).join('');
1974
+ // DESIGN.md §4.3 — collapse the step wall into a one-line receipt. Evidence
1975
+ // one click away, never always-on. The room meta line already carries the
1976
+ // room-level summary (duration · cost · steps · artifacts), so the receipt
1977
+ // summary labels what expands (the trace) without re-stating cost/files.
1978
+ function receiptWrap(inner){
1979
+ if(!steps.length) return inner; // nothing to collapse (routing-only turn)
1980
+ var _rsid=(t.sessionId||'');
1981
+ var _open=!!_receiptOpen[_rsid];
1982
+ var _tests=(t.artifacts&&t.artifacts.tests)||null;
1983
+ var _tp=_tests?(_tests.passed||0):0, _tf=_tests?(_tests.failed||0):0;
1984
+ var parts=[steps.length+' step'+(steps.length===1?'':'s')];
1985
+ if(_tests&&(_tp+_tf)>0) parts.push(_tp+'/'+(_tp+_tf)+' tests');
1986
+ var summary=parts.join(' \\u00b7 ');
1987
+ return '<details class="tl-receipt" data-rsid="'+convHe(_rsid)+'" ontoggle="__rememberReceiptOpen(this)"'+(_open?' open':'')
1988
+ +'><summary class="rcpt-row"><span class="tl-caret">\\u25B8</span><span class="rcpt-sum">'+summary+'</span><span class="rcpt-view">view receipt \\u203A</span></summary>'
1989
+ +'<div class="rcpt-body">'+inner+'</div></details>';
1990
+ }
1991
+ return '<div class="panel tl-panel"><div class="panel-title">Conversation</div>'+head+receiptWrap(activityBlock())+tail+'</div>';
1992
+ }
1993
+
1994
+ // Recursive DOM morph: reconcile an old node toward a new one, descending so
1995
+ // only the leaves that actually changed get touched — a new timeline step
1996
+ // appends its row, every existing row keeps its exact DOM node (no flicker, no
1997
+ // restarted animation). isEqualNode short-circuits any unchanged subtree.
1998
+ function morphNode(o, n){
1999
+ if(o.nodeType!==n.nodeType){ o.replaceWith(n); return; }
2000
+ if(o.nodeType===3){ if(o.nodeValue!==n.nodeValue) o.nodeValue=n.nodeValue; return; }
2001
+ if(o.nodeType!==1){ if(!o.isEqualNode(n)) o.replaceWith(n); return; }
2002
+ if(o.tagName!==n.tagName){ o.replaceWith(n); return; }
2003
+ if(o.isEqualNode(n)) return;
2004
+ // sync attributes
2005
+ var oa=o.attributes, i;
2006
+ for(i=oa.length-1;i>=0;i--){ if(!n.hasAttribute(oa[i].name)) o.removeAttribute(oa[i].name); }
2007
+ var na=n.attributes;
2008
+ for(i=0;i<na.length;i++){ if(o.getAttribute(na[i].name)!==na[i].value) o.setAttribute(na[i].name, na[i].value); }
2009
+ // reconcile child nodes (text + elements) by index — content here is
2010
+ // append-mostly (streaming steps), so positional matching holds
2011
+ var nNodes=Array.prototype.slice.call(n.childNodes);
2012
+ for(i=0;i<nNodes.length;i++){
2013
+ var oNode=o.childNodes[i];
2014
+ if(!oNode){ o.appendChild(nNodes[i]); continue; }
2015
+ morphNode(oNode, nNodes[i]);
2016
+ }
2017
+ while(o.childNodes.length>nNodes.length){ o.removeChild(o.lastChild); }
2018
+ }
2019
+ // Layer 3 · answer the agent's question. Clicking an option (single-select)
2020
+ // sends immediately; multi-select toggles + a Send button. The answer POSTs to
2021
+ // the existing /api/task/:id/message route -> appendMessage continues the SAME
2022
+ // claude session via --resume, and its steps merge back into the timeline.
2023
+ function chAnswerOpt(el){
2024
+ var card=el.closest('.tl-ask'); if(!card||card.classList.contains('answered'))return;
2025
+ if(card.getAttribute('data-ask-multi')==='1'){ el.classList.toggle('picked'); return; }
2026
+ chSendAnswer(card,[el.getAttribute('data-ask-label')]);
2027
+ }
2028
+ function chAnswerSend(btn){
2029
+ var card=btn.closest('.tl-ask'); if(!card)return;
2030
+ var picked=Array.prototype.slice.call(card.querySelectorAll('.ask-opt.picked')).map(function(o){return o.getAttribute('data-ask-label');});
2031
+ if(!picked.length)return;
2032
+ chSendAnswer(card,picked);
2033
+ }
2034
+ function chSendAnswer(card,labels){
2035
+ var sid=_detail; if(!sid)return;
2036
+ _answeredAsks[sid+'|'+(card.getAttribute('data-ask-tuid')||'')]=labels;
2037
+ var qtext=(card.querySelector('.ask-q-text')||{}).textContent||'';
2038
+ var answer='You asked: "'+qtext+'"\\nMy answer: '+labels.join(', ');
2039
+ card.classList.add('answered');
2040
+ card.querySelectorAll('.ask-opt').forEach(function(o){ o.removeAttribute('onclick'); if(labels.indexOf(o.getAttribute('data-ask-label'))>=0)o.classList.add('picked'); });
2041
+ var foot=card.querySelector('.ask-foot, .ask-hint');
2042
+ if(foot)foot.outerHTML='<div class="ask-answered">\\u2713 sending your answer\\u2026</div>';
2043
+ fetch('/api/task/'+encodeURIComponent(sid)+'/message',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:answer})})
2044
+ .then(function(r){return r.json();})
2045
+ .then(function(){ if(typeof fetchAll==='function'){ fetchAll(); } else if(_detail===sid){ showD(sid); } })
2046
+ .catch(function(){ var a=card.querySelector('.ask-answered'); if(a)a.textContent='could not send \\u00b7 try again'; });
2047
+ }
2048
+ // SAME-session live re-render -> morph in place (fine-grained, incl. inside the
2049
+ // timeline). First render / a different session -> full innerHTML write.
2050
+ // (Ega: append-only render, reconcile inside the timeline)
2051
+ function writeDetail(t, html){
2052
+ var dv=document.getElementById('detail-view');
2053
+ var sid=String((t&&t.sessionId)||'');
2054
+ var same=dv.getAttribute('data-sid')===sid && dv.childNodes.length>0;
2055
+ dv.setAttribute('data-sid', sid);
2056
+ if(!same){ dv.innerHTML=html; return; }
2057
+ var tmp=document.createElement('div'); tmp.innerHTML=html;
2058
+ var nw=Array.prototype.slice.call(tmp.childNodes);
2059
+ for(var i=0;i<nw.length;i++){
2060
+ var o=dv.childNodes[i];
2061
+ if(!o){ dv.appendChild(nw[i]); continue; }
2062
+ morphNode(o, nw[i]);
2063
+ }
2064
+ while(dv.childNodes.length>nw.length){ dv.removeChild(dv.lastChild); }
2065
+ }
2066
+ // The cross-vendor receipt panel — the proof a single-vendor tool can't make:
2067
+ // a DIFFERENT vendor reviewed an earlier writer in this room, the chain proves
2068
+ // both, here's the cost. Green ✓ only when the server marked it proven (a clean
2069
+ // read-only cross-vendor review whose chain verified); otherwise a neutral dot
2070
+ // + the honest caveats. codehere does NOT judge the findings here.
2071
+ function crossVendorPanel(cv){
2072
+ function esc(x){ return String(x==null?'':x).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
2073
+ var proven=!!cv.proven;
2074
+ var icon=proven?'\\u2713':'\\u00B7';
2075
+ var bg=proven?'var(--verified)':'var(--fg-30)';
2076
+ var k=proven?(esc(cv.reviewer.agent)+' reviewed '+esc(cv.writer.agent)+'\\u2019s work \\u00b7 a different vendor')
2077
+ :'cross-vendor review \\u00b7 not a clean proof yet';
2078
+ var cav='';
2079
+ if(cv.caveats&&cv.caveats.length){ for(var i=0;i<cv.caveats.length;i++){ cav+='<div class="outcome-item"><span class="pending">\\u2022</span> '+esc(cv.caveats[i])+'</div>'; } }
2080
+ // Corroboration by a different vendor reads as the brand-gradient SEAL (v2 mockup):
2081
+ // the one place the gradient earns its keep, the signature trust object.
2082
+ if(proven){
2083
+ return '<div class="seal-wrap" data-cross-vendor="proven"><div class="seal"><div class="seal-inner">'
2084
+ +'<div class="seal-check">\\u2713</div>'
2085
+ +'<div class="seal-txt">'
2086
+ +'<div class="seal-l1">Corroborated by <b>'+esc(cv.reviewer.agent)+'</b> \\u00b7 a different vendor</div>'
2087
+ +'<div class="seal-l2" data-user-content>'+esc(cv.summary)+'</div>'
2088
+ +'</div></div>'+cav+'</div></div>';
2089
+ }
2090
+ return '<div class="panel" data-cross-vendor="caveated">'
2091
+ +'<div class="panel-title">Cross-vendor review</div>'
2092
+ +'<div class="verified-block" style="padding:14px 0">'
2093
+ +'<div class="badge" style="background:'+bg+'">'+icon+'</div>'
2094
+ +'<div class="k">'+k+'</div>'
2095
+ +'<div class="what">'+esc(cv.summary)+'</div>'
2096
+ +'</div>'+cav+'</div>';
2097
+ }
2098
+ // Lazy-load the receipt for one session, cache it, and re-render the open detail
2099
+ // so the panel joins renderD's output (morph-stable). Sets null first so renderD
2100
+ // won't re-fetch; overwrites with the verdict only if the server returns one.
2101
+ async function loadCrossVendor(id){
2102
+ if(!id || Object.prototype.hasOwnProperty.call(_crossVendorByTask,id)) return;
2103
+ _crossVendorByTask[id]=null;
2104
+ try{
2105
+ const r=await fetch('/api/task/'+encodeURIComponent(id)+'/cross-vendor');
2106
+ if(!r.ok) return;
2107
+ const j=await r.json();
2108
+ const cv=j&&j.crossVendor;
2109
+ if(!cv) return; // stays null — not a cross-vendor review
2110
+ _crossVendorByTask[id]=cv;
2111
+ if(_detail===id){ const tt=findS(id); if(tt) renderD(tt); }
2112
+ }catch(e){}
2113
+ }
2114
+
2115
+ // REQUEST_METHOD §2 — the acceptance contract, rendered at the top of the room:
2116
+ // what "done" means, agreed up front, each criterion met (✓) / unmet (✕) /
2117
+ // pending (○). This is the keystone that turns the verdict from post-hoc vibe
2118
+ // into "did it meet the criteria we set before it ran". Empty => nothing renders.
2119
+ function contractBlockHtml(t){
2120
+ var cs=(t&&t.acceptanceCriteria)||[];
2121
+ if(!Array.isArray(cs)||!cs.length) return '';
2122
+ var rows=cs.map(function(c){
2123
+ var met=(c&&c.met);
2124
+ var g=met===true?'\\u2713':(met===false?'\\u2715':'\\u25CB');
2125
+ var cls=met===true?'met':(met===false?'unmet':'pending');
2126
+ return '<div class="ac-row '+cls+'"><span class="ac-g">'+g+'</span><span class="ac-t" data-user-content>'+convHe(String((c&&c.text)||''))+'</span></div>';
2127
+ }).join('');
2128
+ var n=cs.length, done=cs.filter(function(c){return c&&c.met===true;}).length;
2129
+ return '<div class="contract"><div class="ac-head">Done means<span class="ac-count">'+done+'/'+n+'</span></div>'+rows+'</div>';
2130
+ }
2131
+ function renderD(t) {
2132
+ const s=st(t.status), steps=(t.steps||[]), files=t.artifacts?.files||[], d=taskDur(t);
2133
+ const co=t.cost?t.cost.spent:null;
2134
+ const nStep=steps.length;
2135
+
2136
+ // R8/R9/R10/R11 — Final Report outcome only ✓ when prose summary actually exists.
2137
+ // Empty finalReport object or null/empty summary => honest "unavailable" outcome,
2138
+ // not a green check. Composer must offer a re-spawn affordance for these cases.
2139
+ const hasReportText = !!(t.finalReport && typeof t.finalReport.summary === 'string' && t.finalReport.summary.trim());
2140
+ // Outer-scope mirror of the conversation's default-open state (the real
2141
+ // _convOpen is block-scoped later). When the conversation shows the steps by
2142
+ // default, the separate collapsed "N steps" trace is redundant — hide it.
2143
+ const _convShown = steps.length>0 || !hasReportText;
2144
+ // §G R8 — green ✓ "Final Report" ONLY for a verified live-agent summary
2145
+ // (source === 'agent-summary'). Every other source — deterministic fallback,
2146
+ // error-recovery, mock synthesis — renders an honest ⚠, never a fake ✓.
2147
+ // POSITIVE allowlist (not deny-one) so a new/unknown source can't sneak a ✓.
2148
+ const reportSource = (t.finalReport && t.finalReport.source) || '';
2149
+ const isVerifiedAgentReport = reportSource === 'agent-summary';
2150
+ const isTerminal = ['completed','done','killed','failed','error','crashed'].includes(t.status);
2151
+ // The headline "N files changed" number. Normally derived from the actual
2152
+ // artifact list, so claim === rendered count, always. The optional
2153
+ // artifacts.filesClaimed override is the ONE seam where a headline number can
2154
+ // diverge from the rendered file rows — the honesty probe seeds exactly that
2155
+ // divergence so /verify's counts-consistent invariant has a real lie to catch.
2156
+ const claimedFiles = (t.artifacts && typeof t.artifacts.filesClaimed === 'number') ? t.artifacts.filesClaimed : files.length;
2157
+
2158
+ // S14 · the Outcome panel folded into the CLAIM/CHECKED/RESULT evidence
2159
+ // table (built AFTER the verdict section so the rows can reuse the same
2160
+ // staged verdict — same data-verify-unit="outcome" contract, same attrs).
2161
+ // Running/queued keep the live current-action presentation here.
2162
+ const _oAttrs=' data-verify-unit="outcome" data-verify-files-claimed="'+claimedFiles+'" data-verify-steps="'+nStep+'" data-verify-terminal="'+(isTerminal?'true':'false')+'" data-verify-has-report="'+(hasReportText?'true':'false')+'"';
2163
+ let oHtml='';
2164
+ if(t.status==='running'||t.status==='queued'){
2165
+ // Live current action — the agent's latest move, prominent and updated as
2166
+ // the timeline streams. A "0 of N done" counter reads as nothing happening;
2167
+ // the current action reads as the agent actually working. (Ega dogfood)
2168
+ var _last = nStep ? steps[steps.length-1] : null;
2169
+ var _act = _last ? compactLabel(_last.label||_last.tool||'') : '';
2170
+ oHtml='<div class="panel"'+_oAttrs+'><div class="run-now"><span class="run-pulse"></span><span class="run-now-label" data-user-content>'+(_act||'starting\\u2026')+'</span></div>'
2171
+ +(nStep?'<div class="outcome-item pending run-sub">'+nStep+' step'+(nStep===1?'':'s')+' so far \\u00b7 live</div>':'')
2172
+ +'</div>';
2173
+ }
2174
+
2175
+ // The summary object (the glance turn) is built AFTER the verdict section —
2176
+ // it composes the verdict line, so construction moved below vLine.
2177
+
2178
+ // E-03 · hook BLOCK affordance · render a red-bordered card when the session
2179
+ // was killed by the safety hook (status='error' with a 'blocked' tag or any
2180
+ // step in state='failed' carrying a known block signature). The user gets
2181
+ // three named actions; the audit chain captures whichever they pick.
2182
+ let blockHtml='';
2183
+ const blockedStep = (t.steps||[]).find(s => s.state==='failed' && /block|denied|refused/i.test(String(s.output||s.label||'')));
2184
+ if(blockedStep){
2185
+ const bSid=String(t.sessionId||'').replace(/'/g,'');
2186
+ const bTool=String(blockedStep.label||'blocked-op').replace(/"/g,'').slice(0,80);
2187
+ blockHtml = '<div class="hook-block-card" id="hook-block-card">'
2188
+ + '<div class="hook-block-title">safety hook · BLOCKED</div>'
2189
+ + '<div class="hook-block-msg">'+String(blockedStep.label||'').replace(/</g,'&lt;')+'</div>'
2190
+ + '<div class="hook-block-actions" id="hook-block-actions">'
2191
+ + '<button onclick="overrideBlock(\\''+bSid+'\\',\\''+bTool.replace(/'/g,'')+'\\')">Override + log reason</button>'
2192
+ + '<button onclick="a(\\'kill\\',\\''+bSid+'\\')">Kill session</button>'
2193
+ + '</div>'
2194
+ + '<div id="override-result" class="override-result"></div>'
2195
+ + '</div>';
2196
+ }
2197
+
2198
+ // E-07 · push gate · if a session attempted git push (detect via step label),
2199
+ // surface the gate. Same shape as hook-block but warn-tinted.
2200
+ let pushHtml='';
2201
+ const pushStep = (t.steps||[]).find(s => /git\\s*push/i.test(String(s.label||'')));
2202
+ if(pushStep && pushStep.state==='active'){
2203
+ pushHtml = '<div class="push-gate-card">'
2204
+ + '<div class="push-gate-title">push gate · awaiting confirm</div>'
2205
+ + '<div class="hook-block-msg">'+String(pushStep.label||'').replace(/</g,'&lt;')+'</div>'
2206
+ + '<div class="hook-block-actions">'
2207
+ // MED #13 (atomic audit) · these were toast-only stubs — the recovery-
2208
+ // card lie class (a button narrating an action it doesn't perform).
2209
+ // The daemon has no push-decision endpoint yet (F-PUSH-GATE is [partial]), so
2210
+ // the honest shape is disabled-with-reason until the wire exists.
2211
+ + '<button disabled title="not wired yet \\u00b7 the daemon has no push-decision endpoint (F-PUSH-GATE is in progress); run the push from your terminal">Push</button>'
2212
+ + '<button disabled title="not wired yet \\u00b7 the push command is shown in the step label above">Print command</button>'
2213
+ + '<button disabled title="not wired yet \\u00b7 stop the task instead to cancel its push">Cancel</button>'
2214
+ + '</div>'
2215
+ + '</div>';
2216
+ }
2217
+
2218
+ // G-U10 · crash recovery · when a persisted session shows orphaned state
2219
+ // (status='error' from a daemon death) or the session has no completedAt
2220
+ // despite status being non-running, show a recovery affordance.
2221
+ let crashHtml='';
2222
+ const looksOrphaned = (t.status==='error' || t.status==='crashed') && !t.completedAt;
2223
+ if(looksOrphaned){
2224
+ crashHtml = '<div class="crash-recovery">'
2225
+ + '<div class="crash-recovery-title">orphaned session</div>'
2226
+ + '<div style="color:var(--fg-70);font-size:12px">The daemon died before this task completed. Audit chain has the partial trail; rerun the prompt to continue.</div>'
2227
+ + '</div>';
2228
+ }
2229
+
2230
+ // P3 · Recovery affordance · session-level failure with named options.
2231
+ // Triggers when the session FAILED for reasons other than a deliberate
2232
+ // hook block (E-03 owns that case) and isn't an orphan (G-U10 owns
2233
+ // that). Common cases: agent crashed mid-run, provider error, test
2234
+ // suite failed, the task itself returned non-zero.
2235
+ let recoveryHtml='';
2236
+ const isFailed = ['failed','error','crashed','killed'].includes(t.status);
2237
+ const isOrphan = looksOrphaned;
2238
+ const hasBlock = !!blockedStep;
2239
+ if(isFailed && !isOrphan && !hasBlock){
2240
+ const lastStep = (t.steps||[])[(t.steps||[]).length-1];
2241
+ const lastErr = lastStep && (lastStep.error || lastStep.label || '');
2242
+ const reasonText = t.stalledReason
2243
+ ? '<b>What happened:</b> '+String(t.stalledReason).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]))
2244
+ : lastErr
2245
+ ? '<b>What failed:</b> '+String(lastErr).slice(0,200).replace(/</g,'&lt;')
2246
+ : '<b>What failed:</b> the agent exited with status <code style="background:var(--bg-2);padding:1px 5px;border-radius:3px;font-family:var(--mono);font-size:12px">'+t.status+'</code> · no specific error captured';
2247
+ // Thariq loop-1 · honest recovery: every action is REAL or honestly
2248
+ // disabled-with-reason — no button text asserts what the click won't do
2249
+ // (the §3 binding rule; the recovery-buttons-must-not-lie probe enforces
2250
+ // it). Retry re-POSTs /api/task with this task's own prompt/cwd/agent;
2251
+ // Diagnose focuses the captured failing step; Mark-resolved writes a human
2252
+ // ack into the audit chain. State is exposed via data-verify-recovery.
2253
+ const _canRetry = !!(t.prompt && String(t.prompt).trim());
2254
+ const _failStep = (t.steps||[]).find(s => s.state==='failed');
2255
+ const _hasFailStep = !!_failStep;
2256
+ const _rstate = _canRetry ? (_hasFailStep ? 'failed' : 'failed-no-step') : 'failed-no-prompt';
2257
+ const retryBtn = _canRetry
2258
+ ? '<button class="primary" data-verify-action="retry" onclick="recoverRetry(\\''+t.sessionId+'\\')">Retry this task <span class="kbd">\\u23ce</span></button>'
2259
+ : '<button class="primary" disabled data-verify-action="retry-disabled" title="the original prompt wasn\\u2019t recorded for this run, so it can\\u2019t be respawned">Retry</button>';
2260
+ const diagBtn = _hasFailStep
2261
+ ? '<button class="linkbtn" data-verify-action="diagnose" onclick="recoverDiagnose(\\''+t.sessionId+'\\')">see the failing step</button>'
2262
+ : '<button class="linkbtn" disabled data-verify-action="diagnose-disabled" title="this run captured no single failing step to focus">see the failing step</button>';
2263
+ recoveryHtml = '<div class="recovery-card" data-verify-unit="recovery" data-verify-recovery="'+(t.resolvedAt?'resolved':_rstate)+'">'
2264
+ + '<div class="recovery-title"><span class="dot"></span>Your call \\u00b7 this task did not complete cleanly</div>'
2265
+ + '<div class="recovery-cause">'+reasonText+'</div>'
2266
+ + '<div class="recovery-resolve"><b>codehere:</b> every step that DID run is in the audit chain. Most likely you just want to run it again \\u00b7 same prompt, same agent, same repo. Recovery doesn\\u2019t rewrite history.</div>'
2267
+ + '<div class="recovery-actions" style="align-items:center;gap:14px">'
2268
+ + retryBtn
2269
+ + diagBtn
2270
+ + (t.resolvedAt
2271
+ ? '<button class="linkbtn" disabled data-verify-action="resolve-done" title="already acknowledged \\u00b7 recorded in the audit chain">mark resolved</button>'
2272
+ : '<button class="linkbtn" data-verify-action="resolve" onclick="recoverResolve(\\''+t.sessionId+'\\',this)">mark resolved</button>')
2273
+ + '</div>'
2274
+ + (_canRetry ? '' : '<div class="recovery-reason">Retry is unavailable \\u00b7 this run\\u2019s prompt wasn\\u2019t recorded, so codehere can\\u2019t reconstruct the spawn. (An honest disable, not a dead button.)</div>')
2275
+ // S3 · the ack renders FROM TASK STATE so it sticks across reload —
2276
+ // the click-time badge alone vanished on the next render.
2277
+ + (t.resolvedAt ? '<div class="recovery-ack">\\u2713 acknowledged by you \\u00b7 recorded in the audit chain \\u00b7 '+new Date(t.resolvedAt).toLocaleString()+'</div>' : '')
2278
+ + '</div>';
2279
+ }
2280
+
2281
+ // S14 · files as quiet mono chips ("path +adds −dels", trust grade kept).
2282
+ // KEEPS the .file-tree class + the filetree data-verify contract: the
2283
+ // rendered count is the REAL chip count, so the probe-lie (claims 3, renders
2284
+ // 1) still FAILS H5. Test counts moved into the evidence table's CHECKED row.
2285
+ let chipsHtml='';
2286
+ const filesArr = (t.artifacts && t.artifacts.files) || [];
2287
+ if(filesArr.length){
2288
+ const esc=s=>String(s||'').replace(/</g,'&lt;');
2289
+ const grade=f=>(typeof f.trust==='number')?(f.trust>=85?'A':f.trust>=70?'B':f.trust>=50?'C':'F'):null;
2290
+ chipsHtml='<div class="file-tree fileschips" data-verify-unit="filetree" data-verify-rendered="'+filesArr.length+'">';
2291
+ filesArr.forEach(function(f){
2292
+ const g=grade(f);
2293
+ chipsHtml+='<span class="fchip"><b>'+esc(f.path)+'</b>'
2294
+ +(f.added>0?'<span class="added">+'+f.added+'</span>':'')
2295
+ +(f.removed>0?'<span class="removed">\\u2212'+f.removed+'</span>':'')
2296
+ +(g?'<span class="trust '+g+'">'+g+'</span>':'')
2297
+ +'</span>';
2298
+ });
2299
+ chipsHtml+='</div>';
2300
+ // Inline code-card · render under the chips for any file carrying a
2301
+ // .snippet. The reviewer sees WHAT got written, not just "+N lines".
2302
+ const cards = filesArr.filter(function(f){ return f && f.snippet; }).map(function(f){
2303
+ return codeCard(String(f.path||''), f.snippet, f.lang);
2304
+ }).join('');
2305
+ if(cards) chipsHtml += cards;
2306
+ }
2307
+
2308
+ // Non-git honesty · codehere derives file changes from git diff. In a folder
2309
+ // that isn't a git repo it captures zero file artifacts — NOT because nothing
2310
+ // changed, but because it can't see what did. Say so plainly rather than
2311
+ // letting an empty Files panel imply "no changes".
2312
+ let gitNoteHtml='';
2313
+ if(t.artifacts && t.artifacts.noGitTracking){
2314
+ gitNoteHtml = '<div class="panel git-note"><div class="panel-title">File tracking</div>'
2315
+ + '<div class="git-note-body">This folder isn\\'t a git repository, so codehere couldn\\'t track which files the agent changed. The steps below are what the agent reported. To see file changes and trust scores, run the task in a git repo (<code>git init</code> first).</div>'
2316
+ + '</div>';
2317
+ }
2318
+
2319
+ // Wrap context-source URLs (linear://X · github://Y · figma://Z · drawio://W)
2320
+ // in .ctx-url-chip so the existing overlay-renderer (window.codehereOpenOverlay)
2321
+ // can preview them. Otherwise the URLs render as plain mono · dead text in a
2322
+ // primitive we already shipped to act on. (Used by renderTimeline below —
2323
+ // the old parallel "Activity · phased" tool-card builder it served died in
2324
+ // S14: it was computed but never rendered since the task-#51 timeline merge.)
2325
+ function wrapCtxUrls(escapedText){
2326
+ return escapedText.replace(
2327
+ /(linear|github|figma|drawio):\\/\\/[A-Za-z0-9_\\/\\-.#?=&]+/g,
2328
+ function(m){ return '<span class="ctx-url-chip" data-url="'+m+'" title="click to preview">'+m+'</span>'; }
2329
+ );
2330
+ }
2331
+ // F-08 · token table · per-agent token + cost breakdown when artifacts.usage
2332
+ // is captured. Single row when one adapter ran, multi-row + total when
2333
+ // multiple agents handled this task (e.g., conductor -> specialist).
2334
+ let tokenHtml='';
2335
+ const u = t.artifacts && t.artifacts.usage;
2336
+ if(u && (u.inputTokens || u.outputTokens || u.cacheReadTokens)){
2337
+ const fmt=n=>n>0?n.toLocaleString():'<span class="dim">-</span>';
2338
+ const inT=u.inputTokens||0, outT=u.outputTokens||0;
2339
+ const cr=u.cacheReadTokens||0, cc=u.cacheCreationTokens||0;
2340
+ const total=inT+outT+cr+cc;
2341
+ tokenHtml = '<div class="panel"><div class="panel-title">Tokens</div>'
2342
+ + '<table class="token-table"><thead><tr>'
2343
+ + '<th>agent</th><th>input</th><th>output</th><th>cache read</th><th>cache new</th><th>total</th>'
2344
+ + '</tr></thead><tbody>'
2345
+ + '<tr><td>'+(t.agent||'agent')+'</td>'
2346
+ + '<td>'+fmt(inT)+'</td>'
2347
+ + '<td>'+fmt(outT)+'</td>'
2348
+ + '<td>'+fmt(cr)+'</td>'
2349
+ + '<td>'+fmt(cc)+'</td>'
2350
+ + '<td>'+fmt(total)+'</td></tr>'
2351
+ + '</tbody></table></div>';
2352
+ }
2353
+
2354
+ // F-07 / R11 · the verdict — direction E's calm dot + ONE-WORD line (S14).
2355
+ // Replaces the S12 jewel hero AND the separate Verifier panel: same
2356
+ // VerdictService verdict object, same branch gates, same data-verify
2357
+ // contracts (H1 no-verified-without-git + H9 no-green-on-failed read the
2358
+ // identical attributes off .vline). Only for terminal sessions with a
2359
+ // Final Report · running tasks have nothing to verify yet. Green appears
2360
+ // ONLY in the exact branch that was green before — the only green on the page.
2361
+ let verdictLine='';
2362
+ let verdictProv='';
2363
+ // DESIGN.md §1 · the evidence tier, DERIVED from what actually happened —
2364
+ // never stamped. applyTestObservation (domain/task.ts) is the one writer of
2365
+ // these fields, so this reads its output rather than re-deciding:
2366
+ // tests.observed codehere re-ran the suite and owns the exit code
2367
+ // tests.countSource 'claimed' = we ran it, but the NUMBER is the agent's
2368
+ // (a qualifier on a measured verdict, surfaced in prose)
2369
+ // testsUnobserved we could not run it, and the reason is recorded
2370
+ // 'judged' has no producer today: nothing LLM-derived reaches a verdict — the
2371
+ // Final Report is prose. The tier exists so that if one ever does, it cannot
2372
+ // arrive wearing green.
2373
+ // Did this run change its own test files? Same predicate as session-manager's
2374
+ // touchedTestFiles (kept in sync deliberately — a suffix-only version missed a
2375
+ // whole ecosystem once, per the 2026-06-09 devil's-advocate audit).
2376
+ function editedOwnTests(t){
2377
+ var fs=(t && t.artifacts && t.artifacts.files) || [];
2378
+ return fs.some(function(f){
2379
+ var p=String(f && f.path || '');
2380
+ return /(^|\\/)(tests?|spec|__tests__)\\//i.test(p)
2381
+ || /\\.(test|spec)\\.\\w+$|_test\\.\\w+$/i.test(p)
2382
+ || /(^|\\/)test_[^/]+\\.py$/i.test(p);
2383
+ });
2384
+ }
2385
+ function evidenceTier(t){
2386
+ var a = t && t.artifacts;
2387
+ if(!a) return 'unchecked';
2388
+ if(a.tests && a.tests.observed) return 'measured';
2389
+ if(a.testsUnobserved) return 'unchecked';
2390
+ return 'unchecked';
2391
+ }
2392
+ function vLine(ev, cls, dv, heroTag, word, sub){
2393
+ return '<div class="vline '+cls+'" data-evidence="'+ev+'" data-verify-unit="verifier" data-verify-verdict="'+dv+'" data-verify-verdict-hero="'+heroTag+'" data-verify-gittracked="'+((t.artifacts&&t.artifacts.noGitTracking)?'false':'true')+'">'
2394
+ +'<span class="vl-d"></span><span class="vl-w">'+word+'</span><span class="vl-s">\\u00b7 '+sub+'</span></div>';
2395
+ }
2396
+ // The green-verdict sub-clause, scoped to the ACTUAL independent signal that
2397
+ // earned the green. A LONE cross-vendor pass is one reviewer's corroboration,
2398
+ // so it reads "corroborated by one other vendor" — never "independently
2399
+ // corroborated", which overstates a single reviewer. Disk-observed writes and
2400
+ // multi-signal greens keep the "independently corroborated" wording.
2401
+ function indepClause(grade){
2402
+ var sigs=(grade&&grade.signals)||[];
2403
+ var pass=sigs.filter(function(s){return s&&s.provenance!=='self-reported'&&s.state==='pass';});
2404
+ if(pass.length===1 && pass[0].provenance==='adversarial') return 'corroborated by one other vendor.';
2405
+ var hasDisk=pass.some(function(s){return s.provenance==='observed';});
2406
+ return hasDisk ? 'independently corroborated \\u00b7 file writes confirmed on disk.' : 'independently corroborated.';
2407
+ }
2408
+ const vr=t.verdict&&t.verdict.claim;
2409
+ const _vTests=t.artifacts&&t.artifacts.tests;
2410
+ if(vr && isTerminal && hasReportText && vr.status!=='no-report'){
2411
+ const noGit = !!(t.artifacts && t.artifacts.noGitTracking);
2412
+ const failingTests = ((_vTests&&_vTests.failed)||0) > 0;
2413
+ if(t.mockRun){
2414
+ // Tier 0.1 · a real MOCK run fabricated its own artifacts, so a green
2415
+ // "verified" would be tautological (report vs its own synthetic data).
2416
+ // Say Practice — honest about what a no-key/mock run actually proves.
2417
+ // verdict is NOT "verified" so H1 + a skeptic both read it correctly.
2418
+ verdictLine = vLine(evidenceTier(t),'v-wr','wiring-only','wiring-only',VERDICT_WORDS.practice,'wiring only \\u00b7 the agent fabricated its own result.');
2419
+ verdictProv = 'mock mode \\u00b7 proves the pipeline, not the work \\u00b7 run a real agent (API key or subscription) for a real verdict';
2420
+ } else if(vr.status!=='broken' && !isFailed && !failingTests && _crossVendorByTask[t.sessionId] && _crossVendorByTask[t.sessionId].proven){
2421
+ // Single verdict (no contradiction): a clean cross-vendor review is the
2422
+ // strongest non-broken signal, so it OWNS the headline. But a SINGLE
2423
+ // other-vendor review is corroboration, not proof — so the headline word
2424
+ // is "Verified" (independent corroboration), never "Proven". The seal
2425
+ // below expands it as "Corroborated by X · a different vendor". Never
2426
+ // render "Partial"/"Unverified" above it; broken + honest failures still
2427
+ // win (checked first).
2428
+ verdictLine = vLine(evidenceTier(t),'v-ok','verified','verified',VERDICT_WORDS.verified,'a different vendor reviewed this \\u00b7 corroborated by one other vendor.');
2429
+ } else if(vr.status==='verified'){
2430
+ // S5 honesty gate · the VerdictService says "verified" when the report's
2431
+ // claims MATCH the recorded reality — on a failed task (or with failing
2432
+ // tests) that means the report is honestly describing a failure. The
2433
+ // green word reads as success, so it must never render here.
2434
+ if(isFailed || failingTests){
2435
+ verdictLine = vLine(evidenceTier(t),'v-wr','report-honest-'+(isFailed?'failed':'failing-tests'),'honest-failed',VERDICT_WORDS.stopped,'the report is honest about a failure.');
2436
+ } else if(noGit){
2437
+ // R8 honesty: in a non-git folder the verifier can't observe the file
2438
+ // changes, so a green "verified" would overclaim.
2439
+ verdictLine = vLine(evidenceTier(t),'v-wr','partial','partial',VERDICT_WORDS.partial,'couldn\\u2019t fully verify \\u00b7 not a git repo.');
2440
+ } else if(!t.verdict.grade){
2441
+ // Legacy/seeded verdict with no composite grade attached — keep prior
2442
+ // behavior (real tasks always carry a grade via composeVerdict).
2443
+ verdictLine = vLine(evidenceTier(t),'v-ok','verified','verified',VERDICT_WORDS.verified,'claims match reality.');
2444
+ } else if(t.verdict.grade.green){
2445
+ // 2.2 · green ONLY with an INDEPENDENT signal (observed on disk or
2446
+ // adversarial cross-vendor). The composite grade owns that gate. Name the
2447
+ // ACTUAL signal so a lone cross-vendor pass — the same state the seal
2448
+ // branch above renders once its async fetch resolves — reads as
2449
+ // corroboration by one vendor, not "independently corroborated".
2450
+ verdictLine = vLine(evidenceTier(t),'v-ok','verified','verified',VERDICT_WORDS.verified,indepClause(t.verdict.grade));
2451
+ } else {
2452
+ // Self-report matches its own artifacts, but nothing INDEPENDENT has
2453
+ // confirmed it — shown honestly as reported, never green (the 2.2 gate).
2454
+ verdictLine = vLine(evidenceTier(t),'v-wr','reported','partial',VERDICT_WORDS.reported,'self-reported \\u00b7 not independently checked yet.');
2455
+ }
2456
+ } else if(vr.status==='broken'){
2457
+ verdictLine = vLine(evidenceTier(t),'v-bad','broken','broken',VERDICT_WORDS.caught,'the claim didn\\u2019t match reality.');
2458
+ } else if(vr.status==='nothing-to-verify'){
2459
+ // The report made no checkable CLAIM — but the composite grade can still
2460
+ // be green from an INDEPENDENT signal (the audit observing the declared
2461
+ // file writes on disk). Defer to it: real, observed work reads as Verified
2462
+ // (matching the row + the grade); a true no-op (no green) stays neutral, so
2463
+ // a startup-banner run never shows a false pass. (user-#1 dogfood 2026-06-07.)
2464
+ if(t.verdict.grade && t.verdict.grade.green){
2465
+ // Name the ACTUAL corroborating signal (shared with the green branch
2466
+ // above): file writes on disk, a lone cross-vendor reviewer, or multiple.
2467
+ verdictLine = vLine(evidenceTier(t),'v-ok','verified','verified',VERDICT_WORDS.verified,indepClause(t.verdict.grade));
2468
+ } else {
2469
+ verdictLine = vLine(evidenceTier(t),'v-wr','nothing-to-verify','partial',VERDICT_WORDS.unverified,'the report makes no checkable claim.');
2470
+ }
2471
+ } else {
2472
+ verdictLine = vLine(evidenceTier(t),'v-wr',vr.status,'partial',VERDICT_WORDS.partial,'couldn\\u2019t fully verify.');
2473
+ }
2474
+ if(verdictLine && !verdictProv){
2475
+ // One-line provenance · built ONLY from real fields: tests.observed
2476
+ // means the verifier actually re-ran the suite (rerun-tests use-case);
2477
+ // countSource==='claimed' means the count is the agent's own stdout.
2478
+ // Never "chain intact" here — that claim belongs on /verify where the
2479
+ // chain check actually runs.
2480
+ var _pv=[(agentLabel(t.agent)||'the agent')+' reported'];
2481
+ if(_vTests && _vTests.observed) _pv.push('verifier re-ran the suite');
2482
+ else _pv.push('checked against the recorded run');
2483
+ if(_vTests && _vTests.countSource==='claimed') _pv.push('test count as reported');
2484
+ // Re-running a suite the SAME agent just edited proves the suite passes, not
2485
+ // that the work is right — making the check agree with you is the strongest
2486
+ // lie available to an agent. codehere already computed this signal
2487
+ // (session-manager touchedTestFiles) but used it ONLY to decide whether to
2488
+ // re-run; it never reached the surface, so a run where the agent rewrote its
2489
+ // own test rendered as a clean green "verifier re-ran the tests". Same
2490
+ // predicate as session-manager, deliberately.
2491
+ if(_vTests && _vTests.observed && editedOwnTests(t)) _pv.push('suite edited in this run');
2492
+ _pv.push('hash-chained locally');
2493
+ verdictProv=_pv.join(' \\u00b7 ');
2494
+ }
2495
+ if(verdictProv) verdictProv='<div class="vline-meta">'+verdictProv+'</div>';
2496
+ }
2497
+
2498
+ // THE summary object (detail subtraction, 2026-07-06) · every terminal task
2499
+ // gets ONE glance turn and it is the only place the outcome facts render:
2500
+ // glance sentence (the report's first sentence, or the honest outcome
2501
+ // sentence when no report exists) -> the verdict line (the only verdict on
2502
+ // the page — the receipt's gl-v mini-dot died here) -> one mono receipt
2503
+ // (cost · files · tests · time, DESIGN.md §4.3) -> the full report resting
2504
+ // collapsed as the record. The meta line and the lenses stopped restating
2505
+ // these facts; they read here.
2506
+ let summHtml='';
2507
+ if(isTerminal){
2508
+ var _condBadge = (t.agent==='conductor' && t.delegatedTo && t.delegatedTo!=='conductor')
2509
+ ? '<span class="card-agent">Conductor \\u2192 '+agentLabel(t.delegatedTo)+'</span>' : '';
2510
+ var _glance='', _more='';
2511
+ if(hasReportText){
2512
+ var _rpt=String(t.finalReport.summary);
2513
+ // The glance is the first meaningful sentence — drop adapter banners that
2514
+ // some CLIs (aider's version + in-chat-filenames note) prepend to stdout, so
2515
+ // the lead reads as the agent's words, not tooling noise. Full report keeps it.
2516
+ // \\u0060 = backtick (can't appear literally inside this template literal);
2517
+ // the dot alternative refuses to end inside an ellipsis (loop-1 verifier finds).
2518
+ var _plain=_rpt.replace(/[#*>_~\\[\\]\\u0060]/g,'').replace(/\\s+/g,' ').trim();
2519
+ _plain=_plain.replace(/^Aider v[\\d.]+\\s*/i,'').replace(/^Note: in-chat filenames are always relative to[^.]*\\.\\s*/i,'').trim();
2520
+ var _gm=_plain.match(/^[\\s\\S]*?(?:[!?]|\\.(?!\\.))(?:\\s|$)/);
2521
+ _glance=(_gm?_gm[0]:_plain).trim();
2522
+ var _hasMore=_plain.length>_glance.length+2;
2523
+ var _glsid=encodeURIComponent(String(t.sessionId||''));
2524
+ var _glopen=!!_glMoreOpen[String(t.sessionId||'')];
2525
+ _more=_hasMore
2526
+ ? '<details class="gl-more" data-glsid="'+_glsid+'" ontoggle="__rememberGlOpen(this)"'+(_glopen?' open':'')+'><summary>full report \\u203A</summary><div class="tl-say-text report-md" data-user-content>'+renderReportMd(_rpt)+'</div></details>'
2527
+ : '';
2528
+ } else {
2529
+ _glance=outcomeSentence(t);
2530
+ }
2531
+ var _rc=[];
2532
+ if(co!==null) _rc.push('<span>$'+(co/100).toFixed(co<10?4:2)+'</span>'); // same precision rule as costPill — one convention per screen
2533
+ if(files.length) _rc.push('<span>'+files.length+' file'+(files.length===1?'':'s')+'</span>');
2534
+ if(_vTests && ((_vTests.passed||0)+(_vTests.failed||0))>0) _rc.push('<span>'+(_vTests.passed||0)+'/'+((_vTests.passed||0)+(_vTests.failed||0))+' tests</span>');
2535
+ if(t.durationMs) _rc.push('<span>'+dur(t.durationMs)+'</span>');
2536
+ else if(t.completedAt && t.startedAt) _rc.push('<span>'+dur(t.completedAt-t.startedAt)+'</span>');
2537
+ var _receipt=_rc.length?'<div class="gl-receipt">'+_rc.join('')+'</div>':'';
2538
+ summHtml = '<div class="tl-say summary-turn glance-turn">'+ab(t.agent)
2539
+ + '<div class="tl-say-body">'+_condBadge
2540
+ + '<div class="gl-line" data-user-content>'+convHe(_glance)+'</div>'
2541
+ + verdictLine + verdictProv
2542
+ + _receipt + _more
2543
+ + '</div></div>';
2544
+ // results -> GitHub: if this task came from an issue, offer to post the result back.
2545
+ var _ghm=(t.prompt||'').match(/github\\.com\\/([^\\/\\s]+)\\/([^\\/\\s]+)\\/issues\\/(\\d+)/i);
2546
+ if(_ghm){
2547
+ summHtml += '<div class="gh-back" style="margin:6px 0 0 38px">'
2548
+ +'<button type="button" onclick="_ghPostResult(\\''+t.sessionId+'\\')" style="background:none;border:1px solid rgba(255,255,255,.18);border-radius:7px;color:rgba(255,255,255,.72);font:inherit;font-size:12px;padding:5px 11px;cursor:pointer">Comment result on '+_ghm[1]+'/'+_ghm[2]+'#'+_ghm[3]+'</button>'
2549
+ +'<span id="gh-back-msg" style="margin-left:8px;font-size:12px;color:rgba(255,255,255,.4)"></span></div>';
2550
+ }
2551
+ }
2552
+
2553
+ // S14 · the evidence table — CLAIM / CHECKED / RESULT (direction E). Every
2554
+ // value is a recorded field (verdict findings carry claim+reality verbatim,
2555
+ // artifacts.tests, files on disk, the headline file count); nothing is
2556
+ // invented. With no checkable claim the table collapses to ONE honest
2557
+ // CHECKED row. It carries the outcome data-verify contract, so H3 (no empty
2558
+ // box) + H5 (counts-consistent — the probe-lie still FAILS off these
2559
+ // attributes) keep reading the same truth.
2560
+ if(!(t.status==='running'||t.status==='queued')){
2561
+ const _evCl=_vTests&&_vTests.claimed;
2562
+ const _evP=_vTests?(_vTests.passed||0):0, _evF=_vTests?(_vTests.failed||0):0;
2563
+ const _evDisagree=!!(_evCl&&((_evCl.passed!==_evP)||(_evCl.failed!==_evF)));
2564
+ const _evFinds=(vr&&vr.findings)||[];
2565
+ const _evNoGit=!!(t.artifacts&&t.artifacts.noGitTracking);
2566
+ const _hasVerdict=!!(vr && isTerminal && hasReportText && vr.status!=='no-report');
2567
+ const _noCheckable=_hasVerdict && vr.status==='nothing-to-verify';
2568
+ // CLAIM — only what the report / the agent actually said
2569
+ let claimBits=[];
2570
+ _evFinds.slice(0,2).forEach(function(f){ if(f&&f.claim) claimBits.push('\\u201C'+convHe(f.claim)+'\\u201D'); });
2571
+ if(_evCl) claimBits.push(_evCl.passed+' passed'+(_evCl.failed?' \\u00b7 '+_evCl.failed+' failed':' \\u00b7 0 failed'));
2572
+ // when observed=false the counts ARE the agent's own scraped stdout —
2573
+ // presenting them as the claim is attribution of its actual words
2574
+ else if(_vTests && !_vTests.observed && !_evFinds.length) claimBits.push(_evP+'/'+(_evP+_evF)+' tests green');
2575
+ // Refuter (S14) · the file count joins CLAIM only when the report
2576
+ // EXPLICITLY claimed one (artifacts.filesClaimed) — claimedFiles falls
2577
+ // back to the on-disk count, and attributing disk truth to the agent's
2578
+ // report is constructive attribution (words in the report's mouth).
2579
+ if(t.artifacts && typeof t.artifacts.filesClaimed==='number' && t.artifacts.filesClaimed>0 && !claimBits.some(function(b){return /file/.test(b);})) claimBits.push(t.artifacts.filesClaimed+' file'+(t.artifacts.filesClaimed===1?'':'s')+' changed');
2580
+ // CHECKED — what the verifier compared / what was actually observed
2581
+ let checkedBits=[];
2582
+ _evFinds.slice(0,2).forEach(function(f){ if(f&&f.reality) checkedBits.push(convHe(f.reality)); });
2583
+ if(_vTests && _vTests.observed) checkedBits.push('verifier re-ran the suite \\u00b7 '+_evP+' green'+(_evF?' \\u00b7 '+_evF+' red':''));
2584
+ else if(_vTests) checkedBits.push('recorded test run \\u00b7 '+_evP+' passed \\u00b7 '+_evF+' failed'+(_vTests.durationMs?' \\u00b7 '+Math.round(_vTests.durationMs/100)/10+'s':''));
2585
+ if(files.length) checkedBits.push(files.length+' file'+(files.length===1?'':'s')+' on disk \\u00b7 git diff');
2586
+ else if(_evNoGit) checkedBits.push('not a git repo \\u00b7 file changes not observable');
2587
+ // RESULT — the verdict sentence + DERIVED counts, only when a verdict exists
2588
+ let resultRow='';
2589
+ if(_hasVerdict && !_noCheckable){
2590
+ const _nContr=_evFinds.length+((_evDisagree&&!_evFinds.some(function(f){return f&&f.kind==='tests';}))?1:0);
2591
+ let _rTxt='', _rCls='';
2592
+ if(t.mockRun){ _rTxt='practice run \\u00b7 wiring proven \\u00b7 the result is the agent\\u2019s own fabrication'; }
2593
+ else if(vr.status==='broken'){ _rTxt=_nContr+' contradiction'+(_nContr===1?'':'s')+' \\u00b7 left unmerged \\u00b7 the catch is recorded locally'; _rCls=' bad'; }
2594
+ else if(vr.status==='verified'&&(isFailed||_evF>0)){ _rTxt='no contradiction \\u00b7 the report describes a failing run'; }
2595
+ else if(vr.status==='verified'&&_evNoGit){ _rTxt='couldn\\u2019t fully verify \\u00b7 nothing in the report contradicts what was recorded'; }
2596
+ else if(vr.status==='verified'){ _rTxt='0 contradictions \\u00b7 '+checkedBits.length+' check'+(checkedBits.length===1?'':'s')+' compared'; _rCls=' good'; }
2597
+ else { _rTxt=convHe(vr.summary||'couldn\\u2019t check everything'); }
2598
+ resultRow='<div class="erow2 result'+_rCls+' outcome-item"><span class="elbl">Result</span><span class="etxt">'+_rTxt+'</span></div>';
2599
+ }
2600
+ // Report provenance · zero information loss from the old Outcome panel:
2601
+ // a non-live-agent report (R8 allowlist) or a missing report stays visible.
2602
+ let noteRow='';
2603
+ if(hasReportText && !isVerifiedAgentReport){
2604
+ const _rl = reportSource==='deterministic' ? 'no written report \\u00b7 recorded facts only'
2605
+ : reportSource==='error-recovery' ? 'final report \\u00b7 task did not complete'
2606
+ : 'final report \\u00b7 unverified (not a live-agent summary)';
2607
+ noteRow='<div class="erow2 note outcome-item"><span class="elbl">Report</span><span class="etxt">\\u26A0 '+_rl+'</span></div>';
2608
+ } else if(!hasReportText && isTerminal){
2609
+ noteRow='<div class="erow2 note outcome-item"><span class="elbl">Report</span><span class="etxt">\\u26A0 final report unavailable</span></div>';
2610
+ }
2611
+ const claimRow=(!_noCheckable && hasReportText && claimBits.length)
2612
+ ? '<div class="erow2 outcome-item"><span class="elbl">Claim</span><span class="etxt" data-user-content>'+claimBits.join(' \\u00b7 ')+' \\u00b7 '+convHe(agentLabel(t.agent)||'agent')+', final report</span></div>'
2613
+ : '';
2614
+ if(!checkedBits.length) checkedBits.push(_noCheckable
2615
+ ? 'the report makes no checkable claim \\u00b7 nothing to compare'
2616
+ : nStep+' recorded step'+(nStep===1?'':'s')+' in the local chain');
2617
+ const checkedRow='<div class="erow2 checked outcome-item"><span class="elbl">Checked</span><span class="etxt">'+checkedBits.join(' \\u00b7 ')+'</span></div>';
2618
+ oHtml='<div class="evi"'+_oAttrs+'>'+(_noCheckable?checkedRow:claimRow+checkedRow+resultRow)+noteRow+'</div>';
2619
+ }
2620
+
2621
+ // S14 · "Your call" on a CAUGHT verdict — the decision block for a broken
2622
+ // claim on a non-failed run (a failed run's decision block is the recovery
2623
+ // card, which already owns retry/diagnose/resolve). REAL actions only: a
2624
+ // respawn that appends the verifier's finding to the original prompt (only
2625
+ // when that prompt was recorded — else disabled-with-reason), and the
2626
+ // existing human-ack path into the audit chain. No fictional actions —
2627
+ // codehere doesn't merge, so there is no "merge anyway". Carries the same
2628
+ // .recovery-card + data-verify-recovery contracts so H7
2629
+ // (recovery-buttons-must-not-lie) covers these buttons too.
2630
+ let yourCallBroken='';
2631
+ if(vr && vr.status==='broken' && isTerminal && !isFailed && !hasBlock && !t.mockRun){
2632
+ const _bCanRetry=!!(t.prompt && String(t.prompt).trim());
2633
+ const _bRetry=_bCanRetry
2634
+ ? '<button class="primary" data-verify-action="retry-with-finding" onclick="recoverRetryAttach(\\''+t.sessionId+'\\')">Retry with the contradiction attached <span class="kbd">\\u23ce</span></button>'
2635
+ : '<button class="primary" disabled data-verify-action="retry-with-finding-disabled" title="the original prompt wasn\\u2019t recorded for this run, so it can\\u2019t be respawned with the finding attached">Retry with the contradiction attached</button>';
2636
+ yourCallBroken='<div class="recovery-card" data-verify-unit="recovery" data-verify-recovery="'+(t.resolvedAt?'resolved':'broken-unreviewed')+'">'
2637
+ +'<div class="recovery-title"><span class="dot"></span>Your call \\u00b7 the verifier caught a contradiction</div>'
2638
+ +'<div class="recovery-cause">The claim didn\\u2019t match what was recorded \\u00b7 the work stays unmerged until you decide.</div>'
2639
+ +'<div class="recovery-actions" style="align-items:center;gap:14px">'
2640
+ +_bRetry
2641
+ +(t.resolvedAt
2642
+ ? '<button class="linkbtn" disabled data-verify-action="resolve-done" title="already acknowledged \\u00b7 recorded in the audit chain">mark reviewed</button>'
2643
+ : '<button class="linkbtn" data-verify-action="resolve" onclick="recoverResolve(\\''+t.sessionId+'\\',this)">Mark reviewed \\u00b7 recorded in the audit chain</button>')
2644
+ +'</div>'
2645
+ +(_bCanRetry?'':'<div class="recovery-reason">Retry is unavailable \\u00b7 this run\\u2019s prompt wasn\\u2019t recorded, so codehere can\\u2019t reconstruct the spawn. (An honest disable, not a dead button.)</div>')
2646
+ +(t.resolvedAt?'<div class="recovery-ack">\\u2713 acknowledged by you \\u00b7 recorded in the audit chain \\u00b7 '+new Date(t.resolvedAt).toLocaleString()+'</div>':'')
2647
+ +'</div>';
2648
+ }
2649
+
2650
+ const tc=t.toolTrace||t.steps||[];
2651
+ // P10 · per-step hook badges in trace. Derive same heuristic as the
2652
+ // tool-card hook-badge code (line 1176+): block when step.state===failed
2653
+ // OR /blocked|denied|refused/, override when /override/, ctx when the
2654
+ // step touched a context-source URL, otherwise allow.
2655
+ function stepHookBadge(z){
2656
+ const out = String(z.output||z.label||'').toLowerCase();
2657
+ if(z.state==='failed' || /block|denied|refused/i.test(out)) return '<span class="hook-badge block">block</span>';
2658
+ if(/override/i.test(out)) return '<span class="hook-badge override">override</span>';
2659
+ if(/(linear|github|figma|drawio):\\/\\//i.test(out)) return '<span class="hook-badge ctx">ctx</span>';
2660
+ return '<span class="hook-badge allow">allow</span>';
2661
+ }
2662
+ // Step trace rests collapsed. The summary keeps the count visible; expanding
2663
+ // is lossless because every recorded step still renders inside the details.
2664
+ const traceHtml=tc.length>0
2665
+ ?'<details class="collapsible"><summary>'+tc.length+' steps</summary>'
2666
+ +tc.map(z=>'<div class="tc">'
2667
+ + stepHookBadge(z)
2668
+ + '<div class="tc-label" data-user-content>'+compactLabel(z.label||z.tool||'?')+'</div>'
2669
+ + ((z.fullOutput||z.output)?'<pre class="tc-output" data-user-content>'+ansiToHtml(String(z.fullOutput||z.output))+'</pre>':'')
2670
+ + (z.durationMs?'<span class="tc-dur">'+z.durationMs+'ms</span>':'')
2671
+ + '</div>').join('')+'</details>'
2672
+ :'';
2673
+
2674
+ // R11 — composer enable check uses RAW backend statuses (the st() mapper
2675
+ // converts 'completed'->'done' for display but t.status is still raw here).
2676
+ // Enable on every known state including terminal ones so the user can ask
2677
+ // "what happened?" / request changes; the server-side appendMessage endpoint
2678
+ // is the authority on whether the session can still accept input.
2679
+ const knownStatuses = ['running','queued','done','completed','paused','blocked','failed','error','crashed','killed'];
2680
+ // S1 · the composer never invites a message that can't be delivered. The
2681
+ // SERVER declares deliverability (canFollowUp): live sessions by adapter
2682
+ // capability; finished ones only when the agent's session is resumable
2683
+ // (claude-code + its native token). Disabled-with-reason, never a dead box.
2684
+ const _termState = ['done','completed','failed','error','crashed','killed'].includes(t.status);
2685
+ const _noFollowUp = _termState && t.canFollowUp === false;
2686
+ const inputDisabled = !knownStatuses.includes(t.status) || _noFollowUp;
2687
+ const ta=document.getElementById('detail-ta'), btn=document.getElementById('detail-btn');
2688
+ if(ta){
2689
+ ta.disabled=inputDisabled;
2690
+ if(_noFollowUp) ta.placeholder=(t.agent||'this agent')+' can\\u2019t continue a finished task \\u00b7 start a new one; the project thread carries the context';
2691
+ else if(inputDisabled) ta.placeholder='Follow-up unavailable for this state';
2692
+ else if(t.status==='running') ta.placeholder='Interrupt or ask a question...';
2693
+ else ta.placeholder='Ask a follow-up about this task...';
2694
+ ta.setAttribute('data-verify-followup', _noFollowUp?'disabled-honest':(inputDisabled?'disabled':'enabled'));
2695
+ }
2696
+ if(btn){ btn.disabled=inputDisabled; if(_noFollowUp) btn.title=(t.agent||'this agent')+' can\\u2019t continue a finished task'; else btn.title=''; }
2697
+ const _sf=document.getElementById('detail-send-fail'); if(_sf && _sf.getAttribute('data-sid')!==t.sessionId){ _sf.style.display='none'; _sf.textContent=''; }
2698
+
2699
+ // Failover-aware kicker · when a session crossed vendors (rate-limit
2700
+ // failover · context handoff), surface the lineage in the title kicker
2701
+ // so the cross-vendor wedge is visible at a glance. PHASE4 §H sharpened
2702
+ // moat: "cross-vendor failover with context handoff" is THE unique
2703
+ // differentiator. Show it.
2704
+ let kicker;
2705
+ if(t.failoverFrom && t.failoverTo){
2706
+ // convHe-escape (uppercase first, THEN escape — escaping then uppercasing
2707
+ // would corrupt entities). This kicker path went live with the recovery
2708
+ // lineage wiring (2026-06-08); harden it to match the badge (refuter).
2709
+ const from=convHe((agentLabel(t.failoverFrom)||String(t.failoverFrom)).toUpperCase());
2710
+ const to=convHe((agentLabel(t.failoverTo)||String(t.failoverTo)).toUpperCase());
2711
+ const reason=t.failoverReason ? ' \\u00B7 ' + convHe(String(t.failoverReason).replace(/_/g,' ').toUpperCase()) : '';
2712
+ kicker=from+' \\u203A '+to+reason+' \\u00B7 '+s.toUpperCase();
2713
+ } else {
2714
+ kicker=(agentLabel(t.agent)||'task').toUpperCase()+' \\u00B7 '+s.toUpperCase();
2715
+ }
2716
+ // Artifacts -> side drawer (task #51): accessible via a header trigger +
2717
+ // openable on demand, not stacked inline. Reuses renderArtifacts content.
2718
+ var _artStruct=(t.artifacts&&t.artifacts.structured)||[];
2719
+ var _artHtml=renderArtifacts(_artStruct);
2720
+ var _artN=_artStruct.length;
2721
+ var _artTrig=_artN?'<span class="art-trig" onclick="openArtD()">'+_artN+' artifact'+(_artN===1?'':'s')+' \\u203A</span>':'';
2722
+ var _artDrawer=_artHtml?'<div class="art-scrim'+(_artOpen?' open':'')+'" id="artScrim" onclick="closeArtD()"></div>'
2723
+ +'<div class="art-modal'+(_artOpen?' open':'')+'" id="artDrawer" role="dialog" aria-label="Artifacts"><div class="art-modal-h"><span>Artifacts \\u00B7 '+_artN+'</span><span class="x" role="button" tabindex="0" aria-label="Close artifacts" onclick="closeArtD()">\\u2715</span></div>'
2724
+ +'<div class="art-modal-body">'+_artHtml+'</div></div>':'';
2725
+ // Shared-proof row (Phase 2.5 team visibility): when a session was IMPORTED
2726
+ // from a teammate's signed bundle, attribute it (provenance) + show the audit
2727
+ // attestation. HONEST: the badge is gated on importAudit.attested (verified>0 &&
2728
+ // no tamper) — never a bare "verified"; trust reduces to the signer's key (the
2729
+ // CLI says the same). data-verify-imported lets the verify harness assert it.
2730
+ var provenanceHtml='';
2731
+ if(t.importedFrom){
2732
+ var _fp=String(t.importedFrom);
2733
+ var _au=t.importAudit;
2734
+ var _badge='';
2735
+ if(_au){
2736
+ // importAudit comes from an attacker-controllable bundle (a malicious signer
2737
+ // the recipient may have trusted). Two defenses (refuter 2026-06-08):
2738
+ // - COERCE the counts to numbers — never interpolate the raw field (a
2739
+ // string field would be a stored-XSS sink in the dashboard DOM).
2740
+ // - RE-DERIVE the gate from the coerced counts, do NOT trust the stored
2741
+ // attested flag — else a crafted verified:0/tampered:0/attested:true
2742
+ // would render a green 0-tool-calls hashes-intact badge (a fake proof).
2743
+ var _v=Number(_au.verified)||0;
2744
+ var _t=Number(_au.tampered)||0;
2745
+ if(_t>0){
2746
+ _badge='<span class="proof-badge proof-bad" data-verify-attested="false">\\u2715 audit chain inconsistent \\u00B7 '+_t+' altered</span>';
2747
+ } else if(_v>0){
2748
+ _badge='<span class="proof-badge proof-ok" data-verify-attested="true">\\u2713 '+_v+' agent tool call'+(_v===1?'':'s')+' recorded \\u00B7 hashes intact</span>';
2749
+ } else {
2750
+ _badge='<span class="proof-badge proof-neutral" data-verify-attested="false">no verifiable tool-call audit in this bundle</span>';
2751
+ }
2752
+ }
2753
+ provenanceHtml='<div class="shared-proof" data-verify-imported="true">'
2754
+ +'<span class="prov-from">imported from '+convHe(_fp)+'</span>'
2755
+ +_badge
2756
+ +'<span class="prov-note">signer-attested \\u00B7 your trust = this key</span>'
2757
+ +'</div>';
2758
+ }
2759
+ // Recovery badge (S3 dogfood gap, 2026-06-08): when THIS session is a failover
2760
+ // REPLACEMENT — it took over after the first agent stalled or failed — say so,
2761
+ // so the cross-vendor resilience is VISIBLE instead of the first agent reading
2762
+ // as a bare "killed" row. failoverReason is a controlled enum (stall/rate_limit/
2763
+ // quota_exceeded); convHe-escaped anyway as defense. Gated on both ids present.
2764
+ var recoveredHtml='';
2765
+ if(t.failoverFrom && t.failoverTo){
2766
+ var _rfrom=agentLabel(t.failoverFrom)||String(t.failoverFrom);
2767
+ var _rreason=t.failoverReason?(' \\u00B7 '+convHe(String(t.failoverReason).replace(/_/g,' '))):'';
2768
+ // R7 · honest handoff label, exactly what carried and what didn't: the
2769
+ // replacement got a WARM RESTART with context (the repo + the failure
2770
+ // lineage + the prior agent's work summary, injected as a [handoff:…]
2771
+ // block the session's own prompt makes inspectable) — NOT a live state
2772
+ // transfer. The tooltip + note say so; no surface implies more.
2773
+ recoveredHtml='<div class="shared-proof" data-verify-recovered="true" data-verify-recovered-from="'+convHe(String(t.failoverFrom))+'">'
2774
+ +'<span class="recovered-badge" title="Warm restart with context: the replacement agent got the repo + the failure lineage (plus the prior agent\\u2019s work summary when one was available) \\u00b7 not a live state transfer.">recovered from '+convHe(_rfrom)+_rreason+'</span>'
2775
+ +'<span class="prov-note">the first agent didn\\u2019t finish \\u00B7 '+convHe(agentLabel(t.failoverTo)||String(t.failoverTo))+' restarted the work warm \\u00b7 repo + lineage (+ the prior summary when available), not a live state transfer</span>'
2776
+ +'</div>';
2777
+ }
2778
+ // Tier 1.2 · audit<->disk reconciliation: the audit records what the agent
2779
+ // DECLARED (its tool calls); this line says what codehere could CONFIRM against
2780
+ // the real git diff. 'declared-not-observed' (writes claimed, nothing on disk)
2781
+ // is the signal — a no-op, a write-then-revert, or a fabricated tool_use. The
2782
+ // note + counts are server-generated (no user input); convHe-escaped as defense.
2783
+ var auditReconHtml='';
2784
+ var _ar=t.verdict&&t.verdict.audit;
2785
+ if(_ar && _ar.verdict){
2786
+ var _arv=String(_ar.verdict);
2787
+ // 'unconfirmed' is NEUTRAL ⚠ ("couldn't confirm"), never a red ✕ — a false
2788
+ // fabrication accusation on honest work is the worst error on a proof surface
2789
+ // (refuter 2026-06-09). Only 'consistent' earns a positive ✓.
2790
+ var _arcls = _arv==='consistent' ? 'proof-ok' : 'proof-neutral';
2791
+ var _aricon = _arv==='consistent' ? '\\u2713' : _arv==='unconfirmed' ? '\\u26A0' : '\\u00B7';
2792
+ auditReconHtml='<div class="panel"><div class="panel-title">Audit \\u00B7 disk reconciliation</div>'
2793
+ +'<div class="shared-proof" data-verify-recon="'+_arv+'">'
2794
+ +'<span class="proof-badge '+_arcls+'">'+_aricon+' '+convHe(String(_ar.note||''))+'</span>'
2795
+ +'</div></div>';
2796
+ }
2797
+ // Cross-vendor receipt — async-loaded + cached (see _crossVendorByTask). On
2798
+ // first sight of a session, kick the fetch; render the panel once the verdict
2799
+ // (or null) is cached, so it survives writeDetail's morph-diff.
2800
+ let cvHtml='';
2801
+ { const _cvSid=String(t.sessionId||''); const _cv=_crossVendorByTask[_cvSid];
2802
+ if(_cv===undefined){ loadCrossVendor(_cvSid); }
2803
+ // Show the cross-vendor panel ONLY when it's a real proof — a DIFFERENT
2804
+ // vendor genuinely corroborated the work. The gray "not a clean proof yet"
2805
+ // box was scaffolding that existed to explain what DIDN'T happen; it's noise.
2806
+ // Loud only on the one thing no single-vendor tool can show.
2807
+ else if(_cv && _cv.proven){ cvHtml=crossVendorPanel(_cv); } }
2808
+ // S14/S21 · the conversation rests as ONE quiet row ("conversation · N turns ·
2809
+ // every tool call recorded") that expands the existing unified timeline.
2810
+ // Live runs keep their current-action line in the outcome panel; the full
2811
+ // timeline stays collapsed until clicked. The user's choice is remembered per
2812
+ // task for this browser session and survives SSE re-renders.
2813
+ const _lens=curLens();
2814
+ const _tlHtml=renderTimeline(t, wrapCtxUrls);
2815
+ let convWrap='';
2816
+ if(_tlHtml){
2817
+ const _convSid=String(t.sessionId||'');
2818
+ // The conversation IS the record of what the agent did — the whole point of
2819
+ // codehere. Show it by DEFAULT whenever there are steps (was collapsed under
2820
+ // any report, which hid a 20-step run entirely). Only a report-with-no-steps
2821
+ // leaves it closed. The user's own toggle is still remembered per task.
2822
+ let _convOpen=Object.prototype.hasOwnProperty.call(_convOpenByTask,_convSid)?!!_convOpenByTask[_convSid]:(steps.length>0||!hasReportText);
2823
+ try{
2824
+ const _dvEl=document.getElementById('detail-view');
2825
+ if(_dvEl && _dvEl.getAttribute('data-sid')===_convSid){
2826
+ const _cw=_dvEl.querySelector('details.conv-wrap');
2827
+ if(_cw){ _convOpen=_cw.open; _convOpenByTask[_convSid]=_convOpen; }
2828
+ }
2829
+ }catch(e){}
2830
+ const _nTurns=Array.isArray(t.turns)?t.turns.length:0;
2831
+ const _cBits=['conversation'];
2832
+ if(_nTurns) _cBits.push(_nTurns+' turn'+(_nTurns===1?'':'s'));
2833
+ if(nStep) _cBits.push('every tool call recorded');
2834
+ convWrap='<details class="conv-wrap" data-sid="'+convHe(_convSid)+'" ontoggle="__rememberConvOpen(this)"'+(_convOpen?' open':'')+'><summary class="convrow">'+_cBits.join(' \\u00b7 ')+' <span class="kbd">\\u23ce</span></summary>'+_tlHtml+'</details>';
2835
+ }
2836
+ // DEEP-4 · the runs-timeline — when this task has SIBLING runs (same title),
2837
+ // show a compact strip so you can move between the runs of one task (e.g.
2838
+ // Conductor stalled -> Claude Code errored). Reuses the group key (title).
2839
+ // Sibling = same title AND same cwd, never Untitled, never demo/practice —
2840
+ // without those guards every untitled task claims unrelated "runs" and mock
2841
+ // fixtures show up as pills beside a real run (cold-review P1).
2842
+ var _tKey=title(t.prompt);
2843
+ var _sibs=(_tKey&&_tKey!=='Untitled')?allS(_cached.tasks||{}).filter(function(x){
2844
+ return String(x.sessionId||'') && title(x.prompt)===_tKey
2845
+ && String(x.cwd||'')===String(t.cwd||'')
2846
+ && (/^demo-/.test(String(x.sessionId||''))===/^demo-/.test(String(t.sessionId||'')))
2847
+ && (!!x.mockRun===!!t.mockRun);
2848
+ }):[];
2849
+ var _runsStrip='';
2850
+ if(_sibs.length>1){
2851
+ _sibs.sort(function(a,b){ return (b.startedAt||0)-(a.startedAt||0); });
2852
+ _runsStrip='<div class="runs-strip"><span class="runs-strip-lbl">'+_sibs.length+' runs of this task</span>'
2853
+ +_sibs.map(function(x){ var rc=rowClaims(x); var cur=(x.sessionId===t.sessionId); var cc=(x.cost&&typeof x.cost.spent==='number')?'$'+(x.cost.spent/100).toFixed(2):'';
2854
+ var sid=String(x.sessionId||'').replace(/[^A-Za-z0-9._:-]/g,'');
2855
+ return '<button class="runs-pill st-'+rc.cls+(cur?' cur':'')+'" type="button"'+(cur?' aria-current="true"':' onclick="showD(\\''+sid+'\\')"')+'><span class="rp-who">'+convHe(agentLabel(x.agent))+'</span><span class="rp-st">'+convHe(rc.word)+'</span>'+(cc?'<span class="rp-c">'+cc+'</span>':'')+'</button>'; }).join('')
2856
+ +'</div>';
2857
+ }
2858
+ const _hdrHtml='<div class="detail-header">'
2859
+ +'<span class="back-btn" onclick="hideD()">\\u2190 today \\u00b7 <span class="kbd">Esc</span></span>'
2860
+ +'<div class="detail-title-wrap">'
2861
+ +'<div class="detail-kicker">'+kicker+'</div>'
2862
+ +'<h1 class="detail-title" data-user-content>'+titleH(t.prompt)+'</h1>'
2863
+ +'</div>'
2864
+ +'</div>'
2865
+ +_runsStrip;
2866
+ // S2 · the moment you stop trusting a run is the moment you're looking at
2867
+ // it — the Stop control lives on the detail too, not just the list card.
2868
+ const _stopHero=(['running','queued','paused'].includes(t.status)?'<button class="stop-hero" data-verify-action="stop" title="Stop this agent now" onclick="stopTask(event,\\''+t.sessionId+'\\')">\\u25A0 Stop this agent</button>':'');
2869
+ // S14 · one mono meta line. The status word lives in the kicker above
2870
+ // (AGENT · STATUS) — the duplicated status-icon row died with the hero.
2871
+ // The data-verify contract (status/terminal-fail/has-explanation) rides on
2872
+ // the meta in EVERY lens — H6 reads the same truth everywhere.
2873
+ // Terminal numbers (cost · files · tests · time) live in the summary
2874
+ // receipt — the meta stopped restating them and keeps the run scaffolding.
2875
+ const _metaAttrs=' data-verify-unit="detail" data-verify-status="'+s+'" data-verify-terminal-fail="'+(isFailed?'true':'false')+'" data-verify-has-explanation="'+(!!(blockHtml||crashHtml||recoveryHtml||pushHtml||t.stalledReason)?'true':'false')+'"';
2876
+ const _metaBits=[];
2877
+ if(!isTerminal){ _metaBits.push(d); if(co!==null) _metaBits.push(costPill(co)); }
2878
+ // terminal always states its step count — "0 steps" is honest; an empty
2879
+ // meta line is the empty-container lie (verify-distill loop-2 finding).
2880
+ if(steps.length||isTerminal) _metaBits.push(steps.length+' step'+(steps.length===1?'':'s'));
2881
+ if(_artTrig) _metaBits.push(_artTrig);
2882
+ const _metaHtml='<div class="detail-meta"'+_metaAttrs+'>'+_metaBits.join(' \\u00B7 ')+_stopHero+'</div>';
2883
+ // S20 · STATUS lens — outcomes only: the summary object (glance · verdict ·
2884
+ // receipt) IS the outcomes-only view. Zero steps/tools/timeline. Honesty
2885
+ // survives the lens: decision blocks, failure notes and the amber/red
2886
+ // verdict line always render. Real template branch, not CSS hiding.
2887
+ if(_lens==='status'){
2888
+ writeDetail(t, _hdrHtml
2889
+ +_metaHtml
2890
+ +summHtml
2891
+ +cvHtml
2892
+ +provenanceHtml+recoveredHtml
2893
+ +blockHtml+pushHtml+crashHtml
2894
+ +recoveryHtml+yourCallBroken
2895
+ +gitNoteHtml);
2896
+ return;
2897
+ }
2898
+ // S20 · REVIEW lens — the trust job: the summary object (verdict inside) +
2899
+ // evidence + provenance + any pending decision move to the TOP; the audit
2900
+ // reconciliation is elevated; the conversation rests collapsed; eng detail
2901
+ // keeps its tail seat.
2902
+ if(_lens==='review'){
2903
+ writeDetail(t, _hdrHtml
2904
+ +summHtml
2905
+ +cvHtml
2906
+ +oHtml
2907
+ +provenanceHtml+recoveredHtml
2908
+ +recoveryHtml+yourCallBroken
2909
+ +blockHtml+pushHtml+crashHtml
2910
+ +auditReconHtml
2911
+ +_metaHtml
2912
+ +contractBlockHtml(t)
2913
+ +chipsHtml+gitNoteHtml
2914
+ +convWrap
2915
+ +tokenHtml+(_convShown?'':traceHtml)
2916
+ +_artDrawer);
2917
+ return;
2918
+ }
2919
+ // Conversation-first anatomy (2026-06-19, Ega's design direction): the
2920
+ // conversation IS the hero. Order: header + meta -> any failure/decision block
2921
+ // (the one thing that must interrupt) -> the agent's words + the thread (the
2922
+ // body) -> a COMPACT verdict marker + cross-vendor proof ONLY when it's real
2923
+ // -> then evidence table, audit recon, eng detail rest at the bottom as
2924
+ // progressive disclosure. No trust scaffolding stacked on top of the content;
2925
+ // no panel that exists only to explain what didn't happen.
2926
+ // Phase 2 · the heavy evidence / audit / token detail rests behind ONE calm
2927
+ // disclosure so the default view stays conversation-first (mockup parity).
2928
+ // Content stays in the DOM (collapsed), so the verify harness still reads its
2929
+ // data-verify-* data; only the visual noise is tucked away.
2930
+ const _detailDrawer = (oHtml || auditReconHtml || tokenHtml)
2931
+ ? '<details class="conv-wrap" data-verify-unit="evidence-disclosure"><summary>evidence \\u00b7 audit \\u00b7 tokens</summary>' + oHtml + auditReconHtml + tokenHtml + '</details>'
2932
+ : '';
2933
+ writeDetail(t, _hdrHtml
2934
+ +provenanceHtml
2935
+ +recoveredHtml
2936
+ +_metaHtml
2937
+ +blockHtml+pushHtml+crashHtml
2938
+ +recoveryHtml+yourCallBroken
2939
+ +contractBlockHtml(t)
2940
+ +summHtml
2941
+ +convWrap
2942
+ +cvHtml
2943
+ +chipsHtml+gitNoteHtml
2944
+ +_detailDrawer
2945
+ +(_convShown?'':traceHtml)
2946
+ +_artDrawer);
2947
+ }
2948
+
2949
+ ${RECOVERY_ACTIONS}
2950
+
2951
+ ${CONTEXT_SOURCES}
2952
+ ${SETTINGS_DRAWER}
2953
+
2954
+ ${AUDIT_CHAIN_VERIFY}
2955
+
2956
+ function al(m){const e=document.getElementById('toast');if(e){e.textContent=m;e.style.display='block';setTimeout(()=>{e.style.display='none';},3000);}}
2957
+
2958
+ ${COMPOSER_COST}
2959
+ ${SHARE_MODAL}
2960
+ ${OVERLAY_RENDERER}
2961
+
2962
+ ${WPASS_MODAL}
2963
+
2964
+ // Fix · local pill should reflect the ACTUAL host:port, not the hardcoded
2965
+ // 127.0.0.1:3773 (a user on --port 3899 would see the wrong pill).
2966
+ const localHostEl = document.getElementById('local-host');
2967
+ if(localHostEl) localHostEl.textContent = window.location.host;
2968
+
2969
+ document.addEventListener('keydown', (e)=>{
2970
+ if(e.key==='Escape'){
2971
+ if(document.getElementById('palette-scrim')?.classList.contains('open')){ e.preventDefault(); closePalette(); }
2972
+ else if(document.getElementById('settings-drawer')?.classList.contains('open')) closeDrawer();
2973
+ else if(document.getElementById('share-modal')?.classList.contains('open')) closeShare();
2974
+ else if(document.getElementById('wp-modal')?.classList.contains('open')) wpCloseModal();
2975
+ // Fix D-ESC (2026-05-23) · overlay-renderer Esc handler · was missing
2976
+ else if(document.getElementById('or-overlay')?.classList.contains('open')) orClose();
2977
+ else if(document.getElementById('reshape-modal')?.classList.contains('open')) closeReshape();
2978
+ }
2979
+ });
2980
+ Promise.resolve(fetchAll()).then(function(){ _routeFromHash(); }); // hydrate a deep-link (/#/task/:id) after the first load
2981
+
2982
+ const es=new EventSource('/api/events/sessions');
2983
+ let pollTimer=setInterval(fetchAll,30000), sseTimer=null;
2984
+ es.addEventListener('message',e=>{try{const m=JSON.parse(e.data);if(m.type==='change')fetchAll();}catch{}});
2985
+ es.addEventListener('error',()=>{
2986
+ clearInterval(pollTimer);pollTimer=setInterval(fetchAll,8000);
2987
+ if(sseTimer)clearTimeout(sseTimer);
2988
+ sseTimer=setTimeout(()=>{clearInterval(pollTimer);pollTimer=setInterval(fetchAll,30000);sseTimer=null;},60000);
2989
+ });
2990
+ es.addEventListener('open',()=>{if(sseTimer){clearInterval(pollTimer);pollTimer=setInterval(fetchAll,8000);setTimeout(()=>{clearInterval(pollTimer);pollTimer=setInterval(fetchAll,30000);},10000);}});
2991
+
2992
+ setInterval(pollHealth, 8000);
2993
+ pollHealth();
2994
+
2995
+ ${AUDIT_FOOTER}
2996
+ ${FAILOVER_BANNER}
2997
+ `;
2998
+ //# sourceMappingURL=app-script.js.map