From 4ab1aeb2600f63721bea79094bd7aab7c29266ae Mon Sep 17 00:00:00 2001 From: hhff Date: Tue, 3 Nov 2026 16:26:02 -0500 Subject: [PATCH 1/2] Lessons name their evidence; a goal's questions include those about its tickets; carried-out answers are acts The Stacks lessons all read 'based on 0 things it noticed': the supervisor proposed them without --evidence and the kit allowed it. An agent's lesson now needs --evidence with log ids that exist; the card links them, or says 'with no evidence recorded' for older ones. The case-study review was answered and its 82 links written to production, but the goal's panel never showed it: the question named no goal and the write was never logged. The goal panel now also lists questions about or holding up a ticket in the goal's features; ask warns when a question names no goal, ticket or blocks; supervisor.md says to tag questions with --goal and to log an answer carried out outside a merge as a carry-answer act naming it. Co-Authored-By: Claude Opus 5.5 --- docs/devteam/dashboard/index.html | 6 ++++-- docs/devteam/process/supervisor.md | 4 +++- docs/devteam/scripts/devteam.mjs | 10 +++++++++- test/kit.test.mjs | 2 ++ 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index 5a3423b..06b9e6c 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -1685,7 +1685,9 @@

History

const r = R.goals.find((x) => x.id === id); const feats = (r?.features || []).map((f) => R.features[f]).filter(Boolean); const week = (d) => (d ? `week of ${new Date(`${d}T00:00:00Z`).toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })}` : 'no date'); - const asks = [...(DATA.questions || []), ...(DATA.ooda.answered || [])].filter((q) => q.goal === id || (q.kind === 'goal-definition' && q.question.toLowerCase().includes(String(g.title).toLowerCase().split(':')[0]))); + // the goal's questions: asked for it, or about (or holding up) a ticket in one of its features + const goalTickets = new Set((DATA.progress?.tickets || []).filter((t) => feats.some((f) => f.feature === t.feature)).map((t) => t.id)); + const asks = [...(DATA.questions || []), ...(DATA.ooda.answered || [])].filter((q) => q.goal === id || goalTickets.has(q.ticket) || (q.blocks || []).some((b) => goalTickets.has(b)) || (q.kind === 'goal-definition' && q.question.toLowerCase().includes(String(g.title).toLowerCase().split(':')[0]))); const design = s.ask?.links?.[0] ?? (['ready', 'met'].includes(g.status) && g.check.design ? { title: 'The design', path: `docs/devteam/${g.check.design}` } : null); const decisions = (g.updates || []).filter((u) => u.decision).map((u) => u.decision); showDrawer(`

${esc(g.title)}

@@ -2044,7 +2046,7 @@

History

${cardHead('lightbulb', 'Lesson to keep?', { extra: `applies to ${esc(scopeWords(k.scope))}`, id: k.id, rows: [['Proposed', esc(when(k.created_at))], ['By', esc(who(k.created_by))]] })}

${linkify(k.statement)}

-

${esc(cap(who(k.created_by)))} proposes this as a ${esc(k.kind)}${k.confidence != null ? `, ${Math.round(k.confidence * 100)}% sure` : ''}, based on ${(k.evidence || []).length} thing${(k.evidence || []).length === 1 ? '' : 's'} it noticed. Accepted lessons go into the instructions of every agent they apply to.

+

${esc(cap(who(k.created_by)))} proposes this as a ${esc(k.kind)}${k.confidence != null ? `, ${Math.round(k.confidence * 100)}% sure` : ''}, ${(k.evidence || []).length ? `based on ${k.evidence.map((e) => linkify(e)).join(', ')}` : 'with no evidence recorded'}. Accepted lessons go into the instructions of every agent they apply to.

`).join(''); for (const qid of [...ANSWERED.keys()]) if (!d.questions.some((q) => q.id === qid)) ANSWERED.delete(qid); diff --git a/docs/devteam/process/supervisor.md b/docs/devteam/process/supervisor.md index a97170d..713b193 100644 --- a/docs/devteam/process/supervisor.md +++ b/docs/devteam/process/supervisor.md @@ -122,12 +122,14 @@ devteam.mjs ask --kind one-way-door --class new-dependency --reasons owner-only `check-pr` and `merge` hold the PR to that record. A `result.json` must hash to the owner's answer line for its question. A file a live question pinned may be absent at the head, or must still hash to its question line. A review file on main never changes and is never deleted. The forge's check reads only the landed log, so it refuses a result whose answer is not in that log yet; land the log, then re-run it. A squash merge that deletes the branch leaves the pinned commit on no branch; the review still verifies from the files on main. `metrics` raises `review-unlanded` for an answered result that is still not on main after `stale_after_minutes` × 48 (`run/` is not backed up). It raises `review-file-missing` for a page whose commit is gone and whose files are not on main. -Rules: one decision per question (a goal-definition question lists all of a goal's gaps as a form); lead with the decision; always a recommendation with your confidence; `--default` only when reversible, applied at the deadline; `--blocks` every ticket that waits; `--class` and `--reasons` always, so the owner sees why it needs them. +Rules: `--goal ` on every question that serves a goal, and `--ticket`/`--blocks` for the work it's about, so the goal and the board show it; one decision per question (a goal-definition question lists all of a goal's gaps as a form); lead with the decision; always a recommendation with your confidence; `--default` only when reversible, applied at the deadline; `--blocks` every ticket that waits; `--class` and `--reasons` always, so the owner sees why it needs them. **Never ask the owner the same decision twice.** If they have answered it (in the inbox, on the dashboard, or in chat, recorded with `answer --from-chat`), use that answer. When a kit change makes an earlier answer look insufficient, carry it forward instead of re-asking: for a goal change answered before questions recorded their change, `goal set --approved --change '{…}'` with the change they chose, which is logged as a `carry-answer` act they can correct; for anything else, cite the answer (`--approved`, `--evidence`) and say so in the act. If the kit truly can't take the answer, raise it upstream rather than asking again. **Ask everything that is the owner's.** Every decision you lack the trust to make goes in the inbox, including every question a worker raised that you can't rule on yourself; nothing is held back to keep the inbox short. Every question to the owner is an `ask`, even one you also raise in your own session ("run this command, or tell me to …"): a question that exists only in your chat never reaches the Inbox, the dashboard or the owner's notifications, and the ticket it holds up shows as blocked on nothing. Ask it first, then mention it in chat with its id. Make each one easy to answer: the recommendation first, options, what it blocks, a default where the class allows one. Combine questions only when one answer settles them all. One answer approves one act; a question that settles several acts (two gate changes, a chore and a ticket) names them when asked, `ask --approves |plan-> …`, so the owner's card lists them and the answer approves each once (`merge` the one it names, or `act --refs `) and nothing else. An answer asked without `--approves` can't be stretched over a second act: ask again for it. Trust questions only at a retro. Withdraw what no longer matters (`withdraw --reason …`). If fewer than half of the questions old enough to answer were answered in 14 days, the metrics raise `inbox-stalled`: remind the owner what is blocked and open a problem. +**Carrying out an answer outside a merge** (writing data the owner approved in a review, a change in another system): when it's done, log it as an act that names the answer (`act --class carry-answer --refs -- ""`), so the owner sees the answer was acted on and what it changed. + **The owner answers** from the dashboard or their own terminal: `answer "" [--autonomy 1|2|3 [--scope s]] [--rule ""]`. An answer equal to your recommendation counts as agreement (trust evidence); `--autonomy` sets how you handle the next decision of that class; `--rule` becomes an accepted preference in knowledge for the whole project (`--rule-scope class:` or `component:` narrows it). Your pane receives only the question id; read the answer from the log. **When the owner tells you in your own session.** The owner often answers in the conversation instead of the inbox ("go ahead", "you don't need to ask me about upstream PRs"). Chat is not the record, so put it in the log: if no question covers it, `ask` one that does (the decision, your recommendation, the class), then `answer "" --from-chat --said "" [--autonomy n]`. It is recorded as the owner's answer on channel `chat`, with their words quoted, and listed on the dashboard for them to check. Quote; never paraphrase into something stronger, and never record a chat answer the owner didn't give. A chat answer never counts as agreement evidence for the ladder, can't carry a standing rule (`--rule`), can lower a class's rung but never raise it, and can't answer what is reserved to the owner (one-way doors, trust questions, the `owner_only` classes such as `process-edit` and `new-dependency`): for those, tell the owner the question id and ask them to answer from the dashboard or their terminal. Then relay it to the workers it affects (`send "" --kind answer`), and when you act on it, cite it (`act --approved `). A correction (`correct ` from the owner) drops its class to rung 1; record what you did about it with `repair --how undo|fix-ticket|none`. diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index a318b87..2951140 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -2221,6 +2221,7 @@ function cmdAsk() { for (const r of reasons) if (known.length && !known.includes(r)) warn(`reason "${r}" is not in constitution.md's table`); const blocks = asList(flags.blocks); for (const b of blocks) if (!tickets().has(b)) die(`--blocks ${b}: no such ticket`); + if (!flagStr('goal') && !flagStr('ticket') && !blocks.length && !['trust', 'proposal'].includes(kind)) warn('this question names no --goal, --ticket or --blocks: when it serves a goal, add --goal so the goal shows it'); // A question that asks about several acts names them up front; the owner's card lists them, and // the answer approves each once and nothing else (constitution.md §3, one answer, one act). const approves = [...new Set(asList(flags.approves).flatMap((x) => String(x).split(',')).map((x) => x.trim()).filter(Boolean))]; @@ -3302,7 +3303,14 @@ function cmdKnowledge() { const by = () => (flags.owner ? ownerWriter().by : (supervisorOnly('knowledge'), 'supervisor')); if (sub === 'add') { const w = by(); - const line = appendLog({ type: 'knowledge', kind: flagStr('kind') ?? die('knowledge add needs --kind (fact, lesson, preference, constraint, pointer)'), statement: flagStr('statement') ?? die('knowledge add needs --statement'), scope: flagStr('scope') ?? 'project', status: w === 'owner' ? 'accepted' : 'candidate', confidence: flagNum('confidence'), valence: flagNum('valence'), evidence: asList(flags.evidence) }, w); + // An agent's lesson is proposed from what it saw: the log entries it rests on (observations, + // acts, questions, messages, problems) go with it, so the owner can check them before keeping it. + const evidence = asList(flags.evidence).flatMap((x) => String(x).split(',')).map((x) => x.trim()).filter(Boolean); + if (w !== 'owner' && flagStr('kind') === 'lesson') { + if (!evidence.length) die('a lesson names what it rests on: --evidence (the observations, acts, questions or messages that show it)'); + for (const e of evidence) if (!logGet(e)) die(`--evidence ${e}: no such log entry`); + } + const line = appendLog({ type: 'knowledge', kind: flagStr('kind') ?? die('knowledge add needs --kind (fact, lesson, preference, constraint, pointer)'), statement: flagStr('statement') ?? die('knowledge add needs --statement'), scope: flagStr('scope') ?? 'project', status: w === 'owner' ? 'accepted' : 'candidate', confidence: flagNum('confidence'), valence: flagNum('valence'), evidence }, w); return out(line.id); } if (sub === 'set') { diff --git a/test/kit.test.mjs b/test/kit.test.mjs index bfb185d..e9fc428 100644 --- a/test/kit.test.mjs +++ b/test/kit.test.mjs @@ -636,6 +636,8 @@ test('devteam kit', async (t) => { must(['observe', '--subject', 'bill', '--kind', 'slow-test', '--key', 'slow:x', '--detail', 'sup', '--valence', '-1'], { role: 'sup' }); assert.equal(json(must(['orient', '--json'])).length, 1, 'repeated keys cluster'); const ids = json(must(['log', '--type', 'observation', '--json'])).map((o) => o.id); + assert.match(dt(['knowledge', 'add', '--kind', 'lesson', '--statement', 'mock smtp', '--scope', 'component:bill'], { role: 'sup' }).err, /a lesson names what it rests on/); + assert.match(dt(['knowledge', 'add', '--kind', 'lesson', '--statement', 'mock smtp', '--evidence', 'o-20200101-000000000-nobody'], { role: 'sup' }).err, /no such log entry/); const K = must(['knowledge', 'add', '--kind', 'lesson', '--statement', 'mock smtp', '--scope', 'component:bill', '--evidence', ...ids], { role: 'sup' }); must(['knowledge', 'set', K, '--status', 'accepted'], { role: 'sup' }); assert.match(must(['knowledge', '--for', 'bill-01-card']), /mock smtp/); From acc03694a052993830a3793bdaa08495c59c30c5 Mon Sep 17 00:00:00 2001 From: hhff Date: Tue, 3 Nov 2026 16:35:37 -0500 Subject: [PATCH 2/2] Dashboard: Retros in the nav, each retro peekable by its id A retro is an object: the observation that closes it (its durable id), its write-up (project/retros/.md) and what it decided (--refs). dashboard-data now lists every retro (ooda.retro.all). A Retros nav section shows whether one is due and why, then every retro newest first; #retro/ opens the summary, the write-up, what it decided in words (a lesson's statement, an act's sentence), and the one before and after. The Goals header links the latest. Co-Authored-By: Claude Opus 5.5 --- docs/devteam/dashboard/index.html | 50 +++++++++++++++++++++++++++++-- docs/devteam/scripts/devteam.mjs | 5 +++- test/kit.test.mjs | 7 +++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index 06b9e6c..3b318bf 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -113,6 +113,9 @@ .row-inline input[type=text] { flex: 1; min-width: 180px; } /* the row spaces its buttons; a solid button's own top margin would sit it lower than an outlined one */ .row-inline > button { margin: 0; } + a.card.retro-row { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 12px; color: var(--fg); text-decoration: none; margin-bottom: 8px; font-size: 12.5px; } + a.card.retro-row:hover { background: var(--hover); } + a.card.retro-row span { color: var(--fg-2); } label { display: inline-flex; align-items: center; gap: 6px; } /* tables */ @@ -390,6 +393,7 @@ Progress Board Loop + Retros Health



@@ -476,6 +480,12 @@

Knowledge

+
+

Retros the supervisor's look back at how the work went, across every goal: what merged, what was slow or rejected, what it changed

+
+
+
+

Health what the supervisor is watching and working on; anything it needs from you comes to your Inbox

@@ -1547,7 +1557,7 @@

${esc(g.title const R = o.roadmap; const week = (d) => { if (!d) return 'no date'; const x = new Date(`${d}T00:00:00Z`); x.setUTCDate(x.getUTCDate() - ((x.getUTCDay() + 6) % 7)); return `week of ${x.toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })}`; }; const rt = o.retro; - $('retro').innerHTML = `${rt.last ? 'retro ' : ''}${rt.last ? `${esc(rt.last.at.slice(0, 10))}: ${esc(rt.last.summary)}${rt.last.file ? ` (${esc(rt.last.file)})` : ''}` : 'no retro yet'}${rt.due ? ` · due ${esc((rt.due_since || '').slice(0, 10))}${esc(rt.reasons.join('; '))}` : ''}`; + $('retro').innerHTML = `${rt.last ? `retro ${esc(retroDate(rt.last))}: ${esc(rt.last.summary)}` : 'no retro yet'}${rt.due ? ` · due ${esc((rt.due_since || '').slice(0, 10))}${esc(rt.reasons.join('; '))}` : ''}`; $('unassigned').innerHTML = o.goals.length || R.unassigned.length ? `${R.unassigned.length ? `No goal: ${R.unassigned.map(esc).join(', ')} · ` : ''}${R.rate_points_per_day} pts/day` : ''; const roadmapOf = (gid) => { const r = R.goals.find((x) => x.id === gid); @@ -1809,6 +1819,34 @@

History

${a.correction ? '' : '

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

'}`); } +// A retro's own date is its write-up's (retros/.md); the closing line can come a day later. +const retroDate = (r) => { const m = /(\d{4}-\d{2}-\d{2})\.md$/.exec(r.file || ''); return fmtDate(m ? Date.parse(`${m[1]}T12:00:00Z`) : Date.parse(r.at)); }; +// What a retro decided, in words where the log has them: a lesson's statement, an act's sentence. +const retroRef = (x) => { + const k = (DATA.ooda.knowledge || []).find((y) => y.id === x); + if (k) return `${esc(k.statement)}`; + const a = (DATA.ooda.acts || []).find((y) => y.id === x); + if (a) return `${actView(a).head ?? esc(a.what)} (the act)`; + return linkify(x); +}; +function openRetro(id) { + const all = DATA.ooda.retro?.all || []; + const i = all.findIndex((x) => x.id === id); + const r = all[i]; + if (!r) return showDrawer(`

No retro ${esc(id)}

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

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

+

${linkify(r.summary)}

+ ${file ? `

${esc(file.title)}

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

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

`); +} function openLesson(id) { const k = (DATA.ooda.knowledge || []).find((x) => x.id === id); if (!k) return showDrawer(`

No lesson ${esc(id)}

`); @@ -1954,7 +1992,7 @@

History

if (h.startsWith('q/')) return void openQuestion(h.slice(2)); if (h.startsWith('goal/')) return void openGoal(h.slice(5)); if (h.startsWith('planner/')) return void openPlanner(h.slice(8)); - const PEEKS = { feature: openFeature, story: openStory, release: openRelease, wave: openWave, milestone: openMilestone, act: openAct, lesson: openLesson, problem: openProblem, thread: openThread }; + const PEEKS = { feature: openFeature, story: openStory, release: openRelease, wave: openWave, milestone: openMilestone, act: openAct, retro: openRetro, lesson: openLesson, problem: openProblem, thread: openThread }; 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))); @@ -2013,6 +2051,14 @@

History

count('n-goals', o.goals.length); count('n-questions', d.questions.length + o.knowledge.filter((k) => k.status === 'candidate').length, true); count('n-active', active); + { + const rt = DATA.ooda.retro || {}; + const all = rt.all || []; + count('n-retros', all.length); + if (rt.due) { $('n-retros').textContent = 'due'; $('n-retros').classList.add('hot'); } + $('retro-due').innerHTML = rt.due ? `
due since ${esc((rt.due_since || '').slice(0, 10))} ${esc(rt.reasons.join('; '))}

The supervisor drafts it next (supervisor.md §10).

` : ''; + $('retro-list').innerHTML = all.length ? all.map((r) => `${esc(retroDate(r))}${esc(r.summary)}`).join('') : '

No retro yet.

'; + } count('n-findings', m.findings.length, m.findings.some((f) => f.severity === 'high')); count('n-knowledge', o.knowledge.filter((k) => k.status === 'candidate').length); $('n-observe').textContent = o.observations.length || ''; diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 2951140..7883b97 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -3806,7 +3806,10 @@ function retroDue() { if (first && Date.now() - Date.parse(first) >= every * DAY) { reasons.push(`no retro yet, and work started ${dateOnly(first)}`); since = new Date(Date.parse(first) + every * DAY).toISOString(); } } else if (Date.now() - lastAt >= every * DAY) { reasons.push(`${every} days since the last retro (${dateOnly(last.created_at)})`); since = new Date(lastAt + every * DAY).toISOString(); } for (const f of finishedFeatures()) if (!lastAt || Date.parse(f.finished_at) > lastAt) { reasons.push(`feature ${f.feature} finished ${dateOnly(f.finished_at)}`); if (!since || f.finished_at < since) since = f.finished_at; } - return { due: reasons.length > 0, reasons, due_since: since, last: last ? { at: last.created_at, summary: last.detail, file: last.source_ref ?? null } : null }; + // every closed retro, newest first: its id is the closing observation's, its write-up the file + const all = logObjects('observation').filter((o) => o.kind === 'retro' && o.created_by === 'supervisor').sort(byTime).reverse() + .map((o) => ({ id: o.id, at: o.created_at, summary: o.detail, file: o.source_ref ?? null, refs: o.refs ?? [] })); + return { due: reasons.length > 0, reasons, due_since: since, last: last ? { id: last.id, at: last.created_at, summary: last.detail, file: last.source_ref ?? null } : null, all }; } function retroNumbers(m) { return { diff --git a/test/kit.test.mjs b/test/kit.test.mjs index e9fc428..a0646f3 100644 --- a/test/kit.test.mjs +++ b/test/kit.test.mjs @@ -1287,6 +1287,13 @@ test('devteam kit', async (t) => { assert.ok(d.ooda.acts.some((a) => a.id === A && a.correction?.reason === 'too early' && a.correction.repaired === 'fix-ticket')); assert.equal(d.progress.tickets.length, 1); assert.match(must(['history', 'bill-01-card']), /bill-01-card/); + // a retro is an object: its closing line's id, its write-up, what it decided; the dashboard lists every one + write(path.join(repo, 'docs/devteam/project/retros/2026-01-02.md'), '# Retro\n'); + const R1 = must(['retro', 'close', '2026-01-02', '--summary', 'two things went well', '--refs', A], { role: 'sup' }); + const rt = json(must(['dashboard-data'])).ooda.retro; + assert.deepEqual(rt.all.map((r) => [r.id, r.file, r.refs]), [[R1, 'project/retros/2026-01-02.md', [A]]]); + assert.equal(rt.last.id, R1); + fs.rmSync(path.join(repo, 'docs/devteam/project/retros/2026-01-02.md')); }); await t.test("merge waits for the forge's checks at the head: pending is not passed, failing fails", () => {