From c74caf59d0d56d25b2dc9d9dd06d67e9ac94803b Mon Sep 17 00:00:00 2001 From: OpenClaw Local Date: Sun, 29 Mar 2026 21:28:50 +0200 Subject: [PATCH] Add shared hall collaboration guide --- .env.example | 3 + HALL.md | 35 ++++++ README.md | 1 + README.zh-CN.md | 1 + src/runtime/hall-runtime-dispatch.ts | 54 ++++++++- src/ui/server.ts | 2 + test/hall-runtime-dispatch.test.ts | 162 +++++++++++++++++++++++++++ 7 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 HALL.md diff --git a/.env.example b/.env.example index a302b82..16de85e 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,9 @@ GATEWAY_URL=ws://127.0.0.1:18789 # HALL_RUNTIME_HISTORY_LIMIT=120 # HALL_RUNTIME_EXECUTION_CHAIN_ENABLED=true # HALL_RUNTIME_EXECUTION_MAX_TURNS=3 +# Optional shared hall collaboration guide override. +# By default, hall reads ./HALL.md from this repo on every turn. +# OPENCLAW_HALL_RULES_PATH=/path/to/HALL.md # Optional path overrides when your OpenClaw/Codex data is not in the default home locations. # OPENCLAW_HOME=/path/to/.openclaw # OPENCLAW_CONFIG_PATH=/path/to/openclaw.json diff --git a/HALL.md b/HALL.md new file mode 100644 index 0000000..8321c7d --- /dev/null +++ b/HALL.md @@ -0,0 +1,35 @@ +# Hall Collaboration Guide + +This file defines shared collaboration preferences for the Control Center Collaboration Hall. + +These rules shape tone and teamwork style only. +Do not override explicit operator requests, current owner routing, queued execution order, or hard hall safety rules. + +## Default style + +- Sound like sharp coworkers in a busy work chat, not like a memo or a narrator. +- Prefer direct, useful replies over ceremony. +- Build on the current thread instead of repeating what is already obvious. + +## Discussion + +- The second speaker should add a missing angle, tension, risk, or better alternative instead of paraphrasing the first. +- Disagreement is welcome when it improves the result. If you disagree, give a concrete replacement path. +- Keep discussion compact unless the operator explicitly asks for a full draft. + +## Execution + +- Only do the current slice. Do not steal later steps from the queue. +- If the task asks for a deliverable, post the actual deliverable instead of describing what should be done. + +## Review + +- Reviewer starts with must-fix issues first. +- If the work is good enough, say so clearly. +- If the work is not ready, point to the smallest concrete change that unblocks the next pass. + +## Handoff + +- A handoff should state what now exists, what still matters, and who acts next. +- Mention the next owner directly when the queue already names one. +- Keep handoffs concrete enough that the next person can continue without guesswork. diff --git a/README.md b/README.md index 66ebe27..21385d7 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ Example UI from a local OpenClaw environment: ## Hall workflow - Start with one task in the hall. The first turn stays in `discussion`. - Unless you explicitly `@` one agent, the hall aims to gather at least two short replies so the second person can add a missing angle instead of repeating the first. +- Edit the repo-root `HALL.md` file if you want one shared collaboration style for the whole hall. It is injected into discussion, execution, and handoff turns, but it does not override owner routing or queued execution order. - Use `Arrange execution order` to decide the first owner, later owners, and what each person hands off. - Saving the order does **not** start execution. - Once the queue is ready, the decision card will show `Start execution (...)`. diff --git a/README.zh-CN.md b/README.zh-CN.md index 69330af..259d67a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -81,6 +81,7 @@ ## hall 工作流 - 从一条任务开始,第一轮会先留在 `讨论中`。 - 如果你没有显式 `@` 某个人,hall 会尽量先收两条短回复,让第二个人承接第一人的上下文补缺口,而不是重写一遍。 +- 如果你想给整个 hall 统一协作风格,可以直接改仓库根目录的 `HALL.md`。它会注入 discussion / execution / handoff 的 prompt,但不会覆盖 owner 路由和已排好的执行顺序。 - 用 `安排后续顺序` 来决定谁先做、后面谁接、每一步交接给谁。 - 保存顺序 **不会** 自动开始执行。 - 队列排好后,结论卡会出现 `开始执行(...)`。 diff --git a/src/runtime/hall-runtime-dispatch.ts b/src/runtime/hall-runtime-dispatch.ts index 5595be8..27bf86b 100644 --- a/src/runtime/hall-runtime-dispatch.ts +++ b/src/runtime/hall-runtime-dispatch.ts @@ -1,6 +1,6 @@ import { setTimeout as delay } from "node:timers/promises"; import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { join, relative } from "node:path"; import type { ToolClient } from "../clients/tool-client"; import { HALL_RUNTIME_DIRECT_STREAM_ENABLED, @@ -162,6 +162,9 @@ const CONTROL_CENTER_REPO_ENTRY_FILES = [ "src/runtime/hall-runtime-dispatch.ts", "src/types.ts", ]; +const HALL_RULES_OVERRIDE_ENV = "OPENCLAW_HALL_RULES_PATH"; +const HALL_RULES_DEFAULT_FILE = "HALL.md"; +const HALL_RULES_MAX_CHARS = 6_000; const HALL_REPO_CONTEXT_MAX_FILE_CHARS = 1_600; const HALL_REPO_CONTEXT_MAX_TOTAL_CHARS = 7_200; const HALL_WORKSPACE_PERSONA_FILES = ["AGENTS.md", "SOUL.md", "IDENTITY.md", "USER.md", "README.md"]; @@ -386,6 +389,7 @@ function buildHallRuntimePrompt(input: HallRuntimeDispatchInput, repoContext: Ha const roundRosterBlock = discussionMode ? buildHallDiscussionRosterBlock(input) : buildHallRuntimeRosterBlock(input); + const hallRulesBlock = buildHallRulesPromptBlock(); const selfWorkspacePersona = !discussionMode || isDiscussionParticipantExplicitlyMentioned(input) ? describeHallParticipantWorkspacePersona(input.participant) : ""; @@ -404,6 +408,7 @@ function buildHallRuntimePrompt(input: HallRuntimeDispatchInput, repoContext: Ha recentMessages.length > 0 ? `Recent agent contributions already in thread: ${countRecentAgentContributors(recentMessages)}.` : "", transcriptBlock, roundRosterBlock, + hallRulesBlock, !discussionMode ? `Your semantic responsibility is ${role}.` : "", selfWorkspacePersona ? `Your workspace persona and job boundary: ${selfWorkspacePersona}` : "", taskArtifactBlock, @@ -537,6 +542,19 @@ function buildHallRuntimePrompt(input: HallRuntimeDispatchInput, repoContext: Ha ].filter(Boolean).join("\n"); } +function buildHallRulesPromptBlock(): string { + const guide = readHallRulesGuide(); + if (!guide) return ""; + return [ + `Shared hall collaboration guide (${guide.label}):`, + "Apply these shared preferences to tone, collaboration style, disagreement style, review style, and handoff wording whenever they do not conflict with an explicit operator request, the current owner, queued execution order, or hard hall routing/state rules.", + guide.content, + guide.truncated + ? "Only the leading portion of the shared hall guide was included. Keep the file concise if you want every rule to be visible." + : "", + ].filter(Boolean).join("\n"); +} + function buildHallRuntimeArtifactBlock( artifacts: TaskArtifact[] | undefined, language: HallResponseLanguage, @@ -825,6 +843,40 @@ function describeHallParticipantWorkspacePersona(participant: HallParticipant): return summary; } +function readHallRulesGuide(): { label: string; content: string; truncated: boolean } | undefined { + const sourcePath = resolveHallRulesGuidePath(); + if (!existsSync(sourcePath)) return undefined; + let raw = ""; + try { + raw = readFileSync(sourcePath, "utf8"); + } catch { + return undefined; + } + const normalized = raw.replace(/\r\n/g, "\n").trim(); + if (!normalized) return undefined; + const truncated = normalized.length > HALL_RULES_MAX_CHARS; + return { + label: displayHallRulesGuidePath(sourcePath), + content: truncated + ? `${normalized.slice(0, Math.max(0, HALL_RULES_MAX_CHARS - 16)).trimEnd()}\n[truncated]` + : normalized, + truncated, + }; +} + +function resolveHallRulesGuidePath(): string { + const explicit = process.env[HALL_RULES_OVERRIDE_ENV]?.trim(); + if (explicit) return explicit; + return join(CONTROL_CENTER_REPO_ROOT, HALL_RULES_DEFAULT_FILE); +} + +function displayHallRulesGuidePath(sourcePath: string): string { + const normalizedSource = sourcePath.trim(); + const repoRelative = relative(CONTROL_CENTER_REPO_ROOT, normalizedSource).trim(); + if (repoRelative && !repoRelative.startsWith("..")) return repoRelative; + return normalizedSource; +} + export function summarizeWorkspacePersonaFromFiles(workspaceRoot: string): string { const candidates: Array<{ text: string; score: number }> = []; for (const fileName of HALL_WORKSPACE_PERSONA_FILES) { diff --git a/src/ui/server.ts b/src/ui/server.ts index 195f701..dc7ca09 100644 --- a/src/ui/server.ts +++ b/src/ui/server.ts @@ -253,6 +253,7 @@ const SHARED_DOCUMENT_FILE_CANDIDATES = [ "TOOLS.md", join(".learnings", "LEARNINGS.md"), join("control-center", "README.md"), + join("control-center", "HALL.md"), ] as const; const AGENT_DOCUMENT_FILE_CANDIDATES = [ "AGENTS.md", @@ -12213,6 +12214,7 @@ function documentFilePriority(relativePath: string): number { "identity.md", "soul.md", "user.md", + "hall.md", "tasks.md", "bootstrap.md", "heartbeat.md", diff --git a/test/hall-runtime-dispatch.test.ts b/test/hall-runtime-dispatch.test.ts index 645a9e9..7a9b70f 100644 --- a/test/hall-runtime-dispatch.test.ts +++ b/test/hall-runtime-dispatch.test.ts @@ -1,4 +1,6 @@ import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { pathToFileURL } from "node:url"; @@ -24,6 +26,166 @@ test("workspace persona summary reuses existing agent files instead of hall-only assert.match(coqPersona, /(每日新闻|趋势简报|早晚报主编)/); }); +test("shared hall guide is injected into discussion and execution prompts from an override path", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "hall-rules-guide-")); + const hallRulesPath = join(tempDir, "custom-hall.md"); + const previousRulesPath = process.env.OPENCLAW_HALL_RULES_PATH; + const capturedPrompts: string[] = []; + + await writeFile( + hallRulesPath, + [ + "# Shared Hall Rules", + "- Push useful disagreement when it sharpens the output.", + "- Reviewer only raises must-fix issues before optional polish.", + ].join("\n"), + "utf8", + ); + + try { + process.env.OPENCLAW_HALL_RULES_PATH = hallRulesPath; + + const client = { + sessionsHistory: async () => ({ history: [] }), + agentRun: async (request: { sessionKey?: string; message: string }) => { + capturedPrompts.push(request.message); + return { + ok: true, + text: "第一版先锁住了。", + rawText: "", + sessionKey: request.sessionKey, + }; + }, + } as never; + + await dispatchHallRuntimeTurn({ + client, + hall: { + hallId: "hall", + participants: [], + updatedAt: new Date().toISOString(), + } as never, + taskCard: { + taskCardId: "discussion-card", + hallId: "hall", + projectId: "project", + taskId: "task-global-discussion", + title: "我想录一个群聊功能视频", + description: "先讨论整体讲法", + stage: "discussion", + status: "todo", + createdByParticipantId: "operator", + blockers: [], + requiresInputFrom: [], + mentionedParticipantIds: [], + plannedExecutionOrder: [], + plannedExecutionItems: [], + sessionKeys: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as never, + participant: { + participantId: "coq", + agentId: "coq", + displayName: "Coq-每日新闻", + semanticRole: "planner", + aliases: [], + active: true, + } as never, + triggerMessage: { + hallId: "hall", + messageId: "trigger-discussion", + kind: "task", + authorParticipantId: "operator", + authorLabel: "Operator", + content: "先给我一个整体方向", + createdAt: new Date().toISOString(), + } as never, + mode: "discussion", + }); + + await dispatchHallRuntimeTurn({ + client, + hall: { + hallId: "hall", + participants: [ + { + participantId: "monkey", + agentId: "monkey", + displayName: "Monkey", + semanticRole: "coder", + aliases: [], + active: true, + }, + ], + updatedAt: new Date().toISOString(), + } as never, + taskCard: { + taskCardId: "execution-card", + hallId: "hall", + projectId: "project", + taskId: "task-global-execution", + title: "我想录一个群聊功能视频", + description: "开始写开头", + stage: "execution", + status: "doing", + createdByParticipantId: "operator", + currentOwnerParticipantId: "monkey", + currentOwnerLabel: "Monkey", + blockers: [], + requiresInputFrom: [], + mentionedParticipantIds: [], + plannedExecutionOrder: [], + plannedExecutionItems: [ + { + itemId: "step-1", + participantId: "monkey", + task: "Write three spoken openings", + }, + ], + currentExecutionItem: { + itemId: "step-1", + participantId: "monkey", + task: "Write three spoken openings", + }, + sessionKeys: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as never, + participant: { + participantId: "monkey", + agentId: "monkey", + displayName: "Monkey", + semanticRole: "coder", + aliases: [], + active: true, + } as never, + triggerMessage: { + hallId: "hall", + messageId: "trigger-execution", + kind: "decision", + authorParticipantId: "operator", + authorLabel: "Operator", + content: "开始执行", + createdAt: new Date().toISOString(), + } as never, + mode: "execution", + }); + } finally { + if (previousRulesPath === undefined) delete process.env.OPENCLAW_HALL_RULES_PATH; + else process.env.OPENCLAW_HALL_RULES_PATH = previousRulesPath; + await rm(tempDir, { recursive: true, force: true }); + } + + assert.equal(capturedPrompts.length, 2); + for (const prompt of capturedPrompts) { + assert.match(prompt, /Shared hall collaboration guide/i); + assert.match(prompt, /Push useful disagreement when it sharpens the output\./); + assert.match(prompt, /Reviewer only raises must-fix issues before optional polish\./); + } + assert.match(capturedPrompts[1], /Your current execution item: Write three spoken openings/); +}); + test("brand-new hall threads use a thread-scoped runtime session instead of the shared hall agent session", async () => { const observedSessionKeys: string[] = []; await dispatchHallRuntimeTurn({