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
1 change: 1 addition & 0 deletions changelog.d/1861-meeting-speaks-for.added.md
Original file line number Diff line number Diff line change
@@ -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)).
13 changes: 10 additions & 3 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) : [];
Expand Down Expand Up @@ -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 },
Expand All @@ -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)(
Expand Down
21 changes: 19 additions & 2 deletions core/src/client/project-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 !== '')
: [],
}
}

Expand Down Expand Up @@ -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 === ''
Expand All @@ -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')),
Expand Down
73 changes: 64 additions & 9 deletions core/src/host/meetings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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 }
}
Expand Down Expand Up @@ -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')
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)' },
Expand Down
14 changes: 13 additions & 1 deletion core/src/host/project-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading