diff --git a/changelog.d/1731-meetings.added.md b/changelog.d/1731-meetings.added.md new file mode 100644 index 000000000..caf981aae --- /dev/null +++ b/changelog.d/1731-meetings.added.md @@ -0,0 +1 @@ +Meetings are now recorded in FlyBot: a meeting moves `open → deliberating → decided | blocked`, requires a position from every invited participant and a named decision owner, archives one `decisions/-.md` on `decided` (and nothing on `blocked`), and creates the follow-up tasks with that file as evidence ([#1731](https://github.com/ThinkFlowLab/FlyBot/issues/1731)). diff --git a/core/client.js b/core/client.js index a8bc2c415..4ceed1696 100644 --- a/core/client.js +++ b/core/client.js @@ -6754,7 +6754,8 @@ var SILENT_REGISTRATION_TOOLS = /* @__PURE__ */ new Set([ "detach_capability", "track_links", "project_state", - "project_digest" + "project_digest", + "meeting" ]); var isSilentRegistration = (name) => typeof name === "string" && SILENT_REGISTRATION_TOOLS.has(name); var isHiddenNotice = (block) => block?.type === "tool-removal" && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName)) || block?.type === "tool-addition" && (isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName)); diff --git a/core/index.js b/core/index.js index c5538d531..3dd16083c 100644 --- a/core/index.js +++ b/core/index.js @@ -26,6 +26,7 @@ import * as timeContext from './src/host/time-context.js' import * as tools from './src/host/tools.js' import * as decisions from './src/host/decisions.js' import * as projectState from './src/host/project-state.js' +import * as meetings from './src/host/meetings.js' import * as chatIndex from './src/host/chat-index.js' import * as groups from './src/host/groups.js' import * as approvals from './src/host/approvals.js' @@ -58,7 +59,7 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses // the functions other modules call; they call them through `rt` at run time, never during // install. Listeners run and tools are listed in registration order, so this order holds: // usage meters model requests after every listener that rewrites them. -const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, channel] +const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, meetings, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, channel] export function apply(ctx, config = {}) { const rt = createRuntime(ctx, config) diff --git a/core/src/client/cells.js b/core/src/client/cells.js index 6b04f4ab3..c1333c515 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -724,7 +724,7 @@ const SILENT_REGISTRATION_TOOLS = new Set([ 'read_group_chat', 'remember', 'forget', 'recall', 'read_browser', 'list_capabilities', 'save_capability', 'delete_capability', 'attach_capability', 'detach_capability', - 'track_links', 'project_state', 'project_digest', + 'track_links', 'project_state', 'project_digest', 'meeting', ]) const isSilentRegistration = name => typeof name === 'string' && SILENT_REGISTRATION_TOOLS.has(name) const isHiddenNotice = block => (block?.type === 'tool-removal' && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName))) diff --git a/core/src/host/constants.js b/core/src/host/constants.js index 2df6cb973..c9ed9bfec 100644 --- a/core/src/host/constants.js +++ b/core/src/host/constants.js @@ -49,7 +49,7 @@ export const AGENT_SESSION_TITLE_MAX = 80 // Desktop profile, so a restriction filters this list down to registered names. export const TEAM_TOOLS = [ 'list_bots', 'message_bot', 'stop_bot', 'create_bot', 'update_bot', 'archive_bot', 'list_models', - 'bot_schedules', 'project_state', 'project_digest', + 'bot_schedules', 'project_state', 'project_digest', 'meeting', 'create_group', 'update_group', 'delete_group', 'post_to_group', 'ask_user', 'track_tasks', 'request_secret', 'list_secrets', 'group_relay', 'read_own_chat', 'read_group_chat', 'remember', 'forget', 'recall', 'read_browser', diff --git a/core/src/host/meetings.js b/core/src/host/meetings.js new file mode 100644 index 000000000..58dfaf106 --- /dev/null +++ b/core/src/host/meetings.js @@ -0,0 +1,410 @@ +// Meetings (#1731, Stage 4.1): an unresolved architecture conflict needs a record, not a chat. +// A meeting is a record with a lifecycle — `open → deliberating → decided | blocked` — bound to +// one question (and, when it has one, the issue that triggered it), carrying every invited +// participant's position. A `decided` meeting passes a governance check written as code here and +// writes one archived decision in the Stage 1 shape (`decisions/-.md`); a `blocked` +// meeting writes no file and produces no task. Decisions stay on the existing store +// (`state.meetings`), so there is no second store. +import { createHash, randomBytes } from 'node:crypto' +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { defineTool } from './tool-schema.js' +import { output } from './tools.js' +import { workDirFor } from './project-state.js' +import { ASSISTANT_ROLE } from './project-assistant.js' + +// The Project Assistant takes a position like anyone else, but it does not write the decision +// file and does not close the meeting (the Chief or the owner does). +const CLASSES = ['ordinary', 'major', 'severe'] +const STANCES = ['agree', 'dissent', 'abstain'] +const QUESTION_MAX = 600 +const TEXT_MAX = 600 +const NAME_MAX = 80 +const PARTICIPANTS_MAX = 32 +const OPEN_STATES = new Set(['open', 'deliberating']) + +const oneLine = (value, max) => String(value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max) +// A slug keeps letters and digits of every script, so two different Chinese questions do not both +// fall back to `meeting` and collide on one day's file. +const slugOf = value => oneLine(value, 80).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '').slice(0, 80) +const dateOf = now => new Date(now).toISOString().slice(0, 10) + +const refuse = (code, message) => Object.assign(new Error(`${code}: ${message}`), { code: `meeting/${code}` }) + +// Two meetings on one question are one too many. Compared on the question with casing and +// whitespace flattened, so "Is X good?" and "is x good?" are the same question. +const keyOf = question => oneLine(question, QUESTION_MAX).toLowerCase() + +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 = [] + 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) + } + return names.map(name => ({ name, position: undefined, silent: false })) +} + +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') + const options = value.map(item => oneLine(item, TEXT_MAX)).filter(Boolean) + if (options.length > 12) throw refuse('invalid', 'a meeting carries at most 12 options') + return options +} + +// The governance check, as code (#1731 §2). It answers why the meeting may not close `decided`, +// 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. +export function governanceRefusal(meeting) { + if (!OPEN_STATES.has(meeting.status)) return `the meeting is ${meeting.status}, not open or deliberating` + 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' + if (meeting.dissent !== undefined && oneLine(meeting.dissent.owner, NAME_MAX) === '') { + return 'the dissent is written down without its owner' + } + if (oneLine(meeting.decision, TEXT_MAX) === '') return 'the decision states nothing' + return undefined +} + +const decisionBody = (meeting, file, at) => { + const lines = [ + `# ${oneLine(meeting.title ?? meeting.question, 200)}`, + '', + '## Date', + '', + `${dateOf(at)} (UTC).`, + '', + '## Decision', + '', + oneLine(meeting.decision, TEXT_MAX), + '', + '## Class', + '', + `${meeting.class} — recorded by ${oneLine(meeting.owner, NAME_MAX)} through meeting \`${meeting.id}\`.`, + '', + '## Evidence', + '', + ] + for (const entry of meeting.evidence ?? []) { + const parts = [] + if (entry.sha) parts.push(`sha:${entry.sha}`) + if (entry.pr !== undefined) parts.push(`pr:#${entry.pr}`) + if (entry.check) parts.push(`check:${entry.check}`) + if (parts.length > 0) lines.push(`- ${parts.join(' ')}`) + } + lines.push(`- Meeting \`${meeting.id}\` on: ${oneLine(meeting.question, QUESTION_MAX)}`, '') + if ((meeting.options ?? []).length > 0) { + lines.push('## Options', '') + for (const option of meeting.options) lines.push(`- ${option}`) + lines.push('') + } + lines.push('## Participants', '') + for (const participant of meeting.participants) { + const position = participant.position + const said = position === undefined + ? 'invited; recorded no position (silent)' + : `${position.stance}${position.text ? ` — ${position.text}` : ''}` + lines.push(`- **${participant.name}** — ${said}`) + } + lines.push('', '## Dissent', '') + lines.push(meeting.dissent === undefined + ? 'none recorded' + : `${oneLine(meeting.dissent.text, TEXT_MAX)} — **${oneLine(meeting.dissent.owner, NAME_MAX)}**`) + lines.push('', '## Status', '', 'accepted', '') + lines.push('## Links', '', `- Decision file: ${file}`) + if (meeting.issue !== undefined) lines.push(`- Issue: #${meeting.issue}`) + if (meeting.project !== undefined) lines.push(`- Project: ${meeting.project}`) + if ((meeting.taskIds ?? []).length > 0) lines.push(`- Tasks: ${meeting.taskIds.join(', ')}`) + lines.push('', `Written-by: ${oneLine(meeting.closedBy ?? meeting.owner, NAME_MAX)}`, '') + return lines.join('\n') +} + +export function install(rt) { + rt.state.meetings ??= {} + const store = () => { + rt.state.meetings ??= {} + return rt.state.meetings + } + // The meeting this id names, or a refusal naming the id: a tool caller never gets `undefined`. + const find = id => { + const meeting = store()[String(id ?? '')] + if (meeting === undefined) throw refuse('not-found', `no meeting ${String(id ?? '')}`) + return meeting + } + const stamp = () => Date.now() + + const newMeeting = ({ question, participants, options, issue, project, title }, 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)) + if (same !== undefined) throw refuse('duplicate', `a meeting on that question is already ${same.status}: ${same.id}`) + const id = `meeting-${randomBytes(5).toString('hex')}` + const meeting = { + id, + status: 'open', + question: text, + ...(oneLine(title, 200) ? { title: oneLine(title, 200) } : {}), + slug: '', + participants: readParticipants(participants), + options: readOptions(options), + ...(issue === undefined || issue === null ? {} : { issue }), + ...(project === undefined || project === null ? {} : { project: oneLine(project, NAME_MAX) }), + evidence: [], + taskIds: [], + createdAt: now, + updatedAt: now, + } + meeting.slug = slugOf(meeting.question) || 'meeting' + store()[id] = meeting + return meeting + } + + const position = (meeting, { as, stance, text, silent }, 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) + if (participant === undefined) throw refuse('not-invited', `${name === '' ? 'the caller' : name} was not invited to this meeting`) + if (silent === true) { + // An invited participant who did not answer is recorded as silent, so `decided` can say so + // instead of waiting for a position that will not come. + participant.silent = true + if (meeting.status === 'open') meeting.status = 'deliberating' + meeting.updatedAt = now + return meeting + } + 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 } + if (chosen === 'dissent' && oneLine(text, TEXT_MAX) !== '') { + meeting.dissent = { text: oneLine(text, TEXT_MAX), owner: name } + } + if (meeting.status === 'open') meeting.status = 'deliberating' + meeting.updatedAt = now + return meeting + } + + const decide = async (meeting, args, { now, actor }) => { + if (!OPEN_STATES.has(meeting.status)) throw refuse('closed', `the meeting is ${meeting.status}`) + if (actor?.role === ASSISTANT_ROLE) throw refuse('forbidden', 'a Project Assistant takes a position; it does not close the meeting') + // The candidate is a copy: a refused decision leaves the stored meeting exactly as it was. + const candidate = { + ...meeting, + owner: oneLine(args.owner, NAME_MAX), + decision: oneLine(args.decision ?? args.text, TEXT_MAX), + class: String(args.class ?? 'ordinary'), + evidence: readEvidence(args.evidence), + } + if (!CLASSES.includes(candidate.class)) throw refuse('invalid', `class must be one of ${CLASSES.join(', ')}`) + if (args.dissent !== undefined) { + const text = oneLine(args.dissent?.text, TEXT_MAX) + const ownerName = oneLine(args.dissent?.owner, NAME_MAX) + if (text === '') throw refuse('invalid', 'a dissent needs the objection it records') + candidate.dissent = { text, owner: ownerName } + } + const reason = governanceRefusal(candidate) + if (reason !== undefined) throw refuse('governance', reason) + + const dir = args.dir ?? workDirFor(rt, args.exec) + if (typeof dir !== 'string' || dir === '') throw refuse('no-folder', 'the Session has no working folder to archive the decision in') + const file = decisionsPath(now, candidate.slug) + const target = join(dir, file) + await mkdir(join(dir, 'decisions'), { recursive: true }) + try { + await readFile(target) + throw refuse('exists', `the decision file already exists: ${file}`) + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } + + const closed = { + ...candidate, + status: 'decided', + decidedAt: now, + updatedAt: now, + closedBy: actor?.name ?? candidate.owner, + decisionFile: file, + } + const bytes = Buffer.from(decisionBody(closed, file, now), 'utf8') + // The file appears only after every produced task exists: a refused task deletes the temp + // file, so `decisions/` stays as it was and the meeting can be closed again. + const temp = `${target}.tmp-${randomBytes(4).toString('hex')}` + await writeFile(temp, bytes) + let taskIds + try { + taskIds = await produceTasks(closed, args.tasks, { now, actor }) + } catch (error) { + await rm(temp, { force: true }) + throw error + } + await rename(temp, target) + closed.decisionSha256 = createHash('sha256').update(bytes).digest('hex') + closed.taskIds = taskIds + store()[meeting.id] = closed + return closed + } + + // Follow-up work goes through the one task write path (`tasks.js`), each card carrying the + // decision file as its evidence pointer. A meeting does not keep tasks of its own. + const produceTasks = async (meeting, tasks, { now, actor }) => { + if (tasks === undefined || tasks === null) return [] + if (!Array.isArray(tasks)) throw refuse('invalid', 'tasks must be a list of { botId or assignee, title }') + const ids = [] + for (const item of tasks) { + const title = oneLine(item?.title, 300) + if (title === '') throw refuse('invalid', 'a produced task needs a title') + const wanted = oneLine(item?.botId ?? item?.assignee, NAME_MAX) + const bot = rt.state.bots?.[wanted] + const named = bot ?? Object.values(rt.state.bots ?? {}).find(candidate => candidate?.name === wanted) + if (named === undefined) throw refuse('no-bot', `no Bot named ${wanted === '' ? '(nobody)' : wanted} to own the produced task`) + const created = await rt.taskEndpoint('task-create', { + botId: named.id, + title, + taskClass: 'work', + ...(typeof item?.criteria === 'string' && item.criteria.trim() !== '' ? { criteria: item.criteria } : {}), + evidence: [{ check: meeting.decisionFile, at: now }], + }, actor) + ids.push(created.id) + } + return ids + } + + const block = (meeting, { blocking }, now) => { + if (!OPEN_STATES.has(meeting.status)) throw refuse('closed', `the meeting is ${meeting.status}`) + const question = oneLine(blocking, QUESTION_MAX) + if (question === '') throw refuse('invalid', 'a blocked meeting states the blocking question in one sentence') + meeting.status = 'blocked' + meeting.blockingQuestion = question + meeting.blockedAt = now + meeting.updatedAt = now + return meeting + } + + const summary = meeting => { + 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}` : ''}`] + if (meeting.dissent !== undefined) lines.push(`dissent: ${meeting.dissent.text} — ${meeting.dissent.owner}`) + if (meeting.blockingQuestion !== undefined) lines.push(`blocking: ${meeting.blockingQuestion}`) + 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}` : ''}`}`) + } + return lines.join('\n') + } + + rt.meetingEndpoint = async function meetingEndpoint(action, body = {}, actor) { + await rt.load() + const now = Number.isFinite(Number(body.now)) && body.now !== undefined ? Number(body.now) : stamp() + switch (action) { + case 'open': + return summary(newMeeting(body, now)) + case 'position': + return summary(position(find(body.meeting), { as: body.as ?? actor?.name, stance: body.stance, text: body.text, silent: body.silent }, now)) + case 'decide': + return summary(await decide(find(body.meeting), { ...body, exec: body.exec }, { now, actor })) + case 'block': + return summary(block(find(body.meeting), body, now)) + case 'read': + return summary(find(body.meeting)) + case 'list': + return Object.values(store()).map(summary).join('\n\n') || 'no meetings' + default: + throw refuse('invalid', `unknown meeting action ${String(action)}`) + } + } + + rt.ctx.tools.register(defineTool({ + name: 'meeting', + description: 'Open and run a meeting so an unresolved conflict has a record instead of a chat. A meeting moves open → deliberating → decided | blocked, bound to one question and the issue that triggered it. Record every invited participant\'s position, then close it: `decided` needs a position from every participant (or one recorded as silent), a named owner and a dissent with its own owner, writes one decisions/-.md and creates follow-up tasks; `blocked` states one blocking sentence and writes nothing. At most one open meeting per question.', + parameters: { + action: { type: 'string', required: true, enum: ['open', 'position', 'decide', 'block', 'read', 'list'], description: 'What to do with the meeting' }, + meeting: { type: 'string', description: 'The meeting id, from open' }, + question: { type: 'string', description: 'The question the meeting answers (open)' }, + 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)' }, + 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' }, + 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)' }, + 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' }, + dissent: { + type: 'object', + description: 'The dissent that still stands, with its owner (decide). Omit when none stands.', + properties: { + text: { type: 'string' }, + owner: { type: 'string' }, + }, + additionalProperties: false, + }, + evidence: { + type: 'array', + description: 'What the decision rests on: shas, pull requests and facts (decide)', + items: { + type: 'object', + additionalProperties: false, + properties: { + sha: { type: 'string' }, + pr: { type: 'integer' }, + check: { type: 'string' }, + }, + }, + }, + tasks: { + type: 'array', + description: 'Follow-up tasks a decided meeting produces, each created with the decision file as evidence', + items: { + type: 'object', + additionalProperties: false, + properties: { + botId: { type: 'string' }, + assignee: { type: 'string' }, + title: { type: 'string' }, + criteria: { type: 'string' }, + }, + }, + }, + blocking: { type: 'string', description: 'The blocking question in one sentence (block)' }, + dir: { type: 'string', description: 'Optional folder to archive the decision in; defaults to the Session working folder' }, + }, + output, + async execute(args, exec) { + await rt.load() + const self = rt.selfOf?.(exec?.agent?.id) ?? exec?.agent?.id + const bot = rt.botOf?.(self) + const actor = { name: bot?.name ?? self, role: bot?.role } + const action = String(args?.action ?? '') + return rt.meetingEndpoint(action, { ...args, exec }, actor) + }, + })) +} + +function readEvidence(value) { + if (value === undefined || value === null) return [] + if (!Array.isArray(value)) throw refuse('invalid', 'evidence must be a list of { sha, pr, check }') + if (value.length > 8) throw refuse('invalid', 'evidence carries at most 8 entries') + return value.map((entry) => { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) throw refuse('invalid', 'each evidence entry is an object of { sha, pr, check }') + const sha = oneLine(entry.sha, 64) + const check = oneLine(entry.check, 200) + let pr + if (entry.pr !== undefined && entry.pr !== null && entry.pr !== '') { + pr = typeof entry.pr === 'number' ? entry.pr : Number(entry.pr) + if (!Number.isInteger(pr) || pr < 1) throw refuse('invalid', 'evidence.pr must be a pull request number') + } + if (sha === '' && pr === undefined && check === '') throw refuse('invalid', 'an evidence entry needs a sha, a pr or a check') + return { ...(sha ? { sha } : {}), ...(pr === undefined ? {} : { pr }), ...(check ? { check } : {}) } + }) +} diff --git a/core/src/host/tools.js b/core/src/host/tools.js index 1d5f6b994..f59915c71 100644 --- a/core/src/host/tools.js +++ b/core/src/host/tools.js @@ -28,7 +28,7 @@ export const EPHEMERAL_DENIED_TOOLS = Object.freeze([ 'send_message', 'schedule_create', 'schedule_update', 'schedule_delete', 'message_friend_bot', 'get_friend_file', 'list_friend_bots', 'define_workflow', 'run_workflow', - 'read_browser', 'recall', 'create_child_bot', 'project_digest', + 'read_browser', 'recall', 'create_child_bot', 'project_digest', 'meeting', ]) // The team-facing names a run Bot keeps: read-only, and each reads only what the run Bot itself // may see. `remember`/`forget` are the only writes, and `writeDenied` keeps them inside the run diff --git a/core/test/install.test.js b/core/test/install.test.js index 077ee11fc..f5e9c0110 100644 --- a/core/test/install.test.js +++ b/core/test/install.test.js @@ -66,6 +66,7 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { 'tool detach_capability', 'tool track_tasks', 'tool project_state', + 'tool meeting', 'effect ds-bot: chat index', 'effect ds-bot: external room participants', 'effect ds-bot: group chat section', diff --git a/core/test/meetings.test.js b/core/test/meetings.test.js new file mode 100644 index 000000000..bb1cb4dd5 --- /dev/null +++ b/core/test/meetings.test.js @@ -0,0 +1,225 @@ +// #1731 (Stage 4.1): a meeting is a record with a lifecycle, and a `decided` meeting must pass a +// governance check written as code before it writes one archived decision and produces tasks. +// These tests hold the refusals — a missing position, a missing owner, a dissent without its +// owner, a second open meeting on one question — and the end-to-end path from question to +// decision file to linked tasks. A `blocked` meeting writes no file and no task. +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +const team = () => ({ + version: 2, + revision: 1, + mainBotIds: ['a'], + members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' } }, + bots: { + a: { id: 'a', name: 'Chief', role: 'Chief', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' }, + d1: { id: 'd1', name: 'Dev', role: 'Dev', sessionId: 'd1', createdAt: 2, ownerId: 'owner-1', createdBy: 'a' }, + r1: { id: 'r1', name: 'Reviewer', role: 'Reviewer', sessionId: 'r1', createdAt: 3, ownerId: 'owner-1', createdBy: 'a' }, + pa: { id: 'pa', name: 'Project Assistant (X)', role: 'Project Assistant', sessionId: 'pa', createdAt: 4, ownerId: 'owner-1', createdBy: 'a' }, + }, + rooms: {}, grants: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, +}) + +async function boot() { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-meetings-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(team())) + const ctx = fakeContext() + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async id => ({ agent: { session: { id }, id, status: 'idle', cancel() {} } }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const rt = createRuntime(ctx, { home }) + await rt.load() + const dir = mkdtempSync(join(tmpdir(), 'ds-bot-meetings-work-')) + const exec = (id = 'a') => ({ agent: { id, session: { header: { cwd: dir } } } }) + const meeting = (args, id = 'a') => ctx.registeredTools.get('meeting').execute(args, exec(id)) + const open = async (question, participants = ['Chief', 'Dev', 'Reviewer'], extra = {}) => { + const text = await meeting({ action: 'open', question, participants, ...extra }) + return text.split(' ')[0] + } + const stop = async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) + } + return { ctx, rt, dir, exec, meeting, open, stop } +} + +const positionAll = async (meeting, id) => { + for (const name of ['Chief', 'Dev', 'Reviewer']) await meeting({ action: 'position', meeting: id, as: name, stance: 'agree', text: `${name} agrees` }) +} + +test('#1731: decided refuses without a position from every invited participant', async (t) => { + const { meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Which storage shape should Stage 4 use?') + await meeting({ action: 'position', meeting: id, as: 'Chief', stance: 'agree', text: 'the existing store' }) + await meeting({ action: 'position', meeting: id, as: 'Dev', stance: 'agree', text: 'the existing store' }) + await assert.rejects( + meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Use the existing store.', evidence: [{ pr: 1731 }] }), + /no position recorded for: Reviewer/, + ) +}) + +test('#1731: decided refuses without a named owner', async (t) => { + const { meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Which storage shape should Stage 4 use?') + await positionAll(meeting, id) + await assert.rejects( + meeting({ action: 'decide', meeting: id, decision: 'Use the existing store.', evidence: [{ pr: 1731 }] }), + /names no owner/, + ) +}) + +test('#1731: a dissent with no owner is refused', async (t) => { + const { meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Which storage shape should Stage 4 use?') + await positionAll(meeting, id) + await assert.rejects( + meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Use the existing store.', dissent: { text: 'a new store is cleaner' }, evidence: [{ pr: 1731 }] }), + /dissent is written down without its owner/, + ) +}) + +test('#1731: blocked writes no decision file and produces no task', async (t) => { + const { ctx, rt, dir, meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Which storage shape should Stage 4 use?') + await meeting({ action: 'block', meeting: id, blocking: 'Nobody can name the store owner.' }) + assert.equal(rt.state.meetings[id].status, 'blocked') + assert.equal(existsSync(join(dir, 'decisions')), false, 'a blocked meeting archives nothing') + assert.equal(Object.keys(rt.state.tasks ?? {}).length, 0, 'a blocked meeting produces no task') + assert.equal(ctx.registeredTools.get('meeting') !== undefined, true) +}) + +test('#1731: a second open meeting on the same question is refused', async (t) => { + const { open, stop } = await boot() + t.after(stop) + await open('Which storage shape should Stage 4 use?') + await assert.rejects(open(' which STORAGE shape should stage 4 use? '), /already open: meeting-/) +}) + +test('#1731: a decision file carries participants, owner and evidence shas, and linked tasks carry the file', async (t) => { + const { rt, dir, meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Which storage shape should Stage 4 use?', ['Chief', 'Dev', 'Reviewer'], { issue: 1731 }) + await meeting({ action: 'position', meeting: id, as: 'Chief', stance: 'agree', text: 'the existing store' }) + await meeting({ action: 'position', meeting: id, as: 'Dev', stance: 'agree', text: 'the existing store, one collection' }) + await meeting({ action: 'position', meeting: id, as: 'Reviewer', stance: 'dissent', text: 'a separate meetings file is easier to audit' }) + const sha = 'a'.repeat(40) + await meeting({ + action: 'decide', meeting: id, owner: 'Chief', class: 'major', + decision: 'Meetings live in the existing store; decisions are one markdown file each.', + evidence: [{ sha }, { pr: 1731 }], + tasks: [{ assignee: 'Dev', title: 'Write the meetings tests' }], + }) + const record = rt.state.meetings[id] + assert.equal(record.status, 'decided') + assert.match(record.decisionFile, /^decisions\/\d{4}-\d{2}-\d{2}-which-storage-shape.*\.md$/) + const body = readFileSync(join(dir, record.decisionFile), 'utf8') + for (const name of ['Chief', 'Dev', 'Reviewer']) assert.ok(body.includes(`**${name}**`), `${name} is in the record`) + assert.ok(body.includes(sha), 'the evidence sha is in the record') + assert.ok(body.includes('a separate meetings file is easier to audit — **Reviewer**'), 'dissent carries its owner') + assert.match(body, /Written-by: Chief/) + assert.equal(record.decisionSha256.length, 64) + const tasks = Object.values(rt.state.tasks) + assert.equal(tasks.length, 1) + assert.equal(tasks[0].taskClass, 'work') + assert.equal(tasks[0].evidence[0].check, record.decisionFile) +}) + +test('#1731: a Project Assistant takes a position but does not close the meeting', async (t) => { + const { meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Which storage shape should Stage 4 use?', ['Chief', 'Project Assistant (X)']) + await meeting({ action: 'position', meeting: id, as: 'Chief', stance: 'agree', text: 'the existing store' }) + await meeting({ action: 'position', meeting: id, as: 'Project Assistant (X)', stance: 'agree', text: 'the existing store' }, 'pa') + await assert.rejects( + meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Use the existing store.', evidence: [{ pr: 1731 }] }, 'pa'), + /does not close the meeting/, + ) +}) + +test('#1731: question → positions → decided → one decision file → linked task, end to end', async (t) => { + const { rt, dir, meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Should Stage 4.1 record meetings in the existing store?', ['Chief', 'Dev'], { issue: 1731, title: 'Stage 4.1 meeting storage' }) + assert.equal(rt.state.meetings[id].status, 'open') + await meeting({ action: 'position', meeting: id, as: 'Chief', stance: 'agree', text: 'yes' }) + assert.equal(rt.state.meetings[id].status, 'deliberating') + await meeting({ action: 'position', meeting: id, as: 'Dev', stance: 'agree', text: 'yes, no second store' }) + await meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Record meetings in the existing store and archive one decision file.', evidence: [{ pr: 1731 }], tasks: [{ botId: 'd1', title: 'Ship the record' }] }) + assert.equal(rt.state.meetings[id].status, 'decided') + assert.deepEqual(readdirSync(join(dir, 'decisions')), [rt.state.meetings[id].decisionFile.replace('decisions/', '')]) + assert.equal(Object.values(rt.state.tasks).length, 1) + assert.equal(Object.values(rt.state.tasks)[0].evidence[0].check, rt.state.meetings[id].decisionFile) +}) + +test('#1731: a refused produced task archives nothing, leaves the meeting open, and a retry succeeds', async (t) => { + const { rt, dir, meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Which storage shape should Stage 4 use?') + await positionAll(meeting, id) + await assert.rejects( + meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Use the existing store.', evidence: [{ pr: 1731 }], tasks: [{ botId: 'ghost', title: 'x' }] }), + /no Bot named ghost/, + ) + assert.deepEqual(existsSync(join(dir, 'decisions')) ? readdirSync(join(dir, 'decisions')) : [], [], 'a refused task archives no decision file') + assert.equal(rt.state.meetings[id].status, 'deliberating', 'the meeting did not close') + await meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Use the existing store.', evidence: [{ pr: 1731 }], tasks: [{ botId: 'd1', title: 'Ship the record' }] }) + assert.equal(rt.state.meetings[id].status, 'decided', 'the valid retry closes the meeting') + assert.equal(readdirSync(join(dir, 'decisions')).length, 1, 'exactly one decision file appears') +}) + +test('#1731: a refused decision writes no decision text onto the stored meeting', async (t) => { + const { rt, meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Which storage shape should Stage 4 use?') + await meeting({ action: 'position', meeting: id, as: 'Chief', stance: 'agree', text: 'the existing store' }) + await assert.rejects( + meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'A decision nobody was present for.', evidence: [{ pr: 1731 }] }), + /no position recorded for: Dev, Reviewer/, + ) + assert.equal(rt.state.meetings[id].decision, undefined, 'the refused decision text is not stored') + assert.equal(rt.state.meetings[id].owner, undefined, 'the refused owner is not stored') + assert.equal(rt.state.meetings[id].evidence.length, 0, 'the refused evidence is not stored') + await positionAll(meeting, id) + await meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Use the existing store.', evidence: [{ pr: 1731 }] }) + assert.equal(rt.state.meetings[id].status, 'decided') +}) + +test('#1731: a participant recorded silent lets the meeting close', async (t) => { + const { rt, dir, meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Which storage shape should Stage 4 use?', ['Chief', 'Dev']) + await meeting({ action: 'position', meeting: id, as: 'Chief', stance: 'agree', text: 'the existing store' }) + await meeting({ action: 'position', meeting: id, as: 'Dev', silent: true }) + await meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Use the existing store.', evidence: [{ pr: 1731 }] }) + assert.equal(rt.state.meetings[id].status, 'decided') + const body = readFileSync(join(dir, rt.state.meetings[id].decisionFile), 'utf8') + assert.match(body, /\*\*Dev\*\* — invited; recorded no position \(silent\)/) +}) + +test('#1731: two Chinese questions decided on one day keep separate decision files', async (t) => { + const { rt, dir, meeting, open, stop } = await boot() + t.after(stop) + const first = await open('会议记录应该放在现有 store 里吗?', ['Chief']) + await meeting({ action: 'position', meeting: first, as: 'Chief', stance: 'agree', text: '是' }) + await meeting({ action: 'decide', meeting: first, owner: 'Chief', decision: '记录放现有 store。', evidence: [{ pr: 1731 }] }) + const second = await open('会议记录应该单独建一个库吗?', ['Chief']) + await meeting({ action: 'position', meeting: second, as: 'Chief', stance: 'abstain', text: '否' }) + await meeting({ action: 'decide', meeting: second, owner: 'Chief', decision: '不单独建库。', evidence: [{ pr: 1731 }] }) + const files = [rt.state.meetings[first].decisionFile, rt.state.meetings[second].decisionFile] + assert.notEqual(files[0], files[1]) + assert.deepEqual(readdirSync(join(dir, 'decisions')).sort(), files.map(f => f.replace('decisions/', '')).sort()) +}) diff --git a/core/test/run-bot-tools.test.js b/core/test/run-bot-tools.test.js index ae7ce56e9..9c1d8a238 100644 --- a/core/test/run-bot-tools.test.js +++ b/core/test/run-bot-tools.test.js @@ -111,8 +111,8 @@ test('the constant refuses every name at the guard and the approval seam, with n const { fb, home, callAsDsh, ask, refusalOf, received, saved, stop } = await start() t.after(stop) const id = await runBot(fb, []) - assert.equal(EPHEMERAL_DENIED_TOOLS.length, 31) - assert.equal(new Set(EPHEMERAL_DENIED_TOOLS).size, 31, 'no duplicate name') + assert.equal(EPHEMERAL_DENIED_TOOLS.length, 32) + assert.equal(new Set(EPHEMERAL_DENIED_TOOLS).size, 32, 'no duplicate name') assert.equal(new Set(EPHEMERAL_ALLOWED_TOOLS).size, EPHEMERAL_ALLOWED_TOOLS.length) assert.deepEqual(EPHEMERAL_DENIED_TOOLS.filter(name => ALLOWED.has(name)), [], 'a name is in one list only') diff --git a/docs/governance.md b/docs/governance.md index ed9896400..1387644f7 100644 --- a/docs/governance.md +++ b/docs/governance.md @@ -115,8 +115,8 @@ Stated plainly so nobody plans against a mechanism that does not exist: - The Owner and the human **Maintainer** seats exist (see [PROJECT.md](../PROJECT.md)). There is no Maintainer console, no Member role and no Project Assistant; the multi-human authority contract in [subsystems/team.md](subsystems/team.md) is **planned, not shipped**. -- Classification is currently written in issue and pull request comments. There is no engine that - classifies, convenes or records automatically. +- Classification is still written in issue and pull request comments; there is no engine that + classifies. Meetings are convened and recorded through the host `meeting` tool (§8, Stage 4.1). - **One reclassification case is recorded**, in [decisions/2026-10-29-stage-1-dogfood.md](../decisions/2026-10-29-stage-1-dogfood.md): Triage classified [#1550](https://github.com/ThinkFlowLab/FlyBot/issues/1550) `needs-human` on @@ -126,3 +126,16 @@ Stated plainly so nobody plans against a mechanism that does not exist: - Every Bot writes as one shared `Birfy` identity, so a `Written-by:` line is a claim that the gates check for presence and name. Per-workstation signatures were declined by the owner on 2026-10-25; the residual risk is stated in [AGENTS.md](../AGENTS.md), not solved here. + +## 8. Meetings (Stage 4.1) + +A meeting is how an unresolved architecture conflict gets a record before it gets code. It runs +`open → deliberating → decided | blocked` and is refused a second open meeting on the same question. +A `decided` meeting passes the governance check in `core/src/host/meetings.js` as code — every invited +participant recorded a position (or is recorded as not answering), the decision names its owner, and +a dissent that still stands carries its own owner — and only then writes one +`decisions/-.md` in the shape [`decisions/README.md`](../decisions/README.md) requires and +produces follow-up tasks carrying that file as evidence. `blocked` states the blocking question in one +sentence and writes nothing. A Project Assistant may take a position but never closes the meeting or +writes the decision; the Chief or the owner does. The meeting record itself stays in the existing +store, and the file it archives is the durable part. diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 268f530ac..b26c70d3e 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -596,3 +596,35 @@ Stage 3.2 of the task pool. One queue per role, one worker per claim, one owner **Audit projection (#1730).** `rt.taskAudit(id)` renders one text block from the typed fields already on the card — `handoff` from→to/reason/at, each `evidence` entry (sha, pr, check, at), the `reworkOf` chain oldest→newest, and the current lease holder with its `takenAt`→`expiresAt` window. It is a read: nothing is written, minted or cached, so the same rows render the same text twice. A record stored before the slice carries none of the fields and answers the empty string, not a throw. The Project board puts the same text on its row as `audit` (only when non-empty) and the client shows it inside the existing card detail — no new endpoint and no new page. Tests: `core/test/task-audit.test.js`. - Tests: `core/test/task-queue-lease.test.js` — the cap refusal names role and cap, a re-offer keeps its id, two concurrent claims share no worker state, two expired reads requeue once and record the holder, a live lease refuses a claim naming the holder, a restart with a live lease changes nothing, and the counters read from the rows. + +### Meetings: a decision with a lifecycle (#1731) + +Stage 4.1 gives an unresolved conflict a record instead of a chat. `core/src/host/meetings.js` +keeps meetings in the existing store (`state.meetings`) and registers one `meeting` tool; there is +no second store, no UI-only path and no cron. + +- **Lifecycle.** `open → deliberating → decided | blocked`, bound to one question and, when there is + one, the issue and Project that triggered it. Recording the first position moves `open` to + `deliberating`. At most one meeting per question may be open or deliberating: a second one is + refused `duplicate`, comparing the question with casing and whitespace flattened. +- **Positions.** An invited participant records `agree`, `dissent` or `abstain` with a line of text. + A name that was not invited is refused `not-invited`; a dissent with text is also kept as the + meeting's standing dissent, owned by the participant who recorded it. A participant who never + answers and is not recorded `silent` keeps the meeting from closing. +- **The governance check is code.** `governanceRefusal(meeting)` is the one refusal `decide` runs + before it writes anything: every invited participant has a position or is recorded silent, the + decision names an `owner`, a dissent carries its own `owner`, and the decision states what was + decided. It answers the reason, or `undefined` when the meeting may close. `blocked` needs one + one-sentence blocking question, writes no decision file and produces no task. +- **Archived decision.** `decided` writes exactly one `decisions/-.md` (Stage 1 + shape: Date, Decision, Class, Evidence, Participants, Dissent, Status, Links, `Written-by:`), in + the Session's working folder, and refuses `exists` rather than rewriting an earlier conclusion. + The meeting keeps the file path and its sha256; every evidence `sha`/`pr`/`check` and every + participant name is in the file's text. +- **Linked tasks.** Follow-up cards go through `rt.taskEndpoint('task-create', …)` — the one task + write path — as `taskClass: 'work'`, each carrying the decision file path in its `evidence`. A + meeting never keeps tasks of its own. +- **Who may close.** A Project Assistant (`role: 'Project Assistant'`) may be invited and record a + position under its own name; it may not write the decision file or close the meeting, which is + refused `forbidden`. +- Tests: `core/test/meetings.test.js`. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 57f6756d0..b13b7c1f9 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -9232,12 +9232,13 @@ diff --git a/test/install.test.js b/test/install.test.js 'tool list_models', 'tool create_group', 'tool update_group', -@@ -60,11 +60,17 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { +@@ -60,11 +60,18 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { 'tool delete_capability', 'tool attach_capability', 'tool detach_capability', + 'tool track_tasks', + 'tool project_state', ++ 'tool meeting', 'effect ds-bot: chat index', + 'effect ds-bot: external room participants', + 'effect ds-bot: group chat section', diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 4729a1711..262461438 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -23,12 +23,13 @@ diff --git a/index.js b/index.js import * as delivery from './src/host/delivery.js' import * as roster from './src/host/roster.js' import * as folders from './src/host/folders.js' -@@ -14,6 +14,8 @@ import * as agents from './src/host/agents.js' +@@ -14,6 +14,9 @@ import * as agents from './src/host/agents.js' import * as prompt from './src/host/prompt.js' import * as timeContext from './src/host/time-context.js' import * as tools from './src/host/tools.js' +import * as decisions from './src/host/decisions.js' +import * as projectState from './src/host/project-state.js' ++import * as meetings from './src/host/meetings.js' import * as chatIndex from './src/host/chat-index.js' import * as groups from './src/host/groups.js' import * as approvals from './src/host/approvals.js' @@ -55,7 +56,7 @@ diff --git a/index.js b/index.js // install. Listeners run and tools are listed in registration order, so this order holds: // usage meters model requests after every listener that rewrites them. -const MODULES = [diagnostics, store, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, channel] -+const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, channel] ++const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, meetings, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, channel] export function apply(ctx, config = {}) { - createRuntime(ctx, config) @@ -93,7 +94,7 @@ diff --git a/src/host/constants.js b/src/host/constants.js - 'list_bots', 'message_bot', 'stop_bot', 'create_bot', 'update_bot', 'list_models', - 'create_group', 'update_group', 'delete_group', 'post_to_group', 'ask_user', + 'list_bots', 'message_bot', 'stop_bot', 'create_bot', 'update_bot', 'archive_bot', 'list_models', -+ 'bot_schedules', 'project_state', 'project_digest', ++ 'bot_schedules', 'project_state', 'project_digest', 'meeting', + 'create_group', 'update_group', 'delete_group', 'post_to_group', 'ask_user', 'track_tasks', 'request_secret', 'list_secrets', 'group_relay', 'read_own_chat', 'read_group_chat', 'remember', 'forget', 'recall', 'read_browser', @@ -1349,7 +1350,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js + 'send_message', 'schedule_create', 'schedule_update', 'schedule_delete', + 'message_friend_bot', 'get_friend_file', 'list_friend_bots', + 'define_workflow', 'run_workflow', -+ 'read_browser', 'recall', 'create_child_bot', 'project_digest', ++ 'read_browser', 'recall', 'create_child_bot', 'project_digest', 'meeting', +]) +// The team-facing names a run Bot keeps: read-only, and each reads only what the run Bot itself +// may see. `remember`/`forget` are the only writes, and `writeDenied` keeps them inside the run diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 9b6398fb2..a13eb1f0c 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -904,7 +904,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js + 'read_group_chat', 'remember', 'forget', 'recall', 'read_browser', + 'list_capabilities', 'save_capability', 'delete_capability', + 'attach_capability', 'detach_capability', -+ 'track_links', 'project_state', 'project_digest', ++ 'track_links', 'project_state', 'project_digest', 'meeting', +]) +const isSilentRegistration = name => typeof name === 'string' && SILENT_REGISTRATION_TOOLS.has(name) +const isHiddenNotice = block => (block?.type === 'tool-removal' && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName))) diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 3eaf7c009..68343aacb 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -5514,7 +5514,7 @@ diff --git a/client.js b/client.js actions.noteTurnTime(sessionId, turn, "first", start); }, [sessionId, turn, start]); const visit = useUi((state) => state.visits?.[sessionId]); -@@ -4199,11 +6716,66 @@ +@@ -4199,11 +6716,67 @@ const own = state[sessionId]?.[turn]; return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; }); @@ -5557,7 +5557,8 @@ diff --git a/client.js b/client.js + "detach_capability", + "track_links", + "project_state", -+ "project_digest" ++ "project_digest", ++ "meeting" +]); +var isSilentRegistration = (name) => typeof name === "string" && SILENT_REGISTRATION_TOOLS.has(name); +var isHiddenNotice = (block) => block?.type === "tool-removal" && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName)) || block?.type === "tool-addition" && (isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName)); diff --git a/patches/ds-bot/files/src/host/meetings.js b/patches/ds-bot/files/src/host/meetings.js new file mode 100644 index 000000000..58dfaf106 --- /dev/null +++ b/patches/ds-bot/files/src/host/meetings.js @@ -0,0 +1,410 @@ +// Meetings (#1731, Stage 4.1): an unresolved architecture conflict needs a record, not a chat. +// A meeting is a record with a lifecycle — `open → deliberating → decided | blocked` — bound to +// one question (and, when it has one, the issue that triggered it), carrying every invited +// participant's position. A `decided` meeting passes a governance check written as code here and +// writes one archived decision in the Stage 1 shape (`decisions/-.md`); a `blocked` +// meeting writes no file and produces no task. Decisions stay on the existing store +// (`state.meetings`), so there is no second store. +import { createHash, randomBytes } from 'node:crypto' +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { defineTool } from './tool-schema.js' +import { output } from './tools.js' +import { workDirFor } from './project-state.js' +import { ASSISTANT_ROLE } from './project-assistant.js' + +// The Project Assistant takes a position like anyone else, but it does not write the decision +// file and does not close the meeting (the Chief or the owner does). +const CLASSES = ['ordinary', 'major', 'severe'] +const STANCES = ['agree', 'dissent', 'abstain'] +const QUESTION_MAX = 600 +const TEXT_MAX = 600 +const NAME_MAX = 80 +const PARTICIPANTS_MAX = 32 +const OPEN_STATES = new Set(['open', 'deliberating']) + +const oneLine = (value, max) => String(value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max) +// A slug keeps letters and digits of every script, so two different Chinese questions do not both +// fall back to `meeting` and collide on one day's file. +const slugOf = value => oneLine(value, 80).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '').slice(0, 80) +const dateOf = now => new Date(now).toISOString().slice(0, 10) + +const refuse = (code, message) => Object.assign(new Error(`${code}: ${message}`), { code: `meeting/${code}` }) + +// Two meetings on one question are one too many. Compared on the question with casing and +// whitespace flattened, so "Is X good?" and "is x good?" are the same question. +const keyOf = question => oneLine(question, QUESTION_MAX).toLowerCase() + +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 = [] + 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) + } + return names.map(name => ({ name, position: undefined, silent: false })) +} + +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') + const options = value.map(item => oneLine(item, TEXT_MAX)).filter(Boolean) + if (options.length > 12) throw refuse('invalid', 'a meeting carries at most 12 options') + return options +} + +// The governance check, as code (#1731 §2). It answers why the meeting may not close `decided`, +// 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. +export function governanceRefusal(meeting) { + if (!OPEN_STATES.has(meeting.status)) return `the meeting is ${meeting.status}, not open or deliberating` + 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' + if (meeting.dissent !== undefined && oneLine(meeting.dissent.owner, NAME_MAX) === '') { + return 'the dissent is written down without its owner' + } + if (oneLine(meeting.decision, TEXT_MAX) === '') return 'the decision states nothing' + return undefined +} + +const decisionBody = (meeting, file, at) => { + const lines = [ + `# ${oneLine(meeting.title ?? meeting.question, 200)}`, + '', + '## Date', + '', + `${dateOf(at)} (UTC).`, + '', + '## Decision', + '', + oneLine(meeting.decision, TEXT_MAX), + '', + '## Class', + '', + `${meeting.class} — recorded by ${oneLine(meeting.owner, NAME_MAX)} through meeting \`${meeting.id}\`.`, + '', + '## Evidence', + '', + ] + for (const entry of meeting.evidence ?? []) { + const parts = [] + if (entry.sha) parts.push(`sha:${entry.sha}`) + if (entry.pr !== undefined) parts.push(`pr:#${entry.pr}`) + if (entry.check) parts.push(`check:${entry.check}`) + if (parts.length > 0) lines.push(`- ${parts.join(' ')}`) + } + lines.push(`- Meeting \`${meeting.id}\` on: ${oneLine(meeting.question, QUESTION_MAX)}`, '') + if ((meeting.options ?? []).length > 0) { + lines.push('## Options', '') + for (const option of meeting.options) lines.push(`- ${option}`) + lines.push('') + } + lines.push('## Participants', '') + for (const participant of meeting.participants) { + const position = participant.position + const said = position === undefined + ? 'invited; recorded no position (silent)' + : `${position.stance}${position.text ? ` — ${position.text}` : ''}` + lines.push(`- **${participant.name}** — ${said}`) + } + lines.push('', '## Dissent', '') + lines.push(meeting.dissent === undefined + ? 'none recorded' + : `${oneLine(meeting.dissent.text, TEXT_MAX)} — **${oneLine(meeting.dissent.owner, NAME_MAX)}**`) + lines.push('', '## Status', '', 'accepted', '') + lines.push('## Links', '', `- Decision file: ${file}`) + if (meeting.issue !== undefined) lines.push(`- Issue: #${meeting.issue}`) + if (meeting.project !== undefined) lines.push(`- Project: ${meeting.project}`) + if ((meeting.taskIds ?? []).length > 0) lines.push(`- Tasks: ${meeting.taskIds.join(', ')}`) + lines.push('', `Written-by: ${oneLine(meeting.closedBy ?? meeting.owner, NAME_MAX)}`, '') + return lines.join('\n') +} + +export function install(rt) { + rt.state.meetings ??= {} + const store = () => { + rt.state.meetings ??= {} + return rt.state.meetings + } + // The meeting this id names, or a refusal naming the id: a tool caller never gets `undefined`. + const find = id => { + const meeting = store()[String(id ?? '')] + if (meeting === undefined) throw refuse('not-found', `no meeting ${String(id ?? '')}`) + return meeting + } + const stamp = () => Date.now() + + const newMeeting = ({ question, participants, options, issue, project, title }, 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)) + if (same !== undefined) throw refuse('duplicate', `a meeting on that question is already ${same.status}: ${same.id}`) + const id = `meeting-${randomBytes(5).toString('hex')}` + const meeting = { + id, + status: 'open', + question: text, + ...(oneLine(title, 200) ? { title: oneLine(title, 200) } : {}), + slug: '', + participants: readParticipants(participants), + options: readOptions(options), + ...(issue === undefined || issue === null ? {} : { issue }), + ...(project === undefined || project === null ? {} : { project: oneLine(project, NAME_MAX) }), + evidence: [], + taskIds: [], + createdAt: now, + updatedAt: now, + } + meeting.slug = slugOf(meeting.question) || 'meeting' + store()[id] = meeting + return meeting + } + + const position = (meeting, { as, stance, text, silent }, 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) + if (participant === undefined) throw refuse('not-invited', `${name === '' ? 'the caller' : name} was not invited to this meeting`) + if (silent === true) { + // An invited participant who did not answer is recorded as silent, so `decided` can say so + // instead of waiting for a position that will not come. + participant.silent = true + if (meeting.status === 'open') meeting.status = 'deliberating' + meeting.updatedAt = now + return meeting + } + 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 } + if (chosen === 'dissent' && oneLine(text, TEXT_MAX) !== '') { + meeting.dissent = { text: oneLine(text, TEXT_MAX), owner: name } + } + if (meeting.status === 'open') meeting.status = 'deliberating' + meeting.updatedAt = now + return meeting + } + + const decide = async (meeting, args, { now, actor }) => { + if (!OPEN_STATES.has(meeting.status)) throw refuse('closed', `the meeting is ${meeting.status}`) + if (actor?.role === ASSISTANT_ROLE) throw refuse('forbidden', 'a Project Assistant takes a position; it does not close the meeting') + // The candidate is a copy: a refused decision leaves the stored meeting exactly as it was. + const candidate = { + ...meeting, + owner: oneLine(args.owner, NAME_MAX), + decision: oneLine(args.decision ?? args.text, TEXT_MAX), + class: String(args.class ?? 'ordinary'), + evidence: readEvidence(args.evidence), + } + if (!CLASSES.includes(candidate.class)) throw refuse('invalid', `class must be one of ${CLASSES.join(', ')}`) + if (args.dissent !== undefined) { + const text = oneLine(args.dissent?.text, TEXT_MAX) + const ownerName = oneLine(args.dissent?.owner, NAME_MAX) + if (text === '') throw refuse('invalid', 'a dissent needs the objection it records') + candidate.dissent = { text, owner: ownerName } + } + const reason = governanceRefusal(candidate) + if (reason !== undefined) throw refuse('governance', reason) + + const dir = args.dir ?? workDirFor(rt, args.exec) + if (typeof dir !== 'string' || dir === '') throw refuse('no-folder', 'the Session has no working folder to archive the decision in') + const file = decisionsPath(now, candidate.slug) + const target = join(dir, file) + await mkdir(join(dir, 'decisions'), { recursive: true }) + try { + await readFile(target) + throw refuse('exists', `the decision file already exists: ${file}`) + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } + + const closed = { + ...candidate, + status: 'decided', + decidedAt: now, + updatedAt: now, + closedBy: actor?.name ?? candidate.owner, + decisionFile: file, + } + const bytes = Buffer.from(decisionBody(closed, file, now), 'utf8') + // The file appears only after every produced task exists: a refused task deletes the temp + // file, so `decisions/` stays as it was and the meeting can be closed again. + const temp = `${target}.tmp-${randomBytes(4).toString('hex')}` + await writeFile(temp, bytes) + let taskIds + try { + taskIds = await produceTasks(closed, args.tasks, { now, actor }) + } catch (error) { + await rm(temp, { force: true }) + throw error + } + await rename(temp, target) + closed.decisionSha256 = createHash('sha256').update(bytes).digest('hex') + closed.taskIds = taskIds + store()[meeting.id] = closed + return closed + } + + // Follow-up work goes through the one task write path (`tasks.js`), each card carrying the + // decision file as its evidence pointer. A meeting does not keep tasks of its own. + const produceTasks = async (meeting, tasks, { now, actor }) => { + if (tasks === undefined || tasks === null) return [] + if (!Array.isArray(tasks)) throw refuse('invalid', 'tasks must be a list of { botId or assignee, title }') + const ids = [] + for (const item of tasks) { + const title = oneLine(item?.title, 300) + if (title === '') throw refuse('invalid', 'a produced task needs a title') + const wanted = oneLine(item?.botId ?? item?.assignee, NAME_MAX) + const bot = rt.state.bots?.[wanted] + const named = bot ?? Object.values(rt.state.bots ?? {}).find(candidate => candidate?.name === wanted) + if (named === undefined) throw refuse('no-bot', `no Bot named ${wanted === '' ? '(nobody)' : wanted} to own the produced task`) + const created = await rt.taskEndpoint('task-create', { + botId: named.id, + title, + taskClass: 'work', + ...(typeof item?.criteria === 'string' && item.criteria.trim() !== '' ? { criteria: item.criteria } : {}), + evidence: [{ check: meeting.decisionFile, at: now }], + }, actor) + ids.push(created.id) + } + return ids + } + + const block = (meeting, { blocking }, now) => { + if (!OPEN_STATES.has(meeting.status)) throw refuse('closed', `the meeting is ${meeting.status}`) + const question = oneLine(blocking, QUESTION_MAX) + if (question === '') throw refuse('invalid', 'a blocked meeting states the blocking question in one sentence') + meeting.status = 'blocked' + meeting.blockingQuestion = question + meeting.blockedAt = now + meeting.updatedAt = now + return meeting + } + + const summary = meeting => { + 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}` : ''}`] + if (meeting.dissent !== undefined) lines.push(`dissent: ${meeting.dissent.text} — ${meeting.dissent.owner}`) + if (meeting.blockingQuestion !== undefined) lines.push(`blocking: ${meeting.blockingQuestion}`) + 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}` : ''}`}`) + } + return lines.join('\n') + } + + rt.meetingEndpoint = async function meetingEndpoint(action, body = {}, actor) { + await rt.load() + const now = Number.isFinite(Number(body.now)) && body.now !== undefined ? Number(body.now) : stamp() + switch (action) { + case 'open': + return summary(newMeeting(body, now)) + case 'position': + return summary(position(find(body.meeting), { as: body.as ?? actor?.name, stance: body.stance, text: body.text, silent: body.silent }, now)) + case 'decide': + return summary(await decide(find(body.meeting), { ...body, exec: body.exec }, { now, actor })) + case 'block': + return summary(block(find(body.meeting), body, now)) + case 'read': + return summary(find(body.meeting)) + case 'list': + return Object.values(store()).map(summary).join('\n\n') || 'no meetings' + default: + throw refuse('invalid', `unknown meeting action ${String(action)}`) + } + } + + rt.ctx.tools.register(defineTool({ + name: 'meeting', + description: 'Open and run a meeting so an unresolved conflict has a record instead of a chat. A meeting moves open → deliberating → decided | blocked, bound to one question and the issue that triggered it. Record every invited participant\'s position, then close it: `decided` needs a position from every participant (or one recorded as silent), a named owner and a dissent with its own owner, writes one decisions/-.md and creates follow-up tasks; `blocked` states one blocking sentence and writes nothing. At most one open meeting per question.', + parameters: { + action: { type: 'string', required: true, enum: ['open', 'position', 'decide', 'block', 'read', 'list'], description: 'What to do with the meeting' }, + meeting: { type: 'string', description: 'The meeting id, from open' }, + question: { type: 'string', description: 'The question the meeting answers (open)' }, + 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)' }, + 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' }, + 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)' }, + 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' }, + dissent: { + type: 'object', + description: 'The dissent that still stands, with its owner (decide). Omit when none stands.', + properties: { + text: { type: 'string' }, + owner: { type: 'string' }, + }, + additionalProperties: false, + }, + evidence: { + type: 'array', + description: 'What the decision rests on: shas, pull requests and facts (decide)', + items: { + type: 'object', + additionalProperties: false, + properties: { + sha: { type: 'string' }, + pr: { type: 'integer' }, + check: { type: 'string' }, + }, + }, + }, + tasks: { + type: 'array', + description: 'Follow-up tasks a decided meeting produces, each created with the decision file as evidence', + items: { + type: 'object', + additionalProperties: false, + properties: { + botId: { type: 'string' }, + assignee: { type: 'string' }, + title: { type: 'string' }, + criteria: { type: 'string' }, + }, + }, + }, + blocking: { type: 'string', description: 'The blocking question in one sentence (block)' }, + dir: { type: 'string', description: 'Optional folder to archive the decision in; defaults to the Session working folder' }, + }, + output, + async execute(args, exec) { + await rt.load() + const self = rt.selfOf?.(exec?.agent?.id) ?? exec?.agent?.id + const bot = rt.botOf?.(self) + const actor = { name: bot?.name ?? self, role: bot?.role } + const action = String(args?.action ?? '') + return rt.meetingEndpoint(action, { ...args, exec }, actor) + }, + })) +} + +function readEvidence(value) { + if (value === undefined || value === null) return [] + if (!Array.isArray(value)) throw refuse('invalid', 'evidence must be a list of { sha, pr, check }') + if (value.length > 8) throw refuse('invalid', 'evidence carries at most 8 entries') + return value.map((entry) => { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) throw refuse('invalid', 'each evidence entry is an object of { sha, pr, check }') + const sha = oneLine(entry.sha, 64) + const check = oneLine(entry.check, 200) + let pr + if (entry.pr !== undefined && entry.pr !== null && entry.pr !== '') { + pr = typeof entry.pr === 'number' ? entry.pr : Number(entry.pr) + if (!Number.isInteger(pr) || pr < 1) throw refuse('invalid', 'evidence.pr must be a pull request number') + } + if (sha === '' && pr === undefined && check === '') throw refuse('invalid', 'an evidence entry needs a sha, a pr or a check') + return { ...(sha ? { sha } : {}), ...(pr === undefined ? {} : { pr }), ...(check ? { check } : {}) } + }) +} diff --git a/patches/ds-bot/files/test/meetings.test.js b/patches/ds-bot/files/test/meetings.test.js new file mode 100644 index 000000000..bb1cb4dd5 --- /dev/null +++ b/patches/ds-bot/files/test/meetings.test.js @@ -0,0 +1,225 @@ +// #1731 (Stage 4.1): a meeting is a record with a lifecycle, and a `decided` meeting must pass a +// governance check written as code before it writes one archived decision and produces tasks. +// These tests hold the refusals — a missing position, a missing owner, a dissent without its +// owner, a second open meeting on one question — and the end-to-end path from question to +// decision file to linked tasks. A `blocked` meeting writes no file and no task. +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +const team = () => ({ + version: 2, + revision: 1, + mainBotIds: ['a'], + members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' } }, + bots: { + a: { id: 'a', name: 'Chief', role: 'Chief', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' }, + d1: { id: 'd1', name: 'Dev', role: 'Dev', sessionId: 'd1', createdAt: 2, ownerId: 'owner-1', createdBy: 'a' }, + r1: { id: 'r1', name: 'Reviewer', role: 'Reviewer', sessionId: 'r1', createdAt: 3, ownerId: 'owner-1', createdBy: 'a' }, + pa: { id: 'pa', name: 'Project Assistant (X)', role: 'Project Assistant', sessionId: 'pa', createdAt: 4, ownerId: 'owner-1', createdBy: 'a' }, + }, + rooms: {}, grants: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, +}) + +async function boot() { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-meetings-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(team())) + const ctx = fakeContext() + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async id => ({ agent: { session: { id }, id, status: 'idle', cancel() {} } }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const rt = createRuntime(ctx, { home }) + await rt.load() + const dir = mkdtempSync(join(tmpdir(), 'ds-bot-meetings-work-')) + const exec = (id = 'a') => ({ agent: { id, session: { header: { cwd: dir } } } }) + const meeting = (args, id = 'a') => ctx.registeredTools.get('meeting').execute(args, exec(id)) + const open = async (question, participants = ['Chief', 'Dev', 'Reviewer'], extra = {}) => { + const text = await meeting({ action: 'open', question, participants, ...extra }) + return text.split(' ')[0] + } + const stop = async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) + } + return { ctx, rt, dir, exec, meeting, open, stop } +} + +const positionAll = async (meeting, id) => { + for (const name of ['Chief', 'Dev', 'Reviewer']) await meeting({ action: 'position', meeting: id, as: name, stance: 'agree', text: `${name} agrees` }) +} + +test('#1731: decided refuses without a position from every invited participant', async (t) => { + const { meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Which storage shape should Stage 4 use?') + await meeting({ action: 'position', meeting: id, as: 'Chief', stance: 'agree', text: 'the existing store' }) + await meeting({ action: 'position', meeting: id, as: 'Dev', stance: 'agree', text: 'the existing store' }) + await assert.rejects( + meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Use the existing store.', evidence: [{ pr: 1731 }] }), + /no position recorded for: Reviewer/, + ) +}) + +test('#1731: decided refuses without a named owner', async (t) => { + const { meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Which storage shape should Stage 4 use?') + await positionAll(meeting, id) + await assert.rejects( + meeting({ action: 'decide', meeting: id, decision: 'Use the existing store.', evidence: [{ pr: 1731 }] }), + /names no owner/, + ) +}) + +test('#1731: a dissent with no owner is refused', async (t) => { + const { meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Which storage shape should Stage 4 use?') + await positionAll(meeting, id) + await assert.rejects( + meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Use the existing store.', dissent: { text: 'a new store is cleaner' }, evidence: [{ pr: 1731 }] }), + /dissent is written down without its owner/, + ) +}) + +test('#1731: blocked writes no decision file and produces no task', async (t) => { + const { ctx, rt, dir, meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Which storage shape should Stage 4 use?') + await meeting({ action: 'block', meeting: id, blocking: 'Nobody can name the store owner.' }) + assert.equal(rt.state.meetings[id].status, 'blocked') + assert.equal(existsSync(join(dir, 'decisions')), false, 'a blocked meeting archives nothing') + assert.equal(Object.keys(rt.state.tasks ?? {}).length, 0, 'a blocked meeting produces no task') + assert.equal(ctx.registeredTools.get('meeting') !== undefined, true) +}) + +test('#1731: a second open meeting on the same question is refused', async (t) => { + const { open, stop } = await boot() + t.after(stop) + await open('Which storage shape should Stage 4 use?') + await assert.rejects(open(' which STORAGE shape should stage 4 use? '), /already open: meeting-/) +}) + +test('#1731: a decision file carries participants, owner and evidence shas, and linked tasks carry the file', async (t) => { + const { rt, dir, meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Which storage shape should Stage 4 use?', ['Chief', 'Dev', 'Reviewer'], { issue: 1731 }) + await meeting({ action: 'position', meeting: id, as: 'Chief', stance: 'agree', text: 'the existing store' }) + await meeting({ action: 'position', meeting: id, as: 'Dev', stance: 'agree', text: 'the existing store, one collection' }) + await meeting({ action: 'position', meeting: id, as: 'Reviewer', stance: 'dissent', text: 'a separate meetings file is easier to audit' }) + const sha = 'a'.repeat(40) + await meeting({ + action: 'decide', meeting: id, owner: 'Chief', class: 'major', + decision: 'Meetings live in the existing store; decisions are one markdown file each.', + evidence: [{ sha }, { pr: 1731 }], + tasks: [{ assignee: 'Dev', title: 'Write the meetings tests' }], + }) + const record = rt.state.meetings[id] + assert.equal(record.status, 'decided') + assert.match(record.decisionFile, /^decisions\/\d{4}-\d{2}-\d{2}-which-storage-shape.*\.md$/) + const body = readFileSync(join(dir, record.decisionFile), 'utf8') + for (const name of ['Chief', 'Dev', 'Reviewer']) assert.ok(body.includes(`**${name}**`), `${name} is in the record`) + assert.ok(body.includes(sha), 'the evidence sha is in the record') + assert.ok(body.includes('a separate meetings file is easier to audit — **Reviewer**'), 'dissent carries its owner') + assert.match(body, /Written-by: Chief/) + assert.equal(record.decisionSha256.length, 64) + const tasks = Object.values(rt.state.tasks) + assert.equal(tasks.length, 1) + assert.equal(tasks[0].taskClass, 'work') + assert.equal(tasks[0].evidence[0].check, record.decisionFile) +}) + +test('#1731: a Project Assistant takes a position but does not close the meeting', async (t) => { + const { meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Which storage shape should Stage 4 use?', ['Chief', 'Project Assistant (X)']) + await meeting({ action: 'position', meeting: id, as: 'Chief', stance: 'agree', text: 'the existing store' }) + await meeting({ action: 'position', meeting: id, as: 'Project Assistant (X)', stance: 'agree', text: 'the existing store' }, 'pa') + await assert.rejects( + meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Use the existing store.', evidence: [{ pr: 1731 }] }, 'pa'), + /does not close the meeting/, + ) +}) + +test('#1731: question → positions → decided → one decision file → linked task, end to end', async (t) => { + const { rt, dir, meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Should Stage 4.1 record meetings in the existing store?', ['Chief', 'Dev'], { issue: 1731, title: 'Stage 4.1 meeting storage' }) + assert.equal(rt.state.meetings[id].status, 'open') + await meeting({ action: 'position', meeting: id, as: 'Chief', stance: 'agree', text: 'yes' }) + assert.equal(rt.state.meetings[id].status, 'deliberating') + await meeting({ action: 'position', meeting: id, as: 'Dev', stance: 'agree', text: 'yes, no second store' }) + await meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Record meetings in the existing store and archive one decision file.', evidence: [{ pr: 1731 }], tasks: [{ botId: 'd1', title: 'Ship the record' }] }) + assert.equal(rt.state.meetings[id].status, 'decided') + assert.deepEqual(readdirSync(join(dir, 'decisions')), [rt.state.meetings[id].decisionFile.replace('decisions/', '')]) + assert.equal(Object.values(rt.state.tasks).length, 1) + assert.equal(Object.values(rt.state.tasks)[0].evidence[0].check, rt.state.meetings[id].decisionFile) +}) + +test('#1731: a refused produced task archives nothing, leaves the meeting open, and a retry succeeds', async (t) => { + const { rt, dir, meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Which storage shape should Stage 4 use?') + await positionAll(meeting, id) + await assert.rejects( + meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Use the existing store.', evidence: [{ pr: 1731 }], tasks: [{ botId: 'ghost', title: 'x' }] }), + /no Bot named ghost/, + ) + assert.deepEqual(existsSync(join(dir, 'decisions')) ? readdirSync(join(dir, 'decisions')) : [], [], 'a refused task archives no decision file') + assert.equal(rt.state.meetings[id].status, 'deliberating', 'the meeting did not close') + await meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Use the existing store.', evidence: [{ pr: 1731 }], tasks: [{ botId: 'd1', title: 'Ship the record' }] }) + assert.equal(rt.state.meetings[id].status, 'decided', 'the valid retry closes the meeting') + assert.equal(readdirSync(join(dir, 'decisions')).length, 1, 'exactly one decision file appears') +}) + +test('#1731: a refused decision writes no decision text onto the stored meeting', async (t) => { + const { rt, meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Which storage shape should Stage 4 use?') + await meeting({ action: 'position', meeting: id, as: 'Chief', stance: 'agree', text: 'the existing store' }) + await assert.rejects( + meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'A decision nobody was present for.', evidence: [{ pr: 1731 }] }), + /no position recorded for: Dev, Reviewer/, + ) + assert.equal(rt.state.meetings[id].decision, undefined, 'the refused decision text is not stored') + assert.equal(rt.state.meetings[id].owner, undefined, 'the refused owner is not stored') + assert.equal(rt.state.meetings[id].evidence.length, 0, 'the refused evidence is not stored') + await positionAll(meeting, id) + await meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Use the existing store.', evidence: [{ pr: 1731 }] }) + assert.equal(rt.state.meetings[id].status, 'decided') +}) + +test('#1731: a participant recorded silent lets the meeting close', async (t) => { + const { rt, dir, meeting, open, stop } = await boot() + t.after(stop) + const id = await open('Which storage shape should Stage 4 use?', ['Chief', 'Dev']) + await meeting({ action: 'position', meeting: id, as: 'Chief', stance: 'agree', text: 'the existing store' }) + await meeting({ action: 'position', meeting: id, as: 'Dev', silent: true }) + await meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Use the existing store.', evidence: [{ pr: 1731 }] }) + assert.equal(rt.state.meetings[id].status, 'decided') + const body = readFileSync(join(dir, rt.state.meetings[id].decisionFile), 'utf8') + assert.match(body, /\*\*Dev\*\* — invited; recorded no position \(silent\)/) +}) + +test('#1731: two Chinese questions decided on one day keep separate decision files', async (t) => { + const { rt, dir, meeting, open, stop } = await boot() + t.after(stop) + const first = await open('会议记录应该放在现有 store 里吗?', ['Chief']) + await meeting({ action: 'position', meeting: first, as: 'Chief', stance: 'agree', text: '是' }) + await meeting({ action: 'decide', meeting: first, owner: 'Chief', decision: '记录放现有 store。', evidence: [{ pr: 1731 }] }) + const second = await open('会议记录应该单独建一个库吗?', ['Chief']) + await meeting({ action: 'position', meeting: second, as: 'Chief', stance: 'abstain', text: '否' }) + await meeting({ action: 'decide', meeting: second, owner: 'Chief', decision: '不单独建库。', evidence: [{ pr: 1731 }] }) + const files = [rt.state.meetings[first].decisionFile, rt.state.meetings[second].decisionFile] + assert.notEqual(files[0], files[1]) + assert.deepEqual(readdirSync(join(dir, 'decisions')).sort(), files.map(f => f.replace('decisions/', '')).sort()) +}) diff --git a/patches/ds-bot/files/test/run-bot-tools.test.js b/patches/ds-bot/files/test/run-bot-tools.test.js index ae7ce56e9..9c1d8a238 100644 --- a/patches/ds-bot/files/test/run-bot-tools.test.js +++ b/patches/ds-bot/files/test/run-bot-tools.test.js @@ -111,8 +111,8 @@ test('the constant refuses every name at the guard and the approval seam, with n const { fb, home, callAsDsh, ask, refusalOf, received, saved, stop } = await start() t.after(stop) const id = await runBot(fb, []) - assert.equal(EPHEMERAL_DENIED_TOOLS.length, 31) - assert.equal(new Set(EPHEMERAL_DENIED_TOOLS).size, 31, 'no duplicate name') + assert.equal(EPHEMERAL_DENIED_TOOLS.length, 32) + assert.equal(new Set(EPHEMERAL_DENIED_TOOLS).size, 32, 'no duplicate name') assert.equal(new Set(EPHEMERAL_ALLOWED_TOOLS).size, EPHEMERAL_ALLOWED_TOOLS.length) assert.deepEqual(EPHEMERAL_DENIED_TOOLS.filter(name => ALLOWED.has(name)), [], 'a name is in one list only')