Skip to content

feat(mail): HTML lint lib + Larksuite-native autofix + lark-mail skill#5

Open
bubbmon233 wants to merge 61 commits into
mainfrom
feat/lark-mail
Open

feat(mail): HTML lint lib + Larksuite-native autofix + lark-mail skill#5
bubbmon233 wants to merge 61 commits into
mainfrom
feat/lark-mail

Conversation

@bubbmon233
Copy link
Copy Markdown
Owner

Summary

Adds an HTML lint library + Larksuite-native autofix to lark-cli mail, plus the skills/lark-mail/ skill bundle (2 reference docs, 5 HTML templates, the +lint-html shortcut, and writing-path lint integration across all 6 compose shortcuts).

What's in this PR

1. Lint library (shortcuts/mail/lint/)

3-tier rule set:

  • Error: drop dangerous tags (<script> / <iframe> / <form> / <input> / <link> / <object> / <embed>), on* event handlers, and javascript: / vbscript: / file: URLs.
  • Warning + autofix: rewrite HTML4-era tags (<font> / <center> / <marquee> / <blink>).
  • Larksuite-native autofix: rewrite <p> / <ul> / <ol> / <li> / <blockquote> / <a> to mail-editor native markup so AI can write the simplest HTML and still produce native-quality rendering.
  • Inline-style and URL-scheme allow-list filtering.
  • <style> block passthrough (server adds CSS scope class).

2. +lint-html shortcut (preview / CI)

Read-only HTML preview tool. Default envelope returns only cleaned_html; --show-lint-details adds full warnings[] / errors[]. --strict exits non-zero on any finding (CI gate).

3. Writing-path lint in the 6 compose shortcuts

+send / +draft-create / +reply / +reply-all / +forward / +draft-edit body op all run lint before drafting:

  • lint_applied_count / original_blocked_count — always present.
  • lint_applied[] / original_blocked[] — only with --show-lint-details.
  • compose_hint — points AI consumers to the HTML writing guide.

4. skills/lark-mail/ skill bundle

  • 5 pre-rendered Larksuite-native HTML templates: weekly newsletter, personal weekly report, team weekly report, market research report, résumé.
  • 2 reference docs:
    • references/lark-mail-html.md — writing rules + format primitives + template-usage flow.
    • references/lark-mail-lint-html.md+lint-html usage + return-value contract + 9 examples.
  • SKILL.md updates linking the new docs and templates.

5. Sealed conventions

Fixed writing conventions enforced by the lint library, the Larksuite mail-editor data model, or the upstream service-side sanitiser.

  • @user mention chipid="at-user-N" is the only hard requirement; do not write data-user-id.
  • Highlight palette — 3 colors (pink milestones, yellow follow-ups, green completed); black text, no bold / padding / border-radius.
  • Brand color palette — main black, 3 levels of grey, Lark blue / deep blue, alert red, emergency orange, light pink / light grey backgrounds, border grey.
  • URL scheme allow-listhttp(s): / mailto: / cid: / data:image/* only.
  • Inline style allow-list — font-* / color / background-color / text-* / line-height / letter-spacing / vertical-align / margin* / padding* / width / height / display / border* / list-style* / white-space / word-break / overflow / transition / cursor / opacity.
  • Tag allow-list<p> / <div> / <span> / <a> / <img> / <table> (with <thead> / <tbody> / <tfoot> / <tr> / <td> / <th> / <caption> / <colgroup> / <col>) / <ul> / <ol> / <li> / <blockquote> / <h1>-<h6> / <b> / <i> / <em> / <strong> / <u> / <s> / <sub> / <sup> / <pre> / <code> / <style>.
  • Writing-style floor — subject ≤ 50 chars; decision-first; lists instead of "一、二、三" / "①②③"; emoji only as status tags; greeting / sign-off ≤ 1 paragraph each.

Tests

  • shortcuts/mail/lint/... — unit tests for every rule.
  • shortcuts/mail/mail_lint_html_test.go+lint-html envelope contract.
  • shortcuts/mail/mail_lint_writepath_test.go — writing-path envelope contract.
  • 5 templates verified via +draft-create smoke test.

Test plan

  • go test ./shortcuts/mail/lint/... ./shortcuts/mail/...
  • All 5 templates render correctly via +draft-create smoke
  • Default vs --show-lint-details envelope verified for both +lint-html and +draft-create

herbertliu and others added 10 commits May 15, 2026 14:38
…suite#392)

Introduce three new wiki shortcuts that wrap the corresponding raw APIs
with structured flags, formatted output, my_library alias handling, and
unified envelope shape, replacing the bare `lark-cli wiki spaces list`
/ `wiki nodes list` / `wiki nodes copy` flows for the common cases.

Shortcuts
- wiki +space-list (read, scopes: wiki:space:retrieve):
  lists wiki spaces. Default fetches a single page; --page-all walks
  every page capped by --page-limit (default 10, 0 = unlimited).
  Supports --page-size / --page-token / --format json|pretty|table|csv|ndjson.
  Output: {spaces, has_more, page_token} + Meta.Count. Pretty mode
  distinguishes "no spaces" from "empty page with has_more" and hints
  the caller to resume.

- wiki +node-list (read, scopes: wiki:node:retrieve):
  lists nodes in a space or under a parent. Same pagination + format
  story as +space-list. Accepts the my_library alias for --space-id
  with --as user (resolved via a shared resolveMyLibrarySpaceID helper
  extracted from +node-create); rejects my_library upfront for --as bot.

- wiki +node-copy (high-risk-write, scopes: wiki:node:copy):
  copies a node into a target space or parent. --target-space-id and
  --target-parent-node-token are mutually exclusive. Risk is marked
  high-risk-write to match the upstream API's danger: true flag, so the
  framework requires --yes. Source is preserved; subtree is copied.

Both list shortcuts pick the narrowest scope the upstream API accepts.
The framework's preflight (internal/auth/scope.go MissingScopes) does
exact-string scope matching, so declaring the broader wiki:wiki:readonly
form would wrongly reject tokens that carry only the per-API scope —
which the API itself accepts — and emit a misleading missing-scope hint.

Shared changes
- shortcuts/wiki/wiki_node_create.go: factor out resolveMyLibrarySpaceID
  so +node-list and +node-create share one my_library resolution path.
- shortcuts/wiki/shortcuts.go: register the three new shortcuts.
- skills/lark-wiki/SKILL.md and references/lark-wiki-{space,node-list,
  node-copy}.md: documentation for the new shortcuts.

Tooling
- scripts/check-doc-tokens.sh + Makefile gitleaks target:
  pre-commit check that scans skill reference docs for realistic-looking
  Lark token values without the _EXAMPLE_TOKEN placeholder convention,
  preventing gitleaks false positives.
- .gitleaks.toml: allowlist tuning.
- .gitignore: ignore .tmp/.

Tests
- shortcuts/wiki/wiki_list_copy_test.go: unit tests covering registry
  membership, declared-narrow-scope pinning, flag validation (page-size
  range, page-limit >= 0, target flag exclusivity, my_library + bot
  rejection), auto-pagination merging, --page-limit truncation
  surfacing next cursor, --page-token single-page mode, empty-slice
  serialisation, has_more hint pretty rendering, my_library user-path
  resolution, +node-copy copy-to-space / copy-to-parent + body shape,
  pretty rendering, and the high-risk-write --yes gate.
- tests/cli_e2e/wiki/wiki_shortcut_workflow_test.go: live end-to-end
  workflow exercising the shortcut layer against a real tenant.
  Reuses an existing my_library node as a host so the test never adds
  to the top-layer quota; the copy is placed under the same host node.
- tests/cli_e2e/wiki/coverage.md: shortcut coverage entries added.

Minor cleanups
- skills/lark-doc/references/lark-doc-search.md and
  skills/lark-minutes/references/lark-minutes-search.md: replace
  realistic-looking example ou_ tokens with _EXAMPLE_ placeholders so
  scripts/check-doc-tokens.sh passes.

Change-Id: I9efb0557f477d369d7f26a09c1e154d4ab15b253

Co-authored-by: liujinkun <liujinkun@bytedance.com>
Change-Id: Icada6fb894aaf9a00187fa68c132d3ade8223b99
…e#832)

* feat(doc): add width/height params to buildBatchUpdateData

Extend buildBatchUpdateData signature with width and height int params.
When mediaType is "image" and either dimension is positive, the value is
included in the replace_image payload. Existing call sites pass 0, 0.

* feat(doc): add --width/--height flags with validation to docs +media-insert

* feat(doc): add aspect-ratio auto-calculation helpers

Add computeMissingDimension (pure ratio math) and detectImageDimensions
(header-only image.DecodeConfig) with PNG/JPEG/GIF blank-import decoders,
plus imageDimensions struct; drive with two new TDD tests.

* feat(doc): wire --width/--height into Execute with aspect-ratio calculation

* feat(doc): add best-effort dimension computation to DryRun

* docs: add --width/--height to docs +media-insert SKILL.md

* fix: add SafeInputPath validation to detectImageDimensionsFromPath

* fix: guard computeMissingDimension against division by zero and add rounding

* fix: add dimension upper bound, fix err variable reuse in Execute

* refactor: use early-return guard for zero native dimensions per review

* fix: add pixels unit to dimension validation error messages

* fix: surface dimension detection failures in dry-run to match Execute behavior

* fix: move dimension detection before upload to fail fast

* fix: restore withRollbackWarning on dimension detection errors in Execute

Dimension detection runs after the placeholder block is created (Step 2),
so failures must clean up the block to avoid leaving an empty placeholder
in the document.
* fix(drive): preserve parent token on nested overwrite

Ensure drive +push overwrite requests for nested files keep parent_node aligned with the actual remote parent folder and report parent resolution failures explicitly.

* test(drive): cover nested overwrite push workflow

Add a live drive +push workflow case for overwriting a nested remote file so the PR parent-token fix is exercised against the real backend and verified to converge via +status.
Change-Id: I3d1a8ec4faf1ce585fb9eae45287bf02586e3e90
The skill doc claimed wiki list/copy shortcuts default to --as user, but
the CLI --as default is `auto` (no --as commonly resolves to bot, listing
the app's spaces instead of the user's). Running `wiki +space-list`
without --as therefore returns app-scoped data, contradicting the doc.

Following the established lark-mail convention (concise user-centric
guidance, not a precedence essay):
- add a short "优先使用 user 身份" section to SKILL.md
- fix the --as rows in lark-wiki-space-list / node-list / node-copy
  references to show the real `auto` default and steer to --as user

Change-Id: I539f8d622c1bbad57f8a64c2fc7b7ecc0dfe2116
sang-neo03 and others added 18 commits May 18, 2026 15:25
…ite#910)

* feat(extension): introduce Plugin / Hook framework with command pruning

Add a single public extension contract under extension/platform: integrators
implement the Plugin interface and register Observers, Wrappers, Lifecycle
handlers, and pruning Rules through the Registrar in one Install call.

Command pruning:
  - Rule (Allow / Deny / MaxRisk / Identities) with doublestar globs
  - 4-axis AND evaluation, parent-group aggregation, unknown-risk allow
  - Sources: Plugin.Restrict (single-rule) and ~/.lark-cli/policy.yml
  - Plugin path is fail-closed (envelope on rule error / multiple Restrict);
    yaml path is fail-open (warning, CLI continues)
  - strict-mode stubs now also write the denial annotation so the hook
    layer's denial guard physically isolates Wrap chains on them
  - HOME path never leaked through policy_source label

Hook framework:
  - Observer (panic-safe, Before/After), Wrapper (middleware, may short-circuit
    via AbortError), Lifecycle (Startup + Shutdown only)
  - Recover guards every plugin entry point: Capabilities(), Install(),
    Wrapper factory composition AND inner Handler, Lifecycle handlers
  - namespacedWrap copies AbortError so a plugin's package-level sentinel
    is never mutated across concurrent invocations
  - Selector unknown-risk uniform: ByExactRisk / ByWrite / ByReadOnly never
    match unannotated commands; safety-side hooks opt in via
    ByWrite().Or(ByUnknownRisk())

Bootstrap orchestration (cmd/build.go + cmd/policy.go):
  - InstallAll uses a staging Registrar + atomic commit
  - FailClosed plugin install / Plugin.Restrict conflict / Startup handler
    failure each install a structured envelope guard at every dispatch path
  - walkGuard neutralises every cobra bypass we know of (PersistentPreRunE
    first-wins, ValidateArgs, ParseFlags, legacyArgs, __complete /
    __completeNoDesc, non-runnable groups, required-arg subcommands)
  - cmd/root.go::Execute calls hook.Emit(Shutdown, runErr) after
    rootCmd.Execute; isCompletionCommand skips both __complete and
    __completeNoDesc so Tab completion never triggers Shutdown handlers

Capabilities consistency:
  - Restricts=true must declare FailurePolicy=FailClosed
  - RequiredCLIVersion (semver constraint) is validated against build.Version;
    a malformed constraint is treated as untrusted-config and aborts
    unconditionally, regardless of FailurePolicy (DEV builds included)

JSON envelope contract:
  - error.type closed enum: pruning / strict_mode / hook / plugin_install /
    plugin_conflict / plugin_lifecycle
  - reason_code closed enums per type, all referenced by structured tests

Bootstrap surfaces (new user commands):
  - lark-cli config policy show     -- JSON view of the active Rule + source
  - lark-cli config policy validate -- parse + schema + glob check, no apply

Coverage:
  - extension/platform: every public type has a unit test
  - internal/{pruning,hook,platformhost,policydecision,cmdmeta}: full coverage
    of denial guard isolation, AbortError sentinel safety, observer panic
    safety, lifecycle error/panic typing, staging atomic rollback
  - cmd/plugin_integration_test.go: end-to-end through buildInternal with
    synthetic and real command trees
  - cmd/install_guard_test.go: walkGuard covers auth / config / __complete /
    __completeNoDesc / non-runnable parents

* fix(pruning): deny stub must override Args + PersistentPreRunE

The pruning denyStub and the strict-mode stub previously only swapped
RunE plus Hidden + DisableFlagParsing. Cobra's dispatch order means
several pre-RunE gates can fire BEFORE the stub's RunE ever runs:

  1. Args validator: shortcut commands often declare cobra.NoArgs.
     With DisableFlagParsing=true the user's `--doc xxx --mode append`
     looks like positional args, so ValidateArgs surfaces a usage
     error instead of the pruning / strict_mode envelope. Observer
     hooks also miss the dispatch entirely.

  2. Parent PersistentPreRunE: cmd/auth/auth.go declares a
     PersistentPreRunE that returns external_provider when env
     credentials are set. Cobra's "first PersistentPreRunE wins
     walking up from the leaf" then short-circuits with
     external_provider instead of the leaf's denial envelope.

Both stubs now also set:

  - Args               = cobra.ArbitraryArgs   (bypass gate 1)
  - PersistentPreRunE  = no-op leaf hook       (bypass gate 2)
  - PreRunE / PreRun / PersistentPreRun = nil  (defensive)

Effect: dispatch reaches the wrapped RunE, observers fire, the real
pruning / strict_mode envelope is emitted regardless of credential
provider or flag count.

Adds regression tests covering both gates on both stub paths.

* fix(config): policy subcommand bypasses parent's credential check

cmd/config/config.go::NewCmdConfig declares a PersistentPreRunE that
calls f.RequireBuiltinCredentialProvider; with env credentials set,
it returns external_provider for every config subcommand.

`config policy show` and `config policy validate` are READ-ONLY
diagnostic commands -- they inspect or parse the user-layer rule
without touching credentials. They MUST work regardless of which
credential provider is active, otherwise users on env-credential
deployments cannot debug their policy.

Same shape as the codex C11/C13 fix: install a no-op leaf-level
PersistentPreRunE on the `policy` group so cobra's "first walking up
from leaf" rule picks ours over the config parent's.

Regression caught by divergent e2e (F1-F6 all returned external_provider
before this fix; all pass after). Adds a unit test pinning the
PersistentPreRunE override.

* feat(shortcuts): tag service groups with cmdmeta.Domain

RegisterShortcutsWithContext now calls cmdmeta.SetDomain on each
service-level cobra.Command (im, docs, drive, calendar, ...) so the
business-domain axis is actually populated on every shortcut leaf via
parent-chain inheritance.

Before this change, platform.ByDomain("docs") never matched any
command: the domain annotation was unset across the entire shortcut
tree, so the selector's d != "" guard always failed and risk-style
selectors silently degraded to no-op.

The SetDomain call is placed AFTER the create-or-reuse branch so it
fires whether the service command was freshly created here or had
already been added by cmd/service/service.go's OpenAPI auto-
registration (which runs first and creates im, drive, calendar, etc.).
Without this placement only pure-shortcut services like docs would
have been tagged.

Adds a regression test asserting:
  - service-group cobra.Command carries the cmdmeta.domain annotation
  - leaf shortcuts inherit the domain via parent-chain walk

* feat(diagnostic): add unconditionally allowed command paths for introspection

* feat(plugins): add diagnostic command to inspect installed plugins and their contributions

* fix(cli): surface unknown_subcommand error instead of silent help fallback

When a user passed an unknown subcommand or shortcut (e.g. `lark-cli drive
+bogus`), cobra returned `flag.ErrHelp` for the non-runnable group command,
printed the parent help, and exited 0. AI agents couldn't distinguish a
typo from an intentional help request.

Install a tree-wide guard that attaches a RunE to every group command
without its own Run/RunE. The RunE forwards no-args invocations to help
(preserving prior behavior) and emits a structured unknown_subcommand
ExitError (exit 2) listing available subcommands when args are present.

* refactor(envelope): rename error.type pruning/strict_mode to command_denied

The envelope's `type` field was leaking implementation terms ("pruning",
"strict_mode") that describe enforcement mechanism rather than the user-
facing semantic. It also duplicated `detail.layer`, and forced consumers
to branch on two values for the same conceptual error ("a command was
denied by policy").

Collapse both into a single semantic type "command_denied". The
enforcement layer ("pruning" / "strict_mode") is preserved in
`detail.layer` so debugging and per-layer diagnostics still work.

* feat(platform): fail closed on unannotated/invalid risk when a Rule is active

The pruning engine used to treat any command without a risk annotation as
ALLOW even when a Rule with MaxRisk was set, and would silently skip the
MaxRisk comparison whenever the command's risk string was outside the
closed taxonomy. Both gaps let an unannotated or typo'd write command
slip past an "agent read-only" pruning rule.

Engine now denies before any other axis when a Rule is registered:
  - reason_code "risk_not_annotated" for commands with no risk
  - reason_code "risk_invalid"        for commands whose risk is outside
                                      the read | write | high-risk-write
                                      taxonomy (e.g. typo "wrtie")

Main-flow is preserved: a nil Rule still returns Allowed=true
unconditionally, so a CLI with no pruning plugin behaves identically to
before. ByUnknownRisk() is removed from the public surface since the
Unknown state is no longer reachable through risk-based selectors when
any Rule is active; safety-side widening composition is no longer needed.

* chore(config): hide diagnostic policy/plugins commands from --help

`config policy show`, `config policy validate`, and `config plugins show`
are local-introspection-only commands kept behind the pruning
diagnostic whitelist so operators can always inspect why a command was
denied. They do not need to surface in `--help` for AI agents and were
contributing to help noise.

Hide the `policy` and `plugins` parent groups and both `show` /
`validate` leaves. Commands remain callable by exact name and continue
to bypass user-layer pruning via diagnosticPaths.

* style: gofmt

* fix(platform): nil Selector honours None contract; reject multi-doc policy yaml

- selector.go: And/Or/Not now treat nil Selector as None() per godoc,
  preventing runtime panic when composed selectors are invoked.
- schema.go: Parse rejects multi-document YAML input so a stray '---'
  separator can't silently drop trailing policy constraints.

* chore: go mod tidy

* feat(extension/platform): plugin SDK with policy engine, hooks, and Builder

Introduces extension/platform — the in-process plugin SDK external
Go forks of lark-cli use to extend or restrict the command surface.
Plugins compile in via blank import; there is no dynamic loading
and no RPC isolation.

Public SDK (extension/platform):

  - Plugin interface (Name / Version / Capabilities / Install).
  - Registrar verbs: Observe, Wrap, On, Restrict.
  - Hook types: Observer (side-effect, panic-safe, fires Before/After
    RunE), Wrapper (middleware, may short-circuit via AbortError),
    LifecycleHandler (Startup / Shutdown), Selector with nil-safe
    And/Or/Not composition.
  - Risk / Identity are defined string types with closed taxonomies;
    ParseRisk / ParseIdentity convert raw strings with the
    absent-vs-invalid distinction the engine relies on.
  - Builder ergonomic constructor (NewPlugin().Observer().Wrap()
    ...MustBuild()) that enforces name/hookName grammar, hookName
    uniqueness, and the Restrict ↔ FailClosed pairing regardless of
    call order.
  - Invocation is a read-only interface; the framework's concrete
    invocation type lives in internal/hook so plugins cannot
    fabricate denial / strict-mode / identity state. Args() returns
    a defensive copy on every call so hook mutation cannot leak
    into the original RunE.
  - CommandDeniedError + AbortError carry structured fields for the
    closed `command_denied` / `hook` envelope contract.
  - ResetForTesting gated behind //go:build testing.
  - README + godoc examples (Observer / Wrapper / Restrict) + two
    runnable example forks (audit-observer, readonly-policy).

Host (internal/platform, internal/hook, internal/cmdpolicy):

  - InstallAll: staged plugin registration with atomic commit, panic
    isolation, FailOpen / FailClosed semantics, RequiredCLIVersion
    semver check, single-Restrict invariant, duplicate-plugin-name
    detection.
  - hook.Install wraps every runnable cmd.RunE with:
    Before observers (panic-safe) → denial guard → composed Wrap
    chain → original RunE → After observers (always fire, even on
    err). Denied commands physically bypass the Wrap chain so a
    plugin Wrapper cannot suppress or rewrite a denial; observers
    still see the attempt for audit.
  - Recover shim around plugin Wrappers converts panics (including
    the factory call) into a structured `hook` envelope with
    reason_code=panic; namespacing shim attributes AbortError to
    the namespaced hook name.
  - cmdpolicy (renamed from internal/pruning) is the user-layer
    command policy engine: walks the cobra tree, evaluates each
    runnable command against a Rule's four-axis filter (Allow /
    Deny / MaxRisk / Identities), produces parent-group aggregate
    denials, and installs denyStubs. Rule.AllowUnannotated opts out
    of the unannotated-deny gate for gradual adoption; risk_invalid
    typos always deny with an edit-distance "did you mean"
    suggestion.
  - Strict-mode stub in cmd/prune.go composes the shared
    detail.* / wrapped CommandDeniedError shape via cmdpolicy
    helpers (BuildDenialError / CommandDeniedFromDenial /
    DenialDetailMap), so command_denied envelopes from strict-mode
    and user-layer policy carry the same closed-enum fields
    (detail.layer / reason_code / policy_source). The historical
    short Message + independent Hint are preserved unchanged.
  - cmdpolicy/yaml: structural parsing of ~/.lark-cli/policy.yml
    with KnownFields strict mode, including allow_unannotated.
  - `config policy show` / `config policy validate` and the plugin
    inventory diagnostic surface the resolved Rule (allow,
    deny, max_risk, identities, allow_unannotated) and the hook
    contributions per plugin.

Envelope contract (docs/extension/reason-codes.md):

  - error.type is a closed set: command_denied, hook, plugin_install,
    plugin_conflict, plugin_lifecycle.
  - reason_code is a closed enum per error.type, dispatched on by
    external agents and CI integrations.
  - detail.layer = "policy" | "strict_mode" attributes the rejection.

Build / CI:

  - Makefile unit-test / vet / coverage and ci.yml fast-gate +
    unit-test + coverage now pass -tags testing so register_testing.go
    is visible; ./extension/... is in the package list so the SDK's
    own tests actually run.
  - fmt-check and examples-build Makefile targets.
  - bmatcuk/doublestar/v4 added as a direct dependency for `**` glob
    matching in Rule.Allow / Rule.Deny.

Author-facing material:

  - docs/extension/ (quickstart, plugin-author-guide, reason-codes)
    is provided in the working tree but kept out of git tracking
    per repo convention (.gitignore covers docs/).

Change-Id: I3b8ecc2923bd54c2dff19e5dce8a0855a6f9e703

* feat(extension/platform): plugin SDK with policy engine, hooks, and Builder

Introduces extension/platform — the in-process plugin SDK external
Go forks of lark-cli use to extend or restrict the command surface.
Plugins compile in via blank import; there is no dynamic loading
and no RPC isolation.

Public SDK (extension/platform):

  - Plugin interface (Name / Version / Capabilities / Install).
  - Registrar verbs: Observe, Wrap, On, Restrict.
  - Hook types: Observer (side-effect, panic-safe, fires Before/After
    RunE), Wrapper (middleware, may short-circuit via AbortError),
    LifecycleHandler (Startup / Shutdown), Selector with nil-safe
    And/Or/Not composition.
  - Risk / Identity are defined string types with closed taxonomies;
    ParseRisk / ParseIdentity convert raw strings with the
    absent-vs-invalid distinction the engine relies on.
  - Builder ergonomic constructor (NewPlugin().Observer().Wrap()
    ...MustBuild()) that enforces name/hookName grammar, hookName
    uniqueness, and the Restrict ↔ FailClosed pairing regardless of
    call order.
  - Invocation is a read-only interface; the framework's concrete
    invocation type lives in internal/hook so plugins cannot
    fabricate denial / strict-mode / identity state. Args() returns
    a defensive copy on every call so hook mutation cannot leak
    into the original RunE.
  - CommandDeniedError + AbortError carry structured fields for the
    closed `command_denied` / `hook` envelope contract.
  - ResetForTesting gated behind //go:build testing.
  - README + godoc examples (Observer / Wrapper / Restrict) + two
    runnable example forks (audit-observer, readonly-policy).

Host (internal/platform, internal/hook, internal/cmdpolicy):

  - InstallAll: staged plugin registration with atomic commit, panic
    isolation, FailOpen / FailClosed semantics, RequiredCLIVersion
    semver check, single-Restrict invariant, duplicate-plugin-name
    detection.
  - hook.Install wraps every runnable cmd.RunE with:
    Before observers (panic-safe) → denial guard → composed Wrap
    chain → original RunE → After observers (always fire, even on
    err). Denied commands physically bypass the Wrap chain so a
    plugin Wrapper cannot suppress or rewrite a denial; observers
    still see the attempt for audit.
  - Recover shim around plugin Wrappers converts panics (including
    the factory call) into a structured `hook` envelope with
    reason_code=panic; namespacing shim attributes AbortError to
    the namespaced hook name.
  - cmdpolicy (renamed from internal/pruning) is the user-layer
    command policy engine: walks the cobra tree, evaluates each
    runnable command against a Rule's four-axis filter (Allow /
    Deny / MaxRisk / Identities), produces parent-group aggregate
    denials, and installs denyStubs. Rule.AllowUnannotated opts out
    of the unannotated-deny gate for gradual adoption; risk_invalid
    typos always deny with an edit-distance "did you mean"
    suggestion.
  - Strict-mode stub in cmd/prune.go composes the shared
    detail.* / wrapped CommandDeniedError shape via cmdpolicy
    helpers (BuildDenialError / CommandDeniedFromDenial /
    DenialDetailMap), so command_denied envelopes from strict-mode
    and user-layer policy carry the same closed-enum fields
    (detail.layer / reason_code / policy_source). The historical
    short Message + independent Hint are preserved unchanged.
  - cmdpolicy/yaml: structural parsing of ~/.lark-cli/policy.yml
    with KnownFields strict mode, including allow_unannotated.
  - `config policy show` / `config policy validate` and the plugin
    inventory diagnostic surface the resolved Rule (allow,
    deny, max_risk, identities, allow_unannotated) and the hook
    contributions per plugin.

Envelope contract (docs/extension/reason-codes.md):

  - error.type is a closed set: command_denied, hook, plugin_install,
    plugin_conflict, plugin_lifecycle.
  - reason_code is a closed enum per error.type, dispatched on by
    external agents and CI integrations.
  - detail.layer = "policy" | "strict_mode" attributes the rejection.

Build / CI:

  - Makefile unit-test / vet / coverage and ci.yml fast-gate +
    unit-test + coverage now pass -tags testing so register_testing.go
    is visible; ./extension/... is in the package list so the SDK's
    own tests actually run.
  - fmt-check and examples-build Makefile targets.
  - bmatcuk/doublestar/v4 added as a direct dependency for `**` glob
    matching in Rule.Allow / Rule.Deny.

Author-facing material:

  - docs/extension/ (quickstart, plugin-author-guide, reason-codes)
    is provided in the working tree but kept out of git tracking
    per repo convention (.gitignore covers docs/).

Change-Id: I3b8ecc2923bd54c2dff19e5dce8a0855a6f9e703

* refactor(policy): remove validate command and update diagnostics

* fix(extension/platform): address PR review must-fix items

- cmdpolicy: skip AnnotationPureGroup commands in EvaluateAll,
  aggregateParents, and hasRunnableDescendant so user-layer policy
  no longer blocks `<group> --help` after the unknown-subcommand
  guard attaches RunE to every parent
- cmd/root: tag guarded parent groups with AnnotationPureGroup
- extension/platform: drop `//go:build testing` from register_testing.go
  so `go test ./...` works without an extra build tag
- extension/platform/README: inline reason_code reference, fix plugin
  lifecycle diagram order (init/Register precede RegisteredPlugins)
- cmd/platform_bootstrap: route userPolicyPath through
  core.GetBaseConfigDir so LARKSUITE_CLI_CONFIG_DIR is honoured
- cmdpolicy: add RedactHomeDir helper, fold base config dir and
  $HOME prefixes for config policy show + resolver errors
- internal/platform: reject unrecognised FailurePolicy values with
  invalid_capability instead of silently fail-open
- cmd/config: surface diagnostic policy/plugins commands in
  `config --help` Long text
- CHANGELOG: document command_denied error.type rename and
  unknown_subcommand exit-2 behavior change

* fix(extension/platform): address CodeRabbit review comments + CI gofmt

- hook/install: propagate wrapper-injected ctx to invokeOriginal so
  RunE/Run see context values added by upstream Wrappers
- hook/testing: SetStderrForTesting returns a restore func; tests now
  defer it via t.Cleanup to avoid cross-test sink leakage
- cmdpolicy/active: deep-copy ActivePolicy.Rule on SetActive/GetActive
  so callers can't mutate the stored global through shared slices
- platform/inventory: deep-copy Inventory + nested Plugins / HookEntry
  / RuleView slices on SetActiveInventory / GetActiveInventory
- platform/staging: Restrict clones the plugin-supplied Rule before
  retaining it so the plugin can't mutate it after Install returns
- platform/version: reject RequiredCLIVersion with more than three
  numeric components instead of silently truncating 1.2.3.4 to 1.2.3
- cmd/platform_bootstrap: clear cmdpolicy.SetActive on yaml resolver
  error so config policy show doesn't surface a stale rule
- cmd/platform_bootstrap_test: tmpHome pins LARKSUITE_CLI_CONFIG_DIR
  so host env can't bleed into the policy test fixtures
- cmdpolicy/apply: installDenyStub returns bool; Apply count no longer
  over-reports when strict-mode short-circuits the install
- cmdpolicy/engine: aggregateParents now returns the runnable hybrid's
  own denial status when all children are placeholder branches
- cmdpolicy/resolver_test: use t.TempDir()-rooted missing path instead
  of hardcoded /nonexistent for hermetic missing-file assertion
- cmd/config/plugins: empty-inventory branch emits total: 0 so the
  JSON schema stays stable across populated/empty cases
- cmd/platform_guards_test: select leaf by RunE != nil (not Runnable)
  so the test doesn't nil-deref on Run-only commands
- gofmt run on previously committed cmdpolicy/path*.go (CI fast-gate)

* fix(cmdpolicy): replace filepath.Abs with filepath.Clean for lint policy

The depguard / forbidigo rule blocks filepath.Abs in internal/ on the
grounds that it accesses the filesystem (Getwd) directly. Switch
RedactHomeDir + foldPrefix to operate on filepath.Clean strings; real
callers pass already-absolute paths (resolver builds yamlPath via
filepath.Join on the absolute config root), so the redaction outcome
is unchanged for production inputs. Relative inputs fall through to
the unchanged branch — filepath.Rel rejects the mixed-absoluteness
case with an error, which the foldPrefix helper already treats as
"not a hit".

* refactor(cmdpolicy): pure Resolve + drop path redaction & verbose comments

- Resolve becomes a pure function; I/O moves to LoadYAMLPolicy so
  precedence selection can be unit-tested without vfs mocks
- ActivePolicy drops YAMLPath; config policy show JSON loses yaml_path
  and yaml_shadowed (and the TOCTOU stat that surfaced them)
- RedactHomeDir and path_test.go removed: the home-dir folding was only
  earning its keep through the now-deleted yaml_path field
- cmd/build.go bootstrap block trimmed from 71 to 39 lines by cutting
  PR-rationale comments; one note kept for the fail-CLOSED-vs-fail-OPEN
  business rule
- cmd/config/config.go: parent Long no longer hard-codes hidden command
  hints, matching their Hidden:true intent

Change-Id: Icfbb818ce3ef523c63286bfbed34c49be08ed6a2

* refactor(platform): drop StrictMode/Identity from Invocation interface

These two accessors were documented in the public SDK as "After observers
always see ok=true" but the framework never plumbed values to them, so they
always returned ("", false). Zero internal/example/test callers; a plugin
author trusting the doc would silently get wrong behaviour.

Identity is also fundamentally unsuited for Before observers (per-command
identity resolves inside RunE via f.AuthFor, after Before fires). StrictMode
is a global value better placed on a Framework/Environment interface than
per-Invocation. Removing is non-breaking now (no callers); adding later is
non-breaking too.

Change-Id: Ice200543e9bca3bda759ad98a6e34a56df69e915

* fix(prune): preserve original metadata on strict-mode denial stubs

strictModeStubFrom built a fresh *cobra.Command from scratch, dropping
the original command's annotations (risk_level, lark:supportedIdentities,
cmdmeta.domain) and help text. cobraCommandView is a live proxy walking
parent annotations, so after the Remove+Add replacement, audit observers
firing on a strict-mode-denied command saw Cmd().Risk()=("",false) and
Cmd().Identities()=nil -- breaking the first-class use case for
audit/compliance plugins.

Copy child.Annotations into the stub (stamping the denial annotations on
top) and propagate Short/Long for help-text parity with
cmdpolicy/apply.go::installDenyStub, which preserves these by virtue of
mutating in place.

Regression test asserts risk_level / supportedIdentities / Short / Long
all survive replacement, alongside the denial annotations.

Change-Id: I19810a34575996344b63e839066888c154d69335

* chore(platform): align docs with implementation; fold home in yaml warnings

Followup cleanup to the previous three refactor commits, addressing review
fallout where public docs / examples / contract notes still pointed at
deleted symbols or unimplemented designs:

- cmd/build.go: Build() docstring now mentions the plugin install + Startup
  emit side effects; Shutdown only fires on Execute path
- extension/platform/doc.go, lifecycle.go, invocation.go: drop references
  to the deleted StrictMode/Identity methods, restore minimal Godoc on
  Cmd/Args/Started
- extension/platform/view.go, cmd/platform_bootstrap.go,
  internal/hook/install.go: rewrite "snapshot before pruning" promise to
  match the actual contract (live view + strict-mode stub metadata
  preservation)
- cmd/platform_guards_test.go: stubInvocation drops the two old methods
- cmd/platform_bootstrap.go: redactHome() last-mile folds $HOME -> ~ in
  warnPolicyError so an os.PathError carrying the absolute policy path
  does not leak the user's home dir to stderr / agent / CI logs
- examples/readonly-policy/README.md: drop yaml_path from the sample
  `config policy show` envelope (the field was removed in 52cbb92)

Change-Id: I2874cc2cf9225dfa44a9c07b2449149181b387cb

* chore(build): drop vestigial -tags testing from Makefile and CI

The `testing` build tag was introduced in 461e3c6 to gate
extension/platform/register_testing.go (ResetForTesting); PR review
0efee93 then dropped the //go:build testing directive from that file
so downstream `go test ./...` would work without the tag, but never
cleaned the matching tag references out of Makefile and ci.yml.

The result: 8 places passing -tags testing for a tag that nothing in
the repo actually gates, plus a Makefile comment that confidently
claims a gate exists. Net behaviour is identical to omitting the flag;
the only effect is misleading developers into believing there is a
test-only surface separation.

Drop the flag from vet / unit-test / lint / coverage / deadcode (head
+ base worktree) and remove the misleading comment. ResetForTesting's
public-API exposure was the conscious trade-off taken in 0efee93 and
is left untouched.

Change-Id: If0cd78c87d4aec2a2533419fe75b01aae6b165fd

* feat(cmdpolicy): enrich denial Reason with attempted value + rule constraint

The envelope reason for command_denied previously told the caller WHAT
axis failed but not the concrete values on each side, so an AI agent
reading the envelope could not tell which command identity / risk /
path was attempted vs. which the rule permits. The natural temptation
was then to recommend modifying the rule -- exactly the wrong nudge,
since policy exists to prevent the agent from rewriting its own limits.

Each Reason now carries both the attempted value and the rule's
constraint:

  identity_mismatch:
    "command supports identities [user]; rule allows [bot]"
  domain_not_allowed:
    "command path \"drive/+upload\" not in allow list [docs/** contact/**]"
  command_denylisted:
    "command path \"docs/+delete-doc\" matched deny pattern \"docs/+delete-*\""
  risk_too_high / write_not_allowed:
    "command risk \"high-risk-write\" exceeds rule max_risk \"write\""
  risk_not_annotated:
    "command has no risk_level annotation; rule denies unannotated commands"
    (drops the prescriptive "set allow_unannotated=true" hint -- that
     belongs in docs, not in the engine's denial path)

Adds firstMatch() helper so command_denylisted can name the specific
glob that fired; matchesAny() now wraps firstMatch.

Regression test pins the substring contract per reason_code so future
"comment cleanup" cannot silently strip the values out again.

Change-Id: I17c7cc9411f58e3e43ade5e1ce875f3b7fe3e5ea

* fix(cmdpolicy): gofmt engine_test.go

CI fast-gate flagged the test added in 2eb0c2b as unformatted. Local
make unit-test had it cached; should have run `make vet` (which runs
gofmt-equivalent check via fmt-check) before pushing. Trivial 3-line
indent fix.

Change-Id: I42297ae59f607b97b32e976c9ec1c9ec4ab7de21

* feat(cmd): annotate risk_level on all hand-written cobra commands

Without this, any non-empty user-layer policy.yml (default
allow_unannotated=false) denies these commands with reason_code
risk_not_annotated -- bricking auth login, config init, profile use
etc. on first contact with a policy.

cmdpolicy/engine evaluation now resolves to the intended axis (deny
list / allow list / max_risk / identities) instead of failing closed
on the unannotated gate. Policy authors can write `max_risk: write`
or `allow: [auth/** config/** ...]` to express real intent.

Classification:
  read              auth status/check/list/scopes, config show /
                    policy show / plugins show, doctor, completion,
                    schema, profile list, event list/status/schema/
                    consume
  write             auth login/logout, config init/bind/remove/
                    default-as/strict-mode, profile add/remove/
                    rename/use, event stop/_bus, api (raw transit)
  high-risk-write   update (replaces the CLI binary; failure can
                    leave the install broken)

Notes:
- api standalone is conservatively `write`; per-call risk is unknown
  at parse time (raw transit), so static gating only enforces the
  write-class minimum.
- event _bus is the hidden IPC daemon forked by consume; standalone
  invocation by users is not expected, but the annotation keeps
  policy evaluation consistent with the other event subcommands.
- The two diagnostic-allowlisted commands (config policy show /
  plugins show) still bypass the engine via diagnosticPaths; the
  read annotation is for consistency with surrounding leaves.

---------

Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>
Change-Id: I87bb32c86e3c3362f541ccc6320c656eb795ec9b
…larksuite#935)

Two DryRun functions in the sheets shortcuts called json.Unmarshal without
checking the return value. This looks like a bug, but Validate already
parses and validates the same --style / --data JSON before DryRun runs,
so the error is structurally impossible at this point.

Use _ = assignment + comment to silence the unchecked-error lint warning
and make the safety invariant explicit to future readers.

Co-authored-by: KhanCold <KhanCold@users.noreply.github.com>
Bidirectional sync between a local directory and a Drive folder with
diff detection (new_local, new_remote, modified, unchanged) and
conflict resolution strategies (--on-conflict: remote-wins, local-wins,
keep-both, ask).

Key behaviors:
- Type conflict detection: hard-fail when local file vs remote non-file
  or local directory vs remote file
- Keep-both: rename local with __lark_<hash> suffix, then pull remote;
  occupied map includes localDirs to prevent suffix collision
- Local-wins partial-success: prefer returned file_token on upload failure
- Empty directory mirroring: pre-create local dirs on Drive via
  drivePushWalkLocal before scope preflight
- Structured errors throughout (output.Errorf / output.ErrWithHint)

Includes unit tests and E2E tests (dry-run + live workflow).
* feat(auth): add QR code support for device auth flow

* docs: update login QR code display hints for AI agent

* feat(auth): add ASCII QR code support for auth flow

* docs: add comments for login and auth helper functions

* chore: remove unused qrCodeToBase64 helper function

* fix(auth/login): clarify verification_url handling in login hint
…ite#847)

refactor(slides): rename slide layout lint scope

Change-Id: I1b0e42b6508ec2c5f6ae6dc0d1b7ac23c5bbe2e3

feat(slides): improve lark slides skill guidance

Change-Id: I49563da4ca623a89f5391f36ceb8f5a31417e321

feat(slides): strengthen lark slides planning guidance

Change-Id: If49330e1f9b779bc76a919565ed61a31c255f508

feat(slides): remove lark slides layout lint rules

Change-Id: I64f1fc3b33d05c069c9ef58e61d00aa57ac18ecd

refactor(slides): streamline skill guidance

Change-Id: I3b39faaab7dcac52fac1572590fc5d8934428da5

feat(slides): add slides asset planning guidance

Change-Id: I37303043f7704e4ba484552158390a4e24bf9c42

feat(slides): add visual planning guidance

Change-Id: Idee7c392d41ff02124313d572c547d0a086d9c35

feat(slides): add lark slides planning layer

Change-Id: I3f0765aa53656070d9ba9b388dade19355e7bc6f
* feat: add markdown +patch shortcut

Change-Id: I8159941ff9dec4e5cbf0c757ec19ee172b302224

* fix: align markdown patch validation and dry-run

Change-Id: I98079901e980b74998938afc4917b91a79689948
Change-Id: Iea77769a6a0f4e77e8946b72ddb619782be3ea42
Change-Id: I3e04a82f622853549f11ac49cbd6fefa194c7c56
…arksuite#904)

- +node-get: wrap wiki.spaces.get_node; accepts node_token, obj_token,
  or a Lark URL (URL path auto-infers obj_type); formatted output with
  creator / updated_at. No synthesized url — get_node returns none and a
  BuildResourceURL fallback is a non-canonical link that misleads in a
  read/confirm command (sibling read shortcuts omit it too)
- +node-delete: wrap space.node delete; high-risk-write (--yes gated),
  async delete-node task polling, auto-resolves space_id via get_node
  when --space-id omitted, actionable hints for codes 131011 / 131003.
  The delete-node task result lives under the gateway's generic
  `simple_task_result` key (NOT `delete_node_result`)
- +space-create: wrap spaces.create; user-only identity, --name
  required (no empty-name spaces), flattened space output, no url
- factor the shared wiki async-task poll loop into wiki_async_task.go;
  preserve upstream Lark Detail.Code on poll exhaustion (no longer
  rebuilt via lossy ErrWithHint)
- drive +task_result: add wiki_delete_node scenario so +node-delete's
  async-timeout next_command actually resolves
- skill docs: reference pages for the 3 new shortcuts + SKILL.md
  shortcuts table (no raw nodes.delete API exists — it's shortcut-only,
  so it is intentionally absent from API Resources / permission table);
  drop the circular TestWikiShortcutsIncludeAllCommands change-detector

Change-Id: I316f78290cec5bc50f80d629173e3bf2a35dd005
* feat: support base attachment APIs

* fix: handle duplicate base attachment downloads

* fix: remove unused attachment token helper
Test files legitimately need to construct dangerous Unicode inputs
(RLO, ZWSP, BOM, etc.) to verify validation logic rejects them.
bidichk treats decoded \u escape literals as Trojan Source risks,
which is a false positive for intentional test data.

Change-Id: I555028a992ab008da16129eb41075c333d0099b8
…t --set-priority (larksuite#779)

Add a Priority field to DraftProjection populated from the EML header pair
X-Cli-Priority (CLI/OAPI primary) → X-Priority (RFC fallback for IMAP-回灌
historical drafts), with case-insensitive lookup via the existing
headerValue helper and a local mapping table aligned with the backend
gopkg/mail_priority.PriorityValueToType vocabulary. When neither header is
present (the symmetric read of --set-priority normal=remove_header) the
projection emits "unknown" so agents have a stable read-side surface.

Append one notes entry to buildDraftEditPatchTemplate documenting the
--set-priority flag and the X-Cli-Priority translation contract.

The write-side (--set-priority flag, parsePriority helper, translation
branch in mail_draft_edit.go, EML header target) is unchanged — already
shipped on master.

sprint: S4
* docs(lark-im): clarify message activity search

Change-Id: I2a9a928aab2354dfaf103cdf53add435088ff9e2

* docs(lark-im): keep bot history guidance additive

Change-Id: I6d89610db9f9d1488f207dcc6b92f7aada839f8b
fangshuyu-768 and others added 24 commits May 19, 2026 17:53
…e#948)

Switch `drive +export --file-extension markdown` from the legacy V1
GET /open-apis/docs/v1/content API to the V2
POST /open-apis/docs_ai/v1/documents/{token}/fetch API for
higher-quality Lark-flavored Markdown output.

- Update DryRun and Execute paths to use V2 endpoint with JSON body
- Add docx:document:readonly scope for the new API
- Validate V2 response structure (fail fast on missing document/content)
- Encode token in URL path via validate.EncodePathSegment
- Update unit tests and add V2 response validation error path tests
- Add E2E dry-run test for markdown export path
- Update skill documentation
Change-Id: I637cfaf2d6a228c43e3b3041fef8e030bc80b9d0
* docs(lark-vc): clarify meeting search evidence flow

Change-Id: I997ec0654b9448eb0cc6ed7c15493dd2316ffa39

* docs(lark-vc): clarify pagination precedence

Change-Id: Icdcc38db2ce3db3a3371c6451624fd52a71170e3
Change-Id: I0908c20f6ab9cf76a5d75cc1c81871591aa6a841
… file blocks (larksuite#825)

* feat(doc): warn before overwrite when document contains whiteboard or file blocks

Before executing an overwrite in v1 mode, pre-fetch the current document
and scan the Markdown for <whiteboard> and <file> resource blocks. If any
are found, print a warning to stderr listing the counts and suggesting the
user take a backup with `docs +fetch` first.

Overwrite replaces the entire document and cannot reconstruct these blocks
from Markdown; previously the data was lost with no indication to the caller.
The check is best-effort: a failed pre-fetch silently skips the guard rather
than blocking the overwrite.

* test(doc): add validateSelectionByTitleV1 tests and drop redundant empty-md guard in warnOverwriteResourceBlocks

* fix(doc): use regex for resource block detection, add latency/coverage comments, document skip_task_detail purpose
* feat: add markdown +diff shortcut

Change-Id: I7da27889517707ac6f1d5e8c429e4bdfb49fdcf8

* fix: harden markdown diff downloads

Change-Id: I0020e14ebee780617d790836af1368db851b8cf1

* refactor: address markdown diff review feedback

Change-Id: I0ddb852218ec4784c0f9491896796c3007f04122
Change-Id: If08f236c8ae351f92683f2b861cc999eb6f1d22d
* docs: prefer local comments for drive reviews

Change-Id: Ie2eaa54320cd2612b66b2d617750d23b950e38db

* docs: align drive comment fallback guidance

Change-Id: Ia7512babe3656b57374c86068198c8192871ff81
…ds owner semantic (larksuite#951)

docs +search is in maintenance and will be removed; cloud-space resource
discovery is consolidated onto drive +search. Two related doc/help fixes:

1. Redirect guidance: docs +search -> drive +search
   - skill-template/domains/{doc,sheets}.md
   - lark-base/SKILL.md: --filter '{"doc_types":["BITABLE"]}' -> --doc-types bitable
   - lark-sheets/SKILL.md: body + frontmatter description, add drive-search ref link
   Same server API, equivalent capability; only flattens the entry from
   nested --filter JSON to flags. reference links repointed to lark-drive.

2. Fix creator_ids/--mine semantic: creator -> owner
   The server matches creator_ids (incl. --mine / --creator-ids) by owner
   (document owner), not original creator, despite the OpenAPI field name.
   - shortcuts/drive/drive_search.go: --help Desc and Tip
   - lark-drive/references/lark-drive-search.md: identity section, params, rules, examples
   - lark-drive/SKILL.md: top-level guidance
   - lark-doc/references/lark-doc-search.md: creator_ids usage note (now self-consistent)
   Wire field name creator_ids kept (aligned with the server).

Docs/help strings only, no logic change; gofmt / go vet / package build pass.

Change-Id: If3ebf5a247b7e38b58050c677dc888a310f1c6b6
Change-Id: I5ba1991874e262fb98f3421e61503b58bb71d861
* feat: add incremental skills sync

* fix: address skills sync review feedback
Add an HTML lint library + Larksuite-native autofix to lark-cli mail, plus
the skills/lark-mail/ skill bundle (2 reference docs, 5 HTML templates, the
+lint-html shortcut, and writing-path lint integration across all 6 compose
shortcuts).

Lint library (shortcuts/mail/lint/)

- Error: drop dangerous tags (<script> / <iframe> / <form> / <input> /
  <link> / <object> / <embed>), on* event handlers, javascript: /
  vbscript: / file: URLs.
- Warning + autofix: rewrite HTML4-era <font> / <center> / <marquee> /
  <blink>.
- Larksuite-native autofix: rewrite <p> / <ul> / <ol> / <li> /
  <blockquote> / <a> to mail-editor native markup so AI can write the
  simplest HTML and still produce native-quality rendering.
- Inline-style and URL-scheme allow-list filtering.
- <style> block passthrough (server adds CSS scope class).

+lint-html shortcut (preview / CI)

Read-only HTML preview tool. Default envelope returns only cleaned_html;
--show-lint-details adds full warnings[] / errors[]. --strict exits non-
zero on any finding (CI gate).

Writing-path lint in 6 compose shortcuts

+send / +draft-create / +reply / +reply-all / +forward / +draft-edit body
op all run lint before drafting:

- lint_applied_count / original_blocked_count: always present.
- lint_applied[] / original_blocked[]: only with --show-lint-details.
- compose_hint: points AI consumers to the HTML writing guide.

skills/lark-mail/ skill bundle

5 pre-rendered Larksuite-native HTML templates: weekly newsletter,
personal weekly report, team weekly report, market research report,
résumé.

2 reference docs:
- references/lark-mail-html.md: writing rules + format primitives +
  template-usage flow.
- references/lark-mail-lint-html.md: +lint-html usage + return-value
  contract + 9 worked examples.

SKILL.md updates linking the new docs and templates.

Sealed conventions

- @user mention chip: id="at-user-N" is the only hard requirement; do
  not write data-user-id.
- Highlight palette: 3 colors (pink milestones, yellow follow-ups, green
  completed); black text, no bold / padding / border-radius.
- Brand color palette: main black, 3 levels of grey, Lark blue / deep
  blue, alert red, emergency orange, light pink / light grey
  backgrounds, border grey.
- URL scheme allow-list: http(s): / mailto: / cid: / data:image/* only.
- Inline-style + tag allow-lists.
- Writing-style floor: subject <= 50 chars, decision-first, lists instead
  of mechanical numbering, emoji only as status tags.

Tests

- shortcuts/mail/lint/...: unit tests for every rule.
- shortcuts/mail/mail_lint_html_test.go: +lint-html envelope contract.
- shortcuts/mail/mail_lint_writepath_test.go: writing-path envelope
  contract.
- 5 templates verified via +draft-create smoke test.
Move data["lint_applied_count"] / data["original_blocked_count"]
assignments inside the showDetails branch in applyLintToEnvelope so
all four lint fields enter and leave the envelope together. This
restores the default 3-key envelope (compose_hint / draft_id / reference)
for the compose-family shortcuts (+send / +reply / +reply-all /
+forward / +draft-create / +draft-edit) and keeps the four lint fields
behind --show-lint-details as the tech design intends.

sprint: S2
Remove tip field from buildDraftSavedOutput's returned map and flip the
test assertions in TestBuildDraftSavedOutputIncludesReferenceOnlyWhenPresent
to require tip absence. The compose-family default envelope (+send /
+reply / +reply-all / +forward) now stays within the 3-key contract
(compose_hint / draft_id / reference) defined in tech-design v1 §4.1.5.

hintSendDraft already writes the equivalent guidance to stderr, so no
UX regression — the message reaches the user via the dedicated stderr
hint channel instead of the structured stdout envelope.

sprint: S3
+draft-create now always attaches a fixed draft_edit_hint to its stdout
envelope (alongside compose_hint + draft_id), guiding callers to edit
the existing draft via +draft-edit --draft-id <id> instead of re-running
+draft-create and producing duplicate drafts. The hint is single-target:
only MailDraftCreate emits it; the other 5 compose shortcuts (+send,
+reply, +reply-all, +forward, +draft-edit) keep their existing envelope
shape unchanged.

applyLintToEnvelope no longer writes lint_applied_count or
original_blocked_count. Under --show-lint-details the envelope returns
only the two Finding arrays (lint_applied[] / original_blocked[]) —
callers needing a count compute it via len(arr). The change propagates
to all 6 compose shortcuts via the shared helper.

Tests, the shortcut flag description, and the lark-mail-html /
lark-mail-lint-html reference docs are updated to match.

sprint: S2
…tcut structs (PR 787 followup)

The 4 compose shortcuts (+send, +reply, +reply-all, +forward) were missing
the `HasFormat: true` field in their Shortcut struct literals, so cobra
parse rejected `--format json` with `unknown flag: --format`. This blocked
the JSON envelope output path (compose_hint / lint envelope) that the
verify suite exercises.

The fix mirrors the existing positive siblings in the same package
(mail_draft_create.go:43, mail_draft_edit.go:29, mail_lint_html.go:43):
add a single line `HasFormat:   true,` between `AuthTypes:` and `Flags:`
in each of the 4 Shortcut struct literals. No new manual --format flag
entry is added; the framework auto-registers --format via runner.go when
HasFormat == true.

Refs: verification_report §3.1 (fail_code_bug for INT-CLI-Send-DefaultHidesStrip-01)
+draft-edit only accepted body edits via --patch-file (set_body op),
causing "unknown flag: --body" when called the same way as +draft-create.
This adds --body as a convenience flag that translates directly into a
set_body patch op, making all mail compose shortcuts consistent.

- Add --body flag to MailDraftEdit.Flags
- In buildDraftEditPatch: if --body is set, prepend a set_body op;
  mutual-exclusion check prevents combining with --patch-file body ops
- Update patch template notes to reflect the new --body shorthand
- Existing lint pipeline (Execute loop over ops) already handles the
  new op: HTML is sanitized, envelope hides lint_applied/original_blocked
  by default unless --show-lint-details is passed

Fixes: INT-CLI-DraftEdit-DefaultHidesStrip-01 and dependent cases
Bug 1: Template HTML <li> elements with class "temp-li bullet2" or
"temp-li bullet3" were missing the "bullet1" class required by the
STYLE_LIST_ITEM_NATIVE_INLINE_APPLIED lint rule, causing 18+ warnings
in --strict mode for PersonalWeekly/TeamWeekly/Resume templates.
research--market-report.html used native <li> elements which were
fully converted to Feishu-native list format (ul/ol + li with all
required class, data-* attrs, style props and text span wrapping).

Bug 2: +send lacked --body-file flag, breaking the E2E workflow:
  +lint-html → save cleaned.html → +send --body-file ./cleaned.html
Added --body-file flag (mutually exclusive with --body) that reads
the HTML body from a file. Matches the pattern in +lint-html.
- Add class="not-doclink" to all 20 mention-chip <a id="at-user-N"> elements
  (fixes 20× STYLE_LINK_NATIVE_INLINE_APPLIED warnings in --strict mode)
- Change class="temp-li number2" → "temp-li number1 number2" on the two
  <li data-start="a|b"> sub-items in 下周工作 nested ol
  (fixes 2× STYLE_LIST_ITEM_NATIVE_INLINE_APPLIED warnings)
- Add start="1" to the outer wrapper <ol> of the nested weekly-next sub-list
  (fixes 1× STYLE_LIST_NATIVE_INLINE_APPLIED warning; ensureAttr saw start absent)

All 23 warnings eliminated; +lint-html --strict should now exit 0.
Replace 11 <p> tags with <div> elements (preserving inline styles) to
eliminate STYLE_PARA_WRAPPER_REWRITTEN warnings. The lint engine rewrites
<p> to Lark-native double-wrapped div paragraphs and emits a warning for
each; using <div> directly avoids the rewrite.

Also add class="not-doclink" + native link inline styles to 3 bare <a>
tags in the PR-reference section, eliminating STYLE_LINK_NATIVE_INLINE_APPLIED
warnings.

Verified with lint.Run() directly: blocked=0 applied=0 (0 findings).
All 5 templates now pass +lint-html --strict with 0 findings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Block + Major + selected Nit items from the 2026-05-20 code review.

lint lib (shortcuts/mail/lint):
- Drop opts.Strict / opts.AutoFix from Options; lib always autofixes
  warnings and removes errors (writing-path safety contract). Strip the
  corresponding branches from Run / processElement / processAttributes /
  applyFeishuNativeStyles.
- Make data-ol-id deterministic: per-Run nativeCtx maps each <ol> node to
  a positional index and nodeShortID hashes that index instead of the
  heap pointer. cleaned_html is now byte-stable across runs.
- processAttributes style branch: preserve attr.Val verbatim when no
  property dropped (avoid spurious whitespace differences on idempotent
  input).
- Add LIST_DIRECT_CHILD_NON_LI rule + autofix: wrap non-<li> direct
  children of <ul>/<ol> (notably <ul><ul> nesting) in a synthetic <li>.
- Scrub user-facing "RemoteSanitizer" / "server-side sanitizer" /
  "Lark mail-editor" wording from hints + code comments. Hint text now
  describes the client-side action only.

+lint-html (shortcuts/mail/mail_lint_html.go):
- Remove --strict and --auto-fix flags. cleaned_html always emitted;
  command never bumps exit code (preview / advisory tool).
- Drop the corresponding strict / autofix=false tests.

Writing-path (compose 5 + draft-edit):
- Extract shared readBodyFile() + validateBodyFileMutex() helpers with
  a 32 MB size cap (io.LimitReader). Both --body-file callers route
  through them.
- Add --body-file flag to +draft-create / +draft-edit / +reply /
  +reply-all / +forward (was only on +send). Each implements mutual
  exclusion with --body + cwd-subtree path safety.
- Fix +send body-file + --inline silent inline-image drop: Validate
  now resolves the body content first so the plain-text + inline
  combination errors out the same way --body 'plain' + --inline does.
- Remove unused lintFinding type alias; callers reference lint.Finding
  directly.

Templates (skills/lark-mail/assets/templates):
- Wrap inner <ul>/<ol> in <li> in weekly--team-report.html (4 places)
  and job-application--resume.html (4 places) so they conform to the
  new ul/ol direct-child rule and have valid HTML structure regardless.

Tests:
- Update TestRunWritePathLint_HTMLAlwaysAutofixedWarningNeverElevated
  contract (was the +strict variant) to assert never-elevated behaviour.
- Add e2e write-path lint tests for the 5 other compose shortcuts:
  +send / +reply / +reply-all / +forward / +draft-edit — each asserts
  the <font> autofix reaches the captured EML before the API call.
- Add plain-text + --show-lint-details corner case test that locks
  empty-but-non-nil arrays in the envelope.
- Add ul/ol direct-child-non-li lint test (3 cases).

Docs:
- skill-template/domains/mail.md: fix broken references (the two
  separate allowlist / feishu-native docs were merged into one
  lark-mail-html.md long ago); JSON example no longer mentions internal
  service names.
- skills/lark-mail/SKILL.md:63: section pointer now lands on a real
  references/lark-mail-html.md anchor instead of a phantom in-file
  section.
- 6 references/lark-mail-*.md: add --body-file row alongside --body.
Previous rgb(225,77,42) = #E14D2A has hue 11.5° which falls in the
red-orange boundary and looks almost identical to the 警示红 (h=1.7°)
in real-world mail-client rendering. Visual reviewer (用户在飞书 mail
客户端测 A23 调色盘 case) confirmed they could not perceive the orange
semantic.

Bump to rgb(255,140,40) = #FF8C28 (hue 30°, saturation 84%) which is
unambiguously orange and clearly distinct from 警示红 (h≈2°) and 紧急
橙 (h≈30°) now have ~28° hue spacing.

References:
- A23 V2 testcase 实测视觉反馈
- HSV 色相分析: standard orange hue is 20-50°
之前的示例把多级列表写成"独立 ol + 独立 ul + 独立 ol(接续编号)"
兄弟堆叠的形式,且内部 ul 直接套 ul(违反 HTML 规范要求 <ul>/<ol>
的直接子节点必须是 <li>)。

实测视觉效果(lcpr +send 发文档原样示例到飞书 mail 客户端):
- 编号 1 / 2 的子项不缩进于父项(独立列表跟编号项是兄弟)
- lint autofix 检测到 <ul><ul> 不合规,wrap 出空 <li class="bullet1">
  导致显示空圆点 marker("点后没文字"现象)

修正:合并 3 个独立 ol/ul 为单一 ol,子 ul 嵌套在父 <li> 内:
  <ol>
    <li>第一级
      <ul>
        <li>第二级
          <ul><li>第三级</li></ul>  ← 嵌套在父 li 内,不是兄弟
        </li>
      </ul>
    </li>
    <li>第一级接续编号</li>
  </ol>

同时去掉显式 start="1" / start="2" / data-start,同一 ol 内 li
顺序自动编号,无需手动指定。

注释里补充说明"<ul>/<ol> 的直接子节点必须是 <li>"以防再次误用。
之前修正后的示例混用 ol+ul(外层 ol,内层 ul/ul),读者不容易看清
"全 ol 多级"和"全 ul 多级"分别该怎么写。拆成两个独立示例:

- 示例 1:全 ol 三级(decimal → lower-alpha → lower-roman)
  class 用 number1/number2/number3 区分层级
- 示例 2:全 ul 三级(disc → circle → square)
  class 用 bullet1/bullet2/bullet3 区分层级

两个示例都遵循同样的嵌套规则(子列表放在父 <li> 内、子级 margin-left
24px 视觉缩进),通用规则单独提到注释顶部不重复。
之前两个模板把多级列表写成"独立 ol(start=N) + 独立 blockquote +
独立 <ul><li><ul>...</ul></li></ul> 三明治"的兄弟堆叠模式,结构上:
- 多个独立 ol 拆开导致编号靠 start="N" 硬编码续接,写错就乱
- 三明治外层 ul 内只有一个空 li 包子 ul(违反 HTML 规范 ul 不能直接套 ul)
- autofix wrap 出空 <li class="bullet1"> 显示默认圆点 marker("点后没文字"现象)

修正:每段合并成单 ol,子项 ul 嵌套在父 <li> 内,去掉 start="N" /
data-start="N"(同 ol 内 li 自动连续编号)。三级使用
list-style-type: decimal → lower-alpha → lower-roman 区分层级。

具体变动:
- weekly--team-report.html
  - 本周工作段:3 ol + 3 blockquote + 3 三明治 ul = 9 个兄弟块
    → 单 ol 含 3 li(事件 1/2/3),blockquote / 子项 ul / 孙子项 ul
    都嵌入父 li 内
  - 下周工作段:2 ol + 1 三明治 ol = 3 个兄弟块
    → 单 ol 含 4 li(重点 1/2/3/4),重点 2 li 内嵌 lower-alpha 子 ol
- job-application--resume.html
  - 工作经历 + 项目经历 段同模式重写(单 ol 含多 li 嵌套)
  - 顶部 cover letter 简化:3 行"尊敬的 xxx:/ 您好!/ 长正式介绍"
    → 2 行"[称呼],您好:/ 1 句直接介绍",符合标准中文求职邮件开头风格

验证:两个模板分别跑 lcpr +lint-html --show-lint-details:
- 关键结构 bug 0 个(无 LIST_DIRECT_CHILD_NON_LI 触发)
- cleaned_html 回写再 lint = 0/0 idempotent
- 剩余 STYLE_LIST_NATIVE_INLINE_APPLIED warning 是 lcpr 加 canonical
  属性顺序的 autofix 提示,跟 reference doc 自身列表示例同行为
将 spec §/S2 contract/KB Pitfall/KB conventions/technical-design/editor-kit/
renderer-side CSP/服务端 sanitizer 等内部引用全部改写为公开读者可理解的中立表述:
- spec §4.x 章节号:删除引用锚点,保留注释描述的规则
- S2 contract «XXX»:删除规范名和书名号,有价值的描述改为普通自然语言
- KB Pitfall N / KB conventions:删除知识库引用,保留实际技术说明
- technical-design §4.4:改为 three-tier tag classification
- editor-kit:改为 Feishu mail-editor's renderer
- renderer-side CSP:改为 the rendering layer's CSP
- 服务端 sanitizer:改为客户端兼容性与安全沙箱约束
1. 删除 lark-mail-lint-html.md 中不存在的 --auto-fix 和 --strict 两个 flag(参数表、示例命令、字段说明共 5 处),改成符合"autofix 始终启用"现状的描述
2. 将未知 URL scheme(webcal:// 等)的 lint finding 从 Applied(SeverityWarning)改为 Blocked(SeverityError)——行为是"删除属性",应与其他删除类 finding 语义一致;同步更新 linter_test.go 中对应测试(TestRun_UnknownSchemeWarning → TestRun_UnknownSchemeBlocked)
3. 删除 mail_send.go::resolveSendBody 和 mail_draft_create.go::resolveDraftCreateBody 两个与 body_file.go::resolveBodyFromFlags 完全等价的重复函数,调用点改为 resolveBodyFromFlags
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.