From 74cde3e1e69609b610e329baca1674ce06732a77 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 08:51:27 -0500 Subject: [PATCH 1/6] Sidepeek: attributes first (ID, stored fields with schema tooltips, computed), type icon and pill in the bar (WIP) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T5z3hZtpGaQUGsTaAMQrAg --- docs/devteam/dashboard/index.html | 398 ++++++++++++++++++---------- docs/devteam/schema/log.schema.json | 130 +++++++++ docs/devteam/scripts/devteam.mjs | 37 ++- test/kit.test.mjs | 17 ++ 4 files changed, 440 insertions(+), 142 deletions(-) diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index 7658f57..4e9fe72 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -287,6 +287,16 @@ @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-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 .attr-tip { display: block; } .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); } @@ -1757,6 +1767,148 @@

${esc(g.title for (const id of ['dec-who', 'dec-class', 'dec-corrected', 'dec-mechanical']) $(id).addEventListener('change', () => { if (DATA) renderDecisions(DATA.ooda); }); +// ---- sidepeek attributes: every object opens with its attributes, ID first, as key/value rows. +// Stored fields come from the record itself (/api/record), so a field added to the schema shows up +// with no change here; a known field gets a label, an order and its links, any other a plain value. +// Each key's tooltip is the schema's description of the field. Values computed from other records +// (status, progress, forecast, spend, what links here) follow in their own group. +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; +const RECORDS = new Map(); +let ATTRS = null; +async function loadSchemaDocs() { + try { const r = await fetch('/api/schema'); if (r.ok) SCHEMA_DOCS = await r.json(); } catch {} + if (ATTRS) fillAttrs(); +} +const tipFor = (type, k) => SCHEMA_DOCS?.types?.[type]?.fields?.[k] ?? SCHEMA_DOCS?.envelope?.[k] ?? ''; +const attrKey = (label, tip) => (tip ? `${esc(label)}${esc(tip)}` : esc(label)); +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(' · '); +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' })) : ''); +// Fields any type may carry, then per type: a label where the field's name isn't plain enough, an +// order (the rest follow in schema order), and how to show values that are links or codes. +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' }, + 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) : 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) => `${esc(v.reason)}${v.severity != null ? ` (severity ${esc(v.severity)})` : ''}`, + horizon: fmtDay, deadline: fmtDay, ticket: (v) => tlink(v), feature: (v) => (v === 'chores' ? 'chores (upkeep, not a feature)' : flink(v)), + }, +}; +const ATTR = { + ticket: { + order: ['title', 'feature', 'component', 'kind', 'size', 'risk', 'serves', 'depends_on', 'done_when', 'touches'], + 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)}${m.unit ? ` ${esc(m.unit)}` : ''}` : ''}${m.direction ? ` (${m.direction === 'down' ? 'lower' : 'higher'} is better)` : ''}`, + milestones: (v) => `
    ${v.map((m, i) => `
  1. ${ATTRS?.id ? mlink(ATTRS.id, i + 1, m.title) : esc(m.title)}${m.target != null ? `: ${esc(m.target)}` : ''}${m.by ? ` by ${fmtDay(m.by)}` : ''}
  2. `).join('')}
`, + preconditions: (v) => `
    ${v.map((x) => `
  1. ${linkify(x)}
  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'], + 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) }, + }, + 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) } }, + message: { order: ['kind', 'to', 'text', 're', 'ticket'], fmt: { to: (v) => esc(who(v)), re: (v) => linkify(v) } }, + observation: { order: ['kind', 'subject', 'detail', 'refs'], fmt: { subject: (v) => subjectLink(v) } }, + story: { order: ['text', 'release'], fmt: { release: (v) => rlink(v) } }, + milestone: { order: ['title', 'target', 'by', 'preconditions', 'features'], 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) { + const spec = ATTR[type] || {}; + const schemaOrder = Object.keys(SCHEMA_DOCS?.types?.[docType]?.fields || {}); + const keys = Object.keys(fields).filter((k) => !emptyVal(fields[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]); } catch { html = fmtAny(fields[k]); } + return attrRow(spec.label?.[k] ?? ATTR_COMMON.label[k] ?? words(k), html, tipFor(docType, k)); + }).join(''); +} +// The attributes block for an object. rec: the id of its log record (or null when it has none); pick: +// the part of that record that is this object (a story inside its feature, a milestone inside its +// goal); computed: [label, html, tip] rows worked out from other records. +function attrs({ id, idTip, rec = null, type, docType, pick = null, computed = [] }) { + ATTRS = { kind: DRAWER.kind, id, idTip, rec, type, docType: docType ?? type, pick, computed, html: '' }; + if (rec) RECORDS.has(rec) ? queueMicrotask(fillAttrs) : fetchRecord(rec); + return `
${attrsInner()}
`; +} +function attrsInner() { + const a = ATTRS; + const r = a.rec ? RECORDS.get(a.rec) : null; + const fields = r ? (a.pick ? a.pick(r.fields) ?? {} : r.fields) : null; + const made = r && !a.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(a.type, fields, a.docType) : a.rec && !RECORDS.has(a.rec) ? '
…
loading
' : ''; + const comp = a.computed.filter((x) => x && x[1]).map(([l, h, t]) => attrRow(l, h, t)).join(''); + return `
${attrRow('ID', `${esc(a.id)}`, a.idTip ?? tipFor('_', 'id') ?? '')}${stored}${made}
${comp ? `

Computed

${comp}
` : ''}`; +} +async function fetchRecord(rec) { + try { + const r = await fetch(`/api/record?id=${encodeURIComponent(rec)}`); + if (!r.ok) { RECORDS.set(rec, null); return fillAttrs(); } + RECORDS.set(rec, await r.json()); + } catch { return; } + fillAttrs(); +} +function fillAttrs() { + const el = $('drawer-attrs'); + if (!el || !ATTRS || DRAWER.kind !== ATTRS.kind) return; + const html = attrsInner(); + if (html !== ATTRS.html) { el.innerHTML = html; ATTRS.html = html; } +} +// After each refresh: the open object's record again, so a field someone just set shows. +function refreshAttrs() { if (ATTRS?.rec && !$('drawer').hidden) fetchRecord(ATTRS.rec); } + // ---- drawer: a ticket with everything that bears on it, a settled question, or an artifact ---- let DRAWER = { kind: null, id: null }; let lastFocus = null; @@ -1764,7 +1916,9 @@

${esc(g.title if ($('drawer').hidden) lastFocus = document.activeElement; $('drawer-body').innerHTML = html; const h = $('drawer-body').querySelector('h2#drawer-title'); - $('drawer-bar-title').innerHTML = h ? h.innerHTML : ''; + // the bar: the type's icon and name, then the object's title + 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'; @@ -1772,6 +1926,7 @@

${esc(g.title } function closeDrawer() { DRAWER = { kind: null, id: null }; + ATTRS = null; $('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); @@ -1811,21 +1966,14 @@

${esc(g.title 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))}
` : ''} -
+ ${attrs({ id: t.id, rec: t.id, type: 'ticket', computed: [ + ['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)}` : ''}`, 'Where it is now, from its approvals, its agent\'s last phase and its merge.'], + ['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.'], + ] })} ${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

@@ -1847,31 +1995,25 @@

History

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); showDrawer(`

${esc(g.title)}

-

${esc(s.label)}

- ${g.outcome ? `

${esc(g.outcome)}

` : ''} + ${attrs({ id: g.id, rec: g.id, type: 'goal', 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)}${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.
'}` : '', '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((p) => `${esc(p.title)} (${esc(ago(p.minutes_silent))})`).join(', ') : '', 'Planners working on its features now.'], + ['Readings', (g.readings || []).length ? `
    ${g.readings.map((x) => `
  • ${esc(x.value)} ${esc(g.measure?.unit || '')} · ${esc(when(x.at))}
  • `).join('')}
` : '', 'Readings of its measure.'], + ['Your decisions', decisions.length ? `
    ${decisions.map((d) => `
  • ${esc(words(d.verdict))}${d.notes ? `: ${esc(d.notes)}` : ''}
  • `).join('')}
` : '', 'Every verdict you gave on it, 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)}
-
`); + ${feats.length ? `

${boardLink(`goal:${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 }) { +function openTicketSet({ kind, id, title, sub, computed = [], items, view, attr = {} }) { DRAWER = { kind, id }; const now = Date.parse(DATA.progress.now); const fc = forecast(items, now); @@ -1880,15 +2022,13 @@

History

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'; showDrawer(`

${title}

+ ${attrs({ id, 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)}
-
`); +

Tickets

${cols.length ? cols.map(([n, ts]) => `
${esc(n)}
    ${ts.map((t) => `
  • ${tlink(t.id)}
  • `).join('')}
`).join('') : 'None yet.'}
+ `); } const row = (dt, dd) => (dd ? `
${dt}
${dd}
` : ''); const uniq = (xs) => [...new Set(xs.filter((x) => x !== undefined && x !== null && x !== ''))]; @@ -1898,39 +2038,36 @@

History

const items = p.tickets.filter((t) => t.feature === f); const stories = Object.entries(p.stories).filter(([, s]) => s.feature === f).map(([id]) => id); 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` : ''), + openTicketSet({ kind: 'feature', id: f, title: esc(featureTitle(f)), sub: f === 'chores' ? 'Upkeep: tickets that serve no story.' : '', items, view: `feature:${f}`, attr: { rec: f === 'chores' ? null : f, idTip: 'The feature\'s slug; its tickets\' ids start with their component.' }, 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.'], ] }); } 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) : ''), - ] }); + openTicketSet({ kind: 'story', id: sid, title: esc(st.text ?? sid), items: p.tickets.filter((t) => t.serves.includes(sid)), view: `story:${sid}`, + attr: { rec: st.feature && st.feature !== 'chores' ? st.feature : null, docType: 'feature', 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(', ')), + 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}`, attr: { idTip: 'The release\'s name; it is tagged once every story in it is done.' }, computed: [ + ['Stories', stories.map((x) => `${slink(x)} ${esc(String(p.stories[x].text ?? '').slice(0, 90))}`).join('
'), 'Stories that target it.'], + ['Features', uniq([...items.map((t) => t.feature), ...stories.map((x) => p.stories[x].feature)]).map(flink).join(', '), 'Features with work in it.'], ] }); } 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(', ')), + 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}`, attr: { idTip: 'The wave\'s number: the waves before it merge first.' }, computed: [ + ['Features', uniq(items.map((t) => t.feature)).map(flink).join(', '), 'Features with tickets in it.'], ] }); } function openMilestone(key) { @@ -1939,51 +2076,38 @@

History

const g = goalOf(gid), m = milestonesOf(g).find((x) => x.n === n); if (!m) return showDrawer(`

No milestone ${esc(key)}

`); 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(', ')), - ] }); + 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}`, + attr: { rec: gid, docType: 'goal', 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.'], + ] }); } function openAct(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 }; + if (!a) return showDrawer(`

${esc(id)}

${attrs({ id, rec: id, type: 'act' })}`); const v = actView(a); showDrawer(`

${v.head}

+ ${attrs({ id: a.id, rec: a.id, type: 'act', computed: [ + ['Under', actUnder(a), 'What it carried out: a decision, your answer, a standing rule, or its own call.'], + ['Repaired', a.correction?.repaired ? esc(a.correction.repaired) : '', 'What the supervisor did about your correction.'], + ] })} ${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.

'}`); } function openDecision(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 }; + if (!r) return showDrawer(`

${esc(id)}

${attrs({ id, rec: id, type: 'decision' })}`); 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)}
-
+ ${attrs({ id: r.id, rec: r.id, type: r.kind, computed: [ + ['Under', authorityWords(r.under), 'The trust it rested on when it was made.'], + ['Carried out by', r.kind === 'decision' ? (r.acts.length ? `` : 'nothing yet') : '', 'The acts that cite it.'], + ['Repaired', r.correction?.repaired ? esc(r.correction.repaired) : '', 'What the supervisor did about your correction.'], + ] })} ${r.correction ? '' : '

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

'}`); } // A retro's own date is its write-up's (retros/.md); the closing line can come a day later. @@ -2002,61 +2126,44 @@

History

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 }; + if (!r) return showDrawer(`

${esc(id)}

${attrs({ id, rec: id, type: 'observation' })}`); 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))}

+ showDrawer(`

${esc(retroDate(r))}

+ ${attrs({ id: r.id, rec: r.id, 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(' · ')}

`); } function openLesson(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)}

- ${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)}
-
`); + if (!k) return showDrawer(`

${esc(id)}

${attrs({ id, rec: id, type: 'knowledge' })}`); + showDrawer(`

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

+ ${attrs({ id: k.id, rec: k.id, type: 'knowledge' })} + ${k.status === 'candidate' ? '

Keep or retire it in the Inbox

' : ''}`); } 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 }; + if (!p) return showDrawer(`

${esc(id)}

${attrs({ id, rec: id, type: 'problem' })}`); 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)}
-
`); + ${attrs({ id: p.id, rec: p.id, type: 'problem' })} +

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

`); } async function openThread(mid) { DRAWER = { kind: 'thread', id: 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…

`); + const head = (title) => `

${title}

${attrs({ id: mid, rec: mid, type: 'message', computed: [['From', esc(who(by)), 'Who sent it.']] })}`; + if (!ticket) return showDrawer(`${head('Message')}

Its thread is on the ticket's timeline.

`); + showDrawer(`${head(`Thread on ${tlink(ticket)}`)}

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)}

`); } + 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(`${head('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 byId = new Map(msgs.map((m) => [m.id, m])); @@ -2064,33 +2171,37 @@

History

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(`${head(`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.

`}`); } 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 }; + if (!p) return showDrawer(`

${esc(feature)}

${attrs({ id: `plan-${feature}`, idTip: 'The planner\'s name: plan- and the feature it plans.' })}

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

`); 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}`)}
-
+ ${attrs({ id: `plan-${p.feature}`, idTip: 'The planner\'s name: plan- and the feature it plans. Its tmux window and log file use it.', computed: [ + ['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 || '')}` : ''}`, 'How long since it last did anything, and whether it is stuck.'], + ['Feature', flink(p.feature), 'The feature it is planning.'], + ['For goal', p.goals.map(glink).join(', '), 'The goals the feature serves.'], + ['Tickets so far', esc(p.tickets), 'Tickets it has written into its plan.'], + ['Waiting on you', (p.waiting_on_owner || []).length ? `
    ${p.waiting_on_owner.map((id) => `
  • ${qlink(id)}
  • `).join('')}
` : '', 'Your open questions it is waiting on.'], + ['Asked the supervisor', p.waiting_on_supervisor.length ? `
    ${p.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.'], + ['Branch', `${esc(p.branch)}`, 'Where its plan is being written.'], + ] })}

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

`); } function openQuestion(id) { const q = allQuestions().find((x) => x.id === id); - if (!q) return showDrawer(`

No question ${esc(id)}.

`); + if (!q) { DRAWER = { kind: 'q', id }; return showDrawer(`

${esc(id)}

${attrs({ id, rec: id, type: 'question' })}`); } 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); showDrawer(`

${linkify(q.question)}

+ ${attrs({ id: q.id, rec: q.id, type: 'question', computed: [ + ['Status', esc(q.status), 'open, answered, defaulted or withdrawn.'], + ['Answered', q.answered_at ? `${esc(when(q.answered_at))} by ${esc(q.answered_by === 'owner' ? 'you' : q.answered_by)}` : '', 'When it was settled, and by whom.'], + ['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('')}
` : ''} ${(q.links || []).length ? `` : ''} @@ -2101,9 +2212,10 @@

History

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…

`); + const fa = () => attrs({ id: p, idTip: 'Its path in the repository.', computed: [['Version', `${esc(params.get('ref') || 'main')}`, 'The branch or commit it is read from; committed content only.']] }); + if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) return showDrawer(`

${esc(title)}

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

${esc(title)}

${fa()}

Open in a new tab

`); + showDrawer(`

${esc(title)}

${fa()}

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'))}` : ''}`; @@ -2111,12 +2223,12 @@

History

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)}

+ showDrawer(`

${esc(title)}

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

${esc(title)}

${esc(e.message)}

`); } + catch (e) { showDrawer(`

${esc(title)}

${fa()}

${esc(e.message)}

`); } } // 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. @@ -2157,6 +2269,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)); @@ -2312,6 +2426,7 @@

${linkify(k.statement)}

].join(''); if (DRAWER.kind === 'ticket') openTicket(DRAWER.id, { quiet: true }); + else refreshAttrs(); // 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; } @@ -2586,6 +2701,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..89211ae 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,12 +41,14 @@ ] }, "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$" }, @@ -67,12 +72,15 @@ "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": { @@ -94,24 +102,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 @@ -156,6 +168,7 @@ } }, "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", @@ -296,21 +309,25 @@ "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", @@ -337,9 +354,11 @@ } }, "design": { + "description": "The design document it implements.", "type": "string" }, "notes": { + "description": "Anything else the planner wanted on record.", "type": "string" } }, @@ -355,17 +374,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 +398,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 +420,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 +446,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 +455,7 @@ "minItems": 1 }, "done_when": { + "description": "What must be true for it to count as done.", "type": "array", "items": { "type": "string", @@ -434,12 +464,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" @@ -453,6 +485,7 @@ "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." }, "notes": { + "description": "Anything else the planner wanted on record.", "type": "string" } }, @@ -475,6 +508,7 @@ "type": "object", "properties": { "to": { + "description": "The phase the agent moved to.", "enum": [ "starting", "spec", @@ -492,30 +526,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,16 +571,20 @@ "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": { @@ -547,11 +592,13 @@ "type": "boolean" }, "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", @@ -593,9 +640,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 +656,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 +694,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 +773,7 @@ "type": "object", "properties": { "kind": { + "description": "fact, lesson, preference (often a standing rule of yours), constraint or pointer.", "enum": [ "fact", "lesson", @@ -717,13 +783,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 +801,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 +833,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 +856,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 +891,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 +905,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" @@ -959,24 +1046,29 @@ "description": "The goal a goal-approval question asks the owner to approve, on its linked design." }, "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" @@ -992,15 +1084,18 @@ } }, "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,15 +1118,18 @@ "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": { @@ -1040,6 +1138,7 @@ "description": "the owner's own words, verbatim, when the answer came through 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,6 +1166,7 @@ "additionalProperties": false }, "standing_rule": { + "description": "A rule you gave with the answer, kept as knowledge.", "type": "string" }, "re": { @@ -1250,43 +1350,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 @@ -1302,10 +1412,12 @@ ] }, "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 +1435,7 @@ "additionalProperties": false }, "repaired": { + "description": "What the supervisor did about the correction.", "enum": [ "undo", "fix-ticket", @@ -1346,29 +1459,36 @@ "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": { @@ -1377,6 +1497,7 @@ "pattern": "^d-" }, "corrected": { + "description": "Your correction, if it was its own call and wrong.", "type": "object", "properties": { "reason": { @@ -1394,6 +1515,7 @@ "additionalProperties": false }, "repaired": { + "description": "What the supervisor did about the correction.", "enum": [ "undo", "fix-ticket", @@ -1443,6 +1565,7 @@ "description": "the ticket, planner (plan-) or supervisor" }, "role": { + "description": "Worker, planner, reviewer or supervisor.", "enum": [ "worker", "reviewer", @@ -1451,6 +1574,7 @@ ] }, "phase": { + "description": "The phase the spend was in.", "type": "string" }, "through": { @@ -1458,26 +1582,32 @@ "description": "ISO time of 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 0e92139..9629c41 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -5149,6 +5149,14 @@ 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/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' })); @@ -6161,6 +6169,32 @@ 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 = ['id', 'type', '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)}`); +} +// The schema's descriptions of each field, for the sidepeek's tooltips: the envelope, then per type. +function schemaDocs() { + const desc = (props) => Object.fromEntries(Object.entries(props ?? {}).filter(([, v]) => v?.description).map(([k, v]) => [k, v.description])); + return { envelope: desc(SCHEMA.properties), types: Object.fromEntries(Object.entries(SCHEMA.$defs.types).map(([t, d]) => [t, { about: d.description ?? null, fields: desc(d.properties) }])) }; +} + // history []: what happened around a ticket (or everything), to catch up after a restart. function cmdHistory() { const id = positional[0]; @@ -6376,6 +6410,7 @@ Decide and act (supervisor.md, constitution.md) decide --class c [--scope s] [--approved qid|kid] [--options a b] [--rationale r] [--confidence 0-1] [--refs ids] [--irreversible] -- "" a judgment call and the trust it rests on; prints d-… act --decision d-… -- "" carry out a decision (merge, ticket, withdraw, goal hold, worker, planner and fleet take --decision too) act --class c [--scope s] [--approved qid|kid] [--refs ids] [--irreversible] -- "" an act that is its own decision + record [--json] one record's stored fields, who created it and who set each field decisions [--who owner|supervisor] [--class c] [--corrected] [--tail n] [--json] every call, who made it and under what correct --reason r [--severity n] (owner; --retro for the supervisor) | repair --how undo|fix-ticket|none trust [--json] the ladder: rung, ceiling, agreement, corrections per class @@ -6410,7 +6445,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, '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 c9d3ce8..59ca6a2 100644 --- a/test/kit.test.mjs +++ b/test/kit.test.mjs @@ -647,6 +647,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' }); @@ -1373,6 +1382,14 @@ 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); 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); From 5736d8e733753889897db0eb586b26148718dfaf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 08:56:37 -0500 Subject: [PATCH 2/6] Sidepeek: a set's tickets as the board's kanban, only the columns it uses Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T5z3hZtpGaQUGsTaAMQrAg --- docs/devteam/dashboard/index.html | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index 4e9fe72..0a6dd92 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; } @@ -771,7 +772,12 @@

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 } = {}) { + 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 @@ -2019,7 +2025,6 @@

History

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'; showDrawer(`

${title}

${attrs({ id, type: kind, ...attr, computed })} @@ -2027,10 +2032,9 @@

History

${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)}

` : ''} -

Tickets

${cols.length ? cols.map(([n, ts]) => `
${esc(n)}
    ${ts.map((t) => `
  • ${tlink(t.id)}
  • `).join('')}
`).join('') : 'None yet.'}
+

Tickets

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

None yet.

'} `); } -const row = (dt, dd) => (dd ? `
${dt}
${dd}
` : ''); const uniq = (xs) => [...new Set(xs.filter((x) => x !== undefined && x !== null && x !== ''))]; function openFeature(f) { const p = DATA.progress, R = DATA.ooda.roadmap, rf = R.features[f]; From 212d8e8ffb0035f9db2f5dfbef1eb5f130905ea5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 08:57:44 -0500 Subject: [PATCH 3/6] Sidepeek: a ticket's documents and reviews are computed attributes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T5z3hZtpGaQUGsTaAMQrAg --- docs/devteam/dashboard/index.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index 0a6dd92..53f2a9f 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -294,6 +294,7 @@ .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; } @@ -1974,14 +1975,14 @@

${esc(g.title

${esc(t.title)}

${attrs({ id: t.id, rec: t.id, type: 'ticket', computed: [ ['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)}` : ''}`, 'Where it is now, from its approvals, its agent\'s last phase and its merge.'], + ['Documents', v.files.length ? `` : '', 'Its spec, plan and verification, from its branch until it merges, then from main.'], + ['Reviews', v.reviews.length ? `
    ${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('')}
` : '', 'Each review of its spec, plan and code, and its verdict.'], ['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.'], ] })} - ${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); From fa9d0f91404f184c14bc5e4ad37303231ed99913 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 09:00:23 -0500 Subject: [PATCH 4/6] Sidepeek: a goal's or set's burn-up above its kanban; drop the board and burn-up links Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T5z3hZtpGaQUGsTaAMQrAg --- docs/devteam/dashboard/index.html | 62 ++++++++++++++++++------------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index 53f2a9f..0bd70b9 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -643,14 +643,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 = []; @@ -693,10 +696,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; @@ -706,15 +709,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 ---- @@ -834,7 +837,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]; @@ -2015,26 +2017,36 @@

History

${goalSteps(g, s, R)} ${s.next ? `

${s.next}

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

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

` : ''}`); + ${feats.length ? `

Progress

${progressBlock(filterTickets(DATA.progress, `goal:${g.id}`))}` : ''}`); + if (feats.length) drawPeekChart(filterTickets(DATA.progress, `goal:${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, computed = [], items, view, attr = {} }) { - DRAWER = { kind, id }; +// A set's progress in a sidepeek: 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); + 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.

'}`; +} +// Drawn once the drawer is on screen, so the chart takes the drawer's width. +function drawPeekChart(items) { + if ($('peek-burnup')) burnup(items, Date.parse(DATA.progress.now), { el: $('peek-burnup'), legend: $('peek-legend'), tip: $('peek-tip'), card: $('peek-card'), forecast: null }); +} +function openTicketSet({ kind, id, title, sub, computed = [], items, attr = {} }) { + DRAWER = { kind, id }; showDrawer(`

${title}

${attrs({ id, 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)}

` : ''} -

Tickets

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

None yet.

'} - `); + ${progressBlock(items)}`); + drawPeekChart(items); } const uniq = (xs) => [...new Set(xs.filter((x) => x !== undefined && x !== null && x !== ''))]; function openFeature(f) { @@ -2043,7 +2055,7 @@

Tickets

${live.length ? `
No story ${esc(sid)}

`); - openTicketSet({ kind: 'story', id: sid, title: esc(st.text ?? sid), items: p.tickets.filter((t) => t.serves.includes(sid)), view: `story:${sid}`, + openTicketSet({ kind: 'story', id: sid, title: esc(st.text ?? sid), items: p.tickets.filter((t) => t.serves.includes(sid)), attr: { rec: st.feature && st.feature !== 'chores' ? st.feature : null, docType: 'feature', 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.']] }); } @@ -2063,7 +2075,7 @@

Tickets

${live.length ? `
No milestone ${esc(key)}`); 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}`, + 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)), attr: { rec: gid, docType: 'goal', 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.'], From 7795b23278d78c9418288c0c0fbe74107c4fbf2e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 09:31:03 -0500 Subject: [PATCH 5/6] Sidepeek: history last on every object, per-kind bodies (gates, phases, spend, dependencies, readings, design TL;DR, readiness, changelog, what a call rests on), one peek per object Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T5z3hZtpGaQUGsTaAMQrAg --- docs/devteam/dashboard/index.html | 791 +++++++++++++++++++--------- docs/devteam/schema/log.schema.json | 102 ++-- docs/devteam/scripts/devteam.mjs | 145 ++++- test/kit.test.mjs | 17 + 4 files changed, 774 insertions(+), 281 deletions(-) diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index 0bd70b9..2d06fc0 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -298,7 +298,24 @@ .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 .attr-tip { display: block; } + .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); } @@ -780,18 +797,18 @@

Checks before merge the automated } // 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 } = {}) { +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(''); } @@ -1776,11 +1793,14 @@

${esc(g.title for (const id of ['dec-who', 'dec-class', 'dec-corrected', 'dec-mechanical']) $(id).addEventListener('change', () => { if (DATA) renderDecisions(DATA.ooda); }); -// ---- sidepeek attributes: every object opens with its attributes, ID first, as key/value rows. -// Stored fields come from the record itself (/api/record), so a field added to the schema shows up -// with no change here; a known field gets a label, an order and its links, any other a plain value. -// Each key's tooltip is the schema's description of the field. Values computed from other records -// (status, progress, forecast, spend, what links here) follow in their own group. +// ---- 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'], @@ -1788,14 +1808,17 @@

${esc(g.title q: ['circle-help', 'Question'], file: ['file-text', 'File'], }; let SCHEMA_DOCS = null; -const RECORDS = new Map(); -let ATTRS = null; async function loadSchemaDocs() { try { const r = await fetch('/api/schema'); if (r.ok) SCHEMA_DOCS = await r.json(); } catch {} - if (ATTRS) fillAttrs(); -} -const tipFor = (type, k) => SCHEMA_DOCS?.types?.[type]?.fields?.[k] ?? SCHEMA_DOCS?.envelope?.[k] ?? ''; -const attrKey = (label, tip) => (tip ? `${esc(label)}${esc(tip)}` : esc(label)); + 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); @@ -1812,25 +1835,37 @@

${esc(g.title 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 (the rest follow in schema order), and how to show values that are links or codes. +// 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' }, + 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) : fmtObj(v)), confidence: (v) => (typeof v === 'number' ? share(v) : fmtAny(v)), + 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) => `${esc(v.reason)}${v.severity != null ? ` (severity ${esc(v.severity)})` : ''}`, + 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: { - order: ['title', 'feature', 'component', 'kind', 'size', 'risk', 'serves', 'depends_on', 'done_when', 'touches'], 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('
'), @@ -1841,9 +1876,9 @@

${esc(g.title goal: { order: ['title', 'outcome', 'status', 'priority', 'horizon', 'measure', 'milestones', 'preconditions', 'non_goals'], fmt: { - measure: (m) => `${esc(m.description ?? '')}${m.target != null ? `: ${esc(m.target)}${m.unit ? ` ${esc(m.unit)}` : ''}` : ''}${m.direction ? ` (${m.direction === 'down' ? 'lower' : 'higher'} is better)` : ''}`, - milestones: (v) => `
    ${v.map((m, i) => `
  1. ${ATTRS?.id ? mlink(ATTRS.id, i + 1, m.title) : esc(m.title)}${m.target != null ? `: ${esc(m.target)}` : ''}${m.by ? ` by ${fmtDay(m.by)}` : ''}
  2. `).join('')}
`, - preconditions: (v) => `
    ${v.map((x) => `
  1. ${linkify(x)}
  2. `).join('')}
`, + 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)}` : ''}`, @@ -1859,273 +1894,488 @@

${esc(g.title }, question: { order: ['question', 'kind', 'class', 'scope', 'ticket', 'goal', 'recommendation', 'options', 'default', 'deadline', 'answer', '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) }, + 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) } }, - message: { order: ['kind', 'to', 'text', 're', 'ticket'], fmt: { to: (v) => esc(who(v)), re: (v) => linkify(v) } }, - observation: { order: ['kind', 'subject', 'detail', 'refs'], fmt: { subject: (v) => subjectLink(v) } }, - story: { order: ['text', 'release'], fmt: { release: (v) => rlink(v) } }, - milestone: { order: ['title', 'target', 'by', 'preconditions', 'features'], fmt: { by: fmtDay } }, + 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) { +function storedRows(type, fields, docType, ctx, prefix = '') { const spec = ATTR[type] || {}; - const schemaOrder = Object.keys(SCHEMA_DOCS?.types?.[docType]?.fields || {}); - const keys = Object.keys(fields).filter((k) => !emptyVal(fields[k])); + 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]); } catch { html = fmtAny(fields[k]); } - return attrRow(spec.label?.[k] ?? ATTR_COMMON.label[k] ?? words(k), html, tipFor(docType, k)); + 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 for an object. rec: the id of its log record (or null when it has none); pick: -// the part of that record that is this object (a story inside its feature, a milestone inside its -// goal); computed: [label, html, tip] rows worked out from other records. -function attrs({ id, idTip, rec = null, type, docType, pick = null, computed = [] }) { - ATTRS = { kind: DRAWER.kind, id, idTip, rec, type, docType: docType ?? type, pick, computed, html: '' }; - if (rec) RECORDS.has(rec) ? queueMicrotask(fillAttrs) : fetchRecord(rec); - return `
${attrsInner()}
`; -} -function attrsInner() { - const a = ATTRS; - const r = a.rec ? RECORDS.get(a.rec) : null; - const fields = r ? (a.pick ? a.pick(r.fields) ?? {} : r.fields) : null; - const made = r && !a.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(a.type, fields, a.docType) : a.rec && !RECORDS.has(a.rec) ? '
…
loading
' : ''; - const comp = a.computed.filter((x) => x && x[1]).map(([l, h, t]) => attrRow(l, h, t)).join(''); - return `
${attrRow('ID', `${esc(a.id)}`, a.idTip ?? tipFor('_', 'id') ?? '')}${stored}${made}
${comp ? `

Computed

${comp}
` : ''}`; -} -async function fetchRecord(rec) { +// 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
' : '
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(); +const peekKey = (kind, id) => `${kind}:${id}`; +function peekOf(kind, id, q = {}) { + const k = peekKey(kind, id); + if (!PEEKS.has(k)) fetchPeek(kind, id, q); + return PEEKS.get(k); +} +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/record?id=${encodeURIComponent(rec)}`); - if (!r.ok) { RECORDS.set(rec, null); return fillAttrs(); } - RECORDS.set(rec, await r.json()); - } catch { return; } - fillAttrs(); -} -function fillAttrs() { - const el = $('drawer-attrs'); - if (!el || !ATTRS || DRAWER.kind !== ATTRS.kind) return; - const html = attrsInner(); - if (html !== ATTRS.html) { el.innerHTML = html; ATTRS.html = html; } -} -// After each refresh: the open object's record again, so a field someone just set shows. -function refreshAttrs() { if (ATTRS?.rec && !$('drawer').hidden) fetchRecord(ATTRS.rec); } - -// ---- drawer: a ticket with everything that bears on it, a settled question, or an artifact ---- + const r = await fetch(`/api/peek?${new URLSearchParams({ kind, id, ...q })}`); + const j = await r.json(); + PEEKS.set(k, r.ok ? j : { error: j.error || String(r.status) }); + } catch (e) { if (!PEEKS.has(k)) PEEKS.set(k, { error: String(e?.message ?? e) }); } 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; + const p = PEEKS.get(peekKey(DRAWER.kind, DRAWER.id)); + if (p && !p.error) fetchPeek(DRAWER.kind, DRAWER.id, DRAWER.q || {}); + 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 }); +function redraw() { if (DRAWER.kind && !$('drawer').hidden) OPENERS()[DRAWER.kind]?.(DRAWER.arg ?? DRAWER.id); } +// 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; + LAST_HTML = html; + if (opening) lastFocus = document.activeElement; $('drawer-body').innerHTML = html; const h = $('drawer-body').querySelector('h2#drawer-title'); - // the bar: the type's icon and name, then the object's title 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 (opening) $('drawer-close').focus(); + for (const f of after) f(); } function closeDrawer() { DRAWER = { kind: null, id: null }; - ATTRS = 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 = ''; + DRAWER = { kind, id, arg, q: DRAWER.kind === kind && DRAWER.id === id ? DRAWER.q : 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.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) => showDrawer(`

${esc(id)}

${attrs({ id, peek, type: kind })}${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

+
${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)); + const shas = uniq(gs.slice().sort((a, b) => b.at.localeCompare(a.at)).map((g) => g.sha || 'uncommitted')); + const median = Object.fromEntries((DATA.metrics?.gates?.gates || []).map((g) => [g.gate, g.median_seconds])); + const cell = (sha, name) => { + const runs = gs.filter((g) => (g.sha || 'uncommitted') === sha && g.gate === name); + if (!runs.length) return '–'; + const ok = runs.some((r) => r.ok), bad = 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)}

- ${attrs({ id: t.id, rec: t.id, type: 'ticket', computed: [ - ['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)}` : ''}`, 'Where it is now, from its approvals, its agent\'s last phase and its merge.'], - ['Documents', v.files.length ? `` : '', 'Its spec, plan and verification, from its branch until it merges, then from main.'], - ['Reviews', v.reviews.length ? `
    ${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('')}
` : '', 'Each review of its spec, plan and code, and its verdict.'], + 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.'], ] })} -

History

- ${items.length ? `
    ${items.map(([l, [c, h]]) => `
  1. ${h}
  2. `).join('')}
` : empty('Nothing logged yet.')}`; - showDrawer(html); + ${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: routine acts (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)}

- ${attrs({ id: g.id, rec: g.id, type: 'goal', computed: [ + ${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)}${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.
'}` : '', 'Each milestone: points left and an ETA at the recent rate.'], + ['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((p) => `${esc(p.title)} (${esc(ago(p.minutes_silent))})`).join(', ') : '', 'Planners working on its features now.'], - ['Readings', (g.readings || []).length ? `
    ${g.readings.map((x) => `
  • ${esc(x.value)} ${esc(g.measure?.unit || '')} · ${esc(when(x.at))}
  • `).join('')}
` : '', 'Readings of its measure.'], - ['Your decisions', decisions.length ? `
    ${decisions.map((d) => `
  • ${esc(words(d.verdict))}${d.notes ? `: ${esc(d.notes)}` : ''}
  • `).join('')}
` : '', 'Every verdict you gave on it, oldest first.'], + ['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 ? `

Progress

${progressBlock(filterTickets(DATA.progress, `goal:${g.id}`))}` : ''}`); - if (feats.length) drawPeekChart(filterTickets(DATA.progress, `goal:${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. -// A set's progress in a sidepeek: 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); - 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.

'}`; -} -// Drawn once the drawer is on screen, so the chart takes the drawer's width. -function drawPeekChart(items) { - if ($('peek-burnup')) burnup(items, Date.parse(DATA.progress.now), { el: $('peek-burnup'), legend: $('peek-legend'), tip: $('peek-tip'), card: $('peek-card'), forecast: null }); -} -function openTicketSet({ kind, id, title, sub, computed = [], items, attr = {} }) { - DRAWER = { kind, id }; +

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); + const p = peekOf(kind, id, { ...q, tickets: items.map((t) => t.id).join(',') }); showDrawer(`

${title}

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

${sub}

` : ''} - ${progressBlock(items)}`); - drawPeekChart(items); + ${extra} + ${progressBlock(items)} + ${historySection(p, id)}`); } -const uniq = (xs) => [...new Set(xs.filter((x) => x !== undefined && x !== null && x !== ''))]; 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, attr: { rec: f === 'chores' ? null : f, idTip: 'The feature\'s slug; its tickets\' ids start with their component.' }, computed: [ + 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: esc(st.text ?? sid), items: p.tickets.filter((t) => t.serves.includes(sid)), - attr: { rec: st.feature && st.feature !== 'chores' ? st.feature : null, docType: 'feature', 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.' }, + const pr = DATA.progress, st = pr.stories[sid]; + if (!st) { drawerFor('story', sid); return notFound('story', sid, null, 'No such story.'); } + 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, attr: { idTip: 'The release\'s name; it is tagged once every story in it is done.' }, computed: [ - ['Stories', stories.map((x) => `${slink(x)} ${esc(String(p.stories[x].text ?? '').slice(0, 90))}`).join('
'), 'Stories that target it.'], - ['Features', uniq([...items.map((t) => t.feature), ...stories.map((x) => p.stories[x].feature)]).map(flink).join(', '), 'Features with work in it.'], - ] }); + 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 ? `
  • checks at the last merge: ${gates.map((g) => `${g.ok ? '✓' : '✗'} ${esc(g.gate)}`).join(' · ')} (${esc(when(gates.map((g) => g.at).sort().pop()))})
  • ` : ''} +
+

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, attr: { idTip: 'The wave\'s number: the waves before it merge first.' }, computed: [ + 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.'); } 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)), - attr: { rec: gid, docType: 'goal', pick: (gf) => (gf.milestones || [])[n - 1] ?? null, idTip: 'Its goal\'s id and its number among the goal\'s milestones.' }, + 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); - DRAWER = { kind: 'act', id }; - if (!a) return showDrawer(`

${esc(id)}

${attrs({ id, rec: id, type: 'act' })}`); + 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: a.id, rec: a.id, type: 'act', computed: [ + ${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.'], - ['Repaired', a.correction?.repaired ? esc(a.correction.repaired) : '', 'What the supervisor did about your correction.'], ] })} ${v.detail ? `

${v.detail}

` : ''} - ${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)); - DRAWER = { kind: 'decision', id }; - if (!r) return showDrawer(`

${esc(id)}

${attrs({ id, rec: id, type: 'decision' })}`); + 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)}

- ${attrs({ id: r.id, rec: r.id, type: r.kind, computed: [ + ${attrs({ id, peek: p, type: r.kind, computed: [ ['Under', authorityWords(r.under), 'The trust it rested on when it was made.'], - ['Carried out by', r.kind === 'decision' ? (r.acts.length ? `` : 'nothing yet') : '', 'The acts that cite it.'], - ['Repaired', r.correction?.repaired ? esc(r.correction.repaired) : '', 'What the supervisor did about your correction.'], + ['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.'], ] })} - ${r.correction ? '' : '

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

'}`); + ${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)); }; @@ -2140,112 +2390,165 @@

Tickets

${live.length ? `
${esc(id)}${attrs({ id, rec: id, type: 'observation' })}`); + 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))}` : ''); + 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: r.id, rec: r.id, type: 'observation', computed: [ + ${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)}

-

${[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); - DRAWER = { kind: 'lesson', id }; - if (!k) return showDrawer(`

${esc(id)}

${attrs({ id, rec: id, type: 'knowledge' })}`); + 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: k.id, rec: k.id, type: 'knowledge' })} - ${k.status === 'candidate' ? '

Keep or retire it in the Inbox

' : ''}`); + ${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

' : ''} + ${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); - DRAWER = { kind: 'problem', id }; - if (!p) return showDrawer(`

${esc(id)}

${attrs({ id, rec: id, type: 'problem' })}`); - showDrawer(`

${esc(p.title)}

- ${attrs({ id: p.id, rec: p.id, type: 'problem' })} -

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

`); + 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; - const head = (title) => `

${title}

${attrs({ id: mid, rec: mid, type: 'message', computed: [['From', esc(who(by)), 'Who sent it.']] })}`; - if (!ticket) return showDrawer(`${head('Message')}

Its thread is on the ticket's timeline.

`); - showDrawer(`${head(`Thread on ${tlink(ticket)}`)}

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(`${head('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; + 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(`${head(`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.

`}`); + 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…

' : 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); - DRAWER = { kind: 'planner', id: feature }; - if (!p) return showDrawer(`

${esc(feature)}

${attrs({ id: `plan-${feature}`, idTip: 'The planner\'s name: plan- and the feature it plans.' })}

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

`); - showDrawer(`

Planning ${esc(p.title)}

- ${attrs({ id: `plan-${p.feature}`, idTip: 'The planner\'s name: plan- and the feature it plans. Its tmux window and log file use it.', computed: [ - ['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 || '')}` : ''}`, 'How long since it last did anything, and whether it is stuck.'], - ['Feature', flink(p.feature), 'The feature it is planning.'], - ['For goal', p.goals.map(glink).join(', '), 'The goals the feature serves.'], - ['Tickets so far', esc(p.tickets), 'Tickets it has written into its plan.'], - ['Waiting on you', (p.waiting_on_owner || []).length ? `
    ${p.waiting_on_owner.map((id) => `
  • ${qlink(id)}
  • `).join('')}
` : '', 'Your open questions it is waiting on.'], - ['Asked the supervisor', p.waiting_on_supervisor.length ? `
    ${p.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.'], - ['Branch', `${esc(p.branch)}`, 'Where its plan is being written.'], + 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.'], ] })} -

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

`); + ${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) { DRAWER = { kind: 'q', id }; return showDrawer(`

${esc(id)}

${attrs({ id, rec: id, type: 'question' })}`); } - 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: q.id, rec: q.id, type: 'question', computed: [ + ${attrs({ id, peek: p, type: 'question', computed: [ ['Status', esc(q.status), 'open, answered, defaulted or withdrawn.'], - ['Answered', q.answered_at ? `${esc(when(q.answered_at))} by ${esc(q.answered_by === 'owner' ? 'you' : q.answered_by)}` : '', 'When it was settled, and by whom.'], + ['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)}`); + ${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)}`); } +const FILES = new Map(); 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(); - const fa = () => attrs({ id: p, idTip: 'Its path in the repository.', computed: [['Version', `${esc(params.get('ref') || 'main')}`, 'The branch or commit it is read from; committed content only.']] }); - if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) return showDrawer(`

${esc(title)}

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

${esc(title)}

${fa()}

Open in a new tab

`); - showDrawer(`

${esc(title)}

${fa()}

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)}

${fa()} -
${where}${isMd ? `` : ''}
-
${isMd ? `
${rendered}
` : ''}
${esc(txt)}
`); - if (isMd) renderMermaid($('drawer-body').querySelector('.md')); + 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 } : {}) })}`; + const ext = (path.split('.').pop() || '').toLowerCase(); + const p = peekOf('file', path); + 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}`); + let txt = FILES.get(url); + if (txt === undefined) { + showDrawer(`${head}

Loading…

${tail}`); + try { const r = await fetch(url); const t = await r.text(); if (!r.ok) throw new Error(t); FILES.set(url, txt = t); } + catch (e) { return showDrawer(`${head}

${esc(e.message)}

${tail}`); } + if (DRAWER.kind !== 'file' || DRAWER.id !== path) return; } - catch (e) { showDrawer(`

${esc(title)}

${fa()}

${esc(e.message)}

`); } + 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. @@ -2301,7 +2604,12 @@

Tickets

${live.length ? `
${esc(t.name)} ${t.median_seconds}s
`).join('')}
` : '', ].join(''); - if (DRAWER.kind === 'ticket') openTicket(DRAWER.id, { quiet: true }); - else refreshAttrs(); + 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; } diff --git a/docs/devteam/schema/log.schema.json b/docs/devteam/schema/log.schema.json index 89211ae..1fdb96d 100644 --- a/docs/devteam/schema/log.schema.json +++ b/docs/devteam/schema/log.schema.json @@ -54,7 +54,8 @@ }, "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": [ @@ -62,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": { @@ -84,15 +86,19 @@ "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" @@ -135,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", @@ -154,6 +164,7 @@ } }, "tickets": { + "description": "A rough size, in tickets, before it is planned.", "type": "integer", "minimum": 1 } @@ -165,7 +176,8 @@ "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.", @@ -180,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).", @@ -237,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", @@ -261,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", @@ -283,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, @@ -333,13 +351,16 @@ "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" @@ -482,7 +503,8 @@ "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.", @@ -589,7 +611,8 @@ }, "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.", @@ -625,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, @@ -955,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).", @@ -984,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.", @@ -1004,7 +1030,8 @@ "at" ], "additionalProperties": false - } + }, + "x-tip": "Which secrets you posted, and when. Never the values." }, "links": { "type": "array", @@ -1033,17 +1060,20 @@ "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.", @@ -1081,7 +1111,8 @@ "maxItems": 10, "items": { "type": "string" - } + }, + "x-tip": "The tickets, chores or plans its answer approves, each once." }, "evidence": { "description": "Records behind the recommendation.", @@ -1135,7 +1166,8 @@ "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.", @@ -1171,11 +1203,13 @@ }, "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.", @@ -1280,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.", @@ -1327,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, @@ -1409,7 +1446,8 @@ "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.", @@ -1494,7 +1532,8 @@ "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.", @@ -1543,7 +1582,8 @@ "from", "to" ], - "additionalProperties": false + "additionalProperties": false, + "x-tip": "The ticket numbers reserved for a planner's feature." } }, "additionalProperties": false, @@ -1562,7 +1602,8 @@ "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.", @@ -1579,7 +1620,8 @@ }, "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.", diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 9629c41..796e441 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -5061,9 +5061,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); @@ -5076,7 +5074,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)}`); @@ -5156,6 +5160,15 @@ function cmdDashboard() { 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') ?? ''; + 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] : []), '--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') ?? ''; @@ -6171,7 +6184,7 @@ function cmdSettings() { // 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 = ['id', 'type', 'created_at', 'created_by', 'updates', 'hold_by']; +const FOLD_KEYS = [...ENVELOPE, 'created_at', 'created_by', 'updates', 'hold_by']; function recordView(id) { const o = logGet(id); if (!o) return null; @@ -6189,10 +6202,124 @@ function cmdRecord() { 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 = []; + for (const l of [...readLog().lines].sort((a, b) => a.at.localeCompare(b.at))) { + const own = want.has(l.id); + const body = Object.fromEntries(Object.entries(l).filter(([k]) => !k.startsWith('__'))); + 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) => (git(['log', '-n', '60', '--format=%H%x09%cI%x09%an%x09%s', '--', 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 }); + v.deps = { waits_on: t.depends_on ?? [], blocks: [...tickets().values()].filter((x) => (x.depends_on ?? []).includes(id)).map((x) => x.id) }; + const h = sessionOf(id) ? handoffFor(id) : null; + 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); + } 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 latest run of each gate the supervisor ran at a merge: how main stands for the tag + const runs = readLog().lines.filter((l) => l.type === 'gate' && l.by === 'supervisor').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 }]))); + } + } 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 desc = (props) => Object.fromEntries(Object.entries(props ?? {}).filter(([, v]) => v?.description).map(([k, v]) => [k, v.description])); - return { envelope: desc(SCHEMA.properties), types: Object.fromEntries(Object.entries(SCHEMA.$defs.types).map(([t, d]) => [t, { about: d.description ?? null, fields: desc(d.properties) }])) }; + 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. @@ -6410,7 +6537,6 @@ Decide and act (supervisor.md, constitution.md) decide --class c [--scope s] [--approved qid|kid] [--options a b] [--rationale r] [--confidence 0-1] [--refs ids] [--irreversible] -- "" a judgment call and the trust it rests on; prints d-… act --decision d-… -- "" carry out a decision (merge, ticket, withdraw, goal hold, worker, planner and fleet take --decision too) act --class c [--scope s] [--approved qid|kid] [--refs ids] [--irreversible] -- "" an act that is its own decision - record [--json] one record's stored fields, who created it and who set each field decisions [--who owner|supervisor] [--class c] [--corrected] [--tail n] [--json] every call, who made it and under what correct --reason r [--severity n] (owner; --retro for the supervisor) | repair --how undo|fix-ticket|none trust [--json] the ladder: rung, ceiling, agreement, corrections per class @@ -6432,6 +6558,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 [] @@ -6445,7 +6572,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, record: cmdRecord, '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 59ca6a2..bf45bae 100644 --- a/test/kit.test.mjs +++ b/test/kit.test.mjs @@ -1390,6 +1390,23 @@ test('devteam kit', async (t) => { 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')); + 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); From 728dac6eca35192c619b02c3d596d35d1957dd98 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 09:42:38 -0500 Subject: [PATCH 6/6] Sidepeek: fixes from the second Fable review Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T5z3hZtpGaQUGsTaAMQrAg --- docs/devteam/dashboard/index.html | 113 +++++++++++++++++++++--------- docs/devteam/scripts/devteam.mjs | 31 +++++--- test/kit.test.mjs | 1 + 3 files changed, 102 insertions(+), 43 deletions(-) diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index 2d06fc0..61d132f 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -1930,7 +1930,7 @@

${esc(g.title 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
' : '
Record
not in the log
'; + : 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}
` : ''}
`; } @@ -1938,11 +1938,16 @@

${esc(g.title // ---- 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)) fetchPeek(kind, id, q); - return PEEKS.get(k); + 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); @@ -1951,17 +1956,22 @@

${esc(g.title 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(); - PEEKS.set(k, r.ok ? j : { error: j.error || String(r.status) }); - } catch (e) { if (!PEEKS.has(k)) PEEKS.set(k, { error: String(e?.message ?? e) }); } finally { PEEK_FLIGHT.delete(k); } + 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; - const p = PEEKS.get(peekKey(DRAWER.kind, DRAWER.id)); - if (p && !p.error) fetchPeek(DRAWER.kind, DRAWER.id, DRAWER.q || {}); + 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(); } @@ -1971,13 +1981,29 @@

${esc(g.title 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 }); -function redraw() { if (DRAWER.kind && !$('drawer').hidden) OPENERS()[DRAWER.kind]?.(DRAWER.arg ?? DRAWER.id); } +// 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) { 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; @@ -1987,7 +2013,8 @@

${esc(g.title if (h) h.remove(); $('drawer').hidden = false; $('scrim').hidden = false; document.body.style.overflow = 'hidden'; - if (opening) $('drawer-close').focus(); + if (fresh) $('drawer-close').focus(); + else if (keep) $('drawer-body').querySelector(keep)?.focus(); for (const f of after) f(); } function closeDrawer() { @@ -2000,8 +2027,9 @@

${esc(g.title } // 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 = ''; - DRAWER = { kind, id, arg, q: DRAWER.kind === kind && DRAWER.id === id ? DRAWER.q : undefined }; + 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 ---- @@ -2021,6 +2049,7 @@

${esc(g.title 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) { @@ -2136,7 +2165,7 @@

${esc(g.title

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) => showDrawer(`

${esc(id)}

${attrs({ id, peek, type: kind })}${text ? `

${text}

` : ''}${historySection(peek, id)}`); +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)); @@ -2153,7 +2182,7 @@

Tickets

${live.length ? ` + 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 @@ -2162,12 +2191,14 @@

Tickets

${live.length ? `
–'; - const ok = runs.some((r) => r.ok), bad = runs.some((r) => !r.ok); + 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' : ''}`; @@ -2224,7 +2255,7 @@

Tickets

${live.length ? `
${title}

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

${sub}

` : ''} @@ -2279,7 +2311,7 @@

Readings

${readings.length ? lineChart([{ name: g.measure?.description | } function openStory(sid) { const pr = DATA.progress, st = pr.stories[sid]; - if (!st) { drawerFor('story', sid); return notFound('story', sid, null, 'No such story.'); } + 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.']] }); @@ -2301,7 +2333,7 @@

Readings

${readings.length ? lineChart([{ name: g.measure?.description | ], 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 ? `
  • checks at the last merge: ${gates.map((g) => `${g.ok ? '✓' : '✗'} ${esc(g.gate)}`).join(' · ')} (${esc(when(gates.map((g) => g.at).sort().pop()))})
  • ` : ''} + ${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.

'}` }); } @@ -2317,7 +2349,7 @@

Changelog

${merged.length ? `