diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index e71646d..24c3d95 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -153,6 +153,7 @@ /* board */ /* every column the same width, full or empty; the board scrolls sideways when they don't fit */ .board { display: grid; grid-template-columns: repeat(7, minmax(132px, 1fr)); gap: 8px; overflow-x: auto; padding-bottom: 6px; } + .board-peek { grid-template-columns: repeat(var(--cols), minmax(150px, 1fr)); margin-top: 6px; } .col { min-width: 0; background: var(--well); border: 1px solid var(--line); border-radius: 8px; padding: 8px; min-height: 80px; } .col.none h3 { color: var(--muted); } button.tcard { cursor: pointer; text-align: left; width: 100%; height: auto; margin: 0 0 6px; font: inherit; font-size: 12px; font-weight: 400; color: var(--fg); background: var(--surface); border: 1px solid var(--line); display: block; white-space: normal; transition: none; } @@ -287,6 +288,34 @@ @media (max-width: 900px) { .drawer-bar { margin: 0 -16px 12px; padding: 12px 12px 10px 16px; } } .drawer dl { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 6px 12px; font-size: 12.5px; margin: 10px 0 16px; } .drawer dt { color: var(--muted); } + .drawer-bar h2 { display: flex; align-items: center; gap: 8px; min-width: 0; } + .dtype { display: inline-flex; align-items: center; gap: 6px; flex: none; color: var(--muted); } + .dtype .badge { margin: 0; background: var(--well); color: var(--fg); border: 1px solid var(--line); } + .dtitle { min-width: 0; } + .attrs { margin: 2px 0 18px; padding-bottom: 6px; border-bottom: 1px solid var(--line); } + .attrs dl { margin: 8px 0 10px; } + .attrs dd .links { margin: 0; } + .attrs-h { margin: 6px 0 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); } + .attr-k { position: relative; cursor: help; border-bottom: 1px dotted color-mix(in srgb, var(--muted) 60%, transparent); } + .attr-tip { display: none; position: absolute; left: 0; top: calc(100% + 4px); z-index: 5; width: 280px; padding: 7px 9px; border-radius: 6px; background: var(--raised); color: var(--fg); border: 1px solid var(--line); box-shadow: var(--shadow); font-size: 12px; line-height: 1.45; font-weight: 400; text-transform: none; letter-spacing: 0; white-space: normal; } + .attr-k:hover .attr-tip, .attr-k:focus-visible .attr-tip, .attr-k:focus .attr-tip { display: block; } + .drawer h3 { margin: 18px 0 6px; } + .hist-h { padding-top: 12px; border-top: 1px solid var(--line); } + .mini-chart svg { width: 100%; height: auto; display: block; } + .diagram-wrap { overflow-x: auto; margin: 4px 0 8px; } + .diagram-wrap figure { margin: 0; } + .md.tldr { font-size: 13px; } + .md.tldr ul { margin: 2px 0; } + .phase-strip { display: flex; height: 12px; border-radius: 6px; overflow: hidden; background: var(--well); } + .phase-strip .ph { display: block; height: 100%; } + .ph-think { background: var(--blue); } .ph-make { background: var(--green); } .ph-wait { background: var(--amber); } .ph-stop { background: var(--red); } .ph-other { background: var(--muted); } + .phase-legend { margin-top: 6px; } .phase-legend i { border-top: 0; height: 8px; width: 8px; border-radius: 2px; } + .gates-table { border-collapse: collapse; font-size: 12px; } .gates-table th, .gates-table td { padding: 3px 10px 3px 0; text-align: left; white-space: nowrap; } + .gate-dot.ok { color: var(--green-fg); } .gate-dot.fail { color: var(--red-fg); } .gate-dot.flaky { color: var(--amber-fg); } + .nums { border-collapse: collapse; font-size: 12.5px; } .nums th, .nums td { padding: 3px 14px 3px 0; text-align: left; vertical-align: top; } + .status-trail { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } + .handoff pre { white-space: pre-wrap; margin: 0; font-size: 12px; } + .tcard.inert { cursor: default; } .drawer dd { margin: 0; } .drawer ul { margin: 4px 0; padding-left: 18px; } .tl { list-style: none; padding: 0; margin: 8px 0; border-left: 1px solid var(--line-2); } @@ -631,14 +660,17 @@

Checks before merge the automated } // ---- burn-up chart (hand-built SVG) ---- -function burnup(items, now) { - const el = $('burnup'); - $('legend').hidden = !items.some(isDone); - if (!items.length) { el.innerHTML = empty('No tickets yet. The chart starts when the supervisor or a planner adds them.'); $('forecast').textContent = ''; return; } +// ui: where to draw (the Progress section's chart by default, or a sidepeek's). +const PROGRESS_CHART = () => ({ el: $('burnup'), legend: $('legend'), tip: $('tip'), card: $('burnup-card'), forecast: $('forecast') }); +function burnup(items, now, ui = PROGRESS_CHART()) { + const el = ui.el; + const setForecast = (s) => { if (ui.forecast) ui.forecast.textContent = s; }; + ui.legend.hidden = !items.some(isDone); + if (!items.length) { el.innerHTML = empty('No tickets yet. The chart starts when the supervisor or a planner adds them.'); setForecast(''); return; } const scopePts = items.reduce((a, t) => a + t.weight, 0); if (!items.some(isDone)) { el.innerHTML = empty(`Nothing merged yet: ${items.length} ticket${items.length > 1 ? 's' : ''}, ${scopePts} point${scopePts > 1 ? 's' : ''} of scope. The chart and the forecast start at the first merge.`); - $('forecast').textContent = `0/${scopePts} pts`; + setForecast(`0/${scopePts} pts`); return; } const evs = []; @@ -681,10 +713,10 @@

Checks before merge the automated svg += `${fmtDate(fc.eta)}`; } svg += `${fc.eta && !fc.finished && x(fc.eta) - x(now) < 70 ? '' : `today`}`; - svg += ``; - svg += ``; + svg += ``; + svg += ``; el.innerHTML = svg; - const svgEl = el.querySelector('svg'), hit = el.querySelector('#hit'), xh = el.querySelector('#xh'), tip = $('tip'); + const svgEl = el.querySelector('svg'), hit = el.querySelector('.hit'), xh = el.querySelector('.xh'), tip = ui.tip; hit.addEventListener('mousemove', (e) => { const r = svgEl.getBoundingClientRect(); const vx = ((e.clientX - r.left) / r.width) * W; @@ -694,15 +726,15 @@

Checks before merge the automated xh.setAttribute('x1', vx); xh.setAttribute('x2', vx); xh.setAttribute('opacity', '.3'); tip.style.display = 'block'; tip.innerHTML = ts > now ? `${fmtDate(ts)}
forecast` : `${fmtDate(ts)}
done ${p[2]} · scope ${p[1]}`; - const box = $('burnup-card').getBoundingClientRect(); + const box = ui.card.getBoundingClientRect(); tip.style.left = `${Math.min(e.clientX - box.left + 12, box.width - 140)}px`; tip.style.top = `${e.clientY - box.top - 10}px`; }); hit.addEventListener('mouseleave', () => { tip.style.display = 'none'; xh.setAttribute('opacity', '0'); }); const range = fc.best && fc.worst && fc.worst - fc.best > DAY ? ` (range ${fmtDate(fc.best)} – ${fmtDate(fc.worst)})` : ''; - $('forecast').textContent = fc.finished ? `done: ${fc.done} of ${fc.scope} points` : fc.eta + setForecast(fc.finished ? `done: ${fc.done} of ${fc.scope} points` : fc.eta ? `${fc.done}/${fc.scope} pts · ETA ${fmtDate(fc.eta)}${range} · ${fc.perDay.toFixed(1)} pts/day` - : `${fc.done}/${fc.scope} pts · ETA –`; + : `${fc.done}/${fc.scope} pts · ETA –`); } // ---- widgets ---- @@ -761,17 +793,22 @@

Checks before merge the automated } function board(items, crit) { $('planners').innerHTML = plannerCards(DATA.planners || []); - $('board-cols').innerHTML = COLUMNS.map(([name]) => { + $('board-cols').innerHTML = boardCols(items, crit); +} +// The board's columns and cards, for the Board and for a sidepeek's tickets (only: the columns that +// have cards, so a few tickets don't sit in seven mostly empty columns). +function boardCols(items, crit, { only = false, inert = false } = {}) { + return COLUMNS.filter(([name]) => !only || items.some((t) => t.stage !== 'superseded' && columnOf(t) === name)).map(([name]) => { let cards = items.filter((t) => t.stage !== 'superseded' && columnOf(t) === name).sort((a, b) => (a.wave ?? 99) - (b.wave ?? 99)); const total = cards.length; // finished columns show the most recent few; the rest are counted if (['Merged', 'Released'].includes(name)) cards = cards.sort((a, b) => (b.merged_at || '').localeCompare(a.merged_at || '')).slice(0, 5); return `

${name}${total}

${cards.map((t) => ` - `).join('')}${total > cards.length ? `
+ ${total - cards.length} more
` : ''}
`; + `).join('')}${total > cards.length ? `
+ ${total - cards.length} more
` : ''}`; }).join(''); } @@ -817,7 +854,6 @@

Checks before merge the automated const milestonesOf = (g) => g?.forecast?.milestones || []; const glink = (gid) => { const g = goalOf(gid); return g ? `${esc(g.title)}` : `${esc(gid)}`; }; const mlink = (gid, n, title) => `${esc(title ?? milestonesOf(goalOf(gid)).find((m) => m.n === Number(n))?.title ?? `milestone ${n}`)}`; -const boardLink = (view) => ` What's left of it on the board · its burn-up`; // An id in text links to its panel: goals, lessons, acts, problems, messages (threads), observations. function idLink(k, id) { const href = { g: 'goal', k: 'lesson', a: 'act', d: 'decision', p: 'problem', m: 'thread' }[k]; @@ -1772,234 +1808,621 @@

${esc(g.title for (const id of ['dec-who', 'dec-class', 'dec-corrected', 'dec-mechanical']) $(id).addEventListener('change', () => { if (DATA) renderDecisions(DATA.ooda); }); -// ---- drawer: a ticket with everything that bears on it, a settled question, or an artifact ---- +// ---- sidepeek: every object opens the same way. The bar: its type's icon and name, then its title. +// Then its attributes: ID first, its stored fields (from its record, so a field added to the schema +// shows up with no change here; a known field gets a label and its links, any other a plain value; +// each key's tooltip is the schema's words for the field), then what is computed from other records. +// Then what is particular to it. Its history comes last. +// The record, the history and the particulars come from one /api/peek per object, cached, fetched +// again on each refresh; the drawer is redrawn from DATA and that peek, and left alone when nothing +// it shows changed. +const TYPE_META = { + ticket: ['ticket', 'Ticket'], goal: ['target', 'Goal'], feature: ['layers', 'Feature'], story: ['book-open', 'Story'], release: ['tag', 'Release'], + wave: ['waves', 'Wave'], milestone: ['milestone', 'Milestone'], act: ['zap', 'Act'], decision: ['scale', 'Decision'], retro: ['history', 'Retro'], + lesson: ['lightbulb', 'Knowledge'], problem: ['triangle-alert', 'Problem'], thread: ['messages-square', 'Message'], planner: ['map', 'Planner'], + q: ['circle-help', 'Question'], file: ['file-text', 'File'], +}; +let SCHEMA_DOCS = null; +async function loadSchemaDocs() { + try { const r = await fetch('/api/schema'); if (r.ok) SCHEMA_DOCS = await r.json(); } catch {} + if (DRAWER.kind) redraw(); +} +const tipFor = (type, k) => SCHEMA_DOCS?.types?.[type]?.fields?.[k] ?? (/[.[]/.test(k) ? '' : SCHEMA_DOCS?.envelope?.[k]) ?? ''; +let TIP_N = 0; +const attrKey = (label, tip) => { + if (!tip) return esc(label); + const id = `attr-tip-${++TIP_N}`; + return `${esc(label)}${esc(tip)}`; +}; +const attrRow = (label, html, tip) => (html === '' || html == null || html === false ? '' : `
${attrKey(label, tip)}
${html}
`); +const ISO_AT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/; +const longList = (xs) => xs.length > 4 || xs.some((x) => String(x).length > 48); +// Any value, plainly: text links the ids in it, lists become lists, objects their own key/value pairs. +function fmtAny(v) { + if (emptyVal(v)) return ''; + if (typeof v === 'boolean') return v ? 'yes' : 'no'; + if (typeof v === 'number') return esc(v); + if (typeof v === 'string') return ISO_AT.test(v) ? esc(when(v)) : linkify(v); + if (Array.isArray(v)) { + const items = v.map((x) => (x !== null && typeof x === 'object' ? fmtObj(x) : fmtAny(x))).filter(Boolean); + return v.some((x) => x !== null && typeof x === 'object') || longList(v) ? `
    ${items.map((x) => `
  • ${x}
  • `).join('')}
` : items.join(', '); + } + return fmtObj(v); +} +const fmtObj = (o) => Object.entries(o).filter(([, x]) => !emptyVal(x)).map(([k, x]) => `${esc(words(k).toLowerCase())} ${fmtAny(x)}`).join(' · '); +// A value in one line, for a history entry: long text and lists are cut short. +const fmtShort = (v) => { + if (emptyVal(v)) return 'nothing'; + if (typeof v === 'string') return linkify(v.length > 120 ? `${v.slice(0, 120)}…` : v); + if (Array.isArray(v) && v.every((x) => typeof x !== 'object')) return v.length > 6 ? `${v.slice(0, 6).map((x) => linkify(String(x))).join(', ')} and ${v.length - 6} more` : v.map((x) => linkify(String(x))).join(', '); + if (Array.isArray(v)) return `${v.length} item${v.length === 1 ? '' : 's'}`; + return fmtAny(v); +}; +const list = (xs, f) => (xs?.length ? (xs.length > 1 && longList(xs) ? `
    ${xs.map((x) => `
  • ${f(x)}
  • `).join('')}
` : xs.map(f).join(', ')) : ''); +const linkList = (xs) => list(xs, (x) => (ticketMap().has(x) ? tlink(x) : linkify(String(x)))); +const share = (x) => `${Math.round(x * 100)}%`; +const fmtDay = (d) => (d ? esc(new Date(Date.parse(d)).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' })) : ''); +const unitOf = (u) => (u ? (u === '%' ? '%' : ` ${esc(u)}`) : ''); +const decisionTitle = (id) => (DATA.ooda.decisions || []).find((r) => r.id === id && r.kind === 'decision')?.what ?? null; +// Fields any type may carry, then per type: a label where the field's name isn't plain enough, an +// order where the schema's isn't the one to read in, fields to leave out (hashes and bookkeeping the +// body shows in full), and how to show values that are links or codes. A formatter gets the value +// and the drawer's context (the record, its set_by, and what the opener passed). +const ATTR_COMMON = { + label: { refs: 'About', approved_by: 'Approved by', depends_on: 'Waits on', done_when: 'Done when', non_goals: 'Not in scope', superseded_by: 'Replaced by', one_way_doors: 'One-way doors', why_asked: 'Why asked', source_ref: 'Source', fix_hint: 'Fix', plan_after: 'Plan after', held: 'On hold' }, + fmt: { + refs: linkList, evidence: linkList, blocks: linkList, threatens: (v) => list(v, glink), goals: (v) => list(v, glink), + approved_by: (v) => approvedBy(v), decision: (v) => (typeof v === 'string' ? dlink(v, decisionTitle(v)) : fmtObj(v)), confidence: (v) => (typeof v === 'number' ? share(v) : fmtAny(v)), + rung: (v) => esc(RUNG_WORDS[v] || v), ceiling: (v) => esc(RUNG_WORDS[v] || v), scope: (v) => esc(scopeWords(v)), + corrected: (v, c) => `${esc(v.reason)}${v.severity != null ? ` (severity ${esc(v.severity)})` : ''}${c.set_by?.corrected ? ` · by ${esc(who(c.set_by.corrected.by))}, ${esc(when(c.set_by.corrected.at))}` : ''}`, + horizon: fmtDay, deadline: fmtDay, ticket: (v) => tlink(v), feature: (v) => (v === 'chores' ? 'chores (upkeep, not a feature)' : flink(v)), + links: (v) => ``, + }, +}; +const ATTR = { + ticket: { + fmt: { + size: (v) => (v === 'M' ? 'M (medium)' : v === 'S' ? 'S (small)' : esc(v)), risk: (v) => (v === 'high' ? 'high (two reviewers)' : esc(v)), + serves: (v) => v.map((x) => `${slink(x)} ${esc(String(DATA.progress?.stories?.[x]?.text ?? '').slice(0, 90))}`).join('
'), + depends_on: (v) => list(v, tlink), superseded_by: (v) => tlink(v), touches: (v) => v.map((x) => `${esc(x)}`).join(', '), + done_when: (v) => `
    ${v.map((x) => `
  • ${linkify(x)}
  • `).join('')}
`, + }, + }, + goal: { + order: ['title', 'outcome', 'status', 'priority', 'horizon', 'measure', 'milestones', 'preconditions', 'non_goals'], + fmt: { + measure: (m) => `${esc(m.description ?? '')}${m.target != null ? `: ${esc(m.target)}${unitOf(m.unit)}` : ''}${m.direction ? ` (${m.direction === 'down' ? 'lower' : 'higher'} is better)` : ''}`, + milestones: (v, c) => `
    ${v.map((m, i) => `
  1. ${mlink(c.id, i + 1, m.title)}${m.target != null ? `: ${esc(m.target)}` : ''}${m.by ? ` by ${fmtDay(m.by)}` : ''}
  2. `).join('')}
`, + preconditions: (v, c) => `
    ${v.map((x, i) => `
  1. ${linkify(x)}${(c.unplanned || []).includes(i + 1) ? ' no plan yet' : ''}
  2. `).join('')}
`, + non_goals: (v) => `
    ${v.map((x) => `
  • ${linkify(x)}
  • `).join('')}
`, + hold: (h) => (h ? `${esc(words(h.mode))}${h.reason ? `: ${linkify(h.reason)}` : ''}` : 'run'), + decision: (d) => `${esc(words(d.verdict))}${d.notes ? `: ${linkify(d.notes)}` : ''}`, + approved: (v) => qlink(v), plan_after: (v) => (Array.isArray(v) ? list(v, (x) => (typeof x === 'string' ? glink(x) : fmtObj(x))) : fmtAny(v)), + }, + }, + feature: { + order: ['title', 'goals', 'stories', 'preconditions', 'design', 'notes'], + fmt: { + stories: (v) => `
    ${v.map((s) => `
  • ${slink(s.id)} ${esc(s.text ?? '')}${s.release ? ` (${rlink(s.release)})` : ''}
  • `).join('')}
`, + design: (v) => `
${esc(v)}`, + }, + }, + question: { + order: ['question', 'kind', 'class', 'scope', 'ticket', 'goal', 'recommendation', 'options', 'default', 'deadline', 'answer', 'status'], + hide: ['review', 'result', 'secrets', 'secrets_posted', 'choices', 'status'], + fmt: { why_asked: (w) => `${w.rung ? `${esc(RUNG_WORDS[w.rung])}` : ''}${w.ceiling ? `, at most ${esc(RUNG_WORDS[w.ceiling])}` : ''}${(w.reasons || []).length ? ` · ${esc(w.reasons.map(words).join(', '))}` : ''}`, autonomy: (a) => `${esc(words(a.class))}: ${esc(RUNG_WORDS[a.rung])}${a.scope ? ` (${esc(a.scope)})` : ''}`, goal: (v) => glink(v), re: (v) => linkify(v) }, + }, + decision: { order: ['what', 'class', 'scope', 'authority', 'approved_by', 'rung', 'ceiling', 'options', 'rationale', 'confidence', 'refs', 'reversible'], fmt: { authority: (v) => esc(AUTHORITY_LABEL[v] || v) } }, + act: { order: ['what', 'class', 'scope', 'decision', 'approved_by', 'rung', 'refs', 'reversible'], fmt: { id_block: (b) => `${esc(b.feature)}: ${esc(b.from)} to ${esc(b.to)}` } }, + knowledge: { order: ['statement', 'kind', 'status', 'scope', 'confidence', 'valence', 'evidence'], fmt: { valence: (v) => (v < 0 ? `${esc(v)} (something to avoid)` : esc(v)) } }, + problem: { order: ['title', 'status', 'severity', 'realized', 'threatens', 'assigned_to', 'detail', 'refs'], fmt: { realized: (v) => (v ? 'yes: an issue' : 'no: a risk'), assigned_to: (v) => linkify(v), severity: (v) => `${esc(v)}` } }, + message: { fmt: { to: (v) => esc(who(v)), re: (v) => linkify(v) } }, + observation: { order: ['kind', 'subject', 'detail', 'refs'], label: { source: 'Noticed by', source_ref: 'From file' }, fmt: { subject: (v) => subjectLink(v), source: (v) => esc(who(v)), source_ref: (v) => `${esc(v)}` } }, + story: { fmt: { release: (v) => rlink(v) } }, + milestone: { fmt: { by: fmtDay } }, +}; +const AUTHORITY_LABEL = { rung: 'its rung', answer: 'your answer', default: 'a default', 'standing-rule': 'a standing rule', constitution: 'the constitution' }; +function storedRows(type, fields, docType, ctx, prefix = '') { + const spec = ATTR[type] || {}; + const schemaOrder = Object.keys(SCHEMA_DOCS?.types?.[docType]?.fields || {}).filter((k) => k.startsWith(prefix)).map((k) => k.slice(prefix.length)); + const keys = Object.keys(fields).filter((k) => !emptyVal(fields[k]) && !(spec.hide || []).includes(k)); + const rank = (k) => { const a = (spec.order || []).indexOf(k); if (a >= 0) return a; const b = schemaOrder.indexOf(k); return b >= 0 ? 100 + b : 1000; }; + keys.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)); + return keys.map((k) => { + const f = spec.fmt?.[k] ?? ATTR_COMMON.fmt[k] ?? fmtAny; + let html; + try { html = f(fields[k], ctx); } catch { html = fmtAny(fields[k]); } + return attrRow(spec.label?.[k] ?? ATTR_COMMON.label[k] ?? words(k), html, tipFor(docType, `${prefix}${k}`)); + }).join(''); +} +// The attributes block. peek: the object's peek (undefined while it loads); pick: the part of the +// record that is this object (a story inside its feature, with prefix 'stories[].' for its tooltips); +// computed: [label, html, tip] rows worked out from other records; ctx: what formatters may need. +function attrs({ id, idTip, peek, type, docType, pick = null, prefix = '', computed = [], ctx = {}, noRecord = false }) { + TIP_N = 0; + const r = peek?.record ?? null; + const fields = r ? (pick ? pick(r.fields) : r.fields) : null; + const c = { id, record: r, set_by: r?.set_by ?? {}, ...ctx }; + const made = r && !pick ? attrRow('Created', `${esc(when(r.created_at))} by ${esc(who(r.created_by))}`, 'When the record was created, and by whom.') + (r.updated_at ? attrRow('Last changed', `${esc(when(r.updated_at))} by ${esc(who(r.updated_by))}`, 'The latest change to any of its fields, and who made it.') : '') : ''; + const stored = fields ? storedRows(type, fields, docType ?? type, c, prefix) + : noRecord ? '' : peek === undefined ? '
…
loading
' : peek?.transient ? '
Record
could not load it just now
' : '
Record
not in the log
'; + const comp = computed.filter((x) => x && x[1]).map(([l, h, t]) => attrRow(l, h, t)).join(''); + return `
${attrRow('ID', `${esc(id)}`, idTip ?? tipFor('_', 'id'))}${stored}${made}
${comp ? `

Computed

${comp}
` : ''}
`; +} + +// ---- peeks: one fetch per object, kept, fetched again on each refresh ---- +const PEEKS = new Map(); +const PEEK_FLIGHT = new Set(); +// A failure that isn't "no such object" (the server restarting, a spawn timing out) is not kept: +// the drawer says it couldn't load and tries again on the next refresh or open. +const PEEK_FAIL = new Map(); +const peekKey = (kind, id) => `${kind}:${id}`; +function peekOf(kind, id, q = {}) { + const k = peekKey(kind, id); + if (PEEKS.has(k)) return PEEKS.get(k); + const fail = PEEK_FAIL.get(k); + if (!fail || Date.now() - fail.at > 10000) fetchPeek(kind, id, q); + return fail ? { error: fail.error, transient: true } : undefined; +} +async function fetchPeek(kind, id, q = {}) { + const k = peekKey(kind, id); + if (PEEK_FLIGHT.has(k)) return; + PEEK_FLIGHT.add(k); + if (DRAWER.kind === kind && DRAWER.id === id) DRAWER.q = q; + try { + const r = await fetch(`/api/peek?${new URLSearchParams({ kind, id, ...q })}`); + const j = await r.json().catch(() => ({})); + if (r.ok) { PEEKS.set(k, j); PEEK_FAIL.delete(k); } + else if (r.status === 404) { PEEKS.set(k, { error: j.error || 'not found', missing: true }); PEEK_FAIL.delete(k); } + else if (!PEEKS.has(k)) PEEK_FAIL.set(k, { error: j.error || `HTTP ${r.status}`, at: Date.now() }); + } catch (e) { if (!PEEKS.has(k)) PEEK_FAIL.set(k, { error: String(e?.message ?? e), at: Date.now() }); } finally { PEEK_FLIGHT.delete(k); } + // a drawer may draw another object's peek too (a thread its ticket's): redraw whatever is open + redraw(); +} +// On each data refresh: the open object's peek again (not one that wasn't found), then a redraw. +function refreshDrawer() { + if (!DRAWER.kind || $('drawer').hidden) return; + for (const [kind, id, q] of [[DRAWER.kind, DRAWER.id, DRAWER.q || {}], ...(DRAWER.deps || [])]) { + const p = PEEKS.get(peekKey(kind, id)); + if (!p?.missing) fetchPeek(kind, id, q || {}); + } + if (DRAWER.kind === 'file' && DRAWER.url) refetchFile(DRAWER.url); + redraw(); +} + +// ---- drawer ---- let DRAWER = { kind: null, id: null }; let lastFocus = null; +let LAST_HTML = ''; +let AFTER = []; +const OPENERS = () => ({ ticket: openTicket, goal: openGoal, feature: openFeature, story: openStory, release: openRelease, wave: openWave, milestone: openMilestone, act: openAct, decision: openDecision, retro: openRetro, lesson: openLesson, problem: openProblem, thread: openThread, planner: openPlanner, q: openQuestion, file: openFile }); +// Not under someone mid-action: typing or choosing in the drawer, a confirm step showing, a request +// in flight. The next refresh draws it. +function busyDrawer() { + const body = $('drawer-body'); + const ae = document.activeElement; + return !!(body.querySelector('.confirm, [aria-busy="true"]') || (ae && body.contains(ae) && ['TEXTAREA', 'SELECT', 'INPUT'].includes(ae.tagName))); +} +function redraw() { if (DRAWER.kind && !$('drawer').hidden && !busyDrawer()) OPENERS()[DRAWER.kind]?.(DRAWER.arg ?? DRAWER.id); } +// Where focus was, as a selector that finds the same control after a redraw. +function focusKey(el) { + if (!el || !$('drawer-body').contains(el)) return null; + if (el.id) return `#${CSS.escape(el.id)}`; + for (const a of ['href', 'data-ticket', 'data-hold-id', 'data-mdview', 'aria-describedby']) if (el.getAttribute(a)) return `${el.tagName.toLowerCase()}[${a}="${CSS.escape(el.getAttribute(a))}"]`; + return null; +} +// Draw the drawer; the same HTML again leaves it as it is (charts, diagrams, scroll and focus kept). function showDrawer(html) { - if ($('drawer').hidden) lastFocus = document.activeElement; + const after = AFTER; + AFTER = []; + const opening = $('drawer').hidden; + if (!opening && html === LAST_HTML) return; + const fresh = opening || LAST_HTML === ''; + const keep = fresh ? null : focusKey(document.activeElement); + LAST_HTML = html; + if (opening) lastFocus = document.activeElement; $('drawer-body').innerHTML = html; const h = $('drawer-body').querySelector('h2#drawer-title'); - $('drawer-bar-title').innerHTML = h ? h.innerHTML : ''; + const [icon, label] = TYPE_META[DRAWER.kind] || []; + $('drawer-bar-title').innerHTML = `${label ? `${esc(label)}` : ''}${h ? h.innerHTML : ''}`; if (h) h.remove(); $('drawer').hidden = false; $('scrim').hidden = false; document.body.style.overflow = 'hidden'; - $('drawer-close').focus(); + if (fresh) $('drawer-close').focus(); + else if (keep) $('drawer-body').querySelector(keep)?.focus(); + for (const f of after) f(); } function closeDrawer() { DRAWER = { kind: null, id: null }; + LAST_HTML = ''; $('drawer').hidden = true; $('scrim').hidden = true; document.body.style.overflow = ''; if (/^#(ticket|q|file|goal|planner)/.test(location.hash)) history.replaceState(null, '', location.pathname + location.search); lastFocus?.focus?.(); } +// Each opener names the object (and how to draw it again), then draws it. +const drawerFor = (kind, id, arg) => { + if (DRAWER.kind !== kind || DRAWER.id !== id) { LAST_HTML = ''; PEEK_FAIL.delete(peekKey(kind, id)); } + const same = DRAWER.kind === kind && DRAWER.id === id; + DRAWER = { kind, id, arg, q: same ? DRAWER.q : undefined, deps: same ? DRAWER.deps : undefined, url: same ? DRAWER.url : undefined }; +}; + +// ---- history: every line about the object, oldest first, always last in the drawer ---- const STAGE_WORDS = { planned: 'waiting on other tickets', ready: 'ready to start', "spec'd": 'spec approved', 'planned-in-detail': 'plan approved', building: 'being built', 'in-review': 'in review', merged: 'merged', released: 'released', superseded: 'replaced by another ticket' }; const MSG_WORDS = { 'review-request': 'asked for a review or a go-ahead', 'merge-request': 'asked to merge', blocked: 'is blocked', 'design-change': 'proposed a design change', ruling: 'ruled', answer: 'relayed your answer', info: 'said' }; -function lineView(l) { +const NEW_WORDS = { goal: 'Proposed', feature: 'Planned', problem: 'Opened', knowledge: 'Proposed', ticket: 'Added to the plan', observation: 'Noticed', question: 'Asked', decision: 'Decided', act: 'Did' }; +// A set line on the object itself carries what it replaced: each field from what to what. +function changeView(l) { + const by = cap(who(l.by)); + const fields = Object.keys(l).filter((k) => !ENVELOPE_KEYS.includes(k) && k !== 'prev'); + if (l.type === 'ticket' && fields.length === 1 && fields[0] === 'held') return ['', l.held ? `${esc(by)} put it on hold: ${linkify(l.held)}` : `${esc(by)} took it off hold`]; + if (l.type === 'goal' && fields.length === 1 && fields[0] === 'hold') return ['', `${esc(by)} set it to ${esc(words(l.hold?.mode ?? 'run'))}${l.hold?.reason ? `: ${linkify(l.hold.reason)}` : ''}`]; + const objs = (v) => Array.isArray(v) && v.some((x) => x && typeof x === 'object') || (v && typeof v === 'object' && !Array.isArray(v)); + const one = (k) => (k === 'decision' && l.decision?.verdict ? `verdict ${esc(words(l.decision.verdict).toLowerCase())}${l.decision.notes ? `: ${linkify(l.decision.notes)}` : ''}` : k === 'hold' ? `hold to ${esc(words(l.hold?.mode ?? 'run').toLowerCase())}` : objs(l[k]) || objs(l.prev?.[k]) ? `${esc(words(k).toLowerCase())}${Array.isArray(l[k]) ? ` (now ${l[k].length})` : ''}` : `${esc(words(k).toLowerCase())} ${emptyVal(l.prev?.[k]) ? '' : `from ${fmtShort(l.prev[k])} `}to ${fmtShort(l[k])}`); + return ['', `${esc(by)} changed ${fields.map(one).join('; ')}`]; +} +const ENVELOPE_KEYS = ['v', 'id', 'type', 'op', 'at', 'by', 'channel']; +function lineView(l, self) { const by = who(l.by); + if (l.ignored) return ['', `${esc(cap(by))} tried to ${l.op === 'new' ? 'add' : 'change'} ${esc(Object.keys(l).filter((k) => !ENVELOPE_KEYS.includes(k) && !['prev', 'ignored'].includes(k)).map((k) => words(k).toLowerCase()).join(', ') || l.type)}: ignored (${esc(l.ignored)})`]; + if (l.op === 'set' && l.prev && !['question', 'decision', 'act'].includes(l.type)) return changeView(l); + if (l.op === 'new' && l.id === self && !['phase', 'gate', 'message', 'question', 'decision', 'act'].includes(l.type)) return ['', `${esc(NEW_WORDS[l.type] || 'Created')} by ${esc(by)}`]; switch (l.type) { case 'ticket': { - if (l.op === 'new') return ['', `Added to the plan by ${esc(by)}`]; + if (l.op === 'new') return ['', `${esc(cap(by))} added ${tlink(l.id)} to the plan`]; const keys = Object.keys(l).filter((k) => FIELD_WORDS[k]); - if (keys.length === 1 && keys[0] === 'held') return ['', l.held ? `${esc(cap(by))} put it on hold: ${linkify(l.held)}` : `${esc(cap(by))} took it off hold`]; - return ['', `${esc(cap(by))} changed ${keys.map((k) => FIELD_WORDS[k]).join(', ') || 'details'}`]; + if (keys.length === 1 && keys[0] === 'held') return ['', l.held ? `${esc(cap(by))} put ${tlink(l.id)} on hold: ${linkify(l.held)}` : `${esc(cap(by))} took ${tlink(l.id)} off hold`]; + return ['', `${esc(cap(by))} changed ${keys.map((k) => FIELD_WORDS[k]).join(', ') || 'details'}${l.id !== self ? ` of ${tlink(l.id)}` : ''}`]; } case 'phase': return l.to === 'supervisor' || (l.from && l.from === l.to) ? null : ['', `Agent: ${esc(PHASE_WORDS[l.to] || l.to)}${l.task ? ` (${esc(l.task)})` : ''}${l.note ? ` · ${linkify(l.note)}` : ''}`]; case 'gate': return [l.ok ? 'gate' : 'gate fail', `Check “${esc(l.gate)}” ${l.ok ? 'passed' : 'failed'}${l.sha ? ` at ${esc(l.sha.slice(0, 12))}` : ''}`]; case 'message': return ['msg', `${esc(cap(by))} ${esc(MSG_WORDS[l.kind] || l.kind)}${l.to ? ` → ${esc(who(l.to))}` : ''}: ${linkify(l.text)}`]; case 'question': return l.op === 'new' ? ['q', `Asked you: ${qlink(l.id, l.question)}`] : l.answer !== undefined ? ['q', `You answered ${qlink(l.id, 'the question')}: ${esc(l.answer)}`] : l.status ? ['q', `Question ${esc(l.status)}${l.reason ? `: ${linkify(l.reason)}` : ''}`] : null; - case 'act': { if (['add-ticket', 'replan-within-feature'].includes(l.class)) return null; const v = actView(l); return ['act', `${v.head}${v.detail ? `
${v.detail}
` : ''}${l.decision ? `
under ${dlink(l.decision)}
` : ''}`]; } - case 'decision': return l.op === 'new' ? ['act', `The supervisor decided (${esc(words(l.class))}): ${dlink(l.id, l.what)}${l.rationale ? `
${linkify(l.rationale)}
` : ''}`] : l.corrected ? ['act', `${esc(cap(by))} corrected ${dlink(l.id, 'a decision')}: ${esc(l.corrected.reason)}`] : null; - case 'observation': return ['', `${esc(cap(by))} noticed (${esc(words(l.kind))}): ${linkify(l.detail)}`]; - case 'knowledge': return ['', `Learned: ${linkify(l.statement)}`]; + case 'act': { + if (l.op === 'set') return l.corrected ? ['act', `${esc(cap(by))} corrected it: ${esc(l.corrected.reason)}`] : l.repaired ? ['act', `${esc(cap(by))} repaired it: ${esc(l.repaired)}`] : null; + if (['add-ticket', 'replan-within-feature'].includes(l.class)) return null; + const v = actView(l); + return ['act', `${v.head}${v.detail ? `
${v.detail}
` : ''}${l.decision ? `
under ${dlink(l.decision, decisionTitle(l.decision))}
` : ''}`]; + } + case 'decision': return l.op === 'new' ? ['act', `The supervisor decided (${esc(words(l.class))}): ${dlink(l.id, l.what)}${l.rationale ? `
${linkify(l.rationale)}
` : ''}`] : l.corrected ? ['act', `${esc(cap(by))} corrected ${dlink(l.id, 'a decision')}: ${esc(l.corrected.reason)}`] : l.repaired ? ['act', `${esc(cap(by))} repaired ${dlink(l.id, 'a decision')}: ${esc(l.repaired)}`] : null; + case 'observation': return l.kind === 'reading' && l.measure ? ['', `${esc(cap(by))} read the measure: ${esc(l.measure.value)}${unitOf(l.measure.unit)}`] : ['', `${esc(cap(by))} noticed (${esc(words(l.kind))}): ${linkify(l.detail)}`]; + case 'knowledge': return l.op === 'new' ? ['', `Learned: ${linkify(l.statement)}`] : ['', `${esc(cap(by))} set ${linkify(l.id)} to ${esc(l.status ?? 'changed')}`]; + case 'spend': return ['', `Spent $${esc(Number(l.usd ?? 0).toFixed(2))} (${esc(l.role ?? 'agent')}${l.phase ? `, ${esc(l.phase)}` : ''})`]; + case 'goal': return ['', `${esc(cap(by))} ${l.op === 'new' ? 'proposed' : 'changed'} ${glink(l.id)}`]; + case 'feature': return ['', `${esc(cap(by))} ${l.op === 'new' ? 'planned' : 'changed'} ${flink(l.id)}`]; + case 'problem': return ['', `${esc(cap(by))} ${l.op === 'new' ? 'opened' : 'updated'} a problem: ${linkify(l.title ?? l.id)}${l.status ? ` (${esc(l.status)})` : ''}`]; default: return ['', `${esc(words(l.type))} ${esc(l.op)}`]; } } -async function openTicket(id, { quiet = false } = {}) { - DRAWER = { kind: 'ticket', id }; - if (!quiet) showDrawer('

Loading…

'); - let v; - try { const r = await fetch(`/api/timeline?id=${encodeURIComponent(id)}`); v = await r.json(); if (!r.ok) throw new Error(v.error || r.status); } - catch (e) { if (!quiet) showDrawer(`

Could not load ${esc(id)}: ${esc(e.message)}

`); return; } - if (DRAWER.kind !== 'ticket' || DRAWER.id !== id) return; +function historySection(peek, self) { + const head = '

History

'; + if (peek === undefined) return `${head}

Loading…

`; + if (!peek || peek.error) return ''; + if (DRAWER.kind === 'file') { + const cs = peek.history || []; + return `${head}${cs.length ? `
    ${cs.map((c) => `
  1. ${esc(c.subject)} · ${esc(c.author)} · ${esc(c.sha.slice(0, 12))}
  2. `).join('')}
` : empty('Not committed yet.')}`; + } + const items = (peek.history || []).map((l) => [l, lineView(l, self)]).filter(([, x]) => x); + return `${head}${items.length ? `
    ${items.map(([l, [c, h]]) => `
  1. ${h}
  2. `).join('')}
` : empty('Nothing logged yet.')}`; +} + +// ---- pieces the bodies share ---- +// A Mermaid diagram, drawn once the drawer is on screen. +function diagram(code) { + const id = `dg-${AFTER.length}`; + AFTER.push(() => renderMermaid(document.getElementById(id))); + return `
${esc(code)}
`; +} +const mmLabel = (s) => String(s ?? '').replace(/["<>`#;{}[\]()|]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 48) || '?'; +const DONE_STAGES = ['merged', 'released']; +// Tickets and what waits on what, optionally in columns (waves); the focus ticket outlined. +function depGraph(ts, { focus = null, groups = null } = {}) { + if (!ts.length) return ''; + const n = new Map(ts.map((t, i) => [t.id, `n${i}`])); + const node = (t) => `${n.get(t.id)}["${mmLabel(t.title)}"]${DONE_STAGES.includes(t.stage) ? ':::done' : t.phase === 'blocked' ? ':::blocked' : ''}`; + const lines = ['flowchart LR', 'classDef done opacity:0.55', 'classDef blocked stroke:#d33,stroke-width:2px', 'classDef focus stroke-width:3px']; + if (groups) for (const [label, members] of groups) lines.push(`subgraph g${mmLabel(label).replace(/\W/g, '')}["${mmLabel(label)}"]`, ...members.map(node), 'end'); + else lines.push(...ts.map(node)); + for (const t of ts) for (const d of t.depends_on || []) if (n.has(d)) lines.push(`${n.get(d)} --> ${n.get(t.id)}`); + if (focus && n.has(focus)) lines.push(`class ${n.get(focus)} focus`); + return diagram(lines.join('\n')); +} +// The opening of a design: its TL;DR, rendered, and a link to the whole document. +function designBlock(d, label = 'Design') { + if (!d) return ''; + const href = fileHref({ title: label, path: d.path, ...(d.ref ? { ref: d.ref } : {}) }); + const md = d.tldr && window.marked && window.DOMPurify ? DOMPurify.sanitize(marked.parse(d.tldr, { gfm: true }), { FORBID_TAGS: ['style', 'form', 'input'], FORBID_ATTR: ['style'] }) : d.tldr ? `
${esc(d.tldr)}
` : '

It has no TL;DR yet.

'; + return `

${esc(label)}: the TL;DR

${md}

Read the whole design

`; +} +const reviewList = (rs) => (rs?.length ? `
    ${rs.map((r) => `
  • ${esc(words(r.stage))} review, pass ${esc(r.pass)}: ${r.verdict === 'APPROVE' ? 'approved' : r.verdict === 'CHANGES' ? 'changes asked' : esc(r.verdict || 'not done')}${r.reviewer ? ` by ${esc(r.reviewer)}` : ''}
  • `).join('')}
` : ''); +// A small time chart: one line per series over time, optional horizontal marks (targets). +function lineChart(series, { marks = [], unit = '', height = 160, yMin = 0 } = {}) { + const pts = series.flatMap((s) => s.points); + if (!pts.length) return ''; + const W = 640, H = height, L = 40, R = 12, T = 12, B = 22; + const xs = pts.map((p) => p[0]); + let t0 = Math.min(...xs), t1 = Math.max(...xs, Date.now()); + if (t1 - t0 < DAY) t0 = t1 - DAY; + const yMax = Math.max(...pts.map((p) => p[1]), ...marks.map((m) => m.value), 1) * 1.1; + const x = (ts) => L + ((ts - t0) / (t1 - t0)) * (W - L - R); + const y = (v) => H - B - ((v - yMin) / (yMax - yMin)) * (H - T - B); + let svg = ``; + for (const v of [yMin, (yMin + yMax) / 2, yMax / 1.1]) svg += `${esc(+v.toFixed(v < 10 ? 2 : 0))}${esc(unit)}`; + svg += `${esc(fmtDate(t0))}${esc(fmtDate(t1))}`; + const merged = []; + for (const m of marks) { const same = merged.find((x) => x.value === m.value); if (same) { same.label = `${same.label}; ${m.label}`; same.strong ||= m.strong; } else merged.push({ ...m }); } + let lastY = Infinity; + for (const m of merged.sort((a, b) => b.value - a.value)) { + const ly = Math.max(y(m.value) - 4, T + 8); + const ty = lastY - ly < 12 ? lastY + 12 : ly; + lastY = ty; + svg += `${esc(m.label)}`; + } + series.forEach((s, i) => { + const ps = s.points.slice().sort((a, b) => a[0] - b[0]); + svg += ``; + for (const p of ps) svg += `${esc(`${s.name}: ${p[1]}${unit} · ${fmtDate(p[0])}`)}`; + }); + return `
${series.length > 1 ? `
${series.map((s, i) => `${esc(s.name)}`).join('')}
` : ''}${svg}
`; +} +// A set's progress: its meter and forecast, its burn-up, then its tickets as the board. +function progressBlock(items) { + const now = Date.parse(DATA.progress.now); + const fc = forecast(items, now); + const live = items.filter((t) => t.stage !== 'superseded'); + const left = live.filter((t) => !isDone(t)); + const eta = fc.finished ? 'all merged' : fc.eta ? `ETA ${fmtDate(fc.eta)} at the recent ${fc.perDay.toFixed(1)} points a day` : fc.scope ? 'no forecast yet (nothing merged recently)' : 'no tickets yet'; + const usd = live.reduce((a, t) => a + (ticketMap().get(t.id)?.usd || 0), 0); + if (live.length) AFTER.push(() => { if ($('peek-burnup')) burnup(items, now, { el: $('peek-burnup'), legend: $('peek-legend'), tip: $('peek-tip'), card: $('peek-card'), forecast: null }); }); + return `${meterRow(`${fc.done} of ${fc.scope} points done`, fc.done, fc.scope)} +

${esc(eta)} · ${left.length} of ${live.length} ticket${live.length === 1 ? '' : 's'} left${usd ? ` · $${usd.toFixed(2)} spent` : ''}

+ ${live.length ? `
donescopeforecastpoints: S = 1, M = 2
` : ''} +

Tickets

${live.length ? `
${boardCols(live, new Set(DATA.critical_path), { only: true })}
` : '

None yet.

'}`; +} +const uniq = (xs) => [...new Set(xs.filter((x) => x !== undefined && x !== null && x !== ''))]; +const notFound = (kind, id, peek, text, { noRecord = false } = {}) => showDrawer(`

${esc(id)}

${attrs({ id, peek, type: kind, noRecord })}${peek?.transient ? `

Could not load it just now (${esc(peek.error)}); trying again.

` : text ? `

${text}

` : ''}${historySection(peek, id)}`); +// How long each phase took, as one bar: from each phase line to the next (or to the merge, or now). +function phaseStrip(lines, endAt) { + const ps = lines.filter((l) => l.type === 'phase' && l.to !== 'supervisor').sort((a, b) => a.at.localeCompare(b.at)); + if (!ps.length) return ''; + const end = endAt ? Date.parse(endAt) : Date.now(); + const spans = new Map(); + ps.forEach((l, i) => { + const t0 = Date.parse(l.at), t1 = i + 1 < ps.length ? Date.parse(ps[i + 1].at) : Math.max(t0, end); + if (l.to === 'done') return; + spans.set(l.to, (spans.get(l.to) || 0) + Math.max(0, t1 - t0)); + }); + const total = [...spans.values()].reduce((a, b) => a + b, 0); + if (!total) return ''; + const tone = (p) => (['blocked', 'parked'].includes(p) ? 'stop' : ['spec', 'plan', 'contract'].includes(p) ? 'think' : ['build', 'verify'].includes(p) ? 'make' : ['final-review', 'awaiting-word', 'merging'].includes(p) ? 'wait' : 'other'); + const hrs = (ms) => (ms < 3600e3 ? `${Math.round(ms / 60e3)}m` : `${(ms / 3600e3).toFixed(ms < 36000e3 ? 1 : 0)}h`); + const segs = [...spans].map(([p, ms]) => ({ p, ms, w: (100 * ms) / total })); + return `

Time per phase${endAt ? '' : ' so far'}

+
${segs.map((s) => `${esc(PHASE_SHORT[s.p] || words(s.p))} ${esc(hrs(s.ms))}`).join('')}
`; +} +// Every check at every commit it ran on: passed or failed, flaky where one commit saw both, slow +// where it took over twice the project's usual time for that check. +function gateTable(lines) { + const gs = lines.filter((l) => l.type === 'gate'); + if (!gs.length) return ''; + const names = uniq(gs.map((g) => g.gate)); + // a run on uncommitted changes (a TDD red, then green) doesn't name what ran: never flaky + const shaOf = (g) => (g.dirty || !g.sha ? 'uncommitted' : g.sha); + const shas = uniq(gs.slice().sort((a, b) => b.at.localeCompare(a.at)).map(shaOf)); + const median = Object.fromEntries((DATA.metrics?.gates?.gates || []).map((g) => [g.gate, g.median_seconds])); + const cell = (sha, name) => { + const runs = gs.filter((g) => shaOf(g) === sha && g.gate === name); + if (!runs.length) return '–'; + const ok = sha !== 'uncommitted' && runs.some((r) => r.ok), bad = sha !== 'uncommitted' && runs.some((r) => !r.ok); + const last = runs.sort((a, b) => a.at.localeCompare(b.at)).pop(); + const slow = median[name] && last.ms && last.ms / 1000 > 2 * median[name]; + return `${ok && bad ? '≈' : last.ok ? '✓' : '✗'}${slow ? ' slow' : ''}${ok && bad ? ' flaky' : ''}`; + }; + return `

Gate runs

${names.map((n) => ``).join('')}${shas.slice(0, 12).map((s) => `${names.map((n) => cell(s, n)).join('')}`).join('')}
Commit${esc(n)}
${esc(s.slice(0, 12))}
`; +} +function spendChart(spend) { + if (!spend?.length) return ''; + const roles = uniq(spend.map((s) => s.role || 'agent')); + const series = roles.map((r) => { let sum = 0; return { name: `${r} ($${spend.filter((s) => (s.role || 'agent') === r).reduce((a, s) => a + (s.usd || 0), 0).toFixed(2)})`, points: spend.filter((s) => (s.role || 'agent') === r).map((s) => [Date.parse(s.at), +(sum += s.usd || 0).toFixed(2)]) }; }); + return `

Spend over time

${lineChart(series, { unit: '', height: 140 })}

Cumulative dollars by role, from the agents' session logs.

`; +} + +// ---- the openers ---- +async function openTicket(id) { + drawerFor('ticket', id); + const p = peekOf('ticket', id); + if (!p || p.error) return notFound('ticket', id, p, p?.error ? 'No such ticket.' : ''); const pt = (DATA.progress?.tickets || []).find((x) => x.id === id); - const t = v.ticket; - const items = v.lines.map((l) => [l, lineView(l)]).filter(([, x]) => x); - const html = ` -

${esc(t.title)}

-
${esc(t.id)}
-
-
Status
${esc(cap(STAGE_WORDS[t.stage] || t.stage))}${t.phase && !['merged', 'released'].includes(t.stage) ? ` · agent ${esc(PHASE_WORDS[t.phase] || t.phase)}` : ''}${t.waiting ? ` · ${t.waiting.kind === 'limit' ? 'usage limit' : 'waiting on a prompt'} ${esc(t.waiting.line)}` : ''}
- ${t.held ? `
On hold
${esc(t.held)}
` : ''} -
Part of
${t.feature === 'chores' ? 'chores (upkeep, not a feature)' : flink(t.feature)} · ${esc(t.component)}${pt?.wave ? ` · ${wlink(pt.wave)}` : ''}${pt?.target_release ? ` · ships in ${rlink(pt.target_release)}` : ''}
- ${(pt?.serves || []).length ? `
Serves
${pt.serves.map((x) => `${slink(x)} ${esc(String(DATA.progress.stories[x]?.text ?? '').slice(0, 90))}`).join('
')}
` : ''} - ${ticketMap().get(id)?.usd ? `
Spent
$${esc(ticketMap().get(id).usd.toFixed(2))} so far (its worker and reviewers)
` : ''} -
Size, risk
${t.size === 'M' ? 'medium' : 'small'}, ${t.risk === 'high' ? 'high risk (two reviewers)' : 'normal risk'}
- ${(t.depends_on || []).length ? `
Waits on
${t.depends_on.map(tlink).join(', ')}
` : ''} -
Done when
    ${(t.done_when || []).map((x) => `
  • ${linkify(x)}
  • `).join('')}
-
May change
${(t.touches || []).map((x) => `${esc(x)}`).join(', ')}
- ${t.notes ? `
Notes
${linkify(t.notes)}
` : ''} - ${t.branch ? `
Branch
${esc(t.branch.replace(/^refs\/(heads|remotes\/origin)\//, ''))}
` : ''} - ${t.merge_sha ? `
Merged
${esc(t.merge_sha.slice(0, 12))}
` : ''} -
- ${v.files.length ? `

Documents

` : ''} - ${v.reviews.length ? `

Reviews

    ${v.reviews.map((r) => `
  • ${esc(words(r.stage))} review, pass ${esc(r.pass)}: ${r.verdict === 'APPROVE' ? 'approved' : r.verdict === 'CHANGES' ? 'changes asked' : esc(r.verdict || '–')}${r.reviewer ? ` by ${esc(r.reviewer)}` : ''}
  • `).join('')}
` : ''} -

History

- ${items.length ? `
    ${items.map(([l, [c, h]]) => `
  1. ${h}
  2. `).join('')}
` : empty('Nothing logged yet.')}`; - showDrawer(html); + const t = { ...p.ticket, ...(pt || {}) }; + const all = [...ticketMap().values()]; + const near = [t, ...all.filter((x) => (p.deps.waits_on || []).includes(x.id) || (p.deps.blocks || []).includes(x.id))]; + showDrawer(`

${esc(t.title)}

+ ${attrs({ id, peek: p, type: 'ticket', computed: [ + ['Status', `${esc(cap(STAGE_WORDS[t.stage] || t.stage))}${t.phase && !DONE_STAGES.includes(t.stage) ? ` · agent ${esc(PHASE_WORDS[t.phase] || t.phase)}` : ''}${t.waiting ? ` · ${t.waiting.kind === 'limit' ? 'usage limit' : 'waiting on a prompt'} ${esc(t.waiting.line)}` : ''}`, 'Where it is now, from its approvals, its agent\'s last phase and its merge.'], + ['Documents', p.files.length ? `` : '', 'Its spec, plan and verification, from its branch until it merges, then from main.'], + ['Reviews', reviewList(p.reviews), 'Each review of its spec, plan and code, and its verdict.'], + ['Blocks', list(p.deps.blocks, tlink), 'Tickets that wait on this one.'], + ['Wave', pt?.wave ? wlink(pt.wave) : '', 'The batch of tickets that can run at the same time once the waves before them merge.'], + ['Ships in', pt?.target_release ? rlink(pt.target_release) : '', 'The release its stories target.'], + ['Spent', ticketMap().get(id)?.usd ? `$${esc(ticketMap().get(id).usd.toFixed(2))} so far (its worker and reviewers)` : '', 'What its worker and reviewers have spent, from their session logs.'], + ['Branch', t.branch ? `${esc(t.branch.replace(/^refs\/(heads|remotes\/origin)\//, ''))}` : '', 'Where its work is.'], + ['Merged', t.merge_sha ? `${esc(t.merge_sha.slice(0, 12))}` : '', 'The commit it landed as on main.'], + ] })} + ${p.handoff ? `

Handoff

${esc(p.handoff.text)}
${esc(p.handoff.where)}
` : ''} + ${phaseStrip(p.history, t.merged_at)} + ${gateTable(p.history)} + ${spendChart(p.spend)} + ${near.length > 1 ? `

Dependencies

${depGraph(near, { focus: id })}` : ''} + ${historySection(p, id)}`); } function openGoal(id) { + drawerFor('goal', id); const g = (DATA.ooda.goals || []).find((x) => x.id === id); - if (!g) return showDrawer(`

No goal ${esc(id)}

`); - DRAWER = { kind: 'goal', id }; + const p = peekOf('goal', id); + if (!g) return notFound('goal', id, p, 'It is not among the goals on the dashboard.'); const R = DATA.ooda.roadmap; const s = goalState(g, R, DATA.questions || []); const r = R.goals.find((x) => x.id === id); const feats = (r?.features || []).map((f) => R.features[f]).filter(Boolean); - const week = (d) => (d ? `week of ${new Date(`${d}T00:00:00Z`).toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })}` : 'no date'); - // the goal's questions: asked for it, or about (or holding up) a ticket in one of its features const goalTickets = new Set((DATA.progress?.tickets || []).filter((t) => feats.some((f) => f.feature === t.feature)).map((t) => t.id)); const asks = [...(DATA.questions || []), ...(DATA.ooda.answered || [])].filter((q) => q.goal === id || goalTickets.has(q.ticket) || (q.blocks || []).some((b) => goalTickets.has(b)) || (q.kind === 'goal-definition' && q.question.toLowerCase().includes(String(g.title).toLowerCase().split(':')[0]))); const design = s.ask?.links?.[0] ?? (['ready', 'met'].includes(g.status) && g.check.design ? { title: 'The design', path: `docs/devteam/${g.check.design}` } : null); - const decisions = (g.updates || []).filter((u) => u.decision).map((u) => u.decision); + const verdicts = (g.updates || []).filter((u) => u.decision).map((u) => u.decision); + const problems = (DATA.ooda.problems || []).filter((x) => (x.threatens || []).includes(id)); + const featIds = new Set(feats.map((f) => f.feature)); + // judgment calls only: acts under the constitution (merges, sessions started, tickets edited) are in the history + const calls = (DATA.ooda.decisions || []).filter((x) => ['decision', 'act'].includes(x.kind) && !(x.kind === 'act' && x.under?.authority === 'constitution') && (x.refs || []).some((ref) => ref === id || goalTickets.has(ref) || featIds.has(ref))); + const unit = g.measure?.unit === '%' ? '%' : ''; + const readings = (g.readings || []).map((x) => [Date.parse(x.at), x.value]); + const marks = [...(g.forecast?.milestones || []).map((m) => ({ value: m.target, label: `${m.title} (${m.target}${unit})` })), ...(g.measure?.target != null ? [{ value: g.measure.target, label: `target ${g.measure.target}${unit}`, strong: true }] : [])]; + const items = filterTickets(DATA.progress, `goal:${id}`); showDrawer(`

${esc(g.title)}

-

${esc(s.label)}

- ${g.outcome ? `

${esc(g.outcome)}

` : ''} + ${attrs({ id, peek: p, type: 'goal', ctx: { unplanned: g.status === 'ready' ? (r?.unplanned || []).map((u) => u.n) : [] }, computed: [ + ['State', `${esc(s.label)}`, 'Where the goal is: defining, waiting for your approval, planning, building, measuring, met, held or let go.'], + ['Design', design ? `${esc(design.title || 'The design')}` : '', 'The design document its features implement.'], + ['Forecast', (g.forecast?.milestones || []).length ? `
    ${g.forecast.milestones.map((m) => `
  1. ${mlink(g.id, m.n, m.title)}: ${esc(m.target)}${unitOf(g.measure?.unit)}${m.by ? ` by ${esc(m.by)}` : ''} ${esc(m.status === 'met' ? 'met' : m.late ? 'late' : m.status)}
    ${m.status === 'met' ? 'reached' : `${m.features.length ? `${esc(m.points_left)} points left` : `about ${esc(m.estimate_tickets)} tickets, not planned yet`}${m.eta ? ` · ETA ${esc(m.eta)}` : ''}`}
  2. `).join('')}
${g.forecast.past_horizon ? 'forecast after the horizon' : ''}${g.forecast.rate_points_per_day > 0 ? '' : '
No measured rate yet: sizes only, dates once tickets merge.
'}` : '', 'Each milestone: points left and an ETA at the recent rate.'], + ['Features', feats.length ? `
    ${feats.map((v) => `
  • ${flink(v.feature)}: ${v.points_total - v.points_left} of ${v.points_total} points${v.done ? ', done' : ''}
  • `).join('')}
` : '', 'The features planned for it, and how far each is.'], + ['Planners', planningFor(g).length ? planningFor(g).map((x) => `${esc(x.title)} (${esc(ago(x.minutes_silent))})`).join(', ') : '', 'Planners working on its features now.'], + ['Your verdicts', verdicts.length ? `
    ${verdicts.map((d) => `
  • ${esc(words(d.verdict))}${d.notes ? `: ${esc(d.notes)}` : ''}
  • `).join('')}
` : '', 'Every verdict you gave on its design, oldest first.'], + ['Questions', asks.length ? `
    ${asks.map((q) => `
  • ${qlink(q.id, q.question)}${q.status === 'open' ? ' open' : ''}
  • `).join('')}
` : '', 'Questions asked for it, or about its tickets.'], + ] })} ${goalSteps(g, s, R)} ${s.next ? `

${s.next}

` : ''} ${holdControl(g.id, g.hold_view, { status: g.status })} - ${feats.length ? `

${boardLink(`goal:${g.id}`)}

` : ''} -
- ${design ? `
Design
${esc(design.title || 'The design')}
` : ''} - ${g.horizon ? `
By
${esc(new Date(Date.parse(g.horizon)).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }))}
` : ''} - ${g.priority ? `
Priority
${esc(g.priority)}
` : ''} - ${(g.forecast?.milestones || []).length ? `
Milestones
    ${g.forecast.milestones.map((m) => `
  1. ${mlink(g.id, m.n, m.title)}: ${esc(m.target)}${esc(g.measure?.unit ? ` ${g.measure.unit}` : '')}${m.by ? ` by ${esc(m.by)}` : ''} ${esc(m.status === 'met' ? 'met' : m.late ? 'late' : m.status)}
    ${m.status === 'met' ? 'reached' : `${m.features.length ? `${esc(m.points_left)} points left` : `about ${esc(m.estimate_tickets)} tickets, not planned yet`}${m.eta ? ` · ETA ${esc(m.eta)}` : ''}`}
  2. `).join('')}
${g.forecast.past_horizon ? 'forecast after the horizon' : ''}${g.forecast.rate_points_per_day > 0 ? '' : '
No measured rate yet: sizes only, dates once tickets merge.
'}
` : ''} - ${(g.preconditions || []).length ? `
Must be true first
    ${g.preconditions.map((x, i) => `
  • ${esc(x)}${(r?.unplanned || []).some((u) => u.n === i + 1) && g.status === 'ready' ? ' no plan yet' : ''}
  • `).join('')}
` : ''} - ${(g.non_goals || []).length ? `
Not in scope
    ${g.non_goals.map((x) => `
  • ${esc(x)}
  • `).join('')}
` : ''} - ${feats.length ? `
Features
    ${feats.map((v) => `
  • ${flink(v.feature)}: ${v.points_total - v.points_left} of ${v.points_total} points${v.done ? ', done' : ''}
  • `).join('')}
` : ''} - ${planningFor(g).length ? `
Planners
${planningFor(g).map((p) => `${esc(p.title)} (${esc(ago(p.minutes_silent))})`).join(', ')}
` : ''} - ${(g.readings || []).length ? `
Readings
    ${g.readings.map((x) => `
  • ${esc(x.value)} ${esc(g.measure?.unit || '')} · ${esc(when(x.at))}
  • `).join('')}
` : ''} - ${decisions.length ? `
Your decisions
    ${decisions.map((d) => `
  • ${esc(words(d.verdict))}${d.notes ? `: ${esc(d.notes)}` : ''}
  • `).join('')}
` : ''} - ${asks.length ? `
Questions
    ${asks.map((q) => `
  • ${qlink(q.id, q.question)}${q.status === 'open' ? ' open' : ''}
  • `).join('')}
` : ''} -
ID
${esc(g.id)}
-
`); -} -// ---- panels for the rest: sets of tickets (feature, story, release, wave, milestone) and log -// objects (act, lesson, problem, message thread). A set shows its progress, its forecast and its -// tickets by column, with a link that filters the board and the burn-up to it. -function openTicketSet({ kind, id, title, sub, rows, items, view }) { - DRAWER = { kind, id }; - const now = Date.parse(DATA.progress.now); - const fc = forecast(items, now); - const live = items.filter((t) => t.stage !== 'superseded'); - const left = live.filter((t) => !isDone(t)); - const cols = COLUMNS.map(([name]) => [name, live.filter((t) => columnOf(t) === name)]).filter(([, ts]) => ts.length); - const eta = fc.finished ? 'all merged' : fc.eta ? `ETA ${fmtDate(fc.eta)} at the recent ${fc.perDay.toFixed(1)} points a day` : fc.scope ? 'no forecast yet (nothing merged recently)' : 'no tickets yet'; +

Readings

${readings.length ? lineChart([{ name: g.measure?.description || 'the measure', points: readings }], { marks, unit }) : '

No readings yet. The supervisor reads the measure on its tick (devteam.mjs goal reading).

'} + ${designBlock(p?.design)} + ${problems.length ? `

Problems that threaten it

${problems.map((x) => `
sev ${esc(x.severity)} ${esc(x.title)} · ${esc(x.status)}${x.realized ? '' : ' · a risk'}
`).join('')}` : ''} + ${calls.length ? `

Decisions and acts

    ${calls.slice(0, 30).map((x) => `
  1. ${x.kind === 'decision' ? dlink(x.id, x.what) : `${esc(x.what)}`} · ${authorityWords(x.under)}
  2. `).join('')}
` : ''} + ${feats.length ? `

Progress

${progressBlock(items)}` : ''} + ${historySection(p, id)}`); +} +function openTicketSet({ kind, id, title, sub, computed = [], items, attr = {}, extra = '', q = {} }) { + drawerFor(kind, id); + DRAWER.q = { ...q, tickets: items.map((t) => t.id).join(',') }; + const p = peekOf(kind, id, DRAWER.q); showDrawer(`

${title}

+ ${attrs({ id, peek: p, type: kind, ...attr, computed })} ${sub ? `

${sub}

` : ''} - ${meterRow(`${fc.done} of ${fc.scope} points done`, fc.done, fc.scope)} -

${esc(eta)} · ${left.length} of ${live.length} ticket${live.length === 1 ? '' : 's'} left${(() => { const usd = live.reduce((a, t) => a + (ticketMap().get(t.id)?.usd || 0), 0); return usd ? ` · $${usd.toFixed(2)} spent` : ''; })()}

- ${live.length ? `

${boardLink(view)}

` : ''} -
- ${rows.filter(Boolean).join('')} -
Tickets
${cols.length ? cols.map(([n, ts]) => `
${esc(n)}
    ${ts.map((t) => `
  • ${tlink(t.id)}
  • `).join('')}
`).join('') : 'None yet.'}
-
ID
${esc(id)}
-
`); -} -const row = (dt, dd) => (dd ? `
${dt}
${dd}
` : ''); -const uniq = (xs) => [...new Set(xs.filter((x) => x !== undefined && x !== null && x !== ''))]; + ${extra} + ${progressBlock(items)} + ${historySection(p, id)}`); +} function openFeature(f) { - const p = DATA.progress, R = DATA.ooda.roadmap, rf = R.features[f]; - if (!rf && !p.features.includes(f)) return showDrawer(`

No feature ${esc(f)}

`); - const items = p.tickets.filter((t) => t.feature === f); - const stories = Object.entries(p.stories).filter(([, s]) => s.feature === f).map(([id]) => id); + const pr = DATA.progress, R = DATA.ooda.roadmap, rf = R.features[f]; + if (!rf && !pr.features.includes(f)) { drawerFor('feature', f); return notFound('feature', f, peekOf('feature', f), 'Not on the roadmap.'); } + const items = pr.tickets.filter((t) => t.feature === f); const ms = (DATA.ooda.goals || []).flatMap((g) => milestonesOf(g).filter((m) => (m.features || []).includes(f)).map((m) => mlink(g.id, m.n, m.title))); - openTicketSet({ kind: 'feature', id: f, title: esc(featureTitle(f)), sub: f === 'chores' ? 'Upkeep: tickets that serve no story.' : '', items, view: `feature:${f}`, rows: [ - row('Status', esc(rf?.status ?? '')), - row('For goal', (rf?.goals || []).map(glink).join(', ')), - row('Milestones', ms.join('
')), - row('Stories', stories.map((x) => `${slink(x)} ${esc(String(p.stories[x].text ?? '').slice(0, 90))}`).join('
')), - row('Releases', uniq([...(rf?.releases || []), ...items.map((t) => t.target_release)]).sort().map(rlink).join(', ')), - row('Waves', uniq(items.filter((t) => !isDone(t)).map((t) => t.wave)).sort((a, b) => a - b).map(wlink).join(', ')), - row('Planner', (DATA.planners || []).some((x) => x.feature === f) ? `planning now` : ''), - ] }); + const p = PEEKS.get(peekKey('feature', f)); + const waves = uniq(items.map((t) => t.wave)).sort((a, b) => a - b); + const live = items.filter((t) => t.stage !== 'superseded'); + openTicketSet({ kind: 'feature', id: f, title: esc(featureTitle(f)), sub: f === 'chores' ? 'Upkeep: tickets that serve no story.' : '', items, attr: { idTip: 'The feature\'s slug; its tickets\' ids start with their component.', noRecord: f === 'chores' }, computed: [ + ['Status', esc(rf?.status ?? ''), 'planned, building or done, from its tickets.'], + ['Milestones', ms.join('
'), 'The goal milestones it counts toward.'], + ['Releases', uniq([...(rf?.releases || []), ...items.map((t) => t.target_release)]).sort().map(rlink).join(', '), 'Releases its stories and tickets target.'], + ['Waves', uniq(items.filter((t) => !isDone(t)).map((t) => t.wave)).sort((a, b) => a - b).map(wlink).join(', '), 'Waves its unfinished tickets are in.'], + ['Planner', (DATA.planners || []).some((x) => x.feature === f) ? `planning now` : '', 'A planner working on it now.'], + ['Plan reviews', p?.plan_reviews ? reviewList(p.plan_reviews) || 'none yet' : '', 'Reviews of its plan PR while it is being planned.'], + ], extra: `${designBlock(p?.design)}${live.length > 1 && f !== 'chores' ? `

Dependencies

${depGraph(live, { groups: waves.map((w) => [w ? `Wave ${w}` : 'No wave', live.filter((t) => t.wave === w)]) })}` : ''}` }); } function openStory(sid) { - const p = DATA.progress, st = p.stories[sid]; - if (!st) return showDrawer(`

No story ${esc(sid)}

`); - openTicketSet({ kind: 'story', id: sid, title: `Story ${esc(sid)}`, sub: esc(st.text ?? ''), items: p.tickets.filter((t) => t.serves.includes(sid)), view: `story:${sid}`, rows: [ - row('Feature', st.feature ? flink(st.feature) : ''), - row('Ships in', st.release ? rlink(st.release) : ''), - ] }); + const pr = DATA.progress, st = pr.stories[sid]; + if (!st) { drawerFor('story', sid); return notFound('story', sid, null, 'No such story.', { noRecord: true }); } + openTicketSet({ kind: 'story', id: sid, title: esc(st.text ?? sid), items: pr.tickets.filter((t) => t.serves.includes(sid)), q: st.feature && st.feature !== 'chores' ? { rec: st.feature } : {}, + attr: { docType: 'feature', prefix: 'stories[].', pick: (fs) => { const x = (fs.stories || []).find((y) => y.id === sid); if (!x) return null; const { id: _, ...rest } = x; return rest; }, idTip: 'The story\'s id, stored on its feature.' }, + computed: [['Feature', st.feature ? flink(st.feature) : '', 'The feature the story belongs to.']] }); } function openRelease(r) { - const p = DATA.progress; - const items = p.tickets.filter((t) => t.target_release === r); - const stories = Object.entries(p.stories).filter(([, s]) => s.release === r).map(([id]) => id); - const tagged = p.tickets.some((t) => t.released_in === r); - openTicketSet({ kind: 'release', id: r, title: `Release ${esc(r)}`, sub: tagged ? 'Tagged.' : stories.length && stories.every((s) => { const ts = p.tickets.filter((t) => t.serves.includes(s)); return ts.length && ts.every(isDone); }) ? 'Every story is done: ready to tag.' : '', items, view: `release:${r}`, rows: [ - row('Stories', stories.map((x) => `${slink(x)} ${esc(String(p.stories[x].text ?? '').slice(0, 90))}`).join('
')), - row('Features', uniq([...items.map((t) => t.feature), ...stories.map((x) => p.stories[x].feature)]).map(flink).join(', ')), - ] }); + const pr = DATA.progress; + const items = pr.tickets.filter((t) => t.target_release === r); + const stories = Object.entries(pr.stories).filter(([, s]) => s.release === r).map(([id]) => id); + const tagged = pr.tickets.some((t) => t.released_in === r); + const storyDone = (s) => { const ts = pr.tickets.filter((t) => t.serves.includes(s)); return ts.length && ts.every(isDone); }; + const open = stories.filter((s) => !storyDone(s)); + const merged = items.filter(isDone).sort((a, b) => (b.merged_at || '').localeCompare(a.merged_at || '')); + const p = PEEKS.get(peekKey('release', r)); + const gates = p?.gates || []; + openTicketSet({ kind: 'release', id: r, title: `Release ${esc(r)}`, items, attr: { idTip: 'The release\'s name; it is tagged once every story in it is done.', noRecord: true }, computed: [ + ['Tagged', tagged ? 'yes' : 'not yet', 'Whether the release has been tagged.'], + ['Stories', stories.map((x) => `${slink(x)} ${esc(String(pr.stories[x].text ?? '').slice(0, 90))}${storyDone(x) ? ' done' : ''}`).join('
'), 'Stories that target it.'], + ['Features', uniq([...items.map((t) => t.feature), ...stories.map((x) => pr.stories[x].feature)]).map(flink).join(', '), 'Features with work in it.'], + ], extra: `

Readiness

    +
  • ${open.length ? `${open.length} of ${stories.length} stor${stories.length === 1 ? 'y' : 'ies'} still open: ${open.map(slink).join(', ')}` : stories.length ? `every story is done${tagged ? '' : ': ready to tag'}` : 'no stories target it'}
  • +
  • ${items.filter((t) => !isDone(t)).length} ticket${items.filter((t) => !isDone(t)).length === 1 ? '' : 's'} not merged
  • + ${gates.length && !tagged ? `
  • checks at the last merge into it (${p.last_merge?.ticket ? tlink(p.last_merge.ticket) : 'a ticket'}, ${esc(String(p.last_merge?.sha ?? '').slice(0, 12))}): ${gates.map((g) => `${g.ok ? '✓' : '✗'} ${esc(g.gate)}`).join(' · ')}
  • ` : ''} +
+

Changelog

${merged.length ? `
    ${merged.map((t) => `
  • ${tlink(t.id)} · merged ${esc(when(t.merged_at))}
  • `).join('')}
` : '

Nothing merged yet.

'}` }); } function openWave(w) { - const p = DATA.progress; - const items = p.tickets.filter((t) => String(t.wave) === String(w)); - openTicketSet({ kind: 'wave', id: String(w), title: `Wave ${esc(w)}`, sub: 'Tickets that can run at the same time once the waves before them have merged.', items, view: `wave:${w}`, rows: [ - row('Features', uniq(items.map((t) => t.feature)).map(flink).join(', ')), + const items = DATA.progress.tickets.filter((t) => String(t.wave) === String(w)); + const before = DATA.progress.tickets.filter((t) => t.wave && t.wave < Number(w) && !isDone(t)); + openTicketSet({ kind: 'wave', id: String(w), title: `Wave ${esc(w)}`, sub: 'Tickets that can run at the same time once the waves before them have merged.', items, attr: { idTip: 'The wave\'s number: the waves before it merge first.', noRecord: true }, computed: [ + ['Features', uniq(items.map((t) => t.feature)).map(flink).join(', '), 'Features with tickets in it.'], + ['Waiting on', before.length ? list(before.map((t) => t.id), tlink) : '', 'Unmerged tickets in earlier waves.'], ] }); } function openMilestone(key) { const i = key.lastIndexOf(':'); const gid = key.slice(0, i), n = Number(key.slice(i + 1)); const g = goalOf(gid), m = milestonesOf(g).find((x) => x.n === n); - if (!m) return showDrawer(`

No milestone ${esc(key)}

`); + if (!m) { drawerFor('milestone', key); return notFound('milestone', key, null, 'No such milestone.', { noRecord: true }); } const fs = new Set(m.features || []); - openTicketSet({ kind: 'milestone', id: key, title: `${esc(m.title)}`, sub: `Milestone ${n} of ${glink(gid)}`, items: DATA.progress.tickets.filter((t) => fs.has(t.feature)), view: `milestone:${key}`, rows: [ - row('Status', esc(m.status ?? '')), - row('Target', `${esc(m.target)}${g.measure?.unit ? ` ${esc(g.measure.unit)}` : ''}`), - row('By', m.by ? esc(fmtDate(Date.parse(m.by))) : 'no date (only you set one)'), - row('Forecast', m.eta ? `${esc(fmtDate(Date.parse(m.eta)))}${m.late ? ' late' : ''}` : ''), - row('Features', [...fs].map(flink).join(', ')), - ] }); + const unit = g.measure?.unit === '%' ? '%' : ''; + const readings = (g.readings || []).map((x) => [Date.parse(x.at), x.value]); + openTicketSet({ kind: 'milestone', id: key, title: `${esc(m.title)}`, sub: `Milestone ${n} of ${glink(gid)}`, items: DATA.progress.tickets.filter((t) => fs.has(t.feature)), q: { rec: gid }, + attr: { docType: 'goal', prefix: 'milestones[].', pick: (gf) => (gf.milestones || [])[n - 1] ?? null, idTip: 'Its goal\'s id and its number among the goal\'s milestones.' }, + computed: [ + ['Goal', glink(gid), 'The goal it is a milestone of.'], + ['Status', esc(m.status ?? ''), 'met, current or later, from the goal\'s measure and its features.'], + ['Forecast', m.eta ? `${esc(fmtDate(Date.parse(m.eta)))}${m.late ? ' late' : ''}` : '', 'When it should be reached at the recent rate.'], + ['Features', [...fs].map(flink).join(', '), 'Features planned toward it.'], + ], + extra: readings.length ? `

Readings

${lineChart([{ name: g.measure?.description || 'the measure', points: readings }], { marks: [{ value: m.target, label: `this milestone (${m.target}${unit})`, strong: true }], unit })}` : '' }); } function openAct(id) { + drawerFor('act', id); const a = (DATA.ooda.acts || []).find((x) => x.id === id); - if (!a) return showDrawer(`

No act ${esc(id)}

Only recent acts are on the dashboard.

`); - DRAWER = { kind: 'act', id }; + const p = peekOf('act', id); + if (!a) return notFound('act', id, p, p?.error ? 'No such act.' : 'Older than the acts on the dashboard; its record is above.'); const v = actView(a); + const d = a.decision ? (DATA.ooda.decisions || []).find((x) => x.id === a.decision && x.kind === 'decision') : null; showDrawer(`

${v.head}

+ ${attrs({ id, peek: p, type: 'act', computed: [ + ['Under', actUnder(a), 'What it carried out: a decision, your answer, a standing rule, or its own call.'], + ] })} ${v.detail ? `

${v.detail}

` : ''} -
- ${row('What', linkify(a.what))} - ${row('Kind', esc(words(a.class)))} - ${row('When', esc(when(a.created_at)))} - ${row('Under', actUnder(a))} - ${row('Undo', a.reversible === false ? 'can’t be undone' : 'reversible')} - ${row('About', (a.refs || []).map((r) => linkify(r)).join(', '))} - ${a.correction ? row('Corrected', `${esc(who(a.correction.by))}: ${esc(a.correction.reason)}${a.correction.repaired ? ` · ${esc(a.correction.repaired)}` : ''}`) : ''} -
ID
${esc(a.id)}
-
- ${a.correction ? '' : a.decision ? '

It carried out a decision: correct the decision if the call was wrong (Loop → Decide). If the call was sound and the act went wrong, that is a problem, not a trust drop.

' : '

To correct it, use Correct on it in Loop → Act.

'}`); + ${d ? `

The decision it carried out

${dlink(d.id, d.what)}
${d.rationale ? `
${linkify(d.rationale)}
` : ''}
${authorityWords(d.under)} · ${esc(when(d.at))}${d.correction ? ` · corrected ${esc(d.correction.reason)}` : ''}
` : ''} + ${citedBlock(p, a)} + ${a.correction ? '' : a.decision ? '

It carried out a decision: correct the decision if the call was wrong (Loop → Decide). If the call was sound and the act went wrong, that is a problem, not a trust drop.

' : '

To correct it, use Correct on it in Loop → Act.

'} + ${historySection(p, id)}`); +} +// What a call rested on, inline: your answer as you gave it, or the standing rule's words. +function restsOn(approvedBy) { + if (!approvedBy) return ''; + if (approvedBy.startsWith('q-')) { const q = allQuestions().find((x) => x.id === approvedBy); return q ? `

The answer it rests on

${answeredCard(q)}` : ''; } + const k = (DATA.ooda.knowledge || []).find((x) => x.id === approvedBy); + return k ? `

The standing rule it rests on

${esc(k.statement)} · ${esc(scopeWords(k.scope))}
` : ''; +} +// Records an object cites, in their own words (from its peek). +function citedBlock(p, o) { + const c = Object.entries(p?.cited || {}).filter(([id]) => !id.startsWith('q-') || !o?.approved_by || id !== o.approved_by); + return c.length ? `

What it cites

    ${c.map(([id, x]) => `
  • ${linkify(id)} ${esc(words(x.type))}${x.kind ? ` (${esc(words(x.kind))})` : ''}${x.text ? `: ${linkify(String(x.text).slice(0, 160))}` : ''}
  • `).join('')}
` : ''; } function openDecision(id) { + drawerFor('decision', id); const r = (DATA.ooda.decisions || []).find((x) => x.id === id && ['decision', 'act'].includes(x.kind)); - if (!r) return showDrawer(`

No decision ${esc(id)}

Only recent decisions are on the dashboard.

`); - DRAWER = { kind: 'decision', id }; + const p = peekOf('decision', id); + if (!r) return notFound('decision', id, p, p?.error ? 'No such decision.' : 'Older than the decisions on the dashboard; its record is above.'); + const now = (DATA.ooda.trust || []).find((x) => x.class === r.class); + const acts = r.kind === 'decision' ? r.acts.map((a) => (DATA.ooda.acts || []).find((y) => y.id === a) || { id: a }) : []; showDrawer(`

${linkify(r.what)}

-
- ${row('Kind', esc(words(r.class)))}${r.scope ? row('Scope', esc(r.scope)) : ''} - ${row('When', esc(when(r.at)))} - ${row('Under', authorityWords(r.under))} - ${r.options?.length ? row('Weighed', `
    ${r.options.map((x) => `
  • ${esc(x)}
  • `).join('')}
`) : ''} - ${r.rationale ? row('Why', linkify(r.rationale)) : ''} - ${r.confidence != null ? row('Sure', `${Math.round(r.confidence * 100)}%`) : ''} - ${row('Undo', r.reversible === false ? 'can’t be undone' : 'reversible')} - ${row('About', (r.refs || []).map((x) => (ticketMap().has(x) ? tlink(x) : linkify(x))).join(', '))} - ${row('Carried out by', r.acts.length ? `` : 'nothing yet')} - ${r.correction ? row('Corrected', `${esc(who(r.correction.by))}: ${esc(r.correction.reason)}${r.correction.repaired ? ` · ${esc(r.correction.repaired)}` : ''}`) : ''} -
ID
${esc(r.id)}
-
- ${r.correction ? '' : '

To correct it, use Correct on it in Loop → Decide.

'}`); + ${attrs({ id, peek: p, type: r.kind, computed: [ + ['Under', authorityWords(r.under), 'The trust it rested on when it was made.'], + ['Its class now', now ? `${esc(RUNG_WORDS[now.rung])}${r.under?.rung && r.under.rung !== now.rung ? ` (was ${esc(RUNG_WORDS[r.under.rung])} when decided)` : ''}` : '', 'Where its kind of decision is on the trust ladder today, against when it was made.'], + ] })} + ${restsOn(r.under?.approved_by)} + ${r.kind === 'decision' ? `

Carried out by

${acts.length ? `
    ${acts.map((a) => `
  1. ${a.what ? actView(a).head : esc(a.id)}
  2. `).join('')}
` : '

Nothing yet.

'}` : ''} + ${r.correction ? '' : '

To correct it, use Correct on it in Loop → Decide.

'} + ${historySection(p, id)}`); } // A retro's own date is its write-up's (retros/.md); the closing line can come a day later. const retroDate = (r) => { const m = /(\d{4}-\d{2}-\d{2})\.md$/.exec(r.file || ''); return fmtDate(m ? Date.parse(`${m[1]}T12:00:00Z`) : Date.parse(r.at)); }; @@ -2014,124 +2437,177 @@

History

return linkify(x); }; function openRetro(id) { + drawerFor('retro', id); const all = DATA.ooda.retro?.all || []; const i = all.findIndex((x) => x.id === id); const r = all[i]; - if (!r) return showDrawer(`

No retro ${esc(id)}

`); - DRAWER = { kind: 'retro', id }; + const p = peekOf('retro', id); + if (!r) return notFound('retro', id, p, 'No such retro.'); const file = r.file ? { title: `The write-up (${r.file.split('/').pop()})`, path: `docs/devteam/${r.file}` } : null; const nav = (x, w) => (x ? `${w} ${esc(retroDate(x))}` : ''); - showDrawer(`

Retro, ${esc(retroDate(r))}

+ const nums = p?.numbers; + const keys = nums?.now ? uniq([...Object.keys(nums.now), ...Object.keys(nums.prev || {})]) : []; + const val = (v) => (v === null || v === undefined ? '–' : typeof v === 'object' ? fmtObj(v) || '–' : esc(v)); + showDrawer(`

${esc(retroDate(r))}

+ ${attrs({ id, peek: p, type: 'observation', computed: [ + ['What it decided', r.refs.length ? `
    ${r.refs.map((x) => `
  • ${retroRef(x)}
  • `).join('')}
` : '', 'The lessons, decisions and acts it recorded, in words.'], + ['Write-up', file ? `${esc(file.title)}` : '', 'The retro\'s document.'], + ] })}

${linkify(r.summary)}

- ${file ? `

${esc(file.title)}

` : ''} -
- ${row('What it decided', r.refs.length ? `
    ${r.refs.map((x) => `
  • ${retroRef(x)}
  • `).join('')}
` : '')} - ${row('Closed', esc(when(r.at)))} -
ID
${esc(r.id)}
-
-

${[nav(all[i + 1], '← the one before,'), nav(all[i - 1], 'the next,')].filter(Boolean).join(' · ')}

`); -} + ${keys.length ? `

The numbers

${keys.map((k) => ``).join('')}
This retroThe one before
${esc(words(k))}${val(nums.now[k])}${val(nums.prev?.[k])}
` : nums ? '

Its write-up has no numbers block.

' : ''} +

${[nav(all[i + 1], '← the one before,'), nav(all[i - 1], 'the next,')].filter(Boolean).join(' · ')}

+ ${historySection(p, id)}`); +} +// Which agents' instructions a piece of knowledge goes into, from its scope. +function whoGets(k) { + const sc = k.scope || 'project'; + const ts = [...ticketMap().values()].filter((t) => !isDone(t)); + if (sc === 'project') return 'every agent: the supervisor, planners and workers'; + if (sc.startsWith('component:')) { const c = sc.slice(10); const xs = ts.filter((t) => t.component === c); return `workers on component ${esc(c)}${xs.length ? `: now ${list(xs.map((t) => t.id), tlink)}` : ''}`; } + if (sc.startsWith('feature:')) { const f = sc.slice(8); const xs = ts.filter((t) => t.feature === f); return `the planner and workers of ${flink(f)}${xs.length ? `: now ${list(xs.map((t) => t.id), tlink)}` : ''}`; } + if (sc.startsWith('class:')) return `the supervisor, when it makes a ${esc(words(sc.slice(6)).toLowerCase())} call`; + if (sc.startsWith('chore:')) return `the worker on the ${esc(sc.slice(6))} chore`; + return esc(sc); +} +const obsList = (os) => (os?.length ? `
    ${os.map((o) => `
  • ${o.severity != null ? `${esc(o.severity)} ` : ''}${linkify(o.detail)} · ${esc(words(o.kind))} · ${esc(who(o.by))} · ${esc(when(o.at))}
  • `).join('')}
` : ''); function openLesson(id) { + drawerFor('lesson', id); const k = (DATA.ooda.knowledge || []).find((x) => x.id === id); - if (!k) return showDrawer(`

No lesson ${esc(id)}

`); - DRAWER = { kind: 'lesson', id }; - showDrawer(`

${esc(cap(k.kind))}

-

${linkify(k.statement)}

+ const p = peekOf('lesson', id); + if (!k) return notFound('knowledge', id, p, p?.error ? 'No such record.' : 'Retired, or older than the dashboard shows; its record is above.'); + const obs = p?.related || []; + showDrawer(`

${esc(k.statement.length > 80 ? `${k.statement.slice(0, 80)}…` : k.statement)}

+ ${attrs({ id, peek: p, type: 'knowledge', computed: [ + ['Goes to', k.status === 'accepted' ? whoGets(k) : `nobody yet: it is ${esc(k.status)}`, 'Whose instructions it is put into, from its scope; only accepted knowledge is.'], + ] })} ${k.status === 'candidate' ? '

Keep or retire it in the Inbox

' : ''} -
- ${row('Status', esc(k.status))} - ${row('Applies to', esc(scopeWords(k.scope)))} - ${row('Avoid', k.valence < 0 ? 'yes: this is something not to do' : '')} - ${row('Confidence', k.confidence != null ? esc(`${Math.round(k.confidence * 100)}%`) : '')} - ${row('From', (k.refs || k.evidence || []).map((r) => linkify(r)).join(', '))} - ${row('Recorded', esc(when(k.created_at)))} -
ID
${esc(k.id)}
-
`); + ${obs.length ? `

The signals it came from

${obsList(obs)}` : ''} + ${citedBlock(p, k)} + ${historySection(p, id)}`); } function openProblem(id) { - const p = (DATA.ooda.problems || []).find((x) => x.id === id); - if (!p) return showDrawer(`

No problem ${esc(id)}

It may be closed.

`); - DRAWER = { kind: 'problem', id }; - showDrawer(`

${esc(p.title)}

-

sev ${esc(p.severity)}${p.realized ? 'issue' : 'risk'}

-
- ${row('Status', esc(p.status))} - ${row('Threatens', (p.threatens || []).map(glink).join(', '))} - ${row('Assigned', p.assigned_to ? linkify(p.assigned_to) : '')} - ${row('Detail', p.detail ? linkify(p.detail) : '')} - ${row('About', (p.refs || []).map((r) => linkify(r)).join(', '))} -
ID
${esc(p.id)}
-
`); + drawerFor('problem', id); + const pb = (DATA.ooda.problems || []).find((x) => x.id === id); + const p = peekOf('problem', id); + const rec = p?.record?.fields; + if (!pb && !rec) return notFound('problem', id, p, p?.error ? 'No such problem.' : ''); + const x = pb || { ...rec, id }; + const statuses = (p?.history || []).filter((l) => l.id === id && (l.op === 'new' || l.status !== undefined)); + const goals = (x.threatens || []).map((gid) => goalOf(gid)).filter(Boolean); + showDrawer(`

${esc(x.title)}

+ ${attrs({ id, peek: p, type: 'problem' })} + ${statuses.length ? `

Status

${statuses.map((l) => `${esc(l.status ?? 'open')}${esc(when(l.at))}`).join('→')}
` : ''} + ${goals.length ? `

What it puts at risk

    ${goals.map((g) => { const fc = g.forecast; const last = (fc?.milestones || []).slice(-1)[0]; return `
  • ${glink(g.id)}: ${last?.eta ? `ETA ${esc(last.eta)}` : 'no ETA yet'}${g.horizon ? ` against ${fmtDay(g.horizon)}` : ''}${fc?.past_horizon ? ' after the horizon' : ''}${(fc?.milestones || []).some((m) => m.late) ? ' a milestone is late' : ''}
  • `; }).join('')}
` : ''} + ${p?.related?.length ? `

Related signals

${obsList(p.related)}` : ''} + ${citedBlock(p, x)} + ${historySection(p, id)}`); } async function openThread(mid) { - DRAWER = { kind: 'thread', id: mid }; + drawerFor('thread', mid); const by = mid.replace(/^m-\d{8}-\d{9}-/, ''); const open = (DATA.open_messages || []).find((m) => m.id === mid); const ticket = ticketMap().has(by) ? by : ticketMap().has(open?.to) ? open.to : null; - if (!ticket) return showDrawer(`

Message

${open ? `

${linkify(open.summary)}

` : ''}

From ${esc(who(by))}. Its thread is on the ticket's timeline.

ID
${esc(mid)}
`); - showDrawer(`

Message

Loading the thread…

`); - let v; - try { const r = await fetch(`/api/timeline?id=${encodeURIComponent(ticket)}`); v = await r.json(); if (!r.ok) throw new Error(v.error || r.status); } catch (e) { return showDrawer(`

Message

Could not load the thread: ${esc(e.message || e)}

`); } - if (DRAWER.id !== mid) return; - const msgs = (v.lines || []).filter((l) => l.type === 'message'); + const p = peekOf('thread', mid); + const tp = ticket ? peekOf('ticket', ticket) : null; + if (ticket) DRAWER.deps = [['ticket', ticket, {}]]; + const msgs = (tp?.history || []).filter((l) => l.type === 'message' && l.op === 'new'); const byId = new Map(msgs.map((m) => [m.id, m])); let root = byId.get(mid); while (root?.re && byId.has(root.re)) root = byId.get(root.re); const inThread = (m) => { let x = m; while (x) { if (x.id === root?.id) return true; x = x.re ? byId.get(x.re) : null; } return false; }; const thread = root ? msgs.filter(inThread).sort((a, b) => a.at.localeCompare(b.at)) : []; - showDrawer(`

Thread on ${tlink(ticket)}

- ${thread.length ? thread.map((m) => `
${esc(who(m.by))} → ${esc(who(m.to))} · ${esc(words(m.kind || 'message'))} · ${esc(when(m.at))}
${linkify(m.text)}
`).join('') : `

Not on ${esc(ticket)}'s timeline.

`} -
ID
${esc(mid)}
`); + showDrawer(`

${ticket ? `Thread on ${tlink(ticket)}` : 'Message'}

+ ${attrs({ id: mid, peek: p, type: 'message', computed: [['From', esc(who(by)), 'Who sent it.']] })} + ${!ticket ? '

Its thread is on the ticket\'s timeline.

' : tp === undefined ? '

Loading the thread…

' : tp.error ? `

Could not load the thread: ${esc(tp.error)}

` : thread.length ? `

The thread

${thread.map((m) => `
${esc(who(m.by))} → ${esc(who(m.to))} · ${esc(words(m.kind || 'message'))} · ${esc(when(m.at))}
${linkify(m.text)}
`).join('')}` : `

Not on ${esc(ticket)}'s timeline.

`} + ${historySection(p, mid)}`); } function openPlanner(feature) { - const p = (DATA.planners || []).find((x) => x.feature === feature); - if (!p) return showDrawer(`

No planner for ${esc(feature)}

Its plan may have landed already.

`); - DRAWER = { kind: 'planner', id: feature }; - showDrawer(`

Planning ${esc(p.title)}

-
-
Status
${esc(cap(ago(p.minutes_silent)))}${p.waiting ? ` · ${p.waiting.kind === 'limit' ? 'usage limit' : 'waiting on a prompt'} ${esc(p.waiting.line)}` : ''}${!p.window ? ` · window gone ${esc(p.problem || '')}` : ''}
- ${p.goals.length ? `
For goal
${p.goals.map((gid) => `${esc((DATA.ooda.goals.find((g) => g.id === gid) || {}).title || gid)}`).join(', ')}
` : ''} -
Tickets so far
${esc(p.tickets)}
- ${(p.waiting_on_owner || []).length ? `
Waiting on you
    ${p.waiting_on_owner.map((id) => `
  • ${qlink(id)}
  • `).join('')}
` : ''} - ${p.waiting_on_supervisor.length ? `
Asked the supervisor
    ${p.waiting_on_supervisor.map((m) => `
  • ${esc(MSG_WORDS[m.kind] || m.kind)}: ${linkify(m.text)}
  • `).join('')}
` : ''} -
Branch
${esc(p.branch)}
-
Window
${esc(`plan-${p.feature}`)}
-
-

Its plan lands as one reviewed PR; until then its tickets aren't on the board.

`); + drawerFor('planner', feature); + const pl = (DATA.planners || []).find((x) => x.feature === feature); + const p = peekOf('planner', feature); + const idTip = 'The planner\'s name: plan- and the feature it plans. Its tmux window and log file use it.'; + if (!pl) return showDrawer(`

${esc(feature)}

${attrs({ id: `plan-${feature}`, idTip, peek: p, noRecord: true })}

No planner is working on it; its plan may have landed already.

${historySection(p, `plan-${feature}`)}`); + const draft = (p?.draft || []).map((t) => ({ ...t, stage: 'planned', depends_on: t.depends_on || [] })); + showDrawer(`

Planning ${esc(pl.title)}

+ ${attrs({ id: `plan-${pl.feature}`, idTip, peek: p, noRecord: true, computed: [ + ['Status', `${esc(cap(ago(pl.minutes_silent)))}${pl.waiting ? ` · ${pl.waiting.kind === 'limit' ? 'usage limit' : 'waiting on a prompt'} ${esc(pl.waiting.line)}` : ''}${!pl.window ? ` · window gone ${esc(pl.problem || '')}` : ''}`, 'How long since it last did anything, and whether it is stuck.'], + ['Feature', flink(pl.feature), 'The feature it is planning.'], + ['For goal', pl.goals.map(glink).join(', '), 'The goals the feature serves.'], + ['Tickets so far', esc(pl.tickets), 'Tickets it has written into its plan.'], + ['Waiting on you', (pl.waiting_on_owner || []).length ? `
    ${pl.waiting_on_owner.map((x) => `
  • ${qlink(x)}
  • `).join('')}
` : '', 'Your open questions it is waiting on.'], + ['Asked the supervisor', pl.waiting_on_supervisor.length ? `
    ${pl.waiting_on_supervisor.map((m) => `
  • ${esc(MSG_WORDS[m.kind] || m.kind)}: ${linkify(m.text)}
  • `).join('')}
` : '', 'Messages to the supervisor it has had no reply to.'], + ['Plan reviews', p?.plan_reviews ? reviewList(p.plan_reviews) || 'none yet' : '', 'Reviews of its plan so far.'], + ['Branch', `${esc(pl.branch)}`, 'Where its plan is being written.'], + ] })} + ${designBlock(p?.design, 'Its design')} + ${draft.length ? `

Its draft

The tickets its plan holds so far; they reach the board when its plan PR lands.

${boardCols(draft, new Set(), { only: true, inert: true })}
${draft.length > 1 ? depGraph(draft) : ''}` : ''} +

Its plan lands as one reviewed PR; until then its tickets aren't on the board.

+ ${historySection(p, `plan-${feature}`)}`); } function openQuestion(id) { const q = allQuestions().find((x) => x.id === id); - if (!q) return showDrawer(`

No question ${esc(id)}.

`); - if (q.status === 'open') { closeDrawer(); location.hash = 'inbox'; setTimeout(() => document.getElementById(`q-${id}`)?.scrollIntoView({ block: 'center' }), 0); return; } - DRAWER = { kind: 'q', id }; - const list = choicesOf(q); + if (q?.status === 'open') { closeDrawer(); location.hash = 'inbox'; setTimeout(() => document.getElementById(`q-${id}`)?.scrollIntoView({ block: 'center' }), 0); return; } + drawerFor('q', id); + const p = peekOf('q', id); + if (!q) return notFound('question', id, p, p?.error ? 'No such question.' : ''); + const list2 = choicesOf(q); + const used = (DATA.ooda.decisions || []).find((x) => x.kind === 'answer' && x.id === id)?.acts || []; + const blocked = (q.blocks || []).map((b) => ticketMap().get(b) ? `${tlink(b)} · ${esc(STAGE_WORDS[ticketMap().get(b).stage] || ticketMap().get(b).stage)}` : linkify(b)); + const took = q.answered_at && q.asked_at ? Date.parse(q.answered_at) - Date.parse(q.asked_at) : null; showDrawer(`

${linkify(q.question)}

+ ${attrs({ id, peek: p, type: 'question', computed: [ + ['Status', esc(q.status), 'open, answered, defaulted or withdrawn.'], + ['Settled', q.answered_at ? `${esc(when(q.answered_at))}${q.answered_by ? ` by ${esc(q.answered_by === 'owner' ? 'you' : q.answered_by)}` : ''}` : '', 'When it was answered, defaulted or withdrawn, and by whom.'], + ['Took', took != null && ['answered', 'defaulted'].includes(q.status) ? esc(took < 3600e3 ? `${Math.max(1, Math.round(took / 60e3))} minutes` : took < 2 * DAY ? `${(took / 3600e3).toFixed(1)} hours` : `${(took / DAY).toFixed(1)} days`) : '', 'From asking to the answer.'], + ['Matched', q.matched == null ? '' : q.matched ? 'yes: you went with its recommendation' : 'no: you chose otherwise', 'Whether your answer was its recommendation; this is the evidence for more trust.'], + ] })} ${q.context ? `

${linkify(q.context)}

` : ''} - ${list.length ? `
    ${list.map((c) => `
  • ${esc(c.label)}${c.rec ? ' (recommended)' : ''}${c.detail ? ` · ${linkify(c.detail)}` : ''}${c.effect ? ` · For users: ${linkify(c.effect)}` : ''}
  • `).join('')}
` : ''} + ${list2.length ? `
    ${list2.map((c) => `
  • ${esc(c.label)}${c.rec ? ' (recommended)' : ''}${c.detail ? ` · ${linkify(c.detail)}` : ''}${c.effect ? ` · For users: ${linkify(c.effect)}` : ''}
  • `).join('')}
` : ''} ${(q.links || []).length ? `` : ''} - ${answeredCard(q)}`); -} -async function openFile(params) { - DRAWER = { kind: 'file', id: params.get('path') }; - const p = params.get('path'), title = params.get('title') || p; - const url = `/api/file?${new URLSearchParams({ path: p, ...(params.get('ref') ? { ref: params.get('ref') } : {}) })}`; - const ext = (p.split('.').pop() || '').toLowerCase(); - if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) return showDrawer(`

${esc(title)}

${esc(title)}
`); - if (['html', 'pdf'].includes(ext)) return showDrawer(`

${esc(title)}

Open in a new tab

`); - showDrawer(`

${esc(title)}

Loading…

`); - try { - const r = await fetch(url); const txt = await r.text(); if (!r.ok) throw new Error(txt); - const where = `${esc(p)}${params.get('ref') ? ` at ${esc(params.get('ref'))}` : ''}`; - // Markdown renders (sanitized: agents write these files) with a toggle back to the raw text. - const isMd = ['md', 'markdown'].includes(ext) && window.marked && window.DOMPurify; - const mode = (() => { try { return localStorage.getItem('devteam.mdview') || 'rendered'; } catch { return 'rendered'; } })(); - const rendered = isMd ? DOMPurify.sanitize(marked.parse(txt, { gfm: true }), { FORBID_TAGS: ['style', 'form', 'input'], FORBID_ATTR: ['style'] }) : ''; - showDrawer(`

${esc(title)}

-
${where}${isMd ? `` : ''}
-
${isMd ? `
${rendered}
` : ''}
${esc(txt)}
`); - if (isMd) renderMermaid($('drawer-body').querySelector('.md')); - } - catch (e) { showDrawer(`

${esc(title)}

${esc(e.message)}

`); } + ${answeredCard(q)} + ${blocked.length ? `

What it held up

    ${blocked.map((b) => `
  • ${b}
  • `).join('')}
` : ''} + ${used.length ? `

What used it

    ${used.map((x) => `
  • ${x.startsWith('d-') ? dlink(x, decisionTitle(x)) : linkify(x)}
  • `).join('')}
` : ''} + ${historySection(p, id)}`); +} +// A file's text, kept per URL and fetched again on each refresh (a spec its worker is writing changes); +// one fetch at a time per URL. +const FILES = new Map(); +const FILE_FLIGHT = new Map(); +function refetchFile(url) { + if (FILE_FLIGHT.has(url)) return FILE_FLIGHT.get(url); + const pr = (async () => { + try { const r = await fetch(url); const t = await r.text(); if (!r.ok) throw new Error(t); FILES.set(url, t); } + catch (e) { if (!FILES.has(url)) FILES.set(url, { error: e.message }); } + finally { FILE_FLIGHT.delete(url); } + redraw(); + })(); + FILE_FLIGHT.set(url, pr); + return pr; +} +function openFile(params) { + const path = params.get('path'), title = params.get('title') || path, ref = params.get('ref'); + drawerFor('file', path, params); + const url = `/api/file?${new URLSearchParams({ path, ...(ref ? { ref } : {}) })}`; + DRAWER.url = url; + const ext = (path.split('.').pop() || '').toLowerCase(); + const p = peekOf('file', path, ref ? { ref } : {}); + const head = `

${esc(title)}

${attrs({ id: path, idTip: 'Its path in the repository.', peek: p, noRecord: true, computed: [['Version', `${esc(ref || 'main')}`, 'The branch or commit it is read from; committed content only.'], ['Commits', p?.history?.length ? esc(p.history.length) : '', 'Commits that changed it (the latest 60).']] })}`; + const tail = historySection(p, path); + if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) return showDrawer(`${head}
${esc(title)}
${tail}`); + if (['html', 'pdf'].includes(ext)) return showDrawer(`${head}

Open in a new tab

${tail}`); + const txt = FILES.get(url); + if (txt === undefined) { refetchFile(url); return showDrawer(`${head}

Loading…

${tail}`); } + if (typeof txt !== 'string') return showDrawer(`${head}

${esc(txt.error)}

${tail}`); + const where = `${esc(path)}${ref ? ` at ${esc(ref)}` : ''}`; + // Markdown renders (sanitized: agents write these files) with a toggle back to the raw text. + const isMd = ['md', 'markdown'].includes(ext) && window.marked && window.DOMPurify; + const mode = (() => { try { return localStorage.getItem('devteam.mdview') || 'rendered'; } catch { return 'rendered'; } })(); + const rendered = isMd ? DOMPurify.sanitize(marked.parse(txt, { gfm: true }), { FORBID_TAGS: ['style', 'form', 'input'], FORBID_ATTR: ['style'] }) : ''; + if (isMd) AFTER.push(() => renderMermaid($('drawer-body').querySelector('.md'))); + showDrawer(`${head} +
${where}${isMd ? `` : ''}
+
${isMd ? `
${rendered}
` : ''}
${esc(txt)}
+ ${tail}`); } // Mermaid fenced blocks in markdown render as diagrams. The library loads from the CDN only when a // document has one; offline, or on a diagram with an error, the source stays as a code block. @@ -2172,6 +2648,8 @@

History

// (and the Inbox cards redraw, so a card's progress pill is the one the review just reported) if (closeReview()) { if (DATA) render(DATA); if (/^[a-z]+$/.test(raw)) document.getElementById(raw)?.scrollIntoView(); } const h = decodeURIComponent(raw); + const k = h.startsWith('file?') ? 'file' : h.split('/')[0]; + if (TYPE_META[k] && h.includes('/') || k === 'file') DRAWER = { kind: k, id: null }; if (h.startsWith('ticket/')) return void openTicket(h.slice(7)); if (h.startsWith('q/')) return void openQuestion(h.slice(2)); if (h.startsWith('goal/')) return void openGoal(h.slice(5)); @@ -2185,7 +2663,12 @@

History

window.addEventListener('hashchange', () => { if (DATA) route(); }); $('drawer-close').addEventListener('click', closeDrawer); $('scrim').addEventListener('click', () => (RV ? leaveReview() : closeDrawer())); -document.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; if (RV) leaveReview(); else if (!$('drawer').hidden) closeDrawer(); }); +document.addEventListener('keydown', (e) => { + if (e.key !== 'Escape') return; + // Escape on an attribute's key closes its tooltip, not the drawer + if (document.activeElement?.classList?.contains('attr-k')) return void document.activeElement.blur(); + if (RV) leaveReview(); else if (!$('drawer').hidden) closeDrawer(); +}); document.addEventListener('click', (e) => { if (e.target.closest('[data-rv-close]')) leaveReview(); }); // a review opened from the dashboard closes back to it; one opened from a link goes to the Inbox let RV_FROM = null; @@ -2326,7 +2809,7 @@

${linkify(k.statement)}

m.gates.slowest_tests.length ? `

Slowest tests

${m.gates.slowest_tests.map((t) => `
${esc(t.name)} ${t.median_seconds}s
`).join('')}
` : '', ].join(''); - if (DRAWER.kind === 'ticket') openTicket(DRAWER.id, { quiet: true }); + refreshDrawer(); // last, so every section that holds an input is drawn before its half-typed value comes back for (const [sel, v] of kept) { const el = document.querySelector(sel); if (el) el.value = v; } for (const sel of keptChecks) { const el = document.querySelector(sel); if (el) el.checked = true; } @@ -2440,10 +2923,11 @@

${linkify(k.statement)}

const id = hb.dataset.holdId, mode = hb.dataset.mode; const errId = `hold-${id}`; if (mode === 'let-go' && !hb.classList.contains('confirm')) { hb.classList.add('confirm'); hb.textContent = 'Confirm let go'; setTimeout(() => { hb.classList.remove('confirm'); hb.textContent = 'Let go'; }, 6000); return; } - hb.disabled = true; + hb.disabled = true; hb.setAttribute('aria-busy', 'true'); const ok = mode === 'let-go' ? await post('goal-decide', { id, verdict: 'let-go' }, errId) : mode === 'restore' ? await post('goal-decide', { id, verdict: 'restore' }, errId) : await post('goal-hold', { id, mode }, errId); + hb.removeAttribute('aria-busy'); if (!ok) hb.disabled = false; - else loadFleet(); + else { loadFleet(); redraw(); } return; } const fb = ev.target.closest('[data-fleet]'); @@ -2601,6 +3085,7 @@

${linkify(k.statement)}

} }); +loadSchemaDocs(); load(); setInterval(load, 10000); diff --git a/docs/devteam/schema/log.schema.json b/docs/devteam/schema/log.schema.json index 208b15a..1fdb96d 100644 --- a/docs/devteam/schema/log.schema.json +++ b/docs/devteam/schema/log.schema.json @@ -14,13 +14,16 @@ ], "properties": { "v": { + "description": "Version of the log line format.", "const": 1 }, "id": { + "description": "The record's id in the log: a type letter, when it was created, and who wrote it. Tickets and features use their own slug.", "type": "string", "minLength": 3 }, "type": { + "description": "What kind of record this is.", "enum": [ "goal", "feature", @@ -38,18 +41,21 @@ ] }, "op": { + "description": "new creates the record; set changes some of its fields later.", "enum": [ "new", "set" ] }, "at": { + "description": "When this line was written (UTC).", "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" }, "by": { "type": "string", - "description": "owner, supervisor, a ticket id, or plan-; must match the file's writer (owner-, supervisor-, , plan-)" + "description": "owner, supervisor, a ticket id, or plan-; must match the file's writer (owner-, supervisor-, , plan-)", + "x-tip": "Who wrote the line: you, the supervisor, a ticket's agent or a planner." }, "channel": { "enum": [ @@ -57,7 +63,8 @@ "dashboard", "chat" ], - "description": "on owner lines only: where the owner typed it. chat: the owner said it in the supervisor's session and the supervisor recorded it (answers only, with the owner's words in said)" + "description": "on owner lines only: where the owner typed it. chat: the owner said it in the supervisor's session and the supervisor recorded it (answers only, with the owner's words in said)", + "x-tip": "Where you gave it: the dashboard, your terminal, or the supervisor's chat (recorded with your words)." } }, "$defs": { @@ -67,24 +74,31 @@ "type": "object", "properties": { "title": { + "description": "The goal in a few words.", "type": "string" }, "outcome": { + "description": "What is true for users or the business when the goal is met.", "type": "string" }, "measure": { + "description": "The number that says whether it is met: what is measured, the target, its unit, and whether up or down is better.", "type": "object", "properties": { "description": { + "description": "What is measured.", "type": "string" }, "target": { + "description": "The value that meets the goal.", "type": "number" }, "unit": { + "description": "Its unit.", "type": "string" }, "direction": { + "description": "up if higher is better, down if lower is.", "enum": [ "up", "down" @@ -94,24 +108,28 @@ "additionalProperties": false }, "horizon": { + "description": "The date the goal should be met by, if there is one.", "type": [ "string", "null" ] }, "non_goals": { + "description": "What this goal deliberately leaves out.", "type": "array", "items": { "type": "string" } }, "preconditions": { + "description": "What must be true before the goal can be met; features are planned to make each one true.", "type": "array", "items": { "type": "string" } }, "priority": { + "description": "Its order among the goals: 1 comes first.", "type": "integer", "minimum": 1, "maximum": 99 @@ -123,18 +141,22 @@ "type": "object", "properties": { "title": { + "description": "The milestone in a few words.", "type": "string" }, "target": { + "description": "The value of the goal's measure that reaches it.", "type": "number" }, "by": { + "description": "The date it should be reached by, if you set one.", "type": [ "string", "null" ] }, "preconditions": { + "description": "The goal's preconditions it makes true, by number.", "type": "array", "items": { "type": "integer", @@ -142,6 +164,7 @@ } }, "tickets": { + "description": "A rough size, in tickets, before it is planned.", "type": "integer", "minimum": 1 } @@ -153,9 +176,11 @@ "tickets" ], "additionalProperties": false - } + }, + "x-tip": "The goal in usable slices, in order: each with a target on its measure, the preconditions it makes true, and an optional date." }, "status": { + "description": "draft while it is being defined, ready once you approve it, met when the measure reaches its target, abandoned when let go.", "enum": [ "draft", "ready", @@ -167,7 +192,8 @@ "description": "upkeep: the built-in goal that owns the reserved chores feature and the supervisor's upkeep (planning.md §7). It has no measure, design, milestones or status lifecycle; the earliest upkeep line is the one, later ones are ignored.", "enum": [ "upkeep" - ] + ], + "x-tip": "upkeep for the built-in goal that owns chores and the supervisor's upkeep; empty for every other goal." }, "hold": { "description": "The goal's toggle (planning.md §1, holds): run, drain (nothing new starts; in-flight work finishes and merges) or park (sessions commit WIP, push, write a handoff and stop; ticket merges wait). fleet names the fleet op that set it and prior the hold it replaced, so fleet undrain/unpark can put it back. The owner sets any hold; a supervisor line counts only as a drain of a running goal, the release of its own drain, or a fleet op (the fold enforces it).", @@ -224,7 +250,8 @@ "required": [ "mode" ], - "additionalProperties": false + "additionalProperties": false, + "x-tip": "The goal's toggle: run, drain (nothing new starts, work in flight finishes) or park (sessions stop with a handoff)." }, "decision": { "type": "object", @@ -248,7 +275,8 @@ "required": [ "verdict" ], - "additionalProperties": false + "additionalProperties": false, + "x-tip": "Your latest verdict on its design: approve, refine, let go or restore." }, "plan_after": { "type": "array", @@ -270,15 +298,18 @@ "reason" ], "additionalProperties": false - } + }, + "x-tip": "Goals whose design this one waits on, and why." }, "approved": { "type": "string", - "description": "on a supervisor's set of a goal past draft: the goal-change question (its id) the owner answered with the recommendation. The set must be exactly the change that question proposed; each answer authorises one set." + "description": "on a supervisor's set of a goal past draft: the goal-change question (its id) the owner answered with the recommendation. The set must be exactly the change that question proposed; each answer authorises one set.", + "x-tip": "The goal-change question whose answer authorised the supervisor's latest change." }, "carried": { "type": "boolean", - "description": "On a supervisor's set with approved: the answer was given before goal-change questions recorded their change, and the supervisor carries it forward (a carry-answer act the owner can correct)." + "description": "On a supervisor's set with approved: the answer was given before goal-change questions recorded their change, and the supervisor carries it forward (a carry-answer act the owner can correct).", + "x-tip": "The supervisor carried an older answer of yours forward to make this change." } }, "additionalProperties": false, @@ -296,33 +327,40 @@ "type": "object", "properties": { "title": { + "description": "The feature in a few words.", "type": "string" }, "goals": { + "description": "The goals this feature serves.", "type": "array", "items": { "type": "string" } }, "preconditions": { + "description": "The goal preconditions this feature makes true (#).", "type": "array", "items": { "type": "string" } }, "stories": { + "description": "What users can do once it ships, each with the release it targets.", "type": "array", "items": { "type": "object", "properties": { "id": { + "description": "The story's id: the component's prefix and S with a number.", "type": "string", "pattern": "^[A-Z][A-Z0-9]*-S[0-9]+$" }, "text": { + "description": "What a user can do once it ships.", "type": "string" }, "release": { + "description": "The release it targets.", "type": [ "string", "null" @@ -337,9 +375,11 @@ } }, "design": { + "description": "The design document it implements.", "type": "string" }, "notes": { + "description": "Anything else the planner wanted on record.", "type": "string" } }, @@ -355,17 +395,21 @@ "type": "object", "properties": { "title": { + "description": "The ticket in a few words.", "type": "string" }, "feature": { + "description": "The feature it is part of (chores for upkeep).", "type": "string", "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$" }, "component": { + "description": "The part of the codebase it belongs to.", "type": "string", "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$" }, "kind": { + "description": "contract (an interface others build on), build, integration, chore or spike (a time-boxed investigation).", "enum": [ "contract", "build", @@ -375,18 +419,21 @@ ] }, "size": { + "description": "S or M: tickets are kept small enough for one PR.", "enum": [ "S", "M" ] }, "risk": { + "description": "high gets two reviewers and a full plan read; normal is approved from its TL;DR and plan review.", "enum": [ "normal", "high" ] }, "serves": { + "description": "The stories it helps deliver.", "type": "array", "items": { "type": "string", @@ -394,12 +441,14 @@ } }, "depends_on": { + "description": "Tickets that must merge before this one starts.", "type": "array", "items": { "type": "string" } }, "contracts": { + "description": "Interfaces it provides to other tickets, and ones it consumes.", "type": "object", "properties": { "provides": { @@ -418,6 +467,7 @@ "additionalProperties": false }, "touches": { + "description": "The files and folders it may change; two tickets in flight never overlap.", "type": "array", "items": { "type": "string", @@ -426,6 +476,7 @@ "minItems": 1 }, "done_when": { + "description": "What must be true for it to count as done.", "type": "array", "items": { "type": "string", @@ -434,12 +485,14 @@ "minItems": 1 }, "one_way_doors": { + "description": "Choices in it that are hard to undo; you answer each before its plan is approved.", "type": "array", "items": { "type": "string" } }, "superseded_by": { + "description": "The ticket that replaced it in a re-plan.", "type": [ "string", "null" @@ -450,9 +503,11 @@ "string", "null" ], - "description": "Why the supervisor is not scheduling this ticket yet; null releases it. A held ticket is left out of `ready` and the idle-capacity finding." + "description": "Why the supervisor is not scheduling this ticket yet; null releases it. A held ticket is left out of `ready` and the idle-capacity finding.", + "x-tip": "Why the supervisor is not scheduling it yet; empty once released." }, "notes": { + "description": "Anything else the planner wanted on record.", "type": "string" } }, @@ -475,6 +530,7 @@ "type": "object", "properties": { "to": { + "description": "The phase the agent moved to.", "enum": [ "starting", "spec", @@ -492,30 +548,37 @@ ] }, "from": { + "description": "The phase it left.", "type": [ "string", "null" ] }, "task": { + "description": "The step of its plan it is on.", "type": "string" }, "note": { + "description": "What it said about the move.", "type": "string", "maxLength": 280 }, "sha": { + "description": "The commit it was at.", "type": "string" }, "usd": { + "description": "What it had spent so far, in dollars.", "type": "number", "minimum": 0 }, "input_tokens": { + "description": "Tokens read so far.", "type": "number", "minimum": 0 }, "output_tokens": { + "description": "Tokens written so far.", "type": "number", "minimum": 0 } @@ -530,28 +593,35 @@ "type": "object", "properties": { "gate": { + "description": "Which check ran.", "type": "string" }, "ok": { + "description": "Whether it passed.", "type": "boolean" }, "ms": { + "description": "How long it took, in milliseconds.", "type": "number", "minimum": 0 }, "sha": { + "description": "The commit it ran on.", "type": "string" }, "dirty": { "description": "The run had uncommitted changes, so sha does not name what ran (a TDD red, then green). Not counted for flakiness.", - "type": "boolean" + "type": "boolean", + "x-tip": "The run had uncommitted changes, so it is not counted toward flakiness." }, "tests_total": { + "description": "How many tests ran.", "type": "integer", "minimum": 0, "maximum": 1000000000 }, "tests": { + "description": "Per-test results, for flaky and slow tests.", "type": "array", "items": { "type": "object", @@ -578,7 +648,8 @@ }, "preexisting": { "type": "boolean", - "description": "A ratchet gate that failed here and at the merge base too: pre-existing debt, allowed; not counted in the gate's fail rate." + "description": "A ratchet gate that failed here and at the merge base too: pre-existing debt, allowed; not counted in the gate's fail rate.", + "x-tip": "It failed on main too: existing debt, allowed, not counted against the check." } }, "additionalProperties": false, @@ -593,9 +664,11 @@ "type": "object", "properties": { "to": { + "description": "Who it was sent to.", "type": "string" }, "kind": { + "description": "What sort of message: a question for a review or go-ahead, a merge request, blocked, a design change, a ruling, a relayed answer, or info.", "enum": [ "design-change", "merge-request", @@ -607,22 +680,26 @@ ] }, "text": { + "description": "What it said.", "type": "string", "maxLength": 600 }, "re": { + "description": "The message it replies to.", "type": [ "string", "null" ] }, "sha": { + "description": "The commit it is about.", "type": [ "string", "null" ] }, "ticket": { + "description": "The ticket it is about.", "type": [ "string", "null" @@ -641,46 +718,58 @@ "type": "object", "properties": { "source": { + "description": "Who or what noticed it.", "type": "string" }, "subject": { + "description": "What it is about: a ticket, component, feature, gate, goal, class or record.", "type": "string" }, "kind": { + "description": "What sort of observation (a reading, a slow test, an escaped defect, a retro…).", "type": "string" }, "detail": { + "description": "What was noticed.", "type": "string" }, "detector": { + "description": "The check that found it.", "type": "string" }, "severity": { + "description": "How bad, 0 (nothing) to 4 (severe).", "type": "integer", "minimum": 0, "maximum": 4 }, "valence": { + "description": "Good or bad, -2 to 2.", "type": "integer", "minimum": -2, "maximum": 2 }, "source_key": { + "description": "Groups repeats of the same signal together.", "type": "string" }, "source_ref": { + "description": "The file or record it came from.", "type": "string" }, "refs": { + "description": "Records it relates to.", "type": "array", "items": { "type": "string" } }, "fix_hint": { + "description": "A suggested fix.", "type": "string" }, "measure": { + "description": "The value it read, with its unit.", "type": "object", "properties": { "value": { @@ -708,6 +797,7 @@ "type": "object", "properties": { "kind": { + "description": "fact, lesson, preference (often a standing rule of yours), constraint or pointer.", "enum": [ "fact", "lesson", @@ -717,13 +807,16 @@ ] }, "statement": { + "description": "What was learned.", "type": "string" }, "scope": { + "description": "Where it applies: the whole project, a component, a feature, a kind of decision or a chore.", "type": "string", "pattern": "^(project|component:[a-z0-9-]+|feature:[a-z0-9-]+|class:[a-z0-9-]+|chore:[a-z0-9-]+)$" }, "status": { + "description": "candidate until you keep it; accepted ones go into the instructions of every agent they apply to; stale or retired ones no longer do.", "enum": [ "candidate", "accepted", @@ -732,16 +825,19 @@ ] }, "confidence": { + "description": "How sure whoever proposed it was, 0 to 1.", "type": "number", "minimum": 0, "maximum": 1 }, "valence": { + "description": "Negative means something to avoid.", "type": "integer", "minimum": -2, "maximum": 2 }, "evidence": { + "description": "The records it was learned from.", "type": "array", "items": { "type": "string" @@ -761,17 +857,21 @@ "type": "object", "properties": { "title": { + "description": "The problem in a few words.", "type": "string" }, "realized": { + "description": "true for an issue that is happening, false for a risk that might.", "type": "boolean" }, "severity": { + "description": "How bad, 0 to 4.", "type": "integer", "minimum": 0, "maximum": 4 }, "status": { + "description": "open, owned, mitigating or resolved.", "enum": [ "open", "owned", @@ -780,18 +880,22 @@ ] }, "detail": { + "description": "What is wrong and what is known.", "type": "string" }, "threatens": { + "description": "Goals it puts at risk.", "type": "array", "items": { "type": "string" } }, "assigned_to": { + "description": "Who is handling it.", "type": "string" }, "refs": { + "description": "Records it relates to.", "type": "array", "items": { "type": "string" @@ -811,6 +915,7 @@ "type": "object", "properties": { "kind": { + "description": "What sort of question: a decision, a one-way door, a clarification, a proposal, more trust, defining or approving a goal, something only you can do, or a review.", "enum": [ "owner-decision", "one-way-door", @@ -824,21 +929,27 @@ ] }, "class": { + "description": "The kind of decision on the trust ladder it belongs to.", "type": "string" }, "scope": { + "description": "Narrows the class to one area, if set.", "type": "string" }, "ticket": { + "description": "The ticket it is about.", "type": "string" }, "question": { + "description": "What was asked.", "type": "string" }, "context": { + "description": "Background for answering.", "type": "string" }, "options": { + "description": "The choices offered.", "type": "array", "items": { "type": "string" @@ -868,7 +979,8 @@ "label" ], "additionalProperties": false - } + }, + "x-tip": "The answers offered: a label, what it means, and what it changes for users." }, "secrets": { "description": "Values an owner-action question asks the owner to post, each with where it goes. The value is piped from the owner's process straight to its sink and never written to the log (supervisor.md, owner actions).", @@ -897,7 +1009,8 @@ "sink" ], "additionalProperties": false - } + }, + "x-tip": "Values it asked you to post, and where each goes. Values never reach the log." }, "secrets_posted": { "description": "Which declared secrets the owner has posted, cumulative: the name and when. Never the value or its size.", @@ -917,7 +1030,8 @@ "at" ], "additionalProperties": false - } + }, + "x-tip": "Which secrets you posted, and when. Never the values." }, "links": { "type": "array", @@ -946,37 +1060,45 @@ "title" ], "additionalProperties": false - } + }, + "x-tip": "What to look at to answer: files in the repo or URLs." }, "rule_scope": { "type": "string", "pattern": "^(project|component:[a-z0-9-]+|feature:[a-z0-9-]+|class:[a-z0-9-]+|chore:[a-z0-9-]+)$", - "description": "Where a standing rule given with the answer applies, unless the owner names another scope (chore:devteam-sync for an upstream sync approval)." + "description": "Where a standing rule given with the answer applies, unless the owner names another scope (chore:devteam-sync for an upstream sync approval).", + "x-tip": "Where a standing rule given with the answer applies." }, "goal": { "type": "string", "pattern": "^g-", - "description": "The goal a goal-approval question asks the owner to approve, on its linked design." + "description": "The goal a goal-approval question asks the owner to approve, on its linked design.", + "x-tip": "The goal it is about." }, "recommendation": { + "description": "The supervisor's recommendation.", "type": "string" }, "confidence": { + "description": "How sure the supervisor was of its recommendation, 0 to 1.", "type": "number", "minimum": 0, "maximum": 1 }, "default": { + "description": "What happens if you do not answer by the deadline (only where the class allows one).", "type": [ "string", "null" ] }, "deadline": { + "description": "When the default applies.", "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" }, "blocks": { + "description": "Tickets or chores waiting on the answer.", "type": "array", "items": { "type": "string" @@ -989,18 +1111,22 @@ "maxItems": 10, "items": { "type": "string" - } + }, + "x-tip": "The tickets, chores or plans its answer approves, each once." }, "evidence": { + "description": "Records behind the recommendation.", "type": "array", "items": { "type": "string" } }, "note": { + "description": "The supervisor's note.", "type": "string" }, "why_asked": { + "description": "Why it came to you: the rung and ceiling of its class, and the reasons.", "type": "object", "properties": { "rung": { @@ -1023,23 +1149,28 @@ "additionalProperties": false }, "status": { + "description": "withdrawn or defaulted; an answered question carries your answer instead.", "enum": [ "withdrawn", "defaulted" ] }, "reason": { + "description": "Why it was withdrawn.", "type": "string" }, "answer": { + "description": "Your answer.", "type": "string" }, "said": { "type": "string", "minLength": 1, - "description": "the owner's own words, verbatim, when the answer came through the supervisor's chat" + "description": "the owner's own words, verbatim, when the answer came through the supervisor's chat", + "x-tip": "Your words, verbatim, when you answered in the supervisor's chat." }, "autonomy": { + "description": "What your answer said about the next decision of this class: ask, ask with a default, or decide and tell you.", "type": [ "object", "null" @@ -1067,15 +1198,18 @@ "additionalProperties": false }, "standing_rule": { + "description": "A rule you gave with the answer, kept as knowledge.", "type": "string" }, "re": { "type": "string", - "description": "the worker message this question escalates" + "description": "the worker message this question escalates", + "x-tip": "The agent's message it escalates." }, "change": { "type": "object", - "description": "On a goal-change question about a goal: the exact goal fields it proposes. The owner's answer with the recommendation authorises this change and no other (goal set --approved applies it as is)." + "description": "On a goal-change question about a goal: the exact goal fields it proposes. The owner's answer with the recommendation authorises this change and no other (goal set --approved applies it as is).", + "x-tip": "The exact change to the goal it proposed; your answer authorised that and nothing else." }, "review": { "description": "A review question's pinned files (process/supervisor.md): the agent's page, its optional data and the result's optional schema, read at one commit, each with its sha256 and size; data counts its rows and the rows with a proposal. The owner's process re-hashes them before it accepts a result. A review is answered with its result (answer --review-result) or sent back (answer --decline), never with free text, a default or from chat.", @@ -1180,7 +1314,8 @@ "page", "allow_partial" ], - "additionalProperties": false + "additionalProperties": false, + "x-tip": "The pages and data you reviewed, pinned by hash." }, "result": { "description": "On the owner's answer to a review: the sha256 and size of run/questions//result.json as the owner's process wrote it and, for a result in the standard rows shape, the counts it recomputed: proposed is the rows whose data has a proposal; kept the decided rows whose values equal it (never taken from the page); cleared the other decided rows with every value empty; changed the rest.", @@ -1227,11 +1362,13 @@ "sha256", "bytes" ], - "additionalProperties": false + "additionalProperties": false, + "x-tip": "Your review result, by hash, with how many rows you kept, changed or cleared." }, "declined": { "const": true, - "description": "On the owner's answer to a review: sent back without a result; the answer says why." + "description": "On the owner's answer to a review: sent back without a result; the answer says why.", + "x-tip": "You sent the review back without a result." } }, "additionalProperties": false, @@ -1250,43 +1387,53 @@ "type": "object", "properties": { "class": { + "description": "The kind of decision on the trust ladder.", "type": "string" }, "scope": { + "description": "Narrows the class to one area, if set.", "type": "string" }, "what": { + "description": "What was decided.", "type": "string" }, "rationale": { + "description": "Why.", "type": "string" }, "options": { + "description": "The options weighed.", "type": "array", "items": { "type": "string" } }, "confidence": { + "description": "How sure the supervisor was, 0 to 1.", "type": "number", "minimum": 0, "maximum": 1 }, "refs": { + "description": "What it is about; acts under it stay within these.", "type": "array", "items": { "type": "string" } }, "reversible": { + "description": "Whether it can be undone.", "type": "boolean" }, "rung": { + "description": "The class's rung when it was decided: 1 asks, 2 asks with a default, 3 decides and tells you.", "type": "integer", "minimum": 1, "maximum": 3 }, "ceiling": { + "description": "The highest rung the class can reach.", "type": "integer", "minimum": 1, "maximum": 3 @@ -1299,13 +1446,16 @@ "default", "standing-rule", "constitution" - ] + ], + "x-tip": "What allowed the call: its rung, your answer, a default, a standing rule, or the constitution." }, "approved_by": { + "description": "Your answer or standing rule it rests on.", "type": "string", "pattern": "^[qk]-" }, "corrected": { + "description": "Your correction, if the call was wrong; it drops the class to rung 1.", "type": "object", "properties": { "reason": { @@ -1323,6 +1473,7 @@ "additionalProperties": false }, "repaired": { + "description": "What the supervisor did about the correction.", "enum": [ "undo", "fix-ticket", @@ -1346,37 +1497,46 @@ "type": "object", "properties": { "class": { + "description": "The kind of act or decision.", "type": "string" }, "scope": { + "description": "Narrows the class to one area, if set.", "type": "string" }, "what": { + "description": "What was done.", "type": "string" }, "rung": { + "description": "The class's rung when it acted on its own trust.", "type": "integer", "minimum": 1, "maximum": 3 }, "refs": { + "description": "What it acted on.", "type": "array", "items": { "type": "string" } }, "reversible": { + "description": "Whether it can be undone.", "type": "boolean" }, "approved_by": { + "description": "Your answer or standing rule it rests on.", "type": "string" }, "decision": { "description": "The decision line this act carries out (d-...). The act then takes its class and authority from it.", "type": "string", - "pattern": "^d-" + "pattern": "^d-", + "x-tip": "The decision it carried out." }, "corrected": { + "description": "Your correction, if it was its own call and wrong.", "type": "object", "properties": { "reason": { @@ -1394,6 +1554,7 @@ "additionalProperties": false }, "repaired": { + "description": "What the supervisor did about the correction.", "enum": [ "undo", "fix-ticket", @@ -1421,7 +1582,8 @@ "from", "to" ], - "additionalProperties": false + "additionalProperties": false, + "x-tip": "The ticket numbers reserved for a planner's feature." } }, "additionalProperties": false, @@ -1440,9 +1602,11 @@ "properties": { "who": { "type": "string", - "description": "the ticket, planner (plan-) or supervisor" + "description": "the ticket, planner (plan-) or supervisor", + "x-tip": "The ticket, planner or supervisor that spent it." }, "role": { + "description": "Worker, planner, reviewer or supervisor.", "enum": [ "worker", "reviewer", @@ -1451,33 +1615,41 @@ ] }, "phase": { + "description": "The phase the spend was in.", "type": "string" }, "through": { "type": "string", - "description": "ISO time of the latest response counted" + "description": "ISO time of the latest response counted", + "x-tip": "The latest response counted." }, "usd": { + "description": "Dollars spent.", "type": "number", "minimum": 0 }, "input_tokens": { + "description": "Tokens read.", "type": "number", "minimum": 0 }, "output_tokens": { + "description": "Tokens written.", "type": "number", "minimum": 0 }, "cache_read_tokens": { + "description": "Tokens read from the prompt cache.", "type": "number", "minimum": 0 }, "cache_write_tokens": { + "description": "Tokens written to the prompt cache.", "type": "number", "minimum": 0 }, "by_model": { + "description": "The same, per model.", "type": "object", "additionalProperties": { "type": "object", diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 524d551..e5569ee 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -5108,9 +5108,7 @@ const cmdDashboardData = () => { gitReadCache = new Map(); out(JSON.stringify(da // timeline [--json]: the ticket, and every log line that bears on it (its own edits, // its worker's phases, gates and messages, questions it raised or that block it, acts and // observations that name it), oldest first; plus its review records and its files. -function cmdTimeline() { - gitReadCache = new Map(); - const [id] = positional; +function timelineView(id) { const t = tickets().get(id) ?? die(`no ticket ${id}`); const touches = (l) => l.id === id || l.by === id || l.ticket === id || l.to === id || l.subject === id || (l.refs ?? []).includes(id) || (l.blocks ?? []).includes(id) || (l.serves ?? []).includes(id); @@ -5123,7 +5121,13 @@ function cmdTimeline() { const ref = branch && !mergeShaFor(id) ? branch : mainRef(); const files = ['spec.md', 'plan.md', 'verification.md'].map((f) => `${projectRel}/tickets/${id}/${f}`).filter((f) => ref && git(['cat-file', '-e', `${ref}:${f}`]) !== null).map((f) => ({ title: path.basename(f, '.md'), path: f, ref })); const reviews = ticketReviews(id, branch && !mergeShaFor(id) ? { ref: branch } : WORKTREE).map((r) => ({ file: r.file, stage: r.stage, pass: r.pass, verdict: r.verdict, reviewer: r.reviewer })); - const view = { ticket: { ...t, ...st }, files, reviews, lines: all.map((l) => Object.fromEntries(Object.entries(l).filter(([k]) => !k.startsWith('__')))) }; + return { ticket: { ...t, ...st }, files, reviews, lines: all.map((l) => Object.fromEntries(Object.entries(l).filter(([k]) => !k.startsWith('__')))), st, all }; +} +function cmdTimeline() { + gitReadCache = new Map(); + const [id] = positional; + const { st, all, ...view } = timelineView(id); + const t = view.ticket; if (flags.json) return json(view); out(`${id} [${st?.stage}${st?.phase ? `, ${st.phase}` : ''}] ${t.title}`); for (const l of all) out(`${l.at} ${l.type.padEnd(11)} ${l.by.padEnd(14)} ${l.what ?? l.text ?? l.question ?? l.detail ?? l.answer ?? (l.type === 'phase' ? `${l.from ?? ''} -> ${l.to}` : l.op)}`); @@ -5196,6 +5200,25 @@ function cmdDashboard() { const r = await run(['fleet', 'status', '--json', ...(u.searchParams.get('fetch') === '1' ? [] : ['--no-fetch'])]); return r.ok ? send(res, 200, 'application/json', r.stdout) : send(res, 500, 'application/json', JSON.stringify({ error: r.stderr.trim() })); } + // One record's stored fields, and the schema's words for each field (the sidepeek's attributes). + if (req.method === 'GET' && u.pathname === '/api/record') { + const id = u.searchParams.get('id') ?? ''; + if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) return send(res, 400, 'application/json', JSON.stringify({ error: 'bad id' })); + const r = await run(['record', id, '--json']); + return r.ok ? send(res, 200, 'application/json', r.stdout) : send(res, 404, 'application/json', JSON.stringify({ error: r.stderr.trim() })); + } + if (req.method === 'GET' && u.pathname === '/api/peek') { + const kind = u.searchParams.get('kind') ?? ''; + const id = u.searchParams.get('id') ?? ''; + const ts = (u.searchParams.get('tickets') ?? '').split(',').filter(Boolean); + const rec = u.searchParams.get('rec') ?? ''; + const ref = u.searchParams.get('ref') ?? ''; + if (ref && !/^(?!-)[A-Za-z0-9._\/-]+$/.test(ref)) return send(res, 400, 'application/json', JSON.stringify({ error: 'bad ref' })); + if (!/^[a-z]+$/.test(kind) || !id || id.length > 400 || /[\0\n]/.test(id) || id.startsWith('-') || ts.some((x) => !/^[a-z0-9][a-z0-9-]*$/.test(x)) || (rec && !/^[a-z0-9][a-z0-9-]*$/.test(rec))) return send(res, 400, 'application/json', JSON.stringify({ error: 'bad kind, id, rec or tickets' })); + const r = await run(['peek', kind, id, ...(ts.length ? ['--tickets', JSON.stringify(ts)] : []), ...(rec ? ['--rec', rec] : []), ...(ref ? ['--ref', ref] : []), '--json']); + return r.ok ? send(res, 200, 'application/json', r.stdout) : send(res, 404, 'application/json', JSON.stringify({ error: r.stderr.trim() })); + } + if (req.method === 'GET' && u.pathname === '/api/schema') return send(res, 200, 'application/json', JSON.stringify(schemaDocs())); if (req.method === 'GET' && u.pathname === '/api/timeline') { const id = u.searchParams.get('id') ?? ''; if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) return send(res, 400, 'application/json', JSON.stringify({ error: 'bad ticket id' })); @@ -6208,6 +6231,157 @@ function cmdSettings() { out(v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v)); } +// record : one record as it stands, its stored fields only, for the sidepeek's attributes: who +// created it and when, who last changed it, and who set each field. Fold bookkeeping is left out. +const FOLD_KEYS = [...ENVELOPE, 'created_at', 'created_by', 'updates', 'hold_by']; +function recordView(id) { + const o = logGet(id); + if (!o) return null; + const fields = Object.fromEntries(Object.entries(o).filter(([k]) => !FOLD_KEYS.includes(k) && !k.startsWith('__'))); + const setBy = {}; + for (const u of o.updates ?? []) for (const k of Object.keys(u)) if (!['at', 'by', 'channel'].includes(k)) setBy[k] = { by: u.by, at: u.at }; + const last = (o.updates ?? []).at(-1); + return { id: o.id, type: o.type, created_at: o.created_at, created_by: o.created_by, updated_at: last?.at ?? null, updated_by: last?.by ?? null, fields, set_by: setBy }; +} +function cmdRecord() { + const [id] = positional; + if (!id) die('usage: record [--json]'); + const r = recordView(id) ?? die(`no record ${id}`); + if (flags.json) return json(r); + out(`${r.id} (${r.type}) created ${r.created_at} by ${r.created_by}${r.updated_at ? `, last changed ${r.updated_at} by ${r.updated_by}` : ''}`); + for (const [k, v] of Object.entries(r.fields)) out(` ${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`); +} +// peek : what a sidepeek's body needs beyond the dashboard data: the object's history +// (every log line about it, oldest first, a set line on the object itself carrying the values it +// replaced) and what is particular to its kind (a ticket's gates, phases, spend, dependencies and +// handoff; a goal's or feature's design TL;DR; a planner's draft; a retro's numbers; the records an +// object cites). Sets of tickets (story, release, wave, milestone) take their tickets as --tickets. +const RETRO_BLOCK = /```json devteam-retro\s*\n([\s\S]*?)\n```/; +const designTldr = (rel, ref = null) => { + if (!rel) return null; + const r = rel.replace(/^docs\/devteam\//, '').replace(/^project\//, ''); + const text = ref ? git(['show', `${ref}:${projectRel}/${r}`]) : projectFileText(r); + if (text === null) return null; + const lines = text.split('\n'); + const at = lines.findIndex((l) => /^##\s+TL;?DR\b/i.test(l.trim())); + const body = []; + if (at >= 0) for (const l of lines.slice(at + 1)) { if (/^#{1,2}\s/.test(l.trim())) break; body.push(l); } + return { path: `${projectRel}/${r}`, ref, tldr: at >= 0 ? body.join('\n').trim() : null }; +}; +function historyOf(ids, { types = null, limit = 300 } = {}) { + const want = new Set(ids); + const touches = (l) => want.has(l.id) || want.has(l.by) || want.has(l.ticket) || want.has(l.to) || want.has(l.subject) || want.has(l.who) || want.has(l.goal) || want.has(l.feature) && l.type === 'ticket' + || ['refs', 'blocks', 'serves', 'threatens', 'goals', 'evidence', 'depends_on'].some((k) => (l[k] ?? []).some?.((x) => want.has(x))); + const cur = new Map(); + const res = []; + // a line the fold ignored is shown as such and never becomes the value a later change replaced + const lineKey = (l) => `${l.id}|${l.at}|${l.by}`; + const ignored = new Map((readLog().ignored ?? []).map((x) => [lineKey(x.line), x.why])); + const BULK = ['tests', 'by_model']; + const all = [...readLog().lines, ...(readLog().ignored ?? []).map((x) => x.line)]; + for (const l of all.sort((a, b) => a.at.localeCompare(b.at))) { + const own = want.has(l.id); + const why = ignored.get(lineKey(l)); + const body = Object.fromEntries(Object.entries(l).filter(([k]) => !k.startsWith('__') && !BULK.includes(k))); + if (why) { if (touches(l) && (!types || types.includes(l.type))) res.push({ ...body, ignored: why }); continue; } + if (own && l.op === 'set') { + const was = cur.get(l.id) ?? {}; + body.prev = Object.fromEntries(Object.keys(body).filter((k) => !ENVELOPE.includes(k) && k !== 'prev').map((k) => [k, was[k] ?? null])); + } + if (own) cur.set(l.id, { ...(cur.get(l.id) ?? {}), ...Object.fromEntries(Object.entries(l).filter(([k]) => !ENVELOPE.includes(k) && !k.startsWith('__'))) }); + if (touches(l) && (!types || types.includes(l.type))) res.push(body); + } + return res.slice(-limit); +} +const fileHistory = (rel, ref = null) => (git(['log', '-n', '60', '--format=%H%x09%cI%x09%an%x09%s', ...(ref ? [ref] : []), '--', rel]) ?? '').split('\n').filter(Boolean).map((x) => { const [sha, at, author, subject] = x.split('\t'); return { sha, at, author, subject }; }).reverse(); +function peekView(kind, id) { + // the stored record this object is, or lives in (a story in its feature, a milestone in its goal) + const v = { kind, id, record: recordView(flagStr('rec') ?? (kind === 'planner' ? '' : id)) }; + const cited = (refs) => Object.fromEntries([...new Set(refs ?? [])].map((r) => [r, logGet(r)]).filter(([, o]) => o).map(([r, o]) => [r, { type: o.type, created_at: o.created_at, created_by: o.created_by, kind: o.kind ?? null, text: o.detail ?? o.statement ?? o.what ?? o.title ?? o.question ?? o.text ?? null }])); + if (kind === 'ticket') { + gitReadCache = new Map(); + const t = tickets().get(id) ?? die(`no ticket ${id}`); + const { ticket, files, reviews } = timelineView(id); + Object.assign(v, { ticket, files, reviews }); + v.history = historyOf([id], { limit: 600 }).filter((l) => l.type !== 'spend'); + v.deps = { waits_on: t.depends_on ?? [], blocks: [...tickets().values()].filter((x) => (x.depends_on ?? []).includes(id)).map((x) => x.id) }; + const h = handoffFor(id); + v.handoff = h ? { text: h.text, where: h.where, parked_at: h.parked_at } : null; + v.spend = logObjects('spend').filter((s) => s.who === id).sort(byTime).map((s) => ({ at: s.created_at, role: s.role, usd: s.usd })); + } else if (kind === 'goal') { + const g = logGet(id) ?? die(`no goal ${id}`); + v.history = historyOf([id]); + const designs = [...features().values()].filter((f) => (f.goals ?? []).includes(id)).map((f) => f.design).filter(Boolean); + v.design = designTldr(goalCheck?.(g)?.design ?? designs[0] ?? null); + } else if (kind === 'feature') { + const f = features().get(id); + v.history = historyOf([id], { limit: 300 }); + const planning = planners().find((p) => p.feature === id); + v.design = designTldr(f?.design ?? null, f ? null : planning?.branch); + v.plan_reviews = planning ? reviewRecordsIn({ ref: planning.branch }, `reviews/${id}`).map((r) => ({ file: r.file, stage: r.stage, pass: r.pass, verdict: r.verdict, reviewer: r.reviewer })) : null; + } else if (kind === 'planner') { + const p = planners().find((x) => x.feature === id); + const by = `plan-${id}`; + v.history = historyOf([by]); + if (p) { + const graph = git(['show', `${p.branch}:${projectRel}/log/${by}.jsonl`]) ?? readText(path.join(mainRoot, '.worktrees', by, projectRel, 'log', `${by}.jsonl`)) ?? ''; + const ls = graph.split('\n').filter(Boolean).map((x) => { try { return JSON.parse(x); } catch { return null; } }).filter(Boolean); + const objs = new Map(); + for (const l of ls) if (['ticket', 'feature'].includes(l.type)) objs.set(l.id, { ...(objs.get(l.id) ?? {}), ...Object.fromEntries(Object.entries(l).filter(([k]) => !['v', 'op', 'at', 'by'].includes(k))) }); + v.draft = [...objs.values()].filter((o) => o.type === 'ticket'); + const feat = [...objs.values()].find((o) => o.type === 'feature'); + v.design = designTldr(feat?.design ?? null, p.branch); + v.plan_reviews = reviewRecordsIn({ ref: p.branch }, `reviews/${id}`).map((r) => ({ file: r.file, stage: r.stage, pass: r.pass, verdict: r.verdict, reviewer: r.reviewer })); + } + } else if (kind === 'retro') { + const o = logGet(id) ?? die(`no retro ${id}`); + v.history = historyOf([id]); + const all = logObjects('observation').filter((x) => x.kind === 'retro' && x.created_by === 'supervisor').sort(byTime); + const prev = all[all.findIndex((x) => x.id === id) - 1]; + const nums = (x) => { try { return JSON.parse((x?.source_ref ? projectFileText(x.source_ref.replace(/^project\//, '')) : '')?.match(RETRO_BLOCK)?.[1] ?? 'null'); } catch { return null; } }; + v.numbers = { now: nums(o), prev: nums(prev), prev_id: prev?.id ?? null }; + } else if (kind === 'file') { + v.history = fileHistory(id, flagStr('ref') ?? null); + } else if (['story', 'release', 'wave', 'milestone'].includes(kind)) { + v.history = historyOf(listFlag('tickets'), { types: ['ticket', 'act', 'decision', 'question'] }).filter((l) => l.type !== 'ticket' || l.op === 'new'); + if (kind === 'release') { + // the gates the supervisor ran at the last merge into it, at that merge's head + const ts = listFlag('tickets'); + const last = logObjects('act').filter((a) => /^merge/.test(a.class) && (a.refs ?? []).some((r) => ts.includes(r))).sort(byTime).pop(); + const sha = last ? /\bat ([0-9a-f]{7,40})\b/.exec(last.what ?? '')?.[1] : null; + const runs = sha ? readLog().lines.filter((l) => l.type === 'gate' && l.by === 'supervisor' && l.sha && (l.sha.startsWith(sha) || sha.startsWith(l.sha))).sort((a, b) => a.at.localeCompare(b.at)) : []; + v.gates = Object.values(Object.fromEntries(runs.map((l) => [l.gate, { gate: l.gate, ok: l.ok, sha: l.sha ?? null, at: l.at }]))); + v.last_merge = last ? { id: last.id, at: last.created_at, ticket: (last.refs ?? []).find((r) => ts.includes(r)) ?? null, sha } : null; + } + } else { + const o = logGet(id) ?? die(`no record ${id}`); + v.history = historyOf([id]); + v.cited = cited([...(o.refs ?? []), ...(o.evidence ?? []), ...(o.approved_by ? [o.approved_by] : [])]); + if (o.type === 'problem' || o.type === 'knowledge') v.related = logObjects('observation').filter((x) => (x.refs ?? []).includes(id) || x.subject === id || (o.evidence ?? []).includes(x.id)).sort(byTime).map((x) => ({ id: x.id, at: x.created_at, by: x.created_by, kind: x.kind, detail: x.detail, severity: x.severity ?? null })); + } + return v; +} +function cmdPeek() { + const [kind, id] = positional; + if (!kind || !id) die('usage: peek [--tickets ids] --json'); + json(peekView(kind, id)); +} + +// The schema's descriptions of each field, for the sidepeek's tooltips: the envelope, then per type. +// Nested fields are keyed by path: measure.target, milestones[].title. +function schemaDocs() { + const walk = (props, pre = '', acc = {}) => { + for (const [k, v] of Object.entries(props ?? {})) { + // x-tip: the owner's words for a field whose description is written for agents + if (v?.['x-tip'] ?? v?.description) acc[`${pre}${k}`] = v['x-tip'] ?? v.description; + if (v?.properties) walk(v.properties, `${pre}${k}.`, acc); + if (v?.items?.properties) walk(v.items.properties, `${pre}${k}[].`, acc); + } + return acc; + }; + return { envelope: walk(SCHEMA.properties), types: Object.fromEntries(Object.entries(SCHEMA.$defs.types).map(([t, d]) => [t, { fields: walk(d.properties) }])) }; +} + // history []: what happened around a ticket (or everything), to catch up after a restart. function cmdHistory() { const id = positional[0]; @@ -6444,6 +6618,7 @@ Goals and retros The log log [--type t] [--by w] [--id x] [--tail n] [--json] | log-check | log-sync [--dry-run] | history [] + record [--json] one record's stored fields, who created it and who set each field | peek --json what a dashboard sidepeek shows beyond the data: history and its kind's details Dashboard and releases dashboard [--port p] [--host h] | dashboard-data | notes [] @@ -6457,7 +6632,7 @@ const COMMANDS = { ask: cmdAsk, answer: cmdAnswer, review: cmdReview, secret: cmdSecret, withdraw: cmdWithdraw, questions: cmdQuestions, decide: cmdDecide, decisions: cmdDecisions, act: cmdAct, correct: cmdCorrect, repair: cmdRepair, trust: cmdTrust, observe: cmdObserve, 'observe-metrics': cmdObserveMetrics, 'observe-spend': cmdObserveSpend, orient: cmdOrient, metrics: cmdMetrics, ack: cmdAck, timeline: cmdTimeline, knowledge: cmdKnowledge, problem: cmdProblem, goal: cmdGoal, retro: cmdRetro, chore: cmdChore, - log: cmdLog, 'log-check': cmdLogCheck, 'log-sync': cmdLogSync, history: cmdHistory, + log: cmdLog, record: cmdRecord, peek: cmdPeek, 'log-check': cmdLogCheck, 'log-sync': cmdLogSync, history: cmdHistory, drain: cmdDrain, undrain: cmdUndrain, fleet: cmdFleet, handoff: cmdHandoff, wait: cmdWait, dashboard: cmdDashboard, 'dashboard-data': cmdDashboardData, notes: cmdNotes, }; diff --git a/test/kit.test.mjs b/test/kit.test.mjs index e24efba..7df8559 100644 --- a/test/kit.test.mjs +++ b/test/kit.test.mjs @@ -658,6 +658,15 @@ test('devteam kit', async (t) => { assert.equal(dash.ooda.acts.find((a) => a.id === A1).under.decision, D); }); + await t.test('every field in the log schema says what it means (the sidepeek shows it as the key\'s tooltip)', () => { + const s = JSON.parse(fs.readFileSync(path.join(kit, 'schema', 'log.schema.json'), 'utf8')); + const bare = Object.entries(s.$defs.types).flatMap(([type, d]) => Object.entries(d.properties ?? {}).filter(([, v]) => !v.description).map(([k]) => `${type}.${k}`)); + assert.deepEqual(bare, [], 'add a description to each'); + const r = json(must(['record', G, '--json'])); + assert.equal(r.type, 'goal'); + assert.ok(r.fields.title && r.set_by && !('hold_by' in r.fields)); + }); + await t.test('an answer the owner gave in the supervisor chat: the owner\'s, quoted, listed for checking, never trust evidence', () => { const said = "you shouldn't need to ask me to submit those PRs"; const Qc = must(['ask', '--question', 'Open upstream PRs without asking?', '--class', 'upstream-pr', '--reasons', 'below-rung', '--recommendation', 'yes'], { role: 'sup' }); @@ -1394,6 +1403,32 @@ test('devteam kit', async (t) => { // the data names the page version, so an open tab can tell it was updated underneath it const dataRes = await fetch(`http://127.0.0.1:${port}/api/data`); assert.match(dataRes.headers.get('x-devteam-page') ?? '', /^[0-9a-f]{16}$/); + // the sidepeek's attributes: a record's stored fields, and the schema's words for each field + const rec = await (await fetch(`http://127.0.0.1:${port}/api/record?id=${Qs}`)).json(); + assert.deepEqual([rec.id, rec.type, rec.created_by], [Qs, 'question', 'supervisor']); + assert.ok(rec.fields.question && !('updates' in rec.fields) && !Object.keys(rec.fields).some((k) => k.startsWith('__')), 'stored fields only'); + assert.equal((await fetch(`http://127.0.0.1:${port}/api/record?id=g-20200101-000000000-nobody`)).status, 404); + assert.equal((await fetch(`http://127.0.0.1:${port}/api/record?id=..%2Fx`)).status, 400); + const docs = await (await fetch(`http://127.0.0.1:${port}/api/schema`)).json(); + assert.ok(docs.envelope.id && docs.types.decision.fields.authority && docs.types.ticket.fields.done_when); + assert.ok(docs.types.goal.fields['milestones[].title'] && docs.types.feature.fields['stories[].text'], 'nested fields are keyed by path'); + assert.ok(!/approved_by k-/.test(docs.types.decision.fields.authority), "the owner's words (x-tip) where a description is written for agents"); + // a sidepeek's peek: the record, its history oldest first, and its kind's particulars + const peek = async (q) => fetch(`http://127.0.0.1:${port}/api/peek?${new URLSearchParams(q)}`); + const pq = await (await peek({ kind: 'q', id: Qs })).json(); + assert.equal(pq.record.id, Qs); + assert.ok(pq.history.length && pq.history.every((l, i) => !i || pq.history[i - 1].at <= l.at)); + const pt = await (await peek({ kind: 'ticket', id: 'bill-01-card' })).json(); + assert.ok(pt.ticket && pt.deps && Array.isArray(pt.spend) && pt.history.some((l) => l.type === 'gate')); + assert.ok(pt.history.every((l) => !('tests' in l) && l.type !== 'spend'), 'no per-test bulk or spend lines in a history'); + const own = pt.history.find((l) => l.id === 'bill-01-card' && l.op === 'set'); + assert.ok(own && own.prev, "a set line on the object carries what it replaced"); + const pg = await (await peek({ kind: 'goal', id: G })).json(); + assert.ok(pg.design === null || 'tldr' in pg.design); + const ps = await (await peek({ kind: 'story', id: 'BILL-S1', rec: 'billing', tickets: 'bill-01-card' })).json(); + assert.equal(ps.record.id, 'billing', 'a story reads its feature\'s record'); + assert.equal((await peek({ kind: 'story', id: 'BILL-S1', rec: '../x' })).status, 400); + assert.equal((await peek({ kind: 'ticket', id: 'nope-01-x' })).status, 404); const postSecret = (b, headers = browser) => fetch(`http://127.0.0.1:${port}/api/secret`, { method: 'POST', headers, body: JSON.stringify(b) }); assert.equal((await postSecret({ id: Qs, values: {} }, { 'content-type': 'application/json' })).status, 403, 'a request without browser headers is refused'); for (let i = 0; i < 3; i++) assert.equal((await postSecret({ id: 'q-20200101-000000000-supervisor', values: { A: 'x'.repeat(3 * 1024 * 1024) } })).status, 400);