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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/devteam/dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,8 @@

/* questions: the choices are one list; the recommended one is marked in place */
.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; }
.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; }
.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; }
Expand Down Expand Up @@ -1019,6 +1020,7 @@ <h3 style="margin-top:14px">Checks before merge <span class="sub">the automated
return `
<div class="card qcard ${kindCls}" id="q-${esc(q.id)}">
${cardHead(KIND_ICON[q.kind] || 'circle-help', `<span class="badge ${q.kind === 'one-way-door' ? 'high' : q.kind === 'trust' ? 'good' : q.kind === 'owner-action' ? 'low' : 'medium'}">${esc(KIND_WORDS[q.kind] || words(q.kind))}</span>${q.goal_hold === 'park' ? '<span class="badge">its goal is parked</span>' : ''}`, { extra: (q.blocks || []).length ? `holds up ${q.blocks.map(tlink).join(', ')}` : q.ticket ? `about ${tlink(q.ticket)}` : '', id: q.id, rows: [['Asked', esc(when(q.asked_at))], ...(q.class ? [['Kind', esc(q.class)]] : [])] })}
${q.rewritten ? '<p class="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.</p>' : ''}
<h4>${linkify(q.question)}</h4>
${q.context ? `<p class="ctx">${linkify(q.context)}</p>` : ''}
${(q.links || []).length ? `<div class="links"><span class="muted small">Look at:</span>${q.links.map(linkOf).join('')}</div>` : ''}
Expand Down Expand Up @@ -1158,6 +1160,7 @@ <h4>${linkify(q.question)}</h4>
return `
<div class="card qcard review" id="q-${esc(q.id)}">
${cardHead(KIND_ICON.review, '<span class="badge low">Review</span>', { extra: (q.blocks || []).length ? `holds up ${q.blocks.map(tlink).join(', ')}` : q.ticket ? `about ${tlink(q.ticket)}` : '', id: q.id, rows: [['Asked', esc(when(q.asked_at))], ...(q.class ? [['Kind', esc(q.class)]] : [])] })}
${q.rewritten ? '<p class="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.</p>' : ''}
<h4>${linkify(q.question)}</h4>
${q.context ? `<p class="ctx">${linkify(q.context)}</p>` : ''}
<div class="meta rv-pins">${rv.data ? `${esc(rv.data.rows)} rows${rv.data.proposals ? `, ${esc(rv.data.proposals)} with a proposal` : ''} · ` : ''}page <code>${esc(String(rv.page?.sha256 ?? '').slice(0, 12))}</code> ${ok ? '<span class="badge good">verified</span>' : `<span class="badge high">${esc(q.result_status || 'missing')}</span>`}${rv.allow_partial ? ' · partial answers allowed' : ''}${pr && pr.total ? ` · <span class="badge ${pr.done >= pr.total ? 'good' : 'medium'}">${esc(pr.done)} of ${esc(pr.total)} decided</span>` : ''}</div>
Expand Down Expand Up @@ -2703,6 +2706,8 @@ <h3>Changelog</h3>${merged.length ? `<ul>${merged.map((t) => `<li>${tlink(t.id)}
showTab(TAB);

async function post(action, body, errId) {
// An answer names the question as this page showed it; a question changed since is refused.
if (action === 'answer' && body.seen === undefined) body = { ...body, seen: (DATA.questions || []).find((x) => x.id === body.id)?.hash };
const r = await fetch(`/api/${action}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const out = await r.json();
if (!r.ok) {
Expand Down
12 changes: 10 additions & 2 deletions docs/devteam/schema/log.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1469,6 +1469,12 @@
"const": true,
"description": "On the owner's answer to a review: sent back without a result; the answer says why.",
"x-tip": "You sent the review back without a result."
},
"question_hash": {
"type": "string",
"pattern": "^[0-9a-f]{16}$",
"description": "On the owner's answer: the hash of the question as the owner saw it (every field it was asked with). An answer whose question no longer has this hash answers nothing: the question was changed after the owner answered it, so it is open again.",
"x-tip": "The question as you saw it when you answered."
}
},
"additionalProperties": false,
Expand All @@ -1479,7 +1485,8 @@
"x-set-only": [
"secrets_posted",
"result",
"declined"
"declined",
"question_hash"
]
},
"decision": {
Expand Down Expand Up @@ -1988,7 +1995,8 @@
"standing_rule",
"secrets_posted",
"result",
"declined"
"declined",
"question_hash"
]
},
"knowledge": {
Expand Down
41 changes: 38 additions & 3 deletions docs/devteam/scripts/devteam.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,7 @@ function fold(all) {
if (l.op !== 'new') continue;
if (objects.has(l.id)) {
ignored.push({ line: l, why: 'duplicate id' });
if (l.type === 'question') Object.defineProperty(objects.get(l.id), '__duplicate', { value: true }); // not copied into views or JSON
continue;
}
const { v, op, __file, __line, at, by, ...body } = l;
Expand Down Expand Up @@ -1105,6 +1106,11 @@ function appendLog(fields, by = logWriter()) {
const cur = logGet(fields.id);
if (cur && (type === 'feature' ? cur.id : cur.feature) !== f) die(`${by} may only change the feature "${f}" and its tickets`);
}
// The owner's answer binds to the question as it stands (questionView).
if (type === 'question' && fields.op === 'set' && writerClass(by) === 'owner' && fields.answer !== undefined) {
const q = logGet(fields.id);
if (q?.type === 'question') fields = { ...fields, question_hash: questionHash(q) };
}
const file = logFileFor(by, type);
const prefix = SCHEMA['x-id'].prefix[type];
const build = (at) => {
Expand Down Expand Up @@ -1184,9 +1190,22 @@ const upkeepGoal = () => [...readLog().objects.values()].find((o) => o.type ===
const logGet = (id) => readLog().objects.get(id) ?? null;
const byTime = (a, b) => a.created_at.localeCompare(b.created_at);

// What the owner saw: the question with every field it was asked with (later lines may only add
// the supervisor's status and reason, and the owner's own fields). The owner's answer records this
// hash, and an answer whose question no longer hashes the same answers nothing: the question was
// rewritten after the owner answered it (its line edited, or a backdated duplicate taking its
// place), so it is open again, and the owner answers what it says now.
const QUESTION_LATER = ['status', 'reason', 'answer', 'said', '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);

// Folded questions in the shape the inbox uses.
function questionView(q) {
const ans = q.updates.filter((u) => u.answer !== undefined && writerClass(u.by) === 'owner').pop();
const hash = questionHash(q);
const last = q.updates.filter((u) => u.answer !== undefined && writerClass(u.by) === 'owner').pop();
// An answer from before answers carried the hash (no question_hash) still counts, unless a
// second line for the question exists: then nothing shows which one the owner saw.
const rewritten = !!last && (last.question_hash ? last.question_hash !== hash : !!q.__duplicate);
const ans = rewritten ? null : last;
const st = q.updates.filter((u) => u.status !== undefined).pop();
let status = 'open';
let answer = null;
Expand Down Expand Up @@ -1215,7 +1234,7 @@ function questionView(q) {
question: q.question, context: q.context ?? '', options: q.options ?? [], choices: q.choices ?? [], links: q.links ?? [], goal: q.goal ?? null, change: q.change ?? null, rule_scope: q.rule_scope ?? null, recommendation: q.recommendation ?? '', confidence: q.confidence ?? null,
default: q.default ?? null, deadline: q.deadline ?? null, blocks: q.blocks ?? [], approves: q.approves ?? [], evidence: q.evidence ?? [], note: q.note ?? null, why_asked: q.why_asked ?? null,
secrets: (q.secrets ?? []).map((x) => ({ ...x, to: sinkWords(x.sink), sink_type: SINK_RE.exec(x.sink ?? '')?.[1] ?? null, warning: sinkWarning(x.sink) })), secrets_posted: q.updates.filter((u) => u.secrets_posted !== undefined && writerClass(u.by) === 'owner').pop()?.secrets_posted ?? [],
status, answer, answered_by: answeredBy, answered_at: answeredAt, matched,
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,
review: review?.review ?? null, result: review?.result ?? null, declined: review ? review.declined : null, allow_partial: review ? review.allow_partial : null, result_status: review?.result_status ?? null,
};
Expand Down Expand Up @@ -1431,6 +1450,17 @@ function cmdLogSync() {
}

// Validate every line (live and durable) and list what the fold ignored.
// A second line for a question replaces it if backdated, and a question changed after the owner
// answered it has lost that answer (questionView): both are the log being rewritten.
// The rewritten question fails until the owner answers what it says now; the leftover line then
// warns (the log is append-only, so it can't be removed).
function questionRewrites() {
const errors = [];
const warnings = [];
for (const q of logObjects('question')) if (questionView(q).rewritten) errors.push(`${q.id}: the question changed after the owner answered it, so that answer no longer counts and it is open again`);
for (const i of readLog().ignored) if (i.line.type === 'question' && i.why === 'duplicate id') warnings.push(`${i.line.id}: a second question line with this id (${i.line.__file}:${i.line.__line}); a question is asked once`);
return { errors, warnings };
}
function cmdLogCheck() {
const names = new Set();
for (const d of [liveLogDir(), durableLogDir()]) if (fs.existsSync(d)) for (const f of fs.readdirSync(d)) if (f.endsWith('.jsonl')) names.add(f);
Expand All @@ -1442,6 +1472,9 @@ function cmdLogCheck() {
n += r.lines.length;
}
for (const i of readLog().ignored) out(`ignored: ${i.line.__file}:${i.line.__line} ${i.line.id}: ${i.why}`);
const rw = questionRewrites();
for (const w of rw.warnings) out(`warn: ${w}`);
fails.push(...rw.errors);
report(fails, `${n} log line(s)`);
}

Expand Down Expand Up @@ -2111,6 +2144,7 @@ function cmdValidate() {
}

for (const e of readLog().errors) warnings.push(`log ${e}`);
{ const r = questionRewrites(); errors.push(...r.errors); warnings.push(...r.warnings); }
for (const i of readLog().ignored) if (['ticket', 'feature'].includes(i.line.type) && i.why === 'duplicate id') errors.push(`${i.line.id}: a second ${i.line.type} line with this id (${i.line.__file}:${i.line.__line}) was ignored; use ${i.line.type} set`);
const raw = ladder().raw_ceilings;
for (const [c, v] of Object.entries(loadSettings().autonomy_ceilings ?? {})) {
Expand Down Expand Up @@ -3602,6 +3636,7 @@ function cmdAnswer() {
}
const { by } = fromChat ? { by: 'owner' } : ownerWriter();
const v = openQuestion(id, ['open', 'defaulted']);
if (flagStr('seen') && flagStr('seen') !== v.hash) die(`${id} changed since you saw it; reload and read it again before you answer`);
if (v.kind === 'goal-approval') die(`${id} approves a goal on its design: the owner answers it with goal decide ${v.goal} approve|refine|let-go [--notes "…"] (or on the dashboard)`);
// A review is answered by the owner's own process with its result, or sent back with a reason;
// never in free text, never from chat (a chat can't carry rows, and the supervisor must not
Expand Down Expand Up @@ -6605,7 +6640,7 @@ async function cmdDashboard() {
answer: (b) => {
if (!ID.test(b.id ?? '') || !text(b.answer)) return null;
if (b.decline !== undefined && b.decline !== true) return null;
const args = ['answer', b.id, b.answer.trim(), ...autonomyArgs(b)];
const args = ['answer', b.id, b.answer.trim(), ...autonomyArgs(b), ...(/^[0-9a-f]{16}$/.test(b.seen ?? '') ? ['--seen', b.seen] : [])];
// "Send it back" on a review: the owner's reason, no result
if (b.decline === true) return [...args, '--decline'];
if (text(b.rule, 600)) args.push('--rule', b.rule.trim());
Expand Down
58 changes: 56 additions & 2 deletions test/kit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -977,7 +977,60 @@ test('devteam kit', async (t) => {
assert.match(dt(['merge', '--chore', 'rules', '--dry-run', '--approved', Qn, '--title', 'chore-rules: tighten', '--body-file', body], { role: 'sup' }).err, /was answered "no, leave quality\.md alone", not its recommendation \("yes"\): that approves nothing/);
const Qg = must(['ask', '--question', 'Add the extra rule to quality.md?', '--class', 'governance-edit', '--reasons', 'owner-only', '--recommendation', 'yes'], { role: 'sup' });
must(['answer', Qg, 'yes']);
must(['merge', '--chore', 'rules', '--dry-run', '--approved', Qg, '--title', 'chore-rules: tighten', '--body-file', body], { role: 'sup' });
const mergeArgs = ['merge', '--chore', 'rules', '--dry-run', '--approved', Qg, '--title', 'chore-rules: tighten', '--body-file', body];
must(mergeArgs, { role: 'sup' });
// The answer binds to the question as the owner saw it. The supervisor writes its own file, so it
// could put a backdated second line for that id (which the fold would take as the question) or
// edit the line in place: either way the answer no longer counts, the question is open again,
// and log-check fails.
const logDir = path.join(repo, 'docs/devteam/run/log');
const supFile = path.join(logDir, fs.readdirSync(logDir).find((f) => f.startsWith('supervisor-')));
const kept = fs.readFileSync(supFile, 'utf8');
const orig = kept.split('\n').filter(Boolean).map((l) => JSON.parse(l)).find((l) => l.id === Qg && l.op === 'new');
const wider = 'Add the extra rule to quality.md, and drop every gate?';
const rewrites = {
'a backdated second line': kept + line({ ...orig, at: new Date(Date.parse(orig.at) - 10).toISOString(), question: wider }),
'the line edited in place': kept.replace(JSON.stringify(orig.question), JSON.stringify(wider)),
};
for (const [how, text] of Object.entries(rewrites)) {
fs.writeFileSync(supFile, text);
assert.match(dt(mergeArgs, { role: 'sup' }).err, /is not a question the owner answered/, how);
const v = json(must(['questions', '--id', Qg, '--json']))[0];
assert.equal(v.status, 'open', how);
assert.equal(v.rewritten, true, how);
assert.equal(v.question, wider, how);
const lc = dt(['log-check']);
assert.notEqual(lc.status, 0, how);
assert.match(lc.out, /changed after the owner answered it/, how);
if (how === 'a backdated second line') assert.match(lc.out, /a second question line with this id/);
}
fs.writeFileSync(supFile, kept);
must(mergeArgs, { role: 'sup' });
// an answer from before answers carried the hash still counts, unless a second line for the
// question exists: then nothing shows which one the owner saw
const ownFile = fs.readdirSync(logDir).filter((f) => f.startsWith('owner-')).map((f) => path.join(logDir, f)).find((f) => fs.readFileSync(f, 'utf8').includes(Qg));
const ownKept = fs.readFileSync(ownFile, 'utf8');
fs.writeFileSync(ownFile, ownKept.split('\n').map((l) => { if (!l) return l; const o = JSON.parse(l); if (o.id !== Qg || !o.question_hash) return l; delete o.question_hash; return JSON.stringify(o); }).join('\n'));
must(mergeArgs, { role: 'sup' });
fs.writeFileSync(supFile, rewrites['a backdated second line']);
assert.match(dt(mergeArgs, { role: 'sup' }).err, /is not a question the owner answered/, 'a legacy answer and a second line');
fs.writeFileSync(ownFile, ownKept);
fs.writeFileSync(supFile, kept);
// once the owner answers the question as it now reads, the leftover line only warns
fs.writeFileSync(supFile, rewrites['a backdated second line']);
must(['answer', Qg, 'yes']);
const after = dt(['log-check']);
assert.equal(after.status, 0, after.out + after.err);
assert.match(after.out, /warn: .*a second question line with this id/);
fs.writeFileSync(supFile, kept);
fs.writeFileSync(ownFile, ownKept);
must(mergeArgs, { role: 'sup' });
// the dashboard sends the hash of the question it showed: an answer to one changed since is refused
const Qs = must(['ask', '--question', 'Keep the extra rule?', '--class', 'governance-edit', '--reasons', 'owner-only', '--recommendation', 'yes'], { role: 'sup' });
const seen = json(must(['questions', '--id', Qs, '--json']))[0].hash;
assert.match(seen, /^[0-9a-f]{16}$/);
assert.match(dt(['answer', Qs, 'yes', '--seen', '0000000000000000']).err, /changed since you saw it/);
must(['answer', Qs, 'yes', '--seen', seen]);
git(['worktree', 'remove', '--force', wt('chore-rules')]);
});

Expand Down Expand Up @@ -4074,7 +4127,8 @@ test('link probes: four at a time inside a tick budget, never a prompt, and a pa
// 401/403/405 is a page behind auth: a low note, never a high finding that wakes wait
edit((j) => { j.links = { locked: url('locked', 'auth'), gone: url('gone', 'gone'), console: { kind: 'repo', title: 'Console', remote: 'usbclub/console', path: co } }; });
g(['commit', '-qam', 'links 2'], old); g(['push', '-q', 'origin', 'main'], old);
tick({ PATH: `${shim}${path.delimiter}${process.env.PATH}` });
// these probes are all quick: a long budget only stands in for a loaded machine
tick({ PATH: `${shim}${path.delimiter}${process.env.PATH}`, DEVTEAM_PROBE_BUDGET_MS: '120000' });
const fs2 = json(ok(old, ['metrics', '--json'])).findings;
assert.equal(fs2.find((f) => f.subject === 'locked')?.kind, 'link-behind-auth');
assert.equal(fs2.find((f) => f.subject === 'locked').severity, 'low');
Expand Down
Loading