' + r.title.replace(/' +
'' +
'';
const url = URL.createObjectURL(new Blob([page], { type:"text/html" }));
if(w) w.location = url; else window.open(url, "_blank");
}).catch(e => { if(w) w.close(); toast("Couldn't open the report: " + (e && e.message || e)); });
}
function openReportModal(r){
openModal(() => {
const body = h("div", { class:"modal-b", style:"padding:0;flex:1;min-height:0" }, h("div", { class:"mskel", style:"margin:16px" }, h("i"), h("i")));
fileGet(r.path).then(t => { body.innerHTML = ""; add(body, reportFrame(t, r.title)); })
.catch(e => { body.innerHTML = ""; add(body, h("p", { class:"sub", style:"padding:16px" }, "Couldn't load: " + (e && e.message || e))); });
return [h("div", { class:"modal-h" }, h("div", { class:"grow" }, h("h3", null, r.title), h("p", { class:"sub", style:"margin-top:4px" }, r.path)),
h("button", { class:"btn sm", onclick:() => openReportTab(r) }, icon("external", "xs"), "Open in new tab"),
h("button", { class:"iconbtn", onclick:closeModal, "aria-label":"Close" }, icon("x"))), body];
}, "modal-report");
}
function callReport(s){
if(!s.folder) return empty("This transcript has no call folder, so there is no report.");
const list = callReportList(s);
callSt.reportPick = callSt.reportPick || {};
const pick = list.find(r => r.key === callSt.reportPick[s.id]) || list[0];
const head = h("div", { class:"replist" }, list.map(r => h("div", { class:"repitem" + (r === pick ? " on" : ""), onclick:() => { callSt.reportPick[s.id] = r.key; render(); } },
h("div", { class:"grow" }, h("b", null, r.title), h("span", { class:"faint" }, r.sub)),
h("button", { class:"btn sm", onclick:e => { e.stopPropagation(); openReportModal(r); } }, "Open"),
h("button", { class:"btn sm", title:"Open in a new tab", onclick:e => { e.stopPropagation(); openReportTab(r); } }, icon("external", "xs"), "New tab"))));
let view;
if(pick.key === "reader"){
const c = callReportLoad(s);
if(c.loading) view = h("div", { class:"mskel" }, h("i"), h("i"));
else if(c.err != null && !c.html){
const rt = rows("tasks", t => t.source_id === s.id && t.agent === "call-reader")[0];
view = h("div", { class:"mcard" }, h("p", { class:"sub" }, c.err ? "Couldn't load the report: " + c.err :
"No report yet. The Call Reader writes report.html into this call's folder" + (rt ? " when its task is done (now: " + runState(rt) + ")." : ".")),
h("button", { class:"btn sm", style:"align-self:flex-start", onclick:() => { delete callSt.report[s.id]; scheduleRender(); } }, "Check again"));
} else view = h("div", { class:"mcard", style:"padding:0;overflow:hidden" }, reportFrame(c.html, pick.title),
h("div", { class:"split", style:"padding:8px 14px;border-top:1px solid var(--edge)" }, h("span", { class:"faint grow", style:"font-size:12px" }, pick.path),
h("button", { class:"btn sm", onclick:() => { delete callSt.report[s.id]; scheduleRender(); } }, "Reload")));
} else {
const k = "x:" + pick.path; callSt.report[k] = callSt.report[k] || (fileGet(pick.path).then(t => { callSt.report[k] = { html:t }; scheduleRender(); })
.catch(e => { callSt.report[k] = { err:String(e && e.message || e) }; scheduleRender(); }), { loading:true });
const c = callSt.report[k];
view = c.loading ? h("div", { class:"mskel" }, h("i"), h("i")) : c.html ? h("div", { class:"mcard", style:"padding:0;overflow:hidden" }, reportFrame(c.html, pick.title))
: h("div", { class:"mcard" }, h("p", { class:"sub" }, (pick.key.indexOf("ver:") === 0 ? "Couldn't load this earlier report (" : "This coaching page isn't in the call folder yet (") + pick.path + ")."));
}
return h("div", { class:"repwrap" }, head, view);
}
/* ---------------- Run Call Reader / Run Call Coach (in place of "Process with Claude" on a call) ---------------- */
const RUN_KIND = { Ready:"todo", Queued:"todo", Running:"prog", Waiting:"hold", Done:"done", Blocked:"blocked" };
function taskNote(t){
const m = rows("messages", x => x.thread === "task:" + t.id && x.kind !== "user" && x.kind !== "step").sort(by(x => x.at || x.created_at || "")).pop();
return m ? String(m.body || "").replace(/\s+/g, " ").slice(0, 240) : "";
}
function hasReport(s){ const c = s.folder && needSb() ? callReportLoad(s) : null; return !!(c && c.html); }
function runCall(s, agent, focus, confirmed){
try { const r = runCallAgent(s.id, agent, { hasReport:hasReport(s), focus, confirmed }); commit();
toast((agent === "call-reader" ? "Call Reader" : "Call Coach") + (r.created ? " queued" : " re-queued") + (crewOff() ? " — the crew is off, paste the line below into Claude Code" : " — the Dispatcher picks it up"));
} catch(e){ toast(e.message); }
}
/* the Call Reader: a call already read asks first (and takes an optional focus); the caret runs a first read with a focus */
function readerDone(s){ const t = callAgentTask(s.id, "call-reader"); return !!(t && t.status === "Completed") || hasReport(s); }
function runReader(s, withFocus){ if(readerDone(s) || withFocus) readerFocusModal(s, readerDone(s)); else runCall(s, "call-reader"); }
function readerFocusModal(s, again){
const t = callAgentTask(s.id, "call-reader"), when = callReadAt(s.id);
openModal(() => {
const ta = h("textarea", { class:"ta", style:"min-height:72px", value:(t && t.focus) || "", placeholder:"e.g. Only the estimate screens; who decided the tax rule" });
const go_ = () => { closeModal(); runCall(s, "call-reader", ta.value, true); };
return [modalHead(again ? "Read this call again?" : "Run the Call Reader with a focus"),
h("div", { class:"modal-b" },
h("p", { class:"sub" }, again ? "This call was already read" + (when ? " on " + fmtDY(when) : "") + ". Run it again? The current report is kept as a version and duplicate proposals are skipped."
: "Tell the Call Reader what to look at closely on this run. It still reads the whole call."),
fld2("Focus for this run", ta, again ? "Optional. Leave it empty for a plain re-read." : "Optional.")),
h("div", { class:"modal-f" }, h("button", { class:"btn", onclick:closeModal }, "Cancel"),
h("button", { class:"btn primary", onclick:go_ }, again ? "Run again" : "Run"))];
}, "narrow");
}
/* the Call Coach: a call already coached asks first (pages are kept as versions; each person's coaching row is updated, not duplicated) */
function runCoach(s, withFocus){
const t = callAgentTask(s.id, "call-coach"), again = !!(t && t.status === "Completed");
if(!again && !withFocus){ runCall(s, "call-coach"); return; }
openModal(() => {
const ta = h("textarea", { class:"ta", style:"min-height:64px", value:(t && t.focus) || "", placeholder:"e.g. Only Kishore; look closely at how promises were made" });
const go_ = () => { closeModal(); runCall(s, "call-coach", ta.value, again); };
return [modalHead(again ? "Coach this call again?" : "Run the Call Coach with a focus"),
h("div", { class:"modal-b" },
h("p", { class:"sub" }, again ? "This call was already coached. Run it again? The current coaching pages are kept as versions and each person's draft is updated, not duplicated."
: "Tell the Call Coach what to look at closely, or who to coach. It still scores everyone on our side."),
fld2("Focus for this run", ta, "Optional.")),
h("div", { class:"modal-f" }, h("button", { class:"btn", onclick:closeModal }, "Cancel"), h("button", { class:"btn primary", onclick:go_ }, again ? "Run again" : "Run"))];
}, "narrow");
}
function crewOffBox(t, sid){
const line = crewPasteLine(t, sid);
return h("div", { class:"crewoff" }, h("span", null, "Crew is off — paste in Claude Code:"), h("code", null, line),
h("button", { class:"btn sm ghost", onclick:() => (navigator.clipboard ? navigator.clipboard.writeText(line) : Promise.reject()).then(() => toast("Copied"), () => toast(line)) }, "Copy"));
}
/* a small, safe markdown: HTML is escaped first, then paragraphs, bullet and numbered lists, > quotes, # headings, **bold**, `code` and line breaks */
function mdInline(t){
return t.split("`").map((part, i) => i % 2 ? "" + part + "" : part.replace(/\*\*([^*]+)\*\*/g, "$1").replace(/(^|[\s(])\*([^*\s][^*]*)\*(?=[\s).,;:!?]|$)/g, "$1$2")).join("");
}
function mdHtml(src){
const lines = esc(String(src == null ? "" : src).replace(/\r\n?/g, "\n")).split("\n"), out = [];
let para = [], list = null, quote = [];
const flushP = () => { if(para.length){ out.push("" + para.map(mdInline).join("
") + "
"); para = []; } };
const flushL = () => { if(list){ out.push("<" + list.tag + ">" + list.items.map(x => "" + mdInline(x) + "").join("") + "" + list.tag + ">"); list = null; } };
const flushQ = () => { if(quote.length){ out.push("" + quote.map(mdInline).join("
") + "
"); quote = []; } };
const flush = () => { flushP(); flushL(); flushQ(); };
lines.forEach(l => {
let m;
if(!l.trim()){ flush(); return; }
if((m = l.match(/^\s*#{1,6}\s+(.*)$/))){ flush(); out.push("" + mdInline(m[1]) + "
"); return; }
if((m = l.match(/^\s*>\s?(.*)$/))){ flushP(); flushL(); quote.push(m[1]); return; }
if((m = l.match(/^\s*[-*•]\s+(.*)$/)) || (m = l.match(/^\s*\d+[.)]\s+(.*)$/))){
const tag = /^\s*\d/.test(l) ? "ol" : "ul"; flushP(); flushQ();
if(list && list.tag !== tag) flushL();
list = list || { tag, items:[] }; list.items.push(m[1]); return; }
if(list && /^\s{2,}\S/.test(l)){ list.items[list.items.length - 1] += " " + l.trim(); return; }
flushL(); flushQ(); para.push(l.trim());
});
flush();
return out.join("");
}
/* ---------------- live progress of a run (the Dispatcher writes agent_runs.extra.progress every few seconds) ---------------- */
function runProgress(t){
const r = rows("agent_runs", x => x.task_id === t.id && x.progress).sort(by(x => x.started_at || (x.progress || {}).started_at || "")).pop();
return r ? r.progress : null;
}
const RUN_ICON = { tool:"sliders", text:"chat", thinking:"diamond", result:"check" };
function fmtElapsed(ms){ const s = Math.max(0, Math.floor(ms / 1000)); return s < 3600 ? Math.floor(s / 60) + ":" + pad(s % 60) : Math.floor(s / 3600) + "h " + pad(Math.floor(s / 60) % 60) + "m"; }
function staleText(upd){ const ms = Date.now() - Date.parse(upd); return upd && ms > 6e4 ? "no update for " + Math.floor(ms / 6e4) + "m — may be stuck" : ""; }
/* one compact panel: elapsed (ticks client-side), turns bar, current step, recent steps (expandable unless compact) */
function runLive(t, compact){
const g = runProgress(t);
if(!g) return h("div", { class:"live" }, h("div", { class:"live-h" }, h("span", { class:"spin" }), h("b", null, t.agent), h("span", { class:"faint" }, "running · waiting for the first progress update")));
const pct = g.max_turns ? (+g.turns || 0) / g.max_turns * 100 : 0, recent = (g.recent || []).slice(-15);
const since = g.started_at || "", upd = g.updated_at || "";
return h("div", { class:"live" + (g.done ? " done" : "") },
h("div", { class:"live-h" }, g.done ? icon("check", "sm") : h("span", { class:"spin" }), h("b", null, g.agent || t.agent),
h("span", { class:"mono", "data-since":g.done ? null : since }, fmtElapsed((g.done && g.ended_at ? Date.parse(g.ended_at) : Date.now()) - Date.parse(since || nowISO()))),
h("span", { class:"faint mono" }, (g.turns || 0) + (g.max_turns ? " / " + g.max_turns : "") + " turns" + (g.tokens_out ? " · " + Math.round(g.tokens_out / 100) / 10 + "k out" : "")),
g.done && g.outcome ? pill(g.outcome, g.outcome === "done" ? "done" : "risk") : null),
g.max_turns ? bar(pct) : null,
g.current && !g.done ? h("div", { class:"live-cur" }, g.current) : null,
h("div", { class:"live-stale", "data-upd":g.done ? null : upd }, g.done ? "" : staleText(upd)),
!compact && recent.length ? h("details", { open:!!callSt.runOpen[t.id], ontoggle:e => { callSt.runOpen[t.id] = e.target.open; } },
h("summary", null, plural(recent.length, "recent step")),
h("ul", { class:"live-steps" }, recent.slice().reverse().map(x => h("li", null, icon(RUN_ICON[x.kind] || "right", "xs"), h("span", { class:"grow" }, x.label || x.kind),
h("span", { class:"faint mono" }, x.at ? fmtAgo(x.at) : ""))))) : null);
}
/* one 1-second ticker for every elapsed / stale label on screen: text only, never a re-render */
setInterval(() => {
document.querySelectorAll("[data-since]").forEach(e => { const t = Date.parse(e.getAttribute("data-since")); if(!isNaN(t)) e.textContent = fmtElapsed(Date.now() - t); });
document.querySelectorAll("[data-upd]").forEach(e => { const v = staleText(e.getAttribute("data-upd")); if(e.textContent !== v) e.textContent = v; });
}, 1000);
/* ---------------- People tab: who's who on the call ---------------- */
function fmtTalk(sec){ sec = Math.round(+sec || 0); return sec < 60 ? sec + "s" : Math.floor(sec / 60) + "m " + pad(sec % 60) + "s"; }
function callPeople(s){
const c = callLines(s);
if(c.err) return h("div", { class:"mcard" }, h("p", { class:"sub" }, "Couldn't load the transcript lines: " + c.err));
if(c.loading) return h("div", { class:"mskel" }, h("i"), h("i"), h("i", { style:"width:60%" }));
const stats = speakerStats(c.list);
if(!stats.length) return empty("No speaker turns on this call, so there is no one to map.");
/* a draft per viewer: kept while typing, written on Save */
let d = callSt.ppl[s.id];
if(!d){
const saved = {}; (s.people_map || []).forEach(x => saved[x.speaker] = x);
d = callSt.ppl[s.id] = { busy:false, rows:stats.map(x => { const v = saved[x.speaker] || {};
return { speaker:x.speaker, person:v.person || "", side:v.side || x.side || "unknown", role:v.role || "", decision_maker:!!v.decision_maker }; }) };
}
const by_ = {}; d.rows.forEach(r => by_[r.speaker] = r);
stats.forEach(x => { if(!by_[x.speaker]){ const r = { speaker:x.speaker, person:"", side:x.side, role:"", decision_maker:false }; d.rows.push(r); by_[x.speaker] = r; } });
const p = byId("projects", s.project_id) || {};
const names = uniq(rows("contacts", x => x.project_id === p.id || (!x.project_id && x.account_id === p.account_id)).map(x => x.name)
.concat(rows("users", u => u.type === "TEAM" && u.id !== "u-claude").map(u => u.name)).filter(Boolean)).sort();
const save = () => {
if(d.busy) return;
if(!needSb()){ toast("Sign in to Supabase in Settings first"); return; }
const old = {}; stats.forEach(x => old[x.speaker] = x.side);
const changed = d.rows.filter(r => old[r.speaker] != null && old[r.speaker] !== r.side);
d.busy = true; render();
changed.reduce((pr, r) => pr.then(() => linesSetSide(s.id, r.speaker, r.side)), Promise.resolve()).then(() => {
const side = {}; d.rows.forEach(r => side[r.speaker] = r.side);
(callSt.lines[s.id].list || []).forEach(l => { if(side[l.speaker]) l.side = side[l.speaker]; });
delete callSt.projLines[s.project_id];
const res = savePeople(s.id, d.rows);
d.busy = false; commit();
toast("Saved who's who" + (changed.length ? " · " + plural(changed.length, "speaker") + " moved side" : "") + (res.contacts ? " · " + plural(res.contacts, "client person", "client people") + " in Client people" : ""));
}).catch(e => { d.busy = false; render(); toast("Couldn't save · " + (e && e.message || e)); });
};
const statOf = {}; stats.forEach(x => statOf[x.speaker] = x);
return h("div", { class:"mcard" },
h("p", { class:"sub", style:"margin:0" }, "The Call Reader and Call Coach read this first — set who's who before running them."),
h("datalist", { id:"ppl-names-" + s.id }, names.map(n => h("option", { value:n }))),
h("div", { style:"overflow-x:auto" }, h("table", { class:"tbl ppl" },
h("thead", null, h("tr", null, h("th", null, "Speaker"), h("th", { class:"num" }, "Turns"), h("th", { class:"num" }, "Talk"), h("th", null, "Side"), h("th", null, "Is"), h("th", null, "Role"), h("th", null, "Decides"))),
h("tbody", null, d.rows.filter(r => statOf[r.speaker]).map(r => h("tr", null,
h("td", null, h("b", { class:"side-" + r.side }, r.speaker)),
h("td", { class:"num" }, statOf[r.speaker].turns), h("td", { class:"num" }, fmtTalk(statOf[r.speaker].talk_s)),
h("td", null, sel([["client","Client"],["fieldproxy","Fieldproxy"],["unknown","Unknown"]], r.side, v => { r.side = v; }, "sm")),
h("td", null, h("input", { class:"inp", list:"ppl-names-" + s.id, value:r.person, placeholder:"e.g. Tony", oninput:e => { r.person = e.target.value; } })),
h("td", null, h("input", { class:"inp", value:r.role, placeholder:"e.g. Ops manager", oninput:e => { r.role = e.target.value; } })),
h("td", null, h("label", { class:"chk-l" }, h("input", { type:"checkbox", checked:r.decision_maker, onchange:e => { r.decision_maker = e.target.checked; } }), "Decision-maker"))))))),
h("div", { class:"split" }, h("span", { class:"faint grow", style:"font-size:12px" }, "Saving sets the side on every turn of that speaker, the call's Fieldproxy speakers, and adds named client people to the project's Client people."),
h("button", { class:"btn sm ghost", onclick:() => { delete callSt.ppl[s.id]; scheduleRender(); } }, "Reset"),
h("button", { class:"btn sm primary", disabled:d.busy, onclick:save }, d.busy ? "Saving…" : "Save")));
}
const PTYPE = { requirement:["Requirement","prog"], decision:["Decision","done"], promise:["Our promise","risk"], question:["Question","todo"], bug:["Bug","blocked"], product_match:["Product match","hold"], rule:["Rule","risk"] };
/* To Test on a team task: Anand tests it himself (pass / fail with a note) or asks the Reviewer to test the live app */
function testActions(t, compact){
if(!t || t.status !== "To Test" || t.doer !== "team") return null;
const rev = rows("tasks", x => x.agent === "reviewer" && x.tests_task_id === t.id && x.status !== "Completed")[0];
return h("div", { class:"test-acts" + (compact ? " sm" : "") },
compact ? null : h("span", { class:"test-l" }, icon("eye", "sm"), "Ready to test"),
h("button", { class:"btn sm jade", onclick:e => { e.stopPropagation(); testResult(t, true); commit(); toast("Passed — “" + t.name + "” is done"); } }, icon("check", "sm"), "Passed"),
h("button", { class:"btn sm", style:"color:var(--red)", onclick:e => { e.stopPropagation(); testFailModal(t); } }, "Failed…"),
rev ? h("a", { class:"link", style:"font-size:12.5px", href:"javascript:void 0", onclick:() => openTask(rev.id) }, "Reviewer: " + runState(rev).toLowerCase())
: h("button", { class:"btn sm", title:"The Reviewer clicks through the client's live app, read-only, and passes or fails it with screenshots",
onclick:e => { e.stopPropagation(); reviewerTest(t); commit(); toast("The Reviewer will test it on the live app"); } }, icon("play", "sm"), "Run the Reviewer"));
}
function testFailModal(t){
openModal(() => {
const n = h("textarea", { class:"ta", placeholder:"What's broken? e.g. the map still isn't full window on a 13\" laptop" });
return [modalHead("Failed the test", "Goes back to " + (t.assignee_ids || []).map(firstName).join(", ") + " as in progress, with your note."),
h("div", { class:"modal-b" }, fld2("What broke", n)),
h("div", { class:"modal-f" }, h("button", { class:"btn", onclick:closeModal }, "Cancel"),
h("button", { class:"btn danger", onclick:() => { const v = n.value.trim(); if(!v){ toast("Say what broke"); return; } closeModal(); testResult(t, false, v); commit(); toast("Sent back to in progress"); } }, "Send back"))];
}, "narrow");
}
function proposalCard(pr){
const t = PTYPE[pr.type] || [pr.type, "todo"], m = pr.product_match || null, editing = callSt.edit[pr.id];
const made = pr.created_task_id ? byId("tasks", pr.created_task_id) : null;
return h("div", { class:"prop " + (pr.status || "pending") },
h("div", { class:"prop-h" }, pill(t[0], t[1]), pr.record_status && pr.type === "requirement" ? h("span", { class:"tagp" + (pr.record_status === "confirmed" ? " ok" : "") }, pr.record_status) : null,
editing ? h("input", { class:"inp", style:"flex:1", value:pr.title, onchange:e => { update("proposals", pr.id, { title:e.target.value.trim() || pr.title }); callSt.edit[pr.id] = false; commit(); } })
: h("b", { class:"grow" }, pr.title),
pr.status === "pending" ? h("span", { class:"faint", style:"font-size:11.5px;white-space:nowrap" }, "→ " + proposalTarget(pr)) : null),
pr.detail ? h("p", { class:"prop-d" }, pr.detail) : null,
pr.quote ? h("blockquote", null, "“" + pr.quote + "”", h("small", null, [pr.speaker || "unknown", pr.speaker_role, pr.at_time].filter(Boolean).join(" · "))) : null,
m ? h("div", { class:"prop-m" }, m.asked ? h("div", null, h("span", { class:"k" }, "Asked"), m.asked) : null, m.need ? h("div", null, h("span", { class:"k" }, "Need"), m.need) : null,
m.answer ? h("div", null, h("span", { class:"k" }, "Answer"), m.answer, m.doc ? h("span", { class:"mono faint", style:"font-size:11px" }, " · " + m.doc) : null) : null,
m.verdict ? h("div", null, h("span", { class:"k" }, "Verdict"), m.verdict + (m.confidence ? " · " + m.confidence + " confidence" : "")) : null) : null,
pr.screen ? h("div", { class:"faint", style:"font-size:12px" }, "On screen: " + pr.screen) : null,
h("div", { class:"acts" },
pr.status === "pending" ? [
h("button", { class:"btn sm jade", onclick:() => { try { const r = acceptProposal(pr); commit(); toast(r.linked ? "Linked to the existing record" : "Accepted → " + proposalTarget(pr)); } catch(e){ toast(e.message); } } }, icon("check", "sm"), "Accept"),
h("button", { class:"btn sm", onclick:() => { rejectProposal(pr); commit(); } }, "Reject"),
h("button", { class:"btn sm ghost", onclick:() => { callSt.edit[pr.id] = !editing; scheduleRender(); } }, icon("pencil", "sm"), editing ? "Done" : "Edit")]
: [pill(pr.status === "accepted" ? "Accepted" : "Rejected", pr.status === "accepted" ? "done" : "blocked"),
made ? h("a", { class:"link", href:"javascript:void 0", onclick:() => openTask(made.id) }, made.name) : null,
made && made.doer === "team" ? statusPill(made.status) : null,
made ? testActions(made, true) : null,
pr.created_requirement_id ? h("a", { class:"link", href:"#/projects/" + pr.project_id + "/requirements" }, "Requirement") : null,
pr.created_ticket_id ? h("a", { class:"link", href:"#/projects/" + pr.project_id + "/product" }, "Product ticket") : null,
pr.created_decision_id ? h("span", { class:"faint", style:"font-size:12px" }, "Decision logged") : null,
pr.created_rule_id ? h("a", { class:"link", href:"#/projects/" + pr.project_id + "/requirements", onclick:() => { reqUi.view = "rules"; } }, "Rule " + pr.created_rule_id) : null]));
}
/* search every call on a project: lines are loaded once per project, on first search */
function callSearchBar(p){
const inp = h("input", { class:"inp", style:"max-width:340px", placeholder:"Search every call on this project", oninput:e => { clearTimeout(callSearchBar.t); callSearchBar.t = setTimeout(() => runCallSearch(p, e.target), 200); },
onkeydown:e => { if(e.key === "Escape"){ e.target.value = ""; closePop(); } } });
return h("div", { class:"callbar" },
h("button", { class:"btn primary sm", onclick:() => addCallModal(p) }, icon("plus", "sm"), "Add call"),
h("div", { class:"split", style:"gap:6px;flex:1;justify-content:flex-end" }, icon("search", "sm"), inp));
}
function runCallSearch(p, inp){
const q = inp.value.trim(); if(q.length < 2){ closePop(); return; }
if(!needSb()){ openPop(inp, empty("Sign in to Supabase in Settings to search calls.")); return; }
const c = callSt.projLines[p.id];
if(!c || c.err){ callSt.projLines[p.id] = { loading:true }; openPop(inp, empty("Loading every call…"));
linesLoad({ project_id:p.id }).then(list => { callSt.projLines[p.id] = { list, at:Date.now() }; if(document.activeElement === inp) runCallSearch(p, inp); })
.catch(e => { callSt.projLines[p.id] = { err:e.message }; openPop(inp, empty("Couldn't search: " + e.message)); }); return; }
if(c.loading) return;
const ql = q.toLowerCase(), hits = c.list.filter(l => String(l.text || "").toLowerCase().indexOf(ql) > -1);
const bySrc = {}; hits.forEach(l => (bySrc[l.source_id] = bySrc[l.source_id] || []).push(l));
const res = Object.keys(bySrc).map(sid => { const s = byId("sources", sid) || { subject:sid };
return [h("div", { class:"pop-h" }, (s.subject || "Call") + " · " + plural(bySrc[sid].length, "mention")),
bySrc[sid].slice(0, 8).map(l => h("button", { class:"pop-i", onclick:() => { closePop(); callSt.q[sid] = ""; cwSt.spk[sid] = "all";
callSt.at = callSt.at || {}; callSt.at[sid] = l.seq; callSt.scrolled = ""; go("projects/" + p.id + "/calls/" + sid + "/transcript"); } },
h("span", { class:"mono faint", style:"font-size:11px;width:56px;flex:none" }, fmtTs(l.t_start)),
h("div", { class:"grow", style:"font-size:12.5px" }, h("b", null, (l.speaker || "") + " "), hl(snippet(String(l.text || ""), ql), q))))]; });
openPop(inp, h("div", { style:"max-width:460px;max-height:60vh;overflow:auto" }, res.length ? res : empty("No call mentions “" + q + "”"),
h("div", { class:"faint", style:"font-size:11px;padding:6px 10px" }, plural(hits.length, "mention") + " in " + plural(Object.keys(bySrc).length, "call"))));
}
function snippet(t, ql){ const i = t.toLowerCase().indexOf(ql); return i < 60 ? t.slice(0, 160) : "…" + t.slice(i - 50, i + 110); }
/* ---------------- Requirements tab ---------------- */
let reqUi = { st:"open", q:"", view:"timeline", story:"" };
const REQ_KIND = { proposed:"todo", confirmed:"prog", built:"hold", shipped:"done", declined:"blocked", deferred:"risk", changed:"prop" };
/* ---------------- Requirements: how the asks evolved (SPEC §1c · Evolution mockup) ----------------
requirements.extra.history = [{at, source_id, call_date, kind, from, to, quote, speaker, at_time}],
sources.extra.pulse = {mood, trust, patience:{level 1-5, label, why, quote, speaker, at_time}, risk:{label, why, …}, summary}. */
const RQ_KIND = { added:["+","added","jade"], changed:["↻","changed","gold"], confirmed:["✓","confirmed","blue"], declined:["✕","declined","red"], deferred:["✕","deferred","red"] };
const RQ_VIEWS = [["review","Review"],["spec","Spec"],["rules","Rules"],["timeline","Timeline"],["byreq","By requirement"],["changes","Only changes"],["list","List"]];
function tabRequirements(p, r){
const v = RQ_VIEWS.some(x => x[0] === reqUi.view) ? reqUi.view : "timeline";
const toggles = h("div", { class:"rq-top" },
h("div", { class:"grow" }, h("h2", { class:"rq-h1" }, v === "list" ? "Requirements" : v === "review" ? "Review" : v === "spec" ? "What we'll build" : v === "rules" ? "Business rules" : "How the asks evolved"),
h("span", { class:"faint", style:"font-size:12.5px" }, v === "list" ? "Every requirement on this project, by status" : v === "review" ? "Decide what the client asked for, and keep one list of what to ask them" : v === "spec" ? "By phase, how we'll know each is done, and whether the client agreed"
: v === "rules" ? "The logic the system must follow, each with an example and where it came from" : "Each call's changes to what the client asked for, and how they felt about it")),
h("div", { class:"rq-tg", role:"group", "aria-label":"View" }, RQ_VIEWS.map(x => h("button", { "aria-pressed":String(v === x[0]), onclick:() => { reqUi.view = x[0]; render(); } }, x[1]))));
if(v === "list") return [toggles, reqList(p, r)];
if(v === "spec") return [toggles, reqSpecView(p)];
if(v === "review") return [toggles, reqReviewView(p)];
if(v === "rules") return [toggles, reqRulesView(p)];
const calls = rows("sources", s => s.project_id === p.id && s.kind === "call").sort(by(s => s.occurred_at || ""));
const reqs = rows("requirements", x => x.project_id === p.id && Array.isArray(x.history) && x.history.length);
return h("div", { class:"rq" }, toggles, rqPulse(p, calls), v === "byreq" ? rqByReq(p, reqs) : rqGrid(p, reqs, v === "changes"));
}
function rqCallName(s){ const t = (typeof cwTitle === "function" ? cwTitle(s) : String(s.subject || "Call")).replace(/[\s·-]*\d{4}-\d{2}-\d{2}$/, "") || "Call"; return t.length > 16 ? t.slice(0, 15) + "…" : t; }
function rqDay(d){ if(!d) return ""; const x = new Date(String(d).length === 10 ? d + "T12:00:00" : d); return isNaN(x) ? "" : x.getDate() + " " + MON[x.getMonth()]; }
function rqSay(o){ if(!o) return ""; const at = o.at_time != null && o.at_time !== "" ? (typeof o.at_time === "number" ? cwTime(o.at_time) : String(o.at_time)) : "";
return [o.speaker, at].filter(Boolean).join(" · "); }
function rqLvl(o){ const n = +(o && o.level); return n >= 1 && n <= 5 ? n : 0; }
function rqPulse(p, calls){
const withP = calls.filter(s => s.pulse && typeof s.pulse === "object");
const legend = h("div", { class:"rq-legend" }, [["Mood","var(--jade)"],["Trust","var(--blue)"],["Patience","var(--gold)"]].map(x => h("span", null, h("i", { style:{ background:x[1] } }), x[0])));
const headN = h("div", { class:"rq-sec-h" }, h("h2", null, "Client pulse, call by call"), withP.length ? legend : null);
if(!withP.length) return h("section", { class:"cw-card rq-card" }, headN, h("p", { class:"rq-empty" }, "No pulse yet — the Call Reader records it from its next run."));
const latest = withP[withP.length - 1], prev = withP[withP.length - 2];
const cols = calls.map(s => { const pl = s.pulse && typeof s.pulse === "object" ? s.pulse : null;
const b = (o, c) => h("span", { class:"rq-b", style:{ height:(rqLvl(o) / 5 * 100) + "%", background:c }, title:(o && o.label ? o.label + " · " : "") + rqLvl(o) + "/5" });
return h("a", { class:"rq-col" + (s === latest ? " on" : "") + (pl ? "" : " none"), href:"#/projects/" + p.id + "/calls/" + s.id, title:(typeof cwTitle === "function" ? cwTitle(s) : s.subject) + (pl && pl.summary ? "\n" + pl.summary : pl ? "" : "\nNo pulse recorded") },
h("div", { class:"rq-bars" }, pl ? [b(pl.mood, "var(--jade)"), b(pl.trust, "var(--blue)"), b(pl.patience, "var(--gold)")] : h("span", { class:"rq-nob" })),
h("span", { class:"rq-cn" }, rqCallName(s)), h("span", { class:"rq-cd" }, rqDay(s.occurred_at))); });
const trend = k => { const a = rqLvl((latest.pulse || {})[k]), b = prev ? rqLvl((prev.pulse || {})[k]) : 0; return !b || !a || a === b ? "" : a > b ? " · rising" : " · falling"; };
const pc = (k, label, cls) => { const o = latest.pulse[k] || {};
return h("div", { class:"rq-pc " + cls }, h("span", { class:"k" }, label + (k === "risk" ? "" : trend(k))), h("b", null, o.label || (rqLvl(o) ? rqLvl(o) + " / 5" : "—")),
h("span", { class:"q" }, o.quote ? "“" + o.quote + "”" + (rqSay(o) ? " — " + rqSay(o) : "") : (o.why || ""))); };
return h("section", { class:"cw-card rq-card" }, headN,
h("div", { class:"rq-cols-w" }, h("div", { class:"rq-cols", style:{ gridTemplateColumns:"repeat(" + calls.length + ", minmax(64px, 112px))" } }, cols)),
h("div", { class:"rq-pcs" }, pc("mood", "Mood · last call", "jade"), pc("trust", "Trust", "blue"), pc("patience", "Patience", "gold"), pc("risk", "Risk", "red")));
}
/* columns of the grid: every call (or other source) any shown requirement's history points at, by date */
function rqCols(reqs, only){
const m = {};
reqs.forEach(r => r.history.forEach(e => { if(only && !rqIsChange(e)) return;
const k = e.source_id || "d:" + (e.call_date || String(e.at || "").slice(0, 10)), s = e.source_id ? byId("sources", e.source_id) : null;
if(!m[k]) m[k] = { key:k, s, date:(s && s.occurred_at) || e.call_date || e.at || "", name:s ? rqCallName(s) : rqDay(e.call_date || e.at) || "Other" }; }));
return Object.values(m).sort(by(c => String(c.date)));
}
function rqIsChange(e){ return e.kind === "changed" || e.kind === "declined" || e.kind === "deferred"; }
function rqColKey(e){ return e.source_id || "d:" + (e.call_date || String(e.at || "").slice(0, 10)); }
function rqOrig(r){ const e = r.history.find(x => x.kind === "added") || r.history[0]; return (e && e.to) || r.title; }
function rqNow(r){ return r.title !== rqOrig(r) ? r.title : (r.status || "proposed") + (r.status === "declined" || r.status === "deferred" ? ", out for now" : ""); }
function rqChip(e){ const k = RQ_KIND[e.kind] || ["•", e.kind || "event", "faint"];
return h("span", { class:"rq-chip " + k[2], title:[e.to ? (e.kind === "changed" && e.from ? e.from + " → " + e.to : e.to) : "", e.quote ? "“" + e.quote + "” " + rqSay(e) : ""].filter(Boolean).join("\n") }, k[0] + " " + k[1]); }
function rqGrid(p, all, only){
const reqs = (only ? all.filter(r => r.history.some(rqIsChange)) : all).slice()
.sort(by(r => String((r.history[0] || {}).call_date || (r.history[0] || {}).at || "") + r.title));
const legend = h("div", { class:"rq-legend" }, Object.keys(RQ_KIND).filter(k => k !== "deferred").map(k => h("span", null, h("b", { class:"c-" + RQ_KIND[k][2] }, "●"), " " + (k === "declined" ? "declined or deferred" : RQ_KIND[k][1]))));
const headN = h("div", { class:"rq-sec-h pad" }, h("h2", null, only ? "What changed, call by call" : "What changed in each call"), reqs.length ? legend : null);
if(!reqs.length) return h("section", { class:"cw-card flush rq-card" }, headN, h("p", { class:"rq-empty pad" }, only && all.length ? "No requirement has changed, been declined or deferred yet." : "No changes recorded yet — they appear as calls are read."));
const cols = rqCols(reqs, only), lastK = cols.length ? cols[cols.length - 1].key : "";
const pick = reqs.find(r => r.id === reqUi.story) || null;
const grid = h("div", { class:"rq-grid", role:"table", style:{ gridTemplateColumns:"minmax(220px, 300px) repeat(" + cols.length + ", minmax(110px, 1fr))", minWidth:(260 + cols.length * 120) + "px" } },
h("div", { class:"rq-th", role:"columnheader" }, "Requirement"),
cols.map(c => h("div", { class:"rq-th" + (c.key === lastK ? " last" : ""), role:"columnheader", title:c.s ? (typeof cwTitle === "function" ? cwTitle(c.s) : c.s.subject) + " · " + rqDay(c.date) : "" }, c.name, h("small", null, rqDay(c.date)))),
reqs.map(r => { const sel_ = () => { reqUi.story = pick && pick.id === r.id ? "" : r.id; render();
setTimeout(() => { const x = document.querySelector(".rq-story"); if(x && reqUi.story) x.scrollIntoView({ block:"nearest", behavior:"smooth" }); }, 30); };
return h("div", { class:"rq-row" + (pick && pick.id === r.id ? " on" : ""), role:"row", tabindex:"0", onclick:sel_, onkeydown:e => { if(e.key === "Enter" || e.key === " "){ e.preventDefault(); sel_(); } } },
h("div", { class:"rq-td first" }, h("b", null, rqOrig(r)), h("span", null, "now: " + rqNow(r))),
cols.map(c => h("div", { class:"rq-td" + (c.key === lastK ? " last" : "") }, r.history.filter(e => rqColKey(e) === c.key && (!only || rqIsChange(e))).map(rqChip)))); }));
return [h("section", { class:"cw-card flush rq-card" }, headN, h("div", { class:"rq-scroll" }, grid),
!pick ? h("p", { class:"rq-hint" }, "Click a requirement to see its story.") : null),
pick ? rqStory(p, pick) : null];
}
function rqStory(p, r, inline){
const ev = r.history;
const items = ev.map((e, i) => { const k = RQ_KIND[e.kind] || ["•", e.kind || "event", "faint"], s = e.source_id ? byId("sources", e.source_id) : null;
const where = [s ? (typeof cwTitle === "function" ? cwTitle(s) : s.subject) : "", rqDay(e.call_date || (s && s.occurred_at) || e.at), k[1] + (e.kind === "changed" && ev.slice(0, i).some(x => x.kind === "changed") ? " again" : "")].filter(Boolean).join(" · ");
return h("div", { class:"rq-ev" + (i === ev.length - 1 ? " last" : "") }, h("i", { class:"c-" + k[2] }),
h("div", { class:"rq-evb" }, s ? h("a", { class:"rq-evw", href:"#/projects/" + p.id + "/calls/" + s.id, onclick:() => { const sec = e.at_time != null && e.at_time !== "" ? (typeof e.at_time === "number" ? e.at_time : parseTs(e.at_time)) : null; if(sec != null) cwSt.jump[s.id] = sec; } }, where)
: h("span", { class:"rq-evw" }, where),
h("b", null, e.to || (i === 0 ? rqOrig(r) : r.title)),
e.kind === "changed" && e.from ? h("span", { class:"rq-was" }, "Was: " + e.from + " · now: " + (e.to || r.title)) : null,
e.quote ? h("span", { class:"rq-q" }, "“" + e.quote + "”" + (rqSay(e) ? " — " + rqSay(e) : "")) : null)); });
return h("section", { class:inline ? "rq-story inline" : "cw-card rq-card rq-story" },
inline ? null : h("div", { class:"rq-sec-h" }, h("h2", null, rqOrig(r) + " · its story"),
h("button", { class:"btn sm", onclick:() => { reqUi.story = ""; render(); } }, "Close")),
h("div", { class:"rq-tl" }, items));
}
function rqByReq(p, reqs){
if(!reqs.length) return h("section", { class:"cw-card rq-card" }, h("div", { class:"rq-sec-h" }, h("h2", null, "By requirement")), h("p", { class:"rq-empty" }, "No changes recorded yet — they appear as calls are read."));
const list = reqs.slice().sort(by(r => (r.history.some(rqIsChange) ? "0" : "1") + r.title.toLowerCase()));
return h("div", { class:"rq-byreq" }, list.map(r => h("section", { class:"cw-card rq-card" },
h("div", { class:"rq-sec-h" }, h("div", { class:"grow", style:"min-width:0" }, h("h2", { class:"sm" }, r.title), r.title !== rqOrig(r) ? h("span", { class:"faint", style:"font-size:12.5px" }, "First asked as: " + rqOrig(r)) : null),
h("span", { class:"pill " + (REQ_KIND[r.status] || "todo") }, r.status || "proposed"), h("span", { class:"faint mono", style:"font-size:11px" }, plural(r.history.length, "event"))),
rqStory(p, r, true))));
}
/* the List view: every requirement in a table, by status (the Requirements tab before §1c) */
function reqList(p){
const all = rows("requirements", r => r.project_id === p.id);
const F = { open:r => ["proposed","confirmed","changed","built"].indexOf(r.status) > -1, confirmed:r => r.status === "confirmed", done:r => r.status === "built" || r.status === "shipped",
parked:r => r.status === "declined" || r.status === "deferred", all:() => true };
const q = reqUi.q.toLowerCase();
const list = all.filter(F[reqUi.st] || F.all).filter(r => !q || [r.title, r.detail, r.quote, r.speaker].join(" ").toLowerCase().indexOf(q) > -1)
.sort(by(r => REQ_STATUSES.indexOf(r.status) + ":" + (r.created_at || "")));
const cnt = f => all.filter(f).length;
return [h("div", { class:"filters" },
segm([["open","Open " + cnt(F.open)],["confirmed","Confirmed " + cnt(F.confirmed)],["done","Built / shipped " + cnt(F.done)],["parked","Declined / deferred " + cnt(F.parked)],["all","All " + all.length]], reqUi.st, v => { reqUi.st = v; scheduleRender(); }),
h("input", { class:"inp", style:"width:220px", placeholder:"Filter", value:reqUi.q, oninput:e => { reqUi.q = e.target.value; } , onchange:() => scheduleRender() }),
h("span", { class:"grow" }),
h("button", { class:"btn primary sm", onclick:() => reqModal(p, null) }, icon("plus", "sm"), "Requirement")),
h("div", { class:"card" }, list.length ? h("table", { class:"tbl" },
h("thead", null, h("tr", null, h("th", null, "Requirement"), h("th", null, "Status"), h("th", null, "Said"), h("th", null, "Tasks"))),
h("tbody", null, list.map(r => { const src = byId("sources", r.source_id), sup = r.supersedes_id ? byId("requirements", r.supersedes_id) : null;
return h("tr", null,
h("td", { style:"max-width:420px" }, h("a", { href:"javascript:void 0", style:"font-weight:500;color:var(--text)", onclick:() => reqModal(p, r) }, r.title),
r.detail ? h("div", { class:"faint", style:"font-size:12px;margin-top:2px" }, r.detail) : null,
sup ? h("div", { class:"faint", style:"font-size:11.5px" }, "Replaces: " + sup.title) : null),
h("td", null, sel(REQ_STATUSES, r.status, v => { update("requirements", r.id, Object.assign({ status:v }, v === "confirmed" && !r.confirmed_at ? { confirmed_at:todayISO() } : {}));
logAct(p.id, "", "set requirement “" + r.title + "” to " + v); commit(); }, "bare st-" + (REQ_KIND[r.status] || "todo")),
r.confirmed_at ? h("div", { class:"faint mono", style:"font-size:10.5px" }, "confirmed " + fmtD(r.confirmed_at)) : null),
h("td", { style:"max-width:320px;font-size:12.5px" }, r.quote ? h("div", null, "“" + r.quote + "”") : null,
h("div", { class:"faint", style:"font-size:11.5px" }, [r.speaker, r.at_time, src ? h("a", { href:"javascript:void 0", onclick:() => { if(src.kind === "call"){ const sec = r.at_time ? parseTs(r.at_time) : null; if(sec != null) cwSt.jump[src.id] = sec;
go("projects/" + p.id + "/calls/" + src.id + (sec != null ? "/transcript" : "")); } else { go("projects/" + p.id + "/mail"); mailSt.f = "all"; mailSt.sel = "s:" + src.id; } } }, src.subject || "call") : null]
.filter(Boolean).map((x, i) => [i ? " · " : "", x]))),
h("td", null, (r.task_ids || []).map(id => byId("tasks", id)).filter(Boolean).map(t => h("div", null, h("a", { href:"javascript:void 0", onclick:() => openTask(t.id), style:"font-size:12.5px" }, t.name)))));
})))
: empty(all.length ? "Nothing matches." : "No requirements yet. Accept a requirement from a call's Proposals, or add one."))];
}
function reqModal(p, r){
openModal(() => {
const v = r || {};
const t = h("input", { class:"inp", value:v.title || "" }), d = h("textarea", { class:"ta", value:v.detail || "" });
let st = v.status || "proposed";
const q = h("textarea", { class:"ta", value:v.quote || "", placeholder:"The words that back it, exactly as said" }), sp = h("input", { class:"inp", value:v.speaker || "" }), at = h("input", { class:"inp", value:v.at_time || "", placeholder:"00:17:08" });
const others = rows("requirements", x => x.project_id === p.id && x.id !== v.id);
let sup = v.supersedes_id || "";
const tasks = projTasks(p.id); let tids = (v.task_ids || []).slice();
return [modalHead(r ? "Requirement" : "Add a requirement", p.name), h("div", { class:"modal-b" },
fld2("Title", t), fld2("Detail", d),
h("div", { class:"row" }, fld2("Status", sel(REQ_STATUSES, st, x => st = x)), fld2("Replaces", sel([["", "Nothing"]].concat(others.map(x => [x.id, x.title.slice(0, 60)])), sup, x => sup = x))),
fld2("Quote", q), h("div", { class:"row" }, fld2("Speaker", sp), fld2("At", at)),
fld2("Tasks", sel([["", "Link a task…"]].concat(tasks.filter(x => tids.indexOf(x.id) === -1).map(x => [x.id, x.name.slice(0, 70)])), "", x => { if(x){ tids.push(x); toast("Linked — save to keep it"); } }),
tids.length ? plural(tids.length, "task") + " linked" : null)),
h("div", { class:"modal-f" }, r ? h("button", { class:"btn danger", onclick:() => { remove("requirements", r.id); closeModal(); commit(); } }, "Delete") : null, h("span", { class:"grow" }),
h("button", { class:"btn", onclick:closeModal }, "Cancel"),
h("button", { class:"btn primary", onclick:() => { if(!t.value.trim()) return;
const f = { title:t.value.trim(), detail:d.value, status:st, quote:q.value.trim(), speaker:sp.value.trim(), at_time:at.value.trim(), supersedes_id:sup, task_ids:tids };
if(st === "confirmed" && !v.confirmed_at) f.confirmed_at = todayISO();
if(r) update("requirements", r.id, f); else insert("requirements", Object.assign({ project_id:p.id, source_id:"", screen:"", confirmed_at:"" }, f));
if(sup && !r) update("requirements", sup, { status:"changed" });
closeModal(); commit(); } }, "Save"))];
});
}
/* ---------------- Coaching ---------------- */
const COACH_KIND = { draft:"todo", reviewed:"prog", shared:"done" };
const COACH_SHORT = { structure:"Struct.", articulation:"Artic.", technical:"Tech.", listening:"Listen", expectations:"Expect.", control:"Control", rapport:"Rapport" };
function scoreOf(c, k){ const v = (c.scores || {})[k]; return v == null || v === "" ? null : +(typeof v === "object" ? v.score : v); }
function avgScore(c){ const v = COACH_KEYS.map(k => scoreOf(c, k[0])).filter(x => x != null && !isNaN(x)); return v.length ? v.reduce((a, b) => a + b, 0) / v.length : null; }
function scoreCell(v){ return v == null || isNaN(v) ? h("span", { class:"faint" }, "—") : h("span", { class:"score s" + (v >= 7 ? "g" : v >= 5 ? "a" : "r") }, (Math.round(v * 10) / 10).toString()); }
function coachStatus(c){
const next = c.status === "draft" ? ["reviewed", "Mark reviewed"] : c.status === "reviewed" ? ["shared", "Mark shared"] : null;
return h("span", { class:"split", style:"gap:6px" }, pill(c.status || "draft", COACH_KIND[c.status] || "todo", true),
next && c.kind === "weekly" ? h("button", { class:"btn sm", onclick:e => { e.stopPropagation(); update("coaching", c.id, { status:next[0] }); commit(); toast(next[0] === "shared" ? "Marked shared — send them their page" : "Marked reviewed"); } }, next[1]) : null);
}
function personKey(c){ return c.person_id || "name:" + (c.person_name || "?"); }
function viewCoaching(r){
const all = rows("coaching");
if(r.parts[1]) return coachPerson(decodeURIComponent(r.parts[1]), all);
crumbs = [["Coaching"]];
const weeks = uniq(all.map(c => c.week).filter(Boolean)).sort().reverse();
const wk = r.q.week || weeks[0] || isoWeek();
const people = {}; all.forEach(c => { if(c.person_name === "team" || c.person_id === "team") return; (people[personKey(c)] = people[personKey(c)] || []).push(c); });
const team = all.find(c => c.week === wk && (c.person_name === "team" || c.person_id === "team"));
const rowsP = Object.keys(people).map(k => { const list = people[k], w = list.find(c => c.week === wk && c.kind === "weekly"), calls = list.filter(c => c.week === wk && c.kind === "call");
const prev = list.filter(c => c.kind === "weekly" && c.week < wk).sort(by(c => c.week)).pop();
return { k, name:list[0].person_name || userName(list[0].person_id), w, calls, prev }; }).sort(by(x => x.name));
return h("div", { class:"page" },
pageHead("Call Coach", "Coaching", "Scores for development, not a performance rating. Each person sees only their own page, after you review it.",
weeks.length ? sel(weeks.map(w => [w, w]), wk, v => go("coaching", { week:v })) : null),
h("div", { class:"tiles" }, tile(rowsP.filter(x => x.w).length, "Weekly pages · " + wk), tile(rowsP.filter(x => x.w && x.w.status === "draft").length, "To review", rowsP.some(x => x.w && x.w.status === "draft") ? "warn" : ""),
tile(rowsP.filter(x => x.w && x.w.status === "shared").length, "Shared", "good"), tile(rowsP.reduce((n, x) => n + x.calls.length, 0), "Calls scored this week")),
h("div", { class:"grid g-main" },
card("Team · " + wk, plural(rowsP.length, "person", "people"), rowsP.length ? h("table", { class:"tbl" },
h("thead", null, h("tr", null, h("th", null, "Person"), COACH_KEYS.map(k => h("th", { class:"num", title:k[1] }, COACH_SHORT[k[0]] || k[1])), h("th", { class:"num" }, "Avg"), h("th", null, "Page"))),
h("tbody", null, rowsP.map(x => { const src = x.w || x.calls[0] || {}; const a = avgScore(src), pa = x.prev ? avgScore(x.prev) : null;
return h("tr", { class:"click", onclick:() => go("coaching/" + encodeURIComponent(x.k), { week:wk }) },
h("td", null, h("div", { style:"font-weight:500" }, x.name), h("div", { class:"faint", style:"font-size:11.5px" }, plural(x.calls.length, "call") + (src.focus ? " · focus: " + src.focus : ""))),
COACH_KEYS.map(k => h("td", { class:"num" }, scoreCell(scoreOf(src, k[0])))),
h("td", { class:"num" }, scoreCell(a), pa != null && a != null ? h("span", { class:"faint", style:"font-size:11px;margin-left:4px" }, (a >= pa ? "↑" : "↓") + Math.abs(Math.round((a - pa) * 10) / 10)) : null),
h("td", null, x.w ? coachStatus(x.w) : h("span", { class:"faint", style:"font-size:12px" }, "no weekly page"))); })))
: empty("No coaching yet. The Call Coach scores each call after the Call Reader, and writes weekly pages on Friday."), { flush:true }),
h("div", { class:"grid", style:"align-content:start" },
card("Team summary", team ? coachStatus(team) : null, team ? [
team.focus ? h("p", { style:"margin:0 0 8px" }, team.focus) : null,
listBlock("Product gaps to train on", team.product_questions), listBlock("Promises at risk", team.promises),
team.page_path ? h("button", { class:"btn sm", onclick:() => pageModal("Team summary · " + wk, team.page_path) }, icon("file", "sm"), "Open team.html") : null]
: empty("No team summary for " + wk + " yet."), { internal:true, only:"Only you" }),
card("How it runs", null, h("p", { class:"sub" }, "After each call the Coach scores it quietly. Friday evening it writes one page per person and this summary. You review, then share each person's own page on Monday.")))));
}
function listBlock(title, v){
const l = Array.isArray(v) ? v : v ? [v] : [];
if(!l.length) return null;
return h("div", { style:"margin-bottom:10px" }, h("div", { class:"lab" }, title),
h("ul", { class:"plain" }, l.slice(0, 8).map(x => h("li", null, typeof x === "string" ? x : x.text || x.what || x.topic || x.q || x.question || x.said || JSON.stringify(x)))));
}
function coachPerson(k, all){
const list = all.filter(c => personKey(c) === k).sort(by(c => (c.week || "") + (c.kind === "weekly" ? "1" : "0") + (c.created_at || ""))).reverse();
const name = list[0] ? list[0].person_name || userName(list[0].person_id) : k;
crumbs = [["Coaching", "#/coaching"], [name]];
const weekly = list.filter(c => c.kind === "weekly"), calls = list.filter(c => c.kind !== "weekly");
const cur = weekly.find(c => c.week === (parseRoute().q.week || "")) || weekly[0] || calls[0];
if(!cur) return h("div", { class:"page" }, empty("No coaching for " + name + " yet."));
const trend = weekly.slice(0, 8).reverse();
return h("div", { class:"page" },
pageHead("Coaching · " + (cur.week || ""), name, "Compared only with their own earlier weeks. Scores carry ±1.", [coachStatus(cur),
cur.page_path ? h("button", { class:"btn", onclick:() => pageModal(name + " · " + cur.week, cur.page_path) }, icon("file", "sm"), "Open their page") : null]),
h("div", { class:"grid g-main" },
h("div", { class:"grid", style:"align-content:start" },
card("Scores", cur.kind === "weekly" ? "week " + cur.week : "one call", h("div", { class:"scores" }, COACH_KEYS.map(kk => { const v = scoreOf(cur, kk[0]), why = ((cur.scores || {})[kk[0]] || {}).why;
return h("div", { class:"sc-row", title:why || "" }, h("span", null, kk[1]), h("div", { class:"bar" }, h("i", { style:{ width:(v || 0) * 10 + "%" } })), scoreCell(v)); }))),
card("Better lines", null, (cur.better_lines || []).length ? (cur.better_lines || []).slice(0, 6).map(b => h("div", { class:"better" },
h("div", { class:"faint mono", style:"font-size:10.5px" }, b.at || b.timestamp || ""), h("div", null, h("s", null, b.said || b.was || ""), " → ", h("b", null, b.better || b.line || "")),
b.why ? h("div", { class:"faint", style:"font-size:12px" }, b.why) : null)) : empty("None this time.")),
card("Product questions", null, (cur.product_questions || []).length ? (cur.product_questions || []).map(q => h("div", { class:"better" },
h("b", null, q.question || q.q || q.topic || ""), q.answer ? h("div", null, q.answer) : null, q.doc ? h("div", { class:"mono faint", style:"font-size:11px" }, q.doc) : null)) : empty("None."))),
h("div", { class:"grid", style:"align-content:start" },
card("Focus for next week", null, h("p", { style:"margin:0" }, cur.focus || "—")),
card("English", null, (cur.english || []).length ? h("ul", { class:"plain" }, (cur.english || []).map(e => h("li", null, typeof e === "string" ? e : [h("s", null, e.said || e.pattern || ""), " → ", h("b", null, e.fix || e.better || "")]))) : empty("Nothing repeated.")),
listBlock("Promises", cur.promises) ? card("Promises", null, listBlock("", cur.promises)) : null,
card("Trend", plural(trend.length, "week"), trend.length ? h("table", { class:"tbl" }, h("tbody", null, trend.map(w => h("tr", { class:"click", onclick:() => go("coaching/" + encodeURIComponent(k), { week:w.week }) },
h("td", { class:"mono", style:"font-size:12px" }, w.week), h("td", { class:"num" }, scoreCell(avgScore(w))), h("td", null, pill(w.status || "draft", COACH_KIND[w.status] || "todo")))))) : empty("No weekly pages yet.")),
card("Calls", calls.length, calls.length ? h("ul", { class:"tl" }, calls.slice(0, 12).map(c => { const s = byId("sources", c.source_id) || {};
return h("li", null, h("div", { class:"grow" }, h("div", { class:"nm" }, s.subject || c.source_id || "Call"), h("div", { class:"ctx" }, (c.week || "") + (s.project_id ? " · " + ((byId("projects", s.project_id) || {}).name || "") : ""))), scoreCell(avgScore(c))); })) : empty("No calls scored.")))));
}
function pageModal(title, path){
openModal(() => {
const box = h("div", { class:"mskel" }, h("i"), h("i"));
if(!needSb()) box.replaceWith(empty("Sign in to Supabase in Settings."));
else fileGet(path).then(t => { const f = h("iframe", { class:"reportframe", sandbox:"allow-scripts allow-popups allow-popups-to-escape-sandbox", srcdoc:t, title });
box.replaceWith(f); }).catch(e => box.replaceWith(empty("Couldn't load " + path + ": " + e.message)));
return [modalHead(title, path), h("div", { class:"modal-b", style:"padding:0" }, box)];
}, "wide");
}
/* ---------------- Crew board ---------------- */
function crewState(){ return byId("crew_state", "crew") || { id:"crew", paused:false, max_sessions:3, max_helpers:12, max_browser:2, max_device:1, max_schema:1, quota_low:false, note:"" }; }
function setCrew(patch){
if(byId("crew_state", "crew")) update("crew_state", "crew", patch);
else insert("crew_state", Object.assign({}, crewState(), patch, { id:"crew" }));
commit();
}
function crewTasks(){ return rows("tasks", t => !t.parent_id && (t.agent || t.doer === "claude") && t.status !== "Completed" && t.status !== "Suggested" && liveProject(t)); }
/* why a task is waiting, in words: approvals, blockers, a busy dev app, the pause switch, no routing plan */
function crewWhy(t, all){
const ap = rows("approvals", a => a.task_id === t.id && a.status === "REQUESTED")[0];
if(t.status === "Waiting" || ap) return ap ? "Waiting on your approval: " + (ap.note || "approval").slice(0, 90) : "Paused for an approval";
if(t.status === "Blocked") return "Blocked" + (latestProgress(t.id) ? ": " + String(latestProgress(t.id).body).replace(/\s+/g, " ").slice(0, 90) : "");
if(t.status === "To Test") return readyToMerge().some(x => x.id === t.id) ? "Ready to merge" : "Waiting for you to test";
const blk = rows("deps", d => d.task_id === t.id).map(d => byId("tasks", d.blocked_by_id)).filter(b => b && b.status !== "Completed");
if(blk.length) return "Waits on “" + blk[0].name + "”";
if(crewState().paused) return "Crew paused";
const busy = t.dev_app && all.find(x => x.id !== t.id && x.dev_app === t.dev_app && x.status === "In progress");
if(busy) return "Dev app " + t.dev_app + " busy with “" + busy.name + "”";
if(t.agent !== "call-reader" && t.agent !== "call-coach" && t.agent !== "router" && !t.routing) return "Needs a routing plan (Router runs first)";
return "Queued";
}
function viewCrew(){
crumbs = [["Crew"]];
const cs = crewState(), all = crewTasks();
const working = all.filter(t => t.status === "In progress");
const merges = readyToMerge();
const waiting = all.filter(t => (t.status === "Waiting" || t.status === "Blocked" || t.status === "To Test" || rows("approvals", a => a.task_id === t.id && a.status === "REQUESTED").length) && t.status !== "In progress" && merges.indexOf(t) === -1);
const queued = all.filter(t => t.status === "To do" && waiting.indexOf(t) === -1);
const sugg = rows("tasks", t => t.status === "Suggested" && liveProject(t));
const runs = rows("agent_runs").sort(by(r => r.ended_at || r.started_at || "")).reverse().slice(0, 10);
const evening = rows("updates", u => !u.project_id).sort(by(u => u.at || "")).pop();
const line = (t, why, right) => h("li", { class:"click", onclick:() => go("projects/" + t.project_id + "/list", { t:t.id }) },
h("span", { class:"agent" }, t.agent || "claude"),
h("div", { class:"grow" }, h("div", { class:"nm" }, t.name), h("div", { class:"ctx" }, [(byId("projects", t.project_id) || {}).name, t.dev_app, why].filter(Boolean).join(" · "))), right || null);
return h("div", { class:"page" },
pageHead("Dispatcher", "Crew", "What each agent is doing, and why anything waits. The Dispatcher runs on your laptop; nothing here starts it.",
h("button", { class:"btn " + (cs.paused ? "primary" : "danger"), onclick:() => { setCrew({ paused:!cs.paused }); toast(cs.paused ? "Crew resumed" : "Crew paused: running sessions finish, nothing new starts"); } },
icon(cs.paused ? "play" : "ban", "sm"), cs.paused ? "Resume crew" : "Pause crew")),
cs.paused ? h("div", { class:"note-box warn" }, "The crew is paused. Running sessions finish their step; nothing new starts until you resume.") : null,
cs.quota_low ? h("div", { class:"note-box warn" }, "Quota is low: the Dispatcher runs fewer sessions.") : null,
h("div", { class:"tiles" }, tile(working.length + " / " + (cs.max_sessions || 3), "Sessions working"), tile(waiting.length, "Waiting", waiting.length ? "warn" : ""),
tile(queued.length, "Queued"), tile(merges.length, "Ready to merge", merges.length ? "good" : ""), tile(sugg.length, "Suggested", "", () => go("myday"))),
h("div", { class:"grid g-main" },
h("div", { class:"grid", style:"align-content:start" },
merges.length ? card("Ready to merge", merges.length, h("ul", { class:"tl" }, merges.map(t => line(t, "Reviewer passed",
h("button", { class:"btn sm jade", onclick:e => { e.stopPropagation(); approveMerge(t); commit(); toast("Merge approved — Release merges it into the main app"); } }, "Merge")))), { icon:"check" }) : null,
working.filter(t => runProgress(t)).map(t => card(t.name, (byId("projects", t.project_id) || {}).name || "", [runLive(t, true),
h("a", { class:"link", style:"font-size:12px", href:"#/projects/" + t.project_id + "/list?t=" + t.id }, "Open task")], { icon:"play" })),
card("Working", working.length, working.length ? h("ul", { class:"tl" }, working.filter(t => !runProgress(t)).map(t => line(t, t.session_id ? "session " + String(t.session_id).slice(0, 8) : "", h("span", { class:"chip" }, "working")))
.concat(working.some(t => runProgress(t)) && working.every(t => runProgress(t)) ? [h("li", { class:"faint", style:"font-size:12px" }, "Each running agent has its live card above.")] : [])) : empty("No agent is working right now.")),
card("Waiting", waiting.length, waiting.length ? h("ul", { class:"tl" }, waiting.map(t => line(t, crewWhy(t, all), statusPill(t.status)))) : empty("Nothing waiting."), { icon:"alert" }),
card("Queued", queued.length, queued.length ? h("ul", { class:"tl" }, queued.map(t => line(t, crewWhy(t, all), h("span", { class:"chip" }, "queued")))) : empty("Nothing queued for the crew."))),
h("div", { class:"grid", style:"align-content:start" },
evening ? card("Evening summary", fmtAgo(evening.at), h("div", { class:"bd", style:"white-space:pre-wrap;font-size:13px" }, evening.body)) : null,
card("Limits", null, h("div", { class:"kv" },
[["Sessions", "max_sessions"], ["Helpers", "max_helpers"], ["Browser-heavy", "max_browser"], ["Phone", "max_device"], ["Schema change", "max_schema"]].map(x => [h("span", { class:"k" }, x[0]),
h("input", { class:"inp", type:"number", min:0, style:"width:80px;padding:3px 8px", value:cs[x[1]] == null ? "" : cs[x[1]], onchange:e => setCrew({ [x[1]]:+e.target.value || 0 }) })]),
h("span", { class:"k" }, "Quota low"), h("label", { class:"chk-l" }, h("input", { type:"checkbox", checked:!!cs.quota_low, onchange:e => setCrew({ quota_low:e.target.checked }) }), "run fewer"))),
card("Recent runs", runs.length || "", runs.length ? h("ul", { class:"tl" }, runs.map(r => { const t = byId("tasks", r.task_id);
return h("li", null, h("span", { class:"agent" }, r.agent), h("div", { class:"grow" }, h("div", { class:"nm" }, t ? t.name : r.note || "—"), h("div", { class:"ctx" }, fmtAgo(r.ended_at || r.started_at) + (t && t.outcome ? " · " + t.outcome : ""))),
pill(r.outcome, { done:"done", waiting_on_you:"risk", blocked:"blocked", failed:"blocked" }[r.outcome] || "todo")); })) : empty("No runs recorded yet.")))));
}
/* routes and rail entries for the crew screens */
Object.assign(VIEWS, { coaching:viewCoaching, crew:viewCrew });
RAIL.splice(RAIL.findIndex(x => x[0] === "inbox") + 1, 0, ["crew","Crew","play"], ["coaching","Coaching","users"]);
/* ---------------- My Day: crew sections (through the Day bridge) ---------------- */
function acceptSuggested(t){ setTaskStatus(t, "To do"); update("tasks", t.id, { outcome:"" }); commit(); }
function rejectSuggested(t){ update("tasks", t.id, { outcome:"rejected" }); logAct(t.project_id, t.id, "rejected the suggestion “" + t.name + "”"); remove("tasks", t.id); commit(); }
Object.assign(window.OrbitDay = window.OrbitDay || {}, {
merges(){ return readyToMerge().map(t => ({ id:t.id, title:t.name, project:(byId("projects", t.project_id) || {}).name || "", dev_app:t.dev_app || "",
note:(latestProgress(t.id) || {}).body ? String(latestProgress(t.id).body).replace(/\s+/g, " ").slice(0, 80) : "Reviewer passed" })); },
suggested(){ return rows("tasks", t => t.status === "Suggested" && !t.parent_id && liveProject(t)).sort(by(t => t.created_at || "")).reverse().slice(0, 20)
.map(t => ({ id:t.id, title:t.name, project:(byId("projects", t.project_id) || {}).name || "", from:t.source_id ? "from " + ((byId("sources", t.source_id) || {}).subject || "a call") : "" })); },
waiting(){ return rows("tasks", t => t.status === "Waiting" && !t.parent_id && liveProject(t)).map(t => {
const ap = rows("approvals", a => a.task_id === t.id && a.status === "REQUESTED")[0];
return { id:t.id, title:t.name, project:(byId("projects", t.project_id) || {}).name || "", why:ap ? (ap.note || "approval").slice(0, 80) : "paused for an approval", approval:!!ap }; }); },
merge(id){ const t = byId("tasks", id); if(t){ approveMerge(t); commit(); } },
accept(id){ const t = byId("tasks", id); if(t) acceptSuggested(t); },
reject(id){ const t = byId("tasks", id); if(t) rejectSuggested(t); }
});
/* =====================================================================
The call workspace (7 Oct 2026 · docs/crew/call-workspace-mockup): one
full-width page per call at #/projects//calls/[/].
Tabs: Overview · Decide · Transcript · Reports · People · Tasks.
A re-composition of crew.js: runs, accept/reject, Ask, reports, People and
the live progress panel are the same functions the old call view used.
===================================================================== */
const CW_TABS = [["overview","Overview"],["decide","Decide"],["transcript","Transcript"],["reports","Reports"],["people","People"],["tasks","Tasks"]];
/* proposal types in queue order, with their colour token and plural label */
const CW_TYPES = [["requirement","Requirements","var(--blue)","Requirement"],["promise","Our promises","var(--jade)","Our promise"],["decision","Decisions","var(--violet)","Decision"],
["question","Questions","var(--gold)","Question"],["bug","Bugs","var(--red)","Bug"],["product_match","Product matches","var(--faint)","Product match"],["rule","Rules","var(--gold)","Rule"]];
const CW_TYPE = {}; CW_TYPES.forEach((t, i) => CW_TYPE[t[0]] = { label:t[1], color:t[2], one:t[3], order:i });
const CW_BECOMES = { requirement:"Becomes a requirement in the project record", decision:"Becomes a decision in the project's log", promise:"Becomes a task (to do) for us",
ticket:"Becomes a draft product ticket", suggested:"Becomes a suggested task", rule:"Becomes a business rule on the Requirements tab" };
const CW_CLIENT = ["var(--blue)","var(--blue-2)","var(--blue-3)","var(--blue-4)"], CW_FP = ["var(--jade)","var(--violet)","var(--jade-2)","var(--violet-2)"];
const cwSt = { focus:{}, spk:{}, jump:{}, mode:{}, draft:{}, stick:{}, open:{} }; // per viewer, this tab only
function cwHref(s, tab, q){
const qs = q ? new URLSearchParams(Object.entries(q).filter(e => e[1] != null && e[1] !== "")).toString() : "";
return "#/projects/" + s.project_id + "/calls/" + s.id + (tab && tab !== "overview" ? "/" + tab : "") + (qs ? "?" + qs : "");
}
function cwGo(s, tab, q){ location.hash = cwHref(s, tab, q).slice(1); }
/* a call's own thread object, so the Mail actions (mark processed, make task, log decision, move) work unchanged */
function cwThread(s){ return threadsOf([s])[0]; }
/* ---------- small formatters ---------- */
function cwTime(sec){ sec = Math.max(0, Math.floor(+sec || 0)); const hh = Math.floor(sec / 3600), mm = Math.floor(sec / 60) % 60, ss = sec % 60;
return hh ? hh + ":" + pad(mm) + ":" + pad(ss) : mm + ":" + pad(ss); }
function cwDur(ms, short){ const s = Math.max(0, Math.round(ms / 1000)); if(s < 60) return s + " s";
if(s < 3600) return Math.floor(s / 60) + " min" + (short || !(s % 60) ? "" : " " + (s % 60) + " s");
return Math.floor(s / 3600) + " h " + (Math.floor(s / 60) % 60) + " min"; }
function cwDate(iso){ const d = new Date(iso); if(isNaN(d)) return "";
return ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][d.getDay()] + " " + d.getDate() + " " + ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"][d.getMonth()] + " " + d.getFullYear(); }
function cwRecUrl(s){ const m = String(s.recording_url || "").match(/https?:\/\/\S+/); return m ? m[0] : ""; }
function cwPasscode(s){ const m = String(s.recording_url || "").match(/pass(?:code)?\s*:?\s*(\S+)/i); return m ? m[1] : ""; }
function cwTitle(s){ const acc = byId("accounts", s.account_id) || {};
let t = String(s.subject || "Call"); if(acc.name && t.indexOf(acc.name + " · ") === 0) t = t.slice(acc.name.length + 3);
return t.charAt(0).toUpperCase() + t.slice(1); }
function cwTypeLabel(s){ const t = String(s.call_type || "call"); return (t === "kt" ? "KT" : t.charAt(0).toUpperCase() + t.slice(1)) + (t === "call" ? "" : " call"); }
function cwAt(pr){ return pr && pr.at_time != null && pr.at_time !== "" ? parseTs(pr.at_time) : null; }
function cwSay(pr){ const at = cwAt(pr); return [pr.speaker || "unknown", at != null ? cwTime(at) : ""].filter(Boolean).join(" · "); }
/* ---------- who was on the call: the People mapping first, then the transcript's speakers, then the sources row ---------- */
function cwPeople(s){
const p = byId("projects", s.project_id) || {};
const contacts = rows("contacts", c => c.project_id === p.id || (!c.project_id && c.account_id === p.account_id));
const out = [], seen = {};
const push = (speaker, name, side, role, dm) => {
const k = String(name || speaker || "").trim().toLowerCase(); if(!k || seen[k]) return; seen[k] = 1;
const c = contacts.find(x => sameName(x.name, name || speaker));
out.push({ speaker:speaker || name, name:name || speaker, side:side || "unknown", role:role || (c && c.role) || "", dm:!!dm || !!(c && c.decision_maker) });
};
const map = s.people_map || [];
if(map.length) map.forEach(x => push(x.speaker, x.person || x.speaker, x.side, x.role, x.decision_maker));
else {
const c = callSt.lines[s.id];
if(c && c.list && c.list.length) speakerStats(c.list).sort((a, b) => b.talk_s - a.talk_s).forEach(x => push(x.speaker, cwName(x.speaker), x.side, "", false));
else String(s.people || "").split(",").map(x => x.trim()).filter(Boolean).forEach(n => push(n, cwName(n), speakerSide(n, s.fp_speakers), "", false));
}
const ci = { client:0, fieldproxy:0, unknown:0 };
out.forEach(x => { x.color = x.side === "fieldproxy" ? CW_FP[ci.fieldproxy++ % CW_FP.length] : x.side === "client" ? CW_CLIENT[ci.client++ % CW_CLIENT.length] : "var(--faint)"; });
return out;
}
/* a transcript name as people read it: "scott kuryak" → "Scott Kuryak" (names already cased are left alone) */
function cwName(n){ n = String(n || "").trim(); return n === n.toLowerCase() ? n.replace(/\b\w/g, c => c.toUpperCase()) : n; }
function cwPersonOf(people, name){ return people.find(x => sameName(x.speaker, name) || sameName(x.name, name) || (name && String(x.name).split(/\s+/)[0].toLowerCase() === String(name).split(/\s+/)[0].toLowerCase())); }
function cwAv(x, size){
return h("span", { class:"cw-av" + (size ? " " + size : "") + (x && x.dm ? " dm" : ""), style:{ background:x ? x.color : "var(--faint)" },
title:x ? x.name + (x.dm ? " · decision-maker" : x.role ? " · " + x.role : "") : "" }, initials(x ? x.name : "?"));
}
/* ---------- agent runs: the latest finished run of a call agent, with its time, steps and cost ---------- */
function cwRun(t){
if(!t) return null;
const rs = rows("agent_runs", x => x.task_id === t.id).sort(by(x => x.started_at || ""));
const rich = x => x.turns != null || x.cost_usd != null || x.progress;
const r = rs.filter(x => x.outcome === "done" && rich(x)).pop() || rs.filter(x => x.outcome === "done").pop() || rs.filter(rich).pop();
if(!r) return null;
const g = r.progress || {}, a = Date.parse(g.started_at || r.started_at), b = Date.parse(g.ended_at || r.ended_at);
return { run:r, ms:!isNaN(a) && !isNaN(b) && b > a ? b - a : 0, turns:r.turns != null ? r.turns : g.turns, cost:r.cost_usd, at:r.ended_at || g.ended_at || "" };
}
function cwAgentLine(t){
const st = runState(t);
if(!t) return "Not run yet";
const r = cwRun(t);
if(st === "Done" && r) return ["Done", r.ms ? cwDur(r.ms) : "", r.turns ? r.turns + " steps" : "", r.cost != null ? "$" + (+r.cost).toFixed(2) : ""].filter(Boolean).join(" · ");
if(st === "Running") return "Running now";
if(st === "Queued") return crewOff() ? "Queued · the crew is off" : "Queued · the Dispatcher picks it up";
return st + (taskNote(t) ? " · " + taskNote(t).slice(0, 80) : "");
}
/* ---------- header ---------- */
function cwChips(s){
const out = [], rt = callAgentTask(s.id, "call-reader"), ct = callAgentTask(s.id, "call-coach");
[[rt, "Read", "Reading"], [ct, "Coached", "Coaching"]].forEach(x => {
const t = x[0]; if(!t) return;
const st = runState(t), r = cwRun(t);
if(st === "Done") out.push(h("span", { class:"cw-chip ok" }, "✓ " + x[1] + (r && r.ms ? " · " + cwDur(r.ms, true) : "")));
else if(st === "Running") out.push(h("span", { class:"cw-chip run" }, h("span", { class:"spin" }), x[2] + "…"));
else if(st === "Queued") out.push(h("span", { class:"cw-chip" }, x[2].replace(/ing$/, "") + " queued"));
else if(st === "Blocked") out.push(h("span", { class:"cw-chip bad" }, x[1].replace(/ed$/, "") + " blocked"));
});
const pend = rows("proposals", x => x.source_id === s.id && x.status === "pending").length;
if(pend) out.push(h("a", { class:"cw-chip gold", href:cwHref(s, "decide") }, pend + " to decide"));
const nt = callTasks(s).length;
if(nt) out.push(h("a", { class:"cw-chip", href:cwHref(s, "tasks") }, plural(nt, "task")));
return out;
}
function cwClaudeMenu(s, btn){
const rt = callAgentTask(s.id, "call-reader"), ct = callAgentTask(s.id, "call-coach");
const ready = callCoachReady(s.id, false) || hasReport(s);
const item = (label, sub, fn, off, why) => h("button", { class:"pop-i", disabled:off, title:off ? why : null, style:off ? "opacity:.45;cursor:default" : null,
onclick:() => { if(off) return; closePop(); fn(); } }, h("div", { class:"grow" }, label, sub ? h("small", null, sub) : null));
const sub = (t, first, again) => acBusy(t) ? "Running now · opens its chat" : t && t.status === "Completed" ? again : first;
openPop(btn, h("div", { style:"min-width:250px" },
h("div", { class:"pop-h" }, "Call Reader · " + (runState(rt) || "not run")),
item("Run Call Reader", sub(rt, "Reads the call and proposes", "Already read · opens the chat to run again"), () => acOpen(s, "reader", "start")),
h("div", { class:"pop-h" }, "Call Coach · " + (runState(ct) || "not run")),
item("Run Call Coach", sub(ct, "Scores how we communicated", "Already coached · opens the chat to run again"), () => acOpen(s, "coach", "start"), !ready && !acBusy(ct), "Runs after the Call Reader has finished"),
h("div", { class:"pop-h" }, "Ask"),
item("Ask about this call", "Quotes and times from the call", () => acOpen(s, "ask"))));
}
function cwMoreMenu(s, btn){
const t = cwThread(s), projects = rows("projects", x => !x.archived && x.id !== s.project_id).sort(by(x => x.name));
openPop(btn, h("div", { style:"min-width:230px" },
h("button", { class:"pop-i", onclick:() => { closePop(); makeTaskFrom(t); } }, icon("plus", "sm"), h("div", { class:"grow" }, "Make task")),
h("button", { class:"pop-i", onclick:() => { closePop(); logDecisionFrom(t); } }, icon("check", "sm"), h("div", { class:"grow" }, "Log decision")),
h("div", { class:"pop-h" }, "Move to project"),
h("div", { style:"padding:2px 8px 8px" }, sel([["", "Choose a project…"]].concat(projects.map(x => [x.id, x.name])), "", v => { if(!v) return; closePop();
confirmBox("Move this call?", "It moves to " + (byId("projects", v) || {}).name + " with its proposals left where they are.", "Move", () => { moveThread(t, v); go("projects/" + v + "/calls/" + s.id); }); }, "sm")),
h("div", { class:"pop-sep" }),
h("button", { class:"pop-i", style:"color:var(--red)", onclick:() => { closePop(); cwDeleteCall(s); } }, icon("trash", "sm"), h("div", { class:"grow" }, "Delete call"))));
}
/* Delete call (a wrong file, or the wrong project): the call, its transcript, its Call Reader / Coach / Ask runs and
its undecided points go. Points already accepted stay, as the requirements, tasks and decisions they became.
Soft delete, like the rest of Orbit: rows are marked deleted, so a mistake can be restored from the database. */
function cwDeleteCall(s){
const runs = rows("tasks", t => t.source_id === s.id && ["call-reader","call-coach","call-ask"].indexOf(t.agent) > -1);
if(runs.some(t => t.status === "In progress")){ toast("Claude is reading this call right now. Stop the run first, then delete."); return; }
const prs = rows("proposals", x => x.source_id === s.id), pend = prs.filter(x => x.status === "pending"), kept = prs.length - pend.length;
confirmBox("Delete this call?", "“" + cwTitle(s) + "” · " + cwDate(s.occurred_at) + ". Its transcript, " + plural(runs.length, "Claude run") + " and " +
plural(pend.length, "undecided point") + " go with it." + (kept ? " The " + plural(kept, "point") + " you already accepted or rejected stay as they are." : ""), "Delete call", () => {
const pid = s.project_id;
runs.forEach(t => remove("tasks", t.id)); pend.forEach(x => remove("proposals", x.id)); remove("sources", s.id);
logAct(pid, "", "deleted the call “" + cwTitle(s) + "”");
commit(); go("projects/" + pid + "/mail");
const done = () => toast("Deleted the call");
if(typeof sbOn === "function" && sbOn() && sbSignedIn()) linesDelete(s.id).then(done, () => toast("Deleted the call · its transcript lines couldn't be cleared; they stay hidden with it"));
else done();
}, true);
}
function cwHeader(s, full){
const people = cwPeople(s), cl = people.filter(x => x.side === "client"), fp = people.filter(x => x.side === "fieldproxy"), other = people.filter(x => x.side === "unknown");
const acc = byId("accounts", s.account_id) || {}, rec = cwRecUrl(s), pc = cwPasscode(s), t = cwThread(s);
const caret = icon("down", "xs");
if(!full) return h("div", { class:"cw-mini" },
h("div", { class:"grow", style:"min-width:0" }, h("div", { class:"cw-meta" }, h("span", { class:"cw-type" }, cwTypeLabel(s)), h("span", null, cwDate(s.occurred_at))),
h("h1", { class:"cw-h1 sm" }, cwTitle(s))),
h("div", { class:"cw-chips" }, cwChips(s)));
const group = (label, list) => list.length ? h("div", { class:"cw-grp" }, h("span", { class:"muted" }, label), h("span", { class:"cw-avs" }, list.map(x => cwAv(x)))) : null;
return h("section", { class:"cw-head" },
h("div", { class:"cw-head-l" },
h("div", { class:"cw-meta" }, h("span", { class:"cw-type" }, cwTypeLabel(s)),
h("span", null, [cwDate(s.occurred_at), s.duration_s ? cwDur(s.duration_s * 1000, true) : ""].filter(Boolean).join(" · ")),
rec ? h("a", { href:rec, target:"_blank", rel:"noopener", title:pc ? "Passcode: " + pc : "Open the recording" }, "Recording", icon("external", "xs")) : null),
h("h1", { class:"cw-h1" }, cwTitle(s)),
people.length ? h("div", { class:"cw-people" }, group(acc.name || "Client", cl), group("Fieldproxy", fp), group("Not set", other),
people.some(x => x.dm) ? h("span", { class:"cw-legend" }, h("i"), "ring = decision-maker")
: h("a", { class:"cw-legend muted", href:cwHref(s, "people") }, "Who decides? Set it on People")) : null),
h("div", { class:"cw-head-r" },
h("div", { class:"acts", style:"justify-content:flex-end" },
t.todo ? h("button", { class:"btn", onclick:() => markProcessed(t, true) }, "Mark processed") : h("button", { class:"btn", onclick:() => markProcessed(t, false), title:"Processed — click to mark it to process again" }, icon("check", "sm"), "Processed"),
h("button", { class:"btn", "aria-haspopup":"menu", onclick:e => cwClaudeMenu(s, e.currentTarget) }, "Claude", caret),
h("button", { class:"btn cw-sq", "aria-label":"More actions", "aria-haspopup":"menu", onclick:e => cwMoreMenu(s, e.currentTarget) }, icon("dots"))),
h("div", { class:"cw-chips" }, cwChips(s))));
}
function cwTabs(s, tab){
const pend = rows("proposals", x => x.source_id === s.id && x.status === "pending").length;
const cnt = { decide:pend, reports:s.folder ? callReportList(s).length : 0, people:cwPeople(s).length, tasks:callTasks(s).length };
return h("nav", { class:"cw-tabs", "aria-label":"Call sections" }, CW_TABS.map(t => h("a", { href:cwHref(s, t[0]), "aria-current":tab === t[0] ? "page" : null },
t[1], cnt[t[0]] ? h("span", { class:"cnt" + (t[0] === "decide" ? " gold" : "") }, cnt[t[0]]) : null)));
}
/* ---------- the page ---------- */
function callWorkspace(p, r){
const s = byId("sources", r.parts[3]);
const a = byId("accounts", p.account_id) || {};
if(!s || s.kind !== "call") { crumbs = [["Projects", "#/projects"], [p.name, "#/projects/" + p.id + "/overview"], ["Calls"]];
return h("div", { class:"page" }, empty("That call isn't on this project any more.")); }
const tab = CW_TABS.some(t => t[0] === r.parts[4]) ? r.parts[4] : "overview";
crumbs = [["Projects", "#/projects"], [p.name, "#/projects/" + p.id + "/overview"], ["Calls", "#/projects/" + p.id + "/mail?f=calls"], [cwTitle(s)]];
if(tab !== "decide") cwKeysOff();
callLines(s); // speakers, the strip and the jump targets all need the lines; loaded once per call
let body;
if(tab === "decide") body = cwDecide(s, r);
else if(tab === "transcript") body = cwTranscript(s);
else if(tab === "reports") body = callReport(s);
else if(tab === "people") body = callPeople(s);
else if(tab === "tasks"){ const ts = callTasks(s);
const onPlan = h("div", { class:"cw-onplan" }, h("span", { class:"muted" }, "These tasks sit in the project's phases."),
h("a", { class:"link", href:"javascript:void 0", onclick:() => { setUi({ planMode:"gantt", fCall:Object.assign({}, ui.fCall || {}, { [s.project_id]:s.id }) }); location.hash = "#/projects/" + s.project_id + "/plan"; } }, "See them on the plan →"));
body = ts.length ? [onPlan, h("div", { class:"card" }, h("ul", { class:"tl" }, ts.map(t => taskLine(t, statusPill(t.status)))))] : h("div", { class:"card pad" }, empty("No tasks from this call yet. Accepted promises, bugs and questions arrive here.")); }
else body = cwOverview(s);
return h("div", { class:"page cw" },
(n => { n.classList.add("cw-ptabs"); return n; })(projectHeader(p, "mail")[1]),
cwHeader(s, tab === "overview"), cwTabs(s, tab), body,
AC_KEY[r.q.chat] ? acPanel(s, r.q.chat) : null);
}
/* ---------- Overview ---------- */
function cwOverview(s){
const prs = rows("proposals", x => x.source_id === s.id), people = cwPeople(s);
const sum = String(s.summary || "").trim();
const tiles = CW_TYPES.slice(0, 5).map(t => { const all = prs.filter(x => x.type === t[0]), pend = all.filter(x => x.status === "pending").length;
return h("a", { class:"cw-tile", href:cwHref(s, "decide", { type:t[0], status:pend || !all.length ? "" : "accepted" }) },
h("span", { class:"v", style:t[0] === "bug" && all.length ? "color:var(--red)" : null }, all.length), h("span", { class:"k" }, t[1]),
pend ? h("span", { class:"p" }, pend + " to decide") : h("span", { class:"p faint" }, all.length ? "decided" : "none")); });
return h("div", { class:"cw-body" },
h("div", { class:"cw-main" },
h("section", { class:"cw-card cw-sum" }, h("div", { class:"cw-eyebrow" }, "Summary · by the Call Reader"),
sum ? h("p", null, sum) : h("p", { class:"muted" }, "No summary yet — the Call Reader writes one on its next run.")),
h("div", { class:"cw-tiles" }, tiles),
cwTeamWork(s, prs),
cwPromises(s, prs, people),
cwReportCards(s)),
h("aside", { class:"cw-rail" }, cwAskBox(s), cwClaudeCard(s), cwPeopleCard(s, people)));
}
/* Team-led projects: where this call's points stand with the team, and what is waiting for a test */
function cwTeamWork(s, prs){
const p = byId("projects", s.project_id), lead = teamLead(p); if(!lead) return null;
const ts = uniq(prs.filter(x => x.status === "accepted" && x.created_task_id).map(x => x.created_task_id)).map(id => byId("tasks", id)).filter(t => t && t.doer === "team");
const n = st => ts.filter(t => t.status === st).length, pend = prs.filter(x => x.status === "pending" && teamFlow(x)).length;
const test = ts.filter(t => t.status === "To Test");
const seg = (v, k, cls) => h("div", { class:"tw-seg " + cls }, h("b", null, v), h("span", null, k));
return h("section", { class:"cw-card flush" },
h("div", { class:"cw-card-h" }, h("h2", null, "With " + firstName(lead.id) + "'s team"), h("span", { class:"cw-count" }, ts.length ? plural(ts.length, "point") + " accepted" : "")),
h("div", { class:"tw-strip" }, seg(pend, "to decide", "dec"), seg(n("In progress"), "in progress", "prog"), seg(test.length, "to test", "test"), seg(n("Completed"), "passed", "done")),
test.length ? test.slice(0, 8).map(t => h("div", { class:"cw-prow" },
h("div", { class:"grow" }, h("a", { class:"link", href:"javascript:void 0", onclick:() => openTask(t.id) }, h("b", null, t.name)),
h("span", { class:"muted" }, t.source_ref ? "Said at " + cwTime(parseTs(String(t.source_ref).split(" · ")[0])) : "")),
testActions(t, true)))
: h("p", { class:"cw-none" }, ts.length ? "Nothing waiting for a test." : "Accept points on Decide (in progress) or move them to test when the team says they're done."));
}
function cwPromises(s, prs, people){
const pend = prs.filter(x => x.type === "promise" && x.status === "pending");
const acc = prs.filter(x => x.type === "promise" && x.status === "accepted" && x.created_task_id).map(x => ({ pr:x, t:byId("tasks", x.created_task_id) })).filter(x => x.t);
const items = pend.map(pr => ({ pr })).concat(acc).sort(by(x => (x.t ? "1" : "0") + String(Math.round(cwAt(x.pr) || 0) + 1000000)));
const n = items.length;
const row = x => { const pr = x.pr, who = cwPersonOf(people, pr.speaker), t = x.t, dl = t ? dueLabel(t.due, t.status === "Completed") : null;
return h("div", { class:"cw-prow" }, cwAv(who || { name:pr.speaker || "?", color:"var(--jade)" }, "sm"),
h("div", { class:"grow" }, h("b", null, pr.title), h("span", { class:"muted" }, (pr.quote ? "“" + pr.quote + "” — " : "") + cwSay(pr))),
t ? h("a", { class:"cw-due" + (dl.late ? " late" : ""), href:"javascript:void 0", onclick:() => openTask(t.id), title:"Open the task" }, t.status === "Completed" ? "Done" : "Due " + fmtD(t.due)) : null,
h("button", { class:"btn sm", onclick:() => cwDateModal(pr, t) }, t ? "Change date" : "Set date")); };
return h("section", { class:"cw-card flush" },
h("div", { class:"cw-card-h" }, h("h2", null, "Promises we made"), h("span", { class:"cw-count" }, n ? n + " to track" : "")),
n ? [items.slice(0, 5).map(row), h("a", { class:"cw-more", href:cwHref(s, "decide", { type:"promise", status:pend.length ? "" : "accepted" }) }, "See all " + plural(n, "promise") + " →")]
: h("p", { class:"cw-none" }, "No promises from this call yet."));
}
/* Set date: a pending promise is accepted as a task with that due date; an accepted one moves its task's due */
function cwDateModal(pr, t){
openModal(() => {
const d = h("input", { type:"date", class:"inp", value:(t && t.due) || todayISO() });
const ok = () => {
const v = d.value; if(!v){ toast("Pick a date"); return; }
try {
let task = t;
if(!task){ const r = acceptProposal(byId("proposals", pr.id)); task = byId("tasks", (r.proposal || {}).created_task_id); }
if(task) update("tasks", task.id, Object.assign({ due:v }, task.start && task.start > v ? { start:v } : {}));
closeModal(); commit(); toast(t ? "Due date moved to " + fmtD(v) : "Accepted as a task due " + fmtD(v));
} catch(e){ toast(e.message); }
};
return [modalHead(t ? "Change the date" : "Set a date for this promise", pr.title),
h("div", { class:"modal-b" }, !t ? h("p", { class:"sub", style:"margin-bottom:12px" }, "This accepts the promise as a task (to do) with this due date.") : null, fld2("Due", d)),
h("div", { class:"modal-f" }, h("button", { class:"btn", onclick:closeModal }, "Cancel"), h("button", { class:"btn primary", onclick:ok }, t ? "Save" : "Accept with this date"))];
}, "narrow");
}
function cwReportCards(s){
if(!s.folder) return null;
const list = callReportList(s), reader = list[0], vers = list.filter(r => r.key.indexOf("ver:") === 0), coach = list.filter(r => r.key.indexOf("coach:") === 0), cvers = list.filter(r => r.key.indexOf("cv:") === 0);
const rt = callAgentTask(s.id, "call-reader"), run = cwRun(rt), has = hasReport(s);
const btns = r => h("div", { class:"acts", style:"gap:6px;flex-wrap:nowrap" }, h("button", { class:"btn sm", onclick:() => openReportModal(r) }, "Open"),
h("button", { class:"btn sm cw-sq sm", title:"Open in a new tab", "aria-label":"Open in a new tab", onclick:() => openReportTab(r) }, icon("external", "xs")));
const card_ = (r, thumb, sub) => h("div", { class:"cw-rep" }, thumb,
h("div", { class:"cw-rep-f" }, h("div", { class:"grow", style:"min-width:0" }, h("b", null, r.title), h("span", { class:"muted" }, sub)), btns(r)));
const lines = h("div", { class:"cw-thumb" }, h("span", { class:"t" }, cwTitle(s)), h("i", { style:"width:80%" }), h("i", { style:"width:64%" }), h("i", { style:"width:72%" }));
const bars = () => h("div", { class:"cw-thumb bars" }, [40, 55, 70, 50, 35, 60].map((v, i) => h("i", { style:"height:" + v + "%" + (i === 2 ? ";background:var(--jade)" : "") })));
const readerSub = has || (rt && rt.status === "Completed") ? "Latest" + (run && run.at ? " · " + fmtStamp(run.at) : "") + (vers.length ? " · " + plural(vers.length + 1, "version") : "")
: "Not written yet" + (rt ? " · Call Reader " + runState(rt).toLowerCase() : "");
const earlier = vers.concat(cvers);
return h("section", { class:"cw-sec" }, h("div", { class:"cw-sec-h" }, h("h2", null, "Reports"), h("a", { class:"link", href:cwHref(s, "reports") }, "All reports →")),
h("div", { class:"cw-reps" }, card_(reader, lines, readerSub), coach.map(r => card_(r, bars(), r.sub.replace(/^Draft coaching page/, "Coaching page")))),
earlier.length ? h("div", { class:"cw-vers" }, h("span", { class:"muted" }, "Earlier versions:"), earlier.map(r => h("button", { class:"link", onclick:() => openReportModal(r) }, r.title.replace(/^Call (Reader report|Coach) · /, (m, x) => x === "Coach" ? "Coach · " : "Reader · ")))) : null);
}
/* the Ask box (Overview rail): the same call-ask task as the Transcript chat */
/* the Ask box sends into the Ask chat (the panel), which then opens on it */
function cwAskSend(s){
const q = String(callSt.ask[s.id] || "").trim();
if(!q){ acOpen(s, "ask"); return; }
if(acSendText(s, "call-ask", q)){ callSt.ask[s.id] = "";
if(document.activeElement && document.activeElement.blur) document.activeElement.blur();
acOpen(s, "ask"); }
}
function cwAskSugg(s){
const people = cwPeople(s), dm = people.find(x => x.dm), cl = dm || people.find(x => x.side === "client");
return ["Draft the follow-up email", "What blocks go-live?", cl ? "What did " + String(cl.name).split(/\s+/)[0] + " decide?" : "What was decided?"];
}
function cwAskInput(s, id, ph, minH){
return h("textarea", { class:"ta cw-ask-ta", id, style:"min-height:" + minH + "px", placeholder:ph, value:callSt.ask[s.id] || "", "aria-label":"Ask a question about this call",
oninput:e => { callSt.ask[s.id] = e.target.value; }, onkeydown:e => { if(e.key === "Enter" && (e.metaKey || e.ctrlKey)){ e.preventDefault(); cwAskSend(s); } } });
}
function cwAskBox(s){
const sugg = cwAskSugg(s);
const id = "cw-ask-" + s.id, last = callAskTasks(s.id)[0];
return h("section", { class:"cw-card" },
h("div", { class:"cw-card-t" }, h("h2", null, "Ask about this call"), h("span", { class:"faint" }, "~1 min · cents")),
cwAskInput(s, id, "Ask anything — what the client said about a screen, everything we promised…", 76),
h("div", { class:"cw-sugg" }, sugg.map(q => h("button", { onclick:() => { callSt.ask[s.id] = q; const t = document.getElementById(id); if(t){ t.value = q; t.focus(); t.setSelectionRange(q.length, q.length); } } }, q))),
h("button", { class:"btn primary cw-wide", onclick:() => cwAskSend(s) }, "Ask Claude"),
last ? h("a", { class:"cw-lastask", href:"javascript:void 0", onclick:() => acOpen(s, "ask") }, h("span", { class:"pill runst " + (RUN_KIND[runState(last)] || "todo") }, last.answer ? "Answered" : runState(last)),
h("span", { class:"grow" }, last.question || String(last.name || "").replace(/^Ask: /, "")), "→") : null);
}
function cwClaudeCard(s){
const ready = callCoachReady(s.id, false) || hasReport(s);
const row = (agent, label) => {
const t = callAgentTask(s.id, agent), st = runState(t), running = !!(t && t.status === "In progress");
const dot = { Done:"var(--jade)", Running:"var(--blue)", Queued:"var(--gold)", Ready:"var(--gold)", Blocked:"var(--red)", Waiting:"var(--gold)" }[st] || "var(--line)";
const ok = agent === "call-reader" || ready;
return [h("div", { class:"cw-agent" }, h("span", { class:"cw-dot", style:{ background:dot } }),
h("div", { class:"grow" }, h("b", null, label), h("span", { class:"muted" }, cwAgentLine(t))),
acBusy(t) ? h("button", { class:"btn sm", onclick:() => acOpen(s, agent === "call-reader" ? "reader" : "coach", "chat") }, "Open")
: h("button", { class:"btn sm", disabled:!ok, title:!ok ? "Runs after the Call Reader has finished" : null,
onclick:() => acOpen(s, agent === "call-reader" ? "reader" : "coach", "start") }, t && t.status === "Completed" ? "Run again" : "Run")),
running ? cwLiveLine(t) : null];
};
return h("section", { class:"cw-card" }, h("h2", null, "Claude on this call"), row("call-reader", "Call Reader"), row("call-coach", "Call Coach"),
h("div", { class:"cw-foot" }, "Each run opens its chat: the steps, live progress and anything you send it."));
}
/* one quiet line under a running agent: elapsed (ticks client-side) and the current step */
function cwLiveLine(t){
const g0 = runProgress(t), g = g0 && !g0.done ? g0 : {}, since = g.started_at || (t.extra || {}).run_requested_at || t.run_requested_at || "";
return h("div", { class:"cw-liveline" }, h("span", { class:"spin" }), since ? h("span", { class:"mono", "data-since":since }, fmtElapsed(Date.now() - Date.parse(since))) : null,
h("span", { class:"grow" }, g.current || "Starting"));
}
function cwPeopleCard(s, people){
const acc = byId("accounts", s.account_id) || {};
return h("section", { class:"cw-card" }, h("div", { class:"cw-card-t" }, h("h2", null, "People"), h("a", { class:"link", href:cwHref(s, "people") }, "Edit")),
people.length ? people.map(x => h("div", { class:"cw-person" }, h("span", { class:"grow" }, h("b", null, x.name), " · " + (x.side === "fieldproxy" ? "Fieldproxy" : x.side === "client" ? (acc.name || "Client") : "side not set")),
x.dm ? h("span", { class:"cw-dm" }, "Decides") : h("span", { class:"faint", style:"font-size:12px" }, x.role || "Role?")))
: h("p", { class:"muted", style:"font-size:13px" }, "No speakers yet."));
}
/* ---------- Decide ---------- */
function cwDecideList(s, type, status){
return rows("proposals", x => x.source_id === s.id && (!type || x.type === type) && (status === "all" || x.status === status))
.sort(by(x => String(10 + (CW_TYPE[x.type] || { order:9 }).order) + String(Math.round(cwAt(x) || 0) + 1000000) + (x.created_at || "")));
}
function cwDecide(s, r){
const type = CW_TYPE[r.q.type] ? r.q.type : "", status = ["pending","accepted","rejected"].indexOf(r.q.status) > -1 ? r.q.status : "pending";
const all = rows("proposals", x => x.source_id === s.id), people = cwPeople(s);
if(!all.length) return h("div", { class:"card pad" }, empty("No proposals yet. The Call Reader loads them when it has read the call."));
const inStatus = all.filter(x => x.status === status), list = cwDecideList(s, type, status);
const filt = h("aside", { class:"cw-filters" },
h("div", { class:"cw-card cw-flist" },
h("a", { href:cwHref(s, "decide", { status:status === "pending" ? "" : status }), "aria-current":!type ? "true" : null }, h("span", null, "All"), h("span", null, inStatus.length)),
CW_TYPES.filter(t => all.some(x => x.type === t[0])).map(t => h("a", { href:cwHref(s, "decide", { type:t[0], status:status === "pending" ? "" : status }), "aria-current":type === t[0] ? "true" : null },
h("span", { class:"cw-fl" }, h("i", { style:{ background:t[2] } }), t[1]), h("span", { class:"muted" }, inStatus.filter(x => x.type === t[0]).length)))),
h("div", { class:"cw-card cw-flist st" }, h("div", { class:"cw-eyebrow" }, "Status"),
[["pending","Pending"],["accepted","Accepted"],["rejected","Rejected"]].map(x => h("a", { href:cwHref(s, "decide", { type, status:x[0] === "pending" ? "" : x[0] }), "aria-current":status === x[0] ? "true" : null },
h("span", null, x[1]), h("span", null, all.filter(y => y.status === x[0] && (!type || y.type === type)).length)))),
status === "pending" ? h("div", { class:"cw-keys" }, "Keys: ", h("b", null, "J"), "/", h("b", null, "K"), " move · ", h("b", null, "A"), " accept · ", h("b", null, "R"), " reject · ", h("b", null, "E"), " edit", teamLead(byId("projects", s.project_id)) ? [" · ", h("b", null, "T"), " move to test"] : null) : null);
if(status !== "pending"){
cwKeysOff();
return h("div", { class:"cw-body dec" }, filt, h("div", { class:"cw-main" }, list.length ? list.map(proposalCard) : h("div", { class:"card pad" }, empty("Nothing " + status + (type ? " in " + CW_TYPE[type].label.toLowerCase() : "") + " yet."))));
}
if(!list.length){
cwKeysOff();
return h("div", { class:"cw-body dec" }, filt, h("div", { class:"cw-main" }, h("div", { class:"cw-card cw-done" }, h("h2", null, "All decided"),
h("p", { class:"muted" }, (type ? CW_TYPE[type].label : "Proposals") + " from this call are all accepted or rejected."), h("a", { class:"link", href:cwHref(s, "decide", { type, status:"accepted" }) }, "See what was accepted →"))));
}
const fid = list.some(x => x.id === cwSt.focus[s.id]) ? cwSt.focus[s.id] : list[0].id;
cwSt.focus[s.id] = fid;
const f = list.find(x => x.id === fid), rest = list.filter(x => x.id !== fid);
const label = type ? CW_TYPE[type].label : "All types";
const acceptAll = () => confirmBox("Accept all " + list.length + (type ? " " + CW_TYPE[type].label.toLowerCase() : "") + "?",
"Each becomes its record: requirements, decisions, tasks or product tickets. Open tasks and requirements with the same title are linked, not duplicated.", "Accept all", () => {
let n = 0; list.forEach(x => { try { acceptProposal(byId("proposals", x.id)); n++; } catch(e){} }); commit(); toast("Accepted " + n); });
const teamList = list.filter(teamFlow);
const testAll = () => confirmBox("Move " + teamList.length + (type ? " " + CW_TYPE[type].label.toLowerCase() : "") + " to test?",
"Use this when the team says they're all done. Each becomes " + firstName(teamLead(byId("projects", s.project_id)).id) + "'s task in To Test; requirements are marked built.", "Move to test", () => {
let n = 0; teamList.forEach(x => { try { acceptProposal(byId("proposals", x.id), null, "test"); n++; } catch(e){} }); commit(); toast("Moved " + n + " to test"); });
cwKeysOn(s);
const more = rest.length > 40 ? rest.length - 40 : 0;
return h("div", { class:"cw-body dec" }, filt,
h("div", { class:"cw-main", style:"gap:12px" },
h("div", { class:"cw-bulk" }, h("span", { class:"grow" }, label + " · " + list.length + " pending"),
type && list.length ? h("button", { class:"btn sm jade", onclick:acceptAll }, "Accept all " + list.length + " " + CW_TYPE[type].label.toLowerCase())
: h("span", { class:"faint", style:"font-size:12.5px;color:#C9C2D2" }, "Pick a type to accept in bulk"),
type && teamList.length ? h("button", { class:"btn sm cw-ghostdark", onclick:testAll }, icon("eye", "sm"), "Move all " + teamList.length + " to test") : null,
h("button", { class:"btn sm cw-ghostdark", onclick:() => { const c = document.querySelector(".cw-focus"); if(c){ c.scrollIntoView({ block:"center", behavior:"smooth" }); c.focus(); } } }, "Review one by one")),
cwFocusCard(s, f, list, people),
rest.length ? h("div", { class:"cw-card flush cw-queue" }, rest.slice(0, 40).map(x => cwQueueRow(s, x, list))) : null,
more ? h("div", { class:"muted", style:"font-size:13px;padding:0 4px" }, more + " more · decide the ones above first") : null));
}
/* after deciding one, focus the next in the list (or the one before, at the end) */
function cwNextFocus(s, list, id){ const i = list.findIndex(x => x.id === id), n = list[i + 1] || list[i - 1]; cwSt.focus[s.id] = n ? n.id : ""; }
function cwAccept(s, pr, list, edits, mode){
try { cwNextFocus(s, list, pr.id); const team = teamFlow(pr), r = acceptProposal(byId("proposals", pr.id), edits, mode); commit();
const lead = team ? firstName(teamLead(byId("projects", pr.project_id)).id) : "";
toast(team ? (mode === "test" ? "Moved to test — " + lead + "'s task is ready to test" : "Accepted — in progress with " + lead) + (r.linked ? " (linked to the existing task)" : "")
: r.linked ? "Linked to the existing record" : "Accepted → " + proposalTarget(pr)); } catch(e){ toast(e.message); }
}
/* what a team-led point becomes, for the card's subtitle */
function cwBecomes(pr){ if(!teamFlow(pr)) return CW_BECOMES[proposalKind(pr)];
return "Becomes " + firstName(teamLead(byId("projects", pr.project_id)).id) + "'s task" + (pr.type === "requirement" ? " (and a requirement)" : "") + " · Accept = in progress · Move to test = done, ready to test"; }
function cwReject(s, pr, list){ cwNextFocus(s, list, pr.id); rejectProposal(byId("proposals", pr.id)); commit(); toast("Rejected"); }
function cwEditModal(s, pr, list){
openModal(() => {
const t = h("input", { class:"inp", value:pr.title || "" }), d = h("textarea", { class:"ta", value:pr.detail || "", placeholder:"Optional detail" });
const go = mode => { const v = t.value.trim(); if(!v){ toast("A title is needed"); return; } closeModal(); cwAccept(s, pr, list, { title:v, detail:d.value.trim() }, mode); };
return [modalHead("Edit, then accept", cwBecomes(pr)), h("div", { class:"modal-b" }, fld2("Title", t), fld2("Detail", d)),
h("div", { class:"modal-f" }, h("button", { class:"btn", onclick:closeModal }, "Cancel"),
teamFlow(pr) ? h("button", { class:"btn cw-totest", onclick:() => go("test") }, icon("eye", "sm"), "Move to test") : null,
h("button", { class:"btn jade", onclick:() => { const v = t.value.trim(); if(!v){ toast("A title is needed"); return; } closeModal();
cwAccept(s, pr, list, { title:v, detail:d.value.trim() }); } }, icon("check", "sm"), "Accept"))];
});
}
/* ▶ on a quote: jump to that moment in the Transcript tab (and open the recording, when the call has one) */
function cwJump(s, sec){
if(sec == null) return;
cwSt.jump[s.id] = sec; callSt.q[s.id] = ""; cwSt.spk[s.id] = "all";
const rec = cwRecUrl(s); if(rec) window.open(rec, "_blank", "noopener");
cwGo(s, "transcript");
}
function cwFocusCard(s, pr, list, people){
const T = CW_TYPE[pr.type] || { one:pr.type, color:"var(--faint)" }, who = cwPersonOf(people, pr.speaker), at = cwAt(pr), m = pr.product_match;
const role = who && who.dm ? "decision-maker" : String(pr.speaker_role || (who && who.role) || "");
return h("div", { class:"cw-focus", tabindex:"-1", "data-pid":pr.id },
h("div", { class:"cw-focus-t" }, h("span", { class:"cw-tpill", style:{ color:T.color, background:"color-mix(in srgb, " + T.color + " 12%, white)" } }, T.one),
h("span", { class:"muted", style:"font-size:12px" }, cwBecomes(pr)),
pr.record_status && pr.type === "requirement" && pr.record_status !== "proposed" ? h("span", { class:"tagp ok" }, pr.record_status) : null),
h("h3", null, pr.title),
pr.detail ? h("p", { class:"muted", style:"font-size:13.5px;margin:0" }, pr.detail) : null,
pr.quote || at != null ? h("div", { class:"cw-quote" },
h("button", { class:"cw-play", "aria-label":"Play this moment", title:at != null ? "Jump to " + cwTime(at) + " in the transcript" + (cwRecUrl(s) ? " and open the recording" : "") : "No time on this quote",
disabled:at == null, onclick:() => cwJump(s, at) }, icon("play", "xs")),
h("div", { style:"min-width:0;display:flex;flex-direction:column;gap:4px" }, pr.quote ? h("span", { class:"q" }, "“" + pr.quote + "”") : null,
h("span", { class:"muted", style:"font-size:12.5px" }, h("b", { style:"color:var(--text)" }, pr.speaker || "unknown"), role ? " · " + (role.length > 70 ? role.slice(0, 68) + "…" : role) : "", at != null ? " · " + cwTime(at) : ""))) : null,
m ? h("div", { class:"prop-m" }, m.need ? h("div", null, h("span", { class:"k" }, "Need"), m.need) : null, m.answer ? h("div", null, h("span", { class:"k" }, "Answer"), m.answer) : null,
m.verdict ? h("div", null, h("span", { class:"k" }, "Verdict"), m.verdict) : null) : null,
pr.screen ? h("div", { class:"faint", style:"font-size:12px" }, "On screen: " + pr.screen) : null,
h("div", { class:"cw-focus-a" },
h("button", { class:"btn jade", title:teamFlow(pr) ? "The team is working on it: In progress" : null, onclick:() => cwAccept(s, pr, list) }, "Accept"),
teamFlow(pr) ? h("button", { class:"btn cw-totest", title:"The team says it's done: To Test", onclick:() => cwAccept(s, pr, list, null, "test") }, icon("eye", "sm"), "Move to test") : null,
h("button", { class:"btn", onclick:() => cwEditModal(s, pr, list) }, "Edit, then accept"),
h("button", { class:"btn", style:"color:var(--red)", onclick:() => cwReject(s, pr, list) }, "Reject"),
h("span", { class:"grow" }),
h("button", { class:"btn ghost cw-skip", onclick:() => { const i = list.findIndex(x => x.id === pr.id); cwSt.focus[s.id] = (list[i + 1] || list[0]).id; render(); } }, "Skip")));
}
function cwQueueRow(s, pr, list){
const T = CW_TYPE[pr.type] || { color:"var(--faint)" };
return h("div", { class:"cw-qrow", onclick:e => { if(e.target.closest("button")) return; cwSt.focus[s.id] = pr.id; render(); } },
h("i", { class:"cw-sq8", style:{ background:T.color } }),
h("div", { class:"grow" }, h("b", null, pr.title), h("span", { class:"muted" }, (pr.quote ? "“" + pr.quote + "” — " : "") + cwSay(pr))),
h("div", { class:"acts", style:"gap:6px;flex-wrap:nowrap" },
h("button", { class:"cw-ib ok", "aria-label":"Accept", title:teamFlow(pr) ? "Accept → in progress" : "Accept → " + proposalTarget(pr), onclick:() => cwAccept(s, pr, list) }, icon("check", "sm")),
teamFlow(pr) ? h("button", { class:"cw-ib test", "aria-label":"Move to test", title:"Move to test → ready to test", onclick:() => cwAccept(s, pr, list, null, "test") }, icon("eye", "sm")) : null,
h("button", { class:"cw-ib no", "aria-label":"Reject", title:"Reject", onclick:() => cwReject(s, pr, list) }, icon("x", "sm"))));
}
/* J/K/A/R/E while Decide is on screen; the handler removes itself once the route leaves Decide */
let cwKeyFn = null;
function cwKeysOff(){ if(cwKeyFn){ document.removeEventListener("keydown", cwKeyFn); cwKeyFn = null; } }
function cwKeysOn(s){
if(cwKeyFn && cwKeyFn.sid === s.id) return;
cwKeysOff();
cwKeyFn = e => {
const r = parseRoute();
if(r.parts[2] !== "calls" || r.parts[3] !== s.id || r.parts[4] !== "decide"){ cwKeysOff(); return; }
if(e.metaKey || e.ctrlKey || e.altKey || typing() || $("ln-modal").classList.contains("open") || !$("ln-pop").hidden || r.q.t || r.q.chat) return;
const a = document.activeElement; if(a && /^(SELECT|INPUT|TEXTAREA)$/.test(a.tagName)) return;
const k = e.key.toLowerCase(); if("jkaret".indexOf(k) === -1 || k.length !== 1) return;
const type = CW_TYPE[r.q.type] ? r.q.type : "", status = r.q.status || "pending"; if(status !== "pending") return;
const list = cwDecideList(s, type, "pending"); if(!list.length) return;
const i = Math.max(0, list.findIndex(x => x.id === cwSt.focus[s.id])), pr = list[i];
e.preventDefault();
if(k === "j" || k === "k"){ const n = list[Math.min(list.length - 1, Math.max(0, i + (k === "j" ? 1 : -1)))]; cwSt.focus[s.id] = n.id; render();
const c = document.querySelector(".cw-focus"); if(c) c.scrollIntoView({ block:"nearest" }); }
else if(k === "a") cwAccept(s, pr, list);
else if(k === "t"){ if(teamFlow(pr)) cwAccept(s, pr, list, null, "test"); else toast("Move to test is for team-led projects"); }
else if(k === "r") cwReject(s, pr, list);
else if(k === "e") cwEditModal(s, pr, list);
};
cwKeyFn.sid = s.id;
document.addEventListener("keydown", cwKeyFn);
}
/* ---------- Transcript ---------- */
function cwTranscript(s){
const c = callLines(s);
const rail = cwAskBox(s);
let main;
if(c.err) main = [h("p", { class:"sub" }, "Couldn't load the transcript lines: " + c.err), transcriptCard(s)];
else if(c.loading) main = h("div", { class:"cw-card" }, h("div", { class:"mskel" }, h("i"), h("i"), h("i", { style:"width:60%" })));
else if(!c.list.length) main = transcriptCard(s);
else main = cwTurns(s, c.list);
return h("div", { class:"cw-body" }, h("div", { class:"cw-main", style:"gap:12px" }, main), h("aside", { class:"cw-rail" }, rail));
}
function cwTurns(s, lines){
const people = cwPeople(s), len = Math.max(+s.duration_s || 0, ...lines.map(l => +l.t_end || +l.t_start || 0), 1);
/* a quote's ▶ in Decide asked for a moment: mark the turn it falls in */
if(cwSt.jump[s.id] != null){ const sec = cwSt.jump[s.id]; delete cwSt.jump[s.id];
let best = lines[0]; lines.forEach(l => { if(+l.t_start <= sec) best = l; });
callSt.at = callSt.at || {}; callSt.at[s.id] = best.seq; callSt.scrolled = ""; }
const stats = speakerStats(lines);
const sideOf = {}; stats.forEach(x => { const p = cwPersonOf(people, x.speaker); sideOf[x.speaker] = p ? p.side : x.side; });
const colorOf = sp => { const p = cwPersonOf(people, sp); return p ? p.color : "var(--faint)"; };
const nameOf = sp => { const p = cwPersonOf(people, sp); return p ? p.name : sp || "Unknown"; };
const jumpTo = seq => { callSt.at = callSt.at || {}; callSt.at[s.id] = seq; callSt.scrolled = ""; callSt.q[s.id] = ""; cwSt.spk[s.id] = "all"; render(); };
/* who spoke when */
const strip = h("section", { class:"cw-card cw-strip" },
h("div", { class:"cw-strip-h" }, h("span", null, "Who spoke when · click to jump"), h("span", { class:"mono" }, "0:00 ─ " + cwTime(len))),
stats.slice().sort((a, b) => b.talk_s - a.talk_s).map(x => {
const mine = lines.filter(l => l.speaker === x.speaker);
const track = h("div", { class:"cw-track", title:"Click to jump to " + nameOf(x.speaker) + "'s nearest turn", onclick:e => {
const rc = e.currentTarget.getBoundingClientRect(), t = (e.clientX - rc.left) / rc.width * len;
let best = mine[0]; mine.forEach(l => { if(Math.abs(+l.t_start - t) < Math.abs(+best.t_start - t)) best = l; }); jumpTo(best.seq); } },
mine.map(l => h("i", { style:{ left:(+l.t_start / len * 100) + "%", width:Math.max(0.25, (Math.max(+l.t_end || 0, +l.t_start) - l.t_start) / len * 100) + "%", background:colorOf(x.speaker) } })));
return h("div", { class:"cw-srow" }, h("span", { class:"cw-sname", title:nameOf(x.speaker) + " · " + fmtTalk(x.talk_s) }, nameOf(x.speaker)), track);
}));
/* search + speaker chips */
const q = callSt.q[s.id] || "", spk = cwSt.spk[s.id] || "all", ql = q.toLowerCase();
const bySpk = l => spk === "all" || (spk === "client" && sideOf[l.speaker] === "client") || (spk === "fieldproxy" && sideOf[l.speaker] === "fieldproxy") || spk === "p:" + l.speaker;
const list = lines.filter(bySpk).filter(l => !q || String(l.text || "").toLowerCase().indexOf(ql) > -1 || String(l.speaker || "").toLowerCase().indexOf(ql) > -1);
const chip = (k, label) => h("button", { class:"cw-fchip", "aria-pressed":String(spk === k), onclick:() => { cwSt.spk[s.id] = k; render(); } }, label);
const id = "cw-q-" + s.id;
const bar_ = h("div", { class:"cw-search" },
h("div", { class:"cw-sbox" }, icon("search", "sm"), h("input", { id, type:"text", placeholder:"Search this call", "aria-label":"Search this call", value:q,
oninput:e => { callSt.q[s.id] = e.target.value; const pos = e.target.selectionStart; render();
setTimeout(() => { const i = document.getElementById(id); if(i){ i.focus(); i.setSelectionRange(pos, pos); } }, 0); } })),
h("span", { class:"muted", style:"font-size:13px;white-space:nowrap" }, q ? plural(list.length, "match", "matches") : plural(list.length, "turn")),
h("div", { class:"cw-fchips" }, chip("all", "All speakers"), stats.some(x => sideOf[x.speaker] === "client") ? chip("client", "Client only") : null,
stats.some(x => sideOf[x.speaker] === "fieldproxy") ? chip("fieldproxy", "Fieldproxy only") : null,
stats.map(x => chip("p:" + x.speaker, String(nameOf(x.speaker)).split(/\s+/)[0]))));
/* proposals that fall inside a turn */
const linked = {};
const ordered = lines.slice().sort((a, b) => a.t_start - b.t_start);
rows("proposals", x => x.source_id === s.id && cwAt(x) != null).forEach(pr => {
const at = cwAt(pr); let hit = null;
for(let i = 0; i < ordered.length; i++){ const l = ordered[i], next = ordered[i + 1];
if(at >= +l.t_start && (at <= +l.t_end || (next && at < +next.t_start))){ hit = l; break; } }
if(hit) (linked[hit.seq] = linked[hit.seq] || []).push(pr);
});
const at = callSt.at && callSt.at[s.id];
if(at && callSt.scrolled !== s.id + ":" + at) setTimeout(() => { const e = document.querySelector('.cw-turn[data-seq="' + at + '"]'); if(e){ e.scrollIntoView({ block:"center" }); callSt.scrolled = s.id + ":" + at; } }, 40);
const rec = cwRecUrl(s);
const turns = h("section", { class:"cw-card flush cw-turns" }, list.length ? list.slice(0, 2000).map(l => {
const side = sideOf[l.speaker] || l.side || "unknown", prs = linked[l.seq] || [];
return h("div", { class:"cw-turn" + (String(l.seq) === String(at) ? " on" : ""), "data-seq":l.seq },
h("button", { class:"cw-ts", title:rec ? "Mark this turn and open the recording (go to " + cwTime(l.t_start) + ")" : "Mark this turn",
onclick:() => { jumpTo(l.seq); if(rec) window.open(rec, "_blank", "noopener"); } }, cwTime(l.t_start) + " ▶"),
h("div", { class:"cw-said", style:{ borderLeftColor:side === "unknown" ? "var(--line)" : colorOf(l.speaker) } },
h("b", null, nameOf(l.speaker)), h("span", null, hl(String(l.text || ""), q)),
prs.length ? h("div", { class:"cw-links" }, prs.slice(0, 3).map(pr => { const T = CW_TYPE[pr.type] || { one:pr.type, color:"var(--faint)" };
return h("a", { href:cwHref(s, "decide", { type:pr.type, status:pr.status === "pending" ? "" : pr.status }), style:{ color:T.color }, title:pr.title,
onclick:() => { cwSt.focus[s.id] = pr.id; } }, "Linked to a proposal · " + T.one + (pr.status !== "pending" ? " (" + pr.status + ")" : "")); })) : null));
}) : h("p", { class:"cw-none" }, q ? "No turn mentions “" + q + "”." : "No turns for this speaker."));
return [strip, bar_, turns];
}
/* ---------- the agent chat (SPEC §1c · AgentStart and AgentChat mockups) ----------
A right-hand panel over the call page, opened by every agent start (Claude ▾, "Run again", the Ask
box). One conversation per task in `messages` (thread "task:", kind user|agent|step|system,
extra.status queued|consumed|cancelled). The first Send is today's guarded run; a later Send is a
follow-up the Dispatcher resumes the session with. ?chat=reader|coach|ask keeps it open across renders. */
const AC_KEY = { reader:"call-reader", coach:"call-coach", ask:"call-ask" };
const AC_INFO = {
"call-reader":{ name:"Call Reader", model:"Opus", def:"Read this call.", est:4, start:"Start the Call Reader", word:"read",
intro:"It reads the whole call and proposes requirements, promises, decisions and questions." },
"call-coach":{ name:"Call Coach", model:"Opus", def:"Coach this call.", est:2, start:"Start the Call Coach", word:"coaching",
intro:"It scores how each Fieldproxy person communicated on this call, with counts, better lines and what to practise." },
"call-ask":{ name:"Ask", model:"Sonnet", def:"", est:0.1, start:"Ask about this call", word:"question",
intro:"Ask anything about this call: what the client said about a screen, everything we promised, a follow-up email draft. Answers quote the call with times." } };
function acOpen(s, key, mode){
const agent = AC_KEY[key]; if(!agent) return;
if(mode) cwSt.mode[s.id + ":" + agent] = mode;
cwSt.stick[s.id + ":" + agent] = true;
if(parseRoute().q.chat === key) render(); else setQ({ chat:key });
}
function acClose(){ setQ({ chat:null }); }
/* a run is queued or running: Send then queues a follow-up instead of starting one */
function acBusy(t){ return !!t && (t.status === "In progress" || runState(t) === "Queued"); }
/* Ask spans every call-ask task on the call (oldest first); Reader and Coach have one task each */
function acTasks(s, agent){ return agent === "call-ask" ? callAskTasks(s.id).slice().reverse() : [callAgentTask(s.id, agent)].filter(Boolean); }
function acTask(s, agent){ const l = acTasks(s, agent); return l[l.length - 1] || null; }
function acStarting(s, agent){
const t = acTask(s, agent), m = cwSt.mode[s.id + ":" + agent];
if(!t) return true;
if(acBusy(t)) return false;
return agent === "call-ask" ? m === "start" : m !== "chat";
}
/* the conversation: thread rows, plus (for Ask) the question and answer of tasks from before the chat existed */
function acMsgs(s, agent){
const out = [];
acTasks(s, agent).forEach(t => {
const ms = rows("messages", m => m.thread === "task:" + t.id);
if(agent === "call-ask"){
const q = t.question || String(t.name || "").replace(/^Ask: /, "");
if(q && !ms.some(m => m.kind === "user")) out.push({ id:"q:" + t.id, thread:"task:" + t.id, kind:"user", body:q, at:t.created_at, status:"consumed", author_id:me().id, synth:true });
if(t.answer && !ms.some(m => m.kind === "agent")) out.push({ id:"a:" + t.id, thread:"task:" + t.id, kind:"agent", body:t.answer, at:t.answer_at || t.updated_at, synth:true });
}
ms.forEach(m => out.push(m));
});
return out.sort(by(m => m.at || m.created_at || ""));
}
function acRuns(s, agent){
const ids = {}; acTasks(s, agent).forEach(t => ids[t.id] = 1);
return rows("agent_runs", x => ids[x.task_id] && (x.started_at || (x.progress || {}).started_at)).sort(by(x => x.started_at || x.progress.started_at));
}
function acClock(ts){ const d = new Date(ts); if(isNaN(d)) return "";
const hm = pad(d.getHours()) + ":" + pad(d.getMinutes()); return iso(d) === todayISO() ? hm : d.getDate() + " " + MON[d.getMonth()] + " " + hm; }
function acDay(ts){ const d = new Date(ts); return isNaN(d) ? "" : d.getDate() + " " + MON[d.getMonth()]; }
function acMoney(c){ c = +c; return c < 1 ? c.toFixed(2) : c < 10 ? c.toFixed(1).replace(/\.0$/, "") : String(Math.round(c)); }
/* ---- sending ---- */
function acUserMsg(t, body, status){
return insert("messages", { project_id:t.project_id, thread:"task:" + t.id, author_id:me().id, body, private:true, at:nowISO(), mentions:[], kind:"user", status });
}
/* a follow-up: queued for the Dispatcher, which resumes the session once no run is live */
function acFollow(t, text){
acUserMsg(t, text, "queued");
if(t.status !== "In progress" && t.status !== "To do") setTaskStatus(t, "To do");
update("tasks", t.id, { followup_pending:true, run_requested_at:nowISO() });
}
/* returns true when something was sent */
function acSendText(s, agent, text){
const info = AC_INFO[agent], t = acTask(s, agent), starting = acStarting(s, agent);
text = String(text || "").trim();
try {
if(agent === "call-ask"){
if(!text){ toast("Type a question first"); return false; }
if(t && !starting && (acBusy(t) || t.session_id)){ acFollow(t, text); commit(); toast(acBusy(t) ? "Queued · Claude reads it when the current answer is done" : "Asked · it continues the same conversation"); }
else { const nt = askCall(s.id, text); acUserMsg(nt, text, "consumed"); commit(); toast("Asked" + (crewOff() ? " · the crew is off, paste the line shown into Claude Code" : " · the Dispatcher picks it up")); }
cwSt.mode[s.id + ":" + agent] = "chat";
return true;
}
/* Call Reader / Call Coach: Send in the ready state (or on an idle task with no session to resume) is the guarded run */
if(starting || !(acBusy(t) || (t && t.session_id))){
const body = text || info.def, focus = !text || text === info.def ? "" : text;
const again = agent === "call-reader" ? readerDone(s) : !!(t && t.status === "Completed");
const r = runCallAgent(s.id, agent, { hasReport:hasReport(s), focus, confirmed:again });
acUserMsg(r.task, body, "consumed");
commit();
toast(info.name + (r.created ? " queued" : again ? " queued to run again" : " re-queued") + (crewOff() ? " · the crew is off, paste the line shown into Claude Code" : " · the Dispatcher picks it up"));
} else {
if(!text){ toast("Type a message first"); return false; }
acFollow(t, text); commit();
toast(t.status === "In progress" ? "Queued · Claude reads it when the current run finishes" : "Sent · it continues the same session");
}
cwSt.mode[s.id + ":" + agent] = "chat";
return true;
} catch(e){ toast(e.message); return false; }
}
function acSend(s, agent){
const key = s.id + ":" + agent;
if(!acSendText(s, agent, cwSt.draft[key])) return;
cwSt.draft[key] = ""; cwSt.stick[key] = true;
const ta = document.getElementById("ac-ta-" + key); if(ta){ ta.value = ""; ta.blur(); }
render();
}
function acCancel(m){
update("messages", m.id, { status:"cancelled" });
const t = byId("tasks", String(m.thread).slice(5));
if(t && !rows("messages", x => x.thread === m.thread && x.kind === "user" && x.status === "queued").length) update("tasks", t.id, { followup_pending:false });
commit(); toast("Cancelled");
}
/* ---- the thread ---- */
function acBubble(m, key){
const k = m.kind || "", attrs = { "data-at":m.at || m.created_at || "", "data-run":m.run_id || "" };
if(k === "user"){
const who = m.synth || m.author_id === me().id ? "You" : userName(m.author_id);
if(m.status === "queued") return h("div", Object.assign({ class:"ac-u q" }, attrs), h("div", { class:"ac-ub" }, m.body),
h("span", { class:"ac-qm" }, icon("clock", "xs"), "Queued · Claude reads it when the current run finishes · ",
h("button", { class:"link", onclick:() => acCancel(m) }, "Cancel")));
if(m.status === "cancelled") return h("div", Object.assign({ class:"ac-u q x" }, attrs), h("div", { class:"ac-ub" }, m.body), h("span", { class:"ac-qm" }, "Cancelled"));
return h("div", Object.assign({ class:"ac-u" }, attrs), h("div", { class:"ac-ub" }, m.body, h("div", { class:"ac-um" }, [who, acClock(m.at)].filter(Boolean).join(" · "))));
}
if(k === "agent") return h("div", Object.assign({ class:"ac-a" }, attrs), h("div", { class:"md", html:mdHtml(m.body) }));
if(k === "system") return h("div", Object.assign({ class:"ac-sys" }, attrs), m.body, m.at ? " · " + acClock(m.at) : "");
/* anything else on the task's thread (a note, a progress line, a comment) */
const claude = m.author_id === "u-claude";
return h("div", Object.assign({ class:"ac-note" }, attrs), h("span", { class:"ac-nk" }, (claude ? "Claude" : userName(m.author_id)) + (k ? " · " + k : "") + " · " + acClock(m.at)),
h("div", { class:"md", html:mdHtml(String(m.body || "").slice(0, 1200)) }));
}
function acThread(list, key){
const out = []; let i = 0;
while(i < list.length){
if(list[i].kind !== "step"){ out.push(acBubble(list[i], key)); i++; continue; }
const g = []; while(i < list.length && list[i].kind === "step") g.push(list[i++]);
const gk = key + ":" + g[0].id, open = !!cwSt.open[gk];
out.push(h("div", { class:"ac-steps", "data-at":g[0].at || "", "data-run":g[0].run_id || "" },
(open ? g : g.slice(0, 3)).map(x => h("span", { title:acClock(x.at) }, "✓ " + (x.label || x.body || "step"))),
g.length > 3 ? h("button", { class:"ac-more", onclick:() => { cwSt.open[gk] = !open; render(); } }, open ? "▾ fewer steps" : "▸ " + (g.length - 3) + " more steps") : null));
}
return out;
}
/* "Open thread" on an earlier run: scroll to the first message of that run */
function acJump(key, run){
const b = document.querySelector('[data-sk="ac-' + key + '"]'); if(!b) return;
const from = run.started_at || (run.progress || {}).started_at || "";
const el = Array.from(b.querySelectorAll("[data-at]")).find(e => e.dataset.run === run.id || (e.dataset.at && e.dataset.at >= from));
if(el){ cwSt.stick[key] = false; el.scrollIntoView({ block:"start", behavior:"smooth" }); el.classList.add("ac-hl"); setTimeout(() => el.classList.remove("ac-hl"), 1600); }
}
/* ---- the panel ---- */
function acPanel(s, ck){
const agent = AC_KEY[ck], info = AC_INFO[agent], key = s.id + ":" + agent;
const t = acTask(s, agent), st = runState(t), running = !!(t && t.status === "In progress"), starting = acStarting(s, agent);
const acc = byId("accounts", s.account_id) || {}, msgs = acMsgs(s, agent), runs = acRuns(s, agent);
const g0 = running ? runProgress(t) : null, g = running ? (g0 && !g0.done ? g0 : {}) : null, last = cwRun(t);
const coachBlocked = agent === "call-coach" && !acBusy(t) && !(callCoachReady(s.id, false) || hasReport(s));
/* status pill */
const since = g && (g.started_at || (runs[runs.length - 1] || {}).started_at);
let pillN;
if(starting) pillN = h("span", { class:"ac-pill" }, "Ready");
else if(running) pillN = h("span", { class:"ac-pill run" }, h("i"), "Working", since ? [" · ", h("span", { "data-since":since }, fmtElapsed(Date.now() - Date.parse(since)))] : null);
else if(st === "Queued") pillN = h("span", { class:"ac-pill gold" }, h("i"), crewOff() ? "Queued · crew off" : "Queued");
else if(st === "Done") pillN = h("span", { class:"ac-pill ok" }, "Done");
else if(st === "Blocked") pillN = h("span", { class:"ac-pill bad" }, "Blocked");
else pillN = h("span", { class:"ac-pill" }, st || "Ready");
const head = h("div", { class:"ac-h" },
h("div", { class:"grow", style:"min-width:0" }, h("b", null, info.name),
h("span", null, [acc.name, cwTitle(s), agent === "call-ask" ? "answers quote the call" : "session kept between messages"].filter(Boolean).join(" · "))),
pillN,
running ? (t.stop_requested_at && String(t.stop_requested_at) > String(t.run_requested_at || "")
? h("button", { class:"btn sm ac-stop", disabled:true, title:"The Dispatcher stops it within about 10 seconds" }, "Stopping…")
: h("button", { class:"btn sm ac-stop", title:"Stop this run; what it saved so far stays",
onclick:() => { if(!confirm("Stop the " + info.name + "? What it has saved so far stays.")) return;
update("tasks", t.id, { stop_requested_at:nowISO() }); commit(); toast("Stopping · the Dispatcher ends it within about 10 seconds"); } }, "Stop")) : null,
h("button", { class:"iconbtn", "aria-label":"Close the chat", title:"Close (Esc)", onclick:acClose }, icon("x")));
const barN = running && g && g.max_turns ? h("div", { class:"ac-bar" }, h("span", { style:{ width:Math.min(100, (+g.turns || 0) / g.max_turns * 100) + "%" } })) : running ? h("div", { class:"ac-bar" }) : null;
/* body */
let body;
const thread = msgs.length ? acThread(msgs, key) : [];
if(starting){
const again = agent === "call-reader" ? readerDone(s) : agent === "call-coach" ? !!(t && t.status === "Completed") : false;
const when = agent === "call-reader" ? callReadAt(s.id) : last && last.at ? String(last.at).slice(0, 10) : "";
const dur = last && last.ms ? "About " + cwDur(last.ms, true) + " for this call. " : "";
const ran = runs.filter(r => r.ended_at || (r.progress || {}).done);
body = [thread.length ? h("div", { class:"ac-earlier-t" }, thread) : null,
h("div", { class:"ac-intro" }, h("span", { class:"ac-ic" }, icon(agent === "call-ask" ? "chat" : agent === "call-coach" ? "users" : "file")),
h("b", null, info.start), h("span", null, info.intro + " " + dur + (agent === "call-ask" ? "" : "You can message it while it works."))),
coachBlocked ? h("div", { class:"ac-note-gold" }, "The Call Coach runs after the Call Reader has finished. Run the Call Reader first.") : null,
again ? h("div", { class:"ac-note-gold" }, agent === "call-reader"
? "Already read" + (when ? " on " + acDay(when + "T12:00:00") : "") + ". Sending starts a new read: the current report is kept as a version and duplicate proposals are skipped."
: "Already coached" + (when ? " on " + acDay(when + "T12:00:00") : "") + ". Sending starts a new coaching run: the current coaching pages are kept as versions and each person's draft is updated, not duplicated.") : null,
ran.length ? h("div", { class:"ac-runs" }, h("span", { class:"cw-eyebrow" }, "Earlier on this call"),
ran.slice().reverse().map(r => { const i = ran.indexOf(r), has = msgs.some(m => m.run_id === r.id || ((m.at || "") >= (r.started_at || "") && (!r.ended_at || (m.at || "") <= r.ended_at)));
const what = [acDay(r.started_at || r.ended_at), i === 0 ? "first " + info.word : info.word + " " + (i + 1), r.outcome && r.outcome !== "unknown" ? r.outcome : "",
r.turns ? r.turns + " steps" : "", r.cost_usd != null ? "$" + acMoney(r.cost_usd) : ""].filter(Boolean).join(" · ");
return h("div", { class:"ac-run" }, h("span", { class:"grow" }, what),
has ? h("button", { class:"link", onclick:() => acJump(key, r) }, "Open thread") : h("span", { class:"faint" }, "no thread kept")); })) : null];
} else {
body = [thread.length ? thread : h("p", { class:"ac-sys" }, acBusy(t) ? "Nothing said yet. Steps and messages appear here as it works." : "No messages on this run."),
t && t.status === "To do" && crewOff() ? crewOffBox(t, s.id) : null,
running ? h("div", { class:"ac-cur" }, h("span", { class:"ac-spin" }), h("span", null, (g && g.current) || "Starting")) : null,
running && g && g.updated_at ? h("div", { class:"live-stale", "data-upd":g.updated_at }, staleText(g.updated_at)) : null];
}
const bodyN = h("div", { class:"ac-b" + (starting ? " start" : ""), "data-sk":"ac-" + key,
onscroll:e => { const b = e.currentTarget; cwSt.stick[key] = b.scrollHeight - b.scrollTop - b.clientHeight < 48; } }, body);
if(cwSt.stick[key] !== false) setTimeout(() => { const b = document.querySelector('[data-sk="ac-' + key + '"]'); if(b) b.scrollTop = b.scrollHeight; }, 0);
/* composer */
const fp = cwPeople(s).filter(x => x.side === "fieldproxy");
const chips = agent === "call-ask" ? cwAskSugg(s)
: agent === "call-coach" ? fp.map(x => "Only " + x.name)
: starting ? ["Focus: go-live blockers", "Focus: promises only", "Skip the frames"] : ["Keep it shorter", "Skip the frames", "Who decides here?"];
const taId = "ac-ta-" + key;
const ph = starting ? (info.def || "Ask about this call…")
: agent === "call-ask" ? "Ask about this call… (⌘↵)"
: running ? "Message the " + info.name + " · it reads it when the current run finishes"
: "Message the " + info.name + (t && t.session_id ? " · it continues the same session" : " · sending starts a new run");
const ta = h("textarea", { class:"ac-ta", id:taId, "data-live":"1", placeholder:ph, "aria-label":"Message the " + info.name, value:cwSt.draft[key] || "",
oninput:e => { cwSt.draft[key] = e.target.value; },
onkeydown:e => { if(e.key === "Enter" && (e.metaKey || e.ctrlKey)){ e.preventDefault(); acSend(s, agent); } } });
const cost = last && last.cost != null ? last.cost : info.est;
const metaL = starting ? (agent === "call-ask" ? "Send asks · " + info.model + " · cents" : "Send starts the run · " + info.model + " · ~$" + acMoney(cost) + (last && last.cost != null ? "" : " estimate"))
: running ? [info.model, g && g.turns != null ? (g.turns || 0) + (g.max_turns ? " / " + g.max_turns : "") + " steps" : "", g && g.cost_usd != null ? "$" + acMoney(g.cost_usd) + " so far" : ""].filter(Boolean).join(" · ")
: [info.model, last && last.turns ? last.turns + " steps" : "", last && last.cost != null ? "$" + acMoney(last.cost) + " last run" : ""].filter(Boolean).join(" · ");
const foot = h("div", { class:"ac-f" },
chips.length ? h("div", { class:"ac-chips" }, chips.map(c => h("button", { onclick:() => { cwSt.draft[key] = c; const x = document.getElementById(taId); if(x){ x.value = c; x.focus(); x.setSelectionRange(c.length, c.length); } } }, c))) : null,
h("div", { class:"ac-comp" }, h("label", { for:taId, class:"sr" }, "Message the " + info.name), ta,
h("button", { class:"ac-send", "aria-label":"Send", title:"Send (⌘↵)", disabled:coachBlocked && starting, onclick:() => acSend(s, agent) }, icon("send", "sm"))),
h("div", { class:"ac-meta" }, h("span", null, metaL,
!starting && !acBusy(t) && agent !== "call-ask" && t ? [" · ", h("button", { class:"link", onclick:() => { cwSt.mode[key] = "start"; render(); } }, agent === "call-reader" ? "Start a new read" : "Start a new run")] : null,
!starting && agent === "call-ask" && t && !acBusy(t) ? [" · ", h("button", { class:"link", onclick:() => { cwSt.mode[key] = "start"; render(); } }, "New question")] : null),
h("span", null, "It can't push or change data")));
return [h("div", { class:"ac-scrim", onclick:acClose }),
h("aside", { class:"ac", role:"dialog", "aria-label":info.name + " chat" }, head, barN, bodyN, foot)];
}
/* Esc closes the chat (a modal or popover open on top takes Esc first) */
document.addEventListener("keydown", e => {
if(e.key !== "Escape" || !parseRoute().q.chat || parseRoute().q.t) return;
if($("ln-modal").classList.contains("open") || !$("ln-pop").hidden) return;
acClose();
});
/* ---------- route: #/projects//calls/[/] ---------- */
(function(){
const base = VIEWS.projects;
VIEWS.projects = r => {
if(r.parts[2] === "calls" && r.parts[3]){ const p = byId("projects", r.parts[1]);
if(p) return callWorkspace(p, r); }
if(r.parts[2] === "mail" && r.q.f){ mailSt.f = r.q.f; mailSt.sel = ""; history.replaceState(null, "", "#/" + r.path); }
return base(r);
};
})();
/* =====================================================================
Voice notes and the "new inputs" banner (11 Oct 2026).
- micButton: speak instead of typing into any notes box, with the
browser's own speech recognition (Chrome / Edge). Words appear in the
box as you speak; nothing saves on its own. Hidden where unsupported.
- inputsBanner: the Workflow and Blueprint "since vN" banner — a short
summary (calls by name, documents by folder) with Show the list, and
an Update button that opens a notes box before anything starts.
===================================================================== */
const VOICE_OK = typeof window !== "undefined" && !!(window.SpeechRecognition || window.webkitSpeechRecognition);
let voiceRec = null; // the one dictation running, with ._target = the textarea id it writes into
/* a Speak button for the textarea with this id; onText(text) keeps the caller's draft in step */
function micButton(targetId, onText){
if(!VOICE_OK) return null;
const on = !!(voiceRec && voiceRec._target === targetId);
return h("button", { type:"button", class:"btn sm mic" + (on ? " on" : ""), "aria-pressed":String(on),
title:on ? "Stop listening" : "Speak instead of typing: your words appear in the box",
onclick:e => { e.preventDefault(); voiceToggle(targetId, onText); } },
h("span", { class:"mic-dot", "aria-hidden":"true" }), on ? "Listening… tap to stop" : "Speak");
}
function voiceToggle(id, onText){
if(voiceRec){ const same = voiceRec._target === id; try { voiceRec.stop(); } catch(e){} voiceRec = null; render(); if(same) return; }
const R = window.SpeechRecognition || window.webkitSpeechRecognition;
let r;
try { r = new R(); } catch(e){ toast("This browser can't take dictation"); return; }
r.lang = navigator.language || "en-IN"; r.continuous = true; r.interimResults = true; r._target = id;
const el0 = document.getElementById(id), base = el0 ? String(el0.value || "") : "";
r.onresult = e => {
let heard = "";
for(let i = 0; i < e.results.length; i++) heard += e.results[i][0].transcript;
const text = (base.trim() ? base.replace(/\s*$/, " ") : "") + heard.trim();
const el = document.getElementById(id); if(el) el.value = text;
if(onText) onText(text);
};
r.onerror = ev => {
const why = ev.error === "not-allowed" || ev.error === "service-not-allowed" ? "the microphone is blocked: allow it for this site in the browser's address bar"
: ev.error === "no-speech" ? "no speech heard" : ev.error === "audio-capture" ? "no microphone found" : ev.error;
if(ev.error !== "aborted") toast("Dictation stopped · " + why);
if(voiceRec === r){ voiceRec = null; render(); }
};
r.onend = () => { if(voiceRec === r){ voiceRec = null; render(); } };
voiceRec = r;
try { r.start(); } catch(e){ voiceRec = null; toast("Couldn't start dictation · " + (e && e.message || e)); return; }
render();
}
/* ---------- the "since vN" banner shared by the Workflow and Blueprint tabs ---------- */
const upSt = { list:{}, box:{}, notes:{} };
/* o = {key, since ("v2"), fresh:[sources], names, extra (e.g. "2 open notes"), tail (sentence), label ("Update workflow"), start(notes), busy} */
function inputsBanner(p, o){
const calls = o.fresh.filter(s => s.kind === "call"), docs = o.fresh.filter(s => s.kind === "document");
const groups = {}; docs.forEach(s => { const g = (typeof docGroupOf === "function" && docGroupOf(s)) || "Not in a folder"; (groups[g] = groups[g] || []).push(s); });
const gNames = Object.keys(groups).sort((a, b) => (a === "Not in a folder") - (b === "Not in a folder") || a.localeCompare(b, undefined, { numeric:true }));
const parts = [];
if(calls.length) parts.push(calls.length <= 3 ? calls.map(s => (o.names[s.id] ? o.names[s.id].name : s.subject || "a call") + " (" + bpShortDate(s.occurred_at) + ")").join(", ")
: plural(calls.length, "new call"));
if(docs.length) parts.push(plural(docs.length, "new document") + (gNames.length > 1 || gNames[0] !== "Not in a folder" ? " in " + plural(gNames.length, "folder") : ""));
if(o.extra) parts.push(o.extra);
const k = o.key, listOpen = !!upSt.list[k], boxOpen = !!upSt.box[k], taId = "up-notes-" + k.replace(/\W+/g, "_");
const ta = h("textarea", { class:"ta bp-ta sm", id:taId, "data-live":"1", value:upSt.notes[k] || "",
placeholder:"Anything to tell the agent before it starts? e.g. The March call is the first discovery call; trust the 2027 price list over older ones; the HR file is background only.",
oninput:e => { upSt.notes[k] = e.target.value; } });
return h("div", { class:"bp-banner up-banner" }, icon("alert", "sm"),
h("div", { class:"grow up-main" },
h("div", null, h("b", null, "Since " + o.since + ": "), parts.join(" · ") + ". " + (o.tail || "")),
o.fresh.length ? h("button", { type:"button", class:"lnk up-toggle", "aria-expanded":String(listOpen), onclick:() => { upSt.list[k] = !listOpen; render(); } },
listOpen ? "Hide the list" : "Show the list") : null,
listOpen ? h("div", { class:"up-list" },
calls.length ? h("div", null, h("span", { class:"cw-eyebrow" }, "Calls · " + calls.length),
h("ul", null, calls.map(s => h("li", null, (o.names[s.id] ? o.names[s.id].name : s.subject) + " · " + bpShortDate(s.occurred_at))))) : null,
gNames.map(g => h("details", { class:"up-folder" }, h("summary", null, g + " · " + plural(groups[g].length, "file")),
h("ul", null, groups[g].map(s => h("li", null, typeof docName === "function" ? docName(s) : s.subject)))))) : null,
boxOpen ? h("div", { class:"up-box" },
h("label", { for:taId, class:"up-lab" }, "Notes for this update (optional)"), ta,
h("div", { class:"split", style:"gap:8px;justify-content:flex-start;flex-wrap:wrap" },
micButton(taId, t => { upSt.notes[k] = t; }),
h("button", { class:"btn primary sm", disabled:!!o.busy, onclick:() => { const n = upSt.notes[k] || ""; upSt.box[k] = false; upSt.notes[k] = ""; o.start(n); } }, "Start update"),
h("button", { class:"btn ghost sm", onclick:() => { upSt.box[k] = false; render(); } }, "Cancel"))) : null),
boxOpen ? null : h("button", { class:"btn primary sm", disabled:!!o.busy, onclick:() => { upSt.box[k] = true; render(); setTimeout(() => { const el = document.getElementById(taId); if(el) el.focus(); }, 30); } }, o.label));
}
/* =====================================================================
Solution blueprint (7 Oct 2026 · docs/crew/SPEC.md §1d · mockups
Blueprint.dc.html and Inputs.dc.html): the project's Blueprint tab at
#/projects//blueprint. With no version yet, the Build panel is the page;
with versions (projects.extra.blueprints), the selected version's JSON is
loaded from the sources bucket and shown, and ?build=1 opens the Build panel.
Nothing starts without a click on Start.
===================================================================== */
const bpSt = { json:{}, draft:{}, qOpen:{}, all:{}, ver:{}, busy:{} }; // per viewer, this tab only
/* rough costs for the action line: a Call Reader per unread call, then the Solution Designer */
const BP_EST = { reader:4, readerMin:12, build:3, update:1.5, designerMin:10 };
const BP_DOC_TYPES = { pdf:"application/pdf", docx:"application/vnd.openxmlformats-officedocument.wordprocessingml.document", txt:"text/plain", md:"text/markdown" };
const BP_DOC_MAX = 15 * 1024 * 1024;
const BP_SEV = { high:["HIGH","red"], med:["MED","gold"], low:["LOW","grey"] };
const BP_TOPIC = { model:"Model", platform:"Previous platform", scope:"Scope", process:"Process" };
/* ---------- data ---------- */
function bpVersions(p){ return Array.isArray(p.blueprints) ? p.blueprints.filter(v => v && typeof v === "object") : []; }
function bpAnswers(p){ return p.blueprint_answers && typeof p.blueprint_answers === "object" && !Array.isArray(p.blueprint_answers) ? p.blueprint_answers : {}; }
function bpCalls(p){ return rows("sources", s => s.project_id === p.id && s.kind === "call").sort(by(s => s.occurred_at || "")); }
function bpDocs(p){ return rows("sources", s => s.project_id === p.id && s.kind === "document").sort(by(s => s.occurred_at || "")); }
function bpTask(p){ return rows("tasks", t => t.project_id === p.id && t.agent === "solution-designer" && t.kind === "blueprint").sort(by(t => t.created_at || "")).pop() || null; }
/* a version's file: a full path, or a file name inside the version's folder */
function bpPath(v, k){ const x = String((v && v[k]) || ""); return !x ? "" : x.indexOf("/") > -1 ? x : (v.folder ? v.folder + "/" + x : x); }
function bpStopped(t){ return !!(t && t.stop_requested_at && String(t.stop_requested_at) > String(t.run_requested_at || "")); }
/* the blueprint run is in flight: queued (with a run request), running, or waiting on an approval */
function bpActive(t){ return !!t && !bpStopped(t) && (t.status === "In progress" || t.status === "Waiting" || runState(t) === "Queued"); }
function bpDeps(t){ const r = t && t.routing && typeof t.routing === "object" ? t.routing : {}; return (Array.isArray(r.depends_on) ? r.depends_on : []).map(id => byId("tasks", id)).filter(Boolean); }
/* short names: calls are numbered by date (Call 1, Call 2…), documents D1, D2… */
function bpNames(p){
const m = {};
bpCalls(p).forEach((s, i) => m[s.id] = { short:"C" + (i + 1), name:"Call " + (i + 1) + " · " + cwTitle(s), s });
bpDocs(p).forEach((s, i) => m[s.id] = { short:"D" + (i + 1), name:(bpDocs(p).length > 1 ? "Document " + (i + 1) : "Document") + " · " + (s.subject || s.file || "file"), s });
return m;
}
function bpNameOf(names, v, sid){
if(names[sid]) return names[sid];
const inp = (v && Array.isArray(v.inputs) ? v.inputs : []).find(x => x && x.source_id === sid) || {};
return { short:inp.kind === "document" ? "Doc" : "?", name:inp.title || sid, s:null };
}
function bpSize(n){ n = +n || 0; return n >= 1048576 ? (n / 1048576).toFixed(1) + " MB" : n >= 1024 ? Math.round(n / 1024) + " KB" : n + " B"; }
function bpShortDate(ts){ const d = new Date(String(ts || "").length === 10 ? ts + "T12:00:00" : ts); return isNaN(d) ? "" : d.getDate() + " " + MON[d.getMonth()]; }
function bpMoney(c){ return c == null || c === "" || isNaN(+c) ? "" : "$" + (+c < 10 ? (+c).toFixed(2) : Math.round(+c)); }
function bpCut(s, n){ s = String(s || ""); return s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s; }
/* the selected version's JSON, loaded once per path */
function bpLoad(path){
const c = bpSt.json[path];
if(c) return c;
if(!path) return bpSt.json[path] = { err:"This version has no JSON file recorded." };
if(!needSb()) return { err:"Sign in to Supabase in Settings to load the blueprint." }; // not cached: signing in loads it
bpSt.json[path] = { loading:true };
fileGet(path).then(t => { let data; try { data = JSON.parse(t); } catch(e){ throw new Error("the file isn't valid JSON"); }
if(!data || typeof data !== "object") throw new Error("the file is empty"); bpSt.json[path] = { data }; scheduleRender(); })
.catch(e => { const m = String(e && e.message || e);
bpSt.json[path] = { err:/404|not.?found|Object not found/i.test(m) ? "The blueprint file isn't there (" + path + "). The save may not have finished." : "Couldn't load the blueprint: " + m }; scheduleRender(); });
return bpSt.json[path];
}
/* ---------- documents: /docs/_/, a sources row of kind "document" ---------- */
function bpDocFolder(acc, name){
const slug = String(name || "document").replace(/\.[^.]+$/, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "document";
const base = cleanPathPart(acc.name) + "/docs/" + todayISO() + "_" + slug;
let f = base, n = 1;
while(rows("sources", s => s.folder === f).length) f = base + "-" + (++n);
return f;
}
function bpReadB64(file){
return new Promise((ok, no) => { const r = new FileReader();
r.onload = () => ok(String(r.result || "").replace(/^data:[^,]*,/, "")); r.onerror = () => no(r.error || new Error("couldn't read the file")); r.readAsDataURL(file); });
}
function bpAddDoc(p, file){
if(!file) return;
const ext = String(file.name.split(".").pop() || "").toLowerCase(), type = BP_DOC_TYPES[ext];
/* a Zoom transcript is a call, not a document: it goes in through Add a call so the Call Reader reads it */
if(ext === "vtt"){ addCallModal(p, file); toast("A Zoom transcript is added as a call: set the date and type, and the Call Reader reads it"); return; }
if(!type){ toast("Choose a PDF, Word (.docx), .txt or .md file"); return; }
if(file.size > BP_DOC_MAX){ toast("That file is " + bpSize(file.size) + ". The limit is 15 MB."); return; }
if(!needSb()){ toast("Sign in to Supabase in Settings first: documents are stored there"); return; }
if(bpSt.busy[p.id]) return;
const acc = byId("accounts", p.account_id) || {}, folder = bpDocFolder(acc, file.name);
const safe = file.name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-|-(?=\.)/g, "") || "document." + ext;
bpSt.busy[p.id] = "Uploading " + file.name + "…"; render();
bpReadB64(file).then(b64 => sbFn("file_put", { path:folder + "/" + safe, base64:b64, content_type:type })).then(j => {
const path = String(j.path || folder + "/" + safe), cut = path.lastIndexOf("/");
const dir = cut > -1 ? path.slice(0, cut) : folder, name = cut > -1 ? path.slice(cut + 1) : safe;
const s = insert("sources", { kind:"document", project_id:p.id, account_id:p.account_id, occurred_at:nowISO(), direction:"", from:"", to:"", cc:"",
subject:file.name, people:"", summary:"", file_id:"sb:sources/" + path, file_url:"", external_id:"", thread_id:"", size:file.size, processed_at:"",
folder:dir, file:name, content_type:type, doc_size:file.size });
logAct(p.id, "", "added the document “" + file.name + "” for the blueprint");
bpSt.busy[p.id] = ""; commit();
toast("Document added: " + file.name + (bpVersions(p).length ? ". Update the blueprint to include it." : ""));
return s;
}).catch(e => {
bpSt.busy[p.id] = ""; render();
const m = String(e && e.message || e);
toast(/file path must be/i.test(m) ? "The server doesn't accept document uploads yet: its file-path rule is being widened. Try again once that's live."
: "Couldn't upload the document · " + m);
});
}
/* ---------- Start: read the unread calls, then queue the Solution Designer ---------- */
function bpUnread(p){ return bpCalls(p).filter(s => !readerDone(s)); }
/* Updating the blueprint always updates the Current Workflow first (unless opts.solo: the Workflow asked for this run).
Returns "queued", "waits" (already queued, now also waiting on the new workflow run) or "running". */
function bpStart(p, focus, after, opts){
opts = opts || {};
const calls = bpCalls(p), docs = bpDocs(p), vers = bpVersions(p), mode = vers.length ? "update" : "build";
if(!calls.length && !docs.length){ if(!opts.quiet) toast("Add a call or a document first"); return ""; }
let t = bpTask(p);
if(bpActive(t)){
if(t.status === "In progress"){ if(!opts.quiet) toast("The blueprint is already being built"); return "running"; }
/* queued but not started: make it also wait for the runs it's chained behind */
const dep0 = (t.routing && Array.isArray(t.routing.depends_on)) ? t.routing.depends_on : [];
const add = (after || []).filter(id => dep0.indexOf(id) < 0);
if(add.length){ update("tasks", t.id, { routing:Object.assign({}, t.routing, { depends_on:dep0.concat(add) }) }); commit(); }
if(!opts.quiet) toast("The blueprint is already queued");
return "waits";
}
let wfQueued = false;
if(!opts.solo && typeof wfStart === "function"){ const w0 = typeof wfTask === "function" ? wfTask(p) : null;
if(!bpActive(w0)) wfQueued = !!wfStart(p, "", false, after, { solo:true, quiet:true }); }
const deps = (after || []).slice(), fails = []; // after: tasks this run must wait for (e.g. the Document Reader, then the Workflow)
/* the blueprint pins its pains to workflow boxes: if a Workflow run is queued or running, wait for it */
const wfT = typeof wfTask === "function" ? wfTask(p) : null;
if(wfT && bpActive(wfT) && deps.indexOf(wfT.id) < 0) deps.push(wfT.id);
bpUnread(p).forEach(s => {
try { deps.push(runCallAgent(s.id, "call-reader", { hasReport:false, confirmed:true }).task.id); }
catch(e){ const rt = callAgentTask(s.id, "call-reader");
if(/already running/i.test(e.message) && rt) deps.push(rt.id); else fails.push(cwTitle(s) + ": " + e.message); }
});
if(fails.length){ commit(); toast("Couldn't queue the Call Reader for " + fails.join("; ")); return; }
const names = bpNames(p), prev = vers[vers.length - 1], now = nowISO();
const f = String(focus || "").replace(/\s+/g, " ").trim().slice(0, 500);
const inputs = calls.concat(docs).map(s => s.id);
const prevIds = prev && Array.isArray(prev.inputs) ? prev.inputs.map(x => x && x.source_id) : [];
const fresh = inputs.filter(id => prevIds.indexOf(id) === -1);
const line = s => "- " + s.id + " · " + s.kind + " · " + (names[s.id] ? names[s.id].name : s.subject || s.id) + " · " + String(s.occurred_at || "").slice(0, 10);
const brief = [
"Goal: " + (mode === "update" ? "update" : "build") + " the solution blueprint for " + p.name + " (project " + p.id + ").",
"Mode: " + mode + ".",
"Inputs (" + inputs.length + "):", calls.concat(docs).map(line).join("\n"),
"Calls: read each call's Call Reader output (orbit_project_digest gives the report paths). Documents: orbit_get_document.",
mode === "update" ? "Previous version: v" + (prev.v || vers.length) + " · " + bpPath(prev, "json") + ". Read only what's new plus the previous blueprint" +
(fresh.length ? " (new since v" + (prev.v || vers.length) + ": " + fresh.join(", ") + ")." : ".") : "",
deps.length ? "Waits on: " + deps.join(", ") + " (routing.depends_on) — Call Readers, and any Document Reader or Workflow run queued with it." : "",
f ? "Focus: " + f : "",
"Output: one blueprint JSON saved with orbit_blueprint_save {project:" + p.id + "}. Every input appears in coverage; one with 0 points needs a note.",
"Limits: draft only; never contact the client; never push or change an app."].filter(Boolean).join("\n");
if(!t) t = crewTask(p.id, { name:"Blueprint: " + p.name, doer:"claude", agent:"solution-designer", kind:"blueprint", autonomy:"draft" });
if(t.status !== "To do") setTaskStatus(t, "To do");
const claude = byId("users", "u-claude");
update("tasks", t.id, Object.assign({ doer:"claude", agent:"solution-designer", kind:"blueprint", autonomy:"draft", agent_brief:brief,
routing:Object.assign({}, t.routing && typeof t.routing === "object" ? t.routing : {}, { depends_on:deps }),
run_requested_at:now, rerun_confirmed_at:now, focus:f, inputs, mode },
claude && (t.assignee_ids || []).indexOf(claude.id) === -1 ? { assignee_ids:[claude.id] } : {}));
logAct(p.id, t.id, (mode === "update" ? "asked for a blueprint update" : "asked for the blueprint") + (deps.length ? " after reading " + plural(deps.length, "call") : "") + (f ? " (focus: " + f + ")" : ""));
bpSt.draft["focus:" + p.id] = "";
commit();
if(!opts.quiet) toast((wfQueued || (wfT && bpActive(wfT)) ? "Workflow → Blueprint queued · each starts when the one before it finishes"
: (deps.length ? plural(deps.length, "Call Reader") + " → " : "") + "Blueprint queued") + (crewOff() ? " · the crew is off, paste the line shown into Claude Code" : ""));
return "queued";
}
function bpStop(p){
const t = bpTask(p); if(!t) return;
if(!confirm("Stop the blueprint run? Call Readers still waiting are stopped too; what they saved so far stays.")) return;
const now = nowISO();
[t].concat(bpDeps(t)).forEach(x => { if(x.status !== "Completed" && !bpStopped(x)) update("tasks", x.id, { stop_requested_at:now }); });
commit(); toast("Stopping · the Dispatcher ends it within about 10 seconds");
}
/* ---------- the tab ---------- */
function tabBlueprint(p, r){
const vers = bpVersions(p);
if(!vers.length || (r && r.q && r.q.build)) return bpBuildPage(p, vers);
return bpView(p, vers);
}
/* ---------- screen 7: the Build panel ---------- */
function bpBuildPage(p, vers){
const t = bpTask(p), active = bpActive(t), upd = vers.length > 0;
return h("div", { class:"bp" },
h("div", { class:"bp-head" },
h("div", { class:"grow" }, h("h2", { class:"bp-h1" }, upd ? "Update the blueprint" : "Build the blueprint"),
h("p", { class:"bp-lede" }, "The Solution Designer only starts when every call has been read. Calls that haven't been read yet go through the Call Reader first, then the blueprint builds from all of them together" + (upd ? ", reading only what's new plus the previous version." : "."))),
upd ? h("button", { class:"btn sm", onclick:() => setQ({ build:null }) }, icon("left", "sm"), "Back to the blueprint") : null),
bpCrewNotes(p),
h("div", { class:"bp-build" },
bpInputsCard(p, vers, t, active),
h("div", { class:"bp-side" },
active || (t && (t.status === "Blocked" || bpStopped(t))) ? bpRunCard(p, t) : bpIdleRunCard(),
vers.length ? bpCoverageCard(p, vers[vers.length - 1], null, true) : null)));
}
/* safe mode, a paused crew: say so before anything is queued */
function bpCrewNotes(p){
const cs = crewState(), allowed = Array.isArray(cs.allowed_agents) ? cs.allowed_agents.map(String) : [];
const need = (bpUnread(p).length ? ["call-reader"] : []).concat("solution-designer"), out = allowed.length ? need.filter(a => allowed.indexOf(a) === -1) : [];
return [cs.paused ? h("div", { class:"note-box warn" }, "The crew is paused. Start queues the work; nothing runs until you resume the crew.") : null,
out.length ? h("div", { class:"note-box warn" }, "Safe mode is on and only lets " + allowed.join(", ") + " run. " +
out.map(a => a === "call-reader" ? "The Call Reader" : "The Solution Designer").join(" and ") + " won't start until " + (out.length > 1 ? "they're" : "it's") + " allowed in the Dispatcher's settings.") : null];
}
function bpInputsCard(p, vers, t, active){
const calls = bpCalls(p), docs = bpDocs(p), unread = bpUnread(p), upd = vers.length > 0;
const prev = vers[vers.length - 1], prevIds = prev && Array.isArray(prev.inputs) ? prev.inputs.map(x => x && x.source_id) : [];
const names = bpNames(p), busy = bpSt.busy[p.id];
const rowsN = calls.map(s => {
const rt = callAgentTask(s.id, "call-reader"), done = readerDone(s), st = rt ? runState(rt) : "";
const nv = reportVersions(s).length;
const dur = s.duration_s ? cwDur(s.duration_s * 1000, true) : "";
const state = done ? "read · report v" + (nv + 1) : st === "Running" ? "the Call Reader is reading it now" : st === "Queued" ? "Call Reader queued" : "transcript uploaded, not read yet";
return h("div", { class:"bp-in" + (done ? "" : " warn") },
h("span", { class:"bp-dot " + (done ? "ok" : "warn") }, done ? icon("check", "xs") : "!"),
h("div", { class:"bp-in-t" }, h("b", null, names[s.id].name), h("span", null, [bpShortDate(s.occurred_at), dur, state].filter(Boolean).join(" · "))),
upd && prevIds.indexOf(s.id) === -1 ? h("span", { class:"bp-new" }, "new") : null,
h("span", { class:"bp-in-s " + (done ? "ok" : "warn") }, done ? "Read" : "Call Reader first"));
}).concat(docs.map(s => h("div", { class:"bp-in" },
h("span", { class:"bp-dot ok" }, icon("file", "xs")),
h("div", { class:"bp-in-t" }, h("b", null, names[s.id].name), h("span", null, [bpShortDate(s.occurred_at), s.doc_size || s.size ? bpSize(s.doc_size || s.size) : "", "read directly by the Solution Designer"].filter(Boolean).join(" · "))),
upd && prevIds.indexOf(s.id) === -1 ? h("span", { class:"bp-new" }, "new") : null,
h("span", { class:"bp-in-s ok" }, "Ready"))));
const file = h("input", { type:"file", accept:".pdf,.docx,.txt,.md,.vtt,application/pdf,text/plain,text/markdown,text/vtt", class:"sr", "aria-label":"Choose a document",
onchange:e => { const f = e.target.files && e.target.files[0]; e.target.value = ""; bpAddDoc(p, f); } });
const key = "focus:" + p.id, taId = "bp-focus-" + p.id;
const ta = h("textarea", { class:"ta bp-ta", id:taId, "data-live":"1", value:bpSt.draft[key] || "", placeholder:"e.g. They came from JobNimbus; be careful with the order flow",
oninput:e => { bpSt.draft[key] = e.target.value; } });
const n = unread.length, est = n * BP_EST.reader + (upd ? BP_EST.update : BP_EST.build), mins = n * BP_EST.readerMin + BP_EST.designerMin;
const action = n ? "Read " + plural(n, "call") + ", then " + (upd ? "update" : "build") + " the blueprint" : upd ? "Update blueprint" : "Build the blueprint";
const none = !calls.length && !docs.length;
return h("section", { class:"cw-card bp-card bp-inputs" },
h("div", { class:"bp-card-h" }, h("b", null, "1 · What it will read"), h("span", { class:"bp-meta" }, plural(calls.length, "call") + " · " + plural(docs.length, "document"))),
rowsN.length ? h("div", { class:"bp-inlist" }, rowsN) : h("p", { class:"bp-empty" }, "No calls or documents on this project yet. Add a call from Mail & calls, or a requirement document here."),
h("div", { class:"bp-add" }, file,
h("button", { class:"bp-addbtn", disabled:!!busy, onclick:() => file.click() }, icon(busy ? "clock" : "upload", "sm"), busy || "Add a document or a Zoom transcript (.vtt)"),
h("button", { class:"bp-addbtn", onclick:() => addCallModal(p) }, icon("plus", "sm"), "Add a call")),
h("div", { class:"bp-fld" }, h("label", { for:taId }, "Anything to focus on? (optional)"), ta, micButton(taId, t => { bpSt.draft[key] = t; })),
h("div", { class:"bp-go" },
h("div", { class:"grow" }, h("b", null, active ? "Running · see the right" : action),
h("span", null, none ? "Add a call or a document first." : "About " + (mins >= 60 ? Math.floor(mins / 60) + " h " + (mins % 60) + " min" : mins + " min") + " · ~$" + (est % 1 ? est.toFixed(2) : est) + " estimate (" +
(n ? n + " × Call Reader + " : "") + "Solution Designer)")),
h("button", { class:"btn primary bp-start", disabled:none || active, onclick:() => bpStart(p, bpSt.draft[key]) }, active ? "Running" : upd ? "Update" : "Start")));
}
function bpIdleRunCard(){
return h("section", { class:"cw-card bp-card" }, h("div", { class:"bp-card-h" }, h("b", null, "2 · Running")),
h("p", { class:"bp-empty" }, "Nothing running. Start reads the unread calls one after another, then the Solution Designer builds from all of them."));
}
/* the chain: each Call Reader it waits on, then the Solution Designer, with live state */
function bpRunCard(p, t, compact, o){
o = o || {}; /* the Workflow tab reuses this card: o = {label, building, updating, stop} */
const deps = bpDeps(t), all = [t].concat(deps), running = all.filter(x => x.status === "In progress");
const starts = running.map(x => (runProgress(x) || {}).started_at || (rows("agent_runs", r => r.task_id === x.id && r.started_at).sort(by(r => r.started_at)).pop() || {}).started_at).filter(Boolean).sort();
const stopping = all.some(x => x.status !== "Completed" && bpStopped(x)) && all.some(x => x.status === "In progress");
let pillN;
if(bpStopped(t) && !running.length) pillN = h("span", { class:"ac-pill bad" }, "Stopped");
else if(t.status === "Blocked") pillN = h("span", { class:"ac-pill bad" }, "Blocked");
else if(running.length) pillN = h("span", { class:"ac-pill run" }, h("i"), "Working", starts.length ? [" · ", h("span", { "data-since":starts[0] }, fmtElapsed(Date.now() - Date.parse(starts[0])))] : null);
else pillN = h("span", { class:"ac-pill gold" }, h("i"), crewOff() ? "Queued · crew off" : "Queued");
const rowN = (x, label, waitText) => {
const st = runState(x), r = cwRun(x), g = x.status === "In progress" ? runProgress(x) : null;
let mark, sub, cls = "";
if(st === "Done"){ mark = h("span", { class:"bp-ck" }, icon("check", "xs"));
const props = x.agent === "call-reader" && x.source_id ? rows("proposals", y => y.source_id === x.source_id).length : 0;
sub = h("span", { class:"bp-run-s" }, [r && r.ms ? cwDur(r.ms, true) : "", props ? plural(props, "proposal") : "", r && r.cost != null ? bpMoney(r.cost) : ""].filter(Boolean).join(" · ") || "done"); }
else if(st === "Running"){ mark = h("span", { class:"bp-spin" }); cls = " on";
sub = h("span", { class:"bp-run-s mono" }, [(g && g.current) || "Starting", g && g.turns != null ? (g.turns || 0) + (g.max_turns ? " / " + g.max_turns : "") + " steps" : ""].filter(Boolean).join(" · ")); }
else if(bpStopped(x)){ mark = h("span", { class:"bp-x" }, "×"); cls = " off"; sub = h("span", { class:"bp-run-s" }, "stopped"); }
else if(st === "Blocked"){ mark = h("span", { class:"bp-x" }, "!"); cls = " off"; sub = h("span", { class:"bp-run-s" }, "blocked" + (taskNote(x) ? " · " + taskNote(x).slice(0, 100) : "")); }
else { mark = h("span", { class:"bp-o" }, "○"); cls = " off"; sub = h("span", { class:"bp-run-s" }, waitText || (crewOff() ? "queued · the crew is off" : "queued")); }
return h("li", { class:"bp-run" + cls }, mark, h("div", { class:"bp-run-t" }, h("span", null, label), sub));
};
const names = bpNames(p), open = deps.filter(x => x.status !== "Completed");
const nIn = Array.isArray(t.inputs) ? t.inputs.length : 0, nCalls = (t.inputs || []).filter(id => names[id] && names[id].s && names[id].s.kind === "call").length;
const what = nIn ? (nCalls ? plural(nCalls, "call") : "") + (nIn - nCalls ? (nCalls ? " + " : "") + plural(nIn - nCalls, "document") : "") : "every input";
const waitN = open.length ? "starts when " + open.map(x => { const s = byId("sources", x.source_id); return s && names[s.id] ? names[s.id].name.split(" · ")[0] : "the call"; }).join(", ") + (open.length > 1 ? " are" : " is") + " read" : "";
return h("section", { class:"cw-card bp-card" + (compact ? " bp-runbox" : "") },
h("div", { class:"bp-card-h" }, h("b", null, compact ? (t.mode === "update" ? o.updating || "Updating the blueprint" : o.building || "Building the blueprint") : "2 · Running"), pillN,
all.some(x => x.status === "In progress" || runState(x) === "Queued") && !bpStopped(t) ? (stopping
? h("button", { class:"btn sm ac-stop", disabled:true }, "Stopping…")
: h("button", { class:"btn sm ac-stop", title:"Stop the run; what it saved so far stays", onclick:() => (o.stop || bpStop)(p) }, "Stop")) : null),
h("ol", { class:"bp-runs" },
deps.map(x => { const s = byId("sources", x.source_id); return rowN(x, "Call Reader · " + (s && names[s.id] ? names[s.id].name : x.name)); }),
rowN(t, (o.label || "Solution Designer") + " · " + what, waitN)),
runState(t) === "Queued" && crewOff() ? bpCrewOffBox(t, p) : null);
}
function bpCrewOffBox(t, p){
const line = "run the " + (t.agent || "solution-designer") + " agent on Orbit task " + t.id + " (project " + p.id + ")";
return h("div", { class:"crewoff" }, h("span", null, "Crew is off — paste in Claude Code:"), h("code", null, line),
h("button", { class:"btn sm ghost", onclick:() => (navigator.clipboard ? navigator.clipboard.writeText(line) : Promise.reject()).then(() => toast("Copied"), () => toast(line)) }, "Copy"));
}
/* ---------- screen 6: the blueprint ---------- */
function bpView(p, vers){
const idx = Math.max(0, Math.min(vers.length - 1, bpSt.ver[p.id] != null ? bpSt.ver[p.id] : vers.length - 1)), v = vers[idx], latest = vers[vers.length - 1];
const t = bpTask(p), names = bpNames(p);
/* new inputs: any call or document on the project that the latest version didn't read */
const latestIds = (Array.isArray(latest.inputs) ? latest.inputs : []).map(x => x && x.source_id);
const fresh = bpCalls(p).concat(bpDocs(p)).filter(s => latestIds.indexOf(s.id) === -1);
const lv = "v" + (latest.v || vers.length);
const banner = fresh.length && !bpActive(t) ? inputsBanner(p, { key:"bp:" + p.id, since:lv, fresh, names, tail:"The blueprint doesn't include them yet.",
label:"Update blueprint", start:n => bpStart(p, n), busy:bpActive(t) }) : null;
const c = bpLoad(bpPath(v, "json"));
const bar = bpVersionBar(p, vers, v, idx);
let main, rail;
if(c.loading){ main = [bar, h("section", { class:"cw-card" }, h("div", { class:"mskel" }, h("i"), h("i"), h("i", { style:"width:60%" })), h("span", { class:"bp-meta" }, "Loading the blueprint…"))]; rail = null; }
else if(c.err){ main = [bar, h("section", { class:"cw-card" }, h("p", { class:"bp-empty" }, c.err),
h("button", { class:"btn sm", style:"align-self:flex-start", onclick:() => { delete bpSt.json[bpPath(v, "json")]; scheduleRender(); } }, "Try again"))]; rail = null; }
else {
const b = c.data, ans = bpAnswers(p);
const qs = Array.isArray(b.questions) ? b.questions : [], openQ = qs.filter(q => !ans[q.id] || ans[q.id].status !== "answered").length;
main = [bar,
h("div", { class:"tiles bp-tiles" }, tile((b.pains || []).length, "Pain points"), tile((b.automations || []).length, "Things we automate"),
tile((b.ai || []).length, "AI widgets"), tile(openQ, "Open questions", openQ ? "warn" : "")),
b.summary ? h("section", { class:"cw-card" }, h("span", { class:"cw-eyebrow" }, b.client ? "Summary · " + b.client : "Summary"), h("p", { class:"bp-sum" }, b.summary)) : null,
bpPains(p, b, v, names), bpMap(p, b), bpFixes(b), bpModel(p, b), bpPlatform(b, v, names)];
rail = [bpQuestions(p, b), bpBuiltFrom(p, v, b, names, fresh), bpCoverageCard(p, v, b)];
}
return h("div", { class:"bp" }, bpCrewNotes(p).filter(Boolean).length && bpActive(t) ? bpCrewNotes(p) : null, banner,
bpActive(t) || (t && t.status === "Blocked") ? bpRunCard(p, t, true) : null,
h("div", { class:"bp-cols" }, h("div", { class:"bp-main" }, main), rail ? h("aside", { class:"bp-rail" }, rail) : null));
}
function bpVersionBar(p, vers, v, idx){
const fin = v.status === "final", run = v.run_id ? byId("agent_runs", v.run_id) : null, cost = run && run.cost_usd != null ? bpMoney(run.cost_usd) : "";
const ins = Array.isArray(v.inputs) ? v.inputs : [], nc = ins.filter(x => x && x.kind === "call").length, nd = ins.filter(x => x && x.kind === "document").length;
const from = ins.length ? "Built from " + [nc ? plural(nc, "call") : "", nd ? (nd === 1 ? "1 document" : nd + " documents") : ""].filter(Boolean).join(" + ") : "";
const at = v.at ? fmtStamp(v.at) : "";
const open = k => { const path = bpPath(v, k); if(!path){ toast("This version has no " + (k === "client_html" ? "client" : "internal") + " page"); return; }
if(!needSb()){ toast("Sign in to Supabase in Settings to open it"); return; }
openReportTab({ path, title:p.name + " · blueprint v" + (v.v || idx + 1) + (k === "client_html" ? " · client" : " · internal") }); };
const markFinal = () => {
const list = bpVersions(p).map((x, i) => Object.assign({}, x, { status:i === idx ? "final" : x.status === "final" ? "draft" : (x.status || "draft") }));
update("projects", p.id, { blueprints:list }); logAct(p.id, "", "marked blueprint v" + (v.v || idx + 1) + " final"); commit(); toast("v" + (v.v || idx + 1) + " is the final blueprint");
};
return h("section", { class:"cw-card bp-vbar" },
h("div", { class:"bp-vbar-t" }, h("b", null, "Blueprint v" + (v.v || idx + 1) + " ", h("span", { class:"pill " + (fin ? "done" : "todo") }, fin ? "Final" : "Draft")),
h("span", null, [from, at, "Solution Designer", cost].filter(Boolean).join(" · "))),
h("label", { class:"sr", for:"bp-ver-" + p.id }, "Version"),
(() => { const s = sel(vers.map((x, i) => [String(i), "v" + (x.v || i + 1) + " · " + bpShortDate(x.at) + (x.status === "final" ? " · final" : "")]), String(idx), val => { bpSt.ver[p.id] = +val; render(); }, "bp-sel"); s.id = "bp-ver-" + p.id; return s; })(),
h("button", { class:"btn sm", onclick:() => open("client_html") }, icon("external", "xs"), "Client version"),
h("button", { class:"btn sm", onclick:() => open("internal_html") }, icon("external", "xs"), "Internal version"),
fin ? h("button", { class:"btn sm", disabled:true, title:"This is the final version" }, icon("check", "xs"), "Final") : h("button", { class:"btn sm jade", onclick:markFinal }, "Mark final"),
h("button", { class:"btn sm ghost", onclick:() => setQ({ build:1 }) }, "Update…"));
}
/* an evidence chip: a call with a time jumps to that moment in its transcript; a document just labels */
function bpChip(p, e, names, v){
const s = e.source_id ? byId("sources", e.source_id) : null;
const tip = [e.quote ? "“" + e.quote + "”" : "", e.speaker || ""].filter(Boolean).join(" — ") || null;
const label = e.label || bpNameOf(names, v, e.source_id).short + (e.page ? " · p." + e.page : "");
if(s && s.kind === "call"){
const sec = e.at_time != null && e.at_time !== "" ? (typeof e.at_time === "number" ? e.at_time : parseTs(e.at_time)) : null;
return h("a", { class:"bp-chip link-chip", href:"#/projects/" + p.id + "/calls/" + s.id + (sec != null ? "/transcript" : ""), title:tip,
onclick:() => { if(sec != null){ cwSt.jump[s.id] = sec; callSt.q[s.id] = ""; cwSt.spk[s.id] = "all"; } } }, label);
}
return h("span", { class:"bp-chip", title:tip }, label);
}
function bpPains(p, b, v, names){
const pains = Array.isArray(b.pains) ? b.pains : [], k = "pains:" + p.id, all = !!bpSt.all[k], shown = all ? pains : pains.slice(0, 4);
return h("section", { class:"cw-card bp-card" },
h("div", { class:"bp-card-h" }, h("b", null, "Pain points"), h("span", { class:"bp-meta" }, "every one is backed by a quote or a document page")),
pains.length ? shown.map(x => { const sv = BP_SEV[x.severity] || BP_SEV.low;
return h("div", { class:"bp-pain" },
h("div", { class:"bp-pain-h" }, h("span", { class:"bp-sev " + sv[1] }, sv[0]), h("span", { class:"bp-id" }, x.id), h("b", null, x.title)),
x.impact ? h("span", { class:"bp-impact" }, x.impact) : null,
h("div", { class:"bp-chips" }, (x.evidence || []).map(e => bpChip(p, e, names, v)),
(x.fixed_by || []).length ? h("span", { class:"bp-fixed" }, "→ fixed by " + x.fixed_by.join(", ")) : h("span", { class:"bp-fixed gap" }, "no fix proposed yet"))); })
: h("p", { class:"bp-empty" }, "No pain points in this version."),
pains.length > 4 ? h("button", { class:"link", style:"align-self:flex-start", onclick:() => { bpSt.all[k] = !all; render(); } }, all ? "Show fewer" : "Show all " + pains.length) : null);
}
/* the mind-map: client → pains → what fixes each (automations and AI widgets), as SVG */
function bpMap(p, b){
const pains = Array.isArray(b.pains) ? b.pains : [];
if(!pains.length) return null;
const fx = {}; (b.automations || []).forEach(a => fx[a.id] = { t:a.title, ai:false }); (b.ai || []).forEach(a => fx[a.id] = { t:a.title, ai:true });
const SLOT = 40, W = 760, rowsN = pains.map(x => ({ x, fixes:(x.fixed_by || []).map(id => ({ id, f:fx[id] })) }));
const H = Math.max(80, rowsN.reduce((n, r) => n + Math.max(1, r.fixes.length), 0) * SLOT + 10);
const cy = H / 2, col = { high:["#FBE7E5","#9A3A32"], med:["#FBF1DF","#8A5A00"], low:["#EDEAF1","#4C4456"] };
let y = 5, lines = "", boxes = "";
rowsN.forEach(r => {
const n = Math.max(1, r.fixes.length), py = y + n * SLOT / 2, c = col[r.x.severity] || col.low;
lines += '';
boxes += '' + esc(r.x.id + " · " + r.x.title) + '' +
'' + esc(bpCut(r.x.title, 32)) + "";
if(!r.fixes.length) boxes += 'no fix proposed yet';
r.fixes.forEach((f, i) => {
const fy = y + i * SLOT + SLOT / 2, ai = f.f ? f.f.ai : /^AI/i.test(f.id);
lines += '';
const label = f.id + " · " + (f.f ? f.f.t : "not in this version");
boxes += '' + esc(label) + '' +
'' + esc(bpCut(label, 40)) + "";
});
y += n * SLOT;
});
const root = '' + esc(bpCut(b.client || p.name, 17)) + "";
const svg = '";
return h("section", { class:"cw-card bp-card" },
h("div", { class:"bp-card-h" }, h("b", null, "Mind-map"), h("span", { class:"bp-meta" }, "pain points and what fixes them · scroll sideways on a small screen")),
h("div", { class:"bp-map", "data-sk":"bp-map", html:svg }));
}
function bpFixes(b){
const au = Array.isArray(b.automations) ? b.automations : [], ai = Array.isArray(b.ai) ? b.ai : [];
if(!au.length && !ai.length) return null;
return h("div", { class:"bp-two" },
h("section", { class:"cw-card bp-card" }, h("div", { class:"bp-card-h" }, h("b", null, "Things we automate"), h("span", { class:"bp-meta" }, au.length)),
au.length ? au.map(a => h("div", { class:"bp-fix" }, h("div", { class:"bp-fix-h" }, h("span", { class:"bp-id" }, a.id), h("b", null, a.title)),
h("span", { class:"bp-fix-l" }, h("i", null, "When "), a.trigger || "—", h("i", null, " → "), a.action || ""),
a.notify ? h("span", { class:"bp-fix-l" }, h("i", null, "Tells "), a.notify) : null,
a.client_text ? h("span", { class:"bp-fix-c" }, a.client_text) : null)) : h("p", { class:"bp-empty" }, "None in this version.")),
h("section", { class:"cw-card bp-card" }, h("div", { class:"bp-card-h" }, h("b", null, "AI widgets"), h("span", { class:"bp-meta" }, ai.length)),
ai.length ? ai.map(a => h("div", { class:"bp-fix ai" }, h("div", { class:"bp-fix-h" }, h("span", { class:"bp-id ai" }, a.id), h("b", null, a.title)),
h("span", { class:"bp-fix-l" }, a.widget ? h("code", null, a.widget) : null, a.where ? " · " + a.where : ""),
a.saves ? h("span", { class:"bp-fix-l" }, h("i", null, "Saves "), a.saves) : null,
a.client_text ? h("span", { class:"bp-fix-c" }, a.client_text) : null)) : h("p", { class:"bp-empty" }, "None in this version.")));
}
function bpModel(p, b){
const m = b.model && typeof b.model === "object" ? b.model : {}, en = m.entities || [], st = m.statuses || [], ro = m.roles || [];
if(!en.length && !st.length && !ro.length) return null;
return h("section", { class:"cw-card bp-card" },
h("details", { class:"bp-model", open:!!bpSt.all["model:" + p.id], ontoggle:e => { bpSt.all["model:" + p.id] = e.target.open; } },
h("summary", null, h("b", null, "Model"), h("span", { class:"bp-meta" }, [plural(en.length, "entity", "entities"), plural(st.length, "status list"), plural(ro.length, "role")].join(" · "))),
en.length ? h("div", { class:"bp-ents" }, en.map(x => h("div", { class:"bp-ent" }, h("b", null, x.name), x.purpose ? h("span", { class:"bp-fix-c" }, x.purpose) : null,
(x.fields || []).length ? h("div", { class:"bp-tags" }, x.fields.map(f => h("span", null, f))) : null,
(x.links || []).length ? h("span", { class:"bp-fix-l" }, x.links.join(" · ")) : null))) : null,
st.length ? h("div", { class:"bp-sub" }, h("span", { class:"cw-eyebrow" }, "Statuses"), st.map(x => h("div", { class:"bp-fix-l" }, h("b", null, x.entity + ": "), (x.values || []).join(" → ")))) : null,
ro.length ? h("div", { class:"bp-sub" }, h("span", { class:"cw-eyebrow" }, "Roles"), ro.map(x => h("div", { class:"bp-fix-l" }, h("b", null, x.name + ": "), x.does || ""))) : null));
}
function bpPlatform(b, v, names){
const pl = b.platform; if(!pl || typeof pl !== "object") return null;
const k = { match:["Match","done"], gap:["Gap","blocked"], ask:["Ask","prog"] };
const mn = pl.mentioned || {}, where = mn.label || (mn.source_id ? bpNameOf(names, v, mn.source_id).name : "");
return h("section", { class:"cw-card bp-card" },
h("div", { class:"bp-card-h" }, h("b", null, "Coming from " + (pl.name || "their old system")), h("span", { class:"bp-meta" }, where ? "mentioned in " + where : "")),
h("div", { class:"bp-scroll" }, h("table", { class:"bp-tbl" },
h("thead", null, h("tr", null, h("th", null, "What they use today"), h("th", null, "In Fieldproxy"), h("th", null, "Status"))),
h("tbody", null, (pl.rows || []).map(r => { const s = k[r.status] || [r.status || "—", "todo"];
return h("tr", null, h("td", null, r.source_url ? h("a", { href:r.source_url, target:"_blank", rel:"noopener" }, r.feature) : r.feature),
h("td", null, r.fieldproxy || "", r.note ? h("div", { class:"bp-note" }, r.note) : null), h("td", null, pill(s[0], s[1]))); })))));
}
function bpQuestions(p, b){
const qs = Array.isArray(b.questions) ? b.questions : [], ans = bpAnswers(p);
const stOf = q => (ans[q.id] && ans[q.id].status) || "open";
const order = { open:0, asked_client:1, answered:2 };
const list = qs.slice().sort(by(q => order[stOf(q)] + (q.blocks ? "0" : "1") + String(q.id).padStart(4, "0")));
const k = "q:" + p.id, all = !!bpSt.all[k], shown = all ? list : list.slice(0, 5);
const nOpen = qs.filter(q => stOf(q) !== "answered").length, nAns = qs.length - nOpen;
const save = (q, status) => {
const key = "a:" + p.id + ":" + q.id, prev = ans[q.id] || {}, text = String(bpSt.draft[key] != null ? bpSt.draft[key] : prev.answer || "").trim();
if(status === "answered" && !text){ toast("Type the answer first"); return; }
update("projects", p.id, { blueprint_answers:Object.assign({}, ans, { [q.id]:{ answer:text, status, at:nowISO() } }) });
logAct(p.id, "", status === "answered" ? "answered blueprint " + q.id : "sent blueprint " + q.id + " to the client");
delete bpSt.draft[key]; bpSt.qOpen[p.id + ":" + q.id] = false;
const ta = document.getElementById("bp-a-" + p.id + "-" + q.id); if(ta) ta.blur();
commit(); toast(status === "answered" ? q.id + " answered" : q.id + " marked as asked of the client");
};
const card_ = q => {
const st = stOf(q), a = ans[q.id] || {}, key = "a:" + p.id + ":" + q.id, taId = "bp-a-" + p.id + "-" + q.id;
const tag = st === "answered" ? "Answered" : q.blocks ? "Blocks the " + (q.topic === "model" ? "model" : "build") : (BP_TOPIC[q.topic] || "Question") + (q.ask === "anand" ? " · for you" : "");
const box = () => [h("label", { for:taId, class:"sr" }, "Answer " + q.id),
h("textarea", { class:"ta bp-ta sm", id:taId, "data-live":"1", value:bpSt.draft[key] != null ? bpSt.draft[key] : a.answer || "", placeholder:"Your answer, or what the client said",
oninput:e => { bpSt.draft[key] = e.target.value; } }),
h("div", { class:"bp-qacts" }, h("button", { class:"btn sm primary", onclick:() => save(q, "answered") }, "Save answer"),
st !== "asked_client" ? h("button", { class:"btn sm", onclick:() => save(q, "asked_client") }, "Ask the client") : null)];
return h("div", { class:"bp-q " + st },
h("span", { class:"bp-q-tag " + (st === "answered" ? "ok" : q.blocks ? "blue" : "") }, q.id + " · " + tag),
h("span", { class:"bp-q-t" }, q.text),
st === "answered" ? h("span", { class:"bp-q-a" }, a.answer || "", a.at ? h("small", null, " · " + bpShortDate(a.at)) : null)
: q.why ? h("span", { class:"bp-q-w" }, "Why: " + q.why) : null,
st === "open" ? box()
: st === "asked_client" ? [h("span", { class:"bp-q-sent" }, "Sent to client" + (a.at ? " · " + bpShortDate(a.at) : ""), " · ",
h("button", { class:"link", onclick:() => { bpSt.qOpen[p.id + ":" + q.id] = !bpSt.qOpen[p.id + ":" + q.id]; render(); } }, bpSt.qOpen[p.id + ":" + q.id] ? "Hide" : "Add their answer")),
bpSt.qOpen[p.id + ":" + q.id] ? box() : null]
: [h("button", { class:"link bp-q-edit", onclick:() => { bpSt.qOpen[p.id + ":" + q.id] = !bpSt.qOpen[p.id + ":" + q.id]; render(); } }, bpSt.qOpen[p.id + ":" + q.id] ? "Cancel" : "Edit"),
bpSt.qOpen[p.id + ":" + q.id] ? box() : null]);
};
return h("section", { class:"cw-card bp-card" },
h("div", { class:"bp-card-h" }, h("b", null, "Questions"), h("span", { class:"bp-meta" }, nOpen + " open · " + nAns + " answered")),
qs.length ? shown.map(card_) : h("p", { class:"bp-empty" }, "No questions in this version."),
list.length > 5 ? h("button", { class:"link", style:"align-self:flex-start", onclick:() => { bpSt.all[k] = !all; render(); } }, all ? "Show fewer" : "Show all " + list.length) : null);
}
function bpBuiltFrom(p, v, b, names, fresh){
const ins = Array.isArray(v.inputs) && v.inputs.length ? v.inputs : Object.keys((b && b.coverage) || {}).map(id => ({ source_id:id }));
const cov = (b && b.coverage) || {}, ids = ins.map(x => x.source_id), lv = "v" + (v.v || "");
const missing = bpCalls(p).concat(bpDocs(p)).filter(s => ids.indexOf(s.id) === -1);
return h("section", { class:"cw-card bp-card" }, h("div", { class:"bp-card-h" }, h("b", null, "Built from")),
h("div", { class:"bp-from" },
ins.map(x => { const pts = x.points != null ? x.points : (cov[x.source_id] || {}).points, nm = names[x.source_id] ? names[x.source_id].name : x.title || x.source_id;
return h("div", { class:"bp-from-r" + (pts === 0 ? " zero" : "") }, h("span", { class:"bp-ck" }, icon("check", "xs")), h("span", { class:"grow" }, nm),
h("span", { class:"bp-pts" }, pts == null ? "" : plural(+pts, "point"))); }),
missing.map(s => h("div", { class:"bp-from-r miss" }, h("span", { class:"bp-o" }, "●"), h("span", { class:"grow" }, names[s.id].name), h("span", { class:"bp-pts" }, "not in " + lv)))));
}
/* coverage: which input each pain point came from (pains[].evidence), and how many points each input fed */
function bpCoverageCard(p, v, b, standalone){
if(!b){ b = bpLoad(bpPath(v, "json")).data; if(!b) return null; }
const names = bpNames(p), cov = b.coverage && typeof b.coverage === "object" ? b.coverage : {};
const cols = (Array.isArray(v.inputs) && v.inputs.length ? v.inputs.map(x => x.source_id) : Object.keys(cov)).filter(Boolean);
const pains = Array.isArray(b.pains) ? b.pains : [];
if(!cols.length) return null;
const zero = cols.filter(id => cov[id] && +cov[id].points === 0);
return h("section", { class:"cw-card bp-card" },
h("div", { class:"bp-card-h" }, h("b", null, standalone ? "3 · Coverage check" : "Coverage"), h("span", { class:"bp-meta" }, standalone ? "from v" + (v.v || "") : "")),
h("p", { class:"bp-note" }, "Which input each pain point came from. An input that fed nothing is flagged so you can check it wasn't skipped."),
h("div", { class:"bp-scroll", "data-sk":"bp-cov" + (standalone ? "s" : "") }, h("table", { class:"bp-cov" },
h("thead", null, h("tr", null, h("th", null, standalone ? "Pain point" : "Pain"), cols.map(id => h("th", { title:bpNameOf(names, v, id).name }, bpNameOf(names, v, id).short)))),
h("tbody", null,
pains.map(x => { const src = (x.evidence || []).map(e => e.source_id);
return h("tr", null, h("td", { title:x.id + " · " + x.title }, standalone ? x.id + " · " + x.title : x.id), cols.map(id => h("td", { class:"c" }, src.indexOf(id) > -1 ? h("span", { class:"bp-hit", "aria-label":"yes" }, "●") : ""))); }),
h("tr", { class:"tot" }, h("td", null, standalone ? "Fed by this input" : "Fed"), cols.map(id => { const n = cov[id] ? cov[id].points : null;
return h("td", { class:"c" + (n != null && +n === 0 ? " zero" : "") }, n == null ? "–" : String(n)); }))))),
zero.map(id => { const nm = bpNameOf(names, v, id), s = nm.s;
return h("div", { class:"bp-flag" }, icon("alert", "sm"), h("span", null, nm.name + " fed no pain points." + (cov[id].note ? " The Solution Designer says: " + cov[id].note : " No reason was given."),
s && s.kind === "call" ? [" ", h("a", { href:"#/projects/" + p.id + "/calls/" + s.id }, "Open the call")] : null)); }));
}
/* =====================================================================
Current Workflow graph (10 Oct 2026 · docs/crew/SPEC.md §1f): validate
the Workflow Mapper's JSON, lay it out (lanes are rows, stages are
columns) and draw it as SVG. Pure: no DOM, no db, no network, so the
same file draws the Workflow tab in the app and, loaded into a Node vm by
mcp/lib/workflow.js, the client and internal pages. Light theme only.
===================================================================== */
var WFG = (function(){
const KINDS = ["step", "record", "tool"], STATES = ["ok", "pain", "suspected"], SEV = ["high", "med", "low"];
const LANE_KINDS = ["actor", "records", "tools"], ASK = ["client", "anand"];
const arr = v => Array.isArray(v) ? v : [];
const str = v => v == null ? "" : String(v);
const esc = s => str(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
const C = { red:"#B4483F", redBg:"#FBE7E5", amber:"#A8700F", amberBg:"#FBF1DF", ok:"#B9B1C4", text:"#221C29", muted:"#6B6276",
faint:"#8A8394", line:"#DBD5E2", edge:"#E6E2EB", surf2:"#EDEAF1", stripe:"#FBFAFC", page:"#FFFFFF", wash:"#FFFBEF", toolInk:"#7A520C",
blue:"#3D63B0", ink:"#221C29", green:"#2E8F6C", greenBg:"#E4F1EB", gone:"#CFC8D8" };
const FONT = "Instrument Sans, system-ui, sans-serif", MONO = "IBM Plex Mono, ui-monospace, monospace", DISPLAY = "Fraunces, Georgia, serif";
/* ---------- validate ---------- */
function validate(raw, opts){
const o = opts || {}, inputs = arr(o.inputs), notes = arr(o.notes);
const errors = [], warnings = [];
if(!raw || typeof raw !== "object" || Array.isArray(raw)) return { ok:false, errors:["workflow must be a JSON object"], warnings, workflow:null };
const w = {
client:str(raw.client).trim(), one_liner:str(raw.one_liner).trim(),
stages:arr(raw.stages), lanes:arr(raw.lanes), nodes:arr(raw.nodes), edges:arr(raw.edges), pains:arr(raw.pains),
questions:arr(raw.questions), notes_applied:arr(raw.notes_applied), changes:arr(raw.changes).map(str).filter(Boolean),
coverage:raw.coverage && typeof raw.coverage === "object" && !Array.isArray(raw.coverage) ? raw.coverage : {}
};
if(!w.client) errors.push("client is empty");
if(!w.one_liner) errors.push("one_liner is empty: one sentence on what the business does");
if(w.stages.length < 2 || w.stages.length > 6) errors.push("stages: use 2 to 6 stages (now " + w.stages.length + ")");
if(!w.lanes.length) errors.push("lanes is empty");
if(!w.nodes.length) errors.push("nodes is empty");
const ids = new Set(), stageIds = new Set(), laneIds = new Set();
w.stages.forEach((s, i) => { if(!s || !str(s.id) || !str(s.name)) errors.push("stages[" + i + "] needs id and name"); else if(stageIds.has(s.id)) errors.push("duplicate stage id " + s.id); else stageIds.add(s.id); });
w.lanes.forEach((l, i) => {
if(!l || !str(l.id) || !str(l.name)) { errors.push("lanes[" + i + "] needs id and name"); return; }
if(laneIds.has(l.id)) errors.push("duplicate lane id " + l.id); else laneIds.add(l.id);
if(!l.kind) l.kind = "actor";
if(LANE_KINDS.indexOf(l.kind) < 0) errors.push("lane " + l.id + ": kind must be actor, records or tools");
});
if(w.lanes.filter(l => l && l.kind === "records").length > 1) errors.push("only one lane can be the records lane");
if(w.lanes.filter(l => l && l.kind === "tools").length > 1) errors.push("only one lane can be the tools lane");
const laneOf = {}; w.lanes.forEach(l => { if(l) laneOf[l.id] = l; });
const claim = (id, what) => { if(!str(id).trim()) { errors.push(what + " has no id"); return false; } if(ids.has(id)) { errors.push("duplicate id " + id); return false; } ids.add(id); return true; };
w.nodes.forEach((n, i) => {
if(!n || typeof n !== "object") { errors.push("nodes[" + i + "] is not an object"); return; }
claim(n.id, "nodes[" + i + "]");
if(KINDS.indexOf(n.kind) < 0) errors.push("node " + n.id + ": kind must be step, record or tool");
if(!str(n.title).trim()) errors.push("node " + n.id + " has no title");
if(!laneOf[n.lane]) errors.push("node " + n.id + ": lane " + n.lane + " isn't in lanes");
if(!stageIds.has(n.stage)) errors.push("node " + n.id + ": stage " + n.stage + " isn't in stages");
n.sources = arr(n.sources).filter(x => x && str(x.source_id));
if(n.kind === "record"){ n.fields = arr(n.fields).map(str).filter(Boolean); if(!n.fields.length) errors.push("record " + n.id + " needs at least one field"); if(n.fields.length > 6) warnings.push("record " + n.id + " has " + n.fields.length + " fields; the box shows the first 6"); }
const lk = laneOf[n.lane] && laneOf[n.lane].kind;
if(lk === "records" && n.kind !== "record") warnings.push("node " + n.id + " is a " + n.kind + " in the records lane");
if(lk === "tools" && n.kind !== "tool") warnings.push("node " + n.id + " is a " + n.kind + " in the tools lane");
if(n.kind === "step" && lk && lk !== "actor") warnings.push("step " + n.id + " should sit in a person's lane");
});
const nodeIds = new Set(w.nodes.filter(Boolean).map(n => n.id));
w.edges.forEach((e, i) => {
if(!e || typeof e !== "object") { errors.push("edges[" + i + "] is not an object"); return; }
claim(e.id, "edges[" + i + "]");
if(!nodeIds.has(e.from)) errors.push("edge " + e.id + ": from " + e.from + " isn't a node");
if(!nodeIds.has(e.to)) errors.push("edge " + e.id + ": to " + e.to + " isn't a node");
if(e.from === e.to) errors.push("edge " + e.id + " points at itself");
if(!e.state) e.state = "ok";
e.sources = arr(e.sources).filter(x => x && str(x.source_id));
if(STATES.indexOf(e.state) < 0) errors.push("edge " + e.id + ": state must be ok, pain or suspected");
if(!str(e.label).trim()) warnings.push("edge " + e.id + " has no label");
});
const allIds = new Set(Array.from(ids));
w.pains.forEach((p, i) => {
if(!p || typeof p !== "object") { errors.push("pains[" + i + "] is not an object"); return; }
claim(p.id, "pains[" + i + "]");
if(!allIds.has(p.on) || !(nodeIds.has(p.on) || w.edges.some(e => e && e.id === p.on))) errors.push("pain " + p.id + ": on " + p.on + " isn't a node or an edge");
if(p.state !== "pain" && p.state !== "suspected") errors.push("pain " + p.id + ": state must be pain or suspected");
if(SEV.indexOf(p.severity) < 0) errors.push("pain " + p.id + ": severity must be high, med or low");
if(!str(p.title).trim()) errors.push("pain " + p.id + " has no title");
if(!str(p.impact).trim()) errors.push("pain " + p.id + " has no impact (the cost in their terms)");
p.evidence = arr(p.evidence);
if(!p.evidence.length) errors.push("pain " + p.id + " has no evidence");
p.evidence.forEach((ev, j) => { if(!ev || !str(ev.source_id) || !str(ev.label)) errors.push("pain " + p.id + " evidence[" + j + "] needs source_id and label"); });
if(p.state === "pain" && !p.evidence.some(ev => ev && str(ev.quote).trim())) errors.push("pain " + p.id + " is red (the client said so) but has no quote: give the client's words, or make it suspected");
if(p.state === "suspected" && !p.question) warnings.push("suspected pain " + p.id + " has no question to confirm it");
});
const painIds = new Set(w.pains.filter(Boolean).map(p => p.id));
w.edges.forEach(e => {
if(!e) return;
if(e.state !== "ok"){
if(!e.pain) errors.push("edge " + e.id + " is " + e.state + " but names no pain");
else if(!painIds.has(e.pain)) errors.push("edge " + e.id + ": pain " + e.pain + " isn't in pains");
else { const p = w.pains.find(x => x && x.id === e.pain); if(p.on !== e.id) errors.push("edge " + e.id + " names pain " + e.pain + ", but that pain is on " + p.on);
else if(p.state !== e.state) errors.push("edge " + e.id + " is " + e.state + " but its pain " + p.id + " is " + p.state); }
} else if(e.pain) errors.push("edge " + e.id + " works (state ok) but names pain " + e.pain);
});
w.pains.forEach(p => { if(p && w.edges.some(e => e && e.id === p.on)){ const e = w.edges.find(x => x && x.id === p.on); if(e.pain !== p.id) errors.push("pain " + p.id + " is on edge " + e.id + ", so that edge needs state " + p.state + " and pain " + p.id); } });
const qIds = new Set();
w.questions.forEach((q, i) => {
if(!q || typeof q !== "object") { errors.push("questions[" + i + "] is not an object"); return; }
claim(q.id, "questions[" + i + "]"); qIds.add(q.id);
if(!str(q.text).trim()) errors.push("question " + q.id + " has no text");
if(!str(q.why).trim()) errors.push("question " + q.id + " has no why");
if(!q.ask) q.ask = "client";
if(ASK.indexOf(q.ask) < 0) errors.push("question " + q.id + ": ask must be client or anand");
q.blocks = q.blocks === true;
});
w.pains.forEach(p => { if(p && p.question && !qIds.has(p.question)) errors.push("pain " + p.id + ": question " + p.question + " isn't in questions"); });
w.nodes.forEach(n => { if(n && n.kind !== "tool" && !w.edges.some(e => e && (e.from === n.id || e.to === n.id))) warnings.push("node " + n.id + " (" + n.title + ") isn't connected to anything"); });
if(w.nodes.length > 36) warnings.push(w.nodes.length + " boxes: past about 36 the diagram gets hard to read; merge minor steps");
const noteIds = new Set(notes.map(n => n && n.id));
w.notes_applied = w.notes_applied.map(x => typeof x === "string" ? { note:x, how:"" } : x).filter(x => x && x.note);
w.notes_applied.forEach(x => { if(notes.length && !noteIds.has(x.note)) errors.push("notes_applied: " + x.note + " isn't a note on this project"); if(!str(x.how).trim()) warnings.push("note " + x.note + " applied without a how line"); });
notes.filter(n => n && n.status === "open").forEach(n => { if(!w.notes_applied.some(x => x.note === n.id)) warnings.push("open note " + n.id + " wasn't applied; it stays open"); });
for(const inp of inputs) if(!(inp.source_id in w.coverage)) errors.push("coverage has no entry for input " + inp.source_id + " (" + (inp.title || inp.kind) + ")");
for(const k of Object.keys(w.coverage)){ const c = w.coverage[k]; if(c && +c.points === 0 && !str(c.note).trim()) errors.push("coverage " + k + " has 0 points and no note saying why"); }
return { ok:!errors.length, errors, warnings, workflow:w };
}
/* ---------- the Future Workflow (SPEC 1i): an overlay on a saved workflow ----------
{base_v, summary?, fixes:[{pain, req_ids:[], how}], later:[{pain, phase, why?}], removed:[box or arrow ids],
added_nodes:[node, like nodes], added_edges:[edge, like edges]} */
function validateFuture(w, raw, opts){
const o = opts || {}, errors = [], warnings = [];
if(!raw || typeof raw !== "object" || Array.isArray(raw)) return { ok:false, errors:["future must be a JSON object"], warnings, future:null };
const f = { base_v:raw.base_v, summary:str(raw.summary).trim(), fixes:arr(raw.fixes), later:arr(raw.later), removed:arr(raw.removed).map(str),
added_nodes:arr(raw.added_nodes), added_edges:arr(raw.added_edges) };
const by = index(w), painIds = new Set(arr(w.pains).map(p => p.id)), reqs = o.reqs ? new Set(arr(o.reqs).flatMap(r => [r.id, r.ref].filter(Boolean))) : null;
const seen = new Set();
f.fixes.forEach((x, i) => {
if(!x || !painIds.has(x.pain)){ errors.push("fixes[" + i + "]: pain " + (x && x.pain) + " isn't on the workflow"); return; }
if(seen.has(x.pain)) errors.push("pain " + x.pain + " is listed twice"); seen.add(x.pain);
x.req_ids = arr(x.req_ids).map(str);
if(!x.req_ids.length) errors.push("fix for " + x.pain + " names no requirement");
if(reqs) x.req_ids.forEach(r => { if(!reqs.has(r)) errors.push("fix for " + x.pain + ": " + r + " isn't a requirement on this project"); });
if(!str(x.how).trim()) errors.push("fix for " + x.pain + " has no how (one line: what changes)");
});
f.later.forEach((x, i) => {
if(!x || !painIds.has(x.pain)){ errors.push("later[" + i + "]: pain " + (x && x.pain) + " isn't on the workflow"); return; }
if(seen.has(x.pain)) errors.push("pain " + x.pain + " is both fixed and later, or listed twice"); seen.add(x.pain);
if(!str(x.phase).trim()) errors.push("later " + x.pain + " has no phase");
});
arr(w.pains).forEach(p => { if(!seen.has(p.id)) warnings.push("pain " + p.id + " (" + p.title + ") is neither fixed nor left for later"); });
f.removed.forEach(id => { if(!by[id] || (!by[id].kind && !by[id].from)) errors.push("removed: " + id + " isn't a box or arrow on the workflow"); });
const lanes = new Set(arr(w.lanes).map(l => l.id)), stages = new Set(arr(w.stages).map(x => x.id)), newIds = new Set();
f.added_nodes.forEach((n, i) => {
if(!n || !str(n.id) || by[n.id] || newIds.has(n.id)){ errors.push("added_nodes[" + i + "] needs a new, unique id"); return; }
newIds.add(n.id);
if(KINDS.indexOf(n.kind) < 0) errors.push("added node " + n.id + ": kind must be step, record or tool");
if(!str(n.title).trim()) errors.push("added node " + n.id + " has no title");
if(!lanes.has(n.lane)) errors.push("added node " + n.id + ": lane " + n.lane + " isn't in lanes");
if(!stages.has(n.stage)) errors.push("added node " + n.id + ": stage " + n.stage + " isn't in stages");
if(n.kind === "record") n.fields = arr(n.fields).map(str).filter(Boolean);
});
const live = id => (by[id] && by[id].kind && f.removed.indexOf(id) < 0) || newIds.has(id);
f.added_edges.forEach((e, i) => {
if(!e || !str(e.id) || by[e.id] || newIds.has(e.id)){ errors.push("added_edges[" + i + "] needs a new, unique id"); return; }
newIds.add(e.id);
if(!live(e.from)) errors.push("added edge " + e.id + ": from " + e.from + " isn't a box in the future workflow");
if(!live(e.to)) errors.push("added edge " + e.id + ": to " + e.to + " isn't a box in the future workflow");
});
return { ok:!errors.length, errors, warnings, future:f };
}
/* the workflow as it will be: fixed pains turn green, later pains keep red with their phase, removed boxes and arrows
are struck through, new ones are green */
function applyFuture(w, f){
const c = JSON.parse(JSON.stringify(w)), fx = {}, lt = {}, gone = new Set(arr(f.removed));
arr(f.fixes).forEach(x => fx[x.pain] = x); arr(f.later).forEach(x => lt[x.pain] = x);
c.pains = arr(c.pains).map(p => Object.assign(p, fx[p.id] ? { fixed:fx[p.id] } : {}, lt[p.id] ? { later:lt[p.id] } : {}));
c.nodes = arr(c.nodes).map(n => gone.has(n.id) ? Object.assign(n, { gone:true }) : n).concat(arr(f.added_nodes).map(n => Object.assign({}, n, { added:true })));
c.edges = arr(c.edges).map(e => {
if(gone.has(e.id) || gone.has(e.from) || gone.has(e.to)) e.gone = true;
const p = e.pain ? c.pains.find(x => x.id === e.pain) : null;
if(p && p.fixed){ e.fixed = p.fixed; e.state = "fixed"; } else if(p && p.later) e.later = p.later;
return e;
}).concat(arr(f.added_edges).map(e => Object.assign({ label:"", state:"ok" }, e, { added:true })));
c.future = true;
return c;
}
function futureCounts(w, f){
const red = arr(w.pains).filter(p => p.state === "pain").length, fixedRed = arr(f.fixes).filter(x => (arr(w.pains).find(p => p.id === x.pain) || {}).state === "pain").length;
return { fixed:arr(f.fixes).length, fixed_red:fixedRed, red, later:arr(f.later).length, added:arr(f.added_nodes).length + arr(f.added_edges).length, removed:arr(f.removed).length };
}
function counts(w){
const n = k => arr(w.nodes).filter(x => x && x.kind === k).length, ps = arr(w.pains);
return { steps:n("step"), records:n("record"), tools:n("tool"), pains:ps.filter(p => p.state === "pain").length,
suspected:ps.filter(p => p.state === "suspected").length, questions:arr(w.questions).length };
}
/* ---------- helpers ---------- */
function index(w){
const by = {};
arr(w.nodes).concat(arr(w.edges), arr(w.pains), arr(w.questions)).forEach(x => { if(x && x.id) by[x.id] = x; });
return by;
}
function nameOf(w, id, by){
by = by || index(w);
const x = by[id];
if(!x) return str(id);
if(x.from) return nameOf(w, x.from, by) + " → " + nameOf(w, x.to, by);
return str(x.title || x.text || id);
}
function painsOn(w, id){ return arr(w.pains).filter(p => p && p.on === id); }
function wrap(text, n, max){
const words = str(text).split(/\s+/).filter(Boolean), lines = [];
let cur = "";
for(const x of words){ if((cur + " " + x).trim().length > n && cur){ lines.push(cur); cur = x; } else cur = (cur + " " + x).trim(); }
if(cur) lines.push(cur);
if(max && lines.length > max){ const keep = lines.slice(0, max); keep[max - 1] = clip(lines.slice(max - 1).join(" "), n); return keep; }
return lines;
}
function clip(s, n){ s = str(s); return s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s; }
/* ---------- layout: lanes are rows, stages are columns ---------- */
const LBL = 112, COLW = 192, NW = 160, TOP = 46, LPAD = 30;
function nodeH(n){
if(n.kind === "record") return 30 + Math.min(6, arr(n.fields).length) * 17 + 8;
return 16 + wrap(n.title, 22, 3).length * 16 + 4;
}
function layout(w){
const nodes = arr(w.nodes).filter(Boolean), lanes = arr(w.lanes).filter(Boolean), stages = arr(w.stages).filter(Boolean);
const pos = {}, stageCols = {}, laneY = {}, laneH = {};
let col = 0;
/* `order` is the box's column inside its stage (0 = first); boxes in one lane never share a column */
stages.forEach(st => {
let width = 1;
const offs = {};
lanes.forEach(l => {
let next = 0;
nodes.map((n, i) => ({ n, i })).filter(x => x.n.lane === l.id && x.n.stage === st.id)
.sort((a, b) => (a.n.order != null ? +a.n.order : a.i) - (b.n.order != null ? +b.n.order : b.i) || a.i - b.i)
.forEach(x => { const want = Number.isFinite(+x.n.order) && x.n.order !== null && x.n.order !== "" ? Math.max(0, Math.floor(+x.n.order)) : next;
const k = Math.max(next, Math.min(want, 7)); offs[x.n.id] = k; next = k + 1; width = Math.max(width, next); });
});
stageCols[st.id] = { start:col, width };
Object.keys(offs).forEach(id => { pos[id] = { col:col + offs[id] }; });
col += width;
});
const cols = Math.max(col, 1);
let y = TOP;
lanes.forEach(l => {
const hs = nodes.filter(n => n.lane === l.id).map(nodeH);
const h = Math.max.apply(null, hs.concat([50])) + LPAD * 2;
laneY[l.id] = y; laneH[l.id] = h; y += h;
});
nodes.forEach(n => {
const p = pos[n.id] || { col:0 };
p.w = NW; p.h = nodeH(n);
p.x = LBL + p.col * COLW + (COLW - NW) / 2;
p.y = (laneY[n.lane] || TOP) + ((laneH[n.lane] || 80) - p.h) / 2;
pos[n.id] = p;
});
return { pos, stageCols, laneY, laneH, cols, W:LBL + cols * COLW + 16, H:y + 14 };
}
function bez(p0, p1, p2, p3, t){ const u = 1 - t; return { x:u*u*u*p0.x + 3*u*u*t*p1.x + 3*u*t*t*p2.x + t*t*t*p3.x, y:u*u*u*p0.y + 3*u*u*t*p1.y + 3*u*t*t*p2.y + t*t*t*p3.y }; }
function route(e, L, w){
const a = L.pos[e.from], b = L.pos[e.to];
if(!a || !b) return null;
const na = arr(w.nodes).find(n => n.id === e.from) || {}, nb = arr(w.nodes).find(n => n.id === e.to) || {};
let p0, p1, p2, p3, kind;
if(b.col < a.col){ // backward: a loop drawn below both boxes
kind = "loop";
p0 = { x:a.x + a.w / 2, y:a.y + a.h }; p3 = { x:b.x + b.w / 2, y:b.y + b.h };
const dy = Math.max(a.y + a.h, b.y + b.h) + 34; p1 = { x:p0.x, y:dy }; p2 = { x:p3.x, y:dy };
} else if(na.lane === nb.lane && b.col - a.col > 1){ // same lane, skipping boxes: arc above
kind = "arc";
p0 = { x:a.x + a.w / 2, y:a.y }; p3 = { x:b.x + b.w / 2, y:b.y };
const up = Math.min(a.y, b.y) - 44; p1 = { x:p0.x, y:up }; p2 = { x:p3.x, y:up };
} else if(b.col > a.col){
kind = "fwd";
p0 = { x:a.x + a.w, y:a.y + a.h / 2 }; p3 = { x:b.x, y:b.y + b.h / 2 };
const mx = (p0.x + p3.x) / 2; p1 = { x:mx, y:p0.y }; p2 = { x:mx, y:p3.y };
} else {
kind = "vert";
const down = b.y > a.y;
p0 = { x:a.x + a.w / 2, y:down ? a.y + a.h : a.y }; p3 = { x:b.x + b.w / 2, y:down ? b.y : b.y + b.h };
const my = (p0.y + p3.y) / 2; p1 = { x:p0.x, y:my }; p2 = { x:p3.x, y:my };
}
return { kind, span:p3.x - p0.x, flat:Math.abs(p3.y - p0.y) < 30, top:Math.min(a.y, b.y), d:"M" + p0.x + " " + p0.y + " C" + p1.x + " " + p1.y + " " + p2.x + " " + p2.y + " " + p3.x + " " + p3.y, mid:bez(p0, p1, p2, p3, .5) };
}
/* which boxes and arrows stay bright under a filter or a selection */
function brightness(w, opts){
const o = opts || {}, by = index(w), sel = o.selected && by[o.selected] ? o.selected : null;
const sore = e => e.state !== "ok" && e.state !== "fixed" && !e.gone;
const hurts = id => { const x = by[id]; if(!x) return false; if(x.from) return sore(x);
return painsOn(w, id).some(p => !p.fixed) || arr(w.edges).some(e => sore(e) && (e.from === id || e.to === id)); };
const near = id => {
if(!sel || id === sel) return true;
const s = by[sel], x = by[id];
if(s.from) return id === s.from || id === s.to;
if(x && x.from) return x.from === sel || x.to === sel;
return arr(w.edges).some(e => (e.from === sel && e.to === id) || (e.to === sel && e.from === id));
};
return id => (o.filter !== "pains" || hurts(id)) && near(id);
}
/* ---------- the diagram ---------- */
function diagramSvg(w, opts){
const o = opts || {}, L = layout(w), bright = brightness(w, o), internal = o.internal !== false, interactive = !!o.interactive;
const openNotes = o.openNotes || {}, s = [];
/* a selection fades the rest almost away (and hides its labels) so the picked box and its links stand out;
the "only where it hurts" filter fades less, since the whole map is still the point */
const DIM = o.selected ? ".07" : ".18";
const label = id => internal ? id : "";
s.push('");
return s.join("");
}
/* ---------- the mind map: client → stages → steps → the pains on or into each step ---------- */
function mindSvg(w, opts){
const o = opts || {}, internal = o.internal !== false, interactive = !!o.interactive, by = index(w);
const steps = arr(w.nodes).filter(n => n && n.kind === "step");
const rows = [];
arr(w.stages).forEach(st => {
const L = layout(w);
steps.filter(n => n.stage === st.id).sort((a, b) => (L.pos[a.id] || {}).col - (L.pos[b.id] || {}).col).forEach(n => {
const ps = arr(w.pains).filter(p => { if(p.on === n.id) return true; const e = by[p.on]; return e && e.from && e.to === n.id; });
rows.push({ st, n, ps });
});
});
const SL = 38, hs = rows.map(r => Math.max(1, r.ps.length) * SL);
const Hm = Math.max(120, hs.reduce((a, b) => a + b, 0) + 20), Wm = 1000, cy = Hm / 2;
const lines = [], boxes = [], span = {};
let yy = 10;
rows.forEach((r, i) => {
const h = hs[i], my = yy + h / 2; r.my = my; (span[r.st.id] = span[r.st.id] || []).push(my);
r.ps.forEach((p, j) => {
const py = yy + j * SL + SL / 2, c = p.state === "pain" ? [C.redBg, C.red] : [C.amberBg, C.amber];
lines.push('');
boxes.push("" + esc(p.title + " — " + p.impact) + '' + esc(clip((internal ? p.id + " · " : "") + p.title, 54)) + "");
});
if(!r.ps.length) boxes.push('works today');
yy += h;
});
arr(w.stages).forEach(st => {
const ys = span[st.id]; if(!ys) return;
const sy = (ys[0] + ys[ys.length - 1]) / 2;
lines.push('');
boxes.push('' + esc(clip(st.name, 18)) + "");
rows.filter(r => r.st === st).forEach(r => {
const hurt = r.ps.some(p => p.state === "pain");
lines.push('');
boxes.push("" + esc(r.n.title) + '' + esc(clip(r.n.title, 24)) + "");
});
});
const cl = wrap(w.client, 18, 2);
const root = '' +
cl.map((t, i) => '' + esc(t) + "").join("");
return '";
}
return { validate, counts, layout, diagramSvg, mindSvg, nameOf, painsOn, index, clip, esc, C, validateFuture, applyFuture, futureCounts };
})();
/* =====================================================================
Current Workflow (10 Oct 2026 · docs/crew/SPEC.md §1f · mockup
docs/crew/workflow-mockup/Workflow.html): the project's Workflow tab at
#/projects//workflow. How the client works today as one connected
diagram (src/wfgraph.js draws it), with pains in red or amber, a mind map,
the pain and question lists, and notes that feed the next update. With no
version yet (projects.extra.workflows), the Build panel is the page; ?build=1
opens it later. Nothing starts without a click.
===================================================================== */
const wfSt = { sel:{}, view:{}, filter:{}, ver:{}, draft:{}, noteOn:{}, noteSrc:{}, zoom:{}, wide:{}, pi:{}, center:{}, fsw:{}, fsNote:{}, future:{} }; // per viewer, this tab only
const WF_EST = { reader:4, readerMin:12, build:2.5, update:1.2, mapperMin:8 };
const WF_SRC = [["client_call","Client · on a call"],["client_email","Client · by email"],["anand","Me"],["team","Team"]];
const WF_SRC_LABEL = Object.fromEntries(WF_SRC);
/* ---------- data ---------- */
function wfVersions(p){ return Array.isArray(p.workflows) ? p.workflows.filter(v => v && typeof v === "object") : []; }
function wfNotes(p){ return Array.isArray(p.workflow_notes) ? p.workflow_notes.filter(n => n && typeof n === "object") : []; }
function wfOpenNotes(p){ return wfNotes(p).filter(n => n.status === "open"); }
function wfTask(p){ return rows("tasks", t => t.project_id === p.id && t.agent === "workflow-mapper" && t.kind === "workflow").sort(by(t => t.created_at || "")).pop() || null; }
/* ---------- Start: read the unread calls, then queue the Workflow Mapper ---------- */
/* opts.solo: just this run (the Blueprint asked for it, so the Blueprint is already being queued); opts.quiet: no toast.
Otherwise a Current Workflow run always chains the Blueprint behind it: the blueprint pins its pains to workflow boxes. */
function wfStart(p, focus, future, after, opts){
opts = opts || {};
const calls = bpCalls(p), docs = bpDocs(p), vers = wfVersions(p), mode = future ? "future" : vers.length ? "update" : "build", open = future ? [] : wfOpenNotes(p);
if(future && !vers.length){ toast("Draw the Current Workflow first"); return; }
if(!calls.length && !docs.length){ toast("Add a call or a document first"); return; }
let t = wfTask(p);
if(bpActive(t)){ if(!opts.solo) toast("The workflow is already " + (t.status === "In progress" ? "being drawn" : "queued")); return; }
const deps = (after || []).slice(), fails = []; // after: tasks this run must wait for (e.g. the Document Reader)
bpUnread(p).forEach(s => {
try { deps.push(runCallAgent(s.id, "call-reader", { hasReport:false, confirmed:true }).task.id); }
catch(e){ const rt = callAgentTask(s.id, "call-reader");
if(/already running/i.test(e.message) && rt) deps.push(rt.id); else fails.push(cwTitle(s) + ": " + e.message); }
});
if(fails.length){ commit(); toast("Couldn't queue the Call Reader for " + fails.join("; ")); return; }
const names = bpNames(p), prev = vers[vers.length - 1], now = nowISO();
const f = String(focus || "").replace(/\s+/g, " ").trim().slice(0, 500);
const inputs = calls.concat(docs).map(s => s.id);
const prevIds = prev && Array.isArray(prev.inputs) ? prev.inputs.map(x => x && x.source_id) : [];
const fresh = inputs.filter(id => prevIds.indexOf(id) === -1);
const line = s => "- " + s.id + " · " + s.kind + " · " + (names[s.id] ? names[s.id].name : s.subject || s.id) + " · " + String(s.occurred_at || "").slice(0, 10);
const brief = [
mode === "future" ? "Goal: draw the Future Workflow for " + p.name + " (project " + p.id + "): the latest Current Workflow with Fieldproxy in place — which requirements fix which pains, what's left for later, what disappears and what's new. Save it with orbit_workflow_future_save; don't change the Current Workflow."
: "Goal: " + (mode === "update" ? "update" : "draw") + " the Current Workflow for " + p.name + " (project " + p.id + "): how the client works today, with every dependency and pain point.",
"Mode: " + mode + ".",
"Inputs (" + inputs.length + "):", calls.concat(docs).map(line).join("\n"),
"Start with orbit_workflow {project:" + p.id + "}: it carries each call's workflow facts. Documents: orbit_get_document.",
mode === "update" ? "Previous version: v" + (prev.v || vers.length) + " · " + bpPath(prev, "json") + ". Keep its ids where they still mean the same" +
(fresh.length ? "; new since then: " + fresh.join(", ") + "." : ".") : "",
open.length ? "Notes to apply (" + open.length + "):\n" + open.map(n => "- " + n.id + (n.on ? " on " + n.on : "") + " · " + (WF_SRC_LABEL[n.source] || n.source) + ": " + n.text).join("\n") : "",
deps.length ? "Waits on: " + deps.join(", ") + " (routing.depends_on) — Call Readers, and the Document Reader when it was queued with this run." : "",
f ? "Focus: " + f : "",
"Output: one workflow JSON saved with orbit_workflow_save {project:" + p.id + "}. Every input appears in coverage; one with 0 points needs a note. List each note you applied in notes_applied.",
"Limits: today's workflow only, no Fieldproxy features; never contact the client; never push or change an app."].filter(Boolean).join("\n");
if(!t) t = crewTask(p.id, { name:"Workflow: " + p.name, doer:"claude", agent:"workflow-mapper", kind:"workflow", autonomy:"draft" });
if(t.status !== "To do") setTaskStatus(t, "To do");
const claude = byId("users", "u-claude");
update("tasks", t.id, Object.assign({ doer:"claude", agent:"workflow-mapper", kind:"workflow", autonomy:"draft", agent_brief:brief,
routing:Object.assign({}, t.routing && typeof t.routing === "object" ? t.routing : {}, { depends_on:deps }),
run_requested_at:now, rerun_confirmed_at:now, focus:f, inputs, notes:open.map(n => n.id), mode },
claude && (t.assignee_ids || []).indexOf(claude.id) === -1 ? { assignee_ids:[claude.id] } : {}));
logAct(p.id, t.id, (mode === "update" ? "asked for a workflow update" : "asked for the current workflow") + (open.length ? " with " + plural(open.length, "note") : "") +
(deps.length ? " after reading " + plural(deps.length, "call") : "") + (f ? " (focus: " + f + ")" : ""));
wfSt.draft["focus:" + p.id] = "";
commit();
if(r_q().build) setQ({ build:null });
const bp = !future && !opts.solo && typeof bpStart === "function" ? bpStart(p, "", [t.id], { solo:true, quiet:true }) : "";
if(opts.quiet) return t;
toast((deps.length ? plural(deps.length, "Call Reader") + " → " : "") + "Workflow" + (bp === "queued" || bp === "waits" ? " → Blueprint queued · each starts when the one before it finishes"
: bp === "running" ? " queued · the Blueprint is being built right now; update it again once the workflow is done" : " queued") +
(crewOff() ? " · the crew is off, paste the line shown into Claude Code" : ""));
return t;
}
function r_q(){ try { return parseRoute().q || {}; } catch(e){ return {}; } }
function wfStop(p){
const t = wfTask(p); if(!t) return;
if(!confirm("Stop the workflow run? Call Readers still waiting are stopped too; what they saved so far stays.")) return;
const now = nowISO();
[t].concat(bpDeps(t)).forEach(x => { if(x.status !== "Completed" && !bpStopped(x)) update("tasks", x.id, { stop_requested_at:now }); });
commit(); toast("Stopping · the Dispatcher ends it within about 10 seconds");
}
const WF_RUN = { label:"Workflow Mapper", building:"Drawing the workflow", updating:"Updating the workflow", stop:wfStop };
/* ---------- notes ---------- */
function wfAddNote(p, text, on, source){
text = String(text || "").trim();
if(!text){ toast("Write the note first"); return false; }
const list = wfNotes(p), max = list.reduce((m, x) => Math.max(m, +String(x.id || "").replace(/\D/g, "") || 0), 0);
const rec = { id:"N" + (max + 1), text:text.slice(0, 2000), on:on || "", source:source || "anand", source_id:"", at:nowISO(), by:(session && session.user) || "", status:"open" };
update("projects", p.id, { workflow_notes:list.concat([rec]) });
logAct(p.id, "", "added workflow note " + rec.id + (on ? " on " + on : ""));
commit(); toast("Note " + rec.id + " added · it goes into the next update");
return true;
}
function wfSetNote(p, id, status){
update("projects", p.id, { workflow_notes:wfNotes(p).map(n => n.id === id ? Object.assign({}, n, { status }) : n) });
logAct(p.id, "", (status === "dismissed" ? "dismissed" : "reopened") + " workflow note " + id); commit();
}
/* ---------- the tab ---------- */
function tabWorkflow(p, r){
const vers = wfVersions(p);
if(!vers.length || (r && r.q && r.q.build)) return wfBuildPage(p, vers);
if(r && r.parts && r.parts[3] === "map") return wfFullscreen(p, vers);
return wfView(p, vers);
}
function wfCrewNotes(p){
const cs = crewState(), allowed = Array.isArray(cs.allowed_agents) ? cs.allowed_agents.map(String) : [];
const need = (bpUnread(p).length ? ["call-reader"] : []).concat("workflow-mapper"), out = allowed.length ? need.filter(a => allowed.indexOf(a) === -1) : [];
return [cs.paused ? h("div", { class:"note-box warn" }, "The crew is paused. Start queues the work; nothing runs until you resume the crew.") : null,
out.length ? h("div", { class:"note-box warn" }, "Safe mode is on and only lets " + allowed.join(", ") + " run. " +
out.map(a => a === "call-reader" ? "The Call Reader" : "The Workflow Mapper").join(" and ") + " won't start until " + (out.length > 1 ? "they're" : "it's") + " allowed in the Dispatcher's settings.") : null];
}
/* ---------- the Build panel ---------- */
function wfBuildPage(p, vers){
const t = wfTask(p), active = bpActive(t), upd = vers.length > 0;
return h("div", { class:"bp" },
h("div", { class:"bp-head" },
h("div", { class:"grow" }, h("h2", { class:"bp-h1" }, upd ? "Update the current workflow" : "Draw the current workflow"),
h("p", { class:"bp-lede" }, "How " + ((byId("accounts", p.account_id) || {}).name || "the client") + " works today, from their calls and documents: who does each step, what it needs first, what they keep track of, the tools they use, and where it breaks. Works from the first discovery call. Calls not read yet go through the Call Reader first.")),
upd ? h("button", { class:"btn sm", onclick:() => setQ({ build:null }) }, icon("left", "sm"), "Back to the workflow") : null),
wfCrewNotes(p),
h("div", { class:"bp-build" },
wfInputsCard(p, vers, t, active),
h("div", { class:"bp-side" },
active || (t && (t.status === "Blocked" || bpStopped(t))) ? bpRunCard(p, t, false, WF_RUN)
: h("section", { class:"cw-card bp-card" }, h("div", { class:"bp-card-h" }, h("b", null, "2 · Running")),
h("p", { class:"bp-empty" }, "Nothing running. Start reads the unread calls one after another, then the Workflow Mapper draws the workflow from all of them.")),
wfOpenNotes(p).length ? wfNotesCard(p, null, true) : null)));
}
function wfInputsCard(p, vers, t, active){
const calls = bpCalls(p), docs = bpDocs(p), unread = bpUnread(p), upd = vers.length > 0, names = bpNames(p), busy = bpSt.busy[p.id];
const prev = vers[vers.length - 1], prevIds = prev && Array.isArray(prev.inputs) ? prev.inputs.map(x => x && x.source_id) : [];
const rowsN = calls.map(s => {
const rt = callAgentTask(s.id, "call-reader"), done = readerDone(s), st = rt ? runState(rt) : "";
const state = done ? "read" : st === "Running" ? "the Call Reader is reading it now" : st === "Queued" ? "Call Reader queued" : "not read yet";
return h("div", { class:"bp-in" + (done ? "" : " warn") },
h("span", { class:"bp-dot " + (done ? "ok" : "warn") }, done ? icon("check", "xs") : "!"),
h("div", { class:"bp-in-t" }, h("b", null, names[s.id].name), h("span", null, [bpShortDate(s.occurred_at), s.duration_s ? cwDur(s.duration_s * 1000, true) : "", state].filter(Boolean).join(" · "))),
upd && prevIds.indexOf(s.id) === -1 ? h("span", { class:"bp-new" }, "new") : null,
h("span", { class:"bp-in-s " + (done ? "ok" : "warn") }, done ? "Read" : "Call Reader first"));
}).concat(docs.map(s => h("div", { class:"bp-in" },
h("span", { class:"bp-dot ok" }, icon("file", "xs")),
h("div", { class:"bp-in-t" }, h("b", null, names[s.id].name), h("span", null, [bpShortDate(s.occurred_at), "read directly by the Workflow Mapper"].join(" · "))),
upd && prevIds.indexOf(s.id) === -1 ? h("span", { class:"bp-new" }, "new") : null,
h("span", { class:"bp-in-s ok" }, "Ready"))));
const file = h("input", { type:"file", accept:".pdf,.docx,.txt,.md,.vtt,application/pdf,text/plain,text/markdown,text/vtt", class:"sr", "aria-label":"Choose a document",
onchange:e => { const f = e.target.files && e.target.files[0]; e.target.value = ""; bpAddDoc(p, f); } });
const key = "focus:" + p.id, taId = "wf-focus-" + p.id, open = wfOpenNotes(p).length;
const ta = h("textarea", { class:"ta bp-ta", id:taId, "data-live":"1", value:wfSt.draft[key] || "", placeholder:"e.g. Get the office's import and scheduling steps right; the owner cares most about billing delays",
oninput:e => { wfSt.draft[key] = e.target.value; } });
const n = unread.length, est = n * WF_EST.reader + (upd ? WF_EST.update : WF_EST.build), mins = n * WF_EST.readerMin + WF_EST.mapperMin;
const action = (n ? "Read " + plural(n, "call") + ", then " : "") + (upd ? "update the workflow" : "draw the workflow") + (open ? " with " + plural(open, "open note") : "");
const none = !calls.length && !docs.length;
return h("section", { class:"cw-card bp-card bp-inputs" },
h("div", { class:"bp-card-h" }, h("b", null, "1 · What it will read"), h("span", { class:"bp-meta" }, plural(calls.length, "call") + " · " + plural(docs.length, "document") + (open ? " · " + plural(open, "open note") : ""))),
rowsN.length ? h("div", { class:"bp-inlist" }, rowsN) : h("p", { class:"bp-empty" }, "No calls or documents on this project yet. Add a discovery call, or a document they shared (price list, intake form, spreadsheet)."),
h("div", { class:"bp-add" }, file,
h("button", { class:"bp-addbtn", disabled:!!busy, onclick:() => file.click() }, icon(busy ? "clock" : "upload", "sm"), busy || "Add a document or a Zoom transcript (.vtt)"),
h("button", { class:"bp-addbtn", onclick:() => addCallModal(p) }, icon("plus", "sm"), "Add a call")),
h("div", { class:"bp-fld" }, h("label", { for:taId }, "Anything to focus on? (optional)"), ta, micButton(taId, t => { wfSt.draft[key] = t; })),
h("div", { class:"bp-go" },
h("div", { class:"grow" }, h("b", null, active ? "Running · see the right" : action.charAt(0).toUpperCase() + action.slice(1)),
h("span", null, none ? "Add a call or a document first." : "About " + (mins >= 60 ? Math.floor(mins / 60) + " h " + (mins % 60) + " min" : mins + " min") + " · ~$" + (est % 1 ? est.toFixed(2) : est) + " estimate (" +
(n ? n + " × Call Reader + " : "") + "Workflow Mapper)")),
h("button", { class:"btn primary bp-start", disabled:none || active, onclick:() => wfStart(p, wfSt.draft[key]) }, active ? "Running" : upd ? "Update" : "Start")));
}
/* ---------- the workflow ---------- */
function wfView(p, vers){
const idx = Math.max(0, Math.min(vers.length - 1, wfSt.ver[p.id] != null ? wfSt.ver[p.id] : vers.length - 1)), v = vers[idx], latest = vers[vers.length - 1];
const t = wfTask(p), names = bpNames(p), active = bpActive(t);
const latestIds = (Array.isArray(latest.inputs) ? latest.inputs : []).map(x => x && x.source_id);
const fresh = bpCalls(p).concat(bpDocs(p)).filter(s => latestIds.indexOf(s.id) === -1), open = wfOpenNotes(p);
const lv = "v" + (latest.v || vers.length);
const what = [fresh.length ? plural(fresh.length, "new input") + " (" + fresh.map(s => names[s.id].name.split(" · ")[0] + ", " + bpShortDate(s.occurred_at)).join("; ") + ")" : "",
open.length ? plural(open.length, "open note") : ""].filter(Boolean).join(" and ");
const banner = what && !active ? inputsBanner(p, { key:"wf:" + p.id, since:lv, fresh, names, extra:open.length ? plural(open.length, "open note") : "",
tail:"The diagram doesn't include " + (fresh.length + open.length > 1 ? "them" : "it") + " yet.", label:"Update workflow", start:n => wfStart(p, n), busy:active }) : null;
const c = bpLoad(bpPath(v, "json")), bar = wfVersionBar(p, vers, v, idx);
let main, rail;
if(c.loading){ main = [bar, h("section", { class:"cw-card" }, h("div", { class:"mskel" }, h("i"), h("i"), h("i", { style:"width:60%" })), h("span", { class:"bp-meta" }, "Loading the workflow…"))]; rail = null; }
else if(c.err){ main = [bar, h("section", { class:"cw-card" }, h("p", { class:"bp-empty" }, c.err.replace(/blueprint/g, "workflow")),
h("button", { class:"btn sm", style:"align-self:flex-start", onclick:() => { delete bpSt.json[bpPath(v, "json")]; scheduleRender(); } }, "Try again"))]; rail = null; }
else {
const w = c.data, k = WFG.counts(w), view = wfSt.view[p.id] || "diagram", filt = wfSt.filter[p.id] || "all";
const segBtn = (key, cur, label, set) => h("button", { type:"button", "aria-pressed":String(cur === key), onclick:() => { set(key); render(); } }, label);
main = [bar,
Array.isArray(w.changes) && w.changes.length ? h("div", { class:"wf-changes" }, h("b", null, "What changed in v" + (v.v || idx + 1) + ":"), w.changes.map(x => h("span", { class:"wf-chip" }, x))) : null,
w.one_liner ? h("p", { class:"wf-one" }, w.one_liner) : null,
h("div", { class:"tiles bp-tiles" }, tile(k.steps, "Steps"), tile(k.records, "Records they keep"), tile(k.pains, "Pains the client named", k.pains ? "bad" : ""),
tile(k.suspected, "Suspected, to confirm", k.suspected ? "warn" : ""), tile(k.questions, "Questions")),
h("div", { class:"wf-bar" },
h("div", { class:"seg", role:"group", "aria-label":"View" },
[["diagram","Diagram"],["mind","Mind map"],["pains","Pain points"],["questions","Questions"]].map(x => segBtn(x[0], view, x[1], k2 => wfSt.view[p.id] = k2))),
view === "diagram" ? wfFutureSwitch(p, v, w) : null,
view === "diagram" ? h("div", { class:"seg", role:"group", "aria-label":"Show" },
[["all","Everything"],["pains","Only where it hurts"]].map(x => segBtn(x[0], filt, x[1], k2 => wfSt.filter[p.id] = k2))) : null),
view === "diagram" || view === "mind" ? h("div", { class:"wf-bar" }, wfLegend(), wfZoomBar(p)) : null,
view === "diagram" ? wfCanvas(p, w, WFG.diagramSvg(wfShown(p, v, w), { interactive:true, selected:wfSt.sel[p.id], filter:filt, openNotes:wfNoteCounts(p) }), "wf-diagram")
: view === "mind" ? wfCanvas(p, w, WFG.mindSvg(w, { interactive:true }), "wf-mind")
: view === "pains" ? wfPainTable(p, w, v, names) : wfQuestionTable(w)];
rail = [wfDetail(p, wfShown(p, v, w), v, names), wfNotesCard(p, w), wfBuiltFrom(p, v, w, names)];
}
return h("div", { class:"bp" }, active ? wfCrewNotes(p) : null, banner,
active || (t && t.status === "Blocked") ? bpRunCard(p, t, true, WF_RUN) : null,
h("div", { class:"bp-cols" + (wfSt.wide[p.id] ? " wf-wide" : "") }, h("div", { class:"bp-main" }, main), rail ? h("aside", { class:"bp-rail wf-rail" }, rail) : null));
}
/* the Future Workflow overlay (SPEC 1i) for the shown version: the latest one built on it, else the latest at all */
function wfFutures(p){ return Array.isArray(p.workflow_futures) ? p.workflow_futures.filter(x => x && typeof x === "object") : []; }
function wfFutureFor(p, v){
const fs = wfFutures(p), rec = fs.filter(x => x.base_v === v.v).pop() || null;
if(!rec) return null;
const c = bpLoad(bpPath(rec, "json"));
return c && c.data ? { rec, f:c.data } : { rec, loading:!!(c && c.loading), err:c && c.err };
}
/* the workflow to draw: today's, or with the future overlay applied when the switch is on */
function wfShown(p, v, w){
const fu = wfSt.future[p.id] ? wfFutureFor(p, v) : null;
return fu && fu.f ? WFG.applyFuture(w, fu.f) : w;
}
function wfFutureSwitch(p, v, w){
const fu = wfFutureFor(p, v), on = !!wfSt.future[p.id] && !!(fu && fu.f), sp = spVersions(p), t = wfTask(p), active = bpActive(t);
const k = fu && fu.f ? WFG.futureCounts(w, fu.f) : null;
if(!fu) return sp.length ? h("button", { class:"btn sm", disabled:active, title:"The Workflow Mapper draws this map with Fieldproxy in place, from the spec's requirements", onclick:() => wfStart(p, "", true) }, active ? "Drawing…" : "Draw the Future Workflow") : null;
return h("div", { class:"wf-fut" },
h("div", { class:"seg", role:"group", "aria-label":"Today or with Fieldproxy" },
h("button", { type:"button", "aria-pressed":String(!on), onclick:() => { wfSt.future[p.id] = false; render(); } }, "Today"),
h("button", { type:"button", "aria-pressed":String(on), onclick:() => { wfSt.future[p.id] = true; render(); } }, "With Fieldproxy")),
k ? h("span", { class:"wf-fut-k" }, k.fixed_red + " of " + k.red + " red pains fixed" + (k.later ? " · " + k.later + " later" : "") + (k.added ? " · " + k.added + " new" : "") + (k.removed ? " · " + k.removed + " gone" : "")) : h("span", { class:"faint" }, fu.loading ? "Loading…" : fu.err || ""),
h("button", { class:"btn sm ghost", disabled:active, onclick:() => wfStart(p, "", true) }, active ? "Drawing…" : "Redraw"));
}
function wfNoteCounts(p){ const m = {}; wfOpenNotes(p).forEach(n => { if(n.on) m[n.on] = (m[n.on] || 0) + 1; }); return m; }
function wfLegend(){
return h("div", { class:"wf-legend" },
h("span", null, h("i", { style:"border-color:var(--red)" }), "Breaks today (client said so)"),
h("span", null, h("i", { style:"border-color:var(--gold);border-top-style:dashed" }), "We suspect it breaks"),
h("span", null, h("i", { style:"border-color:#B9B1C4" }), "Works"),
h("span", null, h("b", { class:"wf-k" }), "Step"), h("span", null, h("b", { class:"wf-k rec" }), "Record they keep"), h("span", null, h("b", { class:"wf-k tool" }), "Tool they use today"));
}
/* the drawing at a chosen zoom: "fit" shows the whole width, a number is a scale (1 = 100%).
Drag the background to pan; a click that didn't move selects a box or arrow. */
const WF_ZMIN = 0.2, WF_ZMAX = 2.5;
/* the scale a zoom choice means for this box: "fit" = the whole width; "auto" (nothing chosen yet) = fit,
unless that's too small to read, then 80% and scroll; a number is the scale itself */
function wfScaleFor(z, box, W){
const fit = Math.min(1, Math.max(320, box.clientWidth - 2) / W);
return { s:z === "fit" ? fit : z === "auto" ? (fit >= 0.6 ? fit : 0.8) : +z, fit };
}
/* size the drawing in place (no redraw), keeping the point under `at` (client x/y) where it was */
function wfApplyZoom(p, z, at){
const box = document.querySelector('.wf-canvas[data-pid="' + p.id + '"]'), el = box && box.querySelector("svg");
if(!el) return;
const vb = (el.getAttribute("viewBox") || "").split(/\s+/).map(Number), W = vb[2] || 1000, H = vb[3] || 600;
const { s, fit } = wfScaleFor(z, box, W), s0 = +box.dataset.s || s;
const r = box.getBoundingClientRect(), ax = at ? at.x - r.left : box.clientWidth / 2, ay = at ? at.y - r.top : box.clientHeight / 2;
const cx = (box.scrollLeft + ax) / s0, cy = (box.scrollTop + ay) / s0;
el.setAttribute("width", Math.round(W * s)); el.setAttribute("height", Math.round(H * s));
box.dataset.s = String(s);
if(Math.abs(s - s0) > 1e-6){ box.scrollLeft = cx * s - ax; box.scrollTop = cy * s - ay; }
const txt = (z === "fit" || (z === "auto" && s === fit) ? "Fit · " : "") + Math.round(s * 100) + "%";
document.querySelectorAll('[data-zoom-label="' + p.id + '"]').forEach(x => x.textContent = txt);
wfMiniSync(p);
}
function wfZoomBy(p, factor, at){
const box = document.querySelector('.wf-canvas[data-pid="' + p.id + '"]'); if(!box) return;
const now = +box.dataset.s || 1, next = Math.min(WF_ZMAX, Math.max(WF_ZMIN, Math.round(now * factor * 100) / 100));
wfSt.zoom[p.id] = next; wfApplyZoom(p, next, at);
}
function wfZoomSet(p, z){ wfSt.zoom[p.id] = z; wfApplyZoom(p, z); }
/* the drawing: drag the background to pan, pinch or ⌘/Ctrl + scroll to zoom around the pointer,
+ / − / Fit on the drawing itself; a click that didn't move selects a box or arrow */
function wfCanvas(p, w, svg, key){
const pickId = e => { const g = e.target && e.target.closest ? e.target.closest("[data-id]") : null; return g ? g.getAttribute("data-id") : null; };
let drag = null, moved = false;
const box = h("div", { class:"wf-canvas", "data-sk":key + "-" + p.id, "data-pid":p.id, html:svg, tabindex:"-1",
onpointerdown:e => { if(e.button !== 0) return; drag = { x:e.clientX, y:e.clientY, l:box.scrollLeft, t:box.scrollTop }; moved = false; },
onpointermove:e => { if(!drag) return; const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
if(!moved && Math.abs(dx) + Math.abs(dy) < 5) return;
if(!moved){ moved = true; box.classList.add("panning"); try { box.setPointerCapture(e.pointerId); } catch(x){} }
box.scrollLeft = drag.l - dx; box.scrollTop = drag.t - dy; },
onpointerup:e => { drag = null; box.classList.remove("panning"); try { box.releasePointerCapture(e.pointerId); } catch(x){} },
onpointercancel:() => { drag = null; box.classList.remove("panning"); },
onwheel:e => { if(!e.ctrlKey && !e.metaKey) return; e.preventDefault(); // a trackpad pinch arrives as ctrl + wheel
wfZoomBy(p, Math.exp(-e.deltaY * (e.deltaMode === 1 ? 0.05 : 0.0025)), { x:e.clientX, y:e.clientY }); },
onscroll:() => wfMiniSync(p),
onclick:e => { if(moved){ moved = false; return; } const id = pickId(e); if(key === "wf-fs") wfFsFreeze(p); wfSt.sel[p.id] = id; if(id && key === "wf-fs") wfSt.center[p.id] = id; render(); },
onkeydown:e => {
if(e.key === "+" || e.key === "="){ e.preventDefault(); wfZoomBy(p, 1.2); return; }
if(e.key === "-" || e.key === "_"){ e.preventDefault(); wfZoomBy(p, 1 / 1.2); return; }
if(e.key !== "Enter" && e.key !== " ") return; const id = pickId(e); if(!id) return; e.preventDefault(); wfSt.sel[p.id] = id; render(); } });
const ctl = h("div", { class:"wf-zctl", role:"group", "aria-label":"Zoom" },
h("button", { type:"button", title:"Zoom in", "aria-label":"Zoom in", onclick:() => wfZoomBy(p, 1.25) }, "+"),
h("span", { class:"wf-zctl-v", "data-zoom-label":p.id }, ""),
h("button", { type:"button", title:"Zoom out", "aria-label":"Zoom out", onclick:() => wfZoomBy(p, 1 / 1.25) }, "−"),
h("button", { type:"button", class:"fit", title:"Fit the whole map", "aria-label":"Fit the whole map", onclick:() => wfZoomSet(p, "fit") }, "Fit"));
const wrap = h("div", { class:"cw-card flush wf-canvas-wrap" }, box, ctl);
const first = () => { wfApplyZoom(p, wfSt.zoom[p.id] || "auto");
const c = wfSt.center[p.id]; if(c && wfSt.fsw[p.id]){ delete wfSt.center[p.id]; wfCenterOn(p, wfSt.fsw[p.id], c); } };
requestAnimationFrame(first); // sized once the box is on the page and has its real width
return wrap;
}
function wfZoomBar(p){
const wide = !!wfSt.wide[p.id];
return h("div", { class:"wf-zoom", role:"group", "aria-label":"Zoom and layout" },
h("button", { type:"button", class:"btn sm", title:"Zoom out", "aria-label":"Zoom out", onclick:() => wfZoomBy(p, 1 / 1.25) }, "−"),
h("span", { class:"wf-zoom-v", "data-zoom-label":p.id }, ""),
h("button", { type:"button", class:"btn sm", title:"Zoom in", "aria-label":"Zoom in", onclick:() => wfZoomBy(p, 1.25) }, "+"),
h("button", { type:"button", class:"btn sm", onclick:() => wfZoomSet(p, "fit") }, "Fit"),
h("button", { type:"button", class:"btn sm", onclick:() => wfZoomSet(p, 1) }, "100%"),
h("button", { type:"button", class:"btn sm" + (wide ? " on" : ""), title:"Use the whole width; the side panel moves below", onclick:() => { wfSt.wide[p.id] = !wide; render(); } }, wide ? "Side panel" : "Full width"),
h("button", { type:"button", class:"btn sm primary", onclick:() => go("projects/" + p.id + "/workflow/map") }, icon("external", "xs"), "Open full screen"),
h("span", { class:"wf-hint" }, "Drag to move · pinch or ⌘ + scroll to zoom"));
}
function wfVersionBar(p, vers, v, idx){
const run = v.run_id ? byId("agent_runs", v.run_id) : null, cost = run && run.cost_usd != null ? bpMoney(run.cost_usd) : "";
const ins = Array.isArray(v.inputs) ? v.inputs : [], nc = ins.filter(x => x && x.kind === "call").length, nd = ins.filter(x => x && x.kind === "document").length;
const from = ins.length ? "Built from " + [nc ? plural(nc, "call") : "", nd ? (nd === 1 ? "1 document" : nd + " documents") : ""].filter(Boolean).join(" + ") : "";
const open = k => { const path = bpPath(v, k); if(!path){ toast("This version has no " + (k === "client_html" ? "client" : "internal") + " page"); return; }
if(!needSb()){ toast("Sign in to Supabase in Settings to open it"); return; }
openReportTab({ path, title:p.name + " · workflow v" + (v.v || idx + 1) + (k === "client_html" ? " · client" : " · internal") }); };
return h("section", { class:"cw-card bp-vbar" },
h("div", { class:"bp-vbar-t" }, h("b", null, "Current workflow v" + (v.v || idx + 1)),
h("span", null, [from, v.at ? fmtStamp(v.at) : "", "Workflow Mapper", cost, Array.isArray(v.notes_applied) && v.notes_applied.length ? "applied " + v.notes_applied.join(", ") : ""].filter(Boolean).join(" · "))),
h("label", { class:"sr", for:"wf-ver-" + p.id }, "Version"),
(() => { const s = sel(vers.map((x, i) => [String(i), "v" + (x.v || i + 1) + " · " + bpShortDate(x.at)]), String(idx), val => { wfSt.ver[p.id] = +val; wfSt.sel[p.id] = null; render(); }, "bp-sel"); s.id = "wf-ver-" + p.id; return s; })(),
h("button", { class:"btn sm", onclick:() => open("client_html") }, icon("external", "xs"), "Client version"),
h("button", { class:"btn sm", onclick:() => open("internal_html") }, icon("external", "xs"), "Internal version"),
h("button", { class:"btn sm primary", onclick:() => go("projects/" + p.id + "/workflow/map") }, icon("external", "xs"), "Open full screen"),
h("button", { class:"btn sm ghost", onclick:() => setQ({ build:1 }) }, "Update…"));
}
/* ---------- side rail: what's selected, the notes, what it was built from ---------- */
function wfPainBox(p, x, v, names){
return h("div", { class:"wf-pain" + (x.state === "suspected" ? " amb" : "") },
h("b", null, x.id + " · " + x.title),
h("span", { class:"wf-imp" }, x.impact),
x.fixed ? h("div", { class:"wf-fixed" }, h("b", null, "With Fieldproxy: fixed by " + (x.fixed.req_ids || []).join(", ")), h("span", null, x.fixed.how || "")) : null,
x.later ? h("div", { class:"wf-later" }, "Left for " + x.later.phase + (x.later.why ? ": " + x.later.why : "")) : null,
(x.evidence || []).map(e => h("div", { class:"wf-ev" }, e.quote ? h("q", null, e.quote) : null,
h("span", { class:"wf-src" }, e.speaker ? e.speaker + " · " : "", bpChip(p, e, names, v)))));
}
function wfDetail(p, w, v, names, o){
o = o || {}; /* o.fs: the full-screen map's slide-in panel */
const id = wfSt.sel[p.id], by = WFG.index(w), x = id ? by[id] : null;
const pick = nid => { if(o.fs) wfFsFreeze(p); wfSt.sel[p.id] = nid; if(o.fs) wfSt.center[p.id] = nid; render(); };
if(!x) return h("section", { class:"cw-card wf-detail" }, h("span", { class:"cw-eyebrow" }, "Details"),
h("p", { class:"bp-empty" }, "Click any box or arrow to see what happens there, what goes wrong, and the client's own words."));
const out = [];
if(x.from){
const st = x.state === "pain" ? ["bad","Breaks today"] : x.state === "suspected" ? ["warn","We suspect it breaks"] : ["","Works today"];
out.push(h("span", { class:"cw-eyebrow" }, "Handoff · " + x.id), h("h3", { class:"wf-h" }, WFG.nameOf(w, x.from, by) + " → " + WFG.nameOf(w, x.to, by)),
h("div", { class:"wf-fields" }, x.label ? h("span", null, x.label) : null, h("span", { class:"wf-state " + st[0] }, st[1])));
} else {
const lane = (w.lanes || []).find(l => l.id === x.lane), stage = (w.stages || []).find(s => s.id === x.stage);
out.push(h("span", { class:"cw-eyebrow" }, (x.kind === "step" ? "Step · " + (lane ? lane.name : "") : x.kind === "record" ? "Record they keep" : "Tool they use today") + (stage ? " · " + stage.name : "") + " · " + x.id),
h("h3", { class:"wf-h" }, x.title));
if(x.detail) out.push(h("p", { class:"bp-note" }, x.detail));
if(Array.isArray(x.fields) && x.fields.length) out.push(h("div", { class:"wf-fields mono" }, x.fields.map(f => h("span", null, f))));
const links = (w.edges || []).filter(e => e.from === x.id || e.to === x.id);
if(links.length) out.push(h("div", { class:"wf-links" }, links.map(e => { const o = e.from === x.id;
return h("button", { type:"button", class:e.state !== "ok" ? "hurt" : "", onclick:() => pick(e.id) },
(o ? "→ " : "← ") + (e.label || "") + " · " + WFG.nameOf(w, o ? e.to : e.from, by) + (e.pain ? " (" + e.pain + ")" : "")); })));
}
if(Array.isArray(x.sources) && x.sources.length) out.push(h("div", { class:"sp-links" }, h("span", { class:"faint", style:"font-size:12.5px" }, "From:"),
x.sources.map(e => bpChip(p, e, names, v))));
const ps = x.from ? (x.pain && by[x.pain] ? [by[x.pain]] : []) : WFG.painsOn(w, x.id);
ps.forEach(pn => { out.push(wfPainBox(p, pn, v, names)); if(pn.question && by[pn.question]) out.push(h("p", { class:"bp-note" }, "To confirm: " + pn.question + " · " + by[pn.question].text)); });
if(!ps.length) out.push(h("p", { class:"bp-empty" }, "No pain reported here."));
wfNotes(p).filter(n => n.on === x.id).forEach(n => out.push(wfNoteRow(p, n)));
if(o.fs) out.push(wfFsNoteForm(p, x.id));
else out.push(h("button", { class:"btn sm", style:"align-self:flex-start", onclick:() => { wfSt.noteOn[p.id] = x.id; render(); setTimeout(() => { const ta = document.getElementById("wf-note-" + p.id); if(ta) ta.focus(); }, 30); } }, icon("plus", "xs"), "Add a note on this"));
if(o.fs) return h("div", { class:"wf-detail wf-fs-detail" }, out);
return h("section", { class:"cw-card wf-detail" }, out, h("button", { class:"btn sm ghost", style:"align-self:flex-start", onclick:() => { wfSt.sel[p.id] = null; render(); } }, "Clear selection"));
}
function wfNoteRow(p, n){
const st = n.status === "open" ? ["open", "Open · goes into the next update"] : n.status === "applied" ? ["applied", "Applied in v" + (n.applied_in || "?")] : ["dismissed", "Dismissed"];
return h("div", { class:"wf-note " + st[0] },
h("span", null, n.text),
n.how ? h("span", { class:"wf-how" }, "Changed: " + n.how) : null,
h("div", { class:"wf-note-m" }, h("span", null, n.id), n.on ? h("button", { type:"button", class:"lnk", onclick:() => { wfSt.sel[p.id] = n.on; wfSt.view[p.id] = "diagram"; render(); } }, "on " + n.on) : h("span", null, "whole workflow"),
h("span", null, (WF_SRC_LABEL[n.source] || n.source || "") + (n.at ? " · " + bpShortDate(n.at) : "")), h("span", { class:"wf-tag " + st[0] }, st[1]),
n.status === "open" ? h("button", { type:"button", class:"lnk", onclick:() => wfSetNote(p, n.id, "dismissed") }, "Dismiss")
: n.status === "dismissed" ? h("button", { type:"button", class:"lnk", onclick:() => wfSetNote(p, n.id, "open") }, "Reopen") : null));
}
function wfNotesCard(p, w, compact){
const notes = wfNotes(p), open = notes.filter(n => n.status === "open"), t = wfTask(p), active = bpActive(t);
const key = "note:" + p.id, taId = "wf-note-" + p.id;
const by = w ? WFG.index(w) : {}, ids = w ? (w.nodes || []).concat(w.edges || []) : [];
const on = h("select", { class:"sel wf-sel", id:"wf-on-" + p.id, onchange:e => { wfSt.noteOn[p.id] = e.target.value; } },
h("option", { value:"" }, "Whole workflow"), ids.map(x => h("option", { value:x.id, selected:wfSt.noteOn[p.id] === x.id }, x.id + " · " + bpCut(WFG.nameOf(w, x.id, by), 40))));
const src = h("select", { class:"sel wf-sel", id:"wf-src-" + p.id, onchange:e => { wfSt.noteSrc[p.id] = e.target.value; } },
WF_SRC.map(s => h("option", { value:s[0], selected:(wfSt.noteSrc[p.id] || "client_call") === s[0] }, s[1])));
const ta = h("textarea", { class:"ta bp-ta sm", id:taId, "data-live":"1", value:wfSt.draft[key] || "", placeholder:"e.g. Client says the office, not the crew, picks the chemical",
oninput:e => { wfSt.draft[key] = e.target.value; } });
const shown = compact ? open : notes.slice().reverse();
return h("section", { class:"cw-card wf-notes" },
h("div", { class:"bp-card-h" }, h("b", null, compact ? "Open notes" : "Notes and client feedback"), h("span", { class:"bp-meta" }, open.length ? plural(open.length, "open note") : "none open")),
shown.length ? shown.map(n => wfNoteRow(p, n)) : h("p", { class:"bp-empty" }, "When the client corrects something or you spot a gap, add a note. Update from notes redraws the workflow with them."),
compact ? null : h("div", { class:"wf-noteform" },
h("label", { for:taId, class:"sr" }, "Note"), ta, micButton(taId, t => { wfSt.draft[key] = t; }),
h("div", { class:"wf-row2" }, h("label", null, h("span", null, "Pinned to"), on), h("label", null, h("span", null, "From"), src)),
h("div", { class:"wf-row2 btns" },
h("button", { class:"btn sm primary", onclick:() => { if(wfAddNote(p, wfSt.draft[key], wfSt.noteOn[p.id] || "", wfSt.noteSrc[p.id] || "client_call")){ wfSt.draft[key] = ""; wfSt.noteOn[p.id] = ""; } } }, "Add note"),
h("button", { class:"btn sm", disabled:!open.length || active, title:active ? "A run is already going" : "",
onclick:() => bpUnread(p).length ? setQ({ build:1 }) : wfStart(p, "") }, active ? "Running…" : "Update from " + plural(open.length, "open note")))));
}
function wfBuiltFrom(p, v, w, names){
const ins = Array.isArray(v.inputs) ? v.inputs : [], cov = w.coverage || {};
return h("section", { class:"cw-card wf-from" }, h("div", { class:"bp-card-h" }, h("b", null, "Built from"), h("span", { class:"bp-meta" }, plural(ins.length, "input"))),
ins.map(x => { const k = cov[x.source_id] || {}, n = names[x.source_id];
return h("div", { class:"wf-in" }, h("span", { class:"grow" }, n ? n.name : x.title || x.source_id),
h("span", { class:"bp-meta", title:k.note || "" }, (+k.points || 0) ? plural(+k.points, "point") : "0 · " + (k.note || "no content"))); }));
}
/* ---------- lists ---------- */
function wfPainTable(p, w, v, names){
const by = WFG.index(w), ps = (w.pains || []).slice().sort((a, b) => (a.state === b.state ? 0 : a.state === "pain" ? -1 : 1) || ["high","med","low"].indexOf(a.severity) - ["high","med","low"].indexOf(b.severity));
if(!ps.length) return h("section", { class:"cw-card" }, h("p", { class:"bp-empty" }, "No pain points in this version."));
return h("section", { class:"cw-card flush" }, h("div", { class:"tbl-wrap" }, h("table", { class:"wf-tbl" },
h("thead", null, h("tr", null, ["Pain", "Where", "Cost to them", "Evidence", "Status"].map(x => h("th", null, x)))),
h("tbody", null, ps.map(x => h("tr", null,
h("td", null, h("b", null, x.id + " · " + x.title)),
h("td", null, h("button", { type:"button", class:"lnk", onclick:() => { wfSt.sel[p.id] = x.on; wfSt.view[p.id] = "diagram"; render(); } }, WFG.nameOf(w, x.on, by))),
h("td", null, x.impact),
h("td", null, (x.evidence || []).slice(0, 2).map(e => h("div", { class:"wf-ev" }, e.quote ? h("q", null, e.quote) : null, h("span", { class:"wf-src" }, e.speaker ? e.speaker + " · " : "", bpChip(p, e, names, v))))),
h("td", null, h("span", { class:"wf-state " + (x.state === "pain" ? "bad" : "warn") }, x.state === "pain" ? "Client said so" : "Suspected"))))))));
}
function wfQuestionTable(w){
const qs = w.questions || [];
if(!qs.length) return h("section", { class:"cw-card" }, h("p", { class:"bp-empty" }, "No open questions in this version."));
return h("section", { class:"cw-card flush" }, h("div", { class:"tbl-wrap" }, h("table", { class:"wf-tbl" },
h("thead", null, h("tr", null, ["Question", "Why it matters", "Blocks", "Ask"].map(x => h("th", null, x)))),
h("tbody", null, qs.map(q => h("tr", null, h("td", null, h("b", null, q.id + " · " + q.text)), h("td", null, q.why),
h("td", null, q.blocks ? "Yes" : "No"), h("td", null, q.ask === "anand" ? "You" : "Client")))))));
}
/* ---------- the full-screen map (#/projects//workflow/map) ----------
The map fills the screen; details slide in from the right only when something is picked;
"Next pain" walks every red and amber mark in order, centring each; search jumps to a box;
a small overview shows where you are. Esc closes the panel, then leaves. */
/* the panel docks beside the map and narrows it: keep the scale the reader is looking at */
function wfFsFreeze(p){
const box = document.querySelector('.wf-canvas[data-pid="' + p.id + '"]'), z = wfSt.zoom[p.id] || "auto";
if(box && box.dataset.s && (z === "auto" || z === "fit")) wfSt.zoom[p.id] = +box.dataset.s;
}
function wfFsExit(p){ go("projects/" + p.id + "/workflow"); }
function wfFsPains(w){ return (w.pains || []).slice().sort((a, b) => (+String(a.id).replace(/\D/g, "") || 0) - (+String(b.id).replace(/\D/g, "") || 0)); }
function wfFsStep(p, d){
const w = wfSt.fsw[p.id]; if(!w) return;
const ps = wfFsPains(w); if(!ps.length) return;
const cur = wfSt.pi[p.id] == null ? (d > 0 ? -1 : 0) : wfSt.pi[p.id];
const i = ((cur + d) % ps.length + ps.length) % ps.length;
wfFsFreeze(p); wfSt.pi[p.id] = i; wfSt.sel[p.id] = ps[i].on; wfSt.center[p.id] = ps[i].on; render();
}
/* scroll so a box or arrow sits in the middle of the visible map (left of the panel when it's open) */
function wfCenterOn(p, w, id){
const box = document.querySelector('.wf-canvas[data-pid="' + p.id + '"]'); if(!box) return;
const L = WFG.layout(w), by = WFG.index(w), x = by[id]; if(!x) return;
const mid = n => { const q = L.pos[n]; return q ? { x:q.x + q.w / 2, y:q.y + q.h / 2 } : null; };
let c = null;
if(x.from){ const a = mid(x.from), z = mid(x.to); if(a && z) c = { x:(a.x + z.x) / 2, y:(a.y + z.y) / 2 }; } else c = mid(id);
if(!c) return;
const s = +box.dataset.s || 1, vis = box.clientWidth;
box.scrollTo({ left:Math.max(0, c.x * s - vis / 2), top:Math.max(0, c.y * s - box.clientHeight / 2), behavior:"smooth" });
}
function wfFsNoteForm(p, on){
const key = "fsnote:" + p.id + ":" + on, taId = "wf-fsnote-" + p.id;
const ta = h("textarea", { class:"ta bp-ta sm", id:taId, "data-live":"1", value:wfSt.draft[key] || "", placeholder:"Correction or client feedback about this…",
oninput:e => { wfSt.draft[key] = e.target.value; } });
const src = h("select", { class:"sel wf-sel", onchange:e => { wfSt.noteSrc[p.id] = e.target.value; } },
WF_SRC.map(s => h("option", { value:s[0], selected:(wfSt.noteSrc[p.id] || "client_call") === s[0] }, s[1])));
return h("div", { class:"wf-fs-note" }, h("label", { for:taId }, "Add a note on this"), ta, micButton(taId, t => { wfSt.draft[key] = t; }),
h("div", { class:"wf-row2 btns" }, src,
h("button", { class:"btn sm primary", onclick:() => { if(wfAddNote(p, wfSt.draft[key], on, wfSt.noteSrc[p.id] || "client_call")) wfSt.draft[key] = ""; } }, "Add note")));
}
/* the overview: the whole map small, with a frame for what's on screen; click to jump */
function wfMini(p, w){
const L = WFG.layout(w), k = Math.min(220 / L.W, 150 / L.H), MW = Math.round(L.W * k), MH = Math.round(L.H * k);
const vp = h("div", { class:"wf-mini-vp", id:"wf-mini-vp-" + p.id });
const m = h("div", { class:"wf-mini", id:"wf-mini-" + p.id, title:"Click to jump there", style:"width:" + MW + "px;height:" + MH + "px",
html:WFG.diagramSvg(w, { internal:true, filter:wfSt.filter[p.id] || "all" }),
onclick:e => { const box = document.querySelector('.wf-canvas[data-pid="' + p.id + '"]'); if(!box) return;
const r = m.getBoundingClientRect(), fx = (e.clientX - r.left) / r.width, fy = (e.clientY - r.top) / r.height;
box.scrollTo({ left:fx * box.scrollWidth - box.clientWidth / 2, top:fy * box.scrollHeight - box.clientHeight / 2, behavior:"smooth" }); } }, vp);
const el = m.querySelector("svg"); if(el){ el.setAttribute("width", MW); el.setAttribute("height", MH); }
return m;
}
function wfMiniSync(p){
const box = document.querySelector('.wf-canvas[data-pid="' + p.id + '"]'), vp = document.getElementById("wf-mini-vp-" + p.id), m = document.getElementById("wf-mini-" + p.id);
if(!box || !vp || !m || !box.scrollWidth) return;
const W = m.clientWidth, H = m.clientHeight;
vp.style.left = (box.scrollLeft / box.scrollWidth * W) + "px"; vp.style.top = (box.scrollTop / box.scrollHeight * H) + "px";
vp.style.width = Math.min(W, box.clientWidth / box.scrollWidth * W) + "px"; vp.style.height = Math.min(H, box.clientHeight / box.scrollHeight * H) + "px";
}
function wfFsSearch(p, w){
const res = h("div", { class:"wf-fs-res", hidden:true });
const pickIt = id => { wfFsFreeze(p); wfSt.sel[p.id] = id; wfSt.center[p.id] = id; res.hidden = true; inp.value = ""; inp.blur(); render(); };
const inp = h("input", { class:"wf-fs-q", type:"search", placeholder:"Find a step, record or tool… ( / )", "aria-label":"Find on the map", id:"wf-fs-q-" + p.id,
oninput:e => {
const q = e.target.value.trim().toLowerCase(); res.textContent = "";
if(!q){ res.hidden = true; return; }
const hits = (w.nodes || []).filter(n => [n.title, n.detail, (n.fields || []).join(" ")].join(" ").toLowerCase().indexOf(q) > -1)
.concat((w.pains || []).filter(x => [x.title, x.impact].join(" ").toLowerCase().indexOf(q) > -1).map(x => ({ id:x.on, title:x.id + " · " + x.title, pain:true }))).slice(0, 10);
if(!hits.length) res.append(h("div", { class:"wf-fs-none" }, "Nothing matches “" + e.target.value.trim() + "”"));
hits.forEach(x => res.append(h("button", { type:"button", onclick:() => pickIt(x.id) }, h("span", { class:"wf-fs-k" + (x.pain ? " hurt" : "") }, x.pain ? "Pain" : x.kind === "record" ? "Record" : x.kind === "tool" ? "Tool" : "Step"), x.title)));
res.hidden = false;
},
onkeydown:e => { if(e.key === "Enter"){ const b = res.querySelector("button"); if(b){ e.preventDefault(); b.click(); } } if(e.key === "Escape"){ e.stopPropagation(); inp.value = ""; res.hidden = true; inp.blur(); } } });
return h("div", { class:"wf-fs-search" }, inp, res);
}
function wfFullscreen(p, vers){
const idx = Math.max(0, Math.min(vers.length - 1, wfSt.ver[p.id] != null ? wfSt.ver[p.id] : vers.length - 1)), v = vers[idx];
const c = bpLoad(bpPath(v, "json")), acc = byId("accounts", p.account_id) || {};
const back = h("button", { class:"btn sm", onclick:() => wfFsExit(p) }, icon("left", "xs"), "Workflow");
if(c.loading || c.err) return h("div", { class:"wf-fs", "data-pid":p.id }, h("div", { class:"wf-fs-top" }, back, h("b", { class:"wf-fs-t" }, p.name)),
h("div", { class:"wf-fs-msg" }, c.loading ? "Loading the workflow…" : String(c.err).replace(/blueprint/g, "workflow")));
const w0 = c.data, w = wfShown(p, v, w0), names = bpNames(p), filt = wfSt.filter[p.id] || "all", sel = wfSt.sel[p.id];
wfSt.fsw[p.id] = w;
const ps = wfFsPains(w), pi = wfSt.pi[p.id], cur = pi != null && ps[pi] ? ps[pi] : null;
const red = ps.filter(x => x.state === "pain").length, amb = ps.length - red;
const stepper = h("div", { class:"wf-fs-step", role:"group", "aria-label":"Walk the pain points" },
h("button", { type:"button", class:"btn sm", title:"Previous pain ( [ )", "aria-label":"Previous pain", disabled:!ps.length, onclick:() => wfFsStep(p, -1) }, "‹"),
h("span", { class:"wf-fs-step-t" + (cur ? (cur.state === "pain" ? " hurt" : " maybe") : "") },
cur ? cur.id + " · " + (pi + 1) + " of " + ps.length : ps.length ? plural(ps.length, "pain") + " to check · " + red + " red, " + amb + " amber" : "No pains on this map"),
h("button", { type:"button", class:"btn sm primary", title:"Next pain ( ] )", disabled:!ps.length, onclick:() => wfFsStep(p, 1) }, cur ? "Next ›" : "Start ›"));
const show = h("div", { class:"seg", role:"group", "aria-label":"Show" },
[["all","Everything"],["pains","Only where it hurts"]].map(x => h("button", { type:"button", "aria-pressed":String(filt === x[0]), onclick:() => { wfSt.filter[p.id] = x[0]; render(); } }, x[1])));
const top = h("div", { class:"wf-fs-top" }, back,
h("div", { class:"wf-fs-title" }, h("b", null, acc.name || p.name), h("span", null, "Current workflow v" + (v.v || idx + 1))),
stepper, wfFsSearch(p, w), h("span", { class:"grow" }), wfFutureSwitch(p, v, w0), show,
h("button", { class:"btn sm ghost", title:"Keys: [ ] previous/next pain · / find · + − zoom · Esc close", onclick:() => toast("Keys: ] next pain · [ previous · / find · + and − zoom · Esc closes the panel, then the map") }, "Keys"));
const canvas = wfCanvas(p, w, WFG.diagramSvg(w, { interactive:true, selected:sel, filter:filt, openNotes:wfNoteCounts(p) }), "wf-fs");
const panel = sel && WFG.index(w)[sel] ? h("aside", { class:"wf-fs-panel", "aria-label":"Details" },
h("div", { class:"wf-fs-panel-h" }, h("span", { class:"grow" }), h("button", { class:"btn sm ghost", title:"Close (Esc)", "aria-label":"Close details", onclick:() => { wfFsFreeze(p); wfSt.sel[p.id] = null; render(); } }, icon("x", "xs"))),
wfDetail(p, w, v, names, { fs:true })) : null;
wfFsKeys();
return h("div", { class:"wf-fs", "data-pid":p.id, role:"region", "aria-label":"Current workflow, full screen" }, top,
h("div", { class:"wf-fs-body" }, canvas, wfMini(p, w), panel));
}
function wfFsKeys(){
if(window.__wfFsKeys) return; window.__wfFsKeys = true;
document.addEventListener("keydown", e => {
const fs = document.querySelector(".wf-fs"); if(!fs) return;
const p = byId("projects", fs.getAttribute("data-pid")); if(!p) return;
const t = (document.activeElement || {}).tagName || "", typing = /INPUT|TEXTAREA|SELECT/.test(t) || (document.activeElement && document.activeElement.isContentEditable);
if(e.key === "Escape"){ if(typing) return; e.preventDefault(); if(wfSt.sel[p.id]){ wfSt.sel[p.id] = null; render(); } else wfFsExit(p); return; }
if(typing || e.metaKey || e.ctrlKey || e.altKey) return;
if(e.key === "]" || e.key === "n"){ e.preventDefault(); wfFsStep(p, 1); }
else if(e.key === "[" || e.key === "p"){ e.preventDefault(); wfFsStep(p, -1); }
else if(e.key === "/"){ const q = document.getElementById("wf-fs-q-" + p.id); if(q){ e.preventDefault(); q.focus(); } }
else if(e.key === "+" || e.key === "="){ e.preventDefault(); wfZoomBy(p, 1.25); }
else if(e.key === "-" || e.key === "_"){ e.preventDefault(); wfZoomBy(p, 1 / 1.25); }
});
}
/* =====================================================================
Sources → Documents (11 Oct 2026 · docs/crew/SPEC.md §1g · plan
"Orbit Requirements Spec" v2). The project's Mail & calls tab is now
Sources: calls and emails as before, plus the client's documents grouped
by the folders they shared them in, each with the folder's note, the
Document Reader's summary, how useful it is and what it fed. Import a
whole folder from the browser; "Read N documents" queues the Document
Reader. Nothing is read until you click.
===================================================================== */
const docSt = { view:{}, open:{}, busy:{}, noteEdit:{}, noteDraft:{}, skipped:{}, into:{}, readBox:{}, readNotes:{} }; // per viewer, this tab only
const DOC_EXT = { pdf:"PDF", doc:"Word", docx:"Word", rtf:"Text", txt:"Text", md:"Text", csv:"Sheet", xls:"Sheet", xlsx:"Sheet", xlsm:"Sheet",
ppt:"Slides", pptx:"Slides", png:"Image", jpg:"Image", jpeg:"Image", webp:"Image", gif:"Image", heic:"Image" };
const DOC_TYPE = { pdf:"application/pdf", doc:"application/msword", docx:"application/vnd.openxmlformats-officedocument.wordprocessingml.document", rtf:"application/rtf",
txt:"text/plain", md:"text/markdown", csv:"text/csv", xls:"application/vnd.ms-excel", xlsx:"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
xlsm:"application/vnd.ms-excel.sheet.macroEnabled.12", ppt:"application/vnd.ms-powerpoint", pptx:"application/vnd.openxmlformats-officedocument.presentationml.presentation",
png:"image/png", jpg:"image/jpeg", jpeg:"image/jpeg", webp:"image/webp", gif:"image/gif", heic:"image/heic" };
const DOC_MAX = 15 * 1024 * 1024;
const DOC_USE = { high:["Very useful", "jade"], med:["Somewhat useful", ""], low:["Not much use", "faint"] };
/* ---------- data ---------- */
function docList(p){ return rows("sources", s => s.project_id === p.id && s.kind === "document").sort(by(s => String(s.doc_path || s.subject || "").toLowerCase())); }
function docDigest(s){ return s && s.digest && typeof s.digest === "object" ? s.digest : null; }
function docGroupOf(s){ const dp = String(s.doc_path || ""); return dp.indexOf("/") > -1 ? dp.split("/")[0] : ""; }
function docNotes(p){ return p.doc_folders && typeof p.doc_folders === "object" && !Array.isArray(p.doc_folders) ? p.doc_folders : {}; }
function docTask(p){ return rows("tasks", t => t.project_id === p.id && t.agent === "document-reader" && t.kind === "documents").sort(by(t => t.created_at || "")).pop() || null; }
function docExt(s){ return String(s.file || s.subject || "").split(".").pop().toLowerCase(); }
function docProposals(s){ return rows("proposals", x => x.source_id === s.id && x.status !== "rejected").sort(by(x => (x.status === "pending" ? "0" : "1") + (x.created_at || ""))); }
/* where this document shows up in the latest Current Workflow: boxes and arrows that cite it, pains whose evidence is it */
function docFeeds(p, s){
if(typeof wfVersions !== "function") return null;
const vs = wfVersions(p); if(!vs.length) return null;
const c = bpLoad(bpPath(vs[vs.length - 1], "json")); if(!c || !c.data) return null;
const w = c.data, hit = x => Array.isArray(x.sources) && x.sources.some(y => y && y.source_id === s.id);
const items = (w.nodes || []).filter(hit).map(n => [n.id, n.title]).concat((w.edges || []).filter(hit).map(e => [e.id, e.label || e.id]),
(w.pains || []).filter(x => (x.evidence || []).some(e => e && e.source_id === s.id)).map(x => [x.id, x.title]));
if(!items.length) return h("p", { class:"faint", style:"font-size:12.5px;margin:0" }, "Not cited on the workflow yet (it is from the next workflow update on).");
return h("div", { class:"sp-links" }, h("span", { class:"faint", style:"font-size:12.5px" }, "On the workflow:"),
items.slice(0, 14).map(x => h("a", { class:"chip", href:"#/projects/" + p.id + "/workflow", onclick:() => { if(typeof wfSt !== "undefined") wfSt.sel[p.id] = x[0]; } }, x[0] + " · " + bpCut(x[1], 30))),
items.length > 14 ? h("span", { class:"faint" }, "+" + (items.length - 14)) : null);
}
function docName(s){ const dp = String(s.doc_path || s.subject || s.file || "document"); return dp.split("/").pop(); }
/* ---------- Sources: Mail & calls, or Documents ---------- */
function tabSources(p, r){
const docs = docList(p), v = (r && r.q && r.q.v === "docs") ? "docs" : docSt.view[p.id] || "mail";
const seg = h("div", { class:"seg src-seg", role:"group", "aria-label":"Sources" },
h("button", { type:"button", "aria-pressed":String(v === "mail"), onclick:() => { docSt.view[p.id] = "mail"; if(r && r.q && r.q.v) setQ({ v:null }); else render(); } }, icon("mail", "sm"), "Mail & calls"),
h("button", { type:"button", "aria-pressed":String(v === "docs"), onclick:() => { docSt.view[p.id] = "docs"; render(); } }, icon("file", "sm"), "Documents", docs.length ? h("span", { class:"cnt" }, docs.length) : null));
if(v !== "docs") return [h("div", { class:"src-top" }, seg), callSearchBar(p), mailScreen({ project:p })];
return [h("div", { class:"src-top" }, seg), docPanel(p, docs)];
}
/* ---------- reading: queue the Document Reader on every unread document ---------- */
function docStartRead(p, all, notesText, chain){
const docs = docList(p), todo = all ? docs : docs.filter(s => !docDigest(s));
if(!todo.length){ toast("Every document has been read"); return; }
let t = docTask(p);
if(bpActive(t)){ toast("The Document Reader is already " + (t.status === "In progress" ? "reading" : "queued")); return; }
const notes = docNotes(p), now = nowISO();
const brief = [
"Goal: read " + plural(todo.length, "client document") + " on " + p.name + " (project " + p.id + ") and save a digest for each.",
all ? "Re-read every document, replacing the old digests." : "Read only the documents not read yet.",
String(notesText || "").trim() ? "Anand's notes for this read (follow them):\n" + String(notesText).trim().slice(0, 3000) : "",
"Documents (" + todo.length + "):", todo.map(s => "- " + s.id + " · " + (s.doc_path || s.subject || s.file) + (docGroupOf(s) && notes[docGroupOf(s)] ? " · folder note: " + notes[docGroupOf(s)] : "")).join("\n"),
"Start with orbit_documents {project:" + p.id + "}. For each: orbit_get_document, read it, propose what it clearly states, then orbit_document_digest before the next one.",
"Limits: proposals only; never contact the client; never push or change an app."].filter(Boolean).join("\n");
if(String(notesText || "").trim()) update("projects", p.id, { doc_read_notes:String(notesText).trim().slice(0, 3000) });
docSt.readBox[p.id] = false;
if(!t) t = crewTask(p.id, { name:"Read documents: " + p.name, doer:"claude", agent:"document-reader", kind:"documents", autonomy:"draft" });
if(t.status !== "To do") setTaskStatus(t, "To do");
const claude = byId("users", "u-claude");
update("tasks", t.id, Object.assign({ doer:"claude", agent:"document-reader", kind:"documents", autonomy:"draft", agent_brief:brief,
routing:Object.assign({}, t.routing && typeof t.routing === "object" ? t.routing : {}, { depends_on:[] }),
run_requested_at:now, rerun_confirmed_at:now, inputs:todo.map(s => s.id) },
claude && (t.assignee_ids || []).indexOf(claude.id) === -1 ? { assignee_ids:[claude.id] } : {}));
logAct(p.id, t.id, "asked the Document Reader to read " + plural(todo.length, "document") + (chain ? ", then update the Workflow and the Blueprint" : ""));
commit();
/* the chain: the Workflow Mapper waits for the Document Reader, the Solution Designer waits for the Workflow (it pins pains to workflow boxes) */
if(chain){
const n = String(notesText || "").trim(), wfOk = typeof wfStart === "function";
/* the Workflow chains the Blueprint behind itself; without a workflow module, queue the Blueprint directly */
if(wfOk) wfStart(p, n, false, [t.id], { quiet:true });
else bpStart(p, n, [t.id], { solo:true, quiet:true });
toast("Queued: Document Reader → Workflow update → Blueprint update" + (crewOff() ? " · the crew is off" : " · each starts when the one before it finishes"));
return;
}
toast("Document Reader queued for " + plural(todo.length, "document") + (crewOff() ? " · the crew is off, paste the line shown into Claude Code" : " · the Dispatcher picks it up"));
}
function docStop(p){
const t = docTask(p); if(!t) return;
if(!confirm("Stop reading? Digests saved so far stay; the rest stay unread.")) return;
update("tasks", t.id, { stop_requested_at:nowISO() }); commit(); toast("Stopping · the Dispatcher ends it within about 10 seconds");
}
/* ---------- import a whole folder from the browser ---------- */
/* a zip (a Dropbox or Drive folder download), unpacked in the browser: [{name: path inside the zip, blob, size} | {name, skip}].
Stored and deflated entries; encrypted entries and zip64 archives are refused with a reason. */
function docUnzip(file){
return file.arrayBuffer().then(async buf => {
const v = new DataView(buf), u8 = new Uint8Array(buf), out = [];
let e = -1;
for(let i = buf.byteLength - 22; i >= Math.max(0, buf.byteLength - 65557); i--) if(v.getUint32(i, true) === 0x06054b50){ e = i; break; }
if(e < 0) throw new Error(file.name + " isn't a zip file");
const n = v.getUint16(e + 10, true), cd = v.getUint32(e + 16, true);
if(cd === 0xFFFFFFFF || n === 0xFFFF) throw new Error(file.name + " is a very large (zip64) archive: unzip it on your Mac and import the folder instead");
const dec = new TextDecoder("utf-8");
for(let k = 0, o = cd; k < n; k++){
if(v.getUint32(o, true) !== 0x02014b50) throw new Error(file.name + " looks damaged");
const flags = v.getUint16(o + 8, true), method = v.getUint16(o + 10, true), csize = v.getUint32(o + 20, true), usize = v.getUint32(o + 24, true);
const nlen = v.getUint16(o + 28, true), xlen = v.getUint16(o + 30, true), clen = v.getUint16(o + 32, true), lho = v.getUint32(o + 42, true);
const name = dec.decode(u8.subarray(o + 46, o + 46 + nlen));
o += 46 + nlen + xlen + clen;
if(name.endsWith("/")) continue;
if(flags & 1){ out.push({ name, skip:"password-protected" }); continue; }
const start = lho + 30 + v.getUint16(lho + 26, true) + v.getUint16(lho + 28, true), data = u8.subarray(start, start + csize);
if(method === 0) out.push({ name, blob:new Blob([data]), size:usize });
else if(method === 8 && typeof DecompressionStream !== "undefined")
out.push({ name, blob:await new Response(new Blob([data]).stream().pipeThrough(new DecompressionStream("deflate-raw"))).blob(), size:usize });
else out.push({ name, skip:"packed in a way the browser can't open" });
}
return out;
});
}
/* files from the folder picker, the file picker, or inside zips → [{f, rel}] with each file's path inside what was chosen */
function docImportFiles(p, fileList){
const files = Array.from(fileList || []);
if(!files.length) return;
if(!needSb()){ toast("Sign in to Supabase in Settings first: documents are stored there"); return; }
if(docSt.busy[p.id]){ toast("An import is already running"); return; }
if(files.length === 1 && /\.vtt$/i.test(files[0].name)){ addCallModal(p, files[0]); toast("A Zoom transcript is added as a call: set the date and type, and the Call Reader reads it"); return; }
const zips = files.filter(f => /\.zip$/i.test(f.name)), plain = files.filter(f => !/\.zip$/i.test(f.name));
/* the folder picker gives "//": keep the path inside the chosen folder */
const into = String(docSt.into[p.id] || "").trim();
const items = plain.map(f => { const rel0 = String(f.webkitRelativePath || f.name), parts = rel0.split("/");
const rel = parts.length > 1 ? parts.slice(1).join("/") : rel0;
return { f, rel:into && !f.webkitRelativePath ? into + "/" + rel : rel }; });
if(!zips.length){ docImportItems(p, items, []); return; }
docSt.busy[p.id] = { done:0, total:1, now:"Unpacking " + zips.map(z => z.name).join(", ") }; render();
Promise.all(zips.map(z => docUnzip(z).then(list => {
const names = list.map(x => x.name).filter(n => !/^__MACOSX\//.test(n));
/* a zip of one folder holds "/…": drop that wrapper so its sub-folders become the groups */
const top = names.length && names.every(n => n.indexOf("/") > -1 && n.split("/")[0] === names[0].split("/")[0]) ? names[0].split("/")[0] + "/" : "";
return list.map(x => ({ x, rel:top && x.name.indexOf(top) === 0 ? x.name.slice(top.length) : x.name }));
}))).then(groups => {
const skipped = [];
groups.flat().forEach(({ x, rel }) => {
if(x.skip){ skipped.push(rel + " (" + x.skip + ")"); return; }
const base = rel.split("/").pop(), ext = String(base.split(".").pop() || "").toLowerCase();
items.push({ f:new File([x.blob], base, { type:DOC_TYPE[ext] || "" }), rel });
});
docSt.busy[p.id] = null;
docImportItems(p, items, skipped);
}).catch(e => { docSt.busy[p.id] = null; docSt.skipped[p.id] = [String(e && e.message || e)]; render(); toast("Couldn't open the zip · " + (e && e.message || e)); });
}
function docImportItems(p, items, skipped0){
const have = new Set(docList(p).map(s => String(s.doc_path || s.subject || "")));
const plan = [], skipped = (skipped0 || []).slice();
items.forEach(({ f, rel }) => {
const ext = String(f.name.split(".").pop() || "").toLowerCase();
if(/(^|\/)\.|__MACOSX|(^|\/)~\$/.test(rel)) return;
if(ext === "vtt"){ skipped.push(rel + " (a Zoom call transcript: add it with Add a call on Mail & calls, so the Call Reader reads it)"); return; }
if(!DOC_TYPE[ext]){ skipped.push(rel + " (." + ext + " isn't read)"); return; }
if(f.size > DOC_MAX){ skipped.push(rel + " (over 15 MB)"); return; }
if(!f.size){ skipped.push(rel + " (empty)"); return; }
if(have.has(rel)){ skipped.push(rel + " (already imported)"); return; }
plan.push({ f, rel, ext });
});
docSt.skipped[p.id] = skipped;
if(!plan.length){ render(); toast(skipped.length ? "Nothing new to import · " + skipped.length + " skipped (listed on the page)" : "No files found in that folder"); return; }
const acc = byId("accounts", p.account_id) || {}, batch = "imp-" + Date.now().toString(36);
let done = 0, failed = 0;
docSt.busy[p.id] = { done:0, total:plan.length, now:"" }; render();
const next = i => {
if(i >= plan.length){
docSt.busy[p.id] = null;
logAct(p.id, "", "imported " + plural(done, "document") + (failed ? " (" + failed + " failed)" : ""));
commit();
toast("Imported " + plural(done, "document") + (failed ? " · " + failed + " failed" : "") + (skipped.length ? " · " + skipped.length + " skipped" : "") + ". Add each folder's note, then Read documents.");
return;
}
const x = plan[i], folder = bpDocFolder(acc, x.rel.replace(/\//g, "-"));
const safe = x.f.name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-|-(?=\.)/g, "") || "document." + x.ext;
docSt.busy[p.id] = { done:i, total:plan.length, now:x.rel }; scheduleRender();
bpReadB64(x.f).then(b64 => sbFn("file_put", { path:folder + "/" + safe, base64:b64, content_type:DOC_TYPE[x.ext] })).then(j => {
const path = String(j.path || folder + "/" + safe), cut = path.lastIndexOf("/");
insert("sources", { kind:"document", project_id:p.id, account_id:p.account_id, occurred_at:nowISO(), direction:"", from:"", to:"", cc:"",
subject:x.f.name, people:"", summary:"", file_id:"sb:sources/" + path, file_url:"", external_id:"", thread_id:"", size:x.f.size, processed_at:"",
folder:path.slice(0, cut), file:path.slice(cut + 1), content_type:DOC_TYPE[x.ext], doc_size:x.f.size, doc_path:x.rel, import_id:batch });
done++; if(done % 5 === 0) commit();
}).catch(() => { failed++; }).then(() => next(i + 1));
};
next(0);
}
/* open the stored original: PDFs, images and text in a new tab; Office files download (browsers can't show them) */
const DOC_IN_TAB = ["pdf", "png", "jpg", "jpeg", "gif", "webp", "txt", "md", "csv"];
function docOpen(s){
if(!needSb()){ toast("Sign in to Supabase in Settings to open files"); return; }
const path = s.folder && s.file ? s.folder + "/" + s.file : "";
if(!path){ toast("This document has no stored file"); return; }
const ext = docExt(s), inTab = DOC_IN_TAB.indexOf(ext) > -1;
const win = inTab ? window.open("", "_blank") : null; // opened on the click itself, so it isn't blocked as a pop-up
if(win){ try { win.document.title = docName(s); win.document.body.style.cssText = "font:14px system-ui;padding:24px;color:#6B6276"; win.document.body.textContent = "Opening " + docName(s) + "…"; } catch(e){} }
else toast("Downloading " + docName(s) + "…");
sbFn("file_get", { path, base64:true }).then(j => {
if(!j || !j.base64) throw new Error("the file is empty or missing");
const bin = atob(j.base64), u = new Uint8Array(bin.length);
for(let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
const type = (inTab && (ext === "txt" || ext === "md" || ext === "csv")) ? "text/plain;charset=utf-8" : DOC_TYPE[ext] || j.content_type || "application/octet-stream";
const url = URL.createObjectURL(new Blob([u], { type }));
if(win) win.location.href = url;
else { const a = document.createElement("a"); a.href = url; a.download = docName(s); document.body.appendChild(a); a.click(); a.remove(); toast("Downloaded " + docName(s) + " · open it from your Downloads"); }
setTimeout(() => URL.revokeObjectURL(url), 120000);
}).catch(e => { if(win) try { win.close(); } catch(x){} toast("Couldn't open " + docName(s) + " · " + (e && e.message || e)); });
}
/* move a document to another folder: only its path inside the client's folder changes; the stored file stays where it is */
function docFolderNames(p){ return uniq(docList(p).map(docGroupOf).filter(Boolean).concat(Object.keys(docNotes(p)))).sort((a, b) => a.localeCompare(b, undefined, { numeric:true })); }
function docAskFolder(p){
const n = String(prompt("Name of the new folder") || "").trim().replace(/\//g, "-");
return n || null;
}
function docMove(p, s, folder){
const name = docName(s), path = (folder ? folder + "/" : "") + name;
if(path === s.doc_path) return;
if(docList(p).some(x => x.id !== s.id && String(x.doc_path || "") === path)){ toast("There's already a " + name + (folder ? " in " + folder : " outside the folders")); return; }
update("sources", s.id, { doc_path:path });
logAct(p.id, "", "moved “" + name + "” to " + (folder || "no folder")); commit();
toast("Moved to " + (folder || "Not in a folder"));
}
function docSaveNote(p, group, text){
const notes = Object.assign({}, docNotes(p)); text = String(text || "").trim();
if(text) notes[group] = text.slice(0, 1000); else delete notes[group];
update("projects", p.id, { doc_folders:notes }); logAct(p.id, "", "set the note on the " + group + " folder"); commit();
docSt.noteEdit[p.id + ":" + group] = false; toast(text ? "Folder note saved · the Document Reader reads with it" : "Folder note removed");
}
/* ---------- the panel ---------- */
function docPanel(p, docs){
const read = docs.filter(docDigest).length, unread = docs.length - read, t = docTask(p), active = bpActive(t), busy = docSt.busy[p.id];
const groups = {}; docs.forEach(s => { const g = docGroupOf(s); (groups[g] = groups[g] || []).push(s); });
const names = Object.keys(groups).sort((a, b) => (a === "") - (b === "") || a.localeCompare(b));
const pick = h("input", { type:"file", class:"sr", multiple:true, "aria-label":"Choose a folder to import", onchange:e => { const f = e.target.files; docImportFiles(p, f); e.target.value = ""; } });
pick.setAttribute("webkitdirectory", ""); pick.setAttribute("directory", "");
const one = h("input", { type:"file", class:"sr", multiple:true, "aria-label":"Choose documents or a zip", accept:Object.keys(DOC_TYPE).map(x => "." + x).concat(".zip").join(","),
onchange:e => { docImportFiles(p, e.target.files); e.target.value = ""; } });
const cs = crewState(), allowed = Array.isArray(cs.allowed_agents) ? cs.allowed_agents.map(String) : [];
const blocked = allowed.length && allowed.indexOf("document-reader") === -1;
const useN = k => docs.filter(s => (docDigest(s) || {}).usefulness === k).length;
const pend = docs.reduce((n, s) => n + docProposals(s).filter(x => x.status === "pending").length, 0);
return h("div", { class:"bp doc" },
h("div", { class:"bp-head" },
h("div", { class:"grow" }, h("h2", { class:"bp-h1" }, "Documents"),
h("p", { class:"bp-lede" }, docs.length ? plural(docs.length, "file") + (names.filter(Boolean).length ? " in " + plural(names.filter(Boolean).length, "folder") : "") + " · " + read + " read" + (unread ? " · " + unread + " not read yet" : "") + (read ? " · " + useN("high") + " very useful" : "")
: "Documents the client shares, grouped by their folders. The Document Reader reads each one once; the Workflow, Blueprint and Requirements build on what it found.")),
pick, one,
(() => { const names = docFolderNames(p), cur = docSt.into[p.id] || "";
const m = sel([["", "into: Not in a folder"]].concat(names.map(n => [n, "into: " + n]), cur && names.indexOf(cur) < 0 ? [[cur, "into: " + cur]] : [], [["__new", "into: New folder…"]]), cur, v => {
if(v === "__new"){ const n = docAskFolder(p); docSt.into[p.id] = n || cur; render(); return; } docSt.into[p.id] = v; render(); }, "doc-into");
m.setAttribute("aria-label", "Folder for files you add"); m.title = "Files you add (not a folder or a zip) go into this folder"; return m; })(),
h("button", { class:"btn", disabled:!!busy, title:"Files or a .zip (a Dropbox or Drive folder download): a zip is unpacked and keeps its folders", onclick:() => one.click() }, icon("upload", "sm"), "Add files or a zip"),
h("button", { class:"btn", disabled:!!busy, onclick:() => pick.click() }, icon("upload", "sm"), "Import a folder"),
unread && !active ? h("button", { class:"btn primary", disabled:!!busy, onclick:() => { docSt.readBox[p.id] = !docSt.readBox[p.id]; if(docSt.readNotes[p.id] == null) docSt.readNotes[p.id] = p.doc_read_notes || ""; render(); } }, "Read " + plural(unread, "document")) : null,
!unread && read && !active ? h("button", { class:"btn ghost", onclick:() => { if(confirm("Re-read all " + docs.length + " documents? The current digests are replaced.")) docStartRead(p, true); } }, "Re-read all") : null),
docSt.readBox[p.id] && !active ? h("section", { class:"cw-card doc-readbox" },
h("label", { for:"doc-read-" + p.id }, h("b", null, "Notes for the Document Reader"), h("span", { class:"faint" }, " · optional · what to focus on, what to skip, what you already know")),
h("textarea", { class:"ta bp-ta", id:"doc-read-" + p.id, "data-live":"1", value:docSt.readNotes[p.id] || "",
placeholder:"e.g. Focus on pricing, the spray calendar and the dashboard screenshots. The HR file is background only: don't propose anything from it. They're moving off GorillaDesk.",
oninput:e => { docSt.readNotes[p.id] = e.target.value; } }),
h("div", { class:"split", style:"gap:8px;justify-content:flex-start;flex-wrap:wrap" },
micButton("doc-read-" + p.id, t => { docSt.readNotes[p.id] = t; }),
h("button", { class:"btn", onclick:() => docStartRead(p, false, docSt.readNotes[p.id]) }, "Read " + plural(unread, "document")),
h("button", { class:"btn primary", title:"Document Reader, then the Workflow Mapper updates the workflow, then the Solution Designer updates the blueprint — each waits for the one before",
onclick:() => docStartRead(p, false, docSt.readNotes[p.id], true) }, "Read, then update the Workflow and Blueprint"),
h("button", { class:"btn ghost", onclick:() => { docSt.readBox[p.id] = false; render(); } }, "Cancel")),
h("span", { class:"faint", style:"font-size:12.5px" }, "Your notes and each folder's note go to every agent in the run. Read only: the Document Reader alone. Read, then update: three runs in order, about an hour for a folder this size; the spec waits for you to accept what they propose.")) : null,
h("p", { class:"doc-hint" }, "A .zip from Dropbox or Drive is unpacked here and keeps its folders. Password-protected zips and zips over about 4 GB can't be opened in the browser: unzip those on your Mac, then use Import a folder. A Zoom transcript (.vtt) goes in as a call."),
(docSt.skipped[p.id] || []).length ? h("div", { class:"note-box warn doc-skipped" },
h("div", { class:"split" }, h("b", null, "Last import skipped " + plural(docSt.skipped[p.id].length, "file")), h("button", { class:"btn sm ghost", onclick:() => { docSt.skipped[p.id] = []; render(); } }, "Dismiss")),
h("ul", null, docSt.skipped[p.id].slice(0, 20).map(x => h("li", null, x)), docSt.skipped[p.id].length > 20 ? h("li", null, "… " + (docSt.skipped[p.id].length - 20) + " more") : null)) : null,
pend ? h("div", { class:"note-box split", style:"gap:10px;flex-wrap:wrap" }, h("span", { class:"grow" }, h("b", null, plural(pend, "finding") + " from documents to confirm with the client. "), "Documents describe how they work today, so these are confirmed before they count."),
h("button", { class:"btn sm primary", onclick:() => { if(typeof reqUi !== "undefined") reqUi.view = "review"; if(typeof rvSt !== "undefined"){ rvSt.tab[p.id] = "questions"; rvSt.qtab[p.id] = "docs"; } go("projects/" + p.id + "/requirements"); } }, "Open in Review")) : null,
blocked ? h("div", { class:"note-box warn" }, "Safe mode only lets " + allowed.join(", ") + " run. The Document Reader won't start until it's allowed in the Dispatcher's settings.") : null,
busy ? h("section", { class:"cw-card doc-busy" }, h("div", { class:"split" }, h("b", null, "Importing " + (busy.done + 1) + " of " + busy.total), h("span", { class:"faint mono", style:"font-size:12px" }, bpCut(busy.now, 60))),
h("div", { class:"doc-bar" }, h("i", { style:"width:" + Math.round(busy.done / busy.total * 100) + "%" }))) : null,
active || (t && t.status === "Blocked") ? bpRunCard(p, t, true, { label:"Document Reader", building:"Reading documents", updating:"Reading documents", stop:docStop }) : null,
!docs.length ? h("section", { class:"cw-card" }, h("p", { class:"bp-empty" }, "No documents yet. Download the client's shared folder from Dropbox or Drive, then either add the .zip as it is (Add files or a zip) or unzip it and Import a folder. Its sub-folders become groups here, and each file keeps its path."),
h("p", { class:"bp-empty" }, "Read: PDF, Word, Excel, CSV, PowerPoint, text and images, up to 15 MB each. Video and archives are skipped.")) : null,
names.map(g => docFolderCard(p, g, groups[g])));
}
function docFolderCard(p, g, list){
const note = g ? docNotes(p)[g] || "" : "", key = p.id + ":" + g, editing = !!docSt.noteEdit[key];
const ta = h("textarea", { class:"ta bp-ta sm", "data-live":"1", id:"doc-note-" + p.id + "-" + g.replace(/\W+/g, "_"), value:docSt.noteDraft[key] != null ? docSt.noteDraft[key] : note,
placeholder:"What the client said this folder holds, e.g. \"Our price sheets and how discounts work\"", oninput:e => { docSt.noteDraft[key] = e.target.value; } });
const read = list.filter(docDigest).length;
return h("section", { class:"cw-card flush doc-folder" },
h("div", { class:"doc-fh" },
icon("file", "sm"), h("b", null, g || "Not in a folder"), h("span", { class:"faint", style:"font-size:12.5px" }, plural(list.length, "file") + " · " + read + " read"),
h("span", { class:"grow" }),
g && !editing ? h("button", { class:"btn sm ghost", onclick:() => { docSt.noteEdit[key] = true; docSt.noteDraft[key] = note; render(); } }, note ? "Edit note" : "+ Add the client's note") : null),
g && (note || editing) ? h("div", { class:"doc-note" }, editing ? [ta, h("div", { class:"split", style:"gap:8px;justify-content:flex-start" },
micButton(ta.id, t => { docSt.noteDraft[key] = t; }),
h("button", { class:"btn sm primary", onclick:() => docSaveNote(p, g, docSt.noteDraft[key]) }, "Save note"),
h("button", { class:"btn sm ghost", onclick:() => { docSt.noteEdit[key] = false; render(); } }, "Cancel"))]
: h("span", null, "“" + note + "”")) : null,
list.map(s => docRow(p, s)));
}
function docRow(p, s){
const d = docDigest(s), ext = docExt(s), open = !!docSt.open[s.id], use = d ? DOC_USE[d.usefulness] || ["", ""] : null;
const c = d && d.counts ? d.counts : null;
const fed = c ? [c.steps ? plural(c.steps, "step") : "", c.records ? plural(c.records, "record") : "", c.pains ? plural(c.pains, "pain") : "",
c.rules ? plural(c.rules, "rule") : "", c.proposals ? plural(c.proposals, "proposal") : ""].filter(Boolean).join(", ") : "";
const sub = String(s.doc_path || "").split("/").slice(1, -1).join(" / ");
return h("div", { class:"doc-row" + (open ? " open" : "") },
h("button", { type:"button", class:"doc-rh", "aria-expanded":String(open), onclick:() => { docSt.open[s.id] = !open; render(); } },
h("span", { class:"doc-k k-" + (DOC_EXT[ext] || "File").toLowerCase() }, DOC_EXT[ext] || ext.toUpperCase() || "File"),
h("span", { class:"doc-t" }, h("b", null, docName(s)), sub ? h("span", { class:"faint" }, " · " + sub) : null,
h("span", { class:"doc-s" }, d ? d.summary : "Not read yet")),
docProposals(s).some(x => x.status === "pending") ? h("span", { class:"chip gold" }, docProposals(s).filter(x => x.status === "pending").length + " to confirm") : null,
d ? h("span", { class:"chip " + use[1] }, use[0]) : h("span", { class:"chip" }, "Not read"),
icon(open ? "up" : "down", "xs")),
open ? h("div", { class:"doc-detail" },
h("div", { class:"split", style:"gap:8px;justify-content:flex-start;flex-wrap:wrap" },
(() => { const cur = docGroupOf(s), names = docFolderNames(p);
const m = sel([["", cur ? "Move to folder…" : "Move to folder…"]].concat(names.filter(n => n !== cur).map(n => [n, n]), cur ? [["__none", "Not in a folder"]] : [], [["__new", "New folder…"]]), "", v => {
if(!v) return; if(v === "__new"){ const n = docAskFolder(p); if(n) docMove(p, s, n); else render(); return; } docMove(p, s, v === "__none" ? "" : v); }, "doc-move");
m.setAttribute("aria-label", "Move " + docName(s) + " to a folder"); return m; })(),
h("button", { class:"btn sm", onclick:() => docOpen(s) }, icon(DOC_IN_TAB.indexOf(ext) > -1 ? "external" : "down", "xs"), DOC_IN_TAB.indexOf(ext) > -1 ? "Open the file" : "Download the file"),
DOC_IN_TAB.indexOf(ext) > -1 ? null : h("span", { class:"faint", style:"font-size:12px" }, (DOC_EXT[ext] || "This") + " files open in their own app, not in the browser")),
docProposals(s).length ? h("div", { class:"doc-props" }, h("span", { class:"cw-eyebrow" }, "To decide · " + docProposals(s).filter(x => x.status === "pending").length + " of " + docProposals(s).length),
docProposals(s).map(proposalCard)) : null,
!d ? h("p", { class:"bp-empty" }, "The Document Reader hasn't read this yet.")
: [h("p", { class:"doc-about" }, d.about),
fed ? h("p", { class:"faint", style:"font-size:12.5px;margin:0" }, "Fed: " + fed) : null,
docFeeds(p, s),
docFacts("Steps", (d.facts || {}).steps, x => (x.who ? x.who + ": " : "") + (x.does || "") + (x.tool ? " (" + x.tool + ")" : ""), x => x.page),
docFacts("Records they keep", (d.facts || {}).records, x => (x.name || "") + (Array.isArray(x.fields) && x.fields.length ? " — " + x.fields.join(", ") : ""), x => x.page),
docFacts("Tools", (d.facts || {}).tools, x => (x.name || "") + (x.used_for ? " — " + x.used_for : ""), x => x.page),
docFacts("Pain points", (d.facts || {}).pains, x => (x.where ? x.where + ": " : "") + (x.what || "") + (x.cost ? " (" + x.cost + ")" : ""), x => x.page, true),
docFacts("Rules", d.rules, x => (x.name ? x.name + ": " : "") + "when " + (x.when || "") + ", then " + (x.then || "") + (x.example ? " · e.g. " + x.example : ""), x => x.page),
Array.isArray(d.gaps) && d.gaps.length ? docFacts("Couldn't tell", d.gaps, x => String(x), () => "") : null,
h("p", { class:"faint mono", style:"font-size:11.5px;margin:0" }, (s.doc_path || s.subject) + " · " + bpSize(s.doc_size || s.size) + (d.read_at ? " · read " + bpShortDate(d.read_at) : ""))]) : null);
}
function docFacts(label, list, text, page, hurt){
list = Array.isArray(list) ? list.filter(Boolean) : [];
if(!list.length) return null;
return h("div", { class:"doc-facts" + (hurt ? " hurt" : "") }, h("span", { class:"cw-eyebrow" }, label + " · " + list.length),
h("ul", null, list.slice(0, 30).map(x => { const pg = String(page(x) || "").trim();
return h("li", null, text(x), pg ? h("span", { class:"echip" }, /^\d+(-\d+)?$/.test(pg) ? "p." + pg : pg) : null); }),
list.length > 30 ? h("li", { class:"faint" }, "… " + (list.length - 30) + " more") : null));
}
/* =====================================================================
Requirements → Spec and Rules (11 Oct 2026 · docs/crew/SPEC.md §1h · plan
"Orbit Requirements Spec" v2). The Spec view groups requirements by phase
with their done-when lines (suggestions accepted in place), priority, roles
and links, readiness and gap tiles, and the sign-off bar for the client
spec the Requirements Writer drafts. Rules lists the business rules as
When / Then / Example. Only Anand accepts, confirms, sends and signs.
===================================================================== */
const spSt = { gap:{}, open:{}, draft:{}, ruleF:{} };
const SP_PRIO = { must:["Must","red"], should:["Should","gold"], could:["Could",""], later:["Later","faint"] };
const SP_TYPES = [["feature","Feature"],["report","Report"],["integration","Integration"],["data","Data"],["permission","Permission"],["quality","Quality"]];
const SP_HOW = [["email","By email"],["call","On a call"],["meeting","In a meeting"],["other","Other"]];
/* ---------- data ---------- */
function spVersions(p){ return Array.isArray(p.specs) ? p.specs.filter(v => v && typeof v === "object") : []; }
function spTask(p){ return rows("tasks", t => t.project_id === p.id && t.agent === "requirements-writer" && t.kind === "spec").sort(by(t => t.created_at || "")).pop() || null; }
function spOpenReqs(p){ return rows("requirements", r => r.project_id === p.id && ["proposed","confirmed","changed","built"].indexOf(r.status) > -1); }
function spAccepted(r){ return dwLines(r).filter(x => x.state === "accepted"); }
function spSuggested(r){ return dwLines(r).filter(x => x.state === "suggested"); }
function spReady(r){ return r.status !== "proposed" && spAccepted(r).length > 0 && !!r.priority && !!r.phase && !r.spec_by; }
function spWorkflow(p){
const vs = typeof wfVersions === "function" ? wfVersions(p) : [];
if(!vs.length) return null;
const c = bpLoad(bpPath(vs[vs.length - 1], "json"));
return c && c.data ? c.data : null;
}
/* red and amber workflow pains no requirement says it fixes */
function spLoosePains(p, reqs){
const w = spWorkflow(p); if(!w) return [];
const refs = new Set(); reqs.forEach(r => (Array.isArray(r.wf_refs) ? r.wf_refs : []).forEach(x => refs.add(x)));
return (w.pains || []).filter(x => !refs.has(x.id) && !refs.has(x.on));
}
/* ---------- start the Requirements Writer ---------- */
function spStart(p){
const reqs = spOpenReqs(p), vers = spVersions(p), mode = vers.length ? "update" : "draft";
if(!reqs.length){ toast("Accept some requirements first: the spec is built from them"); return; }
let t = spTask(p);
if(bpActive(t)){ toast("The spec is already being written"); return; }
const prev = vers[vers.length - 1], now = nowISO();
const brief = [
"Goal: " + (mode === "update" ? "update" : "draft") + " the client spec for " + p.name + " (project " + p.id + "): done-when lines, priority, phase, type and roles per requirement; business rules; data, integrations and who can do what; questions for every gap.",
"Mode: " + mode + ".",
"Requirements: " + reqs.length + " open (" + reqs.filter(r => r.status === "confirmed").length + " confirmed). Rules: " + projRules(p.id).length + ".",
mode === "update" ? "Previous version: v" + prev.v + " (" + (prev.status || "draft") + ")" + (prev.status === "signed" ? " — signed: change only what's new and list every change." : ".") : "",
"Start with orbit_spec {project:" + p.id + "}. Save requirement fields with orbit_requirement_update, rules with orbit_rules, the spec with orbit_spec_save.",
"Limits: suggestions only; never confirm, accept done-when lines, confirm rules, send or sign; never contact the client."].filter(Boolean).join("\n");
if(!t) t = crewTask(p.id, { name:"Spec: " + p.name, doer:"claude", agent:"requirements-writer", kind:"spec", autonomy:"draft" });
if(t.status !== "To do") setTaskStatus(t, "To do");
const claude = byId("users", "u-claude");
update("tasks", t.id, Object.assign({ doer:"claude", agent:"requirements-writer", kind:"spec", autonomy:"draft", agent_brief:brief,
routing:Object.assign({}, t.routing && typeof t.routing === "object" ? t.routing : {}, { depends_on:[] }), run_requested_at:now, rerun_confirmed_at:now, mode },
claude && (t.assignee_ids || []).indexOf(claude.id) === -1 ? { assignee_ids:[claude.id] } : {}));
logAct(p.id, t.id, mode === "update" ? "asked for a spec update" : "asked for the client spec");
commit();
toast("Requirements Writer queued" + (crewOff() ? " · the crew is off, paste the line shown into Claude Code" : " · the Dispatcher picks it up"));
}
function spStop(p){
const t = spTask(p); if(!t) return;
if(!confirm("Stop writing the spec? What it saved so far stays.")) return;
update("tasks", t.id, { stop_requested_at:nowISO() }); commit(); toast("Stopping · the Dispatcher ends it within about 10 seconds");
}
/* ---------- done-when lines, edited in place ---------- */
function spSetLines(r, lines, note){
update("requirements", r.id, { done_when:lines }); if(note) logAct(r.project_id, "", note + " on " + reqRef(r)); commit();
}
function spAcceptLine(r, id){ spSetLines(r, dwLines(r).map(x => x.id === id ? Object.assign({}, x, { state:"accepted" }) : x), "accepted a done-when line"); }
function spDropLine(r, id){ spSetLines(r, dwLines(r).filter(x => x.id !== id), "removed a done-when line"); }
function spAcceptAll(r){ spSetLines(r, dwLines(r).map(x => Object.assign({}, x, { state:"accepted" })), "accepted every done-when line"); }
/* ---------- the sign-off bar ---------- */
function spSignBar(p){
const vers = spVersions(p), v = vers[vers.length - 1], t = spTask(p), active = bpActive(t);
if(!v) return h("section", { class:"cw-card sp-sign" },
h("div", { class:"grow" }, h("b", null, "No client spec yet"), h("span", null, "The Requirements Writer suggests done-when lines, priorities, rules, data and roles, then drafts the page the client signs.")),
h("button", { class:"btn primary", disabled:active, onclick:() => spStart(p) }, active ? "Writing…" : "Draft the spec"));
const st = v.status || "draft", signed = v.signed && typeof v.signed === "object" ? v.signed : null;
const step = (k, label) => h("span", { class:"sp-step" + (st === k ? " on" : (k === "draft" || (k === "sent" && st === "signed")) ? " done" : "") }, label);
const open = k => { const path = bpPath(v, k); if(!path) return; if(!needSb()){ toast("Sign in to Supabase in Settings to open it"); return; }
openReportTab({ path, title:p.name + " · spec v" + v.v + (k === "client_html" ? " · client" : " · internal") }); };
const c = v.counts || {};
return h("section", { class:"cw-card sp-sign" },
h("div", { class:"grow" }, h("b", null, "Client spec v" + v.v),
h("span", null, [plural(c.items || 0, "item") + " in " + plural(c.phases || 0, "phase"), plural(c.rules || 0, "rule"), c.questions ? plural(c.questions, "question") : "",
signed ? "signed by " + signed.by + " · " + bpShortDate(signed.at) + " · " + ((SP_HOW.find(x => x[0] === signed.how) || [0, signed.how])[1] || "").toLowerCase()
: st === "sent" ? "sent " + bpShortDate(v.sent_at) : "draft · " + bpShortDate(v.at)].filter(Boolean).join(" · "))),
h("div", { class:"sp-steps" }, step("draft", "Draft"), step("sent", "Sent"), step("signed", "Signed")),
h("button", { class:"btn sm", onclick:() => open("client_html") }, icon("external", "xs"), "Client page"),
h("button", { class:"btn sm ghost", onclick:() => open("internal_html") }, "Internal"),
st === "draft" ? h("button", { class:"btn sm", onclick:() => spMark(p, v, "sent") }, "Mark sent") : null,
st !== "signed" ? h("button", { class:"btn sm primary", onclick:() => spSignModal(p, v) }, "Mark signed off") : null,
h("button", { class:"btn sm ghost", disabled:active, onclick:() => spStart(p) }, active ? "Writing…" : "Update the spec"));
}
function spMark(p, v, status, extra){
const list = spVersions(p).map(x => x.v === v.v ? Object.assign({}, x, { status }, status === "sent" ? { sent_at:nowISO() } : {}, extra || {}) : x);
update("projects", p.id, { specs:list }); logAct(p.id, "", "marked spec v" + v.v + " " + status); commit();
toast(status === "sent" ? "Spec v" + v.v + " marked sent" : "Spec v" + v.v + " signed off");
}
function spSignModal(p, v){
const f = { by:"", how:"email", at:todayISO(), note:"" };
openModal((redraw, close) => {
const by = h("input", { class:"inp", id:"sp-by", placeholder:"Who agreed, e.g. Mike (Owner)", value:f.by, oninput:e => f.by = e.target.value });
const at = h("input", { class:"inp", id:"sp-at", type:"date", value:f.at, oninput:e => f.at = e.target.value });
const note = h("textarea", { class:"ta", id:"sp-note", placeholder:"Their words, e.g. \"Looks right, go ahead\"", value:f.note, oninput:e => f.note = e.target.value });
return [modalHead("Mark spec v" + v.v + " signed off", "Record who agreed and how. Signing locks this version; later changes start a new one."),
h("div", { class:"modal-b sp-form" },
h("label", null, h("span", null, "Signed by"), by),
h("div", { class:"sp-row2" }, h("label", null, h("span", null, "How"), sel(SP_HOW, f.how, x => f.how = x)), h("label", null, h("span", null, "On"), at)),
h("label", null, h("span", null, "What they said"), note)),
h("div", { class:"modal-f" }, h("span", { class:"grow" }), h("button", { class:"btn", onclick:close }, "Cancel"),
h("button", { class:"btn primary", onclick:() => { if(!f.by.trim()){ toast("Say who signed it off"); return; }
close(); spMark(p, v, "signed", { signed:{ by:f.by.trim(), how:f.how, at:f.at || todayISO(), note:f.note.trim(), recorded_by:(session && session.user) || "" } }); } }, "Mark signed off"))];
}, "narrow");
}
/* ---------- the Spec view ---------- */
function reqSpecView(p){
const reqs = spOpenReqs(p), t = spTask(p), loose = spLoosePains(p, reqs);
const G = [["ready", "Ready to build", r => spReady(r), "good"],
["needdw", "Need done-when lines", r => !spAccepted(r).length, "warn"],
["review", "Suggestions to review", r => spSuggested(r).length > 0 || !!r.spec_by, "warn"],
["notask", "No task yet", r => r.status !== "proposed" && !(r.task_ids || []).length, ""],
["untested", "Not tested yet", r => (r.task_ids || []).length > 0 && spAccepted(r).some(x => !(x.test && x.test.pass)), ""]];
const gap = spSt.gap[p.id] || "";
const g = G.find(x => x[0] === gap);
const shown = gap === "pains" ? [] : g ? reqs.filter(g[2]) : reqs;
const phases = {}; shown.forEach(r => { const k = r.phase || "No phase yet"; (phases[k] = phases[k] || []).push(r); });
const order = Object.keys(phases).sort((a, b) => (a === "No phase yet") - (b === "No phase yet") || (a === "Later") - (b === "Later") || a.localeCompare(b, undefined, { numeric:true }));
const prio = r => ["must","should","could","later"].indexOf(r.priority) + 1 || 9;
return h("div", { class:"sp" },
spSignBar(p),
bpActive(t) || (t && t.status === "Blocked") ? bpRunCard(p, t, true, { label:"Requirements Writer", building:"Writing the spec", updating:"Updating the spec", stop:spStop }) : null,
h("div", { class:"sp-gaps" },
G.map(x => { const n = reqs.filter(x[2]).length;
return h("button", { type:"button", class:"tile " + (n ? x[3] : ""), "aria-pressed":String(gap === x[0]), onclick:() => { spSt.gap[p.id] = gap === x[0] ? "" : x[0]; render(); } },
h("div", { class:"v" }, n), h("div", { class:"k" }, x[1])); }),
h("button", { type:"button", class:"tile " + (loose.length ? "bad" : ""), "aria-pressed":String(gap === "pains"), onclick:() => { spSt.gap[p.id] = gap === "pains" ? "" : "pains"; render(); } },
h("div", { class:"v" }, loose.length), h("div", { class:"k" }, "Pains with no requirement"))),
gap ? h("div", { class:"sp-filter" }, "Showing: " + (gap === "pains" ? "workflow pains no requirement fixes" : g[1].toLowerCase()), h("button", { class:"btn sm ghost", onclick:() => { spSt.gap[p.id] = ""; render(); } }, "Show all")) : null,
gap === "pains" ? spPainList(p, loose) : null,
!reqs.length ? h("section", { class:"cw-card" }, h("p", { class:"bp-empty" }, "No open requirements yet. Accept them from a call's or a document's proposals, then draft the spec.")) : null,
order.map(k => [h("div", { class:"sp-phase" }, k + " · " + phases[k].length),
phases[k].sort((a, b) => prio(a) - prio(b) || String(a.created_at || "").localeCompare(String(b.created_at || ""))).map(r => spCard(p, r))]));
}
function spPainList(p, loose){
if(!loose.length) return h("section", { class:"cw-card" }, h("p", { class:"bp-empty" }, "Every workflow pain is covered by a requirement."));
return h("section", { class:"cw-card flush" }, loose.map(x => h("div", { class:"sp-pain" },
h("span", { class:"chip " + (x.state === "pain" ? "red" : "gold") }, x.id), h("div", { class:"grow" }, h("b", null, x.title), h("span", { class:"faint" }, x.impact || "")),
h("button", { class:"btn sm", onclick:() => {
const r = insert("requirements", { project_id:p.id, title:x.title, detail:x.impact || "", status:"proposed", quote:"", speaker:"", source_id:"", at_time:"", screen:"",
confirmed_at:"", supersedes_id:"", task_ids:[], ref:nextReqRef(p.id), wf_refs:[x.id, x.on].filter(Boolean) });
logAct(p.id, "", "made a requirement from workflow pain " + x.id); commit(); toast(reqRef(r) + " created from " + x.id + " · edit it to add done-when lines"); spDrawer(p, r); } }, "Make a requirement"))));
}
function spCard(p, r){
const acc = spAccepted(r), sug = spSuggested(r), pr = SP_PRIO[r.priority], ready = spReady(r);
const rules = (Array.isArray(r.rule_ids) ? r.rule_ids : []), tasks = (r.task_ids || []).map(id => byId("tasks", id)).filter(Boolean);
const tested = acc.filter(x => x.test && x.test.pass).length, src = r.source_id ? byId("sources", r.source_id) : null;
return h("section", { class:"cw-card sp-card" + (ready ? "" : " warn") },
h("div", { class:"sp-ch" },
h("span", { class:"chip mono" }, reqRef(r)),
h("button", { type:"button", class:"sp-title", onclick:() => spDrawer(p, r) }, r.title),
pr ? h("span", { class:"chip " + pr[1] }, pr[0]) : h("span", { class:"chip faint" }, "No priority"),
h("span", { class:"chip " + (r.status === "confirmed" ? "jade" : r.status === "built" ? "blue" : "gold") }, r.status),
(Array.isArray(r.roles) ? r.roles : []).map(x => h("span", { class:"chip" }, x)),
r.spec_by ? h("span", { class:"chip gold", title:"Fields set by " + r.spec_by + "; open it and save to confirm them" }, "Suggested fields") : null),
acc.length || sug.length ? h("ul", { class:"sp-dw" },
acc.map(x => h("li", { class:x.test ? (x.test.pass ? "pass" : "fail") : "" }, h("span", { class:"sp-box" }), x.text,
x.test ? h("span", { class:"chip " + (x.test.pass ? "jade" : "red") }, x.test.pass ? "tested" : "failed") : null)),
sug.map(x => h("li", { class:"sug" }, h("span", { class:"sp-box" }), h("span", { class:"grow" }, x.text),
h("button", { class:"btn sm jade", onclick:() => spAcceptLine(r, x.id) }, "Accept"), h("button", { class:"btn sm ghost", "aria-label":"Remove this suggestion", onclick:() => spDropLine(r, x.id) }, "×"))),
sug.length > 1 ? h("li", { class:"sug-all" }, h("button", { class:"btn sm ghost", onclick:() => spAcceptAll(r) }, "Accept all " + sug.length)) : null)
: h("p", { class:"sp-missing" }, "No done-when lines yet. ", h("button", { type:"button", class:"lnk", onclick:() => spDrawer(p, r) }, "Add them"), " or draft the spec to get suggestions."),
h("div", { class:"sp-links" },
rules.map(id => h("span", { class:"chip" }, "Rule " + id)),
(Array.isArray(r.wf_refs) ? r.wf_refs : []).map(id => h("a", { class:"chip", href:"#/projects/" + p.id + "/workflow", onclick:() => { if(typeof wfSt !== "undefined") wfSt.sel[p.id] = id; } }, "Workflow " + id)),
h("span", { class:"chip" + (tasks.length ? "" : " faint") }, tasks.length ? plural(tasks.length, "task") + (acc.length ? " · " + tested + " of " + acc.length + " tested" : "") : "No task"),
r.quote ? h("span", { class:"sp-q" }, "“" + bpCut(r.quote, 120) + "”", h("span", { class:"echip" }, [r.speaker, src ? (src.kind === "document" ? docName(src) : cwTitle(src)) : "", r.at_time].filter(Boolean).join(" · "))) : null));
}
/* ---------- the edit drawer ---------- */
function spDrawer(p, r0){
const r = byId("requirements", r0.id); if(!r) return;
const f = { title:r.title, detail:r.detail || "", status:r.status, priority:r.priority || "", phase:r.phase || "", rtype:r.rtype || "", roles:(r.roles || []).join(", "),
lines:dwLines(r).map(x => Object.assign({}, x)), add:"", rules:(r.rule_ids || []).slice(), wf:(r.wf_refs || []).join(", ") };
const phases = uniq(rows("requirements", x => x.project_id === p.id && x.phase).map(x => x.phase).concat(["Phase 1", "Phase 2", "Later"]));
const rules = projRules(p.id);
openModal((redraw, close) => {
const inp = (k, ph, id) => h("input", { class:"inp", id, value:f[k], placeholder:ph || "", oninput:e => f[k] = e.target.value });
const dl = h("datalist", { id:"sp-phases" }, phases.map(x => h("option", { value:x })));
const ph = h("input", { class:"inp", id:"sp-phase", list:"sp-phases", value:f.phase, placeholder:"Phase 1", oninput:e => f.phase = e.target.value });
const lineRow = (x, i) => h("div", { class:"sp-line" + (x.state === "suggested" ? " sug" : "") },
h("input", { class:"inp", id:"sp-l-" + x.id, value:x.text, oninput:e => f.lines[i].text = e.target.value }),
x.state === "suggested" ? h("button", { class:"btn sm jade", onclick:() => { f.lines[i].state = "accepted"; redraw(); } }, "Accept") : h("span", { class:"chip jade" }, "Accepted"),
h("button", { class:"btn sm ghost", "aria-label":"Remove line", onclick:() => { f.lines.splice(i, 1); redraw(); } }, "×"));
return [modalHead(reqRef(r) + " · " + bpCut(r.title, 60), "Done-when lines, priority, phase, roles and links. Suggestions from agents are marked; accept or edit them."),
h("div", { class:"modal-b sp-form" },
h("label", null, h("span", null, "Title"), inp("title", "", "sp-title")),
h("label", null, h("span", null, "Detail"), h("textarea", { class:"ta", id:"sp-detail", value:f.detail, oninput:e => f.detail = e.target.value })),
h("div", { class:"sp-row2" },
h("label", null, h("span", null, "Status"), sel(REQ_STATUSES, f.status, x => f.status = x)),
h("label", null, h("span", null, "Priority"), sel([["", "Not set"]].concat(REQ_PRIORITIES.map(x => [x, SP_PRIO[x][0]])), f.priority, x => f.priority = x))),
h("div", { class:"sp-row2" },
h("label", null, h("span", null, "Phase"), ph, dl),
h("label", null, h("span", null, "Type"), sel([["", "Not set"]].concat(SP_TYPES), f.rtype, x => f.rtype = x))),
h("label", null, h("span", null, "Roles (comma separated)"), inp("roles", "Office, Spray tech", "sp-roles")),
h("div", { class:"sp-lines" }, h("span", { class:"sp-lab" }, "Done when"),
f.lines.length ? f.lines.map(lineRow) : h("p", { class:"bp-empty" }, "No lines yet. Each line should be testable: who does what, and what they see."),
h("div", { class:"sp-line" }, h("input", { class:"inp", id:"sp-add", value:f.add, placeholder:"Add a line, e.g. The invoice is emailed the same day", oninput:e => f.add = e.target.value,
onkeydown:e => { if(e.key === "Enter" && f.add.trim()){ e.preventDefault(); f.lines = dwAdd({ done_when:f.lines }, [f.add], "accepted"); f.add = ""; redraw(); } } }),
h("button", { class:"btn sm", onclick:() => { if(!f.add.trim()) return; f.lines = dwAdd({ done_when:f.lines }, [f.add], "accepted"); f.add = ""; redraw(); } }, "Add"))),
rules.length ? h("div", { class:"sp-lines" }, h("span", { class:"sp-lab" }, "Rules it applies"),
h("div", { class:"sp-checks" }, rules.map(x => h("label", { class:"sp-check" }, h("input", { type:"checkbox", checked:f.rules.indexOf(x.id) > -1,
onchange:e => { f.rules = e.target.checked ? uniq(f.rules.concat(x.id)) : f.rules.filter(y => y !== x.id); } }), x.id + " · " + bpCut(x.name, 40))))) : null,
h("label", null, h("span", null, "Workflow pains, boxes or arrows it fixes (comma separated)"), inp("wf", "P7, E16", "sp-wf"))),
h("div", { class:"modal-f" }, h("span", { class:"grow" }), h("button", { class:"btn", onclick:close }, "Cancel"),
h("button", { class:"btn primary", onclick:() => {
if(!f.title.trim()){ toast("A requirement needs a title"); return; }
const list = s => String(s || "").split(",").map(x => x.trim()).filter(Boolean);
update("requirements", r.id, Object.assign({ title:f.title.trim(), detail:f.detail, status:f.status, priority:f.priority, phase:f.phase.trim(), rtype:f.rtype,
roles:list(f.roles), done_when:f.lines.filter(x => String(x.text || "").trim()), rule_ids:f.rules, wf_refs:list(f.wf), spec_by:"", ref:r.ref || nextReqRef(p.id) },
f.status === "confirmed" && !r.confirmed_at ? { confirmed_at:todayISO() } : {}));
logAct(p.id, "", "updated " + reqRef(r) + " in the spec"); close(); commit(); toast(reqRef(r) + " saved"); } }, "Save"))];
}, "wide");
}
/* ---------- Rules ---------- */
function reqRulesView(p){
const all = projRules(p.id), f = spSt.ruleF[p.id] || "all";
const list = all.filter(x => f === "all" || x.status === f);
const used = id => rows("requirements", r => r.project_id === p.id && Array.isArray(r.rule_ids) && r.rule_ids.indexOf(id) > -1);
const n = k => all.filter(x => x.status === k).length;
return h("div", { class:"sp" },
h("div", { class:"split", style:"gap:10px;flex-wrap:wrap" },
segm([["all", "All " + all.length], ["confirmed", "Confirmed " + n("confirmed")], ["proposed", "Proposed " + n("proposed")], ["open", "Open questions " + n("open")]], f, k => { spSt.ruleF[p.id] = k; render(); }),
h("span", { class:"grow" }), h("button", { class:"btn sm primary", onclick:() => spRuleModal(p, null) }, icon("plus", "sm"), "Add a rule")),
!all.length ? h("section", { class:"cw-card" }, h("p", { class:"bp-empty" }, "No business rules yet. They come from calls and documents as Rule proposals, from the Requirements Writer, or add one here: pricing, discounts, credits, holds, cycles, cut-offs, approvals.")) : null,
list.map(x => {
const us = used(x.id);
return h("section", { class:"cw-card sp-rule" + (x.status === "open" ? " warn" : "") },
h("div", { class:"sp-ch" }, h("span", { class:"chip mono" }, x.id), h("b", { class:"grow" }, x.name),
sel(RULE_STATUSES.map(s => [s, s === "open" ? "Open question" : s[0].toUpperCase() + s.slice(1)]), x.status, v => { saveRule(p.id, { id:x.id, status:v }); logAct(p.id, "", "set rule " + x.id + " to " + v); commit(); }, "bare"),
h("button", { class:"btn sm ghost", onclick:() => spRuleModal(p, x) }, icon("pencil", "xs"), "Edit")),
h("div", { class:"sp-wt" }, h("span", null, "When"), h("div", null, x.when || "—"), h("span", null, "Then"), h("div", null, x.then || "—")),
x.example ? h("div", { class:"sp-ex" }, "Example: " + x.example) : h("div", { class:"sp-ex none" }, "No example yet: add one with real numbers so the build can't misread it."),
x.question ? h("div", { class:"sp-ex open" }, "Open: " + x.question) : null,
h("div", { class:"sp-links" }, us.length ? us.map(r => h("span", { class:"chip" }, "Used by " + reqRef(r))) : h("span", { class:"chip faint" }, "Not linked to a requirement"),
(x.evidence || []).map(e => { const s = e.source_id ? byId("sources", e.source_id) : null;
return h("span", { class:"echip", title:e.quote ? "“" + e.quote + "”" + (e.speaker ? " — " + e.speaker : "") : "" },
e.label || [s ? (s.kind === "document" ? docName(s) : cwTitle(s)) : "Source", e.page ? "p." + String(e.page).replace(/^p\.?/i, "") : e.at_time].filter(Boolean).join(" · ")); })));
}));
}
function spRuleModal(p, x){
const f = x ? { name:x.name || "", when:x.when || "", then:x.then || "", example:x.example || "", question:x.question || "", status:x.status || "proposed" }
: { name:"", when:"", then:"", example:"", question:"", status:"confirmed" };
openModal((redraw, close) => {
const ta = (k, ph) => h("textarea", { class:"ta", id:"rule-" + k, value:f[k], placeholder:ph, oninput:e => f[k] = e.target.value });
return [modalHead(x ? x.id + " · Edit rule" : "Add a business rule", "Write it as When / Then with a worked example, so the build can't misread it."),
h("div", { class:"modal-b sp-form" },
h("label", null, h("span", null, "Name"), h("input", { class:"inp", id:"rule-name", value:f.name, placeholder:"Hold after two unpaid sprays", oninput:e => f.name = e.target.value })),
h("label", null, h("span", null, "When"), ta("when", "A customer's two most recent sprays are both unpaid")),
h("label", null, h("span", null, "Then"), ta("then", "The next spray goes on hold until one is paid, or the office releases it with a reason")),
h("label", null, h("span", null, "Example"), ta("example", "Sprays on 3 May and 24 May unpaid → the 14 June spray shows On hold")),
h("div", { class:"sp-row2" }, h("label", null, h("span", null, "Status"), sel(RULE_STATUSES.map(s => [s, s === "open" ? "Open question" : s[0].toUpperCase() + s.slice(1)]), f.status, v => f.status = v)),
h("label", null, h("span", null, "Open question (if any)"), h("input", { class:"inp", id:"rule-q", value:f.question, oninput:e => f.question = e.target.value })))),
h("div", { class:"modal-f" },
x ? h("button", { class:"btn danger", onclick:() => { removeRule(p.id, x.id); logAct(p.id, "", "deleted rule " + x.id); close(); commit(); } }, "Delete") : null,
h("span", { class:"grow" }), h("button", { class:"btn", onclick:close }, "Cancel"),
h("button", { class:"btn primary", onclick:() => {
if(!f.name.trim() || !f.when.trim() || !f.then.trim()){ toast("A rule needs a name, a When and a Then"); return; }
const rec = saveRule(p.id, Object.assign({ name:f.name.trim(), when:f.when.trim(), then:f.then.trim(), example:f.example.trim(), question:f.question.trim(), status:f.status },
x ? { id:x.id } : { by:(session && session.user) || "" }));
logAct(p.id, "", (x ? "edited" : "added") + " rule " + rec.id); close(); commit(); toast("Rule " + rec.id + " saved"); } }, "Save"))];
}, "narrow");
}
/* =====================================================================
Requirements → Review (11 Oct 2026 · docs/crew/SPEC.md §1j). One place to
decide and one list of questions:
- Decide: every pending proposal from client calls, grouped by type, with
the triage agent's verdict (new / duplicate of RQ12 / conflict / weak),
bulk accept, merge and reject.
- Questions: the questions from the Workflow, Blueprint and spec plus the
ones accepted from calls; what the documents found, to confirm with the
client; and the next call's agenda. The Call Reader records answers.
===================================================================== */
const rvSt = { tab:{}, type:{}, verdict:{}, sel:{}, qtab:{}, answer:{}, ansOpen:{} };
const RV_TYPES = [["requirement","Requirements"],["rule","Rules"],["promise","Promises"],["decision","Decisions"],["bug","Bugs"],["product_match","Product"],["question","Questions"]];
const RV_VERDICT = { new:["New","jade"], duplicate:["Duplicate","blue"], conflict:["Conflict","red"], weak:["Weak","faint"] };
const RV_QTABS = [["open","To decide"],["docs","From documents"],["ask","On the agenda"],["answered","Answered"],["not_needed","Not needed"]];
function rvSourceKind(pr){ const s = pr.source_id ? byId("sources", pr.source_id) : null; return s ? s.kind : ""; }
function rvPending(p){ return rows("proposals", x => x.project_id === p.id && x.status === "pending"); }
function rvCallPending(p){ return rvPending(p).filter(x => rvSourceKind(x) !== "document"); }
function rvDocPending(p){ return rvPending(p).filter(x => rvSourceKind(x) === "document"); }
function rvTask(p){ return rows("tasks", t => t.project_id === p.id && t.agent === "proposal-triage" && t.kind === "triage").sort(by(t => t.created_at || "")).pop() || null; }
function rvSrcLabel(pr){
const s = pr.source_id ? byId("sources", pr.source_id) : null; if(!s) return "";
if(s.kind === "document") return (typeof docName === "function" ? docName(s) : s.subject) + (pr.at_time ? " · " + (/^\d+$/.test(String(pr.at_time)) ? "p." + pr.at_time : pr.at_time) : "");
const at = pr.at_time ? (typeof cwTime === "function" && /^\d+(\.\d+)?$/.test(String(pr.at_time)) ? cwTime(+pr.at_time) : String(pr.at_time)) : "";
return (typeof cwTitle === "function" ? cwTitle(s) : s.subject || "Call") + (at ? " · " + at : "");
}
function rvSrcLink(p, pr){
const s = pr.source_id ? byId("sources", pr.source_id) : null; if(!s) return null;
const label = rvSrcLabel(pr);
if(s.kind === "call") return h("a", { class:"echip", href:"#/projects/" + p.id + "/calls/" + s.id + "/transcript", onclick:() => { if(pr.at_time && typeof cwSt !== "undefined"){ const sec = parseTs(pr.at_time); if(sec != null) cwSt.jump[s.id] = sec; } } }, label);
if(s.kind === "document") return h("a", { class:"echip", href:"#/projects/" + p.id + "/mail?v=docs", onclick:() => { if(typeof docSt !== "undefined"){ docSt.view[p.id] = "docs"; docSt.open[s.id] = true; } } }, label);
return h("span", { class:"echip" }, label);
}
/* ---------- triage: queue the agent ---------- */
function rvStartTriage(p){
const todo = rvPending(p).filter(x => !x.triage);
if(!todo.length){ toast("Every pending proposal is triaged"); return; }
let t = rvTask(p);
if(bpActive(t)){ toast("Triage is already running"); return; }
const now = nowISO();
const brief = ["Goal: triage " + plural(todo.length, "pending proposal") + " on " + p.name + " (project " + p.id + "): new, duplicate (of which RQ / BR / QN), conflict (with what) or weak (why).",
"Start with orbit_proposals {project:" + p.id + ", status:\"pending\"}, orbit_spec and orbit_questions. Save with orbit_triage_save in batches.",
"Limits: suggestions only; never accept, merge or reject; never contact the client."].join("\n");
if(!t) t = crewTask(p.id, { name:"Triage proposals: " + p.name, doer:"claude", agent:"proposal-triage", kind:"triage", autonomy:"draft" });
if(t.status !== "To do") setTaskStatus(t, "To do");
const claude = byId("users", "u-claude");
update("tasks", t.id, Object.assign({ doer:"claude", agent:"proposal-triage", kind:"triage", autonomy:"draft", agent_brief:brief,
routing:Object.assign({}, t.routing && typeof t.routing === "object" ? t.routing : {}, { depends_on:[] }), run_requested_at:now, rerun_confirmed_at:now, inputs:todo.map(x => x.id) },
claude && (t.assignee_ids || []).indexOf(claude.id) === -1 ? { assignee_ids:[claude.id] } : {}));
logAct(p.id, t.id, "asked for triage of " + plural(todo.length, "proposal")); commit();
toast("Triage queued for " + plural(todo.length, "proposal") + (crewOff() ? " · the crew is off" : " · the Dispatcher picks it up"));
}
function rvStopTriage(p){ const t = rvTask(p); if(!t) return; update("tasks", t.id, { stop_requested_at:nowISO() }); commit(); toast("Stopping triage"); }
/* ---------- deciding, one or many ---------- */
function rvAct(list, how){
let ok = 0, err = [];
list.forEach(pr => { try {
if(how === "accept") acceptProposal(pr);
else if(how === "reject") rejectProposal(pr);
else if(how === "merge") mergeProposal(pr, (pr.triage || {}).target);
ok++; } catch(e){ err.push(String(pr.title || "").slice(0, 40) + ": " + e.message); } });
list.forEach(pr => { delete rvSt.sel[pr.id]; });
commit();
toast((how === "accept" ? "Accepted " : how === "merge" ? "Merged " : "Rejected ") + plural(ok, "proposal") + (err.length ? " · " + err.length + " couldn't: " + err[0] : ""));
}
/* ---------- the view ---------- */
function reqReviewView(p){
const callP = rvCallPending(p), docP = rvDocPending(p), qs = rvQuestions(p);
const tab = rvSt.tab[p.id] || "decide";
const openQ = qs.filter(q => q.status === "open").length;
return h("div", { class:"sp rv" },
h("div", { class:"split", style:"gap:10px;flex-wrap:wrap" },
segm([["decide", "Decide · " + callP.length], ["questions", "Questions · " + (openQ + docP.length)]], tab, k => { rvSt.tab[p.id] = k; render(); }),
h("span", { class:"faint", style:"font-size:12.5px" }, tab === "decide" ? "What the client asked on calls: accept, merge into what's on record, or reject." : "What we still need from the client, and what the documents found that the client should confirm.")),
tab === "decide" ? rvDecide(p, callP) : rvQuestionsView(p, qs, docP));
}
function rvDecide(p, all){
const t = rvTask(p), active = bpActive(t), untriaged = rvPending(p).filter(x => !x.triage).length;
const ty = rvSt.type[p.id] || "all", vd = rvSt.verdict[p.id] || "all";
const typeN = k => all.filter(x => x.type === k).length;
const byType = all.filter(x => ty === "all" || x.type === ty);
const vOf = x => x.triage ? x.triage.verdict : "none";
const shown = byType.filter(x => vd === "all" || vOf(x) === vd);
const sel = shown.filter(x => rvSt.sel[x.id]);
const marked = k => byType.filter(x => vOf(x) === k);
const groups = RV_TYPES.filter(x => shown.some(y => y.type === x[0]));
return [
h("section", { class:"cw-card rv-tri" },
h("div", { class:"grow" }, h("b", null, untriaged ? plural(untriaged, "proposal") + " not triaged yet" : "Everything is triaged"),
h("span", null, "The triage agent marks each one new, a duplicate of something on record, a conflict, or weak — you then decide in bulk.")),
untriaged && !active ? h("button", { class:"btn primary", onclick:() => rvStartTriage(p) }, "Triage " + plural(untriaged, "proposal")) : null),
active || (t && t.status === "Blocked") ? bpRunCard(p, t, true, { label:"Proposal Triage", building:"Triaging proposals", updating:"Triaging proposals", stop:rvStopTriage }) : null,
!all.length ? h("section", { class:"cw-card" }, h("p", { class:"bp-empty" }, "Nothing to decide from calls. New calls add proposals here once the Call Reader has read them.")) : [
h("div", { class:"rv-filters" },
h("div", { class:"seg" }, [["all", "All " + all.length]].concat(RV_TYPES.filter(x => typeN(x[0])).map(x => [x[0], x[1] + " " + typeN(x[0])])).map(x =>
h("button", { type:"button", "aria-pressed":String(ty === x[0]), onclick:() => { rvSt.type[p.id] = x[0]; render(); } }, x[1]))),
h("div", { class:"seg" }, [["all", "Any verdict"], ["new", "New"], ["duplicate", "Duplicate"], ["conflict", "Conflict"], ["weak", "Weak"], ["none", "Not triaged"]].map(x =>
h("button", { type:"button", "aria-pressed":String(vd === x[0]), onclick:() => { rvSt.verdict[p.id] = x[0]; render(); } }, x[1] + " " + (x[0] === "all" ? byType.length : marked(x[0]).length))))),
h("div", { class:"rv-bulk" },
h("label", { class:"rv-check" }, h("input", { type:"checkbox", checked:shown.length > 0 && sel.length === shown.length,
onchange:e => { shown.forEach(x => { if(e.target.checked) rvSt.sel[x.id] = true; else delete rvSt.sel[x.id]; }); render(); } }), sel.length ? plural(sel.length, "selected") : "Select all shown"),
sel.length ? [h("button", { class:"btn sm jade", onclick:() => rvAct(sel, "accept") }, "Accept " + sel.length),
h("button", { class:"btn sm", onclick:() => rvAct(sel, "reject") }, "Reject " + sel.length)] : null,
h("span", { class:"grow" }),
marked("new").length ? h("button", { class:"btn sm jade", onclick:() => rvAct(marked("new"), "accept") }, "Accept " + marked("new").length + " marked new") : null,
marked("duplicate").length ? h("button", { class:"btn sm", onclick:() => rvAct(marked("duplicate"), "merge") }, "Merge " + marked("duplicate").length + " duplicates") : null,
marked("weak").length ? h("button", { class:"btn sm ghost", onclick:() => rvAct(marked("weak"), "reject") }, "Reject " + marked("weak").length + " weak") : null),
!shown.length ? h("section", { class:"cw-card" }, h("p", { class:"bp-empty" }, "Nothing matches these filters.")) : null,
groups.map(g => [h("div", { class:"sp-phase" }, g[1] + " · " + shown.filter(x => x.type === g[0]).length),
shown.filter(x => x.type === g[0]).sort(by(x => (x.triage ? ["conflict","new","duplicate","weak"].indexOf(x.triage.verdict) : 9) + ":" + (x.created_at || ""))).map(x => rvCard(p, x))])]];
}
function rvCard(p, pr){
const tr = pr.triage || null, v = tr ? RV_VERDICT[tr.verdict] : null;
return h("section", { class:"cw-card rv-card" + (tr && tr.verdict === "conflict" ? " conflict" : "") },
h("div", { class:"rv-ch" },
h("input", { type:"checkbox", class:"rv-cb", "aria-label":"Select " + pr.title, checked:!!rvSt.sel[pr.id], onchange:e => { if(e.target.checked) rvSt.sel[pr.id] = true; else delete rvSt.sel[pr.id]; render(); } }),
h("b", { class:"grow" }, pr.title),
v ? h("span", { class:"chip " + v[1] }, v[0] + (tr.target ? " · " + tr.target : "")) : h("span", { class:"chip faint" }, "Not triaged")),
pr.detail ? h("p", { class:"rv-d" }, pr.detail) : null,
pr.quote ? h("p", { class:"rv-q" }, "“" + bpCut(pr.quote, 260) + "”", pr.speaker ? h("span", { class:"faint" }, " — " + pr.speaker) : null) : null,
tr && tr.reason ? h("p", { class:"rv-why" }, tr.reason) : null,
h("div", { class:"rv-acts" }, rvSrcLink(p, pr), h("span", { class:"grow" }),
tr && tr.verdict === "duplicate" && tr.target ? h("button", { class:"btn sm", onclick:() => rvAct([pr], "merge") }, "Merge into " + tr.target) : null,
h("button", { class:"btn sm jade", onclick:() => rvAct([pr], "accept") }, "Accept"),
h("button", { class:"btn sm ghost", onclick:() => rvAct([pr], "reject") }, "Reject"),
h("span", { class:"faint", style:"font-size:11.5px" }, "→ " + proposalTarget(pr))));
}
/* ---------- questions ---------- */
function rvRaised(p){
const last = (vs) => { const v = vs[vs.length - 1]; if(!v) return null; const c = bpLoad(bpPath(v, "json")); return c && c.data ? c.data : null; };
const wf = typeof wfVersions === "function" ? last(wfVersions(p)) : null, bp = last(bpVersions(p)), sp = typeof spVersions === "function" ? last(spVersions(p)) : null;
return [].concat(
(wf && Array.isArray(wf.questions) ? wf.questions : []).map(q => ({ kind:"workflow", ref:q.id, label:"Workflow " + q.id, text:q.text, why:q.why })),
(bp && Array.isArray(bp.questions) ? bp.questions : []).map(q => ({ kind:"blueprint", ref:q.id, label:"Blueprint " + q.id, text:q.text, why:q.why })),
(sp && Array.isArray(sp.questions) ? sp.questions : []).map((q, i) => ({ kind:"spec", ref:"S" + (i + 1), label:"Spec", text:q.text, why:q.why })));
}
function rvQuestions(p){ return questionPool(p.id, rvRaised(p)); }
function rvSetQ(p, q, patch, note){
const rec = saveQuestion(p.id, Object.assign(q.id ? { id:q.id } : { text:q.text, why:q.why, from:q.from }, patch));
logAct(p.id, "", note + " " + rec.id); commit(); return rec;
}
function rvQuestionsView(p, qs, docP){
const qt = rvSt.qtab[p.id] || "open";
const n = k => k === "docs" ? docP.length : qs.filter(q => q.status === k).length;
const list = qt === "docs" ? [] : qs.filter(q => q.status === qt);
const agenda = qs.filter(q => q.status === "ask");
const copy = () => { const txt = "Questions for the next call — " + p.name + "\n\n" + agenda.map((q, i) => (i + 1) + ". " + q.text).join("\n");
(navigator.clipboard ? navigator.clipboard.writeText(txt) : Promise.reject()).then(() => toast("Agenda copied"), () => toast(txt)); };
return [
h("div", { class:"seg rv-qtabs" }, RV_QTABS.map(x => h("button", { type:"button", "aria-pressed":String(qt === x[0]), onclick:() => { rvSt.qtab[p.id] = x[0]; render(); } }, x[1] + " " + n(x[0])))),
agenda.length ? h("section", { class:"cw-card rv-agenda" },
h("div", { class:"split" }, h("b", null, "Next call agenda · " + agenda.length), h("button", { class:"btn sm", onclick:copy }, icon("copy", "xs"), "Copy the agenda")),
h("ol", null, agenda.map(q => h("li", null, q.text))),
h("span", { class:"faint", style:"font-size:12px" }, "After the call, the Call Reader records the answers here with the client's words.")) : null,
qt === "docs" ? rvDocItems(p, docP)
: !list.length ? h("section", { class:"cw-card" }, h("p", { class:"bp-empty" }, qt === "open" ? "No open questions. The Workflow, Blueprint and spec add theirs here, and so do question proposals you accept." : "Nothing here yet."))
: list.map(q => rvQCard(p, q))];
}
function rvQCard(p, q){
const key = q.id || q.key, open = !!rvSt.ansOpen[key], taId = "rv-ans-" + String(key).replace(/\W+/g, "_").slice(0, 40);
return h("section", { class:"cw-card rv-q-card" + (q.status === "ask" ? " ask" : "") },
h("div", { class:"rv-ch" }, q.id ? h("span", { class:"chip mono" }, q.id) : h("span", { class:"chip faint" }, "new"), h("b", { class:"grow" }, q.text)),
q.why ? h("p", { class:"rv-d" }, q.why) : null,
q.suggestion || q.conflict || q.answerer || q.blocks ? h("div", { class:"rv-dec" },
q.conflict ? h("span", null, h("b", null, "Conflict: "), q.conflict) : null,
q.suggestion ? h("span", null, h("b", null, "Our suggestion: "), q.suggestion) : null,
q.answerer || q.blocks ? h("span", { class:"faint" }, [q.answerer ? "Answers: " + q.answerer : "", q.blocks ? "Blocks: " + q.blocks : ""].filter(Boolean).join(" · ")) : null) : null,
h("div", { class:"sp-links" }, q.kind ? h("span", { class:"chip " + (q.kind === "decision" ? "gold" : "") }, q.kind) : null, (q.from || []).map(f => h("span", { class:"chip" }, f.label || f.kind))),
q.status === "answered" ? h("div", { class:"rv-ans" }, h("b", null, "Answer: "), q.answer || "",
q.answered && q.answered.quote ? h("span", { class:"faint" }, " · “" + bpCut(q.answered.quote, 160) + "”" + (q.answered.speaker ? " — " + q.answered.speaker : "")) : null) : null,
open ? h("div", { class:"rv-ansbox" },
h("textarea", { class:"ta bp-ta sm", id:taId, "data-live":"1", value:rvSt.answer[key] || "", placeholder:"The answer", oninput:e => { rvSt.answer[key] = e.target.value; } }),
h("div", { class:"split", style:"gap:8px;justify-content:flex-start" }, micButton(taId, t => { rvSt.answer[key] = t; }),
h("button", { class:"btn sm primary", onclick:() => { const a = String(rvSt.answer[key] || "").trim(); if(!a){ toast("Write the answer"); return; }
rvSt.ansOpen[key] = false; rvSt.answer[key] = "";
rvSetQ(p, q, { status:"answered", answer:a, answered:{ by:(session && session.user) || "", at:nowISO() } }, "answered"); } }, "Save answer"),
h("button", { class:"btn sm ghost", onclick:() => { rvSt.ansOpen[key] = false; render(); } }, "Cancel"))) : null,
h("div", { class:"rv-acts" }, h("span", { class:"grow" }),
q.status !== "ask" && q.status !== "answered" ? h("button", { class:"btn sm primary", onclick:() => rvSetQ(p, q, { status:"ask" }, "put on the agenda") }, "Ask the client") : null,
q.status === "ask" ? h("button", { class:"btn sm", onclick:() => rvSetQ(p, q, { status:"open" }, "took off the agenda") }, "Take off the agenda") : null,
q.status !== "answered" && !open ? h("button", { class:"btn sm", onclick:() => { rvSt.ansOpen[key] = true; render(); } }, "Answer it myself") : null,
q.status === "answered" ? h("button", { class:"btn sm ghost", onclick:() => rvSetQ(p, q, { status:"open" }, "reopened") }, "Reopen") : null,
q.status !== "not_needed" && q.status !== "answered" ? h("button", { class:"btn sm ghost", onclick:() => rvSetQ(p, q, { status:"not_needed" }, "marked not needed") }, "Not needed") : null));
}
/* what the documents found: describes today, so it's confirmed with the client before it counts */
function rvDocItems(p, docP){
if(!docP.length) return h("section", { class:"cw-card" }, h("p", { class:"bp-empty" }, "Nothing from documents waiting. When the Document Reader finds requirements, rules or decisions in the client's files, they land here to confirm with the client."));
const groups = RV_TYPES.filter(x => docP.some(y => y.type === x[0]));
return [h("p", { class:"faint", style:"font-size:12.5px;margin:0" }, "Documents describe how the client works today; these become requirements once the client confirms them. Accept now if you already know, or put it to the client."),
groups.map(g => [h("div", { class:"sp-phase" }, g[1] + " · " + docP.filter(x => x.type === g[0]).length),
docP.filter(x => x.type === g[0]).map(pr => {
const asked = pr.asked_question && projQuestions(p.id).find(q => q.id === pr.asked_question);
return h("section", { class:"cw-card rv-card" },
h("div", { class:"rv-ch" }, h("b", { class:"grow" }, pr.title), asked ? h("span", { class:"chip blue" }, "On the agenda · " + asked.id) : null),
pr.detail ? h("p", { class:"rv-d" }, pr.detail) : null,
pr.quote ? h("p", { class:"rv-q" }, "“" + bpCut(pr.quote, 220) + "”") : null,
h("div", { class:"rv-acts" }, rvSrcLink(p, pr), h("span", { class:"grow" }),
h("button", { class:"btn sm jade", onclick:() => rvAct([pr], "accept") }, "Accept now"),
!asked ? h("button", { class:"btn sm primary", onclick:() => {
const q = saveQuestion(p.id, { text:"Confirm: " + pr.title + "?", why:"From " + rvSrcLabel(pr), status:"ask", from:[{ kind:"proposal", ref:pr.id, label:rvSrcLabel(pr) }] });
update("proposals", pr.id, { asked_question:q.id }); logAct(p.id, "", "put a document finding on the agenda as " + q.id); commit(); toast("On the next call's agenda as " + q.id); } }, "Ask the client") : null,
h("button", { class:"btn sm ghost", onclick:() => rvAct([pr], "reject") }, "Reject")));
})])];
}
/* =====================================================================
Project → Tracking (SPEC 1k, 14 Oct 2026): Anand's shadow tracking while the team plans its own way.
What the team committed to and by when (slips kept), the testing pack on the date, change requests after
the scope locked, each Workflow pain followed to what removed it, the forms the team sent, and learnings
for Orbit. The rules live in core.js (saveCommitment, saveChange, savePainCov, painCoverage…) so the MCP
tools and this tab behave the same.
===================================================================== */
const CM_LABEL = { planned:"Planned", testing:"Testing", done:"Done", dropped:"Dropped" };
const CR_LABEL = { open:"Not decided", next_phase:"Next phase", swap:"Swap", in_scope:"In this phase", paid:"Paid extra", declined:"Declined" };
const PV_LABEL = { "":"Not judged", automated:"Automated", reduced:"Reduced", manual:"Still manual", not_addressed:"Not addressed" };
const STAGE_LABEL = { no_requirement:["No requirement", "blocked"], proposed:["Proposed", "todo"], agreed:["Agreed", "prog"], built:["Built", "done"] };
/* the latest Current Workflow's pains (loaded once, like the Workflow tab) */
function trPains(p){
const vs = typeof wfVersions === "function" ? wfVersions(p) : [], v = vs[vs.length - 1];
if(!v) return { pains:[], note:"No Current Workflow yet, so there are no pains to follow." };
const c = bpLoad(bpPath(v, "json"));
if(c.loading) return { pains:[], note:"Loading the workflow…" };
if(c.err) return { pains:[], note:c.err };
return { pains:Array.isArray(c.data.pains) ? c.data.pains : [], v:v.v };
}
function trSave(fn, ok){ try { fn(); commit(); if(ok) toast(ok); } catch(e){ toast(e.message); } }
function tabTracking(p){
const cms = projCommitments(p.id), crs = projChanges(p.id), lns = projLearnings(p.id), forms = projList(p.id, "team_forms");
const pw = trPains(p), cov = painCoverage(p.id, pw.pains), T = todayISO();
const open = cms.filter(x => x.status === "planned" || x.status === "testing");
const slips = cms.reduce((n, x) => n + (x.history || []).length, 0);
const decisions = projQuestions(p.id).filter(q => q.kind === "decision" && (q.status === "open" || q.status === "ask")).length;
return h("div", { class:"stack" },
h("div", { class:"split", style:"margin-bottom:12px;gap:10px;flex-wrap:wrap" },
h("p", { class:"sub", style:"margin:0;max-width:72ch" }, "What the team committed to and by when, what changed after the scope locked, and whether each pain the client told us about is really gone. Orbit puts a check-in on your day on each committed date."),
h("span", { class:"grow" }),
h("button", { class:"btn", onclick:() => crModal(p, null) }, icon("plus", "sm"), "Log a change"),
h("button", { class:"btn primary", onclick:() => cmModal(p, null) }, icon("plus", "sm"), "Record a commitment")),
h("div", { class:"tiles", style:"margin-bottom:14px" },
tile(open.length, "Open commitments"),
tile(open.filter(x => x.date && x.date < T).length, "Past their date", open.some(x => x.date && x.date < T) ? "bad" : ""),
tile(slips, "Date moves", slips ? "warn" : ""),
tile(crs.filter(x => x.decision === "in_scope" || x.decision === "swap").length + " / " + crs.length, "Changes let in / asked"),
tile(cov.filter(x => x.stage === "no_requirement").length, "Pains with no requirement", cov.some(x => x.stage === "no_requirement") ? "bad" : ""),
tile(cov.filter(x => x.verdict === "automated").length + " / " + cov.length, "Pains automated", "good"),
tile(decisions, "Open decisions", decisions ? "warn" : "")),
card("Commitments", cms.length ? cms.length + "" : null, cms.length ? cmTable(p, cms) : empty("Nothing recorded yet. After a call, record what the team said they'll build (one phase or the whole app), who, and the date."), { icon:"clock" }),
card("Pain coverage", pw.v ? "Workflow v" + pw.v : null, cov.length ? painTable(p, cov, cms) : empty(pw.note || "No pains on the workflow."), { icon:"check" }),
card("Changes after the lock", crs.length ? crs.length + "" : null, crs.length ? crTable(p, crs, cms) : empty("No new asks logged. Lock a commitment once its scope is agreed; asks after that are logged here and go to the next phase unless you decide otherwise."), { icon:"flag" }),
card("Learnings for Orbit", lns.filter(x => x.status === "open").length + " open", lnList(p, lns), { icon:"layers", internal:true, only:"Only you" }),
forms.length ? card("Forms the team sent", forms.length + "", h("ul", { class:"tr-list" }, forms.map(f => h("li", null, h("b", null, f.title), " · " + (f.by || "the team") + " · " + fmtStamp(f.at) + " · ",
(f.counts ? f.counts.points + " points, " + f.counts.new_from_team + " Orbit hadn't raised, " + f.counts.answered + " answered" : ""),
f.link ? [" · ", h("a", { href:f.link, target:"_blank", rel:"noopener" }, "open")] : null))), { icon:"file" }) : null);
}
/* ---------- commitments ---------- */
function cmTable(p, cms){
const T = todayISO();
return h("div", { class:"tbl-wrap" }, h("table", { class:"tbl" },
h("thead", null, h("tr", null, ["Commitment", "Who", "Date", "Status", "Scope", ""].map(x => h("th", null, x)))),
h("tbody", null, cms.slice().sort(by(x => (x.status === "done" || x.status === "dropped" ? "1" : "0") + (x.date || ""))).map(x => {
const late = (x.status === "planned" || x.status === "testing") && x.date && x.date < T, n = (x.history || []).length;
return h("tr", null,
h("td", null, h("span", { class:"chip mono" }, x.id), " ", h("b", null, x.title), x.phase ? h("div", { class:"faint", style:"font-size:12px" }, x.phase) : null,
x.req_refs && x.req_refs.length ? h("div", { class:"sp-links" }, x.req_refs.map(r => h("span", { class:"chip" }, r))) : null),
h("td", null, x.owner || h("span", { class:"faint" }, "—")),
h("td", { class:"mono" }, fmtDY(x.date), late ? [" ", h("span", { class:"chip red" }, diffDays(x.date, T) + "d late")] : null,
n ? h("div", { title:(x.history || []).map(hh => hh.date + (hh.why ? " — " + hh.why : "")).join("\n") }, h("span", { class:"chip gold" }, "moved " + n + "×"), " first " + fmtDY(x.history[0].date)) : null),
h("td", null, sel(CM_STATUSES.map(s => [s, CM_LABEL[s]]), x.status, v => trSave(() => saveCommitment(p.id, { id:x.id, status:v }), "Saved")),
h("div", { style:"margin-top:4px" }, x.locked_at ? h("button", { class:"btn sm ghost", title:"Locked " + fmtStamp(x.locked_at) + ": new asks become changes", onclick:() => trSave(() => saveCommitment(p.id, { id:x.id, lock:false }), "Unlocked") }, icon("lock", "xs"), "Locked")
: h("button", { class:"btn sm ghost", title:"Freeze the scope: asks after this become change requests", onclick:() => trSave(() => saveCommitment(p.id, { id:x.id, lock:true }), "Scope locked") }, "Lock scope"))),
h("td", { style:"max-width:280px" }, h("span", { class:"sub" }, x.scope || ""), x.note ? h("div", { class:"faint", style:"font-size:12px" }, x.note) : null),
h("td", { style:"white-space:nowrap" },
h("button", { class:"btn sm", onclick:() => prepModal(p, x) }, "Testing prep"), " ",
h("button", { class:"iconbtn", "aria-label":"Edit", onclick:() => cmModal(p, x) }, icon("pencil", "sm"))));
}))));
}
function cmModal(p, x){
const f = Object.assign({ title:"", scope:"", phase:"", owner:"", date:"", req:"", source:"", note:"", why:"" }, x ? { title:x.title, scope:x.scope, phase:x.phase, owner:x.owner, date:x.date, req:(x.req_refs || []).join(", "), source:x.source, note:x.note } : {});
const phases = projPhases(p.id).map(ph => ph.name);
openModal(redraw => {
const inp = (k, ph, attrs) => h("input", Object.assign({ class:"inp", value:f[k] || "", placeholder:ph || "", oninput:e => { f[k] = e.target.value; } }, attrs || {}));
const fld = (l, el) => h("div", { class:"fld" }, h("label", null, l), el);
const moved = x && f.date && f.date !== x.date;
const save = () => trSave(() => {
const rec = { title:f.title.trim(), scope:f.scope.trim(), phase:f.phase.trim(), owner:f.owner.trim(), date:f.date, source:f.source.trim(), note:f.note.trim(),
req_refs:f.req.split(/[\s,]+/).map(s => s.trim().toUpperCase()).filter(Boolean) };
if(x){ rec.id = x.id; if(moved) rec.why = f.why.trim(); }
const out = saveCommitment(p.id, rec);
logAct(p.id, "", (x ? "updated" : "recorded") + " the team's commitment " + out.id + ": " + out.title);
closeModal();
}, x ? "Commitment saved" : "Commitment recorded — a check-in is on your day for " + fmtDY(f.date));
return [modalHead(x ? "Edit " + x.id : "Record a commitment", "What the team said they'll build, who, and by when"),
h("div", { class:"modal-b", style:"display:flex;flex-direction:column;gap:12px" },
fld("What (short)", inp("title", "Phase 1: customer page and master lists")),
fld("Scope as the team put it", h("textarea", { class:"ta", value:f.scope, placeholder:"One phase or the full web app; what's in, what's out", oninput:e => { f.scope = e.target.value; } })),
h("div", { class:"split", style:"gap:10px" },
fld("Phase", h("input", { class:"inp", value:f.phase, list:"tr-phases", placeholder:"Phase 1", oninput:e => { f.phase = e.target.value; } }), h("datalist", { id:"tr-phases" }, phases.map(n => h("option", { value:n })))),
fld("Who", inp("owner", "Ajay")),
fld("Date", dateInp(f.date, v => { f.date = v; redraw(); }))),
moved ? fld("Why the date moved (kept in the history)", inp("why", "Waiting on the product list from Ashley")) : null,
fld("Requirements (refs)", inp("req", "RQ1, RQ4, RQ7")),
h("div", { class:"split", style:"gap:10px" }, fld("Where it was said", inp("source", "Call 3, 14 Oct")), fld("Note", inp("note")))),
h("div", { class:"modal-f" }, h("span", { class:"grow" }), h("button", { class:"btn", onclick:closeModal }, "Cancel"),
h("button", { class:"btn primary", onclick:() => { if(!f.title.trim() || !f.date){ toast("A title and a date are needed"); return; } save(); } }, "Save"))];
});
}
/* ---------- the testing pack ---------- */
function prepModal(p, cm){
const pw = trPains(p), cov = painCoverage(p.id, pw.pains), reqs = rows("requirements", r => r.project_id === p.id);
const rf = ref => reqs.find(r => r.id === ref || String(reqRef(r)).toLowerCase() === String(ref).toLowerCase());
const R = new Map();
(cm.req_refs || []).forEach(ref => { const r = rf(ref); if(r) R.set(r.id, r); });
reqs.filter(r => cm.phase && String(r.phase || "").toLowerCase() === String(cm.phase).toLowerCase()).forEach(r => R.set(r.id, r));
const pains = cov.filter(x => x.commitment === cm.id || x.requirements.some(q => [...R.values()].some(r => reqRef(r) === q.ref)));
pains.forEach(x => x.requirements.forEach(q => { const r = rf(q.ref); if(r) R.set(r.id, r); }));
const list = [...R.values()].filter(r => r.status !== "declined");
const ch = projChanges(p.id).filter(k => k.commitment === cm.id);
const qs = [];
pains.forEach(x => { const pn = pw.pains.find(y => y.id === x.pain) || {}, ev = (pn.evidence || [])[0] || {};
qs.push({ about:x.pain, q:"Show me how this no longer happens: " + x.title + ". Walk it through with a real case.", quote:ev.quote ? "“" + bpCut(ev.quote, 220) + "” — " + (ev.speaker || "") + (ev.label ? " · " + ev.label : "") : "" }); });
list.forEach(r => { const dw = dwLines(r).filter(l => l.state === "accepted");
if(dw.length) dw.filter(l => !(l.test && l.test.pass)).forEach(l => qs.push({ about:reqRef(r), q:"Show me: " + l.text }));
else qs.push({ about:reqRef(r), q:"Show me " + r.title + ". What does the user do, and what do they see?" }); });
ch.filter(k => k.decision === "in_scope" || k.decision === "swap").forEach(k => qs.push({ about:k.id, q:"Added after the lock: " + k.title + ". Is it in, and what did it replace?" }));
ch.filter(k => k.decision === "next_phase" || k.decision === "open").forEach(k => qs.push({ about:k.id, q:"Check this did NOT go in yet (next phase): " + k.title }));
const text = "Testing — " + p.name + " · " + cm.id + " " + cm.title + " (" + fmtDY(cm.date) + ")\n\n" + qs.map((x, i) => (i + 1) + ". [" + x.about + "] " + x.q + (x.quote ? "\n " + x.quote : "")).join("\n");
openModal(() => [modalHead("Testing prep · " + cm.id, cm.title + " · " + (cm.owner || "the team") + " · " + fmtDY(cm.date)),
h("div", { class:"modal-b", style:"display:flex;flex-direction:column;gap:10px;max-height:62vh;overflow:auto" },
h("p", { class:"sub", style:"margin:0" }, pains.length + " pain" + (pains.length === 1 ? "" : "s") + " this should remove · " + list.length + " requirement" + (list.length === 1 ? "" : "s") + " · " + ch.length + " change" + (ch.length === 1 ? "" : "s") + " since the lock"),
!qs.length ? empty("Nothing to test against yet: link requirements to this commitment (Edit) or pains to requirements (Pain coverage).") :
h("ol", { class:"tr-prep" }, qs.map(x => h("li", null, h("span", { class:"chip mono" }, x.about), " ", x.q, x.quote ? h("div", { class:"faint", style:"font-size:12px;margin-top:3px" }, x.quote) : null))),
h("p", { class:"faint", style:"font-size:12px;margin:0" }, "After testing, set each pain's verdict in Pain coverage, with what you saw.")),
h("div", { class:"modal-f" }, h("span", { class:"grow" }), h("button", { class:"btn", onclick:() => (navigator.clipboard ? navigator.clipboard.writeText(text) : Promise.reject()).then(() => toast("Copied"), () => toast("Couldn't copy")) }, icon("copy", "sm"), "Copy"),
h("button", { class:"btn primary", onclick:closeModal }, "Done"))], "wide");
}
/* ---------- pain coverage ---------- */
function painTable(p, cov, cms){
return h("div", { class:"tbl-wrap" }, h("table", { class:"tbl" },
h("thead", null, h("tr", null, ["Pain", "Requirements", "Where it is", "Verdict", "Evidence"].map(x => h("th", null, x)))),
h("tbody", null, cov.map(x => {
const st = STAGE_LABEL[x.stage] || ["", ""];
return h("tr", null,
h("td", { style:"max-width:300px" }, h("span", { class:"chip mono" + (x.severity === "high" ? " red" : "") }, x.pain), " ", x.title),
h("td", null, h("div", { class:"sp-links" }, x.requirements.map(r => h("span", { class:"chip" + (r.missing ? " red" : r.status === "built" || r.status === "shipped" ? " jade" : ""), title:r.missing ? "Not found" : r.title + " · " + r.status + (r.done_when ? " · " + r.passed + "/" + r.done_when + " done-when passed" : "") }, r.ref))),
h("button", { class:"btn sm ghost", onclick:() => {
const v = prompt("Requirement refs that remove " + x.pain + " (comma-separated)", x.requirements.map(r => r.ref).join(", "));
if(v == null) return; trSave(() => savePainCov(p.id, { pain:x.pain, req_refs:v.split(/[\s,]+/).map(s => s.trim().toUpperCase()).filter(Boolean), by:"anand" }), "Linked"); } }, "Link")),
h("td", null, pill(st[0], st[1])),
h("td", null, sel(PAIN_VERDICTS.map(v => [v, PV_LABEL[v]]), x.verdict, v => trSave(() => savePainCov(p.id, { pain:x.pain, verdict:v, by:"anand" }), "Saved"))),
h("td", { style:"min-width:200px" }, h("input", { class:"inp", value:x.evidence || "", placeholder:"What you saw at testing",
onchange:e => trSave(() => savePainCov(p.id, { pain:x.pain, evidence:e.target.value.trim(), by:"anand" }), "Saved") })));
}))));
}
/* ---------- changes ---------- */
function crTable(p, crs, cms){
return h("div", { class:"tbl-wrap" }, h("table", { class:"tbl" },
h("thead", null, h("tr", null, ["Ask", "Who asked", "Against", "Decision", ""].map(x => h("th", null, x)))),
h("tbody", null, crs.slice().reverse().map(k => h("tr", null,
h("td", { style:"max-width:340px" }, h("span", { class:"chip mono" }, k.id), " ", h("b", null, k.title), k.detail ? h("div", { class:"faint", style:"font-size:12px" }, k.detail) : null),
h("td", null, k.asked_by || "—", k.source ? h("div", { class:"faint", style:"font-size:12px" }, k.source) : null),
h("td", { class:"mono" }, k.commitment || "—"),
h("td", null, sel(CR_DECISIONS.map(v => [v, CR_LABEL[v]]), k.decision, v => trSave(() => saveChange(p.id, { id:k.id, decision:v }), "Saved")),
k.decision === "swap" ? h("div", { class:"faint", style:"font-size:12px" }, "for " + (k.swap_for || "?")) : null),
h("td", null, h("button", { class:"iconbtn", "aria-label":"Edit", onclick:() => crModal(p, k) }, icon("pencil", "sm"))))))));
}
function crModal(p, k){
const lk = lockedCommitment(p.id);
const f = Object.assign({ title:"", detail:"", asked_by:"", source:"", decision:k ? k.decision : (lk ? "next_phase" : "open"), swap_for:"", note:"" }, k || {});
openModal(() => {
const inp = (key, ph) => h("input", { class:"inp", value:f[key] || "", placeholder:ph || "", oninput:e => { f[key] = e.target.value; } });
const fld = (l, el) => h("div", { class:"fld" }, h("label", null, l), el);
return [modalHead(k ? "Edit " + k.id : "Log a change", lk ? "Against " + lk.id + " (locked " + fmtStamp(lk.locked_at) + "): new asks go to the next phase unless you decide otherwise" : "No commitment is locked yet"),
h("div", { class:"modal-b", style:"display:flex;flex-direction:column;gap:12px" },
fld("The ask", inp("title", "QR code at the back of the contract")), fld("Detail", inp("detail")),
h("div", { class:"split", style:"gap:10px" }, fld("Who asked", inp("asked_by", "Dan")), fld("Where", inp("source", "Call 4, 21 Oct"))),
h("div", { class:"split", style:"gap:10px" }, fld("Decision", sel(CR_DECISIONS.map(v => [v, CR_LABEL[v]]), f.decision, v => { f.decision = v; })), fld("Swapped for (if swap)", inp("swap_for", "RQ9"))),
fld("Note", inp("note"))),
h("div", { class:"modal-f" }, h("span", { class:"grow" }), h("button", { class:"btn", onclick:closeModal }, "Cancel"),
h("button", { class:"btn primary", onclick:() => { if(!String(f.title).trim()){ toast("Write the ask"); return; }
trSave(() => { const rec = { title:f.title.trim(), detail:f.detail, asked_by:f.asked_by, source:f.source, decision:f.decision, swap_for:f.swap_for, note:f.note };
if(k) rec.id = k.id; const out = saveChange(p.id, rec); logAct(p.id, "", "logged a change request " + out.id + ": " + out.title + " → " + CR_LABEL[out.decision]); closeModal(); }, "Change saved"); } }, "Save"))];
});
}
/* ---------- learnings ---------- */
function lnList(p, lns){
let txt = "";
return h("div", { style:"display:flex;flex-direction:column;gap:8px" },
lns.length ? h("ul", { class:"tr-list" }, lns.slice().reverse().map(x => h("li", { class:x.status !== "open" ? "faint" : "" },
h("span", { class:"chip " + (x.caught_by === "team" ? "gold" : x.caught_by === "orbit" ? "jade" : "") }, x.caught_by === "team" ? "team caught" : x.caught_by === "orbit" ? "Orbit caught" : "both"), " ", x.text,
x.source ? h("span", { class:"faint" }, " · " + x.source) : null, " ",
sel(LN_STATUSES.map(s => [s, { open:"Open", applied:"Applied to Orbit", dropped:"Dropped" }[s]]), x.status, v => trSave(() => saveLearning(p.id, { id:x.id, status:v }), "Saved"), "bare")))) : null,
h("div", { class:"split", style:"gap:8px" },
h("input", { class:"inp grow", placeholder:"Something the team caught that Orbit missed (or the other way round)", oninput:e => { txt = e.target.value; } }),
h("button", { class:"btn sm", onclick:() => { if(!txt.trim()) return; trSave(() => saveLearning(p.id, { text:txt.trim(), caught_by:"team", source:"Anand" }), "Noted"); } }, "Team caught"),
h("button", { class:"btn sm", onclick:() => { if(!txt.trim()) return; trSave(() => saveLearning(p.id, { text:txt.trim(), caught_by:"orbit", source:"Anand" }), "Noted"); } }, "Orbit caught")));
}
/* =====================================================================
Settings → How Orbit works (11 Oct 2026 · plan "Orbit Requirements Spec"
v2): the five project screens, the one question each answers, what's on
it, which agents fill it, what it feeds, and a link to open it on the
project you were last on. Read-only.
===================================================================== */
let guideLastProject = ""; // set by viewProject, so "Open" lands on the project you came from
const GUIDE_SCREENS = [
{ key:"mail", q:"?v=docs", name:"Sources", ask:"What did the client tell us?",
on:"Calls and emails (Mail & calls) and the documents they shared (Documents), grouped by their folders with each folder's note.",
by:["You add calls and import folders", "call-reader", "document-reader"],
feeds:"Every other screen, through evidence links: a quote with its time, or a page.",
use:"Check what was actually said or written before you decide anything." },
{ key:"workflow", name:"Workflow", ask:"How do they work today?",
on:"The connected map: steps by who does them, the records they keep, the tools they use, every handoff, and pains in red (the client said so) or amber (suspected). A full-screen map with Next pain.",
by:["workflow-mapper", "Your notes on any box or arrow"],
feeds:"The Blueprint's pains and the requirements that fix them.",
use:"Understand the business, and walk the client through the pains on a call." },
{ key:"blueprint", name:"Blueprint", ask:"What hurts, and what will we do about it?",
on:"Pains ranked by what they cost, what we automate, where AI helps, the data model, how their old platform maps across, and questions.",
by:["solution-designer", "Your answers to its questions"],
feeds:"Requirements and the plan.",
use:"Agree the direction and pitch the solution." },
{ key:"requirements", name:"Requirements", ask:"Exactly what will we build, and has the client agreed?",
on:"Every ask with its history (Timeline); the Spec view by phase with done-when lines, priorities, roles and gap tiles; the business rules (Rules); and the client spec with its sign-off.",
by:["call-reader", "document-reader", "requirements-writer", "You accept, confirm and sign"],
feeds:"The plan: every task traces back to a requirement.",
use:"Keep scope honest, and get it signed before building." },
{ key:"plan", name:"Plan", ask:"Who builds what, and when?",
on:"Phases and tasks, the Gantt with dependencies, and what the crew is working on.",
by:["planner", "builder", "reviewer"],
feeds:"The client: what gets delivered, and when.",
use:"Run the build and see what's blocked." }
];
function settingsGuide(){
const p = guideLastProject ? byId("projects", guideLastProject) : null;
const projects = rows("projects", x => !x.archived && !x.deleted).sort(by(x => x.name));
const cur = p || projects[0] || null;
const who = x => typeof AGENT_LABEL !== "undefined" && AGENT_LABEL[x]
? h("a", { href:"#/settings/agents/" + x, class:"guide-ag" }, AGENT_LABEL[x]) : h("span", { class:"guide-you" }, x);
return [
h("p", { class:"sub", style:"margin:0 0 14px" }, "Every project has five screens, and each answers one question. Evidence links tie them together: wherever you see a quote with a time or a page, click it to see who said it."),
h("div", { class:"guide-flow" }, GUIDE_SCREENS.map((s, i) => [i ? h("span", { class:"guide-ar", "aria-hidden":"true" }, "→") : null,
h("span", { class:"guide-step" }, h("b", null, s.name), h("span", null, s.ask))])),
cur ? h("div", { class:"split", style:"gap:10px;justify-content:flex-start;margin:4px 0 2px" }, h("span", { class:"faint", style:"font-size:13px" }, "Open links go to"),
sel(projects.map(x => [x.id, x.name]), cur.id, v => { guideLastProject = v; render(); }, "bp-sel")) : null,
GUIDE_SCREENS.map(s => h("section", { class:"cw-card guide-card" },
h("div", { class:"split", style:"gap:10px;flex-wrap:wrap" },
h("div", { class:"grow" }, h("h3", { class:"guide-h" }, s.name), h("span", { class:"guide-q" }, s.ask)),
cur ? h("a", { class:"btn sm", href:"#/projects/" + cur.id + "/" + s.key + (s.q || "") }, "Open on " + bpCut(cur.name, 28), icon("right", "xs")) : null),
h("dl", { class:"guide-dl" },
h("dt", null, "On it"), h("dd", null, s.on),
h("dt", null, "Filled by"), h("dd", null, s.by.map((x, i) => [i ? " · " : null, who(x)])),
h("dt", null, "Feeds"), h("dd", null, s.feeds),
h("dt", null, "Use it to"), h("dd", null, s.use))))
];
}
/* ============================================================
Settings → Agents: the crew Orbit can start (8 Oct 2026).
The list comes from each agent's own instruction file (agents/*.md, embedded by build.py as AGENT_DEFS);
on/off comes from the Dispatcher's safe-mode list (crew_state.allowed_agents); runs, time and cost come
from agent_runs. Read-only: switching an agent on or off changes what the crew may do, so it stays a
deliberate change in the crew settings, not a toggle here.
============================================================ */
const AGENT_GROUPS = [
["Calls and documents", "Read, coach and answer questions on client calls; read the documents clients share.", ["call-reader", "call-coach", "call-ask", "document-reader"]],
["New client and planning", "Map how a new account works today, design the solution, write the spec the client signs, and plan it.", ["workflow-mapper", "solution-designer", "proposal-triage", "requirements-writer", "planner", "router"]],
["Build crew", "Build on dev apps, review, debug and release to the main app.", ["builder", "reviewer", "debugger", "release"]],
["Lead", "One view across every account.", ["account-lead"]],
];
const AGENT_LABEL = { "call-reader":"Call Reader", "call-coach":"Call Coach", "call-ask":"Ask", "workflow-mapper":"Workflow Mapper", "document-reader":"Document Reader", "requirements-writer":"Requirements Writer", "proposal-triage":"Proposal Triage", "solution-designer":"Solution Designer", planner:"Planner",
router:"Router", builder:"Builder", reviewer:"Reviewer", debugger:"Debugger", release:"Release", "account-lead":"Account Lead" };
const AGENT_START = { "call-reader":"Run on a call", "call-coach":"Run on a call, and weekly", "call-ask":"Ask on a call", "workflow-mapper":"Build / Update on a project's Workflow tab", "document-reader":"Read documents on a project's Sources", "requirements-writer":"Draft / Update the spec on a project's Requirements", "proposal-triage":"Triage on a project's Requirements → Review", "solution-designer":"Build / Update on a project's Blueprint tab",
planner:"You, in a Claude session", router:"The Dispatcher, for a task with no routing plan", builder:"The Dispatcher, on a build or fix task",
reviewer:"The Dispatcher, after a dev-app push", debugger:"The Dispatcher, on a bug task", release:"Your Merge tap, after approval", "account-lead":"Weekly, and before a team review" };
function agentOn(id){
const cs = byId("crew_state", "crew") || {}, list = Array.isArray(cs.allowed_agents) ? cs.allowed_agents : [];
return list.length ? list.includes(id) : !cs.paused;
}
function agentStats(id, days){
const since = Date.now() - days * 864e5;
const rs = rows("agent_runs", r => r.agent === id && Date.parse(r.started_at || "") >= since);
const done = rs.filter(r => r.ended_at && r.started_at);
const ms = done.map(r => Date.parse(r.ended_at) - Date.parse(r.started_at)).filter(x => x > 0);
const costs = rs.map(r => +r.cost_usd).filter(x => !isNaN(x) && x > 0);
const last = rows("agent_runs", r => r.agent === id).sort(by(r => r.started_at || "")).pop();
return { runs: rs.length, avgMs: ms.length ? ms.reduce((a, b) => a + b, 0) / ms.length : 0, cost: costs.reduce((a, b) => a + b, 0),
avgCost: costs.length ? costs.reduce((a, b) => a + b, 0) / costs.length : 0, failed: rs.filter(r => r.outcome && !/^(done|running|stopped)$/.test(r.outcome)).length, last };
}
function agentToolName(t){
if(/^mcp__orbit__orbit_/.test(t)) return "Orbit · " + t.replace(/^mcp__orbit__orbit_/, "").replace(/_/g, " ");
if(/^mcp__chrome-devtools__/.test(t)) return "Browser · " + t.replace(/^mcp__chrome-devtools__/, "").replace(/_/g, " ");
return t;
}
function agentMoney(v){ return v >= 10 ? "$" + v.toFixed(0) : "$" + v.toFixed(2); }
function agentDur(ms){ if(!ms) return "—"; const m = Math.round(ms / 60000); return m < 1 ? "<1 min" : m + " min"; }
function agentRow(d){
const on = agentOn(d.id), st = agentStats(d.id, 30), parts = String(d.description).split(/(?<=[.!?])\s/);
const first = parts[0], rest = parts.slice(1).join(" ");
return h("details", { class:"ag-row", "data-sk":"ag-" + d.id },
h("summary", null,
h("div", { class:"ag-main" },
h("div", { class:"ag-name" }, h("b", null, AGENT_LABEL[d.id] || d.name), h("span", { class:"chip " + (on ? "jade" : "") }, on ? "On" : "Off"),
h("a", { href:"#/settings/agents/" + d.id, class:"ag-open", onclick:e => e.stopPropagation() }, "Open →")),
h("div", { class:"ag-desc" }, first),
h("div", { class:"ag-meta" }, [d.model ? d.model[0].toUpperCase() + d.model.slice(1) : "", d.max_turns ? "up to " + d.max_turns + " steps" : "", AGENT_START[d.id] || ""].filter(Boolean).join(" · "))),
h("div", { class:"ag-nums" },
h("div", null, h("b", null, String(st.runs)), h("span", null, "runs, 30 days")),
h("div", null, h("b", null, agentDur(st.avgMs)), h("span", null, "average time")),
h("div", null, h("b", null, st.avgCost ? agentMoney(st.avgCost) : "—"), h("span", null, "average cost")),
h("div", null, h("b", null, st.last ? fmtDY(String(st.last.started_at).slice(0, 10)) : "never"), h("span", null, "last run")))),
h("div", { class:"ag-more" },
rest ? h("p", null, rest) : null,
st.failed ? h("p", { class:"ag-warn" }, plural(st.failed, "run") + " in the last 30 days didn't finish cleanly.") : null,
h("div", { class:"lab" }, "What it can use (" + d.tools.length + ")"),
h("div", { class:"ag-tools" }, d.tools.map(t => h("span", { class:"chip" }, agentToolName(t)))),
h("a", { href:"https://github.com/Anand-orbit-2026/orbit/blob/main/agents/" + d.id + ".md", target:"_blank", rel:"noopener" }, icon("link", "sm"), "Its instructions")));
}
function settingsAgents(){
const defs = typeof AGENT_DEFS !== "undefined" ? AGENT_DEFS : [], cs = byId("crew_state", "crew") || {};
const on = defs.filter(d => agentOn(d.id)).length, all = defs.map(d => agentStats(d.id, 30));
const runs = all.reduce((a, s) => a + s.runs, 0), cost = all.reduce((a, s) => a + s.cost, 0);
const listed = new Set(AGENT_GROUPS.flatMap(g => g[2])), other = defs.filter(d => !listed.has(d.id));
const groups = AGENT_GROUPS.concat(other.length ? [["Other", "", other.map(d => d.id)]] : []);
const safe = Array.isArray(cs.allowed_agents) && cs.allowed_agents.length;
return [
h("div", { class:"tiles" }, tile(defs.length, "Agents"), tile(on, "Switched on"), tile(runs, "Runs, last 30 days"), tile(cost ? agentMoney(cost) : "—", "Cost, last 30 days")),
agentAwakeCard(cs),
h("p", { class:"sub", style:"margin:0 0 14px" }, safe
? "Safe mode is on: only the agents marked On can run, and only after you start them in Orbit (" + (cs.max_sessions || 1) + " at a time). Switching an agent on is a deliberate change in the crew settings, not a toggle here."
: "Every agent can run when the Dispatcher picks up its task."),
groups.map(g => card(g[0], g[1], h("div", { class:"ag-list" }, g[2].map(id => defs.find(d => d.id === id)).filter(Boolean).map(agentRow)), { flush:true }))];
}
/* the crew's keep-awake switch (crew_state.keep_awake, default on): the Dispatcher holds the Mac awake while
any run is going or queued, so a chain of runs doesn't stall when the Mac would sleep between them */
function agentAwakeCard(cs){
const on = cs.keep_awake !== false;
return h("div", { class:"ag-awake" },
h("div", { class:"grow" }, h("b", null, "Keep the Mac awake while the crew has work"),
h("div", { class:"faint", style:"font-size:12.5px" }, on ? "On. While a run is going or waiting its turn, the Mac doesn't go to sleep; when the queue is empty it sleeps as usual. A closed lid on battery still sleeps."
: "Off. The Mac stays awake only during each run; it can sleep between runs and the next one waits until it wakes.")),
h("button", { class:"btn sm" + (on ? "" : " primary"), onclick:() => {
if(!byId("crew_state", "crew")){ toast("The crew isn't set up on this device yet"); return; }
update("crew_state", "crew", { keep_awake:!on }); commit(); toast(on ? "Turned off" : "Turned on: the Mac stays awake while the crew has work"); } }, on ? "Turn off" : "Turn on"));
}
/* ============================================================
One agent's page (9 Oct 2026): Settings → Agents → an agent.
Overview = its card (purpose, inputs, outputs, how it runs, good looks like), stored on that agent's phase in the
"Agents" project (phases.extra.card); Runs = agent_runs with the Dispatcher's measurements (extra.metrics) and a
trend per instructions version (extra.agent_version); Issues = the tasks in that phase, with "Log an improvement";
Changes = the git history of agents/.md (AGENT_DEFS[].history, embedded by build.py).
============================================================ */
let agSt = { tab:"overview" };
function agentsProject(){ return rows("projects", p => p.name === "Agents" && !p.deleted)[0] || null; }
function agentPhase(id){ const p = agentsProject(); return p ? rows("phases", x => x.project_id === p.id && !x.deleted && x.agent_id === id)[0] || null : null; }
function agentRuns(id){
return rows("agent_runs", r => r.agent === id && !r.deleted && r.metrics && r.metrics.turns != null).sort(by(r => r.started_at || "")).reverse();
}
function agentWaste(m){ return (m.spill_reads || 0) + (m.doc_lookups || 0); }
function avg(list, f){ const v = list.map(f).filter(x => x != null && !isNaN(x)); return v.length ? v.reduce((a, b) => a + b, 0) / v.length : null; }
function fmtMin(ms){ return ms == null ? "—" : (ms / 60000 < 1 ? "<1" : (ms / 60000).toFixed(1)) + " min"; }
function agentPage(id){
const d = (typeof AGENT_DEFS !== "undefined" ? AGENT_DEFS : []).find(x => x.id === id);
if(!d) return [h("p", { class:"sub" }, "No agent called " + id + ". "), h("a", { href:"#/settings/agents" }, "Back to Agents")];
const ph = agentPhase(id), runs = agentRuns(id), tasks = ph ? rows("tasks", t => t.phase_id === ph.id && !t.deleted) : [];
const open = tasks.filter(t => t.status !== "Completed");
const r30 = runs.filter(r => Date.parse(r.started_at || "") > Date.now() - 30 * 864e5);
const ms = r => (r.metrics && r.metrics.duration_ms) || (Date.parse(r.ended_at) - Date.parse(r.started_at));
const tabs = [["overview", "Overview"], ["runs", "Runs · " + runs.length], ["issues", "Issues · " + open.length + " open"], ["changes", "Changes · " + d.history.length]];
const tab = tabs.some(t => t[0] === agSt.tab) ? agSt.tab : "overview";
const v = d.version || {};
return [
h("a", { href:"#/settings/agents", class:"ag-back" }, icon("left", "sm"), "All agents"),
h("div", { class:"ag-head" },
h("div", { class:"grow" }, h("div", { class:"ag-name" }, h("h2", null, AGENT_LABEL[id] || d.name), h("span", { class:"chip " + (agentOn(id) ? "jade" : "") }, agentOn(id) ? "On" : "Off")),
h("div", { class:"faint", style:"font-size:13px" }, [d.model ? d.model[0].toUpperCase() + d.model.slice(1) : "", d.max_turns ? "up to " + d.max_turns + " steps" : "", AGENT_START[id] || "",
v.hash ? "instructions " + v.hash + " · " + fmtDY(String(v.at || "").slice(0, 10)) : ""].filter(Boolean).join(" · "))),
h("button", { class:"btn primary", onclick:() => agentIssueModal(d, ph, null) }, icon("plus", "sm"), "Log an improvement")),
h("div", { class:"tiles" },
tile(r30.length, "Runs, last 30 days"), tile(fmtMin(avg(runs, ms)), "Average time"), tile(avg(runs, r => r.metrics.turns) != null ? Math.round(avg(runs, r => r.metrics.turns)) : "—", "Average steps"),
tile(avg(runs, r => r.metrics.cost_usd) != null ? agentMoney(avg(runs, r => r.metrics.cost_usd)) : "—", "Average cost"),
tile(runs.length ? (avg(runs, r => r.metrics.errors) || 0).toFixed(1) : "—", "Errors per run")),
h("nav", { class:"ag-tabs" }, tabs.map(t => h("button", { class:"ag-tab", "aria-current":tab === t[0] ? "true" : null, onclick:() => { agSt.tab = t[0]; scheduleRender(); } }, t[1]))),
tab === "overview" ? agentOverview(d, ph) : tab === "runs" ? agentRunsTab(d, ph, runs) : tab === "issues" ? agentIssuesTab(d, ph, tasks) : agentChangesTab(d)];
}
const CARD_FIELDS = [["purpose", "Purpose", "One line: the job it does"], ["inputs", "Inputs", "What it reads: calls, documents, briefs, the live app…"],
["outputs", "Outputs", "What it produces, and where it lands"], ["how", "How it runs", "What starts it, what it waits for, its limits"],
["good", "Good looks like", "How you judge a run: e.g. every pain has evidence, under 8 minutes, under $4"]];
function cardDefaults(d){
return { purpose:String(d.description).split(/(?<=[.!?])\s/)[0], inputs:"", outputs:"",
how:[AGENT_START[d.id] || "", d.model ? d.model + " model" : "", d.max_turns ? "up to " + d.max_turns + " steps" : ""].filter(Boolean).join(" · "), good:"" };
}
function agentOverview(d, ph){
const c = Object.assign(cardDefaults(d), (ph && ph.card) || {});
const meta = ph && ph.card && ph.card.updated_at ? "Updated " + fmtStamp(ph.card.updated_at) + (ph.card.by ? " by " + ph.card.by : "") : "Not edited yet: suggestions from its instructions";
return [card("Agent card", meta, h("dl", { class:"ag-card" }, CARD_FIELDS.map(f => [h("dt", null, f[1]),
h("dd", null, c[f[0]] ? c[f[0]] : h("span", { class:"faint" }, f[2]))])),
{ action:ph ? h("button", { class:"btn sm", onclick:() => agentCardModal(d, ph) }, icon("pencil", "sm"), "Edit") : null }),
card("What it can use", plural(d.tools.length, "tool"), h("div", { class:"ag-tools" }, d.tools.map(t => h("span", { class:"chip" }, agentToolName(t))))),
!ph ? h("p", { class:"sub" }, "This agent has no phase in the Agents project yet, so its card and issues can't be saved.") : null];
}
function agentCardModal(d, ph){
openModal(() => {
const c = Object.assign(cardDefaults(d), ph.card || {}), boxes = {};
const chk = h("input", { type:"checkbox", id:"ag-card-task", checked:true });
const save = () => {
const next = {}; CARD_FIELDS.forEach(f => next[f[0]] = boxes[f[0]].value.trim());
const changed = CARD_FIELDS.filter(f => (next[f[0]] || "") !== (c[f[0]] || "") && f[0] !== "purpose").map(f => f[1]);
update("phases", ph.id, { card:Object.assign({}, next, { updated_at:nowISO(), by:"Anand" }) });
if(chk.checked && changed.length) agentInsertTask(ph, "Update " + (AGENT_LABEL[d.id] || d.name) + "'s instructions to match its card (" + changed.join(", ") + ")",
"The agent card changed on " + todayISO() + ". Changed: " + changed.join(", ") + ".\n\n" + CARD_FIELDS.map(f => f[1] + ": " + (next[f[0]] || "(empty)")).join("\n") +
"\n\nClaude: update agents/" + d.id + ".md so the agent behaves as the card says, then close this task with the new instructions version.", "build");
closeModal(); commit(); toast(chk.checked && changed.length ? "Card saved, and an improvement task logged" : "Card saved");
};
return [modalHead("Agent card", AGENT_LABEL[d.id] || d.name),
h("div", { class:"modal-b" }, CARD_FIELDS.map(f => { boxes[f[0]] = h("textarea", { class:"inp", id:"ag-" + f[0], rows:f[0] === "purpose" ? 2 : 3, placeholder:f[2], value:c[f[0]] || "" });
return h("div", { class:"fld" }, h("label", { for:"ag-" + f[0] }, f[1]), boxes[f[0]]); }),
h("label", { class:"split", for:"ag-card-task", style:"gap:8px;font-size:13px" }, chk, "If inputs, outputs, how it runs or good-looks-like change, log a task for Claude to update its instructions")),
h("div", { class:"modal-f" }, h("button", { class:"btn", onclick:closeModal }, "Cancel"), h("button", { class:"btn primary", onclick:save }, "Save"))];
});
}
function agentRunsTab(d, ph, runs){
if(!runs.length) return card("Runs", "", empty("No measured runs yet. Each run from now on records its steps, time, cost and errors."));
const byV = {}; runs.forEach(r => { const k = (r.agent_version && r.agent_version.hash) || "before versions"; (byV[k] = byV[k] || []).push(r); });
const ms = r => (r.metrics && r.metrics.duration_ms) || (Date.parse(r.ended_at) - Date.parse(r.started_at));
const trend = Object.entries(byV).sort((a, b) => String((b[1][0].agent_version || {}).at || "").localeCompare(String((a[1][0].agent_version || {}).at || "")));
return [
card("Trend by instructions version", "is each change making it faster, cheaper, cleaner?", h("div", { class:"wrapx" }, h("table", { class:"tbl" },
h("thead", null, h("tr", null, h("th", null, "Version"), h("th", { class:"num" }, "Runs"), h("th", { class:"num" }, "Avg steps"), h("th", { class:"num" }, "Avg time"), h("th", { class:"num" }, "Avg cost"), h("th", { class:"num" }, "Errors / run"), h("th", { class:"num" }, "Wasted steps / run"))),
h("tbody", null, trend.map(([k, l]) => h("tr", null, h("td", { class:"mono" }, k + (l[0].agent_version && l[0].agent_version.at ? " · " + fmtDY(String(l[0].agent_version.at).slice(0, 10)) : "")),
h("td", { class:"num" }, l.length), h("td", { class:"num" }, Math.round(avg(l, r => r.metrics.turns))), h("td", { class:"num" }, fmtMin(avg(l, ms))),
h("td", { class:"num" }, avg(l, r => r.metrics.cost_usd) != null ? agentMoney(avg(l, r => r.metrics.cost_usd)) : "—"),
h("td", { class:"num" }, (avg(l, r => r.metrics.errors) || 0).toFixed(1)), h("td", { class:"num" }, (avg(l, r => agentWaste(r.metrics)) || 0).toFixed(1)))))))),
card("Every run", plural(runs.length, "run"), h("div", { class:"wrapx" }, h("table", { class:"tbl" },
h("thead", null, h("tr", null, h("th", null, "When"), h("th", null, "Project"), h("th", null, "Outcome"), h("th", { class:"num" }, "Steps"), h("th", { class:"num" }, "Time"), h("th", { class:"num" }, "Cost"), h("th", { class:"num" }, "Errors"), h("th", { class:"num" }, "Wasted"), h("th", null, "Version"), h("th", null, ""))),
h("tbody", null, runs.map(r => { const m = r.metrics, p = byId("projects", r.project_id);
return h("tr", null, h("td", { style:"white-space:nowrap" }, fmtStamp(r.started_at)), h("td", null, p ? h("a", { href:"#/projects/" + p.id + "/overview" }, p.name) : "—"),
h("td", null, h("span", { class:"chip" + (/done|finished/.test(r.outcome) ? " jade" : /fail|stop/.test(r.outcome) ? " red" : " gold") }, r.outcome || "—")),
h("td", { class:"num" }, m.turns), h("td", { class:"num" }, fmtMin(ms(r))), h("td", { class:"num" }, m.cost_usd != null ? agentMoney(m.cost_usd) : "—"),
h("td", { class:"num" + (m.errors ? " red-t" : "") }, m.errors), h("td", { class:"num", title:(m.spill_reads || 0) + " re-reads of oversized results · " + (m.doc_lookups || 0) + " doc lookups" }, agentWaste(m)),
h("td", { class:"mono" }, (r.agent_version && r.agent_version.hash) || "—"),
h("td", null, ph ? h("button", { class:"btn sm ghost", onclick:() => agentIssueModal(d, ph, r) }, "Log issue") : null)); })))))];
}
function agentIssuesTab(d, ph, tasks){
if(!ph) return card("Issues", "", empty("This agent has no phase in the Agents project yet."));
const order = { "In progress":0, "To do":1, "Suggested":2, "Waiting":3, "Blocked":4, "To Test":5, "Completed":6 };
const list = tasks.slice().sort((a, b) => (order[a.status] ?? 9) - (order[b.status] ?? 9));
return card("Issues and improvements", plural(tasks.filter(t => t.status !== "Completed").length, "open"),
list.length ? h("ul", { class:"tl" }, list.map(t => h("li", { class:"click", onclick:() => openTask(t.id) }, statusPill(t.status), h("span", { class:"grow" }, t.name),
h("span", { class:"faint mono", style:"font-size:11.5px" }, fmtDY(t.due))))) : empty("Nothing logged yet."),
{ action:h("button", { class:"btn sm primary", onclick:() => agentIssueModal(d, ph, null) }, icon("plus", "sm"), "Log an improvement") });
}
function agentChangesTab(d){
return card("Instructions history", "agents/" + d.id + ".md", d.history.length ? h("ul", { class:"tl" }, d.history.map((c, i) => h("li", null,
h("span", { class:"chip mono" + (i === 0 ? " jade" : "") }, c.hash), h("span", { class:"grow" }, c.subject), h("span", { class:"faint mono", style:"font-size:11.5px" }, fmtDY(String(c.at).slice(0, 10))),
h("a", { href:"https://github.com/Anand-orbit-2026/orbit/commit/" + c.hash, target:"_blank", rel:"noopener" }, icon("external", "xs"))))) : empty("No history."));
}
function agentInsertTask(ph, name, description, kind){
const t = insert("tasks", { project_id:ph.project_id, phase_id:ph.id, parent_id:"", name:name.slice(0, 160), description, type:"TASK", status:"Suggested", priority:"", at_risk:false,
start:todayISO(), due:todayISO(), start_actual:"", due_actual:"", assignee_ids:["u-anand"], follower_ids:[], effort_min:0, private:true, csat_enabled:false,
doer:"anand", kind:kind || "fix", autonomy:"draft" });
logAct(ph.project_id, t.id, "logged “" + t.name + "”");
return t;
}
function agentIssueModal(d, ph, run){
if(!ph){ toast("This agent has no phase in the Agents project yet"); return; }
openModal(() => {
const runs = agentRuns(d.id).slice(0, 15);
const ti = h("input", { class:"inp", id:"ai-title", placeholder:run ? "What went wrong in this run" : "e.g. Spends 6 steps re-reading oversized inputs" });
const wh = h("textarea", { class:"inp", id:"ai-what", rows:4, placeholder:"What happened, what you expected, and why it matters" });
const ga = h("input", { class:"inp", id:"ai-gain", placeholder:"Expected gain, e.g. about 6 fewer steps per run, or the client page stops showing internal notes" });
const kind = sel([["fix", "Issue (something went wrong)"], ["build", "Improvement (make it better)"]], "fix", () => {}, "");
const rs = sel([["", "No specific run"]].concat(runs.map(r => [r.id, fmtStamp(r.started_at) + " · " + ((byId("projects", r.project_id) || {}).name || "—") + " · " + r.metrics.turns + " steps"])), run ? run.id : "", () => {}, "");
const ok = () => {
if(!ti.value.trim()){ toast("Give it a short title"); return; }
const r = byId("agent_runs", rs.value), m = r && r.metrics;
const ev = r ? "\n\nRun: " + r.id + " · " + fmtStamp(r.started_at) + " · " + ((byId("projects", r.project_id) || {}).name || "") + " · " + m.turns + " steps, " + fmtMin(m.duration_ms) + ", " +
(m.cost_usd != null ? agentMoney(m.cost_usd) : "") + ", " + m.errors + " errors (" + m.denied + " blocked), " + m.spill_reads + " oversized re-reads, " + m.doc_lookups + " doc lookups · instructions " +
((r.agent_version && r.agent_version.hash) || "?") + "\nLog: " + ((r.log || (r.extra && r.extra.log)) || "~/Documents/orbit-crew/runs/" + r.task_id + "/" + r.id + ".stream.jsonl") : "";
const t = agentInsertTask(ph, ti.value.trim(), wh.value.trim() + (ga.value.trim() ? "\n\nExpected gain: " + ga.value.trim() : "") + ev, kind.value);
closeModal(); commit(); toast("Logged in " + (AGENT_LABEL[d.id] || d.name) + "'s issues"); agSt.tab = "issues"; scheduleRender();
};
return [modalHead("Log an improvement", AGENT_LABEL[d.id] || d.name),
h("div", { class:"modal-b" }, h("div", { class:"fld" }, h("label", { for:"ai-title" }, "Title"), ti), h("div", { class:"fld" }, h("label", { for:"ai-what" }, "What happened"), wh),
h("div", { class:"row" }, h("div", { class:"fld" }, h("label", null, "Kind"), kind), h("div", { class:"fld" }, h("label", null, "From which run"), rs)),
h("div", { class:"fld" }, h("label", { for:"ai-gain" }, "Expected gain"), ga)),
h("div", { class:"modal-f" }, h("button", { class:"btn", onclick:closeModal }, "Cancel"), h("button", { class:"btn primary", onclick:ok }, "Log it"))];
});
}
/* =====================================================================
Boot
===================================================================== */
(function boot(){
if(typeof sbCatchLink === "function") sbCatchLink(); // a Supabase sign-in link just brought us back
db = loadDb();
if(!db || !db.meta || !db.meta.seeded) seedDb();
if(!session.user || !byId("users", session.user)){ session.user = "u-anand"; saveSession(); }
window.addEventListener("hashchange", render);
if(!location.hash || location.hash === "#" || location.hash === "#/") history.replaceState(null, "", "#/home");
render();
if(syncConfigured()) sync();
if(sbOn() && sbSignedIn()) sbLive();
window.addEventListener("online", sync);
document.addEventListener("visibilitychange", () => { if(document.visibilityState === "visible"){ scheduleRender(); if(syncConfigured()) sync(); if(typeof sbLiveCheck === "function") sbLiveCheck(); } });
/* Another tab saved — pick it up. */
window.addEventListener("storage", e => { if(e.key === LS_DB){ db = loadDb() || db; scheduleRender(); } });
setInterval(() => { if(!typing()) quietRender(); }, 60000);
/* Pick up what Claude and other devices wrote, while this tab is in view. */
/* On Supabase, Realtime delivers changes; this becomes a 5-minute safety check. */
setInterval(() => { if(!syncConfigured() || document.visibilityState !== "visible" || syncing) return;
if(sbOn()) sbLiveCheck();
if(sbOn() && sbLiveUp && Date.now() - sbLastOk < 300000 && !read(LS_DIRTY, []).length) return;
sync(); }, 45000);
})();