diff --git a/README.md b/README.md index df1346d..f80910c 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ docs/devteam/ **Roles.** The **owner** is the human: goals, one-way doors, production, the rules. The **supervisor** is the owner's foreground agent session in the terminal: it plans or makes planners, makes workers, rules, merges, and writes no production code. A **worker** is one session for one ticket, in its own git worktree and tmux window, running superpowers. A **planner** turns one goal into a design, a feature and tickets on a `plan/` branch. A **reviewer** is any session or subagent that did not write the work; its default verdict is not approved. -**Two loops.** Goal → design → roadmap → graph → tickets: the supervisor interviews the owner until each goal is unambiguous, writes a design per goal (the spine that keeps many loops coherent), orders it into milestones, plans one milestone at a time into features with stories and releases, then into a graph of tickets, each one PR. Goals are meant to be large; the forecast says what one costs, and nothing splits it. Observe, orient, decide, act: every supervisor tick, what sessions report becomes observations; recurring observations become knowledge and problems; the supervisor decides what it may and asks the owner everything else through the questions inbox, saying why; every decision is an act the owner can correct, and a correction takes trust back and becomes a lesson. Retros gather the corrections and learnings across tickets and features. +**Two loops.** Goal → design → roadmap → graph → tickets: the supervisor interviews the owner until each goal is unambiguous, writes a design per goal (the spine that keeps many loops coherent), orders it into milestones, plans one milestone at a time into features with stories and releases, then into a graph of tickets, each one PR. Goals are meant to be large; the forecast says what one costs, and nothing splits it. Observe, orient, decide, act: every supervisor tick, what sessions report becomes observations; recurring observations become knowledge and problems; the supervisor decides what it may and asks the owner everything else through the questions inbox, saying why; a judgment call is a decision line that records what was weighed and the trust it rested on (its rung, the owner's answer, a default or a standing rule), and the acts that carry it out cite it; the dashboard's Decide view lists every call, the supervisor's and the owner's, and the Trust Ladder has its own page. The owner corrects a call, and a correction takes trust back and becomes a lesson. Retros gather the corrections and learnings across tickets and features. **One log.** Everything structured is a line in one append-only JSON Lines log, validated against `schema/log.schema.json` on write and folded by timestamp with authority rules (owner > supervisor > planner > worker). State is computed from the log plus git; nothing else is stored. One file per writer: planners write `project/log/plan-.jsonl` in their worktree, so a plan lands only through its reviewed PR; everyone else writes `run/log/.jsonl` live, and the supervisor mirrors those into `project/log/` with the `chore-log` PR. diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index 87f9b8b..7658f57 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -402,6 +402,7 @@ Progress Board Loop + Trust Ladder Retros Health @@ -481,15 +482,29 @@

Knowledge

+
+

Trust Ladder which of your decisions the supervisor may make, and how it earned or lost that

+

Some of your decisions can be handed to the supervisor one step at a time as it keeps recommending what you'd have chosen: first it asks, then it asks with a default, then it decides and tells you. A correction on one of its calls drops that kind back to asking. More trust is always your answer to a question in the Inbox.

+
+

How the rungs moved

+
+
+

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

@@ -805,13 +820,34 @@

Checks before merge the automated const boardLink = (view) => ` What's left of it on the board · its burn-up`; // An id in text links to its panel: goals, lessons, acts, problems, messages (threads), observations. function idLink(k, id) { - const href = { g: 'goal', k: 'lesson', a: 'act', p: 'problem', m: 'thread' }[k]; + const href = { g: 'goal', k: 'lesson', a: 'act', d: 'decision', p: 'problem', m: 'thread' }[k]; if (!href) return `${esc(id.slice(0, 22))}…`; if (k === 'g' && goalOf(id)) return glink(id); return `${esc(id.slice(0, 22))}…`; } const subjectLink = (subj) => (ticketMap().has(subj) ? tlink(subj) : /^[a-z]-\d{8}-\d{9}-/.test(subj ?? '') || /^plan-/.test(subj ?? '') ? linkify(subj) : subj ? `${esc(subj)}` : ''); const scopeWords = (sc) => (!sc || sc === 'project' ? 'the whole project' : sc.replace(/^component:/, 'component ').replace(/^feature:/, 'feature ').replace(/^class:/, 'decisions of kind ').replace(/^chore:devteam-sync/, 'devteam upgrades').replace(/^chore:/, 'chore ')); +// What a call rested on, in words: the supervisor's trust, your answer or a default, a standing rule. +function authorityWords(u) { + if (!u) return ''; + const rung = u.rung ? ` (${esc(RUNG_WORDS[u.rung])}${u.ceiling && u.ceiling !== u.rung ? `, at most ${esc(RUNG_WORDS[u.ceiling])}` : ''})` : ''; + switch (u.authority) { + case 'rung': return `within its trust${rung}`; + case 'answer': return u.approved_by ? `with your answer to ${qlink(u.approved_by)}` : 'with your answer'; + case 'default': return u.approved_by ? `with the default on ${qlink(u.approved_by)}${rung}` : 'with a default'; + case 'standing-rule': return u.approved_by ? `under a standing rule you gave` : 'under a standing rule you gave'; + default: return u.approved_by ? approvedBy(u.approved_by) : 'under the constitution'; + } +} +const dlink = (id, text) => `${text ? esc(text) : 'a decision'}`; +// What an act carried out: a decision, your answer, a standing rule, or its own call. +function actUnder(a) { + const u = a.under; + if (!u) return a.approved_by ? approvedBy(a.approved_by) : 'its own call'; + if (u.decision) return `under ${dlink(u.decision, u.what ? `“${u.what.length > 70 ? `${u.what.slice(0, 70)}…` : u.what}”` : null)}${u.corrected ? ' decision corrected' : ''}`; + if (u.approved_by) return approvedBy(u.approved_by); + return u.authority === 'rung' ? 'its own call, within its trust' : 'its own call'; +} const approvedBy = (id) => (id?.startsWith('q-') ? `with your answer to ${qlink(id)}` : id?.startsWith('k-') ? 'under a standing rule you gave' : `approved by ${esc(id)}`); // Escape, then turn ticket ids and question ids into links and full commit hashes into short ones. function linkify(text) { @@ -819,7 +855,7 @@

Checks before merge the automated const ids = [...ticketMap().keys()].sort((a, b) => b.length - a.length); if (ids.length) h = h.replace(new RegExp(`\\b(${ids.map((x) => x.replace(/[-]/g, '\\-')).join('|')})\\b`, 'g'), (m) => tlink(m)); h = h.replace(/\bq-\d{8}-\d{9}-[a-z0-9-]+/g, (m) => qlink(m)); - h = h.replace(/\b([gkapmo])-\d{8}-\d{9}-[a-z0-9]+(?:-[a-z0-9]+)*/g, (m, k) => idLink(k, m)); + h = h.replace(/\b([gkapmod])-\d{8}-\d{9}-[a-z0-9]+(?:-[a-z0-9]+)*/g, (m, k) => idLink(k, m)); h = h.replace(/\bplan-([a-z0-9]+(?:-[a-z0-9]+)*)\b/g, (m, f) => ((DATA?.planners || []).some((x) => x.feature === f) ? `${m}` : m)); h = h.replace(/\b([0-9a-f]{12})[0-9a-f]{28}\b/g, '$1'); h = h.replace(/\b(sanctuarycomputer\/devteam)#(\d+)/g, 'devteam #$2'); @@ -1656,22 +1692,69 @@

${esc(g.title (acc.length ? acc.map((k) => `
${esc(k.kind)}${linkify(k.statement)}${k.valence < 0 ? ' (avoid)' : ''} · applies to ${esc(scopeWords(k.scope))}${k.created_by === 'owner' ? ' · from you' : ''}
`).join('') : '') || empty('None yet. Things noticed more than once become proposed lessons, which you keep or retire in the Inbox.'); - // Decide: trust ladder + // Trust Ladder $('trust-table').innerHTML = 'Kind of decisionNowAt mostAgreed with youCorrected' + o.trust.map((t) => ` ${esc(cap(t.about || words(t.class)))}
${esc(t.class)}
${esc(RUNG_WORDS[t.rung])}${esc(RUNG_WORDS[t.ceiling])}${t.agreement == null ? '–' : `${Math.round(t.agreement * 100)}% of the last ${esc(t.agreement_questions.length)}`}${t.can_ask ? '
ready to ask you for more trust
' : ''}${t.corrections ? `${esc(t.corrections)} time${t.corrections > 1 ? 's' : ''}` : 'never'}`).join(''); + const moves = (o.trust || []).flatMap((t) => (t.history || []).map((e) => ({ ...e, rung: Math.min(e.rung, t.ceiling), class: t.class, about: t.about }))).sort((a, b) => b.at.localeCompare(a.at)).slice(0, 40); + $('trust-history').innerHTML = moves.length ? moves.map((e) => `
${esc(cap(e.about || words(e.class)))}: ${esc(RUNG_WORDS[e.rung])} +
${esc(when(e.at))} · ${e.by === 'correction' ? `a correction on ${linkify(e.id)}: ${esc(e.why)}` : `your answer to ${qlink(e.id)}`}
`).join('') : empty('No moves yet. Every kind of decision is on its starting rung.'); + + // Decide: every call, the supervisor's and yours + renderDecisions(o); + // Act const feed = o.acts; $('acts-feed').innerHTML = feed.length ? feed.map((a) => { const v = actView(a); return `
${v.head}
${v.detail ? `
${v.detail}
` : ''} -
${when(a.created_at)}${a.approved_by ? ` · ${approvedBy(a.approved_by)}` : ' · on its own'}${a.reversible === false ? ' · can’t be undone' : ''}
+
${when(a.created_at)} · ${actUnder(a)}${a.reversible === false ? ' · can’t be undone' : ''}
${a.correction ? `
Corrected by ${esc(who(a.correction.by))}: ${esc(a.correction.reason)}${a.correction.repaired ? ` · ${esc(a.correction.repaired)}` : ''}
` : ''}
- ${a.correction ? '' : ``} -
`; }).join('') : empty('None yet. Every decision the supervisor makes alone shows here, for you to correct.'); + ${a.correction || a.decision ? '' : ``} + `; }).join('') : empty('None yet. Everything the supervisor does shows here.'); +} +const DEC_KIND = { decision: 'Decided', act: 'Did, as its own call', answer: 'You answered', goal: 'Your call on a goal', rule: 'A standing rule you gave', correction: 'Corrected' }; +function decisionUnder(r) { + if (r.who === 'supervisor' && ['decision', 'act'].includes(r.kind)) return authorityWords(r.under); + const u = r.under || {}; + if (r.kind === 'answer') return [u.recommendation ? (u.matched ? 'went with its recommendation' : `its recommendation was “${esc(u.recommendation)}”`) : '', u.autonomy ? `moved ${esc(words(u.autonomy.class))} to ${esc(RUNG_WORDS[u.autonomy.rung])}` : '', u.standing_rule ? `and gave a standing rule: “${esc(u.standing_rule)}”` : '', u.channel === 'chat' ? 'said in the supervisor chat' : ''].filter(Boolean).join(' · '); + if (r.kind === 'correction') return [u.severity != null ? `severity ${esc(u.severity)}` : '', u.repaired ? `repaired: ${esc(u.repaired)}` : 'not repaired yet'].filter(Boolean).join(' · '); + if (r.kind === 'rule') return u.status === 'accepted' ? '' : esc(u.status); + return ''; +} +// A routine act (a merge the gates passed, a worker started, a default applied) is its own call under +// the constitution; it is in the Act tab, and here only on request, so judgment calls stay in view. +const routine = (r) => r.kind === 'act' && r.under?.authority === 'constitution'; +function renderDecisions(o) { + const all = (o.decisions || []).filter((r) => $('dec-mechanical').checked || !routine(r)); + const sel = $('dec-class'); + const cur = sel.value; + const classes = [...new Set(all.map((r) => r.class).filter(Boolean))].sort(); + sel.innerHTML = '' + classes.map((c) => ``).join(''); + sel.value = classes.includes(cur) ? cur : ''; + const fw = $('dec-who').value; + const only = $('dec-corrected').checked; + const rows = all.filter((r) => (!fw || r.who === fw) && (!sel.value || r.class === sel.value) && (!only || r.correction || r.kind === 'correction')); + const key = (r) => `${r.kind}:${r.id}:${r.at}`; + $('decisions-feed').innerHTML = rows.length ? rows.slice(0, 80).map((r) => { + const mine = r.who === 'supervisor' && ['decision', 'act'].includes(r.kind); + const open = r.kind === 'decision' ? `#decision/${encodeURIComponent(r.id)}` : r.kind === 'act' ? `#act/${encodeURIComponent(r.id)}` : r.kind === 'answer' ? `#q/${encodeURIComponent(r.id)}` : r.kind === 'goal' ? `#goal/${encodeURIComponent(r.id)}` : r.kind === 'rule' ? `#lesson/${encodeURIComponent(r.id)}` : r.id.startsWith('d-') ? `#decision/${encodeURIComponent(r.id)}` : `#act/${encodeURIComponent(r.id)}`; + const under = decisionUnder(r); + const evidence = (r.refs || []).length ? `about ${r.refs.slice(0, 4).map((x) => (ticketMap().has(x) ? tlink(x) : linkify(x))).join(', ')}` : ''; + return ` +
+
${esc(DEC_KIND[r.kind] || r.kind)} ${linkify(r.what)}
+ ${r.rationale ? `
${linkify(r.rationale)}${r.options?.length > 1 ? ` (weighed: ${r.options.map(esc).join(' / ')})` : ''}${r.confidence != null ? ` · ${Math.round(r.confidence * 100)}% sure` : ''}
` : ''} +
${esc(when(r.at))} · ${esc(r.who === 'owner' ? 'you' : who(r.who))}${r.class ? ` · ${esc(words(r.class))}` : ''}${under ? ` · ${under}` : ''}${evidence ? ` · ${evidence}` : ''}${r.kind === 'decision' && r.acts.length ? ` · carried out by ${r.acts.length} act${r.acts.length > 1 ? 's' : ''}` : r.kind === 'decision' ? ' · not acted on yet' : ''}${r.reversible === false ? ' · can’t be undone' : ''}
+ ${r.correction ? `
Corrected by ${esc(who(r.correction.by))}: ${esc(r.correction.reason)}${r.correction.repaired ? ` · ${esc(r.correction.repaired)}` : ''}
` : ''} + ${mine ? `` : ''} +
+ ${mine && !r.correction ? `` : ''} +
`; }).join('') : empty(all.length ? 'Nothing matches.' : 'None yet. Every call, the supervisor\'s and yours, shows here.'); } +for (const id of ['dec-who', 'dec-class', 'dec-corrected', 'dec-mechanical']) $(id).addEventListener('change', () => { if (DATA) renderDecisions(DATA.ooda); }); // ---- drawer: a ticket with everything that bears on it, a settled question, or an artifact ---- @@ -1709,7 +1792,8 @@

${esc(g.title case 'gate': return [l.ok ? 'gate' : 'gate fail', `Check “${esc(l.gate)}” ${l.ok ? 'passed' : 'failed'}${l.sha ? ` at ${esc(l.sha.slice(0, 12))}` : ''}`]; case 'message': return ['msg', `${esc(cap(by))} ${esc(MSG_WORDS[l.kind] || l.kind)}${l.to ? ` → ${esc(who(l.to))}` : ''}: ${linkify(l.text)}`]; case 'question': return l.op === 'new' ? ['q', `Asked you: ${qlink(l.id, l.question)}`] : l.answer !== undefined ? ['q', `You answered ${qlink(l.id, 'the question')}: ${esc(l.answer)}`] : l.status ? ['q', `Question ${esc(l.status)}${l.reason ? `: ${linkify(l.reason)}` : ''}`] : null; - case 'act': { if (['add-ticket', 'replan-within-feature'].includes(l.class)) return null; const v = actView(l); return ['act', `${v.head}${v.detail ? `
${v.detail}
` : ''}`]; } + case 'act': { if (['add-ticket', 'replan-within-feature'].includes(l.class)) return null; const v = actView(l); return ['act', `${v.head}${v.detail ? `
${v.detail}
` : ''}${l.decision ? `
under ${dlink(l.decision)}
` : ''}`]; } + case 'decision': return l.op === 'new' ? ['act', `The supervisor decided (${esc(words(l.class))}): ${dlink(l.id, l.what)}${l.rationale ? `
${linkify(l.rationale)}
` : ''}`] : l.corrected ? ['act', `${esc(cap(by))} corrected ${dlink(l.id, 'a decision')}: ${esc(l.corrected.reason)}`] : null; case 'observation': return ['', `${esc(cap(by))} noticed (${esc(words(l.kind))}): ${linkify(l.detail)}`]; case 'knowledge': return ['', `Learned: ${linkify(l.statement)}`]; default: return ['', `${esc(words(l.type))} ${esc(l.op)}`]; @@ -1874,13 +1958,33 @@

History

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

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

'}`); + ${a.correction ? '' : a.decision ? '

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

' : '

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

'}`); +} +function openDecision(id) { + const r = (DATA.ooda.decisions || []).find((x) => x.id === id && ['decision', 'act'].includes(x.kind)); + if (!r) return showDrawer(`

No decision ${esc(id)}

Only recent decisions are on the dashboard.

`); + DRAWER = { kind: 'decision', id }; + showDrawer(`

${linkify(r.what)}

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

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

'}`); } // 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)); }; @@ -1890,6 +1994,8 @@

History

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)`; + const dd = (DATA.ooda.decisions || []).find((y) => y.id === x && y.kind === 'decision'); + if (dd) return `${dlink(x, dd.what)} (the decision)`; return linkify(x); }; function openRetro(id) { @@ -2055,7 +2161,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, retro: openRetro, lesson: openLesson, problem: openProblem, thread: openThread }; + const PEEKS = { feature: openFeature, story: openStory, release: openRelease, wave: openWave, milestone: openMilestone, act: openAct, decision: openDecision, 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))); @@ -2126,7 +2232,9 @@

History

count('n-knowledge', o.knowledge.filter((k) => k.status === 'candidate').length); $('n-observe').textContent = o.observations.length || ''; $('n-orient').textContent = (o.clusters.length + o.problems.length + o.knowledge.length) || ''; - $('n-decide').textContent = o.trust.filter((t) => t.can_ask).length || ''; + const weekAgo = new Date(Date.now() - 7 * 864e5).toISOString(); + $('n-decide').textContent = (o.decisions || []).filter((r) => r.at > weekAgo && r.kind !== 'correction' && !routine(r)).length || ''; + count('n-trust', o.trust.filter((t) => t.can_ask).length); $('n-act').textContent = o.acts.length || ''; const fcAll = forecast(p.tickets, now); const stats = [ @@ -2473,7 +2581,8 @@

${linkify(k.statement)}

if (send) { const id = send.dataset.correctSend; const reason = document.querySelector(`input[data-reason="${id}"]`).value; - return void post('correct', { id, reason }, id); + // the Decide feed's forms are keyed dec:, so a call listed in both feeds has two + return void post('correct', { id: id.replace(/^dec:/, ''), reason }, id); } }); diff --git a/docs/devteam/governance/constitution.md b/docs/devteam/governance/constitution.md index 7a24860..f289086 100644 --- a/docs/devteam/governance/constitution.md +++ b/docs/devteam/governance/constitution.md @@ -58,10 +58,11 @@ Some owner decisions can be handed to the supervisor a step at a time, as eviden } ``` -- **Three kinds of decision.** `classes` are the owner's decisions that can be delegated, each with a start rung and a ceiling. `constitutional` are the supervisor's own; they are logged as acts and a correction of one feeds knowledge, never authority. `owner_only` stay at "ask" whatever the evidence. A project may lower a ceiling in its settings (`autonomy_ceilings`), never raise one; `validate` fails if it does. +- **Three kinds of decision.** `classes` are the owner's decisions that can be delegated, each with a start rung and a ceiling. `constitutional` are the supervisor's own; they are logged as decisions or acts and a correction of one feeds knowledge, never authority. `owner_only` stay at "ask" whatever the evidence. A project may lower a ceiling in its settings (`autonomy_ceilings`), never raise one; `validate` fails if it does. - **Why a question was asked.** Every question names its class and carries `why_asked`: the class's rung and ceiling, and reasons from the table. The dashboard shows it first. -- **Moving up.** Only through the owner. When answering, the owner can say how the next decision of that class is handled (ask, ask with a default, act and report), for the whole class or one scope; it applies at once, up to the ceiling. Or the supervisor asks a `trust` question when, of the last ten answered questions in the class, the owner went with its recommendation nine or more times (an answer equal to the recommendation; defaulted answers don't count); `ask --kind trust` refuses without that evidence, and the question carries those question ids and the uncorrected acts since the class last dropped. At most one open per class, two in all, asked at a retro. The supervisor never moves itself up. -- **Moving down.** Any correction of an act in a ladder class drops the whole class to rung 1 at once. `devteam.mjs act` refuses below rung 3 unless it cites an answered question in the same class (`--approved `); each answer approves one act. A defaulted answer counts at rung 2, not 1. +- **Moving up.** Only through the owner. When answering, the owner can say how the next decision of that class is handled (ask, ask with a default, act and report), for the whole class or one scope; it applies at once, up to the ceiling. Or the supervisor asks a `trust` question when, of the last ten answered questions in the class, the owner went with its recommendation nine or more times (an answer equal to the recommendation; defaulted answers don't count); `ask --kind trust` refuses without that evidence, and the question carries those question ids and the uncorrected calls since the class last dropped. At most one open per class, two in all, asked at a retro. The supervisor never moves itself up. +- **Decisions and acts.** A judgment call is a `decision` line (`devteam.mjs decide`): what was decided, the options weighed, why, and the authority it rested on, snapshotted when it was made (the class's rung and ceiling, and the answer, default or standing rule it cites). Acts that carry it out cite it (`act --decision `), take its class and stay within its refs. A decision made on the class's own rung lapses if the class drops after it. A mechanical act (a merge the gates passed, a worker started for a ready ticket) needs no decision line and is its own decision; so is every act logged before decisions existed. +- **Moving down.** Any correction of a call in a ladder class (a decision, or an act that was its own decision) drops the whole class to rung 1 at once. A correction goes to the call that was wrong: an act that carried out a decision is corrected through the decision. If the decision was sound and the act went wrong, that is a problem or an escaped defect, not a trust drop. `devteam.mjs decide` and `act` refuse below rung 3 unless they cite an answered question in the same class (`--approved `) or a standing rule the owner scoped to that class (`--approved `, scope `class:`); each answer approves one call (a question that declared its acts with `ask --approves` approves one for each). A defaulted answer counts at rung 2, not 1. - **What this is and isn't.** Every session runs as one user, so the rung check is something the supervisor runs, not a wall. The walls are: workers can't merge (`merge` is the only path and checks everything), the log counts a line only in its own writer's file and only within that writer's authority, the agent's deny rules, tag protection (a project that wants the supervisor to release at rung 3 lets its session push `v*` tags), and the forge's required check if the project turned it on. What the ladder adds is visibility: every owner line carries the channel it came from (dashboard or terminal), the dashboard lists terminal answers for the owner to check, and every rung change is on it. ## 3. Principles diff --git a/docs/devteam/process/supervisor.md b/docs/devteam/process/supervisor.md index 1b922ae..f5ea9ba 100644 --- a/docs/devteam/process/supervisor.md +++ b/docs/devteam/process/supervisor.md @@ -37,7 +37,8 @@ Run it about every 10 minutes when PRs are converging, 30 when quiet. Between ti 7. Before a decision in a ladder class, read `knowledge --for ` and `trust`. Below rung 3, ask (§6). A worker's question that is the owner's call becomes a question with `--re ` and reason `escalated`. **Act** -8. Schedule (§5). Merge (`merge `, §5). Log every decision: `act --class [--refs ids] -- ""`; approvals of specs and plans are acts (`approve-spec --refs `, `approve-plan --refs `; the latter refuses while a one-way door on the ticket is unanswered, or while the committed plan has no TL;DR; a door the owner already answered for the ticket's goal is cited with `--approved `, and counts only when the plan review says `- Goal-level door: covers this plan`). On a `risk: normal` ticket, approve a plan from its TL;DR and its plan review (the verdict holds, its findings are dispositioned) unless one of them gives you a reason to read on; on `risk: high`, or a ticket with a one-way door or a contract, read the whole plan; so are rulings (`contract-ruling`, `approve-design-change`), goal readiness, recoveries and trials. `merge`, `ticket add`/`set`, `answer --defaulted` and `withdraw` log their own acts. +8. Schedule (§5). Merge (`merge `, §5). Log every decision; approvals of specs and plans are acts (`approve-spec --refs `, `approve-plan --refs `; the latter refuses while a one-way door on the ticket is unanswered, or while the committed plan has no TL;DR; a door the owner already answered for the ticket's goal is cited with `--approved `, and counts only when the plan review says `- Goal-level door: covers this plan`), and so are rulings (`contract-ruling`, `approve-design-change`), goal readiness, recoveries and trials. On a `risk: normal` ticket, approve a plan from its TL;DR and its plan review (the verdict holds, its findings are dispositioned) unless one of them gives you a reason to read on; on `risk: high`, or a ticket with a one-way door or a contract, read the whole plan. `merge`, `ticket add`/`set`, `answer --defaulted` and `withdraw` log their own acts. + - **Decide, then act.** A judgment call (you weighed options; a reasonable owner could have chosen otherwise) is a decision first: `decide --class --refs [--options "a" "b"] [--rationale "…"] [--confidence 0-1] [--approved ] -- ""` prints a `d-` id and records the trust it rests on. Each act that carries it out cites it: `act --decision -- ""`, within the decision's refs; `merge`, `ticket add`/`set`, `withdraw`, `goal hold`, `worker`, `planner` and `fleet` take `--decision` too when what they act on is in its refs. Something routine (merging what the gates passed, starting a worker for a ready ticket) is an act on its own: `act --class [--refs ids] -- ""`. A standing rule authorises a call only when the owner scoped it to the class (`class:`). A decision made on the class's own rung lapses if the class drops after it: decide again. 9. Health: a ticket silent for `stale_after_minutes` whose pane shows nothing goes to recovery. **Improve and record** @@ -132,7 +133,7 @@ Rules: `--goal ` on every question that serves a goal, and `--ticket`/`--b **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`. +**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, on a decision or on an act that was its own decision) drops its class to rung 1; record what you did about it with `repair --how undo|fix-ticket|none`. An act that carried out a decision isn't corrected itself: if the decision was wrong, the decision is; if it was sound and the act went wrong, open a problem (`problem add --refs `), which is not a trust drop. ## 7. Messages between sessions diff --git a/docs/devteam/schema/log.schema.json b/docs/devteam/schema/log.schema.json index 8a8def8..208b15a 100644 --- a/docs/devteam/schema/log.schema.json +++ b/docs/devteam/schema/log.schema.json @@ -32,6 +32,7 @@ "knowledge", "problem", "question", + "decision", "act", "spend" ] @@ -1244,8 +1245,104 @@ "declined" ] }, + "decision": { + "description": "What the supervisor decided and under what trust: the class and scope, the options it weighed, why, and the authority it acted on, snapshotted when it decided (the class's rung and ceiling, and the owner's answer, default or standing rule it rests on). Acts that carry it out cite it with decision. Trust is earned and lost on decisions: a correction on a decision drops its class to rung 1. The owner, or the supervisor at a retro, sets corrected; the supervisor then sets repaired.", + "type": "object", + "properties": { + "class": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "what": { + "type": "string" + }, + "rationale": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "string" + } + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "refs": { + "type": "array", + "items": { + "type": "string" + } + }, + "reversible": { + "type": "boolean" + }, + "rung": { + "type": "integer", + "minimum": 1, + "maximum": 3 + }, + "ceiling": { + "type": "integer", + "minimum": 1, + "maximum": 3 + }, + "authority": { + "description": "rung: the class's rung let the supervisor act alone; answer: an owner's answer (approved_by) approved it; default: a question's default applied; standing-rule: a knowledge ruling (approved_by k-...) covers it; constitution: a class the ladder doesn't hold, acted on under the constitution's own terms", + "enum": [ + "rung", + "answer", + "default", + "standing-rule", + "constitution" + ] + }, + "approved_by": { + "type": "string", + "pattern": "^[qk]-" + }, + "corrected": { + "type": "object", + "properties": { + "reason": { + "type": "string" + }, + "severity": { + "type": "integer", + "minimum": 0, + "maximum": 4 + } + }, + "required": [ + "reason" + ], + "additionalProperties": false + }, + "repaired": { + "enum": [ + "undo", + "fix-ticket", + "none" + ] + } + }, + "additionalProperties": false, + "x-required-new": [ + "class", + "what", + "authority" + ], + "x-set-only": [ + "corrected", + "repaired" + ] + }, "act": { - "description": "A decision the supervisor made (incl. approve-spec, approve-plan, merge-word, and worker-slots, whose planner acts carry the feature's id_block). The owner, or the supervisor at a retro, sets corrected; the supervisor then sets repaired.", + "description": "Something the supervisor did (incl. approve-spec, approve-plan, merge-word, and worker-slots, whose planner acts carry the feature's id_block). An act that carries decision did what that decision line decided, and the decision answers for it: the owner corrects the decision, not the act. An act without decision is its own decision (mechanical acts, and every act written before decisions existed). The owner, or the supervisor at a retro, sets corrected; the supervisor then sets repaired.", "type": "object", "properties": { "class": { @@ -1274,6 +1371,11 @@ "approved_by": { "type": "string" }, + "decision": { + "description": "The decision line this act carries out (d-...). The act then takes its class and authority from it.", + "type": "string", + "pattern": "^d-" + }, "corrected": { "type": "object", "properties": { @@ -1415,6 +1517,7 @@ "knowledge": "k", "problem": "p", "question": "q", + "decision": "d", "act": "a", "spend": "s" }, @@ -1447,6 +1550,7 @@ "observation", "knowledge", "problem", + "decision", "act", "phase", "message", @@ -1618,6 +1722,15 @@ "threatens" ] }, + "decision": { + "supervisor": [ + "corrected", + "repaired" + ], + "owner": [ + "corrected" + ] + }, "act": { "supervisor": [ "corrected", diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 826a4b9..8d419e1 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -803,6 +803,9 @@ function goalChangeProblem(q, goalId, qid, l = null) { } // The correction on an act, with who made it and when. +// The supervisor's calls: its decisions, and the acts that are their own decision (no decision +// field: mechanical acts, and acts logged before decisions existed). Trust is earned and lost on these. +const calls = () => [...logObjects('decision'), ...logObjects('act').filter((a) => !a.decision)]; const correctionOf = (a) => { const u = a.updates.filter((x) => x.corrected !== undefined).pop(); return u ? { ...u.corrected, by: u.by, at: u.at, repaired: a.repaired ?? null } : null; @@ -1769,7 +1772,7 @@ function graphEdit(type, idRe) { if (sub === 'add' && logGet(id)) die(`${id} exists; use ${type} set`); if (sub === 'set' && logGet(id)?.type !== type) die(`no ${type} ${id}`); const line = appendLog({ ...body, type, id, op: sub === 'add' ? 'new' : 'set' }, by); - if (by === 'supervisor') appendLog({ type: 'act', class: sub === 'add' ? 'add-ticket' : 'replan-within-feature', what: `${sub === 'add' ? 'added' : 'changed'} ${type} ${id}${sub === 'set' ? ` (${Object.keys(body).join(', ')})` : ''}`, refs: [id], reversible: true }, 'supervisor'); + if (by === 'supervisor') appendLog({ type: 'act', decision: citing(), class: sub === 'add' ? 'add-ticket' : 'replan-within-feature', what: `${sub === 'add' ? 'added' : 'changed'} ${type} ${id}${sub === 'set' ? ` (${Object.keys(body).join(', ')})` : ''}`, refs: [id], reversible: true }, 'supervisor'); out(`${sub === 'add' ? 'added' : 'updated'} ${line.id}`); } @@ -2292,7 +2295,9 @@ function cmdMerge() { const declared = av.approves ?? []; const mine = (a) => !declared.length || (a.refs ?? []).some((r) => [target, `plan-${plan}`].includes(r)); if (declared.length && !declared.includes(target) && !(plan && declared.includes(`plan-${plan}`))) fails.push(`${approved} approves ${declared.join(', ')}, not ${target}; ask again for this one`); - const prior = logObjects('act').filter((a) => a.approved_by === approved && mine(a)); + // Every earlier use counts, merges that cited a decision included; only the decision this merge + // carries out (--decision) is the call the answer approved, not a prior one. + const prior = [...logObjects('decision'), ...logObjects('act')].filter((a) => a.approved_by === approved && mine(a) && a.id !== flagStr('decision')); const landing = prior.length === 1 && prior[0].class === av.class && (prior[0].refs ?? []).includes(target); if (prior.length && !landing) fails.push(`${approved} already approved ${declared.length ? `an act for ${target}` : 'an act'}; ask again for this one`); } @@ -2359,29 +2364,34 @@ function cmdMerge() { mainLogCache = undefined; mergeShaCache.clear(); const cls = id ? 'merge-word' : plan ? 'approve-feature-plan' : 'merge-chore'; - const act = appendLog({ type: 'act', class: cls, what: `merged ${branch} at ${sha}${['park', 'let-go'].includes(heldBy) ? ` while its goal is ${holdWords(heldBy)}, with --force` : ''}${standing?.tag ? ` (upstream ${standing.tag}, verbatim)` : standing ? ` (${standing.what}, under the owner's standing rule)` : ''}`, refs: id ? [id] : plan ? [plan] : [], reversible: false, approved_by: approved ?? standing?.rule.id ?? undefined }, 'supervisor'); + const act = appendLog({ type: 'act', decision: citing(), class: cls, what: `merged ${branch} at ${sha}${['park', 'let-go'].includes(heldBy) ? ` while its goal is ${holdWords(heldBy)}, with --force` : ''}${standing?.tag ? ` (upstream ${standing.tag}, verbatim)` : standing ? ` (${standing.what}, under the owner's standing rule)` : ''}`, refs: id ? [id] : plan ? [plan] : [], reversible: false, approved_by: approved ?? standing?.rule.id ?? undefined }, 'supervisor'); out(`merged ${branch} at ${sha.slice(0, 12)} (${act.id})`); } // ===== 7. trust ===== // The rung a ladder class is on: its start, then every owner direction and every correction of an // act in the class (a drop to 1), in time order, capped at the ceiling. +// How a class's rung moved: the owner's trust answers set it, a correction on a call in the class +// drops it to 1. Oldest first; each event says what moved it. +function rungHistory(cls, scope = null) { + const events = []; + for (const q of questions()) if (q.status === 'answered' && q.autonomy?.class === cls && (!q.autonomy.scope || q.autonomy.scope === scope)) events.push({ at: q.answered_at, rung: q.autonomy.rung, by: 'answer', id: q.id, why: q.question }); + for (const a of calls()) { const c = correctionOf(a); if (c && a.class === cls) events.push({ at: c.at, rung: 1, by: 'correction', id: a.id, why: c.reason }); } + return events.sort((a, b) => a.at.localeCompare(b.at)); +} function rungFor(cls, scope = null) { const def = ladder().classes[cls]; if (!def) return null; - const events = []; - for (const q of questions()) if (q.status === 'answered' && q.autonomy?.class === cls && (!q.autonomy.scope || q.autonomy.scope === scope)) events.push({ at: q.answered_at, rung: q.autonomy.rung }); - for (const a of logObjects('act')) { const c = correctionOf(a); if (c && a.class === cls) events.push({ at: c.at, rung: 1 }); } let r = def.start; - for (const e of events.sort((a, b) => a.at.localeCompare(b.at))) r = e.rung; + for (const e of rungHistory(cls, scope)) r = e.rung; return Math.min(r, def.ceiling); } // Evidence for a trust question: how often the owner went with the supervisor's recommendation on -// the last N answered questions of the class (defaulted answers don't count), and acts in the -// class since it last dropped, none corrected. +// the last N answered questions of the class (defaulted answers don't count), and calls in the +// class since it last dropped (decisions, and acts that were their own), none corrected. function trustEvidence(cls) { const promo = ladder().promotion; - const acts = logObjects('act').filter((a) => a.class === cls).sort(byTime); + const acts = calls().filter((a) => a.class === cls).sort(byTime); const drops = acts.map(correctionOf).filter(Boolean).map((c) => c.at).sort(); const since = drops[drops.length - 1] ?? ''; const after = acts.filter((a) => a.created_at > since); @@ -2397,9 +2407,80 @@ function trustTable() { return Object.entries(ladder().classes).map(([cls, def]) => { const ev = trustEvidence(cls); const rung = rungFor(cls); - return { class: cls, about: def.about, rung, rung_name: RUNG[rung], ceiling: def.ceiling, start: def.start, ...ev, can_ask: rung < def.ceiling && ev.meets_agreement }; + return { class: cls, about: def.about, rung, rung_name: RUNG[rung], ceiling: def.ceiling, start: def.start, ...ev, can_ask: rung < def.ceiling && ev.meets_agreement, history: rungHistory(cls) }; }); } + +// What a call rested on, for one that recorded none (an act that was its own decision). +function actAuthority(a) { + const L = ladder(); + if (a.approved_by?.startsWith('k-')) return 'standing-rule'; + if (a.approved_by) { const q = logGet(a.approved_by); return q?.type === 'question' && questionView(q).status === 'defaulted' ? 'default' : 'answer'; } + if (L.classes[a.class] && a.rung === 3) return 'rung'; + return 'constitution'; +} +// The Decide view: every call, who made it and under what. The supervisor's decisions and the acts +// that were their own decision; the owner's calls computed from the owner's own lines (answers, goal +// verdicts and holds, standing rules, corrections), shown as they are and never rewritten as +// decision lines. Newest first. +function decisionRecords() { + const acts = logObjects('act'); + const carried = new Map(); + for (const a of acts) if (a.decision) (carried.get(a.decision) ?? carried.set(a.decision, []).get(a.decision)).push(a.id); + const rows = []; + const allCalls = calls(); + const usedBy = new Map(); + for (const c of allCalls) if (c.approved_by) (usedBy.get(c.approved_by) ?? usedBy.set(c.approved_by, []).get(c.approved_by)).push(c.id); + const call = (o, kind, under) => ({ + id: o.id, at: o.created_at, who: 'supervisor', kind, class: o.class, scope: o.scope ?? null, what: o.what, rationale: o.rationale ?? null, options: o.options ?? [], confidence: o.confidence ?? null, + refs: o.refs ?? [], reversible: o.reversible !== false, under, acts: kind === 'decision' ? carried.get(o.id) ?? [] : [], correction: correctionOf(o), + }); + for (const d of logObjects('decision')) rows.push(call(d, 'decision', { authority: d.authority, rung: d.rung ?? null, ceiling: d.ceiling ?? null, approved_by: d.approved_by ?? null })); + for (const a of acts) if (!a.decision) rows.push(call(a, 'act', { authority: actAuthority(a), rung: a.rung ?? null, ceiling: ladder().classes[a.class]?.ceiling ?? null, approved_by: a.approved_by ?? null })); + for (const q of questions()) { + if (q.status !== 'answered') continue; + rows.push({ + id: q.id, at: q.answered_at, who: 'owner', kind: 'answer', class: q.class, scope: q.scope, what: `${q.question} → ${q.answer}`, refs: [q.ticket, q.goal].filter(Boolean), + under: { recommendation: q.recommendation || null, matched: q.matched ?? null, autonomy: q.autonomy, standing_rule: q.standing_rule, channel: q.channel }, acts: usedBy.get(q.id) ?? [], correction: null, + }); + } + for (const g of readLog().objects.values()) { + if (g.type !== 'goal') continue; + const title = g.kind === 'upkeep' ? 'Upkeep' : `"${g.title}"`; + for (const u of g.updates ?? []) { + if (writerClass(u.by) !== 'owner') continue; + const said = [ + u.decision ? `${u.decision.verdict}${u.decision.notes ? ` (${u.decision.notes})` : ''}` : null, + u.status !== undefined && !u.decision ? `status ${u.status}` : null, + u.hold !== undefined ? (u.hold ? `${u.hold.mode}${u.hold.reason ? ` (${u.hold.reason})` : ''}` : 'run') : null, + ].filter(Boolean); + if (said.length) rows.push({ id: g.id, at: u.at, who: 'owner', kind: 'goal', class: u.hold !== undefined ? 'goal-hold' : 'goal-approval', scope: null, what: `${title}: ${said.join('; ')}`, refs: [g.id], under: null, acts: [], correction: null }); + } + } + for (const k of logObjects('knowledge')) { + if (writerClass(k.created_by) !== 'owner' || k.status === 'retired') continue; + rows.push({ id: k.id, at: k.created_at, who: 'owner', kind: 'rule', class: null, scope: k.scope ?? null, what: k.statement, refs: k.evidence ?? [], under: { status: k.status }, acts: usedBy.get(k.id) ?? [], correction: null }); + } + for (const c of allCalls) { + const x = correctionOf(c); + if (x) rows.push({ id: c.id, at: x.at, who: writerClass(x.by), kind: 'correction', class: c.class, scope: c.scope ?? null, what: `corrected "${c.what}": ${x.reason}`, refs: [c.id], under: { severity: x.severity ?? null, repaired: x.repaired }, acts: [], correction: null }); + } + return rows.sort((a, b) => b.at.localeCompare(a.at)); +} +const AUTHORITY_WORDS = { rung: 'its rung', answer: 'the owner\'s answer', default: 'a default', 'standing-rule': 'a standing rule', constitution: 'the constitution' }; +function cmdDecisions() { + let rows = decisionRecords(); + if (flagStr('who')) rows = rows.filter((r) => r.who === flagStr('who')); + if (flagStr('class')) rows = rows.filter((r) => r.class === flagStr('class')); + if (flags.corrected) rows = rows.filter((r) => r.correction || r.kind === 'correction'); + rows = rows.slice(0, Number(flagStr('tail') ?? 50)); + if (flags.json) return json(rows); + if (!rows.length) return out('no decisions'); + for (const r of rows) { + const under = ['decision', 'act'].includes(r.kind) ? ` under ${AUTHORITY_WORDS[r.under.authority] ?? r.under.authority}${r.under.rung ? ` (rung ${r.under.rung}${r.under.ceiling ? ` of ${r.under.ceiling}` : ''})` : ''}${r.under.approved_by ? ` ${r.under.approved_by}` : ''}` : ''; + out(`${r.at.slice(0, 16).replace('T', ' ')} ${r.who.padEnd(10)} ${(r.kind).padEnd(10)} ${r.class ?? '-'}: ${String(r.what).slice(0, 120)}${under}${r.correction ? ` CORRECTED: ${r.correction.reason}` : ''}`); + } +} function cmdTrust() { const rows = trustTable(); if (flags.json) return json(rows); @@ -2596,7 +2677,7 @@ function cmdWithdraw() { supervisorOnly('withdraw'); openQuestion(id); appendLog({ type: 'question', op: 'set', id, status: 'withdrawn', reason: flagStr('reason') }, 'supervisor'); - appendLog({ type: 'act', class: 'withdraw-question', what: `withdrew ${id}${flagStr('reason') ? `: ${flagStr('reason')}` : ''}`, refs: [id], reversible: true }, 'supervisor'); + appendLog({ type: 'act', decision: citing(), class: 'withdraw-question', what: `withdrew ${id}${flagStr('reason') ? `: ${flagStr('reason')}` : ''}`, refs: [id], reversible: true }, 'supervisor'); out(`withdrew ${id}`); } @@ -3420,52 +3501,166 @@ function cmdQuestions() { // An act: a decision the supervisor made. For a ladder class it checks the rung first and refuses // (writing nothing) when the supervisor may not act alone; an owner-only class needs an owner answer. -function cmdAct() { - const what = rest ? rest.join(' ') : flagStr('what'); - const cls = flagStr('class'); - if (!cls || !what) die('usage: act --class [--scope s] [--approved ] [--refs ids] [--irreversible] -- ""'); - supervisorOnly('act'); - if (cls === 'improvement-trial') upkeepAllows('a new trial'); +// Whether the supervisor may make a call in class cls (scope), and on what authority: the class's +// rung, an owner's answer or a default (approved q-…), a standing rule (approved k-…), or the +// constitution for a class the ladder doesn't hold. Exits 3 when the rung says ask first. One +// answer authorises one call (a decision, or an act that is its own decision); a question that +// declared its acts (ask --approves) authorises one call for each. +function authorise(cls, scope, approved, refs, verb) { const L = ladder(); - const scope = flagStr('scope') ?? null; - const approved = flagStr('approved') ?? null; + const ownerOnly = (L.owner_only ?? []).includes(cls); let approval = null; - if (approved) { + let rule = null; + if (approved?.startsWith('k-')) { + rule = logGet(approved); + if (rule?.type !== 'knowledge' || writerClass(rule.created_by) !== 'owner' || rule.status !== 'accepted') die(`${approved} is not a standing rule of the owner's (accepted knowledge the owner wrote)`); + // Only a rule the owner scoped to this class: a project-wide preference ("flags first") is + // guidance, never authority over every class on the ladder. + if (rule.scope !== `class:${cls}`) die(`${approved} applies to ${rule.scope}; a standing rule authorises a call only when the owner scoped it to its class (answer … --rule "…" --rule-scope class:${cls})`); + if (ownerOnly) die(`${cls} is the owner's (constitution.md); a standing rule never covers it: ask`); + } else if (approved) { const q = logGet(approved); approval = q?.type === 'question' ? questionView(q) : null; if (!approval || !['answered', 'defaulted'].includes(approval.status)) die(`${approved} is not an answered question`); - reviewApproves(approval, 'act --approved'); + reviewApproves(approval, `${verb} --approved`); } // approve-plan may cite the owner's answer to a one-way door asked at goal level (constitution.md §4); - // it is checked below, against the ticket's goals and its plan review, not as an approve-plan answer. + // the act checks it against the ticket's goals and its plan review, not as an approve-plan answer. const doorCited = cls === 'approve-plan' && approval?.kind === 'one-way-door'; if (approval && !doorCited) { if (approval.class !== cls) die(`${approved} was asked in class ${approval.class ?? '(none)'}, not ${cls}`); const declared = approval.approves ?? []; if (declared.length) { - // a question that declared its acts: this one names which (first in --refs), once each - const part = asList(flags.refs).flatMap((x) => String(x).split(',')).find((r) => declared.includes(r)); - if (!part) die(`${approved} approves ${declared.join(', ')}; name the one this act is for in --refs`); - if (logObjects('act').some((a) => a.approved_by === approved && (a.refs ?? []).includes(part))) die(`${approved} already approved an act for ${part}; ask again for another`); - } else if (logObjects('act').some((a) => a.approved_by === approved)) die(`${approved} already approved an act; ask again for another`); + const part = refs.flatMap((x) => String(x).split(',')).find((r) => declared.includes(r)); + if (!part) die(`${approved} approves ${declared.join(', ')}; name the one this ${verb === 'decide' ? 'decision' : 'act'} is for in --refs`); + const used = calls().find((a) => a.approved_by === approved && (a.refs ?? []).includes(part)); + if (used) die(`${approved} already approved ${used.type === 'decision' ? 'a decision' : 'an act'} for ${part} (${used.id}); ask again for another`); + } else { + const used = calls().find((a) => a.approved_by === approved); + if (used) die(`${approved} already approved ${used.type === 'decision' ? 'a decision' : 'an act'} (${used.id}); ask again for another`); + } } + const def = L.classes[cls]; let rung; - if ((L.owner_only ?? []).includes(cls)) { + let authority = 'constitution'; + if (ownerOnly) { if (!approval || approval.status !== 'answered') die(`${cls} is the owner's (constitution.md); it needs an owner answer in that class (--approved )`); - } else if (L.classes[cls]) { + authority = 'answer'; + } else if (def) { rung = rungFor(cls, scope); - if (approval) { + if (rule) authority = 'standing-rule'; + else if (approval) { if (approval.status === 'defaulted' && rung < 2) die(`${cls} is on rung ${rung} (${RUNG[rung]}); a defaulted answer is not approval there`); + authority = approval.status === 'defaulted' ? 'default' : 'answer'; } else if (rung < 3) { - process.stderr.write(`devteam: ${cls}${scope ? ` (${scope})` : ''} is on rung ${rung} (${RUNG[rung]}); ask first:\n devteam.mjs ask --class ${cls} --question "…" --recommendation "…"${rung === 2 ? ' --default "…"' : ''} --reasons below-rung\nthen act with --approved .\n`); + process.stderr.write(`devteam: ${cls}${scope ? ` (${scope})` : ''} is on rung ${rung} (${RUNG[rung]}); ask first:\n devteam.mjs ask --class ${cls} --question "…" --recommendation "…"${rung === 2 ? ' --default "…"' : ''} --reasons below-rung\nthen ${verb} with --approved .\n`); process.exit(3); - } - } else if (!(L.constitutional ?? []).includes(cls)) warn(`class "${cls}" is not in constitution.md (ladder, constitutional or owner-only)`); + } else authority = 'rung'; + } else { + if (!(L.constitutional ?? []).includes(cls)) warn(`class "${cls}" is not in constitution.md (ladder, constitutional or owner-only)`); + if (rule) authority = 'standing-rule'; + else if (approval) authority = approval.status === 'defaulted' ? 'default' : 'answer'; + } + return { rung, ceiling: def?.ceiling, authority, approval, doorCited }; +} +// A decision an act may carry out: the supervisor's, and not corrected (decide again after a correction). +// A decision made on the class's own trust (its rung, or a default) lapses when the class drops after +// it: a stockpiled call can't outlive the correction that took the trust back. +function decisionFor(id) { + const d = logGet(id); + if (d?.type !== 'decision') die(`no decision ${id}`); + if (correctionOf(d)) die(`${id} was corrected (${correctionOf(d).reason}); decide again`); + if (['rung', 'default'].includes(d.authority) && rungHistory(d.class, d.scope ?? null).some((e) => e.by === 'correction' && e.at > d.created_at)) die(`${d.class} dropped to rung 1 after ${id} was decided; decide again`); + // The authority a decision records is checked against the log, not taken on its word: a decision + // line is the supervisor's own, so one written by hand can't carry trust it never had. + const why = decisionAuthorityGap(d); + if (why) die(`${id} records authority it doesn't have (${why}); decide again`); + return d; +} +function decisionAuthorityGap(d) { + const L = ladder(); + const ownerOnly = (L.owner_only ?? []).includes(d.class); + const q = d.approved_by?.startsWith('q-') ? logGet(d.approved_by) : null; + const v = q?.type === 'question' ? questionView(q) : null; + const inClass = v && (v.class === d.class || (d.class === 'approve-plan' && v.kind === 'one-way-door')); + if (ownerOnly) return v && inClass && v.status === 'answered' ? null : `${d.class} is the owner's and needs the owner's answer in that class`; + switch (d.authority) { + case 'answer': return v && inClass && v.status === 'answered' ? null : `no answered ${d.class} question ${d.approved_by ?? ''}`.trim(); + case 'default': return v && inClass && v.status === 'defaulted' ? null : `no defaulted ${d.class} question ${d.approved_by ?? ''}`.trim(); + case 'standing-rule': { const k = logGet(d.approved_by ?? ''); return k?.type === 'knowledge' && writerClass(k.created_by) === 'owner' && k.status === 'accepted' && k.scope === `class:${d.class}` ? null : `no accepted owner rule scoped class:${d.class}`; } + case 'rung': return L.classes[d.class] && rungFor(d.class, d.scope ?? null) >= 3 ? null : `${d.class} is not on rung 3`; + case 'constitution': return (L.constitutional ?? []).includes(d.class) || !L.classes[d.class] ? null : `${d.class} is on the ladder, not constitutional`; + default: return `unknown authority ${d.authority}`; + } +} +// What a command's act would be about, for --decision: the act must be about something the decision named. +function citeTargets() { + const [a, b] = positional; + switch (command) { + case 'merge': return [a, flagStr('chore') !== undefined ? `chore-${choreSlug(flagStr('chore'))}` : null, flagStr('plan'), flagStr('plan') ? `plan-${flagStr('plan')}` : null].filter(Boolean); + case 'ticket': return ['add', 'set'].includes(a) ? [b] : null; + case 'withdraw': return [a]; + case 'goal': return a === 'hold' ? [b === 'upkeep' ? upkeepOrVirtual().id : b, b] : null; + case 'worker': return [a, ...(features().get(tickets().get(a)?.feature)?.goals ?? [])]; + case 'planner': return [a, `plan-${a}`, ...asList(flags.goal)]; + case 'fleet': return ['fleet', ...logObjects('goal').map((g) => g.id)]; + default: return null; + } +} +// --decision on a command that logs its own act: checked before the command runs (see dispatch). +const citing = () => flagStr('decision') ?? undefined; + +function cmdDecide() { + const what = rest ? rest.join(' ') : flagStr('what'); + const cls = flagStr('class'); + if (!cls || !what) die('usage: decide --class c [--scope s] [--approved qid|kid] [--options "a" "b"] [--rationale "…"] [--confidence 0-1] [--refs ids] [--irreversible] -- ""'); + supervisorOnly('decide'); + if (cls === 'improvement-trial') upkeepAllows('a new trial'); + const scope = flagStr('scope') ?? null; + const approved = flagStr('approved') ?? null; + const refs = asList(flags.refs); + const confidence = flagNum('confidence'); + if (confidence !== undefined && !(confidence >= 0 && confidence <= 1)) die('--confidence is a number from 0 to 1'); + if (['approve-spec', 'approve-plan'].includes(cls) && !tickets().has(refs[0])) die(`${cls} names the ticket first: --refs [other ids]`); + const a = authorise(cls, scope, approved, refs, 'decide'); + if (a.doorCited && a.approval.ticket !== refs[0] && !(features().get(tickets().get(refs[0])?.feature)?.goals ?? []).includes(a.approval.goal)) die(`${approved} answered a one-way door for ${a.approval.ticket ?? a.approval.goal ?? 'something else'}, not for ${refs[0]} or a goal its feature serves`); + const line = appendLog({ + type: 'decision', class: cls, scope: scope ?? undefined, what, rationale: flagStr('rationale'), options: flags.options !== undefined ? listFlag('options') : undefined, + confidence, refs, reversible: !flags.irreversible, rung: a.rung, ceiling: a.ceiling, authority: a.authority, approved_by: approved ?? undefined, + }, 'supervisor'); + out(line.id); +} + +function cmdAct() { + const what = rest ? rest.join(' ') : flagStr('what'); + const decision = flagStr('decision') ? decisionFor(flagStr('decision')) : null; + if (decision && flagStr('class') && flagStr('class') !== decision.class) die(`${decision.id} is in class ${decision.class}; an act carrying it out takes its class`); + if (decision && (flagStr('approved') || flagStr('scope'))) die(`an act carrying out ${decision.id} takes its scope and approval from it; pass no --scope or --approved`); + const cls = decision?.class ?? flagStr('class'); + if (!cls || !what) die('usage: act --decision d-… -- "" | act --class c [--scope s] [--approved qid|kid] [--refs ids] [--irreversible] -- "" (without --decision the act is its own decision)'); + supervisorOnly('act'); + if (cls === 'improvement-trial') upkeepAllows('a new trial'); + const scope = decision ? decision.scope ?? null : flagStr('scope') ?? null; + const approved = decision ? decision.approved_by ?? null : flagStr('approved') ?? null; + const refs = flags.refs !== undefined ? asList(flags.refs) : decision?.refs ?? []; + // An act carries out what its decision covered, nothing else. + if (decision) { + const outside = refs.filter((r) => !(decision.refs ?? []).includes(r)); + if (outside.length) die(`${decision.id} covers ${(decision.refs ?? []).join(', ') || 'no refs'}; ${outside.join(', ')} ${outside.length > 1 ? 'are' : 'is'} outside it: decide again for ${outside.length > 1 ? 'them' : 'it'}`); + if (['approve-spec', 'approve-plan'].includes(cls) && refs[0] !== decision.refs?.[0]) die(`${decision.id} approves ${decision.refs?.[0]}; a ${cls} act under it names that ticket first`); + } + // A decision was authorised when it was made; an act without one is authorised now. + let auth; + if (decision) { + const q = approved?.startsWith('q-') ? logGet(approved) : null; + const approval = q?.type === 'question' ? questionView(q) : null; + auth = { rung: undefined, approval, doorCited: cls === 'approve-plan' && approval?.kind === 'one-way-door' }; + } else auth = authorise(cls, scope, approved, refs, 'act'); + const { approval, doorCited } = auth; // Classes that are about one ticket name it first in refs; checkTicket finds approvals by it. - if (['approve-spec', 'approve-plan'].includes(cls) && !tickets().has(asList(flags.refs)[0])) die(`${cls} names the ticket first: --refs [other ids]`); + if (['approve-spec', 'approve-plan'].includes(cls) && !tickets().has(refs[0])) die(`${cls} names the ticket first: --refs [other ids]`); if (cls === 'approve-plan') { - const t = tickets().get(asList(flags.refs)[0]); - if (!t) die('approve-plan names the ticket: --refs '); + const t = tickets().get(refs[0]); const doors = t.one_way_doors ?? []; let goalDoor = null; if (doorCited) { @@ -3490,7 +3685,7 @@ function cmdAct() { if (!latest.some((r) => r.doors.includes(goalDoor))) die(`${goalDoor} is a goal-level answer; the latest approving plan review (reviews/plan-.md on ticket/${t.id}) must say "- Goal-level door: ${goalDoor} covers this plan" (quality.md §3)`); } } - const line = appendLog({ type: 'act', class: cls, scope: scope ?? undefined, what, rung, refs: asList(flags.refs), reversible: !flags.irreversible, approved_by: approved ?? undefined }, 'supervisor'); + const line = appendLog({ type: 'act', class: cls, scope: scope ?? undefined, what, rung: auth.rung, refs, reversible: decision ? decision.reversible !== false && !flags.irreversible : !flags.irreversible, approved_by: decision ? undefined : approved ?? undefined, decision: decision?.id }, 'supervisor'); out(line.id); } // A plan or design opens with '## TL;DR' and a few bullets: the approach, skimmable (quality.md §6). @@ -3507,14 +3702,18 @@ function tldrGap(text) { if (bullets > 7) return `has a TL;DR of ${bullets} bullets; seven at most, the rest belongs in the plan`; return null; } +// A correction lands on the call that was wrong: a decision, or an act that was its own decision. +// An act that carried out a decision sends you to the decision; if the decision was sound and the +// act went wrong, that is a problem (or an escaped defect), not a trust drop (constitution.md §2). function cmdCorrect() { const [actId] = positional; - if (!actId) die('usage: correct --reason r [--severity 0-4] (owner; the supervisor at a retro adds --retro)'); + if (!actId) die('usage: correct --reason r [--severity 0-4] (owner; the supervisor at a retro adds --retro)'); const by = flags.retro ? (supervisorOnly('correct --retro'), 'supervisor') : ownerWriter().by; const act = logGet(actId); - if (act?.type !== 'act') die(`no act ${actId}`); + if (!['act', 'decision'].includes(act?.type)) die(`no decision or act ${actId}`); + if (act.type === 'act' && act.decision) die(`${actId} carried out ${act.decision}. If the decision was wrong: correct ${act.decision} --reason "…". If it was sound and the act went wrong: problem add --title "…" --severity 0-4 --refs ${actId}${by === 'owner' ? ' --owner' : ''} (a problem, not a trust drop; constitution.md §2)`); if (correctionOf(act)) die(`${actId} is already corrected`); - appendLog({ type: 'act', op: 'set', id: actId, corrected: { reason: flagStr('reason') ?? die('correct needs --reason'), severity: flagNum('severity') } }, by); + appendLog({ type: act.type, op: 'set', id: actId, corrected: { reason: flagStr('reason') ?? die('correct needs --reason'), severity: flagNum('severity') } }, by); if (ladder().classes[act.class]) out(`${act.class} drops to rung 1 (ask)`); if (by === 'owner') nudgeSupervisor(`[devteam correction from owner] on ${actId}; read it with: devteam.mjs log --id ${actId}`); out(`corrected ${actId}`); @@ -3526,8 +3725,8 @@ function cmdRepair() { if (!actId || !['undo', 'fix-ticket', 'none'].includes(how)) die('usage: repair --how undo|fix-ticket|none'); supervisorOnly('repair'); const act = logGet(actId); - if (act?.type !== 'act' || !correctionOf(act)) die(`${actId} is not a corrected act`); - appendLog({ type: 'act', op: 'set', id: actId, repaired: how }, 'supervisor'); + if (!['act', 'decision'].includes(act?.type) || !correctionOf(act)) die(`${actId} is not a corrected decision or act`); + appendLog({ type: act.type, op: 'set', id: actId, repaired: how }, 'supervisor'); out(`repaired ${actId}: ${how}`); } @@ -3638,7 +3837,7 @@ function orientClusters() { if (linked.has(o.id) || ['reading', 'retro', 'outcome'].includes(o.kind)) continue; add(o.source_key ?? `${o.kind}:${o.subject}`, { id: o.id, at: o.created_at, who: o.created_by, valence: o.valence ?? 0, detail: o.detail }); } - for (const a of logObjects('act')) { + for (const a of calls()) { const c = correctionOf(a); if (c && !linked.has(a.id)) add(`correction:${a.class}:${a.scope ?? 'any'}`, { id: a.id, at: c.at, who: c.by, valence: -2, detail: c.reason }); } @@ -3842,7 +4041,7 @@ function cmdGoal() { // an earlier answer carried forward with --change is checked as such (its question recorded no change) const problem = goalChangeProblem(logGet(approved), id, approved, logGet(approved)?.type === 'question' && !questionView(logGet(approved)).change && flagStr('change') !== undefined ? { carried: true } : null); if (problem) die(`goal set --approved: ${problem}`); - if (logObjects('goal').some((g) => g.updates.some((u) => u.approved === approved)) || logObjects('act').some((a) => a.approved_by === approved)) die(`${approved} already authorised a change; ask again for another`); + if (logObjects('goal').some((g) => g.updates.some((u) => u.approved === approved)) || calls().some((a) => a.approved_by === approved)) die(`${approved} already authorised a change; ask again for another`); // the answer authorises the change the question proposed, applied as is if (Object.values(fields()).some((x) => x !== undefined)) die('goal set --approved applies the change its question proposed; pass no other fields'); const qv = questionView(logGet(approved)); @@ -3914,7 +4113,7 @@ function cmdGoal() { logCache = null; const name = isUpkeep(g) ? 'upkeep' : g.id; const now = goalArg(isUpkeep(g) ? 'upkeep' : g.id); - if (by === 'supervisor') appendLog({ type: 'act', class: 'goal-hold', what: `${mode === 'drain' ? 'drained' : 'released my drain on'} ${isUpkeep(g) ? 'Upkeep' : `"${g.title}"`}: ${reason}`, refs: [now.id], reversible: true }, 'supervisor'); + if (by === 'supervisor') appendLog({ type: 'act', decision: citing(), class: 'goal-hold', what: `${mode === 'drain' ? 'drained' : 'released my drain on'} ${isUpkeep(g) ? 'Upkeep' : `"${g.title}"`}: ${reason}`, refs: [now.id], reversible: true }, 'supervisor'); out(`${isUpkeep(g) ? 'Upkeep' : g.id} is ${holdWords(mode)} (${line.id})`); if (mode === 'park') { if (by === 'supervisor') { const sent = sendParkRulings({ goal: now }); out(sent.length ? `told ${sent.join(', ')} to commit, push, write a handoff and park` : 'no running session of it to tell'); } @@ -4188,7 +4387,7 @@ function retroDraft() { const rejected = []; for (const id of ts.keys()) { const b = ticketBranchRef(id); for (const r of ticketReviews(id, b && !mergeShaFor(id) ? { ref: b } : WORKTREE)) if (r.pass === 1 && !r.variant && r.verdict === 'CHANGES') rejected.push(`${id} ${r.stage}`); } const acts = logObjects('act'); - const corrections = acts.map((a) => ({ a, c: correctionOf(a) })).filter((x) => x.c && after(x.c.at)); + const corrections = calls().map((a) => ({ a, c: correctionOf(a) })).filter((x) => x.c && after(x.c.at)); const outcomes = logObjects('observation').filter((o) => o.kind === 'outcome'); const trials = acts.filter((a) => a.class === 'improvement-trial' && after(a.created_at)); const openTrials = acts.filter((a) => a.class === 'improvement-trial' && !outcomes.some((o) => (o.refs ?? []).includes(a.id))); @@ -4840,7 +5039,12 @@ function collectOoda() { const l = ticketSets.filter((x) => (a.refs ?? []).includes(x.id) && x.at <= a.created_at).pop(); return l ? Object.fromEntries(Object.entries(l).filter(([k]) => !ENVELOPE.includes(k) && !k.startsWith('__'))) : undefined; }; - const acts = logObjects('act').map((a) => ({ ...a, correction: correctionOf(a), ladder: !!L.classes[a.class], change: changeOf(a) })); + // Each act says what it carried out: a decision (whose correction is the one that counts), an + // owner's answer or a standing rule, or its own call. + const acts = logObjects('act').map((a) => { + const d = a.decision ? logGet(a.decision) : null; + return { ...a, correction: correctionOf(a), ladder: !!L.classes[a.class], change: changeOf(a), under: a.decision ? { decision: a.decision, what: d?.what ?? null, authority: d?.authority ?? null, corrected: d ? !!correctionOf(d) : false } : { authority: actAuthority(a), approved_by: a.approved_by ?? null, own: true } }; + }); return { goals: logObjects('goal').map(goalView).sort((a, b) => (a.priority ?? 99) - (b.priority ?? 99)), roadmap: collectRoadmap(), @@ -4850,6 +5054,7 @@ function collectOoda() { knowledge: recent(logObjects('knowledge').filter((k) => k.status !== 'retired'), 60), problems: logObjects('problem').filter((p) => p.status !== 'resolved').sort((a, b) => b.severity - a.severity), acts: recent(acts, 60), + decisions: decisionRecords().slice(0, 200), trust: trustTable(), classes: Object.fromEntries(Object.entries(L.classes).map(([k, v]) => [k, { ...v, rung: rungFor(k) }])), reasons: L.reasons, @@ -5450,7 +5655,7 @@ function makeSession(role) { if (role === 'worker' && !resume) appendLog({ type: 'phase', from: null, to: 'starting', note: `started in ${target}` }, id); // a planner parked by a park is running again if (role === 'planner' && lastPhaseOf(name)?.to === 'parked') appendLog({ type: 'phase', from: 'parked', to: 'starting', note: `started in ${target}` }, name); - appendLog({ type: 'act', class: 'worker-slots', what: `${resume ? 'resumed' : 'made'} ${role} ${name} in ${target}${block ? `, ticket numbers ${padNN(block.from)}-${padNN(block.to)}` : ''}${refused ? ` while its goal is ${holdWords(mode)}, with --force` : ''}`, refs: role === 'worker' ? [id] : goalIds, reversible: true, id_block: block ?? undefined }, 'supervisor'); + appendLog({ type: 'act', decision: citing(), class: 'worker-slots', what: `${resume ? 'resumed' : 'made'} ${role} ${name} in ${target}${block ? `, ticket numbers ${padNN(block.from)}-${padNN(block.to)}` : ''}${refused ? ` while its goal is ${holdWords(mode)}, with --force` : ''}`, refs: role === 'worker' ? [id] : goalIds, reversible: true, id_block: block ?? undefined }, 'supervisor'); out(`${resume ? 'resumed' : 'made'} ${role} ${name} in ${target} at ${wt}${block ? `; ticket numbers ${padNN(block.from)}-${padNN(block.to)} reserved` : ''}`); } @@ -5673,7 +5878,7 @@ function fleetOp(mode) { changed.push(isUpkeep(g) ? 'upkeep' : g.id); } if (!changed.length) return out(`every goal is already ${mode === 'park' ? 'parked' : 'draining or parked'}; nothing to do`); - if (by === 'supervisor') appendLog({ type: 'act', class: `fleet-${mode}`, what: `${mode === 'park' ? 'parked' : 'drained'} the fleet on ${host} (${op}): ${reason}`, refs: changed.filter((x) => x !== 'upkeep'), reversible: true }, 'supervisor'); + if (by === 'supervisor') appendLog({ type: 'act', decision: citing(), class: `fleet-${mode}`, what: `${mode === 'park' ? 'parked' : 'drained'} the fleet on ${host} (${op}): ${reason}`, refs: changed.filter((x) => x !== 'upkeep'), reversible: true }, 'supervisor'); out(`${mode === 'park' ? 'parked' : 'draining'} the fleet (${op}): ${changed.length} goal(s) set to ${mode}, Upkeep included; nothing new starts until fleet un${mode}`); if (mode === 'park') { if (by === 'supervisor') { @@ -5906,7 +6111,7 @@ function fleetUndo(mode) { restored.push(`${isUpkeep(g) ? 'Upkeep' : g.title} → ${p.mode}`); } if (legacy) appendLog({ type: 'act', class: 'undrain', what: `undrained the fleet on ${host} (drained by ${legacy.id}${drainHost(legacy) ? ` on ${drainHost(legacy)}` : ''})`, refs: [legacy.id], reversible: true }, 'supervisor'); - if (op && by === 'supervisor') appendLog({ type: 'act', class: `fleet-un${mode}`, what: `un${mode === 'park' ? 'parked' : 'drained'} the fleet on ${host} (${op})`, refs: held.filter((x) => x.hold.fleet === op && !isUpkeep(x)).map((x) => x.id), reversible: true }, 'supervisor'); + if (op && by === 'supervisor') appendLog({ type: 'act', decision: citing(), class: `fleet-un${mode}`, what: `un${mode === 'park' ? 'parked' : 'drained'} the fleet on ${host} (${op})`, refs: held.filter((x) => x.hold.fleet === op && !isUpkeep(x)).map((x) => x.id), reversible: true }, 'supervisor'); logCache = null; out(`un${mode === 'park' ? 'parked' : 'drained'} the fleet${op ? ` (${op})` : ''}${legacy ? `, and lifted the old drain ${legacy.id}` : ''}: ${restored.length ? restored.join('; ') : 'no goal to put back'}`); const rows = restartCommands().map((x) => ` devteam.mjs ${x.cmd.padEnd(36)} ${x.handoff ? 'its handoff note goes into its prompt' : 'no current handoff note'}`); @@ -5993,11 +6198,11 @@ function cmdHistory() { const tail = Number(flagStr('tail') ?? 100); const rows = []; for (const o of readLog().objects.values()) { - const about = o.type === 'message' ? [o.created_by, o.to, o.ticket] : o.type === 'act' ? o.refs ?? [] : o.type === 'question' ? [o.ticket] : [o.created_by, o.subject]; + const about = o.type === 'message' ? [o.created_by, o.to, o.ticket] : ['act', 'decision'].includes(o.type) ? o.refs ?? [] : o.type === 'question' ? [o.ticket] : [o.created_by, o.subject]; if (scope && !about.some((x) => scope.has(x))) continue; if (['phase', 'gate'].includes(o.type) && !scope) continue; const what = o.question ? `${o.question} → ${questionView(o).answer ?? '(open)'}` : o.what ?? o.text ?? o.detail ?? o.statement ?? o.title ?? (o.type === 'phase' ? `${o.from ?? '-'} -> ${o.to}` : o.type === 'gate' ? `${o.gate} ${o.ok ? 'ok' : 'FAIL'}` : ''); - rows.push({ at: o.created_at, line: `${o.created_at} ${o.type.padEnd(11)} ${o.created_by.padEnd(22)} ${String(what).slice(0, 140)}${o.type === 'act' && correctionOf(o) ? ` CORRECTED: ${correctionOf(o).reason}` : ''}` }); + rows.push({ at: o.created_at, line: `${o.created_at} ${o.type.padEnd(11)} ${o.created_by.padEnd(22)} ${String(what).slice(0, 140)}${['act', 'decision'].includes(o.type) && correctionOf(o) ? ` CORRECTED: ${correctionOf(o).reason}` : ''}` }); } for (const r of rows.sort((a, b) => a.at.localeCompare(b.at)).slice(-tail)) out(r.line); } @@ -6198,8 +6403,11 @@ Decide and act (supervisor.md, constitution.md) answer --review-result | answer "" --decline (owner) a review: validate and hash its result, or send it back answer "" [--autonomy 1|2|3 [--scope s]] [--rule ""] (owner) | answer "" --from-chat --said "" | answer --defaulted (supervisor) withdraw [--reason r] | questions [--all] [--id q] [--json] - act --class c [--scope s] [--approved qid] [--refs ids] [--irreversible] -- "" - correct --reason r [--severity n] (owner; --retro for the supervisor) | repair --how undo|fix-ticket|none + decide --class c [--scope s] [--approved qid|kid] [--options a b] [--rationale r] [--confidence 0-1] [--refs ids] [--irreversible] -- "" a judgment call and the trust it rests on; prints d-… + act --decision d-… -- "" carry out a decision (merge, ticket, withdraw, goal hold, worker, planner and fleet take --decision too) + act --class c [--scope s] [--approved qid|kid] [--refs ids] [--irreversible] -- "" an act that is its own decision + decisions [--who owner|supervisor] [--class c] [--corrected] [--tail n] [--json] every call, who made it and under what + correct --reason r [--severity n] (owner; --retro for the supervisor) | repair --how undo|fix-ticket|none trust [--json] the ladder: rung, ceiling, agreement, corrections per class Observe and orient @@ -6229,7 +6437,7 @@ const COMMANDS = { feature: cmdFeature, ticket: cmdTicket, validate: cmdValidate, waves: cmdWaves, ready: cmdReady, status: cmdStatus, roadmap: cmdRoadmap, 'check-ticket': cmdCheckTicket, 'check-pr': cmdCheckPr, merge: cmdMerge, 'review-worktree': cmdReviewWorktree, phase: cmdPhase, gate: cmdGate, send: cmdSend, messages: cmdMessages, 'stop-ok': cmdStopOk, - ask: cmdAsk, answer: cmdAnswer, review: cmdReview, secret: cmdSecret, withdraw: cmdWithdraw, questions: cmdQuestions, act: cmdAct, correct: cmdCorrect, repair: cmdRepair, trust: cmdTrust, + ask: cmdAsk, answer: cmdAnswer, review: cmdReview, secret: cmdSecret, withdraw: cmdWithdraw, questions: cmdQuestions, decide: cmdDecide, decisions: cmdDecisions, act: cmdAct, correct: cmdCorrect, repair: cmdRepair, trust: cmdTrust, observe: cmdObserve, 'observe-metrics': cmdObserveMetrics, 'observe-spend': cmdObserveSpend, orient: cmdOrient, metrics: cmdMetrics, ack: cmdAck, timeline: cmdTimeline, knowledge: cmdKnowledge, problem: cmdProblem, goal: cmdGoal, retro: cmdRetro, chore: cmdChore, log: cmdLog, 'log-check': cmdLogCheck, 'log-sync': cmdLogSync, history: cmdHistory, @@ -6238,4 +6446,17 @@ const COMMANDS = { }; if (!command || command === 'help' || command === '--help') out(HELP); else if (!COMMANDS[command]) die(`unknown command "${command}"\n${HELP}`); -else COMMANDS[command](); +else { + // --decision names the decision a command's act carries out; check it before the command does anything. + if (flags.decision !== undefined) { + if (!flagStr('decision')) die('--decision needs a decision id (d-…)'); + if (command !== 'act') { + const targets = citeTargets(); + if (!targets) die(`${[command, positional[0]].filter(Boolean).join(' ')} logs no act; --decision is for act, merge, ticket add|set, withdraw, goal hold, worker, planner and fleet`); + supervisorOnly('--decision'); + const d = decisionFor(flagStr('decision')); + if (!(d.refs ?? []).some((r) => targets.includes(r))) die(`${d.id} is about ${(d.refs ?? []).join(', ') || 'nothing in particular (it named no --refs)'}; this ${command} is about ${targets.slice(0, 4).join(', ')}${targets.length > 4 ? ', …' : ''}. Decide with --refs naming it, or act without --decision`); + } + } + COMMANDS[command](); +} diff --git a/test/kit.test.mjs b/test/kit.test.mjs index 16b2c23..0a1e770 100644 --- a/test/kit.test.mjs +++ b/test/kit.test.mjs @@ -586,6 +586,78 @@ test('devteam kit', async (t) => { assert.equal(q.channel, 'cli', 'an answer records where the owner typed it'); }); + await t.test('decisions: a call records the trust it rests on, acts carry it out, corrections land on the call', () => { + const sup = { role: 'sup' }; + assert.equal(dt(['decide', '--class', 'release-scope', '--', 'slip BILL-S2'], sup).status, 3, 'decide asks first below rung 3, like act'); + const Qx = must(['ask', '--question', 'Ship the refund flow flagged?', '--class', 'release-scope', '--reasons', 'below-rung', '--recommendation', 'yes'], sup); + must(['answer', Qx, 'yes']); + assert.match(dt(['decide', '--class', 'release-scope', '--confidence', '2', '--approved', Qx, '--', 'x'], sup).err, /--confidence is a number from 0 to 1/); + const D = must(['decide', '--class', 'release-scope', '--approved', Qx, '--options', 'ship flagged', 'hold a week', '--rationale', 'the flag makes it reversible', '--confidence', '0.8', '--refs', 'bill-01-card', '--', 'ship refunds behind a flag'], sup); + assert.match(D, /^d-\d{8}-\d{9}-supervisor$/); + const d = json(must(['log', '--id', D, '--json'])); + const dl = Array.isArray(d) ? d[0] : d; + assert.equal(dl.authority, 'answer'); + assert.equal(dl.rung, 1, 'the rung it was decided on is snapshotted'); + // a decision written by hand can't carry trust it never had: an act under it is refused + { + const supFile = path.join(repo, 'docs/devteam/run/log', fs.readdirSync(path.join(repo, 'docs/devteam/run/log')).find((f) => /^supervisor-/.test(f))); + const before = fs.readFileSync(supFile, 'utf8'); + const fid = 'd-20261101-235959000-supervisor'; + fs.appendFileSync(supFile, line({ id: fid, type: 'decision', op: 'new', at: new Date().toISOString(), by: 'supervisor', class: 'gate-change', what: 'drop the lint gate', refs: ['bill-01-card'], authority: 'answer', approved_by: Qx, reversible: true })); + assert.match(dt(['act', '--decision', fid, '--', 'dropped it'], sup).err, /records authority it doesn't have \(gate-change is the owner's/); + fs.appendFileSync(supFile, line({ id: 'd-20261101-235959001-supervisor', type: 'decision', op: 'new', at: new Date().toISOString(), by: 'supervisor', class: 'production-migration', what: 'migrate it', refs: ['bill-01-card'], authority: 'rung', reversible: true })); + assert.match(dt(['act', '--decision', 'd-20261101-235959001-supervisor', '--', 'shipped'], sup).err, /production-migration is not on rung 3/); + fs.writeFileSync(supFile, before); + } + assert.deepEqual(dl.options, ['ship flagged', 'hold a week']); + assert.match(dt(['act', '--class', 'release-scope', '--approved', Qx, '--', 'again'], sup).err, new RegExp(`already approved a decision \\(${D}\\)`), 'the answer went to the decision'); + assert.match(dt(['act', '--decision', D, '--class', 'release-production', '--', 'x'], sup).err, /takes its class/); + assert.match(dt(['act', '--decision', D, '--approved', Qx, '--', 'x'], sup).err, /takes its scope and approval/); + const A1 = must(['act', '--decision', D, '--', 'turned the refund flag on in staging'], sup); + const A2 = must(['act', '--decision', D, '--', 'turned it on in production'], sup); + const acts = json(must(['log', '--type', 'act', '--json'])).filter((a) => a.decision === D); + assert.deepEqual(acts.map((a) => a.id), [A1, A2], 'one decision, several acts'); + assert.ok(acts.every((a) => a.class === 'release-scope' && !a.approved_by && a.refs.includes('bill-01-card')), 'an act takes its class and refs from the decision'); + assert.match(dt(['correct', A1, '--reason', 'wrong']).err, new RegExp(`carried out ${D}.*problem add`, 's'), 'a correction goes to the decision, or is a problem'); + assert.match(dt(['status', '--decision', D], sup).err, /logs no act/); + assert.match(dt(['act', '--decision', 'd-20990101-000000000-supervisor', '--', 'x'], sup).err, /no decision/); + // the Decide view: the supervisor's call with its acts, and the owner's answer it rested on + let recs = json(must(['decisions', '--json'])); + const rd = recs.find((r) => r.id === D && r.kind === 'decision'); + assert.deepEqual(rd.acts, [A1, A2]); + assert.equal(rd.under.authority, 'answer'); + assert.deepEqual(recs.find((r) => r.id === Qx && r.kind === 'answer').acts, [D], "the owner's answer, shown as the owner's"); + assert.ok(recs.some((r) => r.kind === 'act' && r.class === 'release-production'), 'an act without a decision is its own call'); + assert.ok(!recs.some((r) => r.id === A1), 'an act under a decision is not a call of its own'); + // an act stays within what its decision covered + assert.match(dt(['act', '--decision', D, '--refs', 'bill-02-x', '--', 'and this'], sup).err, /bill-02-x is outside it/); + // another command's act may cite a decision only about what it acts on + assert.match(dt(['withdraw', Qx, '--decision', D], sup).err, /is about bill-01-card; this withdraw is about/); + // a project-wide preference is not authority over a class; a rule the owner scoped to the class is + const K = json(must(['log', '--type', 'knowledge', '--json'])).find((k) => k.statement === 'flags first').id; + assert.match(dt(['decide', '--class', 'release-scope', '--approved', K, '--', 'flag the export too'], sup).err, /applies to project; a standing rule authorises a call only when the owner scoped it/); + const Qk = must(['ask', '--question', 'May exports always ship behind a flag?', '--class', 'release-scope', '--recommendation', 'yes'], sup); + must(['answer', Qk, 'yes', '--rule', 'exports ship behind a flag', '--rule-scope', 'class:release-scope']); + const K2 = json(must(['log', '--type', 'knowledge', '--json'])).find((k) => k.statement === 'exports ship behind a flag').id; + const Dk = must(['decide', '--class', 'release-scope', '--approved', K2, '--', 'flag the export too'], sup); + assert.equal(recs = json(must(['decisions', '--json', '--who', 'supervisor'])).find((r) => r.id === Dk).under.authority, 'standing-rule'); + const Dr = must(['decide', '--class', 'release-production', '--', 'ship v1.0.2'], sup); + assert.equal(json(must(['decisions', '--json'])).find((r) => r.id === Dr).under.rung, 3); + const Ds = must(['decide', '--class', 'release-production', '--', 'ship v1.1.0 next week'], sup); + // trust moves on the decision: a correction drops the class, and the decision can't be carried out again + must(['correct', Dr, '--reason', 'not before the freeze lifts']); + const tr = json(must(['trust', '--json'])).find((r) => r.class === 'release-production'); + assert.equal(tr.rung, 1, 'a corrected decision drops its class'); + assert.equal(tr.history.pop().id, Dr, 'the rung history names the call'); + assert.match(dt(['act', '--decision', Dr, '--', 'tagged'], sup).err, /was corrected.*decide again/); + assert.match(dt(['act', '--decision', Ds, '--', 'tagged v1.1.0'], sup).err, /dropped to rung 1 after .* was decided; decide again/, 'a call made on a rung lapses when the class drops'); + must(['repair', Dr, '--how', 'none'], sup); + assert.ok(json(must(['decisions', '--json', '--corrected'])).some((r) => r.id === Dr && r.kind === 'decision')); + const dash = json(must(['dashboard-data'])); + assert.ok(dash.ooda.decisions.some((r) => r.id === D), 'the dashboard has the Decide feed'); + assert.equal(dash.ooda.acts.find((a) => a.id === A1).under.decision, D); + }); + await t.test('an answer the owner gave in the supervisor chat: the owner\'s, quoted, listed for checking, never trust evidence', () => { const said = "you shouldn't need to ask me to submit those PRs"; const Qc = must(['ask', '--question', 'Open upstream PRs without asking?', '--class', 'upstream-pr', '--reasons', 'below-rung', '--recommendation', 'yes'], { role: 'sup' });