From f7ed9ae771b94ab752d221a0bb54a3727fe9d526 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Wed, 11 Nov 2026 13:08:57 -0500 Subject: [PATCH 01/10] Lineage: every OODA record's chain, both ways, bounded; Mermaid text A lesson, an incident or a decision should show what it came from and what came of it. One link index, built once per read of the log from the fields that cite (evidence, refs, blocks, knowledge, approved_by, decision), gives lineage(id): 6 steps up, 4 down, at most 120 nodes, more than five siblings of one kind on one neighbour as one node. The work graph is drawn but never followed. peek carries it (lineage, lineage_mermaid) and --mermaid prints it for retros and PR bodies; labels are stripped so no text reaches Mermaid's syntax. signalGroups() keeps every group, so a card can say how near a signal an observation is; orientClusters() is built on it. Co-Authored-By: Claude Opus 5.5 --- docs/devteam/scripts/devteam.mjs | 161 +++++++++++++++++++++++++++++-- test/ooda-lineage.test.mjs | 138 ++++++++++++++++++++++++++ 2 files changed, 290 insertions(+), 9 deletions(-) create mode 100644 test/ooda-lineage.test.mjs diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 1b14a19..6ba285d 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -7501,16 +7501,19 @@ function cmdProblem() { // recur); anything else its source_key (a finding's metrics::), else kind:subject. // A fix-regressed or recurred line keeps its own key: it already cites its incident. const signalKeyOf = (o) => (fromSensor(o) && TRIAGE_KINDS.includes(o.kind) ? fpIdOfObservation(o) : null) ?? o.source_key ?? `${o.kind}:${o.subject}`; -// Recurring signals no knowledge, problem or ruling explains yet: three or more observations (or -// corrections) on one key, from at least two writers or on at least two days. A live decision -// that cites an observation explains it (an orient-noise ruling); a corrected one explains nothing, -// so the owner's correction of a ruling brings its signal back. -function orientClusters() { +// The observations (and corrections) nothing explains yet, grouped on the key they recur on, each +// group with whether it spreads (two writers or two days): every group, however small, so a card +// can say how near a signal one is; computed once per read of the log. A live decision that cites +// an observation explains it (an orient-noise ruling); a corrected one explains nothing, so the +// owner's correction of a ruling brings its signal back. +let SIGNALS = null; +function signalGroups() { + if (SIGNALS?.log === readLog()) return SIGNALS.groups; const linked = new Set(); for (const o of [...logObjects('knowledge'), ...logObjects('problem')]) for (const r of [...(o.evidence ?? []), ...(o.refs ?? [])]) linked.add(r); for (const d of logObjects('decision')) if ((d.refs ?? []).some((r) => String(r).startsWith('o-')) && liveDecision(d)) for (const r of d.refs) linked.add(r); const groups = new Map(); - const add = (key, item) => (groups.get(key) ?? groups.set(key, []).get(key)).push(item); + const add = (key, item) => (groups.get(key) ?? groups.set(key, { key, items: [] }).get(key)).items.push(item); for (const o of logObjects('observation')) { if (linked.has(o.id) || ['reading', 'retro', 'outcome', 'link'].includes(o.kind)) continue; add(signalKeyOf(o), { id: o.id, at: o.created_at, who: o.created_by, valence: o.valence ?? 0, detail: o.detail }); @@ -7519,9 +7522,13 @@ function orientClusters() { const c = correctionOf(a); if (c && !linked.has(a.id)) add(`correction:${a.class}:${a.scope ?? 'any'}`, { id: a.id, at: c.at, who: c.by, valence: -2, detail: c.reason }); } + for (const g of groups.values()) g.spread = new Set(g.items.map((i) => i.who)).size >= 2 || new Set(g.items.map((i) => dateOnly(i.at))).size >= 2; + return (SIGNALS = { log: readLog(), groups }).groups; +} +// Recurring signals: three or more of one group that spreads. +function orientClusters() { const res = []; - for (const [key, items] of groups) { - const spread = new Set(items.map((i) => i.who)).size >= 2 || new Set(items.map((i) => dateOnly(i.at))).size >= 2; + for (const { key, items, spread } of signalGroups().values()) { if (items.length < 3 || !spread) continue; const v = items.reduce((s, i) => s + i.valence, 0) / items.length; res.push({ key, count: items.length, valence: +v.toFixed(1), ids: items.map((i) => i.id), latest: items.map((i) => i.detail).pop() }); @@ -14211,6 +14218,135 @@ function historyOf(ids, { types = null, limit = 300 } = {}) { } return res.slice(-limit); } +// ---- lineage: what a record in the loop came from and what came of it ---- +// One index of the links between records, built once per read of the log: an edge from each record +// a field cites to the record citing it (cited before citing, for every field), read off the fields +// that cite, never a walk over strings. The ticket an add-ticket act made comes after the act. Then +// the computed steps: an observation recurs in its signal, and a correction follows the call. +const LINK_FIELDS = { evidence: 'evidence', refs: 'about', blocks: 'asks about', knowledge: 'rests on' }; +let LINKS = null; +function linkIndex() { + if (LINKS?.log === readLog()) return LINKS; + const out = new Map(), inn = new Map(), seen = new Set(); + const edge = (from, to, why) => { + const k = `${from}\0${to}`; + if (from === to || seen.has(k)) return; + seen.add(k); + (out.get(from) ?? out.set(from, []).get(from)).push({ to, why }); + (inn.get(to) ?? inn.set(to, []).get(to)).push({ from, why }); + }; + const known = (t) => typeof t === 'string' && readLog().objects.has(t); + for (const o of readLog().objects.values()) { + for (const [k, why] of Object.entries(LINK_FIELDS)) for (const t of Array.isArray(o[k]) ? o[k] : []) { + if (!known(t)) continue; + if (o.type === 'act' && o.class === 'add-ticket' && k === 'refs' && logGet(t).type === 'ticket') edge(o.id, t, 'made'); + else edge(t, o.id, why); + } + if (known(o.approved_by)) edge(o.approved_by, o.id, 'approved'); + if (known(o.decision)) edge(o.decision, o.id, 'carried out'); + } + for (const c of orientClusters()) for (const id of c.ids) edge(id, `signal:${c.key}`, 'recurs'); + for (const a of calls()) if (correctionOf(a)) edge(a.id, `correction:${a.id}`, 'corrected'); + return (LINKS = { log: readLog(), out, in: inn }); +} +// Where each record opens on the dashboard: its sidepeek's address. +const PEEK_HASH = { observation: 'observation', knowledge: 'lesson', problem: 'problem', decision: 'decision', act: 'act', question: 'q', ticket: 'ticket', goal: 'goal', feature: 'feature', message: 'thread', thread: 'talk' }; +const hrefOf = (o) => { const k = o?.type === 'observation' ? { retro: 'retro', learning: 'learning' }[o.kind] ?? 'observation' : PEEK_HASH[o?.type]; return k ? `#${k}/${encodeURIComponent(o.id)}` : null; }; +const plainLabel = (s) => String(s ?? '').replace(/\s+/g, ' ').trim().slice(0, 60); +// A question is the Inbox's; one answered in a ladder class is the owner's call, drawn in Decide. +const lineageStage = (o) => { const s = stageOf(o); return s === 'inbox' && questionView(o).status === 'answered' && ladder().classes[o.class] ? 'decide' : s; }; +function lineageNode(id) { + if (id.startsWith('signal:')) { + const key = id.slice('signal:'.length); + const g = signalGroups().get(key); + return { id, stage: 'orient', type: 'signal', kind: null, label: plainLabel(`recurring: ${key}`), at: g?.items.at(-1)?.at ?? null, href: `#signal/${encodeURIComponent(key)}` }; + } + if (id.startsWith('correction:')) { + const a = logGet(id.slice('correction:'.length)); + const c = correctionOf(a); + return c ? { id, stage: 'learn', type: 'correction', kind: a.class ?? null, label: plainLabel(`corrected: ${c.reason}`), at: c.at, href: hrefOf(a) } : null; + } + const o = logGet(id); + if (!o) return null; + return { id, stage: lineageStage(o), type: o.type, kind: o.kind ?? o.class ?? null, label: plainLabel(o.what ?? o.statement ?? o.title ?? o.question ?? o.detail ?? o.text) || id, at: o.created_at, href: hrefOf(o) }; +} +// lineage(id): the records it came from (up to 6 steps back) and what came of it (4 steps on), at +// most max in all. The work graph and conversation are what a record is about: drawn, never followed +// further. More than five siblings of one type and kind on one side of one neighbour become one node +// that lists them. The opened record is always the first node. +function lineage(id, { up = 6, down = 4, max = 120 } = {}) { + const L = linkIndex(); + const root = lineageNode(id); + if (!root) return null; + const nodes = new Map([[id, root]]); + let truncated = false; + const walk = (side, hops) => { + let front = [id]; + for (let h = 0; front.length; h++) { + const next = []; + for (const x of front) { + if (x !== id && nodes.get(x).stage === null) continue; + for (const e of (side === 'up' ? L.in : L.out).get(x) ?? []) { + const y = side === 'up' ? e.from : e.to; + if (nodes.has(y)) continue; + if (h >= hops || nodes.size >= max) { truncated = true; return; } + const n = lineageNode(y); + if (n) { nodes.set(y, n); next.push(y); } + } + } + front = next; + } + }; + walk('up', up); + walk('down', down); + let edges = []; + for (const x of nodes.keys()) for (const e of L.out.get(x) ?? []) if (nodes.has(e.to)) edges.push({ from: x, to: e.to, why: e.why }); + // siblings: one type and kind, on one side of one neighbour + const sibs = new Map(); + for (const e of edges) for (const [m, nb, side] of [[e.from, e.to, 'up'], [e.to, e.from, 'down']]) { + const n = nodes.get(m); + if (m === id || ['signal', 'correction'].includes(n.type)) continue; + const k = JSON.stringify([nb, side, n.type, n.kind]); + (sibs.get(k) ?? sibs.set(k, new Set()).get(k)).add(m); + } + const into = new Map(); + const groups = []; + for (const [k, ms] of [...sibs].sort((a, b) => b[1].size - a[1].size)) { + const free = [...ms].filter((m) => !into.has(m)); + if (free.length <= 5) continue; + const [nb, , type, kind] = JSON.parse(k); + let gid = `group:${nb}:${type}:${kind}`; + while (nodes.has(gid) || groups.some((g) => g.id === gid)) gid += '+'; + const first = nodes.get(free[0]); + groups.push({ id: gid, stage: first.stage, type, kind, label: `${free.length} ${kind ? `${kind} ` : ''}${type}s`, at: free.map((m) => nodes.get(m).at).sort().pop() ?? null, href: null, members: free, first: free[0] }); + for (const m of free) into.set(m, gid); + } + const list = []; + for (const [x, n] of nodes) { + if (!into.has(x)) list.push(n); + else { const g = groups.find((y) => y.first === x); if (g) { const { first, ...rest } = g; list.push(rest); } } + } + const seen = new Set(); + edges = edges.map((e) => ({ from: into.get(e.from) ?? e.from, to: into.get(e.to) ?? e.to, why: e.why })).filter((e) => { const k = `${e.from}\0${e.to}\0${e.why}`; if (e.from === e.to || seen.has(k)) return false; seen.add(k); return true; }); + return { nodes: list, edges, truncated }; +} +// The lineage as Mermaid text: a subgraph per stage in the loop's order, what is outside the loop +// after them, generated node ids (n0 is the opened record, n the i-th node), and labels stripped +// to plain words, so no record's text or id reaches Mermaid's syntax. +const LINEAGE_STAGES = [['observe', 'Observe'], ['orient', 'Orient'], ['decide', 'Decide'], ['act', 'Act'], ['learn', 'Learn']]; +const LINEAGE_CLASS = { observe: 'stroke:#4f8bd6', orient: 'stroke:#8a63d2', decide: 'stroke:#d29b2a', act: 'stroke:#3a9e6f', learn: 'stroke:#d0566a' }; +const mermaidText = (s) => String(s ?? '').replace(/[^A-Za-z0-9\s.,:!?()'/+-]/g, '').replace(/\s+/g, ' ').trim().slice(0, 60); +function lineageMermaid(L) { + const n = new Map(L.nodes.map((x, i) => [x.id, `n${i}`])); + const node = (x) => { const t = mermaidText(x.label) || mermaidText(x.id) || 'a record'; return x.type === 'signal' || x.members ? `${n.get(x.id)}(["${t}"])` : `${n.get(x.id)}["${t}"]`; }; + const lines = ['flowchart LR']; + for (const [s, title] of LINEAGE_STAGES) { const xs = L.nodes.filter((x) => x.stage === s); if (xs.length) lines.push(`subgraph s_${s}["${title}"]`, ...xs.map(node), 'end'); } + lines.push(...L.nodes.filter((x) => !LINEAGE_STAGES.some(([s]) => s === x.stage)).map(node)); + for (const e of L.edges) lines.push(`${n.get(e.from)} -->|${mermaidText(e.why)}| ${n.get(e.to)}`); + for (const [s, css] of Object.entries(LINEAGE_CLASS)) { const xs = L.nodes.filter((x) => x.stage === s); if (xs.length) lines.push(`classDef st_${s} ${css},stroke-width:1.5px`, `class ${xs.map((x) => n.get(x.id)).join(',')} st_${s}`); } + if (L.nodes.length) lines.push('classDef current stroke-width:3.5px', 'class n0 current'); + return lines.join('\n'); +} // A file's commits, from the git of the repo it is in: another repo's (read on its main by default) // only when this machine can read it, else none (a view never fails on a missing checkout). const fileHistory = (rel, ref = null, name = 'home') => { const R = name === 'home' ? repo('home') : repoReadable(name) ? repo(name) : null; return fileHistoryIn(R, rel, ref ?? (name === 'home' ? null : R?.mainRef())); }; @@ -14284,6 +14420,8 @@ function peekView(kind, id) { v.gates = Object.values(Object.fromEntries(runs.map((l) => [l.gate, { gate: l.gate, ok: l.ok, sha: l.sha ?? null, at: l.at }]))); v.last_merge = last ? { id: last.id, at: last.created_at, ticket: (last.refs ?? []).find((r) => ts.includes(r)) ?? null, sha } : null; } + } else if (kind === 'signal') { + v.record = null; } else { const o = logGet(id) ?? die(`no record ${id}`); v.history = historyOf([id]); @@ -14298,11 +14436,16 @@ function peekView(kind, id) { v.events = o.fingerprints.map((f) => { const { sensor, key } = splitFpId(f); return { fingerprint: f, cached: jsonOr(path.join(sensorDir(sensor), 'events', fpFile(key)), null) }; }); } } + // what a record in the loop, or a recurring signal, came from and what came of it + const root = kind === 'signal' ? `signal:${id}` : v.record && stageOf(logGet(v.record.id)) !== null ? v.record.id : null; + if (root) { v.lineage = lineage(root); v.lineage_mermaid = v.lineage ? lineageMermaid(v.lineage) : null; } return v; } function cmdPeek() { const [kind, id] = positional; - if (!kind || !id) die('usage: peek [--tickets ids] --json'); + if (!kind || !id) die('usage: peek [--tickets ids] --json | --mermaid'); + // its lineage as Mermaid text, for a retro, a PR body or the supervisor's own reading + if (flags.mermaid) return out(lineageMermaid(lineage(kind === 'signal' ? `signal:${id}` : id) ?? die(`no record ${id}`))); json(peekView(kind, id)); } diff --git a/test/ooda-lineage.test.mjs b/test/ooda-lineage.test.mjs new file mode 100644 index 0000000..c2ef926 --- /dev/null +++ b/test/ooda-lineage.test.mjs @@ -0,0 +1,138 @@ +// Lineage and a sidepeek for every stage (the OODA spec, PR 3): every record in the loop with what +// it came from and what came of it, both ways and bounded, as data and as Mermaid text; where an +// observation sits in the loop; and recurring signals left unexplained for more than a day. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fleetFixture, json, line } from './lib/kit.mjs'; + +const SUP = { DEVTEAM_ROLE: 'supervisor' }; +const stamp = (d) => `${d.toISOString().slice(0, 10).replace(/-/g, '')}-${d.toISOString().slice(11, 23).replace(/[:.]/g, '')}`; +// A line as the kit would have written it agoMs ago, in its writer's file. Ids follow the kit's +// form, so each line picks a distinct agoMs. +function handFor(F) { + const logDir = path.join(F.old, 'docs/devteam/run/log'); + return (by, o, agoMs = 1000) => { + const d = new Date(Date.now() - agoMs); + const id = o.id ?? `${{ observation: 'o', problem: 'p', decision: 'd', act: 'a', knowledge: 'k' }[o.type]}-${stamp(d)}-${by}`; + const file = ['owner', 'supervisor'].includes(by) ? `${by}-${d.toISOString().slice(0, 7)}.jsonl` : `${by}.jsonl`; + fs.mkdirSync(logDir, { recursive: true }); + fs.appendFileSync(path.join(logDir, file), line({ op: 'new', ...(by === 'owner' ? { channel: 'cli' } : {}), ...o, id, at: d.toISOString(), by })); + return id; + }; +} +const H = 3600000; +// A sensor's escalation on one fingerprint, as the sensors write it: a qualify, or a spike in an hour. +const spike = (hand, fp, ago) => hand('supervisor', { type: 'observation', source: 'web', detector: 'sensor', subject: 'sensor:web', kind: 'spike', detail: 'web: a log line is spiking', severity: 4, valence: -1, source_key: `sensor:web:spike:${fp}:${new Date(Date.now() - ago).toISOString().slice(0, 13)}` }, ago); + +// An incident, from its spikes to its outcome: three spikes, the incident that holds them, the fix +// decided, the ticket it added, the fix deployed and what came of it. +function incidentChain(F) { + const hand = handFor(F); + const spikes = [spike(hand, 'log:L1', 50 * H), spike(hand, 'log:L1', 40 * H), spike(hand, 'log:L1', 30 * H)]; + const p = hand('supervisor', { type: 'problem', title: 'payment errors', severity: 3, realized: true, status: 'open', threatens: [], refs: spikes, fingerprints: ['web:log:L1'], sensor: 'web' }, 20 * H); + const ticket = 'fx-01-payment'; + const d = hand('supervisor', { type: 'decision', class: 'incident-fix', scope: 'sev:high', what: 'fix the payment timeout', authority: 'rung', refs: [p, ticket] }, 19 * H); + const a = hand('supervisor', { type: 'act', class: 'add-ticket', decision: d, what: `added ticket ${ticket}`, refs: [ticket], reversible: true }, 18 * H); + hand('supervisor', { type: 'ticket', id: ticket, title: 'payment timeout', feature: 'chores', component: 'pay', kind: 'fix', size: 'S', risk: 'normal', serves: [], depends_on: [], touches: ['pay/**'], done_when: ['no timeouts'] }, 18 * H - 1000); + const deployed = hand('supervisor', { type: 'observation', source: 'supervisor', subject: p, kind: 'fix-deployed', detail: 'the fix is deployed', refs: [p, ticket] }, 10 * H); + const outcome = hand('supervisor', { type: 'observation', source: 'supervisor', subject: d, kind: 'outcome', detail: 'no timeouts since the fix', valence: 1, refs: [d] }, 2 * H); + return { hand, spikes, p, ticket, d, a, deployed, outcome }; +} +const peek = (F, kind, id) => json(F.ok(F.old, ['peek', kind, id, '--json'])); +const ids = (L) => L.nodes.map((n) => n.id); + +test('lineage: an incident\'s chain, both ways and bounded; a noise ruling; siblings collapse', () => { + const F = fleetFixture('ooda-lineage'); + const { old, ok } = F; + try { + const C = incidentChain(F); + // from the first spike, down: the incident, the decision, its act, the ticket it made, the outcome + const down = peek(F, 'observation', C.spikes[0]).lineage; + for (const x of [C.p, C.d, C.a, C.ticket, C.outcome, C.deployed]) assert.ok(ids(down).includes(x), `down from the spike reaches ${x}: ${ids(down).join(' ')}`); + assert.equal(down.nodes[0].id, C.spikes[0], 'the opened record is the first node'); + assert.deepEqual(down.nodes.find((n) => n.id === C.p), { id: C.p, stage: 'orient', type: 'problem', kind: null, label: 'payment errors', at: down.nodes.find((n) => n.id === C.p).at, href: `#problem/${C.p}` }); + assert.equal(down.nodes.find((n) => n.id === C.ticket).href, `#ticket/${C.ticket}`); + assert.equal(down.nodes.find((n) => n.id === C.ticket).stage, null, 'the work graph is outside the loop'); + assert.ok(down.edges.some((e) => e.from === C.spikes[0] && e.to === C.p && e.why === 'about')); + assert.ok(down.edges.some((e) => e.from === C.d && e.to === C.a && e.why === 'carried out')); + assert.ok(down.edges.some((e) => e.from === C.a && e.to === C.ticket && e.why === 'made'), 'the ticket an act added comes after it'); + // from the outcome, up: back to every spike + const up = peek(F, 'observation', C.outcome).lineage; + for (const x of [...C.spikes, C.p, C.d]) assert.ok(ids(up).includes(x), `up from the outcome reaches ${x}`); + assert.equal(up.nodes[0].stage, 'learn'); + // from the decision, both + const both = peek(F, 'decision', C.d).lineage; + for (const x of [...C.spikes, C.p, C.a, C.ticket, C.outcome]) assert.ok(ids(both).includes(x), `the decision's lineage holds ${x}`); + assert.equal(both.truncated, false); + // a noise ruling: the observations it rules on come before it + const o1 = ok(old, ['observe', '--subject', 'app', '--kind', 'note', '--detail', 'a bot hit /health'], SUP).trim(); + const n = ok(old, ['decide', '--class', 'orient-noise', '--refs', 'note:app', o1, '--', 'a health check'], SUP).trim(); + const noise = peek(F, 'observation', o1).lineage; + assert.deepEqual(noise.edges.filter((e) => e.from === o1 && e.to === n), [{ from: o1, to: n, why: 'about' }]); + assert.equal(noise.nodes.find((x) => x.id === n).stage, 'decide'); + // a correction is a Learn node on the call it corrects + ok(old, ['correct', n, '--reason', 'not a bot']); + const fixed = peek(F, 'decision', n).lineage; + const c = fixed.nodes.find((x) => x.id === `correction:${n}`); + assert.equal(c?.stage, 'learn'); + assert.equal(c.href, `#decision/${n}`, 'it opens the call it corrects'); + assert.ok(fixed.edges.some((e) => e.from === n && e.to === c.id && e.why === 'corrected')); + } finally { F.done(); } +}); + +test('lineage: six observations on one signal collapse into one node; the bound truncates; Mermaid text is safe', () => { + const F = fleetFixture('ooda-lineage-collapse'); + const { old, ok } = F; + try { + const hand = handFor(F); + const seven = [...Array(7)].map((_, i) => spike(hand, 'log:L2', (60 - i * 5) * H)); + // one of them became knowledge, with a label that tries to break out of Mermaid's syntax + const k = hand('supervisor', { type: 'knowledge', kind: 'lesson', scope: 'project', status: 'accepted', statement: 'payments"]; click n0 call alert(1) %% {init} #x', evidence: [seven[0]] }, H); + // the signal holds the six still unexplained: from it, up, six siblings on one neighbour are one node + assert.ok(json(ok(old, ['orient', '--json'])).some((c) => c.key === 'web:log:L2'), 'one recurring signal'); + const L = peek(F, 'signal', 'web:log:L2').lineage; + assert.equal(L.nodes[0].id, 'signal:web:log:L2'); + assert.equal(L.nodes[0].href, `#signal/${encodeURIComponent('web:log:L2')}`); + const [g, ...more] = L.nodes.filter((x) => x.members); + assert.deepEqual(more, [], JSON.stringify(L.nodes)); + assert.equal(g.id, 'group:signal:web:log:L2:observation:spike'); + assert.equal(g.label, '6 spike observations'); + assert.deepEqual([...g.members].sort(), seven.slice(1).sort()); + assert.equal(g.stage, 'observe'); + assert.ok(L.edges.some((e) => e.from === g.id && e.to === 'signal:web:log:L2' && e.why === 'recurs'), 'the group takes its members\' edges'); + assert.equal(L.nodes.length, 2); + // five are not collapsed + const k5 = hand('supervisor', { type: 'knowledge', kind: 'lesson', scope: 'project', status: 'accepted', statement: 'five of them', evidence: seven.slice(2) }, H / 2); + assert.equal(peek(F, 'lesson', k5).lineage.nodes.filter((x) => x.members).length, 0, 'five siblings stay five nodes'); + // as Mermaid text: a subgraph per stage it has, generated node ids, labels stripped to plain words + const M = ok(old, ['peek', 'lesson', k, '--mermaid']); + assert.match(M, /^flowchart LR\n/); + assert.equal((M.match(/^subgraph /gm) || []).length, 2, 'one subgraph per stage it has: Observe and Orient'); + assert.match(M, /^subgraph s_observe\["Observe"\]$/m); + const label = /\bn0\["([^"]*)"\]/.exec(M)?.[1]; + assert.ok(label, M); + assert.doesNotMatch(label, /[\]";%#{}]/, 'no syntax in a label'); + assert.equal(label, 'payments click n0 call alert(1) init x'); + assert.match(M, /^class n0 current$/m, 'the opened record is marked'); + assert.doesNotMatch(M, /\b[kod]-\d{8}-/, 'no record id reaches Mermaid'); + assert.match(M, /^n1 -->\|evidence\| n0$/m); + } finally { F.done(); } +}); + +test('lineage: at most 120 nodes, and it says when it stopped', () => { + const F = fleetFixture('ooda-lineage-max'); + try { + const hand = handFor(F); + const many = [...Array(125)].map((_, i) => hand('supervisor', { type: 'observation', source: 'supervisor', subject: 'app', kind: `note-${i}`, detail: `note ${i}` }, (200 - i) * 60000)); + // two lessons, the later resting on the earlier, each citing what fits in a line (LINE_MAX) + const k0 = hand('supervisor', { type: 'knowledge', kind: 'lesson', scope: 'project', status: 'accepted', statement: 'w', evidence: many.slice(0, 62) }, 2000); + const k = hand('supervisor', { type: 'knowledge', kind: 'lesson', scope: 'project', status: 'accepted', statement: 'x', evidence: [k0, ...many.slice(62)] }, 1000); + const L = peek(F, 'lesson', k).lineage; + assert.equal(L.truncated, true); + assert.equal(L.nodes.length, 120); + const small = hand('supervisor', { type: 'knowledge', kind: 'lesson', scope: 'project', status: 'accepted', statement: 'y', evidence: many.slice(0, 2) }, 500); + assert.equal(peek(F, 'lesson', small).lineage.truncated, false); + } finally { F.done(); } +}); From 54608b5cecdeaf27c3ee74c137cab68a65d8f1b3 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Wed, 11 Nov 2026 13:14:15 -0500 Subject: [PATCH 02/10] Sidepeeks for observations, recurring signals and learning; every Loop card opens An observation, a recurring signal and the learning record had no sidepeek, and a Loop card opened only from its timestamp. Now each opens its own: an observation's says where it came from first (its computed provenance, in plain words) and where it sits in the loop (not oriented yet, n of the 3 a signal takes, explained, ruled noise, ignored, set aside, or carried on to the furthest step), which the kit computes once (placeOf) for the card and the peek alike. A signal opens by its key, / and all, with its observations, how long it has waited and what would explain it. One delegated handler opens any [data-open] card, leaving a link, a control, a modified click, a pinned thread layer and a selection alone; the incidents' own handlers go, so nothing fires twice. o- and q- ids in text link to their sidepeeks. Co-Authored-By: Claude Opus 5.5 --- docs/devteam/dashboard/index.html | 131 +++++++++++++++++++++++++++--- docs/devteam/scripts/devteam.mjs | 47 ++++++++++- test/dashboard.test.mjs | 53 ++++++++++++ test/ooda-lineage.test.mjs | 83 ++++++++++++++++++- 4 files changed, 300 insertions(+), 14 deletions(-) diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index ed563eb..e3981e1 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -95,6 +95,9 @@ .stat b { display: block; font-size: 20px; font-weight: 600; letter-spacing: -.02em; margin-top: 2px; font-variant-numeric: tabular-nums; } .card { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 14px 16px; margin-bottom: 8px; box-shadow: var(--shadow); } .card.small { padding: 10px 12px; } + .card[data-open] { cursor: pointer; } + .card[data-open]:hover { background: var(--hover); } + .place { font-size: 12px; color: var(--fg-2); margin-top: 3px; } .card p { margin: 6px 0; } .two { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); } @@ -1028,7 +1031,7 @@

Observations

const mlink = (gid, n, title) => `${esc(title ?? milestonesOf(goalOf(gid)).find((m) => m.n === Number(n))?.title ?? `milestone ${n}`)}`; // 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', t: 'talk' }[k]; + const href = { g: 'goal', k: 'lesson', a: 'act', d: 'decision', p: 'problem', m: 'thread', t: 'talk', o: 'observation', q: 'q' }[k]; if (!href) return `${esc(id.slice(0, 22))}…`; if (k === 'g' && goalOf(id)) return glink(id); return `${esc(id.slice(0, 22))}…`; @@ -1979,18 +1982,18 @@

${esc(g.title // Observe $('observations').innerHTML = o.observations.length ? o.observations.slice(0, 40).map((x) => ` -
${esc(words(x.kind))}${subjectLink(x.subject)} -
${linkify(x.detail)}
${esc(who(x.created_by))}, ${when(x.created_at)}
`).join('') : empty('None yet. What workers and the supervisor notice shows here.'); +
`).join('') : empty('None yet. What workers and the supervisor notice shows here.'); // Orient $('clusters').innerHTML = o.clusters.length ? o.clusters.map((c) => ` -
${esc(words(c.key.split(':')[0]))} ${subjectLink(c.key.split(':').slice(1).join(':'))} · seen ${esc(c.count)} times
${linkify(c.latest)}
`).join('') : empty('None. Something noticed more than once shows here.'); +
${esc(words(c.key.split(':')[0]))} ${subjectLink(c.key.split(':').slice(1).join(':'))} · seen ${esc(c.count)} times
${linkify(c.latest)}
`).join('') : empty('None. Something noticed more than once shows here.'); $('problems').innerHTML = o.problems.length ? o.problems.map((p) => ` -
sev ${esc(p.severity)}${p.realized ? 'issue' : 'risk'}${esc(p.title)} · ${esc(p.status)}${p.assigned_to ? `, ${esc(p.assigned_to)}` : ''}${(p.threatens || []).length ? ` · threatens ${p.threatens.map(esc).join(', ')}` : ''}${p.detail ? `
${esc(p.detail)}
` : ''}
`).join('') : empty('None open.'); +
sev ${esc(p.severity)}${p.realized ? 'issue' : 'risk'}${esc(p.title)} · ${esc(p.status)}${p.assigned_to ? `, ${esc(p.assigned_to)}` : ''}${(p.threatens || []).length ? ` · threatens ${p.threatens.map(esc).join(', ')}` : ''}${p.detail ? `
${esc(p.detail)}
` : ''}
`).join('') : empty('None open.'); const cand = o.knowledge.filter((k) => k.status === 'candidate'); const acc = o.knowledge.filter((k) => k.status === 'accepted'); $('knowledge').innerHTML = (cand.length ? `

${cand.length} proposed lesson${cand.length > 1 ? 's are' : ' is'} waiting for you in the Inbox.

` : '') + - (acc.length ? acc.map((k) => `
${esc(k.kind)}${linkify(k.statement)}${k.valence < 0 ? ' (avoid)' : ''} · applies to ${esc(scopeWords(k.scope))}${k.created_by === 'owner' ? ' · from you' : ''}
`).join('') : '') || + (acc.length ? acc.map((k) => `
${esc(k.kind)}${linkify(k.statement)}${k.valence < 0 ? ' (avoid)' : ''} · applies to ${esc(scopeWords(k.scope))}${k.created_by === 'owner' ? ' · from you' : ''}
`).join('') : '') || empty('None yet. Things noticed more than once become proposed lessons, which you keep or retire in the Inbox.'); // Trust Ladder @@ -2007,7 +2010,7 @@

${esc(g.title // Act const feed = o.acts; $('acts-feed').innerHTML = feed.length ? feed.map((a) => { const v = actView(a); return ` -
+
${v.head}
${v.detail ? `
${v.detail}
` : ''}
${when(a.created_at)} · ${actUnder(a)}${a.reversible === false ? ' · can’t be undone' : ''}
${a.correction ? `
Corrected by ${esc(who(a.correction.by))}: ${esc(a.correction.reason)}${a.correction.repaired ? ` · ${esc(a.correction.repaired)}` : ''}
` : ''} @@ -2047,7 +2050,7 @@

${esc(g.title const under = decisionUnder(r); const evidence = (r.refs || []).length ? `about ${r.refs.slice(0, 4).map((x) => (ticketMap().has(x) ? tlink(x) : linkify(x))).join(', ')}` : ''; return ` -
+
${esc(DEC_KIND[r.kind] || r.kind)} ${linkify(r.what)}
${r.rationale ? `
${linkify(r.rationale)}${r.options?.length > 1 ? ` (weighed: ${r.options.map(esc).join(' / ')})` : ''}${r.confidence != null ? ` · ${Math.round(r.confidence * 100)}% sure` : ''}
` : ''}
${esc(when(r.at))} · ${esc(r.who === 'owner' ? 'you' : who(r.who))}${r.class ? ` · ${esc(words(r.class))}` : ''}${under ? ` · ${under}` : ''}${evidence ? ` · ${evidence}` : ''}${r.kind === 'decision' && r.acts.length ? ` · carried out by ${r.acts.length} act${r.acts.length > 1 ? 's' : ''}` : r.kind === 'decision' && !NO_ACT_CLASSES.includes(r.class) ? ' · not acted on yet' : ''}${r.reversible === false ? ' · can’t be undone' : ''}
@@ -2073,6 +2076,7 @@

${esc(g.title 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'], sensor: ['activity', 'Sensor'], + observation: ['eye', 'Observation'], signal: ['repeat', 'Recurring signal'], learning: ['trending-up', 'Learning'], }; let SCHEMA_DOCS = null; async function loadSchemaDocs() { @@ -2251,7 +2255,7 @@

${esc(g.title 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, sensor: openSensor }); +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, sensor: openSensor, observation: openObservation, signal: openSignal, learning: openLearning }); // 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() { @@ -2579,11 +2583,10 @@

${esc(m.n }).join('')}

` : empty('No sensors yet. The supervisor starts one for each production URL, log and error tracker it finds (settings sensors), and polls them while it runs.'); for (const m of ms.filter((x) => x.kind === 'http')) loadSensorSeries(m.name); $('incidents').innerHTML = incidentBoard(d.incidents || []); - for (const b of $('incidents').querySelectorAll('[data-open]')) b.onclick = () => { location.hash = b.dataset.open; }; const feed = d.sensor_feed || []; $('sensor-exceptions').innerHTML = feed.length ? `
${feed.map(fpRow).join('')}
` : empty(ms.some((m) => m.kind !== 'http') ? 'Nothing read yet. Exceptions, log errors and platform errors the sensors read show here, newest first.' : 'No probes yet. Discovery starts them when it finds an error tracker or a host whose logs the project can already read.'); const obs = (d.ooda?.observations || []).filter((o) => /^sensor:/.test(o.subject || '')); - $('sensor-feed').innerHTML = obs.length ? obs.map((o) => `
${o.severity != null ? `${esc(words(o.kind))} ` : ''}${linkify(o.detail)}
${esc(when(o.created_at))} · ${subjectLink(o.subject)}
`).join('') : empty('Nothing yet. Outages, slow spells and each day\'s rollup show here.'); + $('sensor-feed').innerHTML = obs.length ? obs.map((o) => `
${o.severity != null ? `${esc(words(o.kind))} ` : ''}${linkify(o.detail)}
${esc(when(o.created_at))} · ${subjectLink(o.subject)}
`).join('') : empty('Nothing yet. Outages, slow spells and each day\'s rollup show here.'); } function openSensor(name) { drawerFor('sensor', name); @@ -2890,6 +2893,99 @@

Changelog

${merged.length ? `
    ${merged.map((t) => `
  • ${tlink(t.id)} ${citedBlock(p, k)} ${historySection(p, id)}`); } +/* loop-place start */ +// Where an observation came from, in your words (its provenance, which the kit computes). +function fromWords(pv, o) { + const r = pv?.ref; + switch (pv?.from) { + case 'sensor': return r === 'goal-measure' ? 'From a goal\'s measure, read by the kit' : `From the ${(DATA?.sensors || []).some((x) => x.name === r) ? `${esc(r)}` : esc(r ?? 'kit\'s')} sensor`; + case 'metrics': return r === 'pane' ? 'From the kit\'s check on the agents\' panes' : `From the kit's own checks (${esc(words(r ?? 'a finding').toLowerCase())})`; + case 'spend': return 'From the spend meter'; + case 'owner': return 'From you'; + case 'owner-answer': return r ? `From your answer to ${qlink(r)}` : 'From your answer'; + case 'owner-thread': return r ? `From a thread with you (${linkify(r)})` : 'From a thread with you'; + case 'owner-chat': return 'From what you said in the supervisor chat'; + case 'retro': return 'From a retro'; + case 'worker': case 'planner': return `From ${esc(who(o?.created_by))}`; + case 'supervisor': return 'From the supervisor'; + default: return pv?.from ? `From ${esc(pv.from)}` : ''; + } +} +// A record's sidepeek, from its id (a correction opens the call it corrects). +const recordHref = (id) => { const x = String(id ?? '').replace(/^correction:/, ''); const k = { k: 'lesson', p: 'problem', d: 'decision', a: 'act', o: 'observation', q: 'q' }[x[0]]; return k && x[1] === '-' ? `#${k}/${encodeURIComponent(x)}` : null; }; +const aWord = (w) => `${/^[aeiou]/i.test(w) ? 'an' : 'a'} ${w}`; +// Where an observation sits in the loop, one line: the kit's place for it (collectOoda, peek). +function loopPlace(pl) { + if (!pl) return ''; + const to = (h, t) => (h ? `${t}` : t); + const sig = (t) => to(`#signal/${encodeURIComponent(pl.ref ?? '')}`, t); + const why = pl.why ? `: ${linkify(pl.why)}` : ''; + switch (pl.state) { + case 'new': return 'Not oriented yet'; + case 'signal': return pl.recurring ? `Part of a ${sig('recurring signal')} (seen ${esc(pl.n)} times)` : pl.n >= 3 ? `Seen ${esc(pl.n)} times, by one writer on one day: ${sig('not a recurring signal yet')}` : `Part of a ${sig('recurring signal')} (${esc(pl.n)} of 3)`; + case 'explained': return `Explained: became ${to(recordHref(pl.ref), esc(aWord(pl.why || 'lesson')))}`; + case 'noise': return `Ruled ${to(recordHref(pl.ref), 'noise')}${why}`; + case 'ignored': return `${to(recordHref(pl.ref), 'Ignored at triage')}${why}`; + case 'set-aside': return `${to(recordHref(pl.ref), 'Set aside')} until ${esc(when(pl.until))}${why}`; + case 'carried': return `Carried on: ${esc(pl.why)} ${to(recordHref(pl.ref), '(the furthest step)')}`; + default: return ''; + } +} +/* loop-place end */ +const clip = (x, n) => { const t = String(x ?? ''); return t.length > n ? `${t.slice(0, n)}…` : t; }; +const hoursWords = (h) => (h < 48 ? `${h} hour${h === 1 ? '' : 's'}` : `${Math.floor(h / 24)} days`); +function openObservation(id) { + drawerFor('observation', id); + const p = peekOf('observation', id); + const card = (DATA.ooda.observations || []).find((x) => x.id === id); + const o = card || (p?.record ? { ...p.record.fields, id, created_by: p.record.created_by, created_at: p.record.created_at } : null); + if (!o) return notFound('observation', id, p, p?.error ? 'No such observation.' : ''); + const pv = card?.provenance ?? p?.provenance; + const place = p && p.place !== undefined ? p.place : card?.place; + showDrawer(`

    ${esc(words(o.kind))}: ${esc(clip(o.detail, 80))}

    + ${pv ? `

    ${fromWords(pv, o)}

    ` : ''} + ${attrs({ id, peek: p, type: 'observation', computed: [ + ['Where it sits', loopPlace(place), 'Where it is in the loop: not oriented yet, part of a recurring signal, explained, ruled noise, set aside, or carried on to a decision, an act or an outcome.'], + ['Stage', esc(words(card?.stage ?? p?.stage ?? '')), 'Which stage of the loop it is in: observe, orient, decide, act or learn.'], + ] })} +

    ${linkify(o.detail)}

    + ${citedBlock(p, o)} + ${historySection(p, id)}`); +} +// A recurring signal is computed, not stored: what nothing explains yet on one key. +const signalTitle = (key) => `${words(key.split(':')[0])} ${key.split(':').slice(1).join(':')}`.trim(); +function openSignal(key) { + drawerFor('signal', key); + const p = peekOf('signal', key); + const c = (DATA.ooda.clusters || []).find((x) => x.key === key); + const s = p?.signal; + if (!s && !c) return notFound('signal', key, p, p?.error ? 'Nothing recurs on this key now: it was explained, or never recurred.' : '', { noRecord: true }); + const items = s?.items ?? []; + showDrawer(`

    ${esc(signalTitle(key))}

    + ${attrs({ id: key, idTip: 'The key its observations recur on: a fingerprint, a finding, or a kind and a subject.', peek: p, type: 'signal', noRecord: true, computed: [ + ['Seen', esc(s?.count ?? c?.count), 'How many observations on this key nothing explains yet.'], + ['Recurring', !s || s.recurring ? 'yes' : 'not yet: a signal is three or more, from two writers or on two days', 'Whether Orient lists it as a recurring signal.'], + ['First seen', s?.first_at ? esc(when(s.first_at)) : '', 'The oldest observation nothing explains yet.'], + ['Latest', s?.latest_at ? esc(when(s.latest_at)) : '', 'The newest one.'], + ['Unexplained for', s?.unexplained_hours != null ? esc(hoursWords(s.unexplained_hours)) : '', 'How long since the oldest of them: over a day is orientation debt.'], + ['Explained already', s?.explained?.length ? `${esc(s.explained.length)} earlier observation${s.explained.length > 1 ? 's' : ''} on this key` : '', 'Ones a lesson, a problem or a ruling already explains.'], + ] })} + ${c?.latest ? `

    ${linkify(c.latest)}

    ` : ''} +

    Its observations

    ${items.length ? `
      ${items.map((i) => `
    • ${esc(when(i.at))} ${linkify(clip(i.detail, 160))} · ${esc(who(i.by))}
    • `).join('')}
    ` : p === undefined ? '

    Loading…

    ' : empty('None unexplained.')} + ${s?.explain?.length ? `
    How the supervisor explains it

    With what it teaches, or a ruling that it is noise; you can correct either.

    ${s.explain.map((x) => `
    ${esc(x)}
    `).join('')}
    ` : ''} + ${historySection(p, key)}`); +} +// The weekly learning record (PR 5 adds its figures). +function openLearning(id) { + drawerFor('learning', id); + const p = peekOf('learning', id); + const f = p?.record?.fields; + if (!f) return notFound('learning', id, p, p?.error ? 'No such learning record.' : ''); + showDrawer(`

    ${esc(clip(f.detail || 'Learning', 80))}

    + ${attrs({ id, peek: p, type: 'observation' })} + ${f.detail ? `

    ${linkify(f.detail)}

    ` : ''} + ${historySection(p, id)}`); +} // An incident's part of the problem drawer: its stage and authority, its fingerprints, the fix // ticket, and (folded, framed as untrusted) the latest event of each fingerprint. function incidentSection(inc, p) { @@ -3110,6 +3206,17 @@

    Fingerprints

    ${inc.fingerprints.map((f) => feed.has(f. // a question's link to a changed artifact says so in the viewer's header document.addEventListener('click', (e) => { const a = e.target.closest?.('a[data-av-note]'); if (a) AV_NOTE = a.dataset.avNote || null; }); +// A Loop card opens its sidepeek when clicked anywhere on it. A link, a control or the correct form +// inside it keeps its own click; a Shift, Ctrl, Meta or Alt click, a pinned thread layer and a text +// being selected are the owner's own. +document.addEventListener('click', (e) => { + const card = e.target.closest?.('[data-open]'); + if (!card || e.button !== 0 || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey || TL.pin) return; + const inner = e.target.closest('a, button, input, select, textarea, label, [data-correct-form], .untrusted'); + if (inner && inner !== card && card.contains(inner)) return; + if (String(window.getSelection?.() ?? '')) return; + location.hash = card.dataset.open; +}); let ROUTED = false; function route() { const raw = location.hash.slice(1); @@ -3131,7 +3238,7 @@

    Fingerprints

    ${inc.fingerprints.map((f) => feed.has(f. // a thread: its card, beside its subject (#thread/ is a message's thread, in the sidepeek) // over nothing: a drawer it was followed from (or open under it) closes, as for any other link if (h.startsWith('talk/')) { hideDrawer(); return void tlOpen(h.slice(5)); } - const PEEKS = { feature: openFeature, story: openStory, release: openRelease, wave: openWave, milestone: openMilestone, act: openAct, decision: openDecision, retro: openRetro, lesson: openLesson, problem: openProblem, thread: openThread, sensor: openSensor }; + const PEEKS = { feature: openFeature, story: openStory, release: openRelease, wave: openWave, milestone: openMilestone, act: openAct, decision: openDecision, retro: openRetro, lesson: openLesson, problem: openProblem, thread: openThread, sensor: openSensor, observation: openObservation, signal: openSignal, learning: openLearning }; const [kind, ...rest] = h.split('/'); if (PEEKS[kind] && rest.length) return void PEEKS[kind](rest.join('/')); if (h.startsWith('file?')) return void openFile(new URLSearchParams(location.hash.slice(6))); diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 6ba285d..c5e074d 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -9386,7 +9386,8 @@ function collectOoda() { goals: logObjects('goal').map(goalView).sort((a, b) => (a.priority ?? 99) - (b.priority ?? 99)), roadmap: collectRoadmap(), retro: retroDue(), - observations: recent(logObjects('observation').filter((o) => !['reading', 'retro', 'finding-ack'].includes(o.kind)), 40).map(withOoda), + // each with where it sits in the loop, so its card can say so without the whole index + observations: recent(logObjects('observation').filter((o) => !['reading', 'retro', 'finding-ack'].includes(o.kind)), 40).map((o) => ({ ...withOoda(o), place: placeOf(o) })), clusters: orientClusters(), // every lesson waiting on the owner is in the Inbox, however many: only the rest are the recent 60 knowledge: [...recent(logObjects('knowledge').filter((k) => k.status === 'candidate'), Infinity), ...recent(logObjects('knowledge').filter((k) => !['retired', 'candidate'].includes(k.status)), 60)], @@ -14347,6 +14348,36 @@ function lineageMermaid(L) { if (L.nodes.length) lines.push('classDef current stroke-width:3.5px', 'class n0 current'); return lines.join('\n'); } +// Where an observation sits in the loop, for its card and its sidepeek: ruled noise, ignored at +// triage, set aside, carried on (the furthest stage it reached), explained (it became knowledge, a +// problem or an incident), part of a signal (n of the 3 that make one), or not oriented yet. Null +// for what isn't an observation for Orient (a reading, an outcome, a set-aside itself). +const SET_ASIDE_KINDS = ['finding-ack', 'sensor-ignored', 'sensor-unignored', 'sensor-reconsidered']; +let PLACE_CTX = null; +function placeOf(o) { + if (o?.type !== 'observation' || stageOf(o) !== 'observe' || SET_ASIDE_KINDS.includes(o.kind)) return null; + if (PLACE_CTX?.log !== readLog()) PLACE_CTX = { log: readLog(), ignores: sensorIgnores(), ackHours: loadSettings().finding_ack_hours ?? 24, acks: new Map(logObjects('observation').filter((x) => x.kind === 'finding-ack').sort(byTime).map((x) => [x.source_ref, x])) }; + const C = PLACE_CTX; + const after = (linkIndex().out.get(o.id) ?? []).map((e) => logGet(e.to)).filter(Boolean); + const noise = after.find((x) => x.type === 'decision' && x.class === 'orient-noise' && liveDecision(x)); + if (noise) return { state: 'noise', ref: noise.id, why: noise.what ?? null }; + const fp = fromSensor(o) && TRIAGE_KINDS.includes(o.kind) ? fpIdOfObservation(o) : null; + const ig = fp ? C.ignores.get(fp) : null; + if (ig) return { state: 'ignored', ref: ig.id, why: ig.reason }; + const ack = o.source_key ? C.acks.get(o.source_key) : null; + const until = ack ? new Date(Date.parse(ack.created_at) + C.ackHours * 3600000).toISOString() : null; + if (ack && until > nowIso()) return { state: 'set-aside', ref: ack.id, why: ack.detail, until }; + // what came of it, step by step: the first record it reached at each stage + const ORDER = ['orient', 'decide', 'act', 'learn']; + const reached = (lineage(o.id, { up: 0, down: 4, max: 60 })?.nodes ?? []).slice(1).filter((n) => ORDER.includes(n.stage) && n.type !== 'signal' && !SET_ASIDE_KINDS.includes(n.kind)); + const firstAt = ORDER.map((s) => reached.find((n) => n.stage === s)).filter(Boolean); + const word = (n) => (n.type === 'knowledge' ? n.kind ?? 'lesson' : n.type === 'problem' ? (isIncident(logGet(n.id)) ? 'incident' : 'problem') : n.type === 'question' ? 'your answer' : n.type === 'observation' ? String(n.kind ?? 'outcome').replace(/-/g, ' ') : n.type); + if (firstAt.length === 1 && firstAt[0].stage === 'orient') return { state: 'explained', ref: firstAt[0].id, why: word(firstAt[0]) }; + if (firstAt.length) return { state: 'carried', ref: firstAt.at(-1).id, why: firstAt.map(word).join(' → ') }; + const g = signalGroups().get(signalKeyOf(o)); + if (g && g.items.length > 1) return { state: 'signal', ref: g.key, n: g.items.length, recurring: g.items.length >= 3 && g.spread }; + return { state: 'new', ref: null }; +} // A file's commits, from the git of the repo it is in: another repo's (read on its main by default) // only when this machine can read it, else none (a view never fails on a missing checkout). const fileHistory = (rel, ref = null, name = 'home') => { const R = name === 'home' ? repo('home') : repoReadable(name) ? repo(name) : null; return fileHistoryIn(R, rel, ref ?? (name === 'home' ? null : R?.mainRef())); }; @@ -14421,7 +14452,20 @@ function peekView(kind, id) { v.last_merge = last ? { id: last.id, at: last.created_at, ticket: (last.refs ?? []).find((r) => ts.includes(r)) ?? null, sha } : null; } } else if (kind === 'signal') { + // computed, never stored: what nothing explains yet on its key, and the commands that would explain it v.record = null; + const g = signalGroups().get(id); + const items = [...(g?.items ?? [])].sort((a, b) => a.at.localeCompare(b.at)); + const explained = logObjects('observation').filter((o) => signalKeyOf(o) === id && !items.some((i) => i.id === o.id)).map((o) => o.id); + if (!items.length && !explained.length) die(`no signal ${id}`); + const cite = items.slice(-SET_ASIDE_REFS).map((i) => shq(i.id)).join(' '); + v.signal = { + key: id, count: items.length, spread: !!g?.spread, recurring: items.length >= 3 && !!g?.spread, explained, + items: items.map((i) => ({ id: i.id, at: i.at, by: i.who, detail: i.detail })), first_at: items[0]?.at ?? null, latest_at: items.at(-1)?.at ?? null, + unexplained_hours: items.length ? Math.floor((Date.now() - Date.parse(items[0].at)) / 3600000) : null, + explain: items.length ? [`knowledge add --kind lesson --scope project --statement '' --evidence ${cite}`, `decide --class orient-noise --refs ${shq(id)} ${cite} -- ''`] : [], + }; + v.history = historyOf(items.map((i) => i.id), { limit: 100 }); } else { const o = logGet(id) ?? die(`no record ${id}`); v.history = historyOf([id]); @@ -14439,6 +14483,7 @@ function peekView(kind, id) { // what a record in the loop, or a recurring signal, came from and what came of it const root = kind === 'signal' ? `signal:${id}` : v.record && stageOf(logGet(v.record.id)) !== null ? v.record.id : null; if (root) { v.lineage = lineage(root); v.lineage_mermaid = v.lineage ? lineageMermaid(v.lineage) : null; } + if (v.record?.type === 'observation') v.place = placeOf(logGet(v.record.id)); return v; } function cmdPeek() { diff --git a/test/dashboard.test.mjs b/test/dashboard.test.mjs index 0ef74d6..072d189 100644 --- a/test/dashboard.test.mjs +++ b/test/dashboard.test.mjs @@ -1296,3 +1296,56 @@ test("a goal at its target: the card's Mark met is an owner action, same-origin assert.match(html, /g\.status === 'ready' && g\.at_target \? `

${attrs({ id, peek: p, type: 'observation' })} ${f.detail ? `

${linkify(f.detail)}

` : ''} + ${lineageBlock(p)} ${historySection(p, id)}`); } // An incident's part of the problem drawer: its stage and authority, its fingerprints, the fix @@ -3014,6 +3080,7 @@

Fingerprints

${inc.fingerprints.map((f) => feed.has(f. const goals = (x.threatens || []).map((gid) => goalOf(gid)).filter(Boolean); showDrawer(`

${esc(x.title)}

${attrs({ id, peek: p, type: 'problem' })} + ${lineageBlock(p)} ${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('')}
` : ''} ${incidentSection(p?.incident || (DATA.incidents || []).find((i) => i.id === id), p)} @@ -3131,27 +3198,36 @@

Fingerprints

${inc.fingerprints.map((f) => feed.has(f. // 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. let MERMAID = null; -async function renderMermaid(root) { - const blocks = root ? [...root.querySelectorAll('pre > code.language-mermaid')] : []; - if (!blocks.length) return; - let mermaid; +const loadMermaid = async () => { try { MERMAID ??= import('https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs').then((m) => m.default); - mermaid = await MERMAID; - } catch { MERMAID = null; return; } + return await MERMAID; + } catch (e) { MERMAID = null; throw e; } +}; +// One diagram, as sanitized SVG text: initialize, render, sanitize. Throws when Mermaid can't load +// or the diagram doesn't render. +let MMD_N = 0; +async function drawMermaid(text) { + const mermaid = await loadMermaid(); const dark = matchMedia('(prefers-color-scheme: dark)').matches; // Agents write these documents: a diagram's own %%{init}%% directive could set themeCSS and restyle // the owner's page (the SVG keeps its