From c84b6809d6e0afb0261c984cd9964e39629a70f7 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Wed, 11 Nov 2026 14:50:59 -0500 Subject: [PATCH 1/4] The weekly learning record observe-metrics now writes one learning observation per ended ISO week, with the week's counts in a new top-level figures field and its week in week, back-filled from the log as it stood at each week's end (withLogAt folds only the lines up to the cut). Counts, never shares, so the views divide and a small denominator can be shown as too few to say. The dashboard reads the records and never writes one. Co-Authored-By: Claude Opus 5.5 --- docs/devteam/schema/log.schema.json | 57 +++++++++++++- docs/devteam/scripts/devteam.mjs | 102 ++++++++++++++++++++++++- test/ooda-learning.test.mjs | 114 ++++++++++++++++++++++++++++ 3 files changed, 269 insertions(+), 4 deletions(-) create mode 100644 test/ooda-learning.test.mjs diff --git a/docs/devteam/schema/log.schema.json b/docs/devteam/schema/log.schema.json index 2011fa4..3eece0f 100644 --- a/docs/devteam/schema/log.schema.json +++ b/docs/devteam/schema/log.schema.json @@ -779,7 +779,7 @@ ] }, "observation": { - "description": "Something noticed: a metrics finding, a slow test, a goal reading (kind reading), a trial's outcome (kind outcome), an escaped defect, a retro (kind retro, source_ref = its file). Deduped on source_key.", + "description": "Something noticed: a metrics finding, a slow test, a goal reading (kind reading), a trial's outcome (kind outcome), an escaped defect, a retro (kind retro, source_ref = its file), a week's learning record (kind learning: its week and figures). Deduped on source_key.", "type": "object", "properties": { "source": { @@ -837,6 +837,61 @@ "description": "A suggested fix.", "type": "string" }, + "week": { + "description": "On a learning record (kind learning): the ISO week it counts, in UTC, as yyyy-Www. Its at is when it was written, which for a back-filled week is later.", + "x-tip": "The week these figures count.", + "type": "string", + "pattern": "^\\d{4}-W\\d{2}$" + }, + "figures": { + "description": "On a learning record (kind learning): the week's counts, from the log as it stood at the week's end. Counts, never shares: the views divide.", + "x-tip": "What the week counted: informed, autonomous, trusted.", + "type": "object", + "properties": { + "informed": { + "description": "Informed: what the loop knew at the week's end.", + "x-tip": "Whether the agents work from what the loop has learned.", + "type": "object", + "properties": { + "knowledge": { "type": "integer", "minimum": 0, "description": "Accepted knowledge in force at the week's end." }, + "signals_formed": { "type": "integer", "minimum": 0, "description": "Recurring signals formed by the week's end (three or more on one key, from two writers or on two days)." }, + "signals_explained": { "type": "integer", "minimum": 0, "description": "Of those, the ones a lesson, a problem, an incident or a live decision explains." }, + "starts": { "type": "integer", "minimum": 0, "description": "Workers and planners started fresh in the week." }, + "starts_given": { "type": "integer", "minimum": 0, "description": "Of those, the ones whose prompt carried at least one lesson." } + }, + "required": ["knowledge", "signals_formed", "signals_explained", "starts", "starts_given"], + "additionalProperties": false + }, + "autonomous": { + "description": "Autonomous: how much the supervisor did alone.", + "x-tip": "Whether the supervisor needs to ask less.", + "type": "object", + "properties": { + "ladder_calls": { "type": "integer", "minimum": 0, "description": "Calls in a trust-ladder class made in the week." }, + "trusted_calls": { "type": "integer", "minimum": 0, "description": "Of those, the ones made on trust (its rung, or a standing rule) rather than asked." }, + "questions": { "type": "integer", "minimum": 0, "description": "Decisions put to the owner in the week (decisions, one-way doors, proposals, trust, clarifications)." }, + "merged": { "type": "integer", "minimum": 0, "description": "Tickets merged in the week." } + }, + "required": ["ladder_calls", "trusted_calls", "questions", "merged"], + "additionalProperties": false + }, + "trusted": { + "description": "Trusted: how often the owner agreed, and corrected.", + "x-tip": "Whether the owner agrees with the supervisor's calls.", + "type": "object", + "properties": { + "answered": { "type": "integer", "minimum": 0, "description": "Questions the owner answered in the week that carried a recommendation." }, + "agreed": { "type": "integer", "minimum": 0, "description": "Of those, the ones answered with the recommendation." }, + "calls": { "type": "integer", "minimum": 0, "description": "The supervisor's calls made in the week." }, + "corrections": { "type": "integer", "minimum": 0, "description": "Corrections the owner made in the week." } + }, + "required": ["answered", "agreed", "calls", "corrections"], + "additionalProperties": false + } + }, + "required": ["informed", "autonomous", "trusted"], + "additionalProperties": false + }, "sha": { "description": "A reading of a repo measure: the commit on main its check ran on.", "type": "string" diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 0f34c6a..c6f1b10 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -7584,7 +7584,8 @@ async function cmdObserveMetrics() { const unfetched = await fetchLinkMains(end); const { skipped } = await probeLinks(end); const s = observeSpend(); - out(`recorded ${s} spend line(s) and ${observeFindings(collectMetrics(0))} new observation(s)${unfetched.length ? `; ${unfetched.length} repo link(s) not fetched this tick (time budget): ${unfetched.join(', ')}` : ''}${skipped.length ? `; ${skipped.length} link(s) not probed this tick (time budget): ${skipped.join(', ')}` : ''}`); + const learned = writeLearning(); + out(`recorded ${s} spend line(s) and ${observeFindings(collectMetrics(0))} new observation(s)${learned ? `; ${learned} weekly learning record(s)` : ''}${unfetched.length ? `; ${unfetched.length} repo link(s) not fetched this tick (time budget): ${unfetched.join(', ')}` : ''}${skipped.length ? `; ${skipped.length} link(s) not probed this tick (time budget): ${skipped.join(', ')}` : ''}`); } // The tick's fetch of each repo link's main (home's is the tick's own git fetch), four at a time // inside the tick's budget, so status, metrics and the dashboard read every repo's main as its @@ -8068,7 +8069,7 @@ function signalGroups() { const groups = new Map(); 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; + if (linked.has(o.id) || ['reading', 'retro', 'outcome', 'link', 'learning'].includes(o.kind)) continue; const key = signalKeyOf(o); if (covered(keys, key, o.created_at)) continue; add(key, { id: o.id, at: o.created_at, who: o.created_by, valence: o.valence ?? 0, detail: o.detail }); @@ -8116,6 +8117,99 @@ function incidentDebt() { return [{ id: p.id, title: p.title, resolved_at: at, outcome: outcome.id, decision: d, days: Math.floor((now - Date.parse(at)) / DAY), explain: [`knowledge add --kind lesson --scope ${shq(t?.component ? `component:${t.component}` : 'project')} --statement '' --evidence ${cite}`, `decide --class orient-noise --refs ${shq(p.id)} -- 'nothing to keep'`] }]; }).sort((a, b) => a.resolved_at.localeCompare(b.resolved_at)); } +// ===== the weekly learning record (the OODA spec §6) ===== + +// ISO weeks, in UTC: Monday 00:00 of the week ms falls in, the week's name, and back. +const WEEK = 7 * DAY; +const mondayOf = (ms) => { const d = new Date(ms); return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) - ((d.getUTCDay() + 6) % 7) * DAY; }; +const isoWeekOf = (ms) => { const m = mondayOf(ms); const y = new Date(m + 3 * DAY).getUTCFullYear(); return `${y}-W${String(1 + Math.round((m - mondayOf(Date.UTC(y, 0, 4))) / WEEK)).padStart(2, '0')}`; }; +const weekStartOf = (week) => { const [y, w] = String(week).split('-W').map(Number); return mondayOf(Date.UTC(y, 0, 4)) + (w - 1) * WEEK; }; +// Run fn against the log as it stood at cut: a fold of only the lines with at <= cut, every memo +// built on readLog() following it, and the log-derived globals that aren't (SENSOR_KEYS and the +// process-wide memos) reset for fn and put back after. Git facts (mergedAt) replay as they are. +function withLogAt(cut, fn) { + const full = readLog(); + const saved = { logCache, SENSOR_KEYS, roadmapMemo, STATUS_MEMO, spendMemo, spendItemsMemo, spendWhoMemo, SIGNALS, FP_OBS, LINKS, PLACE_CTX }; + try { + const lines = full.lines.filter((l) => l.at <= cut); + const { objects, ignored } = fold(lines); + const byType = new Map(); + for (const o of objects.values()) { if (!byType.has(o.type)) byType.set(o.type, []); byType.get(o.type).push(o); } + logCache = { lines, objects, errors: full.errors, ignored, unknown: full.unknown, byType }; + SENSOR_KEYS = roadmapMemo = STATUS_MEMO = spendMemo = spendItemsMemo = spendWhoMemo = SIGNALS = FP_OBS = LINKS = PLACE_CTX = null; + return fn(); + } finally { + ({ logCache, SENSOR_KEYS, roadmapMemo, STATUS_MEMO, spendMemo, spendItemsMemo, spendWhoMemo, SIGNALS, FP_OBS, LINKS, PLACE_CTX } = saved); + } +} +// The questions that put a decision to the owner (the schema's kinds, less an action only the owner +// can do, a review, and defining or approving a goal). +const DECISION_KINDS = ['owner-decision', 'one-way-door', 'proposal', 'trust', 'clarification']; +const authorityOfCall = (c) => (c.type === 'decision' ? c.authority : actAuthority(c)); +// One week's figures (counts, never shares) and the records each counts, from the log as it stood +// at the week's end. "In the week" is the named time falling in it. +function learningFigures(week, merges = mergeTimes()) { + const from = weekStartOf(week); + const fromIso = new Date(from).toISOString(), cut = new Date(from + WEEK - 1).toISOString(); + const inW = (at) => typeof at === 'string' && at >= fromIso && at <= cut; + return withLogAt(cut, () => { + const L = ladder(); + const knowledge = logObjects('knowledge').filter((k) => k.status === 'accepted').sort(byTime).map((k) => k.id); + // recurring signals as Orient forms them, before what explains them is taken out + const { ids, keys } = explained(); + const groups = new Map(); + const add = (key, item) => (groups.get(key) ?? groups.set(key, { key, items: [] }).get(key)).items.push(item); + for (const o of logObjects('observation')) if (!['reading', 'retro', 'outcome', 'link', 'learning'].includes(o.kind)) add(signalKeyOf(o), { id: o.id, at: o.created_at, who: o.created_by }); + for (const a of calls()) { const c = correctionOf(a); if (c) add(`correction:${a.class}:${a.scope ?? 'any'}`, { id: a.id, at: c.at, who: c.by }); } + const signals = [...groups.values()].filter((g) => g.items.length >= 3 && (new Set(g.items.map((i) => i.who)).size >= 2 || new Set(g.items.map((i) => dateOnly(i.at))).size >= 2)) + .map((g) => { const by = g.items.filter((i) => ids.has(i.id) || covered(keys, g.key, i.at)).map((i) => i.id); return { key: g.key, ids: g.items.map((i) => i.id), explained: by.length > 0, by }; }); + const starts = logObjects('act').filter((a) => a.class === 'worker-slots' && inW(a.created_at) && String(a.what).startsWith('made ')).sort(byTime).map((a) => ({ id: a.id, given: Array.isArray(a.knowledge) ? a.knowledge : [] })); + const all = calls().sort(byTime); + const weekCalls = all.filter((c) => inW(c.created_at)); + const ladderCalls = weekCalls.filter((c) => L.classes[c.class]).map((c) => ({ id: c.id, class: c.class, authority: authorityOfCall(c) })); + const qs = questions(); + const asked = qs.filter((q) => inW(q.asked_at) && DECISION_KINDS.includes(q.kind)).map((q) => q.id); + // trustEvidence's filter: answered by the owner, with a recommendation (a review's proposals) + const answered = qs.filter((q) => q.status === 'answered' && inW(q.answered_at) && (q.recommendation || (q.kind === 'review' && (q.review?.data?.proposals ?? 0) > 0))).sort((a, b) => a.answered_at.localeCompare(b.answered_at)).map((q) => ({ id: q.id, matched: !!q.matched })); + const corrections = all.filter((c) => inW(correctionOf(c)?.at)).map((c) => c.id); + const merged = merges.filter(([, at]) => at >= from && at < from + WEEK).map(([id]) => id); + const figures = { + informed: { knowledge: knowledge.length, signals_explained: signals.filter((s) => s.explained).length, signals_formed: signals.length, starts_given: starts.filter((s) => s.given.length).length, starts: starts.length }, + autonomous: { trusted_calls: ladderCalls.filter((c) => ['rung', 'standing-rule'].includes(c.authority)).length, ladder_calls: ladderCalls.length, questions: asked.length, merged: merged.length }, + trusted: { agreed: answered.filter((q) => q.matched).length, answered: answered.length, corrections: corrections.length, calls: weekCalls.length }, + }; + return { week, from: fromIso, cut, figures, lists: { knowledge, signals, starts, ladder_calls: ladderCalls, questions: asked, merged, answered, calls: weekCalls.map((c) => c.id), corrections } }; + }); +} +// When each ticket merged (ms), from git, once per read of the whole log: a back-fill asks every week. +const mergeTimes = () => memo('mergeTimes', () => [...tickets().keys()].map((id) => [id, mergedAt(id)]).filter(([, at]) => at)); +// The record's line: its counts, one sentence of them, and refs to what it counted (at most 60: +// the corrections first, then the explained signals, then the knowledge in force). +function learningLine(r) { + const { informed: I, autonomous: A, trusted: T } = r.figures; + const n = (k, one, many = `${one}s`) => `${k} ${k === 1 ? one : many}`; + const detail = `Week ${r.week}: ${n(I.knowledge, 'lesson')} in force, ${I.signals_explained} of ${n(I.signals_formed, 'recurring signal')} explained, ${I.starts_given} of ${n(I.starts, 'start')} given a lesson; ${A.trusted_calls} of ${n(A.ladder_calls, 'ladder call')} made on trust, ${n(A.questions, 'question')} to the owner for ${n(A.merged, 'merged ticket')}; ${T.agreed} of ${n(T.answered, 'recommendation')} agreed, ${n(T.corrections, 'correction')} in ${n(T.calls, 'call')}.`; + const refs = [...new Set([...r.lists.corrections, ...r.lists.signals.filter((s) => s.explained).map((s) => s.by[0]), ...r.lists.knowledge])].slice(0, KNOWLEDGE_IDS_MAX); + return { type: 'observation', source: 'metrics', detector: 'metrics', subject: 'project', kind: 'learning', week: r.week, figures: r.figures, detail: detail.slice(0, 300), source_key: `learning:${r.week}`, refs }; +} +// The metrics pass (supervisor only): one learning record for each ISO week since the log's +// earliest line that has ended and has none yet (any observation with its source_key). A +// back-filled week's record is stamped now (records can't be back-dated) and names its week. +// Every week is counted before any is written, so each count folds the log once. +function writeLearning() { + const log = readLog(); + const first = log.lines.reduce((m, l) => (l.at < m ? l.at : m), log.lines[0]?.at ?? ''); + const start = Date.parse(first); + if (!Number.isFinite(start)) return 0; + const have = new Set(logObjects('observation').map((o) => o.source_key).filter((k) => k?.startsWith('learning:'))); + const weeks = []; + for (let t = mondayOf(start), cur = mondayOf(Date.now()); t < cur; t += WEEK) if (!have.has(`learning:${isoWeekOf(t)}`)) weeks.push(isoWeekOf(t)); + if (!weeks.length) return 0; + const merges = mergeTimes(); + const lines = weeks.map((w) => learningLine(learningFigures(w, merges))); + for (const l of lines) appendLog(l, 'supervisor'); + return lines.length; +} // One value as a POSIX shell word: single-quoted, each ' written '\''. Every value orient puts in a // command it proposes goes through this, since observation text is anyone's to write and the // supervisor runs what it pastes (L-SEC-5). @@ -9973,7 +10067,7 @@ function collectOoda() { roadmap: collectRoadmap(), retro: retroDue(), // 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) })), + observations: recent(logObjects('observation').filter((o) => !['reading', 'retro', 'finding-ack', 'learning'].includes(o.kind)), 40).map((o) => ({ ...withOoda(o), place: placeOf(o) })), clusters: orientClusters(), // orientation debt: recurring signals nothing has explained for more than a day, and resolved // incidents nobody learned from, each oldest first @@ -9990,6 +10084,8 @@ function collectOoda() { // every settled question, newest first, wherever it was answered (dashboard, terminal, chat) or // whether it was defaulted or withdrawn; defaulted ones can still be overruled answered: questions().filter((q) => q.status !== 'open').sort((a, b) => b.answered_at.localeCompare(a.answered_at)).slice(0, 80), + // the weekly learning records, oldest first: read, never written here (observe-metrics writes them) + learning: logObjects('observation').filter((o) => o.kind === 'learning' && o.week && o.figures && o.created_by === 'supervisor').sort((a, b) => a.week.localeCompare(b.week) || byTime(a, b)).map((o) => ({ id: o.id, week: o.week, at: o.created_at, from: new Date(weekStartOf(o.week)).toISOString(), figures: o.figures, detail: o.detail })), log: { lines: log.lines.length, errors: log.errors.length, ignored: log.ignored.length, unknown: log.unknown.length }, }; } diff --git a/test/ooda-learning.test.mjs b/test/ooda-learning.test.mjs new file mode 100644 index 0000000..12ab928 --- /dev/null +++ b/test/ooda-learning.test.mjs @@ -0,0 +1,114 @@ +// The weekly learning record and the chart (the OODA spec, PR 5): observe-metrics writes one +// observation per ended ISO week with the week's figures, back-filled from the log as it stood at +// each week's end; the dashboard reads them and never writes one. +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 DAY = 86400000; +const HOUR = 3600000; +const stamp = (d) => `${d.toISOString().slice(0, 10).replace(/-/g, '')}-${d.toISOString().slice(11, 23).replace(/[:.]/g, '')}`; +// Monday 00:00 UTC of the week ms falls in, and that week's ISO name. +const mondayOf = (ms) => { const d = new Date(ms); return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) - ((d.getUTCDay() + 6) % 7) * DAY; }; +const isoWeek = (ms) => { + const m = mondayOf(ms); + const y = new Date(m + 3 * DAY).getUTCFullYear(); + const w1 = mondayOf(Date.UTC(y, 0, 4)); + return `${y}-W${String(1 + Math.round((m - w1) / (7 * DAY))).padStart(2, '0')}`; +}; +// A line as the kit would have written it at ms, in its writer's file. Lines go in time order. +function handFor(F) { + const logDir = path.join(F.old, 'docs/devteam/run/log'); + return (by, o, ms) => { + const d = new Date(ms); + const id = o.id ?? `${{ observation: 'o', problem: 'p', decision: 'd', act: 'a', knowledge: 'k', question: 'q' }[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 learnings = (F) => json(F.ok(F.old, ['log', '--type', 'observation', '--json'])).filter((o) => o.kind === 'learning').sort((a, b) => a.week.localeCompare(b.week)); + +// Three past weeks, A, B and C, with something to count in each; the current week is never counted. +function threeWeeks(F) { + const hand = handFor(F); + const A = mondayOf(Date.now()) - 21 * DAY, B = A + 7 * DAY, C = B + 7 * DAY; + const o1 = hand('supervisor', { type: 'observation', source: 'repo', subject: 'app', kind: 'note', detail: 'the build was slow' }, A + HOUR); + const o2 = hand('supervisor', { type: 'observation', source: 'repo', subject: 'app', kind: 'note', detail: 'the build was slow again' }, A + DAY + HOUR); + const o3 = hand('supervisor', { type: 'observation', source: 'repo', subject: 'app', kind: 'note', detail: 'the build was slow a third time' }, A + 2 * DAY); + const k1 = hand('owner', { type: 'knowledge', kind: 'lesson', statement: 'Cache the build', scope: 'project', status: 'accepted', evidence: [o1] }, A + 2 * DAY + HOUR); + const d1 = hand('supervisor', { type: 'decision', class: 'priority-change', what: 'raised the build fix', authority: 'rung', rung: 3 }, A + 3 * DAY); + const d2 = hand('supervisor', { type: 'decision', class: 'priority-change', what: 'lowered the docs', authority: 'default' }, A + 3 * DAY + HOUR); + const a1 = hand('supervisor', { type: 'act', class: 'worker-slots', what: 'made worker ab-01-a in .worktrees/ab-01-a', refs: ['ab-01-a'], knowledge: [k1] }, A + 4 * DAY); + const q1 = hand('supervisor', { type: 'question', kind: 'owner-decision', class: 'priority-change', question: 'Ship the cache?', recommendation: 'Yes', options: ['Yes', 'No'] }, B + HOUR); + const q2 = hand('supervisor', { type: 'question', kind: 'owner-action', question: 'Log in to the registry?' }, B + 2 * HOUR); + hand('owner', { type: 'question', op: 'set', id: q1, answer: 'Yes' }, B + 3 * HOUR); + hand('owner', { type: 'decision', op: 'set', id: d1, corrected: { reason: 'not that one' } }, B + DAY); + const a2 = hand('supervisor', { type: 'act', class: 'worker-slots', what: 'made worker ab-02-b in .worktrees/ab-02-b', refs: ['ab-02-b'] }, B + 2 * DAY); + const a3 = hand('supervisor', { type: 'act', class: 'worker-slots', what: 'resumed worker ab-01-a in .worktrees/ab-01-a', refs: ['ab-01-a'] }, B + 2 * DAY + HOUR); + const o4 = hand('supervisor', { type: 'observation', source: 'repo', subject: 'app', kind: 'note', detail: 'the build was slow once more' }, B + 3 * DAY); + hand('owner', { type: 'knowledge', op: 'set', id: k1, status: 'retired' }, C + HOUR); + const d3 = hand('supervisor', { type: 'decision', class: 'priority-change', what: 'raised the tests', authority: 'standing-rule' }, C + 2 * HOUR); + return { A, B, C, o1, o2, o3, o4, k1, d1, d2, d3, a1, a2, a3, q1, q2, weeks: [isoWeek(A), isoWeek(B), isoWeek(C)] }; +} + +test('observe-metrics back-fills one learning record per ended week, idempotently; the dashboard never writes one', () => { + const F = fleetFixture('ooda-learning'); + const { old, ok } = F; + try { + const X = threeWeeks(F); + // reading the dashboard writes nothing + const before = json(ok(old, ['dashboard-data'])); + assert.deepEqual(before.ooda.learning, []); + assert.equal(learnings(F).length, 0, 'dashboard-data wrote a learning record'); + + const t0 = new Date().toISOString(); + ok(old, ['observe-metrics'], SUP); + const rs = learnings(F); + assert.deepEqual(rs.map((r) => r.week), X.weeks, 'one per ended week since the log began, none for this week'); + for (const r of rs) { + assert.equal(r.source_key, `learning:${r.week}`); + assert.equal(r.source, 'metrics'); + assert.equal(r.detector, 'metrics'); + assert.equal(r.subject, 'project'); + assert.ok(r.created_at >= t0, `stamped now, not back-dated: ${r.created_at}`); + assert.ok(r.detail.length <= 300 && r.detail.length > 0); + assert.ok(r.refs.length <= 60); + } + const [a, b, c] = rs; + assert.deepEqual(a.figures, { + informed: { knowledge: 1, signals_explained: 1, signals_formed: 1, starts_given: 1, starts: 1 }, + autonomous: { trusted_calls: 1, ladder_calls: 2, questions: 0, merged: 0 }, + trusted: { agreed: 0, answered: 0, corrections: 0, calls: 3 }, + }); + assert.deepEqual(b.figures, { + informed: { knowledge: 1, signals_explained: 1, signals_formed: 1, starts_given: 0, starts: 1 }, + autonomous: { trusted_calls: 0, ladder_calls: 0, questions: 1, merged: 0 }, + trusted: { agreed: 1, answered: 1, corrections: 1, calls: 2 }, + }); + assert.deepEqual(c.figures, { + informed: { knowledge: 0, signals_explained: 1, signals_formed: 1, starts_given: 0, starts: 0 }, + autonomous: { trusted_calls: 1, ladder_calls: 1, questions: 0, merged: 0 }, + trusted: { agreed: 0, answered: 0, corrections: 0, calls: 1 }, + }); + // refs: the corrections, accepted knowledge and explained signals counted + assert.ok(a.refs.includes(X.k1) && a.refs.includes(X.o1), JSON.stringify(a.refs)); + assert.ok(b.refs.includes(X.d1), 'the correction counted'); + assert.ok(!c.refs.includes(X.k1), 'retired by then'); + + // a second pass writes none + ok(old, ['observe-metrics'], SUP); + assert.equal(learnings(F).length, 3); + + // the dashboard reads them, oldest first + const D = json(ok(old, ['dashboard-data'])); + assert.deepEqual(D.ooda.learning.map((r) => r.week), X.weeks); + assert.deepEqual(D.ooda.learning[1].figures, b.figures); + assert.ok(D.ooda.learning.every((r) => r.id && r.at && r.detail)); + assert.equal(learnings(F).length, 3); + } finally { F.done(); } +}); From f25d1b6df8ddbdec52d6749a6b62c3f154c3a2fd Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Wed, 11 Nov 2026 14:55:57 -0500 Subject: [PATCH 2/4] The Loop's learning chart, and what each week counted Six small charts above the Loop's tabs, two per column (Informed, Autonomous, Trusted), one point per weekly learning record, hollow where the denominator is under five, each opening its week; retros and rung changes are dashed vertical marks. lineChart takes an optional third element per point and xmarks, so its other callers draw as before. Under three records the Loop shows totals since the log began instead, since a trend from two points means nothing. The learning sidepeek shows the figures with every record each counted (recomputed from the log as it stood at the week's end, since the line's refs are capped) and lesson efficacy as lists and counts. Co-Authored-By: Claude Opus 5.5 --- docs/devteam/dashboard/index.html | 103 ++++++++++++++++++++++++++++-- docs/devteam/scripts/devteam.mjs | 30 ++++++++- test/dashboard.test.mjs | 3 + test/ooda-learning.test.mjs | 81 ++++++++++++++++++++++- 4 files changed, 209 insertions(+), 8 deletions(-) diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index 2e0b3c4..9847c5c 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -326,6 +326,12 @@ .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; } + .learning { margin-bottom: 14px; } + .learn-cols { display: grid; gap: 12px; grid-template-columns: repeat(3, minmax(0, 1fr)); } + @media (max-width: 860px) { .learn-cols { grid-template-columns: minmax(0, 1fr); } } + .learn-chart h4 { font-size: 12px; font-weight: 500; margin: 0 0 4px; color: var(--fg-2); } + .learn-chart .card { margin-bottom: 2px; padding: 8px 10px; } + .learn-chart p { margin: 0 0 10px; } .diagram-wrap { overflow-x: auto; margin: 4px 0 8px; } .diagram-wrap figure { margin: 0; } .md.tldr { font-size: 13px; } @@ -622,6 +628,7 @@

Links

Loop how the supervisor notices, makes sense of, decides and acts

+
@@ -2002,6 +2009,9 @@

${esc(g.title return empty(ask ? `No goals yet. The supervisor has asked for them: answer it in the Inbox.` : 'No goals yet. The supervisor asks the owner for them first (process/planning.md §1).'); })()); + // Is it learning: the chart above the tabs + $('learning').innerHTML = learningSection(o); + // Observe $('observations').innerHTML = o.observations.length ? o.observations.slice(0, 40).map((x) => `
${esc(words(x.kind))}${subjectLink(x.subject)} @@ -2438,10 +2448,13 @@

${esc(g.title 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('')}
` : ''); +/* learning start */ // A small time chart: one line per series over time, optional horizontal marks (targets). // span: [from, to] in ms to fix the x axis (a sensor's last 24h); width: the drawing's own width, -// so a narrow card draws a narrow chart with readable text; a series may name its color. -function lineChart(series, { marks = [], unit = '', height = 160, yMin = 0, span = null, width = 640 } = {}) { +// so a narrow card draws a narrow chart with readable text; a series may name its color. A point +// is [ts, value] or [ts, value, { hollow, href, title }]: hollow for too few to say, href to open +// what it counts. xmarks: [{ at, label, href }] draws dashed vertical lines (a retro, a rung change). +function lineChart(series, { marks = [], xmarks = [], unit = '', height = 160, yMin = 0, span = null, width = 640 } = {}) { const pts = series.flatMap((s) => s.points); if (!pts.length) return ''; const W = width, H = height, L = 40, R = 12, T = 12, B = 22; @@ -2467,10 +2480,69 @@

${esc(g.title series.forEach((s, i) => { const ps = s.points.slice().sort((a, b) => a[0] - b[0]); svg += ``; - if (ps.length <= 60) for (const p of ps) svg += `${esc(`${s.name}: ${p[1]}${unit} · ${fmtDate(p[0])}`)}`; + const c = s.color || `var(--series-${(i % 4) + 1}, var(--blue))`; + if (ps.length <= 60) for (const p of ps) { + const o = p[2] || {}; + const dot = `${esc(o.title ?? `${s.name}: ${p[1]}${unit} · ${fmtDate(p[0])}`)}`; + svg += o.href ? `${dot}` : dot; + } }); + for (const m of xmarks) { + const ts = Date.parse(m.at); + if (!(ts >= t0 && ts <= t1)) continue; + const X = x(ts).toFixed(1); + const mark = `${esc(`${m.label} · ${fmtDate(ts)}`)}`; + svg += m.href ? `${mark}` : mark; + } return `
${series.length > 1 ? `
${series.map((s, i) => `${esc(s.name)}`).join('')}
` : ''}${svg}
`; } +// Is the loop making the system smarter? Six small charts, two per column (Informed, Autonomous, +// Trusted), from the weekly learning records, oldest first. Three columns, never one score: a +// correction in one class shows as a dip, not averaged away by a promotion in another. A count and +// a share never share a chart. A point whose denominator is under LEARN_MIN is hollow: too few to say. +const LEARN_MIN = 5; +const learnShare = (a, b) => (b ? Math.round((100 * a) / b) : 0); +const LEARN_CHARTS = [ + ['Informed', [ + { title: 'Accepted knowledge', note: 'lessons, facts and rules in force at the week\'s end', series: [{ name: 'accepted knowledge', v: (f) => f.informed.knowledge }] }, + { title: 'Explained, and given', note: 'recurring signals explained, of those formed; fresh starts given a lesson', unit: '%', series: [ + { name: 'signals explained', v: (f) => learnShare(f.informed.signals_explained, f.informed.signals_formed), n: (f) => f.informed.signals_formed }, + { name: 'starts given a lesson', v: (f) => learnShare(f.informed.starts_given, f.informed.starts), n: (f) => f.informed.starts }, + ] }, + ]], + ['Autonomous', [ + { title: 'Ladder calls made on trust', note: 'of the calls in a trust-ladder class, the ones made on its rung or a standing rule, not asked', unit: '%', series: [{ name: 'on trust', v: (f) => learnShare(f.autonomous.trusted_calls, f.autonomous.ladder_calls), n: (f) => f.autonomous.ladder_calls }] }, + { title: 'Questions per merged ticket', note: 'decisions put to you, for each ticket merged', series: [{ name: 'questions per merged ticket', v: (f) => +(f.autonomous.questions / Math.max(1, f.autonomous.merged)).toFixed(2), n: (f) => f.autonomous.questions, few: (f) => !f.autonomous.merged }] }, + ]], + ['Trusted', [ + { title: 'Agreement', note: 'your answers that went with the recommendation', unit: '%', series: [{ name: 'agreement', v: (f) => learnShare(f.trusted.agreed, f.trusted.answered), n: (f) => f.trusted.answered }] }, + { title: 'Corrections per 100 calls', note: 'calls you corrected, for every hundred the supervisor made', series: [{ name: 'corrections per 100 calls', v: (f) => +((100 * f.trusted.corrections) / Math.max(1, f.trusted.calls)).toFixed(1), n: (f) => f.trusted.calls }] }, + ]], +]; +function learningSection(o) { + const recs = o.learning || []; + const T = o.learning_totals || {}; + const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`; + if (recs.length < 3) { + const since = T.since ? fmtDate(Date.parse(T.since)) : 'the start'; + return `
${[['calls', T.calls], ['lessons kept', T.lessons], ['corrections', T.corrections]].map(([k, v]) => `
${esc(`${k} since ${since}`)}${esc(v ?? 0)}
`).join('')}
+

Since ${esc(since)}: ${esc(plural(T.calls ?? 0, 'call'))}, ${esc(plural(T.lessons ?? 0, 'lesson'))} kept, ${esc(plural(T.corrections ?? 0, 'correction'))}. The loop needs a few weeks of calls, answers and lessons before trends mean anything.

`; + } + const xmarks = (o.learning_marks || []).map((m) => ({ at: m.at, label: m.label, href: m.href })); + const span = [Date.parse(recs[0].from), Date.parse(recs[recs.length - 1].from) + 7 * DAY]; + const chart = (c) => { + const series = c.series.map((s, i) => ({ name: s.name, color: i ? 'var(--amber)' : 'var(--series-1)', points: recs.map((r) => { + const v = s.v(r.figures); + const n = s.n ? s.n(r.figures) : null; + const few = (n !== null && n < LEARN_MIN) || (s.few && s.few(r.figures)); + return [Date.parse(r.from), v, { hollow: few, href: `#learning/${encodeURIComponent(r.id)}`, title: few ? `${s.name}, week ${r.week}: too few to say (n = ${n})` : `${s.name}, week ${r.week}: ${v}${c.unit || ''}${n !== null ? ` (n = ${n})` : ''}` }]; + }) })); + return `

${esc(c.title)}

${lineChart(series, { unit: c.unit || '', height: 120, width: 360, span, xmarks })}

${esc(c.note)}

`; + }; + return `
${LEARN_CHARTS.map(([col, charts]) => `

${esc(col)}

${charts.map(chart).join('')}
`).join('')}
+

Weekly, from each week's learning record: open a point for what it counted. A hollow point is too few to say (under ${LEARN_MIN}). Dashed lines are retros and rung changes.

`; +} +/* learning end */ // 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); @@ -3073,15 +3145,34 @@

Its observations

${items.length ? `

`)); + // one hollow point: week 2's signals explained, formed only twice + assert.equal((h.match(/data-hollow/g) || []).length, 1); + assert.match(h, /signals explained, week 2026-W42: too few to say \(n = 2\)/); + for (const r of recs) assert.ok(h.includes(`href="#learning/${encodeURIComponent(r.id)}"`), 'each point opens its week'); + // the rung change marks every chart; one outside the span marks none + assert.equal((h.match(/href="#decision\/d-x"/g) || []).length, 6); + assert.doesNotMatch(h, /#retro\/o-y/); + // lineChart's old callers are unchanged: a plain [ts, v] point is a filled dot with its own title + const plain = lineChart([{ name: 'p50', points: [[Date.UTC(2026, 0, 1), 3], [Date.UTC(2026, 0, 3), 4]] }], { unit: 'ms' }); + assert.match(plain, /]*fill="var\(--series-1, var\(--blue\)\)">p50: 3ms/); + assert.doesNotMatch(plain, /<a |data-hollow|xmark/); +}); From b5a7aad6b80f630dd994e575576b09ccbcea2453 Mon Sep 17 00:00:00 2001 From: Hugh Francis <hughfrancis89@gmail.com> Date: Wed, 11 Nov 2026 15:04:53 -0500 Subject: [PATCH 3/4] Say how to read the weekly learning record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit supervisor.md §3 says when observe-metrics writes a week's record, what its three columns count, and where to look at what a week counted; operator.md's daily review points at the same records for judging each supervisor. The log-tolerance test's stand-in for a newer kit's field moves off figures, which an observation now has; the G1 golden gains only the Loop's new keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/devteam/process/operator.md | 2 ++ docs/devteam/process/supervisor.md | 1 + docs/devteam/scripts/devteam.mjs | 2 +- test/fixtures/g1/dashboard-data.txt | 7 +++++++ test/log-tolerance.test.mjs | 9 +++++---- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/devteam/process/operator.md b/docs/devteam/process/operator.md index 9fdb6a9..1ac645c 100644 --- a/docs/devteam/process/operator.md +++ b/docs/devteam/process/operator.md @@ -67,6 +67,8 @@ Start by writing `## Review <today, yyyy-mm-dd, UTC>` in `~/operator/NOTES.md`; | Is it climbing the trust ladder, or asking what it could decide? | `trust`, `questions --all --json` (`class`, `why_asked`) | `trust` says the evidence supports a trust question and the last retro asked none; a question in a class the constitution makes the supervisor's own, or at rung 3 where it could act and report; most of its questions resting on `low-confidence` alone | | Do kit frictions recur? | your own notes, retros' "Why" lines, observations about the kit | the same friction in two repos, or twice in one (What could work better) | +Each repo's weekly counts help with the first three: `log --type observation --json` holds one record of kind `learning` per ended week, with its `week` and `figures` (informed, autonomous, trusted). Compare the last few weeks rather than one; a count under five says little. + **Signals.** Some of what you see is a symptom, and relaying it is the failure this review exists to catch. Each of these gets a "why?" answered before anything goes in `NOTES.md`, at the review and at any check-in that sees one: - a supervisor asking the owner in chat instead of with an Inbox question; - a goal whose latest reading is at or past its target but whose status isn't `met` (`goal --json`: `status`, and the last of `readings` against `measure.target` in the way `measure.direction` says); diff --git a/docs/devteam/process/supervisor.md b/docs/devteam/process/supervisor.md index 5641cb8..1478924 100644 --- a/docs/devteam/process/supervisor.md +++ b/docs/devteam/process/supervisor.md @@ -49,6 +49,7 @@ A reading (a sensor's daily rollup, a goal reading, a `spend` line, a `gate` run **Improve and record** 10. `devteam.mjs retro --due`: if due, draft it now (§10) and finish it on the next quiet tick, or first thing once the draft is a day old. On quiet ticks run the improvement loop (§9). + - **Is the loop learning?** Once a week has ended (UTC), `observe-metrics` writes it as one observation of kind `learning` (`source_key: learning:<yyyy-Www>`, with its `week` and `figures`); on its first run it back-fills every past week since the log began, each counted from the log as it stood at that week's end. Never write one yourself. The figures are counts in three columns: informed (accepted knowledge in force; recurring signals explained, of those formed; fresh starts given a lesson), autonomous (ladder calls made on trust, of all ladder calls; decisions put to the owner; tickets merged), trusted (answers that went with your recommendation, of those that had one; corrections, of your calls). The Loop charts them, hollow where fewer than five were counted; `peek learning <id> --json` lists every record a week counted and, for each lesson a start was given, what its evidence's signals did in the four weeks before and since. At a retro, read the weeks since the last one: a share falling, or corrections rising, is a signal to orient on, not a score to defend. 11. Notify the owner only on a new blocking question or something they must act on now. 12. `devteam.mjs log-sync` (pushes the chore-log branch; merge it with `merge --chore log` once a day, after the tick's merges, and before merging a plan that names a newly approved goal). 13. Spend is measured from every agent's session log (settings `usage_sources`: `claude-code` and `codex` are built in; `usage_command` adds any other harness; `model_prices` prices a model the kit doesn't know). An `unpriced-model` finding means spend undercounts until the owner adds a price. diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 051bea2..2ece807 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -15843,7 +15843,7 @@ Observe and orient observe --subject s --kind k --detail d [--source owner-chat|owner-answer|owner-thread|retro|…] [--severity 0-4] [--valence -2..2] [--key k] [--refs ids] [--fix f] [--value n --unit u] observe-link --url u | --path p --title t [--for scope…] [--notes n] [--owner] a resource the work uses; orient proposes it for the registry wait [--minutes n] supervisor, between ticks: blocks until a new message to you, a new owner line, a new high finding, or the tick is due (tick_minutes, default 15); an open high finding returns at once until acted on or acked - observe-metrics | observe-spend | orient [--json] | metrics [--since date] [--json] (observe-metrics records spend lines too) + observe-metrics | observe-spend | orient [--json] | metrics [--since date] [--json] (observe-metrics records spend lines too, and each ended week's learning record) ack <finding-kind> <subject> --reason r [--owner] set a finding aside for finding_ack_hours timeline <ticket-id> [--json] the ticket and every log line, review and file that bears on it knowledge [--status s] [--kind k] [--kit] | knowledge --for <ticket|feature|class> | knowledge add|set … [--kit [on|off]] (--kit: the supervisor's tag on its own entry, "this would hold for any project", for the box's operator; it grants nothing) diff --git a/test/fixtures/g1/dashboard-data.txt b/test/fixtures/g1/dashboard-data.txt index fdfcc80..ab313d3 100644 --- a/test/fixtures/g1/dashboard-data.txt +++ b/test/fixtures/g1/dashboard-data.txt @@ -918,6 +918,13 @@ exit 0 } ], "answered": [], + "learning_totals": { + "calls": 5, + "lessons": 1, + "corrections": 0 + }, + "learning_marks": [], + "learning": [], "log": { "lines": 14, "errors": 0, diff --git a/test/log-tolerance.test.mjs b/test/log-tolerance.test.mjs index 0b63d11..5e666a2 100644 --- a/test/log-tolerance.test.mjs +++ b/test/log-tolerance.test.mjs @@ -85,10 +85,11 @@ test('every kind of line takes a newer kit\'s field: new lines, owner lines, pla try { const at = () => new Date().toISOString(); const sup = path.join(old, 'docs/devteam/run/log', `supervisor-${month()}.jsonl`); - // an unknown field on a new line, and a prototype name: both stripped and listed + // an unknown field on a new line, and a prototype name: both stripped and listed (not figures, + // which an observation now has) const oid = ok(old, ['observe', '--subject', 'app', '--kind', 'note', '--detail', 'seed'], SUP).trim(); const nid = `${oid.replace(/-\d{9}-supervisor$/, '')}-${String(Number(oid.split('-')[2]) + 1).padStart(9, '0')}-supervisor`; - fs.appendFileSync(sup, line({ id: nid, type: 'observation', op: 'new', at: at(), by: 'supervisor', source: 'metrics', subject: 'app', kind: 'note', detail: 'from a newer kit', figures: { n: 1 }, constructor: 1 })); + fs.appendFileSync(sup, line({ id: nid, type: 'observation', op: 'new', at: at(), by: 'supervisor', source: 'metrics', subject: 'app', kind: 'note', detail: 'from a newer kit', insights: { n: 1 }, constructor: 1 })); // a set line with only unknown fields changes nothing const pid = ok(old, ['problem', 'add', '--title', 'slow page', '--severity', '1'], SUP).trim(); fs.appendFileSync(sup, line({ id: pid, type: 'problem', op: 'set', at: at(), by: 'supervisor', figures: { n: 2 } })); @@ -103,10 +104,10 @@ test('every kind of line takes a newer kit\'s field: new lines, owner lines, pla fs.appendFileSync(plan, line({ id: 'pay-01-start', type: 'ticket', op: 'new', at: at(), by: 'plan-pay', title: 'start', feature: 'pay', component: 'app', kind: 'build', size: 'S', risk: 'normal', serves: [], depends_on: [], touches: ['src/**'], done_when: ['its test passes in CI'], figures: { n: 4 } })); const r = run(old, ['log-check']); assert.equal(r.status, 0, r.err || r.out); - for (const want of [/observation\.figures, constructor/, /problem\.figures/, /owner-.*problem\.figures/, /plan-pay\.jsonl.*ticket\.figures/]) assert.match(r.out, want); + for (const want of [/observation\.insights, constructor/, /problem\.figures/, /owner-.*problem\.figures/, /plan-pay\.jsonl.*ticket\.figures/]) assert.match(r.out, want); const o = json(ok(old, ['log', '--id', nid, '--json']))[0]; assert.equal(o.detail, 'from a newer kit'); - assert.ok(!Object.hasOwn(o, 'figures') && !Object.hasOwn(o, 'constructor')); + assert.ok(!Object.hasOwn(o, 'insights') && !Object.hasOwn(o, 'constructor')); assert.equal(json(ok(old, ['log', '--id', pid, '--json']))[0].status, 'owned', 'the owner\'s known field applies'); assert.ok(json(ok(old, ['log', '--id', 'pay-01-start', '--json']))[0], 'the plan file\'s ticket folds'); assert.equal(json(ok(old, ['dashboard-data'])).ooda.log.unknown, 4, 'the dashboard counts what a newer kit wrote'); From bb28753c035fbbaa537701b1da2f7993757f7651 Mon Sep 17 00:00:00 2001 From: Hugh Francis <hughfrancis89@gmail.com> Date: Wed, 11 Nov 2026 15:26:16 -0500 Subject: [PATCH 4/4] The weekly learning record fits its line, back-fills two years, and is written once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refs are trimmed from the end until the line fits LINE_MAX (writer names are unbounded), so the corrections, first, survive and appendLog never dies before observeFindings - the back-fill reaches back at most LEARN_BACKFILL_WEEKS (104) - writeLearning runs under a lock in run/ and reads the log afresh; the dashboard keeps the earliest record per week - only the supervisor's own record holds a week, as the dashboard counts them - the schema's figures objects are open to counts a later kit adds (spec §8) - the dashboard leaves out a record whose three columns aren't objects - questions per merged ticket is hollow on its merges, "no ticket merged" at none Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/devteam/dashboard/index.html | 10 ++- docs/devteam/process/supervisor.md | 2 +- docs/devteam/schema/log.schema.json | 12 +-- docs/devteam/scripts/devteam.mjs | 59 ++++++++++----- .../plans/2026-11-11-ooda-loop-inspectable.md | 3 +- test/ooda-learning.test.mjs | 74 ++++++++++++++++++- 6 files changed, 125 insertions(+), 35 deletions(-) diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index 52c849e..7aae8cf 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -2512,15 +2512,17 @@ <h4><a class="title-link" href="#goal/${encodeURIComponent(g.id)}">${esc(g.title ]], ['Autonomous', [ { title: 'Ladder calls made on trust', note: 'of the calls in a trust-ladder class, the ones made on its rung or a standing rule, not asked', unit: '%', series: [{ name: 'on trust', v: (f) => learnShare(f.autonomous.trusted_calls, f.autonomous.ladder_calls), n: (f) => f.autonomous.ladder_calls }] }, - { title: 'Questions per merged ticket', note: 'decisions put to you, for each ticket merged', series: [{ name: 'questions per merged ticket', v: (f) => +(f.autonomous.questions / Math.max(1, f.autonomous.merged)).toFixed(2), n: (f) => f.autonomous.questions, few: (f) => !f.autonomous.merged }] }, + { title: 'Questions per merged ticket', note: 'decisions put to you, for each ticket merged', series: [{ name: 'questions per merged ticket', v: (f) => +(f.autonomous.questions / Math.max(1, f.autonomous.merged)).toFixed(2), n: (f) => f.autonomous.merged, none: 'no ticket merged' }] }, ]], ['Trusted', [ { title: 'Agreement', note: 'your answers that went with the recommendation', unit: '%', series: [{ name: 'agreement', v: (f) => learnShare(f.trusted.agreed, f.trusted.answered), n: (f) => f.trusted.answered }] }, { title: 'Corrections per 100 calls', note: 'calls you corrected, for every hundred the supervisor made', series: [{ name: 'corrections per 100 calls', v: (f) => +((100 * f.trusted.corrections) / Math.max(1, f.trusted.calls)).toFixed(1), n: (f) => f.trusted.calls }] }, ]], ]; +// a record whose three columns aren't objects (a hand-written line) is left out, not drawn +const learnable = (r) => !!r && !!r.figures && ['informed', 'autonomous', 'trusted'].every((k) => r.figures[k] && typeof r.figures[k] === 'object'); function learningSection(o) { - const recs = o.learning || []; + const recs = (o.learning || []).filter(learnable); const T = o.learning_totals || {}; const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`; if (recs.length < 3) { @@ -2534,8 +2536,8 @@ <h4><a class="title-link" href="#goal/${encodeURIComponent(g.id)}">${esc(g.title const series = c.series.map((s, i) => ({ name: s.name, color: i ? 'var(--amber)' : 'var(--series-1)', points: recs.map((r) => { const v = s.v(r.figures); const n = s.n ? s.n(r.figures) : null; - const few = (n !== null && n < LEARN_MIN) || (s.few && s.few(r.figures)); - return [Date.parse(r.from), v, { hollow: few, href: `#learning/${encodeURIComponent(r.id)}`, title: few ? `${s.name}, week ${r.week}: too few to say (n = ${n})` : `${s.name}, week ${r.week}: ${v}${c.unit || ''}${n !== null ? ` (n = ${n})` : ''}` }]; + const few = n !== null && n < LEARN_MIN; + return [Date.parse(r.from), v, { hollow: few, href: `#learning/${encodeURIComponent(r.id)}`, title: few ? `${s.name}, week ${r.week}: ${n === 0 && s.none ? s.none : `too few to say (n = ${n})`}` : `${s.name}, week ${r.week}: ${v}${c.unit || ''}${n !== null ? ` (n = ${n})` : ''}` }]; }) })); return `<div class="learn-chart" data-chart="${esc(c.title)}"><h4>${esc(c.title)}</h4>${lineChart(series, { unit: c.unit || '', height: 120, width: 360, span, xmarks })}<p class="small muted">${esc(c.note)}</p></div>`; }; diff --git a/docs/devteam/process/supervisor.md b/docs/devteam/process/supervisor.md index fc55f96..1bb9581 100644 --- a/docs/devteam/process/supervisor.md +++ b/docs/devteam/process/supervisor.md @@ -49,7 +49,7 @@ A reading (a sensor's daily rollup, a goal reading, a `spend` line, a `gate` run **Improve and record** 10. `devteam.mjs retro --due`: if due, draft it now (§10) and finish it on the next quiet tick, or first thing once the draft is a day old. On quiet ticks run the improvement loop (§9). - - **Is the loop learning?** Once a week has ended (UTC), `observe-metrics` writes it as one observation of kind `learning` (`source_key: learning:<yyyy-Www>`, with its `week` and `figures`); on its first run it back-fills every past week since the log began, each counted from the log as it stood at that week's end. Never write one yourself. The figures are counts in three columns: informed (accepted knowledge in force; recurring signals explained, of those formed; fresh starts given a lesson), autonomous (ladder calls made on trust, of all ladder calls; decisions put to the owner; tickets merged), trusted (answers that went with your recommendation, of those that had one; corrections, of your calls). The Loop charts them, hollow where fewer than five were counted; `peek learning <id> --json` lists every record a week counted and, for each lesson a start was given, what its evidence's signals did in the four weeks before and since. At a retro, read the weeks since the last one: a share falling, or corrections rising, is a signal to orient on, not a score to defend. + - **Is the loop learning?** Once a week has ended (UTC), `observe-metrics` writes it as one observation of kind `learning` (`source_key: learning:<yyyy-Www>`, with its `week` and `figures`); on its first run it back-fills every past week since the log began (up to two years), each counted from the log as it stood at that week's end. Never write one yourself. The figures are counts in three columns: informed (accepted knowledge in force; recurring signals explained, of those formed; fresh starts given a lesson), autonomous (ladder calls made on trust, of all ladder calls; decisions put to the owner; tickets merged), trusted (answers that went with your recommendation, of those that had one; corrections, of your calls). The Loop charts them, hollow where fewer than five were counted; `peek learning <id> --json` lists every record a week counted and, for each lesson a start was given, what its evidence's signals did in the four weeks before and since. At a retro, read the weeks since the last one: a share falling, or corrections rising, is a signal to orient on, not a score to defend. 11. Notify the owner only on a new blocking question or something they must act on now. 12. `devteam.mjs log-sync` (pushes the chore-log branch; merge it with `merge --chore log` once a day, after the tick's merges, and before merging a plan that names a newly approved goal). 13. Spend is measured from every agent's session log (settings `usage_sources`: `claude-code` and `codex` are built in; `usage_command` adds any other harness; `model_prices` prices a model the kit doesn't know). An `unpriced-model` finding means spend undercounts until the owner adds a price. diff --git a/docs/devteam/schema/log.schema.json b/docs/devteam/schema/log.schema.json index 3eece0f..83878f1 100644 --- a/docs/devteam/schema/log.schema.json +++ b/docs/devteam/schema/log.schema.json @@ -859,8 +859,7 @@ "starts": { "type": "integer", "minimum": 0, "description": "Workers and planners started fresh in the week." }, "starts_given": { "type": "integer", "minimum": 0, "description": "Of those, the ones whose prompt carried at least one lesson." } }, - "required": ["knowledge", "signals_formed", "signals_explained", "starts", "starts_given"], - "additionalProperties": false + "required": ["knowledge", "signals_formed", "signals_explained", "starts", "starts_given"] }, "autonomous": { "description": "Autonomous: how much the supervisor did alone.", @@ -872,8 +871,7 @@ "questions": { "type": "integer", "minimum": 0, "description": "Decisions put to the owner in the week (decisions, one-way doors, proposals, trust, clarifications)." }, "merged": { "type": "integer", "minimum": 0, "description": "Tickets merged in the week." } }, - "required": ["ladder_calls", "trusted_calls", "questions", "merged"], - "additionalProperties": false + "required": ["ladder_calls", "trusted_calls", "questions", "merged"] }, "trusted": { "description": "Trusted: how often the owner agreed, and corrected.", @@ -885,12 +883,10 @@ "calls": { "type": "integer", "minimum": 0, "description": "The supervisor's calls made in the week." }, "corrections": { "type": "integer", "minimum": 0, "description": "Corrections the owner made in the week." } }, - "required": ["answered", "agreed", "calls", "corrections"], - "additionalProperties": false + "required": ["answered", "agreed", "calls", "corrections"] } }, - "required": ["informed", "autonomous", "trusted"], - "additionalProperties": false + "required": ["informed", "autonomous", "trusted"] }, "sha": { "description": "A reading of a repo measure: the commit on main its check ran on.", diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 078e4dd..b7bc158 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -8185,14 +8185,18 @@ function learningFigures(week, merges = mergeTimes()) { } // When each ticket merged (ms), from git, once per read of the whole log: a back-fill asks every week. const mergeTimes = () => memo('mergeTimes', () => [...tickets().keys()].map((id) => [id, mergedAt(id)]).filter(([, at]) => at)); -// The record's line: its counts, one sentence of them, and refs to what it counted (at most 60: -// the corrections first, then the explained signals, then the knowledge in force). +// The record's line: its counts, one sentence of them, and refs to what it counted (at most 60, +// and fewer when long ids would take the line past LINE_MAX: the corrections first, then the +// explained signals, then the knowledge in force, so the ones dropped are from the end). function learningLine(r) { const { informed: I, autonomous: A, trusted: T } = r.figures; const n = (k, one, many = `${one}s`) => `${k} ${k === 1 ? one : many}`; const detail = `Week ${r.week}: ${n(I.knowledge, 'lesson')} in force, ${I.signals_explained} of ${n(I.signals_formed, 'recurring signal')} explained, ${I.starts_given} of ${n(I.starts, 'start')} given a lesson; ${A.trusted_calls} of ${n(A.ladder_calls, 'ladder call')} made on trust, ${n(A.questions, 'question')} to the owner for ${n(A.merged, 'merged ticket')}; ${T.agreed} of ${n(T.answered, 'recommendation')} agreed, ${n(T.corrections, 'correction')} in ${n(T.calls, 'call')}.`; + const base = { type: 'observation', source: 'metrics', detector: 'metrics', subject: 'project', kind: 'learning', week: r.week, figures: r.figures, detail: detail.slice(0, 300), source_key: `learning:${r.week}` }; const refs = [...new Set([...r.lists.corrections, ...r.lists.signals.filter((s) => s.explained).map((s) => s.by[0]), ...r.lists.knowledge])].slice(0, KNOWLEDGE_IDS_MAX); - return { type: 'observation', source: 'metrics', detector: 'metrics', subject: 'project', kind: 'learning', week: r.week, figures: r.figures, detail: detail.slice(0, 300), source_key: `learning:${r.week}`, refs }; + // 250: what appendLog adds (op, id, at, by) + while (refs.length && JSON.stringify({ ...base, refs }).length + 250 > LINE_MAX) refs.pop(); + return { ...base, refs }; } // Lesson efficacy, as the log stood at cut: for each lesson a start's prompt carried, the // observations on the keys of its evidence in the four weeks before it was first given and since. @@ -8215,22 +8219,39 @@ function lessonEfficacy(cut) { }); } // The metrics pass (supervisor only): one learning record for each ISO week since the log's -// earliest line that has ended and has none yet (any observation with its source_key). A -// back-filled week's record is stamped now (records can't be back-dated) and names its week. -// Every week is counted before any is written, so each count folds the log once. +// earliest line (back to LEARN_BACKFILL_WEEKS at most) that has ended and has no record of the +// supervisor's yet, as the dashboard counts them: an owner's or hand line with the source_key +// doesn't stand in for one. A back-filled week's record is stamped now (records can't be +// back-dated) and names its week. Every week is counted before any is written, so each count +// folds the log once; under a lock, read afresh, so two passes at once don't both write a week. +const LEARN_BACKFILL_WEEKS = 104; +const isLearningRecord = (o) => o.kind === 'learning' && o.created_by === 'supervisor' && o.detector === 'metrics'; +// The records the dashboard draws, oldest week first: one per week, the earliest written (two +// passes at once could each have written one), and only one whose three columns are objects, so +// a malformed line is left out, not drawn. +function learningRecords() { + const byWeek = new Map(); + for (const o of logObjects('observation').filter((x) => isLearningRecord(x) && /^\d{4}-W\d{2}$/.test(x.week ?? '') && ['informed', 'autonomous', 'trusted'].every((k) => x.figures?.[k] && typeof x.figures[k] === 'object')).sort((a, b) => a.week.localeCompare(b.week) || byTime(a, b))) if (!byWeek.has(o.week)) byWeek.set(o.week, o); + return [...byWeek.values()]; +} function writeLearning() { - const log = readLog(); - const first = log.lines.reduce((m, l) => (l.at < m ? l.at : m), log.lines[0]?.at ?? ''); - const start = Date.parse(first); - if (!Number.isFinite(start)) return 0; - const have = new Set(logObjects('observation').map((o) => o.source_key).filter((k) => k?.startsWith('learning:'))); - const weeks = []; - for (let t = mondayOf(start), cur = mondayOf(Date.now()); t < cur; t += WEEK) if (!have.has(`learning:${isoWeekOf(t)}`)) weeks.push(isoWeekOf(t)); - if (!weeks.length) return 0; - const merges = mergeTimes(); - const lines = weeks.map((w) => learningLine(learningFigures(w, merges))); - for (const l of lines) appendLog(l, 'supervisor'); - return lines.length; + fs.mkdirSync(runDir, { recursive: true }); + return withLock(runPath('learning'), () => { + logCache = null; + const log = readLog(); + const first = log.lines.reduce((m, l) => (l.at < m ? l.at : m), log.lines[0]?.at ?? ''); + const start = Date.parse(first); + if (!Number.isFinite(start)) return 0; + const have = new Set(logObjects('observation').filter(isLearningRecord).map((o) => o.source_key).filter((k) => k?.startsWith('learning:'))); + const weeks = []; + const cur = mondayOf(Date.now()); + for (let t = Math.max(mondayOf(start), cur - LEARN_BACKFILL_WEEKS * WEEK); t < cur; t += WEEK) if (!have.has(`learning:${isoWeekOf(t)}`)) weeks.push(isoWeekOf(t)); + if (!weeks.length) return 0; + const merges = mergeTimes(); + const lines = weeks.map((w) => learningLine(learningFigures(w, merges))); + for (const l of lines) appendLog(l, 'supervisor'); + return lines.length; + }, { staleMs: 300000, waitMs: 310000 }); } // One value as a POSIX shell word: single-quoted, each ' written '\''. Every value orient puts in a // command it proposes goes through this, since observation text is anyone's to write and the @@ -10112,7 +10133,7 @@ function collectOoda() { // changes mark the chart, so cause sits next to effect learning_totals: { since: log.lines.reduce((m, l) => (l.at < m ? l.at : m), log.lines[0]?.at ?? '') || null, calls: calls().length, lessons: logObjects('knowledge').filter((k) => k.status === 'accepted').length, corrections: calls().filter(correctionOf).length }, learning_marks: [...logObjects('observation').filter((x) => x.kind === 'retro' && x.created_by === 'supervisor').map((x) => ({ at: x.created_at, kind: 'retro', label: 'retro', href: `#retro/${encodeURIComponent(x.id)}` })), ...trust.flatMap((t) => t.history.map((e) => ({ at: e.at, kind: 'rung', label: `${t.class} to rung ${e.rung}`, href: hrefOf(logGet(e.id)) })))].filter((m) => m.at).sort((a, b) => a.at.localeCompare(b.at)), - learning: logObjects('observation').filter((o) => o.kind === 'learning' && o.week && o.figures && o.created_by === 'supervisor').sort((a, b) => a.week.localeCompare(b.week) || byTime(a, b)).map((o) => ({ id: o.id, week: o.week, at: o.created_at, from: new Date(weekStartOf(o.week)).toISOString(), figures: o.figures, detail: o.detail })), + learning: learningRecords().map((o) => ({ id: o.id, week: o.week, at: o.created_at, from: new Date(weekStartOf(o.week)).toISOString(), figures: o.figures, detail: o.detail })), log: { lines: log.lines.length, errors: log.errors.length, ignored: log.ignored.length, unknown: log.unknown.length }, }; } diff --git a/docs/superpowers/plans/2026-11-11-ooda-loop-inspectable.md b/docs/superpowers/plans/2026-11-11-ooda-loop-inspectable.md index e053768..35509ab 100644 --- a/docs/superpowers/plans/2026-11-11-ooda-loop-inspectable.md +++ b/docs/superpowers/plans/2026-11-11-ooda-loop-inspectable.md @@ -357,7 +357,7 @@ Branch `ooda-learning` off `ooda-knowledge`. **Adds two observation fields** (`f ### Task 1: `learningFigures(cut)` and the weekly record -**Files:** schema `observation.properties.figures` (object: `informed: { knowledge, signals_explained, signals_formed, starts_given, starts }`, `autonomous: { trusted_calls, ladder_calls, questions, merged }`, `trusted: { agreed, answered, corrections, calls }`, all integers; `additionalProperties: false`) and `observation.properties.week` (`"^\\d{4}-W\\d{2}$"`); `devteam.mjs`: `logAt(cut)` (fold only lines with `at <= cut`, reusing `fold`; cached per cut), `learningFigures(cut)` computing every count from `logAt(cut)` and the week before it, `writeLearning()` called from `cmdObserveMetrics` (supervisor only): for each ISO week since the log's first line that has ended and has no observation with `source_key: learning:<week>` (checked over all observations), append `{ type: 'observation', source: 'metrics', detector: 'metrics', subject: 'project', kind: 'learning', week, figures, detail: '<one sentence of the numbers>', source_key: 'learning:<week>', refs: <≤60 ids: the corrections, accepted lessons and explained signals counted> }`. Counts, not percentages, are stored; shares are computed in the view. +**Files:** schema `observation.properties.figures` (object: `informed: { knowledge, signals_explained, signals_formed, starts_given, starts }`, `autonomous: { trusted_calls, ladder_calls, questions, merged }`, `trusted: { agreed, answered, corrections, calls }`, all integers, each object open to keys a later kit adds) and `observation.properties.week` (`"^\\d{4}-W\\d{2}$"`); `devteam.mjs`: `logAt(cut)` (fold only lines with `at <= cut`, reusing `fold`; cached per cut), `learningFigures(cut)` computing every count from `logAt(cut)` and the week before it, `writeLearning()` called from `cmdObserveMetrics` (supervisor only): for each ISO week since the log's first line that has ended and has no observation with `source_key: learning:<week>` (checked over all observations), append `{ type: 'observation', source: 'metrics', detector: 'metrics', subject: 'project', kind: 'learning', week, figures, detail: '<one sentence of the numbers>', source_key: 'learning:<week>', refs: <≤60 ids: the corrections, accepted lessons and explained signals counted> }`. Counts, not percentages, are stored; shares are computed in the view. - [ ] Tests: a fixture log spanning three weeks (hand lines with past `at`) gets three learning records on the first `observe-metrics`, none on the second (idempotent), each with `week` and today's `at`; figures match hand-counted values for a small fixture; `dashboard-data` never writes one. - [ ] Commit "The weekly learning record". @@ -390,3 +390,4 @@ Branch `ooda-learning` off `ooda-knowledge`. **Adds two observation fields** (`f 6. **`lineChart`:** extend compatibly. A point may be `[ts, v, { hollow, href, title }]` (hollow: `fill="none"` with a stroke; href: wrapped in `<a>`), and a new option `xmarks: [{ at, label }]` draws vertical dashed lines. Don't mix a count and a share on one chart: six small charts, two per column (Informed: accepted knowledge; share of signals explained. Autonomous: share of ladder calls on trust; questions per merged ticket. Trusted: agreement; corrections per 100 calls). 7. **Incident debt:** an outcome citing it = `refs.includes(p.id)`; resolution time = the last `updates` entry setting `status: 'resolved'`; exclude an incident whose status the owner set (`ownerHolds(p, 'status')`); 30 days from resolution; a live noise decision clears it, so a corrected one re-lists it. 8. Backfill starts at the week of the log's earliest `at` and skips the current week; the `source_key` check scans all observations. `detail` is cut to 300 characters. There are no `decide` commands in `orient`'s output today: the `--on` hint goes in the debt line's suggested `knowledge add` and in the decision sidepeek. Hand-written test lines go in time order. Keep CLI assertions on `ooda.learning`'s shape and the under-three tile rule, since CI has no Chrome. +9. **PR 5 review fixes, second round** (these win over items 5 and 8 and Task 1): `signals_explained` also counts a signal a live noise ruling covers (`covered()`), as Orient does. The record's `refs` are trimmed from the end until the line fits `LINE_MAX` (writer names are unbounded), so the corrections, first, survive. The back-fill reaches back at most `LEARN_BACKFILL_WEEKS` (104, two years), and runs under a lock in `run/` (`withLock`), reading the log afresh, so two passes at once don't both write a week; the dashboard keeps the earliest record per week anyway. A week counts as written only by the supervisor's own record (`created_by` supervisor, `detector` metrics), as the dashboard shows it: an owner's or hand line with the `source_key` doesn't hold it. The schema's `figures` objects keep `required` and integer types but no `additionalProperties: false`, so a later kit can add a count (spec §8); the dashboard leaves out a record whose three columns aren't objects. Questions per merged ticket is hollow on its merges (`merged` under 5; "no ticket merged" at none). diff --git a/test/ooda-learning.test.mjs b/test/ooda-learning.test.mjs index deb749e..8c95ea3 100644 --- a/test/ooda-learning.test.mjs +++ b/test/ooda-learning.test.mjs @@ -32,7 +32,7 @@ function handFor(F) { return id; }; } -const learnings = (F) => json(F.ok(F.old, ['log', '--type', 'observation', '--json'])).filter((o) => o.kind === 'learning').sort((a, b) => a.week.localeCompare(b.week)); +const learnings = (F) => json(F.ok(F.old, ['log', '--type', 'observation', '--tail', '1000', '--json'])).filter((o) => o.kind === 'learning').sort((a, b) => a.week.localeCompare(b.week)); // Three past weeks, A, B and C, with something to count in each; the current week is never counted. function threeWeeks(F) { @@ -164,7 +164,7 @@ test('the Loop draws tiles for a young instance, then six charts with hollow poi const ctx = vm.createContext({}); vm.runInContext(`const DAY = 864e5; const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const fmtDate = (ms) => new Date(ms).toISOString().slice(0, 10);\n${src}\n;globalThis.api = { learningSection, lineChart };`, ctx); const { learningSection, lineChart } = ctx.api; - const figs = (n) => ({ informed: { knowledge: 2, signals_explained: 1, signals_formed: n, starts_given: 3, starts: 6 }, autonomous: { trusted_calls: 4, ladder_calls: 8, questions: 6, merged: 4 }, trusted: { agreed: 9, answered: 10, corrections: 1, calls: 50 } }); + const figs = (n) => ({ informed: { knowledge: 2, signals_explained: 1, signals_formed: n, starts_given: 3, starts: 6 }, autonomous: { trusted_calls: 4, ladder_calls: 8, questions: 6, merged: 6 }, trusted: { agreed: 9, answered: 10, corrections: 1, calls: 50 } }); const rec = (i, n) => ({ id: `o-2026110${i}-000000000-supervisor`, week: `2026-W4${i}`, from: new Date(Date.UTC(2026, 9, 5 + 7 * i)).toISOString(), at: '2026-11-11T00:00:00.000Z', figures: figs(n), detail: 'x' }); const totals = { since: '2026-10-20T01:00:00.000Z', calls: 7, lessons: 1, corrections: 2 }; @@ -186,8 +186,78 @@ test('the Loop draws tiles for a young instance, then six charts with hollow poi // the rung change marks every chart; one outside the span marks none assert.equal((h.match(/href="#decision\/d-x"/g) || []).length, 6); assert.doesNotMatch(h, /#retro\/o-y/); + // questions per merged ticket counts against the merges: a week with none is hollow, and says so + const dry = rec(2, 6); + dry.figures.autonomous.merged = 0; + const hd = learningSection({ learning: [rec(1, 6), dry, rec(3, 6)], learning_totals: totals, learning_marks: [] }); + assert.equal((hd.match(/data-hollow/g) || []).length, 1); + assert.match(hd, /questions per merged ticket, week 2026-W42: no ticket merged/); + // a hand-written record without its three columns is left out, never a crash + const bad = { ...rec(4, 6), figures: { informed: 1 } }; + const hb = learningSection({ learning: [...recs, bad, { ...rec(5, 6), figures: null }], learning_totals: totals, learning_marks: [] }); + assert.equal((hb.match(/<svg/g) || []).length, 6); + assert.ok(!hb.includes(encodeURIComponent(bad.id)), 'the malformed record is not drawn'); // lineChart's old callers are unchanged: a plain [ts, v] point is a filled dot with its own title const plain = lineChart([{ name: 'p50', points: [[Date.UTC(2026, 0, 1), 3], [Date.UTC(2026, 0, 3), 4]] }], { unit: 'ms' }); assert.match(plain, /<circle [^>]*fill="var\(--series-1, var\(--blue\)\)"><title>p50: 3ms/); assert.doesNotMatch(plain, /<a |data-hollow|xmark/); }); + +// Review fixes: a line that would be too long, a log from long ago, a week's record only the +// supervisor's own, and two records for one week. +test("a learning record's refs are trimmed from the end to fit a log line; the corrections survive", () => { + const F = fleetFixture('ooda-learning-long'); + const { old, ok } = F; + try { + const hand = handFor(F); + const A = mondayOf(Date.now()) - 14 * DAY; + const w = `${'t'.repeat(56)}-a`; + const d = hand('supervisor', { type: 'decision', class: 'priority-change', what: 'raised the build fix', authority: 'default' }, A + HOUR); + hand('owner', { type: 'decision', op: 'set', id: d, corrected: { reason: 'not that one' } }, A + 2 * HOUR); + // sixty recurring signals, each on its own key from a ticket with a long name (so each id is + // long), each explained by a lesson citing its first observation + const firsts = []; + for (let i = 0; i < 60; i++) { + const os = [0, 1, 2].map((j) => hand(w, { type: 'observation', source: 'repo', subject: `app${i}`, kind: 'note', detail: `slow ${i}.${j}` }, A + 3 * HOUR + j * DAY + i * 1000)); + firsts.push(os[0]); + } + for (let i = 0; i < 6; i++) hand('owner', { type: 'knowledge', kind: 'lesson', statement: `Lesson ${i}`, scope: 'project', status: 'accepted', evidence: firsts.slice(10 * i, 10 * i + 10) }, A + 3 * DAY + i * 1000); + ok(old, ['observe-metrics'], SUP); + const [a] = learnings(F); + assert.equal(a.week, isoWeek(A)); + assert.equal(a.figures.informed.signals_explained, 60); + assert.ok(a.refs.length > 0 && a.refs.length < 60, `trimmed: ${a.refs.length}`); + assert.equal(a.refs[0], d, 'the correction first, and kept'); + const raw = fs.readdirSync(path.join(old, 'docs/devteam/run/log')).flatMap((f) => fs.readFileSync(path.join(old, 'docs/devteam/run/log', f), 'utf8').split('\n')).find((l) => l.includes('"kind":"learning"')); + assert.ok(Buffer.byteLength(raw) <= 4096, `${Buffer.byteLength(raw)} bytes`); + } finally { F.done(); } +}); + +test('a log from long ago back-fills two years at most; a hand line with the key holds no week; the dashboard shows one record a week', () => { + const F = fleetFixture('ooda-learning-old'); + const { old, ok } = F; + try { + const hand = handFor(F); + const cur = mondayOf(Date.now()); + hand('supervisor', { type: 'observation', source: 'repo', subject: 'app', kind: 'note', detail: 'long ago' }, cur - 160 * 7 * DAY); + const last = isoWeek(cur - 7 * DAY); + const prev = isoWeek(cur - 14 * DAY); + // an owner's line with last week's key, and a malformed supervisor one: neither is a record + hand('owner', { type: 'observation', source: 'metrics', subject: 'project', kind: 'learning', week: last, source_key: `learning:${last}`, detail: 'mine' }, cur - 2 * DAY); + ok(old, ['observe-metrics'], SUP); + const rs = learnings(F).filter((r) => r.created_by === 'supervisor'); + assert.equal(rs.length, 104, 'two years, not three'); + assert.equal(rs[0].week, isoWeek(cur - 104 * 7 * DAY)); + assert.ok(rs.some((r) => r.week === last), 'the owner line did not stand in for the week'); + // a second record for a week (two passes at once), and a malformed one: the dashboard shows the earliest, once + hand('supervisor', { type: 'observation', source: 'metrics', detector: 'metrics', subject: 'project', kind: 'learning', week: prev, source_key: `learning:${prev}`, detail: 'again', figures: (({ informed, ...f }) => ({ ...f, informed: { ...informed, lessons_cited: 2 }, later: { n: 1 } }))(rs.find((r) => r.week === prev).figures) }, Date.now() + 1000); + // a count a later kit adds is no error (the spec, §8) + ok(old, ['log-check']); + hand('supervisor', { type: 'observation', source: 'metrics', detector: 'metrics', subject: 'project', kind: 'learning', week: '2020-W01', source_key: 'learning:2020-W01', detail: 'bad', figures: { informed: 1 } }, Date.now() + 2000); + const D = json(ok(old, ['dashboard-data'])); + assert.equal(D.ooda.learning.length, 104); + assert.equal(D.ooda.learning.filter((r) => r.week === prev).length, 1); + assert.equal(D.ooda.learning.find((r) => r.week === prev).id, rs.find((r) => r.week === prev).id, 'the earliest'); + assert.ok(!D.ooda.learning.some((r) => r.week === '2020-W01')); + } finally { F.done(); } +});