diff --git a/changelog.d/1861-meeting-speaks-for.added.md b/changelog.d/1861-meeting-speaks-for.added.md new file mode 100644 index 000000000..5a20d3cb2 --- /dev/null +++ b/changelog.d/1861-meeting-speaks-for.added.md @@ -0,0 +1 @@ +A meeting can now record who a Project Chief or an owner's agent speaks for: positions carry `speaksFor`, the archived decision file names the proxy beside that position, and a `severe` decision is refused when one side rests only on a proxy whose person has neither taken a position nor been recorded silent ([#1861](https://github.com/ThinkFlowLab/FlyBot/issues/1861)). diff --git a/core/client.js b/core/client.js index 4d1bdd506..dd4c37710 100644 --- a/core/client.js +++ b/core/client.js @@ -20237,7 +20237,10 @@ var meetingOf = (meeting, status) => { // recipients, exactly as the host reads it. level: text(meeting.level) || "ordinary", recipients: Array.isArray(meeting.recipients) ? meeting.recipients.map((item) => text(item)).filter((name) => name !== "") : [], - advance: meeting.advance !== null && typeof meeting.advance === "object" ? { allowed: meeting.advance.allowed === true, reason: text(meeting.advance.reason) || null } : { allowed: true, reason: null } + advance: meeting.advance !== null && typeof meeting.advance === "object" ? { allowed: meeting.advance.allowed === true, reason: text(meeting.advance.reason) || null } : { allowed: true, reason: null }, + // #1861: the host's participants with who each position speaks for, so the row can say a + // Project Chief or an agent held it for someone rather than for themselves. + participants: Array.isArray(meeting.participants) ? meeting.participants.map((participant) => ({ name: text(participant?.name), speaksFor: text(participant?.speaksFor) || null })).filter((participant) => participant.name !== "") : [] }; }; var listOf = (value) => Array.isArray(value) ? value.map((item) => decisionOf(item)).filter((item) => item !== null) : []; @@ -20514,18 +20517,21 @@ function ProjectBoardRecord({ record }) { (0, import_react47.createElement)("span", { className: "bt-project-title" }, file.title === "" ? file.name : file.title), (0, import_react47.createElement)("span", { className: "bt-project-hash", title: file.sha256 }, file.shortHash) )); + const recipientText = (list) => list.length === 0 ? null : list.map((name) => `@${name}`).join(", "); + const proxyText = (meeting) => meeting.participants.filter((participant) => participant.speaksFor !== null).map((participant) => `${participant.name} ${t("for {user}", { user: participant.speaksFor })}`).join(", "); + const proxyRow = (meeting) => proxyText(meeting) === "" ? null : (0, import_react47.createElement)("span", { className: "bt-project-speaks-for", "data-speaks-for": meeting.participants.filter((p) => p.speaksFor !== null).map((p) => p.speaksFor).join(", ") }, proxyText(meeting)); + const levelOf = (meeting) => (0, import_react47.createElement)("span", { className: "bt-project-level", "data-level": meeting.level }, t(meeting.level)); const decidedRows = meetings.decided.map((meeting) => (0, import_react47.createElement)( "div", { key: meeting.key, className: "bt-project-meeting", "data-status": "decided" }, (0, import_react47.createElement)("span", { className: "bt-project-date" }, meeting.date), (0, import_react47.createElement)("span", { className: "bt-project-title" }, meeting.question), meeting.owner === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-owner" }, `@${meeting.owner}`), + proxyRow(meeting), // A decided meeting whose file is gone still reads as decided; the missing path says so // instead of the row disappearing with the file. meeting.path === "" ? (0, import_react47.createElement)("span", { className: "bt-project-missing" }, "decision file gone") : (0, import_react47.createElement)("span", { className: "bt-project-path" }, meeting.path) )); - const recipientText = (list) => list.length === 0 ? null : list.map((name) => `@${name}`).join(", "); - const levelOf = (meeting) => (0, import_react47.createElement)("span", { className: "bt-project-level", "data-level": meeting.level }, t(meeting.level)); const pendingRows = meetings.pending.map((meeting) => (0, import_react47.createElement)( "div", { key: meeting.key, className: "bt-project-meeting", "data-status": meeting.status, "data-level": meeting.level }, @@ -20534,6 +20540,7 @@ function ProjectBoardRecord({ record }) { levelOf(meeting), recipientText(meeting.recipients) === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-recipients" }, recipientText(meeting.recipients)), meeting.advance.allowed ? null : (0, import_react47.createElement)("span", { className: "bt-project-paused" }, meeting.advance.reason), + proxyRow(meeting), (0, import_react47.createElement)("span", { className: "bt-project-status" }, meeting.status) )); return (0, import_react47.createElement)( diff --git a/core/src/client/project-board.js b/core/src/client/project-board.js index 386a1a742..e93f40e87 100644 --- a/core/src/client/project-board.js +++ b/core/src/client/project-board.js @@ -140,6 +140,13 @@ const meetingOf = (meeting, status) => { advance: meeting.advance !== null && typeof meeting.advance === 'object' ? { allowed: meeting.advance.allowed === true, reason: text(meeting.advance.reason) || null } : { allowed: true, reason: null }, + // #1861: the host's participants with who each position speaks for, so the row can say a + // Project Chief or an agent held it for someone rather than for themselves. + participants: Array.isArray(meeting.participants) + ? meeting.participants + .map(participant => ({ name: text(participant?.name), speaksFor: text(participant?.speaksFor) || null })) + .filter(participant => participant.name !== '') + : [], } } @@ -424,10 +431,21 @@ function ProjectBoardRecord({ record }) { h('span', { className: 'bt-project-date' }, file.date), h('span', { className: 'bt-project-title' }, file.title === '' ? file.name : file.title), h('span', { className: 'bt-project-hash', title: file.sha256 }, file.shortHash))) + const recipientText = list => (list.length === 0 ? null : list.map(name => `@${name}`).join(', ')) + // A proxy line reads in the same words a task row already uses for a proxy holder. + const proxyText = meeting => meeting.participants + .filter(participant => participant.speaksFor !== null) + .map(participant => `${participant.name} ${t('for {user}', { user: participant.speaksFor })}`) + .join(', ') + const proxyRow = meeting => (proxyText(meeting) === '' + ? null + : h('span', { className: 'bt-project-speaks-for', 'data-speaks-for': meeting.participants.filter(p => p.speaksFor !== null).map(p => p.speaksFor).join(', ') }, proxyText(meeting))) + const levelOf = meeting => h('span', { className: 'bt-project-level', 'data-level': meeting.level }, t(meeting.level)) const decidedRows = meetings.decided.map(meeting => h('div', { key: meeting.key, className: 'bt-project-meeting', 'data-status': 'decided' }, h('span', { className: 'bt-project-date' }, meeting.date), h('span', { className: 'bt-project-title' }, meeting.question), meeting.owner === null ? null : h('span', { className: 'bt-project-owner' }, `@${meeting.owner}`), + proxyRow(meeting), // A decided meeting whose file is gone still reads as decided; the missing path says so // instead of the row disappearing with the file. meeting.path === '' @@ -436,14 +454,13 @@ function ProjectBoardRecord({ record }) { // #1831: a pending decision card carries its level, who it is addressed to, and — for a severe // card — the host's own refusal to advance it. The words are the host's; the badge only says // which level it is, so a reader can see a severe card without opening it. - const recipientText = list => (list.length === 0 ? null : list.map(name => `@${name}`).join(', ')) - const levelOf = meeting => h('span', { className: 'bt-project-level', 'data-level': meeting.level }, t(meeting.level)) const pendingRows = meetings.pending.map(meeting => h('div', { key: meeting.key, className: 'bt-project-meeting', 'data-status': meeting.status, 'data-level': meeting.level }, h('span', { className: 'bt-project-date' }, meeting.date), h('span', { className: 'bt-project-title' }, meeting.blockingQuestion === null ? meeting.question : meeting.blockingQuestion), levelOf(meeting), recipientText(meeting.recipients) === null ? null : h('span', { className: 'bt-project-recipients' }, recipientText(meeting.recipients)), meeting.advance.allowed ? null : h('span', { className: 'bt-project-paused' }, meeting.advance.reason), + proxyRow(meeting), h('span', { className: 'bt-project-status' }, meeting.status))) return h('section', { className: 'bt-project-record', 'data-decisions': String(decisions.count), 'data-meetings': String(meetings.count) }, h('h4', null, t('Decisions')), diff --git a/core/src/host/meetings.js b/core/src/host/meetings.js index 513d04879..38b8efb55 100644 --- a/core/src/host/meetings.js +++ b/core/src/host/meetings.js @@ -40,13 +40,19 @@ export const decisionsPath = (now, slug) => `decisions/${dateOf(now)}-${slug}.md function readParticipants(value) { if (!Array.isArray(value) || value.length === 0) throw refuse('invalid', 'a meeting needs at least one invited participant') if (value.length > PARTICIPANTS_MAX) throw refuse('invalid', `a meeting invites at most ${PARTICIPANTS_MAX} participants`) - const names = [] + const invited = [] for (const item of value) { const name = oneLine(typeof item === 'string' ? item : item?.name, NAME_MAX) if (name === '') throw refuse('invalid', 'a participant needs a name') - if (!names.includes(name)) names.push(name) + const speaksFor = oneLine(typeof item === 'string' ? '' : item?.speaksFor, NAME_MAX) + if (invited.some(entry => entry.name === name)) continue + invited.push(speaksFor === '' ? { name } : { name, speaksFor }) } - return names.map(name => ({ name, position: undefined, silent: false })) + return invited.map(entry => ({ + ...entry, + position: undefined, + silent: false, + })) } // #1831: the level a decision card carries, and who it is addressed to. `ordinary` closes on one @@ -103,8 +109,36 @@ function readOptions(value) { // or undefined when it may: every invited participant recorded a position (or is recorded as not // answering), the decision names its owner, and a dissent carries its own owner. `blocked` is not // checked here — a blocked meeting is a legitimate outcome with its own one-sentence question. +// #1861: a Project Chief or an owner's agent may hold a position for a person, but a `severe` +// decision may not rest on a proxy alone. When one side's every position is a proxy and the +// spoken-for person has neither taken a position nor been recorded silent, the meeting is refused +// before anything is written: the person whose risk it is has to be on the record. +export function proxyRefusal(meeting) { + const level = DECISION_LEVELS.includes(meeting.level) ? meeting.level : 'ordinary' + if (level !== 'severe') return undefined + const sides = new Map() + for (const participant of meeting.participants ?? []) { + if (participant?.position === undefined) continue + const spokenFor = oneLine(participant.speaksFor ?? participant.position?.speaksFor, NAME_MAX) + const side = sides.get(participant.position.stance) ?? [] + side.push({ name: participant.name, speaksFor: spokenFor }) + sides.set(participant.position.stance, side) + } + for (const [stance, side] of sides) { + if (!side.every(entry => entry.speaksFor !== '')) continue + for (const entry of side) { + const person = (meeting.participants ?? []).find(p => p.name === entry.speaksFor) + if (person !== undefined && (person.position !== undefined || person.silent === true)) continue + return `the ${stance} side of a severe decision rests only on a proxy: ${entry.speaksFor} must take the position itself or be recorded silent` + } + } + return undefined +} + export function governanceRefusal(meeting) { if (!OPEN_STATES.has(meeting.status)) return `the meeting is ${meeting.status}, not open or deliberating` + const proxied = proxyRefusal(meeting) + if (proxied !== undefined) return proxied const missing = (meeting.participants ?? []).filter(p => p.position === undefined && p.silent !== true).map(p => p.name) if (missing.length > 0) return `no position recorded for: ${missing.join(', ')}` if (oneLine(meeting.owner, NAME_MAX) === '') return 'the decision names no owner' @@ -163,7 +197,8 @@ const decisionBody = (meeting, file, at) => { const said = position === undefined ? 'invited; recorded no position (silent)' : `${position.stance}${position.text ? ` — ${position.text}` : ''}` - lines.push(`- **${participant.name}** — ${said}`) + const proxy = oneLine(participant.speaksFor ?? position?.speaksFor, NAME_MAX) + lines.push(`- **${participant.name}** — ${said}${proxy === '' ? '' : ` (speaks for ${proxy})`}`) } lines.push('', '## Dissent', '') lines.push(meeting.dissent === undefined @@ -220,7 +255,7 @@ export function install(rt) { return meeting } - const position = (meeting, { as, stance, text, silent }, now) => { + const position = (meeting, { as, stance, text, silent, speaksFor }, now) => { if (!OPEN_STATES.has(meeting.status)) throw refuse('closed', `the meeting is ${meeting.status}`) const name = oneLine(as, NAME_MAX) const participant = meeting.participants.find(p => p.name === name) @@ -236,7 +271,12 @@ export function install(rt) { participant.silent = false const chosen = String(stance ?? 'agree') if (!STANCES.includes(chosen)) throw refuse('invalid', 'stance must be agree, dissent or abstain') - participant.position = { stance: chosen, text: oneLine(text, TEXT_MAX), at: now } + // #1861: a Project Chief or an owner's agent writes a position on a person's behalf, and the + // record says whose it is; an empty value clears it rather than leaving a stale name behind. + const spokenFor = oneLine(speaksFor, NAME_MAX) + if (spokenFor === '') delete participant.speaksFor + else participant.speaksFor = spokenFor + participant.position = { stance: chosen, text: oneLine(text, TEXT_MAX), at: now, ...(spokenFor === '' ? {} : { speaksFor: spokenFor }) } if (chosen === 'dissent' && oneLine(text, TEXT_MAX) !== '') { meeting.dissent = { text: oneLine(text, TEXT_MAX), owner: name } } @@ -466,7 +506,8 @@ export function install(rt) { if (meeting.blockingAnswer !== undefined) lines.push(`answer: ${meeting.blockingAnswer}`) if (meeting.decisionFile !== undefined) lines.push(`decision file: ${meeting.decisionFile}`, `tasks: ${meeting.taskIds.join(', ') || 'none'}`) for (const participant of meeting.participants) { - lines.push(`- ${participant.name}: ${participant.position === undefined ? 'silent' : `${participant.position.stance}${participant.position.text ? ` — ${participant.position.text}` : ''}`}`) + const proxy = oneLine(participant.speaksFor ?? participant.position?.speaksFor, NAME_MAX) + lines.push(`- ${participant.name}: ${participant.position === undefined ? 'silent' : `${participant.position.stance}${participant.position.text ? ` — ${participant.position.text}` : ''}`}${proxy === '' ? '' : ` (speaks for ${proxy})`}`) } return lines.join('\n') } @@ -483,7 +524,7 @@ export function install(rt) { return summary(made) } case 'position': { - const recorded = position(find(body.meeting), { as: body.as ?? actor?.name, stance: body.stance, text: body.text, silent: body.silent }, now) + const recorded = position(find(body.meeting), { as: body.as ?? actor?.name, stance: body.stance, text: body.text, silent: body.silent, speaksFor: body.speaksFor }, now) await rt.save() return summary(recorded) } @@ -518,9 +559,23 @@ export function install(rt) { title: { type: 'string', description: 'Optional short title for the decision record (open)' }, issue: { type: 'integer', description: 'The issue number that triggered the meeting (open)' }, project: { type: 'string', description: 'The Project the meeting belongs to (open)' }, - participants: { type: 'array', items: { type: 'string' }, description: 'Every invited participant name (open)' }, + participants: { + type: 'array', + items: { + oneOf: [ + { type: 'string' }, + { + type: 'object', + additionalProperties: false, + properties: { name: { type: 'string' }, speaksFor: { type: 'string' } }, + }, + ], + }, + description: 'Every invited participant name, or {name, speaksFor} when the participant is a proxy for someone (open)', + }, options: { type: 'array', items: { type: 'string' }, description: 'Options the meeting weighed (open)' }, as: { type: 'string', description: 'The participant the position belongs to (position); defaults to the calling Bot\'s name' }, + speaksFor: { type: 'string', description: 'The person a proxy position speaks for (position): a Project Chief or the owner\'s agent records whose position this is' }, silent: { type: 'boolean', description: 'Record that the participant did not answer (position); counts as a recorded position for `decided`' }, stance: { type: 'string', enum: ['agree', 'dissent', 'abstain'], description: 'The position taken (position)' }, text: { type: 'string', description: 'What the participant said (position)' }, diff --git a/core/src/host/project-board.js b/core/src/host/project-board.js index d05ba2a05..cb2ea4658 100644 --- a/core/src/host/project-board.js +++ b/core/src/host/project-board.js @@ -187,8 +187,20 @@ export function decisionCardOf(meeting) { const recipients = Array.isArray(meeting?.recipients) ? meeting.recipients.map(item => text(typeof item === 'string' ? item : item?.name)).filter(name => name !== '') : [] + // #1861: the board says who spoke for whom, so a proxy position reads as one. A participant with + // no speaksFor is the person themselves; nothing is inferred. + const participants = Array.isArray(meeting?.participants) + ? meeting.participants + .map(participant => ({ + name: text(participant?.name), + speaksFor: text(participant?.speaksFor ?? participant?.position?.speaksFor) || null, + stance: text(participant?.position?.stance) || null, + silent: participant?.silent === true, + })) + .filter(participant => participant.name !== '') + : [] const refusal = advanceRefusal(meeting) - return { level, recipients, advance: refusal === undefined ? { allowed: true } : { allowed: false, reason: refusal } } + return { level, recipients, participants, advance: refusal === undefined ? { allowed: true } : { allowed: false, reason: refusal } } } export function install(rt) { diff --git a/core/test/meetings.test.js b/core/test/meetings.test.js index bb1cb4dd5..2ea3d10ba 100644 --- a/core/test/meetings.test.js +++ b/core/test/meetings.test.js @@ -223,3 +223,57 @@ test('#1731: two Chinese questions decided on one day keep separate decision fil assert.notEqual(files[0], files[1]) assert.deepEqual(readdirSync(join(dir, 'decisions')).sort(), files.map(f => f.replace('decisions/', '')).sort()) }) + +// #1861: a Project Chief or an owner's agent speaks for a person. The position is recorded once, +// the record says whose it is, and a severe decision may not rest on a proxy alone. +test('#1861: a proxy position is stored, counted once, and the record names whom it speaks for', async (t) => { + const { rt, dir, meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Who holds the storage decision?', ['Chief', 'Owner', { name: 'Project Chief', speaksFor: 'Owner' }]) + const text = await meeting({ action: 'position', meeting: id, as: 'Project Chief', stance: 'agree', text: 'the existing store', speaksFor: 'Owner' }) + assert.match(text, /positions 1\/3/) + assert.match(text, /Project Chief: agree — the existing store \(speaks for Owner\)/) + const stored = rt.state.meetings[id].participants.find(p => p.name === 'Project Chief') + assert.equal(stored.speaksFor, 'Owner') + assert.equal(stored.position.speaksFor, 'Owner') + // The same position read again is one position, not two. + await meeting({ action: 'position', meeting: id, as: 'Owner', silent: true }) + await meeting({ action: 'position', meeting: id, as: 'Chief', stance: 'agree', text: 'the existing store' }) + await meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Use the existing store.', evidence: [{ pr: 1861 }] }) + const body = readFileSync(join(dir, rt.state.meetings[id].decisionFile), 'utf8') + assert.match(body, /\*\*Project Chief\*\* — agree — the existing store \(speaks for Owner\)/) + assert.match(body, /Written-by: Chief/) +}) + +test('#1861: a severe decision is refused when its only agree position is a proxy and the person is absent', async (t) => { + const { rt, dir, meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Should the storage migration be irreversible?', ['Chief', 'Owner', 'Reviewer'], { level: 'severe' }) + await meeting({ action: 'position', meeting: id, as: 'Chief', stance: 'agree', text: 'ship it', speaksFor: 'Owner' }) + await meeting({ action: 'position', meeting: id, as: 'Reviewer', stance: 'dissent', text: 'wait' }) + await assert.rejects( + meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Ship the migration.', evidence: [{ pr: 1861 }] }), + /governance: the agree side of a severe decision rests only on a proxy: Owner must take the position itself or be recorded silent/, + ) + assert.equal(rt.state.meetings[id].status, 'deliberating') + assert.equal(existsSync(join(dir, 'decisions')), false, 'a refused severe decision writes no file') + // The same proxy carries it once the person is recorded silent, and the file says so. + await meeting({ action: 'position', meeting: id, as: 'Owner', silent: true }) + await meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Ship the migration.', evidence: [{ pr: 1861 }] }) + const body = readFileSync(join(dir, rt.state.meetings[id].decisionFile), 'utf8') + assert.match(body, /\*\*Owner\*\* — invited; recorded no position \(silent\)/) + assert.match(body, /\*\*Chief\*\* — agree — ship it \(speaks for Owner\)/) +}) + +test('#1861: the decision card exposes the participants and their proxies', async (t) => { + const { rt, open, meeting, stop } = await boot() + t.after(stop) + const { decisionCardOf } = await import('../src/host/project-board.js') + const id = await open('Who holds the storage decision?', ['Chief', 'Owner']) + await meeting({ action: 'position', meeting: id, as: 'Chief', stance: 'agree', text: 'ship it', speaksFor: 'Owner' }) + const card = decisionCardOf(rt.state.meetings[id]) + assert.deepEqual(card.participants, [ + { name: 'Chief', speaksFor: 'Owner', stance: 'agree', silent: false }, + { name: 'Owner', speaksFor: null, stance: null, silent: false }, + ]) +}) diff --git a/core/test/project-board-levels.test.js b/core/test/project-board-levels.test.js index 78f5cd96b..8f820077d 100644 --- a/core/test/project-board-levels.test.js +++ b/core/test/project-board-levels.test.js @@ -81,7 +81,7 @@ test('#1831: a severe card on the board carries the pause reason the host refuse assert.equal(severe.advance.allowed, false) assert.match(severe.advance.reason, /waits for the Owner/) // A record written before this slice carries none of the three keys and reads as ordinary. - assert.deepEqual(decisionCardOf({ id: 'meeting-0', status: 'open' }), { level: 'ordinary', recipients: [], advance: { allowed: true } }) + assert.deepEqual(decisionCardOf({ id: 'meeting-0', status: 'open' }), { level: 'ordinary', recipients: [], participants: [], advance: { allowed: true } }) }) test('#1831: the board read copies the level, the recipients and the identities it was given', async () => { diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index dd4c7ce72..f92b23a3d 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -343,4 +343,4 @@ inferred from an assignee label. ## Project board metrics and the running table (#1829) -`core/src/client/project-board.js` draws the seven cards the host's `project-board` read carries in `metrics`: four day counts and three seven-day medians, each with a one-line caption naming the field and the window. A value the host answered `null` renders `—` (u+2014) and never `0`; an `ok: false` read draws the same cards plus one line naming the host's reason. `metricsView` is pure and takes the host's object as it came. The running table below renders `metrics.running` — a claimed card's title, status, holder, claim age (from the claim's own start), source and last activity — marks a row whose claim passed `RUNNING_OVERDUE_MS` and sends a click through the same `onOpen` the board rows use; with no rows it draws one empty line. The Waiting / In flight / Blocked numbers stay on the one-line count header beside those groups. +`core/src/client/project-board.js` draws the seven cards the host's `project-board` read carries in `metrics`: four day counts and three seven-day medians, each with a one-line caption naming the field and the window. A value the host answered `null` renders `—` (u+2014) and never `0`; an `ok: false` read draws the same cards plus one line naming the host's reason. `metricsView` is pure and takes the host's object as it came. A meeting row also draws the proxy line (#1861): `projectBoardRecord` keeps each meeting's `participants` with their `speaksFor`, and the decided and pending rows render every proxy as ` for {user}` in the same `for {user}` words a task row uses for its holder, with the spoken-for ids also on `data-speaks-for`. The running table below renders `metrics.running` — a claimed card's title, status, holder, claim age (from the claim's own start), source and last activity — marks a row whose claim passed `RUNNING_OVERDUE_MS` and sends a click through the same `onOpen` the board rows use; with no rows it draws one empty line. The Waiting / In flight / Blocked numbers stay on the one-line count header beside those groups. diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index d7d1e27ab..8fc1bb22f 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -623,6 +623,10 @@ card with no recipients. The same read separates the three identities the record carries `holder` (the claim, never the assignee label) and `speaksFor` (the user a card is written for), while a decision file keeps its own `Written-by:`. Tests: `core/test/project-board-levels.test.js`. +### Speaking for a person (#1861) + +A participant may be invited with `speaksFor` and a `position` may record it, so a Project Chief or an owner's agent can hold a position on a person's behalf; the participant and its position both carry the id, `decide` copies it into the archived record, and the file's `## Participants` line reads `(speaks for )` beside that position. A proxy position counts once in `positions n/m`. `proxyRefusal(meeting)` in `core/src/host/meetings.js` runs inside `governanceRefusal`: a `severe` decision whose only position on one side is a proxy is refused before anything is written unless the spoken-for person is invited and holds their own position or is recorded `silent`. `decisionCardOf` exposes `participants: [{ name, speaksFor, stance, silent }]` and the client meeting row draws the proxy with the existing `for {user}` words. Tests: `core/test/meetings.test.js`. + Reading the board (`project-board`) stays open to a non-owner: the same rows, read-only. Those rows are the ones the caller could open through `tasks` (#1723): `store.js` passes the authenticated actor into `projectBoard(projectId, actor)`, and the board drops every card diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 9d3115751..f46e082f5 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -18372,7 +18372,7 @@ diff --git a/client.js b/client.js } function createBotBrowsers({ bridge, createSource: createSource2, storage = globalThis.localStorage, document: doc = globalThis.document }) { const views = /* @__PURE__ */ new Map(); -@@ -13052,11 +13052,814 @@ +@@ -13052,11 +13052,821 @@ }; } @@ -18589,7 +18589,10 @@ diff --git a/client.js b/client.js + // recipients, exactly as the host reads it. + level: text(meeting.level) || "ordinary", + recipients: Array.isArray(meeting.recipients) ? meeting.recipients.map((item) => text(item)).filter((name) => name !== "") : [], -+ advance: meeting.advance !== null && typeof meeting.advance === "object" ? { allowed: meeting.advance.allowed === true, reason: text(meeting.advance.reason) || null } : { allowed: true, reason: null } ++ advance: meeting.advance !== null && typeof meeting.advance === "object" ? { allowed: meeting.advance.allowed === true, reason: text(meeting.advance.reason) || null } : { allowed: true, reason: null }, ++ // #1861: the host's participants with who each position speaks for, so the row can say a ++ // Project Chief or an agent held it for someone rather than for themselves. ++ participants: Array.isArray(meeting.participants) ? meeting.participants.map((participant) => ({ name: text(participant?.name), speaksFor: text(participant?.speaksFor) || null })).filter((participant) => participant.name !== "") : [] + }; +}; +var listOf = (value) => Array.isArray(value) ? value.map((item) => decisionOf(item)).filter((item) => item !== null) : []; @@ -18866,18 +18869,21 @@ diff --git a/client.js b/client.js + (0, import_react47.createElement)("span", { className: "bt-project-title" }, file.title === "" ? file.name : file.title), + (0, import_react47.createElement)("span", { className: "bt-project-hash", title: file.sha256 }, file.shortHash) + )); ++ const recipientText = (list) => list.length === 0 ? null : list.map((name) => `@${name}`).join(", "); ++ const proxyText = (meeting) => meeting.participants.filter((participant) => participant.speaksFor !== null).map((participant) => `${participant.name} ${t("for {user}", { user: participant.speaksFor })}`).join(", "); ++ const proxyRow = (meeting) => proxyText(meeting) === "" ? null : (0, import_react47.createElement)("span", { className: "bt-project-speaks-for", "data-speaks-for": meeting.participants.filter((p) => p.speaksFor !== null).map((p) => p.speaksFor).join(", ") }, proxyText(meeting)); ++ const levelOf = (meeting) => (0, import_react47.createElement)("span", { className: "bt-project-level", "data-level": meeting.level }, t(meeting.level)); + const decidedRows = meetings.decided.map((meeting) => (0, import_react47.createElement)( + "div", + { key: meeting.key, className: "bt-project-meeting", "data-status": "decided" }, + (0, import_react47.createElement)("span", { className: "bt-project-date" }, meeting.date), + (0, import_react47.createElement)("span", { className: "bt-project-title" }, meeting.question), + meeting.owner === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-owner" }, `@${meeting.owner}`), ++ proxyRow(meeting), + // A decided meeting whose file is gone still reads as decided; the missing path says so + // instead of the row disappearing with the file. + meeting.path === "" ? (0, import_react47.createElement)("span", { className: "bt-project-missing" }, "decision file gone") : (0, import_react47.createElement)("span", { className: "bt-project-path" }, meeting.path) + )); -+ const recipientText = (list) => list.length === 0 ? null : list.map((name) => `@${name}`).join(", "); -+ const levelOf = (meeting) => (0, import_react47.createElement)("span", { className: "bt-project-level", "data-level": meeting.level }, t(meeting.level)); + const pendingRows = meetings.pending.map((meeting) => (0, import_react47.createElement)( + "div", + { key: meeting.key, className: "bt-project-meeting", "data-status": meeting.status, "data-level": meeting.level }, @@ -18886,6 +18892,7 @@ diff --git a/client.js b/client.js + levelOf(meeting), + recipientText(meeting.recipients) === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-recipients" }, recipientText(meeting.recipients)), + meeting.advance.allowed ? null : (0, import_react47.createElement)("span", { className: "bt-project-paused" }, meeting.advance.reason), ++ proxyRow(meeting), + (0, import_react47.createElement)("span", { className: "bt-project-status" }, meeting.status) + )); + return (0, import_react47.createElement)( diff --git a/patches/ds-bot/files/src/client/project-board.js b/patches/ds-bot/files/src/client/project-board.js index 386a1a742..e93f40e87 100644 --- a/patches/ds-bot/files/src/client/project-board.js +++ b/patches/ds-bot/files/src/client/project-board.js @@ -140,6 +140,13 @@ const meetingOf = (meeting, status) => { advance: meeting.advance !== null && typeof meeting.advance === 'object' ? { allowed: meeting.advance.allowed === true, reason: text(meeting.advance.reason) || null } : { allowed: true, reason: null }, + // #1861: the host's participants with who each position speaks for, so the row can say a + // Project Chief or an agent held it for someone rather than for themselves. + participants: Array.isArray(meeting.participants) + ? meeting.participants + .map(participant => ({ name: text(participant?.name), speaksFor: text(participant?.speaksFor) || null })) + .filter(participant => participant.name !== '') + : [], } } @@ -424,10 +431,21 @@ function ProjectBoardRecord({ record }) { h('span', { className: 'bt-project-date' }, file.date), h('span', { className: 'bt-project-title' }, file.title === '' ? file.name : file.title), h('span', { className: 'bt-project-hash', title: file.sha256 }, file.shortHash))) + const recipientText = list => (list.length === 0 ? null : list.map(name => `@${name}`).join(', ')) + // A proxy line reads in the same words a task row already uses for a proxy holder. + const proxyText = meeting => meeting.participants + .filter(participant => participant.speaksFor !== null) + .map(participant => `${participant.name} ${t('for {user}', { user: participant.speaksFor })}`) + .join(', ') + const proxyRow = meeting => (proxyText(meeting) === '' + ? null + : h('span', { className: 'bt-project-speaks-for', 'data-speaks-for': meeting.participants.filter(p => p.speaksFor !== null).map(p => p.speaksFor).join(', ') }, proxyText(meeting))) + const levelOf = meeting => h('span', { className: 'bt-project-level', 'data-level': meeting.level }, t(meeting.level)) const decidedRows = meetings.decided.map(meeting => h('div', { key: meeting.key, className: 'bt-project-meeting', 'data-status': 'decided' }, h('span', { className: 'bt-project-date' }, meeting.date), h('span', { className: 'bt-project-title' }, meeting.question), meeting.owner === null ? null : h('span', { className: 'bt-project-owner' }, `@${meeting.owner}`), + proxyRow(meeting), // A decided meeting whose file is gone still reads as decided; the missing path says so // instead of the row disappearing with the file. meeting.path === '' @@ -436,14 +454,13 @@ function ProjectBoardRecord({ record }) { // #1831: a pending decision card carries its level, who it is addressed to, and — for a severe // card — the host's own refusal to advance it. The words are the host's; the badge only says // which level it is, so a reader can see a severe card without opening it. - const recipientText = list => (list.length === 0 ? null : list.map(name => `@${name}`).join(', ')) - const levelOf = meeting => h('span', { className: 'bt-project-level', 'data-level': meeting.level }, t(meeting.level)) const pendingRows = meetings.pending.map(meeting => h('div', { key: meeting.key, className: 'bt-project-meeting', 'data-status': meeting.status, 'data-level': meeting.level }, h('span', { className: 'bt-project-date' }, meeting.date), h('span', { className: 'bt-project-title' }, meeting.blockingQuestion === null ? meeting.question : meeting.blockingQuestion), levelOf(meeting), recipientText(meeting.recipients) === null ? null : h('span', { className: 'bt-project-recipients' }, recipientText(meeting.recipients)), meeting.advance.allowed ? null : h('span', { className: 'bt-project-paused' }, meeting.advance.reason), + proxyRow(meeting), h('span', { className: 'bt-project-status' }, meeting.status))) return h('section', { className: 'bt-project-record', 'data-decisions': String(decisions.count), 'data-meetings': String(meetings.count) }, h('h4', null, t('Decisions')), diff --git a/patches/ds-bot/files/src/host/meetings.js b/patches/ds-bot/files/src/host/meetings.js index 513d04879..38b8efb55 100644 --- a/patches/ds-bot/files/src/host/meetings.js +++ b/patches/ds-bot/files/src/host/meetings.js @@ -40,13 +40,19 @@ export const decisionsPath = (now, slug) => `decisions/${dateOf(now)}-${slug}.md function readParticipants(value) { if (!Array.isArray(value) || value.length === 0) throw refuse('invalid', 'a meeting needs at least one invited participant') if (value.length > PARTICIPANTS_MAX) throw refuse('invalid', `a meeting invites at most ${PARTICIPANTS_MAX} participants`) - const names = [] + const invited = [] for (const item of value) { const name = oneLine(typeof item === 'string' ? item : item?.name, NAME_MAX) if (name === '') throw refuse('invalid', 'a participant needs a name') - if (!names.includes(name)) names.push(name) + const speaksFor = oneLine(typeof item === 'string' ? '' : item?.speaksFor, NAME_MAX) + if (invited.some(entry => entry.name === name)) continue + invited.push(speaksFor === '' ? { name } : { name, speaksFor }) } - return names.map(name => ({ name, position: undefined, silent: false })) + return invited.map(entry => ({ + ...entry, + position: undefined, + silent: false, + })) } // #1831: the level a decision card carries, and who it is addressed to. `ordinary` closes on one @@ -103,8 +109,36 @@ function readOptions(value) { // or undefined when it may: every invited participant recorded a position (or is recorded as not // answering), the decision names its owner, and a dissent carries its own owner. `blocked` is not // checked here — a blocked meeting is a legitimate outcome with its own one-sentence question. +// #1861: a Project Chief or an owner's agent may hold a position for a person, but a `severe` +// decision may not rest on a proxy alone. When one side's every position is a proxy and the +// spoken-for person has neither taken a position nor been recorded silent, the meeting is refused +// before anything is written: the person whose risk it is has to be on the record. +export function proxyRefusal(meeting) { + const level = DECISION_LEVELS.includes(meeting.level) ? meeting.level : 'ordinary' + if (level !== 'severe') return undefined + const sides = new Map() + for (const participant of meeting.participants ?? []) { + if (participant?.position === undefined) continue + const spokenFor = oneLine(participant.speaksFor ?? participant.position?.speaksFor, NAME_MAX) + const side = sides.get(participant.position.stance) ?? [] + side.push({ name: participant.name, speaksFor: spokenFor }) + sides.set(participant.position.stance, side) + } + for (const [stance, side] of sides) { + if (!side.every(entry => entry.speaksFor !== '')) continue + for (const entry of side) { + const person = (meeting.participants ?? []).find(p => p.name === entry.speaksFor) + if (person !== undefined && (person.position !== undefined || person.silent === true)) continue + return `the ${stance} side of a severe decision rests only on a proxy: ${entry.speaksFor} must take the position itself or be recorded silent` + } + } + return undefined +} + export function governanceRefusal(meeting) { if (!OPEN_STATES.has(meeting.status)) return `the meeting is ${meeting.status}, not open or deliberating` + const proxied = proxyRefusal(meeting) + if (proxied !== undefined) return proxied const missing = (meeting.participants ?? []).filter(p => p.position === undefined && p.silent !== true).map(p => p.name) if (missing.length > 0) return `no position recorded for: ${missing.join(', ')}` if (oneLine(meeting.owner, NAME_MAX) === '') return 'the decision names no owner' @@ -163,7 +197,8 @@ const decisionBody = (meeting, file, at) => { const said = position === undefined ? 'invited; recorded no position (silent)' : `${position.stance}${position.text ? ` — ${position.text}` : ''}` - lines.push(`- **${participant.name}** — ${said}`) + const proxy = oneLine(participant.speaksFor ?? position?.speaksFor, NAME_MAX) + lines.push(`- **${participant.name}** — ${said}${proxy === '' ? '' : ` (speaks for ${proxy})`}`) } lines.push('', '## Dissent', '') lines.push(meeting.dissent === undefined @@ -220,7 +255,7 @@ export function install(rt) { return meeting } - const position = (meeting, { as, stance, text, silent }, now) => { + const position = (meeting, { as, stance, text, silent, speaksFor }, now) => { if (!OPEN_STATES.has(meeting.status)) throw refuse('closed', `the meeting is ${meeting.status}`) const name = oneLine(as, NAME_MAX) const participant = meeting.participants.find(p => p.name === name) @@ -236,7 +271,12 @@ export function install(rt) { participant.silent = false const chosen = String(stance ?? 'agree') if (!STANCES.includes(chosen)) throw refuse('invalid', 'stance must be agree, dissent or abstain') - participant.position = { stance: chosen, text: oneLine(text, TEXT_MAX), at: now } + // #1861: a Project Chief or an owner's agent writes a position on a person's behalf, and the + // record says whose it is; an empty value clears it rather than leaving a stale name behind. + const spokenFor = oneLine(speaksFor, NAME_MAX) + if (spokenFor === '') delete participant.speaksFor + else participant.speaksFor = spokenFor + participant.position = { stance: chosen, text: oneLine(text, TEXT_MAX), at: now, ...(spokenFor === '' ? {} : { speaksFor: spokenFor }) } if (chosen === 'dissent' && oneLine(text, TEXT_MAX) !== '') { meeting.dissent = { text: oneLine(text, TEXT_MAX), owner: name } } @@ -466,7 +506,8 @@ export function install(rt) { if (meeting.blockingAnswer !== undefined) lines.push(`answer: ${meeting.blockingAnswer}`) if (meeting.decisionFile !== undefined) lines.push(`decision file: ${meeting.decisionFile}`, `tasks: ${meeting.taskIds.join(', ') || 'none'}`) for (const participant of meeting.participants) { - lines.push(`- ${participant.name}: ${participant.position === undefined ? 'silent' : `${participant.position.stance}${participant.position.text ? ` — ${participant.position.text}` : ''}`}`) + const proxy = oneLine(participant.speaksFor ?? participant.position?.speaksFor, NAME_MAX) + lines.push(`- ${participant.name}: ${participant.position === undefined ? 'silent' : `${participant.position.stance}${participant.position.text ? ` — ${participant.position.text}` : ''}`}${proxy === '' ? '' : ` (speaks for ${proxy})`}`) } return lines.join('\n') } @@ -483,7 +524,7 @@ export function install(rt) { return summary(made) } case 'position': { - const recorded = position(find(body.meeting), { as: body.as ?? actor?.name, stance: body.stance, text: body.text, silent: body.silent }, now) + const recorded = position(find(body.meeting), { as: body.as ?? actor?.name, stance: body.stance, text: body.text, silent: body.silent, speaksFor: body.speaksFor }, now) await rt.save() return summary(recorded) } @@ -518,9 +559,23 @@ export function install(rt) { title: { type: 'string', description: 'Optional short title for the decision record (open)' }, issue: { type: 'integer', description: 'The issue number that triggered the meeting (open)' }, project: { type: 'string', description: 'The Project the meeting belongs to (open)' }, - participants: { type: 'array', items: { type: 'string' }, description: 'Every invited participant name (open)' }, + participants: { + type: 'array', + items: { + oneOf: [ + { type: 'string' }, + { + type: 'object', + additionalProperties: false, + properties: { name: { type: 'string' }, speaksFor: { type: 'string' } }, + }, + ], + }, + description: 'Every invited participant name, or {name, speaksFor} when the participant is a proxy for someone (open)', + }, options: { type: 'array', items: { type: 'string' }, description: 'Options the meeting weighed (open)' }, as: { type: 'string', description: 'The participant the position belongs to (position); defaults to the calling Bot\'s name' }, + speaksFor: { type: 'string', description: 'The person a proxy position speaks for (position): a Project Chief or the owner\'s agent records whose position this is' }, silent: { type: 'boolean', description: 'Record that the participant did not answer (position); counts as a recorded position for `decided`' }, stance: { type: 'string', enum: ['agree', 'dissent', 'abstain'], description: 'The position taken (position)' }, text: { type: 'string', description: 'What the participant said (position)' }, diff --git a/patches/ds-bot/files/src/host/project-board.js b/patches/ds-bot/files/src/host/project-board.js index d05ba2a05..cb2ea4658 100644 --- a/patches/ds-bot/files/src/host/project-board.js +++ b/patches/ds-bot/files/src/host/project-board.js @@ -187,8 +187,20 @@ export function decisionCardOf(meeting) { const recipients = Array.isArray(meeting?.recipients) ? meeting.recipients.map(item => text(typeof item === 'string' ? item : item?.name)).filter(name => name !== '') : [] + // #1861: the board says who spoke for whom, so a proxy position reads as one. A participant with + // no speaksFor is the person themselves; nothing is inferred. + const participants = Array.isArray(meeting?.participants) + ? meeting.participants + .map(participant => ({ + name: text(participant?.name), + speaksFor: text(participant?.speaksFor ?? participant?.position?.speaksFor) || null, + stance: text(participant?.position?.stance) || null, + silent: participant?.silent === true, + })) + .filter(participant => participant.name !== '') + : [] const refusal = advanceRefusal(meeting) - return { level, recipients, advance: refusal === undefined ? { allowed: true } : { allowed: false, reason: refusal } } + return { level, recipients, participants, advance: refusal === undefined ? { allowed: true } : { allowed: false, reason: refusal } } } export function install(rt) { diff --git a/patches/ds-bot/files/test/meetings.test.js b/patches/ds-bot/files/test/meetings.test.js index bb1cb4dd5..2ea3d10ba 100644 --- a/patches/ds-bot/files/test/meetings.test.js +++ b/patches/ds-bot/files/test/meetings.test.js @@ -223,3 +223,57 @@ test('#1731: two Chinese questions decided on one day keep separate decision fil assert.notEqual(files[0], files[1]) assert.deepEqual(readdirSync(join(dir, 'decisions')).sort(), files.map(f => f.replace('decisions/', '')).sort()) }) + +// #1861: a Project Chief or an owner's agent speaks for a person. The position is recorded once, +// the record says whose it is, and a severe decision may not rest on a proxy alone. +test('#1861: a proxy position is stored, counted once, and the record names whom it speaks for', async (t) => { + const { rt, dir, meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Who holds the storage decision?', ['Chief', 'Owner', { name: 'Project Chief', speaksFor: 'Owner' }]) + const text = await meeting({ action: 'position', meeting: id, as: 'Project Chief', stance: 'agree', text: 'the existing store', speaksFor: 'Owner' }) + assert.match(text, /positions 1\/3/) + assert.match(text, /Project Chief: agree — the existing store \(speaks for Owner\)/) + const stored = rt.state.meetings[id].participants.find(p => p.name === 'Project Chief') + assert.equal(stored.speaksFor, 'Owner') + assert.equal(stored.position.speaksFor, 'Owner') + // The same position read again is one position, not two. + await meeting({ action: 'position', meeting: id, as: 'Owner', silent: true }) + await meeting({ action: 'position', meeting: id, as: 'Chief', stance: 'agree', text: 'the existing store' }) + await meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Use the existing store.', evidence: [{ pr: 1861 }] }) + const body = readFileSync(join(dir, rt.state.meetings[id].decisionFile), 'utf8') + assert.match(body, /\*\*Project Chief\*\* — agree — the existing store \(speaks for Owner\)/) + assert.match(body, /Written-by: Chief/) +}) + +test('#1861: a severe decision is refused when its only agree position is a proxy and the person is absent', async (t) => { + const { rt, dir, meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Should the storage migration be irreversible?', ['Chief', 'Owner', 'Reviewer'], { level: 'severe' }) + await meeting({ action: 'position', meeting: id, as: 'Chief', stance: 'agree', text: 'ship it', speaksFor: 'Owner' }) + await meeting({ action: 'position', meeting: id, as: 'Reviewer', stance: 'dissent', text: 'wait' }) + await assert.rejects( + meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Ship the migration.', evidence: [{ pr: 1861 }] }), + /governance: the agree side of a severe decision rests only on a proxy: Owner must take the position itself or be recorded silent/, + ) + assert.equal(rt.state.meetings[id].status, 'deliberating') + assert.equal(existsSync(join(dir, 'decisions')), false, 'a refused severe decision writes no file') + // The same proxy carries it once the person is recorded silent, and the file says so. + await meeting({ action: 'position', meeting: id, as: 'Owner', silent: true }) + await meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Ship the migration.', evidence: [{ pr: 1861 }] }) + const body = readFileSync(join(dir, rt.state.meetings[id].decisionFile), 'utf8') + assert.match(body, /\*\*Owner\*\* — invited; recorded no position \(silent\)/) + assert.match(body, /\*\*Chief\*\* — agree — ship it \(speaks for Owner\)/) +}) + +test('#1861: the decision card exposes the participants and their proxies', async (t) => { + const { rt, open, meeting, stop } = await boot() + t.after(stop) + const { decisionCardOf } = await import('../src/host/project-board.js') + const id = await open('Who holds the storage decision?', ['Chief', 'Owner']) + await meeting({ action: 'position', meeting: id, as: 'Chief', stance: 'agree', text: 'ship it', speaksFor: 'Owner' }) + const card = decisionCardOf(rt.state.meetings[id]) + assert.deepEqual(card.participants, [ + { name: 'Chief', speaksFor: 'Owner', stance: 'agree', silent: false }, + { name: 'Owner', speaksFor: null, stance: null, silent: false }, + ]) +}) diff --git a/patches/ds-bot/files/test/project-board-levels.test.js b/patches/ds-bot/files/test/project-board-levels.test.js index 78f5cd96b..8f820077d 100644 --- a/patches/ds-bot/files/test/project-board-levels.test.js +++ b/patches/ds-bot/files/test/project-board-levels.test.js @@ -81,7 +81,7 @@ test('#1831: a severe card on the board carries the pause reason the host refuse assert.equal(severe.advance.allowed, false) assert.match(severe.advance.reason, /waits for the Owner/) // A record written before this slice carries none of the three keys and reads as ordinary. - assert.deepEqual(decisionCardOf({ id: 'meeting-0', status: 'open' }), { level: 'ordinary', recipients: [], advance: { allowed: true } }) + assert.deepEqual(decisionCardOf({ id: 'meeting-0', status: 'open' }), { level: 'ordinary', recipients: [], participants: [], advance: { allowed: true } }) }) test('#1831: the board read copies the level, the recipients and the identities it was given', async () => {