diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 78fc5cf..c976c55 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -3115,6 +3115,10 @@ function ownerAnswer(qid, what) { const v = q?.type === 'question' ? questionView(q) : null; if (!v || v.status !== 'answered') die(`${what}: ${qid} is not a question the owner answered`); reviewApproves(v, what); + // An answer approves what it said yes to: the owner's answer must be the question's + // recommendation (a review, its verified result). Answered any other way, the owner declined or + // changed it, and the supervisor asks again; a secrets question approves by what was posted. + if (!(v.secrets ?? []).length && v.kind !== 'review' && !v.matched) die(`${what}: ${qid} was answered "${String(v.answer ?? '').slice(0, 80)}", not its recommendation${v.recommendation ? ` ("${v.recommendation}")` : ' (it has none)'}: that approves nothing; ask again for what the owner wants`); return v; } // A review approves something only through a result the owner gave that still verifies. @@ -4546,9 +4550,9 @@ function decisionAuthorityGap(d) { 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`; + if (ownerOnly) return v && inClass && v.status === 'answered' ? answerUseGap(d, v) : `${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 'answer': return v && inClass && v.status === 'answered' ? answerUseGap(d, v) : `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`; @@ -4556,6 +4560,20 @@ function decisionAuthorityGap(d) { default: return `unknown authority ${d.authority}`; } } +// An owner's answer approves one call, as decide and act enforce (or one per item it approves), +// and the call is about what the question was: a decision line written by hand gets the same rule, +// so it can't reuse an answer that already approved something, or carry one asked about another +// ticket. The earliest call citing the answer (for that item) is the one it approved. +function answerUseGap(d, v) { + const refs = (d.refs ?? []).flatMap((x) => String(x).split(',')); + if (v.ticket && !refs.includes(v.ticket) && !(v.approves ?? []).some((a) => refs.includes(a))) return `${v.id} was asked about ${v.ticket}, not ${refs.join(', ') || 'nothing'}`; + if (d.class === 'approve-plan' && v.kind === 'one-way-door') return null; // a goal-level door covers each plan it names (approve-plan's own check) + const declared = v.approves ?? []; + const part = declared.length ? refs.find((r) => declared.includes(r)) : null; + if (declared.length && !part) return `${v.id} approves ${declared.join(', ')}, and this names none of them`; + const first = calls().filter((c) => c.approved_by === v.id && (!part || (c.refs ?? []).flatMap((x) => String(x).split(',')).includes(part))).sort(byTime)[0]; + return first && first.id !== d.id ? `${v.id} already approved ${first.type === 'decision' ? 'a decision' : 'an act'}${part ? ` for ${part}` : ''} (${first.id})` : null; +} // 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; diff --git a/test/kit.test.mjs b/test/kit.test.mjs index c79c653..856a8c3 100644 --- a/test/kit.test.mjs +++ b/test/kit.test.mjs @@ -705,6 +705,16 @@ test('devteam kit', async (t) => { 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/); + // nor reuse an owner's answer that already approved a call (D used Qx): the earliest call is the one it approved + const rid = 'd-20261101-235959002-supervisor'; + fs.appendFileSync(supFile, line({ id: rid, type: 'decision', op: 'new', at: new Date(Date.now() + 1000).toISOString(), by: 'supervisor', class: 'release-scope', what: 'ship something else', refs: ['bill-02-x'], authority: 'answer', approved_by: Qx })); + assert.match(dt(['act', '--decision', rid, '--', 'shipped'], sup).err, new RegExp(`records authority it doesn't have \\(${Qx} already approved a decision \\(${D}\\)`)); + // nor carry an answer asked about one ticket into a call about another + const Qt = must(['ask', '--question', 'Ship the card flow flagged?', '--class', 'release-scope', '--ticket', 'bill-01-card', '--reasons', 'below-rung', '--recommendation', 'yes'], sup); + must(['answer', Qt, 'yes']); + const tid = 'd-20261101-235959003-supervisor'; + fs.appendFileSync(supFile, line({ id: tid, type: 'decision', op: 'new', at: new Date(Date.now() + 2000).toISOString(), by: 'supervisor', class: 'release-scope', what: 'ship another', refs: ['bill-02-x'], authority: 'answer', approved_by: Qt })); + assert.match(dt(['act', '--decision', tid, '--', 'shipped'], sup).err, new RegExp(`${Qt} was asked about bill-01-card, not bill-02-x`)); fs.writeFileSync(supFile, before); } assert.deepEqual(dl.options, ['ship flagged', 'hold a week']); @@ -948,6 +958,10 @@ test('devteam kit', async (t) => { const refused = dt(['merge', '--chore', 'rules', '--dry-run', '--title', 'chore-rules: tighten', '--body-file', body], { role: 'sup' }); assert.notEqual(refused.status, 0); assert.match(refused.out, /outside project\//); + // the owner's "no" approves nothing: merge needs the answer to be the question's recommendation + const Qn = must(['ask', '--question', 'Add a different rule to quality.md?', '--class', 'governance-edit', '--reasons', 'owner-only', '--recommendation', 'yes'], { role: 'sup' }); + must(['answer', Qn, 'no, leave quality.md alone']); + assert.match(dt(['merge', '--chore', 'rules', '--dry-run', '--approved', Qn, '--title', 'chore-rules: tighten', '--body-file', body], { role: 'sup' }).err, /was answered "no, leave quality\.md alone", not its recommendation \("yes"\): that approves nothing/); const Qg = must(['ask', '--question', 'Add the extra rule to quality.md?', '--class', 'governance-edit', '--reasons', 'owner-only', '--recommendation', 'yes'], { role: 'sup' }); must(['answer', Qg, 'yes']); must(['merge', '--chore', 'rules', '--dry-run', '--approved', Qg, '--title', 'chore-rules: tighten', '--body-file', body], { role: 'sup' });