From 6f01e749796efc8f512e81900f39d8aa250c62f5 Mon Sep 17 00:00:00 2001
From: huyz <739820174@qq.com>
Date: Sat, 26 Sep 2026 00:24:49 +0800
Subject: [PATCH 1/9] fix(services): keep unsaved connection drafts across
background refreshes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The ConnectionForm sync effect unconditionally reset every field,
including a typed PAT, whenever the saved connection values changed.
A background services refresh landing while a draft was unsaved could
therefore silently discard the user's input, and the next submit went
out without a token.
Guard the sync effect with a dirty flag: user edits mark the form dirty
and win over background refreshes; the flag clears once a save or
disconnect accepts the server state. Add a regression test where a
refresh lands with changed saved addresses while a PAT is typed — it
fails on main and passes with this change.
Refs #512
---
.../src/settings/ServiceConnections.tsx | 14 ++++++----
.../test/service-connections.test.tsx | 26 +++++++++++++++++++
2 files changed, 35 insertions(+), 5 deletions(-)
diff --git a/apps/desktop/renderer/src/settings/ServiceConnections.tsx b/apps/desktop/renderer/src/settings/ServiceConnections.tsx
index dee09bdb..d385debe 100644
--- a/apps/desktop/renderer/src/settings/ServiceConnections.tsx
+++ b/apps/desktop/renderer/src/settings/ServiceConnections.tsx
@@ -34,12 +34,14 @@ function ConnectionForm({ connection, onChange, operationsOnly = false }: { conn
const tokenRef = useRef(token);
tokenRef.current = token;
const busyRef = useRef(false);
+ const dirtyRef = useRef(false);
const savedApiUrl = connection.apiUrl ?? "";
const savedWebUrl = connection.webUrl ?? "";
const savedWorkspaceId = connection.workspaceId ?? "";
const savedTokenConfigured = connection.tokenConfigured;
useEffect(() => {
+ if (dirtyRef.current) return; // an unsaved draft wins over a background refresh
setApiUrl(savedApiUrl);
setWebUrl(savedWebUrl);
setWorkspaceId(savedWorkspaceId);
@@ -64,6 +66,7 @@ function ConnectionForm({ connection, onChange, operationsOnly = false }: { conn
});
} catch { setNotice({ type: "error", key: "services.failed" }); return; }
if (response.status !== 200) { setNotice({ type: "error", key: serviceErrorKey(response.body) }); return; }
+ dirtyRef.current = false;
setToken(""); setProbe(null); onChange(response.body);
setNotice({ type: "success", key: "services.saved" });
window.dispatchEvent(new Event(SERVICES_CHANGED));
@@ -81,6 +84,7 @@ function ConnectionForm({ connection, onChange, operationsOnly = false }: { conn
async function disconnect() {
const response = await window.owb.services.disconnect(kind);
if (response.status !== 200) { setNotice({ type: "error", key: serviceErrorKey(response.body) }); return; }
+ dirtyRef.current = false;
setProbe(null); onChange(response.body);
setNotice({ type: "success", key: "services.disconnected" });
window.dispatchEvent(new Event(SERVICES_CHANGED));
@@ -104,27 +108,27 @@ function ConnectionForm({ connection, onChange, operationsOnly = false }: { conn
{t("services.apiUrl")}
setApiUrl(event.target.value)} autoComplete="off" spellCheck={false} />
+ onChange={(event) => { dirtyRef.current = true; setApiUrl(event.target.value); }} autoComplete="off" spellCheck={false} />
{t("services.webUrl")}
setWebUrl(event.target.value)} autoComplete="off" spellCheck={false} />
+ onChange={(event) => { dirtyRef.current = true; setWebUrl(event.target.value); }} autoComplete="off" spellCheck={false} />
{t("services.token")}
setToken(event.target.value)} autoComplete="new-password" />
+ onChange={(event) => { dirtyRef.current = true; setToken(event.target.value); }} autoComplete="new-password" />
{kind === "mem" ?
{t("services.workspace")}
setWorkspaceId(event.target.value)} autoComplete="off" spellCheck={false} />
+ onChange={(event) => { dirtyRef.current = true; setWorkspaceId(event.target.value); }} autoComplete="off" spellCheck={false} />
: null}
{connection.tokenConfigured ? { setClearToken(event.target.checked); setToken(""); }}>{t("services.clearToken")} : null}
+ onChange={(event) => { dirtyRef.current = true; setClearToken(event.target.checked); setToken(""); }}>{t("services.clearToken")} : null}
{t("services.addressHint")}
> : null}
diff --git a/apps/desktop/renderer/test/service-connections.test.tsx b/apps/desktop/renderer/test/service-connections.test.tsx
index 79995851..0ea7e0ea 100644
--- a/apps/desktop/renderer/test/service-connections.test.tsx
+++ b/apps/desktop/renderer/test/service-connections.test.tsx
@@ -106,4 +106,30 @@ describe("independent service connections", () => {
await waitFor(() => expect(services.list.mock.calls.length).toBeGreaterThan(2));
expect(screen.getAllByRole("button", { name: "打开 Doc" }).length).toBe(2);
});
+
+ it("keeps a typed PAT when a background refresh lands with changed saved addresses", async () => {
+ const services = installBridge();
+ render(
);
+ const token = await screen.findByLabelText("个人访问令牌(PAT)");
+ fireEvent.change(token, { target: { value: "fresh-pat" } });
+ services.list.mockImplementation(async () => ({
+ status: 200,
+ body: {
+ connections: [{
+ kind: "doc",
+ apiUrl: "https://docs.example/v2",
+ webUrl: "https://docs.example/work2",
+ configured: true,
+ tokenConfigured: true,
+ workspaceId: null,
+ }],
+ },
+ }));
+ window.dispatchEvent(new Event("owb:services-changed"));
+ await waitFor(() => expect(services.list.mock.calls.length).toBeGreaterThan(1));
+ expect(token).toHaveValue("fresh-pat");
+ fireEvent.click(screen.getByRole("button", { name: "保存连接" }));
+ await screen.findByText("连接已保存,可以检查服务是否可访问。");
+ expect(services.configure).toHaveBeenLastCalledWith(expect.objectContaining({ kind: "doc", token: "fresh-pat" }));
+ });
});
From b798429e5717c99edb383d3fe352da95a1736bce Mon Sep 17 00:00:00 2001
From: PeterGuy326
Date: Thu, 1 Oct 2026 23:07:20 +0800
Subject: [PATCH 2/9] feat(space): add acceptance.v1 contract for human
acceptance verdicts
Introduces the acceptance record that lets a human accept or reject an
agent's deliverable inside a space. This is deliberately separate from
approval: an approval gates permission to act, an acceptance is a verdict
on work already done. Agents may only push work to review; only an
acceptance may move it to done.
Validators are fail-closed in the style of goal.v1: allowlisted keys,
closed enums, bounded text, and decision/verdict cross-checks:
- accepted requires a verdict for every criterion, all passed
- rejected requires a non-empty reason note
- criteriaIndex values must be unique and non-negative
Contract + tests only; the space/document UI lands in a follow-up once
this shape is agreed.
Refs #558
---
apps/server/test/acceptance.test.ts | 189 +++++++++++++++
packages/shared/package.json | 4 +
packages/shared/src/acceptance.ts | 357 ++++++++++++++++++++++++++++
packages/shared/src/index.ts | 1 +
4 files changed, 551 insertions(+)
create mode 100644 apps/server/test/acceptance.test.ts
create mode 100644 packages/shared/src/acceptance.ts
diff --git a/apps/server/test/acceptance.test.ts b/apps/server/test/acceptance.test.ts
new file mode 100644
index 00000000..7c50d16c
--- /dev/null
+++ b/apps/server/test/acceptance.test.ts
@@ -0,0 +1,189 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ ACCEPTANCE_SCHEMA_VERSION,
+ validateAcceptanceCreateRequest,
+ validateAcceptanceRecord,
+ type AcceptanceRecord,
+} from "@roleweave/shared";
+
+function baseRecord(overrides: Record = {}): Record {
+ return {
+ schemaVersion: ACCEPTANCE_SCHEMA_VERSION,
+ acceptanceId: "acc-1",
+ spaceId: "space-billing",
+ source: { positionId: "analyst" },
+ verdicts: [
+ { criteriaIndex: 0, passed: true },
+ { criteriaIndex: 1, passed: true, evidencePath: "产出/增量更新.md" },
+ ],
+ decision: "accepted",
+ decidedBy: "operator",
+ decidedAt: "2026-10-01T12:00:00.000Z",
+ ...overrides,
+ };
+}
+
+test("accepts a well-formed accepted record", () => {
+ const result = validateAcceptanceRecord(baseRecord());
+ assert.equal(result.ok, true);
+ if (!result.ok) return;
+ const value: AcceptanceRecord = result.value;
+ assert.equal(value.schemaVersion, ACCEPTANCE_SCHEMA_VERSION);
+ assert.equal(value.decision, "accepted");
+ assert.equal(value.verdicts.length, 2);
+ assert.equal(value.verdicts[1].evidencePath, "产出/增量更新.md");
+});
+
+test("rejects an accepted decision when a criterion is not passed", () => {
+ const result = validateAcceptanceRecord(
+ baseRecord({ verdicts: [{ criteriaIndex: 0, passed: true }, { criteriaIndex: 1, passed: false }] }),
+ );
+ assert.equal(result.ok, false);
+ if (result.ok) return;
+ assert.equal(result.code, "acceptance_incomplete_verdicts");
+});
+
+test("rejects an accepted decision with an empty verdict list", () => {
+ const result = validateAcceptanceRecord(baseRecord({ verdicts: [] }));
+ assert.equal(result.ok, false);
+ if (result.ok) return;
+ assert.equal(result.code, "acceptance_incomplete_verdicts");
+});
+
+test("rejects a rejected decision with an empty note", () => {
+ const result = validateAcceptanceRecord(
+ baseRecord({ decision: "rejected", note: " ", verdicts: [{ criteriaIndex: 0, passed: false }] }),
+ );
+ assert.equal(result.ok, false);
+ if (result.ok) return;
+ assert.equal(result.code, "acceptance_missing_note");
+});
+
+test("rejects a rejected decision with no note at all", () => {
+ const result = validateAcceptanceRecord(
+ baseRecord({ decision: "rejected", verdicts: [{ criteriaIndex: 0, passed: false }] }),
+ );
+ assert.equal(result.ok, false);
+ if (result.ok) return;
+ assert.equal(result.code, "acceptance_missing_note");
+});
+
+test("keeps a rejected record that carries a reason note", () => {
+ const result = validateAcceptanceRecord(
+ baseRecord({ decision: "rejected", note: "criterion 1 has no reproducible evidence", verdicts: [{ criteriaIndex: 0, passed: false }] }),
+ );
+ assert.equal(result.ok, true);
+ if (!result.ok) return;
+ assert.equal(result.value.decision, "rejected");
+ assert.equal(result.value.note, "criterion 1 has no reproducible evidence");
+});
+
+test("rejects duplicate criteriaIndex values", () => {
+ const result = validateAcceptanceRecord(
+ baseRecord({ verdicts: [{ criteriaIndex: 0, passed: true }, { criteriaIndex: 0, passed: true }] }),
+ );
+ assert.equal(result.ok, false);
+ if (result.ok) return;
+ assert.equal(result.code, "acceptance_duplicate_reference");
+});
+
+test("rejects a negative or non-integer criteriaIndex", () => {
+ for (const criteriaIndex of [-1, 1.5]) {
+ const result = validateAcceptanceRecord(
+ baseRecord({ verdicts: [{ criteriaIndex, passed: true }] }),
+ );
+ assert.equal(result.ok, false, `criteriaIndex ${criteriaIndex} should be rejected`);
+ if (result.ok) continue;
+ assert.equal(result.code, "acceptance_invalid");
+ }
+});
+
+test("rejects an unsupported schemaVersion", () => {
+ const result = validateAcceptanceRecord(baseRecord({ schemaVersion: "acceptance.v2" }));
+ assert.equal(result.ok, false);
+ if (result.ok) return;
+ assert.equal(result.code, "acceptance_invalid");
+});
+
+test("rejects unknown top-level keys", () => {
+ const result = validateAcceptanceRecord(baseRecord({ surprise: true }));
+ assert.equal(result.ok, false);
+ if (result.ok) return;
+ assert.equal(result.code, "acceptance_unknown_field");
+});
+
+test("rejects a decision outside the allowlist", () => {
+ const result = validateAcceptanceRecord(baseRecord({ decision: "maybe" }));
+ assert.equal(result.ok, false);
+ if (result.ok) return;
+ assert.equal(result.code, "acceptance_invalid_enum");
+});
+
+test("rejects a source with an unexpected field", () => {
+ const result = validateAcceptanceRecord(
+ baseRecord({ source: { positionId: "analyst", sneaky: "x" } }),
+ );
+ assert.equal(result.ok, false);
+ if (result.ok) return;
+ assert.equal(result.code, "acceptance_unknown_field");
+});
+
+test("accepts a source carrying turnId and artifactPath", () => {
+ const result = validateAcceptanceRecord(
+ baseRecord({ source: { positionId: "analyst", turnId: "turn-9", artifactPath: "产出/增量更新.md" } }),
+ );
+ assert.equal(result.ok, true);
+ if (!result.ok) return;
+ assert.equal(result.value.source.turnId, "turn-9");
+ assert.equal(result.value.source.artifactPath, "产出/增量更新.md");
+});
+
+test("rejects a non-string note", () => {
+ const result = validateAcceptanceRecord(
+ baseRecord({
+ decision: "rejected",
+ note: 42,
+ verdicts: [{ criteriaIndex: 0, passed: false }],
+ }),
+ );
+ assert.equal(result.ok, false);
+ if (result.ok) return;
+ assert.equal(result.code, "acceptance_invalid");
+});
+
+test("rejects a whitespace-bearing identifier", () => {
+ const result = validateAcceptanceRecord(baseRecord({ spaceId: "space billing" }));
+ assert.equal(result.ok, false);
+ if (result.ok) return;
+ assert.equal(result.code, "acceptance_invalid");
+});
+
+test("rejects a missing required field", () => {
+ const raw = baseRecord();
+ delete raw.decidedBy;
+ const result = validateAcceptanceRecord(raw);
+ assert.equal(result.ok, false);
+ if (result.ok) return;
+ assert.equal(result.code, "acceptance_unknown_field");
+});
+
+test("create request mirrors the same decision/verdict consistency", () => {
+ const rejected = validateAcceptanceCreateRequest({
+ spaceId: "space-billing",
+ source: { positionId: "analyst" },
+ verdicts: [{ criteriaIndex: 0, passed: false }],
+ decision: "accepted",
+ });
+ assert.equal(rejected.ok, false);
+ if (rejected.ok) return;
+ assert.equal(rejected.code, "acceptance_incomplete_verdicts");
+
+ const accepted = validateAcceptanceCreateRequest({
+ spaceId: "space-billing",
+ source: { positionId: "analyst" },
+ verdicts: [{ criteriaIndex: 0, passed: true }],
+ decision: "accepted",
+ });
+ assert.equal(accepted.ok, true);
+});
diff --git a/packages/shared/package.json b/packages/shared/package.json
index c62beb22..ec21dec8 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -45,6 +45,10 @@
"types": "./dist/goals.d.ts",
"import": "./dist/goals.js"
},
+ "./acceptance": {
+ "types": "./dist/acceptance.d.ts",
+ "import": "./dist/acceptance.js"
+ },
"./task-board": {
"types": "./dist/task-board.d.ts",
"import": "./dist/task-board.js"
diff --git a/packages/shared/src/acceptance.ts b/packages/shared/src/acceptance.ts
new file mode 100644
index 00000000..34cd32c1
--- /dev/null
+++ b/packages/shared/src/acceptance.ts
@@ -0,0 +1,357 @@
+/**
+ * Acceptance contracts (v1).
+ *
+ * An AcceptanceRecord is the human act of accepting a deliverable produced by
+ * an agent inside a space. It is deliberately separate from an approval:
+ *
+ * - approval -> permission to act, owned by the workspace/organization,
+ * modelled by ApprovalRecord (packages/shared/src/approvals.ts)
+ * - acceptance -> a verdict on work already done, owned by the space
+ *
+ * The distinction matters because an agent may only ever push work to `review`.
+ * Only an acceptance decision may move it to `done`; the plan side
+ * (GoalWorkItem) and the execution side (GoalTaskExecution) never overwrite
+ * each other.
+ *
+ * Validators are fail-closed: object keys are allowlisted, enums are closed,
+ * text is bounded, and the decision is cross-checked against the verdicts.
+ */
+
+export const ACCEPTANCE_SCHEMA_VERSION = "acceptance.v1" as const;
+
+export const ACCEPTANCE_MAX_ID_LENGTH = 128;
+export const ACCEPTANCE_MAX_SHORT_TEXT_LENGTH = 256;
+export const ACCEPTANCE_MAX_TEXT_LENGTH = 4_096;
+export const ACCEPTANCE_MAX_PATH_LENGTH = 1_024;
+export const ACCEPTANCE_MAX_VERDICTS = 64;
+
+export const acceptanceDecisions = ["accepted", "rejected"] as const;
+export type AcceptanceDecision = (typeof acceptanceDecisions)[number];
+
+/** Per-criterion verdict. An accepted record must cover every criterion. */
+export interface AcceptanceVerdict {
+ criteriaIndex: number;
+ passed: boolean;
+ /** Optional pointer to the artifact that satisfies the criterion. */
+ evidencePath?: string;
+}
+
+export interface AcceptanceSource {
+ positionId: string;
+ turnId?: string;
+ artifactPath?: string;
+}
+
+export interface AcceptanceRecord {
+ schemaVersion: typeof ACCEPTANCE_SCHEMA_VERSION;
+ acceptanceId: string;
+ spaceId: string;
+ source: AcceptanceSource;
+ verdicts: AcceptanceVerdict[];
+ decision: AcceptanceDecision;
+ note?: string;
+ decidedBy: string;
+ decidedAt: string;
+}
+
+export interface AcceptanceCreateRequest {
+ spaceId: string;
+ source: AcceptanceSource;
+ verdicts: AcceptanceVerdict[];
+ decision: AcceptanceDecision;
+ note?: string;
+}
+
+// ── Validators ──────────────────────────────────────────────────────────────
+
+export type AcceptanceValidationCode =
+ | "acceptance_invalid"
+ | "acceptance_unknown_field"
+ | "acceptance_empty_string"
+ | "acceptance_text_too_long"
+ | "acceptance_invalid_enum"
+ | "acceptance_duplicate_reference"
+ | "acceptance_incomplete_verdicts"
+ | "acceptance_missing_note";
+
+export interface AcceptanceValidationFailure {
+ ok: false;
+ code: AcceptanceValidationCode;
+ message: string;
+}
+
+export interface AcceptanceValidationSuccess {
+ ok: true;
+ value: T;
+}
+
+export type AcceptanceValidationResult = AcceptanceValidationSuccess | AcceptanceValidationFailure;
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function fail(code: AcceptanceValidationCode, message: string): AcceptanceValidationFailure {
+ return { ok: false, code, message };
+}
+
+function keysMatch(
+ value: Record,
+ required: readonly string[],
+ optional: readonly string[] = [],
+): boolean {
+ const allowed = new Set([...required, ...optional]);
+ return (
+ Object.keys(value).every((key) => allowed.has(key)) &&
+ required.every((key) => Object.hasOwn(value, key))
+ );
+}
+
+function nonEmptyText(
+ value: unknown,
+ field: string,
+ maxLength = ACCEPTANCE_MAX_TEXT_LENGTH,
+): AcceptanceValidationResult {
+ if (typeof value !== "string" || value.trim().length === 0) {
+ return fail("acceptance_empty_string", `${field} must be a non-empty string`);
+ }
+ if (value.length > maxLength) {
+ return fail("acceptance_text_too_long", `${field} exceeds its text bound`);
+ }
+ if ([...value].some((ch) => ch.charCodeAt(0) < 0x20 && ch !== "\n" && ch !== "\r" && ch !== "\t")) {
+ return fail("acceptance_invalid", `${field} contains a control character`);
+ }
+ return { ok: true, value };
+}
+
+function identifier(value: unknown, field: string): AcceptanceValidationResult {
+ const result = nonEmptyText(value, field, ACCEPTANCE_MAX_SHORT_TEXT_LENGTH);
+ if (!result.ok) return result;
+ if (/\s/.test(result.value)) {
+ return fail("acceptance_invalid", `${field} must not contain whitespace`);
+ }
+ return result;
+}
+
+function pathText(value: unknown, field: string): AcceptanceValidationResult {
+ return nonEmptyText(value, field, ACCEPTANCE_MAX_PATH_LENGTH);
+}
+
+function enumValue(
+ value: unknown,
+ values: readonly T[],
+ field: string,
+): AcceptanceValidationResult {
+ if (typeof value !== "string" || !values.includes(value as T)) {
+ return fail("acceptance_invalid_enum", `${field} is outside the allowlist`);
+ }
+ return { ok: true, value: value as T };
+}
+
+function iso8601(value: unknown, field: string): AcceptanceValidationResult {
+ if (typeof value !== "string") return fail("acceptance_invalid", `${field} must be a string`);
+ if (value.length > ACCEPTANCE_MAX_SHORT_TEXT_LENGTH) {
+ return fail("acceptance_text_too_long", `${field} exceeds its text bound`);
+ }
+ if (Number.isNaN(Date.parse(value))) {
+ return fail("acceptance_invalid", `${field} is not a valid ISO-8601 timestamp`);
+ }
+ return { ok: true, value };
+}
+
+function validateSource(raw: unknown, field: string): AcceptanceValidationResult {
+ if (!isRecord(raw) || !keysMatch(raw, ["positionId"], ["turnId", "artifactPath"])) {
+ return fail("acceptance_unknown_field", `${field} has unexpected or missing fields`);
+ }
+ const positionId = identifier(raw.positionId, `${field}.positionId`);
+ if (positionId.ok === false) return fail(positionId.code, positionId.message);
+
+ const source: AcceptanceSource = { positionId: positionId.value };
+
+ if (raw.turnId !== undefined) {
+ const turnId = identifier(raw.turnId, `${field}.turnId`);
+ if (turnId.ok === false) return fail(turnId.code, turnId.message);
+ source.turnId = turnId.value;
+ }
+ if (raw.artifactPath !== undefined) {
+ const artifactPath = pathText(raw.artifactPath, `${field}.artifactPath`);
+ if (artifactPath.ok === false) return fail(artifactPath.code, artifactPath.message);
+ source.artifactPath = artifactPath.value;
+ }
+ return { ok: true, value: source };
+}
+
+function validateVerdicts(raw: unknown): AcceptanceValidationResult {
+ if (!Array.isArray(raw)) return fail("acceptance_invalid", "verdicts must be an array");
+ if (raw.length > ACCEPTANCE_MAX_VERDICTS) {
+ return fail("acceptance_invalid", `verdicts has too many items (max ${ACCEPTANCE_MAX_VERDICTS})`);
+ }
+ const seen = new Set();
+ const verdicts: AcceptanceVerdict[] = [];
+ for (let index = 0; index < raw.length; index += 1) {
+ const item = raw[index];
+ const field = `verdicts[${index}]`;
+ if (!isRecord(item) || !keysMatch(item, ["criteriaIndex", "passed"], ["evidencePath"])) {
+ return fail("acceptance_unknown_field", `${field} has unexpected or missing fields`);
+ }
+ if (
+ typeof item.criteriaIndex !== "number" ||
+ !Number.isInteger(item.criteriaIndex) ||
+ item.criteriaIndex < 0
+ ) {
+ return fail("acceptance_invalid", `${field}.criteriaIndex must be a non-negative integer`);
+ }
+ if (seen.has(item.criteriaIndex)) {
+ return fail("acceptance_duplicate_reference", `${field}.criteriaIndex is a duplicate`);
+ }
+ seen.add(item.criteriaIndex);
+ if (typeof item.passed !== "boolean") {
+ return fail("acceptance_invalid", `${field}.passed must be a boolean`);
+ }
+ const verdict: AcceptanceVerdict = { criteriaIndex: item.criteriaIndex, passed: item.passed };
+ if (item.evidencePath !== undefined) {
+ const evidencePath = pathText(item.evidencePath, `${field}.evidencePath`);
+ if (!evidencePath.ok) return evidencePath;
+ verdict.evidencePath = evidencePath.value;
+ }
+ verdicts.push(verdict);
+ }
+ return { ok: true, value: verdicts };
+}
+
+/**
+ * Enforce the decision/verdicts cross-checks that keep an accepted record
+ * meaningful:
+ * - accepted requires a non-empty verdict list, all `passed: true`
+ * - rejected requires a non-empty reason note
+ *
+ * `note` is taken raw so a blank note on a rejection is reported as the
+ * specific `acceptance_missing_note` rather than a generic empty string.
+ */
+function checkDecisionConsistency(
+ decision: AcceptanceDecision,
+ verdicts: AcceptanceVerdict[],
+ note: string | undefined,
+): { ok: true } | { ok: false; code: AcceptanceValidationCode; message: string } {
+ if (decision === "accepted") {
+ if (verdicts.length === 0 || verdicts.some((verdict) => !verdict.passed)) {
+ return fail(
+ "acceptance_incomplete_verdicts",
+ "an accepted record requires every criterion to be present and passed",
+ );
+ }
+ return { ok: true };
+ }
+ // rejected
+ if (note === undefined || note.trim().length === 0) {
+ return fail("acceptance_missing_note", "a rejected record requires a non-empty note");
+ }
+ return { ok: true };
+}
+
+/**
+ * A rejection note is read raw so the decision/verdict consistency check owns
+ * the "blank reason" verdict; a present-but-unparsable note still fails loudly.
+ */
+function parseNote(
+ raw: unknown,
+ fallbackField: string,
+): AcceptanceValidationResult {
+ if (raw === undefined) return { ok: true, value: undefined };
+ if (typeof raw !== "string") return fail("acceptance_invalid", `${fallbackField} must be a string`);
+ if (raw.trim().length === 0) return { ok: true, value: raw };
+ if (raw.length > ACCEPTANCE_MAX_TEXT_LENGTH) {
+ return fail("acceptance_text_too_long", `${fallbackField} exceeds its text bound`);
+ }
+ if ([...raw].some((ch) => ch.charCodeAt(0) < 0x20 && ch !== "\n" && ch !== "\r" && ch !== "\t")) {
+ return fail("acceptance_invalid", `${fallbackField} contains a control character`);
+ }
+ return { ok: true, value: raw };
+}
+
+export function validateAcceptanceRecord(raw: unknown): AcceptanceValidationResult {
+ if (
+ !isRecord(raw) ||
+ !keysMatch(
+ raw,
+ ["schemaVersion", "acceptanceId", "spaceId", "source", "verdicts", "decision", "decidedBy", "decidedAt"],
+ ["note"],
+ )
+ ) {
+ return fail("acceptance_unknown_field", "acceptance has unexpected or missing fields");
+ }
+ if (raw.schemaVersion !== ACCEPTANCE_SCHEMA_VERSION) {
+ return fail("acceptance_invalid", "acceptance.schemaVersion is not supported");
+ }
+ const acceptanceId = identifier(raw.acceptanceId, "acceptance.acceptanceId");
+ if (!acceptanceId.ok) return acceptanceId;
+ const spaceId = identifier(raw.spaceId, "acceptance.spaceId");
+ if (!spaceId.ok) return spaceId;
+ const source = validateSource(raw.source, "acceptance.source");
+ if (!source.ok) return source;
+ const verdicts = validateVerdicts(raw.verdicts);
+ if (!verdicts.ok) return verdicts;
+ const decision = enumValue(raw.decision, acceptanceDecisions, "acceptance.decision");
+ if (!decision.ok) return decision;
+ const decidedBy = identifier(raw.decidedBy, "acceptance.decidedBy");
+ if (!decidedBy.ok) return decidedBy;
+ const decidedAt = iso8601(raw.decidedAt, "acceptance.decidedAt");
+ if (!decidedAt.ok) return decidedAt;
+
+ const parsedNote = parseNote(raw.note, "acceptance.note");
+ if (!parsedNote.ok) return parsedNote;
+ const note = parsedNote.value;
+
+ const consistency = checkDecisionConsistency(decision.value, verdicts.value, note);
+ if (!consistency.ok) return fail(consistency.code, consistency.message);
+
+ return {
+ ok: true,
+ value: {
+ schemaVersion: ACCEPTANCE_SCHEMA_VERSION,
+ acceptanceId: acceptanceId.value,
+ spaceId: spaceId.value,
+ source: source.value,
+ verdicts: verdicts.value,
+ decision: decision.value,
+ ...(note !== undefined ? { note } : {}),
+ decidedBy: decidedBy.value,
+ decidedAt: decidedAt.value,
+ },
+ };
+}
+
+export function validateAcceptanceCreateRequest(raw: unknown): AcceptanceValidationResult {
+ if (
+ !isRecord(raw) ||
+ !keysMatch(raw, ["spaceId", "source", "verdicts", "decision"], ["note"])
+ ) {
+ return fail("acceptance_unknown_field", "create request has unexpected or missing fields");
+ }
+ const spaceId = identifier(raw.spaceId, "spaceId");
+ if (!spaceId.ok) return spaceId;
+ const source = validateSource(raw.source, "source");
+ if (!source.ok) return source;
+ const verdicts = validateVerdicts(raw.verdicts);
+ if (!verdicts.ok) return verdicts;
+ const decision = enumValue(raw.decision, acceptanceDecisions, "decision");
+ if (!decision.ok) return decision;
+
+ const parsedNote = parseNote(raw.note, "note");
+ if (!parsedNote.ok) return parsedNote;
+ const note = parsedNote.value;
+
+ const consistency = checkDecisionConsistency(decision.value, verdicts.value, note);
+ if (!consistency.ok) return fail(consistency.code, consistency.message);
+
+ return {
+ ok: true,
+ value: {
+ spaceId: spaceId.value,
+ source: source.value,
+ verdicts: verdicts.value,
+ decision: decision.value,
+ ...(note !== undefined ? { note } : {}),
+ },
+ };
+}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index c0c4f19e..64134633 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -26,6 +26,7 @@ export * from "./drive.js";
export * from "./context-sources.js";
export * from "./updates.js";
export * from "./goals.js";
+export * from "./acceptance.js";
export * from "./task-board.js";
export * from "./relationship-graph.js";
export * from "./avatar.js";
From c2693255751e0145ce742dc58bf68a76023510a4 Mon Sep 17 00:00:00 2001
From: huyz <739820174@qq.com>
Date: Thu, 1 Oct 2026 23:34:04 +0800
Subject: [PATCH 3/9] feat(space): document-driven collaboration space UI with
acceptance gate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Renders a collaboration space (协作区) as a folder in the workspace, where a
single Markdown file is the source of truth for both the document view and
the structure view. No second data model, no custom DSL.
Shared contract (space-doc.v1):
- parseSpaceDoc / renderSpaceDoc around two fixed H2 sections, 验收标准 and
任务. The parser is total: any string yields a result, missing sections
come back empty. The renderer rewrites only those two sections and keeps
every other byte of the document intact, so prose the parser does not
understand is never lost.
- spaceDocCriteriaCount for cheap progress reads without full parse.
Renderer:
- SpaceModule: file tree + document/structure view switch + participants and
owner + task board + acceptance gate.
- AcceptanceGate: per-criterion checkbox with evidence path; 验收通过 stays
disabled until every criterion is ticked; 打回 requires a note. Acceptance
writes done back into the document, so the verdict is durable in the same
file the agent reads.
- acceptance-gate-model holds the pure rules (untickedCriteria, canAccept,
canReject, buildVerdicts, buildAcceptanceRecord) so the gate is testable
without DOM.
- useSpaceFiles reads through window.owb.positionDocs / positionDocFile and
writes through writePositionDoc; when writing is unavailable the space
degrades to read-only instead of throwing.
- SpaceHost bridges the shell: derives participants from positionNames with
the owner pinned first.
Shell wiring:
- App.tsx adds "space" to the activeModule union, a rail item, and the render
branch. rail.space is the only hard registration point and is covered by
i18n.test.tsx.
- zh/en locales gain rail.space plus the space.* copy block.
Tests:
- apps/server/test/space-doc.test.ts (17): round-trip fidelity, fixed point,
CRLF, section isolation, heading alias, unknown status reporting.
- apps/desktop/renderer/test/space-acceptance-gate.test.ts (11).
- apps/desktop/renderer/test/space-module.test.tsx (5).
Refs #558
---
apps/desktop/renderer/src/App.tsx | 17 +-
.../renderer/src/space/AcceptanceGate.tsx | 134 +++++++
apps/desktop/renderer/src/space/SpaceHost.tsx | 58 +++
.../renderer/src/space/SpaceModule.tsx | 336 +++++++++++++++++
.../src/space/acceptance-gate-model.ts | 92 +++++
apps/desktop/renderer/src/space/index.ts | 16 +
.../desktop/renderer/src/space/space-gate.css | 63 ++++
apps/desktop/renderer/src/space/space.css | 293 ++++++++++++++
.../renderer/src/space/useSpaceFiles.ts | 89 +++++
.../test/space-acceptance-gate.test.ts | 116 ++++++
.../renderer/test/space-module.test.tsx | 124 ++++++
apps/server/test/space-doc.test.ts | 179 +++++++++
packages/shared/package.json | 4 +
packages/shared/src/index.ts | 1 +
packages/shared/src/space-doc.ts | 356 ++++++++++++++++++
packages/ui/src/locales/en.ts | 38 ++
packages/ui/src/locales/zh.ts | 38 ++
17 files changed, 1952 insertions(+), 2 deletions(-)
create mode 100644 apps/desktop/renderer/src/space/AcceptanceGate.tsx
create mode 100644 apps/desktop/renderer/src/space/SpaceHost.tsx
create mode 100644 apps/desktop/renderer/src/space/SpaceModule.tsx
create mode 100644 apps/desktop/renderer/src/space/acceptance-gate-model.ts
create mode 100644 apps/desktop/renderer/src/space/index.ts
create mode 100644 apps/desktop/renderer/src/space/space-gate.css
create mode 100644 apps/desktop/renderer/src/space/space.css
create mode 100644 apps/desktop/renderer/src/space/useSpaceFiles.ts
create mode 100644 apps/desktop/renderer/test/space-acceptance-gate.test.ts
create mode 100644 apps/desktop/renderer/test/space-module.test.tsx
create mode 100644 apps/server/test/space-doc.test.ts
create mode 100644 packages/shared/src/space-doc.ts
diff --git a/apps/desktop/renderer/src/App.tsx b/apps/desktop/renderer/src/App.tsx
index 2a018c23..2ff25e9d 100644
--- a/apps/desktop/renderer/src/App.tsx
+++ b/apps/desktop/renderer/src/App.tsx
@@ -34,7 +34,7 @@ import type {
WorkspaceCreateResponse,
WorkspaceInfoResponse,
} from "@roleweave/shared";
-import { ChevronsRight, FolderKanban, FolderOpen, Inbox, MessagesSquare, MoreHorizontal, Network, PanelLeft, PencilLine, Plus, Settings, Undo2, X } from "lucide-react";
+import { Boxes, ChevronsRight, FolderKanban, FolderOpen, Inbox, MessagesSquare, MoreHorizontal, Network, PanelLeft, PencilLine, Plus, Settings, Undo2, X } from "lucide-react";
import { useThemeMode, useThemeProfile } from "./theme-toggle";
import { useTheme, ThemeProvider } from "./theme-context";
import { themeToAntdSeed } from "./theme-resolution";
@@ -84,6 +84,7 @@ import { decodeEscapedUnicode } from "./display-text";
import { SettingsModule, type ConfigurationCategory } from "./settings/SettingsModule";
import { GoalsModule } from "./goals/GoalsModule";
import { ProjectManagementModule } from "./projects/ProjectManagementModule";
+import { SpaceHost } from "./space/SpaceHost.js";
import { ProjectSwitcher } from "./project/ProjectSwitcher";
import { ProjectWorkspaceDialog } from "./project/ProjectWorkspaceDialog";
import { assignDefaultAvatars, avatarSrcFor, readAvatarPreferences, type AvatarValue } from "./PositionAvatar";
@@ -135,7 +136,7 @@ function AppInner({
}) {
const themeContext = useTheme();
const [activeModule, setActiveModuleRaw] = useState<
- "conversation" | "profile" | "org" | "groups" | "reports" | "approvals" | "docs" | "goals" | "projects" | "settings" | "progress"
+ "conversation" | "profile" | "org" | "groups" | "reports" | "approvals" | "docs" | "goals" | "projects" | "settings" | "progress" | "space"
>("conversation");
const [settingsInitialCategory, setSettingsInitialCategory] = useState();
const [contactsOpen, setContactsOpen] = useState(false);
@@ -1971,6 +1972,7 @@ function AppInner({
{ id: "collaboration", label: t("rail.collaboration"), icon: , active: collaborationActive, onSelect: () => setActiveModule("conversation") },
{ id: "org", label: t("rail.org"), icon: , active: activeModule === "org", onSelect: () => setActiveModule("org") },
{ id: "projects", label: t("rail.projects"), icon: , active: projectsActive, onSelect: () => setActiveModule("projects") },
+ { id: "space", label: t("rail.space"), icon: , active: activeModule === "space", onSelect: () => setActiveModule("space") },
{ id: "inbox", label: t("rail.inbox"), icon: , active: inboxActive, onSelect: () => { setActiveModule("approvals"); void approvalState.refresh(); } },
]}
footer={<>
@@ -2209,6 +2211,17 @@ function AppInner({
setActiveModule("conversation");
}}
/>
+ ) : activeModule === "space" ? (
+ {
+ selectPosition(positionId);
+ setActiveModule("conversation");
+ }}
+ />
) : activeModule === "goals" ? (
) => void | Promise;
+ busy?: boolean;
+}
+
+/**
+ * The single place the human signs off. Accept stays disabled until every
+ * criterion is ticked, and reject demands a reason — the same invariants
+ * `acceptance.v1` enforces, surfaced before anything is submitted.
+ */
+export function AcceptanceGate({ draft, criteria, onDecide, busy = false }: AcceptanceGateProps) {
+ const t = useT();
+ const [ticks, setTicks] = useState({});
+ const [evidence, setEvidence] = useState({});
+ const [note, setNote] = useState("");
+ const [pendingDecision, setPendingDecision] = useState(null);
+
+ const remaining = useMemo(() => untickedCriteria(criteria, ticks), [criteria, ticks]);
+ const acceptReady = canAccept(criteria, ticks);
+ const rejectReady = canReject(note);
+
+ const submit = (decision: AcceptanceDecision) => {
+ const record = buildAcceptanceRecord(draft, criteria, ticks, decision, note, evidence);
+ setPendingDecision(null);
+ void onDecide(decision, record);
+ };
+
+ if (criteria.length === 0) {
+ return (
+
+
{t("space.gate.empty")}
+
+ );
+ }
+
+ return (
+
+
+
{t("space.gate.title")}
+
+ {t("space.gate.progress", { done: criteria.length - remaining.length, total: criteria.length })}
+
+
+
+
+
+ {remaining.length > 0 ? (
+
{t("space.gate.remaining", { count: remaining.length })}
+ ) : null}
+
+
setNote(event.target.value)}
+ />
+
+
+ {pendingDecision === null ? (
+ <>
+
}
+ disabled={!acceptReady || busy}
+ onClick={() => setPendingDecision("accepted")}
+ >
+ {t("space.gate.accept")}
+
+
}
+ disabled={!rejectReady || busy}
+ onClick={() => setPendingDecision("rejected")}
+ >
+ {t("space.gate.reject")}
+
+ >
+ ) : (
+ <>
+
+ {pendingDecision === "accepted" ? t("space.gate.confirmAccept") : t("space.gate.confirmReject")}
+
+
submit(pendingDecision)}>
+ {t("space.gate.confirm")}
+
+
setPendingDecision(null)}>{t("space.gate.cancel")}
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/desktop/renderer/src/space/SpaceHost.tsx b/apps/desktop/renderer/src/space/SpaceHost.tsx
new file mode 100644
index 00000000..13d51553
--- /dev/null
+++ b/apps/desktop/renderer/src/space/SpaceHost.tsx
@@ -0,0 +1,58 @@
+import { useMemo } from "react";
+import { useT } from "@roleweave/ui";
+import type { AcceptanceRecord } from "@roleweave/shared";
+import { SpaceModule, type SpaceAgent } from "./SpaceModule.js";
+import { useSpaceFiles } from "./useSpaceFiles.js";
+
+export interface SpaceHostProps {
+ workspaceOpen: boolean;
+ /** The position whose package directory backs the current space, if any. */
+ spacePositionId: string | null;
+ positionNames: Record;
+ ownerPositionId?: string;
+ onOpenBoundSession?: (positionId: string) => void;
+ onAccept?: (record: AcceptanceRecord) => void | Promise;
+}
+
+/**
+ * Bridges the space module to the shell: resolves the current space's files
+ * through the existing position-docs bridge and derives the participant list
+ * from the org, so the module itself stays a pure view.
+ */
+export function SpaceHost({
+ workspaceOpen,
+ spacePositionId,
+ positionNames,
+ ownerPositionId,
+ onOpenBoundSession,
+ onAccept,
+}: SpaceHostProps) {
+ const t = useT();
+ const state = useSpaceFiles(spacePositionId, workspaceOpen);
+
+ const participants = useMemo(() => {
+ const ids = Object.keys(positionNames);
+ // The owner always comes first and is marked; it is an org-level fact.
+ const ordered = ownerPositionId && ids.includes(ownerPositionId)
+ ? [ownerPositionId, ...ids.filter((id) => id !== ownerPositionId)]
+ : ids;
+ return ordered.map((positionId) => ({
+ positionId,
+ name: positionNames[positionId] ?? positionId,
+ owner: positionId === ownerPositionId,
+ }));
+ }, [positionNames, ownerPositionId]);
+
+ return (
+
+ );
+}
diff --git a/apps/desktop/renderer/src/space/SpaceModule.tsx b/apps/desktop/renderer/src/space/SpaceModule.tsx
new file mode 100644
index 00000000..398a6646
--- /dev/null
+++ b/apps/desktop/renderer/src/space/SpaceModule.tsx
@@ -0,0 +1,336 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { Empty, Spin, Tag } from "antd";
+import { FileText, Folder, GitBranch, LayoutList, Users } from "lucide-react";
+import { useT } from "@roleweave/ui";
+import {
+ parseSpaceDoc,
+ renderSpaceDoc,
+ type AcceptanceDecision,
+ type AcceptanceRecord,
+ type DocsFileEntry,
+ type SpaceDoc,
+ type SpaceDocCriterion,
+ type SpaceDocTask,
+} from "@roleweave/shared";
+import { Markdown } from "../markdown/Markdown.js";
+import { AcceptanceGate } from "./AcceptanceGate.js";
+import { buildAcceptanceRecord, type GateDraft } from "./acceptance-gate-model.js";
+import "./space.css";
+
+/** The spine file of a space. Every other file in the folder is supporting. */
+export const SPACE_SPINE_FILE = "目标.md";
+
+export interface SpaceAgent {
+ positionId: string;
+ name: string;
+ /** Exactly one participant is the owner; it comes from the org, not a space field. */
+ owner: boolean;
+}
+
+export interface SpaceModuleProps {
+ workspaceOpen: boolean;
+ /** Name of the space (the folder). */
+ spaceName?: string;
+ files: DocsFileEntry[];
+ /** Injected loader, mirroring DocsPanel's contract so this stays testable. */
+ readFile: (path: string) => Promise<{ content: string; version: string }>;
+ writeFile?: (path: string, content: string, expectedVersion: string) => Promise<{ version: string }>;
+ participants?: SpaceAgent[];
+ onOpenBoundSession?: (positionId: string) => void;
+ onAccept?: (record: AcceptanceRecord) => void | Promise;
+ /** Which file the space is currently focused on; defaults to the spine. */
+ requestedPath?: string | null;
+}
+
+type SpaceView = "document" | "structure";
+
+function statusTone(status: SpaceDocTask["status"]): string {
+ switch (status) {
+ case "done":
+ return "success";
+ case "in_progress":
+ return "processing";
+ case "blocked":
+ return "error";
+ case "review":
+ return "warning";
+ default:
+ return "default";
+ }
+}
+
+export function SpaceModule(props: SpaceModuleProps) {
+ const t = useT();
+ const [view, setView] = useState("document");
+ const [activePath, setActivePath] = useState(props.requestedPath ?? SPACE_SPINE_FILE);
+ const [source, setSource] = useState("");
+ const [version, setVersion] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [decided, setDecided] = useState(null);
+
+ const load = useCallback(
+ async (path: string) => {
+ setLoading(true);
+ setError(null);
+ try {
+ const file = await props.readFile(path);
+ setSource(file.content);
+ setVersion(file.version);
+ } catch {
+ setError(t("space.error.read", { path }));
+ setSource("");
+ } finally {
+ setLoading(false);
+ }
+ },
+ [props, t],
+ );
+
+ useEffect(() => {
+ if (!props.workspaceOpen) return;
+ void load(activePath);
+ }, [props.workspaceOpen, activePath, load]);
+
+ const parsed = useMemo(() => parseSpaceDoc(source), [source]);
+ const doc = parsed.doc;
+ const isSpine = activePath === SPACE_SPINE_FILE;
+
+ const owner = props.participants?.find((agent) => agent.owner);
+ const others = props.participants?.filter((agent) => !agent.owner) ?? [];
+
+ const gateDraft: GateDraft = useMemo(
+ () => ({
+ spaceId: props.spaceName ?? "space",
+ positionId: owner?.positionId ?? "operator",
+ artifactPath: activePath,
+ decidedBy: "operator",
+ }),
+ [props.spaceName, owner?.positionId, activePath],
+ );
+
+ const handleDecide = useCallback(
+ async (decision: AcceptanceDecision, record: AcceptanceRecord) => {
+ setDecided(record);
+ await props.onAccept?.(record);
+ if (decision === "accepted" && props.writeFile && isSpine) {
+ // Acceptance is the only thing allowed to mark criteria done.
+ const next: SpaceDoc = {
+ ...doc,
+ criteria: doc.criteria.map((criterion) => ({ ...criterion, done: true })),
+ };
+ const content = renderSpaceDoc(next, source);
+ try {
+ const result = await props.writeFile(activePath, content, version);
+ setSource(content);
+ setVersion(result.version);
+ } catch {
+ setError(t("space.error.write", { path: activePath }));
+ }
+ }
+ },
+ [props, doc, source, version, activePath, isSpine, t],
+ );
+
+ if (!props.workspaceOpen) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
{props.spaceName ?? t("space.untitled")}
+
+ {t("space.rootHint")}
+
+
+
+ setView("document")}
+ >
+
+ {t("space.view.document")}
+
+ setView("structure")}
+ >
+
+ {t("space.view.structure")}
+
+
+
+
+
+
+
+
+ {loading ? (
+
+
+
+ ) : error ? (
+ {error}
+ ) : view === "document" ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+ {isSpine && !loading ? (
+
+ ) : null}
+
+ );
+}
+
+interface SpaceStructureProps {
+ doc: SpaceDoc;
+ decided: AcceptanceRecord | null;
+}
+
+/** The structural rendering of the same file — never a second copy of it. */
+function SpaceStructure({ doc, decided }: SpaceStructureProps) {
+ const t = useT();
+ const columns: Array<{ id: SpaceDocTask["status"]; label: string }> = [
+ { id: "todo", label: t("space.status.todo") },
+ { id: "in_progress", label: t("space.status.in_progress") },
+ { id: "blocked", label: t("space.status.blocked") },
+ { id: "review", label: t("space.status.review") },
+ { id: "done", label: t("space.status.done") },
+ ];
+
+ return (
+
+
+ {t("space.structure.criteria")}
+ {doc.criteria.length === 0 ? (
+ {t("space.structure.noCriteria")}
+ ) : (
+
+ {doc.criteria.map((criterion: SpaceDocCriterion) => (
+
+
+ {criterion.done ? "✓" : ""}
+
+ {criterion.text}
+
+ ))}
+
+ )}
+
+
+
+ {t("space.structure.board")}
+ {doc.tasksUnparseable ? (
+ {t("space.structure.unparseable")}
+ ) : null}
+
+ {columns.map((column) => {
+ const tasks = doc.tasks.filter((task) => task.status === column.id);
+ return (
+
+
+ {column.label}
+ {tasks.length}
+
+
+
+ );
+ })}
+
+
+
+ {decided ? (
+
+
+
+ {t("space.structure.lastDecision")}
+
+
+ {decided.decision === "accepted" ? t("space.decision.accepted") : t("space.decision.rejected")}
+ {" · "}
+ {decided.decidedAt}
+
+ {decided.note ? {decided.note}
: null}
+
+ ) : null}
+
+ );
+}
+
+export { buildAcceptanceRecord };
diff --git a/apps/desktop/renderer/src/space/acceptance-gate-model.ts b/apps/desktop/renderer/src/space/acceptance-gate-model.ts
new file mode 100644
index 00000000..a94db6bf
--- /dev/null
+++ b/apps/desktop/renderer/src/space/acceptance-gate-model.ts
@@ -0,0 +1,92 @@
+import {
+ ACCEPTANCE_SCHEMA_VERSION,
+ type AcceptanceDecision,
+ type AcceptanceRecord,
+ type AcceptanceVerdict,
+} from "@roleweave/shared";
+import type { SpaceDocCriterion } from "@roleweave/shared";
+
+/**
+ * Build the acceptance payload for a space gate.
+ *
+ * The gate is the one place where the UI must not be able to produce an
+ * invalid record: an `accepted` decision is only legal when every criterion
+ * is ticked. Rather than let the component decide that, it funnels through
+ * here so the rule is testable on its own.
+ */
+export interface GateDraft {
+ spaceId: string;
+ positionId: string;
+ turnId?: string;
+ artifactPath?: string;
+ decidedBy: string;
+}
+
+export interface GateTicks {
+ /** criteriaIndex → ticked. Absent means unticked. */
+ [criteriaIndex: number]: boolean;
+}
+
+export interface GateEvidence {
+ /** criteriaIndex → artifact path shown as evidence. */
+ [criteriaIndex: number]: string | undefined;
+}
+
+export function untickedCriteria(criteria: SpaceDocCriterion[], ticks: GateTicks): number[] {
+ return criteria.filter((criterion) => ticks[criterion.criteriaIndex] !== true).map((criterion) => criterion.criteriaIndex);
+}
+
+/** The accept button is enabled only when this is true. */
+export function canAccept(criteria: SpaceDocCriterion[], ticks: GateTicks): boolean {
+ return criteria.length > 0 && untickedCriteria(criteria, ticks).length === 0;
+}
+
+/** A rejection is only meaningful with a reason; the field is not optional here. */
+export function canReject(note: string): boolean {
+ return note.trim().length > 0;
+}
+
+export function buildVerdicts(criteria: SpaceDocCriterion[], ticks: GateTicks, evidence: GateEvidence = {}): AcceptanceVerdict[] {
+ return criteria.map((criterion) => {
+ const verdict: AcceptanceVerdict = {
+ criteriaIndex: criterion.criteriaIndex,
+ passed: ticks[criterion.criteriaIndex] === true,
+ };
+ const path = evidence[criterion.criteriaIndex];
+ if (path !== undefined && path.trim().length > 0) verdict.evidencePath = path;
+ return verdict;
+ });
+}
+
+export function buildAcceptanceRecord(
+ draft: GateDraft,
+ criteria: SpaceDocCriterion[],
+ ticks: GateTicks,
+ decision: AcceptanceDecision,
+ note: string,
+ evidence: GateEvidence = {},
+ now: () => Date = () => new Date(),
+): AcceptanceRecord {
+ if (decision === "accepted" && !canAccept(criteria, ticks)) {
+ throw new Error("cannot accept while criteria remain unticked");
+ }
+ if (decision === "rejected" && !canReject(note)) {
+ throw new Error("cannot reject without a note");
+ }
+
+ const record: AcceptanceRecord = {
+ schemaVersion: ACCEPTANCE_SCHEMA_VERSION,
+ acceptanceId: `${draft.spaceId}:${now().toISOString()}`,
+ spaceId: draft.spaceId,
+ source: { positionId: draft.positionId },
+ verdicts: buildVerdicts(criteria, ticks, evidence),
+ decision,
+ decidedBy: draft.decidedBy,
+ decidedAt: now().toISOString(),
+ };
+ if (draft.turnId !== undefined) record.source.turnId = draft.turnId;
+ if (draft.artifactPath !== undefined) record.source.artifactPath = draft.artifactPath;
+ if (decision === "rejected") record.note = note.trim();
+
+ return record;
+}
diff --git a/apps/desktop/renderer/src/space/index.ts b/apps/desktop/renderer/src/space/index.ts
new file mode 100644
index 00000000..4dcaac18
--- /dev/null
+++ b/apps/desktop/renderer/src/space/index.ts
@@ -0,0 +1,16 @@
+export { SpaceModule, SPACE_SPINE_FILE } from "./SpaceModule.js";
+export type { SpaceModuleProps, SpaceAgent } from "./SpaceModule.js";
+export { SpaceHost } from "./SpaceHost.js";
+export type { SpaceHostProps } from "./SpaceHost.js";
+export { useSpaceFiles } from "./useSpaceFiles.js";
+export type { SpaceFilesState } from "./useSpaceFiles.js";
+export { AcceptanceGate } from "./AcceptanceGate.js";
+export type { AcceptanceGateProps } from "./AcceptanceGate.js";
+export {
+ buildAcceptanceRecord,
+ buildVerdicts,
+ canAccept,
+ canReject,
+ untickedCriteria,
+} from "./acceptance-gate-model.js";
+export type { GateDraft, GateEvidence, GateTicks } from "./acceptance-gate-model.js";
diff --git a/apps/desktop/renderer/src/space/space-gate.css b/apps/desktop/renderer/src/space/space-gate.css
new file mode 100644
index 00000000..942cd38d
--- /dev/null
+++ b/apps/desktop/renderer/src/space/space-gate.css
@@ -0,0 +1,63 @@
+.owb-app .owb-space-gate {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ max-width: 780px;
+}
+
+.owb-app .owb-space-gate__empty {
+ margin: 0;
+ color: var(--ui-foreground-muted);
+ font-size: 12px;
+}
+
+.owb-app .owb-space-gate__head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.owb-app .owb-space-gate__title {
+ margin: 0;
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.owb-app .owb-space-gate__progress,
+.owb-app .owb-space-gate__hint,
+.owb-app .owb-space-gate__confirm {
+ color: var(--ui-foreground-muted);
+ font-size: 12px;
+}
+
+.owb-app .owb-space-gate__list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.owb-app .owb-space-gate__item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.owb-app .owb-space-gate__evidence {
+ flex: 1;
+ min-width: 0;
+ max-width: 260px;
+}
+
+.owb-app .owb-space-gate__note {
+ max-width: 520px;
+}
+
+.owb-app .owb-space-gate__actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
diff --git a/apps/desktop/renderer/src/space/space.css b/apps/desktop/renderer/src/space/space.css
new file mode 100644
index 00000000..4e9de588
--- /dev/null
+++ b/apps/desktop/renderer/src/space/space.css
@@ -0,0 +1,293 @@
+.owb-app .owb-space-module {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ height: 100%;
+ background: var(--ui-surface);
+ color: var(--ui-foreground);
+}
+
+.owb-app .owb-space-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 12px 20px;
+ border-bottom: 1px solid var(--ui-border);
+}
+
+.owb-app .owb-space-header__title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.owb-app .owb-space-header__title h2 {
+ margin: 0;
+ font-size: 15px;
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.owb-app .owb-space-header__view {
+ display: flex;
+ gap: 4px;
+ padding: 2px;
+ border-radius: 8px;
+ background: var(--ui-surface-sunken, rgba(127, 127, 127, 0.12));
+}
+
+.owb-app .owb-space-header__view button {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--ui-foreground-muted);
+ font-size: 12px;
+ cursor: pointer;
+}
+
+.owb-app .owb-space-header__view button.is-active {
+ background: var(--ui-surface);
+ color: var(--ui-foreground);
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
+}
+
+.owb-app .owb-space-body {
+ display: grid;
+ grid-template-columns: 232px minmax(0, 1fr);
+ flex: 1;
+ min-height: 0;
+}
+
+.owb-app .owb-space-files {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 12px 8px;
+ border-inline-end: 1px solid var(--ui-border);
+ overflow-y: auto;
+}
+
+.owb-app .owb-space-files__head {
+ padding: 8px 8px 4px;
+ color: var(--ui-foreground-muted);
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.owb-app .owb-space-files__list,
+.owb-app .owb-space-agents {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.owb-app .owb-space-files__list button,
+.owb-app .owb-space-agents__item button {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ width: 100%;
+ padding: 5px 8px;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--ui-foreground);
+ font-size: 12px;
+ text-align: start;
+ cursor: pointer;
+}
+
+.owb-app .owb-space-files__list button:hover,
+.owb-app .owb-space-agents__item button:hover {
+ background: var(--ui-surface-hover, rgba(127, 127, 127, 0.12));
+}
+
+.owb-app .owb-space-files__list button.is-active {
+ background: var(--ui-primary-soft, rgba(134, 160, 255, 0.16));
+ color: var(--ui-primary);
+}
+
+.owb-app .owb-space-files__name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.owb-app .owb-space-agents__item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 8px;
+ font-size: 12px;
+}
+
+.owb-app .owb-space-agents__item--owner {
+ color: var(--ui-foreground);
+}
+
+.owb-app .owb-space-content {
+ min-width: 0;
+ overflow-y: auto;
+ padding: 20px 28px;
+}
+
+.owb-app .owb-space-content__loading,
+.owb-app .owb-space-content__error {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 160px;
+ color: var(--ui-foreground-muted);
+ font-size: 13px;
+}
+
+.owb-app .owb-space-content__error {
+ color: var(--ui-danger, #e5484d);
+}
+
+.owb-app .owb-space-doc {
+ max-width: 780px;
+}
+
+.owb-app .owb-space-structure {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.owb-app .owb-space-structure__section h3 {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: 0 0 10px;
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.owb-app .owb-space-structure__hint,
+.owb-app .owb-space-structure__warn {
+ margin: 0;
+ color: var(--ui-foreground-muted);
+ font-size: 12px;
+}
+
+.owb-app .owb-space-structure__warn {
+ color: var(--ui-warning, #f5a524);
+}
+
+.owb-app .owb-space-criteria-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.owb-app .owb-space-criteria-list li {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ font-size: 13px;
+}
+
+.owb-app .owb-space-criteria-list__box {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ margin-top: 1px;
+ border: 1px solid var(--ui-border);
+ border-radius: 4px;
+ color: var(--ui-primary);
+ font-size: 11px;
+}
+
+.owb-app .owb-space-criteria-list li[data-done="true"] {
+ color: var(--ui-foreground-muted);
+ text-decoration: line-through;
+}
+
+.owb-app .owb-space-board {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.owb-app .owb-space-board__column {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 10px;
+ border: 1px solid var(--ui-border);
+ border-radius: 10px;
+ background: var(--ui-surface-sunken, rgba(127, 127, 127, 0.06));
+}
+
+.owb-app .owb-space-board__column-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ color: var(--ui-foreground-muted);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.owb-app .owb-space-board__column ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.owb-app .owb-space-board__card {
+ padding: 8px;
+ border: 1px solid var(--ui-border);
+ border-radius: 8px;
+ background: var(--ui-surface);
+}
+
+.owb-app .owb-space-board__card-title {
+ font-size: 12px;
+ line-height: 1.4;
+}
+
+.owb-app .owb-space-board__card-meta {
+ margin-top: 4px;
+ color: var(--ui-foreground-muted);
+ font-size: 11px;
+}
+
+.owb-app .owb-space-gate-host {
+ border-top: 1px solid var(--ui-border);
+ padding: 12px 20px 16px;
+ background: var(--ui-surface-sunken, rgba(127, 127, 127, 0.05));
+}
+
+@media (max-width: 1100px) {
+ .owb-app .owb-space-body {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .owb-app .owb-space-files {
+ border-inline-end: 0;
+ border-bottom: 1px solid var(--ui-border);
+ }
+ .owb-app .owb-space-board {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
diff --git a/apps/desktop/renderer/src/space/useSpaceFiles.ts b/apps/desktop/renderer/src/space/useSpaceFiles.ts
new file mode 100644
index 00000000..69ba0150
--- /dev/null
+++ b/apps/desktop/renderer/src/space/useSpaceFiles.ts
@@ -0,0 +1,89 @@
+import { useCallback, useEffect, useState } from "react";
+import { useT } from "@roleweave/ui";
+import type { DocsFileEntry } from "@roleweave/shared";
+
+export interface SpaceFilesState {
+ files: DocsFileEntry[];
+ loading: boolean;
+ error: string | null;
+ reload: () => void;
+ readFile: (path: string) => Promise<{ content: string; version: string }>;
+ writeFile: ((path: string, content: string, expectedVersion: string) => Promise<{ version: string }>) | undefined;
+}
+
+/**
+ * Loads the files of one space folder.
+ *
+ * A space is identified by the position whose package directory holds it, so
+ * the existing position-docs IPC is reused unchanged. Writes are optional so
+ * the module degrades to read-only when the bridge lacks them.
+ */
+export function useSpaceFiles(positionId: string | null, workspaceOpen: boolean): SpaceFilesState {
+ const t = useT();
+ const [files, setFiles] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [reloadToken, setReloadToken] = useState(0);
+
+ useEffect(() => {
+ if (!workspaceOpen || !positionId) {
+ setFiles([]);
+ return;
+ }
+ let alive = true;
+ setLoading(true);
+ setError(null);
+ void window.owb
+ .positionDocs(positionId)
+ .then((res) => {
+ if (!alive) return;
+ if (res.status >= 400 || !res.body) {
+ setError(t("space.error.read", { path: positionId }));
+ setFiles([]);
+ return;
+ }
+ setFiles(res.body.files);
+ })
+ .catch(() => {
+ if (alive) setError(t("space.error.read", { path: positionId }));
+ })
+ .finally(() => {
+ if (alive) setLoading(false);
+ });
+ return () => {
+ alive = false;
+ };
+ }, [positionId, workspaceOpen, reloadToken, t]);
+
+ const readFile = useCallback(
+ async (path: string): Promise<{ content: string; version: string }> => {
+ if (!positionId) throw new Error(t("space.error.read", { path }));
+ const res = await window.owb.positionDocFile(positionId, path);
+ if (res.status >= 400 || !res.body) throw new Error(t("space.error.read", { path }));
+ return { content: res.body.content, version: res.body.version };
+ },
+ [positionId, t],
+ );
+
+ const writeFile = useCallback(
+ async (path: string, content: string, expectedVersion: string): Promise<{ version: string }> => {
+ const write = window.owb.writePositionDoc;
+ if (!positionId || !write) throw new Error(t("space.error.write", { path }));
+ const res = await write({ positionId, path, content, expectedVersion });
+ if (res.status >= 400 || !res.body) throw new Error(t("space.error.write", { path }));
+ return { version: res.body.version };
+ },
+ [positionId, t],
+ );
+
+ const reload = useCallback(() => setReloadToken((token) => token + 1), []);
+
+ return {
+ files,
+ loading,
+ error,
+ reload,
+ readFile,
+ writeFile: window.owb.writePositionDoc ? writeFile : undefined,
+ };
+}
diff --git a/apps/desktop/renderer/test/space-acceptance-gate.test.ts b/apps/desktop/renderer/test/space-acceptance-gate.test.ts
new file mode 100644
index 00000000..9973b6ac
--- /dev/null
+++ b/apps/desktop/renderer/test/space-acceptance-gate.test.ts
@@ -0,0 +1,116 @@
+import { describe, expect, it } from "vitest";
+import type { SpaceDocCriterion } from "@roleweave/shared";
+import {
+ buildAcceptanceRecord,
+ buildVerdicts,
+ canAccept,
+ canReject,
+ untickedCriteria,
+ type GateDraft,
+} from "../src/space/acceptance-gate-model.js";
+
+const criteria: SpaceDocCriterion[] = [
+ { criteriaIndex: 0, text: "差异可归因", done: false, marker: " " },
+ { criteriaIndex: 1, text: "口径已评审", done: false, marker: " " },
+ { criteriaIndex: 2, text: "回滚有演练", done: false, marker: " " },
+];
+
+const draft: GateDraft = {
+ spaceId: "space-billing",
+ positionId: "analyst",
+ decidedBy: "operator",
+};
+
+const fixedNow = () => new Date("2026-10-01T12:00:00.000Z");
+
+describe("gate readiness", () => {
+ it("blocks accept until every criterion is ticked", () => {
+ expect(canAccept(criteria, {})).toBe(false);
+ expect(canAccept(criteria, { 0: true, 1: true })).toBe(false);
+ expect(canAccept(criteria, { 0: true, 1: true, 2: true })).toBe(true);
+ });
+
+ it("never accepts an empty criteria list", () => {
+ expect(canAccept([], {})).toBe(false);
+ });
+
+ it("lists exactly the unticked indices", () => {
+ expect(untickedCriteria(criteria, { 1: true })).toEqual([0, 2]);
+ expect(untickedCriteria(criteria, { 0: true, 1: true, 2: true })).toEqual([]);
+ });
+
+ it("requires a non-blank reason to reject", () => {
+ expect(canReject("")).toBe(false);
+ expect(canReject(" ")).toBe(false);
+ expect(canReject("no evidence")).toBe(true);
+ });
+});
+
+describe("verdict construction", () => {
+ it("emits one verdict per criterion, in order", () => {
+ const verdicts = buildVerdicts(criteria, { 0: true, 2: true });
+ expect(verdicts).toEqual([
+ { criteriaIndex: 0, passed: true },
+ { criteriaIndex: 1, passed: false },
+ { criteriaIndex: 2, passed: true },
+ ]);
+ });
+
+ it("attaches evidence only when a path is non-blank", () => {
+ const verdicts = buildVerdicts(criteria, { 0: true }, { 0: "产出/a.md", 1: " " });
+ expect(verdicts[0].evidencePath).toBe("产出/a.md");
+ expect(verdicts[1].evidencePath).toBeUndefined();
+ });
+});
+
+describe("record construction", () => {
+ it("builds an accepted record that satisfies acceptance.v1", () => {
+ const record = buildAcceptanceRecord(
+ draft,
+ criteria,
+ { 0: true, 1: true, 2: true },
+ "accepted",
+ "",
+ {},
+ fixedNow,
+ );
+ expect(record.schemaVersion).toBe("acceptance.v1");
+ expect(record.decision).toBe("accepted");
+ expect(record.verdicts).toHaveLength(3);
+ expect(record.verdicts.every((verdict) => verdict.passed)).toBe(true);
+ expect(record.note).toBeUndefined();
+ expect(record.decidedAt).toBe("2026-10-01T12:00:00.000Z");
+ });
+
+ it("refuses to build an accepted record with a gap", () => {
+ expect(() =>
+ buildAcceptanceRecord(draft, criteria, { 0: true }, "accepted", "", {}, fixedNow),
+ ).toThrow(/unticked/);
+ });
+
+ it("refuses to build a rejected record with no reason", () => {
+ expect(() =>
+ buildAcceptanceRecord(draft, criteria, {}, "rejected", " ", {}, fixedNow),
+ ).toThrow(/note/);
+ });
+
+ it("keeps the rejection reason, trimmed", () => {
+ const record = buildAcceptanceRecord(draft, criteria, {}, "rejected", " needs evidence ", {}, fixedNow);
+ expect(record.decision).toBe("rejected");
+ expect(record.note).toBe("needs evidence");
+ });
+
+ it("carries turn and artifact provenance when provided", () => {
+ const record = buildAcceptanceRecord(
+ { ...draft, turnId: "turn-9", artifactPath: "目标.md" },
+ criteria,
+ { 0: true, 1: true, 2: true },
+ "accepted",
+ "",
+ {},
+ fixedNow,
+ );
+ expect(record.source.turnId).toBe("turn-9");
+ expect(record.source.artifactPath).toBe("目标.md");
+ });
+});
diff --git a/apps/desktop/renderer/test/space-module.test.tsx b/apps/desktop/renderer/test/space-module.test.tsx
new file mode 100644
index 00000000..93c3a559
--- /dev/null
+++ b/apps/desktop/renderer/test/space-module.test.tsx
@@ -0,0 +1,124 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { SpaceModule, SPACE_SPINE_FILE } from "../src/space/SpaceModule.js";
+import type { DocsFileEntry } from "@roleweave/shared";
+
+const SPINE = `# 目标:对账自动化
+
+先说结论。
+
+## 验收标准
+- [ ] 差异可归因
+- [ ] 口径已评审
+
+## 任务
+| 任务 | 状态 | 负责岗位 | 截止 |
+| --- | --- | --- | --- |
+| 写脚本 | in_progress | engineer | 2026-10-05 |
+| 梳理分类 | done | analyst | 2026-09-20 |
+`;
+
+const files: DocsFileEntry[] = [
+ { path: SPACE_SPINE_FILE, kind: "file", size: SPINE.length, modifiedAt: "2026-10-01T00:00:00.000Z" },
+ { path: "笔记.md", kind: "file", size: 10, modifiedAt: "2026-10-01T00:00:00.000Z" },
+];
+
+function setup(overrides: Partial[0]> = {}) {
+ const readFile = vi.fn(async (path: string) => ({ content: path === SPACE_SPINE_FILE ? SPINE : "笔记", version: "v1" }));
+ const writeFile = vi.fn(async () => ({ version: "v2" }));
+ const onAccept = vi.fn();
+ const utils = render(
+ ,
+ );
+ return { ...utils, readFile, writeFile, onAccept };
+}
+
+describe("SpaceModule", () => {
+ it("loads the spine file by default", async () => {
+ const { readFile } = setup();
+ await waitFor(() => expect(readFile).toHaveBeenCalledWith(SPACE_SPINE_FILE));
+ await screen.findByText(/先说结论/);
+ });
+
+ it("marks exactly one participant as owner", async () => {
+ setup();
+ await screen.findByText("修雨");
+ const ownerRow = document.querySelector(".owb-space-agents__item--owner");
+ expect(ownerRow?.textContent).toContain("修雨");
+ expect(document.querySelectorAll(".owb-space-agents__item--owner")).toHaveLength(1);
+ });
+
+ it("switches to the structure view and renders the board columns", async () => {
+ setup();
+ await screen.findByText(/先说结论/);
+ fireEvent.click(screen.getByRole("button", { name: /结构/ }));
+ await waitFor(() => expect(document.querySelector(".owb-space-structure")).toBeTruthy());
+ expect(screen.getByText("写脚本")).toBeTruthy();
+ expect(screen.getByText("梳理分类")).toBeTruthy();
+ });
+
+ it("disables accept until every criterion is ticked", async () => {
+ const { onAccept } = setup();
+ await waitFor(() => expect(document.querySelector(".owb-space-gate")).toBeTruthy());
+ const gate = document.querySelector(".owb-space-gate") as HTMLElement;
+
+ const acceptNow = () =>
+ Array.from(gate.querySelectorAll("button")).find((b) => b.textContent?.includes("验收通过")) as HTMLButtonElement;
+ // antd inserts a space between two CJK characters, so match on the raw text
+ // with whitespace stripped rather than an exact string.
+ const confirmNow = () =>
+ Array.from(gate.querySelectorAll("button")).find(
+ (b) => b.textContent?.replace(/\s/g, "") === "确认",
+ ) as HTMLButtonElement | undefined;
+
+ expect(acceptNow().disabled).toBe(true);
+
+ const boxes = Array.from(gate.querySelectorAll('input[type="checkbox"]')) as HTMLInputElement[];
+ expect(boxes).toHaveLength(2);
+ for (const box of boxes) fireEvent.click(box);
+
+ await waitFor(() => expect(acceptNow().disabled).toBe(false));
+
+ fireEvent.click(acceptNow());
+ await waitFor(() => expect(confirmNow()).toBeTruthy());
+ fireEvent.click(confirmNow() as HTMLButtonElement);
+ await waitFor(() => expect(onAccept).toHaveBeenCalledTimes(1));
+ });
+
+ it("writes criteria back as done on acceptance", async () => {
+ const { writeFile } = setup();
+ await waitFor(() => expect(document.querySelector(".owb-space-gate")).toBeTruthy());
+ const gate = document.querySelector(".owb-space-gate") as HTMLElement;
+ const boxes = Array.from(gate.querySelectorAll('input[type="checkbox"]')) as HTMLInputElement[];
+ for (const box of boxes) fireEvent.click(box);
+ fireEvent.click(
+ Array.from(gate.querySelectorAll("button")).find((b) => b.textContent?.includes("验收通过")) as HTMLButtonElement,
+ );
+ await waitFor(() =>
+ expect(
+ Array.from(gate.querySelectorAll("button")).some((b) => b.textContent?.replace(/\s/g, "") === "确认"),
+ ).toBe(true),
+ );
+ fireEvent.click(
+ Array.from(gate.querySelectorAll("button")).find(
+ (b) => b.textContent?.replace(/\s/g, "") === "确认",
+ ) as HTMLButtonElement,
+ );
+ await waitFor(() => expect(writeFile).toHaveBeenCalled());
+ const [, content] = writeFile.mock.calls[0] as unknown as [string, string, string];
+ expect(content).toContain("- [x] 差异可归因");
+ expect(content).toContain("- [x] 口径已评审");
+ });
+});
diff --git a/apps/server/test/space-doc.test.ts b/apps/server/test/space-doc.test.ts
new file mode 100644
index 00000000..b3df6533
--- /dev/null
+++ b/apps/server/test/space-doc.test.ts
@@ -0,0 +1,179 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ SPACE_DOC_SCHEMA_VERSION,
+ parseSpaceDoc,
+ renderSpaceDoc,
+} from "@roleweave/shared";
+
+const DOC = `# 目标:把结算对账自动化
+
+先把口径对齐,再看边界。
+
+## 背景
+
+这块现在靠人工,每月两个人力日。
+
+## 验收标准
+- [ ] 对账差异能被自动归因
+- [x] 口径文档已评审
+- [ ] 回滚路径有演练记录
+
+## 任务
+| 任务 | 状态 | 负责岗位 | 截止 |
+| --- | --- | --- | --- |
+| 梳理差异分类 | done | analyst | 2026-09-20 |
+| 写对账脚本 | in_progress | engineer | 2026-10-05 |
+| 补回滚演练 | todo | | |
+
+## 风险
+
+数据源有延迟。
+`;
+
+test("parses the title", () => {
+ const { doc } = parseSpaceDoc(DOC);
+ assert.equal(doc.title, "把结算对账自动化");
+ assert.equal(doc.schemaVersion, SPACE_DOC_SCHEMA_VERSION);
+});
+
+test("parses criteria with their checkbox state and stable index", () => {
+ const { doc } = parseSpaceDoc(DOC);
+ assert.equal(doc.criteria.length, 3);
+ assert.deepEqual(doc.criteria.map((c) => c.criteriaIndex), [0, 1, 2]);
+ assert.equal(doc.criteria[0].text, "对账差异能被自动归因");
+ assert.equal(doc.criteria[0].done, false);
+ assert.equal(doc.criteria[1].done, true);
+ assert.equal(doc.criteria[1].marker, "x");
+});
+
+test("parses the task table into typed rows", () => {
+ const { doc } = parseSpaceDoc(DOC);
+ assert.equal(doc.tasks.length, 3);
+ assert.deepEqual(doc.tasks[0], {
+ title: "梳理差异分类",
+ status: "done",
+ assigneePositionId: "analyst",
+ dueDate: "2026-09-20",
+ raw: ["梳理差异分类", "done", "analyst", "2026-09-20"],
+ });
+ assert.equal(doc.tasks[1].status, "in_progress");
+ assert.equal(doc.tasks[2].assigneePositionId, undefined);
+ assert.equal(doc.tasks[2].dueDate, undefined);
+});
+
+test("keeps sections the parser does not own, in document order", () => {
+ const { doc } = parseSpaceDoc(DOC);
+ assert.deepEqual(doc.otherSections.map((s) => s.heading), ["背景", "风险"]);
+ assert.equal(doc.otherSections[0].body.join("\n").trim(), "这块现在靠人工,每月两个人力日。");
+});
+
+test("keeps the intro prose before the first H2", () => {
+ const { doc } = parseSpaceDoc(DOC);
+ assert.equal(doc.intro, "先把口径对齐,再看边界。");
+});
+
+test("a document without the structured sections is still total", () => {
+ const { doc, issues } = parseSpaceDoc("# 只有标题\n\n随便写点东西。\n");
+ assert.equal(doc.title, "只有标题");
+ assert.deepEqual(doc.criteria, []);
+ assert.deepEqual(doc.tasks, []);
+ assert.equal(doc.tasksUnparseable, false);
+ assert.deepEqual(issues, []);
+ assert.equal(doc.intro, "随便写点东西。");
+});
+
+test("an empty string parses without throwing", () => {
+ const { doc } = parseSpaceDoc("");
+ assert.equal(doc.title, "");
+ assert.deepEqual(doc.criteria, []);
+ assert.deepEqual(doc.tasks, []);
+});
+
+test("flags a task table that yields no rows", () => {
+ const { doc } = parseSpaceDoc("# 目标:x\n\n## 任务\n| 任务 | 状态 |\n| --- | --- |\n");
+ assert.equal(doc.tasks.length, 0);
+ assert.equal(doc.tasksUnparseable, true);
+});
+
+test("reports an unknown status instead of guessing", () => {
+ const source = "# 目标:x\n\n## 任务\n| 任务 | 状态 |\n| --- | --- |\n| a | 做完了 |\n";
+ const { doc, issues } = parseSpaceDoc(source);
+ assert.equal(doc.tasks[0].status, "todo");
+ assert.equal(issues.length, 1);
+ assert.match(issues[0].message, /unknown task status/);
+});
+
+test("ignores a non-position assignee and a non-date due, with an issue", () => {
+ const source =
+ "# 目标:x\n\n## 任务\n| 任务 | 状态 | 负责岗位 | 截止 |\n| --- | --- | --- | --- |\n| a | todo | Alice Smith | 明天 |\n";
+ const { doc, issues } = parseSpaceDoc(source);
+ assert.equal(doc.tasks[0].assigneePositionId, undefined);
+ assert.equal(doc.tasks[0].dueDate, undefined);
+ assert.equal(issues.length, 2);
+});
+
+test("accepts header aliases", () => {
+ const source = "# 目标:x\n\n## 任务\n| Title | Status | Assignee | Due Date |\n| --- | --- | --- | --- |\n| a | blocked | engineer | 2026-11-01 |\n";
+ const { doc } = parseSpaceDoc(source);
+ assert.equal(doc.tasks[0].status, "blocked");
+ assert.equal(doc.tasks[0].assigneePositionId, "engineer");
+ assert.equal(doc.tasks[0].dueDate, "2026-11-01");
+});
+
+test("round-trip preserves prose and other sections byte for byte", () => {
+ const { doc } = parseSpaceDoc(DOC);
+ const rendered = renderSpaceDoc(doc, DOC);
+ const reparsed = parseSpaceDoc(rendered).doc;
+
+ assert.equal(reparsed.intro, doc.intro);
+ // startLine is source provenance, so it legitimately shifts after a rewrite;
+ // the content of sections the parser does not own must not change.
+ assert.deepEqual(
+ reparsed.otherSections.map((s) => ({ heading: s.heading, body: s.body })),
+ doc.otherSections.map((s) => ({ heading: s.heading, body: s.body })),
+ );
+ assert.deepEqual(reparsed.criteria, doc.criteria);
+ assert.deepEqual(reparsed.tasks, doc.tasks);
+ assert.ok(rendered.includes("这块现在靠人工,每月两个人力日。"));
+ assert.ok(rendered.includes("数据源有延迟。"));
+});
+
+test("round-trip on an already-canonical document is a fixed point", () => {
+ const { doc } = parseSpaceDoc(DOC);
+ const once = renderSpaceDoc(doc, DOC);
+ const twice = renderSpaceDoc(parseSpaceDoc(once).doc, once);
+ assert.equal(once, twice);
+});
+
+test("toggling a criterion rewrites only its line", () => {
+ const { doc } = parseSpaceDoc(DOC);
+ doc.criteria[0] = { ...doc.criteria[0], done: true };
+ const rendered = renderSpaceDoc(doc, DOC);
+ assert.ok(rendered.includes("- [x] 对账差异能被自动归因"));
+ assert.ok(rendered.includes("- [x] 口径文档已评审"));
+ assert.ok(rendered.includes("- [ ] 回滚路径有演练记录"));
+});
+
+test("renders a task row without assignee or due as empty cells", () => {
+ const { doc } = parseSpaceDoc(DOC);
+ const rendered = renderSpaceDoc(doc, DOC);
+ assert.ok(rendered.includes("| 补回滚演练 | todo | | |"));
+});
+
+test("preserves CRLF line endings", () => {
+ const crlf = DOC.replace(/\n/g, "\r\n");
+ const { doc } = parseSpaceDoc(crlf);
+ const rendered = renderSpaceDoc(doc, crlf);
+ assert.ok(rendered.includes("\r\n"));
+ assert.ok(!rendered.replace(/\r\n/g, "").includes("\n"));
+});
+
+test("criteria before a later section do not leak into it", () => {
+ const source = "# 目标:x\n\n## 验收标准\n- [ ] a\n\n## 备注\n- [ ] 这不是验收项\n";
+ const { doc } = parseSpaceDoc(source);
+ assert.equal(doc.criteria.length, 1);
+ assert.equal(doc.criteria[0].text, "a");
+ assert.equal(doc.otherSections.length, 1);
+ assert.equal(doc.otherSections[0].heading, "备注");
+});
diff --git a/packages/shared/package.json b/packages/shared/package.json
index ec21dec8..60942dbd 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -49,6 +49,10 @@
"types": "./dist/acceptance.d.ts",
"import": "./dist/acceptance.js"
},
+ "./space-doc": {
+ "types": "./dist/space-doc.d.ts",
+ "import": "./dist/space-doc.js"
+ },
"./task-board": {
"types": "./dist/task-board.d.ts",
"import": "./dist/task-board.js"
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 64134633..2bb3b8eb 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -27,6 +27,7 @@ export * from "./context-sources.js";
export * from "./updates.js";
export * from "./goals.js";
export * from "./acceptance.js";
+export * from "./space-doc.js";
export * from "./task-board.js";
export * from "./relationship-graph.js";
export * from "./avatar.js";
diff --git a/packages/shared/src/space-doc.ts b/packages/shared/src/space-doc.ts
new file mode 100644
index 00000000..75f97572
--- /dev/null
+++ b/packages/shared/src/space-doc.ts
@@ -0,0 +1,356 @@
+/**
+ * Space document contract (v1).
+ *
+ * A space (专项作战人机协作区) is a folder inside a workspace. Its spine is one
+ * Markdown file that the human and the agents both edit. The file is the
+ * source of truth; the structure below is derived from it, never stored
+ * alongside it.
+ *
+ * Structure is carried by fixed H2 headings rather than a custom DSL or
+ * front-matter schema, so the same file stays readable as a plain document:
+ *
+ * # 目标:
+ *
+ *
+ *
+ * ## 验收标准
+ * - [ ]
+ *
+ * ## 任务
+ * | 任务 | 状态 | 负责岗位 | 截止 |
+ * | --- | --- | --- | --- |
+ * | | todo | | 2026-10-01 |
+ *
+ * Parsing is pure and total: any string produces a result, and a document
+ * that lacks a section simply yields an empty one. Round-tripping the parsed
+ * value never rewrites prose the parser did not understand.
+ */
+
+import { goalWorkItemStatuses, type GoalWorkItemStatus } from "./goals.js";
+
+export const SPACE_DOC_SCHEMA_VERSION = "space-doc.v1" as const;
+
+export const SPACE_DOC_MAX_CRITERIA = 64;
+export const SPACE_DOC_MAX_TASKS = 128;
+export const SPACE_DOC_MAX_TITLE_LENGTH = 256;
+
+/** Section headings are fixed identifiers, not localised at parse time. */
+export const SPACE_DOC_CRITERIA_HEADING = "验收标准" as const;
+export const SPACE_DOC_TASKS_HEADING = "任务" as const;
+
+export interface SpaceDocCriterion {
+ /** Stable index within the document; matches `acceptance.v1` criteriaIndex. */
+ criteriaIndex: number;
+ text: string;
+ done: boolean;
+ /** Raw checkbox token as written, so an unrelated state is not silently lost. */
+ marker: " " | "x" | "X";
+}
+
+export interface SpaceDocTask {
+ title: string;
+ status: GoalWorkItemStatus;
+ /** A position id when the cell looks like one; otherwise undefined. */
+ assigneePositionId?: string;
+ dueDate?: string;
+ /** Cells the parser could not classify, preserved for the structure view. */
+ raw: string[];
+}
+
+export interface SpaceDocSection {
+ heading: string;
+ /** Line index of the heading in the source, or -1 for a synthesised section. */
+ startLine: number;
+ body: string[];
+}
+
+export interface SpaceDoc {
+ schemaVersion: typeof SPACE_DOC_SCHEMA_VERSION;
+ title: string;
+ /** Everything before the first H2, minus the H1 line. */
+ intro: string;
+ criteria: SpaceDocCriterion[];
+ tasks: SpaceDocTask[];
+ /** Sections that are neither 验收标准 nor 任务, kept in document order. */
+ otherSections: SpaceDocSection[];
+ /** True when a `## 任务` table was found but no row parsed cleanly. */
+ tasksUnparseable: boolean;
+}
+
+export interface SpaceDocParseIssue {
+ line: number;
+ message: string;
+}
+
+export interface SpaceDocParseResult {
+ doc: SpaceDoc;
+ issues: SpaceDocParseIssue[];
+}
+
+function isH1(line: string): boolean {
+ return /^#\s+/.test(line);
+}
+
+function isH2(line: string): boolean {
+ return /^##\s+/.test(line);
+}
+
+function h2Heading(line: string): string {
+ return line.replace(/^##\s+/, "").trim();
+}
+
+/** `# 目标:X` / `# 目标: X` / `# X` all resolve to the title text. */
+export function parseSpaceDocTitle(line: string): string {
+ const text = line.replace(/^#\s+/, "").trim();
+ const stripped = text.replace(/^目标\s*[::]\s*/, "").trim();
+ return stripped.length > 0 ? stripped : text;
+}
+
+function splitTableRow(line: string): string[] {
+ const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
+ return trimmed.split("|").map((cell) => cell.trim());
+}
+
+function isTableSeparator(cells: string[]): boolean {
+ return cells.length > 0 && cells.every((cell) => /^:?-{2,}:?$/.test(cell.replace(/\s/g, "")));
+}
+
+function isTableRow(line: string): boolean {
+ const trimmed = line.trim();
+ return trimmed.startsWith("|") && trimmed.endsWith("|") && trimmed.length > 2;
+}
+
+function parseCriterion(line: string, criteriaIndex: number): SpaceDocCriterion | null {
+ const match = /^\s*[-*+]\s+\[([ xX])\]\s*(.*)$/.exec(line);
+ if (!match) return null;
+ const marker = match[1];
+ if (marker === undefined) return null;
+ const text = (match[2] ?? "").trim();
+ if (text.length === 0) return null;
+ return { criteriaIndex, text, done: marker !== " ", marker: marker as " " | "x" | "X" };
+}
+
+const TASK_COLUMN_ALIASES: Record = {
+ "任务": "title",
+ "标题": "title",
+ "task": "title",
+ "title": "title",
+ "状态": "status",
+ "status": "status",
+ "负责岗位": "assignee",
+ "负责": "assignee",
+ "assignee": "assignee",
+ "owner": "assignee",
+ "截止": "due",
+ "截止时间": "due",
+ "due": "due",
+ "due date": "due",
+};
+
+const POSITION_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
+const CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
+
+function isPositionId(value: string): boolean {
+ return value.length > 0 && value.length <= 64 && POSITION_ID_PATTERN.test(value);
+}
+
+function isCalendarDate(value: string): boolean {
+ if (!CALENDAR_DATE_PATTERN.test(value) || value.startsWith("0000-")) return false;
+ const date = new Date(`${value}T00:00:00.000Z`);
+ return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
+}
+
+function isWorkItemStatus(value: string): value is GoalWorkItemStatus {
+ return (goalWorkItemStatuses as readonly string[]).includes(value);
+}
+
+/** Column index → semantic role, resolved from a header row (or its alias). */
+function resolveColumns(header: string[]): Array<"title" | "status" | "assignee" | "due" | null> {
+ return header.map((cell) => TASK_COLUMN_ALIASES[cell.toLowerCase()] ?? TASK_COLUMN_ALIASES[cell] ?? null);
+}
+
+function parseTaskRow(cells: string[], columns: Array, line: number, issues: SpaceDocParseIssue[]): SpaceDocTask | null {
+ const title = cells[0]?.trim() ?? "";
+ if (title.length === 0) {
+ issues.push({ line, message: "task row has an empty title" });
+ return null;
+ }
+
+ const task: SpaceDocTask = {
+ title: title.slice(0, SPACE_DOC_MAX_TITLE_LENGTH),
+ status: "todo",
+ raw: cells,
+ };
+
+ for (let index = 1; index < cells.length; index += 1) {
+ const role = columns[index] ?? null;
+ const value = cells[index]?.trim() ?? "";
+ if (value.length === 0) continue;
+
+ if (role === "status") {
+ if (isWorkItemStatus(value)) task.status = value;
+ else issues.push({ line, message: `unknown task status "${value}"; kept as todo` });
+ } else if (role === "assignee") {
+ if (isPositionId(value)) task.assigneePositionId = value;
+ else issues.push({ line, message: `"${value}" is not a position id; left unassigned` });
+ } else if (role === "due") {
+ if (isCalendarDate(value)) task.dueDate = value;
+ else issues.push({ line, message: `"${value}" is not a YYYY-MM-DD date; ignored` });
+ }
+ }
+ return task;
+}
+
+function parseTasks(section: SpaceDocSection, issues: SpaceDocParseIssue[]): { tasks: SpaceDocTask[]; unparseable: boolean } {
+ const tasks: SpaceDocTask[] = [];
+ let columns: Array | null = null;
+ let sawTable = false;
+
+ for (let offset = 0; offset < section.body.length; offset += 1) {
+ const line = section.body[offset] ?? "";
+ const lineNumber = section.startLine + offset + 1;
+
+ if (!isTableRow(line)) continue;
+ const cells = splitTableRow(line);
+
+ if (!sawTable) {
+ // First row is the header, the second (separator) is skipped.
+ if (isTableSeparator(cells)) continue;
+ columns = resolveColumns(cells);
+ sawTable = true;
+ continue;
+ }
+ if (isTableSeparator(cells)) continue;
+
+ const task = parseTaskRow(cells, columns ?? [], lineNumber, issues);
+ if (task) tasks.push(task);
+ if (tasks.length >= SPACE_DOC_MAX_TASKS) break;
+ }
+
+ return { tasks, unparseable: sawTable && tasks.length === 0 };
+}
+
+export function parseSpaceDoc(source: string): SpaceDocParseResult {
+ const issues: SpaceDocParseIssue[] = [];
+ const lines = source.replace(/\r\n?/g, "\n").split("\n");
+
+ let title = "";
+ let introLines: string[] = [];
+ const sections: SpaceDocSection[] = [];
+ let current: SpaceDocSection | null = null;
+
+ for (let index = 0; index < lines.length; index += 1) {
+ const line = lines[index] ?? "";
+
+ if (isH2(line)) {
+ current = { heading: h2Heading(line), startLine: index, body: [] };
+ sections.push(current);
+ continue;
+ }
+ if (isH1(line)) {
+ if (title.length === 0) title = parseSpaceDocTitle(line);
+ continue;
+ }
+ if (current) current.body.push(line);
+ else introLines.push(line);
+ }
+
+ let criteria: SpaceDocCriterion[] = [];
+ let tasks: SpaceDocTask[] = [];
+ let tasksUnparseable = false;
+ const otherSections: SpaceDocSection[] = [];
+
+ for (const section of sections) {
+ if (section.heading === SPACE_DOC_CRITERIA_HEADING) {
+ for (let offset = 0; offset < section.body.length; offset += 1) {
+ if (criteria.length >= SPACE_DOC_MAX_CRITERIA) break;
+ const criterion = parseCriterion(section.body[offset] ?? "", criteria.length);
+ if (criterion) criteria.push(criterion);
+ }
+ } else if (section.heading === SPACE_DOC_TASKS_HEADING) {
+ const parsed = parseTasks(section, issues);
+ tasks = parsed.tasks;
+ tasksUnparseable = parsed.unparseable;
+ } else {
+ otherSections.push(section);
+ }
+ }
+
+ return {
+ doc: {
+ schemaVersion: SPACE_DOC_SCHEMA_VERSION,
+ title,
+ intro: introLines.join("\n").trim(),
+ criteria,
+ tasks,
+ otherSections,
+ tasksUnparseable,
+ },
+ issues,
+ };
+}
+
+/**
+ * Rewrite only the two structured sections, leaving every other byte of the
+ * document untouched. This is what makes the structure view safe to edit:
+ * prose the parser did not understand is never regenerated from a lossy model.
+ */
+export function renderSpaceDoc(doc: SpaceDoc, source: string): string {
+ const eol = source.includes("\r\n") ? "\r\n" : "\n";
+ const lines = source.replace(/\r\n?/g, "\n").split("\n");
+
+ const criterionLines = doc.criteria.map(
+ (criterion) => `- [${criterion.done ? "x" : " "}] ${criterion.text}`,
+ );
+ const taskLines = [
+ "| 任务 | 状态 | 负责岗位 | 截止 |",
+ "| --- | --- | --- | --- |",
+ ...doc.tasks.map((task) =>
+ [
+ task.title,
+ task.status,
+ task.assigneePositionId ?? "",
+ task.dueDate ?? "",
+ ].join(" | ").replace(/^/, "| ").replace(/$/, " |"),
+ ),
+ ];
+
+ const replacements = new Map();
+ if (criterionLines.length > 0 || doc.criteria.length > 0) {
+ replacements.set(SPACE_DOC_CRITERIA_HEADING, criterionLines);
+ }
+ const hasTaskSection = lines.some((line) => isH2(line) && h2Heading(line) === SPACE_DOC_TASKS_HEADING);
+ if (doc.tasks.length > 0 || hasTaskSection) {
+ replacements.set(SPACE_DOC_TASKS_HEADING, taskLines);
+ }
+
+ const out: string[] = [];
+ let index = 0;
+ while (index < lines.length) {
+ const line = lines[index] ?? "";
+ if (!isH2(line)) {
+ out.push(line);
+ index += 1;
+ continue;
+ }
+ const heading = h2Heading(line);
+ const replacement = replacements.get(heading);
+ if (!replacement) {
+ out.push(line);
+ index += 1;
+ continue;
+ }
+ out.push(line);
+ index += 1;
+ // Skip the original body up to the next heading, then emit the new body.
+ while (index < lines.length && !isH2(lines[index] ?? "")) index += 1;
+ out.push(...replacement);
+ }
+
+ return out.join(eol);
+}
+
+/** Derive the `acceptance.v1` criteria count a space exposes to the gate. */
+export function spaceDocCriteriaCount(doc: SpaceDoc): number {
+ return doc.criteria.length;
+}
diff --git a/packages/ui/src/locales/en.ts b/packages/ui/src/locales/en.ts
index 03e298f7..5986b185 100644
--- a/packages/ui/src/locales/en.ts
+++ b/packages/ui/src/locales/en.ts
@@ -329,6 +329,7 @@ export const enCatalog: Record = {
"rail.collaboration": "Collaborate",
"rail.projects": "Projects",
"rail.inbox": "Inbox",
+ "rail.space": "Spaces",
"nav.collaboration": "Collaboration mode",
"nav.employee": "Employee views",
"nav.organization": "Organization views",
@@ -1762,4 +1763,41 @@ export const enCatalog: Record = {
"credentials.failed": "The credential change could not be saved. Unlock your system keychain and retry.",
"credentials.saved": "Saved securely. Applies on the next control-plane start.",
"credentials.cleared": "Saved value cleared. Applies on the next control-plane start.",
+
+ // Space (专项作战人机协作区)
+ "space.untitled": "Untitled space",
+ "space.rootHint": "A space is a folder inside a workspace",
+ "space.emptyWorkspace": "Open a workspace before entering a space.",
+ "space.filesTitle": "Files",
+ "space.spineTag": "Spine",
+ "space.participantsTitle": "Participants",
+ "space.ownerTag": "Owner",
+ "space.view.document": "Document",
+ "space.view.structure": "Structure",
+ "space.error.read": "Could not read {path}.",
+ "space.error.write": "Could not write {path}.",
+ "space.structure.criteria": "Acceptance criteria",
+ "space.structure.noCriteria": "This document has no \"## 验收标准\" section yet.",
+ "space.structure.board": "Task board",
+ "space.structure.unparseable": "A \"## 任务\" table was found but no row parsed. Check the header row.",
+ "space.structure.lastDecision": "Last acceptance",
+ "space.decision.accepted": "Accepted",
+ "space.decision.rejected": "Rejected",
+ "space.status.todo": "To do",
+ "space.status.in_progress": "In progress",
+ "space.status.blocked": "Blocked",
+ "space.status.review": "In review",
+ "space.status.done": "Done",
+ "space.gate.title": "Acceptance",
+ "space.gate.empty": "This document has no criteria to accept.",
+ "space.gate.progress": "{done}/{total} confirmed",
+ "space.gate.remaining": "{count} left unconfirmed; confirm them before accepting.",
+ "space.gate.evidencePlaceholder": "Evidence path (optional)",
+ "space.gate.notePlaceholder": "Reason for rejection / note",
+ "space.gate.accept": "Accept",
+ "space.gate.reject": "Reject",
+ "space.gate.confirmAccept": "Accept this deliverable? Accepting counts it as done.",
+ "space.gate.confirmReject": "Reject it? Make sure a reason is written.",
+ "space.gate.confirm": "Confirm",
+ "space.gate.cancel": "Cancel",
};
diff --git a/packages/ui/src/locales/zh.ts b/packages/ui/src/locales/zh.ts
index 6cfd48a3..6cb8f008 100644
--- a/packages/ui/src/locales/zh.ts
+++ b/packages/ui/src/locales/zh.ts
@@ -332,6 +332,7 @@ export const zhCatalog: Record = {
"rail.collaboration": "协作",
"rail.projects": "项目",
"rail.inbox": "收件箱",
+ "rail.space": "协作区",
"nav.collaboration": "协作方式",
"nav.employee": "员工视图",
"nav.organization": "组织视图",
@@ -1765,4 +1766,41 @@ export const zhCatalog: Record = {
"credentials.failed": "无法保存凭据更改。请解锁系统密钥环后重试。",
"credentials.saved": "已安全保存。将在控制面下次启动时生效。",
"credentials.cleared": "已清除保存的值。将在控制面下次启动时生效。",
+
+ // 协作区(专项作战人机协作区)
+ "space.untitled": "未命名协作区",
+ "space.rootHint": "协作区 = 工作区里的一个文件夹",
+ "space.emptyWorkspace": "先打开一个工作区,再进入协作区。",
+ "space.filesTitle": "文件",
+ "space.spineTag": "主线",
+ "space.participantsTitle": "参与者",
+ "space.ownerTag": "负责人",
+ "space.view.document": "文档",
+ "space.view.structure": "结构",
+ "space.error.read": "无法读取 {path}。",
+ "space.error.write": "无法写入 {path}。",
+ "space.structure.criteria": "验收标准",
+ "space.structure.noCriteria": "这份文档还没有「## 验收标准」小节。",
+ "space.structure.board": "任务看板",
+ "space.structure.unparseable": "检测到「## 任务」表格,但没有一行能解析。请检查表头。",
+ "space.structure.lastDecision": "最近一次验收",
+ "space.decision.accepted": "已验收通过",
+ "space.decision.rejected": "已打回",
+ "space.status.todo": "待办",
+ "space.status.in_progress": "进行中",
+ "space.status.blocked": "受阻",
+ "space.status.review": "待验收",
+ "space.status.done": "已完成",
+ "space.gate.title": "验收",
+ "space.gate.empty": "这份文档没有可验收的标准。",
+ "space.gate.progress": "{done}/{total} 项已确认",
+ "space.gate.remaining": "还有 {count} 项未确认,勾选后才能验收通过。",
+ "space.gate.evidencePlaceholder": "证据路径(可选)",
+ "space.gate.notePlaceholder": "打回原因 / 备注",
+ "space.gate.accept": "验收通过",
+ "space.gate.reject": "打回",
+ "space.gate.confirmAccept": "确认通过这份产出?通过后任务计入完成度。",
+ "space.gate.confirmReject": "确认打回?请确保已填写原因。",
+ "space.gate.confirm": "确认",
+ "space.gate.cancel": "取消",
};
From cc748ff21da1c3b9da12d762ff0ec3891cc227cc Mon Sep 17 00:00:00 2001
From: huyz <739820174@qq.com>
Date: Thu, 1 Oct 2026 23:45:37 +0800
Subject: [PATCH 4/9] fix(space): type-check server tests under
noUncheckedIndexedAccess
CI failed at `npm run check` with 19 TS errors, all in the new server
tests. The cause is mundane: `apps/server/tsconfig.json` includes
`test/**/*.ts` and the base config sets `noUncheckedIndexedAccess`, so
every `list[0]` in an assertion is `T | undefined`. I had type-checked
`packages/shared/src` but never the test files, so this only surfaced in
CI.
- space-doc.test.ts: add a single `at(list, index)` helper that asserts
bounds and narrows, then route the 18 indexed assertions through it
instead of scattering `!`. The one error that was not an index read was
`{ ...doc.criteria[0], done: true }`, where spreading a possibly
undefined value widened every field to optional; `at()` fixes that too.
- acceptance.test.ts: `value.verdicts[1].evidencePath` -> optional chain.
This one is on the #559 branch as well.
Verified with the CI command shape: `tsc -p tsconfig.json` for the server
project (includes tests) is clean, and the two suites run from
`apps/server/dist` pass 34/34.
Refs #558
---
apps/server/test/acceptance.test.ts | 2 +-
apps/server/test/space-doc.test.ts | 50 ++++++++++++++++++-----------
2 files changed, 32 insertions(+), 20 deletions(-)
diff --git a/apps/server/test/acceptance.test.ts b/apps/server/test/acceptance.test.ts
index 7c50d16c..bca47d7f 100644
--- a/apps/server/test/acceptance.test.ts
+++ b/apps/server/test/acceptance.test.ts
@@ -32,7 +32,7 @@ test("accepts a well-formed accepted record", () => {
assert.equal(value.schemaVersion, ACCEPTANCE_SCHEMA_VERSION);
assert.equal(value.decision, "accepted");
assert.equal(value.verdicts.length, 2);
- assert.equal(value.verdicts[1].evidencePath, "产出/增量更新.md");
+ assert.equal(value.verdicts[1]?.evidencePath, "产出/增量更新.md");
});
test("rejects an accepted decision when a criterion is not passed", () => {
diff --git a/apps/server/test/space-doc.test.ts b/apps/server/test/space-doc.test.ts
index b3df6533..88de0fb8 100644
--- a/apps/server/test/space-doc.test.ts
+++ b/apps/server/test/space-doc.test.ts
@@ -6,6 +6,18 @@ import {
renderSpaceDoc,
} from "@roleweave/shared";
+/**
+ * Indexed access under `noUncheckedIndexedAccess` is `T | undefined`, which turns
+ * every `list[0]` in an assertion into a type error. These tests assert on known
+ * fixtures, so narrow once here instead of scattering `!` through the file.
+ */
+function at(list: readonly T[], index: number): T {
+ assert.ok(index >= 0 && index < list.length, `index ${index} out of range`);
+ const value = list[index];
+ assert.ok(value !== undefined, `index ${index} is undefined`);
+ return value;
+}
+
const DOC = `# 目标:把结算对账自动化
先把口径对齐,再看边界。
@@ -41,31 +53,31 @@ test("parses criteria with their checkbox state and stable index", () => {
const { doc } = parseSpaceDoc(DOC);
assert.equal(doc.criteria.length, 3);
assert.deepEqual(doc.criteria.map((c) => c.criteriaIndex), [0, 1, 2]);
- assert.equal(doc.criteria[0].text, "对账差异能被自动归因");
- assert.equal(doc.criteria[0].done, false);
- assert.equal(doc.criteria[1].done, true);
- assert.equal(doc.criteria[1].marker, "x");
+ assert.equal(at(doc.criteria, 0).text, "对账差异能被自动归因");
+ assert.equal(at(doc.criteria, 0).done, false);
+ assert.equal(at(doc.criteria, 1).done, true);
+ assert.equal(at(doc.criteria, 1).marker, "x");
});
test("parses the task table into typed rows", () => {
const { doc } = parseSpaceDoc(DOC);
assert.equal(doc.tasks.length, 3);
- assert.deepEqual(doc.tasks[0], {
+ assert.deepEqual(at(doc.tasks, 0), {
title: "梳理差异分类",
status: "done",
assigneePositionId: "analyst",
dueDate: "2026-09-20",
raw: ["梳理差异分类", "done", "analyst", "2026-09-20"],
});
- assert.equal(doc.tasks[1].status, "in_progress");
- assert.equal(doc.tasks[2].assigneePositionId, undefined);
- assert.equal(doc.tasks[2].dueDate, undefined);
+ assert.equal(at(doc.tasks, 1).status, "in_progress");
+ assert.equal(at(doc.tasks, 2).assigneePositionId, undefined);
+ assert.equal(at(doc.tasks, 2).dueDate, undefined);
});
test("keeps sections the parser does not own, in document order", () => {
const { doc } = parseSpaceDoc(DOC);
assert.deepEqual(doc.otherSections.map((s) => s.heading), ["背景", "风险"]);
- assert.equal(doc.otherSections[0].body.join("\n").trim(), "这块现在靠人工,每月两个人力日。");
+ assert.equal(at(doc.otherSections, 0).body.join("\n").trim(), "这块现在靠人工,每月两个人力日。");
});
test("keeps the intro prose before the first H2", () => {
@@ -99,26 +111,26 @@ test("flags a task table that yields no rows", () => {
test("reports an unknown status instead of guessing", () => {
const source = "# 目标:x\n\n## 任务\n| 任务 | 状态 |\n| --- | --- |\n| a | 做完了 |\n";
const { doc, issues } = parseSpaceDoc(source);
- assert.equal(doc.tasks[0].status, "todo");
+ assert.equal(at(doc.tasks, 0).status, "todo");
assert.equal(issues.length, 1);
- assert.match(issues[0].message, /unknown task status/);
+ assert.match(at(issues, 0).message, /unknown task status/);
});
test("ignores a non-position assignee and a non-date due, with an issue", () => {
const source =
"# 目标:x\n\n## 任务\n| 任务 | 状态 | 负责岗位 | 截止 |\n| --- | --- | --- | --- |\n| a | todo | Alice Smith | 明天 |\n";
const { doc, issues } = parseSpaceDoc(source);
- assert.equal(doc.tasks[0].assigneePositionId, undefined);
- assert.equal(doc.tasks[0].dueDate, undefined);
+ assert.equal(at(doc.tasks, 0).assigneePositionId, undefined);
+ assert.equal(at(doc.tasks, 0).dueDate, undefined);
assert.equal(issues.length, 2);
});
test("accepts header aliases", () => {
const source = "# 目标:x\n\n## 任务\n| Title | Status | Assignee | Due Date |\n| --- | --- | --- | --- |\n| a | blocked | engineer | 2026-11-01 |\n";
const { doc } = parseSpaceDoc(source);
- assert.equal(doc.tasks[0].status, "blocked");
- assert.equal(doc.tasks[0].assigneePositionId, "engineer");
- assert.equal(doc.tasks[0].dueDate, "2026-11-01");
+ assert.equal(at(doc.tasks, 0).status, "blocked");
+ assert.equal(at(doc.tasks, 0).assigneePositionId, "engineer");
+ assert.equal(at(doc.tasks, 0).dueDate, "2026-11-01");
});
test("round-trip preserves prose and other sections byte for byte", () => {
@@ -148,7 +160,7 @@ test("round-trip on an already-canonical document is a fixed point", () => {
test("toggling a criterion rewrites only its line", () => {
const { doc } = parseSpaceDoc(DOC);
- doc.criteria[0] = { ...doc.criteria[0], done: true };
+ doc.criteria[0] = { ...at(doc.criteria, 0), done: true };
const rendered = renderSpaceDoc(doc, DOC);
assert.ok(rendered.includes("- [x] 对账差异能被自动归因"));
assert.ok(rendered.includes("- [x] 口径文档已评审"));
@@ -173,7 +185,7 @@ test("criteria before a later section do not leak into it", () => {
const source = "# 目标:x\n\n## 验收标准\n- [ ] a\n\n## 备注\n- [ ] 这不是验收项\n";
const { doc } = parseSpaceDoc(source);
assert.equal(doc.criteria.length, 1);
- assert.equal(doc.criteria[0].text, "a");
+ assert.equal(at(doc.criteria, 0).text, "a");
assert.equal(doc.otherSections.length, 1);
- assert.equal(doc.otherSections[0].heading, "备注");
+ assert.equal(at(doc.otherSections, 0).heading, "备注");
});
From b0fa6209112a3c89f7fc9a4937bdc3871791170c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Thu, 1 Oct 2026 23:47:06 +0800
Subject: [PATCH 5/9] fix(space): narrow verdict index access for
noUncheckedIndexedAccess
Same class of error as the space-doc suite: the base tsconfig sets
noUncheckedIndexedAccess and apps/server/tsconfig.json includes test/**,
so `verdicts[1].evidencePath` is a possibly-undefined read. CI caught it
on #560; this branch carries the same line.
Refs #558
---
apps/server/test/acceptance.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/server/test/acceptance.test.ts b/apps/server/test/acceptance.test.ts
index 7c50d16c..bca47d7f 100644
--- a/apps/server/test/acceptance.test.ts
+++ b/apps/server/test/acceptance.test.ts
@@ -32,7 +32,7 @@ test("accepts a well-formed accepted record", () => {
assert.equal(value.schemaVersion, ACCEPTANCE_SCHEMA_VERSION);
assert.equal(value.decision, "accepted");
assert.equal(value.verdicts.length, 2);
- assert.equal(value.verdicts[1].evidencePath, "产出/增量更新.md");
+ assert.equal(value.verdicts[1]?.evidencePath, "产出/增量更新.md");
});
test("rejects an accepted decision when a criterion is not passed", () => {
From 4ea2434a9b2a240fd0356f46ef6f687d66103de6 Mon Sep 17 00:00:00 2001
From: huyz <739820174@qq.com>
Date: Thu, 1 Oct 2026 23:51:59 +0800
Subject: [PATCH 6/9] fix(packaging): ship the new shared modules in the
runtime allowlist
`npm run check` also runs scripts/test/package-layout.test.mjs, which asserts
that SHARED_RUNTIME_FILES matches the source inventory of
packages/shared/src, in order. The two modules added by this branch were
never listed, so this was not just a red test: they were absent from
RUNTIME_FILE_SETS and would not have shipped inside the packaged app at all.
- dist/acceptance.js
- dist/space-doc.js
Both inserted in the alphabetical position the test compares against.
Verified locally: "runtime manifest is an explicit allowlist for every
packaged consumer" passes, and RUNTIME_FILE_SETS for packages/shared now
reports 41 files including both new modules.
(The only remaining local failure in that file is
"a third-party runtime dependency is either packaged or not required",
which wants the src/vendor/*.cjs produced by `npm run prepare:updater`;
CI runs that step before check, so it does not reproduce there.)
Refs #558
---
apps/desktop/packaging/runtime-layout.cjs | 2 ++
1 file changed, 2 insertions(+)
diff --git a/apps/desktop/packaging/runtime-layout.cjs b/apps/desktop/packaging/runtime-layout.cjs
index ba1d12a4..a9fa04a2 100644
--- a/apps/desktop/packaging/runtime-layout.cjs
+++ b/apps/desktop/packaging/runtime-layout.cjs
@@ -160,6 +160,7 @@ const SHARED_RUNTIME_FILES = [
"pending-approval.cjs",
"position-id.cjs",
"turn-engines.cjs",
+ "dist/acceptance.js",
"dist/agent-binding.js",
"dist/api.js",
"dist/approval-preview.js",
@@ -190,6 +191,7 @@ const SHARED_RUNTIME_FILES = [
"dist/semantic-runtime.js",
"dist/services.js",
"dist/sessions.js",
+ "dist/space-doc.js",
"dist/task-board.js",
"dist/turn-progress.js",
"dist/turns.js",
From 1a19cd5e45d49c78d90301b39b8cc9b1b3b52f7e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Thu, 1 Oct 2026 23:52:39 +0800
Subject: [PATCH 7/9] fix(packaging): ship acceptance.js in the runtime
allowlist
SHARED_RUNTIME_FILES must match packages/shared/src in order, and
RUNTIME_FILE_SETS is what actually gets packaged. Without this entry the
acceptance contract would be missing from the built app.
Refs #558
---
apps/desktop/packaging/runtime-layout.cjs | 1 +
1 file changed, 1 insertion(+)
diff --git a/apps/desktop/packaging/runtime-layout.cjs b/apps/desktop/packaging/runtime-layout.cjs
index ba1d12a4..2c4f9e82 100644
--- a/apps/desktop/packaging/runtime-layout.cjs
+++ b/apps/desktop/packaging/runtime-layout.cjs
@@ -160,6 +160,7 @@ const SHARED_RUNTIME_FILES = [
"pending-approval.cjs",
"position-id.cjs",
"turn-engines.cjs",
+ "dist/acceptance.js",
"dist/agent-binding.js",
"dist/api.js",
"dist/approval-preview.js",
From f849fb8c403510439f895acadeb18a8e53a84c19 Mon Sep 17 00:00:00 2001
From: huyz <739820174@qq.com>
Date: Fri, 2 Oct 2026 00:07:00 +0800
Subject: [PATCH 8/9] fix(space): move the spine filename into the catalogs and
register the rail item
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two renderer failures on the previous run, both real.
1. i18n-cjk-gate (#146) forbids CJK string literals under renderer/src, so
user-facing Chinese must live in the zh/en catalogs. `SPACE_SPINE_FILE =
"目标.md"` was the one offender. It is a filename that shows in the file
tree, so the catalog is the right home: new key `space.spineFile`
("目标.md" / "goal.md"), and the component resolves it through `t()`.
The exported symbol becomes `SPACE_SPINE_FILE_KEY`; the module test reads
the value back out of `zhCatalog` so the fixtures cannot drift from the
catalog.
2. App.test.tsx asserts the exact rail label order, and the new item adds a
sixth entry. Expected list is now
["协作", "组织", "项目", "协作区", "收件箱", "设置"], matching the order in
App.tsx (projects then space then inbox).
Also replaced the deprecated `bordered={false}` on five antd Tags with
`variant="filled"`; it was emitting a warning per render in the test output.
Verified locally: i18n-cjk-gate 1/1, i18n 7/7, space-module 5/5,
space-acceptance-gate 11/11 (24 total), and `tsc --noEmit -p apps/desktop`
is clean.
Refs #558
---
.../renderer/src/space/SpaceModule.tsx | 19 ++++++++++---------
apps/desktop/renderer/src/space/index.ts | 2 +-
apps/desktop/renderer/test/App.test.tsx | 2 +-
.../renderer/test/space-module.test.tsx | 10 +++++++++-
packages/ui/src/locales/en.ts | 1 +
packages/ui/src/locales/zh.ts | 1 +
6 files changed, 23 insertions(+), 12 deletions(-)
diff --git a/apps/desktop/renderer/src/space/SpaceModule.tsx b/apps/desktop/renderer/src/space/SpaceModule.tsx
index 398a6646..a6d7ccf4 100644
--- a/apps/desktop/renderer/src/space/SpaceModule.tsx
+++ b/apps/desktop/renderer/src/space/SpaceModule.tsx
@@ -18,7 +18,7 @@ import { buildAcceptanceRecord, type GateDraft } from "./acceptance-gate-model.j
import "./space.css";
/** The spine file of a space. Every other file in the folder is supporting. */
-export const SPACE_SPINE_FILE = "目标.md";
+export const SPACE_SPINE_FILE_KEY = "space.spineFile";
export interface SpaceAgent {
positionId: string;
@@ -61,8 +61,9 @@ function statusTone(status: SpaceDocTask["status"]): string {
export function SpaceModule(props: SpaceModuleProps) {
const t = useT();
+ const spineFile = t(SPACE_SPINE_FILE_KEY);
const [view, setView] = useState("document");
- const [activePath, setActivePath] = useState(props.requestedPath ?? SPACE_SPINE_FILE);
+ const [activePath, setActivePath] = useState(props.requestedPath ?? spineFile);
const [source, setSource] = useState("");
const [version, setVersion] = useState("");
const [loading, setLoading] = useState(false);
@@ -94,7 +95,7 @@ export function SpaceModule(props: SpaceModuleProps) {
const parsed = useMemo(() => parseSpaceDoc(source), [source]);
const doc = parsed.doc;
- const isSpine = activePath === SPACE_SPINE_FILE;
+ const isSpine = activePath === spineFile;
const owner = props.participants?.find((agent) => agent.owner);
const others = props.participants?.filter((agent) => !agent.owner) ?? [];
@@ -146,7 +147,7 @@ export function SpaceModule(props: SpaceModuleProps) {
{props.spaceName ?? t("space.untitled")}
-
+
{t("space.rootHint")}
@@ -183,8 +184,8 @@ export function SpaceModule(props: SpaceModuleProps) {
>
{file.path}
- {file.path === SPACE_SPINE_FILE ? (
-
+ {file.path === spineFile ? (
+
{t("space.spineTag")}
) : null}
@@ -199,7 +200,7 @@ export function SpaceModule(props: SpaceModuleProps) {
{owner.name}
-
+
{t("space.ownerTag")}
@@ -292,7 +293,7 @@ function SpaceStructure({ doc, decided }: SpaceStructureProps) {
{column.label}
- {tasks.length}
+ {tasks.length}