From 6423e2dbe67fd6cc4b09d2abb5f6465e1d971be2 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 13 Nov 2026 11:35:33 -0500 Subject: [PATCH 1/4] Accept an earlier one-way-door yes; refuse re-asking a decided question A question asked in class one-way-door is a one-way door whatever its kind: approve-plan's door citation, the Goal-level door line, the ticket-level door check, the decision-authority check and the remediation paths now accept it, still only on the owner's own answer, hash bound. ask records --class one-way-door as --kind one-way-door, refuses another kind, and refuses --default on a door. ask refuses a question close to one the owner answered about the same work, pointing at the answer to cite; --new-because "" lets it through and the owner reads it first. Co-Authored-By: Claude Opus 5.5 --- docs/devteam/dashboard/index.html | 3 + docs/devteam/governance/constitution.md | 2 +- docs/devteam/process/supervisor.md | 2 + docs/devteam/schema/log.schema.json | 4 ++ docs/devteam/scripts/devteam.mjs | 89 ++++++++++++++++++++----- test/approvals.test.mjs | 3 +- test/lib/chain.mjs | 87 ++++++++++++++++++++++++ test/search.test.mjs | 44 +++++++++++- 8 files changed, 215 insertions(+), 19 deletions(-) diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index f4801ef0..74b60720 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -208,6 +208,7 @@ .qcard h4 { font-size: 14px; font-weight: 600; margin: 8px 0 4px; line-height: 1.45; } .qcard .ctx { color: var(--fg-2); margin: 4px 0 10px; white-space: pre-wrap; } .qcard .rewritten { color: var(--red-fg); font-weight: 600; margin: 0 0 8px; } + .qcard .again { margin: 0 0 8px; } .choices { display: grid; gap: 6px; margin: 10px 0 8px; } .choice { display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 4px 10px; align-items: start; text-align: left; height: auto; margin: 0; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--line-2); background: var(--surface); color: var(--fg); font-weight: 400; cursor: pointer; } .choice:hover { background: var(--hover); opacity: 1; } @@ -1238,6 +1239,7 @@

Observations

${rankWhy(q)} ${q.thread && !copy ? `` : ''} ${q.rewritten ? '

This question changed after you answered it, so that answer no longer counts. Read it again before you answer: the supervisor\'s log was rewritten, which it should never do.

' : ''} + ${q.new_because ? `

Asked again because: ${esc(q.new_because)}

` : ''}

${qtext(q.question, q.links)}

${q.context ? `

${qtext(q.context, q.links)}

` : ''} ${(q.links || []).length ? `` : ''} @@ -1412,6 +1414,7 @@

${qtext(q.question, q.links)}

${rankWhy(q)} ${q.thread ? `` : ''} ${q.rewritten ? '

This question changed after you answered it, so that answer no longer counts. Read it again before you answer: the supervisor\'s log was rewritten, which it should never do.

' : ''} + ${q.new_because ? `

Asked again because: ${esc(q.new_because)}

` : ''}

${qtext(q.question, q.links)}

${q.context ? `

${qtext(q.context, q.links)}

` : ''}
${rv.data ? `${esc(rv.data.rows)} rows${rv.data.proposals ? `, ${esc(rv.data.proposals)} with a proposal` : ''} · ` : ''}page ${esc(String(rv.page?.sha256 ?? '').slice(0, 12))} ${ok ? 'verified' : `${esc(q.result_status || 'missing')}`}${rv.allow_partial ? ' · partial answers allowed' : ''}${pr && pr.total ? ` · ${esc(pr.done)} of ${esc(pr.total)} decided` : ''}
diff --git a/docs/devteam/governance/constitution.md b/docs/devteam/governance/constitution.md index fabb017d..475078fd 100644 --- a/docs/devteam/governance/constitution.md +++ b/docs/devteam/governance/constitution.md @@ -101,7 +101,7 @@ A change is a one-way door if reverting it is expensive or impossible once shipp An additive change is not a one-way door: a new field, a new optional type or value, a new endpoint or tool beside the old ones, nothing existing removed or narrowed. Decide it yourself and log the act; don't ask the owner to confirm it. When the owner has already answered a one-way door for a goal, that answer covers the tickets that implement it (cite it); never ask the same decision again (`process/supervisor.md` §6). -A one-way door needs an owner answer before the ticket's plan is approved (`act --class approve-plan` refuses without one), two independent final reviews, and `risk: high` on the ticket. The answer is a `one-way-door` question asked for the ticket (`--ticket`), or one the owner already answered for a goal the ticket's feature serves (`--goal`, asked while designing): the supervisor cites that one with `act --class approve-plan --approved `, and the latest approving plan review must say the plan stays inside what the answer covered (`quality.md` §3). An answer for another goal never counts. +A one-way door needs an owner answer before the ticket's plan is approved (`act --class approve-plan` refuses without one), two independent final reviews, and `risk: high` on the ticket. The answer is a `one-way-door` question (`--kind one-way-door`, or any question asked in class `one-way-door`) asked for the ticket (`--ticket`), or one the owner already answered for a goal the ticket's feature serves (`--goal`, asked while designing): the supervisor cites that one with `act --class approve-plan --approved `, and the latest approving plan review must say the plan stays inside what the answer covered (`quality.md` §3). An answer for another goal never counts. ## 5. Changing the rules diff --git a/docs/devteam/process/supervisor.md b/docs/devteam/process/supervisor.md index af11bca6..7ba7d6b5 100644 --- a/docs/devteam/process/supervisor.md +++ b/docs/devteam/process/supervisor.md @@ -198,6 +198,8 @@ Rules: `--goal ` on every question that serves a goal, and `--ticket`/`--b **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. +**Never ask the owner a decision they have already made: cite the answer.** Re-asking is a failure, not caution. An earlier owner answer that covers the same decision is cited, never re-asked: `--approved `, and for a plan under a goal-level one-way door, the plan review's `- Goal-level door: covers this plan` line. That holds whatever kind or class it was asked as: a question asked in class `one-way-door` is a one-way door (`ask` records it as `--kind one-way-door`, and refuses another kind), so its answer is the one-way-door answer. `ask` refuses a question close to one the owner answered about the same work (the same ticket, goal or feature), naming the answer to cite. Only when something material changed since they answered (new facts, a wider scope, a cost they didn't see) ask again with `--new-because ""`; the owner reads it first, as "Asked again because: …". A defaulted or unanswered question is not a decision the owner made, and never blocks one. + **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. Never put a question to the owner through your harness's own blocking dialog (a pop-up or multiple-choice prompt that waits for an answer): it freezes your whole loop, so workers' messages, reviews and merges wait behind it, and it never reaches the Inbox. 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. A goal at its target is one of these: ask `goal-met` (`planning.md` §1), never "mark it met" in chat. Make each one easy to answer: the recommendation first, options, what it blocks, a default where the class allows one. Combine questions only when one answer settles them all. One answer approves one act; a question that settles several acts (two gate changes, a chore and a ticket) names them when asked, `ask --approves |plan-> …`, so the owner's card lists them and the answer approves each once (`merge` the one it names, or `act --refs `) and nothing else. An answer asked without `--approves` can't be stretched over a second act: ask again for it. Trust questions only at a retro. Withdraw what no longer matters (`withdraw --reason …`). If fewer than half of the questions old enough to answer were answered in 14 days, the metrics raise `inbox-stalled`: remind the owner what is blocked and open a problem. **Carrying out an answer outside a merge** (writing data the owner approved in a review, a change in another system): when it's done, log it as an act that names the answer (`act --class carry-answer --refs -- ""`), so the owner sees the answer was acted on and what it changed. diff --git a/docs/devteam/schema/log.schema.json b/docs/devteam/schema/log.schema.json index 0d639d8c..07c61e98 100644 --- a/docs/devteam/schema/log.schema.json +++ b/docs/devteam/schema/log.schema.json @@ -1463,6 +1463,10 @@ "type": "array", "description": "The remediation entries a governance-edit question would approve (name, script, sha, entry_hash; ask --remediations). merge lands a change only if it is one of these, exactly." }, + "new_because": { + "type": "string", + "description": "What materially changed since the owner answered the same decision, when a question asks it again (ask --new-because). The owner reads it first: \"Asked again because: …\"." + }, "owner_said": { "type": "string", "description": "The owner's words, verbatim, when a question asks again after their no. Checked against the transcript like a chat answer's said." diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 7ef8cce8..0e8854e9 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -1622,6 +1622,13 @@ function ratingHistory(q) { const QUESTION_LATER = ['status', 'reason', ...QUESTION_RATING, 'answer', 'said', 'turn', 'autonomy', 'standing_rule', 'secrets_posted', 'result', 'declined', 'question_hash']; const questionHash = (q) => crypto.createHash('sha256').update(canonical(Object.fromEntries(Object.entries(q).filter(([k]) => !k.startsWith('__') && !['updates', 'created_at', 'created_by', 'channel', ...QUESTION_LATER].includes(k))))).digest('hex').slice(0, 16); +// A one-way-door question: asked --kind one-way-door, or asked in the owner-only class one-way-door +// whatever its kind (ask now records those as kind one-way-door; older lines may not). Only the +// question is classified here: what it approves is still the owner's own answer on the fold +// (questionView's status "answered", bound to the question's hash), never a default or a line the +// supervisor wrote, and the class is hashed, so setting it after the answer reopens the question. +const isDoorQuestion = (v) => v?.kind === 'one-way-door' || v?.class === 'one-way-door'; + // Folded questions in the shape the inbox uses. function questionView(q) { const hash = questionHash(q); @@ -1656,7 +1663,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, sensor: q.sensor ?? null, thread: q.thread ?? 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, met: q.met ?? 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 ?? [], stake: ratingOf(q.stake), urgency: ratingOf(q.urgency), settles: Array.isArray(q.settles) ? q.settles.filter((x) => typeof x === 'string') : [], settles_if: typeof q.settles_if === 'string' ? q.settles_if : null, settles_label: [...(q.choices ?? []).map((c) => c?.label), ...(q.options ?? [])].find((l) => sameAnswer(l, q.settles_if)) ?? null, rating_history: ratingHistory(q), approves: q.approves ?? [], evidence: q.evidence ?? [], note: q.note ?? null, why_asked: q.why_asked ?? null, + default: q.default ?? null, deadline: q.deadline ?? null, blocks: q.blocks ?? [], stake: ratingOf(q.stake), urgency: ratingOf(q.urgency), settles: Array.isArray(q.settles) ? q.settles.filter((x) => typeof x === 'string') : [], settles_if: typeof q.settles_if === 'string' ? q.settles_if : null, settles_label: [...(q.choices ?? []).map((c) => c?.label), ...(q.options ?? [])].find((l) => sameAnswer(l, q.settles_if)) ?? null, rating_history: ratingHistory(q), approves: q.approves ?? [], evidence: q.evidence ?? [], note: q.note ?? null, new_because: q.new_because ?? 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, hash, rewritten, autonomy: ans ? (q.autonomy ?? null) : null, standing_rule: ans ? (q.standing_rule ?? null) : null, channel: ans?.channel ?? null, said: ans?.said ?? null, turn: ans?.turn ?? null, @@ -4748,7 +4755,43 @@ function cmdAsk() { out(askLine().id); } // and every earlier answer, is for the recommendation and approves nothing. The kit's own wordings // (goal-met, registry attestations, reviews, goal approvals) are alike by design and skipped. const KIT_WORDED = (cls, kind) => ['goal-met', 'registry-attest'].includes(cls) || ['review', 'goal-approval'].includes(kind); -function askLikeWarnings({ text, cls, kind }) { +// A decision the owner has made is never put to them again (supervisor.md §6): ask refuses a +// question as close as those above to one the owner answered (their own line, bound to the question +// as asked; never a default or an open one) about the same work: a ticket or incident both name, or +// the same goal or feature, and neither about other tickets; or neither about any work at all. +// --new-because says what materially changed, and the owner reads it first. Not for the questions +// whose answers lapse by design (a trust step's evidence, an incident's fix, a remediation's window), +// nor for the kit's own wordings, nor for an answer already spent: one answer approves one call (one +// per item it approves), so a yes that approved its call can't be cited again, and the next call of +// the kind is a new question (the warning below still shows the earlier answer). A one-way door is +// never spent: it covers each plan inside it. +const REASK_EXEMPT = (cls, kind) => KIT_WORDED(cls, kind) || kind === 'trust' || ['incident-fix', 'production-remediation'].includes(cls); +function sameWork(a, b) { + // a request for secrets is about what it asks for: the same secret to the same place, or not the same + const sinks = (x) => (x.secrets ?? []).map((y) => `${y.name}\n${y.sink}`); + if (sinks(a).length || sinks(b).length) return sinks(a).some((y) => sinks(b).includes(y)); + const ta = [a.ticket, ...(a.blocks ?? [])].filter(Boolean); + const tb = [b.ticket, ...(b.blocks ?? [])].filter(Boolean); + if (ta.some((x) => tb.includes(x))) return true; + if (ta.length && tb.length) return false; + if (!ta.length && !tb.length && !a.goal && !b.goal) return true; + if (a.goal && a.goal === b.goal) return true; + return sharesWork(questionAbout(a), questionAbout(b)); +} +function answerSpent(v) { + if (isDoorQuestion(v)) return false; + const used = calls().filter((c) => c.approved_by === v.id); + const declared = v.approves ?? []; + if (!declared.length) return used.length > 0; + const done = new Set(used.flatMap((c) => (c.refs ?? []).flatMap((x) => String(x).split(',')))); + return declared.every((a) => done.has(a)); +} +function reaskWords(v) { + const door = isDoorQuestion(v); + const cite = v.recommendation && v.matched === false && !door ? `they answered other than your recommendation, and that is their decision; act on it, and use it in your recommendations` : `cite the answer instead (--approved ${v.id} on decide or act${door ? `; for a plan under it, act --class approve-plan --approved ${v.id}, and when it was asked for the goal, "- Goal-level door: ${v.id} covers this plan" in the plan review` : ''})`; + return `the owner already decided this: ${v.id} ${quoteData(capBytes300(v.question))} (answered ${quoteData(capBytes300(v.answer))} on ${String(v.answered_at).slice(0, 10)}). Never ask the owner a decision they have made: ${cite}. If something material changed since, ask again with --new-because "", which the owner reads first (supervisor.md §6)`; +} +function askLikeWarnings({ text, cls, kind, about, newBecause }) { if (!text.trim() || KIT_WORDED(cls, kind)) return; const rules = logObjects('knowledge').filter((k) => k.status === 'accepted' && k.kind === 'preference'); const answered = questions().filter((x) => x.status === 'answered' && x.answer && !KIT_WORDED(x.class, x.kind)); @@ -4757,20 +4800,33 @@ function askLikeWarnings({ text, cls, kind }) { const ix = searchIndex(docs); const close = ix.docs.map((d) => ({ d, ...searchSimilar(ix, text, d) })).filter((c) => c.coverage >= 0.6 && c.ratio >= 0.3).sort((a, b) => b.ratio - a.ratio); const line = (s) => quoteData(capBytes300(s)); + const same = REASK_EXEMPT(cls, kind) ? null : close.find((c) => c.d.q && c.d.q.answered_by === 'owner' && !REASK_EXEMPT(c.d.q.class, c.d.q.kind) && sameWork(about, c.d.q) && !answerSpent(c.d.q)); + if (same && !newBecause) die(reaskWords(same.d.q)); for (const c of close.filter((x) => x.d.rule).slice(0, 3)) { const k = c.d.rule; if (cls && writerClass(k.created_by) === 'owner' && k.scope === `class:${cls}`) warn(`a standing rule may settle this: ${k.id} ${line(k.statement)}. If it does, withdraw this question and cite it (--approved ${k.id})`); else warn(`an accepted rule may bear on this: ${k.id} ${line(k.statement)}. It approves nothing here; use it in your recommendation`); } - for (const c of close.filter((x) => x.d.q).slice(0, 3)) warn(`the owner answered a similar question before: ${c.d.q.id} ${line(c.d.q.question)} (answer: ${line(c.d.q.answer)}). If it was this very call and that answer is unused, withdraw this and cite it (--approved ${c.d.q.id}); otherwise it approves nothing here, so use it in your recommendation`); + // An answered one-way door covers each plan inside it, whatever kind it was asked as: cited, never asked again. + for (const c of close.filter((x) => x.d.q).slice(0, 3)) { + if (isDoorQuestion(c.d.q)) warn(`the owner answered a similar one-way door before: ${c.d.q.id} ${line(c.d.q.question)} (answer: ${line(c.d.q.answer)}). If it covers this decision, withdraw this and cite it, never ask it again (supervisor.md §6): act --class approve-plan --approved ${c.d.q.id}, with "- Goal-level door: ${c.d.q.id} covers this plan" in the plan review when it was asked for the goal`); + else warn(`the owner answered a similar question before: ${c.d.q.id} ${line(c.d.q.question)} (answer: ${line(c.d.q.answer)}). If it was this very call and that answer is unused, withdraw this and cite it (--approved ${c.d.q.id}); otherwise it approves nothing here, so use it in your recommendation`); + } } function askLine({ thread } = {}) { supervisorOnly('ask'); // a goal-met question's words are the kit's (below) let q = flagStr('question') ?? (flagStr('class') === 'goal-met' ? null : die('ask needs --question')); - const kind = flagStr('kind') ?? 'owner-decision'; - if (['proposal', 'trust'].includes(kind)) upkeepAllows(`a ${kind} question`); const cls = flagStr('class') ?? null; + // A question in the owner-only class one-way-door is a one-way door: left without --kind it is + // asked as one, and asked as another kind it is refused, so the owner's answer is the one-way-door + // answer approve-plan and the plan review cite (and never re-asked as one), and the kind's own + // rules (no default, the Inbox's floor) hold. Refused, not rewritten: another kind given on purpose + // (a review, an owner-action with secrets) has rules of its own a silent change would drop. + const kind = flagStr('kind') ?? (cls === 'one-way-door' ? 'one-way-door' : 'owner-decision'); + if (cls === 'one-way-door' && kind !== 'one-way-door') die(`a question in class one-way-door is a one-way door: ask it --kind one-way-door, not --kind ${kind}`); + if (kind === 'one-way-door' && flagStr('default') !== undefined) die('a one-way door never has a default: only the owner answers it (constitution.md §4)'); + if (['proposal', 'trust'].includes(kind)) upkeepAllows(`a ${kind} question`); const L = ladder(); if (kind === 'goal-approval') { const g = logGet(flagStr('goal') ?? ''); @@ -5015,9 +5071,12 @@ function askLine({ thread } = {}) { if (rating.stake === undefined || rating.urgency === undefined) warn('rate it: --stake 1-3 --urgency 1-3, so the Inbox can put the most valuable question first (supervisor.md §6)'); ratingWarnings({ ...rating, blocks, def }); // what the owner may already have said about it: never in the way of asking - try { askLikeWarnings({ text: textOf(q, flagStr('context'), flagStr('recommendation'), [].concat(jsonFlag('choices') ?? []).map((c) => c?.label)), cls, kind }); } catch (e) { warn(`the check for earlier answers failed (${e.message}); the question is asked anyway`); } + // and a decision they already made is refused, unless something material changed (--new-because) + const newBecause = flagStr('new-because'); + if (newBecause !== undefined && !newBecause.trim()) die('--new-because says what materially changed since the owner answered; it can\'t be blank'); + try { askLikeWarnings({ text: textOf(q, flagStr('context'), flagStr('recommendation'), [].concat(jsonFlag('choices') ?? []).map((c) => c?.label)), cls, kind, about: { goal: flagStr('goal'), ticket: flagStr('ticket'), blocks, secrets }, newBecause: newBecause?.trim() }); } catch (e) { warn(`the check for earlier answers failed (${e.message}); the question is asked anyway`); } return appendLog({ - type: 'question', kind, class: cls ?? undefined, scope: scope ?? undefined, ticket: flagStr('ticket'), re: flagStr('re'), sensor: flagStr('sensor'), remediation: remediationPin ?? undefined, remediation_entries: remediationEntries, owner_said: ownerSaid ?? undefined, question: q, context: kind === 'goal-approval' ? [flagStr('context'), forecastText(logGet(flagStr('goal')), goalForecast(logGet(flagStr('goal')))), registryAttestText(logGet(flagStr('goal')))].filter(Boolean).join('\n\n') : context, + type: 'question', kind, class: cls ?? undefined, scope: scope ?? undefined, ticket: flagStr('ticket'), re: flagStr('re'), sensor: flagStr('sensor'), remediation: remediationPin ?? undefined, remediation_entries: remediationEntries, owner_said: ownerSaid ?? undefined, new_because: newBecause?.trim() || undefined, question: q, context: kind === 'goal-approval' ? [flagStr('context'), forecastText(logGet(flagStr('goal')), goalForecast(logGet(flagStr('goal')))), registryAttestText(logGet(flagStr('goal')))].filter(Boolean).join('\n\n') : context, rule_scope: flagStr('rule-scope'), goal: flagStr('goal'), change: goalChangeFlag(cls), options: asList(flags.options), choices: goalMet ? GOAL_MET_CHOICES : jsonFlag('choices'), met: goalMet ? { basis: goalMet.basis, reading: goalMet.reading } : undefined, links, recommendation: recommendation || undefined, confidence: flagNum('confidence'), default: def ?? undefined, deadline: def ? deadlineAt ?? new Date(Date.now() + loadSettings().question_default_after_hours * 3600000).toISOString() : undefined, blocks, ...rating, settles: settles.length ? settles : undefined, settles_if: settles.length ? flagStr('settles-if') : undefined, @@ -7493,7 +7552,7 @@ function cmdQuestions() { if (flags.json) return json(qs); if (!qs.length) return out('no questions'); for (const q of qs) { - out(`${q.id} [${q.kind}, ${q.status}${q.goal_hold ? `, its goal ${holdWords(q.goal_hold)}` : ''}]${q.blocks.length ? ` blocks ${q.blocks.join(',')}` : ''}\n ${q.question}`); + out(`${q.id} [${q.kind}, ${q.status}${q.goal_hold ? `, its goal ${holdWords(q.goal_hold)}` : ''}]${q.blocks.length ? ` blocks ${q.blocks.join(',')}` : ''}${q.new_because ? `\n Asked again because: ${q.new_because}` : ''}\n ${q.question}`); for (const c of q.choices) out(` - ${c.label}${c.label === q.recommendation ? ' (recommended)' : ''}${c.detail ? `: ${c.detail}` : ''}${c.effect ? ` For users: ${c.effect}` : ''}`); for (const l of q.links) out(` see: ${l.title} (${l.url ?? (l.remote ? `repo ${l.remote}` : `${l.ref ? `${l.ref}:` : ''}${l.path}`)})`); if (q.review) out(` review: ${q.review.page?.path} at ${String(q.review.commit).slice(0, 12)} (${q.review.ref})${q.review.data ? `, ${q.review.data.rows} rows` : ''}; ${q.result_status} (devteam.mjs review show ${q.id})`); @@ -7550,7 +7609,7 @@ function authorise(cls, scope, approved, refs, verb) { } // approve-plan may cite the owner's answer to a one-way door asked at goal level (constitution.md §4); // 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'; + const doorCited = cls === 'approve-plan' && isDoorQuestion(approval); if (approval && !doorCited) { if (approval.class !== cls) die(`${approved} was asked in class ${approval.class ?? '(none)'}, not ${cls}`); const declared = approval.approves ?? []; @@ -7606,7 +7665,7 @@ function decisionAuthorityGap(d) { 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')); + const inClass = v && (v.class === d.class || (d.class === 'approve-plan' && isDoorQuestion(v))); if (ownerOnly && !(d.authority === 'standing-rule' && (L.owner_rules ?? []).includes(d.class))) 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' ? answerUseGap(d, v) : `no answered ${d.class} question ${d.approved_by ?? ''}`.trim(); @@ -7632,7 +7691,7 @@ function decisionAuthorityGap(d) { 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) + if (d.class === 'approve-plan' && isDoorQuestion(v)) 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`; @@ -7747,7 +7806,7 @@ function cmdAct() { 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' }; + auth = { rung: undefined, approval, doorCited: cls === 'approve-plan' && isDoorQuestion(approval) }; } 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. @@ -7765,7 +7824,7 @@ function cmdAct() { if (!goals.includes(approval.goal)) die(`${approved} answered a one-way door for goal ${approval.goal}, but ${t.id}'s feature ${t.feature} serves ${goals.join(', ') || 'no goal'}; an answer for another goal never covers this plan`); goalDoor = approved; } - } else if (doors.length && !questions().some((q) => q.kind === 'one-way-door' && q.ticket === t.id && q.status === 'answered')) die(`${t.id} lists one-way doors; the owner answers a one-way-door question (--ticket ${t.id}) before its plan is approved, or you cite the owner's answer to the goal's one-way-door question (--approved ) and the plan review says it covers the plan (constitution.md §4)`); + } else if (doors.length && !questions().some((q) => isDoorQuestion(q) && q.ticket === t.id && q.status === 'answered')) die(`${t.id} lists one-way doors; the owner answers a one-way-door question (--ticket ${t.id}) before its plan is approved, or you cite the owner's answer to the goal's one-way-door question (--approved ) and the plan review says it covers the plan (constitution.md §4)`); // The plan opens with its TL;DR, so a low-risk plan can be approved from it (quality.md §6). const R = repoOfTicket(t); // an act: fails fast on a missing or wrong checkout const ref = [`ticket/${t.id}`, `origin/ticket/${t.id}`].find((r) => R.resolveCommit(r)); @@ -14756,7 +14815,7 @@ async function cmdRemediate() { // (never a standing rule or the class's rung); the question was asked about this remediation (and // this release), and the answer is its recommendation, or its default applied at or after its deadline. if (!['answer', 'default'].includes(d.authority) || !q) die(`${d.id} rests on ${d.authority ?? 'nothing'}${d.approved_by ? ` (${d.approved_by})` : ''}: a remediation runs only on the owner's answer to a production-remediation question, or its default`); - if (q.class !== 'production-remediation' && !(q.kind === 'one-way-door' || (q.why_asked?.reasons ?? []).includes('one-way-door'))) die(`${q.id} was asked in class ${q.class ?? '(none)'}, not production-remediation`); + if (q.class !== 'production-remediation' && !(isDoorQuestion(q) || (q.why_asked?.reasons ?? []).includes('one-way-door'))) die(`${q.id} was asked in class ${q.class ?? '(none)'}, not production-remediation`); if (!(q.blocks ?? []).includes(remediationRef(action))) die(`${q.id} was asked about ${(q.blocks ?? []).filter((x) => String(x).startsWith('remediation:')).join(', ') || 'no remediation'}, not ${remediationRef(action)}`); const qrel = (q.blocks ?? []).find((x) => String(x).startsWith('release:')) ?? null; const drel = (d.refs ?? []).find((x) => String(x).startsWith('release:')) ?? null; @@ -14784,7 +14843,7 @@ async function cmdRemediate() { if (to !== rel) die(`${d.id} decided a rollback to ${rel ?? 'no release'}, not ${to}`); const crossed = migrationsSince(to); if (!crossed) die(`no deploy of release ${to} was observed by a sensor: a rollback goes to a release the sensors saw`); - if (crossed.length && !(q && q.status === 'answered' && !q.default && (q.kind === 'one-way-door' || (q.why_asked?.reasons ?? []).includes('one-way-door')))) die(`rolling back to ${to} crosses ${crossed.map((a) => a.id).join(', ')} (production-migration): a one-way door, whatever reversible says. It needs the owner's answer to an ask --kind one-way-door with no default (decide --approved )`); + if (crossed.length && !(q && q.status === 'answered' && !q.default && (isDoorQuestion(q) || (q.why_asked?.reasons ?? []).includes('one-way-door')))) die(`rolling back to ${to} crosses ${crossed.map((a) => a.id).join(', ')} (production-migration): a one-way door, whatever reversible says. It needs the owner's answer to an ask --kind one-way-door with no default (decide --approved )`); } const m = { name: `remediation-${action}`, kind: 'probe', probe: r.script, params: { ...r.params, ...(to ? { to } : {}) }, uses: r.uses, credentials: r.credentials }; let env, redact, src; diff --git a/test/approvals.test.mjs b/test/approvals.test.mjs index 995e3c0e..9d60a643 100644 --- a/test/approvals.test.mjs +++ b/test/approvals.test.mjs @@ -20,7 +20,8 @@ function choreOf(F, slug, files) { write(path.join(old, `docs/devteam/run/pr/chore-${slug}.md`), `# chore-${slug}: x\n\n## One-way doors\n\nNone.\n`); return (...extra) => { const r = run(old, ['merge', '--chore', slug, '--dry-run', ...extra], SUP); return { status: r.status, text: r.out + r.err }; }; } -const answered = (F, opts, answer, role) => { const q = F.ok(F.old, ['ask', ...opts], SUP); F.ok(F.old, ['answer', q, answer, ...(role ?? [])], role ? SUP : undefined); return q; }; +// the same words asked in several classes on purpose: each is asked again past ask's re-ask refusal +const answered = (F, opts, answer, role) => { const q = F.ok(F.old, ['ask', ...opts, '--new-because', 'the test asks it in another class'], SUP); F.ok(F.old, ['answer', q, answer, ...(role ?? [])], role ? SUP : undefined); return q; }; const asked = (question, cls) => ['--question', question, '--recommendation', 'yes', ...(cls ? ['--class', cls, '--reasons', cls === 'release-scope' ? 'below-rung' : 'owner-only'] : [])]; test('a chore that changes a real kit file needs a kit-class answer; outside the kit, the chore rule is as it was', () => { diff --git a/test/lib/chain.mjs b/test/lib/chain.mjs index 6103af71..197b07eb 100644 --- a/test/lib/chain.mjs +++ b/test/lib/chain.mjs @@ -3051,6 +3051,93 @@ http.createServer((req, res) => { git(['worktree', 'remove', '--force', wt('bill-02-vault')]); }); + step('a one-way door asked in class one-way-door, whatever its kind, is the owner\'s one-way-door answer: cited, never re-asked', (t) => { + const sup = { role: 'sup' }; + const logDir = path.join(repo, 'docs/devteam/run/log'); + // the lines written by hand here would be forgeries to every later test: put the log back + const snap = new Map(fs.readdirSync(logDir).map((f) => [f, fs.readFileSync(path.join(logDir, f))])); + t.after(() => { for (const f of fs.readdirSync(logDir)) if (snap.has(f)) fs.writeFileSync(path.join(logDir, f), snap.get(f)); else fs.rmSync(path.join(logDir, f), { recursive: true, force: true }); }); + const supFile = path.join(logDir, fs.readdirSync(logDir).find((f) => /^supervisor-/.test(f))); + // a line the supervisor writes into its own file, after the file's last line (as a kit before ask + // normalised the kind wrote it, or as a session could write it by hand) + const lastAt = () => fs.readFileSync(supFile, 'utf8').trim().split('\n').map((l) => JSON.parse(l).at).sort().pop(); + const next = () => new Date(Math.max(Date.parse(lastAt()) + 1, Date.now())); + const hand = (o) => { + const d = next(); + const id = o.op === 'set' ? o.id : `q-${d.toISOString().slice(0, 10).replace(/-/g, '')}-${d.toISOString().slice(11, 23).replace(/[:.]/g, '')}-supervisor`; + fs.appendFileSync(supFile, line({ type: 'question', op: 'new', ...o, id, at: d.toISOString(), by: 'supervisor' })); + return id; + }; + const tk = { title: 'shared gate', feature: 'billing', component: 'bill', kind: 'build', size: 'S', risk: 'high', serves: ['BILL-S1'], depends_on: [], touches: ['billing/gate/**'], done_when: ['gate test passes in CI'], one_way_doors: ['move the proposal gate into a shared package'] }; + must(['ticket', 'add', 'bill-09-door', '--json', JSON.stringify(tk)], sup); + // the owner's yes to a goal-level question asked as an owner-decision in class one-way-door + const Dc = hand({ kind: 'owner-decision', class: 'one-way-door', goal: G, question: 'Move the proposal gate into a shared package?', recommendation: 'yes' }); + must(['answer', Dc, 'yes']); + // the same kind of question, defaulted by the supervisor; and one "answered" by lines the supervisor wrote + const Dd = hand({ kind: 'owner-decision', class: 'one-way-door', goal: G, question: 'Share the gate across apps?', recommendation: 'yes', default: 'yes' }); + hand({ op: 'set', id: Dd, status: 'defaulted' }); + const Df = hand({ kind: 'owner-decision', class: 'one-way-door', goal: G, question: 'Ship the shared gate now?', recommendation: 'yes' }); + hand({ op: 'set', id: Df, status: 'answered', answer: 'yes' }); // the supervisor may not set answer: the fold drops the line + hand({ op: 'set', id: Df, status: 'answered' }); + fs.appendFileSync(supFile, line({ id: Df, type: 'question', op: 'set', at: next().toISOString(), by: 'owner', channel: 'cli', answer: 'yes' })); // "the owner" in the supervisor's file + // and an owner-decision the owner did answer, which a later line of the supervisor's tries to make a door + const Dp = hand({ kind: 'owner-decision', goal: G, question: 'Name the shared package billing-gate?', recommendation: 'yes' }); + must(['answer', Dp, 'yes']); + hand({ op: 'set', id: Dp, class: 'one-way-door' }); + const qs = json(must(['questions', '--json', '--all'])); + const st = (id) => qs.find((q) => q.id === id)?.status; + assert.deepEqual([st(Dc), st(Dd), st(Df), st(Dp)], ['answered', 'defaulted', 'open', 'answered'], "only the owner's own line answers"); + assert.equal(qs.find((q) => q.id === Dp).class, null, "the supervisor never sets a question's class"); + git(['worktree', 'add', '-q', '-b', 'ticket/bill-09-door', wt('bill-09-door'), 'HEAD']); + t.after(() => git(['worktree', 'remove', '--force', wt('bill-09-door')])); + const tdir = path.join(wt('bill-09-door'), 'docs/devteam/project/tickets/bill-09-door'); + const commit = (msg) => { git(['add', '-A'], wt('bill-09-door')); git(['commit', '-qm', msg], wt('bill-09-door')); return git(['rev-parse', 'HEAD'], wt('bill-09-door')); }; + write(path.join(tdir, 'plan.md'), '# plan\n\n## TL;DR\n\n- move the gate into packages/gate\n\n## Tasks\n\n- T1 gate\n'); + const p1 = commit('bill-09-door: plan'); + const covers = (...ids) => { write(path.join(tdir, 'reviews/plan-1.md'), review('reviewer-a', p1).replace('\n\n|', `\n${ids.map((id) => `- Goal-level door: ${id} covers this plan`).join('\n')}\n\n|`)); commit('plan review'); }; + covers(Dc, Dd, Df, Dp); + const ap = (...extra) => dt(['act', '--class', 'approve-plan', '--refs', 'bill-09-door', ...extra, '--', 'plan ok'], sup); + assert.match(ap().err, /lists one-way doors; /, 'nothing cited, nothing answered for the ticket'); + assert.match(ap('--approved', Dd).err, /never a default|not approval there/, 'a defaulted door approves nothing'); + assert.match(ap('--approved', Df).err, /is not an answered question/, "the supervisor's own lines answer nothing"); + assert.match(ap('--approved', Dp).err, /was asked in class \(none\), not approve-plan/, 'a class set by the supervisor counts for nothing'); + // a decision the supervisor writes by hand on the defaulted door carries nothing either + const fid = `d-${next().toISOString().slice(0, 10).replace(/-/g, '')}-235959500-supervisor`; + fs.appendFileSync(supFile, line({ id: fid, type: 'decision', op: 'new', at: next().toISOString(), by: 'supervisor', class: 'approve-plan', what: 'plan ok', refs: ['bill-09-door'], authority: 'answer', approved_by: Dd, reversible: true })); + assert.match(dt(['act', '--decision', fid, '--', 'plan ok'], sup).err, /records authority it doesn't have \(no answered approve-plan question/); + // the plan review must still say the plan stays inside the answer + write(path.join(tdir, 'reviews/plan-1.md'), review('reviewer-a', p1)); + commit('review without the door line'); + assert.match(ap('--approved', Dc).err, new RegExp(`must say "- Goal-level door: ${Dc} covers this plan"`)); + covers(Dc); + // decided on the owner's yes, then carried out: the decision's authority checks out too + const d = must(['decide', '--class', 'approve-plan', '--refs', 'bill-09-door', '--approved', Dc, '--', 'approve the gate plan'], sup); + must(['act', '--decision', d, '--', 'plan ok'], sup); + assert.equal(json(must(['log', '--type', 'decision', '--json'])).find((x) => x.id === d).approved_by, Dc, 'the earlier yes approves the plan; no second question'); + // asked for the ticket, the same holds without a review line + fs.rmSync(path.join(tdir, 'reviews'), { recursive: true }); + commit('no reviews'); + must(['ticket', 'set', 'bill-09-door', '--json', JSON.stringify({ one_way_doors: ['move the proposal gate into a shared package', 'drop the old gate'] })], sup); + const Dt = hand({ kind: 'owner-decision', class: 'one-way-door', ticket: 'bill-09-door', question: 'Drop the old gate?', recommendation: 'yes' }); + assert.match(ap().err, /lists one-way doors/, 'an open ticket-level door is not an answer'); + must(['answer', Dt, 'yes']); + must(['act', '--class', 'approve-plan', '--refs', 'bill-09-door', '--', 'plan ok'], sup); + // ask: class one-way-door is asked as a one-way door, never as another kind, and never with a default + // the same decision again is refused, with the answer to cite; something material changed lets it through, said first + const again = ['ask', '--class', 'one-way-door', '--goal', G, '--question', 'Move the proposal gate into a shared package?', '--recommendation', 'yes']; + assert.match(dt(again, sup).err, new RegExp(`the owner already decided this: ${Dc} .*cite the answer instead \\(--approved ${Dc} .*"- Goal-level door: ${Dc} covers this plan".*--new-because`)); + const r = dt([...again, '--new-because', 'the package now ships to a second app'], sup); + assert.equal(r.status, 0, r.err); + const asked = json(must(['questions', '--id', r.out, '--json']))[0]; + assert.equal(asked.kind, 'one-way-door', 'left without --kind, it is asked as a one-way door'); + assert.equal(asked.new_because, 'the package now ships to a second app'); + assert.match(r.err, new RegExp(`answered a similar one-way door before: ${Dc}.*never ask it again \\(supervisor\\.md §6\\): act --class approve-plan --approved ${Dc}`), 'the warning still points at citing it'); + must(['withdraw', r.out, '--reason', 'test'], sup); + assert.match(dt(['ask', '--class', 'one-way-door', '--kind', 'owner-decision', '--goal', G, '--question', 'x?', '--recommendation', 'yes'], sup).err, /a question in class one-way-door is a one-way door: ask it --kind one-way-door, not --kind owner-decision/); + assert.match(dt(['ask', '--class', 'one-way-door', '--goal', G, '--question', 'x?', '--recommendation', 'yes', '--default', 'yes'], sup).err, /a one-way door never has a default/); + assert.match(dt(['ask', '--kind', 'one-way-door', '--goal', G, '--question', 'x?', '--recommendation', 'yes', '--default', 'yes'], sup).err, /a one-way door never has a default/); + }); + step('a ticket\'s merge gate fails validate only for overlaps that are its own (D4)', async () => { const sup = { role: 'sup' }; const tk = (title) => JSON.stringify({ title, feature: 'billing', component: 'bill', kind: 'build', size: 'S', risk: 'normal', serves: ['BILL-S1'], depends_on: [], touches: ['billing/shared/**'], done_when: ['tests pass in CI'] }); diff --git a/test/search.test.mjs b/test/search.test.mjs index 79ff1e0e..86dbcb6f 100644 --- a/test/search.test.mjs +++ b/test/search.test.mjs @@ -149,18 +149,58 @@ test("ask: rules and earlier answers that look like the question, as warnings th const q1 = ok(old, ['ask', '--question', 'Should we adopt new devteam releases without asking?', '--class', 'governance-edit', '--choices', '[{"label":"Yes"},{"label":"No"}]'], SUP).trim(); ok(old, ['answer', q1, 'Yes']); ok(old, ['knowledge', 'add', '--kind', 'preference', '--statement', 'Always adopt new devteam releases without asking', '--scope', 'class:governance-edit', '--owner']); - const near = run(old, ['ask', '--question', 'Adopt the new devteam releases without asking each time?', '--class', 'governance-edit'], SUP); + // the same decision the owner already made is refused, pointing at the answer to cite + const reask = run(old, ['ask', '--question', 'Adopt the new devteam releases without asking each time?', '--class', 'governance-edit'], SUP); + assert.notEqual(reask.status, 0, 'a decision the owner made is not asked again'); + assert.match(reask.err, new RegExp(`the owner already decided this: ${q1} "Should we adopt new devteam releases without asking\\?" \\(answered "Yes" on \\d{4}-\\d{2}-\\d{2}\\)\\. .*cite the answer instead \\(--approved ${q1} on decide or act\\)\\. .*--new-because`)); + assert.match(run(old, ['ask', '--question', 'x?', '--new-because', ' '], SUP).err, /--new-because .*can't be blank/); + const near = run(old, ['ask', '--question', 'Adopt the new devteam releases without asking each time?', '--class', 'governance-edit', '--new-because', 'the releases now change the merge gate'], SUP); assert.equal(near.status, 0, near.err); + const nv = JSON.parse(ok(old, ['questions', '--id', near.out.trim(), '--json']))[0]; + assert.equal(nv.new_because, 'the releases now change the merge gate', 'recorded on the question'); + assert.match(ok(old, ['questions', '--id', near.out.trim()]), /\n {2}Asked again because: the releases now change the merge gate\n {2}Adopt the new/, 'and read before the question'); assert.match(near.out.trim(), /^q-\S+$/, 'stdout is still the id alone'); assert.match(near.err, /a standing rule may settle this: k-\S+ "Always adopt/, near.err); assert.match(near.err, new RegExp(`answered a similar question before: ${q1} .*otherwise it approves nothing here`)); const far = run(old, ['ask', '--question', 'Which colour should the login button be?'], SUP); assert.doesNotMatch(far.err, /standing rule|answered a similar|may bear/, far.err); - const other = run(old, ['ask', '--question', 'Adopt the new devteam releases without asking each time?', '--class', 'design'], SUP); + // asked in another class, it is still the decision the owner made: refused without --new-because + assert.match(run(old, ['ask', '--question', 'Adopt the new devteam releases without asking each time?', '--class', 'design'], SUP).err, /already decided this/, 'another type is the same question'); + const other = run(old, ['ask', '--question', 'Adopt the new devteam releases without asking each time?', '--class', 'design', '--new-because', 'a design question now'], SUP); assert.match(other.err, /an accepted rule may bear on this: k-\S+ .*It approves nothing here/, 'a rule for another class only bears on it'); } finally { F.done(); } }); +test('ask refuses only a decision the owner answered: not a defaulted or open one, nor one about other work', () => { + const F = fleetFixture('search-reask'); + const { old, ok, run } = F; + const SUP = { DEVTEAM_ROLE: 'supervisor' }; + try { + // open, then defaulted: the supervisor's own status line, never the owner's answer + const qo = ok(old, ['ask', '--question', 'Should the nightly export include archived invoices?', '--recommendation', 'yes'], SUP).trim(); + const qd = ok(old, ['ask', '--question', 'Should the weekly digest list every failed payment retry?', '--recommendation', 'yes', '--default', 'yes'], SUP).trim(); + const log = path.join(old, 'docs/devteam/run/log'); + const sf = path.join(log, fs.readdirSync(log).find((f) => f.startsWith('supervisor-'))); + fs.appendFileSync(sf, `${JSON.stringify({ v: 1, id: qd, type: 'question', op: 'set', at: '2099-01-01T00:00:00.000Z', by: 'supervisor', status: 'defaulted' })}\n`); + assert.equal(JSON.parse(ok(old, ['questions', '--id', qd, '--json']))[0].status, 'defaulted'); + for (const [q, near] of [[qo, 'Should the nightly export include the archived invoices too?'], [qd, 'Should the weekly digest list each failed payment retry?']]) { + const r = run(old, ['ask', '--question', near, '--recommendation', 'yes'], SUP); + assert.equal(r.status, 0, `${q}: ${r.err}`); + assert.doesNotMatch(r.err, /already decided/); + } + // answered, about one goal: the same words about another goal are another decision + const g1 = ok(old, ['goal', 'add', '--title', 'Exports'], SUP).trim(); + const g2 = ok(old, ['goal', 'add', '--title', 'Imports'], SUP).trim(); + const qa = ok(old, ['ask', '--goal', g1, '--question', 'Should files be written as UTF-8 CSV with a header row?', '--recommendation', 'yes'], SUP).trim(); + ok(old, ['answer', qa, 'yes']); + const same = ['ask', '--question', 'Should the files be written as UTF-8 CSV with a header row?', '--recommendation', 'yes']; + assert.match(run(old, [...same, '--goal', g1], SUP).err, new RegExp(`already decided this: ${qa} `), 'the same goal: refused'); + const r2 = run(old, [...same, '--goal', g2], SUP); + assert.equal(r2.status, 0, r2.err); + assert.match(r2.err, new RegExp(`answered a similar question before: ${qa}`), 'another goal: asked, with the earlier answer for the recommendation'); + } finally { F.done(); } +}); + test('on a corpus the size of a real project, a question is found by its own context: all but 2% in the top 3', () => { const C = searchCorpus(); const docs = [...C.questions.map((q) => ({ id: q.id, fields: [{ text: q.question, w: 2 }, { text: q.context, w: 1 }] })), ...C.observations.map((o) => ({ id: o.id, fields: [{ text: o.subject, w: 2 }, { text: o.detail, w: 1 }] })), ...C.lessons.map((k) => ({ id: k.id, fields: [{ text: k.statement, w: 2 }] }))]; From aa62a9215a1f57a59a40ce33f920f8ed87865150 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 13 Nov 2026 11:37:01 -0500 Subject: [PATCH 2/4] =?UTF-8?q?supervisor.md=20=C2=A76:=20one=20rule=20aga?= =?UTF-8?q?inst=20re-asking,=20cite=20the=20answer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/devteam/process/supervisor.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/devteam/process/supervisor.md b/docs/devteam/process/supervisor.md index 2219820a..428c7b4e 100644 --- a/docs/devteam/process/supervisor.md +++ b/docs/devteam/process/supervisor.md @@ -206,9 +206,9 @@ devteam.mjs ask --kind one-way-door --class new-dependency --reasons owner-only Rules: `--goal ` on every question that serves a goal, and `--ticket`/`--blocks` for the work it's about, so the goal and the board show it; one decision per question (a goal-definition question lists all of a goal's gaps as a form); lead with the decision; always a recommendation with your confidence; `--default` only when reversible and the class is on rung 2 or above (`ask` refuses it below, on a one-way door and on an owner-only class), applied at the deadline; `--blocks` every ticket that waits; `--class` and `--reasons` always, so the owner sees why it needs them. -**Never ask the owner the same decision twice.** If they have answered it (in the inbox, on the dashboard, or in chat, recorded with `answer --from-chat`), use that answer. When a kit change makes an earlier answer look insufficient, carry it forward instead of re-asking: for a goal change answered before questions recorded their change, `goal set --approved --change '{…}'` with the change they chose, which is logged as a `carry-answer` act they can correct; for anything else, cite the answer (`--approved`, `--evidence`) and say so in the act. If the kit truly can't take the answer, raise it upstream rather than asking again. +**Never ask the owner a decision they have already made: cite the answer.** Re-asking is a failure, not caution. 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. -**Never ask the owner a decision they have already made: cite the answer.** Re-asking is a failure, not caution. An earlier owner answer that covers the same decision is cited, never re-asked: `--approved `, and for a plan under a goal-level one-way door, the plan review's `- Goal-level door: covers this plan` line. That holds whatever kind or class it was asked as: a question asked in class `one-way-door` is a one-way door (`ask` records it as `--kind one-way-door`, and refuses another kind), so its answer is the one-way-door answer. `ask` refuses a question close to one the owner answered about the same work (the same ticket, goal or feature), naming the answer to cite. Only when something material changed since they answered (new facts, a wider scope, a cost they didn't see) ask again with `--new-because ""`; the owner reads it first, as "Asked again because: …". A defaulted or unanswered question is not a decision the owner made, and never blocks one. +An earlier owner answer that covers the same decision is cited, never re-asked: `--approved `, and for a plan under a goal-level one-way door, the plan review's `- Goal-level door: covers this plan` line. That holds whatever kind or class it was asked as: a question asked in class `one-way-door` is a one-way door (`ask` records it as `--kind one-way-door`, and refuses another kind), so its answer is the one-way-door answer. `ask` refuses a question close to one the owner answered about the same work (the same ticket, goal or feature), naming the answer to cite. Only when something material changed since they answered (new facts, a wider scope, a cost they didn't see) ask again with `--new-because ""`; the owner reads it first, as "Asked again because: …". A defaulted or unanswered question is not a decision the owner made, and never blocks one. **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. Never put a question to the owner through your harness's own blocking dialog (a pop-up or multiple-choice prompt that waits for an answer): it freezes your whole loop, so workers' messages, reviews and merges wait behind it, and it never reaches the Inbox. 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. A goal at its target is one of these: ask `goal-met` (`planning.md` §1), never "mark it met" in chat. 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. From e124e0ef061d043b15864223da097cb63419b72c Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 13 Nov 2026 11:56:56 -0500 Subject: [PATCH 3/4] Review fixes: door answers bound tighter, re-ask refusal narrower - A goal-level door is cited only as recommended, never from a ticket-level question for another ticket; citing it for plans doesn't use it up for the one-way-door act itself. - ask refuses only decisions (owner-decision, one-way door, a secret already asked for) in the same class, or a door re-asked in any shape; worded alike word for word (versions and names count whole); not after the answer was spent (goal changes included), not for another pinned draft, not when only a blocked ticket differs. --new-because is left off a first question. - Merged #210's own door default refusal in place of ours. Co-Authored-By: Claude Opus 5.5 --- docs/devteam/process/supervisor.md | 2 +- docs/devteam/scripts/devteam.mjs | 61 ++++++++++++++++++++++------- test/approvals.test.mjs | 3 +- test/fixtures/g1/dashboard-data.txt | 1 + test/lib/chain.mjs | 26 +++++++++--- test/next-step.test.mjs | 3 +- test/search.test.mjs | 19 ++++++--- 7 files changed, 86 insertions(+), 29 deletions(-) diff --git a/docs/devteam/process/supervisor.md b/docs/devteam/process/supervisor.md index 428c7b4e..3acdd68d 100644 --- a/docs/devteam/process/supervisor.md +++ b/docs/devteam/process/supervisor.md @@ -208,7 +208,7 @@ Rules: `--goal ` on every question that serves a goal, and `--ticket`/`--b **Never ask the owner a decision they have already made: cite the answer.** Re-asking is a failure, not caution. 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. -An earlier owner answer that covers the same decision is cited, never re-asked: `--approved `, and for a plan under a goal-level one-way door, the plan review's `- Goal-level door: covers this plan` line. That holds whatever kind or class it was asked as: a question asked in class `one-way-door` is a one-way door (`ask` records it as `--kind one-way-door`, and refuses another kind), so its answer is the one-way-door answer. `ask` refuses a question close to one the owner answered about the same work (the same ticket, goal or feature), naming the answer to cite. Only when something material changed since they answered (new facts, a wider scope, a cost they didn't see) ask again with `--new-because ""`; the owner reads it first, as "Asked again because: …". A defaulted or unanswered question is not a decision the owner made, and never blocks one. +An earlier owner answer that covers the same decision is cited, never re-asked: `--approved `, and for a plan under a goal-level one-way door, the plan review's `- Goal-level door: covers this plan` line. That holds whatever kind or class it was asked as: a question asked in class `one-way-door` is a one-way door (`ask` records it as `--kind one-way-door`, and refuses another kind), so its answer is the one-way-door answer. `ask` refuses a decision (an owner-decision, a one-way door, a request for a secret already asked for) worded like one the owner answered in the same class about the same work (the same ticket, goal or feature), naming the answer to cite; an answer already used for its one call, or in a class that can't approve this one, is only mentioned. Only when something material changed since they answered (new facts, a wider scope, a cost they didn't see) ask again with `--new-because ""`; the owner reads it first, as "Asked again because: …". A defaulted or unanswered question is not a decision the owner made, and never blocks one. **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. Never put a question to the owner through your harness's own blocking dialog (a pop-up or multiple-choice prompt that waits for an answer): it freezes your whole loop, so workers' messages, reviews and merges wait behind it, and it never reaches the Inbox. 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. A goal at its target is one of these: ask `goal-met` (`planning.md` §1), never "mark it met" in chat. 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. diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index f4e668f9..e3282ec8 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -1628,6 +1628,9 @@ const questionHash = (q) => crypto.createHash('sha256').update(canonical(Object. // (questionView's status "answered", bound to the question's hash), never a default or a line the // supervisor wrote, and the class is hashed, so setting it after the answer reopens the question. const isDoorQuestion = (v) => v?.kind === 'one-way-door' || v?.class === 'one-way-door'; +// A door answer approves a plan only as recommended: an owner who chose otherwise said no to the +// plan built on the recommendation. A door asked with no recommendation is the owner's open choice. +const doorAgreed = (v) => !v.recommendation || v.matched === true; // Folded questions in the shape the inbox uses. function questionView(q) { @@ -4767,26 +4770,49 @@ const KIT_WORDED = (cls, kind) => ['goal-met', 'registry-attest'].includes(cls) // as asked; never a default or an open one) about the same work: a ticket or incident both name, or // the same goal or feature, and neither about other tickets; or neither about any work at all. // --new-because says what materially changed, and the owner reads it first. Not for the questions -// whose answers lapse by design (a trust step's evidence, an incident's fix, a remediation's window), +// whose answers lapse by design (a trust step's evidence, an incident's fix, a remediation's window, +// a sensor's token, asked again only after the owner's own sensor reconsider), // nor for the kit's own wordings, nor for an answer already spent: one answer approves one call (one // per item it approves), so a yes that approved its call can't be cited again, and the next call of // the kind is a new question (the warning below still shows the earlier answer). A one-way door is // never spent: it covers each plan inside it. -const REASK_EXEMPT = (cls, kind) => KIT_WORDED(cls, kind) || kind === 'trust' || ['incident-fix', 'production-remediation'].includes(cls); +const REASK_EXEMPT = (cls, kind) => KIT_WORDED(cls, kind) || !['owner-decision', 'one-way-door', 'owner-action'].includes(kind) || ['incident-fix', 'production-remediation'].includes(cls); +// An answer settles a question in its own class (an answer in another class can't be cited for this +// one: the kit refuses it, so asking in the right class is no re-ask), or a one-way door's answer +// settles a door asked again in any shape: what the owner was re-asked here. +const settlesClass = (v, cls, kind) => (v.class ?? null) === (cls ?? null) || (isDoorQuestion(v) && (isDoorQuestion({ kind, class: cls }) || !cls || cls === 'approve-plan')); function sameWork(a, b) { // a request for secrets is about what it asks for: the same secret to the same place, or not the same const sinks = (x) => (x.secrets ?? []).map((y) => `${y.name}\n${y.sink}`); if (sinks(a).length || sinks(b).length) return sinks(a).some((y) => sinks(b).includes(y)); + // a question pinned to a text (a draft's blob, an artifact's hash) is about that text: a new draft is a new decision + const pins = (x) => ((x.id ? logGet(x.id)?.links : x.links) ?? []).filter(isObj).map((l) => l.blob ?? l.sha256).filter(Boolean); + if (pins(a).length || pins(b).length) { if (!pins(a).some((y) => pins(b).includes(y))) return false; } const ta = [a.ticket, ...(a.blocks ?? [])].filter(Boolean); const tb = [b.ticket, ...(b.blocks ?? [])].filter(Boolean); if (ta.some((x) => tb.includes(x))) return true; - if (ta.length && tb.length) return false; + // about two different tickets: two decisions (a question that only blocks a ticket may still be the goal's) + if (a.ticket && b.ticket) return false; if (!ta.length && !tb.length && !a.goal && !b.goal) return true; if (a.goal && a.goal === b.goal) return true; return sharesWork(questionAbout(a), questionAbout(b)); } +// The search above finds questions alike enough to mention; a refusal needs more: the two questions +// themselves (not their context) share most of their words, every word counted whole ("v0.3.0" and +// "v0.4.0", "UK" and "US" differ), only the glue words left out. +const REASK_GLUE = new Set(['a', 'an', 'the', 'and', 'or', 'to', 'of', 'for', 'in', 'on', 'at', 'is', 'it', 'be', 'we', 'i', 'you', 'should', 'would', 'could', 'can', 'do', 'does', 'shall', 'will', 'please', 'this', 'that']); +const questionWords = (t) => new Set((String(t ?? '').toLowerCase().match(/[\p{L}\p{N}]+(?:[.'’-][\p{L}\p{N}]+)*/gu) ?? []).filter((w) => !REASK_GLUE.has(w))); +function nearlySameQuestion(a, b) { + const x = questionWords(a); + const y = questionWords(b); + if (!x.size || !y.size) return false; + const both = [...x].filter((w) => y.has(w)).length; + return both / (x.size + y.size - both) >= 0.7; +} function answerSpent(v) { if (isDoorQuestion(v)) return false; + // a goal change records its answer on the goal line, not as a call + if (logObjects('goal').some((g) => g.updates.some((u) => u.approved === v.id))) return true; const used = calls().filter((c) => c.approved_by === v.id); const declared = v.approves ?? []; if (!declared.length) return used.length > 0; @@ -4798,17 +4824,20 @@ function reaskWords(v) { const cite = v.recommendation && v.matched === false && !door ? `they answered other than your recommendation, and that is their decision; act on it, and use it in your recommendations` : `cite the answer instead (--approved ${v.id} on decide or act${door ? `; for a plan under it, act --class approve-plan --approved ${v.id}, and when it was asked for the goal, "- Goal-level door: ${v.id} covers this plan" in the plan review` : ''})`; return `the owner already decided this: ${v.id} ${quoteData(capBytes300(v.question))} (answered ${quoteData(capBytes300(v.answer))} on ${String(v.answered_at).slice(0, 10)}). Never ask the owner a decision they have made: ${cite}. If something material changed since, ask again with --new-because "", which the owner reads first (supervisor.md §6)`; } -function askLikeWarnings({ text, cls, kind, about, newBecause }) { - if (!text.trim() || KIT_WORDED(cls, kind)) return; +// Returns the --new-because to record: left off when nothing the owner answered is like this. +function askLikeWarnings({ text, question, cls, kind, about, newBecause }) { + if (!text.trim() || KIT_WORDED(cls, kind)) return newBecause; const rules = logObjects('knowledge').filter((k) => k.status === 'accepted' && k.kind === 'preference'); const answered = questions().filter((x) => x.status === 'answered' && x.answer && !KIT_WORDED(x.class, x.kind)); const docs = [...rules.map((k) => ({ id: k.id, rule: k, fields: [{ text: k.statement }] })), ...answered.map((x) => ({ id: x.id, q: x, fields: [{ text: textOf(x.question, x.context, x.recommendation, (x.choices ?? []).map((c) => c?.label)) }] }))]; - if (!docs.length) return; + if (!docs.length) return newBecause ? (warn('--new-because is for asking again what the owner answered; nothing they answered is like this, so it is left off'), null) : newBecause; const ix = searchIndex(docs); const close = ix.docs.map((d) => ({ d, ...searchSimilar(ix, text, d) })).filter((c) => c.coverage >= 0.6 && c.ratio >= 0.3).sort((a, b) => b.ratio - a.ratio); const line = (s) => quoteData(capBytes300(s)); - const same = REASK_EXEMPT(cls, kind) ? null : close.find((c) => c.d.q && c.d.q.answered_by === 'owner' && !REASK_EXEMPT(c.d.q.class, c.d.q.kind) && sameWork(about, c.d.q) && !answerSpent(c.d.q)); + const same = REASK_EXEMPT(cls, kind) || about.sensor || (kind === 'owner-action' && !(about.secrets ?? []).length) ? null : close.find((c) => c.d.q && c.d.q.answered_by === 'owner' && !REASK_EXEMPT(c.d.q.class, c.d.q.kind) && settlesClass(c.d.q, cls, kind) && nearlySameQuestion(question, c.d.q.question) && sameWork(about, c.d.q) && !answerSpent(c.d.q)); if (same && !newBecause) die(reaskWords(same.d.q)); + // asked again: only after a decision the owner made, or one like it, does the owner read why + if (newBecause && !close.some((c) => c.d.q)) { warn('--new-because is for asking again what the owner answered; nothing they answered is like this, so it is left off'); newBecause = null; } for (const c of close.filter((x) => x.d.rule).slice(0, 3)) { const k = c.d.rule; if (cls && writerClass(k.created_by) === 'owner' && k.scope === `class:${cls}`) warn(`a standing rule may settle this: ${k.id} ${line(k.statement)}. If it does, withdraw this question and cite it (--approved ${k.id})`); @@ -4819,6 +4848,7 @@ function askLikeWarnings({ text, cls, kind, about, newBecause }) { if (isDoorQuestion(c.d.q)) warn(`the owner answered a similar one-way door before: ${c.d.q.id} ${line(c.d.q.question)} (answer: ${line(c.d.q.answer)}). If it covers this decision, withdraw this and cite it, never ask it again (supervisor.md §6): act --class approve-plan --approved ${c.d.q.id}, with "- Goal-level door: ${c.d.q.id} covers this plan" in the plan review when it was asked for the goal`); else warn(`the owner answered a similar question before: ${c.d.q.id} ${line(c.d.q.question)} (answer: ${line(c.d.q.answer)}). If it was this very call and that answer is unused, withdraw this and cite it (--approved ${c.d.q.id}); otherwise it approves nothing here, so use it in your recommendation`); } + return newBecause; } // Text the owner reads: a literal "\n" typed in a shell argument becomes a line break, so lists and // paragraphs reach the dashboard as written (supervisor.md §6). Only where it can't be anything else: @@ -4881,11 +4911,10 @@ function askLine({ thread } = {}) { // A question in the owner-only class one-way-door is a one-way door: left without --kind it is // asked as one, and asked as another kind it is refused, so the owner's answer is the one-way-door // answer approve-plan and the plan review cite (and never re-asked as one), and the kind's own - // rules (no default, the Inbox's floor) hold. Refused, not rewritten: another kind given on purpose + // rules (no default, below; the Inbox's floor) hold. Refused, not rewritten: another kind given on purpose // (a review, an owner-action with secrets) has rules of its own a silent change would drop. const kind = flagStr('kind') ?? (cls === 'one-way-door' ? 'one-way-door' : 'owner-decision'); if (cls === 'one-way-door' && kind !== 'one-way-door') die(`a question in class one-way-door is a one-way door: ask it --kind one-way-door, not --kind ${kind}`); - if (kind === 'one-way-door' && flagStr('default') !== undefined) die('a one-way door never has a default: only the owner answers it (constitution.md §4)'); if (['proposal', 'trust'].includes(kind)) upkeepAllows(`a ${kind} question`); const L = ladder(); if (kind === 'goal-approval') { @@ -5137,9 +5166,9 @@ function askLine({ thread } = {}) { askPlainChecks({ q, context, kind, cls, secrets, reasons, confidence: flagNum('confidence'), recommendation, L }); // what the owner may already have said about it: never in the way of asking // and a decision they already made is refused, unless something material changed (--new-because) - const newBecause = flagStr('new-because'); + let newBecause = flagStr('new-because'); if (newBecause !== undefined && !newBecause.trim()) die('--new-because says what materially changed since the owner answered; it can\'t be blank'); - try { askLikeWarnings({ text: textOf(q, flagStr('context'), flagStr('recommendation'), [].concat(jsonFlag('choices') ?? []).map((c) => c?.label)), cls, kind, about: { goal: flagStr('goal'), ticket: flagStr('ticket'), blocks, secrets }, newBecause: newBecause?.trim() }); } catch (e) { warn(`the check for earlier answers failed (${e.message}); the question is asked anyway`); } + try { newBecause = askLikeWarnings({ text: textOf(q, flagStr('context'), flagStr('recommendation'), [].concat(jsonFlag('choices') ?? []).map((c) => c?.label)), question: q, cls, kind, about: { goal: flagStr('goal'), ticket: flagStr('ticket'), blocks, secrets, links, sensor: flagStr('sensor') }, newBecause: newBecause?.trim() }); } catch (e) { warn(`the check for earlier answers failed (${e.message}); the question is asked anyway`); } return appendLog({ type: 'question', kind, class: cls ?? undefined, scope: scope ?? undefined, ticket: flagStr('ticket'), re: flagStr('re'), sensor: flagStr('sensor'), remediation: remediationPin ?? undefined, remediation_entries: remediationEntries, owner_said: ownerSaid ?? undefined, new_because: newBecause?.trim() || undefined, question: q, context: kind === 'goal-approval' ? [ownerText(flagStr('context')), forecastText(logGet(flagStr('goal')), goalForecast(logGet(flagStr('goal')))), registryAttestText(logGet(flagStr('goal')))].filter(Boolean).join('\n\n') : context, rule_scope: flagStr('rule-scope'), goal: flagStr('goal'), change: goalChangeFlag(cls), options: asList(flags.options), choices: goalMet ? GOAL_MET_CHOICES : jsonFlag('choices'), met: goalMet ? { basis: goalMet.basis, reading: goalMet.reading } : undefined, links, recommendation: recommendation || undefined, confidence: flagNum('confidence'), default: def ?? undefined, @@ -7685,7 +7714,8 @@ function authorise(cls, scope, approved, refs, verb) { 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); + // a door's answer cited for the plans it covers (approve-plan) never uses it up for its own class + const used = calls().find((a) => a.approved_by === approved && !(a.class === 'approve-plan' && isDoorQuestion(approval))); if (used) die(`${approved} already approved ${used.type === 'decision' ? 'a decision' : 'an act'} (${used.id}); ask again for another`); } } @@ -7773,7 +7803,7 @@ function answerUseGap(d, v) { 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]; + const first = calls().filter((c) => c.approved_by === v.id && !(c.class === 'approve-plan' && isDoorQuestion(v)) && (!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. @@ -7896,13 +7926,16 @@ function cmdAct() { if (doorCited) { if (!doors.length) die(`${t.id} lists no one-way doors; ${approved} has nothing to cover`); if (approval.status !== 'answered') die(`${approved} was not answered by the owner; a one-way door needs the owner's answer, never a default`); + if (!doorAgreed(approval)) die(`${approved} was answered "${String(approval.answer ?? '').slice(0, 80)}", not its recommendation ("${approval.recommendation}"): a plan built on the recommendation isn't what the owner said yes to; replan to their answer and ask about that`); if (approval.ticket !== t.id) { + // a question asked for one ticket is that ticket's door, never a goal-level one for another + if (approval.ticket) die(`${approved} was asked for ${approval.ticket}, not ${t.id}: a ticket's door answer covers that ticket only`); const goals = features().get(t.feature)?.goals ?? []; if (!approval.goal) die(`${approved} is a one-way-door question for neither ${t.id} nor a goal`); if (!goals.includes(approval.goal)) die(`${approved} answered a one-way door for goal ${approval.goal}, but ${t.id}'s feature ${t.feature} serves ${goals.join(', ') || 'no goal'}; an answer for another goal never covers this plan`); goalDoor = approved; } - } else if (doors.length && !questions().some((q) => isDoorQuestion(q) && q.ticket === t.id && q.status === 'answered')) die(`${t.id} lists one-way doors; the owner answers a one-way-door question (--ticket ${t.id}) before its plan is approved, or you cite the owner's answer to the goal's one-way-door question (--approved ) and the plan review says it covers the plan (constitution.md §4)`); + } else if (doors.length && !questions().some((q) => isDoorQuestion(q) && q.ticket === t.id && q.status === 'answered' && doorAgreed(q))) die(`${t.id} lists one-way doors; the owner answers a one-way-door question (--ticket ${t.id}) before its plan is approved, or you cite the owner's answer to the goal's one-way-door question (--approved ) and the plan review says it covers the plan (constitution.md §4)`); // The plan opens with its TL;DR, so a low-risk plan can be approved from it (quality.md §6). const R = repoOfTicket(t); // an act: fails fast on a missing or wrong checkout const ref = [`ticket/${t.id}`, `origin/ticket/${t.id}`].find((r) => R.resolveCommit(r)); diff --git a/test/approvals.test.mjs b/test/approvals.test.mjs index 9d60a643..995e3c0e 100644 --- a/test/approvals.test.mjs +++ b/test/approvals.test.mjs @@ -20,8 +20,7 @@ function choreOf(F, slug, files) { write(path.join(old, `docs/devteam/run/pr/chore-${slug}.md`), `# chore-${slug}: x\n\n## One-way doors\n\nNone.\n`); return (...extra) => { const r = run(old, ['merge', '--chore', slug, '--dry-run', ...extra], SUP); return { status: r.status, text: r.out + r.err }; }; } -// the same words asked in several classes on purpose: each is asked again past ask's re-ask refusal -const answered = (F, opts, answer, role) => { const q = F.ok(F.old, ['ask', ...opts, '--new-because', 'the test asks it in another class'], SUP); F.ok(F.old, ['answer', q, answer, ...(role ?? [])], role ? SUP : undefined); return q; }; +const answered = (F, opts, answer, role) => { const q = F.ok(F.old, ['ask', ...opts], SUP); F.ok(F.old, ['answer', q, answer, ...(role ?? [])], role ? SUP : undefined); return q; }; const asked = (question, cls) => ['--question', question, '--recommendation', 'yes', ...(cls ? ['--class', cls, '--reasons', cls === 'release-scope' ? 'below-rung' : 'owner-only'] : [])]; test('a chore that changes a real kit file needs a kit-class answer; outside the kit, the chore rule is as it was', () => { diff --git a/test/fixtures/g1/dashboard-data.txt b/test/fixtures/g1/dashboard-data.txt index f9ad4fba..46653597 100644 --- a/test/fixtures/g1/dashboard-data.txt +++ b/test/fixtures/g1/dashboard-data.txt @@ -109,6 +109,7 @@ exit 0 "approves": [], "evidence": [], "note": null, + "new_because": null, "why_asked": { "reasons": [] }, diff --git a/test/lib/chain.mjs b/test/lib/chain.mjs index 4496cf90..cb335506 100644 --- a/test/lib/chain.mjs +++ b/test/lib/chain.mjs @@ -2259,7 +2259,8 @@ http.createServer((req, res) => { git(['push', '-q', '--force', 'origin', 'chore-devteam-sync-v0-3-0'], wt('sync')); assert.match(dt(args, { role: 'sup' }).out, /outside project\//, 'with "ask" and no standing rule: the owner answers each sync'); // with "ask" and no standing rule, the answer that approves a sync's kit files is a kit-class one - const QaskSync = must(['ask', '--question', 'Adopt v0.3.0?', '--recommendation', 'yes'], { role: 'sup' }); + // the same words again on purpose, past ask's re-ask refusal + const QaskSync = must(['ask', '--question', 'Adopt v0.3.0?', '--recommendation', 'yes', '--new-because', 'the sync now changes kit files'], { role: 'sup' }); must(['answer', QaskSync, 'yes']); assert.match(dt([...args, '--approved', QaskSync], { role: 'sup' }).out, /changes kit files .*that needs the owner's answer in a governance-edit, process-edit, gate-change question, and .* is classless/, 'a classless answer is not a kit-class one'); const QkitSync = must(['ask', '--question', 'Adopt v0.3.0?', '--class', 'governance-edit', '--reasons', 'owner-only', '--recommendation', 'yes'], { role: 'sup' }); @@ -2499,7 +2500,8 @@ http.createServer((req, res) => { const change = JSON.stringify({ non_goals: ['enterprise invoicing', 'refunds'] }); assert.equal(json(must(['goal', 'show', G, '--json'])).status, 'ready'); assert.match(dt(['goal', 'set', G, '--non-goals', 'x'], { role: 'sup' }).err, /only the owner changes it now \(class goal-change\)/, 'refused without --approved'); - const ask = (extra) => must(['ask', '--kind', 'owner-decision', '--question', 'Add refunds to the non-goals?', '--recommendation', 'Set it', '--options', 'Set it', 'Leave it', ...extra], { role: 'sup' }); + // the same words in each case on purpose, past ask's re-ask refusal + const ask = (extra) => must(['ask', '--kind', 'owner-decision', '--question', 'Add refunds to the non-goals?', '--recommendation', 'Set it', '--options', 'Set it', 'Leave it', '--new-because', 'the test asks it again', ...extra], { role: 'sup' }); assert.match(dt(['ask', '--question', 'Change it?', '--class', 'goal-change', '--goal', G, '--recommendation', 'Set it'], { role: 'sup' }).err, /says the exact change/, 'a goal-change question names its change'); assert.match(dt(['ask', '--question', 'Change it?', '--class', 'goal-change', '--goal', G, '--change', '{"status":"met"}'], { role: 'sup' }).err, /not status/, 'only goal fields the supervisor may propose'); const Qwrong = ask(['--class', 'release-scope']); @@ -3086,6 +3088,11 @@ http.createServer((req, res) => { const Dp = hand({ kind: 'owner-decision', goal: G, question: 'Name the shared package billing-gate?', recommendation: 'yes' }); must(['answer', Dp, 'yes']); hand({ op: 'set', id: Dp, class: 'one-way-door' }); + // a door the owner answered against its recommendation; and one asked for another ticket, with the goal on it + const Dn = hand({ kind: 'owner-decision', class: 'one-way-door', goal: G, question: 'Drop the legacy gate entirely?', recommendation: 'yes' }); + must(['answer', Dn, 'no']); + const Dk = hand({ kind: 'owner-decision', class: 'one-way-door', goal: G, ticket: 'bill-02-vault', question: 'Vault the card fingerprints?', recommendation: 'yes' }); + must(['answer', Dk, 'yes']); const qs = json(must(['questions', '--json', '--all'])); const st = (id) => qs.find((q) => q.id === id)?.status; assert.deepEqual([st(Dc), st(Dd), st(Df), st(Dp)], ['answered', 'defaulted', 'open', 'answered'], "only the owner's own line answers"); @@ -3097,10 +3104,12 @@ http.createServer((req, res) => { write(path.join(tdir, 'plan.md'), '# plan\n\n## TL;DR\n\n- move the gate into packages/gate\n\n## Tasks\n\n- T1 gate\n'); const p1 = commit('bill-09-door: plan'); const covers = (...ids) => { write(path.join(tdir, 'reviews/plan-1.md'), review('reviewer-a', p1).replace('\n\n|', `\n${ids.map((id) => `- Goal-level door: ${id} covers this plan`).join('\n')}\n\n|`)); commit('plan review'); }; - covers(Dc, Dd, Df, Dp); + covers(Dc, Dd, Df, Dp, Dn, Dk); const ap = (...extra) => dt(['act', '--class', 'approve-plan', '--refs', 'bill-09-door', ...extra, '--', 'plan ok'], sup); assert.match(ap().err, /lists one-way doors; /, 'nothing cited, nothing answered for the ticket'); - assert.match(ap('--approved', Dd).err, /never a default|not approval there/, 'a defaulted door approves nothing'); + assert.match(ap('--approved', Dd).err, /was not answered by the owner; a one-way door needs the owner's answer, never a default/, 'a defaulted door approves nothing'); + assert.match(ap('--approved', Dn).err, /was answered "no", not its recommendation \("yes"\)/, "the owner's no approves no plan"); + assert.match(ap('--approved', Dk).err, /was asked for bill-02-vault, not bill-09-door/, "another ticket's door, though it names the goal"); assert.match(ap('--approved', Df).err, /is not an answered question/, "the supervisor's own lines answer nothing"); assert.match(ap('--approved', Dp).err, /was asked in class \(none\), not approve-plan/, 'a class set by the supervisor counts for nothing'); // a decision the supervisor writes by hand on the defaulted door carries nothing either @@ -3116,6 +3125,9 @@ http.createServer((req, res) => { const d = must(['decide', '--class', 'approve-plan', '--refs', 'bill-09-door', '--approved', Dc, '--', 'approve the gate plan'], sup); must(['act', '--decision', d, '--', 'plan ok'], sup); assert.equal(json(must(['log', '--type', 'decision', '--json'])).find((x) => x.id === d).approved_by, Dc, 'the earlier yes approves the plan; no second question'); + // citing it for the plan doesn't use it up for its own class: the one-way-door act itself, once + must(['act', '--class', 'one-way-door', '--approved', Dc, '--refs', 'bill-09-door', '--', 'moved the gate into the shared package'], sup); + assert.match(dt(['act', '--class', 'one-way-door', '--approved', Dc, '--refs', 'bill-09-door', '--', 'again'], sup).err, /already approved an act/); // asked for the ticket, the same holds without a review line fs.rmSync(path.join(tdir, 'reviews'), { recursive: true }); commit('no reviews'); @@ -3126,6 +3138,8 @@ http.createServer((req, res) => { must(['act', '--class', 'approve-plan', '--refs', 'bill-09-door', '--', 'plan ok'], sup); // ask: class one-way-door is asked as a one-way door, never as another kind, and never with a default // the same decision again is refused, with the answer to cite; something material changed lets it through, said first + // the owner's case: the goal's door answered, then the same move asked again for a ticket, as another kind + assert.match(dt(['ask', '--kind', 'one-way-door', '--ticket', 'bill-09-door', '--question', 'Move the proposal gate into a shared package?', '--recommendation', 'yes'], sup).err, new RegExp(`the owner already decided this: ${Dc} `)); const again = ['ask', '--class', 'one-way-door', '--goal', G, '--question', 'Move the proposal gate into a shared package?', '--recommendation', 'yes']; assert.match(dt(again, sup).err, new RegExp(`the owner already decided this: ${Dc} .*cite the answer instead \\(--approved ${Dc} .*"- Goal-level door: ${Dc} covers this plan".*--new-because`)); const r = dt([...again, '--new-because', 'the package now ships to a second app'], sup); @@ -3136,8 +3150,8 @@ http.createServer((req, res) => { assert.match(r.err, new RegExp(`answered a similar one-way door before: ${Dc}.*never ask it again \\(supervisor\\.md §6\\): act --class approve-plan --approved ${Dc}`), 'the warning still points at citing it'); must(['withdraw', r.out, '--reason', 'test'], sup); assert.match(dt(['ask', '--class', 'one-way-door', '--kind', 'owner-decision', '--goal', G, '--question', 'x?', '--recommendation', 'yes'], sup).err, /a question in class one-way-door is a one-way door: ask it --kind one-way-door, not --kind owner-decision/); - assert.match(dt(['ask', '--class', 'one-way-door', '--goal', G, '--question', 'x?', '--recommendation', 'yes', '--default', 'yes'], sup).err, /a one-way door never has a default/); - assert.match(dt(['ask', '--kind', 'one-way-door', '--goal', G, '--question', 'x?', '--recommendation', 'yes', '--default', 'yes'], sup).err, /a one-way door never has a default/); + assert.match(dt(['ask', '--class', 'one-way-door', '--goal', G, '--question', 'x?', '--recommendation', 'yes', '--default', 'yes'], sup).err, /a one-way door has no --default/); + assert.match(dt(['ask', '--kind', 'one-way-door', '--goal', G, '--question', 'x?', '--recommendation', 'yes', '--default', 'yes'], sup).err, /a one-way door has no --default/); }); step('a ticket\'s merge gate fails validate only for overlaps that are its own (D4)', async () => { diff --git a/test/next-step.test.mjs b/test/next-step.test.mjs index 9672163b..891cc136 100644 --- a/test/next-step.test.mjs +++ b/test/next-step.test.mjs @@ -560,7 +560,8 @@ test('the strategy is the owner\'s: no PR changes project/strategy.md without an return (...extra) => { const r = run(old, ['merge', '--chore', slug, '--dry-run', ...extra], SUP); return { status: r.status, text: r.out + r.err }; }; }; const linked = (ref) => ['--links', JSON.stringify([{ title: 'The strategy', path: REL, ref }])]; - const answered = (cls, answer = 'yes') => { const q = ok(old, ['ask', '--question', 'Approve this?', '--recommendation', 'yes', '--stake', '3', '--urgency', '2', ...(cls ? ['--class', cls, '--reasons', cls === 'release-scope' ? 'below-rung' : 'owner-only'] : []), ...(cls === 'strategy-change' ? linked('chore-strategy-first') : [])], SUP); ok(old, ['answer', q, answer]); return q; }; + // the same words each time on purpose: past ask's re-ask refusal after the owner's no + const answered = (cls, answer = 'yes') => { const q = ok(old, ['ask', '--question', 'Approve this?', '--recommendation', 'yes', '--stake', '3', '--urgency', '2', '--new-because', 'the test asks again', ...(cls ? ['--class', cls, '--reasons', cls === 'release-scope' ? 'below-rung' : 'owner-only'] : []), ...(cls === 'strategy-change' ? linked('chore-strategy-first') : [])], SUP); ok(old, ['answer', q, answer]); return q; }; try { assert.match(template, /^# Strategy\n/, 'init wrote the template'); assert.match(template, /## How it wins, in order\n[\s\S]*## Values\n[\s\S]*## Refusals\n[\s\S]*## Not known yet\n/); diff --git a/test/search.test.mjs b/test/search.test.mjs index 86dbcb6f..0adbb07d 100644 --- a/test/search.test.mjs +++ b/test/search.test.mjs @@ -154,6 +154,11 @@ test("ask: rules and earlier answers that look like the question, as warnings th assert.notEqual(reask.status, 0, 'a decision the owner made is not asked again'); assert.match(reask.err, new RegExp(`the owner already decided this: ${q1} "Should we adopt new devteam releases without asking\\?" \\(answered "Yes" on \\d{4}-\\d{2}-\\d{2}\\)\\. .*cite the answer instead \\(--approved ${q1} on decide or act\\)\\. .*--new-because`)); assert.match(run(old, ['ask', '--question', 'x?', '--new-because', ' '], SUP).err, /--new-because .*can't be blank/); + // nothing the owner answered is like it: no "Asked again because" on a first question + const first = run(old, ['ask', '--question', 'Which colour should the alarm light be?', '--new-because', 'it changed'], SUP); + assert.equal(first.status, 0, first.err); + assert.match(first.err, /--new-because is for asking again what the owner answered; nothing they answered is like this, so it is left off/); + assert.equal(JSON.parse(ok(old, ['questions', '--id', first.out.trim(), '--json']))[0].new_because, null); const near = run(old, ['ask', '--question', 'Adopt the new devteam releases without asking each time?', '--class', 'governance-edit', '--new-because', 'the releases now change the merge gate'], SUP); assert.equal(near.status, 0, near.err); const nv = JSON.parse(ok(old, ['questions', '--id', near.out.trim(), '--json']))[0]; @@ -164,9 +169,10 @@ test("ask: rules and earlier answers that look like the question, as warnings th assert.match(near.err, new RegExp(`answered a similar question before: ${q1} .*otherwise it approves nothing here`)); const far = run(old, ['ask', '--question', 'Which colour should the login button be?'], SUP); assert.doesNotMatch(far.err, /standing rule|answered a similar|may bear/, far.err); - // asked in another class, it is still the decision the owner made: refused without --new-because - assert.match(run(old, ['ask', '--question', 'Adopt the new devteam releases without asking each time?', '--class', 'design'], SUP).err, /already decided this/, 'another type is the same question'); - const other = run(old, ['ask', '--question', 'Adopt the new devteam releases without asking each time?', '--class', 'design', '--new-because', 'a design question now'], SUP); + // asked in another class, the earlier answer can't be cited for it (the kit refuses it), so it is asked + const other = run(old, ['ask', '--question', 'Adopt the new devteam releases without asking each time?', '--class', 'design'], SUP); + assert.equal(other.status, 0, other.err); + assert.doesNotMatch(other.err, /already decided/); assert.match(other.err, /an accepted rule may bear on this: k-\S+ .*It approves nothing here/, 'a rule for another class only bears on it'); } finally { F.done(); } }); @@ -183,8 +189,11 @@ test('ask refuses only a decision the owner answered: not a defaulted or open on const sf = path.join(log, fs.readdirSync(log).find((f) => f.startsWith('supervisor-'))); fs.appendFileSync(sf, `${JSON.stringify({ v: 1, id: qd, type: 'question', op: 'set', at: '2099-01-01T00:00:00.000Z', by: 'supervisor', status: 'defaulted' })}\n`); assert.equal(JSON.parse(ok(old, ['questions', '--id', qd, '--json']))[0].status, 'defaulted'); - for (const [q, near] of [[qo, 'Should the nightly export include the archived invoices too?'], [qd, 'Should the weekly digest list each failed payment retry?']]) { - const r = run(old, ['ask', '--question', near, '--recommendation', 'yes'], SUP); + // and a clarification the owner answered: clarifying again is never refused, only mentioned + const qc = ok(old, ['ask', '--kind', 'clarification', '--question', 'Do refunds go back to the original card?'], SUP).trim(); + ok(old, ['answer', qc, 'yes']); + for (const [q, near, kind] of [[qo, 'Should the nightly export include the archived invoices too?'], [qd, 'Should the weekly digest list each failed payment retry?'], [qc, 'Do refunds go back to the original card?', 'clarification']]) { + const r = run(old, ['ask', '--question', near, '--recommendation', 'yes', ...(kind ? ['--kind', kind] : [])], SUP); assert.equal(r.status, 0, `${q}: ${r.err}`); assert.doesNotMatch(r.err, /already decided/); } From 8db440abf6bd7b48f5e7f5b47bc3a7b1e7673766 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 13 Nov 2026 12:10:04 -0500 Subject: [PATCH 4/4] Second review: a door needs a recommendation; residual once-used and re-ask gaps - A one-way door is asked with --recommendation, so its answer approves a plan only as recommended. - The --approves once-used scan skips approve-plan door citations too. - An earlier owner-action without secrets never blocks a question. - --new-because is kept only on a question that re-asks a decision. Co-Authored-By: Claude Opus 5.5 --- docs/devteam/scripts/devteam.mjs | 16 ++++++++++------ test/lib/chain.mjs | 1 + test/search.test.mjs | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index e3282ec8..a3941f71 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -4830,14 +4830,16 @@ function askLikeWarnings({ text, question, cls, kind, about, newBecause }) { const rules = logObjects('knowledge').filter((k) => k.status === 'accepted' && k.kind === 'preference'); const answered = questions().filter((x) => x.status === 'answered' && x.answer && !KIT_WORDED(x.class, x.kind)); const docs = [...rules.map((k) => ({ id: k.id, rule: k, fields: [{ text: k.statement }] })), ...answered.map((x) => ({ id: x.id, q: x, fields: [{ text: textOf(x.question, x.context, x.recommendation, (x.choices ?? []).map((c) => c?.label)) }] }))]; - if (!docs.length) return newBecause ? (warn('--new-because is for asking again what the owner answered; nothing they answered is like this, so it is left off'), null) : newBecause; + if (!docs.length) return newBecause ? (warn('--new-because is for asking again a decision the owner made; nothing they answered is that decision, so it is left off'), null) : newBecause; const ix = searchIndex(docs); const close = ix.docs.map((d) => ({ d, ...searchSimilar(ix, text, d) })).filter((c) => c.coverage >= 0.6 && c.ratio >= 0.3).sort((a, b) => b.ratio - a.ratio); const line = (s) => quoteData(capBytes300(s)); - const same = REASK_EXEMPT(cls, kind) || about.sensor || (kind === 'owner-action' && !(about.secrets ?? []).length) ? null : close.find((c) => c.d.q && c.d.q.answered_by === 'owner' && !REASK_EXEMPT(c.d.q.class, c.d.q.kind) && settlesClass(c.d.q, cls, kind) && nearlySameQuestion(question, c.d.q.question) && sameWork(about, c.d.q) && !answerSpent(c.d.q)); - if (same && !newBecause) die(reaskWords(same.d.q)); - // asked again: only after a decision the owner made, or one like it, does the owner read why - if (newBecause && !close.some((c) => c.d.q)) { warn('--new-because is for asking again what the owner answered; nothing they answered is like this, so it is left off'); newBecause = null; } + const reaskable = (k, secrets) => !(k === 'owner-action' && !(secrets ?? []).length); + const like = (c) => c.d.q && c.d.q.answered_by === 'owner' && !REASK_EXEMPT(c.d.q.class, c.d.q.kind) && reaskable(c.d.q.kind, c.d.q.secrets) && settlesClass(c.d.q, cls, kind) && nearlySameQuestion(question, c.d.q.question) && sameWork(about, c.d.q) && !answerSpent(c.d.q); + const same = close.find(like); + if (same && !REASK_EXEMPT(cls, kind) && !about.sensor && reaskable(kind, about.secrets) && !newBecause) die(reaskWords(same.d.q)); + // asked again: the owner reads why only on a question that asks again what they decided + if (newBecause && !same) { warn('--new-because is for asking again a decision the owner made; nothing they answered is that decision, so it is left off'); newBecause = null; } for (const c of close.filter((x) => x.d.rule).slice(0, 3)) { const k = c.d.rule; if (cls && writerClass(k.created_by) === 'owner' && k.scope === `class:${cls}`) warn(`a standing rule may settle this: ${k.id} ${line(k.statement)}. If it does, withdraw this question and cite it (--approved ${k.id})`); @@ -4991,6 +4993,8 @@ function askLine({ thread } = {}) { for (const r of reasons) if (known.length && !known.includes(r)) warn(`reason "${r}" is not in constitution.md's table`); // silence is never a yes on a one-way door or on what is the owner's alone (constitution.md §1) if (def !== null && (kind === 'one-way-door' || reasons.includes('one-way-door'))) die('a one-way door has no --default: only the owner\'s answer goes through it'); + // a door's answer approves a plan only as recommended (doorAgreed), so a door says what it recommends + if (kind === 'one-way-door' && !flagStr('recommendation')?.trim()) die('a one-way door needs --recommendation: the owner\'s yes to it is what approves the plan built on it'); if (def !== null && (L.owner_only ?? []).includes(cls)) die(`${cls} is the owner's (constitution.md §1): no --default, only their answer approves it`); const blocks = asList(flags.blocks); for (const b of blocks) if (!tickets().has(b) && !isIncident(logGet(b)) && !(cls === 'production-remediation' && /^(remediation|release):\S+$/.test(b))) die(`--blocks ${b}: no such ticket (or incident)`); @@ -7711,7 +7715,7 @@ function authorise(cls, scope, approved, refs, verb) { if (declared.length) { 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)); + const used = calls().find((a) => a.approved_by === approved && (a.refs ?? []).includes(part) && !(a.class === 'approve-plan' && isDoorQuestion(approval))); if (used) die(`${approved} already approved ${used.type === 'decision' ? 'a decision' : 'an act'} for ${part} (${used.id}); ask again for another`); } else { // a door's answer cited for the plans it covers (approve-plan) never uses it up for its own class diff --git a/test/lib/chain.mjs b/test/lib/chain.mjs index cb335506..b1475a6c 100644 --- a/test/lib/chain.mjs +++ b/test/lib/chain.mjs @@ -3152,6 +3152,7 @@ http.createServer((req, res) => { assert.match(dt(['ask', '--class', 'one-way-door', '--kind', 'owner-decision', '--goal', G, '--question', 'x?', '--recommendation', 'yes'], sup).err, /a question in class one-way-door is a one-way door: ask it --kind one-way-door, not --kind owner-decision/); assert.match(dt(['ask', '--class', 'one-way-door', '--goal', G, '--question', 'x?', '--recommendation', 'yes', '--default', 'yes'], sup).err, /a one-way door has no --default/); assert.match(dt(['ask', '--kind', 'one-way-door', '--goal', G, '--question', 'x?', '--recommendation', 'yes', '--default', 'yes'], sup).err, /a one-way door has no --default/); + assert.match(dt(['ask', '--class', 'one-way-door', '--goal', G, '--question', 'Rename the shared package?'], sup).err, /a one-way door needs --recommendation/, 'its answer approves only as recommended'); }); step('a ticket\'s merge gate fails validate only for overlaps that are its own (D4)', async () => { diff --git a/test/search.test.mjs b/test/search.test.mjs index 0adbb07d..80fd4e9b 100644 --- a/test/search.test.mjs +++ b/test/search.test.mjs @@ -157,7 +157,7 @@ test("ask: rules and earlier answers that look like the question, as warnings th // nothing the owner answered is like it: no "Asked again because" on a first question const first = run(old, ['ask', '--question', 'Which colour should the alarm light be?', '--new-because', 'it changed'], SUP); assert.equal(first.status, 0, first.err); - assert.match(first.err, /--new-because is for asking again what the owner answered; nothing they answered is like this, so it is left off/); + assert.match(first.err, /--new-because is for asking again a decision the owner made; nothing they answered is that decision, so it is left off/); assert.equal(JSON.parse(ok(old, ['questions', '--id', first.out.trim(), '--json']))[0].new_because, null); const near = run(old, ['ask', '--question', 'Adopt the new devteam releases without asking each time?', '--class', 'governance-edit', '--new-because', 'the releases now change the merge gate'], SUP); assert.equal(near.status, 0, near.err);