diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6ce0b8a767..af808d8933 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -64,6 +64,22 @@ jobs:
- name: Run tests
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/... ./extension/...
+ windows-compat:
+ needs: fast-gate
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
+ - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
+ with:
+ go-version-file: go.mod
+ - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
+ with:
+ python-version: '3.x'
+ - name: Fetch meta data
+ run: python scripts/fetch_meta.py
+ - name: Run Windows compatibility tests
+ run: go test -count=1 -timeout=5m . ./shortcuts/doc/...
+
lint:
needs: fast-gate
runs-on: ubuntu-latest
diff --git a/go.mod b/go.mod
index 26497138b2..47885987ae 100644
--- a/go.mod
+++ b/go.mod
@@ -18,6 +18,7 @@ require (
github.com/spf13/pflag v1.0.9
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0
+ github.com/yuin/goldmark v1.7.16
github.com/zalando/go-keyring v0.2.8
golang.org/x/net v0.33.0
golang.org/x/sync v0.15.0
diff --git a/go.sum b/go.sum
index 7e42f36199..b452981cf7 100644
--- a/go.sum
+++ b/go.sum
@@ -131,6 +131,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
+github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
diff --git a/shortcuts/doc/docs_script.go b/shortcuts/doc/docs_script.go
new file mode 100644
index 0000000000..2337b2bd60
--- /dev/null
+++ b/shortcuts/doc/docs_script.go
@@ -0,0 +1,128 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package doc
+
+import (
+ "context"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/shortcuts/common"
+ "github.com/larksuite/cli/shortcuts/doc/internal/docxparse"
+)
+
+const (
+ docsScriptParse = "parse"
+ docsScriptMarkdownToXML = "markdown-to-xml"
+)
+
+var DocsScript = common.Shortcut{
+ Service: "docs",
+ Command: "+script",
+ Description: "Parse and profile XML or Markdown, or convert Markdown to LarkOpenCLI XML",
+ Risk: "read",
+ AuthTypes: []string{"user", "bot"},
+ Scopes: []string{},
+ Flags: []common.Flag{
+ {
+ Name: "command",
+ Desc: "local document operation",
+ Required: true,
+ Enum: []string{docsScriptParse, docsScriptMarkdownToXML},
+ },
+ {
+ Name: "content",
+ Desc: "document content; use @relative-file or - for stdin",
+ Required: true,
+ Input: []string{common.File, common.Stdin},
+ },
+ },
+ Tips: []string{
+ "parse auto-detects XML or Markdown and returns only the text and block profile",
+ "markdown-to-xml converts Markdown to LarkOpenCLI XML",
+ },
+ PostMount: installDocsScriptHelp,
+ Validate: validateDocsScript,
+ DryRun: dryRunDocsScript,
+ Execute: executeDocsScript,
+}
+
+type docsScriptParseResult struct {
+ Profile docsScriptPublicProfile `json:"profile"`
+}
+
+// docsScriptPublicProfile is the stable shortcut response. The parser keeps
+// the more detailed breakdown internally so it can be exposed later without
+// changing the counting implementation.
+type docsScriptPublicProfile struct {
+ WordCount int `json:"word_count"`
+ CharCount int `json:"char_count"`
+ BlockCount int `json:"block_count"`
+ Blocks []docxparse.BlockShare `json:"blocks"`
+}
+
+type docsScriptMarkdownResult struct {
+ XML string `json:"xml"`
+}
+
+func installDocsScriptHelp(cmd *cobra.Command) {
+ installDocsShortcutHelp("+script")(cmd)
+ cmd.Example = ` lark-cli docs +script --command parse --content "@draft.xml"
+ lark-cli docs +script --command parse --content "@draft.md"
+ lark-cli docs +script --command markdown-to-xml --content "@draft.md"`
+}
+
+func validateDocsScript(_ context.Context, runtime *common.RuntimeContext) error {
+ if strings.TrimSpace(runtime.Str("content")) == "" {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content cannot be empty").
+ WithParam("--content")
+ }
+ return nil
+}
+
+func dryRunDocsScript(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
+ return common.NewDryRunAPI().
+ Desc("Local LarkOpenCLI document parsing or conversion; no API call is made").
+ Set("command", runtime.Str("command")).
+ Set("input_bytes", len(runtime.Str("content"))).
+ Set("network", false)
+}
+
+func executeDocsScript(_ context.Context, runtime *common.RuntimeContext) error {
+ command := runtime.Str("command")
+ content := runtime.Str("content")
+ switch command {
+ case docsScriptParse:
+ profile, err := docxparse.ParseAuto(content)
+ if err != nil {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument,
+ "could not parse --content as LarkOpenCLI XML or Markdown: %s", err).
+ WithParam("--content").
+ WithCause(err)
+ }
+ runtime.OutFormatRaw(docsScriptParseResult{Profile: docsScriptPublicProfile{
+ WordCount: profile.WordCount,
+ CharCount: profile.CharCount,
+ BlockCount: profile.BlockCount,
+ Blocks: profile.Blocks,
+ }}, nil, nil)
+ return nil
+ case docsScriptMarkdownToXML:
+ xml, err := docxparse.MarkdownToXML(content)
+ if err != nil {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument,
+ "could not convert --content from Markdown to LarkOpenCLI XML: %s", err).
+ WithParam("--content").
+ WithCause(err)
+ }
+ runtime.OutFormatRaw(docsScriptMarkdownResult{XML: xml}, nil, nil)
+ return nil
+ default:
+ return errs.NewValidationError(errs.SubtypeInvalidArgument,
+ "unsupported --command %q", command).
+ WithParam("--command")
+ }
+}
diff --git a/shortcuts/doc/docs_script_test.go b/shortcuts/doc/docs_script_test.go
new file mode 100644
index 0000000000..3b9ab50632
--- /dev/null
+++ b/shortcuts/doc/docs_script_test.go
@@ -0,0 +1,209 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package doc
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/spf13/cobra"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/cmdutil"
+ "github.com/larksuite/cli/shortcuts/doc/internal/docxparse"
+)
+
+func TestDocsScriptParsesAndProfilesXML(t *testing.T) {
+ f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-test"))
+ source := `
标题一个苹果是 an apple。
`
+
+ err := mountAndRunDocs(t, DocsScript, []string{
+ "+script",
+ "--command", docsScriptParse,
+ "--content", source,
+ "--as", "bot",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("execute docs +script: %v", err)
+ }
+
+ var envelope struct {
+ OK bool `json:"ok"`
+ Data map[string]json.RawMessage `json:"data"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatalf("decode stdout: %v\n%s", err, stdout)
+ }
+ if !envelope.OK {
+ t.Fatalf("ok = false: %s", stdout)
+ }
+ if len(envelope.Data) != 1 || envelope.Data["profile"] == nil {
+ t.Fatalf("data = %+v, want only profile", envelope.Data)
+ }
+ var profile docsScriptPublicProfile
+ if err := json.Unmarshal(envelope.Data["profile"], &profile); err != nil {
+ t.Fatalf("decode profile: %v", err)
+ }
+ var profileFields map[string]json.RawMessage
+ if err := json.Unmarshal(envelope.Data["profile"], &profileFields); err != nil {
+ t.Fatalf("decode profile fields: %v", err)
+ }
+ if len(profileFields) != 4 || profileFields["breakdown"] != nil {
+ t.Fatalf("profile fields = %+v, want breakdown hidden", profileFields)
+ }
+ if profile.WordCount != 10 || profile.CharCount != 15 || profile.BlockCount != 2 {
+ t.Fatalf("profile = %+v", profile)
+ }
+ if got := blockCount(profile.Blocks, "title"); got != 1 {
+ t.Fatalf("title count = %d, want 1", got)
+ }
+ if got := blockCount(profile.Blocks, "p"); got != 1 {
+ t.Fatalf("p count = %d, want 1", got)
+ }
+}
+
+func TestDocsScriptParseAutoDetectsMarkdown(t *testing.T) {
+ f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-auto-markdown"))
+
+ err := mountAndRunDocs(t, DocsScript, []string{
+ "+script",
+ "--command", docsScriptParse,
+ "--content", "# 标题\n\n- item",
+ "--as", "bot",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("execute docs +script: %v", err)
+ }
+
+ var envelope struct {
+ Data docsScriptParseResult `json:"data"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatalf("decode stdout: %v\n%s", err, stdout)
+ }
+ if envelope.Data.Profile.BlockCount != 3 {
+ t.Fatalf("profile = %+v, want 3 blocks", envelope.Data.Profile)
+ }
+}
+
+func TestDocsScriptConvertsMarkdownFromStdin(t *testing.T) {
+ f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-markdown"))
+ f.IOStreams.In = bytes.NewBufferString("# 标题\n\n- item")
+
+ err := mountAndRunDocs(t, DocsScript, []string{
+ "+script",
+ "--command", docsScriptMarkdownToXML,
+ "--content", "-",
+ "--as", "bot",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("execute docs +script: %v", err)
+ }
+ if !strings.Contains(stdout.String(), `标题
`) {
+ t.Fatalf("stdout missing converted XML: %s", stdout)
+ }
+ var envelope struct {
+ Data map[string]json.RawMessage `json:"data"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatalf("decode stdout: %v\n%s", err, stdout)
+ }
+ if len(envelope.Data) != 1 || envelope.Data["xml"] == nil {
+ t.Fatalf("data = %+v, want only xml", envelope.Data)
+ }
+}
+
+func TestDocsScriptDryRunHasNoAPICall(t *testing.T) {
+ f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-dry-run"))
+
+ err := mountAndRunDocs(t, DocsScript, []string{
+ "+script",
+ "--command", docsScriptParse,
+ "--content", `text
`,
+ "--dry-run",
+ "--as", "bot",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("execute docs +script dry-run: %v", err)
+ }
+ var got struct {
+ API []any `json:"api"`
+ Command string `json:"command"`
+ Network bool `json:"network"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
+ t.Fatalf("decode dry-run stdout: %v\n%s", err, stdout)
+ }
+ if len(got.API) != 0 || got.Command != docsScriptParse || got.Network {
+ t.Fatalf("dry-run output = %+v", got)
+ }
+}
+
+func TestDocsScriptReturnsTypedParseError(t *testing.T) {
+ f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-error"))
+
+ err := mountAndRunDocs(t, DocsScript, []string{
+ "+script",
+ "--command", docsScriptParse,
+ "--content", `text
`,
+ "--as", "bot",
+ }, f, nil)
+ if err == nil {
+ t.Fatal("expected parse error")
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
+ t.Fatalf("problem = %+v, ok=%v", problem, ok)
+ }
+ var validationErr *errs.ValidationError
+ if !errors.As(err, &validationErr) || validationErr.Param != "--content" {
+ t.Fatalf("error = %#v, want --content metadata", err)
+ }
+}
+
+func TestDocsScriptRejectsMalformedXML(t *testing.T) {
+ f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-malformed"))
+
+ err := mountAndRunDocs(t, DocsScript, []string{
+ "+script",
+ "--command", docsScriptParse,
+ "--content", `text`,
+ "--as", "bot",
+ }, f, nil)
+ if err == nil {
+ t.Fatal("expected malformed XML error")
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
+ t.Fatalf("problem = %+v, ok=%v", problem, ok)
+ }
+}
+
+func TestDocsScriptHelpExamplesAreCrossShellSafe(t *testing.T) {
+ cmd := &cobra.Command{Short: "local document parser"}
+ installDocsScriptHelp(cmd)
+ if strings.Contains(cmd.Example, "cat ") {
+ t.Fatalf("help examples require a platform-specific command: %q", cmd.Example)
+ }
+ if strings.Contains(cmd.Example, "--content @") {
+ t.Fatalf("help examples contain an unquoted @file argument: %q", cmd.Example)
+ }
+ for _, want := range []string{`--content "@draft.xml"`, `--content "@draft.md"`} {
+ if !strings.Contains(cmd.Example, want) {
+ t.Errorf("help examples missing %q: %q", want, cmd.Example)
+ }
+ }
+}
+
+func blockCount(blocks []docxparse.BlockShare, typ string) int {
+ for _, block := range blocks {
+ if block.Type == typ {
+ return block.Count
+ }
+ }
+ return 0
+}
diff --git a/shortcuts/doc/internal/docxparse/markdown.go b/shortcuts/doc/internal/docxparse/markdown.go
new file mode 100644
index 0000000000..010c94b165
--- /dev/null
+++ b/shortcuts/doc/internal/docxparse/markdown.go
@@ -0,0 +1,722 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package docxparse
+
+// Markdown conversion is scoped to the docs +script business domain.
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/yuin/goldmark"
+ gast "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/extension"
+ extast "github.com/yuin/goldmark/extension/ast"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/text"
+ gmutil "github.com/yuin/goldmark/util"
+)
+
+var markdownParser parser.Parser
+
+func init() {
+ markdown := goldmark.New(
+ goldmark.WithExtensions(
+ extension.GFM,
+ extension.DefinitionList,
+ &mathExtension{},
+ &underscoreHTMLExtension{},
+ ),
+ goldmark.WithParserOptions(
+ parser.WithBlockParsers(gmutil.Prioritized(&containerBlockParser{}, 90)),
+ ),
+ )
+ markdownParser = markdown.Parser()
+}
+
+func parseMarkdown(source string) ([]*Node, error) {
+ if err := validateSource(source); err != nil {
+ return nil, err
+ }
+ source = strings.TrimPrefix(source, "\uFEFF")
+ source = normalizeListIndent(source)
+ source = preprocessCJKAdjacentMarkup(source)
+ data := []byte(source)
+ document := markdownParser.Parse(text.NewReader(data))
+ return renderBlockChildren(document, data)
+}
+
+func renderBlockChildren(parent gast.Node, source []byte) ([]*Node, error) {
+ var out []*Node
+ for child := parent.FirstChild(); child != nil; child = child.NextSibling() {
+ nodes, err := renderBlockNode(child, source)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, nodes...)
+ }
+ return out, nil
+}
+
+func renderBlockNode(node gast.Node, source []byte) ([]*Node, error) {
+ switch node.Kind() {
+ case gast.KindParagraph, gast.KindTextBlock:
+ children, err := renderInlineChildren(node, source)
+ if err != nil {
+ return nil, err
+ }
+ return wrapParagraphChildren(children), nil
+ case gast.KindHeading:
+ heading := newElement(headingTag(node.(*gast.Heading).Level), nil)
+ children, err := renderInlineChildren(node, source)
+ if err != nil {
+ return nil, err
+ }
+ for _, child := range children {
+ heading.addChild(child)
+ }
+ return []*Node{heading}, nil
+ case gast.KindBlockquote:
+ return renderContainer("blockquote", nil, node, source)
+ case gast.KindList:
+ return renderList(node.(*gast.List), source)
+ case gast.KindFencedCodeBlock:
+ block := node.(*gast.FencedCodeBlock)
+ language := string(block.Language(source))
+ content := trimOneTrailingNewline(string(node.Lines().Value(source)))
+ lowerLanguage := strings.ToLower(language)
+ if content != "" && (lowerLanguage == "mermaid" || lowerLanguage == "plantuml" || lowerLanguage == "svg") {
+ whiteboard := newElement("whiteboard", map[string]string{"type": lowerLanguage})
+ appendRawTextWithBreaks(whiteboard, content)
+ return []*Node{whiteboard}, nil
+ }
+ attrs := map[string]string(nil)
+ if language != "" {
+ attrs = map[string]string{"lang": language}
+ }
+ pre := newElement("pre", attrs)
+ code := newElement("code", nil)
+ appendRawTextWithBreaks(code, content)
+ pre.addChild(code)
+ return []*Node{pre}, nil
+ case gast.KindCodeBlock:
+ pre := newElement("pre", nil)
+ code := newElement("code", nil)
+ appendRawTextWithBreaks(code, trimOneTrailingNewline(string(node.Lines().Value(source))))
+ pre.addChild(code)
+ return []*Node{pre}, nil
+ case gast.KindThematicBreak:
+ return []*Node{newElement("hr", nil)}, nil
+ case gast.KindHTMLBlock:
+ nodes, err := parseMarkdownHTMLBlock(string(node.Lines().Value(source)))
+ if err != nil {
+ return nil, err
+ }
+ stripMarkdownEscapesInNodes(nodes, false, false)
+ return nodes, nil
+ case kindContainerBlock:
+ container := node.(*containerBlock)
+ return renderContainer(container.spec.tag, container.attrs, node, source)
+ }
+
+ switch node.Kind() {
+ case extast.KindTable:
+ return renderTable(node, source)
+ case extast.KindDefinitionList:
+ return renderDefinitionList(node, source)
+ }
+
+ value := strings.TrimSpace(extractMarkdownText(node, source))
+ if value == "" {
+ return nil, nil
+ }
+ paragraph := newElement("p", nil)
+ paragraph.addChild(newText(value))
+ return []*Node{paragraph}, nil
+}
+
+// parseMarkdownHTMLBlock handles the source-bearing LarkOpenCLI blocks whose
+// Markdown bodies are literal text, then delegates every other XML fragment to
+// the strict XML parser. Escaping literal code is part of Markdown conversion.
+func parseMarkdownHTMLBlock(fragment string) ([]*Node, error) {
+ trimmed := strings.TrimSpace(fragment)
+ for _, tag := range []string{"code", "whiteboard"} {
+ closing := "" + tag + ">"
+ if !strings.HasPrefix(trimmed, "<"+tag) || !strings.HasSuffix(trimmed, closing) {
+ continue
+ }
+ token, contentStart, state := scanXMLToken(trimmed, 0)
+ if state != tokenOK || token.closing || token.selfClosing || token.name != tag {
+ return nil, fmt.Errorf("invalid Markdown <%s> block", tag)
+ }
+ contentEnd := len(trimmed) - len(closing)
+ if contentStart > contentEnd {
+ return nil, fmt.Errorf("invalid Markdown <%s> block", tag)
+ }
+ attrs := normalizeAttributes(tag, tag, token.attrs)
+ block := newElement(tag, attrs)
+ appendRawTextWithBreaks(block, strings.Trim(trimmed[contentStart:contentEnd], "\r\n"))
+ return []*Node{block}, nil
+ }
+ return parseXML(fragment)
+}
+
+func renderContainer(tag string, attrs map[string]string, node gast.Node, source []byte) ([]*Node, error) {
+ attrs = normalizeAttributes(tag, tag, attrs)
+ container := newElement(tag, attrs)
+ children, err := renderBlockChildren(node, source)
+ if err != nil {
+ return nil, err
+ }
+ for _, child := range children {
+ container.addChild(child)
+ }
+ return []*Node{container}, nil
+}
+
+func renderList(list *gast.List, source []byte) ([]*Node, error) {
+ if isTaskList(list) {
+ return renderTaskList(list, source)
+ }
+ tag := "ul"
+ if list.IsOrdered() {
+ tag = "ol"
+ }
+ listNode := newElement(tag, nil)
+ for child := list.FirstChild(); child != nil; child = child.NextSibling() {
+ if child.Kind() != gast.KindListItem {
+ continue
+ }
+ item, err := renderListItem(child.(*gast.ListItem), list.IsTight, source)
+ if err != nil {
+ return nil, err
+ }
+ listNode.addChild(item)
+ }
+ return []*Node{listNode}, nil
+}
+
+func isTaskList(list *gast.List) bool {
+ first := list.FirstChild()
+ if first == nil || first.Kind() != gast.KindListItem {
+ return false
+ }
+ return findTaskCheckbox(first.(*gast.ListItem)) != nil
+}
+
+func findTaskCheckbox(item *gast.ListItem) *extast.TaskCheckBox {
+ for child := item.FirstChild(); child != nil; child = child.NextSibling() {
+ if child.Kind() != gast.KindTextBlock && child.Kind() != gast.KindParagraph {
+ continue
+ }
+ if first := child.FirstChild(); first != nil && first.Kind() == extast.KindTaskCheckBox {
+ return first.(*extast.TaskCheckBox)
+ }
+ }
+ return nil
+}
+
+func renderTaskList(list *gast.List, source []byte) ([]*Node, error) {
+ var out []*Node
+ for child := list.FirstChild(); child != nil; child = child.NextSibling() {
+ if child.Kind() != gast.KindListItem {
+ continue
+ }
+ item := child.(*gast.ListItem)
+ checkboxAST := findTaskCheckbox(item)
+ if checkboxAST == nil {
+ li, err := renderListItem(item, list.IsTight, source)
+ if err != nil {
+ return nil, err
+ }
+ ul := newElement("ul", nil)
+ ul.addChild(li)
+ out = append(out, ul)
+ continue
+ }
+ done := "false"
+ if checkboxAST.IsChecked {
+ done = "true"
+ }
+ checkbox := newElement("checkbox", map[string]string{"done": done})
+ for block := item.FirstChild(); block != nil; block = block.NextSibling() {
+ if block.Kind() == gast.KindTextBlock || block.Kind() == gast.KindParagraph {
+ fragment, err := renderInlineFragment(block, source, true)
+ if err != nil {
+ return nil, err
+ }
+ nodes, err := parseXML(fragment)
+ if err != nil {
+ return nil, err
+ }
+ for _, node := range nodes {
+ checkbox.addChild(node)
+ }
+ continue
+ }
+ nodes, err := renderBlockNode(block, source)
+ if err != nil {
+ return nil, err
+ }
+ for _, node := range nodes {
+ checkbox.addChild(node)
+ }
+ }
+ out = append(out, checkbox)
+ }
+ return out, nil
+}
+
+func renderListItem(item *gast.ListItem, tight bool, source []byte) (*Node, error) {
+ li := newElement("li", nil)
+ children, err := renderBlockChildren(item, source)
+ if err != nil {
+ return nil, err
+ }
+ for _, child := range children {
+ if child.tag == "p" && (tight || paragraphOnlyInline(child)) {
+ for _, grandchild := range child.children {
+ li.addChild(grandchild)
+ }
+ continue
+ }
+ li.addChild(child)
+ }
+ return li, nil
+}
+
+func renderInlineChildren(node gast.Node, source []byte) ([]*Node, error) {
+ fragment, err := renderInlineFragment(node, source, false)
+ if err != nil {
+ return nil, err
+ }
+ nodes, err := parseXML(fragment)
+ if err != nil {
+ return nil, err
+ }
+ stripMarkdownEscapesInNodes(nodes, false, false)
+ return nodes, nil
+}
+
+func renderInlineFragment(parent gast.Node, source []byte, skipCheckbox bool) (string, error) {
+ var out strings.Builder
+ for child := parent.FirstChild(); child != nil; child = child.NextSibling() {
+ if skipCheckbox && child.Kind() == extast.KindTaskCheckBox {
+ continue
+ }
+ fragment, err := renderInlineNode(child, source)
+ if err != nil {
+ return "", err
+ }
+ out.WriteString(fragment)
+ }
+ return out.String(), nil
+}
+
+func renderInlineNode(node gast.Node, source []byte) (string, error) {
+ switch node.Kind() {
+ case gast.KindText:
+ textNode := node.(*gast.Text)
+ value := escapeXMLText(stripBackslashEscapes(string(textNode.Value(source))))
+ if textNode.HardLineBreak() || textNode.SoftLineBreak() {
+ value += "
"
+ }
+ return value, nil
+ case gast.KindString:
+ return escapeXMLText(string(node.(*gast.String).Value)), nil
+ case gast.KindEmphasis:
+ tag := "em"
+ if node.(*gast.Emphasis).Level >= 2 {
+ tag = "b"
+ }
+ return renderInlineContainer(node, tag, nil, source)
+ case gast.KindCodeSpan:
+ return elementXML("code", nil, escapeXMLText(collectMarkdownChildText(node, source))), nil
+ case gast.KindLink:
+ link := node.(*gast.Link)
+ attrs := map[string]string{"href": string(link.Destination)}
+ if len(link.Title) > 0 {
+ attrs["title"] = string(link.Title)
+ }
+ children, err := renderInlineFragment(node, source, false)
+ if err != nil {
+ return "", err
+ }
+ if children == "" {
+ children = escapeXMLText(string(link.Destination))
+ }
+ return elementXML("a", attrs, children), nil
+ case gast.KindImage:
+ image := node.(*gast.Image)
+ destination := string(image.Destination)
+ attrs := map[string]string{}
+ if strings.HasPrefix(destination, "http://") || strings.HasPrefix(destination, "https://") {
+ attrs["href"] = destination
+ } else {
+ attrs["src"] = destination
+ }
+ if len(image.Title) > 0 {
+ attrs["title"] = string(image.Title)
+ }
+ return elementXML("img", attrs, ""), nil
+ case gast.KindRawHTML:
+ return string(node.(*gast.RawHTML).Segments.Value(source)), nil
+ case gast.KindAutoLink:
+ link := node.(*gast.AutoLink)
+ return elementXML("a", map[string]string{"href": string(link.URL(source))}, escapeXMLText(string(link.Label(source)))), nil
+ }
+
+ switch node.Kind() {
+ case extast.KindStrikethrough:
+ return renderInlineContainer(node, "del", nil, source)
+ case kindMathInline:
+ return elementXML("latex", nil, escapeXMLText(stripLatexMarkdownEscapes(string(node.(*mathInline).content)))), nil
+ case kindMathBlock:
+ return elementXML("latex", nil, escapeXMLText(stripLatexMarkdownEscapes(string(node.(*mathBlock).content)))), nil
+ case extast.KindTaskCheckBox:
+ return "", nil
+ }
+
+ if node.Type() == gast.TypeBlock {
+ return escapeXMLText(strings.TrimSpace(extractMarkdownText(node, source))), nil
+ }
+ return escapeXMLText(extractMarkdownText(node, source)), nil
+}
+
+func renderInlineContainer(node gast.Node, tag string, attrs map[string]string, source []byte) (string, error) {
+ children, err := renderInlineFragment(node, source, false)
+ if err != nil {
+ return "", err
+ }
+ return elementXML(tag, attrs, children), nil
+}
+
+func elementXML(tag string, attrs map[string]string, inner string) string {
+ node := newElement(tag, attrs)
+ rendered := renderNodes([]*Node{node})
+ if inner == "" {
+ return rendered
+ }
+ close := "" + tag + ">"
+ if strings.HasSuffix(rendered, close) {
+ return strings.TrimSuffix(rendered, close) + inner + close
+ }
+ return rendered
+}
+
+func wrapParagraphChildren(children []*Node) []*Node {
+ var out []*Node
+ var inline []*Node
+ flush := func() {
+ if len(inline) == 0 {
+ return
+ }
+ paragraph := newElement("p", nil)
+ for _, child := range inline {
+ paragraph.addChild(child)
+ }
+ out = append(out, paragraph)
+ inline = nil
+ }
+ for _, child := range children {
+ if child != nil && child.typ == nodeElement && layoutOf(child.tag) == layoutBlock {
+ flush()
+ out = append(out, child)
+ continue
+ }
+ inline = append(inline, child)
+ }
+ flush()
+ return out
+}
+
+func paragraphOnlyInline(node *Node) bool {
+ if node == nil || node.typ != nodeElement || node.tag != "p" {
+ return false
+ }
+ for _, child := range node.children {
+ if child.typ == nodeElement && layoutOf(child.tag) == layoutBlock {
+ return false
+ }
+ }
+ return true
+}
+
+func renderTable(node gast.Node, source []byte) ([]*Node, error) {
+ table := newElement("table", nil)
+ var body *Node
+ for child := node.FirstChild(); child != nil; child = child.NextSibling() {
+ switch child.Kind() {
+ case extast.KindTableHeader:
+ head := newElement("thead", nil)
+ row, err := renderTableRow(child, true, source)
+ if err != nil {
+ return nil, err
+ }
+ head.addChild(row)
+ table.addChild(head)
+ case extast.KindTableRow:
+ if body == nil {
+ body = newElement("tbody", nil)
+ table.addChild(body)
+ }
+ row, err := renderTableRow(child, false, source)
+ if err != nil {
+ return nil, err
+ }
+ body.addChild(row)
+ }
+ }
+ return []*Node{table}, nil
+}
+
+func renderTableRow(node gast.Node, header bool, source []byte) (*Node, error) {
+ row := newElement("tr", nil)
+ for child := node.FirstChild(); child != nil; child = child.NextSibling() {
+ if child.Kind() != extast.KindTableCell {
+ continue
+ }
+ cellAST := child.(*extast.TableCell)
+ tag := "td"
+ if header {
+ tag = "th"
+ }
+ attrs := map[string]string(nil)
+ switch cellAST.Alignment {
+ case extast.AlignCenter:
+ attrs = map[string]string{"align": "center"}
+ case extast.AlignRight:
+ attrs = map[string]string{"align": "right"}
+ }
+ cell := newElement(tag, attrs)
+ content, err := renderInlineChildren(cellAST, source)
+ if err != nil {
+ return nil, err
+ }
+ for _, inline := range content {
+ cell.addChild(inline)
+ }
+ row.addChild(cell)
+ }
+ return row, nil
+}
+
+func renderDefinitionList(node gast.Node, source []byte) ([]*Node, error) {
+ var out []*Node
+ for child := node.FirstChild(); child != nil; child = child.NextSibling() {
+ switch child.Kind() {
+ case extast.KindDefinitionTerm:
+ fragment, err := renderInlineFragment(child, source, false)
+ if err != nil {
+ return nil, err
+ }
+ nodes, err := parseXML(fragment)
+ if err != nil {
+ return nil, err
+ }
+ paragraph := newElement("p", nil)
+ bold := newElement("b", nil)
+ for _, node := range nodes {
+ bold.addChild(node)
+ }
+ paragraph.addChild(bold)
+ out = append(out, paragraph)
+ case extast.KindDefinitionDescription:
+ quote, err := renderContainer("blockquote", nil, child, source)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, quote...)
+ }
+ }
+ return out, nil
+}
+
+func appendRawTextWithBreaks(parent *Node, content string) {
+ if content == "" {
+ return
+ }
+ start := 0
+ for i := 0; i < len(content); i++ {
+ if content[i] != '\n' && content[i] != '\r' {
+ continue
+ }
+ if i > start {
+ parent.addChild(newText(content[start:i]))
+ }
+ if content[i] == '\r' && i+1 < len(content) && content[i+1] == '\n' {
+ i++
+ }
+ parent.addChild(newElement("br", nil))
+ start = i + 1
+ }
+ if start < len(content) {
+ parent.addChild(newText(content[start:]))
+ }
+}
+
+func stripMarkdownEscapesInNodes(nodes []*Node, inCode, inLatex bool) {
+ for _, node := range nodes {
+ if node == nil {
+ continue
+ }
+ if node.typ == nodeText {
+ switch {
+ case inCode:
+ case inLatex:
+ node.text = stripLatexMarkdownEscapes(node.text)
+ default:
+ node.text = stripBackslashEscapes(node.text)
+ }
+ continue
+ }
+ stripMarkdownEscapesInNodes(node.children, inCode || node.tag == "code" || node.tag == "pre", inLatex || node.tag == "latex")
+ }
+}
+
+func stripBackslashEscapes(value string) string {
+ if !strings.Contains(value, `\`) {
+ return value
+ }
+ var out strings.Builder
+ out.Grow(len(value))
+ for i := 0; i < len(value); i++ {
+ if value[i] == '\\' && i+1 < len(value) && isASCIIPunctuation(value[i+1]) {
+ out.WriteByte(value[i+1])
+ i++
+ continue
+ }
+ out.WriteByte(value[i])
+ }
+ return out.String()
+}
+
+func stripLatexMarkdownEscapes(value string) string {
+ if !strings.Contains(value, `\`) {
+ return value
+ }
+ var out strings.Builder
+ out.Grow(len(value))
+ for i := 0; i < len(value); i++ {
+ if value[i] == '\\' && i+1 < len(value) && strings.ContainsRune("_^&*[]$~<>`#+-=:", rune(value[i+1])) {
+ out.WriteByte(value[i+1])
+ i++
+ continue
+ }
+ out.WriteByte(value[i])
+ }
+ return out.String()
+}
+
+func isASCIIPunctuation(ch byte) bool {
+ return ch >= '!' && ch <= '/' || ch >= ':' && ch <= '@' || ch >= '[' && ch <= '`' || ch >= '{' && ch <= '~'
+}
+
+func trimOneTrailingNewline(value string) string {
+ if strings.HasSuffix(value, "\r\n") {
+ return value[:len(value)-2]
+ }
+ return strings.TrimSuffix(value, "\n")
+}
+
+func collectMarkdownChildText(node gast.Node, source []byte) string {
+ var out strings.Builder
+ for child := node.FirstChild(); child != nil; child = child.NextSibling() {
+ switch child.Kind() {
+ case gast.KindText:
+ out.Write(child.(*gast.Text).Value(source))
+ case gast.KindString:
+ out.Write(child.(*gast.String).Value)
+ default:
+ out.WriteString(collectMarkdownChildText(child, source))
+ }
+ }
+ return out.String()
+}
+
+func extractMarkdownText(node gast.Node, source []byte) string {
+ switch node.Kind() {
+ case gast.KindText:
+ return string(node.(*gast.Text).Value(source))
+ case gast.KindString:
+ return string(node.(*gast.String).Value)
+ case gast.KindCodeSpan:
+ return collectMarkdownChildText(node, source)
+ }
+ if node.Type() == gast.TypeBlock && node.Lines() != nil && node.Lines().Len() > 0 {
+ return string(node.Lines().Value(source))
+ }
+ var out strings.Builder
+ for child := node.FirstChild(); child != nil; child = child.NextSibling() {
+ out.WriteString(extractMarkdownText(child, source))
+ }
+ return out.String()
+}
+
+func headingTag(level int) string {
+ if level < 1 || level > 6 {
+ return "p"
+ }
+ return fmt.Sprintf("h%d", level)
+}
+
+func normalizeListIndent(markdown string) string {
+ lines := strings.Split(markdown, "\n")
+ type stackEntry struct{ indent int }
+ var stack []stackEntry
+ inFence := false
+ changed := false
+ lastOriginal, lastNormalized := 0, 0
+ for i, line := range lines {
+ trimmed := strings.TrimLeft(line, " ")
+ if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
+ inFence = !inFence
+ continue
+ }
+ if inFence || trimmed == "" {
+ continue
+ }
+ indent := len(line) - len(trimmed)
+ if markdownListMarkerLength(trimmed) > 0 {
+ for len(stack) > 0 && indent <= stack[len(stack)-1].indent {
+ stack = stack[:len(stack)-1]
+ }
+ normalized := len(stack) * 4
+ stack = append(stack, stackEntry{indent: indent})
+ lastOriginal, lastNormalized = indent, normalized
+ if indent != normalized {
+ lines[i] = strings.Repeat(" ", normalized) + trimmed
+ changed = true
+ }
+ } else if len(stack) > 0 && indent > lastOriginal {
+ delta := lastNormalized - lastOriginal
+ if delta != 0 {
+ normalized := indent + delta
+ if normalized < 0 {
+ normalized = 0
+ }
+ lines[i] = strings.Repeat(" ", normalized) + trimmed
+ changed = true
+ }
+ }
+ }
+ if !changed {
+ return markdown
+ }
+ return strings.Join(lines, "\n")
+}
+
+func markdownListMarkerLength(value string) int {
+ if len(value) >= 2 && (value[0] == '-' || value[0] == '*' || value[0] == '+') && value[1] == ' ' {
+ return 2
+ }
+ i := 0
+ for i < len(value) && value[i] >= '0' && value[i] <= '9' {
+ i++
+ }
+ if i > 0 && i+1 < len(value) && (value[i] == '.' || value[i] == ')') && value[i+1] == ' ' {
+ return i + 2
+ }
+ return 0
+}
diff --git a/shortcuts/doc/internal/docxparse/markdown_cjk.go b/shortcuts/doc/internal/docxparse/markdown_cjk.go
new file mode 100644
index 0000000000..b89fba4884
--- /dev/null
+++ b/shortcuts/doc/internal/docxparse/markdown_cjk.go
@@ -0,0 +1,284 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package docxparse
+
+import (
+ "strings"
+ "unicode"
+)
+
+// preprocessCJKAdjacentMarkup disambiguates a narrow CommonMark pattern common in
+// Chinese prose: emphasis that ends in punctuation and is immediately followed
+// by a letter (for example **结论。**下一步). Goldmark correctly follows
+// CommonMark's delimiter rules, while LarkOpenCLI accepts this authoring form.
+// Rewriting simple CJK delimiter spans to equivalent DocxXML
+// before parsing removes the ambiguity while leaving nested Markdown, links,
+// code, fenced blocks, and source-bearing XML untouched.
+func preprocessCJKAdjacentMarkup(markdown string) string {
+ if !strings.Contains(markdown, "**") && !strings.Contains(markdown, "~~") {
+ return markdown
+ }
+ lines := strings.SplitAfter(markdown, "\n")
+ var out strings.Builder
+ fenceMarker := rune(0)
+ fenceLength := 0
+ rawSourceTag := ""
+ for _, line := range lines {
+ trimmed := strings.TrimLeft(line, " \t>")
+ if marker, length, ok := markdownFence(trimmed); ok {
+ if fenceMarker == 0 {
+ fenceMarker, fenceLength = marker, length
+ } else if marker == fenceMarker && length >= fenceLength && strings.TrimSpace(runeTail(trimmed, length)) == "" {
+ fenceMarker, fenceLength = 0, 0
+ }
+ out.WriteString(line)
+ continue
+ }
+ if fenceMarker != 0 || leadingIndent(line) >= 4 {
+ out.WriteString(line)
+ continue
+ }
+ out.WriteString(rewriteCJKMarkupLine(line, &rawSourceTag))
+ }
+ return out.String()
+}
+
+func markdownFence(line string) (rune, int, bool) {
+ runes := []rune(line)
+ if len(runes) < 3 || runes[0] != '`' && runes[0] != '~' {
+ return 0, 0, false
+ }
+ marker := runes[0]
+ length := 0
+ for length < len(runes) && runes[length] == marker {
+ length++
+ }
+ return marker, length, length >= 3
+}
+
+func runeTail(value string, start int) string {
+ runes := []rune(value)
+ if start >= len(runes) {
+ return ""
+ }
+ return string(runes[start:])
+}
+
+func leadingIndent(line string) int {
+ count := 0
+ for _, r := range line {
+ switch r {
+ case ' ':
+ count++
+ case '\t':
+ count += 4
+ default:
+ return count
+ }
+ }
+ return count
+}
+
+type cjkMarkupRule struct {
+ delimiter []rune
+ openXML string
+ closeXML string
+}
+
+var cjkMarkupRules = []cjkMarkupRule{
+ {delimiter: []rune("***"), openXML: "", closeXML: ""},
+ {delimiter: []rune("~~"), openXML: "", closeXML: ""},
+ {delimiter: []rune("**"), openXML: "", closeXML: ""},
+}
+
+func rewriteCJKMarkupLine(line string, rawSourceTag *string) string {
+ if *rawSourceTag != "" {
+ runes := []rune(line)
+ closeTag := []rune("" + *rawSourceTag + ">")
+ closeAt := indexRunesFold(runes, 0, closeTag)
+ if closeAt < 0 {
+ return line
+ }
+ closeEnd := closeAt + len(closeTag)
+ prefix := string(runes[:closeEnd])
+ *rawSourceTag = ""
+ return prefix + rewriteCJKMarkupLine(string(runes[closeEnd:]), rawSourceTag)
+ }
+
+ runes := []rune(line)
+ var out strings.Builder
+ for i := 0; i < len(runes); {
+ if runes[i] == '`' && !runeEscaped(runes, i) {
+ if end := codeSpanEnd(runes, i); end > i {
+ out.WriteString(string(runes[i:end]))
+ i = end
+ continue
+ }
+ }
+ if runes[i] == '<' {
+ if tag, end, selfClosing, ok := rawTagAt(runes, i); ok {
+ out.WriteString(string(runes[i:end]))
+ i = end
+ if !selfClosing && (tag == "code" || tag == "pre" || tag == "whiteboard") {
+ close := []rune("" + tag + ">")
+ if closeAt := indexRunesFold(runes, i, close); closeAt >= 0 {
+ closeEnd := closeAt + len(close)
+ out.WriteString(string(runes[i:closeEnd]))
+ i = closeEnd
+ } else {
+ out.WriteString(string(runes[i:]))
+ *rawSourceTag = tag
+ return out.String()
+ }
+ }
+ continue
+ }
+ }
+
+ rewritten := false
+ for _, rule := range cjkMarkupRules {
+ if !exactDelimiterAt(runes, i, rule.delimiter) || runeEscaped(runes, i) {
+ continue
+ }
+ closeAt := delimiterCloser(runes, i+len(rule.delimiter), rule.delimiter)
+ if closeAt < 0 {
+ continue
+ }
+ content := runes[i+len(rule.delimiter) : closeAt]
+ if !shouldRewriteCJKMarkup(content) {
+ continue
+ }
+ out.WriteString(rule.openXML)
+ out.WriteString(escapeXMLText(stripBackslashEscapes(string(content))))
+ out.WriteString(rule.closeXML)
+ i = closeAt + len(rule.delimiter)
+ rewritten = true
+ break
+ }
+ if rewritten {
+ continue
+ }
+ out.WriteRune(runes[i])
+ i++
+ }
+ return out.String()
+}
+
+func rawTagAt(runes []rune, start int) (tag string, end int, selfClosing, ok bool) {
+ if start+1 >= len(runes) || !isASCIILetterRune(runes[start+1]) {
+ return "", 0, false, false
+ }
+ i := start + 1
+ for i < len(runes) && (isASCIILetterRune(runes[i]) || isASCIIDigitRune(runes[i]) || runes[i] == '-' || runes[i] == '_') {
+ i++
+ }
+ tag = strings.ToLower(string(runes[start+1 : i]))
+ quote := rune(0)
+ for ; i < len(runes); i++ {
+ if runes[i] == '\'' || runes[i] == '"' {
+ if quote == 0 {
+ quote = runes[i]
+ } else if quote == runes[i] {
+ quote = 0
+ }
+ continue
+ }
+ if runes[i] == '>' && quote == 0 {
+ trimmed := strings.TrimSpace(string(runes[start : i+1]))
+ return tag, i + 1, strings.HasSuffix(trimmed, "/>"), true
+ }
+ }
+ return "", 0, false, false
+}
+
+func indexRunesFold(haystack []rune, start int, needle []rune) int {
+ for i := start; i+len(needle) <= len(haystack); i++ {
+ if strings.EqualFold(string(haystack[i:i+len(needle)]), string(needle)) {
+ return i
+ }
+ }
+ return -1
+}
+
+func codeSpanEnd(runes []rune, open int) int {
+ length := 0
+ for open+length < len(runes) && runes[open+length] == '`' {
+ length++
+ }
+ for i := open + length; i < len(runes); i++ {
+ if runes[i] != '`' || runeEscaped(runes, i) {
+ continue
+ }
+ end := i
+ for end < len(runes) && runes[end] == '`' {
+ end++
+ }
+ if end-i == length {
+ return end
+ }
+ i = end - 1
+ }
+ return -1
+}
+
+func exactDelimiterAt(runes []rune, start int, delimiter []rune) bool {
+ if start+len(delimiter) > len(runes) {
+ return false
+ }
+ for i, want := range delimiter {
+ if runes[start+i] != want {
+ return false
+ }
+ }
+ marker := delimiter[0]
+ return (start == 0 || runes[start-1] != marker) && (start+len(delimiter) == len(runes) || runes[start+len(delimiter)] != marker)
+}
+
+func delimiterCloser(runes []rune, start int, delimiter []rune) int {
+ for i := start; i+len(delimiter) <= len(runes); i++ {
+ if runes[i] == '\n' {
+ return -1
+ }
+ if exactDelimiterAt(runes, i, delimiter) && !runeEscaped(runes, i) {
+ return i
+ }
+ }
+ return -1
+}
+
+func shouldRewriteCJKMarkup(content []rune) bool {
+ if len(content) == 0 || unicode.IsSpace(content[0]) || unicode.IsSpace(content[len(content)-1]) {
+ return false
+ }
+ for _, r := range content {
+ if r == '`' || r == '[' || r == ']' || r == '<' || r == '>' {
+ return false
+ }
+ }
+ if !containsCJK(content) {
+ return false
+ }
+ return true
+}
+
+func containsCJK(value []rune) bool {
+ for _, r := range value {
+ if isCJKRune(r) || r > unicode.MaxASCII && (unicode.IsPunct(r) || unicode.IsSymbol(r)) {
+ return true
+ }
+ }
+ return false
+}
+
+func isCJKRune(r rune) bool {
+ return unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul)
+}
+
+func runeEscaped(runes []rune, index int) bool {
+ count := 0
+ for i := index - 1; i >= 0 && runes[i] == '\\'; i-- {
+ count++
+ }
+ return count%2 == 1
+}
diff --git a/shortcuts/doc/internal/docxparse/markdown_extensions.go b/shortcuts/doc/internal/docxparse/markdown_extensions.go
new file mode 100644
index 0000000000..ce09687c8a
--- /dev/null
+++ b/shortcuts/doc/internal/docxparse/markdown_extensions.go
@@ -0,0 +1,334 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package docxparse
+
+// This file contains the small Goldmark extensions needed to match the
+// LarkOpenCLI's Markdown surface: math, DocxXML tag names containing
+// underscores, and Markdown-aware callout/grid/column containers.
+
+import (
+ "bytes"
+ "regexp"
+ "strings"
+
+ "github.com/yuin/goldmark"
+ gast "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/text"
+ gmutil "github.com/yuin/goldmark/util"
+)
+
+// ---------- Math ----------
+
+var kindMathInline = gast.NewNodeKind("DocxMathInline")
+var kindMathBlock = gast.NewNodeKind("DocxMathBlock")
+
+type mathInline struct {
+ gast.BaseInline
+ content []byte
+}
+
+func (n *mathInline) Kind() gast.NodeKind { return kindMathInline }
+func (n *mathInline) Dump(source []byte, level int) {
+ gast.DumpHelper(n, source, level, nil, nil)
+}
+
+type mathBlock struct {
+ gast.BaseInline
+ content []byte
+}
+
+func (n *mathBlock) Kind() gast.NodeKind { return kindMathBlock }
+func (n *mathBlock) Dump(source []byte, level int) {
+ gast.DumpHelper(n, source, level, nil, nil)
+}
+
+var (
+ mathBlockMultiLine = regexp.MustCompile(`(?s)^\$\$(.+?)\$\$`)
+ mathInlineMultiLine = regexp.MustCompile(`(?s)^\$([^ \t$].*?)\$`)
+)
+
+type mathInlineParser struct{}
+
+func (p *mathInlineParser) Trigger() []byte { return []byte{'$'} }
+
+func (p *mathInlineParser) Parse(_ gast.Node, reader text.Reader, _ parser.Context) gast.Node {
+ line, _ := reader.PeekLine()
+ if len(line) == 0 || line[0] != '$' {
+ return nil
+ }
+ if len(line) >= 2 && line[1] == '$' {
+ if content, advance := scanMathClose(line[2:], "$$"); advance >= 0 && len(content) > 0 {
+ reader.Advance(2 + advance)
+ return &mathBlock{content: append([]byte(nil), content...)}
+ }
+ match := reader.FindSubMatch(mathBlockMultiLine)
+ if len(match) >= 2 && len(bytes.TrimSpace(match[1])) > 0 && !bytes.Contains(match[1], []byte("= 0 && len(content) > 0 {
+ if content[len(content)-1] == ' ' || content[len(content)-1] == '\t' {
+ return nil
+ }
+ reader.Advance(1 + advance)
+ return &mathInline{content: append([]byte(nil), content...)}
+ }
+ match := reader.FindSubMatch(mathInlineMultiLine)
+ if len(match) < 2 || bytes.Contains(match[1], []byte("` + "`" + `\x00-\x20]+|'[^']*'|"[^"]*"))?)`
+ extendedOpenTag = regexp.MustCompile("^<" + extendedTagNamePattern + extendedAttributePattern + `*\s*/?>`)
+ extendedCloseTag = regexp.MustCompile("^" + extendedTagNamePattern + `\s*>`)
+ peekExtendedOpenTag = regexp.MustCompile(`^<([A-Za-z][A-Za-z0-9_-]*)`)
+ peekExtendedCloseTag = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9_-]*)`)
+ extendedBlockTag = regexp.MustCompile(`^[ ]{0,3}<(/)?\s*([a-zA-Z0-9_\-]+)(` + extendedAttributePattern + `*)\s*(?:>|/>)\s*\n?$`)
+)
+
+type underscoreRawHTMLParser struct{}
+
+func (p *underscoreRawHTMLParser) Trigger() []byte { return []byte{'<'} }
+
+func (p *underscoreRawHTMLParser) Parse(_ gast.Node, reader text.Reader, _ parser.Context) gast.Node {
+ line, _ := reader.PeekLine()
+ if len(line) > 1 && gmutil.IsAlphaNumeric(line[1]) {
+ if match := peekExtendedOpenTag.FindSubmatch(line); match != nil && bytes.IndexByte(match[1], '_') >= 0 {
+ return p.parseMultiLine(extendedOpenTag, reader)
+ }
+ return nil
+ }
+ if len(line) > 2 && line[1] == '/' && gmutil.IsAlphaNumeric(line[2]) {
+ if match := peekExtendedCloseTag.FindSubmatch(line); match != nil && bytes.IndexByte(match[1], '_') >= 0 {
+ return p.parseMultiLine(extendedCloseTag, reader)
+ }
+ }
+ return nil
+}
+
+func (p *underscoreRawHTMLParser) parseMultiLine(re *regexp.Regexp, reader text.Reader) gast.Node {
+ startLine, startSegment := reader.Position()
+ if !reader.Match(re) {
+ return nil
+ }
+ endLine, endSegment := reader.Position()
+ reader.SetPosition(startLine, startSegment)
+ node := gast.NewRawHTML()
+ for {
+ line, segment := reader.PeekLine()
+ if line == nil {
+ break
+ }
+ lineNo, _ := reader.Position()
+ start := segment.Start
+ if lineNo == startLine {
+ start = startSegment.Start
+ }
+ end := segment.Stop
+ if lineNo == endLine {
+ end = endSegment.Start
+ }
+ node.Segments.Append(text.NewSegment(start, end))
+ if lineNo == endLine {
+ reader.Advance(end - start)
+ break
+ }
+ reader.AdvanceLine()
+ }
+ return node
+}
+
+type underscoreHTMLBlockParser struct{}
+
+func (p *underscoreHTMLBlockParser) Trigger() []byte { return []byte{'<'} }
+
+func (p *underscoreHTMLBlockParser) Open(_ gast.Node, reader text.Reader, pc parser.Context) (gast.Node, parser.State) {
+ line, segment := reader.PeekLine()
+ pos := pc.BlockOffset()
+ if pos < 0 || pos >= len(line) || line[pos] != '<' {
+ return nil, parser.NoChildren
+ }
+ match := extendedBlockTag.FindSubmatchIndex(line)
+ if match == nil {
+ return nil, parser.NoChildren
+ }
+ tag := string(line[match[4]:match[5]])
+ if !strings.Contains(tag, "_") {
+ return nil, parser.NoChildren
+ }
+ isClose := match[2] > -1 && bytes.Equal(line[match[2]:match[3]], []byte("/"))
+ hasAttrs := match[6] != match[7]
+ if isClose && hasAttrs {
+ return nil, parser.NoChildren
+ }
+ node := gast.NewHTMLBlock(gast.HTMLBlockType7)
+ node.Lines().Append(segment)
+ reader.Advance(segment.Len() - 1)
+ return node, parser.NoChildren
+}
+
+func (p *underscoreHTMLBlockParser) Continue(node gast.Node, reader text.Reader, _ parser.Context) parser.State {
+ line, segment := reader.PeekLine()
+ if gmutil.IsBlank(line) {
+ return parser.Close
+ }
+ node.Lines().Append(segment)
+ reader.Advance(segment.Len() - 1)
+ return parser.Continue | parser.NoChildren
+}
+
+func (p *underscoreHTMLBlockParser) Close(gast.Node, text.Reader, parser.Context) {}
+func (p *underscoreHTMLBlockParser) CanInterruptParagraph() bool { return false }
+func (p *underscoreHTMLBlockParser) CanAcceptIndentedLine() bool { return false }
+
+// ---------- Markdown-aware DocxXML containers ----------
+
+type containerSpec struct {
+ tag string
+}
+
+var containerSpecs = map[string]*containerSpec{
+ "callout": {tag: "callout"},
+ "grid": {tag: "grid"},
+ "column": {tag: "column"},
+ "div": {tag: "div"},
+}
+
+var kindContainerBlock = gast.NewNodeKind("DocxContainerBlock")
+
+type containerBlock struct {
+ gast.BaseBlock
+ spec *containerSpec
+ attrs map[string]string
+}
+
+func (n *containerBlock) Kind() gast.NodeKind { return kindContainerBlock }
+func (n *containerBlock) Dump(source []byte, level int) {
+ gast.DumpHelper(n, source, level, nil, nil)
+}
+
+type containerBlockParser struct{}
+
+func (p *containerBlockParser) Trigger() []byte { return []byte{'<'} }
+
+var containerOpenTag = regexp.MustCompile(`^<([A-Za-z][A-Za-z0-9_-]*)`)
+
+func (p *containerBlockParser) Open(_ gast.Node, reader text.Reader, _ parser.Context) (gast.Node, parser.State) {
+ line, _ := reader.PeekLine()
+ trimmed := bytes.TrimLeft(line, " \t")
+ leading := len(line) - len(trimmed)
+ if len(trimmed) < 2 || trimmed[0] != '<' {
+ return nil, parser.NoChildren
+ }
+ match := containerOpenTag.FindSubmatch(trimmed)
+ if match == nil {
+ return nil, parser.NoChildren
+ }
+ spec := containerSpecs[strings.ToLower(string(match[1]))]
+ if spec == nil {
+ return nil, parser.NoChildren
+ }
+ openEnd := bytes.IndexByte(trimmed, '>')
+ if openEnd < 0 || openEnd >= 1 && trimmed[openEnd-1] == '/' {
+ return nil, parser.NoChildren
+ }
+ tagEnd := len(match[0])
+ node := &containerBlock{spec: spec, attrs: parseAttributes(string(trimmed[tagEnd:openEnd]))}
+ reader.Advance(leading + openEnd + 1)
+ return node, parser.HasChildren
+}
+
+func (p *containerBlockParser) Continue(node gast.Node, reader text.Reader, _ parser.Context) parser.State {
+ container := node.(*containerBlock)
+ line, segment := reader.PeekLine()
+ trimmed := bytes.TrimLeft(line, " \t")
+ if hasCloseTagPrefix(trimmed, container.spec.tag) {
+ reader.Advance(len(line) - len(trimmed) + closeTagLength(container.spec.tag))
+ return parser.Close
+ }
+ if isXMLTagLine(trimmed) {
+ indent := len(line) - len(trimmed)
+ if indent > 0 && segment.Start+indent <= segment.Stop {
+ reader.AdvanceAndSetPadding(indent, 0)
+ }
+ }
+ return parser.Continue | parser.HasChildren
+}
+
+func (p *containerBlockParser) Close(gast.Node, text.Reader, parser.Context) {}
+func (p *containerBlockParser) CanInterruptParagraph() bool { return true }
+func (p *containerBlockParser) CanAcceptIndentedLine() bool { return true }
+
+func closeTagLength(tag string) int { return len(tag) + len(">") }
+
+func hasCloseTagPrefix(line []byte, tag string) bool {
+ want := []byte("" + tag + ">")
+ return len(line) >= len(want) && bytes.EqualFold(line[:len(want)], want)
+}
+
+func isXMLTagLine(line []byte) bool {
+ if len(line) < 2 || line[0] != '<' {
+ return false
+ }
+ if line[1] == '/' {
+ return len(line) >= 3 && isASCIILetter(line[2])
+ }
+ return isASCIILetter(line[1])
+}
+
+func isASCIILetter(ch byte) bool {
+ return ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z'
+}
diff --git a/shortcuts/doc/internal/docxparse/model.go b/shortcuts/doc/internal/docxparse/model.go
new file mode 100644
index 0000000000..187fe05294
--- /dev/null
+++ b/shortcuts/doc/internal/docxparse/model.go
@@ -0,0 +1,172 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+// Package docxparse parses LarkOpenCLI DocxXML and Markdown into a small,
+// offline DOM for the docs +script shortcut.
+package docxparse
+
+import (
+ "sort"
+ "strings"
+)
+
+// Format is an accepted source document format.
+type Format string
+
+const (
+ FormatXML Format = "xml"
+ FormatMarkdown Format = "markdown"
+)
+
+// ParseResult is the complete result returned by Parse.
+type ParseResult struct {
+ Format Format `json:"format"`
+ XML string `json:"xml"`
+ Profile Profile `json:"profile"`
+}
+
+type nodeType uint8
+
+const (
+ nodeText nodeType = iota
+ nodeElement
+)
+
+// Node is the internal DocxXML DOM representation.
+type Node struct {
+ typ nodeType
+ tag string
+ attrs map[string]string
+ children []*Node
+ text string
+ parent *Node
+}
+
+func newText(text string) *Node {
+ return &Node{typ: nodeText, text: text}
+}
+
+func newElement(tag string, attrs map[string]string) *Node {
+ return &Node{typ: nodeElement, tag: tag, attrs: attrs}
+}
+
+func (n *Node) addChild(child *Node) {
+ if n == nil || child == nil {
+ return
+ }
+ child.parent = n
+ n.children = append(n.children, child)
+}
+
+func (n *Node) writeXML(out *strings.Builder) {
+ if n == nil {
+ return
+ }
+ if n.typ == nodeText {
+ out.WriteString(escapeXMLText(n.text))
+ return
+ }
+
+ out.WriteByte('<')
+ out.WriteString(n.tag)
+ keys := make([]string, 0, len(n.attrs))
+ for key := range n.attrs {
+ keys = append(keys, key)
+ }
+ sort.Slice(keys, func(i, j int) bool {
+ wi, iWeighted := attributeWeight[keys[i]]
+ wj, jWeighted := attributeWeight[keys[j]]
+ switch {
+ case iWeighted && jWeighted && wi != wj:
+ return wi < wj
+ case iWeighted != jWeighted:
+ return iWeighted
+ default:
+ return keys[i] < keys[j]
+ }
+ })
+ for _, key := range keys {
+ out.WriteByte(' ')
+ out.WriteString(key)
+ out.WriteString(`="`)
+ out.WriteString(escapeXMLAttr(n.attrs[key]))
+ out.WriteByte('"')
+ }
+
+ if isVoidTag(n.tag) {
+ out.WriteString("/>")
+ return
+ }
+ out.WriteByte('>')
+ for _, child := range n.children {
+ child.writeXML(out)
+ }
+ out.WriteString("")
+ out.WriteString(n.tag)
+ out.WriteByte('>')
+}
+
+func renderNodes(nodes []*Node) string {
+ var out strings.Builder
+ for _, node := range nodes {
+ node.writeXML(&out)
+ }
+ return out.String()
+}
+
+var attributeWeight = map[string]int{
+ "id": 0,
+ "name": 1,
+ "top-block-id": 2,
+ "parent-block-path": 3,
+ "mode": 4,
+ "start-block-id": 5,
+ "end-block-id": 6,
+ "hit-block-ids": 7,
+}
+
+func escapeXMLText(value string) string {
+ if !strings.ContainsAny(value, "&<>") {
+ return value
+ }
+ var out strings.Builder
+ out.Grow(len(value) + 8)
+ for _, r := range value {
+ switch r {
+ case '&':
+ out.WriteString("&")
+ case '<':
+ out.WriteString("<")
+ case '>':
+ out.WriteString(">")
+ default:
+ out.WriteRune(r)
+ }
+ }
+ return out.String()
+}
+
+func escapeXMLAttr(value string) string {
+ if !strings.ContainsAny(value, "&<>\"'") {
+ return value
+ }
+ var out strings.Builder
+ out.Grow(len(value) + 8)
+ for _, r := range value {
+ switch r {
+ case '&':
+ out.WriteString("&")
+ case '<':
+ out.WriteString("<")
+ case '>':
+ out.WriteString(">")
+ case '"':
+ out.WriteString(""")
+ case '\'':
+ out.WriteString("'")
+ default:
+ out.WriteRune(r)
+ }
+ }
+ return out.String()
+}
diff --git a/shortcuts/doc/internal/docxparse/parse_test.go b/shortcuts/doc/internal/docxparse/parse_test.go
new file mode 100644
index 0000000000..ee5a66c4e9
--- /dev/null
+++ b/shortcuts/doc/internal/docxparse/parse_test.go
@@ -0,0 +1,432 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package docxparse
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestParseXMLBuildsBlockDistribution(t *testing.T) {
+ result, err := Parse(`TP
`, FormatXML)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ if result.XML != `TP
` {
+ t.Fatalf("XML = %q", result.XML)
+ }
+ if result.Profile.BlockCount != 5 {
+ t.Fatalf("block total = %d, want 5", result.Profile.BlockCount)
+ }
+ shares := map[string]BlockShare{}
+ for _, share := range result.Profile.Blocks {
+ shares[share.Type] = share
+ }
+ if got := shares["li"]; got.Count != 2 || got.Ratio != 0.4 {
+ t.Fatalf("li share = %+v, want count=2 ratio=0.4", got)
+ }
+ for _, typ := range []string{"title", "p", "ul"} {
+ if got := shares[typ]; got.Count != 1 || got.Ratio != 0.2 {
+ t.Errorf("%s share = %+v, want count=1 ratio=0.2", typ, got)
+ }
+ }
+}
+
+func TestParseXMLRejectsInvalidInput(t *testing.T) {
+ tests := []struct {
+ name string
+ source string
+ }{
+ {name: "unsupported tag", source: `x`},
+ {name: "missing closing tag", source: `one`},
+ {name: "invalid nesting", source: `x`},
+ {name: "malformed block id", source: ``},
+ {name: "unterminated cdata", source: ``},
+ {name: "tag spacing", source: `< p>text< / p>`},
+ {name: "self closing slash spacing", source: ``},
+ {name: "unquoted attribute", source: `
text
`},
+ {name: "invalid entity", source: `one &unknown;
`},
+ {name: "missing required ancestor", source: `cell | `},
+ {name: "missing required attribute", source: `
`},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if _, err := Parse(tt.source, FormatXML); err == nil {
+ t.Fatalf("Parse(%q) succeeded, want validation error", tt.source)
+ }
+ })
+ }
+}
+
+func TestParseAutoDetectsXMLAndMarkdown(t *testing.T) {
+ tests := []struct {
+ name string
+ source string
+ blocks int
+ }{
+ {name: "xml", source: `TP
`, blocks: 2},
+ {name: "markdown", source: "# T\n\nP", blocks: 2},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ profile, err := ParseAuto(tt.source)
+ if err != nil {
+ t.Fatalf("ParseAuto() error = %v", err)
+ }
+ if profile.BlockCount != tt.blocks {
+ t.Fatalf("profile = %+v, want %d blocks", profile, tt.blocks)
+ }
+ })
+ }
+}
+
+func TestParseAutoDoesNotTreatMalformedXMLAsMarkdown(t *testing.T) {
+ if _, err := ParseAuto(`text`); err == nil {
+ t.Fatal("ParseAuto() succeeded, want malformed XML error")
+ }
+}
+
+func TestParseXMLAcceptsPublicTagAliasesWithoutChangingInput(t *testing.T) {
+ source := `
onetwo
`
+ result, err := Parse(source, FormatXML)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ if result.XML != source {
+ t.Fatalf("XML = %q, want original %q", result.XML, source)
+ }
+ if result.Profile.BlockCount != 2 {
+ t.Fatalf("profile = %+v, want p and img blocks", result.Profile)
+ }
+}
+
+func TestParseXMLAcceptsPublicAttributeAliasesWithoutChangingInput(t *testing.T) {
+ source := `x
`
+ result, err := Parse(source, FormatXML)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ if result.XML != source {
+ t.Fatalf("XML = %q, want original %q", result.XML, source)
+ }
+ if result.Profile.BlockCount != 3 {
+ t.Fatalf("profile = %+v, want callout, p, and img blocks", result.Profile)
+ }
+}
+
+func TestParseXMLPreservesValidCDATA(t *testing.T) {
+ source := ` d]]>`
+ result, err := Parse(source, FormatXML)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ if result.XML != source {
+ t.Fatalf("XML = %q, want original %q", result.XML, source)
+ }
+}
+
+func TestParseXMLPreservesUTF8BOM(t *testing.T) {
+ source := "\uFEFFtext
"
+ result, err := Parse(source, FormatXML)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ if result.XML != source {
+ t.Fatalf("XML = %q, want original input", result.XML)
+ }
+}
+
+func TestParseMarkdownConvertsLarkOpenCLIBlocks(t *testing.T) {
+ source := "# 标题\n\nHello **world**.\n\n- [x] Done\n- [ ] Todo\n\n" +
+ "| A | B |\n| --- | --- |\n| 1 | 2 |\n\n" +
+ "```go\nfmt.Println(\"x\")\n```\n\n$E=mc^2$\n"
+ result, err := Parse(source, FormatMarkdown)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ for _, fragment := range []string{
+ `标题
`,
+ `Hello world.
`,
+ `Done`,
+ `Todo`,
+ ``,
+ `fmt.Println("x")
`,
+ `E=mc^2
`,
+ } {
+ if !strings.Contains(result.XML, fragment) {
+ t.Errorf("XML missing %q:\n%s", fragment, result.XML)
+ }
+ }
+}
+
+func TestParseMarkdownContainerKeepsMarkdownChildren(t *testing.T) {
+ source := "\n\n## Note\n\n- item\n\n\n"
+ result, err := Parse(source, FormatMarkdown)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ want := `Note
`
+ if result.XML != want {
+ t.Fatalf("XML = %q, want %q", result.XML, want)
+ }
+}
+
+func TestParseMarkdownMatchesLarkOpenCLIFixtures(t *testing.T) {
+ t.Run("deep nested list", func(t *testing.T) {
+ result, err := Parse("1. 第一层\n - 第二层\n - 第三层\n - 第四层\n", FormatMarkdown)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ if strings.Contains(result.XML, "") || strings.Contains(result.XML, "") || !strings.Contains(result.XML, "第四层") {
+ t.Fatalf("nested list converted incorrectly: %s", result.XML)
+ }
+ })
+
+ t.Run("fenced mermaid", func(t *testing.T) {
+ result, err := Parse("```mermaid\nflowchart LR\nA-->B\n```", FormatMarkdown)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ want := `flowchart LR
A-->B`
+ if result.XML != want {
+ t.Fatalf("XML = %q, want %q", result.XML, want)
+ }
+ })
+
+ t.Run("raw whiteboard source", func(t *testing.T) {
+ source := "\nflowchart LR\n A --> B\n"
+ result, err := Parse(source, FormatMarkdown)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ want := `flowchart LR
A --> B`
+ if result.XML != want {
+ t.Fatalf("XML = %q, want %q", result.XML, want)
+ }
+ })
+
+ t.Run("raw code stays literal", func(t *testing.T) {
+ source := "\nif a < b && c > d {\n fmt.Println(\"**raw**\")\n}\n"
+ result, err := Parse(source, FormatMarkdown)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ want := `if a < b && c > d {
fmt.Println("**raw**")
}`
+ if result.XML != want {
+ t.Fatalf("XML = %q, want %q", result.XML, want)
+ }
+ })
+
+ t.Run("underscore tags", func(t *testing.T) {
+ result, err := Parse(`text more`, FormatMarkdown)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ if !strings.Contains(result.XML, ``, FormatMarkdown)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ for _, want := range []string{`world`, FormatMarkdown)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ if result.XML != `hello world
` {
+ t.Fatalf("XML = %q", result.XML)
+ }
+ })
+
+ t.Run("public cite alias converts attributes", func(t *testing.T) {
+ result, err := Parse(`hello `, FormatMarkdown)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ if result.XML != `hello
` {
+ t.Fatalf("XML = %q", result.XML)
+ }
+ })
+
+ t.Run("markdown backslash escapes", func(t *testing.T) {
+ result, err := Parse(`"source\_token": \[abc\] path\\to`, FormatMarkdown)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ for _, want := range []string{`source_token`, `[abc]`, `path\to`} {
+ if !strings.Contains(result.XML, want) {
+ t.Errorf("XML missing %q: %s", want, result.XML)
+ }
+ }
+ })
+
+ t.Run("adjacent CJK emphasis", func(t *testing.T) {
+ source := `***你好。***S 和 ~~再见。~~T。**agent team 做 brownfield 项目,带来的感知会强烈得多**——前提。**这个时刻,才是真正属于 agent team 的"闪光时刻"。**翟霖`
+ result, err := Parse(source, FormatMarkdown)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ for _, want := range []string{
+ `你好。S`,
+ `再见。T`,
+ `agent team 做 brownfield 项目,带来的感知会强烈得多`,
+ `这个时刻,才是真正属于 agent team 的"闪光时刻"。翟霖`,
+ } {
+ if !strings.Contains(result.XML, want) {
+ t.Errorf("XML missing %q: %s", want, result.XML)
+ }
+ }
+ })
+
+ t.Run("div parses markdown children", func(t *testing.T) {
+ result, err := Parse("\n\n**bold**\n\n
", FormatMarkdown)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ if result.XML != `` {
+ t.Fatalf("XML = %q", result.XML)
+ }
+ })
+}
+
+func TestPreprocessCJKAdjacentMarkupUsesRuneOffsetsAfterRawBlock(t *testing.T) {
+ tests := []struct {
+ name string
+ lineEnding string
+ final string
+ }{
+ {name: "EOF", lineEnding: "\n"},
+ {name: "LF", lineEnding: "\n", final: "\n"},
+ {name: "CRLF", lineEnding: "\r\n", final: "\r\n"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ source := "**raw**" + tt.lineEnding + "Ⱥ**你好。**S" + tt.final
+ want := "**raw**" + tt.lineEnding + "Ⱥ你好。S" + tt.final
+ if got := preprocessCJKAdjacentMarkup(source); got != want {
+ t.Fatalf("preprocessCJKAdjacentMarkup() = %q, want %q", got, want)
+ }
+ })
+ }
+}
+
+func TestTextProfileMatchesLarkOpenCLIContract(t *testing.T) {
+ result, err := Parse(`标题一个苹果是 an apple。
`, FormatXML)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ profile := result.Profile
+ if profile.WordCount != 10 || profile.CharCount != 15 {
+ t.Fatalf("profile = %+v, want word_count=10 char_count=15", profile)
+ }
+ if profile.Breakdown.HanChars != 7 || profile.Breakdown.EnglishWords != 2 || profile.Breakdown.ChinesePunctuations != 1 {
+ t.Fatalf("breakdown = %+v", profile.Breakdown)
+ }
+}
+
+func TestTextProfileMatchesAuthoringCounterCases(t *testing.T) {
+ tests := []struct {
+ name string
+ source string
+ words int
+ chars int
+ blocks int
+ english int
+ numbers int
+ han int
+ listItems int
+ }{
+ {
+ name: "english number and punctuation",
+ source: `Hello world 123.45。
`,
+ words: 4, chars: 17, blocks: 1, english: 2, numbers: 1,
+ },
+ {
+ name: "list and checkbox markers",
+ source: `完成`,
+ words: 7, chars: 9, blocks: 4, english: 1, han: 3, listItems: 2,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := Parse(tt.source, FormatXML)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ profile := result.Profile
+ if profile.WordCount != tt.words || profile.CharCount != tt.chars || profile.BlockCount != tt.blocks {
+ t.Fatalf("profile = %+v, want words=%d chars=%d blocks=%d", profile, tt.words, tt.chars, tt.blocks)
+ }
+ if profile.Breakdown.EnglishWords != tt.english || profile.Breakdown.NumberWords != tt.numbers || profile.Breakdown.HanChars != tt.han {
+ t.Fatalf("breakdown = %+v", profile.Breakdown)
+ }
+ if got := blockCountForTest(profile.Blocks, "li"); got != tt.listItems {
+ t.Fatalf("li count = %d, want %d", got, tt.listItems)
+ }
+ })
+ }
+}
+
+func TestTextProfileUsesVisibleAttributeFallbacks(t *testing.T) {
+ result, err := Parse(`
`, FormatXML)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ profile := result.Profile
+ if profile.WordCount != 3 || profile.CharCount != 11 {
+ t.Fatalf("profile = %+v, want word_count=3 char_count=11", profile)
+ }
+ if profile.Breakdown.EnglishWords != 2 || profile.Breakdown.HanChars != 1 {
+ t.Fatalf("breakdown = %+v", profile.Breakdown)
+ }
+}
+
+func TestParseRejectsUnsafeXMLDeclarations(t *testing.T) {
+ _, err := Parse(`]>&x;
`, FormatXML)
+ if err == nil || !strings.Contains(err.Error(), "DOCTYPE or ENTITY") {
+ t.Fatalf("Parse() error = %v, want unsafe declaration rejection", err)
+ }
+}
+
+func TestParseRejectsInvalidUTF8(t *testing.T) {
+ _, err := Parse(string([]byte{'<', 'p', '>', 0xff, '<', '/', 'p', '>'}), FormatXML)
+ if err == nil || !strings.Contains(err.Error(), "valid UTF-8") {
+ t.Fatalf("Parse() error = %v, want UTF-8 rejection", err)
+ }
+}
+
+func TestParseRejectsExcessiveNesting(t *testing.T) {
+ source := strings.Repeat("", MaxNestingDepth+1)
+ _, err := Parse(source, FormatXML)
+ if err == nil || !strings.Contains(err.Error(), "nesting exceeds") {
+ t.Fatalf("Parse() error = %v, want nesting limit rejection", err)
+ }
+}
+
+func TestParseXMLRejectsNestedInvalidTagStarts(t *testing.T) {
+ if _, err := Parse(`<<<text
`, FormatXML); err == nil {
+ t.Fatal("Parse() succeeded, want invalid XML token error")
+ }
+}
+
+func blockCountForTest(blocks []BlockShare, typ string) int {
+ for _, block := range blocks {
+ if block.Type == typ {
+ return block.Count
+ }
+ }
+ return 0
+}
diff --git a/shortcuts/doc/internal/docxparse/profile.go b/shortcuts/doc/internal/docxparse/profile.go
new file mode 100644
index 0000000000..4cbb7cb2ef
--- /dev/null
+++ b/shortcuts/doc/internal/docxparse/profile.go
@@ -0,0 +1,397 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package docxparse
+
+import (
+ "fmt"
+ "math"
+ "sort"
+ "strings"
+)
+
+// Profile describes LarkOpenCLI document structure and visible text without
+// requiring callers to inspect the full XML.
+type Profile struct {
+ WordCount int `json:"word_count"`
+ CharCount int `json:"char_count"`
+ Breakdown TextBreakdown `json:"breakdown"`
+ BlockCount int `json:"block_count"`
+ Blocks []BlockShare `json:"blocks"`
+}
+
+// BlockShare reports one LarkOpenCLI block type's count and share. Structural
+// and inline-only tags are intentionally excluded.
+type BlockShare struct {
+ Type string `json:"type"`
+ Count int `json:"count"`
+ Ratio float64 `json:"ratio"`
+}
+
+// TextProfile is the internal result of the LarkOpenCLI semantic counter.
+type TextProfile struct {
+ WordCount int `json:"word_count"`
+ CharCount int `json:"char_count"`
+ Breakdown TextBreakdown `json:"breakdown"`
+}
+
+type TextBreakdown struct {
+ HanChars int `json:"han_chars"`
+ EnglishWords int `json:"english_words"`
+ NumberWords int `json:"number_words"`
+ ChinesePunctuations int `json:"chinese_punctuations"`
+ EnglishLetters int `json:"english_letters"`
+ Digits int `json:"digits"`
+ EnglishPunctuations int `json:"english_punctuations"`
+ SymbolWords int `json:"symbol_words"`
+ SymbolChars int `json:"symbol_chars"`
+}
+
+// Parse validates XML or converts Markdown to DocxXML, then builds its
+// structure and visible-text profile.
+func Parse(source string, format Format) (ParseResult, error) {
+ var (
+ nodes []*Node
+ outputXML string
+ err error
+ )
+ switch format {
+ case FormatXML:
+ nodes, err = parseXML(source)
+ outputXML = source
+ case FormatMarkdown:
+ nodes, err = parseMarkdown(source)
+ default:
+ return ParseResult{}, fmt.Errorf("unsupported input format %q", format)
+ }
+ if err != nil {
+ return ParseResult{}, err
+ }
+ if err := validateStructure(nodes); err != nil {
+ return ParseResult{}, err
+ }
+ if format == FormatMarkdown {
+ outputXML = renderNodes(nodes)
+ }
+
+ return ParseResult{
+ Format: format,
+ XML: outputXML,
+ Profile: buildProfile(nodes),
+ }, nil
+}
+
+// ParseAuto detects XML versus Markdown from the content and returns only the
+// document profile. XML-like input is parsed strictly; all other input is
+// interpreted as Markdown.
+func ParseAuto(source string) (Profile, error) {
+ result, err := Parse(source, detectFormat(source))
+ if err != nil {
+ return Profile{}, err
+ }
+ return result.Profile, nil
+}
+
+// MarkdownToXML converts Markdown to canonical LarkOpenCLI XML.
+func MarkdownToXML(source string) (string, error) {
+ result, err := Parse(source, FormatMarkdown)
+ if err != nil {
+ return "", err
+ }
+ return result.XML, nil
+}
+
+func detectFormat(source string) Format {
+ trimmed := strings.TrimSpace(strings.TrimPrefix(source, "\uFEFF"))
+ if strings.HasPrefix(trimmed, "<") {
+ return FormatXML
+ }
+ return FormatMarkdown
+}
+
+func validateStructure(nodes []*Node) error {
+ type frame struct {
+ node *Node
+ exit bool
+ }
+ frames := make([]frame, 0, len(nodes))
+ for i := len(nodes) - 1; i >= 0; i-- {
+ frames = append(frames, frame{node: nodes[i]})
+ }
+ ancestors := map[string]int{}
+ depth := 0
+ for len(frames) > 0 {
+ current := frames[len(frames)-1]
+ frames = frames[:len(frames)-1]
+ node := current.node
+ if node == nil || node.typ != nodeElement {
+ continue
+ }
+ if current.exit {
+ ancestors[node.tag]--
+ depth--
+ continue
+ }
+ if depth >= MaxNestingDepth {
+ return fmt.Errorf("document nesting exceeds limit %d at <%s>", MaxNestingDepth, node.tag)
+ }
+ if err := validateRequiredAttributes(node); err != nil {
+ return err
+ }
+ if required := requiredAncestorTags[node.tag]; len(required) > 0 {
+ matched := false
+ for tag := range required {
+ if ancestors[tag] > 0 {
+ matched = true
+ break
+ }
+ }
+ if !matched {
+ allowed := make([]string, 0, len(required))
+ for tag := range required {
+ allowed = append(allowed, tag)
+ }
+ sort.Strings(allowed)
+ return fmt.Errorf("LarkOpenCLI tag <%s> requires an ancestor in [%s]", node.tag, strings.Join(allowed, ", "))
+ }
+ }
+
+ ancestors[node.tag]++
+ depth++
+ frames = append(frames, frame{node: node, exit: true})
+ for i := len(node.children) - 1; i >= 0; i-- {
+ frames = append(frames, frame{node: node.children[i]})
+ }
+ }
+ return nil
+}
+
+func validateRequiredAttributes(node *Node) error {
+ for _, attr := range requiredAttributes[node.tag] {
+ if strings.TrimSpace(node.attrs[attr]) == "" {
+ return fmt.Errorf("LarkOpenCLI tag <%s> requires attribute %q", node.tag, attr)
+ }
+ }
+ for _, alternatives := range requiredAnyAttributes[node.tag] {
+ matched := false
+ for _, attr := range alternatives {
+ if strings.TrimSpace(node.attrs[attr]) != "" {
+ matched = true
+ break
+ }
+ }
+ if !matched {
+ return fmt.Errorf("LarkOpenCLI tag <%s> requires one of attributes [%s]", node.tag, strings.Join(alternatives, ", "))
+ }
+ }
+ return nil
+}
+
+func buildProfile(nodes []*Node) Profile {
+ counts := map[string]int{}
+ total := 0
+ var walk func(*Node)
+ walk = func(node *Node) {
+ if node == nil || node.typ != nodeElement {
+ return
+ }
+ layout := layoutOf(node.tag)
+ isBlock := layout == layoutBlock || layout == layoutDual && node.parent == nil
+ if isBlock {
+ counts[node.tag]++
+ total++
+ }
+ for _, child := range node.children {
+ walk(child)
+ }
+ }
+ for _, node := range nodes {
+ walk(node)
+ }
+
+ distribution := make([]BlockShare, 0, len(counts))
+ for typ, count := range counts {
+ ratio := 0.0
+ if total > 0 {
+ ratio = math.Round(float64(count)/float64(total)*1_000_000) / 1_000_000
+ }
+ distribution = append(distribution, BlockShare{Type: typ, Count: count, Ratio: ratio})
+ }
+ sort.Slice(distribution, func(i, j int) bool {
+ if distribution[i].Count != distribution[j].Count {
+ return distribution[i].Count > distribution[j].Count
+ }
+ return distribution[i].Type < distribution[j].Type
+ })
+ segments := extractSegments(nodes)
+ stats := newTextCounter().countSegments(segments)
+ return Profile{
+ WordCount: stats.WordCount,
+ CharCount: stats.CharCount,
+ Breakdown: stats.Breakdown,
+ BlockCount: total,
+ Blocks: distribution,
+ }
+}
+
+type segmentKind uint8
+
+const (
+ segmentText segmentKind = iota
+ segmentMarker
+ segmentCode
+)
+
+type textSegment struct {
+ text string
+ kind segmentKind
+}
+
+var ignoredResourceTags = map[string]bool{
+ "whiteboard": true, "sheet": true, "source": true, "chat_card": true,
+ "base_refer": true, "bitable": true, "synced_reference": true,
+ "poll": true, "isv": true, "mindnote": true, "sub-page-list": true,
+ "okr": true, "html5-block": true,
+}
+
+var ignoredInlineTags = map[string]bool{
+ "button": true, "cite": true, "latex": true, "bookmark": true,
+}
+
+func extractSegments(nodes []*Node) []textSegment {
+ var segments []textSegment
+ for _, node := range nodes {
+ extractNodeSegments(node, &segments)
+ }
+ return segments
+}
+
+func extractNodeSegments(node *Node, segments *[]textSegment) {
+ if node == nil {
+ return
+ }
+ if node.typ == nodeText {
+ if strings.TrimSpace(node.text) != "" {
+ *segments = append(*segments, textSegment{text: node.text})
+ }
+ return
+ }
+ if ignoredInlineTags[node.tag] || ignoredResourceTags[node.tag] {
+ return
+ }
+ if node.tag == "task" {
+ return
+ }
+ if node.tag == "synced-source" && len(node.children) == 0 {
+ return
+ }
+
+ switch node.tag {
+ case "ul", "ol":
+ sequence := 1
+ for _, child := range node.children {
+ if child.typ == nodeElement && child.tag == "li" {
+ if node.tag == "ul" {
+ *segments = append(*segments, textSegment{text: "•", kind: segmentMarker})
+ } else {
+ marker := sequence
+ if raw := child.attrs["seq"]; raw != "" {
+ if _, err := fmt.Sscanf(raw, "%d", &marker); err == nil {
+ sequence = marker
+ }
+ }
+ *segments = append(*segments, textSegment{text: fmt.Sprintf("%d.", marker)})
+ sequence++
+ }
+ }
+ extractNodeSegments(child, segments)
+ }
+ return
+ case "checkbox":
+ marker := "☐"
+ if node.attrs["done"] == "true" {
+ marker = "☑"
+ }
+ *segments = append(*segments, textSegment{text: marker, kind: segmentMarker})
+ }
+
+ kind := segmentText
+ if node.tag == "pre" || node.tag == "code" && (node.parent == nil || node.parent.tag != "p") {
+ kind = segmentCode
+ }
+ text := visibleInlineText(node)
+ if strings.TrimSpace(text) == "" && !hasBlockChildren(node) {
+ if node.tag == "img" {
+ text = node.attrs["caption"]
+ } else {
+ text = firstNonEmpty(node.attrs["text"], node.attrs["name"], node.attrs["title"], node.attrs["alt"], node.attrs["caption"])
+ }
+ }
+ if strings.TrimSpace(text) != "" {
+ *segments = append(*segments, textSegment{text: text, kind: kind})
+ }
+
+ for _, child := range node.children {
+ if child.typ != nodeElement || isInlineForExtraction(child.tag) {
+ continue
+ }
+ extractNodeSegments(child, segments)
+ }
+}
+
+func visibleInlineText(node *Node) string {
+ var out strings.Builder
+ var walk func(*Node)
+ walk = func(current *Node) {
+ if current.typ == nodeText {
+ out.WriteString(current.text)
+ return
+ }
+ if current != node && !isInlineForExtraction(current.tag) {
+ return
+ }
+ if ignoredInlineTags[current.tag] {
+ return
+ }
+ if current.tag == "br" {
+ out.WriteByte('\n')
+ return
+ }
+ if current != node {
+ if display := firstNonEmpty(current.attrs["text"], current.attrs["name"], current.attrs["title"], current.attrs["alt"]); display != "" {
+ out.WriteString(display)
+ return
+ }
+ }
+ for _, child := range current.children {
+ walk(child)
+ }
+ }
+ for _, child := range node.children {
+ walk(child)
+ }
+ return out.String()
+}
+
+func hasBlockChildren(node *Node) bool {
+ for _, child := range node.children {
+ if child.typ == nodeElement && !isInlineForExtraction(child.tag) {
+ return true
+ }
+ }
+ return false
+}
+
+func isInlineForExtraction(tag string) bool {
+ layout := layoutOf(tag)
+ return layout == layoutInline || layout == layoutDual
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if value != "" {
+ return value
+ }
+ }
+ return ""
+}
diff --git a/shortcuts/doc/internal/docxparse/schema.go b/shortcuts/doc/internal/docxparse/schema.go
new file mode 100644
index 0000000000..fa6908b14d
--- /dev/null
+++ b/shortcuts/doc/internal/docxparse/schema.go
@@ -0,0 +1,268 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package docxparse
+
+import (
+ "sort"
+ "strconv"
+ "strings"
+)
+
+type tagLayout string
+
+const (
+ layoutBlock tagLayout = "block"
+ layoutInline tagLayout = "inline"
+ layoutDual tagLayout = "dual"
+ layoutStructural tagLayout = "structural"
+ layoutCommand tagLayout = "command"
+)
+
+type tagSpec struct {
+ canonical string
+ layout tagLayout
+}
+
+var tagSpecs = map[string]tagSpec{}
+
+// tagAliases mirrors the public compatibility aliases declared by the
+// LarkOpenCLI SDK. Parsing keeps the caller's XML unchanged; aliases are only
+// canonicalized in the in-memory tree used for profiling and Markdown output.
+var tagAliases = map[string]string{
+ "strong": "b",
+ "text": "span",
+ "equation": "latex",
+ "lark-table": "table",
+ "lark-tr": "tr",
+ "lark-td": "td",
+ "image": "img",
+ "reference-synced": "synced_reference",
+ "source-synced": "synced-source",
+ "at": "cite",
+ "chat-card": "chat_card",
+ "folder_manager": "folder-manager",
+}
+
+type attributeAliasRule struct {
+ canonical string
+ transform func(string) (string, bool)
+}
+
+var commonAttributeAliases = map[string]attributeAliasRule{
+ "color": {canonical: "text-color"},
+ "textcolor": {canonical: "text-color"},
+ "text_color": {canonical: "text-color"},
+ "bgcolor": {canonical: "background-color"},
+ "background_color": {canonical: "background-color"},
+}
+
+var tagAttributeAliases = map[string]map[string]attributeAliasRule{
+ "img": {
+ "url": {canonical: "href"},
+ "file_key": {canonical: "img_key"},
+ },
+ "callout": {
+ "color": {canonical: "background-color"},
+ "icon": {canonical: "emoji"},
+ },
+ "column": {
+ "width": {canonical: "width-ratio", transform: normalizeWidthRatio},
+ },
+ "chat_card": {
+ "id": {canonical: "chat-id", transform: requireChatID},
+ },
+ "cite": {
+ "user_id": {canonical: "user-id"},
+ },
+}
+
+var rawTagAttributeAliases = map[string]map[string]attributeAliasRule{
+ "at": {
+ "id": {canonical: "user-id"},
+ "user_id": {canonical: "user-id"},
+ },
+}
+
+var requiredAttributes = map[string][]string{
+ "task": {"task-id"},
+}
+
+var requiredAnyAttributes = map[string][][]string{
+ "img": {{"src", "img_key", "href"}},
+ "whiteboard": {{"token", "type"}},
+ "chat_card": {{"token", "chat-id"}},
+ "bookmark": {{"href", "name"}},
+}
+
+func init() {
+ registerTags(layoutBlock,
+ "title", "h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "h9", "p",
+ "div", "ul", "ol", "li", "blockquote", "grid", "column", "table", "thead",
+ "tbody", "tfoot", "tr", "hr", "pre", "img", "source", "bitable", "sheet",
+ "mindnote", "whiteboard", "base_refer", "synced_reference", "isv", "html5-block",
+ "view", "synced-source", "readonly-block", "figure", "callout", "checkbox",
+ "chat_card", "okr", "okr-objective", "okr-key-result", "okr-progress", "poll",
+ "agenda", "folder-manager", "sub-page-list", "wiki_catalog", "wiki_recent_update",
+ "chart-embedded", "chart-refer-host-perm", "chart_embedded", "chart_refer_host_perm",
+ "bookmark", "task", "vc-tabs", "vc-summary-tab", "vc-transcribe-tab", "append",
+ )
+ registerTags(layoutInline, "b", "em", "u", "del", "i", "span", "br", "inline-file", "mention-date", "cite", "button", "time", "a")
+ registerTags(layoutDual, "latex", "code")
+ registerTags(layoutStructural, "th", "td", "colgroup", "col", "sub-page")
+ registerTags(layoutCommand,
+ "comment", "block_delete", "str_delete", "str_replace", "block_replace", "block_insert",
+ "block_move", "block_copy_insert_after", "src_block_ids", "create", "answer", "response",
+ "identifier", "genre", "anchor", "type", "revision", "pattern", "replacement",
+ "replace_content", "action", "content", "parameter", "generation", "block_id",
+ )
+
+}
+
+func registerTags(layout tagLayout, tags ...string) {
+ for _, tag := range tags {
+ tagSpecs[tag] = tagSpec{canonical: tag, layout: layout}
+ }
+}
+
+func lookupTag(raw string) (tagSpec, bool) {
+ key := strings.ToLower(strings.TrimSpace(raw))
+ if canonical, ok := tagAliases[key]; ok {
+ key = canonical
+ }
+ spec, ok := tagSpecs[key]
+ if !ok {
+ return tagSpec{}, false
+ }
+ return spec, true
+}
+
+func layoutOf(tag string) tagLayout {
+ spec, ok := lookupTag(tag)
+ if !ok {
+ return ""
+ }
+ return spec.layout
+}
+
+var voidTags = map[string]bool{
+ "br": true,
+ "col": true,
+ "hr": true,
+ "img": true,
+ "source": true,
+ "sub-page": true,
+}
+
+func isVoidTag(tag string) bool { return voidTags[tag] }
+
+var preserveSpaceTags = map[string]bool{
+ "title": true, "h1": true, "h2": true, "h3": true, "h4": true,
+ "h5": true, "h6": true, "h7": true, "h8": true, "h9": true,
+ "p": true, "i": true, "b": true, "em": true, "u": true, "del": true,
+ "code": true, "li": true, "a": true, "span": true,
+}
+
+var strictPhrasingTags = map[string]bool{
+ "title": true, "span": true, "b": true, "em": true,
+ "u": true, "del": true, "a": true,
+}
+
+var autoCloseTags = map[string]map[string]bool{
+ "li": {"li": true},
+ "tr": {"tr": true},
+ "td": {"td": true, "th": true, "tr": true, "tbody": true, "tfoot": true},
+ "th": {"th": true, "td": true, "tr": true, "tbody": true, "tfoot": true},
+ "tbody": {"tbody": true, "tfoot": true},
+ "thead": {"tbody": true, "tfoot": true},
+ "column": {"column": true},
+}
+
+var requiredAncestorTags = map[string]map[string]bool{
+ "column": {"grid": true},
+ "thead": {"table": true},
+ "tbody": {"table": true},
+ "tfoot": {"table": true},
+ "tr": {"table": true, "thead": true, "tbody": true, "tfoot": true},
+ "th": {"tr": true},
+ "td": {"tr": true},
+ "colgroup": {"table": true},
+ "col": {"table": true, "colgroup": true},
+ "okr-objective": {"okr": true},
+ "okr-key-result": {"okr": true, "okr-objective": true},
+ "okr-progress": {"okr-objective": true, "okr-key-result": true},
+ "sub-page": {"sub-page-list": true},
+}
+
+func shouldAutoClose(openTag, nextTag string) bool {
+ if strictPhrasingTags[openTag] && layoutOf(nextTag) == layoutBlock {
+ return true
+ }
+ return autoCloseTags[openTag] != nil && autoCloseTags[openTag][nextTag]
+}
+
+func normalizeAttributes(rawTag, canonical string, attrs map[string]string) map[string]string {
+ rules := make(map[string]attributeAliasRule, len(commonAttributeAliases)+4)
+ for alias, rule := range commonAttributeAliases {
+ rules[alias] = rule
+ }
+ for alias, rule := range tagAttributeAliases[canonical] {
+ rules[alias] = rule
+ }
+ rawKey := strings.ToLower(strings.TrimSpace(rawTag))
+ for alias, rule := range rawTagAttributeAliases[rawKey] {
+ rules[alias] = rule
+ }
+
+ aliases := make([]string, 0, len(rules))
+ for alias := range rules {
+ aliases = append(aliases, alias)
+ }
+ sort.Strings(aliases)
+ for _, alias := range aliases {
+ value, exists := attrs[alias]
+ if !exists {
+ continue
+ }
+ rule := rules[alias]
+ if rule.transform != nil {
+ var ok bool
+ value, ok = rule.transform(value)
+ if !ok {
+ continue
+ }
+ }
+ if canonicalValue, exists := attrs[rule.canonical]; !exists || strings.TrimSpace(canonicalValue) == "" {
+ if attrs == nil {
+ attrs = map[string]string{}
+ }
+ attrs[rule.canonical] = value
+ }
+ delete(attrs, alias)
+ }
+
+ if rawKey == "at" {
+ if attrs == nil {
+ attrs = map[string]string{}
+ }
+ attrs["type"] = "user"
+ }
+ return attrs
+}
+
+func normalizeWidthRatio(value string) (string, bool) {
+ trimmed := strings.TrimSuffix(strings.TrimSpace(value), "%")
+ if trimmed == "" {
+ return value, false
+ }
+ width, err := strconv.ParseFloat(trimmed, 64)
+ if err != nil {
+ return value, false
+ }
+ return strconv.FormatFloat(width/100, 'f', 6, 64), true
+}
+
+func requireChatID(value string) (string, bool) {
+ trimmed := strings.TrimSpace(value)
+ return trimmed, strings.HasPrefix(trimmed, "oc_")
+}
diff --git a/shortcuts/doc/internal/docxparse/wordcount.go b/shortcuts/doc/internal/docxparse/wordcount.go
new file mode 100644
index 0000000000..32890cb607
--- /dev/null
+++ b/shortcuts/doc/internal/docxparse/wordcount.go
@@ -0,0 +1,342 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package docxparse
+
+// This file implements the LarkOpenCLI document text-counting contract.
+
+import (
+ "regexp"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+
+ "golang.org/x/text/width"
+)
+
+const chinesePunctuation = ",。!?;:、()《》〈〉“”‘’【】「」『』〔〕…—~·¥"
+const englishPunctuation = `!"#$%&'()*+,-./:;<=>?@[\]^_` + "`" + `{|}~`
+
+var (
+ urlToken = regexp.MustCompile(`^https?://[!-~]+`)
+ asciiCompoundToken = regexp.MustCompile(`^[A-Za-z0-9]+(?:[._/@:-][A-Za-z0-9]+)+`)
+)
+
+type lexemeKind uint8
+
+const (
+ lexemeNone lexemeKind = iota
+ lexemeEnglish
+ lexemeNumber
+)
+
+type textCounter struct {
+ stats TextProfile
+ lexeme lexemeKind
+ lexemeHasDigit bool
+ symbolRunLength int
+ atBoundary bool
+}
+
+func newTextCounter() *textCounter {
+ return &textCounter{atBoundary: true}
+}
+
+func (c *textCounter) countSegments(segments []textSegment) TextProfile {
+ for _, segment := range segments {
+ c.endUnit()
+ c.atBoundary = true
+ switch segment.kind {
+ case segmentMarker:
+ c.writeMarker(segment.text)
+ case segmentCode:
+ c.writeCode(segment.text)
+ default:
+ c.write(segment.text)
+ }
+ c.endUnit()
+ c.atBoundary = true
+ }
+ c.endUnit()
+ return c.stats
+}
+
+func (c *textCounter) write(value string) {
+ for offset := 0; offset < len(value); {
+ if token := matchASCIICompound(value[offset:]); token != "" {
+ c.writeASCIICompound(token)
+ offset += len(token)
+ continue
+ }
+ r, size := utf8.DecodeRuneInString(value[offset:])
+ if r == '/' && isVisibleHanSeparator(value, offset, size) {
+ c.endUnit()
+ c.stats.Breakdown.EnglishPunctuations++
+ c.stats.Breakdown.SymbolWords++
+ c.stats.WordCount++
+ c.stats.CharCount++
+ c.atBoundary = false
+ offset += size
+ continue
+ }
+ c.writeRune(r)
+ offset += size
+ }
+}
+
+func (c *textCounter) writeMarker(value string) {
+ for _, r := range value {
+ if unicode.IsSpace(r) {
+ continue
+ }
+ c.endUnit()
+ c.stats.WordCount++
+ c.stats.CharCount++
+ c.atBoundary = false
+ }
+}
+
+func (c *textCounter) writeCode(value string) {
+ for _, r := range value {
+ c.writeCodeRune(r)
+ }
+}
+
+func (c *textCounter) writeCodeRune(r rune) {
+ if unicode.IsSpace(r) {
+ c.endUnit()
+ c.atBoundary = true
+ return
+ }
+ if unicode.Is(unicode.Han, r) {
+ c.endLexeme()
+ c.endSymbolRun(false)
+ c.stats.Breakdown.HanChars++
+ c.stats.WordCount++
+ c.stats.CharCount++
+ c.atBoundary = false
+ return
+ }
+ if isASCIILetterRune(r) {
+ c.endSymbolRun(false)
+ c.stats.Breakdown.EnglishLetters++
+ c.stats.CharCount++
+ if c.lexeme == lexemeNone || c.lexeme == lexemeNumber {
+ c.lexeme = lexemeEnglish
+ }
+ c.atBoundary = false
+ return
+ }
+ if isASCIIDigitRune(r) {
+ c.endSymbolRun(false)
+ c.stats.Breakdown.Digits++
+ c.stats.CharCount++
+ c.atBoundary = false
+ return
+ }
+ if isChinesePunctuation(r) {
+ c.endLexeme()
+ c.endSymbolRun(false)
+ c.stats.Breakdown.ChinesePunctuations++
+ c.stats.WordCount++
+ c.stats.CharCount++
+ c.atBoundary = false
+ return
+ }
+ if isEnglishPunctuation(r) {
+ keepsLexeme := c.lexeme == lexemeEnglish && (r == '\'' || r == '-')
+ if !keepsLexeme {
+ hadLexeme := c.lexeme != lexemeNone
+ c.endLexeme()
+ if !hadLexeme && (c.symbolRunLength > 0 || c.atBoundary) {
+ c.symbolRunLength++
+ }
+ }
+ c.stats.Breakdown.EnglishPunctuations++
+ c.stats.CharCount++
+ if keepsLexeme {
+ c.atBoundary = false
+ }
+ return
+ }
+ if unicode.Is(unicode.Symbol, r) {
+ c.writeSymbol(r)
+ return
+ }
+ c.endLexeme()
+ c.endSymbolRun(false)
+ c.atBoundary = false
+}
+
+func (c *textCounter) writeRune(r rune) {
+ if unicode.IsSpace(r) {
+ c.endUnit()
+ c.atBoundary = true
+ return
+ }
+ if unicode.Is(unicode.Han, r) {
+ c.endLexeme()
+ c.endSymbolRun(false)
+ c.stats.Breakdown.HanChars++
+ c.stats.WordCount++
+ c.stats.CharCount++
+ c.atBoundary = false
+ return
+ }
+ if isASCIILetterRune(r) {
+ c.endSymbolRun(false)
+ c.stats.Breakdown.EnglishLetters++
+ c.stats.CharCount++
+ if c.lexeme == lexemeNone || c.lexeme == lexemeNumber {
+ c.lexeme = lexemeEnglish
+ }
+ c.atBoundary = false
+ return
+ }
+ if isASCIIDigitRune(r) {
+ c.endSymbolRun(false)
+ c.stats.Breakdown.Digits++
+ c.stats.CharCount++
+ c.lexemeHasDigit = true
+ if c.lexeme == lexemeNone {
+ c.lexeme = lexemeNumber
+ }
+ c.atBoundary = false
+ return
+ }
+ if isChinesePunctuation(r) {
+ c.endLexeme()
+ c.endSymbolRun(false)
+ c.stats.Breakdown.ChinesePunctuations++
+ c.stats.WordCount++
+ c.stats.CharCount++
+ c.atBoundary = false
+ return
+ }
+ if isEnglishPunctuation(r) {
+ keepsLexeme := c.lexeme == lexemeEnglish && (r == '\'' || r == '-' || c.lexemeHasDigit && r == '.') ||
+ c.lexeme == lexemeNumber && (r == '.' || r == ',' || r == '-')
+ if !keepsLexeme {
+ hadLexeme := c.lexeme != lexemeNone
+ c.endLexeme()
+ if !hadLexeme && (c.symbolRunLength > 0 || c.atBoundary) {
+ c.symbolRunLength++
+ }
+ }
+ c.stats.Breakdown.EnglishPunctuations++
+ c.stats.CharCount++
+ if keepsLexeme {
+ c.atBoundary = false
+ }
+ return
+ }
+ if unicode.Is(unicode.Symbol, r) {
+ c.writeSymbol(r)
+ return
+ }
+ c.endLexeme()
+ c.endSymbolRun(false)
+ c.atBoundary = false
+}
+
+func matchASCIICompound(value string) string {
+ if match := urlToken.FindString(value); match != "" {
+ return match
+ }
+ match := asciiCompoundToken.FindString(value)
+ if match == "" || !strings.ContainsAny(match, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") {
+ return ""
+ }
+ return match
+}
+
+func (c *textCounter) writeASCIICompound(token string) {
+ c.endUnit()
+ c.stats.Breakdown.EnglishWords++
+ c.stats.WordCount++
+ for _, r := range token {
+ switch {
+ case isASCIILetterRune(r):
+ c.stats.Breakdown.EnglishLetters++
+ c.stats.CharCount++
+ case isASCIIDigitRune(r):
+ c.stats.Breakdown.Digits++
+ c.stats.CharCount++
+ case isEnglishPunctuation(r):
+ c.stats.Breakdown.EnglishPunctuations++
+ c.stats.CharCount++
+ }
+ }
+ c.atBoundary = false
+}
+
+func (c *textCounter) writeSymbol(r rune) {
+ c.endLexeme()
+ c.endSymbolRun(false)
+ units := utf16Units(r)
+ c.stats.Breakdown.SymbolWords++
+ c.stats.Breakdown.SymbolChars += units
+ c.stats.WordCount++
+ c.stats.CharCount += units
+ c.atBoundary = false
+}
+
+func (c *textCounter) endUnit() {
+ c.endLexeme()
+ c.endSymbolRun(true)
+}
+
+func (c *textCounter) endLexeme() {
+ switch c.lexeme {
+ case lexemeEnglish:
+ c.stats.Breakdown.EnglishWords++
+ c.stats.WordCount++
+ case lexemeNumber:
+ c.stats.Breakdown.NumberWords++
+ c.stats.WordCount++
+ }
+ c.lexeme = lexemeNone
+ c.lexemeHasDigit = false
+}
+
+func (c *textCounter) endSymbolRun(countWord bool) {
+ if c.symbolRunLength > 0 && countWord {
+ c.stats.Breakdown.SymbolWords++
+ c.stats.WordCount++
+ }
+ if c.symbolRunLength > 0 {
+ c.atBoundary = false
+ }
+ c.symbolRunLength = 0
+}
+
+func isVisibleHanSeparator(value string, offset, size int) bool {
+ if offset == 0 || offset+size >= len(value) {
+ return false
+ }
+ previous, _ := utf8.DecodeLastRuneInString(value[:offset])
+ next, _ := utf8.DecodeRuneInString(value[offset+size:])
+ return unicode.Is(unicode.Han, previous) && unicode.Is(unicode.Han, next)
+}
+
+func isASCIILetterRune(r rune) bool { return r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' }
+func isASCIIDigitRune(r rune) bool { return r >= '0' && r <= '9' }
+
+func isChinesePunctuation(r rune) bool {
+ if strings.ContainsRune(chinesePunctuation, r) {
+ return true
+ }
+ kind := width.LookupRune(r).Kind()
+ return unicode.Is(unicode.Punct, r) && (kind == width.EastAsianWide || kind == width.EastAsianFullwidth)
+}
+
+func isEnglishPunctuation(r rune) bool {
+ return r < utf8.RuneSelf && strings.ContainsRune(englishPunctuation, r)
+}
+
+func utf16Units(r rune) int {
+ if r > 0xffff {
+ return 2
+ }
+ return 1
+}
diff --git a/shortcuts/doc/internal/docxparse/xml.go b/shortcuts/doc/internal/docxparse/xml.go
new file mode 100644
index 0000000000..b2679e9b37
--- /dev/null
+++ b/shortcuts/doc/internal/docxparse/xml.go
@@ -0,0 +1,572 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package docxparse
+
+import (
+ "fmt"
+ "html"
+ "regexp"
+ "strconv"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+)
+
+const (
+ MaxInputBytes = 20_000_000
+ MaxNestingDepth = 1024
+)
+
+var forbiddenXMLDeclaration = regexp.MustCompile(`(?i) MaxInputBytes {
+ return fmt.Errorf("input is too large (%d bytes, limit %d)", len(source), MaxInputBytes)
+ }
+ if forbiddenXMLDeclaration.MatchString(source) {
+ return fmt.Errorf("XML input must not contain DOCTYPE or ENTITY declarations")
+ }
+ if !utf8.ValidString(source) {
+ return fmt.Errorf("input must be valid UTF-8")
+ }
+ return nil
+}
+
+func parseXML(source string) ([]*Node, error) {
+ if err := validateSource(source); err != nil {
+ return nil, err
+ }
+ source = strings.TrimPrefix(source, "\uFEFF")
+
+ root := newElement("__fragment__", nil)
+ stack := []*Node{root}
+ for i := 0; i < len(source); {
+ lt := strings.IndexByte(source[i:], '<')
+ if lt < 0 {
+ if err := validateXMLText(source[i:], i); err != nil {
+ return nil, err
+ }
+ appendText(stack[len(stack)-1], source[i:])
+ break
+ }
+ lt += i
+ if err := validateXMLText(source[i:lt], i); err != nil {
+ return nil, err
+ }
+ appendText(stack[len(stack)-1], source[i:lt])
+
+ token, end, state := scanXMLToken(source, lt)
+ switch state {
+ case tokenComment, tokenProcessingInstruction:
+ i = end
+ continue
+ case tokenCDATA:
+ appendTextValue(stack[len(stack)-1], token.text)
+ i = end
+ continue
+ case tokenInvalid:
+ return nil, fmt.Errorf("invalid XML token at byte %d", lt)
+ case tokenIncomplete:
+ return nil, fmt.Errorf("unterminated XML tag at byte %d", lt)
+ }
+
+ spec, allowed := lookupTag(token.name)
+ if !allowed {
+ return nil, fmt.Errorf("unsupported LarkOpenCLI tag <%s> at byte %d", token.name, lt)
+ }
+ canonical := spec.canonical
+ if token.spacingNormalized {
+ return nil, fmt.Errorf("invalid whitespace in XML tag <%s> at byte %d", token.name, lt)
+ }
+
+ if token.closing {
+ if isVoidTag(canonical) {
+ return nil, fmt.Errorf("void tag <%s/> must not have a closing tag", canonical)
+ }
+ if len(stack) == 1 {
+ return nil, fmt.Errorf("unexpected closing tag %s> at byte %d", canonical, lt)
+ }
+ open := stack[len(stack)-1].tag
+ if open != canonical {
+ return nil, fmt.Errorf("mismatched closing tag %s> at byte %d; expected %s>", canonical, lt, open)
+ }
+ stack = stack[:len(stack)-1]
+ i = end
+ continue
+ }
+
+ if len(stack) > 1 && shouldAutoClose(stack[len(stack)-1].tag, canonical) {
+ return nil, fmt.Errorf("invalid <%s> inside <%s> at byte %d", canonical, stack[len(stack)-1].tag, lt)
+ }
+ attrs := normalizeAttributes(token.name, canonical, token.attrs)
+ node := newElement(canonical, attrs)
+ stack[len(stack)-1].addChild(node)
+ if !token.selfClosing && !isVoidTag(canonical) {
+ if len(stack) > MaxNestingDepth {
+ return nil, fmt.Errorf("XML nesting exceeds limit %d at byte %d", MaxNestingDepth, lt)
+ }
+ stack = append(stack, node)
+ }
+ i = end
+ }
+
+ if len(stack) > 1 {
+ return nil, fmt.Errorf("missing closing tag %s> at end of input", stack[len(stack)-1].tag)
+ }
+ normalizeParsedLineBreaks(root.children, false, false)
+ for _, child := range root.children {
+ child.parent = nil
+ }
+ return root.children, nil
+}
+
+// normalizeParsedLineBreaks removes formatting newlines from ordinary XML,
+// while source-bearing code/whiteboard blocks keep semantic
+// line breaks as explicit
nodes. str_replace pattern/replacement payloads
+// retain raw newlines because their string matching semantics depend on them.
+func normalizeParsedLineBreaks(nodes []*Node, sourceBlock, stringMutation bool) {
+ for _, node := range nodes {
+ if node == nil || node.typ != nodeElement {
+ continue
+ }
+ nextSourceBlock := sourceBlock || node.tag == "code" || node.tag == "whiteboard"
+ nextStringMutation := stringMutation || node.tag == "str_replace"
+ preserveRaw := nextStringMutation && (node.tag == "pattern" || node.tag == "replacement")
+ if node.tag == "code" || node.tag == "whiteboard" {
+ trimSourceBlockBoundaryNewlines(node.children)
+ }
+ children := make([]*Node, 0, len(node.children))
+ for _, child := range node.children {
+ if child.typ != nodeText || !strings.ContainsAny(child.text, "\r\n") {
+ children = append(children, child)
+ continue
+ }
+ switch {
+ case preserveRaw:
+ children = append(children, child)
+ case nextSourceBlock:
+ for _, replacement := range rawTextWithBreakNodes(child.text) {
+ replacement.parent = node
+ children = append(children, replacement)
+ }
+ default:
+ child.text = strings.NewReplacer("\r", "", "\n", "").Replace(child.text)
+ if child.text != "" {
+ children = append(children, child)
+ }
+ }
+ }
+ node.children = children
+ normalizeParsedLineBreaks(node.children, nextSourceBlock, nextStringMutation)
+ }
+}
+
+func trimSourceBlockBoundaryNewlines(children []*Node) {
+ for _, child := range children {
+ if child.typ == nodeText {
+ child.text = strings.TrimLeft(child.text, "\r\n")
+ break
+ }
+ if child.typ == nodeElement {
+ break
+ }
+ }
+ for i := len(children) - 1; i >= 0; i-- {
+ child := children[i]
+ if child.typ == nodeText {
+ child.text = strings.TrimRight(child.text, "\r\n")
+ break
+ }
+ if child.typ == nodeElement {
+ break
+ }
+ }
+}
+
+func rawTextWithBreakNodes(content string) []*Node {
+ if content == "" {
+ return nil
+ }
+ var nodes []*Node
+ start := 0
+ for i := 0; i < len(content); i++ {
+ if content[i] != '\n' && content[i] != '\r' {
+ continue
+ }
+ if i > start {
+ nodes = append(nodes, newText(content[start:i]))
+ }
+ if content[i] == '\r' && i+1 < len(content) && content[i+1] == '\n' {
+ i++
+ }
+ nodes = append(nodes, newElement("br", nil))
+ start = i + 1
+ }
+ if start < len(content) {
+ nodes = append(nodes, newText(content[start:]))
+ }
+ return nodes
+}
+
+type tokenState uint8
+
+const (
+ tokenOK tokenState = iota
+ tokenInvalid
+ tokenIncomplete
+ tokenComment
+ tokenProcessingInstruction
+ tokenCDATA
+)
+
+type xmlToken struct {
+ name string
+ attrs map[string]string
+ text string
+ closing bool
+ selfClosing bool
+ spacingNormalized bool
+}
+
+func scanXMLToken(source string, start int) (xmlToken, int, tokenState) {
+ if strings.HasPrefix(source[start:], ""); closeAt >= 0 {
+ contentEnd := contentStart + closeAt
+ return xmlToken{text: source[contentStart:contentEnd]}, contentEnd + len("]]>"), tokenCDATA
+ }
+ return xmlToken{}, len(source), tokenIncomplete
+ }
+ if strings.HasPrefix(source[start:], ""); closeAt >= 0 {
+ if strings.Contains(source[start+4:start+4+closeAt], "--") {
+ return xmlToken{}, start + 1, tokenInvalid
+ }
+ return xmlToken{}, start + 4 + closeAt + 3, tokenComment
+ }
+ return xmlToken{}, len(source), tokenIncomplete
+ }
+ if strings.HasPrefix(source[start:], "") {
+ if closeAt := strings.Index(source[start+2:], "?>"); closeAt >= 0 {
+ return xmlToken{}, start + 2 + closeAt + 2, tokenProcessingInstruction
+ }
+ return xmlToken{}, len(source), tokenIncomplete
+ }
+
+ quote := byte(0)
+ end := -1
+ for i := start + 1; i < len(source); i++ {
+ switch source[i] {
+ case '\'', '"':
+ if quote == 0 {
+ quote = source[i]
+ } else if quote == source[i] {
+ quote = 0
+ }
+ case '>':
+ if quote == 0 {
+ end = i + 1
+ i = len(source)
+ }
+ case '<':
+ // A second unquoted '<' cannot belong to the current XML tag.
+ // Stop here so a long sequence of invalid tag starts is scanned
+ // once instead of repeatedly searching to a distant '>'.
+ if quote == 0 {
+ return xmlToken{}, start + 1, tokenInvalid
+ }
+ }
+ }
+ if end < 0 {
+ candidate := strings.TrimSpace(source[start+1:])
+ if candidate == "" || !isTagNameStart(candidate[0]) && candidate[0] != '/' {
+ return xmlToken{}, start + 1, tokenInvalid
+ }
+ return xmlToken{}, len(source), tokenIncomplete
+ }
+
+ body := source[start+1 : end-1]
+ if body == "" {
+ return xmlToken{}, end, tokenInvalid
+ }
+ token := xmlToken{}
+ position := 0
+ for position < len(body) && isXMLSpace(body[position]) {
+ position++
+ }
+ if position > 0 {
+ token.spacingNormalized = true
+ }
+ if position >= len(body) || body[position] == '!' {
+ return xmlToken{}, end, tokenInvalid
+ }
+ if body[position] == '/' {
+ token.closing = true
+ position++
+ spaceStart := position
+ for position < len(body) && isXMLSpace(body[position]) {
+ position++
+ }
+ if position > spaceStart {
+ token.spacingNormalized = true
+ }
+ }
+ if position >= len(body) || !isTagNameStart(body[position]) {
+ return xmlToken{}, end, tokenInvalid
+ }
+ nameStart := position
+ position++
+ for position < len(body) && isTagNamePart(body[position]) {
+ position++
+ }
+ token.name = body[nameStart:position]
+ rawRemainder := body[position:]
+ remainder := strings.TrimRightFunc(rawRemainder, unicode.IsSpace)
+ if token.closing {
+ if strings.TrimSpace(remainder) != "" {
+ return xmlToken{}, end, tokenInvalid
+ }
+ return token, end, tokenOK
+ }
+ if strings.HasSuffix(remainder, "/") {
+ if len(remainder) != len(rawRemainder) {
+ return xmlToken{}, end, tokenInvalid
+ }
+ token.selfClosing = true
+ remainder = strings.TrimRightFunc(strings.TrimSuffix(remainder, "/"), unicode.IsSpace)
+ }
+ trimmedAttrs := strings.TrimLeftFunc(remainder, unicode.IsSpace)
+ if trimmedAttrs != "" && !isAttributeNameStart(trimmedAttrs[0]) {
+ return xmlToken{}, end, tokenInvalid
+ }
+ var ok bool
+ token.attrs, ok = parseStrictAttributes(remainder)
+ if !ok {
+ return xmlToken{}, end, tokenInvalid
+ }
+ return token, end, tokenOK
+}
+
+func isXMLSpace(ch byte) bool {
+ return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n'
+}
+
+func isTagNameStart(ch byte) bool {
+ return ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z'
+}
+
+func isTagNamePart(ch byte) bool {
+ return isTagNameStart(ch) || ch >= '0' && ch <= '9' || ch == '_' || ch == '-' || ch == '.' || ch == ':'
+}
+
+func isAttributeNameStart(ch byte) bool {
+ return isTagNameStart(ch) || ch == '_' || ch == ':'
+}
+
+func parseAttributes(source string) map[string]string {
+ attrs := map[string]string{}
+ for i := 0; i < len(source); {
+ for i < len(source) && unicode.IsSpace(rune(source[i])) {
+ i++
+ }
+ if i >= len(source) {
+ break
+ }
+ start := i
+ for i < len(source) && isAttributeNameByte(source[i]) {
+ i++
+ }
+ if start == i {
+ i++
+ continue
+ }
+ name := source[start:i]
+ for i < len(source) && unicode.IsSpace(rune(source[i])) {
+ i++
+ }
+ value := ""
+ if i < len(source) && source[i] == '=' {
+ i++
+ for i < len(source) && unicode.IsSpace(rune(source[i])) {
+ i++
+ }
+ if i < len(source) && (source[i] == '\'' || source[i] == '"') {
+ quote := source[i]
+ i++
+ start = i
+ for i < len(source) && source[i] != quote {
+ i++
+ }
+ value = source[start:i]
+ if i < len(source) {
+ i++
+ }
+ } else {
+ start = i
+ for i < len(source) && !unicode.IsSpace(rune(source[i])) {
+ i++
+ }
+ value = source[start:i]
+ }
+ }
+ attrs[name] = html.UnescapeString(value)
+ }
+ if len(attrs) == 0 {
+ return nil
+ }
+ return attrs
+}
+
+// parseStrictAttributes implements the quoted attribute grammar accepted by
+// XML. parseAttributes remains intentionally permissive for the Markdown
+// container extension, whose input is Markdown rather than an XML document.
+func parseStrictAttributes(source string) (map[string]string, bool) {
+ attrs := map[string]string{}
+ for i := 0; i < len(source); {
+ spaceStart := i
+ for i < len(source) && isXMLSpace(source[i]) {
+ i++
+ }
+ if i >= len(source) {
+ break
+ }
+ if i == spaceStart || !isAttributeNameStart(source[i]) {
+ return nil, false
+ }
+
+ nameStart := i
+ i++
+ for i < len(source) && isTagNamePart(source[i]) {
+ i++
+ }
+ name := source[nameStart:i]
+ if _, exists := attrs[name]; exists {
+ return nil, false
+ }
+
+ for i < len(source) && isXMLSpace(source[i]) {
+ i++
+ }
+ if i >= len(source) || source[i] != '=' {
+ return nil, false
+ }
+ i++
+ for i < len(source) && isXMLSpace(source[i]) {
+ i++
+ }
+ if i >= len(source) || (source[i] != '\'' && source[i] != '"') {
+ return nil, false
+ }
+
+ quote := source[i]
+ i++
+ valueStart := i
+ for i < len(source) && source[i] != quote {
+ if source[i] == '<' {
+ return nil, false
+ }
+ i++
+ }
+ if i >= len(source) {
+ return nil, false
+ }
+ rawValue := source[valueStart:i]
+ if invalidXMLEntityAt(rawValue) >= 0 {
+ return nil, false
+ }
+ attrs[name] = html.UnescapeString(rawValue)
+ i++
+ }
+ if len(attrs) == 0 {
+ return nil, true
+ }
+ return attrs, true
+}
+
+func isAttributeNameByte(ch byte) bool {
+ return ch > ' ' && ch != '=' && ch != '/' && ch != '>'
+}
+
+func appendText(parent *Node, raw string) {
+ if parent == nil || raw == "" {
+ return
+ }
+ appendTextValue(parent, html.UnescapeString(raw))
+}
+
+func appendTextValue(parent *Node, text string) {
+ if parent == nil || text == "" {
+ return
+ }
+ if strings.TrimSpace(text) == "" && !preserveSpaceTags[parent.tag] && parent.tag != "whiteboard" {
+ return
+ }
+ if count := len(parent.children); count > 0 && parent.children[count-1].typ == nodeText {
+ parent.children[count-1].text += text
+ return
+ }
+ parent.addChild(newText(text))
+}
+
+func validateXMLText(value string, absoluteOffset int) error {
+ if offset := strings.Index(value, "]]>"); offset >= 0 {
+ return fmt.Errorf("invalid ]]> sequence in XML text at byte %d", absoluteOffset+offset)
+ }
+ if offset := invalidXMLEntityAt(value); offset >= 0 {
+ return fmt.Errorf("invalid XML entity at byte %d", absoluteOffset+offset)
+ }
+ return nil
+}
+
+func invalidXMLEntityAt(value string) int {
+ for cursor := 0; cursor < len(value); {
+ relative := strings.IndexByte(value[cursor:], '&')
+ if relative < 0 {
+ return -1
+ }
+ start := cursor + relative
+ endRelative := strings.IndexByte(value[start+1:], ';')
+ if endRelative < 0 {
+ return start
+ }
+ end := start + 1 + endRelative
+ if !isValidXMLEntity(value[start+1 : end]) {
+ return start
+ }
+ cursor = end + 1
+ }
+ return -1
+}
+
+func isValidXMLEntity(entity string) bool {
+ switch entity {
+ case "amp", "lt", "gt", "quot", "apos":
+ return true
+ }
+
+ base := 10
+ digits := ""
+ switch {
+ case strings.HasPrefix(entity, "#x"):
+ base = 16
+ digits = entity[2:]
+ case strings.HasPrefix(entity, "#"):
+ digits = entity[1:]
+ default:
+ return false
+ }
+ if digits == "" {
+ return false
+ }
+ value, err := strconv.ParseUint(digits, base, 32)
+ if err != nil {
+ return false
+ }
+ r := rune(value)
+ return r == '\t' || r == '\n' || r == '\r' ||
+ r >= 0x20 && r <= 0xD7FF ||
+ r >= 0xE000 && r <= 0xFFFD ||
+ r >= 0x10000 && r <= utf8.MaxRune
+}
diff --git a/shortcuts/doc/shortcuts.go b/shortcuts/doc/shortcuts.go
index 25c4e61c9c..e81f604cd6 100644
--- a/shortcuts/doc/shortcuts.go
+++ b/shortcuts/doc/shortcuts.go
@@ -33,6 +33,8 @@ func docsSkillReadCommandForShortcut(shortcut string) string {
return docsSkillReadCommand + " references/lark-doc-update.md"
case "history-list", "history-revert", "history-revert-status":
return docsSkillReadCommand + " references/lark-doc-history.md"
+ case "script":
+ return docsSkillReadCommand + " references/lark-doc-script.md"
default:
return docsSkillReadCommand
}
@@ -52,6 +54,8 @@ func docsHelpCommandForShortcut(shortcut string) string {
return "lark-cli docs +history-revert --help"
case "history-revert-status":
return "lark-cli docs +history-revert-status --help"
+ case "script":
+ return "lark-cli docs +script --help"
default:
return "lark-cli docs --help"
}
@@ -64,6 +68,7 @@ func Shortcuts() []common.Shortcut {
DocsCreate,
DocsFetch,
DocsUpdate,
+ DocsScript,
DocsHistoryList,
DocsHistoryRevert,
DocsHistoryRevertStatus,
diff --git a/skills/lark-doc/SKILL.md b/skills/lark-doc/SKILL.md
index 478be8bb9f..e01d521ac3 100644
--- a/skills/lark-doc/SKILL.md
+++ b/skills/lark-doc/SKILL.md
@@ -1,7 +1,6 @@
---
name: lark-doc
-version: 2.0.0
-description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。当用户明确要操作飞书思维笔记时,也使用本 skill。"
+description: "飞书云文档(Docx / Wiki)内容操作:读取、创建、编辑文档,插入或下载图片附件,以及操作思维笔记。用户提供文档 URL/token(包括 doubao.com 的 /docx/、/wiki/)时使用;按 URL 路径/token 而非域名路由。遇到嵌入的电子表格、多维表格或画板,先提取 token,再切到对应 skill。文档评论走 lark-drive;表格或 Base 内部数据操作不在本 skill。"
metadata:
requires:
bins: ["lark-cli"]
@@ -10,36 +9,36 @@ metadata:
# docs
+## 按场景读取
+
+**CRITICAL:先判断场景,再读取该场景的参考文件;不要在任务开始时一次性读取全部参考文件。每个文件只在首次进入对应阶段时读取一次。**
+
+- **读取**:先读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md),再获取或总结文档。
+- **创建**:从零创作时,场景判断后必须首先完整读取 [`lark-doc-authoring.md`](references/lark-doc-authoring.md);读取前禁止起草、选择格式、读取 `lark-doc-create.md` 或执行创建。仅创建空文档、原样导入用户提供的完整内容、机械格式转换可跳过 Authoring。
+- **编辑**:语义改写、润色、重组、补写或排版时,必须先完整读取 [`lark-doc-authoring.md`](references/lark-doc-authoring.md);明确旧文本到新文本的替换、纯删除或纯移动可跳过 Authoring,直接按 [`lark-doc-update.md`](references/lark-doc-update.md) 操作。
+
**身份:文档操作默认使用 `--as user`。首次使用前执行 `lark-cli auth login`。**
```bash
# 常用示例
lark-cli docs +fetch --doc "文档URL或token;若 URL 存在 #share-... 锚点,优先使用锚点方式读取,不要全文拉取"
-lark-cli docs +create --content '标题内容
'
-lark-cli docs +update --doc "文档URL或token" --command append --content '内容
'
+lark-cli docs +create --doc-format xml --content '标题内容
'
+lark-cli docs +update --doc "文档URL或token" --command append --doc-format xml --content '内容
'
```
-## 前置条件 — 执行操作前必读
-
-**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:**
-1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
-2. **读取文档(`docs +fetch`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`` / `` 输出结构)
-3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和必读 [`lark-doc-style.md`](references/style/lark-doc-style.md)(写作原则:默认段落、按体裁、组件克制);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
-
-**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
-
> **格式选择规则(全局):**
-> - **创建 / 导入场景**(`docs +create`,或 `docs +update --command append/overwrite` 的整段写入):XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;否则默认 XML。
-> - **精准编辑场景**(`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML(`--doc-format xml`,即默认值)。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
+> - **语义创作 / 全文重建:**默认且显式使用 XML,只读取 [`lark-doc-xml.md`](references/lark-doc-xml.md)。`Presentation Decision` 只确定 `presentation_mode`,不预先选择 block;生成后通过 Draft Parse Gate 的 `profile.blocks` 检查实际组件是否符合 content contract 与可选 platform adapter。选择 XML 不授权增加无必要的 rich block。
+> - **Markdown 例外:**仅当用户明确要求 Markdown,或原样导入 / 保真重建用户提供的 `.md` 内容时,改为只读取 [`lark-doc-md.md`](references/lark-doc-md.md) 并全程使用 Markdown。必要能力无法承载时先说明冲突,不擅自混用或降级。
+> - **精准编辑:**默认使用 XML 并保留未授权内容;只有跨行 `str_replace` 等明确依赖 Markdown 的单次操作才切换为 Markdown。每份草稿、每个 `--content` / `--pattern` payload 只能使用一种语法,禁止 Markdown 与 XML 混写;delete / move / copy 不写内容。
## 快速决策
- 用户要**复制文档 / 创建文档副本 / 另存为副本**时,切到 [`lark-drive`](../lark-drive/SKILL.md),按其中的复制指引使用 `lark-cli drive files copy`;不要用 `docs +fetch` + `docs +create` 重建正文,也不要走 `drive +export` / `drive +import`。
-- 先判定任务路径:找文档 / 导入导出走 [`lark-drive`](../lark-drive/SKILL.md);只读 / 摘要用 `docs +fetch` 默认 `simple`;明确旧文本 → 新文本直接 `str_replace`;只有 block 链接、评论锚点、插入 / 替换 / 删除 / 移动才局部 fetch `with-ids`;保真改写已有内容才读 `full`
+- 先判定任务路径:找文档 / 导入导出走 [`lark-drive`](../lark-drive/SKILL.md);只读 / 摘要用 `docs +fetch` 默认 `simple`;已有文档改写按 [`lark-doc-update.md`](references/lark-doc-update.md) 的 Observe-Diagnose-Patch Loop 先 fetch 再局部 patch;明确旧文本 → 新文本的简单替换可直接 `str_replace`,但写后必须 fetch 验证;只有 block 链接、评论锚点、插入 / 替换 / 删除 / 移动才局部 fetch `with-ids`;保真改写已有内容才读 `full`
- block 直达链接格式:`文档基础 URL#block_id`;没有 block_id 时局部 fetch `with-ids`
-- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID,插入 / 复制后要重新 fetch 才能拿到新 block ID
+- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」处理:每次更新后都按 block ID 已变更处理;需要继续或重复修改时,先重新 fetch 最新内容和 block ID,不要复用旧 fetch 结果
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
- 写文档时,由内容和用户意图决定表达形式;流程、架构、路线图、关键指标等信息可以使用画板,但不要默认把重要信息都画板化
-- 新增或更新画板时,按 [`lark-doc-whiteboard.md`](references/lark-doc-whiteboard.md) 选型;Mermaid 可由主 Agent 直接插入,SVG / 复杂图 / 已有画板更新按其中流程隔离到 SubAgent
+- 新增画板按复杂度处理:简单 Mermaid / SVG 图可由主 Agent 直接写入草稿;复杂图或需要专门视觉设计的 SVG 交给 SubAgent 产出完整 `...`;特别复杂或已有画板更新,主 Agent 先建 ``,再启动 SubAgent 读取 `lark-whiteboard` 写入
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
- 用户想把文档回滚到某个 `revision_id` 或某一时刻 → 先读 [`lark-doc-history.md`](references/lark-doc-history.md),按其中流程操作
@@ -48,7 +47,7 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`)
- 用户明确要操作思维笔记时;已有**思维笔记**,走 [思维笔记链路](references/lark-doc-mindnote.md);新建**思维笔记**,走 [lark-doc-whiteboard](references/lark-doc-whiteboard.md)
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
-- 用户需要统计文档的**总字数 / 总字符数**(word count / character count)时,先读取 [`lark-doc-word-stat.md`](references/lark-doc-word-stat.md),并按其中流程调用 [`scripts/doc_word_stat.py`](scripts/doc_word_stat.py);统计口径以该脚本为准,不要改用其他方式自行计算。
+- 用户需要解析 XML / Markdown、把 Markdown 转成 XML,或统计文档的**总字数 / 总字符数**时,读取 [`lark-doc-script.md`](references/lark-doc-script.md)。
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
- 文档内容中出现嵌入的 ``、`` 或 `` 标签时 → **必须主动提取 token 并切到对应技能下钻读取内部数据**,不能只呈现标签本身
@@ -70,6 +69,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs + [flags]`)
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown / im-markdown; `im-markdown` only after fetch for `lark-im`) |
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
+| [`+script`](references/lark-doc-script.md) | Parse XML or Markdown into a local profile, or convert Markdown to XML |
| [`+history-list` / `+history-revert` / `+history-revert-status`](references/lark-doc-history.md) | List document history, revert to a `history_version_id`, and query revert task status |
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
diff --git a/skills/lark-doc/references/genres/business-analysis.md b/skills/lark-doc/references/genres/business-analysis.md
new file mode 100644
index 0000000000..fda6b2aa68
--- /dev/null
+++ b/skills/lark-doc/references/genres/business-analysis.md
@@ -0,0 +1,30 @@
+# Genre Contract: Business Analysis / 商业分析 (`report.business_analysis`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| presentation_mode / 表达模式 | `normal`;结论前置、具体、条件化;模型只用于改变比较或暴露约束,不用管理黑话代替判断 |
+| 内容逻辑 | 围绕一个具体决策,比较现状 / 不行动与真实替代项;用统一目标和口径评价价值、全周期成本、风险、约束与可实施性,给出推荐、暂缓或验证门及翻转条件 |
+| 事实 / 边界 | 事实、估算、假设、未知和外部依赖分开;数字标来源、时点、单位、口径和置信范围;利益相关方、不可货币化影响和权限边界显著;分析建议不等于批准或承诺 |
+| 错误 | 为预选方案找论据;无现状基准或真实替代项;口径不一却排名;单一 ROI / BCR / 评分替代平衡判断;套 SWOT;估算冒充事实;忽略全周期成本、依赖或分配影响;未获批写成已承诺;建议不回链证据 |
+
+## 适用与消歧
+
+比较投资、资源、市场、产品、经营或供应选项并支持判断,但正文不要求具名决策者作出选择 / 批准,也不形成授权、资源拨付或执行承诺入口。`商业`、`市场分析`、`SWOT`单独只用于召回;回答研究问题走 [`research-report.md`](research-report.md),纯指标解读走 [`data-report.md`](data-report.md),命中上述 ask / 授权入口时走 Workplace Proposal,接口、不变量和实现取舍为主走 Technical RFC。
+
+## 子类型
+
+投资 / 资源配置;build-buy-partner 或 vendor;市场进入 / 扩张;产品 / 组合优先级;经营模式 / 流程;定价 / 商业模式;高不确定性的试点或阶段门。分析深度随金额、复杂度、不可逆性、影响范围和风险提高。
+
+## 证据与方法
+
+- 定义问题、目标、成功标准、范围、约束、决策 owner / 时点和现状 / 不行动基准;记录选项生成与排除理由。
+- 对每个可行选项用相同维度比较收益、全生命周期成本、时间、能力 / 依赖、风险、受影响方、不可货币化影响和可逆性。
+- 现状数据与预测分开;按需说明币种、价格时点、折现和估算方法。不得从官网标价推断销量、收入或份额。
+- 对可能翻转结论的假设做范围、情景或敏感性分析,并给 switching value、决策门或验证信号;评分模型须解释权重和证据,不能只报总分。
+- 缺目标、成功标准、基准或可行选项时只产出 decision frame / options discovery;关键估算用 `[成本区间待核]` 和验证计划,可能翻转结论且无法界定时标记 `blocked`。
+
+## 结构与高质量写法
+
+推荐与条件 → case for change / 目标 / 现状基准 → 选项生成、排除理由与同口径比较 → 关键假设、风险、情景与翻转条件 → 建议为何优于替代 → 阶段门、监测 / 学习计划与未决条件。把现状当真实选项,用区间和场景替代伪精确单点,显著说明谁获益、谁承担成本,以及什么新证据会改变建议。
diff --git a/skills/lark-doc/references/genres/data-report.md b/skills/lark-doc/references/genres/data-report.md
new file mode 100644
index 0000000000..3da6cfc5cf
--- /dev/null
+++ b/skills/lark-doc/references/genres/data-report.md
@@ -0,0 +1,32 @@
+# Genre Contract: Data Report / 数据报告 (`report.data_report`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| presentation_mode / 表达模式 | `normal`;准确、可复算、少形容词;标题表达发现、对象和时点,并保留不确定性 |
+| 内容逻辑 | 先建立指标契约和可比基线,再回答发生了什么、为何重要、还不能断言什么;观测、解释假设与行动条件分开,限制紧邻相关结论 |
+| 事实 / 边界 | 核心指标标定义、单位、分子分母、总体 / 分群、时间窗、来源 / 版本、更新时间和修订状态;比较须同口径,估计须披露可得不确定性,敏感小群体须汇总、抑制或限制访问;数据图须标轴、单位、分母、时点和来源,并提供文字等价信息 |
+| 错误 | 只列数字;隐藏分母或口径变化;不可比数据排名;选择性窗口 / 分群;相关性当因果;图轴、单位或来源缺失;统计显著冒充效应大小或业务胜出;伪精确;限制藏在附录 |
+
+## 适用与消歧
+
+解读已定义指标、趋势、分布、漏斗、监控、估计或实验观察值。`有数据`、`有数字`、`分析一下`单独不决定路由;研究问题、抽样和可推广性为主走 [`research-report.md`](research-report.md),比较商业选项走 [`business-analysis.md`](business-analysis.md),组织状态、偏差和下一步走 Workplace 周期报告。
+
+## 子类型
+
+- KPI / 经营表现与趋势;分群、cohort 与分布;漏斗 / 路径与监控异常。
+- A/B 或实验 readout;设计和推断不足时只能报告观察值,不宣布因果胜出。
+- 预测、估计、修订或统计简报;须标模型 / 假设、适用期和修订状态。
+
+## 证据与方法
+
+- 保留可复算的基数、过滤、聚合、估计区间和质量说明;比较前核对定义、总体、时间窗、分母和处理方法。
+- 按误解风险同时给绝对值、绝对变化、相对变化和长期基线;不用多余小数位制造精确感。
+- 覆盖、缺失、偏差、口径变化和修订若会改变解释,须与对应发现同处,并说明可能方向、规模和影响。
+- 描述性差异不得写成因果;解释标为待验证假设。统计显著性不等于效应大小、实际重要性或完整决策依据。
+- 缺定义、分母、时间或来源时使用 `[指标定义待核]`、`[分母待核]`,对应值不得进入结论;不可比数据分开展示。核心决策依赖的质量缺口无法关闭时标记 `blocked`。
+
+## 结构与高质量写法
+
+关键发现与决策限制 → 指标契约 / 数据质量 → 总览与基线 → 必要分维、分布和反例 → 可支持的解释与待验证假设 → 条件式行动 / 验证门 → 方法、修订和来源。每段按“观测 → 基线 / 背景 → 限制 → 含义”推进;复杂图同时给出文字结论和必要精确值,任何视觉不得成为唯一证据。
diff --git a/skills/lark-doc/references/genres/execution-plan.md b/skills/lark-doc/references/genres/execution-plan.md
new file mode 100644
index 0000000000..58df0f1329
--- /dev/null
+++ b/skills/lark-doc/references/genres/execution-plan.md
@@ -0,0 +1,27 @@
+# Genre Contract: Execution Plan / 执行计划 (`workplace.execution_plan`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| presentation_mode / 表达模式 | `normal`;以交付和判断为单位,具体、紧凑、可推进;计划可信度来自依赖、产能和验收闭环,不来自章节数量或精确到没有依据的日期 |
+| 内容逻辑 | 从已批准结果、成功标准、范围和约束出发,按“交付物 / 工作流 → 依赖与关键路径 → 带退出条件的里程碑 → owner / 接口 / 资源 → 风险触发与备选 → 治理、变更与验收”推进 |
+| 事实 / 边界 | 区分已确认承诺、估算、假设和待定项;时间、owner、预算、产能、权限、依赖与验收方须可追溯且算术相容;未批准方向不得写成承诺,关键资源或安全前提未知时收窄计划或 `blocked` |
+| 错误 | 任务清单冒充计划、活动无交付物 / 完成定义、里程碑只是日期、排期不服从依赖与产能、所有事项同优先级、接口或验收方缺失、风险无预警信号 / 动作 / owner、变更后不更新基线,任一出现即失败 |
+
+## 适用与消歧
+
+用于方向和目标已定后,组织一次性项目、迁移、发布、活动战役、专项治理或跨团队变更。主要任务仍是选择方向、申请预算 / 资源或授权时走 `proposal.md`;比较策略选项且不形成批准入口走 `business-analysis.md`;发布已授权规则 / 通知走 `formal-doc.md`;重复确定路径走 `sop-tutorial.md`;报当前状态走 `weekly-report.md`。
+
+“项目计划、执行方案、实施计划、营销策划”只作召回词。营销策划若仍在决定打法或预算,按上述 Proposal / Business Analysis 消歧;只有已定打法的协同落地走本合同。
+
+## 可执行性与证据
+
+- 先写可验收结果、范围 / 非范围、约束和最迟决策点;再按交付物而非部门名称拆工作包。每个关键工作包说明 owner、输入 / 输出、依赖、完成定义和验收方。
+- 标出关键路径、可并行项、阶段入口 / 退出条件与资源瓶颈;日期由依赖、产能和必要审批 / 制作 / 校准时间推导。无法推导时用相对时间、区间或具体占位,不补造精确排期。
+- 风险写预警信号、影响、预防 / 响应动作、决策 owner 和备选路径;备选必须说明何时切换及切换后的安全或业务终态,不写“加强沟通”。
+- 治理只保留会产生判断的节奏:接口、升级条件、决策权、范围 / 基线变更和重新验收。密集对应关系可用一张排期、依赖或责任表,但表格不能替代关键路径和取舍说明。
+
+## 高质量写法
+
+让每个目标能一路回链到交付物、里程碑和工作包,让每个日期能回链依赖与产能,让每个风险能回链触发后的动作。资源不足时缩范围、分阶段或设决策门,不用“全渠道、全覆盖、同步推进”制造伪可行性。
diff --git a/skills/lark-doc/references/genres/formal-doc.md b/skills/lark-doc/references/genres/formal-doc.md
new file mode 100644
index 0000000000..422c7f282a
--- /dev/null
+++ b/skills/lark-doc/references/genres/formal-doc.md
@@ -0,0 +1,36 @@
+# Genre Contract: Formal Document / 内部正式材料 (`workplace.formal_doc`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| presentation_mode / 表达模式 | 固定 `formal`;格式中立的内容稿完成后再应用。庄重、准确、简洁、直接;正式性来自真实权威、事实、边界、责任和生命周期,不来自套话、层级或装饰 |
+| 允许 block | `title`(完整文稿最多 1 个)、`p`、`h1`、`h2`、`h3`、`h4`;标题层级连续且不超过四级 |
+| 限用 block | `ul`、`ol`容器及`li`子块仅承载真实并列或顺序;`table`容器及`thead`、`tbody`、`tfoot`、`tr`子块仅承载多对象同字段信息;`img`、`figure`仅承载有必要证据作用、来源说明和文字等价信息的材料 |
+| 禁止 block | 禁止未列入允许 / 限用清单的类型,包括`callout`、`checkbox`、`grid`容器及`column`子块、`whiteboard`、`blockquote`、`pre`、根级`code`和`hr`;禁止装饰色、贴纸、伪红头、伪印章和无证据作用的配图 |
+| 内容逻辑 | 先按读者任务选择规则 / 制度、已批准通知 / 安排、检查整改 / 台账或已核定正式说明之一;只写完成该任务所需的对象、依据、要求 / 发现、责任、核验和生命周期,不混写子类型 |
+| 事实 / 边界 | 只把已确认的授权、要求、事实和立场写成定论;来源陈述、原始记录、已复核事实和推断分开;外发前确认保密、商业秘密、个人信息、素材权利和发布权限;关键缺口使 `Publish Gate = blocked` |
+| 错误 | 因“正式”误判公文,把本 leaf 当方案 / 总结 / 简报兜底,伪造批准 / 生效,用通知偷渡未获授权的新规则,网络素材冒充本单位事实,检查线索写成责任结论,或措施与发现不对应,任一出现即失败 |
+
+## 适用与收口
+
+用于把已授权的非公文组织规则或安排、可复核的检查整改记录,或已核定的组织立场写成正式载体,使读者能够判断适用范围、应采取的行动、记录状态或核心立场。
+
+待批准方向走 `proposal.md`;复杂一次性执行走 `execution-plan.md`;重复操作步骤走 `sop-tutorial.md`;党政机关公文走 `official-redhead.md`;高层简报走 `memo-brief.md`;周期状态走 `weekly-report.md`;学习总结走 Retrospective / Report。`正式、制度、通知、方案、计划、总结、简报、讲话稿`等词单独不触发本体裁,本体裁也不是不确定请求的 fallback。
+
+## 按读者任务选择唯一内容路径
+
+| 读者任务 | 内容主线 |
+|-|-|
+| 判断持续规则 | 目的与权威 → 适用 / 不适用范围 → 必要定义 → 规范要求 → 责任、例外与升级 → 生效、维护、复审和替代 |
+| 执行已批准通知 | 发布主体与批准状态 → 受影响对象及范围 → 已确认事项与生效时间 → 动作、责任与期限 → 例外、反馈和联系人 |
+| 复核检查整改 | 对象、范围、方法与证据状态 → 每项可观察发现、标准、影响和已支持原因 → 对应措施、责任与期限 → 核验、关闭证据和变更痕迹 |
+| 理解已核定立场 | 讲者或发布主体、场合、受众与时长 → 核心立场 → 必要事实和理由 → 期望理解或行动;不混入制度效力 |
+
+## 证据与高质量写法
+
+- 规则类按需写维护责任、版本、批准、生效、复审和替代状态;稳定描述做什么、谁负责、何时生效,易变操作方法链接到受控 SOP。规范词优先沿用组织现有定义,强度不明时标`[规范强度待确认]`。
+- 检查整改区分用户陈述、原始记录、已复核事实和待补证线索;关键日期、数量或结论证据不足时就近标`[证据待补:补证动作]`,不得推断原因或责任;归档补正保留原记录。
+- 检查措施必须对应具体发现并可核验;已批准通知只传达授权范围内的事项;正式讲话只使用已核定立场,并按真实语速朗读校验。
+- 使用主动句、明确主体和一致术语,一句只表达一个事实、判断、要求或许可;清单严守用户指定数量与字段,不机械补背景或文控字段。
+- 批准者、依据、权限、适用范围、生效状态或发布条件不明时使用具体占位并保持草案;不得以版式、标题或署名暗示已经批准、签发或生效。
diff --git a/skills/lark-doc/references/genres/meeting-minutes.md b/skills/lark-doc/references/genres/meeting-minutes.md
new file mode 100644
index 0000000000..06b9778e33
--- /dev/null
+++ b/skills/lark-doc/references/genres/meeting-minutes.md
@@ -0,0 +1,24 @@
+# Genre Contract: Meeting Minutes / 会议纪要 (`workplace.meeting_minutes`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| presentation_mode / 表达模式 | `normal`;中性、精确、按议题和决定组织,用稳定标签区分决定、建议、未决和待确认,不重放发言顺序 |
+| 内容逻辑 | 先说明会议身份和记录状态,再按议题写实际材料 / 必要讨论摘要 → 决定及理由 / 异议 → 未决项 → 行动 → 审阅材料;深度与治理风险相称 |
+| 事实 / 边界 | 出席、法定人数、冲突、动议、表决、决定、owner、期限和批准状态均须来自会议材料或确认;只保留治理所需个人信息,草稿不得冒充批准版;历史状态固定为文字快照,不得由`checkbox`、`task`等可变交互块改写 |
+| 错误 | 摘要冒充逐字稿、讨论流水账、建议写成决定、行动不可跟踪、法定人数不明却宣称决定有效、草稿冒充批准、静默改历史或泄露无关个人信息,任一出现即失败 |
+
+## 适用与消歧
+
+用于某次已发生会议的可引用治理记录,使缺席者、执行者和审核者确认决定、未决与行动。逐字 / 逐发言人 / 可回放内容只是 transcript 源材料;会前准备走 `memo-brief.md`;非会议状态走 `weekly-report.md`;党政机关法定“纪要”走 `official-redhead.md`。
+
+## 子类型与治理证据
+
+普通工作会可精简为会议身份、决定、未决和行动;项目决策会补必要理由与审阅材料;董事会、委员会、表决或法定会议按章程 / 适用规则记录出席、法定人数、利益冲突、动议、票决、精确决议及认证。
+
+证据可来自 agenda、出席记录、实际审阅材料、动议 / 投票和录音 / 逐字稿,但正文只链接关键来源,不复制附件淹没决定。来源冲突并列保留并交主持人 / 参会者确认。
+
+## 结构与高质量写法
+
+标明名称 / 类型、日期时间、地点 / 方式、主持 / 记录和草稿 / 已批准状态;每个议题围绕结果而非发言顺序。行动项写交付物 / 动作、责任人 / 单位、时间要求和状态。缺失信息用`[决议原文待确认]`、`[owner 待确认]`等具体占位;法定人数或批准不明时不得宣称有效,保持草稿并进入确认流程。
diff --git a/skills/lark-doc/references/genres/memo-brief.md b/skills/lark-doc/references/genres/memo-brief.md
new file mode 100644
index 0000000000..6da9e71a0a
--- /dev/null
+++ b/skills/lark-doc/references/genres/memo-brief.md
@@ -0,0 +1,25 @@
+# Genre Contract: Memo / Brief (`workplace.memo_brief`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| presentation_mode / 表达模式 | `normal`;直接、克制、按具名读者控制信息密度,首屏给结论、状态或 ask,不设固定篇幅 |
+| 内容逻辑 | 先选信息、决策或会前三种模式之一,再按“核心事项 / ask → 必要事实 → 影响 / 取舍 → 风险 / 未知 → 动作”推进;只有真实选择才写选项 |
+| 事实 / 边界 | 事实、数字、立场、审批状态和时点均须可核验;未知与假设就近标记;Memo 只可作完整 Proposal 的决策封面,不替代其论证 |
+| 错误 | 首屏无结论或 ask、把完整 Proposal 压成摘要、编造审批 / 立场、用固定篇幅删证据、细节不解释影响,任一出现即失败 |
+
+## 适用与消歧
+
+用于让具名内部读者快速知悉、判断或完成会前准备。请求批准完整方向、预算、资源或执行承诺走 `proposal.md`;按周期判断相对目标的位置走 `weekly-report.md`;“摘要 / 简报”单词本身不触发本体裁。
+
+## 子类型与证据
+
+- 信息 Brief:变化 → 影响 → 当前状态 / 风险 → 下一步;无须行动时明确“仅供知悉”。
+- 决策 Memo:决定事项 / 时点 → 现状 → 真实选项及同口径影响 → 推荐与证据 → 明确决策入口。
+- 会前 Brief:会议目标 → 已核验的参与方立场 / 利益 → 要点与禁区 → 期望结果;未知立场不得补造。
+- 按需标读者、作者 / 责任团队、日期和信息截至时间。持续更新时说明相对上版的变化及下次更新点。
+
+## 结构与高质量写法
+
+按重要性而非材料顺序组织,一个段落一个观点;关键判断不藏在附件。建议写清谁做什么、为什么以及怎样判断完成,并呈现足以改变判断的风险、反例和不确定性。缺关键事实时用`[关键结论待确认]`、`[数据口径待核]`等具体占位,或收窄为待核问题清单;仍要求据此批准时必须 `blocked`。
diff --git a/skills/lark-doc/references/genres/official-redhead.md b/skills/lark-doc/references/genres/official-redhead.md
new file mode 100644
index 0000000000..3c07b7b3dd
--- /dev/null
+++ b/skills/lark-doc/references/genres/official-redhead.md
@@ -0,0 +1,72 @@
+# Genre Contract: Official Document / 公文内容稿 (`workplace.official_redhead`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| presentation_mode / 表达模式 | 固定 `formal`;庄重、准确、简洁、直接。禁 emoji、网感、营销话术、情绪化评价、空话和机械编号 |
+| 允许 block | `title`(完整文稿最多 1 个)、`p`、`h1`、`h2`、`h3`、`h4`;标题层级连续且不超过四级 |
+| 少用 block | `ul`、`ol`容器及`li`子块仅用于真实并列项,不替代公文层级序号;`table`容器及`thead`、`tbody`、`tfoot`、`tr`子块仅用于非表格难以清楚表达的多对象同字段信息 |
+| 禁止 block | 禁止未列入允许 / 少用清单的类型,包括`callout`、`grid`容器及`column`子块、`checkbox`、`whiteboard`、`blockquote`、`pre`、根级`code`、`hr`、`img`、`figure`;禁装饰色、伪红头和伪印章 |
+| 内容逻辑 | 按行文目的、机关关系和受众确定唯一文种,再按“必要依据 / 缘由 → 核心事项 / 决定 → 可执行要求 → 必要结语”推进 |
+| 事实 / 边界 | 只写已给定或已核验的事实、依据、权限和决定;未知项具体占位,关键缺口使 `Publish Gate = blocked`;飞书只交付内容审校稿,不宣称已签发或生效 |
+| 错误 | 禁止文种或行文关系错误、报告夹请示、请示一文多事 / 多头主送、批复无对应请示、引用 / 文号 / 序号 / 附件不规范,以及编造事实、依据、权限或制发要素 |
+
+## 适用
+
+仅在明确要求公文、红头 / 套红、正式发文,或法定文种与机关行文关系、发文字号、签发人、主送机关等制发要素共同出现时使用。“红头文件”是制发信号,不是文种;普通公司通知、制度、检查 / 整改材料走 `formal-doc.md`,普通会议记录走 `meeting-minutes.md`。
+
+## 文种选择
+
+按“行文目的 → 发文与受文机关关系 → 受众范围”判断,不按单个关键词判断。
+
+| 文种 | 适用意图 |
+|-|-|
+| 决议 | 会议讨论通过重大决策 |
+| 决定 | 对重要事项作出决策部署、奖惩或变更 / 撤销决定 |
+| 命令(令) | 公布法规规章、施行重大强制措施、授予衔级或嘉奖 |
+| 公报 | 权威公布重要决定或重大事项 |
+| 公告 | 向国内外宣布重要或法定事项 |
+| 通告 | 在一定范围公布应遵守或周知的事项 |
+| 意见 | 对重要问题提出见解和处理办法 |
+| 通知 | 要求下级 / 有关单位执行或周知,批转、转发公文 |
+| 通报 | 表彰、批评、传达重要精神或告知重要情况 |
+| 报告 | 向上级汇报工作、反映情况或答复询问,不请求决定 |
+| 请示 | 向上级请求指示或批准;一文一事,原则上只主送一个上级机关 |
+| 批复 | 答复下级机关请示,必须有对应来文 |
+| 议案 | 政府依法向同级人大或其常委会提请审议 |
+| 函 | 不相隶属机关间商洽、询答、请求批准或答复审批 |
+| 纪要 | 记载正式会议主要情况和议定事项,不写逐字过程 |
+
+优先消歧:汇报且不求决定用报告,求上级决定用请示,不相隶属机关商洽用函;面向明确单位执行用通知,面向一定范围不特定对象遵守用通告,向国内外宣布重要 / 法定事项用公告,传达情况或评价用通报。
+
+## 行文与事实
+
+- 按隶属关系、职权和授权行文;一般不越级,特殊越级时同时抄送被越过机关。
+- 上行文原则上主送一个上级机关,不抄送下级;报告不得夹带请示。除直接交办外,不主送上级负责人个人。
+- 一份主文保持一个行文方向和授权状态;同一事项若既需向上请求批准又需向下要求执行,应拆分文稿或待批准后另行制发,附件不得偷渡尚未授权的执行要求。
+- 下行要求不得超出发文机关权限;涉及其他地区 / 部门职权时先协商。联合行文仅限必要且主体关系适当的情形。
+- 只把已确认的决定写成指令。措施按需写明主体、动作、对象、期限、标准和反馈去向;对不相隶属机关使用`商请`、`请予`、`函复`等匹配关系的措辞。
+- 缺少授权、关键依据、核心事实、适用范围或审批决定时不得发布;不得猜测文号、签发人、密级或紧急程度。
+
+## 内容结构
+
+- 标题一般使用“发文机关 + 事由 + 文种”,内含法规、规章或被印发文件名称时使用书名号。
+- 主送机关使用全称、规范简称或同类机关统称。附件说明与附件顺序、名称逐字一致;多个附件用阿拉伯数字编号,名称末尾不加标点。
+
+| 文种 | 常用结构 |
+|-|-|
+| 通知 | 缘由 / 依据 → 事项 → 对象 / 时间 → 已确认要求 |
+| 请示 | 缘由 / 依据 → 单一请示事项与倾向意见 → `妥否,请批示` |
+| 批复 | 准确引用来文 → 明确意见 → 执行要求 → `此复` |
+| 函 | 事项 / 依据 → 商请或答复 → `请予函复` / `特此函复` |
+| 报告 | 情况 → 事实 / 成效 → 问题 → 后续安排 → `特此报告` |
+| 纪要 | 会议基本信息 → 主要情况 → 议定事项 / 责任 / 时限 |
+
+## 文号、引用与序号
+
+- 普通发文字号采用“机关代字 + 完整年份 + 顺序号”,如 `×政发〔2026〕8号`;年份用六角括号,顺序号不加“第”、不编虚位。命令(令)的令号可用 `第×号`。
+- 首次引用其他公文时写完整标题和文号:`《××机关关于印发〈××办法〉的通知》(×发〔2026〕8号)`。不只写文号,不用论文式参考文献编号。
+- 文件、法律法规名称使用书名号;直接引文使用中文双引号,内层用单引号。引文须核对原文、效力、制定机关和适用范围;无法核实则标记 `[引文待核]`。
+- 正文层级依次使用 `一、`、`(一)`、`1.`、`(1)`,不得写成 `1、`、`(一)、`,不得跳级;超过四级时重组内容。
+- 成文日期写为 `2026年7月13日`,月日不补零。标点和数字按 GB/T 15834、GB/T 15835 使用;全称及规范简称前后一致。
diff --git a/skills/lark-doc/references/genres/prd.md b/skills/lark-doc/references/genres/prd.md
new file mode 100644
index 0000000000..21340ada52
--- /dev/null
+++ b/skills/lark-doc/references/genres/prd.md
@@ -0,0 +1,25 @@
+# Genre Contract: PRD / 产品需求 (`workplace.prd`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| presentation_mode / 表达模式 | `rich`;具体、行为化、术语和状态一致;在有明确内容作用时用场景、状态流、表格、图示和其他 rich block 降低理解与验收成本,但不让视觉组件替代需求、证据或验收,不用固定大模板制造完整感 |
+| 内容逻辑 | 方向已定后按“用户问题 / 证据 → 目标 / 结果 → 范围 / 非目标 → 场景 → 行为需求 / 验收 → 异常 / 边界 → 适用质量约束 → 依赖 / 开放问题”推进 |
+| 事实 / 边界 | 用户需要、指标、研究、阈值、可行性、owner、状态和排期须可追溯;需求描述可观察结果,acceptance criteria 验结果;安全、隐私、无障碍等仅按实际风险和标准纳入 |
+| 错误 | 功能清单无用户问题、Proposal 论证吞没需求、范围 / 非目标缺失、需求暗藏实现、验收不可测、正常路径无异常、伪造研究 / 阈值 / 批准或机械填质量模板,任一出现即失败 |
+
+## 适用与消歧
+
+用于方向与投入原则已定后,让产品、设计、研发和测试就用户问题、范围、可观察行为与完成标准形成共识。是否立项 / 选择方向 / 批资源走 `proposal.md`;架构、接口和实现取舍走 `technical-doc.md`;已批准重复操作走 `sop-tutorial.md`。“需求 / 功能”单词本身不触发。
+
+## 证据与需求写法
+
+- 明确目标用户、任务情境、问题及研究 / 行为 / 支持证据;内部偏好和预设功能不冒充用户需要。
+- 产品目标连接可观测结果,指标标口径、来源和时间窗。未知目标值用`[目标值待产品 / 数据确认]`并给确认 owner / 时点,不编使用量或阈值。
+- 关键需求写成 actor + trigger / precondition + observable outcome + failure / edge;术语和状态一致。用户故事格式只是工具,不是章节配额。
+- 每个质量约束给可验证门槛或明确待确认项;不适用时不填模板。需求、验收 / 测试和来源保持追踪。
+
+## 结构与高质量写法
+
+先定范围、非目标、优先级、依赖、假设和开放问题,防止 scope creep;再按关键场景写正常、异常和边界行为。把大而不可测的需求拆到可验收粒度,不用“体验更好 / 性能高”等形容词。没有用户证据时收窄为假设和研究计划;关键合规 / 安全门缺失时 `blocked`,开放问题不得藏在脚注。
diff --git a/skills/lark-doc/references/genres/proposal.md b/skills/lark-doc/references/genres/proposal.md
new file mode 100644
index 0000000000..bb144cdb44
--- /dev/null
+++ b/skills/lark-doc/references/genres/proposal.md
@@ -0,0 +1,24 @@
+# Genre Contract: Proposal / 方案提案 (`workplace.proposal`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| presentation_mode / 表达模式 | `normal`;结论前置、具体、可审议,主动呈现代价、反例与不确定性,不用宏大背景或伪精确制造可批准感 |
+| 内容逻辑 | 明确 decision / 决策者 / 时点,再按“改变理由与不行动基准 → 目标 → 真实选项同口径比较 → 推荐 → 资源 / 交付 → 风险 / 未知 → 决策入口”推进 |
+| 事实 / 边界 | 区分事实、估算、假设和未知;收益、成本、资源、用户证据、审批和排期须可追溯;进入执行决策才写治理 / 退出条件,未批准不得写成既有承诺 |
+| 错误 | 无决策者 / ask、无不行动基准、预设单一答案、选项口径不同、成本风险后置、未批先承诺、编造收益 / 审批或与 PRD 混写,任一出现即失败 |
+
+## 适用与消歧
+
+用于请求具名决策者批准、驳回或选择方向、预算、资源、试点或执行承诺。方向已定并定义产品行为 / 验收走 `prd.md`;短决策封面走 `memo-brief.md`;已批准安排的发布走 `formal-doc.md`。“方案”单词本身不触发本体裁。
+
+## 子类型与证据
+
+可用于概念 / 方向、投资 / 预算、资源申请、变更、试点 / 实验和执行承诺提案;深度随阶段、金额、风险和不可逆性裁剪。必须给 case for change、目标 / 成功标准、不行动或最小变化基准,以及足以判断的成本、收益、依赖、风险和敏感因素。
+
+存在真实选择时纳入可行替代并以相同范围、时间和评价标准比较;没有真实替代时说明约束如何收敛,不能造假选项。不可量化影响可定性,但须说明原因及其决策影响。
+
+## 结构与高质量写法
+
+先把选择题写对,再论证推荐;显式记录被放弃选项和推荐代价。数字不足时使用范围、依据和验证计划,不补精确点估。进入执行决策时按需补 owner、里程碑、治理、衡量、退出 / 复盘;缺决策权、关键成本或安全合规依据时收窄为探索稿,仍要求批准则 `blocked`。
diff --git a/skills/lark-doc/references/genres/research-report.md b/skills/lark-doc/references/genres/research-report.md
new file mode 100644
index 0000000000..39f3111d39
--- /dev/null
+++ b/skills/lark-doc/references/genres/research-report.md
@@ -0,0 +1,32 @@
+# Genre Contract: Research Report / 调研报告 (`report.research_report`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| presentation_mode / 表达模式 | `normal`;证据驱动、校准、术语一致;摘要独立可读,语气强度不得超过证据强度 |
+| 内容逻辑 | 先确定研究问题与研究类型,再交付当前答案、证据强度和可推广边界;按问题或主题组织发现,解释、建议和验证计划必须回链发现 |
+| 事实 / 边界 | 区分原始事实或参与者陈述、分析推断、假设和建议;方法披露足以评估偏差;适用时确认委托、利益、同意、匿名或保密、敏感数据用途;研究材料须确认使用权、去标识、来源与说明,复杂视觉附文字等价信息;未知不补造 |
+| 错误 | 无明确问题;方法黑箱;资料摘要冒充发现;样本外推;醒目个案冒充模式;事实、解释和建议混写;相关写成因果;合规状态、授权或行业共识靠猜 |
+
+## 适用与消歧
+
+以明确研究问题、研究设计或材料、发现和限制为主要交付。`调研`、`研究过`、`访谈`、`问卷`单独只用于召回;只解读既定指标走 [`data-report.md`](data-report.md),比较特定战略或资源选项走 [`business-analysis.md`](business-analysis.md),方法并非判断重点的问题框架综合走 [`white-paper.md`](white-paper.md)。
+
+## 子类型
+
+- **定量 / 定性 / 混合研究**:根据问题选择总体、抽样或招募、工具、采集和分析方法;不用一种方法的规范冒充全部研究标准。
+- **用户研究 / 项目或政策评估**:说明场景、参与者、干预或对象、成功标准、观察窗口和用途。
+- **证据综合**:只有检索范围、纳排和综合方法明确时才作为研究发现;普通资料汇总不得升级为系统结论。
+
+## 证据与方法
+
+- 明确对象、用途、非目标和适用情境;按需披露委托 / 执行方、总体与纳排、抽样 / 招募、样本量、工具 / 题项、采集方式 / 语言 / 时点、响应 / 脱落、加权、编码 / 分析和质量控制。
+- 写明偏差、缺失、反例、负结果、替代解释及其可能方向;透明报告不等于设计无偏,也不证明结论可复现。
+- 人员或敏感研究在适用规则下确认知情同意、撤回与伤害风险、匿名 / 保密、访问和数据用途;未获授权不公开可识别材料、原始数据或代码。
+- 引文只说明有出处的体验或机制,不把单个引文写成频率;结论只推广到设计和样本支持的人群、时间与环境。
+- 无原始材料只能产出研究范围或计划,不能生成 findings;方法或样本缺失时用 `[抽样方法待核]`、`[采集时点待核]` 并收窄为探索性观察。关键伦理、授权或方法缺口会改变结论时标记 `blocked`。
+
+## 结构与高质量写法
+
+独立答案、证据强度与关键限制 → 问题 / 范围 / 既有知识 → 方法 / 样本 → 按问题或主题组织的发现 → 解释、反例与替代解释 → 有边界的建议 / 验证 → 局限、来源与必要附录。摘要覆盖目的、方法、发现、含义和限制;正文以“主张 → 证据 → 限定”推进,不按作业时间线罗列过程,不用组件数量代替研究质量。
diff --git a/skills/lark-doc/references/genres/retrospective.md b/skills/lark-doc/references/genres/retrospective.md
new file mode 100644
index 0000000000..c33d5c8861
--- /dev/null
+++ b/skills/lark-doc/references/genres/retrospective.md
@@ -0,0 +1,25 @@
+# Genre Contract: Retrospective / 复盘 (`workplace.retrospective`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| presentation_mode / 表达模式 | `normal`;坦诚、无责备、因果克制,围绕证据和下一轮改变,不用“加强沟通 / 持续关注”代替可验证实验 |
+| 内容逻辑 | 界定已结束周期 / 事件,再按“目标 / 证据 → 预期与实际 → 聚类观察 → 洞见 / 待验证因果 → 保留项 → 少量改进实验 → 复查”推进 |
+| 事实 / 边界 | 事实 / 观察、解释 / 假设、洞见和行动分层;结论回链事件、指标或交付物;根因仅在证据充分时声明,否则写可证伪假设;保护必要隐私 |
+| 错误 | 周报换标题、成绩陈列 / 情绪宣泄、个人归罪、单一根因臆测、行动无 owner / 验证 / 跟踪、不回看上轮或把模板便签当结论,任一出现即失败 |
+
+## 适用与消歧
+
+用于回看明确迭代、阶段、项目或事件,形成可复用学习并改变下一轮做法。当前状态与升级需求走 `weekly-report.md`;仍在未知中止损、取证、恢复或调查生产事故走 `technical-doc.md`。生产事故可由 Technical 主文承载影响 / 时间线 / 根因 / 恢复,再附本体裁的团队学习层。
+
+## 证据与因果
+
+- 开头界定范围、时间、目标 / 原计划、参与视角和已知证据;不得补造指标、时间线、共识、原因或行动。
+- 同时识别应保留与应改变的条件,按影响聚类;以系统、流程、工具、接口和当时条件为对象,不把惩罚叙事冒充根因。
+- 个人工作心得 / 成长反思以一个真实事件或转折为证据,呈现“当时判断 → 反证 / 后果 → 新认识 → 下一次可观察行为”;不代写材料没有提供的情绪、动机、心路或成长。
+- 证据不足时写“促成条件 / 假设 + 验证方式”,不能用确定语气。缺基线用`[基线待补]`,涉及安全 / 法务而证据不足时转 Technical 并 `blocked`。
+
+## 结构与高质量写法
+
+便签、4Ls、Start / Stop / Continue 只是收集手段,成稿须综合为主题和判断。改进实验写动作、owner、目标时间、验证条件和跟踪位置,优先改变系统而不是要求人“更小心”;按需补上轮行动效果和下轮复查点。项目收尾可增加成本、范围、相关方和知识移交,但不机械扩章。
diff --git a/skills/lark-doc/references/genres/route-consumer.md b/skills/lark-doc/references/genres/route-consumer.md
new file mode 100644
index 0000000000..f17f3b83a2
--- /dev/null
+++ b/skills/lark-doc/references/genres/route-consumer.md
@@ -0,0 +1,37 @@
+# Genre Contract: Consumer / 消费决策内容 (`router.consumer`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| presentation_mode / 表达模式 | `rich`;具体、可信、可亲近,体验感服务于选择,不用热情语气替代测试、价格和适用条件 |
+| 内容逻辑 | 围绕具体消费场景,用“需求 / 使用条件 → 评价标准 → 体验或测试证据 → 权衡 → 适合谁 / 不适合谁”推进;合集和比较须共享标准,不按品牌逐段堆卖点 |
+| 事实 / 边界 | 只声称真实体验或有方法支撑的测试;披露赠品、佣金、赞助和其他重要关系;标明版本、时间、价格口径及限制;用户 / 专家引语、图片和前后对比须有授权、来源、语境与真实性依据,非文字证据须有文字等价信息;遵守目标法域和平台当期消费者、广告与高风险品类规则,关键利益关系、核心功效、安全条件或报价条款缺失时 blocked |
+| 错误 | 编造使用经历、未披露商业关系、无方法的评分 / 排名、把主观偏好写成客观最佳、隐藏不适用人群或总成本、用极端个案概括功效、过期信息仍当现状,任一出现即失败 |
+
+## 适用与消歧
+
+用于帮助读者购买、比较、避坑或判断某种生活方式是否适合自己。仅出现小红书、微信等平台名不触发;明确要求最终交付小红书笔记或微信公众号文章时走 `route_platform`,再选择对应 leaf,消费选择任务作为该 leaf contract 的硬约束,不再并读 Consumer。以公共事件核实为主走 Media,以价值判断为主走 Opinion,以品牌拥有的转化内容走 Marketing。
+
+“测评”必须继续区分独立比较、真实个人体验和品牌演示:前两者可走本合同,品牌控制结论或行动入口时走 Marketing,并保留显著披露。
+
+## 子类型
+
+| 子类型 | 读者任务与推进 |
+|-|-|
+| 单品体验 / 好物分享 | 判断某物在真实场景是否值得;使用背景 → 观察 → 优缺点 → 适用人群 |
+| 对比测评 / 排名 | 在同一任务下选择;方法与样本 → 共同标准 → 结果 → 权衡与不确定性 |
+| 合集 / 清单 | 快速缩小候选范围;选择门槛 → 分组理由 → 各项差异 → 最终选择路径 |
+| 探店 / 服务体验 | 判断是否到访或购买服务;时间地点 → 实际流程 / 价格 → 体验证据 → 限制 |
+| 生活方式内容 | 判断实践成本与可复制性;目标 → 做法 → 真实投入 / 结果 → 适用边界 |
+
+## 证据、披露与合规
+
+- 第一人称体验交代使用时长、频率、版本和条件;未亲测就明确资料来源,不伪装成亲历。比较结论说明样本、标准、测量方法及未覆盖变量。
+- 把“真实 / 有效”“适合当前读者”“值得当前价格”分开判断。强参数品只保留会改变选择的指标,并解释版本口径和决策影响;使用评分 / 排名时公开标准、权重、主观边界和反转条件,安全或资格等一票否决项不得被平均分稀释。
+- 商业关系和激励在读者接触推荐时清楚出现,不能藏在模糊标签或文末。披露、重大限制和安全警示须就近可见,不能只藏在链接或视觉装饰中。
+- 健康、安全、金融、未成年人等高风险内容只写证据支持且适用法域允许的范围;不能核实的功效或个体化建议删除。规则冲突时按交付地区、渠道和发布时间核验,不把单一国家指南写成全球义务。
+
+## 结构与高质量写法
+
+先告诉读者评判基准,再给结论,才能让“推荐”可复核。优点与代价写在同一决策语境内,价格同时说明时间、地区、规格和附加成本。结尾给条件化选择,而不是人人适用的口号;关键参数待补时用具体占位并暂停对应结论,不能靠语气填空。
diff --git a/skills/lark-doc/references/genres/route-creative.md b/skills/lark-doc/references/genres/route-creative.md
new file mode 100644
index 0000000000..3b6c962eea
--- /dev/null
+++ b/skills/lark-doc/references/genres/route-creative.md
@@ -0,0 +1,36 @@
+# Genre Contract: Creative / 叙事创作 (`router.creative`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| presentation_mode / 表达模式 | `rich`;语言、节奏和视角服务指定叙事体验,表达自由不替代人物动机、因果和场景可读性 |
+| 内容逻辑 | 本合同只覆盖叙事创作;先确认体验、篇幅、视角和约束,用“人物欲望 → 阻力 → 选择 → 代价 → 变化”形成场景因果;剧本和互动叙事分别服从可演行动与有后果分支 |
+| 事实 / 边界 | 用户授权的虚构可创造,但真实背景、引用、既有作品正史和人物身份不得伪造;图片、题记、原作片段和创作参考须遵守来源与使用权限,非文字参考及互动分支图须附文字等价的关系、路径和状态说明;区分明确设定、合理创作补白和待确认约束;核心世界观 / 权利边界冲突且无法安全收窄时 blocked |
+| 错误 | 只堆设定不发生选择、人物为推进情节突然失去动机、冲突靠偶然或外力无代价解决、视角 / 时态无意漂移、剧本写成解释性小说、互动分支无状态差异、把诗歌静默纳入交付,任一出现即失败 |
+
+## 适用与消歧
+
+仅用于网文、短篇故事、同人叙事、互动小说、剧本和故事大纲等以事件、人物选择和变化为核心的创作。诗歌、歌词、纯抒情散文不在本合同范围;收到这类请求时先确认目标或使用相应专用规则,不因“Creative”一级名称而静默扩写。
+
+以论点和证据表达判断走 Opinion;以真实个人经历建立专业信誉走 Personal Brand。世界观说明若目标只是知识解释,不因带角色名就成为故事。
+
+## 子类型
+
+| 子类型 | 读者任务与推进 |
+|-|-|
+| 短篇 / 网文 | 获得连续叙事体验;触发变化 → 升级阻力 → 关键选择 → 后果 / 回响 |
+| 同人叙事 | 在约定正史与角色核心上体验新情境;明确时间点 / 偏离点 → 角色选择 → 新后果 |
+| 剧本 / 短剧 / 叙事短视频 | 看见可拍、可演的行动与冲突;媒介 / 时长 / 制作约束 → 场景目标 → 视觉、行动、声音 / 对话 → 转折 → 场景状态变化 |
+| 互动小说 | 作出有信息依据且有后果的选择;状态 → 选择 → 反馈 → 状态改变 → 后续分支 |
+| 故事大纲 | 判断故事能否成立并继续创作;前提 → 人物弧 → 节点因果 → 高潮选择 → 结局变化 |
+
+## 设定与真实性
+
+- 先锁定用户给定的人物、关系、禁区、正史时间点和期望体验;未指定的创作空间可以补白,但不得覆盖明确约束。关键歧义有多个会显著改变成品的方向时先询问。
+- 使用真实地点、历史、科学或文化材料时核验会影响情节的事实;有意架空应让读者能辨认其虚构约定。同人创作不把自设冒充正史,也不虚构原作引语。
+- 真实人物、未公开经历、受保护素材和委托作品按授权边界处理;不能确认可用性时改为原创替代或保持 blocked。
+
+## 结构与高质量写法
+
+每个场景都让人物为目标采取行动,并在离场时改变信息、关系、资源或风险。细节同时承担感官、人物或伏笔功能,背景通过当前冲突释放,不集中讲解。对话要改变局面而非重复旁白;剧本动作、声音和调度须在声明的演员、场地、道具与媒介条件下可实现,不用固定“前三秒 / 每分钟一反转”公式代替因果。结局兑现前文建立的选择与代价。大纲可显式呈现结构,成稿则把结构转化为可体验的场景。
diff --git a/skills/lark-doc/references/genres/route-knowledge.md b/skills/lark-doc/references/genres/route-knowledge.md
new file mode 100644
index 0000000000..0c3307a97e
--- /dev/null
+++ b/skills/lark-doc/references/genres/route-knowledge.md
@@ -0,0 +1,39 @@
+# Genre Contract: Knowledge / 知识与教程 (`router.knowledge`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| presentation_mode / 表达模式 | `normal`;具体、可操作、按读者水平解释;科普可生动、有好奇心,但不得牺牲准确性或编造戏剧性 |
+| 内容逻辑 | 先选择唯一主模式和读者起点,再承诺一个理解、学习、一次操作、检索或选择结果;第一屏给适用对象、目标和关键前置,概念、步骤、练习 / 验证、反馈与例外按需渐进展开 |
+| 事实 / 边界 | 事实、版本、命令、UI 路径和链接须核验;示例与规则分开;截图 / 案例不得泄露敏感信息;已知自助路径可写,组织受控重复作业走 SOP,设计、精确技术契约或未知诊断走 Technical |
+| 错误 | 不声明读者起点;教程变理论课;学习计划无基线 / 完成标准 / 调整规则;只有原则无步骤 / 例子;步骤无结果或验证;版本、权限、环境缺失;编造命令、UI 或链接;FAQ 脱离真实问题;资源合集无标准 / 注释 / 维护;视觉成为唯一信息;把未知排障写成确定答案 |
+
+## 适用与消歧
+
+适用自主理解、学习 / 备考规划、一次已知任务或检索复用。`科普`、`教程`、`指南`、`攻略`、`学习计划`、`FAQ`、`知识库`、`资源合集`只用于召回;“知识库”是容器或渠道,不决定文章体裁。组织要求多人按批准版本重复执行并留痕走 [`sop-tutorial.md`](sop-tutorial.md);未来设计、API 精确契约、生产状态变更或未知根因走 [`technical-doc.md`](technical-doc.md);研究或数据形成新洞察走 Report。
+
+## 主模式
+
+| 模式 / 读者任务 | 结构推进 |
+|-|-|
+| Explanation / 科普:建立正确心智模型 | 现象 / 误区 → 概念模型 → 机制与证据 → 例子 → 争议、限制与适用边界 |
+| Tutorial:通过受引导练习获得技能 | 学习目标 → 起点 / 环境 → 安全练习 → checkpoint → 复盘与下一步 |
+| Learning plan:在现实约束下持续提高 | 基线诊断 → 可观察的阶段目标 → 练习 / 资料 / 时间 → 完成标准与反馈 → 调整规则 |
+| How-to:完成一个已知目标 | 目标 → 前置 → 最短有效步骤与可观察结果 → 变体 / 已知错误 → 完成验证 |
+| FAQ / known troubleshooting:快速找到已验证答案 | 按真实问题或症状分组 → 直接答案 → 必要条件 / 操作 → 相关内容;需要新假设或根因调查时转 Technical |
+| Resource guide:按标准选择资源 | 使用场景 / 筛选标准 → 分类 → 每项适配、代价与访问条件 → 维护信息 |
+| Reference / KB article:检索并复用事实或解法 | 上下文 / 适用版本 → 事实或 issue-resolution → 限制 / 相关项 → 时效性强时标 owner / last verified |
+
+## 事实、步骤与维护
+
+- 明确受众的已有知识、范围 / 非范围、版本、环境、权限与风险;术语在首次需要时解释,不先灌输完整理论。
+- 学习计划按阶段 / 能力、可用时间、既有任务和可得资料控制强度;目标拆成可观察表现,每阶段合写练习、完成标准、反馈和调整条件。会显著改变安排的缺口先问或条件化,不补造基础 / 时间。
+- 顺序任务一项写一个清楚动作,紧邻给可观察结果;命令、输入、输出和成功验证须能在声明环境中复现,危险或不可逆警告必须在动作前。
+- FAQ 只收真实用户问题或检索需求;否则按用户任务重组。资源指南先写选择标准,再给有描述的精选链接,不用外链代替核心上下文。
+- 时效性内容标适用版本 / 时间并说明维护边界;复杂视觉须有可传达同等信息的正文,图片、案例和代码不得成为无解释的唯一依据。
+- 版本或权限不明时用 `[适用版本待核]`、`[所需权限待确认]` 并只写不受影响部分;未验证命令或链接不进入发布稿。缺口可能造成损失、安全风险或关键分叉时标记 `blocked`。
+
+## 高质量写法
+
+第一屏让读者知道能理解、学会、完成或找到什么;用读者语言、具体动词和可验证结果推进,每节只增加必要的新理解或动作。先给最短可行路径,再在需要处补原理、变体和进一步阅读;示例只服务迁移,不扩张为用户未要求的全套内容,也不用丰富组件掩盖解释不足。
diff --git a/skills/lark-doc/references/genres/route-marketing.md b/skills/lark-doc/references/genres/route-marketing.md
new file mode 100644
index 0000000000..55dc7962c9
--- /dev/null
+++ b/skills/lark-doc/references/genres/route-marketing.md
@@ -0,0 +1,40 @@
+# Genre Contract: Marketing / 营销与公关 (`router.marketing`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| presentation_mode / 表达模式 | `rich`;清楚、有吸引力且可行动,表达强度不得超过承诺、证据和授权,紧迫感不得制造误导 |
+| 内容逻辑 | 先锁定受众、漏斗阶段和唯一主要读者结果;转化内容用“场景 / 问题 → 有边界的价值主张 → 证据 → 关键条件 / 异议 → 一个 CTA”推进,公关稿按已授权事实、相关方影响、组织回应和后续更新推进 |
+| 事实 / 边界 | 所有客观、比较、功效和稀缺性主张发布前有相称证据;价格、资格、期限和限制就近可见;广告身份与商业关系按目标法域 / 平台规则披露;评价、案例、引语、图片和活动素材须真实、可核且获授权,非文字证据须有文字等价信息;核心主张证据、适用法域、发布授权、关键交易条件或任务要求的行动入口缺失时 blocked |
+| 错误 | 无证据的“最佳 / 保证 / 第一”、隐藏限制或自动续费、伪造倒计时 / 库存 / 评价、把广告伪装成独立报道、未经授权承诺赔付或责任、转化内容多个 CTA 争抢、用复杂 block 掩盖价值缺口,任一出现即失败 |
+
+## 适用与消歧
+
+用于组织拥有或授权、目标是认知、转化、留存或公共关系管理的内容。由新闻机构独立选题、核实和报道的内容走 Media;组织自有新闻稿、媒体通稿、品牌声明和回应口径走 Marketing,即使采用新闻结构也不变成独立报道。
+
+个人真实体验用于帮助消费选择时走 Consumer;明确要求最终交付小红书笔记或微信公众号文章时走 `route_platform`,再选择对应 leaf,营销目标、商业关系和交易条件作为该 leaf contract 的硬约束,不再并读 Marketing。出现“新闻稿、软文、活动文案”只作召回信号,仍须确认发布主体、受众、行动和商业关系。
+
+内部营销策划、增长方案或活动执行计划不因“营销”进入本合同:比较打法走 Business Analysis,请求预算 / 资源 / 战役批准走 Proposal,已定打法的协同落地走 Execution Plan;只有最终面向受众的传播、招募或转化成稿走 Marketing。
+
+## 子类型
+
+| 子类型 | 读者任务与推进 |
+|-|-|
+| 广告 / 短文案 | 迅速判断是否值得行动;受众场景 → 单一利益 → 可信理由 → 条件 → CTA |
+| 详情页 / 落地页 | 完成比较与转化;价值主张 → 关键能力 → 证据 → 方案 / 条款 → 异议 → CTA |
+| 活动 / 私域话术 | 判断是否参与并知道下一步;对象 → 收益 → 时间地点 / 门槛 → 风险限制 → 行动 |
+| 新闻稿 / 媒体通稿 | 获取组织已授权消息;可发布事实 → 为什么重要 → 引语 / 背景 → 联系与更新安排 |
+| 声明 / 危机回应 | 理解已知事实和组织行动;事件范围 → 已确认影响 → 当前措施 → 未知项 → 下次更新时间 |
+
+## 证据、授权与合规
+
+- 建立“主张—证据”对应:定量效果说明口径、样本和时间,比较主张保证对象与标准可比;图片、引语、评价和案例保留来源、必要语境及授权记录。
+- 披露和限制应让普通受众在作决定前看见并理解,不能由链接、模糊缩写或弱提示代替。规则随法域、媒介、品类和时间变化,交付前核验当期法律、监管与平台要求。
+- 公关内容只写已获授权的事实和承诺;事故原因、责任、补偿、调查结论未核定时明确 unknown。关键批准或法律审阅未完成,不生成可直接外发版本。
+
+## 结构与高质量写法
+
+价值主张具体到受众、场景和结果,证据紧跟对应主张。文案须锚定品牌独有资产、产品细节或品类语境;换成竞品名仍成立就返工。多版本应改变受众状态、主张、证据或场景并说明选择条件,不做同义改写。
+
+删除不改变理解或行动的品牌空话,不把真实痛点升级为羞耻、身份不足或恐惧操控。有转化目标时,次级入口均服务同一主要行动;优惠资格和截止时间采用可比较字段,待补价格、库存或链接用语义化占位,并让受影响结论保持 blocked。
diff --git a/skills/lark-doc/references/genres/route-media.md b/skills/lark-doc/references/genres/route-media.md
new file mode 100644
index 0000000000..0dee4acf26
--- /dev/null
+++ b/skills/lark-doc/references/genres/route-media.md
@@ -0,0 +1,36 @@
+# Genre Contract: Media / 资讯媒体 (`router.media`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| presentation_mode / 表达模式 | `normal`;准确、中立、紧凑,信息密度服从读者快速理解,不用戏剧化措辞替代事实强度 |
+| 内容逻辑 | 先确定快讯 / 报道、解释、人物特写或访谈的读者任务;关键信息优先,随后给证据、必要背景、相关方视角和仍未知事项,段落按重要性、因果或时间关系推进 |
+| 事实 / 边界 | 区分已核事实、来源说法、推断和 unknown;准确优先于抢发,关键主张可追溯,负面涉及方获得合理回应机会;引语须忠实可核,图片和原始材料须有使用权限、来源、语境说明及文字等价信息,更正、披露和关键缺口须直接可见;核心事实、来源真实性、发布权限缺失,或严重负面指控尚未提供回应机会时保持草稿并 blocked |
+| 错误 | 把组织自有通稿伪装成独立报道、标题超出证据、单一匿名来源承载重大指控、引语失真、事实与评论混写、遗漏重大反方或不确定性、图片无权利 / 来源 / 文字等价信息,任一出现即失败 |
+
+## 适用与消歧
+
+本合同用于以独立采集、核实和公共理解为职责的新闻内容。请求出现“新闻稿、媒体稿、报道”只作召回信号:编辑方能独立核实、选择角度并承担报道判断时走 Media;由组织拥有、批准并面向媒体或公众发布的新闻稿、品牌声明和公关口径走 Marketing。
+
+以立场说服为主走 Opinion;以购买决策和亲身体验为主走 Consumer;内部事实简报不因写得像新闻而改变读者任务。渠道名、标题风格或“像媒体一样写”均不能单独触发;明确要求最终交付小红书笔记或微信公众号文章时走 `route_platform`,再选择对应 leaf,资讯核实边界作为该 leaf contract 的硬约束。
+
+## 子类型
+
+| 子类型 | 读者任务与推进 |
+|-|-|
+| 快讯 / 硬新闻 | 尽快知道发生了什么及其可信程度;核心事实 → 来源与范围 → 必要背景 → 下一确认点 |
+| 解释报道 | 理解为什么发生、如何运作及争议在哪里;问题 → 机制 / 时间线 → 多方证据 → 已知边界 |
+| 人物 / 特写 | 通过可核场景和经历理解人物或议题;场景 → 关键变化 → 证据与他者视角 → 公共意义 |
+| 访谈 / 问答 | 准确获取受访者观点及上下文;交代身份与场景,忠实编辑问答,不补造连接语或立场 |
+
+## 证据与真实性
+
+- 为可能引发争议的事实保留可追溯材料,记录来源身份、接近事实的方式、核实状态和使用限制;匿名只在有公共价值且无法安全具名时采用,并说明读者判断所需的来源范围。
+- 引语逐字可核;压缩、翻译和转述不得改变含义。无法确认的数字、时间、身份或因果就近标明 unknown,不用“据悉”“有消息称”遮蔽来源质量。
+- 开盒、网暴、羞辱、未成年人或其他可能放大伤害的事件只保留理解事实、责任和传播机制所需的最少信息;不为证明热点而复刻身份线索、攻击性内容或未核传言。
+- 更正要说明改了什么;新证据改变核心判断时更新标题和结论。发布前无法核实的核心主张不得靠占位符放行。
+
+## 结构与高质量写法
+
+标题和导语只承诺正文已证明的内容。每段承担一个信息动作,并在首次出现时交代人物、机构、时间和口径;背景只保留改变理解的部分。多方说法按证据权重而非形式上的各打一板排列,不把可验证事实写成“双方观点”,也不把尚无结论写成确定因果。
diff --git a/skills/lark-doc/references/genres/route-opinion.md b/skills/lark-doc/references/genres/route-opinion.md
new file mode 100644
index 0000000000..fb02fffe9c
--- /dev/null
+++ b/skills/lark-doc/references/genres/route-opinion.md
@@ -0,0 +1,38 @@
+# Genre Contract: Opinion / 观点评论 (`router.opinion`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| presentation_mode / 表达模式 | `rich`;立场鲜明但措辞精确、公平,论证密度高于情绪密度,锋利不等于侮辱或夸张 |
+| 内容逻辑 | 明确可争辩的中心判断及其重要性,用理由和证据推进;对有实质争议的主张呈现最强相关反论并回应,结论说明判断边界或行动含义 |
+| 事实 / 边界 | 区分事实、推断、价值判断、预测和个人经验;事实可追溯,证据强度匹配主张强度,不把相关性写成因果或把个案外推为普遍规律;引语、图片和作品片段须有可核来源、必要语境与使用权限,非文字证据须有文字等价信息,重要利益关系须显著披露;关键事实缺失时收窄主张,无法成立则 blocked |
+| 错误 | 只有态度没有论点、稻草人反驳、选择性证据、人格攻击、标题先定罪、把经验冒充统计、隐藏重大反例或利益关系、结论超出论证,任一出现即失败 |
+
+## 适用与消歧
+
+用于帮助读者评估一个判断、立场或解释框架。事件复述和独立核实走 Media;围绕购买选择的测评走 Consumer;组织为行动或转化发声走 Marketing。出现“评论、专栏、观点”只是召回词,正文必须有可辨认的判断和论证任务。
+
+文化评论关注作品、现象的意义和判断;若主要提供剧情复述或故事体验,不走本合同。个人经历可以作为观察入口,但若目标是展示经历与能力,走 Personal Brand。
+
+## 子类型
+
+| 子类型 | 读者任务与推进 |
+|-|-|
+| 时评 / 公共议题评论 | 判断事件意味着什么;争点 → 判断 → 证据与机制 → 反论 → 后果 / 建议 |
+| 商业 / 行业评论 | 评估策略、趋势或制度;基线 → 驱动因素 → 证据 → 替代解释 → 适用条件 |
+| 文化评论 | 理解作品或现象的价值;分析对象 → 解释框架 → 细读证据 → 限度 → 判断 |
+| 专栏 / 随笔 | 从观察或经验形成可迁移洞见;具体场景 → 反思 → 关联 → 有边界的结论 |
+
+## 证据与论证
+
+- 开头尽早写出“我主张什么”和“为什么现在值得讨论”,避免用大段背景延迟论点。每个理由回答一个潜在质疑,并由事实、例子、机制或可靠来源支持。
+- 反论选择真正能动摇中心判断的版本,不挑最弱说法;回应可以承认条件、修改范围或解释为何仍不改变结论。观点平衡不是机械分配篇幅。
+- 公共争议先拆清事实真伪、规则 / 权利、价值取舍、责任归属和 unknown,再分别判断;行动建议须对应具体主体、权限 / 义务、可用杠杆与代价,不用“多方协同”抹平责任边界。
+- 预测写明前提和时间范围;价值判断说明采用的标准。涉及他人动机、违法或伤害的判断不得凭语气升级为事实。
+
+## 结构与高质量写法
+
+段落之间形成“主张 → 理由 → 证据 → 推论”的可追链条,过渡词只标真实关系。文化评论选择一个能统摄正文的主分析轴,把情节、语言、镜头、声音、表演或结构写成“形式选择 → 产生效果 → 支持何种解释 / 评价”的证据链;比较或综述可有多个对象,但不能退化成剧情复述或维度清单,也不把效果直接冒充创作者意图。
+
+结尾不复述全文,而是给出经反论校准后的判断、仍然未知的部分,或读者下一步应重新考虑什么。随笔可弱化显式论证标记,但不能牺牲观察与结论之间的可理解联系。
diff --git a/skills/lark-doc/references/genres/route-personal-brand.md b/skills/lark-doc/references/genres/route-personal-brand.md
new file mode 100644
index 0000000000..84989fafea
--- /dev/null
+++ b/skills/lark-doc/references/genres/route-personal-brand.md
@@ -0,0 +1,36 @@
+# Genre Contract: Personal Brand / 个人品牌 (`router.personal_brand`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| presentation_mode / 表达模式 | `normal`;可信、具体、有辨识度,声音服从目标读者和真实经历,不用自我评价替代成果证据 |
+| 内容逻辑 | 从目标读者和目标机会出发,用“身份 / 价值定位 → 相关经历 → 可验证贡献 → 做事方式 → 下一步意图”组织;每项经历说明情境、本人动作、结果及与目标的关系 |
+| 事实 / 边界 | 职位、时间、职责、学历、技能、作品和指标须真实可核,个人贡献与团队成果分开;尊重保密、个人信息、雇主和作品权利,作品、图片和推荐语须确认归属、使用权限与必要语境,非文字证据须有文字等价信息;关键身份、时间、归属或公开权限缺失时用具体占位,无法安全表述则 blocked |
+| 错误 | 夸大头衔 / 技能 / 指标、把团队成果全归个人、关键词堆砌、伪造推荐语或客户、泄露敏感信息、作品无归属 / 权限、同一经历前后矛盾、渠道语气改变事实,任一出现即失败 |
+
+## 适用与消歧
+
+用于让招聘方、合作方、客户或专业社群判断“这个人是谁、做过什么、能带来什么”。仅出现平台名称不触发;明确要求最终交付小红书笔记或微信公众号文章时走 `route_platform`,再选择对应 leaf,个人身份、经历和信誉目标作为该 leaf contract 的硬约束,不再并读 Personal Brand。
+
+以项目经验得失来改进下一轮走 Retrospective;以购买体验帮助他人选择走 Consumer;以组织身份转化客户走 Marketing。出现“介绍、主页、复盘”不能单独触发,须确认目标是个人能力和信誉呈现。
+
+## 子类型
+
+| 子类型 | 读者任务与推进 |
+|-|-|
+| 简历 / CV | 快速判断岗位匹配;摘要 → 相关经历与成果 → 技能 / 教育 → 必要补充 |
+| 求职信 / 自我介绍 / 简介 | 理解动机与差异化价值;目标 → 相关证据 → 工作方式 → 明确下一步 |
+| 个人主页 | 建立清晰定位并找到入口;一句定位 → 代表证据 → 领域 / 服务 → 联系或作品 |
+| 作品集 / 案例集 | 判断能力如何形成结果;问题 → 约束与本人角色 → 过程决策 → 结果与反思 |
+| 个人成长回顾 | 理解身份与能力变化;起点 → 关键选择 → 证据 → 学到什么 → 下一方向 |
+
+## 证据与真实性
+
+- 成果优先写可核结果及其口径,不能量化时写可观察变化、交付物或他人采用情况,不编造数字。明确“负责、协作、支持、批准”等角色差异。
+- 时间线、组织名、客户名、作品链接和推荐语在公开前确认准确与授权;需匿名时保留问题、本人动作和结果的判断价值,不留下可反推的敏感细节。
+- 技能由近期作品、职责范围或实际使用场景支撑;自我定位可以有主张,但不能使用未获认可的资质、奖项或身份。
+
+## 结构与高质量写法
+
+先筛选与目标读者最相关的经历,不把完整人生经历当作专业证明。经历条目以动作和影响开头,背景只写理解贡献所需的约束;案例说明权衡和本人判断,比工具清单更能证明能力。CTA 具体到希望发生的下一步,并只提供获授权的联系方式。
diff --git a/skills/lark-doc/references/genres/route-platform.md b/skills/lark-doc/references/genres/route-platform.md
new file mode 100644
index 0000000000..86bcbabed8
--- /dev/null
+++ b/skills/lark-doc/references/genres/route-platform.md
@@ -0,0 +1,8 @@
+# Genre Router: Platform / 平台发布稿 (`route_platform`)
+
+仅当最终交付物是小红书笔记或微信公众号文章时进入本 router;按目标平台选择且只读取一个 leaf。仅把平台作为研究对象、信息来源或业务渠道时不触发;双平台成稿分别路由和生成。
+
+| 关键词 | Leaf |
+|-----------|------------------------------------|
+| XHS、小红书 | [`xiaohongshu.md`](xiaohongshu.md) |
+| 微信、wechat | [`wechat.md`](wechat.md) |
diff --git a/skills/lark-doc/references/genres/route-report.md b/skills/lark-doc/references/genres/route-report.md
new file mode 100644
index 0000000000..3e88d40b04
--- /dev/null
+++ b/skills/lark-doc/references/genres/route-report.md
@@ -0,0 +1,10 @@
+# Genre Router: Report (`router.report`)
+
+用数据、样本或研究形成洞察走本类;按读者任务选择且只读一个 leaf,关键词仅用于召回,`报告 / 分析 / 研究 / 数据 / 白皮书`单独不决定路由。组织执行 / 批准走 Workplace,自主学习走 Knowledge。
+
+| 读者任务 / 关键词、强信号与排除 | Leaf |
+|-|-|
+| 回答明确研究问题,方法、样本和可推广边界决定可信度;调研报告、访谈 / 问卷 / 用户研究。仅解读既定指标时排除 | [`research-report.md`](research-report.md) |
+| 解读已定义指标、趋势、分布、漏斗或实验观察值;数据报告、经营数据、指标复盘。需重新设计样本回答问题时排除 | [`data-report.md`](data-report.md) |
+| 让专业读者系统理解并评估问题、框架或方案;白皮书、行业框架、技术 / 政策议题。获客、产品卖点或 CTA 为主时排除 | [`white-paper.md`](white-paper.md) |
+| 比较战略、投资、市场、产品或资源选项及成本、收益、风险;商业分析、可行性、进入 / 自建或采购判断。正文要求具名决策者选择 / 批准,或形成授权、资源拨付、执行承诺入口时排除 | [`business-analysis.md`](business-analysis.md) |
diff --git a/skills/lark-doc/references/genres/route-workplace.md b/skills/lark-doc/references/genres/route-workplace.md
new file mode 100644
index 0000000000..ae17c76e8b
--- /dev/null
+++ b/skills/lark-doc/references/genres/route-workplace.md
@@ -0,0 +1,17 @@
+# Genre Router: Workplace (`router.workplace`)
+
+组织内决策、执行、留档走本类;先按读者任务与生命周期选择且只读一个 leaf,关键词仅用于召回,排除信号优先于同名词。
+
+| 读者任务 / 关键词、强信号与排除 | Leaf |
+|-|-|
+| 快速知悉、短判断或会前准备;备忘录、决策摘要、会前材料。完整批准论证走 Proposal,周期状态走 Weekly | [`memo-brief.md`](memo-brief.md) |
+| 按周期判断相对承诺的状态、偏差、风险和下一步;周报、日报、月报、项目状态。原因学习走 Retrospective,完整分析走 Report | [`weekly-report.md`](weekly-report.md) |
+| 请具名决策者批准方向、预算、资源或执行承诺;提案、立项、资源申请。已定产品行为走 PRD | [`proposal.md`](proposal.md) |
+| 将方向已定的一次性项目、变更、专项行动或营销战役转成可协同推进的交付、依赖、里程碑与验收;项目计划、执行方案、实施计划。仍在比较方向或请求批准走 Proposal / Report,重复稳定路径走 SOP | [`execution-plan.md`](execution-plan.md) |
+| 将已授权的内部规则 / 安排、可复核的检查整改记录或已核定组织立场写成正式载体;制度、公司通知、整改记录、讲话底稿。待批准方向走 Proposal,复杂执行走 Execution Plan,法定公文走 Official;`正式`单独不触发 | [`formal-doc.md`](formal-doc.md) |
+| 党政机关法定公文拟制、审校或制发;明确要求公文 / 红头 / 套红 / 正式发文,或法定文种与机关行文关系、文号、主送等制发要素共同出现。`通知 / 报告 / 公告 / 纪要 / 正式 / 官方`单独不触发 | [`official-redhead.md`](official-redhead.md) |
+| 记录已发生会议的决定、异议、行动和批准状态;会议记录、行动项。逐字稿不走本 leaf,法定公文纪要走 Official | [`meeting-minutes.md`](meeting-minutes.md) |
+| 从已结束周期 / 事件提炼证据化学习并改变下一轮;复盘、回顾、经验教训。当前状态走 Weekly,活跃未知事故走 Technical | [`retrospective.md`](retrospective.md) |
+| 方向已定,定义用户问题、范围、产品行为与验收;PRD、用户故事、验收标准。是否投入走 Proposal,实现取舍走 Technical | [`prd.md`](prd.md) |
+| 评审未来技术设计、查询精确契约或调查未知故障;RFC、API、架构、事故调查。产品行为走 PRD,已定重复路径走 SOP | [`technical-doc.md`](technical-doc.md) |
+| 按已批准、可验证路径重复达到终态,或为已知事件类别预置响应路径;SOP、runbook、值班 / 操作手册、BCP / 处置预案。应急预案若主要发布权威职责走 Formal,法定制发走 Official,活跃未知事故走 Technical,一次学习教程走 Knowledge | [`sop-tutorial.md`](sop-tutorial.md) |
diff --git a/skills/lark-doc/references/genres/sop-tutorial.md b/skills/lark-doc/references/genres/sop-tutorial.md
new file mode 100644
index 0000000000..8e77fa4266
--- /dev/null
+++ b/skills/lark-doc/references/genres/sop-tutorial.md
@@ -0,0 +1,41 @@
+# Genre Contract: SOP / Runbook (`workplace.sop_tutorial`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| presentation_mode / 表达模式 | `normal`;命令式、具体、顺序稳定,一步一动作并紧邻可观察判据,不写无条件的“适当 / 必要时” |
+| 内容逻辑 | 先定 routine / controlled / high-risk,并识别是否为响应预案,再按“版本 → 触发 / 范围 / 终态 → 角色 / 前置 → 动作 / 判据 / 证据 → 异常 / 停止 / 恢复 → 完成记录 / 复审”推进 |
+| 事实 / 边界 | owner、版本、环境、资格、权限、工具、命令、阈值、预期结果和恢复路径均须已验证;警告在动作前;命令成功不等于业务终态;流程图 / 示意不能替代可执行步骤、判据与异常路径,须有文字等价;关键未知使可发布稿 `blocked` |
+| 错误 | 教程冒充 SOP、未分风险、缺 owner / 版本 / 前置、一条多动作、编造入口 / 阈值 / 权限 / 命令、停止后状态未知、只写“必要时回滚”或未验证终态;响应预案无分级触发、替补指挥、降级路径或解除条件,任一出现即失败 |
+
+## 适用与风险分类
+
+用于组织规定的重复作业、沿已批准路线取得确定终态的 runbook,或针对已知事件类别预置并授权的响应 / 业务连续性路径。一次性自助 how-to / 学习走 Knowledge;未来设计取舍、活跃未知故障或临场根因调查走 `technical-doc.md`;只建立组织权威、职责或发布要求而不提供现场步骤走 `formal-doc.md`。“教程 / 操作 / 手册 / 应急预案”单词本身不触发。
+
+| 分类 | 增量证明义务 |
+|-|-|
+| `routine` | 阶段或终态验证、常见异常和升级 |
+| `controlled` | 再含审批、接受 / 拒绝、偏差记录、变更复审和代表性试跑 |
+| `high-risk` | 再含 precheck、hold point、go / no-go、停止条件,以及可执行 rollback / fallback / roll-forward 和恢复验证 |
+
+## 响应预案增量
+
+- 涉及人身安全或法定直报时,其优先级高于业务与财产;按已核风险设置进入、升级、降级和解除条件,明确指挥 / 决策权限、替补角色、首轮动作、信息报送与对外口径边界。联络序列、等待时长和重试次数须预先批准;未知时保留占位,仅放行无需等待授权的安全动作。
+- 预设负责人失联、断网断电、主资源不可用等降级场景及可达的安全终态;恢复须验证真实业务终态。发布前按风险做桌面推演或代表性演练,高风险场景包含故障注入并记录缺口、owner 和复验。
+
+## 文控与证据
+
+写明触发、目标终态、范围、owner / 资格、当前版本 / 环境、前置、权限、工具和输入。命令、参数、阈值、预期输出、备份 / 恢复资产和试跑结果须来自真实环境;流程变更后更新、复审并标 superseded 状态。
+
+关键缺口就近使用`[待环境 owner 验证]`等具体占位。命令、权限、阈值、停止或恢复判据未知时只保留安全只读 precheck,不得发布可执行稿,`Publish Gate = blocked`。
+
+## 步骤、异常与恢复
+
+- 每个关键步骤只写一个动作,紧邻可观察结果、阈值与证据;验证需要操作时另列一步。未知偏差停止于已知安全状态,记录证据并升级。
+- high-risk 在不可逆动作前设置 hold point:列 go / no-go 信号、决策人和信号缺失时的安全终态。rollback 写触发条件、适用范围、步骤、阈值、停止点和恢复后业务验证,不能只写命令回执。
+- 有状态迁移另列不可逆点、写入归属、checkpoint / 幂等,以及完整、无重复、有序或等价验证;关闭 fallback 前必须证明新终态稳定。
+
+## 高质量写法
+
+让具备规定基础资格但不熟流程的人可独立复现;选择条件写在动作前,稳定原理链接出去,不混入原理课或临场诊断。按风险裁剪篇幅但不删证明义务;按适用治理要求由代表性执行者试跑,未经任何实际验证不得发布。
diff --git a/skills/lark-doc/references/genres/technical-doc.md b/skills/lark-doc/references/genres/technical-doc.md
new file mode 100644
index 0000000000..f627b33a9e
--- /dev/null
+++ b/skills/lark-doc/references/genres/technical-doc.md
@@ -0,0 +1,38 @@
+# Genre Contract: Technical Document / 技术文档 (`workplace.technical_doc`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| presentation_mode / 表达模式 | `rich`;精确、可证伪、术语与版本稳定;在有明确内容作用时用代码、表格、架构 / 状态 / 时序图和其他 rich block 降低实现与诊断成本,但不让视觉组件替代契约、证据或操作说明,规范词仅在明确采用的互操作 / 安全 / 验收语义中使用 |
+| 内容逻辑 | 必须且只能选 design_rfc、api_reference、incident_diagnostic 一种主模式;分别按“证据 → 取舍 / 设计 → 验收”“契约 → 错误 / 兼容”“影响 → 假设 / 检查 → 验证 / 升级”推进 |
+| 事实 / 边界 | 标对象、环境、版本、时间、范围和证据窗;事实、推断、决定、未知分开;示例 / 图不替代契约;任何改状态动作须有授权、影响、停止、还原和恢复验证,关键缺口按 reader impact 处理 |
+| 错误 | 按关键词路由、三模式混写、设计无取舍 / 验收、reference 漏权限 / 错误 / 生命周期 / 兼容、未知故障直接定根因、改状态无授权 / 停止 / 还原或图作唯一证据,任一出现即失败 |
+
+## 先选唯一主模式
+
+| 主模式 | 读者任务 | 排除 |
+|-|-|-|
+| `design_rfc` | 评审者能批准并实现未来技术状态,理解替代、后果和验收 | 产品可观察行为走 PRD;既定路径走 SOP |
+| `api_reference` | 调用者无需猜版本、权限、输入、行为、副作用、错误与生命周期 | 仍在讨论接口取舍时走 design_rfc |
+| `incident_diagnostic` | 响应者以安全、有区分度的动作缩小未知、止损、恢复或升级 | 单纯团队学习走 Retrospective;已知重复处置走 SOP |
+
+## 共同证据边界
+
+标明对象、环境、版本、时间、范围 / 前置和证据位置 / 窗口;结论回链仓库、IDL / schema、日志、metrics、traces、变更记录或验证实验。缺口就近使用具体占位、收窄或 `blocked`;数据分级、访问、保留、重放、owner、时限和升级只在适用时形成门禁。
+
+代码与命令示例须实际验证并标环境 / 版本;架构、状态或时序图必须附文字等价,不能成为唯一证据或唯一操作说明。
+
+## Design RFC
+
+按问题证据 → 目标 / 非目标 → 约束 / 不变量 → 真实备选与同口径取舍 → 接口 / 数据 / 状态设计 → 失败、安全、兼容与迁移 → 上线 / rollback → 可观测性、测试 / 验收 → 未决决定推进。每项关键决定写 why、被否方案及后果;不得隐藏低置信度或版本偏差。
+
+## API Reference
+
+写清版本 / 环境 / 权限 / 签名、输入约束、行为 / 副作用 / 幂等、输出、已知错误及可操作恢复、限流 / 分页 / 重试、兼容 / 弃用。事件、异步、CLI、SDK、流式按需补 channel / message、交付 / 顺序、生命周期 / 耗尽、I/O、取消与背压;未知语义明确 unspecified,不从示例推断承诺。
+
+## Incident Diagnostic
+
+按影响与 expected / actual → 当前状态与证据链 → 可证伪假设 → 信息增益高且副作用低的检查 → 止损 / 恢复验证 → 升级与后续 RCA 推进。每项检查写预期观察及其支持 / 排除的假设。
+
+修改状态前必须确认授权、目标范围、潜在副作用、停止条件、还原路径和恢复判据;分开止损、根因与永久修复。缺证据、授权、owner、还原或升级路径时只给安全只读检查并 `blocked`;涉及安全 / 法务时先保全证据和升级。
diff --git a/skills/lark-doc/references/genres/wechat.md b/skills/lark-doc/references/genres/wechat.md
new file mode 100644
index 0000000000..bd838ea5f0
--- /dev/null
+++ b/skills/lark-doc/references/genres/wechat.md
@@ -0,0 +1,38 @@
+# Genre Contract: WeChat Official Account / 微信公众号文章 (`platform.wechat`)
+
+## 核心定位(硬约束)
+
+- 交付物是飞书文档中的“微信公众号风格”内容稿,不代表实际发布,也不执行微信平台审核、流量、商业或发布规则。
+- `presentation_mode` 使用 `rich`:可信、有观点、有叙事或论证推进,在专业感与亲近感之间保持平衡。公众号不是加长版小红书,也不是公文或报告换皮。
+- 一篇只服务一个读者任务和一个可兑现承诺;标题、封面、摘要、导语、正文与结尾围绕同一主线。不编造亲历、身份、数据、引语、案例或效果;无来源时不用“多数、普遍、研究表明”等统计口吻,材料不足时明确收窄表达。
+- 飞书源稿禁止使用 `callout`;生成后通过 Draft Parse Gate 的 `profile.blocks` 检查,其他 block 按真实信息关系选择。
+
+## 适用与消歧
+
+用户明确要“微信公众号文章、公众号推文、微信长文、微信爆文、公众号风格”时使用,内容保存在哪里不影响本合同生效。
+
+普通微信聊天消息、群公告、朋友圈文案、视频号口播、小程序页面和服务通知不走本合同。仅把微信作为研究对象、信息来源或业务渠道时也不触发;若同时要公众号稿和正式体裁,分别生成,不混写。
+
+## 内容模式
+
+| 模式 | 内容脊柱 |
+|-|-|
+| 知识 / 方法 | 读者处境 → 核心原理 / 结论 → 方法与验证 → 成本、例外和适用边界 → 可执行认识 |
+| 观点 / 解释 | 现象或争点 → 中心判断 → 理由、证据与机制 → 相关反论 / 边界 → 校准后的结论 |
+| 资讯 / 热点 | 已确认事实 → 为什么重要 → 必要背景与多方信息 → 争议 / 未知 → 当前结论或更新点 |
+| 案例 / 故事 | 具体场景 → 选择与行动 → 可观察结果 → 代价 / 失误 → 可迁移洞见 |
+| 品牌 / 行动 | 读者场景 → 有边界的价值 → 证据 / 体验 → 条件与取舍 → 清楚结论 |
+
+## 成稿要求
+
+- 先钉住具体读者、核心问题与中心判断;内部比较信息清晰型、问题 / 冲突型、观点浓缩型标题,成稿只输出既有张力又不透支正文的一个。
+- 标题负责建立准确预期;摘要按需补充关键背景、判断或阅读收益,不复述标题。摘要、导语和首节必须各有信息增量。封面只保留一个视觉中心,图片文案不制造第二个主题。
+- 导语在首屏内用具体场景、问题、变化或判断说明“为什么值得读”,随后尽快进入主线,不用宏大背景、客套话或悬念拖延核心信息。
+- 正文沿一条逻辑线展开,小标题概括本节增量。段落各有一个主要意思,但长短随内容变化:重点句可独立成段,证据、故事和推理要保留完整上下文,避免短句过多造成逻辑断裂。
+- 使用自然、可交流的书面语;用具体细节、例子、转折和取舍形成作者声音,不靠网络热词、排比口号或统一句式制造“爆文感”,避免连续复用同一反转句式。
+- 完整稿至少给出一个封面或正文视觉方案;已有图片时就近用于提供证据、解释信息、建立场景或调节长文节奏。图片不设固定数量,也不为“图文并茂”强塞装饰图,正文仍须独立可读。
+- 结尾回扣开头问题或中心判断,留下结论、影响或自然的下一步;互动句、emoji 和话题标签均按需使用,不要求固定收尾动作。
+
+## 交付前检查
+
+确认标题没有透支正文,摘要与导语没有重复,文章主线连续,每节都在推进事实、故事、论证或方法,手机上容易扫读但不过度碎片化,图片确实帮助理解,且没有空洞口号、标题党、模板腔或虚构事实。
diff --git a/skills/lark-doc/references/genres/weekly-report.md b/skills/lark-doc/references/genres/weekly-report.md
new file mode 100644
index 0000000000..9d1bac0f04
--- /dev/null
+++ b/skills/lark-doc/references/genres/weekly-report.md
@@ -0,0 +1,24 @@
+# Genre Contract: Weekly / Status Report (`workplace.weekly_report`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| presentation_mode / 表达模式 | `normal`;具体、短、面向判断,稳定使用最小字段与状态语义,不用“持续推进”代替产出 |
+| 内容逻辑 | 围绕报告对象和周期,按“总体状态 / 最大变化 → 对照基线的产出 → 偏差 / 风险 / 依赖 → 下一里程碑 → ask”推进,只写影响判断的变化 |
+| 事实 / 边界 | 状态须回链范围、时间、质量、成本、资源或阻塞证据;事实、当前状态和下期计划分开;无基线或数据不足时写 unknown,不猜完成率、原因、owner 或日期 |
+| 错误 | 活动流水账、无周期 / 基线、健康色无判据、风险被埋、猜测根因冒充事实、下一步无里程碑、ask 不可执行或自动汇总不可追,任一出现即失败 |
+
+## 适用与消歧
+
+用于按固定或约定周期判断当前相对目标 / 计划 / 承诺的位置。解释已结束周期为何如此并改变下一轮走 `retrospective.md`;完整指标洞察走数据报告;一次性高层知会走 `memo-brief.md`。“报告 / 进展”单词本身不触发本体裁。
+
+## 状态与证据
+
+- 标明报告对象、周期 / 截至时间;进展使用已验收产出、里程碑或有口径指标,会议数、沟通和投入时长本身不等于进展。
+- On track / 红黄绿等状态须有预先定义或就近说明的判据。无基线时明确“无法判断是否按计划”,而不是默认绿色。
+- 风险、问题、依赖和阻塞按已知程度写影响、当前缓解、责任方与升级需求;冲突数据并列保留并标`[口径待核]`。
+
+## 结构与高质量写法
+
+个人短更新可收缩,项目群 / 月报可按需增加趋势、成本或预算,但不复制无用栏目。优先写相对上期和相对承诺的 delta;稳定低风险项可链接原记录。ask 写明对象、事项和需要时间,关键数据延迟时说明最近可用时间点及其判断影响。
diff --git a/skills/lark-doc/references/genres/white-paper.md b/skills/lark-doc/references/genres/white-paper.md
new file mode 100644
index 0000000000..c9bba8ded3
--- /dev/null
+++ b/skills/lark-doc/references/genres/white-paper.md
@@ -0,0 +1,32 @@
+# Genre Contract: White Paper / 白皮书 (`report.white_paper`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| presentation_mode / 表达模式 | `normal`;系统、清楚、克制;权威来自真实主体、证据与归属,不来自篇幅、正式腔或视觉复杂度 |
+| 内容逻辑 | 先确认白皮书类型、发布主体、专业读者和期望判断,再用证据建立问题、评价标准或框架、论证、反例及应用边界;框架必须实际解释或比较 |
+| 事实 / 边界 | 客观主张连接真实来源、时点、范围和限制;事实、解释、价值判断、提议与品牌立场可区分;政策身份、发布状态、利益、资助和案例选择不得虚构或隐匿;证据图与材料须确认使用权、来源和说明,复杂视觉附文字等价信息 |
+| 错误 | 政策与品牌身份混写;标题或版式伪造权威;宏大背景填篇幅;自创框架仅作装饰;来源不可追;单一案例冒充共识;忽略反证 / 利益冲突;CTA 吞没证据;复杂组件代替论证 |
+
+## 适用与消歧
+
+让专业读者系统理解并评估问题、框架或解决路径。先区分有权主体的政府政策白皮书与专业、技术或品牌资助白皮书;`白皮书`、`正式`、`权威`单独不产生政府或标准身份。明确研究问题和方法为核心走 [`research-report.md`](research-report.md),特定组织选项决策走 [`business-analysis.md`](business-analysis.md),设计 / RFC / 接口契约走 Technical,产品卖点、获客或 CTA 为主走 Marketing。
+
+## 子类型
+
+- **政府政策白皮书**:只有真实有权主体可使用;准确标政策、咨询、立法与发布状态,不模拟批准或法律效力。
+- **政策 / 专业问题白皮书**:围绕问题、证据、评价标准、方案和影响形成可审查论证。
+- **技术 / 行业 landscape 白皮书**:解释技术、标准或系统框架;一旦主要任务是批准实现设计或查询精确契约,改走 Technical。
+- **品牌资助白皮书**:证据评估仍须是主体任务;披露资助、产品利益和案例选择,转化内容与论证分层。
+
+## 证据与边界
+
+- 开头明确作者 / 发布主体、读者、使用场景、范围、文档状态、核心立场和期望判断。
+- 主张强度匹配证据层级;有限测试、相关观察、厂商数据或单一案例不得扩写成绝对承诺或行业共识。
+- 框架的每一层都须增加解释、比较或选择价值;问题原因、评价标准与方案逻辑相连,并处理重要反证、替代解释和可行性限制。
+- 主体或授权不明时用 `[发布主体待确认]`,不得写成政府、官方或标准;核心证据不足时收窄为 concept note / outline。利益关系或关键政策状态无法确认的发布稿标记 `blocked`。
+
+## 结构与高质量写法
+
+独立摘要(主体、论点、证据边界) → 问题与现有证据 → 评价标准或核心框架 → 逐层论证、方案与反例 → 应用 / 政策含义及条件 → 限制、利益关系与来源。摘要让忙碌读者复述主张和保留条件;长篇才增加目录或附录,不以背景、封面、缩写或组件制造权威感。
diff --git a/skills/lark-doc/references/genres/xiaohongshu.md b/skills/lark-doc/references/genres/xiaohongshu.md
new file mode 100644
index 0000000000..3b09c30960
--- /dev/null
+++ b/skills/lark-doc/references/genres/xiaohongshu.md
@@ -0,0 +1,37 @@
+# Genre Contract: Xiaohongshu Note / 小红书笔记 (`platform.xiaohongshu`)
+
+## 核心定位(硬约束)
+
+- 交付物是飞书文档中的“小红书风格”内容稿,不代表实际发布,也不执行小红书平台审核、禁词、流量或商业规则。
+- `presentation_mode` 使用 `rich`:鲜活、有节奏、有画面感。小红书风格偏爱 emoji、图文并茂和清晰轻松的阅读体验,但装饰不能代替内容。
+- 一篇只解决一个主要问题;标题、封面、首屏和正文围绕同一获得感并真正兑现。不编造亲历、身份、数字、效果或用户反馈,材料不足时用第二人称、场景化讲解或中性叙述。
+- 飞书源稿禁止使用 `callout`;生成后通过 Draft Parse Gate 的 `profile.blocks` 检查,其他 block 按真实信息关系选择。
+
+## 适用与消歧
+
+用户明确要“小红书笔记、小红书写法、小红书 style、红书感、XHS 风格”时使用,内容保存在哪里不影响本合同生效。
+
+仅把小红书作为研究对象、数据源或业务渠道时不触发:小红书运营方案走 Workplace,平台数据或竞品分析走 Report,规则说明走 Knowledge。若同时要小红书风格稿和正式体裁,分别生成,不混写。
+
+## 笔记主任务
+
+| 主任务 | 内容脊柱 |
+|-|-|
+| 教程 / 攻略 / 知识 | 痛点场景 → 核心判断 → 分步做法 → 易错点 / 限制 → 马上可做的一步 |
+| 体验 / 测评 / 探店 | 使用场景 → 具体观察 → 亮点与槽点 → 适合谁 / 不适合谁 → 选择建议 |
+| 观点 / 热点 | 争议或反差 → 核心判断 → 理由与例子 → 另一面 / 边界 → 留给读者的问题 |
+| 个人经历 / 成长 | 真实困扰 → 转折瞬间 → 做过什么 → 可观察变化 → 可迁移认识 |
+| 推荐 / 种草 / 活动 | 目标人群与场景 → 核心价值 → 具体理由 / 体验 → 使用条件与取舍 |
+
+## 成稿要求
+
+- 先钉住具体读者、场景与获得感;内部比较搜索清晰型、痛点共鸣型、反差好奇型 3 个标题,成稿只输出正文能兑现的最强一个。
+- 首屏用 1—3 个短段落完成“具体场景 / 冲突 → 核心判断 → 内容预告”,不从宏大背景或自我介绍讲起。
+- 正文用短段落和有意义的小标题按信息增量推进;每节新增动作、观察、例子、判断或限制。“活人感”来自具体细节、选择和取舍,不靠强塞网感词。
+- emoji 可比正式体裁用得更积极,用于导航、语气和停顿,但不连续堆叠。围绕一个视觉中心设计封面,图片 / 截图 / 示意图就近服务对应内容;无可用图片时给出简短配图建议,正文仍须独立可读。
+- 核心主题词自然出现在标题或首屏,相关表达按需进入小标题和正文;话题标签少而相关,不为覆盖关键词而复读。
+- 结尾用一句记忆点收束;互动问题可选且至多一个,不要求固定收尾动作。
+
+## 交付前检查
+
+确认读者能一眼判断“这和我有关”,标题承诺已兑现,每节都有实质信息,手机上容易扫读,emoji 与图片确实帮助理解。出现公文腔、长铺垫、文字墙、题文错配、空情绪或虚构事实时返工。
diff --git a/skills/lark-doc/references/lark-doc-authoring.md b/skills/lark-doc/references/lark-doc-authoring.md
new file mode 100644
index 0000000000..23e34e46a3
--- /dev/null
+++ b/skills/lark-doc/references/lark-doc-authoring.md
@@ -0,0 +1,103 @@
+# Lark Doc Authoring
+
+本文件定义从零创作,以及对已有正文进行改写、润色、重组、补写和排版的流程。根 `SKILL.md` 负责场景与格式路由;本文件负责内容判断;格式文件定义表达语法;`create` / `update` 定义写入操作。
+
+## Philosophy
+
+文档是为读者服务的信息传递,不是作者的自我表达。唯一标准是:读者能否以最低成本获取并正确理解。
+
+- **读者本位**:落地前先回答:读者是谁、为什么要读、带着什么任务来。按读者的任务组织内容,不按功能或作者视角罗列。
+- **结构先行**:结论先行,先整体后局部;按逻辑分组与递进,依据关系选择列表、步骤或表格,使内容便于扫读。(特殊体裁除外)
+- **极简表达**:默认使用能清楚表达关系的最简单形式;不损失信息地压缩文字;删冗余,用短句、动词和数据,在文字难以说清流程、交互或层级时用图。
+
+## Hard Rules
+
+以下规则约束所有阶段;具体路由、证据状态和组件要求在对应步骤展开。
+
+1. **约束栈**:只采用 Prepare 选择的一个 content contract;需要平台原生写法时可追加一个 platform adapter。事实、法规与安全边界 > 用户硬约束 > 读者任务 > content contract > platform adapter > Presentation;后项不得牺牲或放宽前项。
+2. **事实边界**:重要主张必须按 Prepare 的证据状态处理,不得把来源主张、推断或缺口写成已核验事实。
+3. **表达一致**:同一对象、动作和状态保持同名;用户给出样例或已有文档时,在不违反更高优先级规则的前提下延续其有效结构、语气、术语和编号。**如使用编号或标题层级,全文保持一套一致体系,同级不跳号、不跳级,不混用互不兼容的编号体系**。
+4. **授权与保真**:编辑时只改授权范围,保留其余内容和资源;除非用户明确要求重建,不用 `overwrite` 代替局部修改。
+
+## Step Plan
+
+**CRITICAL:按当前场景严格执行 `Prepare → Draft → Deliver`;条件式步骤仅在命中时执行。**
+
+### Prepare
+
+1. 明确读者任务、交付形态、文档生命周期、硬约束和禁区。编辑已有文档时,按 [`lark-doc-fetch.md`](lark-doc-fetch.md) 读取最小充分范围,并保留所有不在本次授权修改范围内的内容和资源。
+2. 按下方 Route Template Index 选择且只读取一个 content contract;需要特定发布平台的原生写法时,再追加且只读取一个 platform adapter。混合内容意图作为 contract 的约束,不并读多个 contract;多平台交付共享 contract 与事实基础,但分别执行后续流程。
+3. 盘点材料,只使用以下四种证据状态;不得把后三种写成已核验事实:
+ - **已核验事实**:本任务已核对直接支持该陈述的可定位证据,而不只是确认某来源这样说;证据的时间 / 版本、范围和口径须匹配陈述。
+ - **来源主张**:本任务只能确认用户或材料做出该陈述,尚未核对其真实性;正文保留来源归属。
+ - **推断**:由已列明的事实或来源主张推出;正文能指出前提,结论强度不得超过前提。
+ - **缺口**:证据缺失、冲突、过时或依赖未经确认的假设,导致关键输入或结论未定;能安全条件化或标注时继续,否则 `blocked`。
+ 时效性强、高风险或用户明确要求核验的信息,作为事实使用前必须核验来源、时间和口径;无法核验时降为来源主张或缺口,不得静默补全。
+4. 形成内部 `Authoring Brief`,作为进入 Draft 的前置输入。Brief 可以只写一小段,但必须覆盖:
+ - **目标**:目标读者、读者任务和期望的读后变化。
+ - **内容**:所选 content contract、可选 platform adapter、核心承诺、内容脊柱,以及关键事实、证据和缺口。
+ - **边界**:用户硬约束、必写 / 不写内容、授权范围、保真项和安全禁区。
+
+### Draft
+
+1. 按 `Authoring Brief`、所选 contract 和可选 adapter 完成格式中立的内容稿。先写清核心命题、关键事实与细节、各部分推进关系和真实取舍;每一节都应增加新的理解、证据、场景、判断或行动。内部记录不能代替缺口在读者可见正文中的实际处理。
+2. 先修订结构、论证 / 叙事、信息密度和段落衔接,再精简套话、重复、模糊形容和来源堆砌;格式与组件不得反向改变内容判断。
+3. 按根 `SKILL.md` 的格式规则选择 XML 或 Markdown,并只读取一个格式参考;未选择的格式文件不得读取。
+4. 形成只包含 `presentation_mode` 的 `Presentation Decision`,再按该 mode、所选 contract 与可选 adapter 生成 release candidate;不预先声明或记录 blocks,单份 release candidate 禁止混用格式。`presentation_mode` 继承 content contract,且只能使用以下三种值;platform adapter 可替换非 `formal` 的 mode,但不得放宽 contract 的准确性、证据、安全、语气或组件限制。`formal` 只能由明确规定正式结构与组件边界的 content contract 提供,不能由 adapter 单独升级。mode 不决定 XML / Markdown,也不产生法律或发布效力。
+ - **`formal`(表达非常正式)**:庄重、准确、简洁、直接,结构与措辞服从正式规范;只使用 contract 明示允许或限用的 block,不以 rich block、颜色或装饰制造正式感。
+ - **`normal`(正常)**:清楚、可信、自然,按读者任务选择叙述、步骤、状态、责任和判据;默认使用最简单的有效结构,表格、代码、清单、画板等仅在降低理解、执行、出错或验收成本时使用。
+ - **`rich`(表达非常丰富)**:允许更鲜明的声音、节奏、场景和视觉层次,但不得削弱事实与边界;在约束允许且有明确内容作用时,鼓励使用 `img`、`whiteboard`、`callout`、`grid` 等 rich block 丰富体验,不设数量配额。
+ 不因“更克制”“更活泼”或视觉丰富度的细分新增 mode;这些差异由 content contract 与可选 platform adapter 约束。
+5. release candidate 生成后,执行 Draft Parse Gate,并结合返回的 profile 按下方质量检测表检查当前稿件。
+ - release candidate 已有草稿文件时,直接执行 `lark-cli docs +script --command parse --content "@<草稿相对路径>" --format json`。
+ - 命令必须成功,并返回 `data.profile.word_count`、`data.profile.char_count`、`data.profile.block_count` 和 `data.profile.blocks`。Parse Gate 只提供语法、基础统计和实际 block 清单,不代表前端视觉验收,也不代替质量判断。
+ - 解析失败或质量检测未通过时,能用当前材料修复的直接修订;缺少关键事实、材料、授权或用户选择时标记 `Publish Gate = blocked`。任何正文变化都必须重新执行 Draft Parse Gate 和质量检测。
+6. 只有最新 release candidate 解析成功、用户硬约束与质量检测全部通过,且检查后正文未再变化时,才标记 `Publish Gate = ready`。默认只保留必要的检查证据和未关闭问题,不生成或展示固定格式的内部报告。
+
+### Deliver
+
+1. 只有 `Publish Gate = ready` 时才写入;新建读取 [`lark-doc-create.md`](lark-doc-create.md),编辑读取 [`lark-doc-update.md`](lark-doc-update.md)。离线草稿不写入。
+2. 按 create / update 的规则执行写入和传输验证。仅重试同一 release candidate 的传输时无需重复内容检查;任何正文变化都必须返回 Draft 重新校验。
+3. 最终只交付用户需要的结果,以及会影响使用的来源、未关闭缺口、失败 / 阻塞原因、异常和文档 URL / token。
+
+## Route Template Index
+
+先按读者的主要任务选择唯一 content contract,再按需追加一个 platform adapter。contract 决定内容任务、证据和体裁边界;adapter 只调整与其兼容的平台结构、语气和组件,不得替换或放宽 contract。两者的组件限制合并生效,禁止或限用条件取更严格者;仅提到、研究或分析某平台不触发 adapter。
+
+### Content routes
+
+| 文件名 | 主要读者任务 |
+|-|-|
+| [`route-workplace.md`](genres/route-workplace.md) | 组织决策、执行、留档 |
+| [`route-report.md`](genres/route-report.md) | 数据 / 研究形成洞察 |
+| [`route-knowledge.md`](genres/route-knowledge.md) | 理解、自学、一次操作 |
+| [`route-media.md`](genres/route-media.md) | 告知公共事实 / 事件 / 人物 |
+| [`route-opinion.md`](genres/route-opinion.md) | 形成并论证判断 |
+| [`route-consumer.md`](genres/route-consumer.md) | 真实体验辅助选择 |
+| [`route-marketing.md`](genres/route-marketing.md) | 建立信任并推动行动 |
+| [`route-personal-brand.md`](genres/route-personal-brand.md) | 本人经历、能力、作品 |
+| [`route-creative.md`](genres/route-creative.md) | 角色、冲突、情节 / 分支 |
+
+### Optional platform adapter
+
+| 文件名 | 主要读者任务 |
+|-|-|
+| [`route-platform.md`](genres/route-platform.md) | 按目标平台形成可发布成稿 |
+
+## 质量检测表
+
+写入前检查以下各项。重点记录未通过项及其正文证据;不要求固定报告格式。
+
+| 检测项 | 通过标准 |
+|---|---|
+| 读者与范围 | 内容服务读者任务,核心命题和交付范围清楚,无无用章节 |
+| 事实与缺口 | 重要主张可追溯;已核验事实有直接支持它的对应证据,来源主张有归属,推断有前提,缺口已补齐、条件化、标注或阻断发布 |
+| 结构与体裁 | 各部分关系和顺序合理,完成 contract 与可选 adapter 的要求,无缺项、重复或近邻体裁混用 |
+| 表达 | 表达具体、简练、术语一致;叙述未被列举化,标题、列表、表格和编号各司其职,并符合所选 mode、contract 与 adapter |
+| 一致性 | 序号、编号与标题层级采用一套体系,同级不跳号、不跳级;颜色与视觉强调保持统一语义并符合所选 mode;同一对象、动作、状态和专有名词全文同名 |
+| 字数与硬约束 | 用户硬约束全部满足;有明确字数或字符数要求时,以 `profile.word_count` / `profile.char_count` 的实测值为准,不自行估算 |
+| Blocks 与组件 | `profile.block_count` 与 `profile.blocks` 反映实际 block;实际类型同时满足 contract 与 adapter 已声明的允许、限用和禁止条件,未声明限制的一方不得反推 allow-list;rich block 服务所选 mode 和真实信息关系 |
+| 格式与解析 | 单份稿件格式单一,Draft Parse Gate 通过 |
+| 保真 | 未授权内容和资源保持不变 |
+
+未通过但可用当前材料修复时继续修订;缺少关键事实、材料、授权或用户选择时,`Publish Gate = blocked`。
diff --git a/skills/lark-doc/references/lark-doc-create.md b/skills/lark-doc/references/lark-doc-create.md
index 461e76ec01..3a157b9986 100644
--- a/skills/lark-doc/references/lark-doc-create.md
+++ b/skills/lark-doc/references/lark-doc-create.md
@@ -1,26 +1,18 @@
# docs +create(创建飞书云文档)
-> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
-> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
-> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制)
-> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、单 Agent 串行撰写)
->
-> **未读完以上文件就生成内容会导致格式错误。**
-
-从 XML(默认)或 Markdown 内容创建一个新的飞书云文档。
+从 XML(默认)或 Markdown 内容创建一个新的飞书云文档;语义创作默认使用 XML,只有 Authoring 明确判定为 Markdown 例外时才使用 Markdown。
-> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;没有明确指示时默认 XML(表达能力更强,可承载更丰富的结构化内容)。不要在用户没要求的情况下主动从 XML 切到 Markdown,也不要在用户已给出 Markdown 时强行改成 XML。
+> **职责与前置条件:**本文件只负责创建操作。语义创作必须已完成 [`lark-doc-authoring.md`](lark-doc-authoring.md) 的 Prepare 和 Draft 阶段,且 `Publish Gate = ready`;空文档或原样导入可由根路由直接进入。首次执行 `lark-cli` 前只再读取 [`lark-shared`](../../lark-shared/SKILL.md)。
## 命令
```bash
-# 创建 XML 文档(默认格式,推荐)
-lark-cli docs +create --content '项目计划目标
记录本周重点。
'
-
-# 仅当用户明确要求导入 Markdown 时才使用;文档标题用 --title,正文标题按内容自然组织
-lark-cli docs +create --doc-format markdown --title "项目计划" --content $'## 目标\n\n- 明确重点\n- 记录待办'
+# 先在当前工作目录下创建任务独占、名称唯一的临时 XML 文件,并写入已验收草稿
+lark-cli docs +create --doc-format xml --content "@<任务独占目录>/<唯一文件名>.xml"
```
+单次内容优先使用 `--content -` 从 stdin 读取。必须使用 `@file` 时,在当前工作目录下创建任务独占目录,并为每篇文档创建名称唯一的临时 XML 文件;明确命中 Markdown 例外时才创建临时 Markdown 文件。不得复用固定草稿名、已存在文件或其他任务的目录。`@file` 只接受当前工作目录下的相对路径,且参数必须整体加引号,例如 `"@<任务独占目录>/<唯一文件名>.xml"`;不要传绝对路径。完成 Deliver 的传输验证后,只清理本任务创建的文件和目录,不得使用通配符清理。
+
## 返回值
```json
@@ -42,6 +34,12 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'#
- **`document.new_blocks`**:本次操作新增的 block 列表(如画板)。`block_id` 可用于 `docs +update` 的 `--block-id` 做精确编辑;`block_token` 是资源块(如画板)的 token,可交给 `lark-whiteboard` 等 skill 继续操作
+## 结果处理与退出条件
+
+1. 检查命令是否成功、业务结果是否成功,并逐项处理 `warnings` / `degrade_details`;不得只看到文档 URL 就宣布完成。
+2. 读取 [`lark-doc-fetch.md`](lark-doc-fetch.md),fetch 新文档的最小充分范围,与通过 Publish Gate 的版本比较,仅验证内容被完整传输、资源未丢失且降级已处理。
+3. 传输不一致或存在未处理降级时,基于 fetch 结果修复并重新验证;实际结果与已批准版本一致后才结束。
+
> \[!IMPORTANT]
> 如果文档是**以应用身份(bot)创建**的,如 `lark-cli docs +create --as bot` 在文档创建成功后,CLI 会**尝试为当前 CLI 用户自动授予该文档的 `full_access`(可管理权限)**。
>
@@ -60,21 +58,7 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'#
| ------------------- | -- |---------------------------------------------|
| `--title` | 否 | 文档标题,Markdown 导入时使用;XML 创建推荐在 `--content` 开头写 `...`;多个标题仅保留第一个并在 `warnings` / `degrade_details` 提示 |
| `--content` | 视情况 | 文档内容(XML 或 Markdown 格式);不传 `--content` 时必须传 `--title` |
-| `--reference-map` | 否 | 结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。 |
-| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
+| `--reference-map` | 否 | 结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、任务独占目录内的相对 `@file`,或 `-` 从 stdin 读取。 |
+| `--doc-format` | 否 | CLI 与语义创作均默认 `xml`,并建议显式传入;仅用户明确要求 Markdown 或保真导入 Markdown 时使用 `markdown`。单次内容禁止混用两种语法。 |
| `--parent-token` | 否 | 父文件夹或知识库节点 token(与 `--parent-position` 互斥) |
| `--parent-position` | 否 | 父节点位置,如 `my_library`(与 `--parent-token` 互斥) |
-
-## 最佳实践
-
-- **较长文档**:参考 [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) 先建骨架再分段写入;短文档可一次写完整内容
-- **表达形式**:由用户目标和内容决定。需要结构化表达时可参考 [`lark-doc-style.md`](style/lark-doc-style.md),但不要默认套用固定开头、固定富 block 比例或固定图表
-
-## 参考
-
-- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、单 Agent 串行撰写)
-- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制)
-- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
-- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
-- [`lark-doc-update.md`](lark-doc-update.md) — 更新文档
-- [`lark-doc-media-insert.md`](lark-doc-media-insert.md) — 插入图片/文件到文档
diff --git a/skills/lark-doc/references/lark-doc-md.md b/skills/lark-doc/references/lark-doc-md.md
index b8ae2d0a32..a7c8ae61fa 100644
--- a/skills/lark-doc/references/lark-doc-md.md
+++ b/skills/lark-doc/references/lark-doc-md.md
@@ -42,7 +42,7 @@
**导出 → 更新 工作流示例:**
1. `docs +fetch` 导出得到 `C:\\Users\\test\[1\]`
-2. 用 `str_replace --pattern 'C:\\Users\\test\[1\]'` 匹配(直接使用导出的转义形式)
+2. 用 `str_replace --pattern "C:\\Users\\test\[1\]"` 匹配(直接使用导出的转义形式)
3. `--content` 中的替换内容也要保持转义:`C:\\Users\\prod\[2\]`
自行构造 Markdown 内容写入时同理:如字面文本 `a]b` 应写为 `a\]b`,`C:\Users` 应写为 `C:\\Users`。
@@ -64,13 +64,9 @@ Markdown 格式支持通过 URL 插入网络图片,图片将自动从 HTTP 下
```
- `alt text` 为图片描述(可选,可留空)
- URL 支持 `http://` 和 `https://` 协议
-- 对应的 XML 格式为:`
`
## Markdown 不支持的 Block 类型
-非原生 Markdown 语法的内容(如下划线、高亮框(Callout)、勾选框、多维表格、画板、思维导图、电子表格、网格布局、引用(@文档/@人)、按钮、日期提醒、行内文件、文字颜色/背景色、同步块等)采用 XML 语法表示,详见 [`lark-doc-xml.md`](lark-doc-xml.md)。
-> **⚠️ XML 标签会被解析并生效**:即使在 `--doc-format markdown` 下,``、``、`
` 等 XML 标签也会被识别为对应的富文本节点,**不会**按字面量显示。如需字面量输出尖括号包裹的文本(例如示例中的 ``),必须转义左尖括号:`\`、`\
`。
+Markdown 不支持下划线、高亮框(Callout)、勾选框、多维表格、画板、思维导图、电子表格、网格布局、引用(@文档 / @人)、按钮、日期提醒、行内文件、文字颜色 / 背景色、同步块等能力。需要其中任何一项时,不得在 Markdown 草稿中嵌入 XML;返回 Serialization Decision,将整份 release candidate 切换为 XML 后重新生成。
-## 参考
-
-- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
+> **⚠️ XML 标签会被解析并生效**:即使在 `--doc-format markdown` 下,意外出现的 ``、``、`
` 等标签也会被识别为富文本节点,不会按字面量显示。Markdown 正文需要展示 `` 时,必须转义左尖括号,例如 `\`、`\
`。
diff --git a/skills/lark-doc/references/lark-doc-script.md b/skills/lark-doc/references/lark-doc-script.md
new file mode 100644
index 0000000000..7f4a154172
--- /dev/null
+++ b/skills/lark-doc/references/lark-doc-script.md
@@ -0,0 +1,64 @@
+# `docs +script`
+
+`docs +script` 在本地解析文档内容或转换格式,不发起 OpenAPI 请求,也不修复输入。`--content` 支持字面内容、`@当前目录下的相对路径` 和 `-`(stdin);长内容优先使用 `@file` 或 stdin。`--format json` 只控制 CLI 输出格式,不表示输入格式。
+
+## 解析 XML 或 Markdown
+
+使用同一条 `parse` 指令。shortcut 根据内容自动识别 XML 或 Markdown,调用方不需要判断或声明输入格式:
+
+```bash
+lark-cli docs +script --command parse --content "@document.xml" --format json
+lark-cli docs +script --command parse --content "@document.md" --format json
+```
+
+统计已存在的在线文档时,先读取完整 XML,把返回的 `data.document.content` 写入当前工作目录下任务独占、名称唯一的临时 XML 文件,再解析该文件:
+
+```bash
+lark-cli docs +fetch --doc "<文档 URL 或 token>" --doc-format xml --detail full --format json
+lark-cli docs +script --command parse --content "@<任务独占目录>/<唯一文件名>.xml" --format json
+```
+
+XML 输入执行严格解析;不完整标签、错误嵌套、非法属性、未知实体或不支持的 LarkOpenCLI 标签会返回非零退出码。Markdown 输入按 LarkOpenCLI Markdown 语义解析。资源块内部未出现在输入文本中的内容不计入字数或字符数。
+
+成功时 `data` 只包含 `profile`:
+
+```json
+{
+ "data": {
+ "profile": {
+ "word_count": 10,
+ "char_count": 15,
+ "block_count": 2,
+ "blocks": [
+ {"type": "p", "count": 1, "ratio": 0.5},
+ {"type": "title", "count": 1, "ratio": 0.5}
+ ]
+ }
+ }
+}
+```
+
+- `data.profile.word_count`:语义字数。统计汉字、英文单词 / URL / code path、数字、中文标点和独立可见符号;英文单词内部按一个语义单位计算。
+- `data.profile.char_count`:可见字符数,不含空格;统计汉字、英文字母、数字、中英文标点和可见符号,非 BMP 符号按 UTF-16 code unit 计算。
+- `data.profile.block_count`:block 总数。
+- `data.profile.blocks[]`:每种 block 的 `type`、`count` 和 `ratio`;`ratio = count / block_count`。
+
+## Markdown 转 XML
+
+`markdown-to-xml` 只负责把 Markdown 转成 LarkOpenCLI XML:
+
+```bash
+lark-cli docs +script --command markdown-to-xml --content "@document.md" --format json
+```
+
+成功时 `data` 只包含转换结果:
+
+```json
+{
+ "data": {
+ "xml": "标题
正文
"
+ }
+}
+```
+
+该指令不返回 `profile`。需要统计原 Markdown 时,独立执行 `--command parse`。
diff --git a/skills/lark-doc/references/lark-doc-update.md b/skills/lark-doc/references/lark-doc-update.md
index 905d69175a..cc54071f0d 100644
--- a/skills/lark-doc/references/lark-doc-update.md
+++ b/skills/lark-doc/references/lark-doc-update.md
@@ -1,20 +1,40 @@
# docs +update(更新飞书云文档)
-> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
-> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
-> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制)
-> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、单 Agent 串行改写)
->
-> **未读完以上文件就生成内容会导致格式错误。**
-
通过八种指令精确更新飞书云文档。支持字符串级别和 block 级别的操作。
+> **Authoring 前置条件:**语义改写、润色、重组、补写或排版必须已完成 [`lark-doc-authoring.md`](lark-doc-authoring.md) 的 Prepare 和 Draft 阶段,且 `Publish Gate = ready`,才能执行写入;写入后必须返回 Deliver 完成传输完整性验证。明确旧文本到新文本的替换、纯删除或纯移动可跳过 Authoring,但不得跳过本文件的 Observe 与写后 Verify。
+
> **⚠️ 格式选择规则:**
-> - **局部精修**(`str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after`):优先使用 XML(默认)。XML 能稳定表达 block 结构和样式,精准编辑更可控;不要因为 Markdown 写起来更简单就自行切换。
-> - **整段写入**(`append` / `overwrite`):XML 和 Markdown 都可以。用户提供 `.md` 本地文件或明确要求 Markdown 时直接用 Markdown;否则默认 XML。
+> - **局部精修**:默认 XML。`str_replace` 行内匹配、`block_insert_after` 和 `block_replace` 都优先使用 XML;只有跨行 `str_replace` 等明确依赖 Markdown 的单次操作才使用 Markdown。delete / move / copy 不写内容。
+> - **整段写入**:`append` 和获授权的 `overwrite` 默认 XML;只有用户明确要求 Markdown 或保真写入 Markdown 来源时才使用 Markdown。
+> - **禁止混用**:每个 `--content` / `--pattern` payload 只能使用一种语法;不得在 Markdown 中嵌 XML,也不得在 XML 中混 Markdown。选择 XML 不授权新增无必要的 rich block。
>
-> **Markdown 局限 & block ID 前提:** Markdown 不携带 block ID,也无样式(颜色、对齐、callout 等)。需要按 block ID 定位(`block_*` 指令的 `--block-id`)时,先 `docs +fetch --detail with-ids` **配合 `--scope`(`outline` / `range` / `keyword` / `section`)局部获取**目标段落,不要全量 fetch。拿到 block ID 后 `--content` 仍可用 Markdown,只是写入内容不带样式。
+> **Markdown 局限 & block ID 前提:** Markdown 不携带 block ID,也无样式(颜色、对齐、callout 等)。需要按 block ID 定位(`block_*` 指令的 `--block-id`)时,先 `docs +fetch --detail with-ids` **配合 `--scope`(`outline` / `range` / `keyword` / `section`)局部获取**目标段落,不要全量 fetch。拿到 block ID 后,`--content` 仍默认使用 XML;只有已命中 Markdown 例外时才使用 Markdown。
+
+## 本地内容隔离
+
+单次内容优先使用 `--content -` 从 stdin 读取。必须使用 `@file` 时,在当前工作目录下创建任务独占目录,并为每次写入创建名称唯一的临时 XML 文件;明确命中 Markdown 例外时才创建临时 Markdown 文件。不得复用固定文件名、已存在文件或其他任务的目录。`@file` 参数必须整体加引号,例如 `"@<任务独占目录>/<唯一文件名>.xml"`。完成写后 Verify / Deliver 后,只清理本任务创建的文件和目录,不得使用通配符清理。
+
+## Observe-Diagnose-Patch Loop
+
+适用于修改已有飞书文档:调整语气、精简冗余、增补章节、修复结构混乱、按领导意见修改,或在已有图片、引用、表格、评论、资源块的文档上做保真改写。
+
+> [!IMPORTANT]
+> 核心原则:先观察,再诊断,再局部 patch,最后 fetch 验证。这个流程比全文重写安全;除非用户明确要求完全重建,或文档确实已无保留价值,不要轻易使用 `overwrite`,否则会丢失评论和未支持的资源。
+> 每次 `docs +update` 后,都按 block ID 已发生变更处理。如果需要继续修改、重复修改同一处内容,或引用刚插入 / 替换后的内容,必须先重新 `docs +fetch --detail with-ids` 拉取最新内容和 block ID,再执行下一轮 patch。
+
+1. **Observe(读取现状)**:先 `docs +fetch` 读取当前文档状态,并按意图选择最小范围。
+ - 改某一节或大文档:先 `--scope outline --max-depth 2` 找章节,再 `--scope section --start-block-id <标题id> --detail with-ids`
+ - 精确跨节区间:用 `--scope range --start-block-id xxx --end-block-id yyy`
+ - 只有模糊关键词:用 `--scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids`
+ - 明确整篇重构才读 `--detail with-ids` 全文;只读摘要或确认事实时用更轻的 fetch
+2. **Diagnose(诊断问题)**:判断用户目标、当前结构、语气、重复、断流、事实口径和需要保留的资源;识别哪些 block 必须原样保留。
+3. **Patch Plan(制定局部计划)**:把修改拆成最小安全操作:简单行内替换用 `str_replace`;整段/整块重写用 `block_replace`;增补章节用 `block_insert_after`;删冗余用 `block_delete`;调整顺序用 `block_move_after`。
+4. **Patch(精确修改)**:按 block / section 执行局部命令。保护 ``、`
`、``、``、``、``、`` 等 token 化内容,不要改成纯文本或占位符。同一 block 的多处修改合并成一次 `block_replace`。
+5. **Verify(fetch 验证)**:每轮写操作后按影响范围重新 fetch,检查用户要求、结构、语气、事实、资源块和 block ID 是否符合预期;不满足就基于最新 fetch 结果继续 Diagnose / Patch,不要沿用上一轮 block ID。
+
+复杂结构重组时,优先“先插入新结构,再删除旧 block”:用 `block_insert_after` 插入 grid / table / callout / 新章节,再用 `block_delete` 删除旧段落。这样比 `overwrite` 更能保住图片、评论、引用、资源块和不相关内容。`str_replace` 的匹配范围取决于格式:XML 模式只适合行内匹配;Markdown 模式可跨行和使用 `前缀...后缀`,但跨 block 或容器级重写仍优先用 block 指令。
## 参数
@@ -22,9 +42,9 @@
|------|------|------|
| `--doc` | 是 | 文档 URL 或 token |
| `--command` | 是 | 操作指令(见下方指令速查表) |
-| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
+| `--doc-format` | 否 | CLI 与写入默认 `xml`;仅用户明确要求、保真 Markdown 来源或跨行 `str_replace` 等必要操作时使用 `markdown`;单次 payload 禁止混用 |
| `--content` | 视指令 | 写入内容(`str_replace` 传空字符串可实现删除) |
-| `--reference-map` | 否 | 结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。 |
+| `--reference-map` | 否 | 结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、任务独占目录内的相对 `@file`,或 `-` 从 stdin 读取。 |
| `--pattern` | 视指令 | 匹配文本(str_replace) |
| `--block-id` | 视指令 | 目标 block ID(block_* 操作),逗号分隔可批量删除,-1 表示末尾 |
| `--src-block-ids` | 视指令 | 源 block ID(逗号分隔),用于 block_copy_insert_after / block_move_after |
@@ -45,12 +65,12 @@
## Block ID 生命周期
-写操作后不要默认复用之前 fetch 到的 block ID:
+安全规则:每次写操作后都按 block ID 已变更处理。需要连续修改、重复修改或操作新插入内容时,必须重新 fetch 最新内容和 block ID;不要默认复用之前 fetch 到的 block ID。
-- `overwrite` / `block_replace` / `block_delete`:受影响旧 ID 失效,继续 block 级操作前重新 fetch
-- `block_insert_after` / `append` / `block_copy_insert_after`:锚点 / 源 ID 通常保留,新内容是新 ID;要操作新内容先重新 fetch
-- `block_move_after`:被移动 ID 通常保留,但位置、章节、range 语义变化;后续依赖位置时重新 fetch
-- `str_replace`:简单行内替换通常不改变 ID;跨行 / 大段替换后如继续 block 级操作,先重新 fetch
+- `overwrite` / `block_replace` / `block_delete`:受影响旧 ID 失效,继续 block 级操作前必须重新 fetch
+- `block_insert_after` / `append` / `block_copy_insert_after`:新内容一定是新 ID;要操作新内容或继续编辑插入点附近内容,先重新 fetch
+- `block_move_after`:位置、章节、range 语义已变化;后续依赖位置或章节边界时重新 fetch
+- `str_replace`:即使是简单行内替换,也不要在后续 block 级操作中假设旧 ID 仍正确;跨行 / 大段替换后必须重新 fetch
## 指令示例
@@ -71,12 +91,12 @@ lark-cli docs +update --doc "" --command str_replace \
lark-cli docs +update --doc "" --command str_replace \
--pattern "旧链接" --content '新链接 点击查看'
-# 仅当用户明确要求时才使用 Markdown
+# 仅当用户明确要求或该次操作必须跨行匹配时才使用 Markdown
lark-cli docs +update --doc "" --command str_replace \
--doc-format markdown --pattern "旧内容" --content "新内容"
# Markdown 模式下支持跨行匹配(--pattern 与 --content 都需要真实换行;"..."/'...' 里的 \n 是字面量)
-# 多行内容推荐 heredoc 或 --content @file.md,避免 shell 转义踩坑
+# 多行内容推荐 heredoc;必须落盘时使用 @"$CONTENT_FILE",避免 shell 转义和并发串文件
lark-cli docs +update --doc "" --command str_replace \
--doc-format markdown \
--pattern "$(printf '## 旧标题\n\n第一段原文\n\n第二段原文')" \
@@ -197,64 +217,8 @@ lark-cli docs +update --doc "" --command block_move_after \
| `warnings` | 警告信息列表 |
| `document.new_blocks` | 本次操作新增的 block 列表(如画板)。`block_id` 可用于后续精确编辑;`block_token` 是资源块 token(如画板)可交给 `lark-whiteboard` 等 skill 继续操作 |
-## 典型工作流
-
-### 精确 block 级更新
-
-1. **获取文档内容和 block ID**:
- ```bash
- lark-cli docs +fetch --doc "" --detail with-ids
- ```
-
-2. **定位目标 block**:从返回的 XML 中找到要修改的 block 及其 `id` 属性
-
-3. **执行更新**:
- ```bash
- # 替换特定 block
- lark-cli docs +update --doc "" --command block_replace \
- --block-id "blkcnXXXX" --content "新内容
"
-
- # 在某 block 后插入
- lark-cli docs +update --doc "" --command block_insert_after \
- --block-id "blkcnXXXX" --content "追加的章节
"
- ```
-
-### 简单文本替换
-
-不需要 block ID,直接匹配替换:
-
-```bash
-lark-cli docs +update --doc "" --command str_replace \
- --pattern "v1.0" --content "v2.0"
-```
-
## 画板处理
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 ``,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。
-画板的语法选型与插入示例见 [`lark-doc-xml.md`](lark-doc-xml.md) 与 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md)。
-
-## 最佳实践
-
-- **精确操作优于全文覆盖**:使用 `block_replace`/`block_insert_after` 精确修改,避免 `overwrite` 全文覆盖
-- **str_replace 的匹配范围取决于格式**:
- - **XML 模式(默认)**:`--pattern` 只支持**行内**匹配,不支持跨行 / 跨 block。段落、整块或容器级(列表、表格、分栏、引用块等)改动请改用 `block_replace` 指定 block_id 重建。
- - **Markdown 模式**(`--doc-format markdown`):`--pattern` 同时支持**行内和跨行**匹配,还支持 `前缀...后缀` 省略号语法(用 `...` 串联首尾片段匹配一大段内容),可以一次替换多行文本;但仍建议优先按最小片段匹配,跨 block 容器级重写仍优先用 `block_replace`,避免副作用。
-- **保护不可重建的内容**:图片、画板、电子表格等以 token 形式存储,替换时避开这些 block
-- **str_replace 的 replacement 支持富文本**:可以用行内标签 ``、``、``、`` 等替换普通文本为富文本
-- **同一 block 只能被 replace 一次**:多次修改同一 block 请合并为一次 block_replace
-- **block_delete 支持批量**:用逗号分隔多个 block_id 一次删除
-- **复杂结构重组**:将多个段落转换为 grid / table 等复杂布局时,分步操作比 overwrite 更安全:
- 1. 用 `block_insert_after` 在目标位置插入新的富文本结构
- 2. 用 `block_delete` 批量删除旧的 block
- 3. 这样可以保留文档中其他不相关的内容(图片、评论等)
-- **表达形式**:插入或替换内容时,优先沿用用户要求和已有文档风格;需要结构化表达时可参考 [`lark-doc-style.md`](style/lark-doc-style.md),但不要为了固定丰富度主动添加组件
-
-## 参考
-
-- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、单 Agent 串行改写)
-- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制)
-- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
-- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
-- [`lark-doc-create.md`](lark-doc-create.md) — 创建文档
-- [`lark-doc-media-insert.md`](lark-doc-media-insert.md) — 插入图片/文件到文档
+新增画板的语法选型见 [`lark-doc-xml.md`](lark-doc-xml.md) 的资源块说明;插入和复杂画板处理见 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md)。
diff --git a/skills/lark-doc/references/lark-doc-whiteboard.md b/skills/lark-doc/references/lark-doc-whiteboard.md
index 90393571d9..fda58a869c 100644
--- a/skills/lark-doc/references/lark-doc-whiteboard.md
+++ b/skills/lark-doc/references/lark-doc-whiteboard.md
@@ -6,7 +6,7 @@
| Skill | 核心职责 | 约束 |
|-------------------|-----------------------------------------------------------|---------------------------------|
-| `lark-doc` | 识别画板机会、使用 Mermaid/SVG 创建图表、调度 SubAgent、插入简单 SVG 画板或复杂空白画板 | 主 Agent 不直接创作画板内容; |
+| `lark-doc` | 识别画板机会、使用 Mermaid/SVG 创建图表、调度 SubAgent、插入简单图表或复杂空白画板 | 简单图可由主 Agent 直接写入;复杂图再隔离到 SubAgent |
| `lark-whiteboard` | 查询/导出已有画板;复杂图表生成(Mermaid/DSL/SVG 路由、场景选型、渲染验证);写入已有/空白画板 | 仅特别复杂的图表或已有画板更新时由独立 SubAgent 读取 |
## 画板适用规则
@@ -29,11 +29,9 @@
> [!IMPORTANT]
> ⚠️ **分别对每个图表进行决策**
-如果有多个位置需要插入图表,你需要根据每个图表的内容**分别决定**采用步骤 2A 还是 2B
-中的方式插入这个图表。在需要插入思维导图、时序图、类图、饼图、甘特图的时候可以插入 mermaid 块,在需要插入其他类型图表时启动
-SubAgent 插入 SVG。
+如果有多个位置需要插入图表,你需要根据每个图表的内容**分别决定**采用步骤 2A 还是 2B。思维导图、时序图、类图、饼图、甘特图可插入 mermaid 块;其他类型图表使用 SVG,简单图由主 Agent 直接写入,复杂图再启动 SubAgent。
-建议优先使用 SVG 插入图表,除非其属于思维导图、时序图、类图、饼图、甘特图这类可以直接使用 mermaid 语法描述,且不适宜用 SVG 绘制的图表
+简单 Mermaid / SVG 图可由主 Agent 直接写入本地 XML;需要专门视觉设计、信息密度较高或容易布局翻车的 SVG,再启动 SubAgent 产出完整片段。
### 步骤 2A: 使用 mermaid 插入图表
diff --git a/skills/lark-doc/references/lark-doc-word-stat.md b/skills/lark-doc/references/lark-doc-word-stat.md
deleted file mode 100644
index 156b859121..0000000000
--- a/skills/lark-doc/references/lark-doc-word-stat.md
+++ /dev/null
@@ -1,93 +0,0 @@
-# 文档统计:总字数 / 总字符数
-
-当用户需要统计 Docx / Wiki 文档的总字数或总字符数时,使用本 skill 附带脚本 `scripts/doc_word_stat.py`。统计口径以该脚本为准,不要改用其他方式自行计算,也不要只读取 simple 摘要后统计。
-
-## 调用方式
-
-在线文档使用 XML full 内容,并让脚本读取 `docs +fetch --format json` 的 envelope:
-
-```bash
-lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
- | python3 skills/lark-doc/scripts/doc_word_stat.py --protocol xml --lark-json --pretty
-```
-
-`$URL` 可以是用户给出的 docx/wiki URL,也可以是可被 `docs +fetch` 解析的 token。
-
-## 统计范围
-
-先判断用户要求的是**整篇文档**还是**局部内容**:
-
-- 整篇文档的总字数 / 总字符数:按上方「调用方式」抓取 `full` 内容后统计。
-- 本次新增 / 替换 / 改写片段的字数:优先统计拟写内容本身;内容已写入文档时,只 fetch 对应 block / range 后统计。不得用整篇文档字数对比局部目标。
-
-如需在自动化或回归验证中发现未覆盖块类型,追加严格参数:
-
-```bash
-lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
- | python3 skills/lark-doc/scripts/doc_word_stat.py --protocol xml --lark-json --pretty --fail-on-unsupported --fail-on-unknown
-```
-
-## 如何读取结果
-
-脚本输出 JSON。对用户汇报时默认只读两个核心字段:
-
-- `word_count`:总字数。按语义单位统计汉字、英文单词/URL/code path、数字、中文标点;普通贴着英文的英文标点不计入,但独立 ASCII 符号、中文之间的 `/` 等以脚本结果为准。
-- `char_count`:总字符数。统计汉字、英文字母、数字、中英文标点和脚本识别的可见符号;空格不计入。
-
-其余字段用于排查或解释:
-
-- `breakdown`:拆分统计来源,例如 `han_chars`、`english_words`、`digits`、`chinese_punctuations`。
-- `unknown_blocks`:脚本遇到未知 XML/Markdown 块类型;通常表示需要扩展解析规则。
-- `unsupported_blocks`:脚本识别到块类型,但当前无法可靠提取可见文本。
-- `diagnostics.has_unknown` / `diagnostics.has_unsupported`:快速判断统计是否存在覆盖风险。
-
-如果 `unknown_blocks` 或 `unsupported_blocks` 非空,回复用户时要说明“已统计可提取文本,但存在未覆盖块,结果可能偏低”,并列出对应块类型。为空时可直接给出结果。
-
-## 字数遵循校验
-
-当用户给了明确字数要求(写 N 字 / x-y 字 / x 字左右 / 上下浮动)时执行;没有明确字数要求则跳过。字数必须按本文流程用脚本统计,不要自己估。
-
-1. 先按「统计范围」确认统计对象,再把要求归一成目标区间:`>x`→`[x+1, +∞)`;`` — `
` 上传网络图片
-- `` — 简单图由 SubAgent 直接插入 `完整自包含 SVG`;也可用本地文件简写 ``、``、``,CLI 会写入前展开为内联内容;复杂图使用 `` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
+- `` — 简单 Mermaid / SVG 图可由主 Agent 直接写入;复杂 SVG 可启动 SubAgent 产出完整 `完整自包含 SVG`;特别复杂或已有画板更新,使用 `` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
- `` — `` 空白;`` 复制已有
- `` — ``,必传 task-id(任务 guid)
- `` — ``,必传 chat-id
@@ -127,7 +127,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
```xml
文档标题
-一级标题
+一级标题不要和 title 重复
加粗文本,绿色文本
diff --git a/skills/lark-doc/references/style/lark-doc-create-workflow.md b/skills/lark-doc/references/style/lark-doc-create-workflow.md
deleted file mode 100644
index a9ab5c3b15..0000000000
--- a/skills/lark-doc/references/style/lark-doc-create-workflow.md
+++ /dev/null
@@ -1,47 +0,0 @@
-# 从零创作工作流
-
-用户提供主题、需求或简要说明,需要生成一份新的飞书文档时,遵循本工作流。
-
-## 核心方法论 — Code-Act Loop
-
-通过自适应的 **Code-Act Loop** 驱动文档创作,而非固定模板式的工作流。每次任务都循环执行:
-
-1. **Plan(规划)** — 根据用户目标和文档当前状态,评估下一步该做什么
-2. **Execute(执行)** — 由主 Agent 自己运行 `lark-cli docs` 命令推进正文;仅画板渲染按需隔离到 SubAgent(见步骤三)
-3. **Observe(观察)** — 检查命令输出,验证正确性,确认内容是否满足用户目标
-4. **Iterate(迭代)** — 如需调整,回到 Plan 继续循环
-
-循环在文档达到质量标准且满足用户需求时结束。不要试图一次性产出完美内容——迭代打磨效果更好。根据用户实际需求灵活决定文档结构和版块,而不是套用固定模板。
-
-
-## 典型 Code-Act Loop 流程
-
-### 步骤一:规划与撰写(单 Agent 串行)
-
-正文由主 Agent 串行维护,**不按章节拆给并行 Agent**,避免上下文割裂、重复矛盾和全文级约束失效。
-
-1. 分析用户需求:受众、目的、范围
-2. 设计大纲:根据任务自然选择结构。可以是短文、纪要、FAQ、方案、报告、清单或其他形式;不要默认套固定章节、固定开头或固定富 block 配比
-3. `docs +create` 创建并撰写:
- - **短文档**:一次写入完整内容。使用 Markdown 时,避免同时传入 `--title` 和同名 `# 标题`
- - **长文档**:先建骨架(标题 + 各级标题),再由主 Agent **顺序逐节**用 `block_insert_after --block-id <章节标题 block_id>` 补全正文;写完一节再写下一节,始终带着已写内容的上下文,保证衔接、不重复
- - ⚠️ 不要一次性把超长完整内容塞进 `--content`,容易触发字符/参数限制;长文按节分次写入
- - ⚠️ 同一节内多次插入时,要锚到**上一个新插入的 block**(按 [`lark-doc-update.md`](../lark-doc-update.md) 的「Block ID 生命周期」),否则反复锚同一个标题会让段落顺序颠倒
- - ⚠️ 若先建骨架写了占位摘要,补正文时**删除占位摘要**,不要留残渣
- - ⚠️ **`@file` 路径限制**:`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理
-
-### 步骤二:整合审查与画板识别(串行)
-
-4. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果
-5. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材;检查跨节有无重复、矛盾或断流。再按 `lark-doc-style.md` 的「写完自检」快速核对,发现问题就地定向修正
-6. **画板识别**:逐章节扫描,判断是否有段落用图明显比文字更易懂(流程 / 架构 / 时间线 / 对比 / 占比等,见 `lark-doc-style.md` 的画板原则)。默认用文字,只有确需图示才记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
-
-### 步骤三:画板处理与润色
-
-7. **优先处理步骤二识别出的画板需求**:读取并按 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 选型和插入;正文本身不交给 SubAgent
-8. 由**主 Agent 自行润色**(不另起内容子 Agent,正文始终一人维护):文字密集且不易读时,优先拆段、加小标题或调整顺序——叙述内容保持成段,**不要默认改成列表**,只有确属并列要点 / 步骤才用列表(见 `lark-doc-style.md`);只有确实存在行列数据时才用 ``。其余富 block 的取舍一律遵循 `lark-doc-style.md` 的写作原则,不主动堆叠。需要明显分隔的主题可补充 `
`,不强制章节间都使用。本地图片使用 `docs +media-insert` 插入
-
-### 步骤四:专项校验
-
-9. **字数门禁**:如果用户给出任何明确字数要求(如“700-800 字”“1000 字左右”“不少于 500 字”“控制在 800 字以内”),本步骤必须执行,不属于按需项。读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;未得到脚本统计结果前,不得向用户声明“符合字数要求”。若没有明确字数要求,则跳过本项,不读取该 workflow。若执行了专项校验,向用户呈现目标区间、`word_count` 和达标结论
-10. **重复标题检查**:文档生成后,检查文档标题和正文第一个标题块是否重复;若重复,删除或改写正文第一个标题块,避免读者看到同一标题连续出现
diff --git a/skills/lark-doc/references/style/lark-doc-style.md b/skills/lark-doc/references/style/lark-doc-style.md
deleted file mode 100644
index 71ba1bd974..0000000000
--- a/skills/lark-doc/references/style/lark-doc-style.md
+++ /dev/null
@@ -1,68 +0,0 @@
-# 飞书文档写作原则
-
-写飞书文档,像一个该领域资深的人类作者那样写,而不是把内容"装配"成组件。
-本文只讲"何时用、什么风格";具体标签 / 命令语法见 [`lark-doc-xml.md`](../lark-doc-xml.md)。
-
-## 一、用户明确要求优先
-
-用户点名要某种格式——高亮块、分栏、列表、某编号体例、表格、画板、某模板、某已有文档的风格——**一律照用户的来,下面的"默认克制"全部让位**。用户给了样例或已有文档,就沿用它的结构与语气。
-
-## 二、默认写连贯段落
-
-用户没指定时,**默认是连贯段落**;其余按内容类型分流,别一律"少用结构",也别什么都升标题:
-
-| 内容 | 用什么 | ❌ 别 |
-|---|---|---|
-| 叙述、论证、分析、说明 | **连贯段落** | 拆成列举 |
-| 真·行列数据(预算、指标、对比、排期、字段说明) | **表格** | 写成段落或把字段堆成一行 |
-| 字段:值(主题、时长、负责人等,少量) | **加粗标签行**或一句话 | 每字段一个标题 |
-| 方法 / 措施 + 每项一段描述 | **加粗引导句段落**(「**全程督导。**…」) | 每项升标题 |
-| 任务清单 / 检查项 / 待办事项 | **``** | 用普通列表替代可交互待办 |
-| 纯短并列项(无描述,如材料清单) | 列表 | — |
-| 章节(内容成块、需在目录导航) | 标题层级 | — |
-
-- 判断标准:**去掉结构后能顺成段落,就用段落;成行成列的数据,就用表格。**
-- **红线一:标题层级只给"章节"。** "小标题 + 一两句话"的小项(字段、方法、要点)不该占标题层级——按上表降成标签行 / 加粗引导句段落(否则目录里全是没信息量的条目)。
-- **红线二:列举(「一是 / 二是」「第一 / 第二」「(1)(2)(3)」)只给真正并列的具体项,且别每节都用。**
- - 「一是 / 二是」是党务列举的措辞——只用在列具体的**问题 / 措施**那一处;背景、现状、认识、分析、过渡、总结**一律成段**。
- - **整篇每段 / 每节都"一是 / 二是",和"每段一个 bullet"是同一个骨架化的错——不因为是党务就变对**(纯清单 / 台账类除外)。
-
-## 三、按体裁写
-
-- **公文 / 法律 / 学术 / 申报 / 项目方案等严肃正式提交物**:靠规范的标题层级、段落与编号体系表达;**默认不用高亮块、分栏**,要强调用加粗或规范小标题。
-- **面向公众号、微信等外部平台粘贴 / 发布的内容**:不用飞书特有富 block(高亮块、分栏等),粘出去会丢样式 / 错乱;改用标准标题、段落、列表、引用。
-- **一般文档**:以可读为先,不堆砌结构。
-
-## 四、编号与层级
-
-- **一套编号体例、全篇一致;最忌中文大层级与阿拉伯小数编号混用。**
- - 公文 / 正式材料常用:「一、→(一)→ 1.→(1)」(中文大层级 + 阿拉伯细分层级)。
- - 学术 / 技术 / 商业报告:「1 → 1.1 → 1.1.1」或「一、→(一)→ 1.」,**择一**。
- - ⚠️ **「一、」只能配「(一)」;要用阿拉伯小数就从顶层全用「1 / 1.1」。绝不「一、」配「1.1 / 2.1」**——这是最常见的混用。
- - **不混用**多套(别"第X部分"+"一、"+"1."混着来);**同级不跳号**;**不跳级**。
-- **编号 / 标题层级只给"章节"**,不要为了凑齐体例把每个小项都编上「(一)」、升成标题(小项处理方式见上文「二、默认写连贯段落」)。
-- 简单的 1.2.3 并列项用原生 `- …
` 让飞书自动编号、自动对齐;「一、(一)」原生产不出,才手打成文字——此时用标题级别表达层次,**不靠手动缩进**、各级顶格(全角括号「()」叠手动缩进会视觉错位)。
-
-## 五、飞书特有组件,克制使用
-
-- **高亮块 ``**:很重的强提醒信号,**默认不用**;只给"不提醒就会出错 / 遗漏"的关键项,全文极少(0~1 个),不要每节导语 / 结论都做成高亮块。
-- **分栏 ``**:仅左右信息量相当、确需并排对照的短内容;否则用段落或表格。
-- **画板**:默认用文字,只在**图示明显比文字更易懂**(流程、架构、时间线、对比、占比等)或用户要求时才用。怎么插、用哪种类型见 [`lark-doc-xml.md`](../lark-doc-xml.md) 与 [`lark-doc-whiteboard.md`](../lark-doc-whiteboard.md)。
-- **颜色**:默认朴素、不上色;需要时保持语义一致,按下表选择对应颜色,不为装饰上色。可用色见 [`lark-doc-xml.md`](../lark-doc-xml.md) 的「美化系统」。
-
-| 语义 | 背景色 | 文字色 |
-|-|-|-|
-| 信息、说明 | `light-blue` | `blue` |
-| 成功、推荐 | `light-green` | `green` |
-| 警告 / 错误 / 风险 | `light-red` | `red` |
-| 注意、待确认 | `light-yellow` | `yellow` |
-| 中性、辅助 | `light-gray` | — |
-
-## 六、写完自检
-
-交付前快速回看:
-- **叙述是否被列举化**:背景 / 现状 / 认识 / 分析 / 成效 / 过渡 / 总结等应成段;列举只用于同层级、可并列处理的信息,如问题、措施、步骤、任务或材料清单。若正文反复使用连续编号、项目符号或固定并列句式,导致内容缺少叙述,应把背景 / 认识 / 分析 / 过渡改写成有承接关系的段落(纯清单 / 台账类除外)。
-- **数据是否正确呈现**:成行成列的数据应使用表格呈现,不要写成段落,也不要用分隔符把多个字段硬串在一起。
-- **标题是否滥用**:"小标题 + 一句话"的小项不要升成标题;应改成标签行、加粗引导句段落或普通段落。
-- **编号是否统一**:全篇一套、不跳号、不跳级,尤其不要中文 + 阿拉伯混用(如「一、」配「1.1」)。
-- **组件是否克制且保真**:高亮块 / 分栏 / 画板 / 颜色应符合体裁和用户要求;引用 / 图片 / 资源块必须保留。
diff --git a/skills/lark-doc/references/style/lark-doc-update-workflow.md b/skills/lark-doc/references/style/lark-doc-update-workflow.md
deleted file mode 100644
index 31ed974776..0000000000
--- a/skills/lark-doc/references/style/lark-doc-update-workflow.md
+++ /dev/null
@@ -1,48 +0,0 @@
-# 改写增强工作流
-
-用户提供已有文档链接或 token,需要改写、润色、补充或重排版时,遵循本工作流。
-
-## 核心方法论 — Code-Act Loop
-通过自适应的 **Code-Act Loop** 驱动文档改写,而非固定模板式的工作流。每次任务都循环执行:
-1. **Plan(规划)** — 根据用户目标和文档当前状态,评估下一步该做什么
-2. **Execute(执行)** — 由主 Agent 自己运行 `lark-cli docs` 命令推进改写;仅画板渲染按需隔离到 SubAgent(见步骤二)
-3. **Observe(观察)** — 检查命令输出,验证正确性,确认内容是否满足用户目标
-4. **Iterate(迭代)** — 如需调整,回到 Plan 继续循环
-
-## 核心原则:精准手术优于全量覆盖
-1. **精准手术**:只改用户指定的 block,不改其他 block。
-2. **全量覆盖**:如果用户明确要改整篇,才用 `overwrite` 命令。
-3. **保真约束**:改写时原文里的 ``(@人)、``(@文档)、`
`、``、``、``、``、`` 等行内组件和资源块一律原样保留(含所有 token / user-id / doc-id 属性),不许替换成纯文本姓名、链接或占位符。
-
-## 工作流程
-
-### 步骤一:分析与画板识别(串行)
-
-1. **选择读取范围**(节省上下文的关键):
- - 用户只改某一节 / 文档较大 → 先 `docs +fetch --scope outline --max-depth 2` 拿目录,再 `docs +fetch --scope section --start-block-id <目标标题id> --detail with-ids` 精读该节(`section` 会自动展开到下一个同级/更高级标题前,不用手动算结束 block id)
- - 需要精确跨节区间 → `docs +fetch --scope range --start-block-id xxx --end-block-id yyy`(或 `--end-block-id -1` 读到末尾)
- - 用户只给了模糊关键词 → `docs +fetch --scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids`
- - 用户明确要改整篇 → `docs +fetch --detail with-ids`
- - 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) 中「选 `--scope`(读取范围)」小节
-2. 系统性评估:用户想改什么、现有文档风格是什么、哪些内容需要保留、哪些问题影响理解
-3. **画板识别**:逐章节扫描,判断是否有段落用图明显比文字更易懂(流程 / 架构 / 时间线 / 对比 / 占比等,见 `lark-doc-style.md` 的画板原则)。默认用文字,只有确需图示才记录需要插图的章节(block ID)、推荐画板类型、mermaid/SVG路径和源内容片段
-4. 向用户简要说明改进计划(包含识别出的画板机会)
-
-### 步骤二:定向改写(单 Agent 串行)
-
-5. **优先处理步骤一识别出的画板候选段落**:读取并按 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 选型和插入;正文本身不交给 SubAgent
-6. 由主 Agent **顺序逐节**改写,**不按章节拆给并行 Agent**,避免上下文割裂、重复矛盾和全文级约束失效:
- - 沿用或轻微调整已有文档风格,除非用户要求彻底重排版
- - 优先通过重写段落、调整标题、补充小标题提升可读性;叙述内容保持成段,**不要默认改成列表**,只有确属并列要点 / 步骤才用列表(见 `lark-doc-style.md`)
- - 富 block 是可选表达手段,不因固定比例而添加,取舍遵循 `lark-doc-style.md` 的写作原则;画板类需求只走第 5 步
-
-### 步骤三:验证(串行)
-
-7. 获取更新后文档局部内容,检查是否符合用户目标和已有风格
-8. 检查是否满足用户目标并保留原有关键内容。再按 `lark-doc-style.md` 的「写完自检」快速核对,发现问题则定向修正
-
-### 步骤四:专项校验(按需执行)
-
-9. 仅当用户预期需要校验字数时,才读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;否则跳过本项,不读取该 workflow。若执行了专项校验,向用户呈现结果
-
-**上下文节省提示**:主 Agent 改某节时如需重新读取,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉当前章节,不要重复拉全文。
diff --git a/skills/lark-doc/scripts/doc_word_stat.py b/skills/lark-doc/scripts/doc_word_stat.py
deleted file mode 100755
index 46fe1ff86a..0000000000
--- a/skills/lark-doc/scripts/doc_word_stat.py
+++ /dev/null
@@ -1,1243 +0,0 @@
-#!/usr/bin/env python3
-# Copyright (c) 2026 Lark Technologies Pte. Ltd.
-# SPDX-License-Identifier: MIT
-"""Standalone Lark Docs word and character counter for XML or Markdown input."""
-
-from __future__ import annotations
-
-import argparse
-import json
-import re
-import sys
-import unicodedata
-from dataclasses import dataclass, field
-from pathlib import Path
-from typing import Any, Literal, Protocol
-from xml.etree import ElementTree as ET
-
-# ---------------------------------------------------------------------------
-# Data model
-# ---------------------------------------------------------------------------
-
-@dataclass
-class TextRun:
- text: str
- attrs: dict[str, Any] = field(default_factory=dict)
-
-
-@dataclass
-class Block:
- type: str
- attrs: dict[str, Any] = field(default_factory=dict)
- children: list["Block"] = field(default_factory=list)
- text_runs: list[TextRun] = field(default_factory=list)
- raw: Any = None
-
-
-@dataclass
-class Segment:
- text: str
- block_type: str
- block_id: str | None = None
- kind: str = "text"
- boundary_before: bool = True
- boundary_after: bool = True
-
- def to_dict(self) -> dict[str, Any]:
- return {
- "text": self.text,
- "block_type": self.block_type,
- "block_id": self.block_id,
- "kind": self.kind,
- "boundary_before": self.boundary_before,
- "boundary_after": self.boundary_after,
- }
-
-
-@dataclass(frozen=True)
-class UnknownBlock:
- type: str
- block_id: str | None = None
- action: str = "recurse_children"
-
- def to_dict(self) -> dict[str, str | None]:
- return {
- "type": self.type,
- "block_id": self.block_id,
- "action": self.action,
- }
-
-# ---------------------------------------------------------------------------
-# Counting rules
-# ---------------------------------------------------------------------------
-
-CHINESE_PUNCTUATION = set(",。!?;:、()《》〈〉“”‘’【】「」『』〔〕…—~·¥")
-ENGLISH_PUNCTUATION = set(
- r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"""
-)
-
-
-LexemeKind = Literal["english", "number"]
-URL_TOKEN_RE = re.compile(r"https?://[!-~]+")
-ASCII_COMPOUND_TOKEN_RE = re.compile(
- r"[A-Za-z0-9]+(?:[._/@:-][A-Za-z0-9]+)+"
-)
-
-
-@dataclass
-class Stats:
- word_count: int = 0
- char_count: int = 0
- han_chars: int = 0
- english_words: int = 0
- number_words: int = 0
- chinese_punctuations: int = 0
- english_letters: int = 0
- digits: int = 0
- english_punctuations: int = 0
- symbol_words: int = 0
- symbol_chars: int = 0
-
- def to_dict(self) -> dict[str, object]:
- return {
- "word_count": self.word_count,
- "char_count": self.char_count,
- "breakdown": {
- "han_chars": self.han_chars,
- "english_words": self.english_words,
- "number_words": self.number_words,
- "chinese_punctuations": self.chinese_punctuations,
- "english_letters": self.english_letters,
- "digits": self.digits,
- "english_punctuations": self.english_punctuations,
- "symbol_words": self.symbol_words,
- "symbol_chars": self.symbol_chars,
- },
- }
-
-
-def is_han(ch: str) -> bool:
- code = ord(ch)
- return (
- 0x3400 <= code <= 0x4DBF
- or 0x4E00 <= code <= 0x9FFF
- or 0xF900 <= code <= 0xFAFF
- or 0x20000 <= code <= 0x2A6DF
- or 0x2A700 <= code <= 0x2B73F
- or 0x2B740 <= code <= 0x2B81F
- or 0x2B820 <= code <= 0x2CEAF
- or 0x30000 <= code <= 0x3134F
- )
-
-
-def is_ascii_letter(ch: str) -> bool:
- return ("a" <= ch <= "z") or ("A" <= ch <= "Z")
-
-
-def is_digit(ch: str) -> bool:
- return "0" <= ch <= "9"
-
-
-def is_chinese_punctuation(ch: str) -> bool:
- if ch in CHINESE_PUNCTUATION:
- return True
- return unicodedata.category(ch).startswith("P") and unicodedata.east_asian_width(ch) in {
- "W",
- "F",
- }
-
-
-def is_english_punctuation(ch: str) -> bool:
- return ch in ENGLISH_PUNCTUATION
-
-
-def is_unicode_symbol(ch: str) -> bool:
- return unicodedata.category(ch).startswith("S")
-
-
-def utf16_units(ch: str) -> int:
- return len(ch.encode("utf-16-le")) // 2
-
-
-class Counter:
- def __init__(self) -> None:
- self.stats = Stats()
- self._lexeme_kind: LexemeKind | None = None
- self._lexeme_has_digit = False
- self._symbol_run_length = 0
- self._at_boundary = True
-
- def count_segments(self, segments: list[Segment]) -> Stats:
- for segment in segments:
- if segment.boundary_before:
- self._end_unit()
- self._at_boundary = True
- if segment.kind == "marker":
- self.write_marker(segment.text)
- elif segment.kind == "code":
- self.write_code(segment.text)
- else:
- self.write(segment.text)
- if segment.boundary_after:
- self._end_unit()
- self._at_boundary = True
- self._end_unit()
- return self.stats
-
- def write(self, text: str) -> None:
- i = 0
- while i < len(text):
- consumed = self._write_ascii_compound_token(text, i)
- if consumed:
- i += consumed
- continue
- if self._write_visible_ascii_separator(text, i):
- i += 1
- continue
- self._write_char(text[i])
- i += 1
-
- def write_marker(self, text: str) -> None:
- for ch in text:
- if ch.isspace():
- continue
- self._end_unit()
- self.stats.word_count += 1
- self.stats.char_count += 1
- self._at_boundary = False
-
- def write_code(self, text: str) -> None:
- for ch in text:
- self._write_code_char(ch)
-
- def _write_code_char(self, ch: str) -> None:
- if ch.isspace():
- self._end_unit()
- self._at_boundary = True
- return
-
- if is_han(ch):
- self._end_lexeme()
- self._end_symbol_run(count_word=False)
- self.stats.han_chars += 1
- self.stats.word_count += 1
- self.stats.char_count += 1
- self._at_boundary = False
- return
-
- if is_ascii_letter(ch):
- self._end_symbol_run(count_word=False)
- self.stats.english_letters += 1
- self.stats.char_count += 1
- if self._lexeme_kind is None:
- self._lexeme_kind = "english"
- elif self._lexeme_kind == "number":
- self._lexeme_kind = "english"
- self._at_boundary = False
- return
-
- if is_digit(ch):
- self._end_symbol_run(count_word=False)
- self.stats.digits += 1
- self.stats.char_count += 1
- self._at_boundary = False
- return
-
- if is_chinese_punctuation(ch):
- self._end_lexeme()
- self._end_symbol_run(count_word=False)
- self.stats.chinese_punctuations += 1
- self.stats.word_count += 1
- self.stats.char_count += 1
- self._at_boundary = False
- return
-
- if is_english_punctuation(ch):
- keeps_lexeme = self._lexeme_kind == "english" and ch in {"'", "-"}
- if not keeps_lexeme:
- had_lexeme = self._lexeme_kind is not None
- self._end_lexeme()
- if not had_lexeme and (self._symbol_run_length > 0 or self._at_boundary):
- self._symbol_run_length += 1
- self.stats.english_punctuations += 1
- self.stats.char_count += 1
- if keeps_lexeme:
- self._at_boundary = False
- return
-
- if is_unicode_symbol(ch):
- self._write_symbol_char(ch)
- return
-
- self._end_lexeme()
- self._end_symbol_run(count_word=False)
- self._at_boundary = False
-
- def _write_char(self, ch: str) -> None:
- if ch.isspace():
- self._end_unit()
- self._at_boundary = True
- return
-
- if is_han(ch):
- self._end_lexeme()
- self._end_symbol_run(count_word=False)
- self.stats.han_chars += 1
- self.stats.word_count += 1
- self.stats.char_count += 1
- self._at_boundary = False
- return
-
- if is_ascii_letter(ch):
- self._end_symbol_run(count_word=False)
- self.stats.english_letters += 1
- self.stats.char_count += 1
- if self._lexeme_kind is None:
- self._lexeme_kind = "english"
- elif self._lexeme_kind == "number":
- self._lexeme_kind = "english"
- self._at_boundary = False
- return
-
- if is_digit(ch):
- self._end_symbol_run(count_word=False)
- self.stats.digits += 1
- self.stats.char_count += 1
- self._lexeme_has_digit = True
- if self._lexeme_kind is None:
- self._lexeme_kind = "number"
- self._at_boundary = False
- return
-
- if is_chinese_punctuation(ch):
- self._end_lexeme()
- self._end_symbol_run(count_word=False)
- self.stats.chinese_punctuations += 1
- self.stats.word_count += 1
- self.stats.char_count += 1
- self._at_boundary = False
- return
-
- if is_english_punctuation(ch):
- # Apostrophes/hyphens can connect English runs. Dot/comma/hyphen
- # can format numeric runs such as 3.14, 1,000, 2026-06-30, or
- # 7-9. Alphanumeric versions like v1.2.3 should remain one semantic
- # run too. These punctuations still count as characters.
- keeps_lexeme = (
- self._lexeme_kind == "english"
- and (ch in {"'", "-"} or (self._lexeme_has_digit and ch == "."))
- ) or (
- self._lexeme_kind == "number"
- and ch in {".", ",", "-"}
- )
- if not keeps_lexeme:
- had_lexeme = self._lexeme_kind is not None
- self._end_lexeme()
- if not had_lexeme and (self._symbol_run_length > 0 or self._at_boundary):
- self._symbol_run_length += 1
- self.stats.english_punctuations += 1
- self.stats.char_count += 1
- if keeps_lexeme:
- self._at_boundary = False
- return
-
- if is_unicode_symbol(ch):
- self._write_symbol_char(ch)
- return
-
- self._end_lexeme()
- self._end_symbol_run(count_word=False)
- self._at_boundary = False
-
- def _write_visible_ascii_separator(self, text: str, index: int) -> bool:
- ch = text[index]
- if ch != "/" or index == 0 or index + 1 >= len(text):
- return False
- if not is_han(text[index - 1]) or not is_han(text[index + 1]):
- return False
-
- self._end_unit()
- self.stats.english_punctuations += 1
- self.stats.symbol_words += 1
- self.stats.word_count += 1
- self.stats.char_count += 1
- self._at_boundary = False
- return True
-
- def _write_ascii_compound_token(self, text: str, start: int) -> int:
- token = self._match_ascii_compound_token(text, start)
- if not token:
- return 0
-
- self._end_unit()
- self.stats.english_words += 1
- self.stats.word_count += 1
- for ch in token:
- if is_ascii_letter(ch):
- self.stats.english_letters += 1
- self.stats.char_count += 1
- elif is_digit(ch):
- self.stats.digits += 1
- self.stats.char_count += 1
- elif is_english_punctuation(ch):
- self.stats.english_punctuations += 1
- self.stats.char_count += 1
- elif is_unicode_symbol(ch):
- units = utf16_units(ch)
- self.stats.symbol_chars += units
- self.stats.char_count += units
- elif is_chinese_punctuation(ch):
- self.stats.chinese_punctuations += 1
- self.stats.char_count += 1
- elif is_han(ch):
- self.stats.han_chars += 1
- self.stats.char_count += 1
- self._at_boundary = False
- return len(token)
-
- def _match_ascii_compound_token(self, text: str, start: int) -> str | None:
- match = URL_TOKEN_RE.match(text, start)
- if match:
- return match.group(0)
-
- match = ASCII_COMPOUND_TOKEN_RE.match(text, start)
- if not match:
- return None
- token = match.group(0)
- if any(is_ascii_letter(ch) for ch in token):
- return token
- return None
-
- def _write_symbol_char(self, ch: str) -> None:
- self._end_lexeme()
- self._end_symbol_run(count_word=False)
- units = utf16_units(ch)
- self.stats.symbol_words += 1
- self.stats.symbol_chars += units
- self.stats.word_count += 1
- self.stats.char_count += units
- self._at_boundary = False
-
- def _end_unit(self) -> None:
- self._end_lexeme()
- self._end_symbol_run(count_word=True)
-
- def _end_lexeme(self) -> None:
- if self._lexeme_kind == "english":
- self.stats.english_words += 1
- self.stats.word_count += 1
- elif self._lexeme_kind == "number":
- self.stats.number_words += 1
- self.stats.word_count += 1
- self._lexeme_kind = None
- self._lexeme_has_digit = False
-
- def _end_symbol_run(self, *, count_word: bool) -> None:
- if self._symbol_run_length >= 1 and count_word:
- self.stats.symbol_words += 1
- self.stats.word_count += 1
- if self._symbol_run_length:
- self._at_boundary = False
- self._symbol_run_length = 0
-
-# ---------------------------------------------------------------------------
-# Markdown parser
-# ---------------------------------------------------------------------------
-
-HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
-LIST_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+(.*)$")
-QUOTE_RE = re.compile(r"^\s*>\s?(.*)$")
-TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$")
-
-
-def parse_markdown(source: str) -> list[Block]:
- lines = source.splitlines()
- blocks: list[Block] = []
- paragraph: list[str] = []
- i = 0
-
- def flush_paragraph() -> None:
- if paragraph:
- blocks.append(Block(type="paragraph", text_runs=[TextRun(clean_inline(" ".join(paragraph)))]))
- paragraph.clear()
-
- while i < len(lines):
- line = lines[i]
- stripped = line.strip()
- if not stripped:
- flush_paragraph()
- i += 1
- continue
-
- if stripped.startswith("```") or stripped.startswith("~~~"):
- flush_paragraph()
- fence = stripped[:3]
- code_lines: list[str] = []
- i += 1
- while i < len(lines) and not lines[i].strip().startswith(fence):
- code_lines.append(lines[i])
- i += 1
- if i < len(lines):
- i += 1
- blocks.append(Block(type="code", text_runs=[TextRun("\n".join(code_lines))]))
- continue
-
- heading = HEADING_RE.match(line)
- if heading:
- flush_paragraph()
- blocks.append(Block(type="heading", text_runs=[TextRun(clean_inline(heading.group(2)))]))
- i += 1
- continue
-
- if _looks_like_table(lines, i):
- flush_paragraph()
- table, consumed = _parse_table(lines, i)
- blocks.append(table)
- i += consumed
- continue
-
- item = LIST_RE.match(line)
- if item:
- flush_paragraph()
- items: list[Block] = []
- while i < len(lines):
- match = LIST_RE.match(lines[i])
- if not match:
- break
- items.append(Block(type="list_item", text_runs=[TextRun(clean_inline(match.group(1)))]))
- i += 1
- blocks.append(Block(type="list", children=items))
- continue
-
- quote = QUOTE_RE.match(line)
- if quote:
- flush_paragraph()
- quote_lines: list[str] = []
- while i < len(lines):
- match = QUOTE_RE.match(lines[i])
- if not match:
- break
- quote_lines.append(match.group(1))
- i += 1
- blocks.append(Block(type="quote", text_runs=[TextRun(clean_inline(" ".join(quote_lines)))]))
- continue
-
- paragraph.append(stripped)
- i += 1
-
- flush_paragraph()
- return blocks
-
-
-def clean_inline(text: str) -> str:
- text = re.sub(r"!\[([^\]]*)\]\([^)]+\)", r"\1", text)
- text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
- text = re.sub(r"([*_`~]{1,3})(.*?)\1", r"\2", text)
- text = text.replace("\\", "")
- return text
-
-
-def _looks_like_table(lines: list[str], i: int) -> bool:
- return i + 1 < len(lines) and "|" in lines[i] and TABLE_SEP_RE.match(lines[i + 1]) is not None
-
-
-def _parse_table(lines: list[str], i: int) -> tuple[Block, int]:
- rows: list[Block] = []
- consumed = 0
- while i + consumed < len(lines):
- line = lines[i + consumed]
- stripped = line.strip()
- if not stripped or "|" not in stripped:
- break
- if consumed == 1 and TABLE_SEP_RE.match(stripped):
- consumed += 1
- continue
- cells = [cell.strip() for cell in stripped.strip("|").split("|")]
- row = Block(
- type="tr",
- children=[
- Block(type="table_cell", text_runs=[TextRun(clean_inline(cell))])
- for cell in cells
- if cell
- ],
- )
- rows.append(row)
- consumed += 1
- return Block(type="table", children=rows), consumed
-
-# ---------------------------------------------------------------------------
-# XML parser
-# ---------------------------------------------------------------------------
-
-INLINE_TAGS = {
- "b",
- "strong",
- "i",
- "em",
- "u",
- "s",
- "del",
- "span",
- "text",
- "plain_text",
- "code",
- "a",
- "link",
- "mention",
- "mention-doc",
- "mention-user",
-}
-
-TYPE_ALIASES = {
- "doc": "document",
- "document": "document",
- "fragment": "fragment",
- "p": "paragraph",
- "paragraph": "paragraph",
- "heading": "heading",
- "h1": "heading",
- "h2": "heading",
- "h3": "heading",
- "h4": "heading",
- "h5": "heading",
- "h6": "heading",
- "h7": "heading",
- "h8": "heading",
- "h9": "heading",
- "ul": "list",
- "ol": "list",
- "li": "list_item",
- "task": "task",
- "todo": "list_item",
- "blockquote": "quote",
- "quote": "quote",
- "br": "br",
- "hr": "hr",
- "title": "title",
- "checkbox": "checkbox",
- "grid": "grid",
- "column": "column",
- "table": "table",
- "colgroup": "colgroup",
- "col": "col",
- "tr": "tr",
- "td": "table_cell",
- "th": "table_cell",
- "pre": "code",
- "code_block": "code",
- "callout": "callout",
- "figure": "figure",
- "toggle": "toggle",
- "img": "image",
- "source": "source",
- "file": "file",
- "media": "media",
- "latex": "latex",
- "cite": "cite",
- "bookmark": "bookmark",
- "button": "button",
- "whiteboard": "whiteboard",
- "mermaid": "mermaid",
- "plantuml": "plantuml",
- "poll": "poll",
- "isv": "isv",
- "mindnote": "mindnote",
- "diagram": "diagram",
- "sheet": "sheet",
- "bitable": "bitable",
- "base-ref": "base_ref",
- "base_ref": "base_ref",
- "base-refer": "base_ref",
- "base_refer": "base_ref",
- "synced-reference": "synced_reference",
- "synced_reference": "synced_reference",
- "synced-source": "synced_source",
- "synced_source": "synced_source",
- "okr": "okr",
- "chat-card": "chat_card",
- "chat_card": "chat_card",
- "sub_page_list": "sub-page-list",
- "sub-page-list": "sub-page-list",
-}
-
-SUBTYPE_ATTR_TAGS = {
- "a",
- "button",
- "cite",
- "img",
- "sheet",
- "source",
- "whiteboard",
- "base-ref",
- "base_ref",
- "base-refer",
- "base_refer",
- "synced-reference",
- "synced_reference",
- "synced-source",
- "synced_source",
- "okr",
- "chat-card",
- "chat_card",
- "sub_page_list",
- "sub-page-list",
-}
-
-MAX_XML_INPUT_CHARS = 20_000_000
-FORBIDDEN_XML_DECL_RE = re.compile(r" str:
- if "}" in tag:
- return tag.rsplit("}", 1)[1]
- return tag
-
-
-def block_type_for(elem: ET.Element) -> str:
- tag = local_name(elem.tag)
- explicit = elem.attrib.get("block_type")
- if explicit is None and tag not in SUBTYPE_ATTR_TAGS:
- explicit = elem.attrib.get("type")
- if explicit:
- return TYPE_ALIASES.get(explicit, explicit)
- return TYPE_ALIASES.get(tag, tag)
-
-
-def ensure_safe_xml_source(source: str) -> None:
- if len(source) > MAX_XML_INPUT_CHARS:
- raise UserInputError(
- f"XML input is too large ({len(source)} chars, limit {MAX_XML_INPUT_CHARS})"
- )
- if FORBIDDEN_XML_DECL_RE.search(source):
- raise UserInputError("XML input must not contain DOCTYPE or ENTITY declarations")
-
-
-def parse_xml(source: str) -> list[Block]:
- source = source.strip()
- if not source:
- return []
- ensure_safe_xml_source(source)
- try:
- root = ET.fromstring(source)
- except ET.ParseError:
- # docs +fetch raw output can occasionally include adjacent top-level
- # blocks. Wrap them so standard ElementTree can parse the stream.
- root = ET.fromstring(f"{source}")
- return [_parse_block(root)]
-
-
-def _parse_block(elem: ET.Element) -> Block:
- block = Block(type=block_type_for(elem), attrs=dict(elem.attrib), raw=elem)
- _collect_content(elem, block)
- if not block.text_runs and not block.children:
- if block.type == "image":
- display = elem.attrib.get("caption")
- else:
- display = (
- elem.attrib.get("text")
- or elem.attrib.get("name")
- or elem.attrib.get("title")
- or elem.attrib.get("alt")
- or elem.attrib.get("caption")
- )
- if display:
- block.text_runs.append(TextRun(display, dict(elem.attrib)))
- return block
-
-
-def _collect_content(elem: ET.Element, block: Block) -> None:
- if elem.text:
- block.text_runs.append(TextRun(elem.text))
-
- for child in list(elem):
- tag = local_name(child.tag)
- if tag == "br":
- block.text_runs.append(TextRun("\n"))
- elif tag in INLINE_TAGS:
- _collect_inline(child, block)
- else:
- block.children.append(_parse_block(child))
- if child.tail:
- block.text_runs.append(TextRun(child.tail))
-
-
-def _collect_inline(elem: ET.Element, block: Block) -> None:
- if local_name(elem.tag) == "br":
- block.text_runs.append(TextRun("\n", dict(elem.attrib)))
- return
-
- display = (
- elem.attrib.get("text")
- or elem.attrib.get("name")
- or elem.attrib.get("title")
- or elem.attrib.get("alt")
- )
- if display:
- block.text_runs.append(TextRun(display, dict(elem.attrib)))
- return
-
- if elem.text:
- block.text_runs.append(TextRun(elem.text, dict(elem.attrib)))
- for child in list(elem):
- _collect_inline(child, block)
- if child.tail:
- block.text_runs.append(TextRun(child.tail))
-
-# ---------------------------------------------------------------------------
-# Block extraction registry
-# ---------------------------------------------------------------------------
-
-@dataclass
-class ExtractContext:
- unknown_blocks: list[UnknownBlock] = field(default_factory=list)
- resource_texts: dict[str, str] = field(default_factory=dict)
-
-
-class Handler(Protocol):
- def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
- raise NotImplementedError
-
-
-def block_id(block: Block) -> str | None:
- for key in ("id", "block_id", "block-id", "token"):
- value = block.attrs.get(key)
- if isinstance(value, str) and value:
- return value
- return None
-
-
-def runs_text(block: Block) -> str:
- return "".join(run.text for run in block.text_runs)
-
-
-def raw_tag(block: Block) -> str:
- tag = getattr(getattr(block, "raw", None), "tag", "") or ""
- if "}" in tag:
- return tag.rsplit("}", 1)[1]
- return tag
-
-
-class TextBlockHandler:
- def __init__(self, kind: str = "text") -> None:
- self.kind = kind
-
- def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
- segments: list[Segment] = []
- text = runs_text(block)
- if text.strip():
- segments.append(
- Segment(
- text=text,
- block_type=block.type,
- block_id=block_id(block),
- kind=self.kind,
- )
- )
- for child in block.children:
- segments.extend(registry.extract(child, ctx))
- return segments
-
-
-class ContainerHandler:
- def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
- segments: list[Segment] = []
- text = runs_text(block)
- if text.strip():
- segments.append(
- Segment(text=text, block_type=block.type, block_id=block_id(block), kind="text")
- )
- for child in block.children:
- segments.extend(registry.extract(child, ctx))
- return segments
-
-
-class ListHandler:
- def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
- tag = raw_tag(block)
- if tag not in {"ol", "ul"}:
- return ContainerHandler().extract(block, registry, ctx)
-
- segments: list[Segment] = []
- text = runs_text(block)
- if text.strip():
- segments.append(
- Segment(text=text, block_type=block.type, block_id=block_id(block), kind="text")
- )
-
- next_seq = 1
- for child in block.children:
- if child.type == "list_item":
- if tag == "ol":
- seq = child.attrs.get("seq")
- if isinstance(seq, str) and seq.isdigit():
- marker = seq
- next_seq = int(seq) + 1
- else:
- marker = str(next_seq)
- next_seq += 1
- segments.append(
- Segment(
- text=f"{marker}.",
- block_type="list_marker",
- block_id=block_id(child),
- kind="text",
- )
- )
- else:
- segments.append(
- Segment(
- text="•",
- block_type="list_marker",
- block_id=block_id(child),
- kind="marker",
- )
- )
- segments.extend(registry.extract(child, ctx))
- return segments
-
-
-class CheckboxHandler(TextBlockHandler):
- def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
- return [
- Segment(
- text="☑" if block.attrs.get("done") == "true" else "☐",
- block_type="checkbox_marker",
- block_id=block_id(block),
- kind="marker",
- ),
- *super().extract(block, registry, ctx),
- ]
-
-
-class UnknownHandler:
- def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
- ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block)))
- return ContainerHandler().extract(block, registry, ctx)
-
-
-class IgnoreHandler:
- def __init__(self, action: str = "ignored") -> None:
- self.action = action
-
- def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
- ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block), action=self.action))
- return []
-
-
-class TaskHandler:
- def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
- task_id = block.attrs.get("task-id") or block.attrs.get("task_id")
- if isinstance(task_id, str) and task_id:
- text = ctx.resource_texts.get(f"task:{task_id}")
- if text and text.strip():
- marker = "☑" if block.attrs.get("status") in {"done", "completed", "complete"} else "☐"
- return [
- Segment(text=marker, block_type="task_marker", block_id=block_id(block), kind="marker"),
- Segment(text=text, block_type="task", block_id=block_id(block), kind="resource_title"),
- ]
-
- ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block), action="ignored_resource"))
- return []
-
-
-class WhiteboardHandler:
- def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
- board_type = block.attrs.get("type")
- is_empty_resource_shell = not board_type and not block.children and not runs_text(block).strip()
- action = (
- "ignored_resource"
- if board_type in {"blank", "mermaid", "plantuml", "svg"} or is_empty_resource_shell
- else "unsupported_resource"
- )
- ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block), action=action))
- return []
-
-
-class SyncedSourceHandler:
- def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
- if block.children:
- segments: list[Segment] = []
- for child in block.children:
- segments.extend(registry.extract(child, ctx))
- return segments
-
- ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block), action="unsupported_resource"))
- return []
-
-
-class Registry:
- def __init__(self) -> None:
- self._handlers: dict[str, Handler] = {}
- self._unknown = UnknownHandler()
-
- def register(self, *types: str, handler: Handler) -> None:
- for typ in types:
- self._handlers[typ] = handler
-
- def extract(self, block: Block, ctx: ExtractContext) -> list[Segment]:
- handler = self._handlers.get(block.type, self._unknown)
- return handler.extract(block, self, ctx)
-
-
-def default_registry() -> Registry:
- registry = Registry()
- registry.register("document", "fragment", "root", handler=ContainerHandler())
- registry.register("title", handler=TextBlockHandler("title"))
- registry.register("paragraph", "p", handler=TextBlockHandler("text"))
- registry.register(
- "heading",
- "h",
- "h1",
- "h2",
- "h3",
- "h4",
- "h5",
- "h6",
- "h7",
- "h8",
- "h9",
- handler=TextBlockHandler("heading"),
- )
- registry.register("list", "ul", "ol", handler=ListHandler())
- registry.register("list_item", "li", "todo", handler=TextBlockHandler("list_item"))
- registry.register("checkbox", handler=CheckboxHandler("list_item"))
- registry.register(
- "quote",
- "blockquote",
- "callout",
- "toggle",
- "grid",
- "column",
- "figure",
- handler=ContainerHandler(),
- )
- registry.register("table", "thead", "tbody", "tr", handler=ContainerHandler())
- registry.register("table_cell", "td", "th", handler=TextBlockHandler("table_cell"))
- registry.register("code", "code_block", "pre", handler=TextBlockHandler("code"))
- registry.register("link", "a", "mention", "mention-doc", "mention-user", "time", handler=TextBlockHandler("inline"))
- registry.register("image", "img", handler=TextBlockHandler("caption"))
- registry.register("colgroup", "col", "br", "hr", handler=IgnoreHandler("ignored_structure"))
- registry.register("button", "cite", "latex", "bookmark", handler=IgnoreHandler("ignored_inline"))
- registry.register("task", handler=TaskHandler())
- registry.register("whiteboard", handler=WhiteboardHandler())
- registry.register("synced_source", handler=SyncedSourceHandler())
- registry.register(
- "mermaid",
- "sheet",
- "source",
- "file",
- "media",
- "chat_card",
- "base_ref",
- "bitable",
- "synced_reference",
- "poll",
- "isv",
- "mindnote",
- "diagram",
- "sub-page-list",
- handler=IgnoreHandler("ignored_resource"),
- )
- registry.register(
- "okr",
- "plantuml",
- handler=IgnoreHandler("unsupported_resource"),
- )
- return registry
-
-# ---------------------------------------------------------------------------
-# CLI
-# ---------------------------------------------------------------------------
-
-VERSION = "0.1-alpha"
-
-
-def build_diagnostics(items: list) -> dict[str, object]:
- actions: dict[str, int] = {}
- types: dict[str, int] = {}
- unsupported_types: dict[str, int] = {}
- unknown_types: dict[str, int] = {}
- for item in items:
- actions[item.action] = actions.get(item.action, 0) + 1
- types[item.type] = types.get(item.type, 0) + 1
- if item.action == "unsupported_resource":
- unsupported_types[item.type] = unsupported_types.get(item.type, 0) + 1
- if item.action == "recurse_children":
- unknown_types[item.type] = unknown_types.get(item.type, 0) + 1
- return {
- "actions": actions,
- "types": types,
- "unsupported_types": unsupported_types,
- "unknown_types": unknown_types,
- "has_unsupported": bool(unsupported_types),
- "has_unknown": bool(unknown_types),
- }
-
-
-def read_input(path: str) -> str:
- if path == "-":
- return sys.stdin.read()
- return Path(path).read_text(encoding="utf-8")
-
-
-def read_resource_texts(path: str | None) -> dict[str, str]:
- if not path:
- return {}
- payload = json.loads(Path(path).read_text(encoding="utf-8"))
- if not isinstance(payload, dict):
- raise ValueError("--resource-texts must be a JSON object")
- return {str(key): str(value) for key, value in payload.items()}
-
-
-def extract_lark_json_content(source: str) -> str:
- try:
- envelope = json.loads(source)
- except json.JSONDecodeError as exc:
- raise UserInputError(f"could not parse lark-cli JSON envelope: {exc}") from exc
-
- if not isinstance(envelope, dict):
- raise UserInputError("lark-cli JSON envelope must be an object")
- data = envelope.get("data")
- if not isinstance(data, dict):
- raise UserInputError("lark-cli JSON envelope is missing object field data")
- document = data.get("document")
- if not isinstance(document, dict):
- raise UserInputError("lark-cli JSON envelope is missing object field data.document")
- content = document.get("content")
- if not isinstance(content, str):
- raise UserInputError("lark-cli JSON envelope is missing string field data.document.content")
- return content
-
-
-HELP_EPILOG = """
-Examples:
- Local XML file:
- python3 doc_word_stat.py --protocol xml /absolute/path/doc.xml
-
- Local Markdown file:
- python3 doc_word_stat.py --protocol md /absolute/path/doc.md
-
- Pipe an extracted local file:
- cat /absolute/path/doc.xml | python3 doc_word_stat.py --protocol xml --pretty
-
- Lark CLI XML fetch, JSON envelope output:
- lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \\
- | python3 doc_word_stat.py --protocol xml --lark-json --pretty
-
- Lark CLI Markdown fetch, raw content output:
- lark-cli docs +fetch --doc "$URL" --doc-format markdown \\
- | python3 doc_word_stat.py --protocol md
-
- Strict integration for agents or automation:
- lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \\
- | python3 doc_word_stat.py --protocol xml --lark-json --fail-on-unsupported --fail-on-unknown
-"""
-
-
-def parse_args() -> argparse.Namespace:
- parser = argparse.ArgumentParser(
- description="Count semantic words and visible characters in Lark Docs XML or Markdown.",
- epilog=HELP_EPILOG,
- formatter_class=argparse.RawDescriptionHelpFormatter,
- )
- parser.add_argument(
- "--version",
- action="version",
- version=f"%(prog)s {VERSION}",
- )
- parser.add_argument(
- "input",
- nargs="?",
- default="-",
- help="input file path, or '-' / omitted for stdin",
- )
- parser.add_argument(
- "--protocol",
- choices=("xml", "md"),
- required=True,
- help="input protocol produced by docs +fetch",
- )
- parser.add_argument(
- "--pretty",
- action="store_true",
- help="pretty-print JSON output",
- )
- parser.add_argument(
- "--segments",
- action="store_true",
- help="include extracted text segments for debugging",
- )
- parser.add_argument(
- "--lark-json",
- action="store_true",
- help="read lark-cli docs +fetch JSON and count data.document.content",
- )
- parser.add_argument(
- "--resource-texts",
- help='optional JSON object mapping resource keys to visible text, e.g. {"task:": "title"}',
- )
- parser.add_argument(
- "--fail-on-unsupported",
- action="store_true",
- help="exit with code 2 when unsupported_blocks is non-empty",
- )
- parser.add_argument(
- "--fail-on-unknown",
- action="store_true",
- help="exit with code 3 when unknown XML/Markdown block types are encountered",
- )
- return parser.parse_args()
-
-
-def main() -> int:
- args = parse_args()
- source = read_input(args.input)
- if args.lark_json:
- try:
- source = extract_lark_json_content(source)
- except UserInputError as exc:
- print(f"error: {exc}", file=sys.stderr)
- return 1
-
- if args.protocol == "xml":
- try:
- blocks = parse_xml(source)
- except (ET.ParseError, UserInputError) as exc:
- print(f"error: could not parse XML input: {exc}", file=sys.stderr)
- return 1
- else:
- blocks = parse_markdown(source)
-
- ctx = ExtractContext(resource_texts=read_resource_texts(args.resource_texts))
- registry = default_registry()
- segments = []
- for block in blocks:
- segments.extend(registry.extract(block, ctx))
-
- stats = Counter().count_segments(segments)
- payload = stats.to_dict()
- payload["protocol"] = args.protocol
- payload["unknown_blocks"] = [item.to_dict() for item in ctx.unknown_blocks]
- payload["unsupported_blocks"] = [
- item.to_dict() for item in ctx.unknown_blocks if item.action == "unsupported_resource"
- ]
- payload["diagnostics"] = build_diagnostics(ctx.unknown_blocks)
- if args.segments:
- payload["segments"] = [segment.to_dict() for segment in segments]
-
- indent = 2 if args.pretty else None
- print(json.dumps(payload, ensure_ascii=False, indent=indent, sort_keys=args.pretty))
- if args.fail_on_unsupported and payload["unsupported_blocks"]:
- return 2
- if args.fail_on_unknown and payload["diagnostics"]["has_unknown"]:
- return 3
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
-
diff --git a/tests/cli_e2e/docs/coverage.md b/tests/cli_e2e/docs/coverage.md
index 77e243c179..9f83b90b78 100644
--- a/tests/cli_e2e/docs/coverage.md
+++ b/tests/cli_e2e/docs/coverage.md
@@ -1,9 +1,9 @@
# Docs CLI E2E Coverage
## Metrics
-- Denominator: 11 leaf commands
-- Covered: 6
-- Coverage: 54.5%
+- Denominator: 12 leaf commands
+- Covered: 7
+- Coverage: 58.3%
## Summary
- TestDocs_CreateAndFetchWorkflow: proves `docs +create` and `docs +fetch`; key `t.Run(...)` proof points are `create as bot` and `fetch as bot`.
@@ -11,6 +11,7 @@
- TestDocs_UpdateWorkflow: proves `docs +update` via `update-title-and-content as bot`, then re-fetches the same doc in `verify as bot` to assert persisted title/content changes.
- TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance; its fetch case asserts fetch sends the default `extra_param`, and its update case asserts `--reference-map` is sent as request body `reference_map`.
- TestDocs_CreateTitleDryRunPrependsContent: proves `docs +create --title` dry-run prepends an escaped `...` tag to request body `content`.
+- TestDocsScriptParseXMLFromFile, TestDocsScriptConvertsMarkdown, and TestDocsScriptDryRunIsLocal prove `docs +script` parses `@file` XML without changing it, converts Markdown, returns word/character/block profiles, and performs no API call.
- TestDocs_DryRunDefaultsToV2OpenAPI also proves `docs +history-list`, `docs +history-revert`, and `docs +history-revert-status` dry-run endpoint and query/body shapes.
- TestDocs_HistoryWorkflow proves the guarded live history flow (`LARK_DOC_HISTORY_E2E=1`): create, update, list prior revisions, revert, poll status when needed, and fetch to verify reverted content.
- Setup note: docs workflows create a Drive folder through `drive files create_folder` in `helpers_test.go`; that helper is external to the docs domain and is not counted here.
@@ -25,6 +26,7 @@
| ✓ | docs +history-list | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history list; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--page-size`; `--page-token` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
| ✓ | docs +history-revert | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--history-version-id`; `--wait-timeout-ms` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
| ✓ | docs +history-revert-status | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert status; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--task-id` | live workflow polls only when revert returns `running` |
+| ✓ | docs +script | shortcut | docs_script_test.go::TestDocsScriptParseXMLFromFile; docs_script_test.go::TestDocsScriptParseMarkdownFromFile; docs_script_test.go::TestDocsScriptConvertsMarkdown; docs_script_test.go::TestDocsScriptDryRunIsLocal | `--command parse`; `--command markdown-to-xml`; `--content @file`; local dry-run | auto-detects XML/Markdown for profile parsing or converts Markdown to XML; no API call |
| ✕ | docs +media-download | shortcut | | none | no media fixture workflow yet |
| ✕ | docs +media-insert | shortcut | | none | requires deterministic upload fixture and rollback assertions |
| ✕ | docs +media-preview | shortcut | | none | requires deterministic media fixture |
diff --git a/tests/cli_e2e/docs/docs_script_test.go b/tests/cli_e2e/docs/docs_script_test.go
new file mode 100644
index 0000000000..84ae18ed02
--- /dev/null
+++ b/tests/cli_e2e/docs/docs_script_test.go
@@ -0,0 +1,123 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package docs
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ clie2e "github.com/larksuite/cli/tests/cli_e2e"
+ "github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
+)
+
+func TestDocsScriptParseXMLFromFile(t *testing.T) {
+ workDir := t.TempDir()
+ input := `标题一个苹果是 an apple。
`
+ if err := os.WriteFile(filepath.Join(workDir, "draft.xml"), []byte(input), 0o600); err != nil {
+ t.Fatalf("write draft: %v", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ t.Cleanup(cancel)
+ result, err := clie2e.RunCmd(ctx, clie2e.Request{
+ Args: []string{
+ "docs", "+script",
+ "--command", "parse",
+ "--content", "@draft.xml",
+ },
+ DefaultAs: "bot",
+ WorkDir: workDir,
+ Env: docsScriptE2EEnv(),
+ })
+ require.NoError(t, err)
+ result.AssertExitCode(t, 0)
+ result.AssertStdoutStatus(t, true)
+ require.Equal(t, int64(10), gjson.Get(result.Stdout, "data.profile.word_count").Int())
+ require.Equal(t, int64(15), gjson.Get(result.Stdout, "data.profile.char_count").Int())
+ require.Equal(t, int64(2), gjson.Get(result.Stdout, "data.profile.block_count").Int())
+ require.False(t, gjson.Get(result.Stdout, "data.xml").Exists())
+ require.False(t, gjson.Get(result.Stdout, "data.input_format").Exists())
+ require.False(t, gjson.Get(result.Stdout, "data.command").Exists())
+ require.False(t, gjson.Get(result.Stdout, "data.profile.breakdown").Exists())
+ require.False(t, gjson.Get(result.Stdout, "data.profile.compatibility").Exists())
+}
+
+func TestDocsScriptParseMarkdownFromFile(t *testing.T) {
+ workDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(workDir, "draft.md"), []byte("# 标题\n\n- item"), 0o600); err != nil {
+ t.Fatalf("write draft: %v", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ t.Cleanup(cancel)
+ result, err := clie2e.RunCmd(ctx, clie2e.Request{
+ Args: []string{
+ "docs", "+script",
+ "--command", "parse",
+ "--content", "@draft.md",
+ },
+ DefaultAs: "bot",
+ WorkDir: workDir,
+ Env: docsScriptE2EEnv(),
+ })
+ require.NoError(t, err)
+ result.AssertExitCode(t, 0)
+ result.AssertStdoutStatus(t, true)
+ require.Equal(t, int64(3), gjson.Get(result.Stdout, "data.profile.block_count").Int())
+ require.False(t, gjson.Get(result.Stdout, "data.xml").Exists())
+ require.False(t, gjson.Get(result.Stdout, "data.profile.breakdown").Exists())
+}
+
+func TestDocsScriptConvertsMarkdown(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ t.Cleanup(cancel)
+ result, err := clie2e.RunCmd(ctx, clie2e.Request{
+ Args: []string{
+ "docs", "+script",
+ "--command", "markdown-to-xml",
+ "--content", "# 标题\n\n- item",
+ },
+ DefaultAs: "bot",
+ Env: docsScriptE2EEnv(),
+ })
+ require.NoError(t, err)
+ result.AssertExitCode(t, 0)
+ result.AssertStdoutStatus(t, true)
+ require.Equal(t, `标题
`, gjson.Get(result.Stdout, "data.xml").String())
+ require.False(t, gjson.Get(result.Stdout, "data.profile").Exists())
+ require.False(t, gjson.Get(result.Stdout, "data.input_format").Exists())
+ require.False(t, gjson.Get(result.Stdout, "data.command").Exists())
+}
+
+func TestDocsScriptDryRunIsLocal(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ t.Cleanup(cancel)
+ result, err := clie2e.RunCmd(ctx, clie2e.Request{
+ Args: []string{
+ "docs", "+script",
+ "--command", "parse",
+ "--content", `text
`,
+ "--dry-run",
+ },
+ DefaultAs: "bot",
+ Env: docsScriptE2EEnv(),
+ })
+ require.NoError(t, err)
+ result.AssertExitCode(t, 0)
+ require.Equal(t, int64(0), gjson.Get(result.Stdout, "api.#").Int())
+ require.False(t, gjson.Get(result.Stdout, "network").Bool())
+ require.Equal(t, "parse", gjson.Get(result.Stdout, "command").String())
+}
+
+func docsScriptE2EEnv() map[string]string {
+ return map[string]string{
+ "LARKSUITE_CLI_APP_ID": "docs-script-e2e",
+ "LARKSUITE_CLI_APP_SECRET": "secret",
+ "LARKSUITE_CLI_BRAND": "feishu",
+ }
+}