From f6dd4d59a1867492dd1140ce5aa6e9a897fc8efd Mon Sep 17 00:00:00 2001 From: jiayiyan <43424880@qq.com> Date: Mon, 18 May 2026 20:11:48 +0800 Subject: [PATCH] docs: add office-agent document template spec and implementation plan --- docs/superpowers/.DS_Store | Bin 0 -> 6148 bytes ...26-05-18-office-agent-document-template.md | 1405 +++++++++++++++++ ...26-05-18-office-agent-document-template.md | 387 +++++ 3 files changed, 1792 insertions(+) create mode 100644 docs/superpowers/.DS_Store create mode 100644 docs/superpowers/plans/2026-05-18-office-agent-document-template.md create mode 100644 docs/superpowers/specs/2026-05-18-office-agent-document-template.md diff --git a/docs/superpowers/.DS_Store b/docs/superpowers/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5b8bf860ad173e57575135a028c0043e73f709f5 GIT binary patch literal 6148 zcmeHK%}T>S5Z<+|O({YS3VK`cS}-EVe&b~7Jje;8xDKMD64vl(L+G(?U{g`m08wPu15xtb#jrr9V+WKc29 z-!$R3H(16-EMgCA@%ul5Q4(jl<9zb9TD`H}w3=4iy7Qjo)XV*RlJ))Z4UR6QOoCGP zgR3~24(-h|ndW|+Mq`x_hY^I_UdL%Dr+qm|!%XFR+F`Y<*3jOW&AN_r)DgXIZ{88J zlLMzC4!g(mdCS_`-8;P)J|{1!eA7&Fpj^p@!4lpfj(AHiB?l#ztQ05L!e z5Cbd8fH@AV_DWVyMH2(Wz)uX|{ve>0lQo&NWzQ)ai_?m0=#Ua`|}SYIU#+70$S;k$Pf)7+7YYriV73|L5?_ zR6g>TQ)omC5Ci{=0bUf_9Y-NEZP`2zA83FEH>06GTfa literal 0 HcmV?d00001 diff --git a/docs/superpowers/plans/2026-05-18-office-agent-document-template.md b/docs/superpowers/plans/2026-05-18-office-agent-document-template.md new file mode 100644 index 0000000..c5df3bd --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-office-agent-document-template.md @@ -0,0 +1,1405 @@ +# Office-Agent Document Template 实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 新增 `workflow-template-document`、`workflow-agent-office`、`workflow-agent-docx-diff` 三个包,通过 `office-agent` CLI 实现 Word 文档生成/编辑 workflow。 + +**Architecture:** `workflow-template-document` 定义纯结构(roles / moderator / descriptor);`workflow-agent-office` 直接实现 `AdapterFn`,调用 `office-agent` CLI 生成或编辑文档;`workflow-agent-docx-diff` 直接实现 `AdapterFn`,调用 `docx-diff` CLI 产出差异报告。两个 agent 均跳过 LLM extraction,直接 `schema.parse(JSON.parse(raw))`。 + +**Tech Stack:** Bun, TypeScript (NodeNext), zod/v4, Biome, bun:test + +**Branch:** `user/jiayiyan/feat_office-agent-document-template`(已从最新 main 创建) + +--- + +## 文件清单 + +### workflow-template-document +| 操作 | 路径 | +|------|------| +| 新建 | `packages/workflow-template-document/package.json` | +| 新建 | `packages/workflow-template-document/tsconfig.json` | +| 新建 | `packages/workflow-template-document/src/types.ts` | +| 新建 | `packages/workflow-template-document/src/roles/writer.ts` | +| 新建 | `packages/workflow-template-document/src/roles/differ.ts` | +| 新建 | `packages/workflow-template-document/src/roles/index.ts` | +| 新建 | `packages/workflow-template-document/src/roles.ts` | +| 新建 | `packages/workflow-template-document/src/moderator.ts` | +| 新建 | `packages/workflow-template-document/src/descriptor.ts` | +| 新建 | `packages/workflow-template-document/src/index.ts` | +| 新建 | `packages/workflow-template-document/__tests__/document-template.test.ts` | + +### workflow-agent-office +| 操作 | 路径 | +|------|------| +| 新建 | `packages/workflow-agent-office/package.json` | +| 新建 | `packages/workflow-agent-office/tsconfig.json` | +| 新建 | `packages/workflow-agent-office/src/types.ts` | +| 新建 | `packages/workflow-agent-office/src/runner.ts` | +| 新建 | `packages/workflow-agent-office/src/agent.ts` | +| 新建 | `packages/workflow-agent-office/src/package-descriptor.ts` | +| 新建 | `packages/workflow-agent-office/src/index.ts` | +| 新建 | `packages/workflow-agent-office/__tests__/runner.test.ts` | +| 新建 | `packages/workflow-agent-office/__tests__/agent.test.ts` | + +### workflow-agent-docx-diff +| 操作 | 路径 | +|------|------| +| 新建 | `packages/workflow-agent-docx-diff/package.json` | +| 新建 | `packages/workflow-agent-docx-diff/tsconfig.json` | +| 新建 | `packages/workflow-agent-docx-diff/src/types.ts` | +| 新建 | `packages/workflow-agent-docx-diff/src/runner.ts` | +| 新建 | `packages/workflow-agent-docx-diff/src/agent.ts` | +| 新建 | `packages/workflow-agent-docx-diff/src/package-descriptor.ts` | +| 新建 | `packages/workflow-agent-docx-diff/src/index.ts` | +| 新建 | `packages/workflow-agent-docx-diff/__tests__/runner.test.ts` | +| 新建 | `packages/workflow-agent-docx-diff/__tests__/agent.test.ts` | + +### 根目录修改 +| 操作 | 路径 | +|------|------| +| 修改 | `tsconfig.json` — 追加三个 references | +| 修改 | `docs/architecture.md` — 新增三个包的描述 | + +--- + +## Phase 1:workflow-template-document + +### Task 1:包脚手架 + +**Files:** +- 新建: `packages/workflow-template-document/package.json` +- 新建: `packages/workflow-template-document/tsconfig.json` + +- [ ] **Step 1:创建 package.json** + +```json +{ + "name": "@uncaged/workflow-template-document", + "version": "0.1.0", + "files": ["src", "dist", "package.json"], + "type": "module", + "types": "src/index.ts", + "exports": { + ".": { + "bun": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "test": "bun test" + }, + "dependencies": { + "@uncaged/workflow-register": "workspace:^", + "@uncaged/workflow-runtime": "workspace:^", + "zod": "^4.0.0" + }, + "devDependencies": { + "@uncaged/workflow-protocol": "workspace:^" + }, + "publishConfig": { + "access": "public" + } +} +``` + +- [ ] **Step 2:创建 tsconfig.json** + +```json +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "composite": true + }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../workflow-protocol" }, + { "path": "../workflow-runtime" }, + { "path": "../workflow-register" } + ] +} +``` + +- [ ] **Step 3:在根 tsconfig.json 的 `references` 数组末尾追加** + +```json +{ "path": "packages/workflow-template-document" } +``` + +- [ ] **Step 4:安装依赖** + +```bash +cd packages/workflow-template-document && bun install +``` + +Expected:lockfile 更新,无报错。 + +--- + +### Task 2:类型 + 角色 schema(TDD) + +**Files:** +- 新建: `packages/workflow-template-document/src/types.ts` +- 新建: `packages/workflow-template-document/src/roles/writer.ts` +- 新建: `packages/workflow-template-document/src/roles/differ.ts` +- 新建: `packages/workflow-template-document/src/roles/index.ts` +- 新建: `packages/workflow-template-document/__tests__/document-template.test.ts`(先写失败测试) + +- [ ] **Step 1:创建占位 index.ts** + +```typescript +// src/index.ts +export {}; +``` + +- [ ] **Step 2:写失败测试** + +```typescript +// __tests__/document-template.test.ts +import { describe, expect, test } from "bun:test"; +import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js"; +import { validateWorkflowDescriptor } from "@uncaged/workflow-register"; +import { END, type ModeratorContext, type RoleStep, START } from "@uncaged/workflow-runtime"; +import { buildDocumentDescriptor } from "../src/descriptor.js"; +import { documentTable } from "../src/moderator.js"; +import type { DifferMeta, WriterMeta } from "../src/roles/index.js"; +import type { DocumentMeta } from "../src/roles.js"; + +const documentModerator = tableToModerator(documentTable); + +function makeCtx( + steps: ModeratorContext["steps"], +): ModeratorContext { + return { + threadId: "01TEST000000000000000000TR", + depth: 0, + bundleHash: "TESTHASH00001", + start: { role: START, content: "", meta: {}, timestamp: 0, parentState: null }, + steps, + }; +} + +function writerGenerateStep(): RoleStep { + return { + role: "writer", + contentHash: "STUBHASHWRITER001", + meta: { mode: "generate", outputDocx: "/out/output.docx", sourceDocx: null } satisfies WriterMeta, + refs: [], + timestamp: 1, + }; +} + +function writerEditStep(): RoleStep { + return { + role: "writer", + contentHash: "STUBHASHWRITER002", + meta: { mode: "edit", outputDocx: "/out/modified.docx", sourceDocx: "/out/original.docx" } satisfies WriterMeta, + refs: [], + timestamp: 1, + }; +} + +function differStep(): RoleStep { + return { + role: "differ", + contentHash: "STUBHASHDIFF001", + meta: { + sourceDocx: "/out/original.docx", + modifiedDocx: "/out/modified.docx", + diffDocx: "/out/diff.docx", + } satisfies DifferMeta, + refs: [], + timestamp: 2, + }; +} + +describe("documentTable", () => { + test("START → writer", () => { + expect(documentModerator(makeCtx([]))).toBe("writer"); + }); + + test("writer (generate) → END", () => { + expect(documentModerator(makeCtx([writerGenerateStep()]))).toBe(END); + }); + + test("writer (edit) → differ", () => { + expect(documentModerator(makeCtx([writerEditStep()]))).toBe("differ"); + }); + + test("differ → END", () => { + expect(documentModerator(makeCtx([writerEditStep(), differStep()]))).toBe(END); + }); +}); + +describe("buildDocumentDescriptor", () => { + test("descriptor passes validation", () => { + const descriptor = buildDocumentDescriptor(); + expect(() => validateWorkflowDescriptor(descriptor)).not.toThrow(); + }); + + test("descriptor has writer and differ roles", () => { + const descriptor = buildDocumentDescriptor(); + expect(Object.keys(descriptor.roles)).toContain("writer"); + expect(Object.keys(descriptor.roles)).toContain("differ"); + }); +}); +``` + +- [ ] **Step 3:运行测试,确认失败** + +```bash +cd packages/workflow-template-document && bun test +``` + +Expected:`Cannot find module '../src/moderator.js'` 或类似模块未找到错误。 + +- [ ] **Step 4:创建 src/types.ts** + +```typescript +export type DocumentStartInput = { + prompt: string; + inputDocx: string | null; +}; +``` + +- [ ] **Step 5:创建 src/roles/writer.ts** + +```typescript +import type { RoleDefinition } from "@uncaged/workflow-runtime"; +import * as z from "zod/v4"; + +export const writerMetaSchema = z.discriminatedUnion("mode", [ + z.object({ + mode: z.literal("generate"), + outputDocx: z.string(), + sourceDocx: z.null(), + }), + z.object({ + mode: z.literal("edit"), + outputDocx: z.string(), + sourceDocx: z.string(), + }), +]); + +export type WriterMeta = z.infer; + +export const writerRole: RoleDefinition = { + description: "Generates or modifies a Word document via an external agent.", + systemPrompt: "", + schema: writerMetaSchema, +}; +``` + +- [ ] **Step 6:创建 src/roles/differ.ts** + +```typescript +import type { RoleDefinition } from "@uncaged/workflow-runtime"; +import * as z from "zod/v4"; + +export const differMetaSchema = z.object({ + sourceDocx: z.string(), + modifiedDocx: z.string(), + diffDocx: z.string(), +}); + +export type DifferMeta = z.infer; + +export const differRole: RoleDefinition = { + description: "Produces a Word-format diff report of the writer's changes (edit mode only).", + systemPrompt: "", + schema: differMetaSchema, +}; +``` + +- [ ] **Step 7:创建 src/roles/index.ts** + +```typescript +export { differMetaSchema, differRole } from "./differ.js"; +export type { DifferMeta } from "./differ.js"; +export { writerMetaSchema, writerRole } from "./writer.js"; +export type { WriterMeta } from "./writer.js"; +``` + +--- + +### Task 3:roles + moderator + descriptor + index 收尾 + +**Files:** +- 新建: `packages/workflow-template-document/src/roles.ts` +- 新建: `packages/workflow-template-document/src/moderator.ts` +- 新建: `packages/workflow-template-document/src/descriptor.ts` +- 修改: `packages/workflow-template-document/src/index.ts` + +- [ ] **Step 1:创建 src/roles.ts** + +```typescript +import type { RoleDefinition } from "@uncaged/workflow-runtime"; +import { type DifferMeta, differRole } from "./roles/differ.js"; +import { type WriterMeta, writerRole } from "./roles/writer.js"; + +export const DOCUMENT_WORKFLOW_DESCRIPTION = + "Generates a new Word document from a prompt, or edits an existing one and produces a diff report."; + +export type DocumentMeta = { + writer: WriterMeta; + differ: DifferMeta; +}; + +export type DocumentRoles = { + [K in keyof DocumentMeta]: RoleDefinition; +}; + +export const documentRoles: DocumentRoles = { + writer: writerRole, + differ: differRole, +}; +``` + +- [ ] **Step 2:创建 src/moderator.ts** + +```typescript +import { + END, + type ModeratorCondition, + type ModeratorTable, + START, +} from "@uncaged/workflow-runtime"; +import type { DocumentMeta } from "./roles.js"; +import type { WriterMeta } from "./roles/writer.js"; + +const writerIsEditMode: ModeratorCondition = { + name: "writerIsEditMode", + description: "Writer ran in edit mode and produced a modified document", + check: (ctx) => { + const writerStep = ctx.steps.find((s) => s.role === "writer"); + if (writerStep === undefined) return false; + return (writerStep.meta as WriterMeta).mode === "edit"; + }, +}; + +export const documentTable: ModeratorTable = { + [START]: [{ condition: "FALLBACK", role: "writer" }], + writer: [ + { condition: writerIsEditMode, role: "differ" }, + { condition: "FALLBACK", role: END }, + ], + differ: [{ condition: "FALLBACK", role: END }], +}; +``` + +- [ ] **Step 3:创建 src/descriptor.ts** + +```typescript +import { buildDescriptor } from "@uncaged/workflow-register"; +import { documentTable } from "./moderator.js"; +import { DOCUMENT_WORKFLOW_DESCRIPTION, documentRoles } from "./roles.js"; + +export function buildDocumentDescriptor() { + return buildDescriptor({ + description: DOCUMENT_WORKFLOW_DESCRIPTION, + roles: documentRoles, + table: documentTable, + }); +} +``` + +- [ ] **Step 4:更新 src/index.ts** + +```typescript +import type { WorkflowDefinition } from "@uncaged/workflow-runtime"; +import { documentTable } from "./moderator.js"; +import { + DOCUMENT_WORKFLOW_DESCRIPTION, + type DocumentMeta, + type DocumentRoles, + documentRoles, +} from "./roles.js"; + +export { buildDocumentDescriptor } from "./descriptor.js"; +export { documentTable } from "./moderator.js"; +export { + type DifferMeta, + differMetaSchema, + differRole, + type WriterMeta, + writerMetaSchema, + writerRole, +} from "./roles/index.js"; +export { + DOCUMENT_WORKFLOW_DESCRIPTION, + type DocumentMeta, + type DocumentRoles, + documentRoles, +} from "./roles.js"; +export type { DocumentStartInput } from "./types.js"; + +export const documentWorkflowDefinition: WorkflowDefinition = { + description: DOCUMENT_WORKFLOW_DESCRIPTION, + roles: documentRoles, + table: documentTable, +}; +``` + +- [ ] **Step 5:运行测试,确认通过** + +```bash +cd packages/workflow-template-document && bun test +``` + +Expected:6 tests pass(4 moderator + 2 descriptor)。 + +- [ ] **Step 6:运行全量构建检查** + +```bash +cd /Users/yanjiayi/workspace/workflow && bun run check +``` + +Expected:无 TypeScript 错误,无 Biome 警告。 + +- [ ] **Step 7:Commit** + +```bash +git add packages/workflow-template-document/ tsconfig.json +git commit -m "feat(template): add workflow-template-document with writer/differ roles and moderator" +``` + +--- + +## Phase 2:workflow-agent-office + +### Task 4:包脚手架 + +**Files:** +- 新建: `packages/workflow-agent-office/package.json` +- 新建: `packages/workflow-agent-office/tsconfig.json` + +- [ ] **Step 1:创建 package.json** + +```json +{ + "name": "@uncaged/workflow-agent-office", + "version": "0.1.0", + "files": ["src", "dist", "package.json"], + "type": "module", + "types": "src/index.ts", + "exports": { + ".": { + "bun": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "test": "bun test" + }, + "dependencies": { + "@uncaged/workflow-runtime": "workspace:^", + "@uncaged/workflow-util": "workspace:^", + "@uncaged/workflow-util-agent": "workspace:^" + }, + "publishConfig": { + "access": "public" + } +} +``` + +- [ ] **Step 2:创建 tsconfig.json** + +```json +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "composite": true + }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../workflow-protocol" }, + { "path": "../workflow-runtime" }, + { "path": "../workflow-util" }, + { "path": "../workflow-util-agent" } + ] +} +``` + +- [ ] **Step 3:在根 tsconfig.json 的 `references` 数组末尾追加** + +```json +{ "path": "packages/workflow-agent-office" } +``` + +- [ ] **Step 4:安装依赖** + +```bash +cd packages/workflow-agent-office && bun install +``` + +--- + +### Task 5:runner 实现(TDD) + +**Files:** +- 新建: `packages/workflow-agent-office/src/types.ts` +- 新建: `packages/workflow-agent-office/src/runner.ts` +- 新建: `packages/workflow-agent-office/__tests__/runner.test.ts` + +- [ ] **Step 1:写失败测试** + +```typescript +// __tests__/runner.test.ts +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { describe, expect, mock, test } from "bun:test"; +import { ok, err } from "@uncaged/workflow-util"; +import type { SpawnCliConfig } from "@uncaged/workflow-util-agent"; +import { editDocument, generateDocument } from "../src/runner.js"; + +type MockSpawnResult = Awaited>; + +function makeSpawn(result: MockSpawnResult) { + return mock(async (_cmd: string, _args: string[], _opts: SpawnCliConfig) => result); +} + +function tempDir(): string { + const dir = join(tmpdir(), `office-test-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +describe("generateDocument", () => { + test("calls office-agent create with correct args and returns outputDocx path", async () => { + const base = tempDir(); + const spawnFn = makeSpawn(ok("agent reply") as MockSpawnResult); + // Simulate CLI creating the file + const outFile = join(base, "thread1", "output.docx"); + mkdirSync(join(base, "thread1"), { recursive: true }); + writeFileSync(outFile, ""); + + const result = await generateDocument( + { outputDir: base, command: "office-agent", timeout: null }, + "thread1", + "Write a report", + spawnFn, + ); + + expect(result.outputDocx).toBe(outFile); + expect(result.sourceDocx).toBeNull(); + expect(spawnFn.mock.calls[0][0]).toBe("office-agent"); + expect(spawnFn.mock.calls[0][1]).toEqual(["create", "Write a report", "-o", "output.docx"]); + expect(spawnFn.mock.calls[0][2].cwd).toBe(join(base, "thread1")); + }); + + test("uses PATH office-agent when command is null", async () => { + const base = tempDir(); + const spawnFn = makeSpawn(ok("") as MockSpawnResult); + mkdirSync(join(base, "t2"), { recursive: true }); + writeFileSync(join(base, "t2", "output.docx"), ""); + + await generateDocument( + { outputDir: base, command: null, timeout: null }, + "t2", + "Generate", + spawnFn, + ); + + expect(spawnFn.mock.calls[0][0]).toBe("office-agent"); + }); + + test("throws on non_zero_exit", async () => { + const base = tempDir(); + const spawnFn = makeSpawn( + err({ kind: "non_zero_exit", exitCode: 1, stdout: "", stderr: "error" }) as MockSpawnResult, + ); + + await expect( + generateDocument({ outputDir: base, command: null, timeout: null }, "t3", "fail", spawnFn), + ).rejects.toThrow("office-agent failed (exit 1)"); + }); + + test("throws on timeout", async () => { + const base = tempDir(); + const spawnFn = makeSpawn(err({ kind: "timeout" }) as MockSpawnResult); + + await expect( + generateDocument({ outputDir: base, command: null, timeout: null }, "t4", "slow", spawnFn), + ).rejects.toThrow("office-agent: timed out"); + }); + + test("throws when output file not created", async () => { + const base = tempDir(); + const spawnFn = makeSpawn(ok("") as MockSpawnResult); + // Do NOT create output.docx + + await expect( + generateDocument({ outputDir: base, command: null, timeout: null }, "t5", "no file", spawnFn), + ).rejects.toThrow("output file not found"); + }); +}); + +describe("editDocument", () => { + test("copies input to original.docx and modified.docx, calls edit, returns paths", async () => { + const base = tempDir(); + // Create a fake inputDocx + const inputFile = join(base, "source.docx"); + writeFileSync(inputFile, "original content"); + + const spawnFn = makeSpawn(ok("") as MockSpawnResult); + // Simulate CLI overwriting modified.docx + const outDir = join(base, "te1"); + mkdirSync(outDir, { recursive: true }); + writeFileSync(join(outDir, "modified.docx"), "modified content"); + + const result = await editDocument( + { outputDir: base, command: "office-agent", timeout: null }, + "te1", + "Edit the doc", + inputFile, + spawnFn, + ); + + expect(result.outputDocx).toBe(join(outDir, "modified.docx")); + expect(result.sourceDocx).toBe(join(outDir, "original.docx")); + expect(spawnFn.mock.calls[0][1]).toEqual(["edit", "modified.docx", "Edit the doc"]); + }); + + test("throws on spawn_failed", async () => { + const base = tempDir(); + const inputFile = join(base, "src.docx"); + writeFileSync(inputFile, ""); + const spawnFn = makeSpawn( + err({ kind: "spawn_failed", message: "not found" }) as MockSpawnResult, + ); + + await expect( + editDocument({ outputDir: base, command: null, timeout: null }, "te2", "edit", inputFile, spawnFn), + ).rejects.toThrow("spawn failed"); + }); +}); +``` + +- [ ] **Step 2:运行测试,确认失败** + +```bash +cd packages/workflow-agent-office && bun test __tests__/runner.test.ts +``` + +Expected:`Cannot find module '../src/runner.js'` + +- [ ] **Step 3:创建 src/types.ts** + +```typescript +export type OfficeAgentConfig = { + outputDir: string; + command: string | null; + timeout: number | null; +}; +``` + +- [ ] **Step 4:创建 src/runner.ts** + +```typescript +import { copyFile, mkdir, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { spawnCli } from "@uncaged/workflow-util-agent"; +import type { OfficeAgentConfig } from "./types.js"; + +type SpawnCliFn = typeof spawnCli; + +function throwSpawnError(e: Awaited> extends { ok: false; error: infer E } ? E : never): never { + if (e.kind === "non_zero_exit") + throw new Error(`office-agent failed (exit ${e.exitCode}): ${e.stderr}`); + if (e.kind === "timeout") + throw new Error("office-agent: timed out"); + throw new Error(`office-agent: spawn failed: ${e.message}`); +} + +async function assertFileExists(path: string): Promise { + try { + await stat(path); + } catch { + throw new Error(`office-agent: output file not found: ${path}`); + } +} + +export async function generateDocument( + config: OfficeAgentConfig, + threadId: string, + prompt: string, + spawnCliFn: SpawnCliFn = spawnCli, +): Promise<{ outputDocx: string; sourceDocx: null }> { + const outputDir = join(config.outputDir, threadId); + await mkdir(outputDir, { recursive: true }); + const command = config.command ?? "office-agent"; + const result = await spawnCliFn(command, ["create", prompt, "-o", "output.docx"], { + cwd: outputDir, + timeoutMs: config.timeout, + }); + if (!result.ok) throwSpawnError(result.error); + const outputDocx = join(outputDir, "output.docx"); + await assertFileExists(outputDocx); + return { outputDocx, sourceDocx: null }; +} + +export async function editDocument( + config: OfficeAgentConfig, + threadId: string, + prompt: string, + inputDocx: string, + spawnCliFn: SpawnCliFn = spawnCli, +): Promise<{ outputDocx: string; sourceDocx: string }> { + const outputDir = join(config.outputDir, threadId); + await mkdir(outputDir, { recursive: true }); + const originalDocx = join(outputDir, "original.docx"); + const modifiedDocx = join(outputDir, "modified.docx"); + await copyFile(inputDocx, originalDocx); + await copyFile(inputDocx, modifiedDocx); + const command = config.command ?? "office-agent"; + const result = await spawnCliFn(command, ["edit", "modified.docx", prompt], { + cwd: outputDir, + timeoutMs: config.timeout, + }); + if (!result.ok) throwSpawnError(result.error); + await assertFileExists(modifiedDocx); + return { outputDocx: modifiedDocx, sourceDocx: originalDocx }; +} +``` + +> **注意**:`throwSpawnError` 的参数类型可以简化为 `import type { SpawnCliError } from "@uncaged/workflow-util-agent"` 直接 import,避免复杂类型推导。把 `throwSpawnError` 的签名改成: +> ```typescript +> import type { SpawnCliError } from "@uncaged/workflow-util-agent"; +> function throwSpawnError(e: SpawnCliError): never { ... } +> ``` + +- [ ] **Step 5:运行测试,确认通过** + +```bash +cd packages/workflow-agent-office && bun test __tests__/runner.test.ts +``` + +Expected:7 tests pass。 + +--- + +### Task 6:agent + package-descriptor + index(TDD) + +**Files:** +- 新建: `packages/workflow-agent-office/src/agent.ts` +- 新建: `packages/workflow-agent-office/src/package-descriptor.ts` +- 修改: `packages/workflow-agent-office/src/index.ts`(新建) + +- [ ] **Step 1:写 agent 测试** + +```typescript +// __tests__/agent.test.ts +import { describe, expect, test } from "bun:test"; +import { packageDescriptor } from "../src/package-descriptor.js"; +import { createOfficeAgent } from "../src/agent.js"; + +describe("createOfficeAgent", () => { + test("returns an AdapterFn (function)", () => { + const agent = createOfficeAgent({ outputDir: "/tmp", command: null, timeout: null }); + expect(typeof agent).toBe("function"); + }); + + test("AdapterFn returns a RoleFn (function)", () => { + const agent = createOfficeAgent({ outputDir: "/tmp", command: null, timeout: null }); + const roleFn = agent("", expect.anything() as never); + expect(typeof roleFn).toBe("function"); + }); +}); + +describe("packageDescriptor", () => { + test("has correct name", () => { + expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-office"); + }); + + test("has outputDir in configSchema required", () => { + const schema = packageDescriptor.configSchema as { required: string[] }; + expect(schema.required).toContain("outputDir"); + }); +}); +``` + +- [ ] **Step 2:运行测试,确认失败** + +```bash +cd packages/workflow-agent-office && bun test __tests__/agent.test.ts +``` + +Expected:`Cannot find module '../src/agent.js'` + +- [ ] **Step 3:创建 src/agent.ts** + +```typescript +import * as z from "zod/v4"; +import { join } from "node:path"; +import type { AdapterFn, RoleResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime"; +import { createLogger } from "@uncaged/workflow-util"; +import { editDocument, generateDocument } from "./runner.js"; +import type { OfficeAgentConfig } from "./types.js"; + +const log = createLogger(); + +type ParsedInput = { prompt: string; inputDocx: string | null }; + +function parseStartInput(content: string): ParsedInput { + try { + const parsed = JSON.parse(content) as Record; + if (typeof parsed.prompt === "string") { + return { + prompt: parsed.prompt, + inputDocx: typeof parsed.inputDocx === "string" ? parsed.inputDocx : null, + }; + } + } catch { + // not JSON — treat whole content as prompt, generate mode + } + return { prompt: content, inputDocx: null }; +} + +export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn { + return (_systemPrompt: string, schema: z.ZodType) => + async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise> => { + const { prompt, inputDocx } = parseStartInput(ctx.start.content); + log("8FQKP3NV", `office-agent: mode=${inputDocx === null ? "generate" : "edit"} thread=${ctx.threadId}`); + + let raw: string; + if (inputDocx === null) { + const result = await generateDocument(config, ctx.threadId, prompt); + raw = JSON.stringify({ mode: "generate", outputDocx: result.outputDocx, sourceDocx: null }); + } else { + const result = await editDocument(config, ctx.threadId, prompt, inputDocx); + raw = JSON.stringify({ mode: "edit", outputDocx: result.outputDocx, sourceDocx: result.sourceDocx }); + } + + const meta = schema.parse(JSON.parse(raw)) as T; + return { meta, childThread: null }; + }; +} +``` + +- [ ] **Step 4:创建 src/package-descriptor.ts** + +```typescript +import type { PackageDescriptor } from "@uncaged/workflow-runtime"; + +export const packageDescriptor: PackageDescriptor = { + name: "@uncaged/workflow-agent-office", + version: "0.1.0", + capabilities: ["office-agent-cli", "docx-generate", "docx-edit"], + configSchema: { + type: "object", + required: ["outputDir"], + properties: { + outputDir: { + type: "string", + description: "Root directory for workflow outputs; subdirs are created per threadId.", + }, + command: { + anyOf: [{ type: "string" }, { type: "null" }], + description: "Path to office-agent CLI binary; null uses PATH.", + }, + timeout: { + anyOf: [{ type: "number" }, { type: "null" }], + description: "Timeout in milliseconds; null means no limit.", + }, + }, + additionalProperties: false, + }, +}; +``` + +- [ ] **Step 5:创建 src/index.ts** + +```typescript +export { createOfficeAgent } from "./agent.js"; +export { packageDescriptor } from "./package-descriptor.js"; +export type { OfficeAgentConfig } from "./types.js"; +``` + +- [ ] **Step 6:运行所有测试,确认通过** + +```bash +cd packages/workflow-agent-office && bun test +``` + +Expected:全部通过(runner + agent)。 + +- [ ] **Step 7:运行全量构建检查** + +```bash +cd /Users/yanjiayi/workspace/workflow && bun run check +``` + +Expected:无 TypeScript 错误,无 Biome 警告。 + +- [ ] **Step 8:Commit** + +```bash +git add packages/workflow-agent-office/ tsconfig.json +git commit -m "feat(agent): add workflow-agent-office with generate/edit AdapterFn" +``` + +--- + +## Phase 3:workflow-agent-docx-diff + +### Task 7:包脚手架 + +**Files:** +- 新建: `packages/workflow-agent-docx-diff/package.json` +- 新建: `packages/workflow-agent-docx-diff/tsconfig.json` + +- [ ] **Step 1:创建 package.json** + +```json +{ + "name": "@uncaged/workflow-agent-docx-diff", + "version": "0.1.0", + "files": ["src", "dist", "package.json"], + "type": "module", + "types": "src/index.ts", + "exports": { + ".": { + "bun": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "test": "bun test" + }, + "dependencies": { + "@uncaged/workflow-runtime": "workspace:^", + "@uncaged/workflow-util-agent": "workspace:^", + "@uncaged/workflow-template-document": "workspace:^" + }, + "publishConfig": { + "access": "public" + } +} +``` + +- [ ] **Step 2:创建 tsconfig.json** + +```json +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "composite": true + }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../workflow-protocol" }, + { "path": "../workflow-runtime" }, + { "path": "../workflow-util-agent" }, + { "path": "../workflow-template-document" } + ] +} +``` + +- [ ] **Step 3:在根 tsconfig.json 的 `references` 数组末尾追加** + +```json +{ "path": "packages/workflow-agent-docx-diff" } +``` + +- [ ] **Step 4:安装依赖** + +```bash +cd packages/workflow-agent-docx-diff && bun install +``` + +--- + +### Task 8:runner 实现(TDD) + +**Files:** +- 新建: `packages/workflow-agent-docx-diff/src/types.ts` +- 新建: `packages/workflow-agent-docx-diff/src/runner.ts` +- 新建: `packages/workflow-agent-docx-diff/__tests__/runner.test.ts` + +- [ ] **Step 1:写失败测试** + +```typescript +// __tests__/runner.test.ts +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { describe, expect, mock, test } from "bun:test"; +import { ok, err } from "@uncaged/workflow-util"; +import type { SpawnCliConfig } from "@uncaged/workflow-util-agent"; +import { runDocxDiff } from "../src/runner.js"; + +type MockSpawnResult = Awaited>; + +function makeSpawn(result: MockSpawnResult) { + return mock(async (_cmd: string, _args: string[], _opts: SpawnCliConfig) => result); +} + +function tempDir(): string { + const dir = join(tmpdir(), `diff-test-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +describe("runDocxDiff", () => { + test("exit 0: success, returns DifferMeta JSON", async () => { + const dir = tempDir(); + const sourceDocx = join(dir, "original.docx"); + const modifiedDocx = join(dir, "modified.docx"); + const diffDocx = join(dir, "diff.docx"); + writeFileSync(sourceDocx, ""); + writeFileSync(modifiedDocx, ""); + + const spawnFn = makeSpawn(ok("") as MockSpawnResult); + // simulate docx-diff creating the diff file + writeFileSync(diffDocx, ""); + + const raw = await runDocxDiff( + { command: "docx-diff" }, + sourceDocx, + modifiedDocx, + diffDocx, + spawnFn, + ); + const meta = JSON.parse(raw); + expect(meta.sourceDocx).toBe(sourceDocx); + expect(meta.modifiedDocx).toBe(modifiedDocx); + expect(meta.diffDocx).toBe(diffDocx); + + expect(spawnFn.mock.calls[0][1]).toEqual([ + sourceDocx, + modifiedDocx, + "--output", + "docx", + "--out-file", + diffDocx, + ]); + }); + + test("exit 1 (changes found): treated as success", async () => { + const dir = tempDir(); + const sourceDocx = join(dir, "s.docx"); + const modifiedDocx = join(dir, "m.docx"); + const diffDocx = join(dir, "diff.docx"); + writeFileSync(sourceDocx, ""); + writeFileSync(modifiedDocx, ""); + writeFileSync(diffDocx, ""); + + const spawnFn = makeSpawn( + err({ kind: "non_zero_exit", exitCode: 1, stdout: "", stderr: "" }) as MockSpawnResult, + ); + + await expect( + runDocxDiff({ command: "docx-diff" }, sourceDocx, modifiedDocx, diffDocx, spawnFn), + ).resolves.toBeDefined(); + }); + + test("exit 2: throws error", async () => { + const dir = tempDir(); + const spawnFn = makeSpawn( + err({ kind: "non_zero_exit", exitCode: 2, stdout: "", stderr: "fatal error" }) as MockSpawnResult, + ); + + await expect( + runDocxDiff({ command: null }, "s.docx", "m.docx", "diff.docx", spawnFn), + ).rejects.toThrow("docx-diff failed"); + }); + + test("timeout: throws error", async () => { + const spawnFn = makeSpawn(err({ kind: "timeout" }) as MockSpawnResult); + + await expect( + runDocxDiff({ command: null }, "s.docx", "m.docx", "diff.docx", spawnFn), + ).rejects.toThrow("timed out"); + }); + + test("throws when diff file not created", async () => { + const dir = tempDir(); + const spawnFn = makeSpawn(ok("") as MockSpawnResult); + // do NOT create diffDocx + + await expect( + runDocxDiff({ command: null }, "s.docx", "m.docx", join(dir, "missing.docx"), spawnFn), + ).rejects.toThrow("diff file not found"); + }); + + test("uses PATH docx-diff when command is null", async () => { + const dir = tempDir(); + const diffDocx = join(dir, "diff.docx"); + writeFileSync(diffDocx, ""); + const spawnFn = makeSpawn(ok("") as MockSpawnResult); + + await runDocxDiff({ command: null }, "s.docx", "m.docx", diffDocx, spawnFn); + + expect(spawnFn.mock.calls[0][0]).toBe("docx-diff"); + }); +}); +``` + +- [ ] **Step 2:运行测试,确认失败** + +```bash +cd packages/workflow-agent-docx-diff && bun test __tests__/runner.test.ts +``` + +Expected:`Cannot find module '../src/runner.js'` + +- [ ] **Step 3:创建 src/types.ts** + +```typescript +export type DocxDiffAgentConfig = { + command: string | null; +}; +``` + +- [ ] **Step 4:创建 src/runner.ts** + +```typescript +import { stat } from "node:fs/promises"; +import { spawnCli } from "@uncaged/workflow-util-agent"; +import type { SpawnCliError } from "@uncaged/workflow-util-agent"; +import type { DocxDiffAgentConfig } from "./types.js"; + +type SpawnCliFn = typeof spawnCli; + +function throwSpawnError(e: SpawnCliError): never { + if (e.kind === "non_zero_exit") + throw new Error(`docx-diff failed (exit ${e.exitCode}): ${e.stderr}`); + if (e.kind === "timeout") + throw new Error("docx-diff: timed out"); + throw new Error(`docx-diff: spawn failed: ${e.message}`); +} + +export async function runDocxDiff( + config: DocxDiffAgentConfig, + sourceDocx: string, + modifiedDocx: string, + diffDocx: string, + spawnCliFn: SpawnCliFn = spawnCli, +): Promise { + const command = config.command ?? "docx-diff"; + const result = await spawnCliFn( + command, + [sourceDocx, modifiedDocx, "--output", "docx", "--out-file", diffDocx], + { cwd: null, timeoutMs: null }, + ); + + if (!result.ok) { + const e = result.error; + // exit 1 = changes found (normal) + if (e.kind === "non_zero_exit" && e.exitCode === 1) { + // fall through to file check + } else { + throwSpawnError(e); + } + } + + try { + await stat(diffDocx); + } catch { + throw new Error(`docx-diff: diff file not found: ${diffDocx}`); + } + + return JSON.stringify({ sourceDocx, modifiedDocx, diffDocx }); +} +``` + +- [ ] **Step 5:运行测试,确认通过** + +```bash +cd packages/workflow-agent-docx-diff && bun test __tests__/runner.test.ts +``` + +Expected:6 tests pass。 + +--- + +### Task 9:agent + package-descriptor + index(TDD) + +**Files:** +- 新建: `packages/workflow-agent-docx-diff/src/agent.ts` +- 新建: `packages/workflow-agent-docx-diff/src/package-descriptor.ts` +- 新建: `packages/workflow-agent-docx-diff/src/index.ts` +- 新建: `packages/workflow-agent-docx-diff/__tests__/agent.test.ts` + +- [ ] **Step 1:写 agent 测试** + +```typescript +// __tests__/agent.test.ts +import { describe, expect, test } from "bun:test"; +import { packageDescriptor } from "../src/package-descriptor.js"; +import { createDocxDiffAgent } from "../src/agent.js"; + +describe("createDocxDiffAgent", () => { + test("returns an AdapterFn (function)", () => { + const agent = createDocxDiffAgent({ command: null }); + expect(typeof agent).toBe("function"); + }); + + test("AdapterFn returns a RoleFn (function)", () => { + const agent = createDocxDiffAgent({ command: null }); + const roleFn = agent("", expect.anything() as never); + expect(typeof roleFn).toBe("function"); + }); +}); + +describe("packageDescriptor", () => { + test("has correct name", () => { + expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-docx-diff"); + }); +}); +``` + +- [ ] **Step 2:运行测试,确认失败** + +```bash +cd packages/workflow-agent-docx-diff && bun test __tests__/agent.test.ts +``` + +Expected:`Cannot find module '../src/agent.js'` + +- [ ] **Step 3:创建 src/agent.ts** + +```typescript +import * as z from "zod/v4"; +import { join, dirname } from "node:path"; +import type { AdapterFn, RoleResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime"; +import type { WriterMeta } from "@uncaged/workflow-template-document"; +import { runDocxDiff } from "./runner.js"; +import type { DocxDiffAgentConfig } from "./types.js"; + +export function createDocxDiffAgent(config: DocxDiffAgentConfig): AdapterFn { + return (_prompt: string, schema: z.ZodType) => + async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise> => { + const writerStep = ctx.steps.find((s) => s.role === "writer"); + if (writerStep === undefined) throw new Error("differ: no writer step found"); + + const writerMeta = writerStep.meta as WriterMeta; + if (writerMeta.mode !== "edit") + throw new Error("differ: writer did not run in edit mode"); + + const diffDocx = join(dirname(writerMeta.outputDocx), "diff.docx"); + const raw = await runDocxDiff( + config, + writerMeta.sourceDocx, + writerMeta.outputDocx, + diffDocx, + ); + + const meta = schema.parse(JSON.parse(raw)) as T; + return { meta, childThread: null }; + }; +} +``` + +- [ ] **Step 4:创建 src/package-descriptor.ts** + +```typescript +import type { PackageDescriptor } from "@uncaged/workflow-runtime"; + +export const packageDescriptor: PackageDescriptor = { + name: "@uncaged/workflow-agent-docx-diff", + version: "0.1.0", + capabilities: ["docx-diff-cli", "docx-diff-report"], + configSchema: { + type: "object", + properties: { + command: { + anyOf: [{ type: "string" }, { type: "null" }], + description: "Path to docx-diff CLI binary; null uses PATH.", + }, + }, + additionalProperties: false, + }, +}; +``` + +- [ ] **Step 5:创建 src/index.ts** + +```typescript +export { createDocxDiffAgent } from "./agent.js"; +export { packageDescriptor } from "./package-descriptor.js"; +export type { DocxDiffAgentConfig } from "./types.js"; +``` + +- [ ] **Step 6:运行所有测试,确认通过** + +```bash +cd packages/workflow-agent-docx-diff && bun test +``` + +Expected:全部通过(runner + agent)。 + +- [ ] **Step 7:运行全量构建检查** + +```bash +cd /Users/yanjiayi/workspace/workflow && bun run check +``` + +Expected:无 TypeScript 错误,无 Biome 警告。 + +- [ ] **Step 8:Commit** + +```bash +git add packages/workflow-agent-docx-diff/ tsconfig.json +git commit -m "feat(agent): add workflow-agent-docx-diff with docx-diff AdapterFn" +``` + +--- + +## Phase 4:收尾 + +### Task 10:更新 architecture.md + 全量验证 + +**Files:** +- 修改: `docs/architecture.md` + +- [ ] **Step 1:在 architecture.md 的 Package map 表格中补充三个包** + +在 `Agent adapters` 分组下(`workflow-agent-hermes` 行之后)追加: + +```markdown +| | `@uncaged/workflow-agent-office` → `workflow-agent-office` | `AdapterFn` via `office-agent` CLI; generates or edits Word documents, stores outputs per threadId. | +| | `@uncaged/workflow-agent-docx-diff` → `workflow-agent-docx-diff` | `AdapterFn` via `docx-diff` CLI; produces Word-format diff reports for document edit workflows. | +``` + +在 `Templates` 分组下(`workflow-template-solve-issue` 行之后)追加: + +```markdown +| | `@uncaged/workflow-template-document` → `workflow-template-document` | Document generation/editing workflow definition (writer + differ roles, moderator table, descriptor). | +``` + +- [ ] **Step 2:运行全量测试** + +```bash +cd /Users/yanjiayi/workspace/workflow && bun test +``` + +Expected:所有测试通过,无新增失败。 + +- [ ] **Step 3:Commit** + +```bash +git add docs/architecture.md +git commit -m "docs(architecture): add workflow-agent-office, workflow-agent-docx-diff, workflow-template-document" +``` + +--- + +## 验收标准 + +- [ ] `bun run check` 无错误(TypeScript + Biome) +- [ ] `bun test` 全部通过 +- [ ] 三个包均在 `tsconfig.json` references 中 +- [ ] `workflow-template-document` 的 moderator 4 个路径均有测试覆盖 +- [ ] `workflow-agent-office` runner 测试覆盖:正常生成、正常编辑、非零退出、超时、文件未生成 +- [ ] `workflow-agent-docx-diff` runner 测试覆盖:exit 0、exit 1(正常)、exit 2(错误)、超时、diff 文件未生成 diff --git a/docs/superpowers/specs/2026-05-18-office-agent-document-template.md b/docs/superpowers/specs/2026-05-18-office-agent-document-template.md new file mode 100644 index 0000000..05f6ffd --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-office-agent-document-template.md @@ -0,0 +1,387 @@ +# 设计文档:office-agent 文档生成/编辑 Workflow 体系 + +**日期:** 2026-05-18 + +--- + +## 概述 + +在 monorepo 中新增三个包,实现通过 `office-agent` CLI 生成或编辑 Word 文档的完整 workflow 体系。 + +| 包 | npm name | 职责 | +|---|---|---| +| `workflow-template-document` | `@uncaged/workflow-template-document` | 纯结构:角色定义、meta schema、调度表、descriptor | +| `workflow-agent-office` | `@uncaged/workflow-agent-office` | writer 角色执行器:调用 `office-agent` CLI | +| `workflow-agent-docx-diff` | `@uncaged/workflow-agent-docx-diff` | differ 角色执行器:调用 `docx-diff` CLI | + +Template 只定义结构,不含执行逻辑。执行器与 template 解耦。 + +--- + +## 一、`workflow-template-document` + +### Thread 启动输入 + +```typescript +// src/types.ts +type DocumentStartInput = { + prompt: string; // 用户指令 + inputDocx: string | null; // null = 生成模式;本机绝对路径 = 编辑模式 +}; +``` + +start.content 为 JSON `{ prompt, inputDocx }` 或纯文本(fallback:generate 模式,整段作为 prompt)。 + +### 角色与 Meta + +`WriterMeta` 使用 discriminated union,在 schema 层区分两种模式: + +```typescript +const writerMetaSchema = z.discriminatedUnion("mode", [ + z.object({ + mode: z.literal("generate"), + outputDocx: z.string(), // 生成产物绝对路径 + sourceDocx: z.null(), + }), + z.object({ + mode: z.literal("edit"), + outputDocx: z.string(), // 修改后产物:/modified.docx + sourceDocx: z.string(), // 原始副本:/original.docx + }), +]); +type WriterMeta = z.infer; + +// differ:仅编辑模式执行 +const differMetaSchema = z.object({ + sourceDocx: z.string(), + modifiedDocx: z.string(), + diffDocx: z.string(), +}); +type DifferMeta = z.infer; +``` + +两个角色的 `systemPrompt` 均为 `""`。 + +### 调度表 + +``` +START → writer ──(mode = "edit")──→ differ → END + ↘(mode = "generate")→ END +``` + +### 公开导出 + +template 导出两个对象供消费方使用: + +- `documentWorkflowDefinition: WorkflowDefinition` — 传入 `createWorkflow` 的 `def` 参数 +- `buildDocumentDescriptor(): WorkflowDescriptor` — bundle 导出用 + +```typescript +// bundle 侧用法 +export const descriptor = buildDocumentDescriptor(); +export const run = createWorkflow(documentWorkflowDefinition, { adapter, overrides }); +``` + +### 包文件结构 + +``` +packages/workflow-template-document/ + src/ + types.ts # DocumentStartInput + roles/ + writer.ts # writerMetaSchema, WriterMeta, writerRole + differ.ts # differMetaSchema, DifferMeta, differRole + index.ts + roles.ts # DocumentMeta, documentRoles + moderator.ts # writerIsEditMode condition + documentTable + definition.ts # documentWorkflowDefinition + descriptor.ts # buildDocumentDescriptor() + index.ts + __tests__/ + moderator.test.ts + package.json + tsconfig.json +``` + +### 依赖 + +```json +{ + "@uncaged/workflow-protocol": "workspace:^", + "@uncaged/workflow-runtime": "workspace:^", + "@uncaged/workflow-register": "workspace:^", + "zod": "^4.0.0" +} +``` + +--- + +## 二、`workflow-agent-office` + +### office-agent CLI 接口 + +```bash +# 生成模式:在 CWD 生成 output.docx +office-agent create "" -o output.docx + +# 编辑模式:在 CWD 对 modified.docx 进行修改(覆写) +office-agent edit modified.docx "" +``` + +- 两个命令均为阻塞调用(CLI 内部消费 SSE,退出即完成) +- 输出文件落到调用方设定的 CWD +- 退出码 0 = 成功,非零 = 失败 + +### 文件命名约定 + +| 模式 | 文件 | 路径 | +|---|---|---| +| generate | 输出 | `/output.docx` | +| edit | 原始副本(workflow-owned 快照) | `/original.docx` | +| edit | 修改后产物 | `/modified.docx` | + +edit 模式先将 `inputDocx` 复制为 `original.docx`(不可变快照),再复制为 `modified.docx`,对 `modified.docx` 调用 CLI。agent 覆写 `modified.docx`,`original.docx` 保持不变。differ 对比这两个 workflow-owned 文件,不依赖用户原始路径。 + +### 执行流程 + +**生成模式(`inputDocx = null`):** +1. `mkdir -p `(`/`) +2. `const command = config.command ?? "office-agent"` +3. `spawnCli(command, ["create", prompt, "-o", "output.docx"], { cwd: outputDir, timeoutMs })` +4. 验证 `outputDir/output.docx` 存在 +5. 返回 `JSON.stringify({ mode: "generate", outputDocx, sourceDocx: null })` + +**编辑模式(`inputDocx ≠ null`):** +1. `mkdir -p ` +2. `copyFile(inputDocx, /original.docx)` +3. `copyFile(inputDocx, /modified.docx)` +4. `const command = config.command ?? "office-agent"` +5. `spawnCli(command, ["edit", "modified.docx", prompt], { cwd: outputDir, timeoutMs })` +6. 验证 `outputDir/modified.docx` 存在 +7. 返回 `JSON.stringify({ mode: "edit", outputDocx: modifiedPath, sourceDocx: originalPath })` + +### AdapterFn 实现(直接实现,不经过 runtime.extract) + +CLI 产出确定性 JSON,直接 `schema.parse(JSON.parse(raw))` 跳过 LLM extraction: + +```typescript +export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn { + return (_systemPrompt: string, schema: z.ZodType) => + async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise> => { + const { prompt, inputDocx } = parseStartInput(ctx.start.content); + const raw = await runOfficeAgent(config, ctx.threadId, prompt, inputDocx); + const meta = schema.parse(JSON.parse(raw)) as T; + return { meta, childThread: null }; + }; +} +``` + +`_systemPrompt` 为 writer 角色的 systemPrompt(空字符串),实际指令从 `ctx.start.content` 解析。 + +### 配置 + +```typescript +type OfficeAgentConfig = { + outputDir: string; // 输出根目录,runner 在此下按 threadId 建子目录 + command: string | null; // null → runner 内 resolve 为 "office-agent" + timeout: number | null; // null → 不设超时;单位 ms +}; +``` + +### 错误处理 + +```typescript +if (!result.ok) { + const e = result.error; + if (e.kind === "non_zero_exit") + throw new Error(`office-agent failed (exit ${e.exitCode}): ${e.stderr}`); + if (e.kind === "timeout") + throw new Error("office-agent: timed out"); + // "spawn_failed" + throw new Error(`office-agent: spawn failed: ${e.message}`); +} +if (!existsSync(expectedPath)) + throw new Error(`office-agent: output file not found: ${expectedPath}`); +``` + +### packageDescriptor + +```typescript +// src/package-descriptor.ts +export const packageDescriptor: PackageDescriptor = { + name: "@uncaged/workflow-agent-office", + version: "0.1.0", + capabilities: ["office-agent-cli", "docx-generate", "docx-edit"], + configSchema: { + type: "object", + required: ["outputDir"], + properties: { + outputDir: { type: "string", description: "Root directory for workflow outputs." }, + command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to office-agent CLI; null uses PATH." }, + timeout: { anyOf: [{ type: "number" }, { type: "null" }], description: "Timeout in ms; null means no limit." }, + }, + additionalProperties: false, + }, +}; +``` + +### 包文件结构 + +``` +packages/workflow-agent-office/ + src/ + types.ts # OfficeAgentConfig, OfficeAgentOpt + runner.ts # runOfficeAgent()(spawnCli 封装 + 文件验证) + agent.ts # createOfficeAgent(): AdapterFn + package-descriptor.ts # packageDescriptor + index.ts + __tests__/ + runner.test.ts + agent.test.ts + package.json + tsconfig.json +``` + +### 依赖 + +```json +{ + "@uncaged/workflow-protocol": "workspace:^", + "@uncaged/workflow-util": "workspace:^", + "@uncaged/workflow-util-agent": "workspace:^" +} +``` + +--- + +## 三、`workflow-agent-docx-diff` + +`differ` 角色专用执行器。从 `ctx.steps` 读取 `WriterMeta`,调用本地 `docx-diff` CLI。 + +### docx-diff 退出码约定 + +| 退出码 | 含义 | runner 处理 | +|---|---|---| +| 0 | 无差异 | 正常,验证 diffDocx 存在 | +| 1 | 有差异 | 正常(显式处理为成功),验证 diffDocx 存在 | +| 2+ | 错误 | throw | + +runner 收到 `SpawnCliError { kind: "non_zero_exit", exitCode: 1 }` 时视为成功,验证文件后继续;`exitCode >= 2` 才 throw。 + +### 执行流程 + +``` +1. 从 ctx.steps 找到 writer 步骤,读取 WriterMeta +2. 验证 mode === "edit"(否则 throw) +3. diffDocx = join(dirname(writer.outputDocx), "diff.docx") +4. const command = config.command ?? "docx-diff" +5. spawnCli(command, + [writer.sourceDocx, writer.outputDocx, "--output", "docx", "--out-file", diffDocx], + { cwd: null, timeoutMs: null }) + exit 0 或 1 → 验证 diffDocx 存在 + exit 2+ → throw +6. 返回 JSON.stringify({ sourceDocx, modifiedDocx: writer.outputDocx, diffDocx }) +``` + +### AdapterFn 实现(直接实现,不经过 runtime.extract) + +```typescript +export function createDocxDiffAgent(config: DocxDiffAgentConfig = { command: null }): AdapterFn { + return (_prompt: string, schema: z.ZodType) => + async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise> => { + const writerStep = ctx.steps.find(s => s.role === "writer"); + if (!writerStep) throw new Error("differ: no writer step found"); + const writerMeta = writerStep.meta as WriterMeta; + if (writerMeta.mode !== "edit") + throw new Error("differ: writer did not run in edit mode"); + const raw = await runDocxDiff(config, writerMeta); + const meta = schema.parse(JSON.parse(raw)) as T; + return { meta, childThread: null }; + }; +} +``` + +### 配置 + +```typescript +type DocxDiffAgentConfig = { + command: string | null; // null → runner 内 resolve 为 "docx-diff" +}; +``` + +### packageDescriptor + +```typescript +export const packageDescriptor: PackageDescriptor = { + name: "@uncaged/workflow-agent-docx-diff", + version: "0.1.0", + capabilities: ["docx-diff-cli", "docx-diff-report"], + configSchema: { + type: "object", + properties: { + command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to docx-diff CLI; null uses PATH." }, + }, + additionalProperties: false, + }, +}; +``` + +### 包文件结构 + +``` +packages/workflow-agent-docx-diff/ + src/ + types.ts # DocxDiffAgentConfig + runner.ts # runDocxDiff()(exit 1 处理 + 文件验证) + agent.ts # createDocxDiffAgent(): AdapterFn + package-descriptor.ts # packageDescriptor + index.ts + __tests__/ + runner.test.ts + agent.test.ts + package.json + tsconfig.json +``` + +### 依赖 + +```json +{ + "@uncaged/workflow-protocol": "workspace:^", + "@uncaged/workflow-util-agent": "workspace:^", + "@uncaged/workflow-template-document": "workspace:^" +} +``` + +--- + +## 四、外部 bundle(外部 workspace 消费) + +```typescript +import { createOfficeAgent } from "@uncaged/workflow-agent-office"; +import { createDocxDiffAgent } from "@uncaged/workflow-agent-docx-diff"; +import { + buildDocumentDescriptor, + documentWorkflowDefinition, +} from "@uncaged/workflow-template-document"; +import { createWorkflow } from "@uncaged/workflow-runtime"; +import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util"; +import { join } from "node:path"; + +const outputDir = join(getDefaultWorkflowStorageRoot(), "outputs"); + +export const descriptor = buildDocumentDescriptor(); +export const run = createWorkflow(documentWorkflowDefinition, { + adapter: createOfficeAgent({ outputDir, command: null, timeout: null }), + overrides: { differ: createDocxDiffAgent() }, +}); +``` + +--- + +## 不在范围内 + +- 重试逻辑(失败直接 throw) +- office-agent server 的启停管理(假设 server 已在运行) +- docx-diff HTML/terminal 格式输出(仅 docx) +- 跨机器执行(`inputDocx` 须为本机有效绝对路径)