diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index b928b423..713963d9 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -213,6 +213,7 @@ .ctx li, .qcard .d li, .qcard .e li { margin: 2px 0; } .ctx code, .qcard h4 code { font: 12px var(--mono); } .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; } @@ -1243,6 +1244,7 @@

Observations

${rankWhy(q)} ${q.thread && !copy ? `
From a thread: ${esc(q.thread_title || 'a conversation with the supervisor')}
` : ''} ${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)}

` : ''}

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

${q.context ? `
${qblock(q.context, q.links)}
` : ''} ${(q.links || []).length ? `` : ''} @@ -1476,6 +1478,7 @@

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

${rankWhy(q)} ${q.thread ? `
From a thread: ${esc(q.thread_title || 'a conversation with the supervisor')}
` : ''} ${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)}

` : ''}

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

${q.context ? `
${qblock(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 e914f194..908086b4 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 6f1eef1c..3acdd68d 100644 --- a/docs/devteam/process/supervisor.md +++ b/docs/devteam/process/supervisor.md @@ -206,7 +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. + +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/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 33aaf384..a3941f71 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -1622,6 +1622,16 @@ 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'; +// 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) { const hash = questionHash(q); @@ -1656,7 +1666,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, @@ -4755,21 +4765,92 @@ 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 }) { - if (!text.trim() || KIT_WORDED(cls, kind)) return; +// 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, +// 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) || !['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; + // 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; + 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)`; +} +// 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 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 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})`); 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`); + } + 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: @@ -4828,9 +4909,15 @@ function askLine({ thread } = {}) { supervisorOnly('ask'); // a goal-met question's words are the kit's (below) let q = ownerText(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, 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 (['proposal', 'trust'].includes(kind)) upkeepAllows(`a ${kind} question`); const L = ladder(); if (kind === 'goal-approval') { const g = logGet(flagStr('goal') ?? ''); @@ -4906,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)`); @@ -5080,9 +5169,12 @@ function askLine({ thread } = {}) { ratingWarnings({ ...rating, blocks, def }); 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 - 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) + 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 { 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, 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, + 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, 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, @@ -7558,7 +7650,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})`); @@ -7616,17 +7708,18 @@ 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 ?? []; 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 { - 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`); } } @@ -7685,7 +7778,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(); @@ -7710,11 +7803,11 @@ 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`; - 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. @@ -7825,7 +7918,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. @@ -7837,13 +7930,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) => 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' && 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)); @@ -14834,7 +14930,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; @@ -14862,7 +14958,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/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 d42da767..b1475a6c 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']); @@ -3053,6 +3055,106 @@ 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' }); + // 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"); + 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, 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, /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 + 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'); + // 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'); + 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 + // 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); + 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 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 () => { 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/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 79ff1e0e..80fd4e9b 100644 --- a/test/search.test.mjs +++ b/test/search.test.mjs @@ -149,18 +149,67 @@ 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/); + // 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 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); + 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); + // 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(); } }); +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'); + // 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/); + } + // 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 }] }))];