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/1859-project-chief.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Every Project now gets its own Chief, created with the Project: the record carries `chief: { botId, sessionId, createdAt }`, the Chief Bot carries the Project binding, and `project-delete` retires it with the Project. It speaks for that one Project only, never for the user's main Chief (#1859).
11 changes: 10 additions & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1430,6 +1430,8 @@ var ZH = {
"Bind": "绑定",
"Member unbound.": "已解除成员的绑定。",
"Project Assistant": "项目助手",
"Project Chief": "项目 Chief",
"Chief of this Project": "本项目的 Chief",
"Pause the daily digest": "暂停每日摘要",
"Assistant settings saved.": "助手设置已保存。",
"Add to the assistant memory": "添加到助手记忆",
Expand Down Expand Up @@ -20679,6 +20681,7 @@ function ProjectMembersPanel({ project, rows, onClose } = {}) {
const name = text(row?.assignee);
if (name !== "" && !boardMembers.includes(name)) boardMembers.push(name);
}
const chief = text(project?.chief?.botId);
return (0, import_react47.createElement)(
"div",
{ className: "bt-project-panel", "data-panel": "project-members" },
Expand All @@ -20692,14 +20695,20 @@ function ProjectMembersPanel({ project, rows, onClose } = {}) {
(0, import_react47.createElement)("span", null, `@${owner}`),
(0, import_react47.createElement)("span", { className: "bt-project-member-kind" }, t("Owner"))
)),
chief === "" ? null : (0, import_react47.createElement)(
"li",
{ key: `chief:${chief}`, className: "bt-project-member", "data-member-role": "chief", "data-member-id": chief },
(0, import_react47.createElement)("span", null, t("Project Chief")),
(0, import_react47.createElement)("span", { className: "bt-project-member-kind" }, t("Chief of this Project"))
),
boardMembers.map((name) => (0, import_react47.createElement)(
"li",
{ key: `board:${name}`, className: "bt-project-member", "data-member-role": "board", "data-member-id": name },
(0, import_react47.createElement)("span", null, `@${name}`),
(0, import_react47.createElement)("span", { className: "bt-project-member-kind" }, t("On the board"))
))
),
owners.length === 0 && boardMembers.length === 0 ? (0, import_react47.createElement)("div", { className: "bt-project-empty" }, t("No member is named yet.")) : null,
owners.length === 0 && boardMembers.length === 0 && chief === "" ? (0, import_react47.createElement)("div", { className: "bt-project-empty" }, t("No member is named yet.")) : null,
(0, import_react47.createElement)("p", { className: "bt-project-note" }, t("Member management is not part of this slice; owners and the members their cards name are listed here.")),
(0, import_react47.createElement)(
"div",
Expand Down
3 changes: 2 additions & 1 deletion core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import * as connectorAccess from './src/host/connector-access.js'
import * as capabilities from './src/host/capabilities.js'
import * as schedules from './src/host/schedules.js'
import * as projectAssistant from './src/host/project-assistant.js'
import * as projectChief from './src/host/project-chief.js'
import * as automationDefault from './src/host/automation-default.js'
import * as usage from './src/host/usage.js'
import * as update from './src/host/update.js'
Expand All @@ -59,7 +60,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, 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]
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, projectChief, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, channel]

export function apply(ctx, config = {}) {
const rt = createRuntime(ctx, config)
Expand Down
2 changes: 2 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -1383,6 +1383,8 @@ const ZH = {
'Bind': '绑定',
'Member unbound.': '已解除成员的绑定。',
'Project Assistant': '项目助手',
'Project Chief': '项目 Chief',
'Chief of this Project': '本项目的 Chief',
'Pause the daily digest': '暂停每日摘要',
'Assistant settings saved.': '助手设置已保存。',
'Add to the assistant memory': '添加到助手记忆',
Expand Down
8 changes: 7 additions & 1 deletion core/src/client/project-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -560,16 +560,22 @@ export function ProjectMembersPanel({ project, rows, onClose } = {}) {
const name = text(row?.assignee)
if (name !== '' && !boardMembers.includes(name)) boardMembers.push(name)
}
// The Project's own Chief (#1859), read from the record: created with the Project, never the
// user's main Chief acting part-time.
const chief = text(project?.chief?.botId)
return h('div', { className: 'bt-project-panel', 'data-panel': 'project-members' },
h('h3', null, t('Members')),
h('ul', { className: 'bt-project-members' },
owners.map(owner => h('li', { key: `owner:${owner}`, className: 'bt-project-member', 'data-member-role': 'owner', 'data-member-id': owner },
h('span', null, `@${owner}`),
h('span', { className: 'bt-project-member-kind' }, t('Owner')))),
chief === '' ? null : h('li', { key: `chief:${chief}`, className: 'bt-project-member', 'data-member-role': 'chief', 'data-member-id': chief },
h('span', null, t('Project Chief')),
h('span', { className: 'bt-project-member-kind' }, t('Chief of this Project'))),
boardMembers.map(name => h('li', { key: `board:${name}`, className: 'bt-project-member', 'data-member-role': 'board', 'data-member-id': name },
h('span', null, `@${name}`),
h('span', { className: 'bt-project-member-kind' }, t('On the board'))))),
owners.length === 0 && boardMembers.length === 0 ? h('div', { className: 'bt-project-empty' }, t('No member is named yet.')) : null,
owners.length === 0 && boardMembers.length === 0 && chief === '' ? h('div', { className: 'bt-project-empty' }, t('No member is named yet.')) : null,
h('p', { className: 'bt-project-note' }, t('Member management is not part of this slice; owners and the members their cards name are listed here.')),
h('div', { className: 'bt-project-row-actions' },
h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => onClose?.() }, t('Back to board'))))
Expand Down
82 changes: 82 additions & 0 deletions core/src/host/project-chief.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// The Project Chief (#1859): every Project gets its own Chief, created with the Project. It is not
// the user's main Chief acting part-time for a Project — the main Chief stays outside, and this one
// speaks for exactly one Project. The record carries `chief: { botId, sessionId, createdAt }`, the
// same place and shape the assistant uses, and `project-delete` retires it with the Project.
//
// Nothing here invents a schedule or a memory of its own: the Chief is one hidden Bot with a Session
// and instructions, bound to the Project through `bindProject`, so a host that has no Chief seam
// still creates the Project and records why (the store owns that fallback, not this module).
const CHIEF_ROLE = 'Project Chief'

export const chiefName = project => `${CHIEF_ROLE} (${project.name})`

// The one boundary that matters: the instructions name this Project and forbid answering for any
// other. Another Project's name is never quoted here, so two Chiefs cannot learn each other's scope
// from their own prompt.
export const chiefInstructions = project => [
`You are the Project Chief of "${project.name}" (Project id ${project.id}). You are that Project's own Chief, not the user's main Chief acting part-time.`,
'You speak for that Project and for no other: never answer for, decide for, or report on another Project, and never let another Project\'s work be attributed to this one.',
'The user\'s main Chief stays outside this Project. Report to it when asked, but keep this Project\'s work and its record here.',
].join('\n\n')

const text = value => (typeof value === 'string' ? value.trim() : '')
const refusal = (message, code = 'bot/invalid') => Object.assign(new Error(message), { code })

export function install(rt) {
const projectOf = id => rt.readProject?.(text(id)) ?? null

const activeProject = (id) => {
const project = projectOf(id)
if (project === null) throw refusal(`Unknown Project ${text(id) || '(none)'}`, 'bot/not-found')
if (project.status === 'archived') throw refusal(`Project ${project.name} is archived`, 'bot/refused')
return project
}

// The record is the answer; a Bot that has since been deleted is not a Chief, so the read
// resolves `null` instead of handing back a dead id.
const chiefOf = (projectId) => {
const project = projectOf(projectId)
if (project === null) return null
const chief = project.chief
if (typeof chief?.botId !== 'string' || chief.botId === '') return null
return rt.botOf?.(chief.botId) === undefined ? null : chief
}

const create = async (projectId) => {
const project = activeProject(projectId)
const existing = chiefOf(project.id)
if (existing !== null) return { project, chief: existing, created: false }
const bot = await rt.createBot({
name: chiefName(project),
role: CHIEF_ROLE,
instructions: chiefInstructions(project),
hidden: true,
ownerId: text(project.ownerUserId) || rt.initialOwnerId?.(),
})
// The Chief and its binding are plumbing, not a change of the Project: a Project created with
// one still answers `createdAt === updatedAt`.
const stamped = project.updatedAt
rt.bindProject?.({ kind: 'bot', id: bot.id, projectId: project.id })
project.updatedAt = stamped
const chief = { botId: bot.id, sessionId: rt.chatOf?.(bot.id) ?? bot.id, createdAt: Date.now() }
project.chief = chief
await rt.save?.()
return { project, chief, created: true }
}

// Retirement goes through the host's own Bot deletion, so the Session, the memory archive and the
// board cards are handled by the same path every other Bot deletion takes.
const remove = async (projectId) => {
const project = projectOf(projectId)
if (project === null) return null
const chief = chiefOf(project.id) ?? (typeof project.chief?.botId === 'string' ? project.chief : null)
if (chief === null) return null
await rt.deleteBot?.(chief.botId)
.catch(error => rt.warn?.('ds-bot: removing the Project Chief Bot failed: %s', error?.message ?? error))
delete project.chief
await rt.save?.()
return chief
}

rt.projectChief = { create, chiefOf, remove, chiefName, chiefInstructions, CHIEF_ROLE }
}
26 changes: 26 additions & 0 deletions core/src/host/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,18 @@ export function install(rt) {
if (project === null) throw projectRefusal(`Unknown Project ${String(id ?? '')}`, 'bot/not-found')
return project
}
// The Project Chief seam (#1859), read at call time so a host that ships no Chief module — an
// older core, or a narrowed test host — is told once instead of on every Project. It answers
// `undefined` and never throws: automation being off must not refuse a Project.
let chiefSeamMissing = false
const projectChief = () => {
if (rt.projectChief !== undefined) return rt.projectChief
if (!chiefSeamMissing) {
chiefSeamMissing = true
rt.warn?.('ds-bot: this host has no Project Chief seam, so no Project gets its own Chief (#1859)')
}
return undefined
}
const projectMemberOf = (kind, id) => {
if (kind === 'bot' || kind === 'group') {
const member = ownEntry(kind === 'bot' ? state.bots : state.rooms, id)
Expand Down Expand Up @@ -526,6 +538,8 @@ export function install(rt) {
const deleteProject = async (id) => {
const project = requireProject(id)
await rt.projectAssistant?.remove?.(project.id)
// The Project's own Chief retires with it (#1859), by the same path a deleted Bot takes.
await projectChief()?.remove?.(project.id)
delete state.projects[project.id]
delete state.projectMemory[project.id]
for (const member of [...Object.values(state.bots), ...Object.values(state.rooms)]) {
Expand Down Expand Up @@ -619,6 +633,18 @@ export function install(rt) {
project.assistant = { error: error?.message ?? String(error) }
}
}
// Every Project gets its own Chief (#1859) — not the user's main Chief acting part-time.
// Same contract as the assistant: a spawn failure is recorded on the record, and a host
// without the seam still creates the Project with the reason written down.
const chief = projectChief()
if (chief === undefined) project.chief = { error: 'this host has no Project Chief seam, so no Chief was created' }
else {
try {
await chief.create(project.id)
} catch (error) {
project.chief = { error: error?.message ?? String(error) }
}
}
await save()
return { project }
}
Expand Down
8 changes: 4 additions & 4 deletions core/test/project-board-panels.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -253,14 +253,14 @@ test("the settings panel shows the host's refusal instead of pretending the save
assert.match(drawnStatus(draw), /Only the owner of this Project/)
})

test('the members panel lists the owners and the members the board names, and offers no write', () => {
test('the members panel lists the owners, the Project Chief and the members the board names, and offers no write', () => {
resetSlots()
const tree = render(ProjectMembersPanel, {
project: { id: 'project-1', ownerUserId: 'owner-1', ownerUserIds: ['owner-1', 'owner-2'] },
project: { id: 'project-1', ownerUserId: 'owner-1', ownerUserIds: ['owner-1', 'owner-2'], chief: { botId: 'bot-chief', sessionId: 's-chief', createdAt: 1 } },
rows, onClose() {},
})
const members = all(tree).filter(node => node.props?.['data-member-role'] !== undefined)
assert.deepEqual(members.map(node => node.props['data-member-id']), ['owner-1', 'owner-2', 'Dev4'])
assert.deepEqual(members.map(node => node.props['data-member-role']), ['owner', 'owner', 'board'])
assert.deepEqual(members.map(node => node.props['data-member-id']), ['owner-1', 'owner-2', 'bot-chief', 'Dev4'])
assert.deepEqual(members.map(node => node.props['data-member-role']), ['owner', 'owner', 'chief', 'board'])
assert.equal(byAction(tree, 'project-bind'), undefined, 'membership management is a later slice, not a dead button here')
})
Loading
Loading