Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions docs/devteam/scripts/devteam.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -4546,16 +4550,30 @@ 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`;
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}`;
}
}
// 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;
Expand Down
14 changes: 14 additions & 0 deletions test/kit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -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' });
Expand Down
Loading