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/1831-decision-levels.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Decision cards carry a level (ordinary, major, severe) and the people they are addressed to; a severe card never advances on its own and says why. The Project board shows the level badge and recipients, and a card row names its claim holder and the user the Assistant speaks for.
36 changes: 34 additions & 2 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1387,6 +1387,11 @@ var ZH = {
Meetings: "会议",
"Not decided": "未决定",
"decision file gone": "决策文件已不在",
// The decision card's level and the identity a row carries (#1831).
ordinary: "普通",
major: "重大",
severe: "严重",
"for {user}": "代 {user} 发言",
// The Project board names its columns with the same keys the task record uses.
"not-started": "未开始",
"in-progress": "进行中",
Expand Down Expand Up @@ -20140,6 +20145,14 @@ var PROJECT_BOARD_CSS = `
.bt-project-path{font-size:11px;opacity:.6;font-family:var(--bt-mono,ui-monospace,monospace)}
.bt-project-missing{font-size:11px;opacity:.6}
.bt-project-status{font-size:11px;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-left:auto}
/* #1831: the card's level, the people it is addressed to, and the identity a row or a comment
carries. A severe card is the only one painted louder, because it is the only one that waits. */
.bt-project-level{font-size:10px;text-transform:uppercase;letter-spacing:.06em;padding:1px 6px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));opacity:.75}
.bt-project-level[data-level="severe"]{opacity:1;border-color:#c2410c;color:#c2410c}
.bt-project-recipients{font-size:11px;opacity:.65}
.bt-project-paused{font-size:11px;opacity:.75;color:#c2410c}
.bt-project-holder{font-size:11px;opacity:.7}
.bt-project-speaks-for{font-size:11px;opacity:.7;font-style:italic}
.bt-project-actions{display:flex;gap:8px;margin:0 0 14px}
.bt-project-metrics{margin:0 0 14px}
.bt-project-metric-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(132px,1fr));gap:8px}
Expand Down Expand Up @@ -20218,7 +20231,13 @@ var meetingOf = (meeting, status) => {
owner: text(meeting.owner) || null,
status,
path: text(meeting.path),
blockingQuestion: text(meeting.blockingQuestion) || null
blockingQuestion: text(meeting.blockingQuestion) || null,
// #1831: the level and recipients the host answered, and the refusal to advance a severe card.
// Nothing is inferred here: a record written before the slice carries an ordinary card and no
// 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 }
};
};
var listOf = (value) => Array.isArray(value) ? value.map((item) => decisionOf(item)).filter((item) => item !== null) : [];
Expand Down Expand Up @@ -20262,6 +20281,10 @@ function projectBoardView({ project, rows, canManage, tasks, decisions, meetings
title: text(task?.title) || text(task?.note) || null,
priority: text(task?.priority) || null,
audit: text(row.audit) || null,
// #1831: who is doing the card, and who is speaking for whom. Both are host truth;
// a card carrying neither renders exactly as before.
holder: text(row.holder) || null,
speaksFor: text(row.speaksFor) || null,
board: boardOfRow(row, task)
};
})
Expand Down Expand Up @@ -20328,6 +20351,10 @@ function ProjectBoardRows({ view, onOpen }) {
// second read. A row without one renders exactly as before.
row.audit === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-audit" }, row.audit)
),
// "Who is doing" is the claim, never the assignee label; a card the Assistant speaks for
// says so, so the three identities (Bot, Assistant, user) never read as one.
row.holder === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-holder", "data-holder": row.holder }, `@${row.holder}`),
row.speaksFor === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-speaks-for", "data-speaks-for": row.speaksFor }, t("for {user}", { user: row.speaksFor })),
row.priority === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-priority" }, row.priority),
row.runId === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-ref" }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`)
);
Expand Down Expand Up @@ -20497,11 +20524,16 @@ function ProjectBoardRecord({ record }) {
// 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 },
{ key: meeting.key, className: "bt-project-meeting", "data-status": meeting.status, "data-level": meeting.level },
(0, import_react47.createElement)("span", { className: "bt-project-date" }, meeting.date),
(0, import_react47.createElement)("span", { className: "bt-project-title" }, meeting.blockingQuestion === null ? meeting.question : meeting.blockingQuestion),
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),
(0, import_react47.createElement)("span", { className: "bt-project-status" }, meeting.status)
));
return (0, import_react47.createElement)(
Expand Down
5 changes: 5 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -1340,6 +1340,11 @@ const ZH = {
Meetings: '会议',
'Not decided': '未决定',
'decision file gone': '决策文件已不在',
// The decision card's level and the identity a row carries (#1831).
ordinary: '普通',
major: '重大',
severe: '严重',
'for {user}': '代 {user} 发言',
// The Project board names its columns with the same keys the task record uses.
'not-started': '未开始',
'in-progress': '进行中',
Expand Down
34 changes: 33 additions & 1 deletion core/src/client/project-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ export const PROJECT_BOARD_CSS = `
.bt-project-path{font-size:11px;opacity:.6;font-family:var(--bt-mono,ui-monospace,monospace)}
.bt-project-missing{font-size:11px;opacity:.6}
.bt-project-status{font-size:11px;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-left:auto}
/* #1831: the card's level, the people it is addressed to, and the identity a row or a comment
carries. A severe card is the only one painted louder, because it is the only one that waits. */
.bt-project-level{font-size:10px;text-transform:uppercase;letter-spacing:.06em;padding:1px 6px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));opacity:.75}
.bt-project-level[data-level="severe"]{opacity:1;border-color:#c2410c;color:#c2410c}
.bt-project-recipients{font-size:11px;opacity:.65}
.bt-project-paused{font-size:11px;opacity:.75;color:#c2410c}
.bt-project-holder{font-size:11px;opacity:.7}
.bt-project-speaks-for{font-size:11px;opacity:.7;font-style:italic}
.bt-project-actions{display:flex;gap:8px;margin:0 0 14px}
.bt-project-metrics{margin:0 0 14px}
.bt-project-metric-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(132px,1fr));gap:8px}
Expand Down Expand Up @@ -124,6 +132,14 @@ const meetingOf = (meeting, status) => {
status,
path: text(meeting.path),
blockingQuestion: text(meeting.blockingQuestion) || null,
// #1831: the level and recipients the host answered, and the refusal to advance a severe card.
// Nothing is inferred here: a record written before the slice carries an ordinary card and no
// 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 },
}
}

Expand Down Expand Up @@ -184,6 +200,10 @@ export function projectBoardView({ project, rows, canManage, tasks, decisions, m
title: text(task?.title) || text(task?.note) || null,
priority: text(task?.priority) || null,
audit: text(row.audit) || null,
// #1831: who is doing the card, and who is speaking for whom. Both are host truth;
// a card carrying neither renders exactly as before.
holder: text(row.holder) || null,
speaksFor: text(row.speaksFor) || null,
board: boardOfRow(row, task),
}
}),
Expand Down Expand Up @@ -249,6 +269,10 @@ function ProjectBoardRows({ view, onOpen }) {
// The audit text the row already carries (#1730): shown inside the card detail, never a
// second read. A row without one renders exactly as before.
row.audit === null ? null : h('span', { className: 'bt-project-audit' }, row.audit)),
// "Who is doing" is the claim, never the assignee label; a card the Assistant speaks for
// says so, so the three identities (Bot, Assistant, user) never read as one.
row.holder === null ? null : h('span', { className: 'bt-project-holder', 'data-holder': row.holder }, `@${row.holder}`),
row.speaksFor === null ? null : h('span', { className: 'bt-project-speaks-for', 'data-speaks-for': row.speaksFor }, t('for {user}', { user: row.speaksFor })),
row.priority === null ? null : h('span', { className: 'bt-project-priority' }, row.priority),
row.runId === null ? null : h('span', { className: 'bt-project-ref' }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`))
})))
Expand Down Expand Up @@ -409,9 +433,17 @@ function ProjectBoardRecord({ record }) {
meeting.path === ''
? h('span', { className: 'bt-project-missing' }, 'decision file gone')
: h('span', { className: 'bt-project-path' }, meeting.path)))
const pendingRows = meetings.pending.map(meeting => h('div', { key: meeting.key, className: 'bt-project-meeting', 'data-status': meeting.status },
// #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),
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
67 changes: 66 additions & 1 deletion core/src/host/meetings.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,48 @@ function readParticipants(value) {
return names.map(name => ({ name, position: undefined, silent: false }))
}

// #1831: the level a decision card carries, and who it is addressed to. `ordinary` closes on one
// Maintainer's agreement, `major` needs every participating Maintainer to agree, and `severe`
// (security, data loss, irreversible) pauses on a substantial minority objection for the Owner.
export const DECISION_LEVELS = CLASSES
const RECIPIENTS_MAX = 32

export const decisionLevel = (value, fallback = 'ordinary') => {
if (value === undefined || value === null || oneLine(value, 20) === '') return fallback
const level = oneLine(value, 20)
if (!DECISION_LEVELS.includes(level)) throw refuse('invalid', `level must be one of ${DECISION_LEVELS.join(', ')}`)
return level
}

// Who a card is addressed to: every Maintainer it names, plus the Owner when it needs one. The
// list is stored as names, deduped, and read back as names — no membership set is invented here.
const readRecipients = (value) => {
if (value === undefined || value === null) return []
if (!Array.isArray(value)) throw refuse('invalid', 'recipients must be a list of names')
const names = []
for (const item of value) {
const name = oneLine(typeof item === 'string' ? item : item?.name, NAME_MAX)
if (name === '') throw refuse('invalid', 'a recipient needs a name')
if (!names.includes(name)) names.push(name)
}
if (names.length > RECIPIENTS_MAX) throw refuse('invalid', `a card is addressed to at most ${RECIPIENTS_MAX} recipients`)
return names
}

// The one level that never advances on its own. A `severe` card is refused automatic promotion
// with a reason a reader can check — protection against a real risk, not a veto for whoever
// objects. `ordinary` and `major` advance by their own rule, so this answers nothing for them.
export function advanceRefusal(meeting) {
if (meeting === null || typeof meeting !== 'object') return 'no decision card to advance'
const level = DECISION_LEVELS.includes(meeting.level) ? meeting.level : 'ordinary'
if (level !== 'severe') return undefined
const dissent = (Array.isArray(meeting.participants) ? meeting.participants : [])
.filter(participant => participant?.position?.stance === 'dissent').length
return dissent > 0
? `a severe decision waits for the Owner: ${dissent} participant${dissent === 1 ? '' : 's'} dissented`
: 'a severe decision waits for the Owner, who confirms it before it advances'
}

function readOptions(value) {
if (value === undefined || value === null) return []
if (!Array.isArray(value)) throw refuse('invalid', 'options must be a list of one-line options')
Expand Down Expand Up @@ -89,6 +131,16 @@ const decisionBody = (meeting, file, at) => {
'',
`${meeting.class} — recorded by ${oneLine(meeting.owner, NAME_MAX)} through meeting \`${meeting.id}\`.`,
'',
'## Level',
'',
`${decisionLevel(meeting.level)}${advanceRefusal(meeting) === undefined ? '' : ' — needs the Owner before it advances'}.`,
'',
'## Recipients',
'',
...((meeting.recipients ?? []).length === 0
? ['none named']
: (meeting.recipients ?? []).map(name => `- ${oneLine(name, NAME_MAX)}`)),
'',
'## Evidence',
'',
]
Expand Down Expand Up @@ -140,7 +192,7 @@ export function install(rt) {
}
const stamp = () => Date.now()

const newMeeting = ({ question, participants, options, issue, project, title }, now) => {
const newMeeting = ({ question, participants, options, issue, project, title, level, recipients }, now) => {
const text = oneLine(question, QUESTION_MAX)
if (text === '') throw refuse('invalid', 'a meeting needs the question it answers')
const same = Object.values(store()).find(other => OPEN_STATES.has(other.status) && keyOf(other.question) === keyOf(text))
Expand All @@ -154,6 +206,8 @@ export function install(rt) {
slug: '',
participants: readParticipants(participants),
options: readOptions(options),
level: decisionLevel(level),
recipients: readRecipients(recipients),
...(issue === undefined || issue === null ? {} : { issue }),
...(project === undefined || project === null ? {} : { project: oneLine(project, NAME_MAX) }),
evidence: [],
Expand Down Expand Up @@ -262,6 +316,10 @@ export function install(rt) {
owner: oneLine(args.owner, NAME_MAX),
decision: oneLine(args.decision ?? args.text, TEXT_MAX),
class: String(args.class ?? 'ordinary'),
// #1831: a decision card carries a level and its recipients. A decide may set either; one
// it does not name keeps what the card was opened with, so the level never drifts silently.
level: decisionLevel(args.level, decisionLevel(meeting.level)),
recipients: args.recipients === undefined ? readRecipients(meeting.recipients) : readRecipients(args.recipients),
evidence: readEvidence(args.evidence),
}
if (!CLASSES.includes(candidate.class)) throw refuse('invalid', `class must be one of ${CLASSES.join(', ')}`)
Expand Down Expand Up @@ -398,6 +456,11 @@ export function install(rt) {
const positions = meeting.participants.filter(p => p.position !== undefined).length
const head = `${meeting.id} [${meeting.status}] ${meeting.question}`
const lines = [head, `positions ${positions}/${meeting.participants.length}${meeting.owner ? ` | owner ${meeting.owner}` : ''}`]
// #1831: the card's level and recipients are part of what the caller is told, and a severe
// card says in the summary why it will not advance on its own.
lines.push(`level: ${decisionLevel(meeting.level)}${(meeting.recipients ?? []).length === 0 ? '' : ` | recipients: ${(meeting.recipients ?? []).join(', ')}`}`)
const paused = advanceRefusal(meeting)
if (paused !== undefined) lines.push(`paused: ${paused}`)
if (meeting.dissent !== undefined) lines.push(`dissent: ${meeting.dissent.text} — ${meeting.dissent.owner}`)
if (meeting.blockingQuestion !== undefined) lines.push(`blocking: ${meeting.blockingQuestion}`)
if (meeting.blockingAnswer !== undefined) lines.push(`answer: ${meeting.blockingAnswer}`)
Expand Down Expand Up @@ -464,6 +527,8 @@ export function install(rt) {
owner: { type: 'string', description: 'Who owns the decision (decide)' },
decision: { type: 'string', description: 'What was decided, one paragraph (decide)' },
class: { type: 'string', enum: ['ordinary', 'major', 'severe'], description: 'The decision class (decide), ordinary by default' },
level: { type: 'string', enum: ['ordinary', 'major', 'severe'], description: 'The decision level (open, decide): ordinary, major or severe; severe does not advance without the Owner. Default ordinary, or whatever the card already carries' },
recipients: { type: 'array', items: { type: 'string' }, description: 'Who the card is addressed to (open, decide): the Maintainers, and the Owner when one must decide' },
dissent: {
type: 'object',
description: 'The dissent that still stands, with its owner (decide). Omit when none stands.',
Expand Down
Loading
Loading