From 526108c9c495b9fe046613106b25d008133ccd47 Mon Sep 17 00:00:00 2001 From: hhff Date: Tue, 3 Nov 2026 12:50:08 -0500 Subject: [PATCH] Owner questions: one answer approves several acts only when the question named them; a blocked ticket with no question says the question was never asked The owner answered 'yes to both' to a question about two gate changes; the first landed and the second was refused, since one answer approves one act. The owner chose declared parts: ask --approves ... names the acts when asked, the owner's card lists them, and the answer approves each once (merge the one it names, or act --refs it) and nothing else. An answer without --approves still approves one act. The Stacks supervisor also put a decision to the owner only in its own chat; the ticket showed blocked with nothing in the Inbox. supervisor.md now says every question to the owner is an ask, and the 'blocked' finding says no open owner question names the ticket and, if it waits on the owner, to ask. Co-Authored-By: Claude Opus 5.5 --- docs/devteam/dashboard/index.html | 1 + docs/devteam/process/supervisor.md | 2 +- docs/devteam/schema/log.schema.json | 9 +++++++ docs/devteam/scripts/devteam.mjs | 37 +++++++++++++++++++++++------ test/kit.test.mjs | 13 ++++++++++ 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index cb33c10..cc8db9e 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -900,6 +900,7 @@

Checks before merge the automated

${linkify(q.question)}

${q.context ? `

${linkify(q.context)}

` : ''} ${(q.links || []).length ? `` : ''} + ${(q.approves || []).length ? `
Your answer approves each of these, once: ${q.approves.map((a) => !/^(chore|plan)-/.test(a) ? tlink(a) : `${esc(a)}`).join(', ')}
` : ''} ${q.change ? `
Approving sets on ${esc((DATA.ooda.goals.find((g) => g.id === q.goal) || {}).title || q.goal)}:
    ${Object.entries(q.change).map(([k, v]) => `
  • ${esc(words(k))}: ${Array.isArray(v) && v.length && typeof v[0] === 'object' ? `
      ${v.map((m) => `
    1. ${esc(m.title || JSON.stringify(m))}${m.target != null ? ` → ${esc(m.target)}` : ''}
    2. `).join('')}
    ` : esc(typeof v === 'object' ? JSON.stringify(v) : v)}
  • `).join('')}
` : ''} ${goalAsk ? (() => { const settled = (DATA.ooda.answered || []).filter((a) => a.goal === q.goal && a.kind !== 'goal-approval' && a.status === 'answered'); return settled.length ? `
Rests on your answers to: ${settled.map((a) => `${qlink(a.id, a.question)} (${esc(a.answer)})`).join('; ')}
` : ''; })() : ''} ${(q.secrets || []).length ? secretsForm(q) : `${list.length ? `
${choiceHtml}
` : ''} diff --git a/docs/devteam/process/supervisor.md b/docs/devteam/process/supervisor.md index c976a58..d664e84 100644 --- a/docs/devteam/process/supervisor.md +++ b/docs/devteam/process/supervisor.md @@ -126,7 +126,7 @@ Rules: one decision per question (a goal-definition question lists all of a goal **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. 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. 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. +**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. **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. diff --git a/docs/devteam/schema/log.schema.json b/docs/devteam/schema/log.schema.json index 93d7579..aabc415 100644 --- a/docs/devteam/schema/log.schema.json +++ b/docs/devteam/schema/log.schema.json @@ -916,6 +916,15 @@ "type": "string" } }, + "approves": { + "description": "The acts this question approves, named when asked (ask --approves): ticket ids, chore- or plan-. The answer approves each once and nothing else.", + "type": "array", + "minItems": 2, + "maxItems": 10, + "items": { + "type": "string" + } + }, "evidence": { "type": "array", "items": { diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index c6ab6cf..2bc5360 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -706,7 +706,7 @@ function questionView(q) { return { id: q.id, kind: q.kind, class: q.class ?? null, scope: q.scope ?? null, ticket: q.ticket ?? null, re: q.re ?? null, asked_at: q.created_at, question: q.question, context: q.context ?? '', options: q.options ?? [], choices: q.choices ?? [], links: q.links ?? [], goal: q.goal ?? null, change: q.change ?? null, rule_scope: q.rule_scope ?? null, recommendation: q.recommendation ?? '', confidence: q.confidence ?? null, - default: q.default ?? null, deadline: q.deadline ?? null, blocks: q.blocks ?? [], evidence: q.evidence ?? [], note: q.note ?? null, why_asked: q.why_asked ?? null, + default: q.default ?? null, deadline: q.deadline ?? null, blocks: q.blocks ?? [], approves: q.approves ?? [], evidence: q.evidence ?? [], note: q.note ?? null, why_asked: q.why_asked ?? null, secrets: (q.secrets ?? []).map((x) => ({ ...x, to: sinkWords(x.sink), sink_type: SINK_RE.exec(x.sink ?? '')?.[1] ?? null, warning: sinkWarning(x.sink) })), secrets_posted: q.updates.filter((u) => u.secrets_posted !== undefined && writerClass(u.by) === 'owner').pop()?.secrets_posted ?? [], status, answer, answered_by: answeredBy, answered_at: answeredAt, matched, autonomy: ans ? (q.autonomy ?? null) : null, standing_rule: ans ? (q.standing_rule ?? null) : null, channel: ans?.channel ?? null, said: ans?.said ?? null, @@ -2023,9 +2023,15 @@ function cmdMerge() { if (av.secrets.length && (!chore?.startsWith('secret-') || !av.answer?.startsWith('Posted'))) fails.push(`${approved} asked for secrets; it approves only its own chore-secret PR, once the owner posted them (its answer: ${av.answer})`); // One answer approves one thing. The exception: one act of the answer's class that recorded the // decision for this very ticket or chore, which this merge now lands. - const prior = logObjects('act').filter((a) => a.approved_by === approved); - const landing = prior.length === 1 && prior[0].class === av.class && (prior[0].refs ?? []).includes(id ?? (chore ? `chore-${chore}` : plan)); - if (prior.length && !landing) fails.push(`${approved} already approved an act; ask again for this one`); + // A question that declared its acts (ask --approves) approves each of them once: only the + // prior acts for this one count, and one not on its list is never approved by it. + const target = id ?? (chore ? `chore-${chore}` : plan); + 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)); + 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`); } // Gates: the project's own commands, at the exact head, in a detached worktree. const gates = Object.entries(loadSettings().gates ?? {}); @@ -2203,6 +2209,15 @@ 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`); + // 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))]; + if (approves.length) { + if (!['owner-decision', 'one-way-door'].includes(kind)) die(`--approves belongs on an owner-decision or one-way-door question, not ${kind}`); + if (approves.length < 2) die('--approves names two or more acts; a question about one act needs no --approves'); + if (approves.length > 10) die('--approves names at most 10 acts; split the question'); + for (const a of approves) if (!(tickets().has(a) || /^chore-[a-z0-9][a-z0-9-]*$/.test(a) || /^plan-[a-z0-9][a-z0-9-]*$/.test(a))) die(`--approves ${a}: name a ticket id, chore- or plan-`); + } const open = questions().filter((x) => x.status === 'open'); let evidence = asList(flags.evidence); let recommendation = flagStr('recommendation') ?? ''; @@ -2238,7 +2253,7 @@ function cmdAsk() { type: 'question', kind, class: cls ?? undefined, scope: scope ?? undefined, ticket: flagStr('ticket'), re: flagStr('re'), question: q, context: kind === 'goal-approval' ? [flagStr('context'), forecastText(logGet(flagStr('goal')), goalForecast(logGet(flagStr('goal'))))].filter(Boolean).join('\n\n') : flagStr('context'), rule_scope: flagStr('rule-scope'), goal: flagStr('goal'), change: goalChangeFlag(cls), options: asList(flags.options), choices: jsonFlag('choices'), links: jsonFlag('links'), recommendation: recommendation || undefined, confidence: flagNum('confidence'), default: def ?? undefined, deadline: def ? new Date(Date.now() + loadSettings().question_default_after_hours * 3600000).toISOString() : undefined, - blocks, evidence, note: flagStr('note'), secrets, review, + blocks, approves: approves.length ? approves : undefined, evidence, note: flagStr('note'), secrets, review, why_asked: { rung: onLadder ? rungFor(cls, scope) : undefined, ceiling: onLadder ? L.classes[cls].ceiling : undefined, reasons }, }, 'supervisor'); out(line.id); @@ -3134,7 +3149,13 @@ function cmdAct() { 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}`); - if (logObjects('act').some((a) => a.approved_by === approved)) die(`${approved} already approved an act; ask again for another`); + 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`); } let rung; if ((L.owner_only ?? []).includes(cls)) { @@ -4252,7 +4273,9 @@ function collectMetrics(since) { // owner's, already in their inbox, so say which question rather than tell the supervisor to act. const ownerQs = ['blocked', 'awaiting-word'].includes(p.phase) ? openQs.filter((q) => q.ticket === id || (q.blocks ?? []).includes(id)) : []; if (p.phase === 'blocked' && h >= 1 && ownerQs.length) add('medium', 'blocked-on-owner', id, `blocked for ${h.toFixed(1)}h on the owner's answer to ${ownerQs.map((q) => q.id).join(', ')}`, 'nothing for the supervisor until it is answered; remind the owner what it blocks if it sits'); - else if (p.phase === 'blocked' && h >= 1) add('high', 'blocked', id, `blocked for ${h.toFixed(1)}h`, 'answer its message, re-plan, or park it'); + // blocked with nothing in the owner's Inbox about it: if it waits on the owner, the question was + // never asked (one put only in the supervisor's chat never reaches the Inbox or the dashboard) + else if (p.phase === 'blocked' && h >= 1) add('high', 'blocked', id, `blocked for ${h.toFixed(1)}h, and no open owner question names it`, `if it waits on the owner, ask (ask --blocks ${id}): a question put only in your chat never reaches the Inbox; otherwise answer its message, re-plan, or park it`); // awaiting the word while an open owner question blocks it: the word waits on the owner if (p.phase === 'awaiting-word' && h >= 1 && ownerQs.length) add('medium', 'blocked-on-owner', id, `waiting for the word for ${h.toFixed(1)}h on the owner's answer to ${ownerQs.map((q) => q.id).join(', ')}`, 'nothing for the supervisor until it is answered; remind the owner what it blocks if it sits'); else if (p.phase === 'awaiting-word' && h >= 1) add('medium', 'waiting-on-supervisor', id, `waiting for the stage or merge word for ${h.toFixed(1)}h`, 'answer its review-request: give the word, run merge, or say what is missing'); diff --git a/test/kit.test.mjs b/test/kit.test.mjs index ec00ba0..05b7bd4 100644 --- a/test/kit.test.mjs +++ b/test/kit.test.mjs @@ -548,6 +548,18 @@ test('devteam kit', async (t) => { must(['repair', A, '--how', 'fix-ticket'], { role: 'sup' }); assert.notEqual(dt(['correct', A, '--reason', 'again']).status, 0, 'one correction per act'); assert.notEqual(dt(['act', '--class', 'gate-change', '--', 'drop a gate'], { role: 'sup' }).status, 0, 'owner-only classes refuse'); + // one answer, several acts, only when the question named them when it was asked + assert.match(dt(['ask', '--question', 'x', '--class', 'gate-change', '--approves', 'bill-01-card'], { role: 'sup' }).err, /two or more acts/); + assert.match(dt(['ask', '--question', 'x', '--class', 'gate-change', '--approves', 'bill-01-card,nope nope'], { role: 'sup' }).err, /--approves nope nope: name a ticket id/); + assert.match(dt(['ask', '--kind', 'owner-action', '--question', 'x', '--approves', 'bill-01-card,chore-y'], { role: 'sup' }).err, /--approves belongs on an owner-decision/); + const Qp = must(['ask', '--question', 'Exclude generated files from dead-code, and raise bill-01-card\'s ceiling?', '--class', 'gate-change', '--recommendation', 'yes to both', '--approves', 'chore-dead-code-generated,bill-01-card'], { role: 'sup' }); + assert.deepEqual(json(must(['questions', '--id', Qp, '--json']))[0].approves, ['chore-dead-code-generated', 'bill-01-card']); + must(['answer', Qp, 'yes to both']); + assert.match(dt(['act', '--class', 'gate-change', '--approved', Qp, '--', 'which?'], { role: 'sup' }).err, /approves chore-dead-code-generated, bill-01-card; name the one/); + must(['act', '--class', 'gate-change', '--approved', Qp, '--refs', 'chore-dead-code-generated', '--', 'excluded generated files'], { role: 'sup' }); + assert.match(dt(['act', '--class', 'gate-change', '--approved', Qp, '--refs', 'chore-dead-code-generated', '--', 'again'], { role: 'sup' }).err, /already approved an act for chore-dead-code-generated/, 'each part once'); + must(['act', '--class', 'gate-change', '--approved', Qp, '--refs', 'bill-01-card', '--', 'raised the ceiling'], { role: 'sup' }); + assert.match(dt(['act', '--class', 'gate-change', '--approved', Qp, '--refs', 'bill-02-x', '--', 'and this'], { role: 'sup' }).err, /name the one this act is for/, 'nothing it did not name'); // production releases are on the ladder: asked at rung 1, released and reported once the owner moves it to 3 assert.equal(dt(['act', '--class', 'release-production', '--', 'tag v1'], { role: 'sup' }).status, 3, 'rung 1: ask first'); const Qr = must(['ask', '--class', 'release-production', '--reasons', 'below-rung', '--question', 'Ship v1 to production?', '--recommendation', 'yes'], { role: 'sup' }); @@ -1407,6 +1419,7 @@ test('devteam kit', async (t) => { fs.appendFileSync(path.join(repo, 'docs/devteam/run/log/bill-03-a.jsonl'), line({ id: `h-${stamp.slice(0, 8)}-${stamp.slice(8)}-bill-03-a`, type: 'phase', op: 'new', at: at.toISOString(), by: 'bill-03-a', to: 'blocked' })); const kinds = () => json(must(['metrics', '--json'])).findings.filter((f) => f.subject === 'bill-03-a').map((f) => f.kind); assert.ok(kinds().includes('blocked'), 'blocked for 2h on nobody in particular'); + assert.match(json(must(['metrics', '--json'])).findings.find((f) => f.subject === 'bill-03-a' && f.kind === 'blocked').suggestion, /a question put only in your chat never reaches the Inbox/, 'and if it waits on the owner, the question was never asked'); const Qo = must(['ask', '--ticket', 'bill-03-a', '--question', 'Which shared module name?', '--recommendation', 'billing-shared'], sup); assert.deepEqual(kinds().filter((k) => k.startsWith('blocked')), ['blocked-on-owner'], "waiting on the owner's answer: say which question, don't tell the supervisor to act"); assert.match(json(must(['metrics', '--json'])).findings.find((f) => f.kind === 'blocked-on-owner').detail, new RegExp(Qo));