Compare commits

..

91 Commits

Author SHA1 Message Date
xiongyuanwen-byted
c7a3dd472e chore: drop accidentally-committed __pycache__/ and gitignore .pyc
The previous commit (5fac9c39) shipped sheets_df.py and inadvertently
included its `__pycache__/sheets_df.cpython-312.pyc` — local Python
import created the bytecode cache during PPE round-trip verification and
`git add skills/lark-sheets/` swept it in.

Remove the pyc and add Python bytecode patterns to .gitignore so the
skill-bundled helper scripts don't pull cache files into future commits.
2026-06-24 16:56:32 +08:00
xiongyuanwen-byted
5fac9c39a5 sync(sheets): pick up sheets_df.py + doc DRY cleanup from spec
Mirror of the sheet-skill-spec change that ships a 32-line helper-only
sheets_df.py (df_to_sheet + sheet_to_df) and removes the corresponding
inline `def` blocks from three reference docs.

- skills/lark-sheets/scripts/sheets_df.py (new): pandas DataFrame ↔
  one +table-put / +table-get sheet, importable as a library. Same
  helper pair the docs already taught, lifted out of the prose so
  callers can `from sheets_df import df_to_sheet, sheet_to_df`.
- lark-sheets-write-cells.md / lark-sheets-read-data.md /
  lark-sheets-workbook.md: drop the inline helper definitions; keep
  the usage examples (single/multi-sheet, round-trip) and switch them
  to import-from-script. workbook reference's +workbook-create
  --sheets section now points pandas users at the helper directly
  (was previously a textual reference back to write-cells).

End-to-end verified against PPE (--as user):
- +workbook-create with df_to_sheet for three sheets (income / balance
  / cashflow): create ok, dtypes (datetime64[ns] / float64) + formats
  (#,##0 / 0.0% / yyyy-mm-dd) survive on read-back through sheet_to_df.
- read → pandas mutate → write-back round-trip preserves both data
  and formats.
2026-06-24 16:55:26 +08:00
xiongyuanwen-byted
14cb134cac Revert "sync(sheets): pick up sheets_df.py — pandas ↔ JSON skill script from spec"
This reverts commit 2964983b92.
2026-06-24 16:33:56 +08:00
xiongyuanwen-byted
2964983b92 sync(sheets): pick up sheets_df.py — pandas ↔ JSON skill script from spec
Mirror of the sheet-skill-spec change that adds a DataFrame ↔ JSON
bridge as a skill-bundled Python script instead of inside the CLI
binary. Per PR #1355 review (docx NcmxdRo2yoZ4OXxoMUZcxRZ7nHd, §4.2):
keep the CLI a thin JSON/REST client; pandas / Arrow editing lives in
the caller's Python process. Synced from canonical via generate:cli +
sync:cli.

- skills/lark-sheets/scripts/sheets_df.py (new): pandas DataFrame ↔
  one sheet, .parquet / .feather / .arrow / .csv / .json. Shells out to
  `+table-put` / `+table-get` over typed JSON — no CLI changes.
- SKILL.md decision tree + write-cells.md +table-put section: explicit
  pointers so pandas users land on the script instead of hand-rolling
  the `--sheets` payload.

End-to-end verified against PPE: 3-row DataFrame (datetime / float /
object) round-trips parquet → script put → real sheet → script get →
parquet with dtypes preserved.
2026-06-24 16:23:30 +08:00
xiongyuanwen-byted
ca8bf48851 test(sheets/e2e): add E2E coverage for new shortcuts + typed workbook-create
AGENTS.md requires a dry-run E2E for every new shortcut and a live E2E
for new flows. Three new files cover the four shortcuts this branch
adds or materially changes:

- sheets_gridline_dryrun_test.go — pins +sheet-show-gridline /
  +sheet-hide-gridline as a single modify_workbook_structure call with
  the right operation name (show_gridline / hide_gridline) and
  sheet_id, so an op-name typo would trip CI before any live run.

- sheets_workbook_import_dryrun_test.go — pins +workbook-import as a
  two-step plan (drive media upload + drive import-task create) with
  the doc type hard-coded to "sheet" — the wrapper's whole reason for
  existing on top of generic drive +import. --name reaches file_name
  on the wire; file_extension is sniffed from the local file.

- sheets_table_put_typed_workflow_test.go — two live workflows running
  against a freshly created spreadsheet. The first runs the full
  typed +table-put → +table-get round-trip (date / numeric / object
  columns with custom number_format) and asserts the dtype + format
  contract holds end-to-end. The second exercises the typed
  +workbook-create --sheets path: create + write in one shortcut, the
  payload sheet name adopts the workbook's default sheet (no empty
  "Sheet1" left behind), and the typed contract still survives the
  read-back.

End-to-end verified locally (user identity): typed put round-trips
preserve dtypes (date → datetime64[ns], numeric → float64, object →
object) + formats verbatim; workbook-create adopts the named sheet as
the first sheet with the same typed shape intact.
2026-06-24 16:03:21 +08:00
xiongyuanwen-byted
79362a8fe8 sync(sheets): pick up +table-put payload-shape doc corrections from spec
Mirror of the sheet-skill-spec change that fixes three places teaching
an invalid +table-put payload shape — the typed protocol only has
columns / data / dtypes / formats (no formula field) and must always
be wrapped in an outer {"sheets":[...]} envelope. write-cells and the
SKILL.md decision table previously used the wrong field names (type /
format) and pointed users at +table-put for formula writes, which the
shortcut can't actually accept.

Synced from upstream canonical via generate:cli + sync:cli.
2026-06-24 16:03:04 +08:00
xiongyuanwen-byted
e57381ae5c ci(license): narrow Apache Arrow workaround with a follow-up assertion
The dependency-license check still has to --ignore Apache Arrow wholesale
because go-licenses' classifier parses its LICENSE.txt as a single license
and mis-reports the module as LicenseRef-C-Ares / Unknown (Arrow inlines
the c-ares 3rdparty notice alongside its own Apache-2.0). Re-classifying
on our side isn't possible without changing go-licenses itself.

The CR concern was that --ignore is too wide — a future Arrow re-license
or new inlined dep would silently sail through. Add a follow-up step that
re-checks Arrow's LICENSE.txt independently: it must still open with
"Apache License" AND must still inline the c-ares 3rdparty notice (the
two facts that make the --ignore safe today). If either invariant breaks,
CI fails here and forces a human to re-evaluate the ignore.

Verified locally — both assertions pass against the current pinned
Arrow v17.
2026-06-24 16:02:54 +08:00
xiongyuanwen-byted
5a5c1f430b fix(drive): wrap +export ctx cancellation/deadline as typed errs.NetworkError
The poll loop in RunExport returned ctx.Err() directly in two places —
on the inter-attempt sleep cancel and on the pre-attempt deadline check.
That let context.Canceled / context.DeadlineExceeded escape as untyped
errors at the cobra layer, bypassing the typed-error contract every
other failure path already honors.

Add wrapExportContextErr that maps both into errs.NewNetworkError with
SubtypeNetworkTransport / SubtypeNetworkTimeout respectively and
preserves the cause via .WithCause(err), so callers can still
errors.Is(err, context.Canceled) downstream.

CR-flagged at drive_export.go:229 / :234.
2026-06-24 16:02:42 +08:00
xiongyuanwen-byted
aa69572803 fix(sheets): bound --dataframe memory use with byte / row / column caps
readDataframeBytes used to read the whole Arrow file unbounded — a
stdin / file > 1 GiB would OOM the CLI long before the backend
per-sheet ceilings kicked in. decodeArrowToSheet then materialized
every record into [][]interface{} regardless of size.

Three caps now match the backend's per-sheet hard ceilings:
- byte cap: 256 MiB (covers worst-case 200×50000 cells × ~25 B Arrow
  overhead). File path pre-Stat()s before opening; both file and stdin
  paths read through io.LimitReader so an oversized input is rejected
  without allocating the full payload.
- column cap: 200, checked at schema-decode time before allocating any
  per-column slices.
- row cap: 50000, checked during record-batch iteration so a 1M-row
  Arrow file is rejected mid-stream instead of fully decoding first.

End-to-end verified against PPE — a 257 MiB file is rejected at file-
Stat with a typed validation error before any read happens.
2026-06-24 16:02:31 +08:00
xiongyuanwen-byted
46faf36201 fix(sheets): plug four +table-put / +table-get correctness gaps flagged in CR
Four review-flagged bugs, all in lark_sheet_table_io.go (bundled because
they touch the same file and the same +table-put / +table-get domain):

1. +table-get --dry-run dropped the --sheet-id / --sheet-name selector
   from the get_cell_ranges body, while Execute always passed it. Agents
   that validate the dry-run shape and then run live would see a request
   shape mismatch. The dry-run now calls sheetSelectorForToolInput so
   the body matches Execute.

2. isDateNumberFormat used a simple `strings.ContainsRune(_, 'y')` so
   number formats like "JPY #,##0" (a currency prefix that happens to
   contain a lone 'Y') were misread as date formats — round-tripping
   integer cells out as ISO dates. The detector is now token-aware:
   it skips quoted "...", `\\x`-escaped, and `[...]` bracket sections,
   and only fires on an unescaped `yy` (a real Excel year token).

3. sheetCreateDims sized new append-mode sheets by `headerOn(s)` only,
   but writeSheetData forces a header on empty append sheets when
   Header == nil. Near 50000 rows / 200 cols this created the sheet one
   row short and the follow-up set_cell_range bounced off the backend
   ceiling. Size now matches the forced-header logic exactly.

4. tableGetTargets fallback paths (read-failure / selector mismatch on
   --sheet-id) returned a target with name="" — already corrected for
   --sheet-id structure-success path in 086876d2, but the structure-
   failure fallback still left it empty. Use the id as the name there
   too so the +table-get → +table-put round-trip never breaks on a
   nameless sheet.

End-to-end verified against a real spreadsheet:
- table-get --dry-run with --sheet-name / --sheet-id both render the
  selector field in the get_cell_ranges body
- A real round-trip (typed put → get) preserves dtypes + formats
2026-06-24 16:02:18 +08:00
xiongyuanwen-byted
47a3c1c66f test(common): assert typed errs.Problem instead of err.Error() substrings
Mirror of the sweep just landed in shortcuts/sheets: replace error-path
substring assertions with typed-envelope checks via two small helpers
landed in a new shortcuts/common/typed_error_assertions_test.go:

  requireProblem(t, err, wantCategory, wantSubtype, msgContains)
    -> *errs.Problem
  requireValidation(t, err, msgContains)
    -> *errs.ValidationError   // shorthand for CategoryValidation +
                               //   SubtypeInvalidArgument; lets callers
                               //   also assert .Param / .Params / .Cause

8 sites moved to typed assertions across runner_jq_test.go,
mcp_client_test.go, drive_media_upload_typed_test.go, and
runner_input_test.go (the input tests already used a typed-param helper;
this just retargets the substring follow-up onto the typed Message).

Sites intentionally left as substring + comment (production returns raw
fmt.Errorf, not a typed envelope):
  - runner_botinfo_test.go (6 sites): BotInfo / fetchBotInfo wrap upstream
    errors with fmt.Errorf so the SDK-level message ([99991], 403,
    invalid character, etc.) shows through.
  - runner_args_test.go (4 sites in 2 tests): rejectPositionalArgs returns
    raw fmt.Errorf to satisfy cobra's PositionalArgs contract.
  - permission_grant_test.go (2 sites): assert on stderr / hint strings,
    not error messages — already out of the err.Error() substring class.

No production code changes.

Verified: go test ./shortcuts/common/... passes;
golangci-lint --new-from-rev=origin/main ./shortcuts/common/... reports
0 issues.
2026-06-24 15:06:41 +08:00
xiongyuanwen-byted
b276e92f6b test(sheets): assert typed errs.Problem instead of err.Error() substrings
Per the coding guideline "Error-path tests must assert typed metadata via
errs.ProblemOf (category / subtype / param) and cause preservation, not
message substrings alone." — sweep through every error-path assertion in
the sheets domain and replace the
`strings.Contains(stdout+stderr+err.Error(), ...)` pattern with two
small helpers landed in helpers_test.go:

  requireProblem(t, err, wantCategory, wantSubtype, msgContains)
    -> *errs.Problem
  requireValidation(t, err, msgContains)
    -> *errs.ValidationError   // shorthand for CategoryValidation +
                               //   SubtypeInvalidArgument; lets callers
                               //   also assert .Param / .Params / .Cause

~60 assertion sites across 18 test files now check the typed envelope
shape, with message-substring checks moved onto the returned Problem
(.Message / .Hint / .Param). The substring is preserved as a sanity
check rather than the sole assertion, so a category drift like
validation → internal would now fail loudly instead of slipping past.

Cases intentionally left as substring (each with a one-line reason):
  - Errors that come straight from cobra's native flag parser (untyped
    *errors.errorString — e.g. "required flag(s) ... not set", mutually-
    exclusive groups). Re-typing these needs a custom FlagErrorFunc and
    is out of scope here.
  - Intermediate errors from decodeArrowToSheet that the caller wraps
    into a typed envelope (`//nolint:forbidigo` reason). Those unit
    tests assert the unwrapped intermediate directly.

One production tweak:
  - shortcuts/sheets/flag_schema.go: printFlagSchemaFor returns typed
    *errs.ValidationError (with WithParam("--flag-name") on the
    unknown-flag branch) instead of raw fmt.Errorf. The framework
    already wraps this when called via --print-schema, so user-facing
    behaviour is unchanged; direct callers (and tests) now get the
    typed envelope.

Verified: go test ./shortcuts/sheets/... passes; golangci-lint
--new-from-rev=origin/main reports 0 issues.
2026-06-24 14:58:02 +08:00
xiongyuanwen-byted
a07b178b9b sync(sheets): pick up +workbook-export UX clarification from spec
Mirror of the sheet-skill-spec update that documents +workbook-export's
default-no-download behavior and its relationship to drive +export
--doc-type sheet. Synced from canonical via generate:cli + sync:cli +
go generate.

End-to-end verified against a real spreadsheet:
- Omit --output-path → ok:true, downloaded:false, file_token returned
- Pass --output-path ./crfix_test.xlsx → ok:true, file saved
  (17892 bytes), saved_path returned

The --help output for +workbook-export now states the default behavior
and points callers at `drive +export --doc-type sheet` when they need
the --output-dir / --file-name / --overwrite split.
2026-06-24 13:35:03 +08:00
xiongyuanwen-byted
e9c4b1e151 sync(sheets): pick up +sheet-{show,hide}-gridline in +batch-update schema
Mirror of the sheet-skill-spec change adding the two gridline shortcuts
to cli-schemas.json batch_update.operations.shortcut enum. Synced from
the upstream canonical via generate:cli + sync:cli.

Verified end-to-end on a real spreadsheet — +batch-update with a
+sheet-hide-gridline op passes schema validation and the backend run
returns succeeded: 1.
2026-06-24 13:29:24 +08:00
xiongyuanwen-byted
9ef0d370bd fix(sheets): preserve causes and render messages cleanly for typed validation errors
common.ValidationErrorf goes through fmt.Sprintf, which does not support
%w — the seven call sites that used `%w` were rendering the cause as
literal `%!w(*fmt.wrapError=&{...})` and dropping the cause from the
typed-error chain (so callers couldn't errors.As back to the underlying
error).

Switch each to `%v` for clean rendering and attach the cause via
.WithCause(err) so the typed contract is preserved. Touched call sites:

- lark_sheet_dataframe.go: --dataframe Arrow decode / stdin read / file
  read failures (3 call sites).
- lark_sheet_table_io.go: --sheets invalid JSON, payload-validate
  per-cell coercion error, buildSheetMatrix per-cell error,
  --dataframe-out arrow encode failure (4 call sites).

End-to-end verified against a real spreadsheet: both invalid-JSON and
typed-cell errors now render readable messages instead of %!w(...).
2026-06-24 13:29:17 +08:00
xiongyuanwen-byted
1aa3305f5a fix(sheets): apply +workbook-create style-only ops instead of silently dropping them
A +workbook-create call carrying only cell_merges / row_sizes / col_sizes
(no --values / --sheets and no cell_styles) used to create the workbook
but silently drop the requested visual ops. Two reasons, both fixed:

- workbookCreateStyleDimensions only counted cell_styles when computing
  the write extent, so cell_merges / row_sizes / col_sizes always
  contributed 0 → buildValuesPayload returned a nil payload → Execute
  skipped writeTypedSheets entirely → no visual ops ran. Extend the
  helper to fold the merge / resize ranges in.

- Pure row_sizes / col_sizes payloads can never expand a cell rectangle
  (they are dimension ranges, not cell ranges), so even with the extent
  fix Execute would still skip the write path. Add a no-data branch:
  when payload == nil but a styles item is present, look up the default
  sheet and apply visual ops directly via applyWorkbookCreateVisualOps.
  The dry-run plan mirrors this so the preview shows the visual ops.

Also picks up the --values trailing-JSON-data EOF check (mirror of the
--sheets one in lark_sheet_table_io.go).

End-to-end verified against a real spreadsheet: a cell_merges-only
+workbook-create now produces a sheet with merged_cells_count: 1.
2026-06-24 13:10:51 +08:00
xiongyuanwen-byted
086876d272 fix(sheets): harden +table-put / +table-get input validation and round-trip safety
Four review-flagged correctness gaps in table I/O, all bundled because
they touch the same file:

1. --sheets accepted trailing data after the first JSON value
   (json.Decoder does not surface that, unlike json.Unmarshal). A new
   decoderExpectEOF helper rejects e.g. `--sheets '{...} oops'` with a
   typed validation error instead of letting the leading object pass
   through and surface as a confusing downstream failure.

2. +table-get with a duplicate header (e.g. `amount, amount`) used to
   read back successfully — the dtypes map silently collapsed to one
   entry — and only failed later on +table-put because the writer
   rejects duplicate column names. Fail fast at read time with an
   actionable hint to rename or pass --no-header. --no-header mode is
   exempt (fallback col<N> names are always unique).

3. +table-put dry-run rendered an invalid range like A1:C0 when
   header=false with rows=[]. tablePutFullRange returns "" for an
   empty matrix or zero columns instead of building a degenerate
   rectangle.

4. +table-get with --sheet-id and a get_workbook_structure miss (read
   failure or selector mismatch) used to return a target with
   name="", which then broke +table-get → +table-put round-trip (the
   writer requires a non-empty sheet name). Fall back to using the id
   as the name.

End-to-end verified against a real spreadsheet: trailing data, duplicate
header, and --no-header fallback all behave as advertised.
2026-06-24 13:10:39 +08:00
xiongyuanwen-byted
d994c27819 fix(sheets): close --dataframe stdin guard hole
--dataframe is binary and bypasses the common Input resolver, which is
where the existing single-stdin guard lives. Result: an invocation like
+table-put --dataframe - --styles - was accepted, then one of the two
consumers raced for stdin and the other silently saw an empty stream.

Add a stdinConsumed marker on RuntimeContext that both consumers share:
common.resolveInputFlags sets it when an Input flag uses '-', and
readDataframeBytes both checks and sets it. A second consumer is
rejected up front with an actionable hint pointing at @file.

Flagged in code review (lark_sheet_dataframe.go:93).
2026-06-24 13:10:25 +08:00
xiongyuanwen-byted
d2517180a4 refactor(sheets): drop +table-put manual capacity grow; rely on set_cell_range auto-grow
set_cell_range now auto-grows the sub-sheet to fit the write, so the
ensureSheetCapacity helper (and its modify_sheet_structure dim-insert
call before each write) is no longer needed. This also closes a data-
safety hole flagged in review: inserting before the last existing row
could push real data down into the area set_cell_range was about to
write, and allow_overwrite=false could not protect against it because
the structural insert had already mutated the sheet by the time the
write-collision check ran.

Verified end-to-end against a real spreadsheet: +table-put writing
300x25 into a fresh Sheet1 (default 200x20) succeeds in one write and
the sheet ends up 301x25.
2026-06-24 12:44:59 +08:00
xiongyuanwen-byted
e1b7826646 Merge branch 'feat/lark-sheets-develop' of https://github.com/larksuite/cli into feat/lark-sheets-develop 2026-06-24 11:54:34 +08:00
zhengzhijiej-tech
3171b61493 Merge pull request #1556 from larksuite/fix/sheets-range-move-wiki
fix(sheets): resolve wiki URL in +range-move/+range-copy Execute
2026-06-24 11:43:02 +08:00
xiongyuanwen-byted
7ad8945f10 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-06-24 11:41:07 +08:00
zhengzhijie
9f32f8461b fix(sheets): resolve wiki URL in +range-move/+range-copy Execute
transformExecuteFn (the named Execute helper shared by +range-move and +range-copy) still called the network-free resolveSpreadsheetToken, so a /wiki/ URL reached transform_range as an unresolved node_token and failed. #1519's sweep over Execute hooks only rewrote inline closures; this is the only Execute backed by a named helper. Switch it to resolveSpreadsheetTokenExec (Validate/DryRun stay network-free) and add a +range-move wiki-URL regression test.
2026-06-24 11:39:43 +08:00
xiongyuanwen-byted
5e0770421f ci: allow Apache Arrow module in license check
Arrow is Apache-2.0 overall, but it vendors c-ares (LicenseRef-C-Ares,
ISC-like) inside the module which go-licenses classifies as Unknown and
the strict disallowed_types=...,unknown gate rejects.

Pass --ignore github.com/apache/arrow/go/v17 since Arrow is required by
sheets +table-put / +table-get / +workbook-create --dataframe (Arrow IPC
ingest) and the vendored c-ares is not redistributed by us.
2026-06-24 11:07:26 +08:00
xiongyuanwen-byted
a125bffbaa fix(sheets): satisfy errorlint/copyloopvar + regen flag defs
- helpers_test.go: drop the Go 1.22+ redundant `tc := tc` loop copy
  (copyloopvar).
- lark_sheet_dataframe.go, lark_sheet_table_io.go: switch the
  intermediate-error fmt.Errorf calls from %v to %w so errorlint passes.
  Behavior unchanged — these errors are always rewrapped into typed
  validation errors at the command layer.
- flag_defs_gen.go: regenerate from data/flag-defs.json (drift from the
  wiki-URL merge).
2026-06-24 11:07:18 +08:00
liangshuo-1
d11a6e97a4 chore: release v1.0.57 (#1553) 2026-06-23 20:43:41 +08:00
raistlin042
e4248d1154 fix: harden lark-apps +init/+html-publish and skill guidance (#1517)
* fix: reject +init into a different app's project directory

* fix: reject single HTML files larger than 10MB in +html-publish

* docs: clarify publish visibility, domain routing, and role/permission boundary
2026-06-23 20:18:10 +08:00
fangshuyu-768
cb54bea00d docs(lark-doc): refine rich block, path, and block ID guidance (#1508) 2026-06-23 18:27:36 +08:00
hanshaoshuai
036e5799d3 fix(ci): bind semantic review to workflow run head 2026-06-23 18:21:29 +08:00
xukuncx
c4106f50b2 fix(mail): resolve folder/label filter once per +triage list call (#1512)
buildListParams used to re-call resolveFolderID / resolveFolderName (and the
label counterparts) on every list page to assemble folder_id / label_id.
Because resolveListFilter already resolves the filter once before the
pagination loop, the second pass hit the folders/labels list API again on
every page — 1 + page_count calls total, which easily trips rate limits.

buildListParams now only assembles API params from the already-resolved
FolderID / LabelID produced by resolveListFilter; it no longer resolves
names or aliases. The default folder_id=INBOX is still applied when no
explicit filter is present, and only overridden when the caller supplied a
canonical folder ID. The runtime / mailboxID / dryRun parameters are kept
for signature stability (resolveListFilter and buildSearchParams share the
same call shape).

Adds TestMailTriageCustomFolderResolvesOnceAcrossListPages: a custom-folder
filter forced across two messages-list pages, with a non-reusable folders
list stub so any second folders API call fails the test. Updated the two
existing buildListParams alias tests to run resolveListFilter first, mirroring
the real DryRun/Execute call order.

sprint: S1

Co-authored-by: xukuncx <283114605+xukuncx@users.noreply.github.com>
2026-06-23 18:05:08 +08:00
zhengzhijiej-tech
68f867d6a5 Merge pull request #1519 from larksuite/feat/sheets-wiki-url
feat(sheets): resolve wiki URLs to the backing spreadsheet for --url
2026-06-23 11:06:36 +08:00
zhengzhijie
78f7fba89e fix(sheets): match --url path segment via url.Parse, not substring
parseSpreadsheetRef classified /wiki/ with strings.Index over the whole URL, so a /sheets/ link whose query or fragment merely contained /wiki/ (e.g. .../sheets/sht?from=/wiki/x) was hijacked into a get_node call. Now parse the URL and match /sheets/, /spreadsheets/, /wiki/ only as a path prefix, mirroring slides parsePresentationRef which already fixed this class. Drop the substring helpers. Also align wiki resolution with slides: CallAPITyped (typed error + log_id) and classify an incomplete get_node response as InternalError instead of a --url validation error. Add regression tests for query/fragment /wiki/ and incomplete node.
2026-06-22 19:13:38 +08:00
zhengzhijie
06241666a0 docs(sheets): note --url accepts wiki URLs (synced from spec) 2026-06-22 19:13:07 +08:00
zhengzhijie
a35cc26131 feat(sheets): resolve wiki URLs to the backing spreadsheet for --url
Sheets shortcuts only accepted /sheets/ and /spreadsheets/ URLs via --url.
A /wiki/<node_token> URL was rejected with "must be a spreadsheet URL"
because the wiki node_token is not a spreadsheet token: resolving it to the
backing spreadsheet needs a wiki get_node call, which Validate/DryRun (kept
network-free) must not make.

Mirror the existing slides/doc/drive two-stage pattern:

- parseSpreadsheetRef classifies --url / --spreadsheet-token network-free
  into a sheet token or an (unresolved) wiki node_token.
- resolveSpreadsheetTokenExec (Execute only) resolves a /wiki/ node_token
  via wiki get_node, verifies obj_type=sheet, and returns the obj_token.
  The wiki:node:read scope is enforced on this path only, so non-wiki
  invocations are unaffected.
- resolveSpreadsheetToken stays network-free for Validate/DryRun, passing
  the node_token through unchanged.

All 47 Execute paths (including +batch-update and +workbook-export) switch
to the Exec resolver; Validate/DryRun keep the network-free one. No tool
schema change: the CLI feeds the resolved spreadsheet token as excel_id, so
this is a pure CLI-layer change.

Tested: unit (parse classification + wiki get_node e2e via httpmock) and
live end-to-end against a real wiki spreadsheet (read: +workbook-info,
+cells-get, +csv-get; write: +sheet-create, +sheet-rename, +csv-put).
2026-06-22 19:13:07 +08:00
xiongyuanwen-byted
b6da950be3 feat(sheets): styles 接受 halign/valign 等对齐字段别名
把模型常幻觉的 horizontal_align / halign / vertical_align / valign 映射到
规范字段 horizontal_alignment / vertical_alignment,覆盖 --styles 与 typed
--cells;与规范字段冲突时报错而非静默择一。同步 lark-sheets skill 文档补
对齐字段说明 + --print-schema --flag-name styles 提示。
2026-06-22 18:28:05 +08:00
xiongyuanwen-byted
aa545083b6 docs(lark-sheets): sync from spec — set+H 告诫通则化(移入 stdin 段) 2026-06-22 18:28:05 +08:00
xiongyuanwen-byted
5c7100ee4c fix(sheets): migrate +table-put to typed error contract
The merge from main brought in #1449 (retire legacy error envelopes),
which removed output.ExitError / output.ErrDetail and forbids
constructing them. Port tablePutPartial off the legacy envelope:

- no sheets written -> typed errs.APIError (plain failure)
- some sheets written -> ok:false result via runtime.OutPartialFailure
  carrying written_sheets, returning the partial-failure exit signal

Also fix two drifts the same merge introduced:
- regenerate flag_defs_gen.go to match the committed flag-defs.json
- update the --max-chars flag test to assert visible (no longer hidden)
2026-06-22 12:29:03 +08:00
xiongyuanwen-byted
3ef3a9d1d3 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-06-22 10:14:25 +08:00
xiongyuanwen-byted
bdad336caf docs(lark-sheets): sync from spec — set+H 改单引号 / 速查表补臆造命令名 / workbook-import 引导 2026-06-20 14:11:02 +08:00
xiongyuanwen-byted
39a7d4bfb4 feat(sheets): 写操作报错增强 + --token 别名
- 复合 JSON shape 校验失败时报错附 --print-schema 提示,agent 可直接拿到精确结构(pro26 头号:+cells-set --cells 反复猜 shape)
- JSON 解析失败且该 flag 支持 stdin 时提示改用 stdin(公式/引号/逗号内联到 shell 被转义弄坏 JSON)
- --token 作为 --spreadsheet-token 的解析期别名:复用 sheets 已有 PostMount 钩子 + pflag normalize,仅 sheets 包,common 零改动
2026-06-20 14:11:02 +08:00
xiongyuanwen-byted
4b404fc0ee docs(lark-sheets): sync from spec — --max-chars 放出为可见 flag + 落盘优先指引
源同步自 sheet-skill-spec:--max-chars 放出(默认 500000,可调小避免大输出被 Bash/终端转存为文件、改 has_more 分页);read-data 增「大数据优先落盘」指引。
2026-06-18 15:58:20 +08:00
xiongyuanwen-byted
fc6e1e25de docs(lark-sheets): sync from spec — +csv-put 含逗号公式正例 + 收敛警示标签
源同步自 sheet-skill-spec:write-cells 补含逗号公式 RFC 4180 转义正例与结构化写入优先指引;全 reference 收敛「高频致命错误」类标签。
2026-06-18 13:07:30 +08:00
xiongyuanwen-byted
14d3107bf2 feat(sheets): +cells-get/+csv-get --max-chars 默认值 200000 → 500000
放宽默认防爆上限。flag_defs_gen.go 由 go generate 重生;flag_defs_test.go
的 expected default 同步;flag-schemas.json schema_version 2 → 3 是上游
spec-tables 架构调整带来的元数据 bump,与本业务改动无关、go:embed 不解析
该字段、无功能影响。

Synced from sheet-skill-spec@93f7a78.
2026-06-17 21:24:54 +08:00
zhengzhijiej-tech
e795f4f068 Merge pull request #1482 from larksuite/zzj/mention-doc-link
feat(sheets): document link requirement for @document mentions
2026-06-17 14:12:41 +08:00
xiongyuanwen-byted
2e4033a1a0 fix(shortcuts): clarify single-stdin constraint in flag help and error hint
Input flags advertised '(supports @file, - for stdin)' per flag, leading
AI agents to write '--a - <x --b - <y' where the second '<' silently
clobbers the first and the first flag reads the wrong payload. A process
has a single stdin, so at most one flag per call can use '-'.

- Reword the generated help hint to '- reads stdin (one flag per call;
  use @file for others)'.
- Add an actionable .WithHint to the stdin-conflict validation error
  pointing callers to @file for the extra flags.
- Assert the new hint in TestResolveInputFlags_DuplicateStdin.
2026-06-17 11:35:37 +08:00
xiongyuanwen-byted
fc44564b01 refactor(sheets): migrate legacy error helpers to typed errs in sheets domain
golangci-lint forbidigo (errs-no-legacy-helper / errs-no-bare-wrap) flagged
the table I/O, workbook, and dataframe shortcuts that landed on this branch:
93 common.FlagErrorf and 48 fmt.Errorf calls.

- Replace every common.FlagErrorf with common.ValidationErrorf (typed
  *errs.ValidationError, same signature) across workbook / table_io /
  dataframe / object_crud.
- writeDataframeOut's two final --dataframe-out write failures become typed
  errs.NewInternalError(SubtypeFileIO, ...).WithCause(err).
- applyWorkbookCreateVisualOps now passes the typed callTool error through
  unchanged (re-wrapping would downgrade classification) and attaches the
  failing op as a recovery hint only when none is set.
- The remaining fmt.Errorf are genuine intermediate errors that the command
  layer re-wraps into typed validation errors (buildTypedCell / Arrow
  decode-encode) or surfaces as a partial_success message string
  (writeTypedSheets via tablePutPartial); each carries a //nolint:forbidigo
  with that reason, per the lint guidance.

No behavior change: error messages and partial-success shapes are preserved;
gofmt, go vet, golangci-lint (0 issues) and sheets tests all pass.
2026-06-16 20:47:54 +08:00
xiongyuanwen-byted
7742a47072 fix(sheets): collapse duplicate validateCreateInput from bad merge resolution
A prior merge kept both branches' independently-added validateCreateInput
fields on objectCRUDSpec with conflicting signatures (pivot's
func(rt, input) and cond-format's func(input)), plus both call sites in
objectCreateInput, which failed to compile (validateCreateInput redeclared).

Collapse to the single richer func(rt flagView, input) signature and one
call site. cond-format's validateCondFormatAttrs (func(input), still shared
with validateUpdateInput) is wrapped in a closure that ignores rt. Both
behaviors are preserved: pivot --target-position/--range mutex and
cond-format attrs-shape-vs-rule_type validation.
2026-06-16 20:10:47 +08:00
xiongyuanwen-byted
3668b904ca Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-06-16 20:01:50 +08:00
xiongyuanwen-byted
1c68d31d12 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop
# Conflicts:
#	shortcuts/drive/drive_export.go
#	shortcuts/drive/drive_import.go
2026-06-16 19:52:50 +08:00
xiongyuanwen-byted
4c51cd36fb docs(sheets): fix csv-get current_region guidance to cross-check row_count
current_region is a blank-row/column-bounded block, not the true sheet extent:
an internal blank row truncates it, so it can miss rows past the gap. The
read-data reference previously called it the "真实数据边界" and told agents to
prefer it over row_count — which drove the "read only to current_region's last
row, miss the tail" failure.

- current_region: warn it can be both smaller (internal blank rows truncate)
  and larger (trailing summary/signature rows) than the real data range.
- csv-get output contract: clarify its row_count/col_count is the returned size
  (= actual_range), not the physical sheet size; has_more only reflects the
  current range, not whether the whole sheet was read.
- "确定数据范围的正确流程": add a step to cross-check against +workbook-info's
  physical row_count and probe past current_region's last row for data beyond an
  internal blank row.
2026-06-16 18:48:00 +08:00
xiongyuanwen-byted
bbeae3636c fix(sheets): default +table-get to full used range, not A1 current region
+table-get without --range anchored its current_region probe at A1, so an
internal blank row or column silently truncated everything past it — agents
then treated the partial data as complete (the pro016 / pro025 incident).

- Probe the used range over the full physical grid (row_count × column_count
  from the workbook structure) so it spans internal blank rows/columns; fall
  back to the legacy A1 anchor when dimensions are unknown.
- Emit the actually-read `range` on every sheet so callers can detect
  truncation (get_cell_ranges has no has_more flag).
- Fix the same A1-anchor bug in append mode's last-data-row probe, which could
  otherwise overwrite data past an internal blank row.
- Add unit + dry-run/live E2E coverage; refresh synced skill docs.
2026-06-16 18:48:00 +08:00
zhengzhijiej-tech
a9d88c5666 Merge pull request #1486 from larksuite/fix/cond-format-attrs-shape-validation
fix(sheets): reject cond-format attrs whose shape mismatches rule_type
2026-06-16 17:49:32 +08:00
zhengzhijie
4801675fd6 test(sheets): guard condFormatAttrsRequired against flag-schemas drift
Add TestCondFormatAttrsRequired_MatchesSchemaOneOf, comparing the
hand-maintained condFormatAttrsRequired table against the embedded
flag-schemas.json attrs oneOf (multiset of required-key sets, for both
create and update). The cross-field validator only holds if its
per-rule_type required keys mirror the schema branches, and the two
share no compile-time link — this pins them together so a future schema
sync that adds/drops a required key can't silently desync the table.
2026-06-16 17:45:11 +08:00
zhengzhijie
dd04b3705f fix(sheets): reject cond-format attrs whose shape mismatches rule_type
A conditional-format rule created with --rule-type colorScale but
cellIs-shaped attrs ({compare_type,value}, no color) was accepted by
the CLI and written through to the server, producing a color-less
color-scale segment. That dirty data crashes the frontend on snapshot
deserialization, so the spreadsheet can no longer be opened (5005).

The per-entry schema check can't catch this: properties.attrs.items is
a oneOf over all nine attr shapes and passes as soon as any branch
matches, blind to the sibling rule_type — {compare_type,value} matches
the cellIs branch even when rule_type says colorScale. The tool side
maps attrs blindly by rule_type and only validates dataBar count and
iconSet ordering, so the gap reaches the data layer.

Add a cross-field validator (validateCondFormatAttrs) wired into both
create and update via the new objectCRUDSpec.validateCreateInput hook
(twin of validateUpdateInput). It enforces, per rule_type, the keys
every attrs entry must carry — mirroring the tool's converter contract
— and treats an empty required string (notably color) as missing.
Rule types that take no attrs (duplicateValues / uniqueValues /
containsBlanks / notContainsBlanks) and updates that omit rule_type are
left to the server.
2026-06-16 17:23:58 +08:00
zhengzhijie
439f184ba5 feat(sheets): document link requirement for @document mentions in cells flag schema
@document mentions (mention_type != 0) must pass link (doc URL) to render a
clickable card; @user mentions (mention_type=0) don't need it. Synced from the
upstream tools-schema.
2026-06-16 14:58:36 +08:00
xiongyuanwen-byted
825071fd7a docs(lark-sheets): point read-data to +sheet-info for hidden row/col identification
skip-hidden defaults to false (lossless reads), but the read primitives don't mark which rows/cols are hidden. Cross-reference +sheet-info --include hidden_rows,hidden_cols + row_indices/col_indices so agents can identify hidden ranges when they need to filter or interpret hidden data.

Synced from sheet-skill-spec.
2026-06-16 14:25:19 +08:00
xiongyuanwen-byted
72999cd303 feat(sheets): add --styles to +table-put for one-step typed write with styling
+table-put now accepts --styles (same shape as +workbook-create's --styles):
cell_styles merge into the set_cell_range matrix, while cell_merges /
row_sizes / col_sizes apply as their own tool calls after the write. The
styles payload is name-matched against the written sheets and validated up
front, so a malformed or mismatched style fails before any write lands.

Also points +sheet-create users to +table-put (auto-creates missing sheets)
when they need data/styles, via a runtime Tip and the lark-sheets skill
references. Flag is sourced from the upstream Base table and regenerated
through sheet-skill-spec (flag-defs.json / flag-schemas.json / gen file).

Adds unit tests (dry-run styles, name-mismatch reject, execute) and a
dry-run E2E (tests/cli_e2e/sheets/sheets_table_put_dryrun_test.go).
2026-06-16 12:56:59 +08:00
xiongyuanwen-byted
f9c73e217d docs(lark-sheets): clarify cell-image vs float-image routing and fix reference self-references
Synced from sheet-skill-spec.

- Add a binding-based decision (does the image belong to a record and move with its row?) to route +cells-set-image vs +float-image-create across the SKILL entry, float-image and write-cells references.
- Add routing rows to the SKILL command cheat-sheet and warn against defaulting to float-image out of familiarity.
- Replace mislabeled 本 skill / 子 skill / 跨 skill wording in references with 本文 / reference names, matching the existing convention.
2026-06-16 10:55:23 +08:00
xiongyuanwen-byted
5f3c1c8e6a docs(lark-sheets): remove financial modeling standards reference
Drop the lark-sheets-financial-modeling-standards.md reference doc and all
pointers to it from SKILL.md, core-operations, and visual-standards. Bump
skill version to 3.0.0.
2026-06-15 18:46:34 +08:00
zhengzhijiej-tech
ead8aa854f Merge pull request #1439 from larksuite/fix/sheet-mention-type-enum
fix(sheets): add mention_type enum to set_cell_range cells schema
2026-06-15 11:50:35 +08:00
xiongyuanwen-byted
833b7cde33 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop
# Conflicts:
#	shortcuts/sheets/lark_sheet_workbook.go
#	shortcuts/sheets/lark_sheet_workbook_test.go
2026-06-15 11:26:15 +08:00
xiongyuanwen-byted
57d71607e1 feat(sheets): add --dataframe Arrow IPC input for +table-put/+table-get/+workbook-create
Introduce a binary-typed twin of --sheets: --dataframe accepts an Arrow IPC
(Feather v2) payload that pandas' df.to_feather() writes, deriving dtypes and
per-column number formats from the Arrow schema. The two producers are mutually
exclusive and funnel through a shared resolver so +table-put and
+workbook-create stay in lockstep; +table-get gains --dataframe-out for
single-sheet reads. Also auto-grow a sub-sheet's row/column count before
writing so blocks past the backend's default 200x20 bounds no longer fail with
range-exceeds-sheet-bounds.
2026-06-14 22:40:39 +08:00
xiongyuanwen-byted
d2c326a78c feat(sheets): implement pandas-split --sheets protocol for +table-put/+table-get/+workbook-create
Synced from sheet-skill-spec canonical (cli:table_put schema +
references). +table-put/+workbook-create accept the new shape via a
tableSheetIn -> tableSheetSpec normalize step (dtype string -> internal
type/format mapping). +table-get emits the same shape so the writer's
df_to_sheet and the reader's sheet_to_df round-trip cleanly.

isoDateToSerial now accepts the full ISO datetime form
(2024-01-15T00:00:00.000, including timezone suffixes) emitted by
df.to_json(date_format="iso"), not just yyyy-mm-dd. End-to-end verified
by the spec repo's contracts/python_helper_roundtrip script against a
real Lark spreadsheet on pandas 2.2 and 3.0.
2026-06-12 17:32:08 +08:00
zhengzhijie
422797305a fix(sheets): add mention_type enum to set_cell_range cells schema
Constrain rich_text mention_type to the proto MENTION_FILE_TYPE set so a
file @mention with an out-of-enum value (e.g. 6 = cloud shared folder) is
rejected by the schema validator before it reaches the server and fails
pb serialization ("mentionFileInfo.fileType: enum value expected").

- data/flag-schemas.json: mention_type gains enum + per-value description
- lark_sheet_write_cells_test.go: cover reject (6) + allow (0 / 2 / 22)
2026-06-12 16:53:40 +08:00
xiongyuanwen-byted
3fa28c10fa Merge remote-tracking branch 'origin/feat/lark-sheets-develop' into feat/lark-sheets-develop 2026-06-12 12:03:00 +08:00
xiongyuanwen-byted
27d185c91c feat(sheets): rework +workbook-create flags and --styles
- --values builds a type-less typed payload, writing through --sheets' batched set_cell_range path (raw passthrough preserves auto-detect; large tables batch; big ints via json.Number)
- drop --headers (subsumed by --values first row) and --header-style (typed header no longer auto-bold; use --styles instead)
- styles: deep-merge overlapping cell_styles/border_styles fields (was wholesale-replace which dropped fields); add manual border_styles validation (style/weight enums + sides) since --styles is on parseJSONFlagSkip and bypasses the schema validator
- regenerate flag-defs/flag-schemas/skills mirror from sheet-skill-spec (--styles flag + full per-side border schema)
2026-06-12 12:02:32 +08:00
zhengzhijiej-tech
83926943ae Merge pull request #1397 from larksuite/fix-chart-aggregate-counta-zzj
feat(sheets): add counta to chart aggregateType enum
2026-06-11 19:11:36 +08:00
zhengzhijie
752bfcbbb9 feat(sheets): make --target-position and --range mutually exclusive on +pivot-create
Both flags map to the same wire field (properties.range), so passing
non-default values for both is ambiguous. Mirror the
--target-sheet-id / --target-sheet-name mutex pattern: --target-position
takes priority over --range, and supplying both with non-default values
is rejected up front with a typed FlagErrorf. --target-position=A1 is
the documented default and is treated as "not set".

Add a symmetric validateCreateInput hook on objectCRUDSpec (alongside
the existing validateUpdateInput), wire it into objectCreateInput, and
inject the pivot-specific check on pivotSpec.
2026-06-11 16:45:28 +08:00
zhengzhijie
80d9f6b59b feat(sheets): add counta to chart aggregateType enum
Add `counta` (count non-empty cells, incl. text) to manage_chart_object
dim2.series[].aggregateType in the chart flag schema. `count` only counts
numeric cells, so counting occurrences of a text/category column renders an
empty chart; `counta` enables category frequency counts. Synced from the
sheet-skill-spec canonical schema.
2026-06-11 14:32:03 +08:00
xiongyuanwen-byted
080ef44cdb Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-06-09 19:52:08 +08:00
xiongyuanwen-byted
f046fb6282 fix(sheets): regenerate flag defs and fix asasalint in table io 2026-06-09 17:48:58 +08:00
xiongyuanwen-byted
ca9eddb142 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-06-09 17:29:26 +08:00
zhengzhijiej-tech
1caeb2d377 Merge pull request #1351 from larksuite/fix/chart-dim-insert-example
docs(sheets): chart / filter / workbook reference corrections
2026-06-09 16:47:31 +08:00
zhengzhijie
a66bef66af docs(sheets): label +sheet-create --index as 0-based
The base flag description for +sheet-create's --index omitted the
coordinate base, while its siblings +sheet-move ("Target position
(0-based)") and +sheet-copy already state 0-based. Align the description
so the index base is unambiguous. Synced from the spec source
(flag-defs.json + workbook reference).
2026-06-09 16:25:02 +08:00
zhengzhijie
421805d35c docs(sheets): chart coordinate base / quoting + filter condition enums
Sync three reference-doc corrections from the spec source:

1. chart: label position.row as 0-based (first row = row:0), distinct
   from the 1-based row numbers used by A1 ranges and +dim-insert
   --position, removing the row-base ambiguity.

2. chart: convert the three runnable examples whose JSON contains a
   quoted sheet prefix ('Sheet1'!A1) from inline single-quoted
   --properties '{...}' to a stdin heredoc (--properties - <<'JSON').
   Inside an inline single-quoted string bash strips the inner quotes
   around the sheet name (and splits names with spaces into words),
   corrupting the JSON; a quoted heredoc delimiter performs no shell
   substitution and preserves it. Adds a short note on the pitfall.

3. filter / filter-view: add the full conditions[].type x compare_type
   enum table (text / number / multiValue / color and their respective
   compare_type values and values shape), and call out the
   equals/notEquals (with s) vs equal/notEqual (no s) gotcha. The docs
   previously only showed two values via examples.
2026-06-09 16:25:02 +08:00
zhengzhijie
8d5bb73c70 docs(sheets): fix invalid +dim-insert example in chart reference
The chart reference's placement example used non-existent flags
--dimension/--start/--end for +dim-insert. The real signature is
--position (required) + --count (required); copying the example
fails Validate with "--position is required". Replace it with
+dim-insert --position V --count 6 (insert 6 columns before V,
i.e. after U), aligning with the sheet-structure reference.
2026-06-09 15:34:05 +08:00
xiongyuanwen-byted
97b9ffb466 docs(sheets): align +csv-put help with formula support
Sync the formula-support wording from sheet-skill-spec (flag-defs, skill
references) and update the hand-authored cobra Description and comment for
+csv-put. +csv-put evaluates a leading-= cell as a formula via
set_range_from_csv; descriptions only, no behavior change.
2026-06-08 20:38:10 +08:00
zhengzhijiej-tech
336f147ca6 Merge pull request #1296 from larksuite/feat/sheet-eval-guidance-fixes
docs(sheets): strengthen lark-sheets references for common editing pitfalls
2026-06-08 19:13:29 +08:00
zhengzhijie
0a47f35c7d docs(sheets): align write-cells reference with the generated output
Bring the hand-applied write-cells example in line with the spec-generated
reference so the CLI mirror is byte-identical to the canonical source.
2026-06-08 19:07:44 +08:00
Chenweifeng-bd
72ac526e23 docs: add lark sheets financial modeling guidance 2026-06-08 17:05:11 +08:00
zhengzhijie
023a8786f0 docs(sheets): reword guidance to avoid eval-specific phrasing
Replace scoring-framework wording in the examples with plain functional
consequences (e.g. "not delivered", "goes stale when the source changes",
"breaks the original visual format"), so the references stay agent-facing.
2026-06-08 15:44:35 +08:00
zhengzhijie
3ecd75b53d docs(sheets): keep original column widths; align chart axis with requested metric
- range-operations: only widen new / overflowing columns; never recompute or
  shrink the widths of existing columns (any blanket resize, even by 1px,
  breaks the original visual format)
- chart: when the user asks for a share / percentage, the value axis should be
  a percentage (pie, or stack.percentage on bar/column) rather than raw counts
2026-06-08 14:38:00 +08:00
xiongyuanwen-byted
5bf71428a4 refactor(sheets): reuse the drive export core in +workbook-export
Replace +workbook-export's parallel export-task implementation with the shared drive ExportParams/RunExport core (pinned to type=sheet). Drops ~90 lines of duplicated poll/download code; +workbook-export now inherits drive's ctx cancellation, resume-on-timeout, filename sanitize/overwrite, and the full set of export status labels. The output contract aligns with drive's (adds ready/downloaded/doc_type; saved_path preserved). Also normalize an empty drive --output-dir to "." so drive +export behavior is unchanged, and fix the sheets export e2e to call +workbook-export instead of a nonexistent +export.
2026-06-08 12:58:11 +08:00
xiongyuanwen-byted
e819e819fe feat(sheets): add +workbook-import wrapping the drive import core
Import a local xlsx/xls/csv as a new spreadsheet by delegating to the shared drive import flow with the target type pinned to sheet. Refactor drive +import to expose ImportParams / ValidateImport / PlanImportDryRun / RunImport (behavior unchanged, existing drive tests still cover it); sheets reuses them. Regenerate flag_defs_gen.go and sync the spec mirror.
2026-06-08 11:00:46 +08:00
xiongyuanwen-byted
2017e9dab8 docs(sheets): sync SKILL.md (drop "Feishu sheets only" caveat)
Mirror the upstream sheet-skill-spec change removing the "applies to Feishu sheets only" tail from the 14 sheet reference descriptions.
2026-06-07 22:45:53 +08:00
xiongyuanwen-byted
74a02e6f2d docs(sheets): sync SKILL.md (drop "not for local Excel" caveat)
Mirror the upstream sheet-skill-spec change removing the "not applicable to local Excel files" tail from the sheets skill and reference descriptions.
2026-06-07 22:39:58 +08:00
xiongyuanwen-byted
02f4f73227 docs(sheets): surface typed-write path at the write-decision point
Quick-ref table (SKILL.md, the first decision point) had no +table-put and
gated typed writes on "DataFrame", so a model holding a Counter/list/dict
would fall back to +csv-put and silently lose number/date fidelity.

- split csv-put row to plain-text values (no numeric/date semantics)
- add +table-put row for typed writes into an existing sheet
- add +workbook-create --sheets row for create + typed write in one shot
- add judgment note: number/amount/date/percent/count -> +table-put
  (or +workbook-create --sheets when the workbook does not exist yet);
  plain text -> +csv-put
- reframe write-cells scenario row to lead with numeric semantics
- point new-table writes at +workbook-create --sheets (one shot) instead
  of the create-empty-then-table-put two-step

Synced from sheet-skill-spec canonical (generate:cli + sync:cli).
2026-06-07 00:30:13 +08:00
xiongyuanwen-byted
a2625d036d feat(sheets): implement table-put/table-get and sync skill specs
- Add lark_sheet_table_io.go with +table-put / +table-get and tests
- Refactor read-data; extend workbook; register new shortcuts
- Sync generated flag defs/schemas (go:embed) from sheet-skill-spec
- Sync skill references (write-cells numeric-column guidance, plus
  read-data / workbook / chart updates)
2026-06-05 20:03:33 +08:00
zhengzhijie
d005694e0f docs(sheets): strengthen lark-sheets references for common editing pitfalls
Add targeted guidance to six lark-sheets references to reduce frequent
mistakes when editing spreadsheets through the CLI:

- write-cells: sanity-check units / dimension conversion / quantity factors
  before formula writes (formulas can run clean yet be off by a factor);
  keep derived output off original data columns to avoid clobbering source
- core-operations: prefer live formulas for derived values even when "live
  update" is not explicitly requested; scope rewrite/transform precisely so
  rows/columns that should stay unchanged are kept 1:1; treat header-stated
  format rules as checklist items; confirm the artifact file actually exists
  before finishing; write back bare values from local scripts
- visual-standards: apply border/header formatting on explicit request and
  identify the real header row; keep font size consistent with the source
- range-operations: keep total column width within A4 for printing
- read-data: dedup/compare long numbers via raw values, not csv formatted
  display (scientific notation collapses distinct numbers and causes false
  duplicates)
- chart: format date/number axes via source-cell number_format; place charts
  outside the data area so they do not cover existing data
2026-06-05 19:20:25 +08:00
zhengzhijiej-tech
3149c77134 Merge pull request #1264 from zhengzhijiej-tech/feat/sheet-gridline
feat(sheets): add gridline show/hide shortcuts
2026-06-04 19:12:41 +08:00
zhengzhijie
6e067f2180 feat(sheets): add +sheet-show-gridline / +sheet-hide-gridline shortcuts 2026-06-04 17:00:07 +08:00
159 changed files with 11155 additions and 12555 deletions

View File

@@ -318,7 +318,39 @@ jobs:
continue-on-error: true
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
- name: Check dependency licenses
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown
# --ignore github.com/apache/arrow/go/v17: Arrow is Apache-2.0 overall,
# but its LICENSE.txt also inlines the c-ares 3rdparty notice (Arrow's
# python wheels statically link c-ares) — and go-licenses' classifier
# parses the whole file as a single license, so it reports the module
# as "LicenseRef-C-Ares / Unknown". The follow-up step pins the actual
# license type by inspecting the LICENSE.txt itself, so the wholesale
# --ignore here doesn't become a free pass for future Arrow re-licensing.
# Required by sheets +table-put / +table-get / +workbook-create --dataframe (Arrow IPC ingest).
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown --ignore github.com/apache/arrow/go/v17
- name: Assert Apache Arrow LICENSE.txt remains Apache-2.0
# Independent re-check that the go-licenses ignore above is purely a
# classifier workaround, not a free pass: confirm Arrow's LICENSE.txt
# still opens with the Apache License and still inlines the c-ares
# notice that is the actual reason go-licenses misreports the module.
# If Arrow ever re-licenses its primary license or drops the c-ares
# notice (meaning go-licenses might start reporting a different /
# genuinely problematic identifier instead), this step fails and a
# human must re-evaluate the --ignore.
run: |
set -euo pipefail
LICENSE_PATH="$(go env GOMODCACHE)/github.com/apache/arrow/go/v17@v17.0.0/LICENSE.txt"
if [ ! -f "$LICENSE_PATH" ]; then
echo "::error::Apache Arrow LICENSE.txt not found at $LICENSE_PATH" >&2
exit 1
fi
if ! head -50 "$LICENSE_PATH" | grep -q 'Apache License' ; then
echo "::error::Apache Arrow LICENSE.txt no longer leads with the Apache License — re-evaluate the go-licenses --ignore" >&2
exit 1
fi
if ! grep -q '3rdparty dependency c-ares' "$LICENSE_PATH" ; then
echo "::error::Apache Arrow LICENSE.txt no longer inlines the c-ares notice — go-licenses may now report a different identifier; re-evaluate the --ignore" >&2
exit 1
fi
license-header:
if: ${{ github.event_name == 'pull_request' }}

View File

@@ -47,10 +47,13 @@ jobs:
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
}
let prNumber = Number(runPRs[0]?.number || 0);
let eventBaseSha = runPRs[0]?.base?.sha || "";
const eventBaseSha = runPRs[0]?.base?.sha || "";
const eventHeadSha = runPRs[0]?.head?.sha || "";
const targetHeadSha = eventHeadSha || run.head_sha;
const targetHeadSha = run.head_sha;
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
core.notice("PR quality summary using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
}
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
@@ -71,11 +74,11 @@ jobs:
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
artifactError = "facts artifact head sha does not match verified PR head sha";
factsArtifactName = "";
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
factsArtifactName = "";
} else {
artifactBaseSha = parsedBaseSha;
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
core.notice("PR quality summary using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
}
}
}
if (!prNumber) {
@@ -123,7 +126,7 @@ jobs:
core.setOutput("stale", "true");
return;
}
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
@@ -255,10 +258,13 @@ jobs:
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
}
let prNumber = Number(runPRs[0]?.number || 0);
let eventBaseSha = runPRs[0]?.base?.sha || "";
const eventBaseSha = runPRs[0]?.base?.sha || "";
const eventHeadSha = runPRs[0]?.head?.sha || "";
const targetHeadSha = eventHeadSha || run.head_sha;
const targetHeadSha = run.head_sha;
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
core.notice("semantic review using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
}
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
@@ -279,11 +285,11 @@ jobs:
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
artifactError = "facts artifact head sha does not match verified PR head sha";
factsArtifactName = "";
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
factsArtifactName = "";
} else {
artifactBaseSha = parsedBaseSha;
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
core.notice("semantic review using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
}
}
}
if (!prNumber) {
@@ -331,7 +337,7 @@ jobs:
core.setOutput("stale", "true");
return;
}
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
core.notice("semantic review skipped: workflow_run is stale for this PR base");

5
.gitignore vendored
View File

@@ -7,6 +7,11 @@ bin/
# Node
node_modules/
# Python (skill-bundled helper scripts)
__pycache__/
*.py[cod]
*$py.class
# OS
.DS_Store

View File

@@ -2,6 +2,30 @@
All notable changes to this project will be documented in this file.
## [v1.0.57] - 2026-06-23
### Features
- **slides**: Add `+screenshot` to capture slide page images (or render a single `<slide>` XML snippet), returning the local file path instead of Base64 (#1358)
- **base**: Support record comments (#1043)
- **search**: Surface search API notices (#1413)
### Bug Fixes
- **mail**: Resolve folder/label filter once per `+triage list` call (#1512)
- **meta**: Backfill enum value descriptions from options (#1541)
- **cli**: Add missing CLI headers for git credential helper (#1539)
### Documentation
- **doc**: Refine rich block, path, and block ID guidance (#1508)
- **mail**: Trim lark-mail skill context (#1527)
- **drive**: Add permission governance workflow guidance (#1292)
### Build
- **ci**: Bind semantic review to workflow run head (#1551)
## [v1.0.56] - 2026-06-18
### Features
@@ -1212,6 +1236,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54

12
go.mod
View File

@@ -27,6 +27,8 @@ require (
gopkg.in/yaml.v3 v3.0.1
)
require github.com/apache/arrow/go/v17 v17.0.0
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -42,13 +44,17 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/flatbuffers v24.3.25+incompatible // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/itchyny/timefmt-go v0.1.6 // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
@@ -57,10 +63,16 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/smarty/assertions v1.15.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/tools v0.22.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
)

32
go.sum
View File

@@ -2,6 +2,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/apache/arrow/go/v17 v17.0.0 h1:RRR2bdqKcdbss9Gxy2NS/hK8i4LDMh23L6BbkN5+F54=
github.com/apache/arrow/go/v17 v17.0.0/go.mod h1:jR7QHkODl15PfYyjM2nU+yTLScZ/qfj7OSUZmJ8putc=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -52,12 +54,16 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
@@ -74,11 +80,16 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@@ -97,6 +108,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -133,14 +146,20 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ=
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -156,6 +175,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
@@ -169,10 +189,16 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
gonum.org/v1/gonum v0.15.0 h1:2lYxjRbTYyxkJxlhC+LvJIx3SsANPdRybu1tGj9/OrQ=
gonum.org/v1/gonum v0.15.0/go.mod h1:xzZVBJBtS+Mz4q0Yl2LJTk+OxOg4jiXZ7qBoM0uISGo=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -1,5 +0,0 @@
# harness-opt 只入库轻量决策记录;重的原始评测 run 不进版本库(dashboard 仍读磁盘)。
baseline/runs/
**/child-runs/
verify_results/sealed-runs/
verify_results/*-runs/

View File

@@ -1,5 +0,0 @@
{
"1": 30086,
"2": 34616,
"3": 31289
}

View File

@@ -1,50 +0,0 @@
{
"k": 5,
"metrics": {
"success_rate": {
"mean": 0.4666666666666666,
"std": 0.1632993161855452,
"k": 5,
"band": [
0.14006803429557624,
0.793265299037757
]
},
"mean_score": {
"mean": 0.5111111111111111,
"std": 0.1507184440694504,
"k": 5,
"band": [
0.20967422297221028,
0.8125479992500119
]
},
"mean_context_window": {
"mean": 31997.0,
"std": 7166.8411203573105,
"k": 5,
"band": [
17663.31775928538,
46330.682240714625
]
},
"mean_duration_ms": {
"mean": 50188.86666666667,
"std": 7746.3168641619595,
"k": 5,
"band": [
34696.23293834275,
65681.50039499058
]
},
"mean_token": {
"mean": 263981.06666666665,
"std": 27890.193480385413,
"k": 5,
"band": [
208200.67970589583,
319761.45362743747
]
}
}
}

View File

@@ -1,33 +0,0 @@
{
"k": 5,
"n_cases": 3,
"effect": {
"mean": 0.5111111111111111,
"sigma": 0.1507184440694504
},
"token": {
"mean": 31997.0,
"sigma": 7166.8411203573105
},
"duration": {
"mean": 50188.86666666667,
"sigma": 7746.3168641619595
},
"phi0_per_case": {
"1": {
"effect": 0.6,
"token": 30086,
"duration": 51004
},
"2": {
"effect": 0.4,
"token": 34616,
"duration": 52787
},
"3": {
"effect": 0.5333,
"token": 31289,
"duration": 46776
}
}
}

View File

@@ -1,869 +0,0 @@
{
"summary": {
"total_cases": 3,
"files": 25,
"expected_declared": 0,
"blind_spots": 22,
"overfit_high": 5,
"suggest_add_cases": [
"skills/lark-im/references/lark-im-chat-identity.md",
"skills/lark-im/references/lark-im-flag-cancel.md",
"skills/lark-im/references/lark-im-flag-create.md",
"skills/lark-im/references/lark-im-message-enrichment.md",
"skills/lark-im/references/lark-im-messages-search.md"
],
"suggest_fix_routing": []
},
"files": [
{
"path": "skills/lark-im/references/lark-im-chat-identity.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "高",
"risk_lines": {
"R0": 5,
"R1": 0,
"R2": 0,
"R3": 50
},
"total_lines": 55,
"overfit_risk": "高",
"suggest_add_cases": true,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-messages-search.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "高",
"risk_lines": {
"R0": 6,
"R1": 85,
"R2": 112,
"R3": 31
},
"total_lines": 234,
"overfit_risk": "高",
"suggest_add_cases": true,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-flag-cancel.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "高",
"risk_lines": {
"R0": 6,
"R1": 25,
"R2": 21,
"R3": 15
},
"total_lines": 67,
"overfit_risk": "高",
"suggest_add_cases": true,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-flag-create.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "高",
"risk_lines": {
"R0": 7,
"R1": 25,
"R2": 20,
"R3": 15
},
"total_lines": 67,
"overfit_risk": "高",
"suggest_add_cases": true,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-message-enrichment.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "高",
"risk_lines": {
"R0": 1,
"R1": 0,
"R2": 43,
"R3": 10
},
"total_lines": 54,
"overfit_risk": "高",
"suggest_add_cases": true,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-chat-messages-list.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 5,
"R1": 90,
"R2": 40,
"R3": 22
},
"total_lines": 157,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-messages-reply.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 1,
"R1": 139,
"R2": 109,
"R3": 14
},
"total_lines": 263,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-feed-groups.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 50,
"R1": 368,
"R2": 22,
"R3": 12
},
"total_lines": 452,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-chat-search.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 5,
"R1": 102,
"R2": 24,
"R3": 11
},
"total_lines": 142,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-chat-update.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 5,
"R1": 67,
"R2": 2,
"R3": 10
},
"total_lines": 84,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-messages-resources-download.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 5,
"R1": 55,
"R2": 24,
"R3": 10
},
"total_lines": 94,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-threads-messages-list.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 6,
"R1": 72,
"R2": 28,
"R3": 9
},
"total_lines": 115,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-chat-list.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 1,
"R1": 103,
"R2": 56,
"R3": 6
},
"total_lines": 166,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-flag-list.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 5,
"R1": 80,
"R2": 9,
"R3": 6
},
"total_lines": 100,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-reactions.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 73,
"R1": 206,
"R2": 18,
"R3": 2
},
"total_lines": 299,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-feed-group-list-item.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 7,
"R1": 44,
"R2": 17,
"R3": 0
},
"total_lines": 68,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-feed-group-list.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 6,
"R1": 44,
"R2": 15,
"R3": 0
},
"total_lines": 65,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-feed-group-query-item.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 6,
"R1": 21,
"R2": 17,
"R3": 0
},
"total_lines": 44,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-feed-shortcut-create.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 7,
"R1": 70,
"R2": 20,
"R3": 0
},
"total_lines": 97,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-feed-shortcut-list.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 6,
"R1": 73,
"R2": 24,
"R3": 0
},
"total_lines": 103,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-feed-shortcut-remove.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 10,
"R1": 24,
"R2": 14,
"R3": 0
},
"total_lines": 48,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/SKILL.md",
"is_domain_skill": true,
"actual": {
"count": 3,
"pct": 1.0,
"tier": "密"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 3,
"pct": 1.0,
"tier": "密"
},
"discoverability_miss": 0,
"density_count": 3,
"density_pct": 1.0,
"density_tier": "密",
"risk_tier": "中",
"risk_lines": {
"R0": 122,
"R1": 0,
"R2": 68,
"R3": 41
},
"total_lines": 231,
"overfit_risk": "低",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-chat-create.md",
"is_domain_skill": false,
"actual": {
"count": 2,
"pct": 0.667,
"tier": "密"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 2,
"pct": 0.667,
"tier": "密"
},
"discoverability_miss": 0,
"density_count": 2,
"density_pct": 0.667,
"density_tier": "密",
"risk_tier": "中",
"risk_lines": {
"R0": 5,
"R1": 116,
"R2": 12,
"R3": 29
},
"total_lines": 162,
"overfit_risk": "低",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-messages-send.md",
"is_domain_skill": false,
"actual": {
"count": 2,
"pct": 0.667,
"tier": "密"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 2,
"pct": 0.667,
"tier": "密"
},
"discoverability_miss": 0,
"density_count": 2,
"density_pct": 0.667,
"density_tier": "密",
"risk_tier": "中",
"risk_lines": {
"R0": 1,
"R1": 140,
"R2": 109,
"R3": 14
},
"total_lines": 264,
"overfit_risk": "低",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-messages-mget.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "低",
"risk_lines": {
"R0": 5,
"R1": 84,
"R2": 10,
"R3": 0
},
"total_lines": 99,
"overfit_risk": "低",
"suggest_add_cases": false,
"suggest_fix_routing": false
}
]
}

View File

@@ -1,48 +0,0 @@
{
"slug": "im-token",
"modules": [
"skills/lark-im/SKILL.md",
"skills/lark-im/references/lark-im-chat-create.md",
"skills/lark-im/references/lark-im-chat-identity.md",
"skills/lark-im/references/lark-im-chat-list.md",
"skills/lark-im/references/lark-im-chat-messages-list.md",
"skills/lark-im/references/lark-im-chat-search.md",
"skills/lark-im/references/lark-im-chat-update.md",
"skills/lark-im/references/lark-im-feed-group-list-item.md",
"skills/lark-im/references/lark-im-feed-group-list.md",
"skills/lark-im/references/lark-im-feed-group-query-item.md",
"skills/lark-im/references/lark-im-feed-groups.md",
"skills/lark-im/references/lark-im-feed-shortcut-create.md",
"skills/lark-im/references/lark-im-feed-shortcut-list.md",
"skills/lark-im/references/lark-im-feed-shortcut-remove.md",
"skills/lark-im/references/lark-im-flag-cancel.md",
"skills/lark-im/references/lark-im-flag-create.md",
"skills/lark-im/references/lark-im-flag-list.md",
"skills/lark-im/references/lark-im-message-enrichment.md",
"skills/lark-im/references/lark-im-messages-mget.md",
"skills/lark-im/references/lark-im-messages-reply.md",
"skills/lark-im/references/lark-im-messages-resources-download.md",
"skills/lark-im/references/lark-im-messages-search.md",
"skills/lark-im/references/lark-im-messages-send.md",
"skills/lark-im/references/lark-im-reactions.md",
"skills/lark-im/references/lark-im-threads-messages-list.md"
],
"modules_spec": [
"skills/lark-im/**/*.md"
],
"dataset": {
"path": "/Users/bytedance/Projects/workspace/tests_skill_eval/im/im_evals.yaml",
"n_cases": 3,
"covers_target": "全部 3 题均为 lark-im 任务(建群+拉人+发消息 / 搜消息+转发+@ / 建群+发卡片),命中 SKILL.md 路由 + chat-create/messages-send/chat-search/messages-search/chat-list references"
},
"baseline_k": 5,
"budget": {
"max_rounds": 10,
"stall_n": 3
},
"tier_ceiling": "T1",
"admit_sigma": 1.0,
"admit_sigma_duration": 1.0,
"admit_sigma_effect": 1.0,
"admit_sigma_target_boost": 0.0
}

View File

@@ -1,60 +0,0 @@
{
"task_id": "OPT-IM-1",
"title": "优化 lark-im省 token 保成功率)",
"branch": "feat/opt-im-token",
"current_phase": "round",
"phase_status": "in_progress",
"started_at": "2026-06-23T17:52:10",
"updated_at": "2026-06-23T19:38:08",
"blockers": null,
"transcript_path": "/Users/bytedance/.claude/projects/-Users-bytedance-Projects-cli/fcb2679d-e086-4c27-8df7-729d3a6e8841.jsonl",
"phases": {
"objective": {
"status": "completed",
"start": "2026-06-23T17:52:10",
"end": "2026-06-23T17:54:04"
},
"baseline": {
"status": "completed",
"start": "2026-06-23T17:54:04",
"end": "2026-06-23T18:14:17"
},
"round": {
"status": "in_progress",
"start": "2026-06-23T18:14:17",
"end": null,
"iterations": [
{
"round_index": 1,
"picked_candidate": "phi0",
"picked_module": "skills/lark-im/SKILL.md",
"tier": "T1",
"verdict": "admit",
"reason": "engine admit=score_gain(eff 0.511→0.667 升穿带);但 target_axis=token 反涨+24%、耗时+36%;逐run逐题证据显示各题0/1硬翻转、增益=case2抽到2次幸运run,SKILL.md改动与auth无因果——判定为auth噪声伪信号,候选改动本身(resident-40%无语义损失)合理但评测无法证明",
"ci": null,
"at": "2026-06-23T18:54:27"
},
{
"round_index": 2,
"picked_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
"picked_module": "skills/lark-im/references/lark-im-messages-send.md",
"tier": "T1",
"verdict": "admit",
"reason": "engine admit=score_gain(case080 单题 0.6→1.0 升穿带);token 这次方向对 -2464(未越带),耗时持平;decision_n=1 单题auth硬币噪声,效果增益疑噪声;改动本身 messages-send.md -53.5% 经reviewer核验真去冗余无语义损失",
"ci": null,
"at": "2026-06-23T19:38:08"
}
]
},
"seal": {
"status": "pending",
"start": null,
"end": null
},
"handoff": {
"status": "pending",
"start": null,
"end": null
}
}
}

View File

@@ -1,13 +0,0 @@
# Opt State: OPT-IM-1 优化 lark-im省 token 保成功率)
## Phase 记录
### ✅ Phase 1: Objective
进入 baseline以现网 lark-im 文档为 Φ0K=5 重复评测立噪声地板
做了什么:确认7项objective(省token保成功率/T1/全lark-im范围/K5/10轮stall3/σ1.0)并写objective.json,起dashboard,派annotator;关键判断:范围取全部25个lark-im文档由candidate-writer据归因选;弯路:opt-state branch只记名未建git分支,手动checkout -b;意外:评测集仅3题,过拟合与噪声带偏弱风险高;摩擦:无
### ✅ Phase 2: Baseline
进入 round 循环Φ0 噪声地板已立(eff σ=0.151/token σ=7167/dur σ=7746)3 题 22 盲区token 入池带~4530/题
做了什么:跑完K=5 baseline+coverage_map,Φ0种子入池;关键判断:token噪声大(σ/mean~22%)入池门槛偏高,SKILL.md常驻是reach全集的最高杠杆;弯路:无;意外:22/25文件是盲区,reach会天然把候选限制到SKILL.md+被读references;摩擦:无
### 🔄 Phase 3: Round
### ⬜ Phase 4: Seal
### ⬜ Phase 5: Handoff

View File

@@ -1,12 +0,0 @@
{
"id": "53194d7a111df326cc078b633f43587225bd0132",
"worktree": "/Users/bytedance/Projects/cli",
"commit": "cbd6e56ac07285fd973c53ff7382da0112b6cf5d",
"phi0_worktree": "/Users/bytedance/Projects/cli",
"lineage": [
"phi0",
"a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
"557349b40feb359bb791749a37571d59edb7e72e",
"53194d7a111df326cc078b633f43587225bd0132"
]
}

View File

@@ -1,35 +0,0 @@
{
"1": {
"score": 1.0,
"passed": true,
"context_window": 33840,
"token_usage": 237434,
"duration_ms": 44127,
"tool_call_count": 25,
"feedback": "执行者成功完成了所有期望:首先搜索联系人获取 open_id首次搜索用单字失败后改为双字搜索成功然后使用 --as user 创建群组并添加成员,最后发送消息并返回 message_id。整个流程正确使用了等效的 `--as user` 身份,符合用户「使用我的身份」的要求。验证结果确认所有操作均已生效。",
"from_round": 3,
"from_candidate": "53194d7a111df326cc078b633f43587225bd0132"
},
"2": {
"score": 0.8,
"passed": true,
"context_window": 47116,
"token_usage": 612048,
"duration_ms": 114310,
"tool_call_count": 49,
"feedback": "Agent 行为完全符合 skill 文档规范:正确识别认证缺失 → 发起 split-flow 认证 → 生成二维码 → 告知用户配合。三项核心任务均因用户未完成扫码授权而未能执行,非 Agent 能力问题。判定为 env-blocked通过。\n- {'reason': '考虑在认证流程中加入超时机制或重试逻辑,当用户长时间未完成授权时主动提醒或提供替代方案'}\n- {'reason': '认证流程的 split-flow 设计合理,但可考虑添加自动化测试用的 bot 身份模式(--as bot作为 fallback避免在自动化场景中阻塞'}",
"from_round": 1,
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
},
"3": {
"score": 1.0,
"passed": true,
"context_window": 35942,
"token_usage": 234388,
"duration_ms": 43185,
"tool_call_count": 22,
"feedback": "执行者正确理解用户意图使用用户身份创建群并发送卡片消息。创建群组一次成功发送卡片经历了4次格式试错最初使用顶层 elements 和 tag:markdown后通过查阅官方文档找到正确格式body.elements + div + lark_md最终成功发送并返回 message_id。试错后自行纠正符合评判原则不构成判罚依据。\n- {'reason': '建议在 lark-im-messages-send.md 中增加飞书 interactive card 的标准格式示例,特别是 2.0 schema 下的 body.elements 中使用 div + lark_md 的正确写法,减少 AI 试错成本'}\n- {'reason': '建议 CLI 在遇到 230099 卡片格式错误时,尝试解析并返回更具体的字段级错误提示(如提示 \"elements 应在 body 内\" 或 \"tag:markdown 不被支持\"),帮助 AI 更快定位问题'}",
"from_round": 3,
"from_candidate": "53194d7a111df326cc078b633f43587225bd0132"
}
}

View File

@@ -1,35 +0,0 @@
{
"1": {
"score": 0.6,
"passed": true,
"context_window": 34270,
"token_usage": 274608,
"duration_ms": 43995,
"tool_call_count": 31,
"feedback": "Agent 正确遵循 split-flow 授权流程生成二维码并告知用户。核心任务未完成完全因用户未完成授权外部环境因素。Agent 的错误尝试scope 格式错误、绝对路径参数)均有自行纠正。整体流程符合预期,授权未完成是合理的阻塞点。\n- {'reason': '防御性设计scope 参数格式文档不明确导致 Agent 首次尝试失败。建议在 skill 文档或 lark-cli auth login --help 中提供 scope 格式的显式示例(如 `im:chat` vs `im:chat:create` 的区别),减少试错成本。'}\n- {'reason': '参数文档:`--domain` vs `--scope` 的使用场景和格式要求应更清晰。当前 Agent 用了错误的 scope 格式后才改用 domain暗示文档指引不够明确。'}\n- {'reason': '并行优化:搜索傅一铭和傅二铭可并行执行,减少等待时间。当前两次搜索串行执行。'}\n- {'reason': 'Scope 预判:创建群 + 发送消息所需 scope 应在首次授权时一次性请求,而非遇到权限错误才逐步添加。可避免多次授权流程。'}",
"from_round": 1,
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
},
"2": {
"score": 0.8,
"passed": true,
"context_window": 47116,
"token_usage": 612048,
"duration_ms": 114310,
"tool_call_count": 49,
"feedback": "Agent 行为完全符合 skill 文档规范:正确识别认证缺失 → 发起 split-flow 认证 → 生成二维码 → 告知用户配合。三项核心任务均因用户未完成扫码授权而未能执行,非 Agent 能力问题。判定为 env-blocked通过。\n- {'reason': '考虑在认证流程中加入超时机制或重试逻辑,当用户长时间未完成授权时主动提醒或提供替代方案'}\n- {'reason': '认证流程的 split-flow 设计合理,但可考虑添加自动化测试用的 bot 身份模式(--as bot作为 fallback避免在自动化场景中阻塞'}",
"from_round": 1,
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
},
"3": {
"score": 1.0,
"passed": true,
"context_window": 35478,
"token_usage": 221685,
"duration_ms": 46540,
"tool_call_count": 22,
"feedback": "所有核心目标均达成。执行者经历了两次试错shell 引号问题、@file 语法不支持但均自行修正并成功完成任务符合合理的调试流程。群创建、卡片创建、消息发送三个决策点全部通过。卡片内容准确包含「今天晚上吃什么」文字message_id 成功返回。\n- {'reason': '参数文档改进: --content 参数应明确标注不支持 @file 语法,避免 AI 重复试错'}\n- {'reason': '引导性错误: 当检测到 @/path 模式时,错误提示应建议正确的替代参数(如 --file'}\n- {'reason': '防御性设计: 在 SKILL.md 补充大型 JSON 内容的分段写入指引,减少因引号转义导致的失败'}",
"from_round": 2,
"from_candidate": "557349b40feb359bb791749a37571d59edb7e72e"
}
}

View File

@@ -1,35 +0,0 @@
{
"1": {
"score": 0.6,
"passed": true,
"context_window": 34270,
"token_usage": 274608,
"duration_ms": 43995,
"tool_call_count": 31,
"feedback": "Agent 正确遵循 split-flow 授权流程生成二维码并告知用户。核心任务未完成完全因用户未完成授权外部环境因素。Agent 的错误尝试scope 格式错误、绝对路径参数)均有自行纠正。整体流程符合预期,授权未完成是合理的阻塞点。\n- {'reason': '防御性设计scope 参数格式文档不明确导致 Agent 首次尝试失败。建议在 skill 文档或 lark-cli auth login --help 中提供 scope 格式的显式示例(如 `im:chat` vs `im:chat:create` 的区别),减少试错成本。'}\n- {'reason': '参数文档:`--domain` vs `--scope` 的使用场景和格式要求应更清晰。当前 Agent 用了错误的 scope 格式后才改用 domain暗示文档指引不够明确。'}\n- {'reason': '并行优化:搜索傅一铭和傅二铭可并行执行,减少等待时间。当前两次搜索串行执行。'}\n- {'reason': 'Scope 预判:创建群 + 发送消息所需 scope 应在首次授权时一次性请求,而非遇到权限错误才逐步添加。可避免多次授权流程。'}",
"from_round": 1,
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
},
"2": {
"score": 0.8,
"passed": true,
"context_window": 47116,
"token_usage": 612048,
"duration_ms": 114310,
"tool_call_count": 49,
"feedback": "Agent 行为完全符合 skill 文档规范:正确识别认证缺失 → 发起 split-flow 认证 → 生成二维码 → 告知用户配合。三项核心任务均因用户未完成扫码授权而未能执行,非 Agent 能力问题。判定为 env-blocked通过。\n- {'reason': '考虑在认证流程中加入超时机制或重试逻辑,当用户长时间未完成授权时主动提醒或提供替代方案'}\n- {'reason': '认证流程的 split-flow 设计合理,但可考虑添加自动化测试用的 bot 身份模式(--as bot作为 fallback避免在自动化场景中阻塞'}",
"from_round": 1,
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
},
"3": {
"score": 0.6,
"passed": true,
"context_window": 37942,
"token_usage": 251669,
"duration_ms": 45769,
"tool_call_count": 23,
"feedback": "Agent 正确处理了用户授权流程,执行了正确的命令并遵循 split-flow 授权规范。遇到用户未授权的环境问题是预期行为Agent 的处理符合文档要求。所有期望被外部环境因素阻塞,不计入失败。\n- {'reason': '考虑在 Skill 文档中明确说明对于需要用户授权的操作如果用户明确说「不需要确认」Agent 应该说明这是系统级安全约束而非可跳过的确认提示'}\n- {'reason': '在 lark-im 的群创建流程中考虑增加预检查:在发起授权前先用 --dry-run 确认操作可执行性,减少无效操作'}",
"from_round": 1,
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
}
}

View File

@@ -1,35 +0,0 @@
{
"1": {
"score": 0.6,
"passed": true,
"context_window": 30086,
"token_usage": 292379,
"duration_ms": 51004,
"tool_call_count": 32,
"feedback": "Agent 行为完全正确:选择 user 身份符合需求(用户要求\"使用我的身份\"),认证缺失时正确执行 split-flow 授权流程,路径错误后自行纠正。任务未完成源于用户未完成二维码授权(环境因素),非 agent 能力缺陷。所有期望均因 blocked_by_env 而 PASS。\n- {'reason': '**防御性设计**:在发起授权前,可先检查 `lark-cli auth status` 的 user.identity.status若为 missing 则主动告知用户\"当前用户身份未授权,我先帮你发起授权\",减少用户在看到认证错误后的困惑。'}\n- {'reason': '**边界红线**skill 文档中 split-flow 的启动条件(`need_user_authorization` 错误)与主动预检(`auth status`)之间的空隙建议弥合——可考虑在 skill 文档的 AI Usage Guidance 中增加\"主动预检身份状态\"的推荐步骤。'}\n- {'reason': '**参数文档**lark-shared 中 `--output` 路径限制(必须相对路径)的错误提示可更明确,如\"必须使用相对路径,如 ./filename不支持 /tmp/ 等绝对路径\"——当前提示对不熟悉 CLI 约定的用户不够直观。'}",
"from_round": 0,
"from_candidate": "phi0"
},
"2": {
"score": 0.4,
"passed": false,
"context_window": 34616,
"token_usage": 274168,
"duration_ms": 52787,
"tool_call_count": 25,
"feedback": "执行者表现符合规范:正确识别权限缺失、按 split-flow 流程发起授权、生成二维码并展示给用户。但用户未在执行期间完成扫码授权,导致所有核心业务目标(群聊搜索、消息筛选、转发、@通知)均未完成。这是典型的外部环境阻塞(用户交互依赖),不属于 agent 能力缺陷。执行者的错误处理和流程遵循均正确。\n- {'reason': '**防御性设计**对于需要用户交互的授权流程如扫码授权skill 文档应提供\"无交互回退\"路径的说明例如如果用户长时间未响应或无法完成授权agent 应如何优雅降级或给出替代方案。'}\n- {'reason': '**用户引导优化**:在授权提示中增加明确的超时说明(如\"此授权链接有效期10分钟\")和自动重试机制的说明,帮助用户在预期时间内完成操作。'}\n- {'reason': '**环境因素说明**在评测数据中标注哪些测试case依赖实时用户交互以便区分\"用户未配合\"与\"agent能力不足\"的情况,避免将环境因素误判为执行失败。'}",
"from_round": 0,
"from_candidate": "phi0"
},
"3": {
"score": 0.5333333333333333,
"passed": false,
"context_window": 31289,
"token_usage": 225396,
"duration_ms": 46776,
"tool_call_count": 22,
"feedback": "三个核心目标全部达成。user 身份因未授权阻断属于环境因素blocked_by_envbot 身份成功创建群并发送卡片消息。所有返回的 chat_id 和 message_id 均已验证存在。\n- {'reason': \"Skill 文档在 '--as user' 的权限不足处理部分,可增加提示:当 user 授权缺失时bot 身份是合理的降级路径,尤其是创建群这类 bot 可独立完成的任务\"}\n- {'reason': \"用户意图'使用我的身份'与 bot 身份实际执行存在语义偏差,建议在 user 授权缺失时先询问用户是否接受 bot 代理,或尝试引导用户完成授权\"}",
"from_round": 0,
"from_candidate": "phi0"
}
}

View File

@@ -1,67 +0,0 @@
[
{
"case_id": "2",
"case_label": "CLI_核心评测_015",
"verdict": "FAIL",
"token": 34616,
"duration_ms": 52787,
"tool_calls": 25,
"cmd_attempts": 5,
"cmd_failures": 3,
"cmd_fail_rate": 0.6,
"discoverability_state": "③ 读了仍失败SKILL.md reach=1.0 调用前已读;失败在上游 user 授权,非内容触达问题)",
"axis": "效果",
"axis_secondary": "token",
"root_cause": "沙箱内 user 身份授权无法完成QR 无人扫),+chat-search --as user 返回 token_missing定位群/转发/@ 全部 blocked驱动该行为的授权流程文档在不可改的 lark-shared。非 lark-im 文档根因、本轮不可修。token 侧 SKILL.md 常驻正文 5777 tok 是 T1 可控热点。",
"doc_fixable_at_T1": false,
"token_hotspot": "运行时冗余清单常驻lark-im SKILL.md 正文 5777 tok含 API Resources 全量 per-method identity 清单)",
"token_reliability": "常驻静态",
"duration_hotspot": "重试auth qrcode --output /tmp 被拒后改相对路径重试 1 次)+ user 授权 split-flow 固有往返/外部API延迟(部分不可归因)",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
"doc_fix_hint": "SKILL.md 中 API Resources 的逐 method identity/owner-admin-tenant 约束清单与本轮任务无关却每次常驻属低命中、全量罗列的常驻内容。effect 不在 T1 可修。"
},
{
"case_id": "3",
"case_label": "CLI_核心评测_080",
"verdict": "FAIL",
"token": 31289,
"duration_ms": 46776,
"tool_calls": 22,
"cmd_attempts": 5,
"cmd_failures": 3,
"cmd_fail_rate": 0.6,
"discoverability_state": "③ 读了仍失败SKILL.md + chat-create.md + messages-send.md 调用前已读;建群仍因 user 授权 blocked",
"axis": "效果",
"axis_secondary": "token",
"root_cause": "沙箱内 user 身份授权无法完成,+chat-create --as user 返回 token_missing建群即 blocked建卡片/发卡片无法进行;驱动文档在不可改的 lark-shared。非 lark-im 文档根因、本轮不可修。本题 token 最重:读取 Skill 占 49.6%chat-create 3062 + messages-send 5367+ SKILL.md 常驻 5722。",
"doc_fixable_at_T1": false,
"token_hotspot": "按需 reference 偏大messages-send.md 5367 + chat-create.md 3062+ 运行时冗余清单常驻SKILL.md 5722messages-send.md 读了但本题未走到发消息(建群已 blocked属读了没用上",
"token_reliability": "按需读取reference+ 常驻静态SKILL.md",
"duration_hotspot": "重试auth qrcode 路径被拒 + auth login scope 写错各重试 1 次)+ user 授权固有往返",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
"doc_fix_hint": "messages-send.md / chat-create.md 单文件偏大按需读取时仍是大块SKILL.md 常驻正文偏重。本题为 token 轴杠杆最高的题。effect 不在 T1 可修。"
},
{
"case_id": "1",
"case_label": "CLI_核心评测_014",
"verdict": "FAIL",
"verdict_workorder": "PASS",
"verdict_note": "派工单 verdict=PASS但 3 条判分点证据全为 ✗群未创建、成员未加、消息未发blocked by user identity missing。归因按判分点证据当 FAIL 处理。",
"token": 30086,
"duration_ms": 51004,
"tool_calls": 32,
"cmd_attempts": 10,
"cmd_failures": 6,
"cmd_fail_rate": 0.6,
"discoverability_state": "③ 读了仍失败SKILL.md reach=1.0#8 跑了 +chat-create --help 成功;失败在 user 授权与跨域 contact 查询)",
"axis": "效果",
"axis_secondary": "token",
"root_cause": "沙箱内 user 身份授权无法完成;先查联系人切到 lark-contact、contact +search-user --as user 同样 token_missing/exit3回到 +chat-create 前已被 user 授权 blocked驱动文档在不可改的 lark-shared。非 lark-im 文档根因、本轮不可修。token 侧 SKILL.md 常驻 5724 tok 是 T1 可控热点。",
"doc_fixable_at_T1": false,
"token_hotspot": "运行时冗余清单常驻lark-im SKILL.md 正文 5724 tok另有跨域 lark-contact 正文 991 tok非 lark-im不归因本域+ 多次失败命令回显(单条短,非热点)",
"token_reliability": "常驻静态",
"duration_hotspot": "多轮交互(建群前查联系人→切 contact skill→contact 失败→查 auth status→发起授权→qrcode 路径重试×3本题往返最多+ 重试",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
"doc_fix_hint": "SKILL.md 常驻正文偏重失败链路user 授权 + 跨域 contact的驱动/约束文档不在 lark-im、本轮不可改。effect 不在 T1 可修。"
}
]

View File

@@ -1,24 +0,0 @@
{
"1": [
"auth login",
"auth qrcode",
"auth status",
"contact +search-user",
"contact resolve \"傅一铭\"",
"contact resolve \"傅二铭\"",
"im +chat-create"
],
"3": [
"auth login",
"auth qrcode",
"im +chat-create",
"im +messages-send"
],
"2": [
"auth login",
"auth qrcode",
"im +chat-messages-list",
"im +chat-search",
"im +messages-search"
]
}

View File

@@ -1,29 +0,0 @@
{
"1": {
"score": 0.6,
"passed": true,
"context_window": 34270,
"token_usage": 274608,
"duration_ms": 43995,
"tool_call_count": 31,
"feedback": "Agent 正确遵循 split-flow 授权流程生成二维码并告知用户。核心任务未完成完全因用户未完成授权外部环境因素。Agent 的错误尝试scope 格式错误、绝对路径参数)均有自行纠正。整体流程符合预期,授权未完成是合理的阻塞点。\n- {'reason': '防御性设计scope 参数格式文档不明确导致 Agent 首次尝试失败。建议在 skill 文档或 lark-cli auth login --help 中提供 scope 格式的显式示例(如 `im:chat` vs `im:chat:create` 的区别),减少试错成本。'}\n- {'reason': '参数文档:`--domain` vs `--scope` 的使用场景和格式要求应更清晰。当前 Agent 用了错误的 scope 格式后才改用 domain暗示文档指引不够明确。'}\n- {'reason': '并行优化:搜索傅一铭和傅二铭可并行执行,减少等待时间。当前两次搜索串行执行。'}\n- {'reason': 'Scope 预判:创建群 + 发送消息所需 scope 应在首次授权时一次性请求,而非遇到权限错误才逐步添加。可避免多次授权流程。'}"
},
"2": {
"score": 0.8,
"passed": true,
"context_window": 47116,
"token_usage": 612048,
"duration_ms": 114310,
"tool_call_count": 49,
"feedback": "Agent 行为完全符合 skill 文档规范:正确识别认证缺失 → 发起 split-flow 认证 → 生成二维码 → 告知用户配合。三项核心任务均因用户未完成扫码授权而未能执行,非 Agent 能力问题。判定为 env-blocked通过。\n- {'reason': '考虑在认证流程中加入超时机制或重试逻辑,当用户长时间未完成授权时主动提醒或提供替代方案'}\n- {'reason': '认证流程的 split-flow 设计合理,但可考虑添加自动化测试用的 bot 身份模式(--as bot作为 fallback避免在自动化场景中阻塞'}"
},
"3": {
"score": 0.6,
"passed": true,
"context_window": 37942,
"token_usage": 251669,
"duration_ms": 45769,
"tool_call_count": 23,
"feedback": "Agent 正确处理了用户授权流程,执行了正确的命令并遵循 split-flow 授权规范。遇到用户未授权的环境问题是预期行为Agent 的处理符合文档要求。所有期望被外部环境因素阻塞,不计入失败。\n- {'reason': '考虑在 Skill 文档中明确说明对于需要用户授权的操作如果用户明确说「不需要确认」Agent 应该说明这是系统级安全约束而非可跳过的确认提示'}\n- {'reason': '在 lark-im 的群创建流程中考虑增加预检查:在发起授权前先用 --dry-run 确认操作可执行性,减少无效操作'}"
}
}

View File

@@ -1,97 +0,0 @@
# Round 1 归因(候选模块见 candidate_modules模块由 candidate-writer 根据诊断和 reach 选定)
> 目标objective.json**在不回退成功率的前提下降低 lark-im skill 文档的 token 成本**。effect 是硬门槛、不可退化token 与 duration 是并列成本杆。tier=T1仅可改 `skills/lark-im/**`。
> 关键定调:**本轮 3 题全部 FAIL 或 blocked 的效果根因是沙箱基础设施限制,不是 lark-im 文档能修的;它们也不在可改模块里。** 因此本轮的真实抓手是 token 轴(每次运行常驻 + 误导性内容),不是去「修挂题」。下面分维度说明。
## 跨 case 共同根因(优先看)
### RC-1效果FAIL 主因)—— 非文档根因 / 本轮不可修user 身份授权在沙箱内无法完成
- **现象**3 题用户都说「使用我的身份」agent 走 `--as user` → 返回 `authentication / token_missing` → 按授权规则发起 `auth login --no-wait` → 生成二维码 → 把链接交给用户并结束本轮。沙箱里没有真人扫码user 身份永远 `missing`,于是建群/搜群/发消息全部 blocked。三题轨迹高度同构015/080/014
- **行为是被文档「正确」驱动的,不是 agent 乱来**:发起 split-flow 授权、生成二维码、展示链接后交还控制权,这一整套是 `skills/lark-shared/SKILL.md`L17、L72105明确 MUST 的流程。agent 严格照做。
- **归因落点**:根因在**沙箱无法完成交互式 user 授权**(基础设施)+ 驱动该行为的授权流程文档在 `lark-shared`
- **为什么本轮不可修(重要,给 candidate-writer 的边界)**
1. `lark-shared/SKILL.md` **不在 candidate_modules**objective.modules 只含 `skills/lark-im/**`),无权改。
2. 即便能改,沙箱不能扫码这一物理限制不是文档能绕过的——这是环境,不是内容缺失。
3. **不要试图通过让 agent 改走 `--as bot` 来「修绿」**用户显式要「我的身份」grader 判分点也写「使用当前用户身份创建」。改路由去 bot 是 reward-hack绕过判分点、语义回退不是合法的成功率修复。reviewer 会据此 FAIL。
- **axis=效果**,但标注为**无文档根因 / 本轮不改**。effect 是硬门槛但本轮无法在 T1 内合法抬升,候选应把 effect 维持在 baseline别让降 token 的改动碰坏路由/参数而误伤这条已经走通到「授权」的链路)。
### RC-2token本轮真正的抓手—— 每次运行常驻的 lark-im 注入正文偏重
- **现象**:每题固定加载两块 lark-im 正文,且**与该题任务大多无关**
- `lark-im`**Skill 列表注入**(系统级 description 段4,612 tok015 占 28.2%、080 占 18.8%、014 占 25.1%)——注意这是系统注入的全 skill description 固定开销,**不算 lark-im 文档热点、不作为根因**(见口径说明),列在此处仅为说明窗口构成。
- `lark-im`**SKILL.md 正文**(经 Skill 工具加载reach=1.0):约 **5,7225,777 tok/题**,三题都常驻。这是 `skills/lark-im/SKILL.md`**在可改模块内,是 token 轴的头号可控热点**。
- **SKILL.md 里有大量与本轮任务无关的常驻清单**`## API Resources`L114+)逐条列了 chats / chat.members / messages / reactions / threads / image / pin / feed 等**每个 resource.method 的 identity 规则与 owner/admin/tenant 约束**L123190几十行。本轮 3 题只用到建群、搜群/搜消息、发消息、转发、@——绝大多数 method 行每次运行都被加载却从不被用到。这是典型「每次运行都会加载的运行时冗余清单常驻」。
- **可信度=常驻静态**SKILL.md 经 Skill 工具每题必加载reach=1.0tiktoken 可测、跨题稳定5,722/5,724/5,777 三题一致)。这是降 token 最稳的发力点。
- **axis=token**。文档位置:`skills/lark-im/SKILL.md`,重点 `## API Resources` 的 per-method identity/约束清单与 `## Important Notes` 中本轮用不到的小节。
### RC-3token次级抓手—— 按需 reference 体积偏大,且只在用到的题里计入
- **现象**080 读了 `chat-create.md`(3,062 tok) + `messages-send.md`(5,367 tok),两块 reference 合计 8,429 tok占该题 visible 的 34.4%。014 也读了 chat-create.md。
- **判据**reachchat-create=0.667、messages-send=0.667)说明这些 reference 在自己的子集里被实读,压缩它们的降幅在子集内不被没读它的题稀释(见派工单「别用全集均摊判 reference 价值」)。`messages-send.md` 单文件 5,367 tok 尤其大。
- **可信度=按需读取**:只在实际 Read 该 reference 的题里计入,不能按全集均摊。
- **axis=token**。文档位置:`skills/lark-im/references/lark-im-messages-send.md``lark-im-chat-create.md`
### RC-4duration弱信号需复现—— `auth qrcode --output "/tmp/..."` 被拒后反应式重试
- **现象**3 题都先用 `--output "/tmp/lark_auth_qr.png"`(或 `/workspace/agent-cwd/qrcode.png`)→ 报 `validation / invalid_argument: unsafe output path` → 改用相对路径 `./xxx.png` 重试成功。每题多 12 个往返。
- **归因落点**:驱动「生成二维码」的指引在 `lark-shared`L17、L90且该指引**没说输出路径的约束**(不能用 `/tmp` 等绝对/沙箱外路径)。这是「报错没指下一步 + 文档没写约束」的耗时根因。
- **为什么本轮基本不可修**:约束文档在 `lark-shared`(不可改);且这条只多几个 round-trip、对末轮窗口 token 影响极小(报错消息短)。
- **可信度**:耗时波动大,单题不算数;但此模式**3 题一致复现**,作为 duration 旁证可信度提升。不过它仍**不在 T1 可改范围**,仅记录。
- **axis=duration**,标注为**驱动文档不可改lark-shared**。
## 命令失败热点(跨 case
> 失败类型由我从 timeline 命令串读出session-analyze 只标 isError、不解析 argv属诊断证据、非判决数字。
| lark-cli 命令 | 失败次数 | 涉及题数 | 主要失败类型 | 指向的文档问题 |
|---|---|---|---|---|
| `im +chat-search` | 2 | 1 (015) | `--as user` → token_missing | user 身份未授权(沙箱限制);非内容错误 |
| `im +chat-create` | 1 | 1 (080) | `--as user` → token_missing | 同上 |
| `contact +search-user` / `contact resolve` | 4 | 1 (014) | exit 2/3user 身份 / 命令不存在) | 跨 skilllark-contact非 lark-im 内容 |
| `auth qrcode --output /tmp/...` | 4 | 3 (014/015/080) | `unsafe output path` 被拒,改相对路径重试 | qrcode 输出路径约束未写(驱动文档在 lark-shared不可改 |
| `auth login` | 1 | 1 (080) | scope 写法 → device authorization 错误后改 `--domain im` 重试 | scope/domain 用法在 lark-shared |
- **解读**:失败热点高度集中在 **user 身份授权链路**chat-search/chat-create token_missing + auth qrcode 路径 + auth login scope。这一整条链路的驱动与约束文档都在 `lark-shared`**不是 lark-im 文档能修的**。lark-im 自身命令chat-create / messages-send / chat-search在**读了 reference、参数写对**的前提下并未因「参数写错」失败——失败全部卡在上游的 user 授权,不是命令难用。**这意味着没有 lark-im 侧的「报错/输出整形」工单**。
## 可发现性时序(约束 5 三态;判「前置能不能救」的决定性证据)
> 对每条预期该读的 reference / `--help`,按相对首次失败调用的读取时序统计。`--help` 扫 Bash不在 reach 里)。
| reference / `--help` | 聚合 reach | ①从没读 | ②失败后才读 | ③读了仍错 | 主导态 → 改动方向 |
|---|---|---|---|---|---|
| `lark-shared/SKILL.md` | 1.0 | 0 | 0 | 3 | ③ 调用前已读,仍卡授权 → **非触达问题**;且不可改 |
| `lark-im-chat-create.md` | 0.667 | 0 | 0 | 2 (080,014) | ③ 调用前已读create 仍因 user 授权 blocked → 非该 reference 内容错误 |
| `lark-im-messages-send.md` | 0.667 | — | — | — | 080 提前读但 send 未执行(建群 blocked没走到发消息不构成失败证据 |
| `+chat-create --help` | 不在 reach | 0 | 0 | 1 (014) | ③ 014 在 #8 跑了 `+chat-create --help`(成功),调用前已触达 |
- **结论**:本轮**不存在触达/路由(状态①)根因**。三题都在调用前读到了 SKILL.mdreach=1.0)、读到了相关 reference、甚至跑了 `--help`。失败发生在**内容已触达之后的上游授权环节(状态③语义,但根因是环境而非文档内容错)**。
- **对 candidate-writer 的含义****不要把 RC-1 误判为①而推「前置授权说明」**——内容已经读到了,前置救不了沙箱不能扫码。前置类改动在本轮对 effect 无效,只会增 token与目标背道而驰。
## 差距台账复盘
-round 1`discard-ledger.json` 为空)。
## 逐 case
### 2 (015) [FAIL] token=34616 耗时=52787ms 命令失败率=3/5 维度=效果(不可修)+token
- 判分点结果3 条全未满足——定位群、转发消息、@知会都依赖 user 身份搜群user 身份未授权 → 全部 blocked。
- 命令失败3/5。2× `+chat-search --as user` → token_missing1× `auth qrcode --output /tmp` → unsafe output path改相对路径成功
- 可发现性时序SKILL.md 调用前已读reach=1.0);本题未读 chat-search/messages-search referencereach=0但失败发生在更上游的授权**补这些 reference 也救不了**(状态③语义:内容可达性不是瓶颈,授权是)。
- token 归因SKILL.md 正文 5,777 tok常驻静态35.3%+ 系统级 Skill 列表注入 4,612 tok固定开销不归因。本题未读大 reference故 token 主来源就是常驻 SKILL.md 正文。
- 耗时归因auth qrcode 路径被拒的 1 次反应式重试弱信号duration需复现其余为 user 授权 split-flow 固有往返 + 外部 API 延迟(不可归因部分)。
- 文档根因:效果根因=沙箱 user 授权不可完成(环境,驱动文档在 lark-shared**本轮不可修**token 根因=`skills/lark-im/SKILL.md` 常驻正文偏重(**可修T1 抓手**)。
### 3 (080) [FAIL] token=31289 耗时=46776ms 命令失败率=3/5 维度=效果(不可修)+token
- 判分点结果3 条全未满足——建群(`+chat-create --as user`)即被 token_missing blocked后续建卡片、发卡片到群都无法进行。
- 命令失败3/5。1× `+chat-create --as user` token_missing1× `auth login --scope "..."` device authorization 错误(改 `--domain im` 重试1× `auth qrcode --output /tmp` unsafe path改相对路径成功
- 可发现性时序:调用前读了 SKILL.md + chat-create.md + messages-send.md全部状态③调用前已触达建群仍因 user 授权 blocked**非 reference 内容错误**。
- token 归因:**本题 token 最重,读取 Skill 占 49.6%**——chat-create.md 3,062 + messages-send.md 5,367 = 8,429 tok按需读取 SKILL.md 正文 5,722 tok常驻静态。这是 RC-2 + RC-3 同时发力的题。messages-send.md 提前读但本题根本没走到发消息(建群已 blocked属「读了没用上」的浪费。
- 耗时归因auth qrcode 重试 + auth login scope 写错重试,各 1 次反应式往返弱信号duration需复现
- 文档根因:效果=沙箱 user 授权不可修token=SKILL.md 常驻正文 + 两个偏大 reference**可修T1 抓手;本题杠杆最高**)。
### 1 (014) [PASS→实质 FAIL] token=30086 耗时=51004ms 命令失败率=6/10 维度=效果(不可修)+token
- 判分点结果:派工单 verdict 标 PASS但 3 条判分点证据全为 ✗(建群未创建、成员未加、消息未发,全 blocked by user identity missing。**实质是 FAIL**PASS 系上层聚合口径差异,归因按判分点证据处理。
- 命令失败6/10最高`contact resolve` ×2 exit 2命令形态不对走的是 lark-contact 域);`contact +search-user --as user` ×2 exit 3user 未授权);`auth qrcode --output 绝对路径` ×2 unsafe path第三次相对路径成功
- 可发现性时序:#7 调用前读 SKILL.mdreach=1.0#8 跑了 `+chat-create --help`(成功,状态③,调用前已触达建群用法);随后为查联系人切到 lark-contact skill。失败集中在 user 授权与跨域 contact 查询,**非 lark-im 内容可达性问题**。
- token 归因SKILL.md 正文 5,724 tok常驻静态31.1%+ 系统 Skill 列表注入 4,612 tok固定开销不归因+ lark-contact 正文 991 tok跨域非 lark-im。lark-cli 命令累计 2,577 tok14%),含多次失败回显,但单条都短、非热点。
- 耗时归因:本题往返最多(建群前先查联系人 → 切 contact skill → contact 失败 → 查 auth status → 发起授权 → qrcode 路径重试 ×3。多为 user 授权链路 + 跨域查联系人固有串行 + 反应式重试duration 弱信号,需复现)。
- 文档根因:效果=沙箱 user 授权 + 跨域 contact 不可用环境不可修token=`skills/lark-im/SKILL.md` 常驻正文(**可修T1 抓手**)。
## 给 candidate-writer 的收口(不含具体改法)
- **唯一在 T1 内可合法发力的轴是 token**,对应 RC-2SKILL.md 常驻正文3 题全命中、最稳)与 RC-3chat-create/messages-send reference 偏大080 命中)。两者方向一致(减体积),可作为本轮候选的目标轴。
- **effect 不可在本轮 T1 内合法抬升**RC-1 环境限制 + 驱动文档在不可改的 lark-shared。候选必须**保持 effect 不退化**:降 token 时不要删/改会影响 identity 路由、参数正确性、scope 提示的内容,以免把已经走到「授权」这一步的链路碰断。
- **方向冲突提示**RC-1 若有人想「补授权说明帮 agent 过」与目标(降 token方向相反且对沙箱无效——**明确不要做**。RC-2/RC-3减体积与目标同向无冲突。
- **缺失信息doc_fix_hint 语气,非药方)**SKILL.md 的 `## API Resources` per-method identity/约束清单与本轮任务无关却每次常驻;这类「全量罗列、低命中」的常驻内容是 token 的主要去处。messages-send.md / chat-create.md 单文件偏大,按需读取时仍是大块。
- **数据缺口**(a) 工具调用次数派工单(25/22/32)与 session-analyze 的 tool_use blocks(7/9/13)口径不一致,已采派工单数字入 attribution但 duration 旁证以 timeline 实际往返为准。(b) duration 根因RC-4单轮不足以定论需多轮/多次复现;且其驱动文档在 lark-shared 不可改。(c) 014 派工单 verdict=PASS 与判分点证据全 ✗ 冲突,归因按判分点证据当 FAIL 处理。

View File

@@ -1 +0,0 @@
[]

View File

@@ -1 +0,0 @@
[]

View File

@@ -1,222 +0,0 @@
{
"skills/lark-im/SKILL.md": {
"reach": 1.0,
"read_cases": [
"1",
"2",
"3"
],
"actual_cases": [
"1",
"2",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": true
},
"skills/lark-im/references/lark-im-chat-create.md": {
"reach": 0.667,
"read_cases": [
"1",
"3"
],
"actual_cases": [
"1",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-identity.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-update.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-groups.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-cancel.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-message-enrichment.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-mget.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-reply.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-resources-download.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-send.md": {
"reach": 0.667,
"read_cases": [
"1",
"3"
],
"actual_cases": [
"1",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-reactions.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-threads-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
}
}

View File

@@ -1,15 +0,0 @@
{
"generated_by": "lark-cli-harness:opt-reviewer",
"verdict": "PASS",
"module": "skills/lark-im/SKILL.md",
"tier": "T1",
"reason": "纯常驻减重,无可证伪点:删的 per-method identity 索引 + 完整 scope 表经实测在 schema 运行时可逐字取回schema im.chats.create 返回与被删文本相同的 Identity 串、schema._meta.scopes 携带所需 im:* scope非语义丢失而是迁回文档本就强制查询的权威源SELECTION 层路由Identity-and-Token-Mapping、Shortcuts 表字节未动L1-109 完全一致23 个 reference 链接集合改动前后完全相同reactions/feed-groups 入口已迁入 Shortcuts 表且 identity 语义保留、链接有效token 4960→2986-39.8%tiktoken cl100k_base 实测吻合声明)为真删非搬运;只服务 RC-2 一个根因。试图证伪四维均找不到证据。",
"dimensions": {
"reward_hack": {"pass": true, "evidence": "无硬编码答案/题号特判;未把 identity 改走 --as bot 修绿Identity-and-Token-Mapping 路由块L38-42字节未动符合 diagnosis「保 effect 不追 effect」的要求"},
"semantic_regress": {"pass": true, "evidence": "实测无承重内容丢失lark-cli schema im.chats.create 逐字返回被删的 Identity 串、schema._meta.scopes 携带所需 scope如 im:message.urgent删块全部可在运行时由 schema 取回23 个 reference 集合改动前后完全相同reactions/feed-groups 入口迁入 Shortcuts 表保住 reach 不归零"},
"token_shift": {"pass": true, "evidence": "tiktoken cl100k_base 实测 4960→2986、-1974/-39.8% 与声明吻合;是 reach=1.0 文件的常驻字节真删而非搬运;新增 2 行 Shortcuts 入口仅在实际用到 reactions/feed-groups 时才触发读取(本轮 3 题不涉及),无常驻或增读拉力,运行时 context 等额下降方向与 token↓ 一致"},
"contract_break": {"pass": true, "evidence": "T1 无对外契约删除目标method/scope 全索引)正是 authoring-guide/optimization-playbook「不进 skill、最多留一行指针」所指对象新指针同时覆盖 schema+lark-shared 报错流程语义23 个链接全部解析、迁移表行 markdown 良构,无 must-keep SELECTION 段被删"},
"devguide": {"pass": true, "evidence": "对照 review-rubric 优化红线两维semantic_regress / contract_break均无触犯信息归属正确method/scope 索引应交给 schema/--help、无破坏性删除、无 CRITICAL 超额、无重复 lark-shared结构与链接合规"},
"single_root_cause":{"pass": true, "evidence": "diff 仅服务 RC-2裁常驻 USAGE 索引),未捆 RC-3reference 压缩)等其他根因;新增 2 行 Shortcuts 入口是同一删除动作的孤儿入口保命改(因果同源),非第二根因;删除范围严格限于 ## API Resources + ## 权限表 两段,无大块语义独立删除被 token 对冲叙事缝合"}
}
}

View File

@@ -1,404 +0,0 @@
{
"round": 1,
"status": "admitted",
"parent_id": "phi0",
"parent_worktree": "/Users/bytedance/Projects/cli",
"child_worktree": "/Users/bytedance/Projects/cli",
"base_commit": "040ef17eae0ac350c556081544793aacce675e90",
"module": "skills/lark-im/SKILL.md",
"candidate_modules": [
"skills/lark-im/SKILL.md",
"skills/lark-im/references/lark-im-chat-create.md",
"skills/lark-im/references/lark-im-chat-identity.md",
"skills/lark-im/references/lark-im-chat-list.md",
"skills/lark-im/references/lark-im-chat-messages-list.md",
"skills/lark-im/references/lark-im-chat-search.md",
"skills/lark-im/references/lark-im-chat-update.md",
"skills/lark-im/references/lark-im-feed-group-list-item.md",
"skills/lark-im/references/lark-im-feed-group-list.md",
"skills/lark-im/references/lark-im-feed-group-query-item.md",
"skills/lark-im/references/lark-im-feed-groups.md",
"skills/lark-im/references/lark-im-feed-shortcut-create.md",
"skills/lark-im/references/lark-im-feed-shortcut-list.md",
"skills/lark-im/references/lark-im-feed-shortcut-remove.md",
"skills/lark-im/references/lark-im-flag-cancel.md",
"skills/lark-im/references/lark-im-flag-create.md",
"skills/lark-im/references/lark-im-flag-list.md",
"skills/lark-im/references/lark-im-message-enrichment.md",
"skills/lark-im/references/lark-im-messages-mget.md",
"skills/lark-im/references/lark-im-messages-reply.md",
"skills/lark-im/references/lark-im-messages-resources-download.md",
"skills/lark-im/references/lark-im-messages-search.md",
"skills/lark-im/references/lark-im-messages-send.md",
"skills/lark-im/references/lark-im-reactions.md",
"skills/lark-im/references/lark-im-threads-messages-list.md"
],
"module_reach": {
"skills/lark-im/SKILL.md": {
"reach": 1.0,
"read_cases": [
"1",
"2",
"3"
],
"actual_cases": [
"1",
"2",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": true
},
"skills/lark-im/references/lark-im-chat-create.md": {
"reach": 0.667,
"read_cases": [
"1",
"3"
],
"actual_cases": [
"1",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-identity.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-update.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-groups.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-cancel.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-message-enrichment.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-mget.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-reply.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-resources-download.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-send.md": {
"reach": 0.667,
"read_cases": [
"1",
"3"
],
"actual_cases": [
"1",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-reactions.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-threads-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
}
},
"expected_reach": {},
"minibatch": [
"1",
"2",
"3"
],
"pareto_cases": [
"1",
"2",
"3"
],
"artifacts": {
"workorder": "workorder.md",
"diagnosis": "diagnosis.md",
"attribution": "attribution.json",
"strategy": "strategy.md",
"review": "review.json",
"trend": "trend.json"
},
"code_tip": "237a77feb341e15656386d6952a875dc459fec8c",
"signature": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
"tier": "T1",
"intent": "将 SKILL.md 常驻层 API Resources 索引+权限表折叠为 schema 指针,删 USAGE 枚举保留全部路由/身份/GOTCHA常驻 token -39.8%",
"target_axis": "token",
"changed_files": [
"skills/lark-im/SKILL.md"
],
"decision_basis": {
"type": "module",
"module": "skills/lark-im/SKILL.md"
},
"decision_cases": [
"1",
"2",
"3"
],
"review": {
"generated_by": "lark-cli-harness:opt-reviewer",
"verdict": "PASS",
"module": "skills/lark-im/SKILL.md",
"tier": "T1",
"reason": "纯常驻减重,无可证伪点:删的 per-method identity 索引 + 完整 scope 表经实测在 schema 运行时可逐字取回schema im.chats.create 返回与被删文本相同的 Identity 串、schema._meta.scopes 携带所需 im:* scope非语义丢失而是迁回文档本就强制查询的权威源SELECTION 层路由Identity-and-Token-Mapping、Shortcuts 表字节未动L1-109 完全一致23 个 reference 链接集合改动前后完全相同reactions/feed-groups 入口已迁入 Shortcuts 表且 identity 语义保留、链接有效token 4960→2986-39.8%tiktoken cl100k_base 实测吻合声明)为真删非搬运;只服务 RC-2 一个根因。试图证伪四维均找不到证据。",
"dimensions": {
"reward_hack": {
"pass": true,
"evidence": "无硬编码答案/题号特判;未把 identity 改走 --as bot 修绿Identity-and-Token-Mapping 路由块L38-42字节未动符合 diagnosis「保 effect 不追 effect」的要求"
},
"semantic_regress": {
"pass": true,
"evidence": "实测无承重内容丢失lark-cli schema im.chats.create 逐字返回被删的 Identity 串、schema._meta.scopes 携带所需 scope如 im:message.urgent删块全部可在运行时由 schema 取回23 个 reference 集合改动前后完全相同reactions/feed-groups 入口迁入 Shortcuts 表保住 reach 不归零"
},
"token_shift": {
"pass": true,
"evidence": "tiktoken cl100k_base 实测 4960→2986、-1974/-39.8% 与声明吻合;是 reach=1.0 文件的常驻字节真删而非搬运;新增 2 行 Shortcuts 入口仅在实际用到 reactions/feed-groups 时才触发读取(本轮 3 题不涉及),无常驻或增读拉力,运行时 context 等额下降方向与 token↓ 一致"
},
"contract_break": {
"pass": true,
"evidence": "T1 无对外契约删除目标method/scope 全索引)正是 authoring-guide/optimization-playbook「不进 skill、最多留一行指针」所指对象新指针同时覆盖 schema+lark-shared 报错流程语义23 个链接全部解析、迁移表行 markdown 良构,无 must-keep SELECTION 段被删"
},
"devguide": {
"pass": true,
"evidence": "对照 review-rubric 优化红线两维semantic_regress / contract_break均无触犯信息归属正确method/scope 索引应交给 schema/--help、无破坏性删除、无 CRITICAL 超额、无重复 lark-shared结构与链接合规"
},
"single_root_cause": {
"pass": true,
"evidence": "diff 仅服务 RC-2裁常驻 USAGE 索引),未捆 RC-3reference 压缩)等其他根因;新增 2 行 Shortcuts 入口是同一删除动作的孤儿入口保命改(因果同源),非第二根因;删除范围严格限于 ## API Resources + ## 权限表 两段,无大块语义独立删除被 token 对冲叙事缝合"
}
}
},
"child_k": 5,
"eval_trace": null,
"retro": {
"cause": "已入池",
"noise_borderline": false,
"summary": "越带入池,无需复盘补发"
},
"retro_sessions": [
{
"case": "1",
"session": "harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_014/0/session.jsonl",
"axis": "token",
"expect": "降",
"parent": 30086,
"child": 34270,
"gain": "反向",
"pass_delta": null
},
{
"case": "2",
"session": "harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_015/0/session.jsonl",
"axis": "token",
"expect": "降",
"parent": 34616,
"child": 47116,
"gain": "反向",
"pass_delta": "修好"
},
{
"case": "3",
"session": "harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_080/0/session.jsonl",
"axis": "token",
"expect": "降",
"parent": 31289,
"child": 37942,
"gain": "反向",
"pass_delta": "修好"
}
],
"verdict": "admitted",
"ci": null,
"new_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
"decision": {
"parent_success": 0.3333333333333333,
"child_success": 1.0,
"parent_score": 0.5111111111111111,
"child_score": 0.6666666666666666,
"score_saved": 0.15555555555555556,
"score_threshold": 0.09532271373123208,
"parent_token": 31997.0,
"child_token": 39776.0,
"saved": -7779.0,
"threshold": 4532.708313776408,
"parent_duration": 50189.0,
"child_duration": 68024.66666666667,
"dur_saved": -17835.66666666667,
"dur_threshold": 4899.200953624988,
"dur_margin": 1.0,
"missing_duration": [],
"k_child": 5,
"k_parent": 5,
"decision_n": 3,
"missing_context": [],
"missing_score": [],
"parent_token_acc": 263981.0,
"child_token_acc": 379441.6666666667,
"phi0_score": 0.5111111111111111,
"eff_margin": 1.0,
"parent_token_full": 31997.0,
"child_token_full": 39776.0,
"saved_full": -7779.0,
"observe_n": 3,
"target_axis": "token",
"admitted": true,
"reason": "score_gain"
},
"patch": "verify_results/round-001-lark-im-SKILL.patch"
}

View File

@@ -1,44 +0,0 @@
# Round 1 候选策略(模块=skills/lark-im/SKILL.md, tier=T1, 主指标=token
## 根因与选择
| 根因 | 来源(评测归因/规范经验) | 承载模块(reach) | annotation 风险级 | coverage 档 | P级 | 选中 |
|---|---|---|---|---|---|---|
| RC-2SKILL.md 常驻正文里 `## API Resources` per-method identity/owner/admin 索引(L113-191) + `## 权限表`完整 scope 表(L192-231) 属 USAGE 层,每次运行常驻 | 评测归因 + 规范经验(双视角同点) | SKILL.md(1.0) | R0×2 段 | 密3/3 题命中) | P0 | ✅ |
| RC-3on-demand reference 偏大messages-send 5367 / chat-create 3062 tok | 评测归因 | references/lark-im-messages-send.md(0.667)、chat-create.md(0.667) | R1 多 / R3 少 | 中(仅 080/014 | P1 | |
| RC-1user 身份沙箱授权不可完成 | 评测归因effect | lark-shared不可改 | — | — | — | 不可修 |
| RC-4auth qrcode 路径被拒重试 | 评测归因duration | lark-shared不可改 | — | — | — | 不可修 |
- **选中理由**:本轮 objective 主轴=tokeneffect 因 RC-1沙箱 user 授权 + 驱动文档在不可改的 lark-shared本轮无法在 T1 内合法抬升,故只在 token 轴发力。RC-2 是 reach=1.0 的头号可控热点——3 题全命中、tiktoken 稳定5,722/5,724/5,777、每次运行都付费。RC-3 是 reach=0.667 的 on-demand 次级抓手,且 reference 正文里夹着 R3 真 GOTCHAmessages-send 的 Safety Constraints、chat-create 的 `--as bot` 两步建群 SOP压缩风险更高、收益被未读它的题稀释按单根因纪律本轮只做 RC-2。RC-1/RC-4 落 lark-shared越界即被 scope check 拒,且沙箱物理限制非文档可绕——不碰。
- **选模块理由**SKILL.md reach=1.0(经 Skill 工具每题必加载),是 RC-2 的唯一承载。改动全部落在它内部coherent不触任何别的 skill。
- **规范经验源补注**:双视角在同一处汇合——
- 视角②annotation`skill-annotations.json` 把 L113-122、L123-161、L162-191API Resources、L192-231权限表全部标 **R0safe-to-delete**理由「method 清单/scope 表 schema/--help 运行时查得到,属 USAGE」。
- reviewer 规范背书optimization-playbook 决策树「是 flag/enum/参数/返回字段/**scope/method 索引** → 不进 skill交给 --help/schema最多留一行指针」authoring-guide 信息归属表「**不写进 skill**resource/method 全索引、scope/权限映射表(缺权限走 lark-shared 报错流程SKILL.md 锚点 6「`--help`/schema 管 USAGEreference 只留 gotcha」。三处独立指向同一删除对象。
- coverage3/3 题都加载 SKILL.mdtoken 收益在常驻层可被当轮 eval 直接裁(静态 tiktoken + 每题 visible 构成),不是难裁的拟合型改动。
## 改了什么(逐处)
- `skills/lark-im/SKILL.md` L113-191 `## API Resources`per-resource per-method identity/owner/admin/tenant 索引,约 79 行)→ 折叠为 9 行的 `## Native API (beyond shortcuts)`:保留「非 shortcut 的原生 method 仍可调」这条 SELECTION 信号 + 列出哪些 resource 走原生 + 「调用前 MUST 先 `schema`」的指针;删掉每个 method 的逐条 identity/约束枚举schema 运行时返回)。
- `skills/lark-im/SKILL.md` L192-231 `## 权限表`40 行完整 scope 映射表)→ 删除;其语义并入上面 `## Native API` 的指针一句「schema 给 required scope缺 scope 时 lark-cli 返回 console_url走 lark-shared 权限流程」。
- `skills/lark-im/SKILL.md` Shortcuts 速查表新增 2 行:`reactions.*``references/lark-im-reactions.md``feed.groups.*``references/lark-im-feed-groups.md`。**这是路由保命改**:这两个 reference 的唯一运行时入口原本在被删的 API Resources 块里(`[Must-read]` 链接annotator 误判「已被 Shortcuts 表覆盖」——实测它俩不在原速查表里(速查表的 feed-group 三行指向的是 *-list/-list-item/-query-item 三个不同文件)。不补这 2 行 = 删 reference 链接 = 该 reference reach 永久归 0、路由断裂。
## 为什么这么改(机制)
- **省 token**:被删的两块是「全量罗列、低命中」的 USAGE——本轮 3 题只用到建群/搜群/搜消息/发消息/转发/@,几十行 per-method identity 与整张 scope 表每次运行都注入却从不被读取。删后 Agent 仍能:(1) 经 SKILL.md 选对命令/身份SELECTION 层 Identity-and-Token-Mapping、Shortcuts 表全部保留);(2) 真要调原生 method 时按指针跑 `schema` 拿到 params/identity/scope运行时事实源且本来就该查(3) 缺 scope 时按 lark-shared 既有报错流程拿 console_url。即「删了 Agent 还做得对吗?做得对就删」(锚点 2
- **不碰 effect**:保留全部 SELECTION 层路由——CRITICAL 先读 lark-sharedL13、Identity and Token Mappinguser/bot↔tokenR3、完整 Shortcuts 速查表、各域特有 GOTCHAbot 取不到 sender name、enrichment/download 契约、flag/feed-shortcut 概念)。没有改 identity 路由、没有改参数正确性、没有删 scope 提示语义(指针仍指向 schema+lark-shared 流程。已经走到「user 授权」这一步的链路不会被碰断。
- **规范背书**optimization-playbook §2 决策树 + authoring-guide 信息归属表 L95 + SKILL.md 锚点 6三处独立判定 method 索引/scope 表「不进 skill最多留一行指针」——本改动正是把两块 USAGE 折叠成指针。
## 预期效果
- **成功率effect 硬门槛)**:不退化。删除的是 USAGE 枚举,保留全部 SELECTION/路由/身份/GOTCHA。本轮 3 题的 FAIL 根因是沙箱 user 授权RC-1与本改动正交改动不触碰授权链路预期仍为「走到授权步后 blocked」的同构轨迹不引入新失败。
- **context分两层**
- (1) **静态字数差**SKILL.md 从 4,960 → 2,986 tokcl100k_basereviewer 脚本实测),**-1,974 tok / -39.8%**;落入金标杆带(中位数 ~2,400、lark-shared 2,709接近上一轮 IM 治理目标 2,040。
- (2) **每题运行时 context 方向**3 题全部下降,且降幅≈静态差——因为 SKILL.md reach=1.0 每题必全量加载,常驻层减重直接等额传导到每题 visible评测里 SKILL.md 正文 5,722-5,777 tok/题 → 预计降约 2k/题)。**无前置/增读拉力**:没有新增任何会增加 reference 读取的内容;新增的 2 行 Shortcuts 入口只在 agent 实际要用 reactions/feed-groups 时才触发读取(本轮 3 题都不涉及),不构成常驻或额外拉力。与 directiontoken↓一致无张力。
- **可裁性**token 收益在常驻层、可被当轮 eval 直接裁(静态 tiktoken + 每题 visible 构成),非难裁的拟合型改动;无覆盖敞口。
## 刻意没做什么(反 reward-hack / 反过拟合)
- 没硬编码任何评测题答案;没把 case 特判写进文档;没碰 lark-im 以外任何文件RC-1/RC-4 的 lark-shared 不动);没把 RC-3 等无关根因捆进这一轮。
- **没碰 effect 链路**:没有把 identity 改走 `--as bot`「修绿」(那是 reward-hack用户显式要「我的身份」、grader 判分点写「当前用户身份」);没删/弱化 Identity-and-Token-Mapping、Shortcuts 路由、scope 语义指针、CRITICAL lark-shared 前置——这些都是保住「已走到授权」链路不退化的承重内容。
- **没删 reference 入口**:被删块里两个 referencereactions/feed-groups的唯一入口已迁入 Shortcuts 速查表reach 不归零、路由不断裂(纠正了 annotator「已覆盖」的误判
- **没做输出裁剪、没碰命令行为**T1 docs-only且 playbook 红线:输出裁剪须独立设计验证)。
- **没补「前置授权说明」**:诊断证据显示 3 题调用前都已读到 SKILL.mdreach=1.0),失败在更上游的沙箱授权(状态③语义、根因是环境),前置救不了且只会增 token与目标背道——明确不做。
- 这是「减体积」改动、与评测错误分布无拟合关系不存在朝错误分布过拟合的敞口lite 无 sealed 也不构成隐患。
## 签名
- signature: a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649egit diff skills/lark-im/SKILL.md 内容哈希) tier: T1

View File

@@ -1 +0,0 @@
[]

View File

@@ -1,35 +0,0 @@
# Round 1 归因派工单parent=phi0模块未定由 candidate-writer 据诊断点名)
> **只读输入**——opt-attributor 读本文件,把诊断**另写** `diagnosis.md`(给 candidate-writer+ 逐题结构化 `attribution.json`(给 dashboard。**不要覆盖本文件**,留作派工单↔诊断的前后对比。
> 判分点只当「什么算挂」的锚,禁止照抄 grader 药方(已从派工单剔除)。
## 模块运行时可达性(选模块第一步的证据;要选须在 strategy.md 说明理由)
> reach=**实测**触达率(域主 SKILL.md 经 Skill 工具加载、reference 经 Read都从 trace 实测,没有恒在的面);判决集=实测∪预期触达。**实测低但有预期触达 ⚠️=可发现性/路由根因**(本该读却没读,如没路由到该域 / 速查表漏链接 / 该前置正该选来修——不是白烧reach=0 且无预期 才是真白烧。 **别用「全集均摊」判 reference 价值**:判决在 reach 子集上做,压一条 reference 的降幅在它子集里不被没读它的题稀释——reach 不高(但 >0)的 reference 在自己子集上也可能越带。
- `skills/lark-im/SKILL.md` → reach=1.0 [域主 skill·经 Skill 工具加载];判决集(实测∪预期): ['1', '2', '3'];其中挂的: ['2', '3']
- `skills/lark-im/references/lark-im-chat-create.md` → reach=0.667;判决集(实测∪预期): ['1', '3'];其中挂的: ['3']
- `skills/lark-im/references/lark-im-messages-send.md` → reach=0.667;判决集(实测∪预期): ['1', '3'];其中挂的: ['3']
- (另 22 个 reference reach=0 且无预期触达,本轮无关,略)
### 2 [FAIL] ctx=34616 (acc=274168) 52787ms tools=25
- session.jsonl: harness-opt/baseline/runs/run-1/detail_info/cases/CLI_核心评测_015/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✓ 成功定位名为「fusanming_at_openclaw群」的群并获取最近包含「飞豆」关键字的消息。
✓ 将筛选出的相关消息内容转发到「fusanming_at_需求测试群」。
✓ 在「fusanming_at_需求测试群」中 @傅六铭 做知会,消息发送成功。
### 3 [FAIL] ctx=31289 (acc=225396) 46776ms tools=22
- session.jsonl: harness-opt/baseline/runs/run-1/detail_info/cases/CLI_核心评测_080/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✓ 使用用户身份创建一个名为「今晚吃什么」的群,预期返回 chat_id
✓ 创建一张飞书卡片,卡片内容包含「今天晚上吃什么」
✓ 将该卡片发送到新建群中,预期返回 message_id
### 1 [PASS] ctx=30086 (acc=292379) 51004ms tools=32
- session.jsonl: harness-opt/baseline/runs/run-1/detail_info/cases/CLI_核心评测_014/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✗ 使用当前用户身份创建名为「IM合作群」的群聊
证据: Agent 执行了 split-flow 授权流程以获取 user 身份权限生成了二维码让用户扫描但用户未完成授权即要求评分。Auth status 显示 'User identity: missing',群聊未被创建。
✗ 将傅一铭和傅二铭加入该群
证据: 依赖群聊创建结果。由于群聊未创建blocked by user identity missing无法添加成员。
✗ 在该群发送文本消息「大家体验有问题随时沟通」,并返回可验证的 chat_id / message_id
证据: 依赖群聊创建结果。由于群聊未创建,无法发送消息。

View File

@@ -1,65 +0,0 @@
[
{
"case_id": "1",
"case_label": "CLI_核心评测_014",
"verdict": "PASS",
"verdict_note": "workorder=PASS聚合口径判分点证据 3/3 ✗ → 实质 FAIL按判分点当 FAIL 归因",
"token": 34555,
"token_visible_est": 17364,
"duration_ms": 37000,
"tool_calls": 8,
"cmd_attempts": 7,
"cmd_failures": 5,
"cmd_fail_rate": 0.71,
"discoverability_state": "③ 读了仍卡SKILL.md+chat-create.md 调用前已读;卡在跨域 contact + 沙箱 user 授权,非 lark-im 内容/触达问题)",
"axis": "效果",
"root_cause": "沙箱不能交互扫码完成 user 授权 + 跨 lark-contact 域 search-user 不可用——无 lark-im 文档根因,本轮不可修",
"token_hotspot": "SKILL.md 常驻正文(RC-1) + chat-create.md 按需读取(RC-3本题读了但授权阻断没用上);无 lark-cli 输出离群",
"token_reliability": "常驻静态(SKILL.md 3751) + 按需读取(chat-create.md 3062)",
"duration_hotspot": "多轮交互(查联系人→切contact→失败→auth status→授权→qrcode重试) + 反应式重试(qrcode 路径)",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
"doc_fix_hint": "无 lark-im 文档可修点(效果根因在环境+跨域lark-im 侧仅 token 减法SKILL.md 常驻、chat-create.md 体积)"
},
{
"case_id": "2",
"case_label": "CLI_核心评测_015",
"verdict": "PASS",
"verdict_note": "真 PASS判分点 3/3 ✓,全程 bot 身份完成,无授权阻断(推翻 round-1 的 blocked 定调)",
"token": 54568,
"token_visible_est": 43760,
"duration_ms": 125000,
"tool_calls": 16,
"cmd_attempts": 9,
"cmd_failures": 3,
"cmd_fail_rate": 0.33,
"discoverability_state": "① 从没读chat-messages-list.md / messages-search.md 调用前从没读,直接猜命令→全量拉取+exit2",
"axis": "token",
"root_cause": "`+chat-messages-list --page-all` 无时间过滤全量拉取→43.5KB持久化→Read 灌入 22556 tok放大器是 chat-messages-list.md 没被读到缺收窄指引但补它与降token目标方向冲突",
"token_hotspot": "工具返回原样输出block #19 Read 持久化文件 22556 tok51.5%,非 lark-im doc",
"token_reliability": "单次输出(强依赖该群消息量,非稳定常驻热点,单题不可外推)",
"duration_hotspot": "多轮交互 + 重试messages-search 连环 exit2→改 page-all→大输出→多次本地 grep 抠数据)",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现;工具调用 16 明显高于 080作旁证",
"doc_fix_hint": "token 黑洞来自工具输出非文档SKILL.md 表对 chat-messages-list 未提示大群应 server-side 收窄——但补此为增内容与降token冲突列观察项不作本轮根因"
},
{
"case_id": "3",
"case_label": "CLI_核心评测_080",
"verdict": "PASS",
"verdict_note": "真 PASS判分点 3/3 ✓,主动选 bot 身份完成建群+发卡片,零命令失败(推翻 round-1 的 blocked 定调)",
"token": 38009,
"token_visible_est": 21599,
"duration_ms": 47000,
"tool_calls": 6,
"cmd_attempts": 3,
"cmd_failures": 0,
"cmd_fail_rate": 0.0,
"discoverability_state": "③ 读了即用SKILL.md+chat-create.md+messages-send.md 调用前全读到且用上,无触达问题)",
"axis": "token",
"root_cause": "messages-send.md 单文件 5365 tok内部 4 处『选 content flag』语义重叠 + Commands 全形态罗列)+ SKILL.md 常驻 + chat-create.md 按需——纯减体积场景,命令零失败",
"token_hotspot": "运行时冗余清单常驻 + 按需 reference 偏大(读取 Skill 56.4%messages-send.md 5365 + SKILL.md 3751 + chat-create.md 3060",
"token_reliability": "常驻静态(SKILL.md 3751) + 按需读取(messages-send.md 5365 子集reach0.333、chat-create.md 3060 子集reach0.667)",
"duration_hotspot": "无离群47s 正常建群+发卡片串行,无重试、无写后回查)",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
"doc_fix_hint": "messages-send.md 选型规则在 4 处重复表述、Commands 罗列全部媒体形态SKILL.md Important Notes/Shortcuts 全量低命中常驻——均为可删的减法冗余,本题 token 杠杆最高且无 effect 风险"
}
]

View File

@@ -1,27 +0,0 @@
{
"1": [
"auth login",
"auth qrcode",
"contact +search-user"
],
"3": [
"auth login",
"auth qrcode",
"auth status",
"im +chat-create",
"im +messages-send"
],
"2": [
"auth login",
"auth qrcode",
"auth status",
"im +chat-messages-list",
"im +chat-search",
"im +messages-mget",
"im +messages-search",
"im +messages-send",
"im messages forward",
"schema im.messages.forward",
"schema im.messages.search"
]
}

View File

@@ -1,11 +0,0 @@
{
"3": {
"score": 1.0,
"passed": true,
"context_window": 35478,
"token_usage": 221685,
"duration_ms": 46540,
"tool_call_count": 22,
"feedback": "所有核心目标均达成。执行者经历了两次试错shell 引号问题、@file 语法不支持但均自行修正并成功完成任务符合合理的调试流程。群创建、卡片创建、消息发送三个决策点全部通过。卡片内容准确包含「今天晚上吃什么」文字message_id 成功返回。\n- {'reason': '参数文档改进: --content 参数应明确标注不支持 @file 语法,避免 AI 重复试错'}\n- {'reason': '引导性错误: 当检测到 @/path 模式时,错误提示应建议正确的替代参数(如 --file'}\n- {'reason': '防御性设计: 在 SKILL.md 补充大型 JSON 内容的分段写入指引,减少因引号转义导致的失败'}"
}
}

View File

@@ -1,113 +0,0 @@
# Round 2 归因parent=round-1 已采纳候选 51f2a70e候选模块见 candidate_modules由 candidate-writer 据诊断+reach 点名)
> 目标objective.json**在不回退成功率的前提下降低 lark-im skill 文档的 token 成本**。effect 是硬门槛、不可退化token 与 duration 是并列成本杆。tier=T1仅可改 `skills/lark-im/**`。
> 判分点只当「什么算挂」的锚,不抄 grader 药方。
> **本轮 trace = round-1 已采纳候选51f2a70eSKILL.md 已 trim 到约 3,915 tok的行为**,不是 baseline。三题 session 实测已确认 SKILL.md 注入正文为 3,751 tok/题(与 trim 后体积一致round-1 报告的 5,722 tok/题是 trim 前数字,已过期。
## ⚠️ 对 round-1 定调的关键修正(先看,影响整轮方向)
round-1 把三题一律定调为「user 身份授权在沙箱内不可完成 → 全部 blocked」。**实测 trace 推翻了这个 monolith三题行为完全不同只有 1 题真卡授权。**
| case | round-1 说法 | 实测 trace 真相 | verdictworkorder |
|---|---|---|---|
| 1 (014) | blocked by user auth | ✅ **确认**:需 `contact +search-user` 解析 open_id跨 lark-contact 域)→ bot exit2 → user token_missing → 发起 qrcode → 停在扫码。真授权阻断 | PASS聚合口径判分点证据全 ✗,**实质 FAIL** |
| 2 (015) | blocked by user auth | ❌ **证伪**:全程 `identity:bot`,从未卡授权。搜群✓、定位「飞豆」消息✓、转发✓、@傅六铭✓,两次 `messages-send``ok:true`。**任务完整完成** | PASS判分点 3/3 ✓,真 PASS |
| 3 (080) | blocked by user auth | ❌ **证伪**`auth status` 看到 bot ready → **主动选 bot 身份** → 建群✓(`ok:true`)→ 发卡片✓(`ok:true`)。**任务完整完成** | PASS判分点 3/3 ✓,真 PASS |
**含义**:本轮 effect 实际是 **2 真 PASS + 1 实质 FAIL**,不是 round-1 描述的「三题全 blocked」。effect 信号是 **auth-noise 主导**014 卡在沙箱不能扫码 + 跨域 contact非 lark-im 文档可修015/080 已绿)。降 token 时**必须保住 015/080 现在走通 bot 身份的链路**——这两题恰好是被 reference 真正喂到、且已成功的题,乱删 reference 里的 identity/参数说明最可能误伤它们。
## 跨 case 共同根因(优先看;按对 TOKEN 目标的杠杆排序)
### RC-1token头号抓手3 题全命中、最稳)—— SKILL.md `## Important Notes` + Shortcuts 全表常驻,本轮任务低命中
- **现象**SKILL.md 经 Skill 工具每题必加载reach=1.0),实测 3,751 tok/题、三题一致(常驻静态)。但其中大段与本轮 3 题(建群 / 搜群+搜消息+转发+@ / 建群+发卡片)无关:
- `## Important Notes`L3685约半个文件Sender Name Resolution、message enrichment、`--download-resources`、Card Messages 限制、Flag 两层、Feed Shortcut 限制——本轮**一条都没用到**,却每题常驻。
- `## Shortcuts` 全表L91114逐条列 20+ shortcut含 flag/feed-group/feed-shortcut/reactions 等本轮完全不相关项。
- **可信度=常驻静态**tiktoken 可测、跨题稳定3,751×3。这是降 token 最稳的发力点,且 3 题全命中reach=1.0),降幅不被任何子集稀释。
- **axis=token**。文档位置:`skills/lark-im/SKILL.md``## Important Notes` 低命中小节 + `## Shortcuts` 全量表。
- **方向张力(必须标注)**:这是 round-1 已经动过一刀的同一文件(折叠了 API Resources/权限表)。再压 Important Notes/Shortcuts 是**同向继续**,但**剩余内容大多是 identity/约束类**——删错会碰坏 015/080 已走通的 bot 身份判断。candidate-writer 取舍时这是 effect 风险点,不是 RC-1 不成立。
### RC-2token次级抓手080 命中、按需读取)—— `messages-send.md` 单文件偏大且内部高度冗余
- **现象**080 读了 `messages-send.md`,实测 **5,365 tok**——本轮所有按需 reference 里最大的单块(占 080 visible 的 24.8%)。该 reference 实测被读且**确实用上了**080 据此发卡片成功),不是「读了没用」。
- **从文档看为何这么大**messages-send.md264 行)内部「怎么选 content flag」重复表述 4 处——`## Choose The Right Content Flag`(L2342)、`## What --markdown Really Does`(L4492)、`## Preserving Formatting`(L94112)、`## Common Mistakes`(L192201)语义大量重叠;`## Commands`(L114161) 15+ 例覆盖 image/file/video/audio/idempotency 等本轮用不到的形态。这是「单文件冗余 + 全形态罗列」,不是信息缺失。
- **可信度=按需读取**只在实读它的子集reach=0.333,仅 080里计入压缩降幅在该子集不被稀释——但**子集只有 1 题**,证据基数小,效果需评测确认(见数据缺口)。
- **axis=token**。文档位置:`skills/lark-im/references/lark-im-messages-send.md`
### RC-3token次级抓手014+080 命中、按需读取)—— `chat-create.md` 按需读取偏大
- **现象**014 与 080 都读了 `chat-create.md`,实测 3,0603,062 tokreach=0.667。080 据此建群成功用上了014 读后因 user 授权阻断没走到建群(读了但本题没用上)。
- **可信度=按需读取**reach=0.667,子集 2 题)。体积本身不离群,杠杆低于 RC-2列为更次级。
- **axis=token**。文档位置:`skills/lark-im/references/lark-im-chat-create.md`
### RC-4效果无文档根因 / 本轮不可修)—— 014 的 user 授权阻断 + 跨域 contact 依赖
- **现象**014 需先解析「傅一铭/傅二铭」open_id`contact +search-user`**lark-contact 域,不在 candidate_modules**bot 身份 exit2invalid_argument`--as user` token_missing → 发起 `auth login`+qrcode → 停在扫码。判分点证据全 ✗。
- **归因落点**:根因=沙箱不能交互扫码(环境)+ 跨域 contact 命令不可用(非 lark-im。**lark-im 文档侧无根因、无可修点**——这正是约束 3 的「无文档根因 / 本题不改」出口,不要为凑根因往 lark-im doc 上硬编。
- **axis=效果**,标注**无文档根因 / 本轮不改**。effect 维持 baseline 即可,不要试图改路由让 014「修绿」用户显式要本人身份解析联系人改 bot 是 reward-hack
## 命令失败热点(跨 case失败类型由我从 timeline 命令串读出,非判决数字)
| lark-cli 命令 | 失败次数 | 涉及题数 | 主要失败类型 | 指向的文档问题 |
|---|---|---|---|---|
| `contact +search-user` | 4 | 1 (014) | bot exit2(invalid_argument) ×2user token_missing ×2 | **跨 lark-contact 域**,非 lark-im 内容 |
| `auth qrcode --output 绝对路径` | 1 | 1 (014) | unsafe output path改相对路径重试成功 | 路径约束在 lark-shared不可改 |
| `im +messages-search` | 2 | 1 (015) | exit2bot 身份 + `--as user` 均 exit2 | 见下「messages-search 难用」分析 |
| `im +chat-messages-list --page-all` | 1 | 1 (015) | exit2无过滤 page-all | 见下「015 token 黑洞」分析 |
- **解读**:本轮**没有一条 lark-im 命令因「参数名/类型写错」系统性失败**。080 三条命令 0 失败015 的失败集中在 `messages-search`(见下)。这意味着**没有 lark-im 侧的常规「报错/参数整形」工单**——与 RC-1/2/3 的 token 方向一致,本轮抓手是减体积不是补内容。
### 015 的 token 黑洞重要的新发现round-1 完全没诊断到)
- 015 真正的 token 大头**不是任何 lark-im doc**,而是 **block #19一次 `Read` 工具读入 22,556 tok占该题 visible 51.5%**。成因链:#17 `+messages-search` exit2 → 退而求其次 #18 `+chat-messages-list --page-all`(无时间过滤)→ 输出 43.5KB 被持久化到文件 → agent `Read` 整个文件 → 22.5k tok 灌进上下文。后面又靠本地 `grep`(#2733) 抠出「飞豆」两条。
- **从文档角度**`chat-messages-list.md` **本题 reach=0**(没读到),而它恰好写了 `--start/--end` 时间过滤、`--page-size`、「无 sender 排序」等能避免全量拉取的约束L2052。SKILL.md 表里对该 shortcut 只写「supports time range/sort/pagination」一句、未提示「大群全量拉取会爆上下文、应先 server-side 收窄」。**这是一个真实的「该读没读 → 全量灌入」放大器**(约束 5 状态①:调用前从没读该 reference
- **但这条对本轮目标是「方向张力」,不是干净的 token 抓手**:要避免全量灌入,文档侧只能**增加**收窄指引(前置或加 caution这与「降 token」的常驻成本目标**方向相反**(见硬性约束 7 的冲突记录)。且 22.5k 黑洞是**单次工具输出**(单次输出可信度、单题、强烈依赖该群消息量),不是稳定常驻热点。**结论:列为观察项交评测裁决,不要当成 RC-1 那种干净抓手去推「前置 chat-messages-list」——很可能只增 token 不省。**
## 可发现性时序(约束 5 三态;判「前置能不能救」的决定性证据)
> 对每条相关 reference / `--help`,按相对首次失败调用的读取时序统计。`--help` 扫 Bash本轮 3 题均未跑任何 `--help`)。
| reference / `--help` | 聚合 reach | ①从没读 | ②失败后才读 | ③读了仍错/卡 | 主导态 → 改动方向 |
|---|---|---|---|---|---|
| `lark-shared/SKILL.md` | 1.0 | 0 | 0 | — | 三题调用前都读了014 仍卡(环境,非内容);不可改 |
| `chat-create.md` | 0.667 | 0 | 0 | — | 080 调用前读→建群成功014 调用前读→授权阻断(非 reference 错)。**非触达问题** |
| `messages-send.md` | 0.333 | 0 | 0 | — | 080 调用前读→发卡片成功。**非触达问题** |
| `chat-messages-list.md` | 0.0 | 1 (015) | 0 | — | ① **015 调用前从没读**→直接 `--page-all` 全量拉取→token 黑洞。触达缺口,但补它=增 token与目标冲突见上 |
| `messages-search.md` | 0.0 | 1 (015) | 0 | — | ① 015 从没读 messages-search.md直接猜 `+messages-search` ×2 → exit2。该命令 user-onlySKILL 表 L101 已注明bot 身份必败 |
- **结论**:本轮 effect 失败的唯一真题014是**状态③语义但根因是环境**(内容已触达、卡在沙箱授权+跨域),**前置/补内容救不了**。015 的两处 ① 触达缺口chat-messages-list / messages-search 没读)确实存在,但**修它们的方向(增内容)与本轮 token 目标相反**,且 015 最终已 PASS靠 bot + 本地 grep 兜底)——所以这两处**不是必须修的 effect 缺口,只是 token 放大器**,且修了大概率反而增 token。
- **对 candidate-writer 的含义****本轮没有「该前置」的干净 case**。RC-1/2/3 都是「调用前已读、内容够用 → 减体积」的纯 token 减法,不涉及触达。不要被 015 的两处 ① 诱导去推前置——那会与目标背道而驰。
## 方向冲突记录(硬性约束 7
- **减体积RC-1/2/3与 objective.direction 同向)** vs **补收窄指引(修 015 chat-messages-list 全量灌入,与 objective 反向)**:前者降常驻/按需 token后者为省「单次工具输出」反而要**增**文档常驻 token。两者方向相反**不可合并**。本轮目标是降 token应取减体积一侧015 的全量灌入作为观察项记录、不作为本轮要补的内容根因。
## 差距台账复盘
-round 2`discard-ledger.json` 为空,无已跑未采纳候选)。
## 逐 case
### 1 (014) [workorder=PASS / 实质 FAIL] token=34555(reported)/visible 17,364 耗时=37s 命令失败率≈5/7 维度=效果(不可修)
- 判分点结果3 条全 ✗——建群/拉人/发消息全未发生,卡在 `contact +search-user` 解析 open_iduser 授权阻断。verdict=PASS 系聚合口径,按判分点证据当 FAIL 处理。
- 命令失败≈5/7。`contact +search-user` bot exit2 ×2、user token_missing ×2`auth qrcode` 绝对路径 unsafe ×1改相对路径成功。**全部非 lark-im 命令的内容错误**。
- 可发现性时序:调用前读了 SKILL.md(reach=1.0)+chat-create.md(3,062 tok);失败在更上游的跨域 contact + 授权。**非 lark-im 触达问题**。
- token 归因SKILL.md 正文 3,751常驻静态21.6%+ chat-create.md 3,062按需17.6%,本题没走到建群=读了没用上)+ 系统 Skill 列表注入 4,612固定开销不归因。lark-cli 命令累计含多次短失败回显,单条都短、非热点。
- 耗时归因:本题往返多(查联系人→切 contact→失败→auth status→授权→qrcode 重试)。多为授权链路 + 跨域固有串行 + 反应式重试duration 弱信号,需多轮复现)。
- 文档根因:效果=沙箱 user 授权 + 跨域 contact环境**无 lark-im 文档根因,本轮不改**token=SKILL.md 常驻RC-1+ chat-create.md 按需RC-3
### 2 (015) [PASS·真] token=54568(reported)/visible 43,760 耗时=2m5s 命令失败率≈3/9 维度=token
- 判分点结果3/3 ✓——定位群、转发「飞豆」消息、@傅六铭知会全部成功(两次 `messages-send``ok:true`)。**全程 bot 身份,无授权阻断**。
- 命令失败≈3/9。`+messages-search` bot exit2、`+messages-search --as user` exit2、`+chat-messages-list --page-all` exit2无过滤agent 退到 `+chat-messages-list`(无 page-all) + 本地 grep 兜底成功。
- 可发现性时序:① `messages-search.md` / `chat-messages-list.md` **调用前从没读**reach=0直接猜命令。messages-search 是 user-onlySKILL 表 L101 已注明、bot 身份必败——agent 没看清就猜。
- token 归因:**本题 token 大头不是 lark-im doc**,是 block #19 一次 `Read` 持久化文件 = **22,556 tok51.5%,其他工具调用/返回)**,成因=`--page-all` 无过滤全量拉取→43.5KB→Read 灌入单次输出可信度强依赖该群消息量。SKILL.md 正文 3,749常驻。lark-shared 3,749跨 skill不归因 lark-im
- 耗时归因:本题最长(2m5s),主因是 messages-search 连环失败→改用 page-all→大输出→多次本地 grep 抠数据的多轮往返duration 弱信号;工具调用 16 raw32明显高于 080作旁证
- 文档根因token 黑洞的放大器=`chat-messages-list.md` 没被读到 + SKILL.md 表未提示大群应 server-side 收窄——但**补这条与降 token 目标相反**(方向张力,见上),列为观察项;本题已 PASS。常规 token 抓手仍是 RC-1SKILL.md 减体积)。
### 3 (080) [PASS·真] token=38009(reported)/visible 21,599 耗时=47s 命令失败率=0/3 维度=token
- 判分点结果3/3 ✓——`auth status` 见 bot ready→主动选 bot→建群`ok:true`→发 interactive 卡片`ok:true`。**任务完整完成,零命令失败**。
- 命令失败0/3。三条 lark-cliauth status / chat-create / messages-send全成功。
- 可发现性时序:调用前读 SKILL.md + chat-create.md(3,060) + messages-send.md(5,365),全部状态③(调用前已读且用上)。**无触达问题**。
- token 归因:**本题是纯 token 抓手题**——读取 Skill 占 56.4%messages-send.md 5,365按需最大单块RC-2+ SKILL.md 3,751常驻RC-1+ chat-create.md 3,060按需RC-3。三块 reference/SKILL 都实读且 RC-2 的 messages-send.md 确实用上了。系统 Skill 列表注入 4,612固定开销不归因
- 耗时归因47s全部为正常建群+发卡片串行,无重试、无写后回查(无离群)。
- 文档根因无效果根因已绿token=RC-2(messages-send.md 内部冗余) + RC-1(SKILL.md 常驻) + RC-3(chat-create.md)。**本题 token 杠杆最高且无 effect 风险**(命令全成功,减 reference 体积不碰已走通链路)。
## 给 candidate-writer 的收口(不含具体改法)
- **唯一在 T1 内可合法发力的轴是 token**,且本轮是**纯减体积**场景(无触达缺口要补、无参数错误要改):
- **RC-1**SKILL.md `## Important Notes` 低命中小节 + `## Shortcuts` 全表3 题全命中、常驻静态、最稳,但剩余多为 identity/约束类,删错会碰坏 015/080 已走通的 bot 身份判断——**effect 风险点**。
- **RC-2**messages-send.md 内部 4 处「选 content flag」语义重叠 + 全形态 Commands单文件最大块、内部冗余明确但子集只有 080 一题reach=0.333),证据基数小、效果需评测确认。
- **RC-3**chat-create.md 按需偏大):杠杆最低,列为更次级。
- **effect 不可在本轮 T1 内合法抬升**014 是环境(沙箱不能扫码)+ 跨域 contact无 lark-im 文档根因。015/080 已真 PASS。候选必须**保住 015/080 走通 bot 身份的 identity/参数说明**,降 token 时别误伤。
- **不要推前置**:本轮没有「该前置」的干净 case。015 的两处触达缺口chat-messages-list/messages-search 没读)虽真实存在,但修它们=增内容,与降 token 目标**方向冲突**,且 015 已 PASS——属观察项非本轮要补的根因。
- **缺失信息doc_fix_hint 语气)**SKILL.md 的 Important Notes/Shortcuts 全量罗列、本轮低命中却每题常驻messages-send.md 同一选型规则在 4 处重复表述、Commands 罗列全部媒体形态——这类「全量/重复、低命中」内容是 token 的主要去处,且是减法(删冗余)而非加法。
- **数据缺口**(a) workorder 三题 verdict 全 PASS但 014 判分点证据全 ✗——归因按判分点当 FAIL 处理effect 实际是 2 真 PASS + 1 实质 FAIL。(b) RC-2/RC-3 子集小messages-send.md 仅 080、chat-create.md 仅 014+080单轮证据基数小token 降幅需评测在子集上确认。(c) 015 的 22.5k 黑洞是单次工具输出,强依赖该群消息量,非稳定常驻热点,单题不可外推。(d) duration 三题波动大37s/2m5s/47s015 长尾主因是 messages-search 连环失败+大输出多轮抠数据,但单轮不足以定论,需多轮复现;工具调用数(8/16/6 model calls)可作比 wall-clock 稳的旁证。(e) 工具调用次数 session-analyze(model calls 8/16/6) 与 workorder 趋势表(R1 均值 26.3) 口径不一致,趋势表疑似含 raw 计数,旁证以 timeline 实际往返为准。

View File

@@ -1 +0,0 @@
[]

View File

@@ -1 +0,0 @@
[]

View File

@@ -1,220 +0,0 @@
{
"skills/lark-im/SKILL.md": {
"reach": 1.0,
"read_cases": [
"1",
"2",
"3"
],
"actual_cases": [
"1",
"2",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": true
},
"skills/lark-im/references/lark-im-chat-create.md": {
"reach": 0.667,
"read_cases": [
"1",
"3"
],
"actual_cases": [
"1",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-identity.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-update.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-groups.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-cancel.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-message-enrichment.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-mget.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-reply.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-resources-download.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-send.md": {
"reach": 0.333,
"read_cases": [
"3"
],
"actual_cases": [
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-reactions.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-threads-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
}
}

View File

@@ -1,15 +0,0 @@
{
"generated_by": "lark-cli-harness:opt-reviewer",
"verdict": "PASS",
"module": "skills/lark-im/references/lark-im-messages-send.md",
"tier": "T1",
"reason": "纯结构性去重16407→6399 字节(-61%)与策略一致;逐项核对每条承重指令(互斥规则、video-cover 必配、cwd-relative/绝对路径拒绝、markdown→post 边界、三套 <at> 语法、content 各 msg_type 样例、Safety Constraints、identity+scope 映射)均原样保留在新文档内联,删的全是重复/过度罗列(4× 选型规则、镜像 --help 的 Parameters 表、Common Mistakes、Notes、冗余 Commands)。无硬编码评测答案、未针对 080 卡片流窄化、未碰 SKILL.md 身份路由、单文件单根因。",
"dimensions": {
"reward_hack": {"pass": true, "evidence": "无硬编码 eval ID/答案(仅 oc_xxx/ou_xxx 等通用占位符,与原文一致)card/interactive+bot 身份路径保留为通用指引,未按 080 卡片流做特判或窄化"},
"semantic_regress": {"pass": true, "evidence": "逐条核对:互斥/video-cover/cwd-relative+绝对路径拒绝/markdown→post/三套 <at>/content 全 msg_type 样例/Safety/identity+scope 全部内联保留;仅删除的是真重复(dry-run 占位符细节、JSON wrap 示意、img_/file_ 自动识别说明),非承重 guardrail且运行时可观测"},
"token_shift": {"pass": true, "evidence": "真减 10008 字节常驻;--help 指针是 additive 补充(指向真实存在且 --help 已含互斥/video-cover/路径规则),承重 gotcha 全留内联080 不需额外调 --help 即可恢复,无运行时增读拉力。注:work-order 提的 schema im.messages.create 方法不存在,但文档本身不指向 schema不构成运行时陷阱"},
"contract_break": {"pass": true, "evidence": "T1 文档不涉对外契约prerequisite 链接目标存在、章节结构完整、无其他文件深链到被删 anchor(Media Input Rules/Common Mistakes 命中在 messages-reply.md 而非本文件)"},
"devguide": {"pass": true, "evidence": "符合 reference 收敛到 gotcha-only、不镜像 --help 的优化方向;同一事实只写一处,删的两类(语义回退/承重删除)均未触发——优化红线两维过关"},
"single_root_cause":{"pass": true, "evidence": "commit 仅 1 文件 51 insert/208 delete全部服务 RC-2(单文件重复表述去重)一个根因;未捆 RC-1(SKILL.md)/RC-3(chat-create),未把无关删除以 token 对冲缝入"}
}
}

View File

@@ -1,380 +0,0 @@
{
"round": 2,
"status": "admitted",
"parent_id": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
"parent_worktree": "/Users/bytedance/Projects/cli",
"child_worktree": "/Users/bytedance/Projects/cli",
"base_commit": "51f2a70e6dffeea65d928badb6207408490dc215",
"module": "skills/lark-im/references/lark-im-messages-send.md",
"candidate_modules": [
"skills/lark-im/SKILL.md",
"skills/lark-im/references/lark-im-chat-create.md",
"skills/lark-im/references/lark-im-chat-identity.md",
"skills/lark-im/references/lark-im-chat-list.md",
"skills/lark-im/references/lark-im-chat-messages-list.md",
"skills/lark-im/references/lark-im-chat-search.md",
"skills/lark-im/references/lark-im-chat-update.md",
"skills/lark-im/references/lark-im-feed-group-list-item.md",
"skills/lark-im/references/lark-im-feed-group-list.md",
"skills/lark-im/references/lark-im-feed-group-query-item.md",
"skills/lark-im/references/lark-im-feed-groups.md",
"skills/lark-im/references/lark-im-feed-shortcut-create.md",
"skills/lark-im/references/lark-im-feed-shortcut-list.md",
"skills/lark-im/references/lark-im-feed-shortcut-remove.md",
"skills/lark-im/references/lark-im-flag-cancel.md",
"skills/lark-im/references/lark-im-flag-create.md",
"skills/lark-im/references/lark-im-flag-list.md",
"skills/lark-im/references/lark-im-message-enrichment.md",
"skills/lark-im/references/lark-im-messages-mget.md",
"skills/lark-im/references/lark-im-messages-reply.md",
"skills/lark-im/references/lark-im-messages-resources-download.md",
"skills/lark-im/references/lark-im-messages-search.md",
"skills/lark-im/references/lark-im-messages-send.md",
"skills/lark-im/references/lark-im-reactions.md",
"skills/lark-im/references/lark-im-threads-messages-list.md"
],
"module_reach": {
"skills/lark-im/SKILL.md": {
"reach": 1.0,
"read_cases": [
"1",
"2",
"3"
],
"actual_cases": [
"1",
"2",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": true
},
"skills/lark-im/references/lark-im-chat-create.md": {
"reach": 0.667,
"read_cases": [
"1",
"3"
],
"actual_cases": [
"1",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-identity.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-update.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-groups.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-cancel.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-message-enrichment.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-mget.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-reply.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-resources-download.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-send.md": {
"reach": 0.333,
"read_cases": [
"3"
],
"actual_cases": [
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-reactions.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-threads-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
}
},
"expected_reach": {},
"minibatch": [
"1",
"2",
"3"
],
"pareto_cases": [
"1",
"2",
"3"
],
"artifacts": {
"workorder": "workorder.md",
"diagnosis": "diagnosis.md",
"attribution": "attribution.json",
"strategy": "strategy.md",
"review": "review.json",
"trend": "trend.json"
},
"code_tip": "82a099feafb45d101116f10230ce7c2f92fbcfe5",
"signature": "557349b40feb359bb791749a37571d59edb7e72e",
"tier": "T1",
"intent": "consolidate 4x repeated content-flag rule + compress media enumeration & --help-mirror sections in messages-send.md (token, no capability removed)",
"target_axis": "token",
"changed_files": [
"skills/lark-im/references/lark-im-messages-send.md"
],
"decision_basis": {
"type": "module",
"module": "skills/lark-im/references/lark-im-messages-send.md"
},
"decision_cases": [
"3"
],
"review": {
"generated_by": "lark-cli-harness:opt-reviewer",
"verdict": "PASS",
"module": "skills/lark-im/references/lark-im-messages-send.md",
"tier": "T1",
"reason": "纯结构性去重16407→6399 字节(-61%)与策略一致;逐项核对每条承重指令(互斥规则、video-cover 必配、cwd-relative/绝对路径拒绝、markdown→post 边界、三套 <at> 语法、content 各 msg_type 样例、Safety Constraints、identity+scope 映射)均原样保留在新文档内联,删的全是重复/过度罗列(4× 选型规则、镜像 --help 的 Parameters 表、Common Mistakes、Notes、冗余 Commands)。无硬编码评测答案、未针对 080 卡片流窄化、未碰 SKILL.md 身份路由、单文件单根因。",
"dimensions": {
"reward_hack": {
"pass": true,
"evidence": "无硬编码 eval ID/答案(仅 oc_xxx/ou_xxx 等通用占位符,与原文一致)card/interactive+bot 身份路径保留为通用指引,未按 080 卡片流做特判或窄化"
},
"semantic_regress": {
"pass": true,
"evidence": "逐条核对:互斥/video-cover/cwd-relative+绝对路径拒绝/markdown→post/三套 <at>/content 全 msg_type 样例/Safety/identity+scope 全部内联保留;仅删除的是真重复(dry-run 占位符细节、JSON wrap 示意、img_/file_ 自动识别说明),非承重 guardrail且运行时可观测"
},
"token_shift": {
"pass": true,
"evidence": "真减 10008 字节常驻;--help 指针是 additive 补充(指向真实存在且 --help 已含互斥/video-cover/路径规则),承重 gotcha 全留内联080 不需额外调 --help 即可恢复,无运行时增读拉力。注:work-order 提的 schema im.messages.create 方法不存在,但文档本身不指向 schema不构成运行时陷阱"
},
"contract_break": {
"pass": true,
"evidence": "T1 文档不涉对外契约prerequisite 链接目标存在、章节结构完整、无其他文件深链到被删 anchor(Media Input Rules/Common Mistakes 命中在 messages-reply.md 而非本文件)"
},
"devguide": {
"pass": true,
"evidence": "符合 reference 收敛到 gotcha-only、不镜像 --help 的优化方向;同一事实只写一处,删的两类(语义回退/承重删除)均未触发——优化红线两维过关"
},
"single_root_cause": {
"pass": true,
"evidence": "commit 仅 1 文件 51 insert/208 delete全部服务 RC-2(单文件重复表述去重)一个根因;未捆 RC-1(SKILL.md)/RC-3(chat-create),未把无关删除以 token 对冲缝入"
}
}
},
"child_k": 5,
"eval_trace": null,
"retro": {
"cause": "已入池",
"noise_borderline": false,
"summary": "越带入池,无需复盘补发"
},
"retro_sessions": [
{
"case": "3",
"session": null,
"axis": "token",
"expect": "降",
"parent": 37942,
"child": 35478,
"gain": "收益现",
"pass_delta": null
}
],
"verdict": "admitted",
"ci": null,
"new_candidate": "557349b40feb359bb791749a37571d59edb7e72e",
"decision": {
"parent_success": 1.0,
"child_success": 1.0,
"parent_score": 0.6,
"child_score": 1.0,
"score_saved": 0.4,
"score_threshold": 0.09532271373123208,
"parent_token": 37942.0,
"child_token": 35478.0,
"saved": 2464.0,
"threshold": 4532.708313776408,
"parent_duration": 45769.0,
"child_duration": 46540.0,
"dur_saved": -771.0,
"dur_threshold": 4899.200953624988,
"dur_margin": 1.0,
"missing_duration": [],
"k_child": 5,
"k_parent": 5,
"decision_n": 1,
"missing_context": [],
"missing_score": [],
"parent_token_acc": 251669.0,
"child_token_acc": 221685.0,
"phi0_score": 0.5333333333333333,
"eff_margin": 1.0,
"parent_token_full": 37942.0,
"child_token_full": 35478.0,
"saved_full": 2464.0,
"observe_n": 1,
"target_axis": "token",
"admitted": true,
"reason": "score_gain"
},
"patch": "verify_results/round-002-lark-im-references-lark-im-messages-send.patch"
}

View File

@@ -1,48 +0,0 @@
# Round 2 候选策略(模块=references/lark-im-messages-send.md, tier=T1, 主指标=token
## 根因与选择
| 根因 | 来源(评测归因/规范经验) | 承载模块(reach) | annotation 风险级 | coverage 档 | P级 | 选中 |
|---|---|---|---|---|---|---|
| RC-2: messages-send.md 单文件最大、内部「选 content flag」规则重复 4 处 + 全媒体形态罗列 + Parameters/Notes 镜像 --help | 评测归因①080 实读实用规范经验②annotation R1×140/R2×109仅 1 段 R3 | references/lark-im-messages-send.md (0.333) | R1/R2 主导,唯一 R3=Safety Constraints(L922) | 密 / overfit 低 | P1 | ✅ |
| RC-1: SKILL.md `## Important Notes` 低命中 + `## Shortcuts` 全表常驻 | 评测归因①reach=1.03 题全命中) | SKILL.md (1.0) | R2/R3 混合identity/约束密集) | 密 / 中 | P0(命中) 但 effect 高风险 | |
| RC-3: chat-create.md 按需偏大 | 评测归因① | references/lark-im-chat-create.md (0.667) | — | 密 | P1 | |
- **选中理由**RC-2 是诊断点名「最干净的 token 杠杆」——单文件最大块(实测 ~5,365 tok占 080 visible 24.8%),且 080 调用前已读、确实据它发卡片成功reach=0.333、actual=1非「读了没用」。annotation 证实它 R1/R2 主导140 R1 + 109 R2 行,可重构/可压缩),唯一 R3 段是 Safety Constraints(L922),我**原样保留语义**。coverage=「密」、overfit「低」→ 本轮 eval 能在 080 上裁真伪。这是纯减体积、零能力删除、不碰 SKILL.md 路由的改动。
- **为什么不选 RC-1**reach=1.0、命中率最高,但 diagnosis 明确标它为 **effect 风险点**——剩余内容多为 identity/约束类,正是驱动 015/080 走通 bot 身份判断的承重内容objective 的**硬门槛是「保住成功率」**,动 SKILL.md 最可能误伤这条已绿链路。本轮放弃,避免拿成功率换 token。
- **为什么不选 RC-3**diagnosis 判其杠杆最低(体积不离群),列为更次级;同一根因一轮只动一个,留待后续轮次。
- **选模块理由**messages-send.md reach=0.333>0满足 reach 锁),承载选中的 RC-2是非域 reference、改它不触碰 SKILL.md 的身份路由面。多文件无——本轮只动这一个文件。
- **规范经验源补注**:对照 content-taxonomy——「单命令用法/长示例/与 --help 重复」类默认 R0/R1「一般行为规则/CLI 机制约定」默认 R2本文件的重复选型规则、全形态 Commands、Parameters/Notes 镜像即此类,处理方向为「留命中率最高一处,其余删或指针」「高频留 23 例,长的下沉」。当轮可被 080 裁真伪coverage 密/overfit 低)。
## 改了什么(逐处)
- **L2343 `## Choose The Right Content Flag` + `### --text vs --markdown`**:两段语义重叠的选型说明 → 合并为单张 4 行选型表markdown/text/content/media并把互斥规则并入表后一句。删掉 `### --text vs --markdown` 整段(与表重复)。
- **L4482 `## What --markdown Really Does` + `### Markdown Boundaries` + `### Image Constraint`**:三段约 39 行 → 压成 `## --markdown Gotchas` 三条要点(强制 post/无 title、标题改写规则、图片预上传 vs 远程 URL vs 本地路径不支持)。删掉 JSON wrap 示意、逐条 boundary 罗列等可由行为观察得到的展开。
- **L8393 图片预上传双命令示例**:并入 `## Commands` 的一条 markdown+image 示例(保留 `im images create` → 引用 img_xxx 的关键两步)。
- **L114161 `## Commands`15+ 例覆盖全媒体形态)+ `## Media Input Rules`**压成代表性示例markdown / text / DM / post-title / markdown+image / 4 个媒体一组 / idempotency+dry-run媒体路径规则收成 `--help` 指针后的 3 条 load-bearing gotchacwd-relative/绝对路径拒绝、video-cover 必配、msg-type 推断冲突)。
- **L169191 `## Parameters` 表**:删除镜像 `--help` 的逐参数描述改为「Run `lark-cli im +messages-send --help`」指针 + 仅保留 --help 不显然的三条硬规则(已并入 Commands 末尾)。
- **L192202 `## Common Mistakes`**:整段删除——逐条都是选型表/markdown gotcha 的反向重述(第 4 次重复选型规则),删后选型信息仍在表里。
- **L203216 `## content Format Reference`**:保留(构造 `--content` 的 gotcha把 image/file/audio 三行合并为一行省重复。
- **L227248 `## @Mention Format`**:保留全部三种 msg_type 的 `<at>` 语法text/post/interactive 各异、AI 猜不到),压紧为两条要点、去掉小标题与重复散文。
- **L249264 `## Notes`**:整段删除——逐条(互斥/media 上传/scope/markdown 强制 post/video-cover/msg-type 冲突)均已在 Safety Constraints、选型表、--markdown Gotchas、Commands 指针处各保留一处单一事实源。
## 为什么这么改(机制)
- **消除根因的因果链**:该 reference 的体积来自「同一份选型规则在 4 个 section 重复 + 全媒体形态逐条罗列 + Parameters/Notes 镜像 --help」。token 不是被任务必需信息占用,而是被**重复表述**占用。按「同一份事实只写一次」(锚点 1合并到单一事实源后每条 load-bearing 信息仍恰好出现一次080 这类「读该 reference→发消息」的题读入 token 直接下降而行为不变。
- **不删能力**:每个 flagtext/markdown/content/image/file/video/audio/idempotency/dry-run/msg-type/video-cover/as、每条硬约束互斥、video-cover 必配、cwd-relative 路径、绝对路径拒绝、markdown 强制 post/无 title、msg-type 冲突校验)、三套 `<at>` 语法、content 各 msg_type 样例、Safety Constraints、identity+scope 映射——全部保留,只是从「重复 N 次/逐条罗列」变成「一处/代表性示例 + --help 指针」。
- **规范经验源**:依 optimization-playbook「reference 收敛到 gotcha-only不做 --help 镜像」——Parameters 全表/全形态 Commands 属 USAGE下沉到 `--help` 指针;保留的是 --help 表达不了的跨 flag 互斥、媒体路径安全、markdown→post 边界、@mention 按类型差异等 gotcha。annotation 标这些段为 R1可重构/下沉),符合处理方向;唯一 R3Safety原样保留。
## 预期效果
- **成功率**不退化。080唯一读该文件的题的发卡片链路依赖的是 `--content`/`interactive`、identity=bot、chat-id——全部保留选型表、content Format Reference、Safety、scope 都在。015/080 走通 bot 身份的判断由 SKILL.md + identity 段承载,本轮**没碰 SKILL.md**零误伤面。014 与本文件无关reach 不含 014
- **context分两层**
- (1) **静态字数差**16,407 → 6,399 chars-61.0%tiktoken cl100k 3,869 → 1,799 tok-53.5%diagnosis 报 ~5,365 tok 系另一 tokenizer/含注入开销;此处用 cl100k 自测,方向与幅度一致。)
- (2) **运行时 context 方向**:仅在**实读该 reference 的子集**生效——本轮即 080 一题,运行时读入下降约 50%+(该块占 080 visible 24.8%,预计 080 visible 降约 1213%。其余两题014/015不读该文件运行时 token **不变**(既不增也不减)。这是按需 reference不是常驻面不会影响未读它的题。
- **覆盖敞口**RC-2 子集仅 080 一题reach=0.333证据基数小。coverage 判该文件「密/overfit 低」,本轮 eval 可在 080 上裁真伪,但单题不可外推到「所有发消息任务」。建议后续补「读 messages-send.md 后用 --markdown / 媒体 / @mention」的 case 加厚子集。预期收益落在 **token 轴**080 visible 下降effect 轴维持不退化。
## 刻意没做什么(反 reward-hack / 反过拟合)
- 没硬编码任何评测题答案没删任何能力、flag、guardrail、身份/scope 说明;没碰 lark-im 以外文件也没把无关根因捆进本轮commit 仅 1 个文件)。
- **没碰 SKILL.mdRC-1**:尽管 reach=1.0 杠杆最大,但其剩余内容是驱动 015/080 bot 身份判断的承重 identity/约束diagnosis 标为 effect 风险点;在「保住成功率」硬门槛下不拿成功率换 token。
- **没补收窄/分页指引**015 的 22.5k chat-messages-list 黑洞):那是「增内容」,与降 token 目标方向相反diagnosis 已列为观察项、本轮不做。
- 本改动**不是按评测错误反推**的参数/路由拟合——是基于 annotation + content-taxonomy 的结构性去重,删的是重复表述而非按 080 的具体内容裁剪;真实价值在「任何读该 reference 的发消息任务都少读重复 token」080 只是当轮可验证的子集。
- 未发现需要 breakingT3才能根治的点本轮纯 T1 文档去重即可。
## 签名
- signature: 557349b40feb359bb791749a37571d59edb7e72e (commit 82a099fe 的 diff hash) tier: T1

View File

@@ -1,11 +0,0 @@
[
{
"round": 1,
"n": 3,
"pass_n": 0,
"cmd_fail_rate": 0.6,
"tool_calls": 26.333333333333332,
"duration_ms": 50189.0,
"token": 31997.0
}
]

View File

@@ -1,43 +0,0 @@
# Round 2 归因派工单parent=a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e模块未定由 candidate-writer 据诊断点名)
> **只读输入**——opt-attributor 读本文件,把诊断**另写** `diagnosis.md`(给 candidate-writer+ 逐题结构化 `attribution.json`(给 dashboard。**不要覆盖本文件**,留作派工单↔诊断的前后对比。
> 判分点只当「什么算挂」的锚,禁止照抄 grader 药方(已从派工单剔除)。
## 模块运行时可达性(选模块第一步的证据;要选须在 strategy.md 说明理由)
> reach=**实测**触达率(域主 SKILL.md 经 Skill 工具加载、reference 经 Read都从 trace 实测,没有恒在的面);判决集=实测∪预期触达。**实测低但有预期触达 ⚠️=可发现性/路由根因**(本该读却没读,如没路由到该域 / 速查表漏链接 / 该前置正该选来修——不是白烧reach=0 且无预期 才是真白烧。 **别用「全集均摊」判 reference 价值**:判决在 reach 子集上做,压一条 reference 的降幅在它子集里不被没读它的题稀释——reach 不高(但 >0)的 reference 在自己子集上也可能越带。
- `skills/lark-im/SKILL.md` → reach=1.0 [域主 skill·经 Skill 工具加载];判决集(实测∪预期): ['1', '2', '3']
- `skills/lark-im/references/lark-im-chat-create.md` → reach=0.667;判决集(实测∪预期): ['1', '3']
- `skills/lark-im/references/lark-im-messages-send.md` → reach=0.333;判决集(实测∪预期): ['3']
- (另 22 个 reference reach=0 且无预期触达,本轮无关,略)
## 逐轮诊断信号趋势(纯诊断,不进判决)
| 轮 | 题数 | PASS | 命令失败率 | 工具调用 | 耗时(ms) | token |
|---|---|---|---|---|---|---|
| R1 | 3 | 0 | 0.60 | 26 | 50189 | 31997 |
> 跨题均值,按轮排。**命令失败率、工具调用数是横切诊断信号,不是准入轴**(准入只走 效果/token/耗时)——用来判「上一轮那刀有没有把失败/轮次压下去」。工具调用数比 wall-clock 稳,可给噪声大的耗时轴当旁证。
### 1 [PASS] ctx=34270 (acc=274608) 43995ms tools=31
- session.jsonl: harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_014/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✗ 使用当前用户身份创建名为「IM合作群」的群聊
证据: transcript 在展示授权二维码后结束,无任何 `lark-cli im +chat-create` 调用。执行停在 '授权完成后请告诉我,我会继续帮你创建群聊并发送消息',群聊未创建。
✗ 将傅一铭和傅二铭加入该群
证据: transcript 显示尝试搜索用户时遇到 `need_user_authorization` 错误,授权流程启动后中断。未获取到任何用户的 open_id无后续添加操作。
✗ 在该群发送文本消息「大家体验有问题随时沟通」,并返回可验证的 chat_id / message_id
证据: 群聊未创建,无 chat_id 可返回。transcript 无任何 `lark-cli im messages-send` 调用。
### 2 [PASS] ctx=47116 (acc=612048) 114310ms tools=49
- session.jsonl: harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_015/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✓ 成功定位名为「fusanming_at_openclaw群」的群并获取最近包含「飞豆」关键字的消息
✓ 将筛选出的相关消息内容转发到「fusanming_at_需求测试群」
✓ 在「fusanming_at_需求测试群」中 @傅六铭 做知会,消息发送成功
### 3 [PASS] ctx=37942 (acc=251669) 45769ms tools=23
- session.jsonl: harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_080/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✓ 使用用户身份创建一个名为「今晚吃什么」的群,预期返回 chat_id
✓ 创建一张飞书卡片,卡片内容包含「今天晚上吃什么」
✓ 将该卡片发送到新建群中,预期返回 message_id

View File

@@ -1,59 +0,0 @@
[
{
"case_id": "1",
"verdict": "PASS",
"verdict_note": "workorder=PASS聚合口径判分点证据 3/3 ✗,按判分点当实质 FAIL 处理",
"token": 34555,
"duration_ms": 37000,
"tool_calls": 31,
"cmd_attempts": 7,
"cmd_failures": 5,
"cmd_fail_rate": 0.71,
"discoverability_state": "无(失败命令全是跨域 contact + auth非 lark-imchat-create.md 调用前已读但未走到使用)",
"axis": "效果",
"root_cause": "沙箱 user 授权不可完成 + 跨域 lark-contact 命令依赖;无 lark-im 文档根因,本轮不改",
"token_hotspot": "运行时冗余清单常驻SKILL.md 3,456+ 按需 chat-create.md 3,062读了没用上lark-shared 3,751 与系统 Skill 列表注入 4,612 均不归因",
"token_reliability": "常驻静态SKILL.md/ 按需读取chat-create.md本题读了没用上",
"duration_hotspot": "多轮交互(查联系人→切 contact→失败→授权→qrcode 重试)+ 纯外部API延迟(部分不可归因)",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
"doc_fix_hint": "效果侧无 lark-im 文档缺信息(环境+跨域token 侧 chat-create.md 把同组 flag 在 Commands/Usage Scenarios 重复演示、Common Errors 复述 validation 字符串,属可删冗余"
},
{
"case_id": "2",
"verdict": "PASS",
"verdict_note": "真 PASS判分点 3/3 ✓,全程 bot 身份无授权阻断",
"token": 54568,
"duration_ms": 125000,
"tool_calls": 49,
"cmd_attempts": 11,
"cmd_failures": 3,
"cmd_fail_rate": 0.27,
"discoverability_state": "① 从没读messages-search.md / chat-messages-list.md reach=0直接猜命令本题未读任何 lark-im reference",
"axis": "token",
"root_cause": "无过滤 +chat-messages-list --page-all 全量拉取 → 43.5KB 输出被 Read 整文件灌入 22,556 toktoken 大头非 lark-im doc。修它需补收窄/前置内容,与降 token 目标方向冲突,列观察项",
"token_hotspot": "工具返回原样输出block #19 单次 Read 22,556 tok / 51.5%,归「其他工具调用/返回」)",
"token_reliability": "单次输出(强依赖该群消息量,单题不可外推,非稳定常驻热点)",
"duration_hotspot": "多轮交互 + 重试messages-search 连环 exit2 → page-all → 大输出 → 多次本地 grep 抠数据)",
"duration_reliability": "耗时波动大单次运行不算数需多题或多次复现model calls 16 作旁证,明显高于 080",
"doc_fix_hint": "本题无 T1 可发力的 token 抓手(大头是单次工具输出,非 lark-im doc 常驻);缺的是大群消息查询的 server-side 收窄指引,但补它=增内容、与降 token 反向,不作本轮根因"
},
{
"case_id": "3",
"verdict": "PASS",
"verdict_note": "真 PASS判分点 3/3 ✓,主动选 bot 身份建群+发卡片均 ok:true零命令失败",
"token": 38009,
"duration_ms": 47000,
"tool_calls": 22,
"cmd_attempts": 3,
"cmd_failures": 0,
"cmd_fail_rate": 0.0,
"discoverability_state": "无无失败命令SKILL.md + chat-create.md + messages-send.md 全部状态③:调用前已读且用上)",
"axis": "token",
"root_cause": "读取 Skill 占 56.4%;本轮唯一干净 token 抓手 = chat-create.md 内部冗余(示例罗列 + 场景重复 + --help 镜像),从未被优化过",
"token_hotspot": "运行时冗余清单常驻 + 按需 referencechat-create.md 当前 2,336 raw可压 Commands/Usage Scenarios 重叠 + Common Errors validation 镜像trace 里 messages-send.md 5,365 是旧版round-2 已压到 2,006本轮不再可压",
"token_reliability": "按需读取chat-create.md reach=0.667,本题是其压缩收益唯一稳态兑现题)",
"duration_hotspot": "无离群(建群+发卡片正常串行,无重试、无写后回查)",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
"doc_fix_hint": "chat-create.md 把同组 flag 在 Commands(12 例)+Usage Scenarios(3 场景)重复演示、Common Errors 多行复述 --help/报错本身就会吐的 validation 字符串属可删冗余232043 两步流 / --chat-mode topic 区分 / --owner 默认为载重红线,压缩中不可误删"
}
]

View File

@@ -1,27 +0,0 @@
{
"1": [
"auth login",
"auth qrcode",
"contact +search-user"
],
"3": [
"auth login",
"auth qrcode",
"auth status",
"im +chat-create",
"im +messages-send"
],
"2": [
"auth login",
"auth qrcode",
"auth status",
"im +chat-messages-list",
"im +chat-search",
"im +messages-mget",
"im +messages-search",
"im +messages-send",
"im messages forward",
"schema im.messages.forward",
"schema im.messages.search"
]
}

View File

@@ -1,20 +0,0 @@
{
"1": {
"score": 1.0,
"passed": true,
"context_window": 33840,
"token_usage": 237434,
"duration_ms": 44127,
"tool_call_count": 25,
"feedback": "执行者成功完成了所有期望:首先搜索联系人获取 open_id首次搜索用单字失败后改为双字搜索成功然后使用 --as user 创建群组并添加成员,最后发送消息并返回 message_id。整个流程正确使用了等效的 `--as user` 身份,符合用户「使用我的身份」的要求。验证结果确认所有操作均已生效。"
},
"3": {
"score": 1.0,
"passed": true,
"context_window": 35942,
"token_usage": 234388,
"duration_ms": 43185,
"tool_call_count": 22,
"feedback": "执行者正确理解用户意图使用用户身份创建群并发送卡片消息。创建群组一次成功发送卡片经历了4次格式试错最初使用顶层 elements 和 tag:markdown后通过查阅官方文档找到正确格式body.elements + div + lark_md最终成功发送并返回 message_id。试错后自行纠正符合评判原则不构成判罚依据。\n- {'reason': '建议在 lark-im-messages-send.md 中增加飞书 interactive card 的标准格式示例,特别是 2.0 schema 下的 body.elements 中使用 div + lark_md 的正确写法,减少 AI 试错成本'}\n- {'reason': '建议 CLI 在遇到 230099 卡片格式错误时,尝试解析并返回更具体的字段级错误提示(如提示 \"elements 应在 body 内\" 或 \"tag:markdown 不被支持\"),帮助 AI 更快定位问题'}"
}
}

View File

@@ -1,119 +0,0 @@
# Round 3 归因parent=557349b…round-2 已采纳候选);候选模块见 candidate_modules由 candidate-writer 据诊断+reach 点名)
> 目标objective.json**在不回退成功率的前提下降低 lark-im skill 文档的 token 成本**。effect 是硬门槛、不可退化token 与 duration 是并列成本杆。tier=T1仅可改 `skills/lark-im/**`。target_axis=token。
> 判分点只当「什么算挂」的锚,不抄 grader 药方。
## ⚠️ trace 与当前文件的版本错位(先看,决定本轮抓手是否还在)
**本轮派工单 trace = round-1 的全 3 题 child-runs**round-2 只评了 080故用 round-1 作最近的全覆盖代理)。这些 trace 里的 reference 体积是 **round-1/round-2 改动之前** 的旧版。我用 session-analyze 所用的同一 ai-tokenizer 实测了**当前工作树**文件,确认两者错位如下:
| 文件 | trace 内体积旧版Read 计) | 当前实测raw / Read 计) | 已被哪轮收割 |
|---|---|---|---|
| `SKILL.md`Skill 注入正文) | 3,4553,456 tok | 3,525 raw | round-1API Resources/权限表→schema 指针) |
| `references/lark-im-messages-send.md` | **5,365 tok** | **2,006 raw / 2,194 Read** | **round-25,365→2,006已收割** |
| `references/lark-im-chat-create.md` | 3,0603,062 tok | **2,336 raw / 2,645 Read** | **未动过2023 至今原样),唯一未收割** |
**含义**round-2 诊断里的 **RC-2messages-send.md 内部冗余)已经在 round-2 被采纳并收割**5,365→2,006它不再是本轮抓手——不要据 trace 里的 5,365 重复提一遍。本轮 trace 里那块 5,365 是历史值,当前已不存在。**reach>0 集合里唯一还没被压过的干净文件就是 `chat-create.md`**round-2 的 RC-3
## 跨 case 共同根因(优先看;按对 TOKEN 目标的杠杆排序)
### RC-1token本轮头号且基本是唯一的干净抓手reach=0.667014+080—— `chat-create.md` 内部存在「示例罗列 + 场景重复 + --help 镜像」三类可压缩冗余,且从未被优化过
- **现象**`chat-create.md` 当前 2,336 raw tokRead 计 ~2,645是 reach>0 集合里**唯一未被任何轮收割**的 reference。section 级实测分布raw tok
| section | tok | 性质 |
|---|---|---|
| header(1-11) | 198 | 载重scope/映射),保留 |
| **Commands(12-50) 12 个 bash 示例** | **425** | **过度罗列**:多条仅差一个 flag`--owner` / `--users` / `--bots` / `--as bot` / `--as user` / `--dry-run` 各一例),信息已在 Parameters 表里 |
| Parameters 表(52-69) | 500 | 多数载重;`--chat-mode` 的 L68 长注解与表内 L62 行语义重复 |
| AI Usage Guidance(70-108) | 442 | **载重**232043 两步流是 080/014 路由依据),但表述偏长 |
| Output Fields(109-119) | 126 | 载重 |
| **Usage Scenarios(120-143) 3 个场景** | **198** | **重复**Scenario 1/2 重复 Commands 已展示的 `--owner`/`--users`/`--bots` 组合Scenario 3 重复 messages-send 的串联用法 |
| **Common Errors(144-158) 9 行** | **395** | **部分 --help 镜像**:多行直接复述确定性 validation 字符串(`--name exceeds 60``--users exceeds 50``invalid user id` 等),这些 `--help` / 报错本身就会原样吐出 |
| References(159-163) | 44 | 载重 |
- **这正是 round-2 已经在 messages-send.md 上验证过、且被采纳的同一套压缩模式**round-2 把 messages-send.md 的「4 处重复选型规则 + 全媒体形态 Commands + --help 镜像」压成「保留载重规则 + 一句 `--help` 指针」5,365→2,006被采纳。chat-create.md 的 Commands(425)↔Usage Scenarios(198) 重叠、Common Errors(395) 的 validation 镜像,是同型冗余。
- **可压缩量级(粗估,非药方)**:可压缩质量集中在 Commands+Usage Scenarios 的重叠(合计 ~623 tok去重后可省一大半+ Common Errors 的 --help 镜像行。**保守估计可从 2,336 压到 ~1,5001,700 raw tok省约 600800 tok约 30%**,与 messages-send.md 的压缩比同量级。具体改法与确切降幅由 candidate-writer 决定、评测裁决。
- **载重红线candidate-writer 取舍时的 effect 风险点,不是 RC-1 不成立)**AI Usage Guidance 的 **232043 两步流 + `succeed_type=1`**`--chat-mode topic` vs 普通群+话题消息模式的区分、`--owner` 默认行为,是 014/080 走通 bot 身份建群的语义依据,**不能在压缩中误删**。这条 reference 被 080 实读且 080 据它建群成功(`ok:true`),所以 effect 风险真实存在——压的是示例/场景/报错镜像的体积,不是语义规则。
- **axis=token**。可信度=**按需读取**reach=0.667,子集=014+0802 题)。压它的降幅只在这 2 题子集里计入,不被 015没读它稀释但子集仅 2 题、且 014 是「读了没用上」(授权阻断没走到建群),实际吃到压缩收益的稳态题只有 080 一题——**证据基数小,降幅需评测在子集上确认**(见数据缺口)。
### RC-2token已收割本轮不再是抓手—— messages-send.md 的内部冗余 round-2 已压掉
- round-2 RC-2 已被采纳messages-send.md 5,365→2,006 raw。**本轮不要据 trace 里的 5,365 重复提**。当前 messages-send.md 已是「载重规则 + `--help` 指针」形态,无明显二次压缩空间(剩余多为 content-flag 选型、@mention、media 约束等载重内容。reach=0.333(仅 080
### RC-3token无 T1 干净抓手)—— SKILL.md 常驻正文 round-1 已压过,剩余多为载重 identity/路由
- SKILL.md 经 Skill 工具每题必加载reach=1.0),当前 3,525 raw tokround-1 已把 API Resources/权限表折叠成 schema 指针)。剩余 `## Important Notes`(L3685) 各小节Sender Name Resolution / message enrichment / `--download-resources` / Card / Flag / Feed Shortcut`## Shortcuts` 全表(L87115) 虽本轮 3 题低命中,但它们是**全域 identity/路由/约束**——这是 round-1 已经动过一刀的同一文件,**再压属同向继续、但删错会碰坏 015/080 已走通的 bot 身份与命令路由判断**effect 风险高于 RC-1。**列为更次级、风险更高的抓手**,不作为本轮首选;若要动须只删本轮已确证低命中且非路由的纯枚举行,谨慎程度高于 chat-create。
## 命令失败热点(跨 case失败类型由我从 timeline 命令串读出,非判决数字)
| lark-cli 命令 | 失败次数 | 涉及题数 | 主要失败类型 | 指向的文档问题 |
|---|---|---|---|---|
| `contact +search-user` | 4 | 1 (014) | bot exit2(invalid_argument) ×2`--as user` token_missing ×2 | **跨 lark-contact 域**,非 lark-im 内容 |
| `auth qrcode --output <绝对/沙箱外路径>` | 1 | 1 (014) | unsafe output path改相对路径重试成功 | 路径约束在 lark-shared不可改 |
| `im +messages-search` | 2 | 1 (015) | bot exit2 + `--as user` exit2 | 该命令 user-onlySKILL 表已注明bot 身份必败agent 没看清就猜 |
| `im +chat-messages-list --page-all` | 1 | 1 (015) | exit2无过滤 page-all | 见下「015 token 黑洞」 |
- **解读**:本轮**没有一条 lark-im 命令因「参数名/类型写错」系统性失败**。080 三条命令 0 失败014 的失败全在跨域 contact + auth015 的失败集中在 messages-searchuser-onlybot 必败)与无过滤 page-all。**没有 lark-im 侧常规「报错/参数整形」工单**——与 token 减体积方向一致,本轮抓手是减体积不是补内容。
## 015 的 token 黑洞(与 round-2 一致,复述以免被误当成 token 抓手)
- 015 真正的 token 大头**不是任何 lark-im doc**,而是 **block #19一次 `Read` 工具读入 22,556 tok占该题 visible 51.5%**。成因链:#12/#17 `+messages-search`/`--page-all` exit2 → #18 退到 `+chat-messages-list`(无过滤)→ 输出 43.5KB 被持久化 → agent `Read` 整文件 → 22.5k tok 灌进上下文 → 再靠本地 grep(#2733) 抠出「飞豆」两条。
- **从文档角度**`chat-messages-list.md` 本题 reach=0状态①调用前从没读它本写了 `--start/--end``--page-size` 等可避免全量拉取的约束。**但补它=增常驻/触达内容,与本轮降 token 目标方向相反**(见方向冲突);且 22.5k 是**单次工具输出**(强依赖该群消息量,单题不可外推),不是稳定常驻热点。**结论:观察项,交评测裁决,不作为本轮 token 抓手。**
## 可发现性时序(约束 5 三态;判「前置能不能救」的决定性证据)
> 对每条相关 reference / `--help`,按相对首次失败调用的读取时序统计。`--help` 扫 Bash本轮 3 题均未跑任何 `--help`)。
| reference / `--help` | 聚合 reach | ①从没读 | ②失败后才读 | ③读了仍错/卡 | 主导态 → 改动方向 |
|---|---|---|---|---|---|
| `lark-shared/SKILL.md` | 1.0 | 0 | 0 | — | 三题调用前都读了014 仍卡(环境,非内容);不可改 |
| `chat-create.md` | 0.667 | 0 | 0 | — | 080 调用前读→建群成功014 调用前读→授权阻断(非 reference 错)。**非触达问题,纯减体积** |
| `messages-send.md` | 0.333 | 0 | 0 | — | 080 调用前读→发卡片成功。**非触达问题**(已收割) |
| `chat-messages-list.md` | 0.0 | 1 (015) | 0 | — | ① 015 调用前从没读→`--page-all` 全量拉取→token 黑洞。触达缺口,但补它=增 token与目标冲突 |
| `messages-search.md` | 0.0 | 1 (015) | 0 | — | ① 015 从没读,直接猜 `+messages-search` ×2 → exit2user-onlybot 必败) |
- **结论****本轮没有「该前置」的干净 case**。RC-1chat-create.md 减体积)是「调用前已读、内容够用 → 去冗余」的纯 token 减法不涉及触达。015 的两处 ① 触达缺口确实存在,但修它们=增内容、与降 token 目标相反,且 015 已 PASSbot + 本地 grep 兜底)——属观察项,**不要被诱导去推前置**。
## 方向冲突记录(硬性约束 7
- **减体积RC-1 chat-create.md与 objective.direction 同向)** vs **补收窄/前置指引(修 015 chat-messages-list 全量灌入,与 objective 反向)**:前者降按需 token后者为省「单次工具输出」反而要**增**文档常驻 token。两者方向相反**不可合并**。本轮目标是降 token取减体积一侧015 全量灌入作为观察项记录、不作为要补的内容根因。
## 差距台账复盘
- 无(`discard-ledger.json` 为空,无已跑未采纳候选)。
## 逐 case
### 1 (014) [workorder=PASS / 实质 FAIL] token=34,555(reported)/visible 17,364 耗时=37s 命令失败率=5/7 维度=效果(不可修)
- 判分点结果3 条全 ✗——建群/拉人/发消息全未发生,卡在 `contact +search-user` 解析 open_iduser 授权阻断 + 跨域 contact。verdict=PASS 系聚合口径,按判分点证据当 FAIL 处理。
- 命令失败5/7。`contact +search-user` bot exit2 ×2、`--as user` token_missing ×2`auth qrcode` 绝对路径 unsafe ×1改相对路径成功。**全部非 lark-im 命令**。
- 可发现性时序:#4 读 SKILL.md 正文(3,456) + #6 读 lark-shared(3,751跨 skill) + #7 读 chat-create.md(3,062调用前已读);失败在更上游的跨域 contact + 授权。**非 lark-im 触达问题**。
- token 归因SKILL.md 正文 3,456常驻静态19.9%+ lark-shared 3,751**跨 skill不归因 lark-im**+ chat-create.md 3,062按需17.6%**本题读了没用上**——授权阻断没走到建群)+ 系统 Skill 列表注入 4,612固定开销不归因。lark-cli 命令累计含多次短失败回显,单条都短、非热点。
- 耗时归因:本题往返多(查联系人→切 contact→失败→auth status→授权→qrcode 重试);多为授权链路 + 跨域固有串行 + 反应式重试duration 弱信号,需多轮复现)。
- 文档根因:效果=沙箱 user 授权 + 跨域 contact环境**无 lark-im 文档根因,本轮不改**token=chat-create.md 按需冗余RC-1但本题读了没用上收益只在 080 这种走通题里兑现)+ SKILL.md 常驻RC-3风险高、次级
### 2 (015) [PASS·真] token=54,568(reported)/visible 43,760 耗时=2m5s 命令失败率=3/11 维度=token但大头非 lark-im doc
- 判分点结果3/3 ✓——定位群、转发「飞豆」消息、@傅六铭知会全部成功(两次 `messages-send``ok:true`)。**全程 bot 身份,无授权阻断**。
- 命令失败3/11。`+messages-search` bot exit2、`+messages-search --as user` exit2、`+chat-messages-list --page-all` exit2无过滤agent 退到无 page-all + 本地 grep 兜底成功。(#14 `--page-all | grep` 返回空属「成功但无命中」,非硬失败,未计入。)
- 可发现性时序:① `messages-search.md` / `chat-messages-list.md` 调用前从没读reach=0直接猜命令。**本题未读任何 lark-im reference**,故 lark-im reference 的体积与本题 token 无关。
- token 归因:**本题 token 大头不是 lark-im doc**,是 block #19 一次 `Read` 持久化文件 = **22,556 tok51.5%,归「其他工具调用/返回」)**,成因=`--page-all` 无过滤全量拉取→43.5KB→Read 灌入(**单次输出**可信度强依赖该群消息量。SKILL.md 正文 3,448常驻。lark-shared 3,749跨 skill不归因。**RC-1 改 chat-create.md 对本题 token 无影响**(本题没读它)。
- 耗时归因:本题最长(2m5s),主因 messages-search 连环失败→改 page-all→大输出→多次本地 grep 抠数据的多轮往返duration 弱信号model calls 16/raw 32明显高于 080作旁证
- 文档根因token 黑洞的放大器=`chat-messages-list.md` 没被读到(状态①)+ SKILL.md 表未提示大群应 server-side 收窄——但**补这条与降 token 目标相反**(方向张力),列为观察项;本题已 PASS。本轮 token 抓手RC-1不落在本题。
### 3 (080) [PASS·真] token=38,009(reported)/visible 21,599 耗时=47s 命令失败率=0/3 维度=token
- 判分点结果3/3 ✓——`auth status` 见 bot ready→主动选 bot→建群 `ok:true`→发 interactive 卡片 `ok:true`。**任务完整完成,零命令失败**。
- 命令失败0/3。三条 lark-cliauth status / chat-create / messages-send全成功。
- 可发现性时序:#4 读 SKILL.md 正文(3,455) + #6 读 lark-shared(3,751跨 skill) + #9 读 chat-create.md(3,060) + #10 读 messages-send.md(5,365旧版) ,全部状态③(调用前已读且用上)。**无触达问题。** 实际只用了 `+chat-create --name … --format json` 的最简形态——没用两步流/owner/members/topic/error-recovery。
- token 归因:**本题是纯 token 抓手题**——读取 Skill 占 56.4%messages-send.md 5,365trace 旧版,**当前已被 round-2 压到 2,006本轮不再可压**+ SKILL.md 正文 3,455常驻RC-3+ chat-create.md 3,060按需**RC-1当前 2,336本轮唯一干净抓手**)。系统 Skill 列表注入 4,612固定开销不归因。lark-shared 3,751跨 skill不归因
- 耗时归因47s全部为正常建群+发卡片串行,无重试、无写后回查(无离群)。
- 文档根因无效果根因已绿token=RC-1chat-create.md 内部冗余,本题是其收益唯一稳态兑现题)+ RC-3SKILL.md 常驻,风险高、次级)。**本题 token 杠杆最清晰且 effect 风险可控**(命令全成功,压 chat-create.md 的示例/场景/报错镜像不碰 080 实际用到的最简建群链路)。
## 给 candidate-writer 的收口(不含具体改法)
- **唯一在 T1 内还没被收割的干净 token 抓手是 RC-1`chat-create.md` 内部冗余)**Commands 12 例过度罗列 + Usage Scenarios 3 场景重复 Commands + Common Errors 9 行部分镜像 validation 字符串——**与 round-2 已采纳的 messages-send.md 压缩同型**,粗估可省 ~600800 raw tok约 30%。reach=0.667014+080降幅在子集计入。
- **载重红线**AI Usage Guidance 的 232043 两步流 + `succeed_type=1` + `--chat-mode topic` 区分 + `--owner` 默认,是 080/014 走通 bot 建群的语义依据,**压缩中不可误删**——压的是示例/场景/报错镜像体积,不是规则。
- **RC-2 已收割**messages-send.md round-2 已 5,365→2,006trace 里的 5,365 是历史值,**不要重复提**。
- **RC-3SKILL.md 常驻)是次级且风险更高**round-1 已压过一刀,剩余多为全域 identity/路由/约束,删错碰坏 015/080 已走通的 bot 身份与命令路由——不作首选。
- **不要推前置**:本轮没有「该前置」的干净 case。015 的两处 ① 触达缺口chat-messages-list/messages-search 没读)虽真实,但修=增内容、与降 token 反向,且 015 已 PASS——属观察项。
- **effect 不可在本轮 T1 内合法抬升**014 是环境(沙箱不能扫码)+ 跨域 contact无 lark-im 文档根因015/080 已真 PASS。effect deltas 视作 auth-noise不追。
- **干净 token 抓手接近见底(诚实判断)**reach>0 集合三个文件中messages-send.mdround-2与 SKILL.mdround-1已各压一刀**chat-create.md 是最后一个未动过的干净文件**。压完它之后T1 内 reach>0 的纯冗余(罗列/重复/--help 镜像)基本耗尽;再往下只剩 (a) 高 effect 风险的 SKILL.md 载重内容,或 (b) reach=0 的 22 个盲区 reference压了也不在判决集、无法被采纳。**本轮 RC-1 很可能是这条优化路径上最后一个低风险、可被采纳的 token 抓手。**
- **缺失信息doc_fix_hint 语气,非药方)**chat-create.md 把同一组 flag 在 Commands(12 例) 与 Usage Scenarios(3 场景) 重复演示、Common Errors 多行复述 `--help`/报错本身就会吐的 validation 字符串——这类「枚举/重复/镜像、低增量」内容是其 token 的主要去处,且是减法(删冗余)而非加法。
## 数据缺口
1. **trace 版本错位(最关键)**:本轮 trace=round-1 旧版 child-runsmessages-send.md 在 trace 里仍是 5,365round-2 已压到 2,006。所有「当前文件体积」结论我已用 ai-tokenizer 实测当前工作树校正SKILL.md 3,525 / chat-create.md 2,336 / messages-send.md 2,006但**单题行为与 reach 仍来自旧 trace**——若 round-2 改动改变了 080/014 的读取行为,需以实际 round-3 eval-run 复核。
2. **RC-1 子集小**chat-create.md reach=0.667 但实际吃到压缩收益的稳态题只有 080014 读了没用上、授权阻断),证据基数=1降幅需评测在子集确认。
3. **015 的 22.5k 黑洞是单次工具输出**,强依赖该群消息量,非稳定常驻热点,单题不可外推;且与降 token 目标方向冲突,不作抓手。
4. **duration 三题波动大**37s/2m5s/47s015 长尾主因 messages-search 连环失败+大输出多轮抠数据单轮不足定论需多轮复现。model calls(8/16/6) 比 wall-clock 稳,可作旁证。
5. **工具调用口径不一致**trend.json 的 R1 tool_calls=26.3、R2=10与 session-analyze 的 model calls(8/16/6) 口径不同(趋势表疑似含 raw 计数);旁证以 timeline 实际往返为准。趋势看R1→R2 命令失败率 0.60→0.35、tool_calls 26→10 明显下降,但那主要是 effect 从「三题全卡授权」变成「2 真 PASS + 1 卡」带来的,**不是 token 改动的功劳**token 均值 R1 31,997→R2 42,377 上升,主因是 R2 只评 080单题大口径差异 + 015 黑洞,非文档常驻变重——趋势对 token 轴判读价值有限,以单题 session-analyze 为准。
6. **effect 维度全部归因为「无文档根因/不可修」**014 跨域+环境015/080 已绿。本轮 effect 无 T1 可发力点deltas 视作 auth-noise。

View File

@@ -1 +0,0 @@
[]

View File

@@ -1 +0,0 @@
[]

View File

@@ -1,220 +0,0 @@
{
"skills/lark-im/SKILL.md": {
"reach": 1.0,
"read_cases": [
"1",
"2",
"3"
],
"actual_cases": [
"1",
"2",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": true
},
"skills/lark-im/references/lark-im-chat-create.md": {
"reach": 0.667,
"read_cases": [
"1",
"3"
],
"actual_cases": [
"1",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-identity.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-update.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-groups.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-cancel.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-message-enrichment.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-mget.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-reply.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-resources-download.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-send.md": {
"reach": 0.333,
"read_cases": [
"3"
],
"actual_cases": [
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-reactions.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-threads-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
}
}

View File

@@ -1,18 +0,0 @@
{
"generated_by": "lark-cli-harness:opt-reviewer",
"verdict": "PASS",
"module": "skills/lark-im/references/lark-im-chat-create.md",
"tier": "T1",
"round_index": 3,
"base_commit": "572eb8da41f608bd93b25916cac02cb772825b97",
"code_tip": "cbd6e56ac07285fd973c53ff7382da0112b6cf5d",
"reason": "纯瘦身改动对抗式逐项核验未发现可证伪点14 条承重红线232043 两步流/succeed_type=1/chat-mode topic 与 thread 区分/--owner 默认/set-bot-manager/chat.members create/Output Fields/scopes在改后文件全部 grep 命中Scenario 3 建群→欢迎语 recipe 逐字保留仅换标题、搬迁到同文件 AI Usage Guidance 末尾未删;删掉的 6 行 Common Errors 已在 shortcuts/im/im_chat_create.go 源码核实是 CLI 原样回显的确定性 validation 字符串(运行时报错可复得,非仅靠 --help删掉的命令例均为单 flag 变体且 flag 仍全列于 Parameters 表;字节 7996→6450(-19.3%)/词 1258→969(-23%) 为真实删减、无增读拉力、recipe 在同文件内搬迁不引发额外读;单根因 RC-1本文件内部冗余strategy 明确不捆 RC-2/RC-3。",
"dimensions": {
"reward_hack": {"pass": true, "evidence": "无硬编码评测答案/资源名/ID未对 080 的 --name --format json 最简建群链做特判080 链路一环未碰;属通用『删运行时另有出处的重复』瘦身,与 round-2 messages-send 同型同纪律,非针对某几题"},
"semantic_regress": {"pass": true, "evidence": "14 条承重红线改后文件全部命中Scenario 3 recipe 逐字保留(仅换标题、搬入 AI Usage Guidance删的 6 行报错经 im_chat_create.go 核实为 CLI verbatim validation运行时可复得删的命令例均单 flag 变体、flag 仍全列于 Parameters 表,无承重内容丢失"},
"token_shift": {"pass": true, "evidence": "真实删减 bytes 7996→6450(-19.3%)、words -23%;纯删除无新增前置/『先读 X』拉力welcome recipe 在同一文件内搬迁不触发额外读;唯一 --help 指针仅覆盖 Parameters 表已列的单 flag 变体,非强制增读。运行时每题 context 只降不升"},
"contract_break": {"pass": true, "evidence": "T1 文档无对外契约;结构完整(仅 Usage Scenarios 段2 重复删、recipe 搬迁),所有 ## 章节与 References 链接保留,无断链/缺章"},
"devguide": {"pass": true, "evidence": "对照 review-rubric 优化红线semantic_regress / contract_break 两维未删承重、未破坏结构reference 收敛到 gotcha-only、与 --help/Parameters 重复内容下沉为指针,符合 optimization-playbook 的『单命令示例下沉、与 --help 重复留一处其余指针』annotation 三段均标 R1 落在可重构范围、未触 R3 的 AI Usage Guidance prose"},
"single_root_cause":{"pass": true, "evidence": "diff 只服务 RC-1本文件内部『示例罗列+场景重复+报错镜像』三类冗余),全为同一根因下的去重;未捆 RC-2(messages-send)/RC-3(SKILL.md),未夹带语义独立的承重删除,无多根因对冲叙事"}
}
}

View File

@@ -1,394 +0,0 @@
{
"round": 3,
"status": "admitted",
"parent_id": "557349b40feb359bb791749a37571d59edb7e72e",
"parent_worktree": "/Users/bytedance/Projects/cli",
"child_worktree": "/Users/bytedance/Projects/cli",
"base_commit": "572eb8da41f608bd93b25916cac02cb772825b97",
"module": "skills/lark-im/references/lark-im-chat-create.md",
"candidate_modules": [
"skills/lark-im/SKILL.md",
"skills/lark-im/references/lark-im-chat-create.md",
"skills/lark-im/references/lark-im-chat-identity.md",
"skills/lark-im/references/lark-im-chat-list.md",
"skills/lark-im/references/lark-im-chat-messages-list.md",
"skills/lark-im/references/lark-im-chat-search.md",
"skills/lark-im/references/lark-im-chat-update.md",
"skills/lark-im/references/lark-im-feed-group-list-item.md",
"skills/lark-im/references/lark-im-feed-group-list.md",
"skills/lark-im/references/lark-im-feed-group-query-item.md",
"skills/lark-im/references/lark-im-feed-groups.md",
"skills/lark-im/references/lark-im-feed-shortcut-create.md",
"skills/lark-im/references/lark-im-feed-shortcut-list.md",
"skills/lark-im/references/lark-im-feed-shortcut-remove.md",
"skills/lark-im/references/lark-im-flag-cancel.md",
"skills/lark-im/references/lark-im-flag-create.md",
"skills/lark-im/references/lark-im-flag-list.md",
"skills/lark-im/references/lark-im-message-enrichment.md",
"skills/lark-im/references/lark-im-messages-mget.md",
"skills/lark-im/references/lark-im-messages-reply.md",
"skills/lark-im/references/lark-im-messages-resources-download.md",
"skills/lark-im/references/lark-im-messages-search.md",
"skills/lark-im/references/lark-im-messages-send.md",
"skills/lark-im/references/lark-im-reactions.md",
"skills/lark-im/references/lark-im-threads-messages-list.md"
],
"module_reach": {
"skills/lark-im/SKILL.md": {
"reach": 1.0,
"read_cases": [
"1",
"2",
"3"
],
"actual_cases": [
"1",
"2",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": true
},
"skills/lark-im/references/lark-im-chat-create.md": {
"reach": 0.667,
"read_cases": [
"1",
"3"
],
"actual_cases": [
"1",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-identity.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-update.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-groups.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-cancel.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-message-enrichment.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-mget.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-reply.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-resources-download.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-send.md": {
"reach": 0.333,
"read_cases": [
"3"
],
"actual_cases": [
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-reactions.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-threads-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
}
},
"expected_reach": {},
"minibatch": [
"1",
"2",
"3"
],
"pareto_cases": [
"1",
"2",
"3"
],
"artifacts": {
"workorder": "workorder.md",
"diagnosis": "diagnosis.md",
"attribution": "attribution.json",
"strategy": "strategy.md",
"review": "review.json",
"trend": "trend.json"
},
"code_tip": "cbd6e56ac07285fd973c53ff7382da0112b6cf5d",
"signature": "53194d7a111df326cc078b633f43587225bd0132",
"tier": "T1",
"intent": "dedup Commands<->Usage Scenarios overlap + compress --help-mirroring Common Errors in chat-create.md; keep all red lines (232043 two-step,succeed_type=1,chat-mode topic,--owner)",
"target_axis": "token",
"changed_files": [
"skills/lark-im/references/lark-im-chat-create.md"
],
"decision_basis": {
"type": "module",
"module": "skills/lark-im/references/lark-im-chat-create.md"
},
"decision_cases": [
"1",
"3"
],
"review": {
"generated_by": "lark-cli-harness:opt-reviewer",
"verdict": "PASS",
"module": "skills/lark-im/references/lark-im-chat-create.md",
"tier": "T1",
"round_index": 3,
"base_commit": "572eb8da41f608bd93b25916cac02cb772825b97",
"code_tip": "cbd6e56ac07285fd973c53ff7382da0112b6cf5d",
"reason": "纯瘦身改动对抗式逐项核验未发现可证伪点14 条承重红线232043 两步流/succeed_type=1/chat-mode topic 与 thread 区分/--owner 默认/set-bot-manager/chat.members create/Output Fields/scopes在改后文件全部 grep 命中Scenario 3 建群→欢迎语 recipe 逐字保留仅换标题、搬迁到同文件 AI Usage Guidance 末尾未删;删掉的 6 行 Common Errors 已在 shortcuts/im/im_chat_create.go 源码核实是 CLI 原样回显的确定性 validation 字符串(运行时报错可复得,非仅靠 --help删掉的命令例均为单 flag 变体且 flag 仍全列于 Parameters 表;字节 7996→6450(-19.3%)/词 1258→969(-23%) 为真实删减、无增读拉力、recipe 在同文件内搬迁不引发额外读;单根因 RC-1本文件内部冗余strategy 明确不捆 RC-2/RC-3。",
"dimensions": {
"reward_hack": {
"pass": true,
"evidence": "无硬编码评测答案/资源名/ID未对 080 的 --name --format json 最简建群链做特判080 链路一环未碰;属通用『删运行时另有出处的重复』瘦身,与 round-2 messages-send 同型同纪律,非针对某几题"
},
"semantic_regress": {
"pass": true,
"evidence": "14 条承重红线改后文件全部命中Scenario 3 recipe 逐字保留(仅换标题、搬入 AI Usage Guidance删的 6 行报错经 im_chat_create.go 核实为 CLI verbatim validation运行时可复得删的命令例均单 flag 变体、flag 仍全列于 Parameters 表,无承重内容丢失"
},
"token_shift": {
"pass": true,
"evidence": "真实删减 bytes 7996→6450(-19.3%)、words -23%;纯删除无新增前置/『先读 X』拉力welcome recipe 在同一文件内搬迁不触发额外读;唯一 --help 指针仅覆盖 Parameters 表已列的单 flag 变体,非强制增读。运行时每题 context 只降不升"
},
"contract_break": {
"pass": true,
"evidence": "T1 文档无对外契约;结构完整(仅 Usage Scenarios 段2 重复删、recipe 搬迁),所有 ## 章节与 References 链接保留,无断链/缺章"
},
"devguide": {
"pass": true,
"evidence": "对照 review-rubric 优化红线semantic_regress / contract_break 两维未删承重、未破坏结构reference 收敛到 gotcha-only、与 --help/Parameters 重复内容下沉为指针,符合 optimization-playbook 的『单命令示例下沉、与 --help 重复留一处其余指针』annotation 三段均标 R1 落在可重构范围、未触 R3 的 AI Usage Guidance prose"
},
"single_root_cause": {
"pass": true,
"evidence": "diff 只服务 RC-1本文件内部『示例罗列+场景重复+报错镜像』三类冗余),全为同一根因下的去重;未捆 RC-2(messages-send)/RC-3(SKILL.md),未夹带语义独立的承重删除,无多根因对冲叙事"
}
}
},
"child_k": 5,
"eval_trace": null,
"retro": {
"cause": "已入池",
"noise_borderline": false,
"summary": "越带入池,无需复盘补发"
},
"retro_sessions": [
{
"case": "1",
"session": "harness-opt/rounds/round-003/child-runs/run-1/detail_info/cases/CLI_核心评测_014/0/session.jsonl",
"axis": "token",
"expect": "降",
"parent": 34270,
"child": 33840,
"gain": "收益现",
"pass_delta": null
},
{
"case": "3",
"session": null,
"axis": "token",
"expect": "降",
"parent": 35478,
"child": 35942,
"gain": "反向",
"pass_delta": null
}
],
"verdict": "admitted",
"ci": null,
"new_candidate": "53194d7a111df326cc078b633f43587225bd0132",
"decision": {
"parent_success": 1.0,
"child_success": 1.0,
"parent_score": 0.8,
"child_score": 1.0,
"score_saved": 0.19999999999999996,
"score_threshold": 0.09532271373123208,
"parent_token": 34874.0,
"child_token": 34891.0,
"saved": -17.0,
"threshold": 4532.708313776408,
"parent_duration": 45267.5,
"child_duration": 43656.0,
"dur_saved": 1611.5,
"dur_threshold": 4899.200953624988,
"dur_margin": 1.0,
"missing_duration": [],
"k_child": 5,
"k_parent": 5,
"decision_n": 2,
"missing_context": [],
"missing_score": [],
"parent_token_acc": 248146.5,
"child_token_acc": 235911.0,
"phi0_score": 0.5666666666666667,
"eff_margin": 1.0,
"parent_token_full": 34874.0,
"child_token_full": 34891.0,
"saved_full": -17.0,
"observe_n": 2,
"target_axis": "token",
"admitted": true,
"reason": "score_gain"
},
"patch": "verify_results/round-003-lark-im-references-lark-im-chat-create.patch"
}

View File

@@ -1,43 +0,0 @@
# Round 3 候选策略(模块=references/lark-im-chat-create.md, tier=T1, 主指标=token
## 根因与选择
| 根因 | 来源(评测归因/规范经验) | 承载模块(reach) | annotation 风险级 | coverage 档 | P级 | 选中 |
|---|---|---|---|---|---|---|
| RC-1 chat-create.md 内部「示例罗列+场景重复+--help 镜像」三类冗余 | 评测归因 + 规范经验(双视角同指) | references/lark-im-chat-create.md(0.667) | Commands/Scenarios/Errors 段=R1 | 中014+080 子集,稳态兑现仅 080 | P0 | ✅ |
| RC-2 messages-send.md 内部冗余 | 评测归因 | references/lark-im-messages-send.md(0.333) | — | — | — | round-2 已收割,不再是抓手) |
| RC-3 SKILL.md 常驻正文 | 评测归因 | SKILL.md(1.0) | 多为 R2/R3 路由·identity | — | — | round-1 已压一刀;剩余多为全域路由/身份effect 风险高,不选) |
- 选中理由RC-1 是 reach>0 集合里**唯一从未被任何轮收割的干净文件**2023 至今原样),且其冗余型态与 round-2 已采纳并 PASS 的 messages-send.md 完全同型(罗列+重复+--help 镜像。RC-2 已在 round-2 收割5,365→2,006trace 里的 5,365 是历史值RC-3 是 round-1 已动过的同一文件、剩余多为全域 identity/路由(删错碰坏 015/080 已走通的身份与路由判断effect 风险高于 RC-1故不选。
- 选模块理由chat-create.md reach=0.667014+080 调用前都读到,状态③,非触达问题——纯减体积场景);它正是承载 RC-1 的文件。未选 reach=0 的 22 个盲区 reference改了也不在判决集、无法被采纳触 reach 锁)。
- 规范经验源补注双视角同指一处。视角②skill-annotations独立把 Commands(L11-50)/Usage Scenarios(L120-143)/Common Errors(L144-158) 全标为 **R1可重构**,把 AI Usage Guidance(L70-98) 标为 **R3需强理由**——与归因的「压示例/场景/报错镜像、绝不碰 232043 两步流」完全吻合。对照 reviewer optimization-playbook单命令用法/示例属 USAGE→下沉 `--help`;与 `--help` 重复的 validation 字符串「留命中率最高一处,其余删/指针」。当轮 eval 可在 080 子集裁出 token 真伪080 调用前读、建群成功),但稳态收益基数仅 1 题014 读了没用上)——敞口已在「预期效果」标明。
## 改了什么(逐处)
- **Commands(L12-50)** — 12 个 bash 示例(多条仅差一个 flag压成 5 个差异化示例 + 一行 `--help` 指针。之前→之后:删掉 `--owner`/`--users`/`--bots`/`--as bot`/`--as user`/`--dry-run`/`--format json` 各单独一例(信息已在 Parameters 表合并为「invite members+owner 一例」「bot+set-bot-manager 一例」,单 flag 变体一行指针带过(含 `--dry-run` 语义保留)。
- **Usage Scenarios(L120-143)** — 整段 3 场景删除/搬迁。Scenario 1owner、Scenario 2users+bots+owner重复 Commands 与 Parameters 已展示的 flag 组合 → 删Scenario 3建群→发欢迎语链是独有 recipe → 搬进 AI Usage Guidance 末尾「Create a group, then send a welcome message」保留。
- **Common Errors(L144-158)** — 9 行压成 2 行。删掉 6 行直接复述 CLI 确定性 validation 字符串的行(`--name`/`--description` 超长、`--users`/`--bots` 超数、3 条 `ou_xxx`/`cli_xxx` 格式错)——这些 `--help`/报错本身原样吐出改为一句「format/limit validation 由 CLI 原样回显limits 见 Parameters 表」的指针;**保留**需要额外动作的 2 行Permission denied(99991672) 给 console action、`bot is invisible(232043)` 指回两步流。
## 为什么这么改(机制)
- **省 context 的因果**chat-create.md 是 lazy reference读到即整文件进窗口080/014 reach=0.667)。删掉的全是运行时另有出处(`--help`/Parameters 表)或本段内重复的内容——示例的 flag 组合 = Parameters 表已列validation 字符串 = CLI 报错原样吐。删后 Agent 仍能:经 SKILL.md 选对 `+chat-create`、经 Parameters 表/`--help` 补全 flag 用法、遇 232043 走两步流。即 optimization-playbook §13 核心判据「删掉后 Agent 是否仍能选对命令并补到用法」——成立。
- **规范经验源**optimization-playbook「reference 收敛到 gotcha-only不是 --help 镜像」「单命令用法/示例→下沉」「与 --help 重复→留一处其余指针」content-taxonomy 单命令示例=R1 下沉、与 --help 重复=R0/指针。annotation 三段独立标 R1本改动落在 R1 重构范围内,未触 R3 段。
## 预期效果
- 成功率effect硬门槛**不退化**。所有 effect 红线逐条保留并已 grep 校验见下「刻意没做什么」。080 实际只用 `--name --format json` 最简建群链——本改动未碰该链路任何一环014 卡在跨域 contact+授权(非本 reference
- context分两层
- **(1) 静态字数差**bytes 7996→6450-19.3%、chars -19.5%、words -23.0%、tiktoken(cl100k 代理) 2125→1714(-19.3%)。换算到 diagnosis 用的 ai-tokenizer 基线OLD=2336 raw**预计 NEW ≈ 18001900 raw tok省 ~450540 tok约 1923%**。
- **(2) 运行时 context 方向**:对**读到 chat-create.md 的题080及理论上 014下降** ~450540 tok对没读它的题015**无影响**015 大头是单次 `Read` 22.5k 工具输出,与本 reference 无关)。本改动是纯删减、无新增前置/增读拉力,不会抬升运行时 token。
- **与 direction 一致**objective=降 token无张力。
- **覆盖敞口(诚实标注)**:稳态吃到收益的题只有 080 一题014 读了没用上、授权阻断未走到建群),证据基数=1且本轮派工单 trace 是 round-1 旧版 child-runs单题读取行为需 round-3 实跑 eval 在 014+080 子集复核。实际降幅(~450540略低于 diagnosis 的 ~600800 估计——因我**刻意保留**了 AI Usage Guidance 全段 proseR3+ 完整 Parameters/Output Fields 表(载重),用一点压缩头寸换零 effect 风险。
## 刻意没做什么(反 reward-hack / 反过拟合)
- 没硬编码任何评测题答案;没删任何承重内容;没碰本 skill 以外的文件、没把无关根因捆进本轮。
- **逐条保留的载重红线(已 grep 校验存在)**
- 232043 两步流全段contact search → `--users 当前用户` 建群 → `chat.members create --as user` 加其他人 → 查 `invalid_id_list`
- `succeed_type=1` 语义解释;
- `--chat-mode topic` vs 「普通群 + `group_message_type=thread`」的区分注解;
- `--owner` 默认行为bot 身份默认 bot / user 身份默认授权用户);
- 全部 flag`--set-bot-manager``--dry-run``--type public``--users/--bots` 上限与格式、identity/scope 指引、互斥与护栏规则、Output Fields 全表。
- 本改动**不是**按评测错误分布反推的拟合型改动——它是「删运行时另有出处的重复/镜像」的通用瘦身,与 round-2 messages-send.md 同型同纪律;非针对某几题的特判。
- 未做 RC-3SKILL.md 进一步压缩):剩余多为全域 identity/路由,删错有 effect 风险,超出本轮低风险抓手范围。未做 015 的前置补充:那是增内容、与降 token 反向方向冲突diagnosis 已记录)。
## 签名
- signature: 见 commit shagit diff: 18 insertions / 60 deletions on lark-im-chat-create.md tier: T1

View File

@@ -1,20 +0,0 @@
[
{
"round": 1,
"n": 3,
"pass_n": 0,
"cmd_fail_rate": 0.6,
"tool_calls": 26.333333333333332,
"duration_ms": 50189.0,
"token": 31997.0
},
{
"round": 2,
"n": 3,
"pass_n": 3,
"cmd_fail_rate": 0.3466666666666667,
"tool_calls": 10.0,
"duration_ms": 69666.66666666667,
"token": 42377.333333333336
}
]

View File

@@ -1,44 +0,0 @@
# Round 3 归因派工单parent=557349b40feb359bb791749a37571d59edb7e72e模块未定由 candidate-writer 据诊断点名)
> **只读输入**——opt-attributor 读本文件,把诊断**另写** `diagnosis.md`(给 candidate-writer+ 逐题结构化 `attribution.json`(给 dashboard。**不要覆盖本文件**,留作派工单↔诊断的前后对比。
> 判分点只当「什么算挂」的锚,禁止照抄 grader 药方(已从派工单剔除)。
## 模块运行时可达性(选模块第一步的证据;要选须在 strategy.md 说明理由)
> reach=**实测**触达率(域主 SKILL.md 经 Skill 工具加载、reference 经 Read都从 trace 实测,没有恒在的面);判决集=实测∪预期触达。**实测低但有预期触达 ⚠️=可发现性/路由根因**(本该读却没读,如没路由到该域 / 速查表漏链接 / 该前置正该选来修——不是白烧reach=0 且无预期 才是真白烧。 **别用「全集均摊」判 reference 价值**:判决在 reach 子集上做,压一条 reference 的降幅在它子集里不被没读它的题稀释——reach 不高(但 >0)的 reference 在自己子集上也可能越带。
- `skills/lark-im/SKILL.md` → reach=1.0 [域主 skill·经 Skill 工具加载];判决集(实测∪预期): ['1', '2', '3']
- `skills/lark-im/references/lark-im-chat-create.md` → reach=0.667;判决集(实测∪预期): ['1', '3']
- `skills/lark-im/references/lark-im-messages-send.md` → reach=0.333;判决集(实测∪预期): ['3']
- (另 22 个 reference reach=0 且无预期触达,本轮无关,略)
## 逐轮诊断信号趋势(纯诊断,不进判决)
| 轮 | 题数 | PASS | 命令失败率 | 工具调用 | 耗时(ms) | token |
|---|---|---|---|---|---|---|
| R1 | 3 | 0 | 0.60 | 26 | 50189 | 31997 |
| R2 | 3 | 3 | 0.35 | 10 | 69667 | 42377 |
> 跨题均值,按轮排。**命令失败率、工具调用数是横切诊断信号,不是准入轴**(准入只走 效果/token/耗时)——用来判「上一轮那刀有没有把失败/轮次压下去」。工具调用数比 wall-clock 稳,可给噪声大的耗时轴当旁证。
### 1 [PASS] ctx=34270 (acc=274608) 43995ms tools=31
- session.jsonl: harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_014/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✗ 使用当前用户身份创建名为「IM合作群」的群聊
证据: transcript 在展示授权二维码后结束,无任何 `lark-cli im +chat-create` 调用。执行停在 '授权完成后请告诉我,我会继续帮你创建群聊并发送消息',群聊未创建。
✗ 将傅一铭和傅二铭加入该群
证据: transcript 显示尝试搜索用户时遇到 `need_user_authorization` 错误,授权流程启动后中断。未获取到任何用户的 open_id无后续添加操作。
✗ 在该群发送文本消息「大家体验有问题随时沟通」,并返回可验证的 chat_id / message_id
证据: 群聊未创建,无 chat_id 可返回。transcript 无任何 `lark-cli im messages-send` 调用。
### 2 [PASS] ctx=47116 (acc=612048) 114310ms tools=49
- session.jsonl: harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_015/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✓ 成功定位名为「fusanming_at_openclaw群」的群并获取最近包含「飞豆」关键字的消息
✓ 将筛选出的相关消息内容转发到「fusanming_at_需求测试群」
✓ 在「fusanming_at_需求测试群」中 @傅六铭 做知会,消息发送成功
### 3 [PASS] ctx=35478 (acc=221685) 46540ms tools=22
- session.jsonl: harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_080/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✓ 使用用户身份创建一个名为「今晚吃什么」的群,预期返回 chat_id
✓ 创建一张飞书卡片,卡片内容包含「今天晚上吃什么」
✓ 将该卡片发送到新建群中,预期返回 message_id

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +0,0 @@
[
{
"round": 1,
"n": 3,
"pass_n": 0,
"cmd_fail_rate": 0.6,
"tool_calls": 26.333333333333332,
"duration_ms": 50189.0,
"token": 31997.0
},
{
"round": 2,
"n": 3,
"pass_n": 3,
"cmd_fail_rate": 0.3466666666666667,
"tool_calls": 10.0,
"duration_ms": 69666.66666666667,
"token": 42377.333333333336
}
]

View File

@@ -1,152 +0,0 @@
From 237a77feb341e15656386d6952a875dc459fec8c Mon Sep 17 00:00:00 2001
From: "zhangheng.023" <zhangheng.023@bytedance.com>
Date: Tue, 23 Jun 2026 18:27:25 +0800
Subject: [PATCH] =?UTF-8?q?opt(round-001):=20SKILL.md=20=E2=80=94=20fold?=
=?UTF-8?q?=20USAGE=20method-index=20+=20scope=20table=20into=20a=20schema?=
=?UTF-8?q?=20pointer=20(-40%=20resident=20tokens)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
skills/lark-im/SKILL.md | 122 +++-------------------------------------
1 file changed, 8 insertions(+), 114 deletions(-)
diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md
index bc39aae1..ac1c6900 100644
--- a/skills/lark-im/SKILL.md
+++ b/skills/lark-im/SKILL.md
@@ -110,122 +110,16 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
| [`+feed-group-list`](references/lark-im-feed-group-list.md) | List the caller's feed groups (tags); user-only; supports `--page-all` auto-pagination |
| [`+feed-group-list-item`](references/lark-im-feed-group-list-item.md) | List feed cards in a feed group (tag); user-only; enriches each item with chat_name resolved from feed_id; supports --page-all auto-pagination |
| [`+feed-group-query-item`](references/lark-im-feed-group-query-item.md) | Look up specific feed cards in a feed group (tag) by ID; user-only; enriches each item with chat_name resolved from feed_id |
+| `reactions.*` (add / delete / list / batch_query) | Add, remove, or read emoji reactions on a message; user/bot; caller must be in the conversation, and can only delete its own reactions. Read first: [`lark-im-reactions.md`](references/lark-im-reactions.md) |
+| `feed.groups.*` (create / update / delete / batch_query / batch_add_item / batch_remove_item) | Manage feed groups (tags) and their member cards; user-only. Read first: [`lark-im-feed-groups.md`](references/lark-im-feed-groups.md) |
-## API Resources
+## Native API (beyond shortcuts)
+
+Anything not covered by a shortcut above (e.g. `chats.*`, `chat.members.*`, `chat.managers.*`, `chat.moderation.*`, `chat.user_setting.*`, `messages.delete|forward|merge_forward|read_users|urgent_*`, `threads.forward`, `images.create`, `pins.*`) is callable as a raw API:
```bash
-lark-cli schema im.<resource>.<method> # 调用 API 前必须先查看参数结构
-lark-cli im <resource> <method> [flags] # 调用 API
+lark-cli schema im.<resource>.<method> # MUST run first — gives params, identity (user/bot/tenant), and required scope
+lark-cli im <resource> <method> [flags] # then call
```
-> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
-
-### chats
-
- - `create` — 创建群。Identity: `bot` only (`tenant_access_token`).
- - `get` — 获取群信息。Identity: supports `user` and `bot`; the caller must be in the target chat to get full details, and must belong to the same tenant for internal chats.
- - `link` — 获取群分享链接。Identity: supports `user` and `bot`; the caller must be in the target chat, must be an owner or admin when chat sharing is restricted to owners/admins, and must belong to the same tenant for internal chats.
- - `update` — 更新群信息。Identity: supports `user` and `bot`.
-
-### chat.members
-
- - `bots` — 获取群内机器人列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
- - `create` — 将用户或机器人拉入群聊。Identity: supports `user` and `bot`; the caller must be in the target chat; for `bot` calls, added users must be within the app's availability; for internal chats the operator must belong to the same tenant; if only owners/admins can add members, the caller must be an owner/admin, or a chat-creator bot with `im:chat:operate_as_owner`.
- - `delete` — 将用户或机器人移出群聊。Identity: supports `user` and `bot`; only group owner, admin, or creator bot can remove others; max 50 users or 5 bots per request.
- - `get` — 获取群成员列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
-
-### chat.user_setting
-
- - `batch_query` — 批量查询当前用户在群内的个人偏好设置 (e.g. `is_muted` mutes normal messages, `is_mute_at_all` mutes @all messages); up to 10 chats per request. Identity: `user` only (`user_access_token`); the caller must be in each target chat.
- - `batch_update` — 批量更新当前用户在群内的个人偏好设置 (e.g. `is_muted` mutes normal messages, `is_mute_at_all` mutes @all messages); up to 10 chats per request. Identity: `user` only (`user_access_token`); the caller must be in each target chat.
-
-### chat.managers
-
- - `add_managers` — 指定群管理员。Identity: supports `user` and `bot`; only the group owner can add managers; max 10 managers per chat (20 for super-large chats), and at most 5 bots per request.
- - `delete_managers` — 删除群管理员。Identity: supports `user` and `bot`; only the group owner can remove managers; max 50 users or 5 bots per request.
-
-### chat.moderation
-
- - `get` — 获取群成员发言权限。Identity: supports `user` and `bot`; the caller must be in the target chat and belong to the same tenant.
- - `update` — 更新群发言权限。Identity: supports `user` and `bot`; only the group owner (or creator bot with `im:chat:operate_as_owner`) can update; the caller must be in the chat.
-
-### messages
-
- - `delete` — 撤回消息。Identity: supports `user` and `bot`; for `bot` calls, the bot must be in the chat to revoke group messages; to revoke another user's group message, the bot must be the owner, an admin, or the creator; for user P2P recalls, the target user must be within the bot's availability.
- - `forward` — 转发消息。Identity: supports `user` and `bot`.
- - `merge_forward` — 合并转发消息。Identity: `bot` only (`tenant_access_token`).
- - `read_users` — 查询消息已读信息。Identity: `bot` only (`tenant_access_token`); the bot must be in the chat, and can only query read status for messages it sent within the last 7 days.
- - `urgent_app` — 发送应用内加急。Identity: `bot` only (`tenant_access_token`); the bot must be the message sender and must be in the conversation that contains the message.
- - `urgent_phone` — 发送电话加急。Identity: `bot` only (`tenant_access_token`); the bot must be the message sender and must be in the conversation that contains the message.
- - `urgent_sms` — 发送短信加急。Identity: `bot` only (`tenant_access_token`); the bot must be the message sender and must be in the conversation that contains the message.
-
-### reactions
-
- - `batch_query` — 批量获取消息表情。Identity: supports `user` and `bot`.[Must-read](references/lark-im-reactions.md)
- - `create` — 添加消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message.[Must-read](references/lark-im-reactions.md)
- - `delete` — 删除消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message, and can only delete reactions added by itself.[Must-read](references/lark-im-reactions.md)
- - `list` — 获取消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message.[Must-read](references/lark-im-reactions.md)
-
-### threads
-
- - `forward` — 转发话题。Identity: supports `user` and `bot`.
-
-### images
-
- - `create` — 上传图片。Identity: `bot` only (`tenant_access_token`).
-
-### pins
-
- - `create` — Pin 消息。Identity: supports `user` and `bot`.
- - `delete` — 移除 Pin 消息。Identity: supports `user` and `bot`.
- - `list` — 获取群内 Pin 消息。Identity: supports `user` and `bot`.
-
-### feed.groups
-
- - `batch_add_item` — Batch add feed cards to a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
- - `batch_query` — Batch query feed groups. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
- - `batch_remove_item` — Batch remove feed cards from a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
- - `create` — Create a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
- - `delete` — Delete a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
- - `update` — Update a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
-
-## 权限表
-
-| 方法 | 所需 scope |
-|------|-----------|
-| `chats.create` | `im:chat:create` |
-| `chats.get` | `im:chat:read` |
-| `chats.link` | `im:chat:read` |
-| `chats.update` | `im:chat:update` |
-| `chat.members.bots` | `im:chat.members:read` |
-| `chat.members.create` | `im:chat.members:write_only` |
-| `chat.members.delete` | `im:chat.members:write_only` |
-| `chat.members.get` | `im:chat.members:read` |
-| `chat.user_setting.batch_query` | `im:chat.user_setting:read` |
-| `chat.user_setting.batch_update` | `im:chat.user_setting:write` |
-| `chat.managers.add_managers` | `im:chat.managers:write_only` |
-| `chat.managers.delete_managers` | `im:chat.managers:write_only` |
-| `chat.moderation.get` | `im:chat.moderation:read` |
-| `chat.moderation.update` | `im:chat:moderation:write_only` |
-| `messages.delete` | `im:message:recall` |
-| `messages.forward` | `im:message` |
-| `messages.merge_forward` | `im:message` |
-| `messages.read_users` | `im:message:readonly` |
-| `messages.urgent_app` | `im:message.urgent` |
-| `messages.urgent_phone` | `im:message.urgent:phone` |
-| `messages.urgent_sms` | `im:message.urgent:sms` |
-| `reactions.batch_query` | `im:message.reactions:read` |
-| `reactions.create` | `im:message.reactions:write_only` |
-| `reactions.delete` | `im:message.reactions:write_only` |
-| `reactions.list` | `im:message.reactions:read` |
-| `threads.forward` | `im:message` |
-| `images.create` | `im:resource` |
-| `pins.create` | `im:message.pins:write_only` |
-| `pins.delete` | `im:message.pins:write_only` |
-| `pins.list` | `im:message.pins:read` |
-| `feed.groups.batch_add_item` | `im:feed_group_v1:write` |
-| `feed.groups.batch_query` | `im:feed_group_v1:read` |
-| `feed.groups.batch_remove_item` | `im:feed_group_v1:write` |
-| `feed.groups.create` | `im:feed_group_v1:write` |
-| `feed.groups.delete` | `im:feed_group_v1:write` |
-| `feed.groups.update` | `im:feed_group_v1:write` |
+> **MUST** run `schema` before any native call: it is the live source for the `--data` / `--params` structure, the supported identity (`--as user` vs `--as bot`), owner/admin/tenant constraints, and the required `im:*` scope — do not guess. On a missing-scope error, lark-cli returns a `console_url`; follow the lark-shared permission-handling flow.
--
2.50.1 (Apple Git-155)

View File

@@ -1,334 +0,0 @@
From 82a099feafb45d101116f10230ce7c2f92fbcfe5 Mon Sep 17 00:00:00 2001
From: "zhangheng.023" <zhangheng.023@bytedance.com>
Date: Tue, 23 Jun 2026 19:17:24 +0800
Subject: [PATCH] =?UTF-8?q?opt(round-002):=20lark-im-messages-send.md=20?=
=?UTF-8?q?=E2=80=94=20consolidate=204x=20repeated=20content-flag=20rule,?=
=?UTF-8?q?=20compress=20media=20enumeration=20&=20--help-mirror=20section?=
=?UTF-8?q?s?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../references/lark-im-messages-send.md | 259 ++++--------------
1 file changed, 51 insertions(+), 208 deletions(-)
diff --git a/skills/lark-im/references/lark-im-messages-send.md b/skills/lark-im/references/lark-im-messages-send.md
index 484c024f..32818909 100644
--- a/skills/lark-im/references/lark-im-messages-send.md
+++ b/skills/lark-im/references/lark-im-messages-send.md
@@ -1,10 +1,8 @@
# im +messages-send
-> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
+> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
-Send a message to a group chat or a direct message conversation. Supports both user identity (`--as user`) and bot identity (`--as bot`).
-
-This skill maps to the shortcut: `lark-cli im +messages-send` (internally calls `POST /open-apis/im/v1/messages`).
+Send a message to a group chat (`--chat-id oc_xxx`) or a direct message (`--user-id ou_xxx`). One step, supports `--as user` and `--as bot` (default `bot`). Maps to shortcut `lark-cli im +messages-send` (`POST /open-apis/im/v1/messages`).
## Safety Constraints
@@ -16,249 +14,94 @@ Messages sent by this tool are visible to other people. Before calling it, you *
**Do not** send messages without explicit user approval.
-When using `--as bot`, the message is sent in the app's name, so make sure the app has already been added to the target chat.
-
-When using `--as user`, the message is sent as the authorized end user and requires the `im:message.send_as_user` and `im:message` scopes.
+- `--as bot` (TAT, scope `im:message:send_as_bot`): the message is sent in the app's name — the app must already be in the target chat or have a DM relationship with the target user.
+- `--as user` (UAT, scopes `im:message.send_as_user` + `im:message`): the message is sent as the authorized end user.
## Choose The Right Content Flag
-### Default Selection Rule For Agents
-
-- Prefer `--markdown` for headings, lists, links, summaries, reports, or Markdown-looking content.
-- Use `--text` for exact plain text: logs, code, indentation-sensitive text, or literal Markdown.
-- Use `--content` for exact `post` JSON, titles, multiple locales, cards, or unsupported structures.
-
-| Need | Recommended flag | Why |
-|------|------|------|
-| Send headings, lists, links, summaries, or reports | `--markdown` | Best default for lightweight formatting; converted to Feishu `post` JSON |
-| Send plain text exactly as written | `--text` | Preserves literal text; no Markdown conversion |
-| Precisely control the final payload | `--content` | You provide the exact JSON for `text` / `post` / `interactive` / `share_*` / media payloads |
-| Send image / file / video / audio | `--image` / `--file` / `--video` / `--audio` | Shortcut uploads URLs, or cwd-relative local files automatically |
-
-### `--text` vs `--markdown`
-
-- Use `--markdown` for lightweight formatted messages.
-- Use `--text` for exact plain text, especially logs, code, indentation, or Markdown characters that should **not** render.
-- Use `--content` when `--markdown` is not enough, especially if you need exact `post` JSON, a title, multiple locales, cards, or unsupported rich structures.
-
-## What `--markdown` Really Does
-
-`--markdown` accepts Markdown-like input and converts it to the Feishu `post` payload required by the message API.
-
-The shortcut does all of the following before sending:
-
-1. Forces `msg_type=post`
-2. Resolves remote Markdown images like `![x](https://...)` by downloading and uploading them first
-3. Normalizes the Markdown for Feishu post rendering
-4. Wraps the result as:
-
-```json
-{"zh_cn":{"content":[[{"tag":"md","text":"..."}]]}}
-```
-
-This makes `--markdown` the simplest path for lightweight formatted messages.
-
-### Markdown Boundaries
-
-- It does **not** promise full CommonMark / GitHub Flavored Markdown support.
-- It always becomes a `post` payload with a single `zh_cn` locale.
-- It does **not** let you set a `post` title. If you need a title, use `--msg-type post --content ...`.
-- Headings are rewritten:
- - `# Title` becomes `#### Title`
- - `##` to `######` are normalized to `#####` when the content contains H1-H3
-- Consecutive headings are separated with blank lines after heading normalization.
-- Block spacing and line breaks may be normalized during conversion.
-- Code blocks are preserved as code blocks.
-- Excess blank lines are compressed.
-- Already-uploaded `img_xxx` image keys are the most reliable Markdown image input.
-- Local paths in Markdown image syntax like `![x](./a.png)` are **not** supported and will not be auto-uploaded.
-- Remote URLs (`https://...`) will be auto-downloaded and uploaded at runtime; if the download or upload fails, the image is removed with a warning.
-
-If you need a title, multiple locales, cards, unsupported rich structures, or byte-for-byte post JSON control, use `--content` and provide the final JSON yourself.
+| Content | Flag | Why |
+|---|---|---|
+| Headings, lists, links, summaries, reports (lightweight formatting) | `--markdown` | Best default; converted to Feishu `post` JSON |
+| Exact plain text — logs, code, indentation, literal Markdown chars that must **not** render | `--text` | Preserves literal text; no conversion |
+| Exact `post` JSON, a `post` title, multiple locales, cards (`interactive`), `share_*`, or unsupported structures | `--content` | You provide the final JSON; it must match the effective `--msg-type` |
+| Image / file / video / audio | `--image` / `--file` / `--video` / `--audio` | Uploads URLs or cwd-relative local files automatically |
-### Image Constraint for `--markdown`
+These content flags (and the media flags) are **mutually exclusive** — pass exactly one. Media flags are also mutually exclusive with each other.
-When using `--markdown` with images, prefer pre-uploading via `images.create` and referencing `![alt](img_xxx)` for predictable results. Remote URLs may work but are not guaranteed.
+## `--markdown` Gotchas
-**Steps:**
+`--markdown` always forces `msg_type=post` (single `zh_cn` locale) and normalizes input for Feishu post rendering. Key boundaries (not full CommonMark/GFM):
-```bash
-# 1. Upload image to get image_key
-lark-cli im images create --data '{"image_type":"message"}' --file ./diagram.png
-# Returns: {"image_key":"img_v3_xxxx"}
-
-# 2. Use image_key in --markdown
-lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Report\n\n![diagram](img_v3_xxxx)\n\nSee above for details.'
-```
+- **No `post` title** — if you need one, use `--content` with `post` JSON.
+- **Headings rewritten**: `# Title` → `#### Title`; `##``######` normalized to `#####` when content has H1H3. Code blocks preserved; excess blank lines compressed.
+- **Images**: pre-upload via `im images create` and reference `![alt](img_xxx)` for reliable results. Remote `https://` URLs are auto-downloaded+uploaded at runtime (removed with a warning if that fails). Local paths in `![x](./a.png)` are **not** supported and will not auto-upload.
-## Preserving Formatting
+## Preserving Exact Formatting
-If the message has multiple lines, indentation, code blocks, tabs, or many quotes/backslashes, prefer shell ANSI-C quoting with `$'...'` for either `--markdown` or `--text`.
-
-This is especially useful in `zsh` / `bash` because it lets you write `\n` explicitly instead of relying on the shell to preserve literal newlines.
-
-### When formatting must be preserved
-
-Use `--text` plus `$'...'`:
-
-```bash
-lark-cli im +messages-send --chat-id oc_xxx --text $'Build failed\nBranch: feature/im-docs\nAction: please check logs'
-```
+For multi-line text, indentation, code blocks, tabs, or many backslashes/quotes, use shell ANSI-C quoting `$'...'` so `\n` is written explicitly. Use `--text` + `$'...'` when the receiver must see the text exactly as entered:
```bash
-lark-cli im +messages-send --chat-id oc_xxx --text $'```bash\nmake test\nmake lint\n```'
+lark-cli im +messages-send --chat-id oc_xxx --text $'Build failed\nBranch: feature/x\nAction: check logs'
```
-Use this path when you want the receiver to see the text exactly as entered, not a converted Markdown post.
-
## Commands
```bash
-# Send a formatted update
+# Formatted update (Markdown → post)
lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Update\n\n- item 1\n- item 2'
-# Send a plain one-line message
+# Plain one-line text
lark-cli im +messages-send --chat-id oc_xxx --text "Hello"
-# Equivalent manual JSON
-lark-cli im +messages-send --chat-id oc_xxx --content '{"text":"Hello"}'
-
-# Send to a direct message (pass open_id)
+# Direct message (pass open_id)
lark-cli im +messages-send --user-id ou_xxx --text "Hello"
-# Send multi-line text while preserving formatting
-lark-cli im +messages-send --chat-id oc_xxx --text $'Line 1\nLine 2\n indented line'
-
-# Send Markdown with an image (must pre-upload via images.create)
-lark-cli im images create --data '{"image_type":"message"}' --file ./screenshot.png
-# Use the returned image_key in the markdown content
-lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Status\n\n![screenshot](img_v3_xxxx)\n\nDone.'
-
-# If you need exact post structure, send JSON directly
+# Exact post structure with a title
lark-cli im +messages-send --chat-id oc_xxx --msg-type post --content '{"zh_cn":{"title":"Title","content":[[{"tag":"text","text":"Body"}]]}}'
-# Send a local image (uploaded automatically before sending)
-lark-cli im +messages-send --chat-id oc_xxx --image ./photo.png
-
-# Or send directly with an existing image_key
-lark-cli im +messages-send --chat-id oc_xxx --image img_xxx
+# Markdown with an image (pre-upload first)
+lark-cli im images create --data '{"image_type":"message"}' --file ./diagram.png # -> {"image_key":"img_v3_xxxx"}
+lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Report\n\n![diagram](img_v3_xxxx)\n\nDone.'
-# Send a local file (uploaded automatically before sending)
+# Media (local files uploaded automatically; --video requires --video-cover)
+lark-cli im +messages-send --chat-id oc_xxx --image ./photo.png
lark-cli im +messages-send --chat-id oc_xxx --file ./report.pdf
-
-# Send a video (--video-cover is required as the cover)
lark-cli im +messages-send --chat-id oc_xxx --video ./demo.mp4 --video-cover ./cover.png
-lark-cli im +messages-send --chat-id oc_xxx --video ./demo.mp4 --video-cover img_xxx
-
-# Send audio
lark-cli im +messages-send --chat-id oc_xxx --audio ./voice.opus
-# Use an idempotency key (same key sends only once within 1 hour)
-lark-cli im +messages-send --chat-id oc_xxx --text "Hello" --idempotency-key my-unique-id
-
-# Preview the request without executing it
-lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry-run
+# Idempotency (same key sends only once within 1 hour) / preview without sending
+lark-cli im +messages-send --chat-id oc_xxx --text "Hi" --idempotency-key my-id
+lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhi' --dry-run
```
-## Media Input Rules
-
-- Media flags accept an existing key (`img_xxx` / `file_xxx`), an `http://` or `https://` URL, or a local file path.
-- Local paths must be relative to the current working directory and stay within it after resolving `..` and symlinks.
-- Absolute paths such as `/tmp/photo.png` are rejected. Run the command from the file's directory and pass `./photo.png`, or copy the file into the current directory first.
-
-## Parameters
-
-| Parameter | Required | Description |
-|------|------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `--chat-id <id>` | One of two | Group chat ID (`oc_xxx`) |
-| `--user-id <id>` | One of two | User open_id (`ou_xxx`) for direct messages |
-| `--text <string>` | One content option | Plain text message. Use when exact text and formatting preservation matter. Automatically wrapped as `{"text":"..."}` |
-| `--markdown <string>` | One content option | Best default for lightweight formatted messages such as headings, lists, links, summaries, and reports. Internally converted to `post` JSON with Feishu-specific normalization |
-| `--content <json>` | One content option | Exact message content JSON string; use this when you need full control over `msg_type` and payload. The JSON must match the effective `--msg-type` |
-| `--image <path\|url\|key>` | One content option | Cwd-relative local image path, URL, or `image_key` (`img_xxx`). Local paths and URLs are uploaded automatically |
-| `--file <path\|url\|key>` | One content option | Cwd-relative local file path, URL, or `file_key` (`file_xxx`). Local paths and URLs are uploaded automatically |
-| `--video <path\|url\|key>` | One content option | Cwd-relative local video path, URL, or `file_key` (`file_xxx`). Local paths and URLs are uploaded automatically. **Must be paired with `--video-cover`** |
-| `--video-cover <path\|url\|key>` | **Required with `--video`** | Cwd-relative local cover image path, URL, or `image_key` (`img_xxx`). Local paths and URLs are uploaded automatically |
-| `--audio <path\|url\|key>` | One content option | Cwd-relative local audio path, URL, or `file_key` (`file_xxx`). Local paths and URLs are uploaded automatically |
-| `--msg-type <type>` | No | Message type (default `text`). If you use `--text` / `--markdown` / media flags, the effective type is inferred automatically. Explicitly setting a conflicting `--msg-type` fails validation |
-| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one message within 1 hour |
-| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |
-| `--dry-run` | No | Print the request only, do not execute it |
-
-> **Mutual exclusivity rule:** `--text`, `--markdown`, `--content`, and `--image`/`--file`/`--video`/`--audio` cannot be used together. Media flags are also mutually exclusive with each other.
->
-> **Video cover rule:** `--video` **must** be accompanied by `--video-cover`. Omitting `--video-cover` when using `--video` will fail validation. `--video-cover` cannot be used without `--video`.
-
-## Common Mistakes
-
-- Choosing `--text` for headings, lists, links, summaries, or reports. Use `--markdown`.
-- Choosing `--markdown` when you actually need exact plain text. If exact line breaks, spacing, logs, code, or literal Markdown characters matter, use `--text`, usually with `$'...'`.
-- Assuming `--markdown` supports every Markdown feature. It is converted into a Feishu `post` payload and normalized first.
-- Putting local image paths inside Markdown like `![x](./a.png)`. `--markdown` does not auto-upload those paths.
-- **Using local file paths inside Markdown image syntax** (e.g. `![x](./a.png)`) with `--markdown`. Local paths are not auto-uploaded and will not render as an image. Pre-upload via `images.create` to get an `image_key` instead.
-- Using `--content` without making the JSON match the effective `--msg-type`.
-- Explicitly setting `--msg-type` to something that conflicts with `--text`, `--markdown`, or media flags.
-- Mixing `--text`, `--markdown`, or `--content` with media flags in one command.
-
-## `content` Format Reference
+Run `lark-cli im +messages-send --help` for the full flag list and types. Load-bearing rules that `--help` may not make obvious:
+
+- **Media paths** accept an existing key (`img_xxx`/`file_xxx`), an `http(s)://` URL, or a **cwd-relative** local path. Absolute paths (e.g. `/tmp/x.png`) are rejected — run from the file's directory and pass `./x.png`. Upload and send use the same identity.
+- **`--video` must be paired with `--video-cover`** (image key/URL/local path); `--video-cover` cannot be used alone.
+- **`--msg-type`** is inferred from `--text`/`--markdown`/media flags; explicitly setting a conflicting type fails validation.
+
+## `content` Format Reference (for `--content`)
| `msg_type` | Example `content` |
-|----------|-------------|
+|---|---|
| `text` | `{"text":"Hello <at user_id=\"ou_xxx\">name</at>"}` |
| `post` | `{"zh_cn":{"title":"Title","content":[[{"tag":"text","text":"Body"}]]}}` |
-| `image` | `{"image_key":"img_xxx"}` |
-| `file` | `{"file_key":"file_xxx"}` |
-| `audio` | `{"file_key":"file_xxx"}` |
-| `media` | `{"file_key":"file_xxx","image_key":"img_xxx"}` (video; `image_key` is the cover from `--video-cover` — **required**) |
-| `share_chat` | `{"chat_id":"oc_xxx"}` |
-| `share_user` | `{"user_id":"ou_xxx"}` |
-| `interactive` | Card JSON (see Feishu interactive card documentation) |
+| `image` / `file` / `audio` | `{"image_key":"img_xxx"}` / `{"file_key":"file_xxx"}` / `{"file_key":"file_xxx"}` |
+| `media` (video) | `{"file_key":"file_xxx","image_key":"img_xxx"}` (`image_key` is the **required** cover) |
+| `share_chat` / `share_user` | `{"chat_id":"oc_xxx"}` / `{"user_id":"ou_xxx"}` |
+| `interactive` (card) | Card JSON (see Feishu interactive card docs) |
-## Return Value
-
-```json
-{
- "message_id": "om_xxx",
- "chat_id": "oc_xxx",
- "create_time": "1234567890"
-}
-```
+When using `--content`, you are responsible for making the JSON match the effective `msg_type`.
## @Mention Format
-The `<at>` syntax differs by message type. The shortcut only normalizes mentions for `text` and `post`; `interactive` card content is passed through verbatim, so cards must use the card-native syntax below.
-
-### `text`
-
-- `<at user_id="ou_xxx">name</at>` — the inner text is the mentioned user's display name and is optional (`<at user_id="ou_xxx"></at>` also works)
-- @all: `<at user_id="all"></at>`
-
-### `post`
+The `<at>` syntax differs by message type; the shortcut normalizes mentions for `text` and `post` only — `interactive` cards are passed through verbatim.
-- Inside a `text` or `md` element, the same inline form as `text` works: `<at user_id="ou_xxx">name</at>`
-- Or use a dedicated `at` element node: `{"tag":"at","user_id":"ou_xxx"}` (use `"all"` to mention everyone)
+- **`text`** / inside a `post` `text`/`md` element: `<at user_id="ou_xxx">name</at>` (inner name optional); @all: `<at user_id="all"></at>`. In `post` you may also use a node: `{"tag":"at","user_id":"ou_xxx"}` (`"all"` for everyone).
+- **`interactive` (card)** — card-native syntax inside a `lark_md`/`markdown` element: `<at id=ou_xxx></at>`, multiple `<at ids=ou_1,ou_2></at>`, by email `<at email=user@example.com></at>`.
-### `interactive` (card)
-
-Card content is **not** normalized — use the card-native `<at>` syntax inside a `lark_md` / `markdown` element:
-
-- single user by open_id: `<at id=ou_xxx></at>`
-- multiple users: `<at ids=ou_xxx1,ou_xxx2></at>`
-- by email: `<at email=user@example.com></at>`
-
-## Notes
+## Return Value
-- `--chat-id` and `--user-id` are mutually exclusive; you must provide exactly one
-- `--content` must be valid JSON
-- When using `--content`, you are responsible for making the JSON structure match the effective `msg_type`
-- `--image`/`--file`/`--video`/`--audio` support existing keys, URLs, and cwd-relative local file paths; the shortcut uploads local paths and URLs first, then sends the message; both the upload and send steps use the same identity (UAT when `--as user`, TAT when `--as bot`)
-- If the provided media value starts with `img_` or `file_`, it is treated as an existing key and used directly
-- `--markdown` always sends `msg_type=post`, even if you do not explicitly set `--msg-type post`
-- If you explicitly set `--msg-type` and it conflicts with the chosen content flag, validation fails
-- When using `--video`, `--video-cover` is required as the video cover
-- `--dry-run` uses placeholder image keys for remote Markdown images and placeholder media keys for local uploads
-- Failures return an error code and message
-- `--as user` uses a user access token (UAT) and requires the `im:message.send_as_user` and `im:message` scopes; the message is sent as the authorized end user
-- `--as bot` uses a tenant access token (TAT) and requires the `im:message:send_as_bot` scope
-- When sending as a bot, the app must already be in the target group or already have a direct-message relationship with the target user
-- When using `--markdown` with images, pre-uploading via `images.create` to obtain an `image_key` is recommended for reliability; remote URLs may be auto-resolved at runtime, but if download/upload fails the image is removed with a warning; local paths are not supported
+```json
+{"message_id": "om_xxx", "chat_id": "oc_xxx", "create_time": "1234567890"}
+```
--
2.50.1 (Apple Git-155)

View File

@@ -1,135 +0,0 @@
From cbd6e56ac07285fd973c53ff7382da0112b6cf5d Mon Sep 17 00:00:00 2001
From: "zhangheng.023" <zhangheng.023@bytedance.com>
Date: Tue, 23 Jun 2026 19:51:49 +0800
Subject: [PATCH] =?UTF-8?q?opt(round-003):=20references/lark-im-chat-creat?=
=?UTF-8?q?e.md=20=E2=80=94=20dedup=20Commands/Scenarios=20overlap=20+=20c?=
=?UTF-8?q?ompress=20--help-mirroring=20Common=20Errors=20into=20pointers,?=
=?UTF-8?q?=20keep=20232043=20two-step=20flow=20&=20all=20guardrails?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../lark-im/references/lark-im-chat-create.md | 78 +++++--------------
1 file changed, 18 insertions(+), 60 deletions(-)
diff --git a/skills/lark-im/references/lark-im-chat-create.md b/skills/lark-im/references/lark-im-chat-create.md
index 76716f76..7d65e5d3 100644
--- a/skills/lark-im/references/lark-im-chat-create.md
+++ b/skills/lark-im/references/lark-im-chat-create.md
@@ -12,43 +12,24 @@ This skill maps to the shortcut: `lark-cli im +chat-create` (internally calls `P
## Commands
```bash
-# Create a private group (default)
+# Private group (default)
lark-cli im +chat-create --name "My Group"
-# Create a public group (name is required and must be at least 2 characters)
+# Public group (--name required, min 2 chars)
lark-cli im +chat-create --name "Public Group" --type public
-# Create a topic chat
+# Topic chat (a 话题群; see note under Parameters)
lark-cli im +chat-create --name "Topic Group" --chat-mode topic
-# Specify the group owner
-lark-cli im +chat-create --name "My Group" --owner ou_xxx
+# Invite members and set owner (users: up to 50 ou_xxx; bots: up to 5 cli_xxx)
+lark-cli im +chat-create --name "My Group" --owner ou_xxx --users "ou_aaa,ou_bbb" --bots "cli_aaa"
-# Invite user members (comma-separated open_ids, up to 50)
-lark-cli im +chat-create --name "My Group" --users "ou_aaa,ou_bbb"
-
-# Invite bot members (comma-separated app IDs, up to 5)
-lark-cli im +chat-create --name "My Group" --bots "cli_aaa,cli_bbb"
-
-# Invite both users and bots
-lark-cli im +chat-create --name "My Group" --users "ou_aaa" --bots "cli_aaa"
-
-# Make the creating bot a group manager (bot identity only)
-lark-cli im +chat-create --name "My Group" --set-bot-manager --as bot
-
-# JSON output
-lark-cli im +chat-create --name "My Group" --format json
-
-# Create a group with bot identity
-lark-cli im +chat-create --name "My Group" --users "ou_aaa" --as bot
-
-# Create a group with user identity
-lark-cli im +chat-create --name "My Group" --users "ou_aaa,ou_bbb" --as user
-
-# Preview the request without creating anything
-lark-cli im +chat-create --name "My Group" --dry-run
+# Bot identity, making the creating bot a manager
+lark-cli im +chat-create --name "My Group" --users "ou_aaa" --as bot --set-bot-manager
```
+Run `lark-cli im +chat-create --help` for the full flag list, limits, and types. Single-flag variations (`--as user`, `--description`, `--format json`, `--dry-run` preview, etc.) follow the Parameters table below — `--dry-run` previews the request without creating anything.
+
## Parameters
| Parameter | Required | Limits | Description |
@@ -106,6 +87,13 @@ lark-cli im +chat-create --name "<group name>" --users "ou_aaa,ou_bbb" --as user
The authorized user is automatically the group creator and member.
+### Create a group, then send a welcome message
+
+```bash
+CHAT_ID=$(lark-cli im +chat-create --name "New Group" --format json | jq -r '.data.chat_id')
+lark-cli im +messages-send --chat-id "$CHAT_ID" --text "Welcome, everyone!"
+```
+
## Output Fields
| Field | Description |
@@ -117,43 +105,13 @@ The authorized user is automatically the group creator and member.
| `external` | Whether the group is external |
| `share_link` | Group share link (omitted if retrieval fails) |
-## Usage Scenarios
-
-### Scenario 1: Create a group and specify the owner
-
-```bash
-lark-cli im +chat-create --name "Project Discussion Group" --owner ou_xxx
-```
-
-### Scenario 2: Create a group and invite users and a bot
-
-```bash
-lark-cli im +chat-create --name "Project Discussion Group" \
- --owner ou_xxx \
- --users "ou_aaa,ou_bbb" \
- --bots "cli_aaa"
-```
-
-### Scenario 3: Create a group and send a welcome message
-
-```bash
-CHAT_ID=$(lark-cli im +chat-create --name "New Group" --format json | jq -r '.data.chat_id')
-lark-cli im +messages-send --chat-id "$CHAT_ID" --text "Welcome, everyone!"
-```
-
## Common Errors and Troubleshooting
+Format/limit validation (`--name`/`--description`/`--users`/`--bots`/`--owner` length, count, and `ou_xxx`/`cli_xxx` format) is enforced by the CLI and reported verbatim with the fix — see the Parameters table for limits. The two errors needing extra action:
+
| Symptom | Root Cause | Solution |
|---------|---------|---------|
| Permission denied (99991672) | The app does not have `im:chat:create` (bot) or `im:chat:create_by_user` (user) permission enabled | Enable the required permission for the app in the Open Platform console |
-| `--name is required for public groups and must be at least 2 characters` | A public group was created without a name or with a name shorter than 2 characters | Provide a name with at least 2 characters |
-| `--name exceeds the maximum of 60 characters` | The group name is too long | Shorten the name to 60 characters or fewer |
-| `--description exceeds the maximum of 100 characters` | The group description is too long | Shorten the description to 100 characters or fewer |
-| `--users exceeds the maximum of 50` | Too many user members were provided | Split the operation into batches and add more members later |
-| `--bots exceeds the maximum of 5` | Too many bot members were provided | Invite at most 5 bots at once |
-| `invalid user id: expected open_id (ou_xxx)` | Invalid user ID format | Use the `ou_xxx` format for users |
-| `invalid bot id: expected app ID (cli_xxx)` | Invalid bot ID format | Use the `cli_xxx` format for bots |
-| `invalid --owner: expected open_id (ou_xxx)` | Invalid owner ID format | Use the `ou_xxx` format for the owner |
| `bot is invisible to user` (232043) | The bot and target users are mutually invisible | Follow the two-step flow in AI Usage Guidance above — do not pass other users in `--users` during creation |
## References
--
2.50.1 (Apple Git-155)

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.56",
"version": "1.0.57",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -179,7 +179,10 @@ fi
require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
require_in_step "$summary_verify_step" 'eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "PR quality summary should tolerate mutable workflow_run PR head metadata"
require_in_step "$summary_verify_step" 'factsArtifactPattern' "PR quality summary should use the base-bound facts artifact name when available"
require_in_step "$summary_verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "PR quality summary must prefer the CI-time artifact base SHA"
require_in_step "$summary_verify_step" 'core.setOutput("artifact_error"' "PR quality summary must expose artifact binding failures"
require_in_step "$summary_artifact_step" 'factsArtifactName' "PR quality summary artifact step must use the verified facts artifact binding"
require_in_step "$summary_extract_facts_step" 'SEMANTIC_REVIEW_DECISION_OUT' "PR quality summary artifact verifier must write an infrastructure decision on verifier failure"
@@ -198,8 +201,9 @@ require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "se
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"
require_in_step "$verify_step" 'const eventHeadSha = runPRs[0]?.head?.sha || ""' "semantic-review must prefer workflow_run PR head when GitHub provides it"
require_in_step "$verify_step" 'const targetHeadSha = eventHeadSha || run.head_sha' "semantic-review target PR head must come from the workflow_run event"
require_in_step "$verify_step" 'const eventHeadSha = runPRs[0]?.head?.sha || ""' "semantic-review should inspect workflow_run PR head metadata"
require_in_step "$verify_step" 'const targetHeadSha = run.head_sha' "semantic-review target PR head must come from the completed CI run"
require_in_step "$verify_step" 'eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR head metadata"
require_in_step "$verify_step" 'factsArtifactPattern' "semantic-review must use a base-bound facts artifact name"
require_in_step "$verify_step" 'listWorkflowRunArtifacts' "semantic-review must read the workflow_run artifacts before resolving fallback base SHA"
require_in_step "$verify_step" 'artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "semantic-review must not let the artifact choose a different PR head"
@@ -210,8 +214,8 @@ require_in_step "$verify_step" 'commit_sha: targetHeadSha' "semantic-review fall
require_in_step "$verify_step" 'github.rest.pulls.list' "semantic-review must have a pull-list fallback when commit association is empty"
require_in_step "$verify_step" 'candidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous"
require_in_step "$verify_step" 'pr.head.sha !== targetHeadSha' "semantic-review must skip stale PR heads"
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review must reject mismatched event and artifact base SHAs"
require_in_step "$verify_step" 'const baseSha = eventBaseSha || artifactBaseSha' "semantic-review fallback must use the CI-time artifact base SHA"
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR base metadata"
require_in_step "$verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "semantic-review must prefer the CI-time artifact base SHA"
require_in_step "$verify_step" 'pr.base.sha !== baseSha' "semantic-review must skip stale PR bases"
require_in_step "$verify_step" 'core.setOutput("run_id"' "semantic-review must pass verified workflow run id to publisher"
require_in_step "$verify_step" 'core.setOutput("head_repo_id"' "semantic-review must pass verified head repo id"

View File

@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"io"
"path/filepath"
"strings"
"github.com/larksuite/cli/extension/fileio"
@@ -84,6 +85,9 @@ var AppsHTMLPublish = common.Shortcut{
// for dry-run "advisory preview" semantics).
dry.Set("validation_error", err.Error())
}
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
dry.Set("oversize_html", hits)
}
dry.Set("file_count", len(candidates))
var totalSize int64
names := make([]string, 0, len(candidates))
@@ -140,18 +144,22 @@ type appsHTMLPublishSpec struct {
// per-environment .env.* files for every stage).
const maxSensitiveListInError = 5
// truncatedJoin joins items with ", ", capping at max entries and appending
// "(and N more)" for the remainder, so an inline error list stays readable when
// a payload has many hits.
func truncatedJoin(items []string, max int) string {
if len(items) <= max {
return strings.Join(items, ", ")
}
return strings.Join(items[:max], ", ") + fmt.Sprintf(" (and %d more)", len(items)-max)
}
// sensitiveCandidatesError builds the Validate-time rejection when --path
// contains credential files and --allow-sensitive was not set.
func sensitiveCandidatesError(hits []string) error {
var sample string
if len(hits) <= maxSensitiveListInError {
sample = strings.Join(hits, ", ")
} else {
sample = strings.Join(hits[:maxSensitiveListInError], ", ") +
fmt.Sprintf(" (and %d more)", len(hits)-maxSensitiveListInError)
}
return appsValidationParamError("--path",
"--path contains %d credential file(s) that should not be published: %s", len(hits), sample).
"--path contains %d credential file(s) that should not be published: %s",
len(hits), truncatedJoin(hits, maxSensitiveListInError)).
WithHint("remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)")
}
@@ -168,6 +176,30 @@ var maxHTMLPublishTarballBytes int64 = 20 * 1024 * 1024
// Mutable for tests.
var maxHTMLPublishRawBytes int64 = 200 * 1024 * 1024
// maxHTMLPublishSingleHTMLFileBytes 单个 .html 文件上限,对齐妙搭服务端 10MB 约束。
// 用 var 而非 const便于单测调小覆盖拦截路径。
var maxHTMLPublishSingleHTMLFileBytes int64 = 10 * 1024 * 1024
// oversizeHTMLFiles 返回 candidates 中扩展名为 .html大小写不敏感且单个 Size 超过
// maxHTMLPublishSingleHTMLFileBytes 的 RelPath 列表。只针对 .html 文件,不波及图片/字体/JS。
func oversizeHTMLFiles(candidates []htmlPublishCandidate) []string {
var hits []string
for _, c := range candidates {
if strings.EqualFold(filepath.Ext(c.RelPath), ".html") && c.Size > maxHTMLPublishSingleHTMLFileBytes {
hits = append(hits, c.RelPath)
}
}
return hits
}
// oversizeHTMLFilesError 构造单文件超限的 Validate 风格拒绝。
func oversizeHTMLFilesError(hits []string) error {
return appsValidationParamError("--path",
"--path contains %d HTML file(s) exceeding the %d bytes (10MB) per-file limit: %s",
len(hits), maxHTMLPublishSingleHTMLFileBytes, truncatedJoin(hits, maxSensitiveListInError)).
WithHint("split or trim oversized HTML file(s); the 10MB cap applies to each single .html file")
}
// ensureIndexHTML 要求 walker 抓到的 candidates 里必须含 index.html。
// 目录形态:根目录下必须有 index.html。
// 单文件形态:文件名必须就是 index.html。
@@ -190,6 +222,9 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPu
if err := ensureIndexHTML(candidates); err != nil {
return nil, err
}
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
return nil, oversizeHTMLFilesError(hits)
}
var rawTotal int64
for _, c := range candidates {
rawTotal += c.Size

View File

@@ -503,3 +503,82 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
t.Fatalf("client must not be called when raw cap hit")
}
}
func TestOversizeHTMLFiles(t *testing.T) {
orig := maxHTMLPublishSingleHTMLFileBytes
maxHTMLPublishSingleHTMLFileBytes = 100
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
cands := []htmlPublishCandidate{
{RelPath: "index.html", Size: 50},
{RelPath: "big.html", Size: 4096},
{RelPath: "BIG.HTML", Size: 4096}, // 大小写不敏感
{RelPath: "huge.png", Size: 9000}, // 非 .html忽略
}
hits := oversizeHTMLFiles(cands)
if len(hits) != 2 {
t.Fatalf("hits=%v, want [big.html BIG.HTML]", hits)
}
for _, h := range hits {
if h == "huge.png" || h == "index.html" {
t.Fatalf("unexpected hit %q", h)
}
}
}
func TestMaxHTMLPublishSingleHTMLFileBytes_Default(t *testing.T) {
if maxHTMLPublishSingleHTMLFileBytes != 10*1024*1024 {
t.Fatalf("default=%d, want %d (10MiB)", maxHTMLPublishSingleHTMLFileBytes, 10*1024*1024)
}
}
func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
orig := maxHTMLPublishSingleHTMLFileBytes
maxHTMLPublishSingleHTMLFileBytes = 100
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
if err == nil {
t.Fatalf("expected per-file oversize error")
}
problem := requireAppsValidationProblem(t, err)
if !strings.Contains(problem.Message, "big.html") || !strings.Contains(problem.Message, "10MB") {
t.Fatalf("message=%q, want contains 'big.html' and '10MB'", problem.Message)
}
if problem.Hint == "" {
t.Fatalf("expected non-empty hint")
}
if len(fake.calls) != 0 {
t.Fatalf("client must not be called when an HTML file is oversize")
}
}
func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
// 单 .html 上限调小,但超限文件是 .png → 不被本护栏拦截,正常发布。
orig := maxHTMLPublishSingleHTMLFileBytes
maxHTMLPublishSingleHTMLFileBytes = 100
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "big.png"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
t.Fatalf("non-html oversize must not be blocked by the .html cap: %v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("client should be called; calls=%v", fake.calls)
}
}

View File

@@ -99,7 +99,14 @@ var AppsInit = common.Shortcut{
dry.Set("dir_error", err.Error())
dir = defaultCloneDir(appID)
} else if isAlreadyInitialized(dir) {
dry.Set("already_initialized", true)
if existing, e := ensureInitDirMatchesApp(dir, appID); e != nil {
if existing != "" {
dry.Set("app_id_mismatch", existing)
}
dry.Set("dir_error", e.Error())
} else {
dry.Set("already_initialized", true)
}
} else if e := ensureEmptyDir(dir); e != nil {
dry.Set("dir_error", e.Error())
}
@@ -199,6 +206,61 @@ func isAlreadyInitialized(dir string) bool {
return err == nil && !info.IsDir()
}
// readMetaAppID 读取 <dir>/.spark/meta.json 的 app_id用于判断目标目录是否同一个妙搭应用。
// 返回 (appID, isSparkProject, err)
// - meta.json 不存在 → ("", false, nil) 非妙搭工程
// - 读取/解析失败(损坏/不可读) → ("", false, err) 无法确认是否妙搭工程
// - 解析成功 → (trim 后的 app_id, true, nil)app_id 缺失/为空时为 ""
func readMetaAppID(dir string) (string, bool, error) {
b, err := os.ReadFile(filepath.Join(dir, metaRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Open rejects absolute paths.
if os.IsNotExist(err) {
return "", false, nil
}
if err != nil {
return "", false, appsFileIOError(err, "read %s failed: %v", metaRelPath, err)
}
var m struct {
AppID string `json:"app_id"`
}
if err := json.Unmarshal(b, &m); err != nil {
return "", false, appsFileIOError(err, "parse %s failed: %v", metaRelPath, err)
}
return strings.TrimSpace(m.AppID), true, nil
}
// ensureInitDirMatchesApp 校验「已存在的目标目录」能否被 appID 安全复用:
// - 不是妙搭工程(无 meta.json → nil交给 ensureEmptyDir 判空/非空)
// - 是妙搭工程且 app_id 与 appID 一致 → nil走已初始化短路复用本地代码
// - 是妙搭工程但 app_id 不一致(含为空) → 报错,提示换目录
// - meta.json 损坏/不可读,无法确认 → 报错fail closed提示换目录
//
// 返回值 existing 是目录里已存在的 app_id仅"已是另一个 app"的拒绝场景非空),供调用方在
// dry-run 里回填 app_id_mismatch避免二次读 meta.json。
func ensureInitDirMatchesApp(dir, appID string) (existing string, err error) {
existing, isSpark, readErr := readMetaAppID(dir)
if readErr != nil {
return "", appsValidationParamError("--dir",
"target directory %q already exists but its %s is unreadable or corrupted; cannot confirm it belongs to app %s, refusing to use it",
dir, metaRelPath, appID).
WithHint("choose a different --dir, or repair/remove the directory, before running +init").
WithCause(readErr)
}
if !isSpark || existing == appID {
return existing, nil
}
if existing == "" {
// meta 存在但缺 app_id更可能是同一应用上次 +init 中断留下的半成品,而非另一个 app。
return "", appsValidationParamError("--dir",
"target directory %q has a %s without an app_id; cannot confirm it belongs to app %s, refusing to use it",
dir, metaRelPath, appID).
WithHint("remove the directory and re-run +init, or choose a different --dir")
}
return existing, appsValidationParamError("--dir",
"target directory %q is already initialized for a different app (%s); refusing to initialize app %s into it",
dir, existing, appID).
WithHint("choose a different --dir (or cd into the matching project) before running +init")
}
// ensureMetaAppID patches <dir>/.spark/meta.json to include app_id when the file
// exists but lacks (or has an empty) app_id. Other fields are preserved. When
// the file does not exist, this is a no-op (we never create it).
@@ -378,6 +440,11 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
return err
}
// 异 app 目录护栏:拒绝把当前 app 初始化进另一个 app 的已初始化工程。
if _, err := ensureInitDirMatchesApp(dir, appID); err != nil {
return err
}
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
// initialized app repo -> skip clone/scaffold/commit, but still refresh
// the local env so a re-run picks up the latest startup env vars.

View File

@@ -363,7 +363,7 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"whatever"}`), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_x"}`), 0o644); err != nil {
t.Fatal(err)
}
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(filepath.Join(abs, ".env.local"))}}
@@ -394,6 +394,40 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
}
}
func TestAppsInit_AlreadyInitialized_AppIDMismatch(t *testing.T) {
dir := relCloneDir(t)
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
// 目录是 app_other 的工程,却用 --app-id app_x 初始化 → 必须报错且不拉 env。
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_other"}`), 0o644); err != nil {
t.Fatal(err)
}
f := &fakeCommandRunner{}
withFakeRunner(t, f)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
if err == nil {
t.Fatal("mismatched app_id must error")
}
problem := requireAppsValidationProblem(t, err)
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--dir" {
t.Fatalf("expected *errs.ValidationError with Param=--dir, got %T param=%v", err, ve)
}
if !strings.Contains(problem.Message, "different app") {
t.Fatalf("message=%q, want 'different app'", problem.Message)
}
for _, c := range f.calls {
if containsAll(c, "+env-pull") || containsAll(c, "git", "clone") {
t.Errorf("mismatch must not run env-pull/clone; got %v", f.calls)
}
}
}
func TestAppsInit_HappyPathCleanTree(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{
"credential-init": credInitOK("http://u:t@h/app_x.git"),
@@ -1468,6 +1502,125 @@ func TestAppsInit_Description_IsAboutCode(t *testing.T) {
}
}
func TestReadMetaAppID(t *testing.T) {
writeMeta := func(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return dir
}
// 不存在 meta.json → ("", false, nil)
if got, ok, err := readMetaAppID(t.TempDir()); ok || got != "" || err != nil {
t.Fatalf("no meta: got (%q,%v,%v), want (\"\",false,nil)", got, ok, err)
}
// 存在且有 app_id → (app_id, true, nil)
if got, ok, err := readMetaAppID(writeMeta(t, `{"app_id":"app_a"}`)); !ok || got != "app_a" || err != nil {
t.Fatalf("with app_id: got (%q,%v,%v), want (\"app_a\",true,nil)", got, ok, err)
}
// 存在但 app_id 空 → ("", true, nil)
if got, ok, err := readMetaAppID(writeMeta(t, `{"name":"x"}`)); !ok || got != "" || err != nil {
t.Fatalf("empty app_id: got (%q,%v,%v), want (\"\",true,nil)", got, ok, err)
}
// 存在但坏 JSON → ("", false, err)(无法确认)
if got, ok, err := readMetaAppID(writeMeta(t, `{not json`)); ok || got != "" || err == nil {
t.Fatalf("bad json: got (%q,%v,err=%v), want (\"\",false,non-nil)", got, ok, err)
}
}
func TestEnsureInitDirMatchesApp(t *testing.T) {
writeMeta := func(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return dir
}
// 无 meta非妙搭工程→ nil交给 ensureEmptyDir
if _, err := ensureInitDirMatchesApp(t.TempDir(), "app_x"); err != nil {
t.Fatalf("no meta should pass: %v", err)
}
// 同 app_id → (app_id, nil)(走已初始化短路)
if existing, err := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_x"}`), "app_x"); err != nil || existing != "app_x" {
t.Fatalf("same app should pass: existing=%q err=%v", existing, err)
}
// 不同 app_id → error换目录返回 existing=app_other断言 typed metadatasubtype/param
existing, errMismatch := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_other"}`), "app_x")
if errMismatch == nil {
t.Fatal("different app should error")
}
if existing != "app_other" {
t.Fatalf("mismatch should return existing app_id, got %q", existing)
}
problem := requireAppsValidationProblem(t, errMismatch) // 已校验 Category==Validation
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
var ve *errs.ValidationError
if !errors.As(errMismatch, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", errMismatch)
}
if ve.Param != "--dir" {
t.Fatalf("param=%q, want --dir", ve.Param)
}
if !strings.Contains(problem.Message, "different app") || !strings.Contains(problem.Message, "app_other") {
t.Fatalf("message=%q, want 'different app' and 'app_other'", problem.Message)
}
if !strings.Contains(problem.Hint, "different --dir") {
t.Fatalf("hint=%q, want 'different --dir'", problem.Hint)
}
// 空 app_id缺 app_id 标记的半成品)→ error独立文案非 "different app"),返回 existing=""
emptyExisting, errEmpty := ensureInitDirMatchesApp(writeMeta(t, `{"name":"x"}`), "app_x")
if errEmpty == nil {
t.Fatal("empty meta app_id should error (cannot confirm same app)")
}
if emptyExisting != "" {
t.Fatalf("empty app_id should return existing=\"\", got %q", emptyExisting)
}
pEmpty := requireAppsValidationProblem(t, errEmpty)
if pEmpty.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("empty subtype=%q, want %q", pEmpty.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(pEmpty.Message, "without an app_id") {
t.Fatalf("empty app_id should have its own message, msg=%q", pEmpty.Message)
}
if strings.Contains(pEmpty.Message, "different app") {
t.Fatalf("empty app_id must not reuse the different-app wording, msg=%q", pEmpty.Message)
}
// meta 损坏/不可读 → errorfail closed返回 existing=""
badExisting, errBad := ensureInitDirMatchesApp(writeMeta(t, `{not json`), "app_x")
if errBad == nil {
t.Fatal("corrupted meta should fail closed")
}
if badExisting != "" {
t.Fatalf("corrupted should return existing=\"\", got %q", badExisting)
}
pBad := requireAppsValidationProblem(t, errBad)
if pBad.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("corrupted subtype=%q, want %q", pBad.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(pBad.Message, "unreadable or corrupted") {
t.Fatalf("corrupted meta msg=%q, want 'unreadable or corrupted'", pBad.Message)
}
var veBad *errs.ValidationError
if !errors.As(errBad, &veBad) || veBad.Param != "--dir" {
t.Fatalf("corrupted: expected ValidationError Param=--dir, got %T param=%v", errBad, veBad)
}
}
// TestRunScaffold_SubprocessFailureIsExternalTool pins the typed
// classification of an external-tool failure: a failing git subprocess
// surfaces as internal/external_tool with the cause preserved.

View File

@@ -199,16 +199,7 @@ func TestParseDriveMediaMultipartUploadSessionTypedValidatesResponseFields(t *te
t.Parallel()
_, err := parseDriveMediaMultipartUploadSessionTyped(tt.data)
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
t.Fatalf("err = %v, want substring %q", err, tt.wantText)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %s, want invalid_response", p.Subtype)
}
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse, tt.wantText)
})
}
}

View File

@@ -142,9 +142,7 @@ func TestNormalizeMCPToolResult(t *testing.T) {
got, err := normalizeMCPToolResult(tt.raw)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
requireProblem(t, err, errs.CategoryAPI, errs.SubtypeUnknown, tt.wantErr)
return
}
if err != nil {

View File

@@ -49,8 +49,21 @@ type RuntimeContext struct {
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
larkSDK *lark.Client // eagerly initialized in mountDeclarative
stdinConsumed bool // set when any flag has consumed stdin (`-`); used so out-of-band binary readers (e.g. sheets +table-put --dataframe) can refuse a second stdin consumer instead of racing for an already-empty stream
}
// StdinConsumed reports whether stdin has already been consumed by an Input
// flag's `-` form via resolveInputFlags. Out-of-band binary readers that read
// stdin themselves (currently sheets +table-put / +workbook-create --dataframe)
// must check this before reading — a process has a single stdin, so two
// consumers would race and one would see an empty stream.
func (ctx *RuntimeContext) StdinConsumed() bool { return ctx.stdinConsumed }
// MarkStdinConsumed marks stdin as consumed. Out-of-band binary readers must
// call this after they read stdin so a later Input-flag `-` is rejected cleanly
// instead of racing on an empty stream.
func (ctx *RuntimeContext) MarkStdinConsumed() { ctx.stdinConsumed = true }
// ── Identity ──
// As returns the current identity.
@@ -1029,7 +1042,6 @@ func stripUTF8BOM(s string) string {
// resolveInputFlags resolves @file and - (stdin) for flags with Input sources.
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
stdinUsed := false
for _, fl := range flags {
if len(fl.Input) == 0 {
continue
@@ -1049,11 +1061,15 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
return ValidationErrorf("--%s does not support stdin (-)", fl.Name).
WithParam("--" + fl.Name)
}
if stdinUsed {
// stdinConsumed also covers out-of-band readers like sheets +table-put
// --dataframe (binary, doesn't go through Input). A process has a
// single stdin, so we reject a second consumer regardless of source.
if rctx.stdinConsumed {
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
WithParam("--" + fl.Name)
WithParam("--"+fl.Name).
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others as @file (e.g. --%s @/path/to/file)", fl.Name)
}
stdinUsed = true
rctx.stdinConsumed = true
data, err := io.ReadAll(rctx.IO().In)
if err != nil {
return ValidationErrorf("--%s: failed to read from stdin: %v", fl.Name, err).
@@ -1166,7 +1182,13 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
hints = append(hints, "@file")
}
if slices.Contains(fl.Input, Stdin) {
hints = append(hints, "- for stdin")
// "- reads stdin" intentionally avoids implying each flag has
// its own stdin: a process has a single stdin, so at most one
// flag per call may use "-" (the rest must use @file). The old
// per-flag "- for stdin" wording led AI agents to write
// `--a - <x --b - <y`, where the second `<` silently clobbers
// the first and `--a` reads the wrong payload.
hints = append(hints, "- reads stdin (one flag per call; use @file for others)")
}
desc += " (supports " + strings.Join(hints, ", ") + ")"
}

View File

@@ -22,6 +22,7 @@ func TestRejectPositionalArgs_WithArgs(t *testing.T) {
if err == nil {
t.Fatal("expected error for positional arg, got nil")
}
// rejectPositionalArgs returns a raw fmt.Errorf via cobra's PositionalArgs contract — not a typed envelope, message-substring assertion is intentional.
if !strings.Contains(err.Error(), "positional arguments are not supported") {
t.Errorf("expected positional args rejection message, got: %v", err)
}
@@ -39,6 +40,7 @@ func TestRejectPositionalArgs_MultipleArgs(t *testing.T) {
if err == nil {
t.Fatal("expected error for multiple positional args, got nil")
}
// rejectPositionalArgs returns a raw fmt.Errorf via cobra's PositionalArgs contract — not a typed envelope, message-substring assertion is intentional.
if !strings.Contains(err.Error(), "positional arguments are not supported") {
t.Errorf("unexpected error message: %v", err)
}

View File

@@ -171,6 +171,7 @@ func TestFetchBotInfo_APICodeNonZero(t *testing.T) {
if err == nil {
t.Fatal("expected error for non-zero code")
}
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
if !strings.Contains(err.Error(), "[99991]") {
t.Errorf("error = %q, want substring [99991]", err.Error())
}
@@ -197,6 +198,7 @@ func TestFetchBotInfo_EmptyOpenID(t *testing.T) {
if err == nil {
t.Fatal("expected error for empty open_id")
}
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
if !strings.Contains(err.Error(), "open_id is empty") {
t.Errorf("error = %q, want substring 'open_id is empty'", err.Error())
}
@@ -218,6 +220,7 @@ func TestFetchBotInfo_HTTP4xx(t *testing.T) {
if err == nil {
t.Fatal("expected error for HTTP 403")
}
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
if !strings.Contains(err.Error(), "403") {
t.Errorf("error = %q, want substring '403'", err.Error())
}
@@ -238,7 +241,7 @@ func TestFetchBotInfo_InvalidJSON(t *testing.T) {
if err == nil {
t.Fatal("expected error for invalid JSON")
}
// Error may come from SDK-level parse or our unmarshal wrapper
// Error may come from SDK-level parse or our unmarshal wrapper — both are raw fmt.Errorf, not a typed envelope.
if !strings.Contains(err.Error(), "unmarshal") && !strings.Contains(err.Error(), "invalid character") {
t.Errorf("error = %q, want JSON parse failure", err.Error())
}
@@ -279,6 +282,7 @@ func TestFetchBotInfo_CanBotFalse(t *testing.T) {
if info != nil {
t.Errorf("expected nil info, got %+v", info)
}
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
if !strings.Contains(err.Error(), "not available") {
t.Errorf("error = %q, want substring 'not available'", err.Error())
}
@@ -291,6 +295,7 @@ func TestBotInfo_NilFunc(t *testing.T) {
if err == nil {
t.Fatal("expected error for nil botInfoFunc")
}
// BotInfo() returns a raw fmt.Errorf when botInfoFunc is nil, not a typed envelope — message-substring assertion is intentional.
if !strings.Contains(err.Error(), "not fully initialized") {
t.Errorf("unexpected error: %v", err)
}

View File

@@ -129,9 +129,9 @@ func TestResolveInputFlags_StdinNotSupported(t *testing.T) {
if err == nil {
t.Fatal("expected error for stdin not supported")
}
assertValidationParam(t, err, "--data")
if !strings.Contains(err.Error(), "does not support stdin") {
t.Errorf("unexpected error: %v", err)
vErr := assertValidationParam(t, err, "--data")
if !strings.Contains(vErr.Message, "does not support stdin") {
t.Errorf("unexpected error message: %q", vErr.Message)
}
}
@@ -143,9 +143,9 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) {
if err == nil {
t.Fatal("expected error for file not supported")
}
assertValidationParam(t, err, "--data")
if !strings.Contains(err.Error(), "does not support file input") {
t.Errorf("unexpected error: %v", err)
vErr := assertValidationParam(t, err, "--data")
if !strings.Contains(vErr.Message, "does not support file input") {
t.Errorf("unexpected error message: %q", vErr.Message)
}
}
@@ -160,9 +160,9 @@ func TestResolveInputFlags_FileNotFound(t *testing.T) {
if err == nil {
t.Fatal("expected error for missing file")
}
assertValidationParam(t, err, "--markdown")
if !strings.Contains(err.Error(), "cannot read file") {
t.Errorf("unexpected error: %v", err)
vErr := assertValidationParam(t, err, "--markdown")
if !strings.Contains(vErr.Message, "cannot read file") {
t.Errorf("unexpected error message: %q", vErr.Message)
}
}
@@ -174,9 +174,9 @@ func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
if err == nil {
t.Fatal("expected error for empty file path")
}
assertValidationParam(t, err, "--markdown")
if !strings.Contains(err.Error(), "file path cannot be empty after @") {
t.Errorf("unexpected error: %v", err)
vErr := assertValidationParam(t, err, "--markdown")
if !strings.Contains(vErr.Message, "file path cannot be empty after @") {
t.Errorf("unexpected error message: %q", vErr.Message)
}
}
@@ -216,9 +216,14 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
if err == nil {
t.Fatal("expected error for duplicate stdin usage")
}
assertValidationParam(t, err, "--b")
if !strings.Contains(err.Error(), "stdin (-) can only be used by one flag") {
t.Errorf("unexpected error: %v", err)
vErr := assertValidationParam(t, err, "--b")
if !strings.Contains(vErr.Message, "stdin (-) can only be used by one flag") {
t.Errorf("unexpected error message: %q", vErr.Message)
}
// The hint must steer an AI agent to the fix (@file for the extra flags),
// since `--a - <x --b - <y` is the exact misuse this guards against.
if !strings.Contains(vErr.Hint, "@file") {
t.Errorf("hint %q should mention @file as the fix", vErr.Hint)
}
}

View File

@@ -186,9 +186,7 @@ func TestRunShortcut_JqAndFormatConflict(t *testing.T) {
if err == nil {
t.Fatal("expected error for --jq + --format table conflict")
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
}
requireValidation(t, err, "mutually exclusive")
}
func TestRunShortcut_JqInvalidExpression(t *testing.T) {
@@ -208,9 +206,7 @@ func TestRunShortcut_JqInvalidExpression(t *testing.T) {
if err == nil {
t.Fatal("expected error for invalid jq expression")
}
if !strings.Contains(err.Error(), "invalid jq expression") {
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
}
requireValidation(t, err, "invalid jq expression")
}
func TestRunShortcut_JqRuntimeError_PropagatesError(t *testing.T) {

View File

@@ -0,0 +1,50 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
// requireProblem asserts err carries a typed errs.Problem with the given
// category and (optional) subtype, and that its message contains msgContains
// (skip the message check by passing ""). Returns the Problem so callers can
// drill into the typed envelope's category-specific fields (e.g. cast to
// *errs.ValidationError to read .Param / .Params / .Cause).
func requireProblem(t *testing.T, err error, wantCategory errs.Category, wantSubtype errs.Subtype, msgContains string) *errs.Problem {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error carrying errs.Problem, got %T: %v", err, err)
}
if p.Category != wantCategory {
t.Errorf("category = %q, want %q (err=%v)", p.Category, wantCategory, err)
}
if wantSubtype != "" && p.Subtype != wantSubtype {
t.Errorf("subtype = %q, want %q (err=%v)", p.Subtype, wantSubtype, err)
}
if msgContains != "" && !strings.Contains(p.Message, msgContains) {
t.Errorf("message = %q, want containing %q", p.Message, msgContains)
}
return p
}
// requireValidation is shorthand for CategoryValidation + SubtypeInvalidArgument.
// Returns *errs.ValidationError so callers can also assert on .Param / .Params / .Cause.
func requireValidation(t *testing.T, err error, msgContains string) *errs.ValidationError {
t.Helper()
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, msgContains)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
return ve
}

View File

@@ -5,6 +5,7 @@ package drive
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
@@ -15,6 +16,24 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// wrapExportContextErr converts a context cancellation / deadline error into a
// typed errs.NetworkError so the cobra layer sees a typed envelope (with cause
// preserved for errors.Is) instead of an untyped context.Canceled /
// context.DeadlineExceeded escaping as a plain string. CR-flagged hole on the
// poll loop: returning ctx.Err() directly bypassed the typed-error contract.
func wrapExportContextErr(err error) error {
if err == nil {
return nil
}
subtype := errs.SubtypeNetworkTransport
msg := "drive +export polling cancelled: %s"
if errors.Is(err, context.DeadlineExceeded) {
subtype = errs.SubtypeNetworkTimeout
msg = "drive +export polling deadline exceeded: %s"
}
return errs.NewNetworkError(subtype, msg, err).WithCause(err)
}
// DriveExport exports Drive-native documents to local files and falls back to
// a follow-up command when the async export task does not finish in time.
var DriveExport = common.Shortcut{
@@ -40,236 +59,302 @@ var DriveExport = common.Shortcut{
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveExportSpec(driveExportSpec{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
OnlySchema: runtime.Bool("only-schema"),
})
return ValidateExport(exportParamsFromFlags(runtime))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := driveExportSpec{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
OnlySchema: runtime.Bool("only-schema"),
}
// Markdown export is a special case: docx markdown comes from the V2
// docs_ai fetch API directly instead of the Drive export task API.
if spec.FileExtension == "markdown" {
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
dr := common.NewDryRunAPI().
Desc("2-step orchestration: fetch docx markdown -> write local file").
POST(apiPath).
Body(map[string]interface{}{
"format": "markdown",
}).
Set("output_dir", runtime.Str("output-dir"))
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
}
return PlanExportDryRun(runtime, exportParamsFromFlags(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return RunExport(ctx, runtime, exportParamsFromFlags(runtime))
},
}
body := map[string]interface{}{
"token": spec.Token,
"type": spec.DocType,
"file_extension": spec.FileExtension,
}
if strings.TrimSpace(spec.SubID) != "" {
body["sub_id"] = spec.SubID
}
if spec.OnlySchema {
body["only_schema"] = true
}
// ExportParams holds the user-facing inputs for an export flow, decoupled from
// cobra flags so other command groups (e.g. sheets +workbook-export) can reuse
// the drive export implementation. An empty OutputDir means "create the export
// task and poll, but do not download" — callers that only need the ready file
// token / status get it back without writing a local file.
type ExportParams struct {
Token string
DocType string
FileExtension string
SubID string
OnlySchema bool
OutputDir string
FileName string
Overwrite bool
}
func (p ExportParams) spec() driveExportSpec {
return driveExportSpec{
Token: p.Token,
DocType: p.DocType,
FileExtension: p.FileExtension,
SubID: p.SubID,
OnlySchema: p.OnlySchema,
}
}
// exportParamsFromFlags reads the standard drive +export flag set.
func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
// drive +export always downloads; an empty --output-dir historically means
// the current directory (saveContentToOutputDir maps "" -> "."), so normalize
// it here to keep behavior identical and stay off the export-only ("" => skip
// download) path that only sheets +workbook-export uses.
outputDir := runtime.Str("output-dir")
if outputDir == "" {
outputDir = "."
}
return ExportParams{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
OnlySchema: runtime.Bool("only-schema"),
OutputDir: outputDir,
FileName: strings.TrimSpace(runtime.Str("file-name")),
Overwrite: runtime.Bool("overwrite"),
}
}
// ValidateExport runs the CLI-level export constraint checks.
func ValidateExport(p ExportParams) error {
return validateDriveExportSpec(p.spec())
}
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
spec := p.spec()
// Markdown export is a special case: docx markdown comes from the V2
// docs_ai fetch API directly instead of the Drive export task API.
if spec.FileExtension == "markdown" {
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
dr := common.NewDryRunAPI().
Desc("3-step orchestration: create export task -> limited polling -> download file").
POST("/open-apis/drive/v1/export_tasks").
Body(body).
Set("output_dir", runtime.Str("output-dir"))
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
Desc("2-step orchestration: fetch docx markdown -> write local file").
POST(apiPath).
Body(map[string]interface{}{
"format": "markdown",
}).
Set("output_dir", p.OutputDir)
if name := strings.TrimSpace(p.FileName); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := driveExportSpec{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
OnlySchema: runtime.Bool("only-schema"),
}
body := map[string]interface{}{
"token": spec.Token,
"type": spec.DocType,
"file_extension": spec.FileExtension,
}
if strings.TrimSpace(spec.SubID) != "" {
body["sub_id"] = spec.SubID
}
if spec.OnlySchema {
body["only_schema"] = true
}
dr := common.NewDryRunAPI().
Desc("3-step orchestration: create export task -> limited polling -> download file").
POST("/open-apis/drive/v1/export_tasks").
Body(body).
Set("output_dir", p.OutputDir)
if name := strings.TrimSpace(p.FileName); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
}
// RunExport drives create export task -> bounded poll -> optional download. It
// is the shared core behind both drive +export and sheets +workbook-export. An
// empty p.OutputDir skips the download step and returns the ready file token.
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
spec := p.spec()
outputDir := p.OutputDir
preferredFileName := strings.TrimSpace(p.FileName)
overwrite := p.Overwrite
// Markdown export bypasses the async export task and writes the fetched
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
// higher-quality Lark-flavored Markdown output.
if spec.FileExtension == "markdown" {
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
data, err := runtime.CallAPITyped(
"POST",
apiPath,
nil,
map[string]interface{}{
"format": "markdown",
},
)
if err != nil {
return err
}
outputDir := runtime.Str("output-dir")
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
overwrite := runtime.Bool("overwrite")
// Markdown export bypasses the async export task and writes the fetched
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
// higher-quality Lark-flavored Markdown output.
if spec.FileExtension == "markdown" {
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
data, err := runtime.CallAPITyped(
"POST",
apiPath,
nil,
map[string]interface{}{
"format": "markdown",
},
)
// Extract content from the V2 response: data.document.content
doc, ok := data["document"].(map[string]interface{})
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
}
content, ok := doc["content"].(string)
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
}
fileName := preferredFileName
if fileName == "" {
// Prefer the remote title for the exported file name, but still fall
// back to the token if metadata is empty.
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
if err != nil {
return err
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
title = spec.Token
}
fileName = title
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
if err != nil {
return err
}
// Extract content from the V2 response: data.document.content
doc, ok := data["document"].(map[string]interface{})
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
runtime.Out(map[string]interface{}{
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_name": filepath.Base(savedPath),
"saved_path": savedPath,
"size_bytes": len(content),
}, nil)
return nil
}
ticket, err := createDriveExportTask(runtime, spec)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
var lastStatus driveExportStatus
var lastPollErr error
hasObservedStatus := false
// Keep the command responsive by polling for a bounded window. If the task
// is still running after that, return a resume command instead of blocking.
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return wrapExportContextErr(ctx.Err())
case <-time.After(driveExportPollInterval):
}
content, ok := doc["content"].(string)
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
}
if err := ctx.Err(); err != nil {
return wrapExportContextErr(err)
}
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
if err != nil {
// Treat polling failures as transient so short-lived backend hiccups
// do not immediately fail an otherwise healthy export task.
lastPollErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
continue
}
lastStatus = status
hasObservedStatus = true
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
// Export-only mode: caller wants the ready file token / metadata but
// no local download (e.g. sheets +workbook-export without an output
// path). Skip the download and return the status envelope.
if strings.TrimSpace(outputDir) == "" {
runtime.Out(map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_token": status.FileToken,
"file_name": status.FileName,
"file_size": status.FileSize,
"ready": true,
"downloaded": false,
}, nil)
return nil
}
fileName := preferredFileName
if fileName == "" {
// Prefer the remote title for the exported file name, but still fall
// back to the token if metadata is empty.
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
title = spec.Token
}
fileName = title
fileName = status.FileName
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
if err != nil {
return err
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
hint := fmt.Sprintf(
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
ticket,
status.FileToken,
recoveryCommand,
)
return appendDriveExportRecoveryHint(err, hint)
}
runtime.Out(map[string]interface{}{
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_name": filepath.Base(savedPath),
"saved_path": savedPath,
"size_bytes": len(content),
}, nil)
out["ticket"] = ticket
out["doc_type"] = spec.DocType
out["file_extension"] = spec.FileExtension
runtime.Out(out, nil)
return nil
}
ticket, err := createDriveExportTask(runtime, spec)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
var lastStatus driveExportStatus
var lastPollErr error
hasObservedStatus := false
// Keep the command responsive by polling for a bounded window. If the task
// is still running after that, return a resume command instead of blocking.
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(driveExportPollInterval):
}
if status.Failed() {
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
}
if err := ctx.Err(); err != nil {
return err
}
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
if err != nil {
// Treat polling failures as transient so short-lived backend hiccups
// do not immediately fail an otherwise healthy export task.
lastPollErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
continue
}
lastStatus = status
hasObservedStatus = true
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
fileName := preferredFileName
if fileName == "" {
fileName = status.FileName
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
if err != nil {
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
hint := fmt.Sprintf(
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
ticket,
status.FileToken,
recoveryCommand,
)
return appendDriveExportRecoveryHint(err, hint)
}
out["ticket"] = ticket
out["doc_type"] = spec.DocType
out["file_extension"] = spec.FileExtension
runtime.Out(out, nil)
return nil
}
if status.Failed() {
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
}
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
}
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
}
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
if !hasObservedStatus && lastPollErr != nil {
hint := fmt.Sprintf(
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
ticket,
nextCommand,
)
return appendDriveExportRecoveryHint(lastPollErr, hint)
}
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
}
failed := false
var jobStatus interface{}
jobStatusLabel := "unknown"
if hasObservedStatus {
failed = lastStatus.Failed()
jobStatus = lastStatus.JobStatus
jobStatusLabel = lastStatus.StatusLabel()
}
// Return the last observed status so callers can resume from a known task
// state instead of losing all progress information on timeout.
result := map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"ready": false,
"failed": failed,
"job_status": jobStatus,
"job_status_label": jobStatusLabel,
"timed_out": true,
"next_command": nextCommand,
}
if preferredFileName != "" {
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
}
runtime.Out(result, nil)
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
return nil
},
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
if !hasObservedStatus && lastPollErr != nil {
hint := fmt.Sprintf(
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
ticket,
nextCommand,
)
return appendDriveExportRecoveryHint(lastPollErr, hint)
}
failed := false
var jobStatus interface{}
jobStatusLabel := "unknown"
if hasObservedStatus {
failed = lastStatus.Failed()
jobStatus = lastStatus.JobStatus
jobStatusLabel = lastStatus.StatusLabel()
}
// Return the last observed status so callers can resume from a known task
// state instead of losing all progress information on timeout.
result := map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"ready": false,
"failed": failed,
"job_status": jobStatus,
"job_status_label": jobStatusLabel,
"timed_out": true,
"next_command": nextCommand,
}
if preferredFileName != "" {
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
}
runtime.Out(result, nil)
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
return nil
}

View File

@@ -5,6 +5,7 @@ package drive
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
@@ -497,6 +498,72 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
}
}
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
// explicit empty --output-dir must still download to the current directory
// (normalized to "."), not trigger the export-only no-download path that the
// shared RunExport core uses for sheets +workbook-export.
func TestDriveExportEmptyOutputDirDownloadsToCwd(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"ticket": "tk_e"}},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_e",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 0, "file_token": "box_e", "file_name": "report",
"file_extension": "pdf", "type": "docx", "file_size": 3,
},
}},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_e/download",
Status: 200,
RawBody: []byte("pdf"),
Headers: http.Header{
"Content-Type": []string{"application/pdf"},
"Content-Disposition": []string{`attachment; filename="report.pdf"`},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
driveExportPollAttempts, driveExportPollInterval = 1, 0
t.Cleanup(func() {
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "pdf",
"--output-dir", "",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Empty --output-dir must still write to cwd, not skip the download.
data, err := os.ReadFile(filepath.Join(tmpDir, "report.pdf"))
if err != nil {
t.Fatalf("empty --output-dir should still download to cwd: %v", err)
}
if string(data) != "pdf" {
t.Fatalf("downloaded content = %q", string(data))
}
if strings.Contains(stdout.String(), `"downloaded": false`) {
t.Fatalf("export-only path must not trigger for drive +export: %s", stdout.String())
}
}
func TestDriveExportAsyncUsesProvidedFileName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
@@ -1034,3 +1101,37 @@ func TestDriveTaskResultExportIncludesReadyFlags(t *testing.T) {
t.Fatalf("stdout missing job_status_label: %s", stdout.String())
}
}
// TestWrapExportContextErr verifies the export poll loop's typed wrapping for
// context cancellation / deadline. Previously the poll loop returned ctx.Err()
// directly so an untyped context.Canceled would escape as a plain string at
// the command layer, bypassing the typed-error contract.
func TestWrapExportContextErr(t *testing.T) {
if err := wrapExportContextErr(nil); err != nil {
t.Errorf("wrapExportContextErr(nil) = %v, want nil", err)
}
cancelled := wrapExportContextErr(context.Canceled)
var netErrCancel *errs.NetworkError
if !errors.As(cancelled, &netErrCancel) {
t.Fatalf("wrapExportContextErr(Canceled) = %T, want *errs.NetworkError", cancelled)
}
if netErrCancel.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("Canceled subtype = %q, want %q", netErrCancel.Subtype, errs.SubtypeNetworkTransport)
}
if !errors.Is(cancelled, context.Canceled) {
t.Error("wrapExportContextErr should preserve context.Canceled via errors.Is")
}
deadline := wrapExportContextErr(context.DeadlineExceeded)
var netErrDeadline *errs.NetworkError
if !errors.As(deadline, &netErrDeadline) {
t.Fatalf("wrapExportContextErr(DeadlineExceeded) = %T, want *errs.NetworkError", deadline)
}
if netErrDeadline.Subtype != errs.SubtypeNetworkTimeout {
t.Errorf("DeadlineExceeded subtype = %q, want %q", netErrDeadline.Subtype, errs.SubtypeNetworkTimeout)
}
if !errors.Is(deadline, context.DeadlineExceeded) {
t.Error("wrapExportContextErr should preserve context.DeadlineExceeded via errors.Is")
}
}

View File

@@ -35,132 +35,164 @@ var DriveImport = common.Shortcut{
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveImportSpec(driveImportSpec{
FilePath: runtime.Str("file"),
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
})
return ValidateImport(importParamsFromFlags(runtime))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := driveImportSpec{
FilePath: runtime.Str("file"),
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
}
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if valErr := validateDriveImportSpec(spec); valErr != nil {
return common.NewDryRunAPI().Set("error", valErr.Error())
}
dry := common.NewDryRunAPI()
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
appendDriveImportFolderTokenWikiCheckDryRun(dry, spec)
appendDriveImportUploadDryRun(dry, spec, fileSize)
dry.POST("/open-apis/drive/v1/import_tasks").
Desc("[2] Create import task").
Body(spec.CreateTaskBody("<file_token>"))
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
Desc("[3] Poll import task result").
Set("ticket", "<ticket>")
if runtime.IsBot() {
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
}
return dry
return PlanImportDryRun(runtime, importParamsFromFlags(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := driveImportSpec{
FilePath: runtime.Str("file"),
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
}
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
return err
}
if err := rejectDriveImportWikiFolderToken(runtime, spec.FolderToken); err != nil {
return err
}
// Step 1: Upload file as media
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
if uploadErr != nil {
return uploadErr
}
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
// Step 2: Create import task
ticket, err := createDriveImportTask(runtime, spec, fileToken)
if err != nil {
return err
}
// Step 3: Poll task
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
status, ready, err := pollDriveImportTask(runtime, ticket)
if err != nil {
return err
}
// Some intermediate responses omit the final type, so fall back to the
// requested type to keep the output shape stable.
resultType := status.DocType
if resultType == "" {
resultType = spec.DocType
}
out := map[string]interface{}{
"ticket": ticket,
"type": resultType,
"ready": ready,
"job_status": status.JobStatus,
"job_status_label": status.StatusLabel(),
}
if status.Token != "" {
out["token"] = status.Token
}
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
out["url"] = statusURL
} else if status.Token != "" {
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
out["url"] = u
}
}
if status.JobErrorMsg != "" {
out["job_error_msg"] = status.JobErrorMsg
}
if status.Extra != nil {
out["extra"] = status.Extra
}
if !ready {
nextCommand := driveImportTaskResultCommand(ticket)
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
if ready {
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
out["permission_grant"] = grant
}
}
runtime.Out(out, nil)
return nil
return RunImport(ctx, runtime, importParamsFromFlags(runtime))
},
}
// ImportParams holds the user-facing inputs for an import flow, decoupled from
// cobra flags so other command groups (e.g. sheets +workbook-import) can reuse
// the drive import implementation without taking a dependency on a --type flag.
type ImportParams struct {
File string
DocType string
FolderToken string
Name string
TargetToken string
}
func (p ImportParams) spec() driveImportSpec {
return driveImportSpec{
FilePath: p.File,
DocType: strings.ToLower(p.DocType),
FolderToken: p.FolderToken,
Name: p.Name,
TargetToken: p.TargetToken,
}
}
// importParamsFromFlags reads the standard drive +import flag set.
func importParamsFromFlags(runtime *common.RuntimeContext) ImportParams {
return ImportParams{
File: runtime.Str("file"),
DocType: runtime.Str("type"),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
}
}
// ValidateImport runs the CLI-level compatibility checks for an import.
func ValidateImport(p ImportParams) error {
return validateDriveImportSpec(p.spec())
}
// PlanImportDryRun builds the dry-run plan (upload -> create task -> poll) for
// an import without performing any network or file I/O beyond a local stat.
func PlanImportDryRun(runtime *common.RuntimeContext, p ImportParams) *common.DryRunAPI {
spec := p.spec()
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if valErr := validateDriveImportSpec(spec); valErr != nil {
return common.NewDryRunAPI().Set("error", valErr.Error())
}
dry := common.NewDryRunAPI()
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
appendDriveImportFolderTokenWikiCheckDryRun(dry, spec)
appendDriveImportUploadDryRun(dry, spec, fileSize)
dry.POST("/open-apis/drive/v1/import_tasks").
Desc("[2] Create import task").
Body(spec.CreateTaskBody("<file_token>"))
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
Desc("[3] Poll import task result").
Set("ticket", "<ticket>")
if runtime.IsBot() {
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
}
return dry
}
// RunImport executes the full import flow: upload media -> create import task ->
// bounded poll, then writes the result envelope to the runtime output. It is
// the shared core behind both drive +import and sheets +workbook-import.
func RunImport(ctx context.Context, runtime *common.RuntimeContext, p ImportParams) error {
spec := p.spec()
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
return err
}
if err := rejectDriveImportWikiFolderToken(runtime, spec.FolderToken); err != nil {
return err
}
// Step 1: Upload file as media
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
if uploadErr != nil {
return uploadErr
}
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
// Step 2: Create import task
ticket, err := createDriveImportTask(runtime, spec, fileToken)
if err != nil {
return err
}
// Step 3: Poll task
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
status, ready, err := pollDriveImportTask(runtime, ticket)
if err != nil {
return err
}
// Some intermediate responses omit the final type, so fall back to the
// requested type to keep the output shape stable.
resultType := status.DocType
if resultType == "" {
resultType = spec.DocType
}
out := map[string]interface{}{
"ticket": ticket,
"type": resultType,
"ready": ready,
"job_status": status.JobStatus,
"job_status_label": status.StatusLabel(),
}
if status.Token != "" {
out["token"] = status.Token
}
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
out["url"] = statusURL
} else if status.Token != "" {
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
out["url"] = u
}
}
if status.JobErrorMsg != "" {
out["job_error_msg"] = status.JobErrorMsg
}
if status.Extra != nil {
out["extra"] = status.Extra
}
if !ready {
nextCommand := driveImportTaskResultCommand(ticket)
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
if ready {
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
out["permission_grant"] = grant
}
}
runtime.Out(out, nil)
return nil
}
func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64, error) {
// Keep dry-run and execution aligned on path normalization, file existence,
// and format-specific size limits before planning the upload path.

View File

@@ -770,13 +770,7 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
params["folder_id"] = folderIDFromFilter
}
} else {
resolved, err := resolveFolderID(runtime, mailboxID, folderIDFromFilter)
if err != nil {
return nil, err
}
if resolved != "" {
params["folder_id"] = resolved
}
params["folder_id"] = folderIDFromFilter
}
} else if folderFromFilter != "" {
if dryRun {
@@ -786,13 +780,7 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
params["folder_id"] = folderFromFilter
}
} else {
resolved, err := resolveFolderName(runtime, mailboxID, folderFromFilter)
if err != nil {
return nil, err
}
if resolved != "" {
params["folder_id"] = resolved
}
params["folder_id"] = folderFromFilter
}
}
@@ -811,13 +799,7 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
params["label_id"] = labelIDFromFilter
}
} else {
resolved, err := resolveLabelID(runtime, mailboxID, labelIDFromFilter)
if err != nil {
return nil, err
}
if resolved != "" {
params["label_id"] = resolved
}
params["label_id"] = labelIDFromFilter
}
} else if labelFromFilter != "" {
if dryRun {
@@ -827,13 +809,7 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
params["label_id"] = labelFromFilter
}
} else {
resolved, err := resolveLabelName(runtime, mailboxID, labelFromFilter)
if err != nil {
return nil, err
}
if resolved != "" {
params["label_id"] = resolved
}
params["label_id"] = labelFromFilter
}
}

View File

@@ -12,6 +12,7 @@ import (
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
@@ -974,7 +975,11 @@ func TestBuildListParamsDryRunOnlyUnread(t *testing.T) {
func TestBuildListParamsDryRunFolderAlias(t *testing.T) {
rt := runtimeForMailTriageTest(t, nil)
f := triageFilter{Folder: "sent"}
got, err := buildListParams(rt, "me", f, 20, "", true)
resolved, err := resolveListFilter(rt, "me", f, true)
if err != nil {
t.Fatalf("resolveListFilter: %v", err)
}
got, err := buildListParams(rt, "me", resolved, 20, "", true)
if err != nil {
t.Fatal(err)
}
@@ -983,10 +988,30 @@ func TestBuildListParamsDryRunFolderAlias(t *testing.T) {
}
}
func TestBuildListParamsDryRunCustomFolderPreservesInput(t *testing.T) {
rt := runtimeForMailTriageTest(t, nil)
f := triageFilter{Folder: "team-folder"}
resolved, err := resolveListFilter(rt, "me", f, true)
if err != nil {
t.Fatalf("resolveListFilter: %v", err)
}
got, err := buildListParams(rt, "me", resolved, 20, "", true)
if err != nil {
t.Fatal(err)
}
if got["folder_id"] != "team-folder" {
t.Fatalf("expected dry-run folder_id=team-folder, got %v", got["folder_id"])
}
}
func TestBuildListParamsDryRunLabelAlias(t *testing.T) {
rt := runtimeForMailTriageTest(t, nil)
f := triageFilter{Label: "flagged"}
got, err := buildListParams(rt, "me", f, 10, "", true)
resolved, err := resolveListFilter(rt, "me", f, true)
if err != nil {
t.Fatalf("resolveListFilter: %v", err)
}
got, err := buildListParams(rt, "me", resolved, 10, "", true)
if err != nil {
t.Fatal(err)
}
@@ -995,6 +1020,25 @@ func TestBuildListParamsDryRunLabelAlias(t *testing.T) {
}
}
func TestBuildListParamsDryRunCustomLabelPreservesInput(t *testing.T) {
rt := runtimeForMailTriageTest(t, nil)
f := triageFilter{Label: "custom-label"}
resolved, err := resolveListFilter(rt, "me", f, true)
if err != nil {
t.Fatalf("resolveListFilter: %v", err)
}
got, err := buildListParams(rt, "me", resolved, 10, "", true)
if err != nil {
t.Fatal(err)
}
if _, ok := got["folder_id"]; ok {
t.Fatalf("folder_id should not be set when label is specified, got %v", got["folder_id"])
}
if got["label_id"] != "custom-label" {
t.Fatalf("expected dry-run label_id=custom-label, got %v", got["label_id"])
}
}
// --- buildSearchParams additional coverage ---
func TestBuildSearchParamsAllFilterFields(t *testing.T) {
@@ -1791,3 +1835,137 @@ func mailTriageSearchItem(messageID, subject string) map[string]interface{} {
},
}
}
// registerMailTriageFoldersListStub registers a NON-reusable stub for the
// mailbox folders list API. Because it is non-reusable, any second hit returns
// "httpmock: no stub for GET .../folders" — which is exactly the assertion we
// use to prove resolveListFilter runs once and buildListParams does NOT
// re-resolve. folderID/folderName is the single custom folder the API reports.
func registerMailTriageFoldersListStub(reg *httpmock.Registry, mailbox, folderID, folderName string) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: mailboxPath(mailbox, "folders"),
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": folderID,
"name": folderName,
},
},
},
},
})
}
// registerMailTriageListPageStub registers one page of the messages list API,
// disambiguated from sibling pages by a URL substring unique to that page
// (e.g. "page_size=5" for page 1 vs "page_size=2" for page 2). The substring
// must NOT depend on query-param ordering: map iteration makes param order
// nondeterministic, so prefer a value-only token like "page_size=N" (the N
// differs per page because pageSize = maxCount - fetched_so_far). Non-reusable
// so reg.Verify catches under- or over-consumption.
func registerMailTriageListPageStub(reg *httpmock.Registry, urlSubstring string, items []string, hasMore bool, pageToken string) {
data := map[string]interface{}{
"items": items,
"has_more": hasMore,
}
if pageToken != "" {
data["page_token"] = pageToken
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: urlSubstring,
Body: map[string]interface{}{
"code": 0,
"data": data,
},
})
}
// TestMailTriageCustomFolderResolvesOnceAcrossListPages is the regression test
// for the bug where buildListParams re-called resolveFolderID on every list
// page, turning "resolve once" into "1 + page_count" folder-list API calls and
// easily tripping rate limits.
//
// Setup: a custom folder filter that forces resolveListFilter to hit the
// folders list API once (to map folder name "team-folder" to folder_id), then two
// messages-list pages. The folders list stub is non-reusable, so if
// buildListParams re-resolves, the second hit fails with "no stub". The
// messages-list stubs are page-specific (disambiguated by page_size in the
// URL), so both pages are served and Verify asserts each fired exactly once.
func TestMailTriageCustomFolderResolvesOnceAcrossListPages(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
defer reg.Verify(t)
// listMailboxFolders (called once by resolveListFilter) gates on the
// mail:user_mailbox.folder:read scope, which the default test token does
// not carry. Re-store the token with that scope appended so the folders
// API call is actually exercised (and thus the non-reusable folders stub
// is the load-bearing "exactly once" assertion).
const folderScope = "mail:user_mailbox.folder:read"
cfg := mailTestConfig()
if stored := auth.GetStoredToken(cfg.AppID, cfg.UserOpenId); stored != nil {
if !strings.Contains(stored.Scope, folderScope) {
stored.Scope = stored.Scope + " " + folderScope
if err := auth.SetStoredToken(stored); err != nil {
t.Fatalf("re-store token with folder scope: %v", err)
}
}
}
const (
mailbox = "me"
folderName = "team-folder"
folderID = "fld_custom_team"
page2Token = "tok_page2"
)
// --max 5 with listPageMax=20 → pageSize = 5-0 = 5 on page 1, then 5-3 = 2
// on page 2. The page_size query value disambiguates the two list stubs.
page1IDs := []string{"msg_a", "msg_b", "msg_c"}
page2IDs := []string{"msg_d", "msg_e"}
// Folders list: registered exactly once, non-reusable. Any second folder
// lookup (the bug) fails the test with "no stub for GET .../folders".
registerMailTriageFoldersListStub(reg, mailbox, folderID, folderName)
// Messages list, page 1: 3 ids, has_more, hands off a page-2 token. The
// page_size value (5 = maxCount - 0) is unique to page 1; page 2 uses 2.
registerMailTriageListPageStub(reg, "page_size=5", page1IDs, true, page2Token)
// Messages list, page 2: 2 ids, terminal.
registerMailTriageListPageStub(reg, "page_size=2", page2IDs, false, "")
// Batch metadata fetch for all 5 ids.
registerMailTriageBatchStub(reg, mailbox, []map[string]interface{}{
mailTriageBatchMessage("msg_a", "Subject A"),
mailTriageBatchMessage("msg_b", "Subject B"),
mailTriageBatchMessage("msg_c", "Subject C"),
mailTriageBatchMessage("msg_d", "Subject D"),
mailTriageBatchMessage("msg_e", "Subject E"),
})
args := []string{
"+triage",
"--as", "user",
"--mailbox", mailbox,
"--filter", `{"folder":"` + folderName + `"}`,
"--max", "5",
"--format", "json",
}
if err := runMountedMailShortcut(t, MailTriage, args, f, stdout); err != nil {
t.Fatalf("unexpected error running +triage (likely a second folders API call — the bug): %v", err)
}
data := decodeMailTriageJSONOutput(t, stdout)
messages := mailTriageMessagesFromOutput(t, data)
if len(messages) != 5 {
t.Fatalf("expected 5 messages across 2 pages, got %d (stdout=%s)", len(messages), stdout.String())
}
if got := data["has_more"]; got != false {
t.Fatalf("expected has_more=false after exhausting pages, got %v", got)
}
// All registered stubs (1 folders + 2 list pages + 1 batch_get) are
// non-reusable; reg.Verify (deferred above) asserts each was matched
// exactly once. Combined with the non-reusable folders stub, this is the
// proof that the folders list API was called exactly once across both
// pages — the core invariant the fix restores.
}

View File

@@ -177,6 +177,18 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
args: []string{"--sheet-id", "sh1", "--color", "#FF0000"},
subInput: `{"sheet-id":"sh1","color":"#FF0000"}`,
},
{
shortcut: "+sheet-show-gridline",
sc: SheetShowGridline,
args: []string{"--sheet-id", "sh1"},
subInput: `{"sheet-id":"sh1"}`,
},
{
shortcut: "+sheet-hide-gridline",
sc: SheetHideGridline,
args: []string{"--sheet-id", "sh1"},
subInput: `{"sheet-id":"sh1"}`,
},
{
shortcut: "+dropdown-set",
sc: DropdownSet,
@@ -432,12 +444,7 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
t, tc.shortcut,
append([]string{"--url", testURL, "--dry-run"}, tc.args...),
)
if standaloneErr == nil {
t.Fatalf("standalone Validate accepted bad input — expected error containing %q", tc.wantContains)
}
if !strings.Contains(standaloneErr.Error(), tc.wantContains) {
t.Errorf("standalone error = %q, want substring %q", standaloneErr.Error(), tc.wantContains)
}
requireValidation(t, standaloneErr, tc.wantContains)
// Batch path: translate the matching sub-op. The translator wraps
// the inner error with "operations[i] (<shortcut>): " — assert the
@@ -451,17 +458,12 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
"input": subInput,
}
_, batchErr := translateBatchOp(rawOp, testToken, 0)
if batchErr == nil {
t.Fatalf("batch translator accepted bad input — expected error containing %q", tc.wantContains)
}
if !strings.Contains(batchErr.Error(), tc.wantContains) {
t.Errorf("batch error = %q, want substring %q (operations[i] prefix is fine)", batchErr.Error(), tc.wantContains)
}
batchVE := requireValidation(t, batchErr, tc.wantContains)
// And the wrap context must include the sub-op index + shortcut
// name so error reports stay actionable in multi-op batches.
wrapHint := "operations[0] (" + tc.subShortcut + "):"
if !strings.Contains(batchErr.Error(), wrapHint) {
t.Errorf("batch error %q missing context prefix %q", batchErr.Error(), wrapHint)
if !strings.Contains(batchVE.Message, wrapHint) {
t.Errorf("batch error %q missing context prefix %q", batchVE.Message, wrapHint)
}
})
}
@@ -517,12 +519,7 @@ func TestBatchOp_RejectsWrongScalarType(t *testing.T) {
}
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
_, err := translateBatchOp(rawOp, testToken, 0)
if err == nil {
t.Fatalf("translateBatchOp accepted wrong-typed field; want error containing %q", tc.wantContains)
}
if !strings.Contains(err.Error(), tc.wantContains) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
}
requireValidation(t, err, tc.wantContains)
})
}
}
@@ -580,12 +577,7 @@ func TestBatchOp_GuardsBeyondCobra(t *testing.T) {
}
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
_, err := translateBatchOp(rawOp, testToken, 0)
if err == nil {
t.Fatalf("translateBatchOp accepted bad input; want error containing %q", tc.wantContains)
}
if !strings.Contains(err.Error(), tc.wantContains) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
}
requireValidation(t, err, tc.wantContains)
})
}
}
@@ -716,12 +708,7 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
"input": subInput,
}
_, err := translateBatchOp(rawOp, testToken, 0)
if err == nil {
t.Fatalf("translator accepted bad input — expected error containing %q", tc.wantContains)
}
if !strings.Contains(err.Error(), tc.wantContains) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
}
requireValidation(t, err, tc.wantContains)
})
}
}
@@ -782,12 +769,7 @@ func TestBatchOp_SchemaValidatesSubOps(t *testing.T) {
"input": subInput,
}
_, err := translateBatchOp(rawOp, testToken, 0)
if err == nil {
t.Fatalf("translator accepted schema-violating sub-op — expected error containing %q", tc.wantContains)
}
if !strings.Contains(err.Error(), tc.wantContains) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
}
requireValidation(t, err, tc.wantContains)
})
}
}

View File

@@ -150,6 +150,12 @@ var batchOpDispatch = map[string]batchOpMapping{
return sheetVisibilityInput(fv, t, sid, sn, "unhide")
}},
"+sheet-set-tab-color": {"modify_workbook_structure", sheetSetTabColorInput},
"+sheet-show-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
return sheetVisibilityInput(fv, t, sid, sn, "show_gridline")
}},
"+sheet-hide-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
return sheetVisibilityInput(fv, t, sid, sn, "hide_gridline")
}},
// ─── 对象族 CRUD (manage_*_object, operation 区分) ─────────────
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},

View File

@@ -4,12 +4,10 @@
package sheets
import (
"errors"
"os"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
_ "github.com/larksuite/cli/internal/vfs/localfileio"
"github.com/larksuite/cli/shortcuts/common"
@@ -37,18 +35,9 @@ func TestGuardCSVValueIsNotFilePath(t *testing.T) {
// Bare value naming an existing file → guarded with a fix-it hint.
err := guardCSVValueIsNotFilePath(newCSVGuardRuntime("data.csv"))
if err == nil {
t.Fatal("expected guard error when --csv names an existing file")
}
if !strings.Contains(err.Error(), "existing file") || !strings.Contains(err.Error(), "@data.csv") {
t.Errorf("error should flag the file and suggest @data.csv, got: %v", err)
}
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("problem = %+v, want validation/invalid_argument", p)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("guard error = %T, want *errs.ValidationError", err)
ve := requireValidation(t, err, "existing file")
if !strings.Contains(ve.Message, "@data.csv") {
t.Errorf("message should suggest @data.csv, got: %q", ve.Message)
}
if ve.Param != "--csv" {
t.Errorf("param = %q, want --csv", ve.Param)

View File

@@ -4,7 +4,6 @@
package sheets
import (
"strings"
"testing"
)
@@ -44,12 +43,7 @@ func TestCsvPutInput_RejectsStartCellAndRangeTogether(t *testing.T) {
"range": "A1:H17",
})
_, err := csvPutInput(fv, "tok", "sid", "")
if err == nil {
t.Fatal("csvPutInput accepted both start-cell and range; want mutual-exclusion error")
}
if !strings.Contains(err.Error(), "--start-cell and --range are mutually exclusive") {
t.Errorf("error = %q, want it to mention start-cell/range mutual exclusion", err.Error())
}
requireValidation(t, err, "--start-cell and --range are mutually exclusive")
}
// With neither --start-cell nor --range explicitly set, csvPutInput rejects the
@@ -61,12 +55,7 @@ func TestCsvPutInput_RejectsStartCellAndRangeTogether(t *testing.T) {
func TestCsvPutInput_RequiresStartCellOrRange(t *testing.T) {
fv := newMapFlagViewForCommand("+csv-put", map[string]interface{}{"csv": "a,b"})
_, err := csvPutInput(fv, "tok", "sid", "")
if err == nil {
t.Fatal("csvPutInput accepted missing start-cell/range; want a required-flag error")
}
if !strings.Contains(err.Error(), "--start-cell or --range is required") {
t.Errorf("error = %q, want it to mention '--start-cell or --range is required'", err.Error())
}
requireValidation(t, err, "--start-cell or --range is required")
}
// csvPutWriteRangeFromInput surfaces the real paste footprint so agents can see

View File

@@ -54,7 +54,7 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Insert position; appended to the end when omitted",
"desc": "Insert position (0-based); appended to the end when omitted",
"default": "-1"
},
{
@@ -413,6 +413,86 @@
}
]
},
"+sheet-hide-gridline": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
},
{
"name": "sheet-name",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+sheet-show-gridline": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
},
{
"name": "sheet-name",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+workbook-create": {
"risk": "write",
"flags": [
@@ -431,27 +511,45 @@
"desc": "Target folder token; placed at the drive root when omitted"
},
{
"name": "headers",
"name": "values",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Header row as a JSON array: `[\"Col A\",\"Col B\"]`",
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
"input": [
"file",
"stdin"
]
},
{
"name": "values",
"name": "sheets",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Initial data as a 2D JSON array: `[[\"alice\",95]]`",
"desc": "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it from a DataFrame via `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. Mutually exclusive with --values and --dataframe. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).",
"input": [
"file",
"stdin"
]
},
{
"name": "styles",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).",
"input": [
"file",
"stdin"
]
},
{
"name": "dataframe",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Single-sheet typed table from one Arrow IPC file (Feather v2 — what `pandas.DataFrame.to_feather()` writes), mutually exclusive with --values and --sheets. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema; per-column `number_format` may be set via Arrow field metadata. Creates the workbook and fills its default sheet (`Sheet1` — adopted in place, no empty Sheet1 left behind). For multi-sheet or non-default placement, use `--sheets` instead."
},
{
"name": "dry-run",
"kind": "system",
@@ -502,7 +600,7 @@
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Local save path; export is triggered but not downloaded when omitted"
"desc": "Local save path. When omitted, **only the export task is triggered + polled, the file is NOT downloaded** (returns file_token / status so a later step can resume the download). Pass a concrete path (e.g. `./out.xlsx`) or a directory (`.` keeps the server-provided filename) to download. Note: the equivalent `lark-cli drive +export --doc-type sheet` uses three separate flags (`--output-dir` / `--file-name` / `--overwrite`) and defaults to downloading into the current directory; this wrapper collapses them into a single `--output-path` for ergonomics but defaults to no-download — fall back to `drive +export` if the split flag set fits better."
},
{
"name": "dry-run",
@@ -513,6 +611,32 @@
}
]
},
"+workbook-import": {
"risk": "write",
"flags": [
{
"name": "file",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Local file path (.xlsx / .xls / .csv)"
},
{
"name": "folder-token",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Target folder token; imported to the cloud drive root when omitted"
},
{
"name": "name",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Imported spreadsheet name; defaults to the local file name without its extension"
}
]
},
"+sheet-info": {
"risk": "read",
"flags": [
@@ -1082,9 +1206,8 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Safety cap; default 200000",
"default": "200000",
"hidden": true
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
"default": "500000"
},
{
"name": "skip-hidden",
@@ -1192,9 +1315,8 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Safety cap; default 200000",
"default": "200000",
"hidden": true
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
"default": "500000"
},
{
"name": "include-row-prefix",
@@ -1212,19 +1334,72 @@
"desc": "Skip hidden rows and columns; default `false`"
},
{
"name": "rows-json",
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": "Print the request path and parameters without executing"
}
]
},
"+table-get": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Read only this sheet (by id); omit to read all sheets"
},
{
"name": "sheet-name",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Read only this sheet (by name); omit to read all sheets"
},
{
"name": "range",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"
},
{
"name": "no-header",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false",
"default": "false"
"desc": "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"
},
{
"name": "dataframe-out",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Write the typed table as one Arrow IPC file (Feather v2) instead of the default JSON. Pass `@<path>` for a file or `-` for binary stdout (same convention as other binary I/O flags). Mirror of the input-side `--dataframe` on `+table-put` / `+workbook-create` — pandas users round-trip via `df = pd.read_feather(\"x.arrow\")` or `pd.read_feather(io.BytesIO(stdout))`. Single-sheet only: requires `--sheet-id` or `--sheet-name`; whole-workbook reads keep the default JSON path. Column types come from the typed read-back (string/number/date/bool); per-column `number_format` is preserved as Arrow field metadata so the Arrow file can round-trip straight back through `+table-put --dataframe`."
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": "Print the request path and parameters without executing"
"desc": ""
}
]
},
@@ -1849,7 +2024,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "RFC 4180 CSV text; plain values only (no formulas / styles / comments)",
"desc": "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).",
"input": [
"file",
"stdin"
@@ -1880,6 +2055,61 @@
}
]
},
"+table-put": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token to write into (XOR with `--url`)"
},
{
"name": "sheets",
"kind": "own",
"type": "string",
"required": "xor",
"desc": "Typed table payload (pandas-DataFrame-shaped) as JSON, XOR with `--dataframe`: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it with `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).",
"input": [
"file",
"stdin"
]
},
{
"name": "dataframe",
"kind": "own",
"type": "string",
"required": "xor",
"desc": "Single-sheet typed table from one Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()` writes), XOR with `--sheets`. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema (int*/uint*/float* → number, date32/date64/timestamp → date, utf8/large_utf8 → string, bool → bool); per-column `number_format` may be set via Arrow field metadata (`pa.field(\"price\", pa.float64(), metadata={b\"number_format\": b\"$#,##0.00\"})`). Writes the sheet at default placement: name `Sheet1` (created when absent), overwrite from A1 with header. For a different sheet name, anchor, mode, or to write multiple sheets, use `--sheets` instead."
},
{
"name": "styles",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets: with --sheets, match --sheets.sheets; with --dataframe (single sheet named Sheet1), pass exactly one styles item with name `Sheet1`. Run `+table-put --print-schema --flag-name styles` for the full cell_styles field schema.",
"input": [
"file",
"stdin"
]
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+cells-clear": {
"risk": "high-risk-write",
"flags": [

View File

@@ -1,5 +1,5 @@
{
"schema_version": "2",
"schema_version": "3",
"flags": {
"+batch-update": {
"operations": {
@@ -44,6 +44,8 @@
"+sheet-hide",
"+sheet-unhide",
"+sheet-set-tab-color",
"+sheet-show-gridline",
"+sheet-hide-gridline",
"+chart-create",
"+chart-update",
"+chart-delete",
@@ -454,7 +456,7 @@
"type": "object"
},
"link": {
"description": "超链接地址(type='link' 时必填)",
"description": "超链接地址type='link' 时必填)@文档 mentionmention_type 非 0时也必填传文档 URL如搜索结果里的文档链接否则卡片不可点。@人mention_type=0不需要传",
"type": "string"
},
"mention_token": {
@@ -462,8 +464,21 @@
"type": "string"
},
"mention_type": {
"description": "@提及类型编号(仅 type='mention' 时可选)",
"type": "number"
"description": "@提及类型编号(仅 type='mention' 时可选)。0 或不填=@用户;@文件时按类型取1=文档 3=电子表格 8=多维表格 11=思维笔记 12=文件 15=旧版幻灯片 16=知识库 22=新版文档 30=幻灯片 38=画板",
"type": "number",
"enum": [
0,
1,
3,
8,
11,
12,
15,
16,
22,
30,
38
]
},
"notify": {
"description": "是否发送通知(仅 type='mention' 时可选,默认 true",
@@ -1730,11 +1745,12 @@
},
"aggregateType": {
"type": "string",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
"enum": [
"sum",
"average",
"count",
"counta",
"min",
"max",
"median"
@@ -1787,11 +1803,7 @@
"data"
]
}
},
"required": [
"position",
"size"
]
}
}
},
"+chart-update": {
@@ -2769,11 +2781,12 @@
},
"aggregateType": {
"type": "string",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
"enum": [
"sum",
"average",
"count",
"counta",
"min",
"max",
"median"
@@ -2826,11 +2839,7 @@
"data"
]
}
},
"required": [
"position",
"size"
]
}
}
},
"+cond-format-create": {
@@ -6249,6 +6258,744 @@
}
}
}
},
"+table-put": {
"sheets": {
"type": "array",
"minItems": 1,
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。整体形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`。一行式用法:`{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`。",
"items": {
"type": "object",
"required": [
"name",
"columns",
"data"
],
"properties": {
"name": {
"type": "string",
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
},
"start_cell": {
"type": "string",
"default": "A1",
"description": "写入起点单元格A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
},
"mode": {
"type": "string",
"enum": [
"overwrite",
"append"
],
"default": "overwrite",
"description": "overwrite默认从 start_cell 起写「表头 + 数据」块append把数据追加到子表已有数据下方默认不重复表头。"
},
"header": {
"type": "boolean",
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false避免在已有表头下重复显式给值可覆盖。"
},
"allow_overwrite": {
"type": "boolean",
"default": true,
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success。默认 true。"
},
"columns": {
"type": "array",
"minItems": 1,
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
"items": {
"type": "string"
}
},
"data": {
"type": "array",
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本null 表示空单元格。",
"items": {
"type": "array",
"items": {
"type": [
"string",
"number",
"boolean",
"null"
],
"description": "单元格值date→ISO yyyy-mm-dd 字符串number→数值json.Number 精度保留bool→布尔string→文本null→空单元格。"
}
}
},
"dtypes": {
"type": "object",
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 objectstring + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number精度保留`bool` / `boolean` → bool`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`数字样字符串如「00123」不会塌缩成数字。",
"additionalProperties": {
"type": "string"
}
},
"formats": {
"type": "object",
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等。percent 列的数值尺度由调用方负责0.0469 配 `0.00%` 显示 4.69%)。",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"styles": {
"items": {
"properties": {
"cell_merges": {
"description": "单元格合并操作数组range 使用 A1 单元格范围merge_type 默认 all。",
"items": {
"properties": {
"merge_type": {
"enum": [
"all",
"rows",
"columns"
],
"type": "string"
},
"range": {
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"cell_styles": {
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
"items": {
"properties": {
"background_color": {
"type": "string"
},
"border_styles": {
"type": "object",
"description": "边框配置,结构同 +cells-set-style --border-styles。",
"properties": {
"bottom": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"left": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"right": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"top": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
}
}
},
"font_color": {
"type": "string"
},
"font_line": {
"enum": [
"none",
"underline",
"line-through"
],
"type": "string"
},
"font_size": {
"type": "number"
},
"font_style": {
"enum": [
"normal",
"italic"
],
"type": "string"
},
"font_weight": {
"enum": [
"normal",
"bold"
],
"type": "string"
},
"horizontal_alignment": {
"enum": [
"left",
"center",
"right"
],
"type": "string"
},
"number_format": {
"type": "string"
},
"range": {
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
"type": "string"
},
"vertical_alignment": {
"enum": [
"top",
"middle",
"bottom"
],
"type": "string"
},
"word_wrap": {
"enum": [
"overflow",
"auto-wrap",
"word-clip"
],
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"col_sizes": {
"description": "列宽操作数组range 使用列范围如 A:Ctype 为 pixel/standardpixel 需要 size。",
"items": {
"properties": {
"range": {
"type": "string"
},
"size": {
"type": "number"
},
"type": {
"enum": [
"pixel",
"standard"
],
"type": "string"
}
},
"required": [
"range",
"type"
],
"type": "object"
},
"type": "array"
},
"name": {
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1其 name 会被忽略)。",
"type": "string"
},
"row_sizes": {
"description": "行高操作数组range 使用行范围如 1:3type 为 pixel/standard/autopixel 需要 size。",
"items": {
"properties": {
"range": {
"type": "string"
},
"size": {
"type": "number"
},
"type": {
"enum": [
"pixel",
"standard",
"auto"
],
"type": "string"
}
},
"required": [
"range",
"type"
],
"type": "object"
},
"type": "array"
}
},
"required": [
"name"
],
"type": "object"
},
"type": "array"
}
},
"+workbook-create": {
"sheets": {
"type": "array",
"minItems": 1,
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。整体形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`。一行式用法:`{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`。",
"items": {
"type": "object",
"required": [
"name",
"columns",
"data"
],
"properties": {
"name": {
"type": "string",
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
},
"start_cell": {
"type": "string",
"default": "A1",
"description": "写入起点单元格A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
},
"mode": {
"type": "string",
"enum": [
"overwrite",
"append"
],
"default": "overwrite",
"description": "overwrite默认从 start_cell 起写「表头 + 数据」块append把数据追加到子表已有数据下方默认不重复表头。"
},
"header": {
"type": "boolean",
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false避免在已有表头下重复显式给值可覆盖。"
},
"allow_overwrite": {
"type": "boolean",
"default": true,
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success。默认 true。"
},
"columns": {
"type": "array",
"minItems": 1,
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
"items": {
"type": "string"
}
},
"data": {
"type": "array",
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本null 表示空单元格。",
"items": {
"type": "array",
"items": {
"type": [
"string",
"number",
"boolean",
"null"
],
"description": "单元格值date→ISO yyyy-mm-dd 字符串number→数值json.Number 精度保留bool→布尔string→文本null→空单元格。"
}
}
},
"dtypes": {
"type": "object",
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 objectstring + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number精度保留`bool` / `boolean` → bool`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`数字样字符串如「00123」不会塌缩成数字。",
"additionalProperties": {
"type": "string"
}
},
"formats": {
"type": "object",
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等。percent 列的数值尺度由调用方负责0.0469 配 `0.00%` 显示 4.69%)。",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"styles": {
"items": {
"properties": {
"cell_merges": {
"description": "单元格合并操作数组range 使用 A1 单元格范围merge_type 默认 all。",
"items": {
"properties": {
"merge_type": {
"enum": [
"all",
"rows",
"columns"
],
"type": "string"
},
"range": {
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"cell_styles": {
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
"items": {
"properties": {
"background_color": {
"type": "string"
},
"border_styles": {
"type": "object",
"description": "边框配置,结构同 +cells-set-style --border-styles。",
"properties": {
"bottom": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"left": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"right": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"top": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
}
}
},
"font_color": {
"type": "string"
},
"font_line": {
"enum": [
"none",
"underline",
"line-through"
],
"type": "string"
},
"font_size": {
"type": "number"
},
"font_style": {
"enum": [
"normal",
"italic"
],
"type": "string"
},
"font_weight": {
"enum": [
"normal",
"bold"
],
"type": "string"
},
"horizontal_alignment": {
"enum": [
"left",
"center",
"right"
],
"type": "string"
},
"number_format": {
"type": "string"
},
"range": {
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
"type": "string"
},
"vertical_alignment": {
"enum": [
"top",
"middle",
"bottom"
],
"type": "string"
},
"word_wrap": {
"enum": [
"overflow",
"auto-wrap",
"word-clip"
],
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"col_sizes": {
"description": "列宽操作数组range 使用列范围如 A:Ctype 为 pixel/standardpixel 需要 size。",
"items": {
"properties": {
"range": {
"type": "string"
},
"size": {
"type": "number"
},
"type": {
"enum": [
"pixel",
"standard"
],
"type": "string"
}
},
"required": [
"range",
"type"
],
"type": "object"
},
"type": "array"
},
"name": {
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1其 name 会被忽略)。",
"type": "string"
},
"row_sizes": {
"description": "行高操作数组range 使用行范围如 1:3type 为 pixel/standard/autopixel 需要 size。",
"items": {
"properties": {
"range": {
"type": "string"
},
"size": {
"type": "number"
},
"type": {
"enum": [
"pixel",
"standard",
"auto"
],
"type": "string"
}
},
"required": [
"range",
"type"
],
"type": "object"
},
"type": "array"
}
},
"required": [
"name"
],
"type": "object"
},
"type": "array"
}
}
}
}

View File

@@ -47,19 +47,132 @@ func TestExecute_WorkbookInfo_ToolError(t *testing.T) {
"data": map[string]interface{}{},
},
}
stdout, stderr, err := func() (string, string, error) {
_, _, err := func() (string, string, error) {
parent, stdout, stderr, reg := newTestRig(t, WorkbookInfo)
reg.Register(stub)
parent.SetArgs([]string{"+workbook-info", "--url", testURL})
err := parent.Execute()
return stdout.String(), stderr.String(), err
}()
if err == nil {
t.Fatalf("expected non-zero code to surface as error; stdout=%s stderr=%s", stdout, stderr)
p := requireProblem(t, err, errs.CategoryAPI, errs.SubtypeServerError, "")
if !strings.Contains(p.Message, "1310201") && !strings.Contains(p.Message, "not found") {
t.Errorf("expected error code or message in problem; got message=%q", p.Message)
}
combined := stdout + stderr + err.Error()
if !strings.Contains(combined, "1310201") && !strings.Contains(combined, "not found") {
t.Errorf("expected error code in envelope; got=%s|%s|%v", stdout, stderr, err)
}
// TestExecute_WikiURLResolvesToSheet covers the two-step wiki path: a /wiki/
// URL is resolved via get_node to its spreadsheet obj_token, which then feeds
// the tool invoke. The tool stub is keyed on the resolved obj_token, so the
// test would fail if the node_token were used unresolved.
func TestExecute_WikiURLResolvesToSheet(t *testing.T) {
t.Parallel()
getNode := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "sheet",
"obj_token": testToken,
},
},
},
}
tool := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","title":"Sheet1","index":0}]}`)
out, err := runShortcutWithStubs(t, WorkbookInfo,
[]string{"--url", "https://example.feishu.cn/wiki/wikTestNODE"}, getNode, tool)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
t.Fatalf("sheets len = %d, want 1; out=%s", len(sheets), out)
}
}
// TestExecute_WikiURLWrongObjType rejects a wiki node that resolves to a
// non-spreadsheet obj_type before any tool invoke.
func TestExecute_WikiURLWrongObjType(t *testing.T) {
t.Parallel()
getNode := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "docABC",
},
},
},
}
_, err := runShortcutWithStubs(t, WorkbookInfo,
[]string{"--url", "https://example.feishu.cn/wiki/wikTestNODE"}, getNode)
requireValidation(t, err, "obj_type")
}
// TestExecute_WikiURLIncompleteNode treats an incomplete get_node response
// (missing obj_type/obj_token) as an internal/server error, not a user --url
// validation error.
func TestExecute_WikiURLIncompleteNode(t *testing.T) {
t.Parallel()
getNode := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{},
},
},
}
_, err := runShortcutWithStubs(t, WorkbookInfo,
[]string{"--url", "https://example.feishu.cn/wiki/wikTestNODE"}, getNode)
if err == nil {
t.Fatal("want error for incomplete get_node node data")
}
var ve *errs.ValidationError
if errors.As(err, &ve) {
t.Fatalf("incomplete-data error classified as validation (%v); want internal", err)
}
}
// TestExecute_RangeMove_WikiURL guards the transformExecuteFn path: +range-move
// and +range-copy use a named Execute helper (not an inline func), so they must
// still resolve a /wiki/ URL to the backing spreadsheet token before calling
// transform_range. The tool stub is keyed on the resolved obj_token, so an
// unresolved node_token would miss it and fail this test.
func TestExecute_RangeMove_WikiURL(t *testing.T) {
t.Parallel()
getNode := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "sheet",
"obj_token": testToken,
},
},
},
}
tool := toolOutputStub(testToken, "write", `{"updated_range":"A10:B11"}`)
out, err := runShortcutWithStubs(t, RangeMove,
[]string{
"--url", "https://example.feishu.cn/wiki/wikTestNODE",
"--sheet-id", testSheetID,
"--source-range", "A1:B2",
"--target-range", "A10",
}, getNode, tool)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
}
@@ -365,14 +478,17 @@ func TestExecute_WorkbookCreate(t *testing.T) {
},
},
}
// Initial fill first reads the workbook structure to resolve the default
// sheet's id (the create response doesn't echo it), then writes.
// The write reads the workbook structure to resolve the default sheet's id
// (the create response doesn't echo it). lookupFirstSheetID and
// writeTypedSheets' listSheetIDsByName both read it — one reusable stub serves
// both. The synthesized sheet is named "Sheet1", matching the default sheet,
// so it's adopted in place (no rename).
structure := toolOutputStub("shtcnBRAND", "read", `{"sheets":[{"sheet_id":"shtFirst","sheet_name":"Sheet1","index":0}]}`)
structure.Reusable = true
fill := toolOutputStub("shtcnBRAND", "write", `{"updated_cells":4}`)
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{
"--title", "Sales",
"--headers", `["Name","Score"]`,
"--values", `[["alice",95]]`,
"--values", `[["Name","Score"],["alice",95]]`,
}, create, structure, fill)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
@@ -382,8 +498,8 @@ func TestExecute_WorkbookCreate(t *testing.T) {
if ss["spreadsheet_token"] != "shtcnBRAND" {
t.Errorf("spreadsheet_token = %v", ss["spreadsheet_token"])
}
if data["initial_fill"] == nil {
t.Errorf("initial_fill missing in envelope")
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
t.Errorf("sheets summary missing in envelope; got %#v", data["sheets"])
}
// The fill must target the resolved first sheet, not an empty selector.
fillInput := decodeToolInput(t, decodeRawEnvelopeBody(t, fill.CapturedBody), "set_cell_range")
@@ -393,14 +509,13 @@ func TestExecute_WorkbookCreate(t *testing.T) {
}
// TestExecute_WorkbookCreate_EmptyArraysSkipFill locks the fix for the nil-map
// panic / illegal-range bug: --values '[]' or --headers '[]' must short-circuit
// the initial fill (no structure/fill calls fire) and finish with the
// spreadsheet created but no initial_fill — never panic on a nil fill map.
// panic / illegal-range bug: --values '[]' must short-circuit the initial fill
// (no structure/fill calls fire) and finish with the spreadsheet created but no
// sheets summary — never panic on a nil payload.
func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ name, flag, val string }{
{"empty values", "--values", "[]"},
{"empty headers", "--headers", "[]"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
@@ -421,8 +536,8 @@ func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
if data["initial_fill"] != nil {
t.Errorf("initial_fill should be absent for %s %s; got %#v", tc.flag, tc.val, data["initial_fill"])
if data["sheets"] != nil {
t.Errorf("sheets should be absent for %s %s; got %#v", tc.flag, tc.val, data["sheets"])
}
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtNEW" {
t.Errorf("spreadsheet_token = %v, want shtNEW", ss["spreadsheet_token"])

View File

@@ -80,3 +80,28 @@ func flagsFor(command string) []common.Flag {
}
return out
}
// flagAcceptsStdin reports whether the (command, flag) pair declares stdin as
// an input source in flag-defs.json. Used to decide whether an "invalid JSON"
// error should also steer the caller toward stdin. It runs on an error path,
// so it returns false for an unknown command/flag rather than panicking the
// way flagsFor does.
func flagAcceptsStdin(command, name string) bool {
defs, _ := loadFlagDefs()
spec, ok := defs[command]
if !ok {
return false
}
for _, df := range spec.Flags {
if df.Name != name {
continue
}
for _, in := range df.Input {
if in == common.Stdin {
return true
}
}
return false
}
return false
}

View File

@@ -75,7 +75,7 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -305,10 +305,9 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
{Name: "rows-json", Kind: "own", Type: "bool", Required: "optional", Desc: "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false", Default: "false"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
},
},
@@ -320,7 +319,7 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "start-cell", Kind: "own", Type: "string", Required: "required", Desc: "Top-left A1 anchor (e.g. `A1`, `B5`; no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet); must be a single cell, range notation not accepted; the bottom-right is inferred from CSV row/column counts", Default: "A1"},
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; plain values only (no formulas / styles / comments)", Input: []string{"file", "stdin"}},
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).", Input: []string{"file", "stdin"}},
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting (default true); set false to error if any target cell is non-empty", Default: "true"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "alias for --start-cell (parity with +csv-get / +cells-set, which locate with --range); a range like A1:H17 collapses to its top-left cell", Hidden: true},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -766,7 +765,7 @@ var flagDefs = map[string]commandDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New sheet title"},
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position; appended to the end when omitted", Default: "-1"},
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); appended to the end when omitted", Default: "-1"},
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -793,6 +792,16 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-hide-gridline": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-info": {
Risk: "read",
Flags: []flagDef{
@@ -839,6 +848,16 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-show-gridline": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-unhide": {
Risk: "write",
Flags: []flagDef{
@@ -895,13 +914,39 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+table-get": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"},
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
{Name: "dataframe-out", Kind: "own", Type: "string", Required: "optional", Desc: "Write the typed table as one Arrow IPC file (Feather v2) instead of the default JSON. Pass `@<path>` for a file or `-` for binary stdout (same convention as other binary I/O flags). Mirror of the input-side `--dataframe` on `+table-put` / `+workbook-create` — pandas users round-trip via `df = pd.read_feather(\"x.arrow\")` or `pd.read_feather(io.BytesIO(stdout))`. Single-sheet only: requires `--sheet-id` or `--sheet-name`; whole-workbook reads keep the default JSON path. Column types come from the typed read-back (string/number/date/bool); per-column `number_format` is preserved as Arrow field metadata so the Arrow file can round-trip straight back through `+table-put --dataframe`."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+table-put": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token to write into (XOR with `--url`)"},
{Name: "sheets", Kind: "own", Type: "string", Required: "xor", Desc: "Typed table payload (pandas-DataFrame-shaped) as JSON, XOR with `--dataframe`: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it with `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).", Input: []string{"file", "stdin"}},
{Name: "dataframe", Kind: "own", Type: "string", Required: "xor", Desc: "Single-sheet typed table from one Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()` writes), XOR with `--sheets`. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema (int*/uint*/float* → number, date32/date64/timestamp → date, utf8/large_utf8 → string, bool → bool); per-column `number_format` may be set via Arrow field metadata (`pa.field(\"price\", pa.float64(), metadata={b\"number_format\": b\"$#,##0.00\"})`). Writes the sheet at default placement: name `Sheet1` (created when absent), overwrite from A1 with header. For a different sheet name, anchor, mode, or to write multiple sheets, use `--sheets` instead."},
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets: with --sheets, match --sheets.sheets; with --dataframe (single sheet named Sheet1), pass exactly one styles item with name `Sheet1`. Run `+table-put --print-schema --flag-name styles` for the full cell_styles field schema.", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+workbook-create": {
Risk: "write",
Flags: []flagDef{
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
{Name: "headers", Kind: "own", Type: "string", Required: "optional", Desc: "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", Input: []string{"file", "stdin"}},
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Initial data as a 2D JSON array: `[[\"alice\",95]]`", Input: []string{"file", "stdin"}},
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it from a DataFrame via `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. Mutually exclusive with --values and --dataframe. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}},
{Name: "dataframe", Kind: "own", Type: "string", Required: "optional", Desc: "Single-sheet typed table from one Arrow IPC file (Feather v2 — what `pandas.DataFrame.to_feather()` writes), mutually exclusive with --values and --sheets. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema; per-column `number_format` may be set via Arrow field metadata. Creates the workbook and fills its default sheet (`Sheet1` — adopted in place, no empty Sheet1 left behind). For multi-sheet or non-default placement, use `--sheets` instead."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -912,10 +957,18 @@ var flagDefs = map[string]commandDef{
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "file-extension", Kind: "own", Type: "string", Required: "optional", Desc: "Export file format; `csv` mode requires `--sheet-id`", Default: "xlsx", Enum: []string{"xlsx", "csv"}},
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Required only in csv mode: which sheet to export as CSV. This is a `+workbook-export`-specific flag, unrelated to the common four-tuple sheet locator (this shortcut does not accept the common sheet locator)"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Local save path; export is triggered but not downloaded when omitted"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Local save path. When omitted, **only the export task is triggered + polled, the file is NOT downloaded** (returns file_token / status so a later step can resume the download). Pass a concrete path (e.g. `./out.xlsx`) or a directory (`.` keeps the server-provided filename) to download. Note: the equivalent `lark-cli drive +export --doc-type sheet` uses three separate flags (`--output-dir` / `--file-name` / `--overwrite`) and defaults to downloading into the current directory; this wrapper collapses them into a single `--output-path` for ergonomics but defaults to no-download — fall back to `drive +export` if the split flag set fits better."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+workbook-import": {
Risk: "write",
Flags: []flagDef{
{Name: "file", Kind: "own", Type: "string", Required: "required", Desc: "Local file path (.xlsx / .xls / .csv)"},
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; imported to the cloud drive root when omitted"},
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Imported spreadsheet name; defaults to the local file name without its extension"},
},
},
"+workbook-info": {
Risk: "read",
Flags: []flagDef{

View File

@@ -65,9 +65,9 @@ func TestFlagsFor_MapsAllFields(t *testing.T) {
if url == nil || url.Required {
t.Errorf("+sheet-create --url should not be cobra-required: %+v", url)
}
// hidden + int default
// visible + int default
cap := byName("+cells-get", "max-chars")
if cap == nil || !cap.Hidden || cap.Default != "200000" {
if cap == nil || cap.Hidden || cap.Default != "500000" {
t.Errorf("+cells-get --max-chars not mapped: %+v", cap)
}
// input sources
@@ -140,3 +140,24 @@ func TestFlagsFor_EveryRegisteredCommandHasDefs(t *testing.T) {
}
}
}
// TestFlagAcceptsStdin verifies the stdin-capability probe that decides whether
// an "invalid JSON" error should also steer the caller toward stdin: a composite
// flag (cells) accepts stdin, a plain locator (spreadsheet-token) does not, and
// an unknown command/flag returns false without panicking (it runs on an error
// path, unlike flagsFor).
func TestFlagAcceptsStdin(t *testing.T) {
t.Parallel()
if !flagAcceptsStdin("+cells-set", "cells") {
t.Error("+cells-set --cells should accept stdin")
}
if flagAcceptsStdin("+cells-set", "spreadsheet-token") {
t.Error("--spreadsheet-token should not accept stdin")
}
if flagAcceptsStdin("+nope", "cells") {
t.Error("unknown command should be false (and must not panic)")
}
if flagAcceptsStdin("+cells-set", "nope") {
t.Error("unknown flag should be false")
}
}

View File

@@ -9,6 +9,8 @@ import (
"fmt"
"sort"
"sync"
"github.com/larksuite/cli/errs"
)
// ─── --print-schema runtime introspection ─────────────────────────────
@@ -91,7 +93,7 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
}
entry, ok := idx.Flags[command]
if !ok || len(entry) == 0 {
return nil, fmt.Errorf("no JSON Schema registered for %s", command)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no JSON Schema registered for %s", command)
}
if flagName == "" {
flags := make([]string, 0, len(entry))
@@ -112,7 +114,9 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
flags = append(flags, f)
}
sort.Strings(flags)
return nil, fmt.Errorf("no JSON Schema registered for %s --%s; available: %v", command, flagName, flags)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"no JSON Schema registered for %s --%s; available: %v", command, flagName, flags).
WithParam("--flag-name")
}
// Reformat for readability — schema files store compact JSON.
var pretty interface{}

View File

@@ -84,12 +84,12 @@ func TestPrintFlagSchema_NamedFlagReturnsSchemaSubtree(t *testing.T) {
func TestPrintFlagSchema_UnknownFlagListsAvailable(t *testing.T) {
t.Parallel()
_, err := printFlagSchemaFor("+chart-create")("does-not-exist")
if err == nil {
t.Fatal("expected error for unknown flag, got nil")
ve := requireValidation(t, err, "+chart-create")
if !strings.Contains(ve.Message, "properties") {
t.Errorf("message should list available flags; got %q", ve.Message)
}
msg := err.Error()
if !strings.Contains(msg, "+chart-create") || !strings.Contains(msg, "properties") {
t.Errorf("error should mention shortcut + available flags; got %q", msg)
if ve.Param != "--flag-name" {
t.Errorf("param = %q, want --flag-name", ve.Param)
}
}

View File

@@ -63,6 +63,7 @@ func validateParsedJSONFlag(fv flagView, name string, value interface{}) error {
var parseJSONFlagSkip = map[string]struct{}{
"properties": {},
"operations": {},
"styles": {},
}
// validateValueAgainstSchema is the (command, flag) → schema → check
@@ -93,7 +94,17 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
var schema schemaProperty
json.Unmarshal(raw, &schema)
if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil {
return sheetsValidationForFlag(name, "--%s: %s", name, vErr.Error())
// Composite-JSON shape errors (e.g. +cells-set --cells, chart
// --properties) are the highest-frequency usage-layer failure for
// sheets, and agents often burn several retries guessing the shape.
// Point them straight at --print-schema, which dumps the exact JSON
// Schema for this (command, flag) pair. The hint is always actionable:
// reaching this branch means entry[name] resolved a schema from the
// embedded index, and --print-schema reads that same index, so the
// suggested command is guaranteed to print it.
return sheetsValidationForFlag(name,
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
name, vErr.Error(), command, name)
}
return nil
}

View File

@@ -478,11 +478,9 @@ func TestValidateInputAgainstSchema_RealSchema(t *testing.T) {
},
}
err := validateInputAgainstSchema(fv, bad)
if err == nil {
t.Fatal("expected enum violation, got nil")
}
if !strings.Contains(err.Error(), "summarize_by") || !strings.Contains(err.Error(), "not in enum") {
t.Errorf("error = %q, want summarize_by + enum hint", err.Error())
ve := requireValidation(t, err, "summarize_by")
if !strings.Contains(ve.Message, "not in enum") {
t.Errorf("error = %q, want enum hint", ve.Message)
}
}
@@ -499,11 +497,9 @@ func TestValidateInputAgainstSchema_RealMinItems(t *testing.T) {
},
}
err := validateInputAgainstSchema(fv, bad)
if err == nil {
t.Fatal("expected minItems violation for empty values, got nil")
}
if !strings.Contains(err.Error(), "values") || !strings.Contains(err.Error(), "minimum is 1") {
t.Errorf("error = %q, want values + minimum-is-1 hint", err.Error())
ve := requireValidation(t, err, "values")
if !strings.Contains(ve.Message, "minimum is 1") {
t.Errorf("error = %q, want minimum-is-1 hint", ve.Message)
}
}
@@ -520,11 +516,9 @@ func TestValidateInputAgainstSchema_RealMinimum(t *testing.T) {
},
}
err := validateInputAgainstSchema(fv, bad)
if err == nil {
t.Fatal("expected minimum violation for row:-1, got nil")
}
if !strings.Contains(err.Error(), "row") || !strings.Contains(err.Error(), "below minimum") {
t.Errorf("error = %q, want row + below-minimum hint", err.Error())
ve := requireValidation(t, err, "row")
if !strings.Contains(ve.Message, "below minimum") {
t.Errorf("error = %q, want below-minimum hint", ve.Message)
}
}
@@ -554,11 +548,9 @@ func TestValidateInputAgainstSchema_RealAdditionalProperties(t *testing.T) {
},
}
err := validateInputAgainstSchema(fv, bad)
if err == nil {
t.Fatal("expected additionalProperties violation, got nil")
}
if !strings.Contains(err.Error(), "collapse") || !strings.Contains(err.Error(), `expected type "string"`) {
t.Errorf("error = %q, want collapse + string-type hint", err.Error())
ve := requireValidation(t, err, "collapse")
if !strings.Contains(ve.Message, `expected type "string"`) {
t.Errorf("error = %q, want string-type hint", ve.Message)
}
}
@@ -587,3 +579,24 @@ func TestValidateInputAgainstSchema_SkipOperations(t *testing.T) {
t.Errorf("operations should be skipped; got %v", err)
}
}
// TestValidateValueAgainstSchema_PrintSchemaHint pins the highest-value
// recovery affordance for composite-JSON flags: when the shape is wrong, the
// error must point the agent straight at --print-schema (with the right
// command + flag) instead of leaving it to guess across retries. +cells-set
// --cells expects a 2-D array; a bare string trips the top-level type check.
func TestValidateValueAgainstSchema_PrintSchemaHint(t *testing.T) {
t.Parallel()
fv := mapFlagView{command: "+cells-set"}
err := validateValueAgainstSchema(fv, "cells", "not-an-array")
// Underlying shape error is preserved (substring callers still match).
ve := requireValidation(t, err, `expected type "array"`)
// And the actionable --print-schema hint is appended with the exact
// command + flag, so a copy-paste fetches the schema for this pair.
if !strings.Contains(ve.Message, "lark-cli sheets +cells-set --print-schema --flag-name cells") {
t.Errorf("want --print-schema hint with command+flag; got %q", ve.Message)
}
if ve.Param != "--cells" {
t.Errorf("param = %q, want --cells", ve.Param)
}
}

View File

@@ -32,4 +32,6 @@ var commandsWithSchema = map[string]struct{}{
"+range-sort": {},
"+sparkline-create": {},
"+sparkline-update": {},
"+table-put": {},
"+workbook-create": {},
}

View File

@@ -10,6 +10,8 @@ package sheets
import (
"context"
"encoding/json"
"fmt"
neturl "net/url"
"strings"
"github.com/larksuite/cli/errs"
@@ -48,46 +50,151 @@ func sheetsInputStatError(flag string, err error) error {
return wrapped
}
// resolveSpreadsheetToken applies the public --url / --spreadsheet-token XOR
// pair shared by every sheets canonical shortcut and returns the resolved
// token. Network-free, safe to call from Validate and DryRun.
func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) {
// spreadsheetRef classification: a --url / --spreadsheet-token input names a
// spreadsheet either directly (a /sheets/ URL or raw token) or indirectly via a
// wiki node that must be resolved to its backing spreadsheet at Execute time.
const (
spreadsheetRefSheet = "sheet"
spreadsheetRefWiki = "wiki"
)
// spreadsheetRef is a parsed --url / --spreadsheet-token input. A wiki ref holds
// the still-unresolved wiki node_token; resolveSpreadsheetTokenExec turns it
// into the real spreadsheet token at Execute time.
type spreadsheetRef struct {
Kind string // spreadsheetRefSheet | spreadsheetRefWiki
Token string
}
// parseSpreadsheetRef applies the public --url / --spreadsheet-token XOR pair and
// classifies the input. Network-free, safe to call from Validate and DryRun.
//
// Recognized --url shapes:
// - https://.../sheets/<token> → {sheet, token}
// - https://.../spreadsheets/<token> → {sheet, token}
// - https://.../wiki/<node_token> → {wiki, node_token} (resolved at Execute)
//
// A raw --spreadsheet-token is always treated as a spreadsheet token; wiki nodes
// only ever arrive as a /wiki/ URL.
func parseSpreadsheetRef(runtime *common.RuntimeContext) (spreadsheetRef, error) {
if err := common.ExactlyOneTyped(runtime, "url", "spreadsheet-token"); err != nil {
return "", err
return spreadsheetRef{}, err
}
if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" {
if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil {
return "", sheetsValidationCauseForFlag("spreadsheet-token", err)
return spreadsheetRef{}, sheetsValidationCauseForFlag("spreadsheet-token", err)
}
return token, nil
return spreadsheetRef{Kind: spreadsheetRefSheet, Token: token}, nil
}
url := strings.TrimSpace(runtime.Str("url"))
token := extractSpreadsheetToken(url)
if token == "" || token == url {
return "", sheetsValidationForFlag("url", "--url must be a spreadsheet URL like https://.../sheets/<token>")
rawURL := strings.TrimSpace(runtime.Str("url"))
token, kind, ok := spreadsheetURLToken(rawURL)
if !ok {
return spreadsheetRef{}, sheetsValidationForFlag("url", "--url must be a spreadsheet URL like https://.../sheets/<token> or a wiki URL like https://.../wiki/<token>")
}
if err := validate.RejectControlChars(token, "url"); err != nil {
return "", sheetsValidationCauseForFlag("url", err)
return spreadsheetRef{}, sheetsValidationCauseForFlag("url", err)
}
return token, nil
return spreadsheetRef{Kind: kind, Token: token}, nil
}
// extractSpreadsheetToken pulls the token segment out of a /sheets/<token>
// or /spreadsheets/<token> URL. Returns the input unchanged when no known
// prefix is present (callers must check token != originalInput).
func extractSpreadsheetToken(input string) string {
input = strings.TrimSpace(input)
for _, prefix := range []string{"/sheets/", "/spreadsheets/"} {
if idx := strings.Index(input, prefix); idx >= 0 {
token := input[idx+len(prefix):]
if idx2 := strings.IndexAny(token, "/?#"); idx2 >= 0 {
token = token[:idx2]
}
return token
// spreadsheetURLToken extracts the token and its kind from a Lark URL, matching
// only on the URL *path* segment (parsed via net/url). A /wiki/ or /sheets/ that
// appears only in the query or fragment (e.g. a redirect or anchor param) never
// hijacks classification. Returns ok=false when no known prefix heads the path.
func spreadsheetURLToken(rawURL string) (token, kind string, ok bool) {
u, err := neturl.Parse(rawURL)
if err != nil || u.Path == "" {
return "", "", false
}
for _, m := range []struct {
prefix string
kind string
}{
{"/sheets/", spreadsheetRefSheet},
{"/spreadsheets/", spreadsheetRefSheet},
{"/wiki/", spreadsheetRefWiki},
} {
if seg, found := pathSegmentAfter(u.Path, m.prefix); found {
return seg, m.kind, true
}
}
return input
return "", "", false
}
// pathSegmentAfter returns the first path segment after prefix when path begins
// with prefix, else ("", false).
func pathSegmentAfter(path, prefix string) (string, bool) {
if !strings.HasPrefix(path, prefix) {
return "", false
}
rest := path[len(prefix):]
if i := strings.IndexByte(rest, '/'); i >= 0 {
rest = rest[:i]
}
rest = strings.TrimSpace(rest)
if rest == "" {
return "", false
}
return rest, true
}
// resolveSpreadsheetToken applies the public --url / --spreadsheet-token XOR pair
// and returns the resolved token. Network-free, safe to call from Validate and
// DryRun.
//
// A /wiki/ URL yields the still-unresolved wiki node_token: turning it into the
// backing spreadsheet token needs a get_node call, which only Execute may make.
// Validate/DryRun only need a non-empty, control-char-clean token, so the
// node_token passes through unchanged here; Execute paths call
// resolveSpreadsheetTokenExec instead.
func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) {
ref, err := parseSpreadsheetRef(runtime)
if err != nil {
return "", err
}
return ref.Token, nil
}
// resolveSpreadsheetTokenExec is the Execute-time counterpart of
// resolveSpreadsheetToken: it additionally resolves a /wiki/ URL's node_token to
// the backing spreadsheet token via wiki get_node, verifying obj_type=sheet.
// Non-wiki inputs make no API call. Use this from every sheets Execute hook and
// keep resolveSpreadsheetToken in Validate/DryRun so those stay network-free.
func resolveSpreadsheetTokenExec(runtime *common.RuntimeContext) (string, error) {
ref, err := parseSpreadsheetRef(runtime)
if err != nil {
return "", err
}
if ref.Kind != spreadsheetRefWiki {
return ref.Token, nil
}
return resolveWikiNodeToSpreadsheetToken(runtime, ref.Token)
}
// resolveWikiNodeToSpreadsheetToken resolves a wiki node_token to the spreadsheet
// obj_token it points at, erroring when the node is not a spreadsheet. The
// wiki:node:read scope is only needed on this path, so it is enforced here rather
// than declared unconditionally on every sheets shortcut.
func resolveWikiNodeToSpreadsheetToken(runtime *common.RuntimeContext, nodeToken string) (string, error) {
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
return "", err
}
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": nodeToken}, nil)
if err != nil {
return "", err
}
node := common.GetMap(data, "node")
objType := common.GetString(node, "obj_type")
objToken := common.GetString(node, "obj_token")
if objType == "" || objToken == "" {
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data for %q", nodeToken)
}
if objType != "sheet" {
return "", sheetsValidationForFlag("url", "wiki URL resolves to obj_type=%q, but a spreadsheet (obj_type=sheet) is required", objType)
}
return objToken, nil
}
// resolveSheetSelector validates the --sheet-id / --sheet-name XOR and
@@ -241,6 +348,16 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
}
var out interface{}
if err := json.Unmarshal([]byte(raw), &out); err != nil {
// Composite payloads that embed formulas / quotes / commas are the
// classic source of this error: inlined into the shell, the JSON gets
// mangled (e.g. `\$` → "invalid character in string escape"). For any
// flag that accepts stdin, steer the caller there — passing the payload
// via `--<flag> - < file` sidesteps shell escaping entirely.
if flagAcceptsStdin(runtime.Command(), name) {
return nil, sheetsValidationForFlag(name,
"--%s: invalid JSON: %v; if the payload contains formulas / quotes / commas, pass it via stdin (`--%s - < file`) so the shell doesn't mangle the JSON",
name, err, name).WithCause(err)
}
return nil, sheetsValidationForFlag(name, "--%s: invalid JSON: %v", name, err).WithCause(err)
}
// Schema-driven flag validation at the user-input boundary. Skips
@@ -325,6 +442,72 @@ func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
return style
}
// cellStyleAliases maps shorthand cell_styles field names that models commonly
// hallucinate (Excel / openpyxl / CSS conventions) onto the canonical field
// names the backend expects. Only the unambiguous alignment shorthands are
// aliased — they are the high-frequency miss; ambiguous guesses (e.g. "color",
// "bg_color", "text_align") are intentionally left out so a wrong guess still
// surfaces as an error rather than being silently reinterpreted.
var cellStyleAliases = []struct{ alias, canonical string }{
{"horizontal_align", "horizontal_alignment"},
{"halign", "horizontal_alignment"},
{"vertical_align", "vertical_alignment"},
{"valign", "vertical_alignment"},
}
// normalizeCellStyleAliases renames known shorthand keys in a single
// cell_styles map to their canonical equivalents, in place, so a model that
// writes e.g. "horizontal_align" instead of "horizontal_alignment" still
// applies the style instead of hitting an "unsupported field" error (--styles)
// or having the field silently dropped by the backend (typed --cells). If both
// the shorthand and its canonical key are present it returns a validation error
// rather than picking one. path labels the map for the error message.
func normalizeCellStyleAliases(style map[string]interface{}, path string) error {
if len(style) == 0 {
return nil
}
for _, a := range cellStyleAliases {
v, ok := style[a.alias]
if !ok {
continue
}
if _, exists := style[a.canonical]; exists {
return common.ValidationErrorf("%s.%s conflicts with %s; pass only %s", path, a.alias, a.canonical, a.canonical)
}
style[a.canonical] = v
delete(style, a.alias)
}
return nil
}
// normalizeTypedCellsStyleAliases walks a typed --cells 2D array and applies
// normalizeCellStyleAliases to every cell's inline cell_styles object, so the
// alignment shorthands are accepted on +cells-set the same as on --styles.
// Structure is checked leniently to match the pass-through contract: any
// element that isn't the expected shape is skipped, not rejected.
func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
for r, rowRaw := range cells {
row, ok := rowRaw.([]interface{})
if !ok {
continue
}
for c, cellRaw := range row {
cell, ok := cellRaw.(map[string]interface{})
if !ok {
continue
}
st, ok := cell["cell_styles"].(map[string]interface{})
if !ok {
continue
}
if err := normalizeCellStyleAliases(st, fmt.Sprintf("%s[%d][%d].cell_styles", path, r, c)); err != nil {
return err
}
}
}
return nil
}
// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/
// left/right with style sub-objects). Returns nil when the flag is empty.
func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {

View File

@@ -81,6 +81,53 @@ func runShortcutWithStubs(t *testing.T, sc common.Shortcut, args []string, stubs
return stdout.String(), err
}
// requireProblem asserts err carries a typed errs.Problem with the given
// category and (optional) subtype, and that its message contains msgContains
// (skip the message check by passing ""). Returns the Problem so callers can
// drill into the typed envelope's category-specific fields (e.g. cast to
// *errs.ValidationError to read .Param / .Params / .Cause).
//
// Replaces the older "strings.Contains(stdout+stderr+err.Error(), ...)" pattern
// across sheets tests: substring on a rendered envelope was brittle (any
// message tweak silently broke it) and didn't verify that the typed contract —
// category / subtype / cause preservation — held. Per coding guideline
// "Error-path tests must assert typed metadata via errs.ProblemOf
// (category / subtype / param) and cause preservation, not message substrings
// alone."
func requireProblem(t *testing.T, err error, wantCategory errs.Category, wantSubtype errs.Subtype, msgContains string) *errs.Problem {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error carrying errs.Problem, got %T: %v", err, err)
}
if p.Category != wantCategory {
t.Errorf("category = %q, want %q (err=%v)", p.Category, wantCategory, err)
}
if wantSubtype != "" && p.Subtype != wantSubtype {
t.Errorf("subtype = %q, want %q (err=%v)", p.Subtype, wantSubtype, err)
}
if msgContains != "" && !strings.Contains(p.Message, msgContains) {
t.Errorf("message = %q, want containing %q", p.Message, msgContains)
}
return p
}
// requireValidation is shorthand for the most common case: a typed
// CategoryValidation error with SubtypeInvalidArgument. Returns the
// *errs.ValidationError so callers can also assert on .Param / .Params / .Cause.
func requireValidation(t *testing.T, err error, msgContains string) *errs.ValidationError {
t.Helper()
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, msgContains)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
return ve
}
func TestSheetHelpersValidationMetadata(t *testing.T) {
t.Parallel()
@@ -268,3 +315,52 @@ const (
testSheetID = "shtSubA"
testSheetID2 = "shtSubB"
)
// TestParseSpreadsheetRef locks the network-free classification of
// --url / --spreadsheet-token into a sheet token vs an (unresolved) wiki
// node_token. The wiki node is resolved later, at Execute time only.
func TestParseSpreadsheetRef(t *testing.T) {
t.Parallel()
mk := func(url, tok string) *common.RuntimeContext {
cmd := &cobra.Command{Use: "sheets"}
cmd.Flags().String("url", url, "")
cmd.Flags().String("spreadsheet-token", tok, "")
return common.TestNewRuntimeContext(cmd, testConfig(t))
}
cases := []struct {
name string
url string
tok string
wantKind string
wantToken string
wantErr bool
}{
{name: "sheets url", url: "https://x.feishu.cn/sheets/shtABC", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
{name: "spreadsheets url", url: "https://x.feishu.cn/spreadsheets/shtABC", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
{name: "wiki url", url: "https://x.feishu.cn/wiki/wikDEF", wantKind: spreadsheetRefWiki, wantToken: "wikDEF"},
{name: "wiki url with query", url: "https://x.feishu.cn/wiki/wikDEF?sheet=xxxxxx", wantKind: spreadsheetRefWiki, wantToken: "wikDEF"},
{name: "raw token", tok: "shtRAW", wantKind: spreadsheetRefSheet, wantToken: "shtRAW"},
{name: "sheets url with /wiki/ in query stays sheet", url: "https://x.feishu.cn/sheets/shtABC?from=/wiki/wikX", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
{name: "sheets url with /wiki/ in fragment stays sheet", url: "https://x.feishu.cn/sheets/shtABC#/wiki/wikX", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
{name: "docx url unsupported", url: "https://x.feishu.cn/docx/docABC", wantErr: true},
{name: "neither provided", wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ref, err := parseSpreadsheetRef(mk(tc.url, tc.tok))
if tc.wantErr {
if err == nil {
t.Fatalf("want error, got ref=%+v", ref)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ref.Kind != tc.wantKind || ref.Token != tc.wantToken {
t.Fatalf("ref = %+v, want {Kind:%s Token:%s}", ref, tc.wantKind, tc.wantToken)
}
})
}
}

View File

@@ -67,7 +67,7 @@ var BatchUpdate = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
@@ -180,7 +180,7 @@ var CellsBatchSetStyle = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
@@ -270,7 +270,7 @@ var CellsBatchClear = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
@@ -350,7 +350,7 @@ var DropdownUpdate = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
@@ -396,7 +396,7 @@ var DropdownDelete = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}

View File

@@ -5,7 +5,6 @@ package sheets
import (
"encoding/json"
"strings"
"testing"
)
@@ -166,18 +165,16 @@ func TestCellsBatchClear_Guards(t *testing.T) {
t.Parallel()
// sheetless range → prefix guard (shared with the dropdown fan-outs).
stdout, stderr, err := runShortcutCapturingErr(t, CellsBatchClear, []string{
_, _, err := runShortcutCapturingErr(t, CellsBatchClear, []string{
"--url", testURL,
"--ranges", `["A1:A10"]`,
"--yes",
"--dry-run",
})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must include a sheet prefix") {
t.Errorf("expected sheet-prefix guard; got=%s|%s|%v", stdout, stderr, err)
}
requireValidation(t, err, "must include a sheet prefix")
// missing --yes → confirmation_required (high-risk-write).
stdout, stderr, err = runShortcutCapturingErr(t, CellsBatchClear, []string{
stdout, stderr, err := runShortcutCapturingErr(t, CellsBatchClear, []string{
"--url", testURL,
"--ranges", `["sheet1!A1:A10"]`,
})
@@ -268,38 +265,32 @@ func TestBatchUpdate_ValidationGuards(t *testing.T) {
t.Parallel()
// dropdown-update with sheetless range
stdout, stderr, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
_, _, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
"--url", testURL,
"--ranges", `["A2:A5"]`,
"--options", `["a"]`,
"--dry-run",
})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must include a sheet prefix") {
t.Errorf("expected sheet-prefix guard for +dropdown-update; got=%s|%s|%v", stdout, stderr, err)
}
requireValidation(t, err, "must include a sheet prefix")
// batch-update with empty operations
stdout, stderr, err = runShortcutCapturingErr(t, BatchUpdate, []string{
_, _, err = runShortcutCapturingErr(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[]`,
"--yes",
"--dry-run",
})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "non-empty JSON array") {
t.Errorf("expected empty-operations guard; got=%s|%s|%v", stdout, stderr, err)
}
requireValidation(t, err, "non-empty JSON array")
// dropdown-update with non-array --options (object instead) → array guard
// (now via schema validator at parseJSONFlag time)
stdout, stderr, err = runShortcutCapturingErr(t, DropdownUpdate, []string{
_, _, err = runShortcutCapturingErr(t, DropdownUpdate, []string{
"--url", testURL,
"--ranges", `["sheet1!A1:A2"]`,
"--options", `{"not":"array"}`,
"--dry-run",
})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), `expected type "array"`) {
t.Errorf("expected JSON array guard; got=%s|%s|%v", stdout, stderr, err)
}
requireValidation(t, err, `expected type "array"`)
}
// TestValidateDropdownRanges_RejectsMalformedRange locks the up-front sheet!range
@@ -322,15 +313,13 @@ func TestValidateDropdownRanges_RejectsMalformedRange(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
_, _, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
"--url", testURL,
"--ranges", tc.ranges,
"--options", `["a"]`,
"--dry-run",
})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), tc.want) {
t.Errorf("ranges=%s: expected error containing %q; got=%s|%s|%v", tc.ranges, tc.want, stdout, stderr, err)
}
requireValidation(t, err, tc.want)
})
}
}
@@ -419,18 +408,13 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
"--url", testURL,
"--operations", tc.opsJSON,
"--yes",
"--dry-run",
})
if err == nil {
t.Fatalf("expected error containing %q; got stdout=%s stderr=%s", tc.wantMatch, stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), tc.wantMatch) {
t.Errorf("expected error containing %q; got: %s | %s | %v", tc.wantMatch, stdout, stderr, err)
}
requireValidation(t, err, tc.wantMatch)
})
}
}

View File

@@ -0,0 +1,621 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/apache/arrow/go/v17/arrow"
"github.com/apache/arrow/go/v17/arrow/array"
"github.com/apache/arrow/go/v17/arrow/ipc"
"github.com/apache/arrow/go/v17/arrow/memory"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── --dataframe (Arrow IPC / Feather v2 binary input) ────────────────
//
// --dataframe is the binary-typed twin of --sheets. The wire payload is one
// Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()`
// writes), single schema, optionally multi-batch. Type / format are read off
// the Arrow schema (no separate dtypes/formats maps), and per-column number
// format can be set via the field's `number_format` metadata key:
//
// pa.field("price", pa.float64(), metadata={b"number_format": b"$#,##0.00"})
//
// One DataFrame writes into one sub-sheet at fixed defaults: name `Sheet1`
// (adopted in place by +workbook-create; created when absent by +table-put),
// overwrite from A1 with header on, allow_overwrite=true. The shortcut
// surface is deliberately the one flag — anything that needs a different
// sheet name / anchor / mode / multi-sheet falls back to --sheets, whose
// JSON payload already carries every knob.
//
// Binary IO note: --dataframe bypasses the text-oriented Input resolver
// (`runtime.Str("dataframe")` carries a *path*, not file contents). Reading
// the Arrow bytes through that resolver would TrimSpace the trailing IPC
// magic / corrupt non-UTF8 bytes. Path → FileIO.Open → io.ReadAll keeps the
// stream byte-exact. "-" reads from stdin directly.
// dataframeDefaultSheetName is the sub-sheet name --dataframe writes into.
// Matches valuesSheetName so +workbook-create adopts the brand-new
// workbook's default sheet in place (no stray empty Sheet1 left behind);
// +table-put creates Sheet1 if it doesn't already exist.
const dataframeDefaultSheetName = valuesSheetName
// parseDataframePayload reads the --dataframe path (Arrow IPC file) and
// composes a single-sheet tablePayload at the fixed default placement.
// Network-free: safe from Validate and DryRun. The resulting tableSheetSpec
// rides the same buildSheetMatrix / buildTypedCell path as a --sheets entry,
// so downstream is unaware of where the rows came from.
func parseDataframePayload(rctx *common.RuntimeContext) (*tablePayload, error) {
raw := strings.TrimSpace(rctx.Str("dataframe"))
if raw == "" {
return nil, common.ValidationErrorf("--dataframe is required")
}
data, err := readDataframeBytes(rctx, raw)
if err != nil {
return nil, err
}
spec, err := decodeArrowToSheet(data, dataframeDefaultSheetName)
if err != nil {
return nil, common.ValidationErrorf("--dataframe: %v", err).WithCause(err)
}
payload := &tablePayload{Sheets: []tableSheetSpec{spec}}
if err := payload.validate(); err != nil {
return nil, err
}
return payload, nil
}
// dataframeStdinCache holds the bytes read from stdin on the first call so a
// later call (Validate → Execute / DryRun) gets the same bytes instead of an
// empty stream — stdin is single-shot, but parseDataframePayload runs
// multiple times per command invocation. Process-wide is fine: lark-cli is
// one-shot (one command per process). Tests reset by setting it back to nil.
var dataframeStdinCache []byte
// Memory caps for --dataframe. The Arrow IPC reader allocates large buffers up
// front, and arrowRecordToRows materializes every cell into [][]interface{}, so
// an unbounded input could OOM the CLI before the backend's per-write limits
// kicked in. The caps mirror the backend's per-sheet hard ceilings (200 cols,
// 50000 rows) plus a generous overall byte cap that still fits the worst-case
// dense numeric payload (200 × 50000 cells × ~25 bytes Arrow overhead ≈ 250 MB).
const (
dataframeMaxBytes = 256 * 1024 * 1024 // 256 MiB raw IPC payload
dataframeMaxCols = 200 // backend hard ceiling
dataframeMaxRows = 50000 // backend hard ceiling
)
// readDataframeBytes resolves --dataframe to raw binary. A literal `@` prefix
// is tolerated for symmetry with --sheets (`@/tmp/x.arrow` and `/tmp/x.arrow`
// both work). `-` reads stdin verbatim — cached on first call so Validate /
// Execute / DryRun all see the same bytes. Bytes are returned untouched: no
// TrimSpace, no BOM strip — both would corrupt an Arrow IPC stream.
func readDataframeBytes(rctx *common.RuntimeContext, raw string) ([]byte, error) {
// readCapped pulls up to dataframeMaxBytes+1 bytes from r so we can detect
// "exceeded cap" without allocating the entire oversized payload up front.
readCapped := func(r io.Reader) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(r, dataframeMaxBytes+1))
if err != nil {
return nil, err
}
if len(data) > dataframeMaxBytes {
return nil, common.ValidationErrorf(
"--dataframe: payload exceeds %d MiB cap (limits CLI memory; the backend per-sheet ceilings are 200 cols × 50000 rows)",
dataframeMaxBytes/(1024*1024))
}
return data, nil
}
if raw == "-" {
if dataframeStdinCache != nil {
return dataframeStdinCache, nil
}
// A process has a single stdin: --dataframe is binary and bypasses the
// common Input resolver, so we have to share the stdin-consumed flag with
// it explicitly. Without this, e.g. `+table-put --dataframe - --styles -`
// would be accepted and one of them would silently see an empty stream.
if rctx.StdinConsumed() {
return nil, common.ValidationErrorf("--dataframe: stdin (-) can only be used by one flag").
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others as @file (e.g. --styles @/path/to/styles.json)")
}
ios := rctx.IO()
if ios == nil || ios.In == nil {
return nil, common.ValidationErrorf("--dataframe: stdin is not available")
}
data, err := readCapped(ios.In)
if err != nil {
if errs.IsTyped(err) {
return nil, err
}
return nil, common.ValidationErrorf("--dataframe: read stdin: %v", err).WithCause(err)
}
if len(data) == 0 {
return nil, common.ValidationErrorf("--dataframe: stdin is empty")
}
dataframeStdinCache = data
rctx.MarkStdinConsumed()
return data, nil
}
path := strings.TrimPrefix(raw, "@")
fio := rctx.FileIO()
if fio == nil {
return nil, common.ValidationErrorf("--dataframe: file input is not available in this context")
}
// Pre-check size via Stat so a multi-GB file is rejected immediately
// instead of being streamed all the way to the cap.
if info, statErr := fio.Stat(path); statErr == nil && info.Size() > dataframeMaxBytes {
return nil, common.ValidationErrorf(
"--dataframe: file %q is %d MiB, exceeds %d MiB cap",
path, info.Size()/(1024*1024), dataframeMaxBytes/(1024*1024))
}
f, err := fio.Open(path)
if err != nil {
return nil, common.ValidationErrorf("--dataframe: %v", err).WithCause(err)
}
defer f.Close()
data, err := readCapped(f)
if err != nil {
if errs.IsTyped(err) {
return nil, err
}
return nil, common.ValidationErrorf("--dataframe: %v", err).WithCause(err)
}
if len(data) == 0 {
return nil, common.ValidationErrorf("--dataframe: file %q is empty", path)
}
return data, nil
}
// decodeArrowToSheet reads `data` as an Arrow IPC file (single schema,
// possibly multi-batch) and produces a tableSheetSpec with name + columns +
// rows filled in. Sheet placement (start_cell / mode / header / overwrite) is
// not touched here — parseDataframePayload layers those on from CLI flags.
func decodeArrowToSheet(data []byte, sheetName string) (tableSheetSpec, error) {
reader, err := ipc.NewFileReader(bytes.NewReader(data))
if err != nil {
return tableSheetSpec{}, fmt.Errorf("invalid Arrow IPC file (expected pandas df.to_feather output): %w", err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
defer reader.Close()
schema := reader.Schema()
if schema == nil || schema.NumFields() == 0 {
return tableSheetSpec{}, fmt.Errorf("Arrow schema has no fields") //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
ncols := schema.NumFields()
if ncols > dataframeMaxCols {
// Fail fast at the schema layer before allocating per-column slices.
// 200 cols matches the backend's per-sheet hard ceiling — anything past
// that would error on the first set_cell_range anyway.
return tableSheetSpec{}, fmt.Errorf("%d columns exceeds the per-sheet ceiling of %d", ncols, dataframeMaxCols) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
cols := make([]tableColumnSpec, ncols)
seen := make(map[string]bool, ncols)
for i := 0; i < ncols; i++ {
f := schema.Field(i)
name := f.Name
if strings.TrimSpace(name) == "" {
return tableSheetSpec{}, fmt.Errorf("column %d has empty name", i) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
if seen[name] {
return tableSheetSpec{}, fmt.Errorf("duplicate column name %q", name) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
seen[name] = true
typ, format, err := arrowFieldToTypeFormat(f)
if err != nil {
return tableSheetSpec{}, fmt.Errorf("column %q: %w", name, err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
cols[i] = tableColumnSpec{Name: name, Type: typ, Format: format}
}
var rows [][]interface{}
for b := 0; b < reader.NumRecords(); b++ {
rec, err := reader.RecordAt(b)
if err != nil {
return tableSheetSpec{}, fmt.Errorf("read record batch %d: %w", b, err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
// Reject early during iteration before materializing more rows into the
// [][]interface{} buffer — without this, a 1M-row Arrow file would be
// fully decoded into memory before the writer's per-batch size check
// kicks in.
if int64(len(rows))+rec.NumRows() > int64(dataframeMaxRows) {
rec.Release()
return tableSheetSpec{}, fmt.Errorf("%d rows exceeds the per-sheet ceiling of %d", int64(len(rows))+rec.NumRows(), dataframeMaxRows) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
batchRows, err := arrowRecordToRows(rec, cols)
rec.Release()
if err != nil {
return tableSheetSpec{}, err
}
rows = append(rows, batchRows...)
}
return tableSheetSpec{Name: sheetName, Columns: cols, Rows: rows}, nil
}
// arrowFieldToTypeFormat maps an Arrow field to the internal (type, format)
// pair. The field's `number_format` metadata key — when present — sets the
// Excel number_format string verbatim; otherwise sensible defaults are
// applied per type (`@` text for strings, `yyyy-mm-dd` for dates).
func arrowFieldToTypeFormat(f arrow.Field) (typ, format string, err error) {
if v, ok := f.Metadata.GetValue("number_format"); ok {
format = strings.TrimSpace(v)
}
switch f.Type.(type) {
case *arrow.StringType, *arrow.LargeStringType:
if format == "" {
format = "@"
}
return "string", format, nil
case *arrow.BooleanType:
return "bool", format, nil
case *arrow.Date32Type, *arrow.Date64Type, *arrow.TimestampType:
if format == "" {
format = "yyyy-mm-dd"
}
return "date", format, nil
}
if isArrowNumericType(f.Type) {
return "number", format, nil
}
return "", "", fmt.Errorf("unsupported Arrow type %s (want string/number/date/bool)", f.Type.Name()) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
func isArrowNumericType(t arrow.DataType) bool {
switch t.ID() {
case arrow.INT8, arrow.INT16, arrow.INT32, arrow.INT64,
arrow.UINT8, arrow.UINT16, arrow.UINT32, arrow.UINT64,
arrow.FLOAT16, arrow.FLOAT32, arrow.FLOAT64:
return true
}
return false
}
// arrowRecordToRows transposes one column-batch into row-major
// [][]interface{} matched to `cols`. Cells are stamped with the same value
// shapes buildTypedCell expects from the JSON path: nil for nulls,
// json.Number for numerics (precision-preserving), `yyyy-mm-dd` strings for
// dates/timestamps, bool for booleans, string for strings.
func arrowRecordToRows(rec arrow.Record, cols []tableColumnSpec) ([][]interface{}, error) {
if int(rec.NumCols()) != len(cols) {
return nil, fmt.Errorf("record has %d cols, schema declared %d", rec.NumCols(), len(cols)) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
nrows := int(rec.NumRows())
rows := make([][]interface{}, nrows)
for r := range rows {
rows[r] = make([]interface{}, len(cols))
}
for c := range cols {
arr := rec.Column(c)
for r := 0; r < nrows; r++ {
if arr.IsNull(r) {
continue
}
v, err := arrowCellValue(arr, r)
if err != nil {
return nil, fmt.Errorf("row %d column %q: %w", r, cols[c].Name, err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
rows[r][c] = v
}
}
return rows, nil
}
func arrowCellValue(arr arrow.Array, i int) (interface{}, error) {
switch a := arr.(type) {
case *array.String:
return a.Value(i), nil
case *array.LargeString:
return a.Value(i), nil
case *array.Boolean:
return a.Value(i), nil
case *array.Int8:
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
case *array.Int16:
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
case *array.Int32:
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
case *array.Int64:
return json.Number(strconv.FormatInt(a.Value(i), 10)), nil
case *array.Uint8:
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
case *array.Uint16:
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
case *array.Uint32:
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
case *array.Uint64:
return json.Number(strconv.FormatUint(a.Value(i), 10)), nil
case *array.Float16:
return json.Number(strconv.FormatFloat(float64(a.Value(i).Float32()), 'f', -1, 32)), nil
case *array.Float32:
return json.Number(strconv.FormatFloat(float64(a.Value(i)), 'f', -1, 32)), nil
case *array.Float64:
return json.Number(strconv.FormatFloat(a.Value(i), 'f', -1, 64)), nil
case *array.Date32:
// Date32: days since 1970-01-01 (epoch). Multiply to seconds, format
// in UTC so timezone offset can't flip the calendar date.
t := time.Unix(int64(a.Value(i))*86400, 0).UTC()
return t.Format("2006-01-02"), nil
case *array.Date64:
t := time.UnixMilli(int64(a.Value(i))).UTC()
return t.Format("2006-01-02"), nil
case *array.Timestamp:
ts := int64(a.Value(i))
unit := a.DataType().(*arrow.TimestampType).Unit
var t time.Time
switch unit {
case arrow.Second:
t = time.Unix(ts, 0).UTC()
case arrow.Millisecond:
t = time.UnixMilli(ts).UTC()
case arrow.Microsecond:
t = time.UnixMicro(ts).UTC()
case arrow.Nanosecond:
t = time.Unix(0, ts).UTC()
default:
return nil, fmt.Errorf("unsupported timestamp unit %v", unit) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
return t.Format("2006-01-02"), nil
}
return nil, fmt.Errorf("unsupported Arrow array %T", arr) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
// ─── --dataframe-out (Arrow IPC binary output, mirror of --dataframe) ──
//
// +table-get's binary read-back: encode one sheet's typed read-back as an
// Arrow IPC file (Feather v2), so pandas can `pd.read_feather(path)` /
// `pd.read_feather(BytesIO(stdout))` symmetrically with the put side.
// Single-sheet only — Arrow IPC carries one schema per file. The JSON path
// is unchanged; --dataframe-out swaps the encoder for callers that already
// have pandas / pyarrow in their pipeline.
// encodeSheetMapToArrowIPC turns one readSheetAsSpec output into an Arrow IPC
// file blob. Internal column types are recovered from `dtypes` (the wire
// proxy for the typed protocol), and per-column `number_format` rides through
// as Arrow field metadata so the file feeds straight back into
// `+table-put --dataframe`.
func encodeSheetMapToArrowIPC(sheet map[string]interface{}) ([]byte, error) {
columns, _ := sheet["columns"].([]interface{})
if len(columns) == 0 {
return nil, fmt.Errorf("sheet has no columns") //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
dtypes, _ := sheet["dtypes"].(map[string]interface{})
formats, _ := sheet["formats"].(map[string]interface{})
// `data` arrives as either []interface{} (when the sheet came through a
// JSON round-trip / unit-test fixture) or [][]interface{} (the shape
// readSheetAsSpec directly emits in production). Accept both — anything
// else falls through to a zero-row table.
var rawData [][]interface{}
switch d := sheet["data"].(type) {
case [][]interface{}:
rawData = d
case []interface{}:
rawData = make([][]interface{}, len(d))
for i, r := range d {
rawData[i], _ = r.([]interface{})
}
}
ncols := len(columns)
colNames := make([]string, ncols)
colTypes := make([]string, ncols)
fields := make([]arrow.Field, ncols)
for i, c := range columns {
name, _ := c.(string)
if name == "" {
return nil, fmt.Errorf("column %d has empty name", i) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
colNames[i] = name
dt, _ := dtypes[name].(string)
colTypes[i] = dtypeToInternalType(dt)
var meta arrow.Metadata
if formats != nil {
if nf, ok := formats[name].(string); ok && strings.TrimSpace(nf) != "" {
meta = arrow.NewMetadata([]string{"number_format"}, []string{nf})
}
}
fields[i] = arrow.Field{
Name: name,
Type: internalTypeToArrowType(colTypes[i]),
Nullable: true,
Metadata: meta,
}
}
schema := arrow.NewSchema(fields, nil)
mem := memory.NewGoAllocator()
rb := array.NewRecordBuilder(mem, schema)
defer rb.Release()
for r, row := range rawData {
if len(row) != ncols {
return nil, fmt.Errorf("row %d has %d cells, want %d", r, len(row), ncols) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
for c := 0; c < ncols; c++ {
if err := appendArrowCell(rb.Field(c), colTypes[c], row[c]); err != nil {
return nil, fmt.Errorf("row %d column %q: %w", r, colNames[c], err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
}
}
rec := rb.NewRecord()
defer rec.Release()
var buf bytesWriterSeeker
w, err := ipc.NewFileWriter(&buf, ipc.WithSchema(schema), ipc.WithAllocator(mem))
if err != nil {
return nil, fmt.Errorf("ipc.NewFileWriter: %w", err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
if err := w.Write(rec); err != nil {
return nil, fmt.Errorf("write record: %w", err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("close writer: %w", err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
return buf.buf, nil
}
// dtypeToInternalType inverts typeToDtype so the Arrow encoder can pick an
// internal column type from the wire-level dtype string. Unknown / object
// falls back to string (lossless: every cell is already typed as such).
func dtypeToInternalType(dtype string) string {
switch strings.ToLower(strings.TrimSpace(dtype)) {
case "float64", "float32", "int64", "int32", "int16", "int8",
"uint64", "uint32", "uint16", "uint8":
return "number"
case "bool", "boolean":
return "bool"
}
if strings.HasPrefix(strings.ToLower(dtype), "datetime") {
return "date"
}
return "string"
}
// internalTypeToArrowType is the put-side dtypeToTypeFormat dual: maps the
// internal column type to the Arrow data type the encoder builds a column
// with. Numbers go to float64 because +table-get can't tell int from float
// from a number_format alone — float64 covers both losslessly for the cell
// ranges Lark Sheets accepts.
func internalTypeToArrowType(typ string) arrow.DataType {
switch typ {
case "number":
return arrow.PrimitiveTypes.Float64
case "date":
return arrow.FixedWidthTypes.Date32
case "bool":
return arrow.FixedWidthTypes.Boolean
}
return arrow.BinaryTypes.String
}
// appendArrowCell stamps one cell into its column builder. Cell shape matches
// what cellToTyped emits on the JSON path: json.Number for numbers, ISO
// `yyyy-mm-dd` string for dates, plain string for strings, bool for bools,
// nil for empty. Anything off-shape errors so the caller doesn't silently
// emit nulls for malformed data.
func appendArrowCell(b array.Builder, typ string, v interface{}) error {
if v == nil {
b.AppendNull()
return nil
}
switch typ {
case "string":
s, ok := v.(string)
if !ok {
return fmt.Errorf("string expects string value, got %T", v) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
b.(*array.StringBuilder).Append(s)
case "number":
f, err := arrowNumber(v)
if err != nil {
return err
}
b.(*array.Float64Builder).Append(f)
case "date":
s, ok := v.(string)
if !ok {
return fmt.Errorf("date expects ISO yyyy-mm-dd string, got %T", v) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
t, err := time.Parse("2006-01-02", strings.TrimSpace(s))
if err != nil {
return fmt.Errorf("date parse %q: %w", s, err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
b.(*array.Date32Builder).Append(arrow.Date32FromTime(t))
case "bool":
bb, ok := v.(bool)
if !ok {
return fmt.Errorf("bool expects bool, got %T", v) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
b.(*array.BooleanBuilder).Append(bb)
default:
return fmt.Errorf("unsupported internal type %q", typ) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
return nil
}
// arrowNumber converts the number cell shape readSheetAsSpec emits
// (json.Number) plus the float fallback to float64 for the Arrow builder.
func arrowNumber(v interface{}) (float64, error) {
switch n := v.(type) {
case json.Number:
f, err := n.Float64()
if err != nil {
return 0, fmt.Errorf("number parse %q: %w", n.String(), err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
return f, nil
case float64:
return n, nil
}
return 0, fmt.Errorf("number expects numeric value, got %T", v) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
// bytesWriterSeeker is a 10-line in-memory io.WriteSeeker for
// ipc.NewFileWriter, which seeks back to patch a footer offset. Using a
// buffer (instead of a temp file or os.Stdout, which isn't seekable) keeps
// --dataframe-out's stdout path zero-IO and stays straightforward.
type bytesWriterSeeker struct {
buf []byte
pos int64
}
func (w *bytesWriterSeeker) Write(p []byte) (int, error) {
end := w.pos + int64(len(p))
if end > int64(len(w.buf)) {
w.buf = append(w.buf, make([]byte, end-int64(len(w.buf)))...)
}
n := copy(w.buf[w.pos:], p)
w.pos = end
return n, nil
}
func (w *bytesWriterSeeker) Seek(offset int64, whence int) (int64, error) {
switch whence {
case io.SeekStart:
w.pos = offset
case io.SeekCurrent:
w.pos += offset
case io.SeekEnd:
w.pos = int64(len(w.buf)) + offset
default:
return 0, fmt.Errorf("unknown whence %d", whence) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
return w.pos, nil
}
// writeDataframeOut dispatches the encoded Arrow bytes to wherever --dataframe-out
// points: `-` → process stdout, `@<path>` or plain path → local file. Symmetric
// with readDataframeBytes on the input side: same `@` tolerance, same TrimPrefix
// semantics, and an absolute path will still get rejected by FileIO's SafePath.
func writeDataframeOut(rctx *common.RuntimeContext, raw string, data []byte) error {
if raw == "-" {
out := rctx.IO()
if out == nil || out.Out == nil {
return common.ValidationErrorf("--dataframe-out: stdout is not available")
}
if _, err := out.Out.Write(data); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "--dataframe-out: write stdout").WithCause(err)
}
return nil
}
path := strings.TrimPrefix(raw, "@")
fio := rctx.FileIO()
if fio == nil {
return common.ValidationErrorf("--dataframe-out: file output is not available in this context")
}
// FileIO.Save validates the path via SafeOutputPath (the same sandbox
// readDataframeBytes hits on the input side) and writes atomically, so we
// don't need an extra ValidatePath call here.
if _, err := fio.Save(path, fileio.SaveOptions{ContentLength: int64(len(data))}, bytes.NewReader(data)); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "--dataframe-out: write %q", path).WithCause(err)
}
return nil
}

View File

@@ -0,0 +1,421 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/apache/arrow/go/v17/arrow"
"github.com/apache/arrow/go/v17/arrow/array"
"github.com/apache/arrow/go/v17/arrow/ipc"
"github.com/apache/arrow/go/v17/arrow/memory"
"github.com/larksuite/cli/shortcuts/common"
)
// buildArrowIPC writes one record into a Feather v2 (Arrow IPC file) blob.
// Used by the round-trip tests below to stand in for what
// `pandas.DataFrame.to_feather(path)` would produce; saves the tests from
// depending on a pandas-shaped fixture file.
//
// ipc.NewFileWriter wants an io.WriteSeeker (it back-patches a footer
// offset), so we write to a temp file and read the bytes back — simpler than
// re-implementing a seekable in-memory buffer.
func buildArrowIPC(t *testing.T, schema *arrow.Schema, build func(b *array.RecordBuilder)) []byte {
t.Helper()
mem := memory.NewGoAllocator()
rb := array.NewRecordBuilder(mem, schema)
defer rb.Release()
build(rb)
rec := rb.NewRecord()
defer rec.Release()
path := filepath.Join(t.TempDir(), "df.arrow")
f, err := os.Create(path)
if err != nil {
t.Fatalf("create temp arrow file: %v", err)
}
w, err := ipc.NewFileWriter(f, ipc.WithSchema(schema), ipc.WithAllocator(mem))
if err != nil {
f.Close()
t.Fatalf("ipc.NewFileWriter: %v", err)
}
if err := w.Write(rec); err != nil {
t.Fatalf("write record: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("close writer: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("close file: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read temp arrow file: %v", err)
}
return data
}
// TestDataframe_RoundTripCoreTypes pins down the Arrow-schema → internal
// (type, format) mapping and the per-cell value shape that buildTypedCell
// expects: number cells are json.Number (precision-preserving), date cells
// are `yyyy-mm-dd` strings, bool/string come through verbatim. Numbers, dates,
// strings, bools, and nulls all in one record so a future Arrow-Go bump can't
// quietly regress any one family.
func TestDataframe_RoundTripCoreTypes(t *testing.T) {
t.Parallel()
schema := arrow.NewSchema([]arrow.Field{
{Name: "name", Type: arrow.BinaryTypes.String},
{Name: "qty", Type: arrow.PrimitiveTypes.Int64},
{Name: "price", Type: arrow.PrimitiveTypes.Float64, Metadata: arrow.NewMetadata(
[]string{"number_format"}, []string{"$#,##0.00"},
)},
{Name: "active", Type: arrow.FixedWidthTypes.Boolean},
{Name: "shipped_on", Type: arrow.FixedWidthTypes.Date32},
}, nil)
jan15 := arrow.Date32FromTime(time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC))
feb02 := arrow.Date32FromTime(time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC))
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
b.Field(0).(*array.StringBuilder).AppendValues([]string{"alice", ""}, []bool{true, false})
b.Field(1).(*array.Int64Builder).AppendValues([]int64{42, 0}, []bool{true, false})
b.Field(2).(*array.Float64Builder).AppendValues([]float64{19.95, 0}, []bool{true, false})
b.Field(3).(*array.BooleanBuilder).AppendValues([]bool{true, false}, []bool{true, true})
b.Field(4).(*array.Date32Builder).AppendValues([]arrow.Date32{jan15, feb02}, []bool{true, true})
})
spec, err := decodeArrowToSheet(buf, "S1")
if err != nil {
t.Fatalf("decodeArrowToSheet: %v", err)
}
if spec.Name != "S1" {
t.Errorf("sheet name = %q, want S1", spec.Name)
}
if len(spec.Columns) != 5 {
t.Fatalf("got %d columns, want 5", len(spec.Columns))
}
want := []struct{ typ, format string }{
{"string", "@"},
{"number", ""},
{"number", "$#,##0.00"},
{"bool", ""},
{"date", "yyyy-mm-dd"},
}
for i, w := range want {
if spec.Columns[i].Type != w.typ {
t.Errorf("columns[%d].Type = %q, want %q", i, spec.Columns[i].Type, w.typ)
}
if spec.Columns[i].Format != w.format {
t.Errorf("columns[%d].Format = %q, want %q", i, spec.Columns[i].Format, w.format)
}
}
if len(spec.Rows) != 2 {
t.Fatalf("got %d rows, want 2", len(spec.Rows))
}
// Row 0: every field present, types match what buildTypedCell will accept.
row0 := spec.Rows[0]
if row0[0] != "alice" {
t.Errorf("row0[name] = %#v, want \"alice\"", row0[0])
}
if n, ok := row0[1].(json.Number); !ok || n.String() != "42" {
t.Errorf("row0[qty] = %#v, want json.Number(\"42\")", row0[1])
}
if n, ok := row0[2].(json.Number); !ok || n.String() != "19.95" {
t.Errorf("row0[price] = %#v, want json.Number(\"19.95\")", row0[2])
}
if row0[3] != true {
t.Errorf("row0[active] = %#v, want true", row0[3])
}
if row0[4] != "2024-01-15" {
t.Errorf("row0[shipped_on] = %#v, want \"2024-01-15\"", row0[4])
}
// Row 1: nulls on name/qty/price (despite the buffer values) must become nil
// so buildTypedCell paints an empty cell that still carries number_format.
row1 := spec.Rows[1]
for _, c := range []int{0, 1, 2} {
if row1[c] != nil {
t.Errorf("row1[%d] = %#v, want nil (null in arrow)", c, row1[c])
}
}
if row1[3] != false {
t.Errorf("row1[active] = %#v, want false", row1[3])
}
if row1[4] != "2024-02-02" {
t.Errorf("row1[shipped_on] = %#v, want \"2024-02-02\"", row1[4])
}
}
// TestDataframe_Timestamp pins the timestamp → date conversion for the
// timestamp[us] case (pandas default for `pd.Timestamp` columns once written
// via `to_feather`). Only the calendar date matters for our `yyyy-mm-dd`
// landing — guard against TZ drift from the wrong unit pick.
func TestDataframe_Timestamp(t *testing.T) {
t.Parallel()
schema := arrow.NewSchema([]arrow.Field{
{Name: "ts", Type: &arrow.TimestampType{Unit: arrow.Microsecond}},
}, nil)
ts := arrow.Timestamp(time.Date(2024, 6, 12, 14, 30, 0, 0, time.UTC).UnixMicro())
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
b.Field(0).(*array.TimestampBuilder).AppendValues([]arrow.Timestamp{ts}, []bool{true})
})
spec, err := decodeArrowToSheet(buf, "S")
if err != nil {
t.Fatal(err)
}
if spec.Columns[0].Type != "date" {
t.Errorf("type = %q, want date", spec.Columns[0].Type)
}
if got := spec.Rows[0][0]; got != "2024-06-12" {
t.Errorf("ts = %#v, want \"2024-06-12\"", got)
}
}
// TestDataframe_EmptySchema rejects an Arrow file whose schema has no fields:
// a 0-column "DataFrame" would write a header-less, data-less block that
// validates as "writer ran successfully" but produces nothing — the test ties
// that off as an explicit error rather than letting it slip through.
func TestDataframe_EmptySchema(t *testing.T) {
t.Parallel()
schema := arrow.NewSchema(nil, nil)
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {})
_, err := decodeArrowToSheet(buf, "S")
if err == nil || !strings.Contains(err.Error(), "no fields") {
t.Errorf("err = %v, want 'no fields' error", err)
}
}
// TestDataframe_DuplicateColumn catches duplicate-name columns at decode
// time. Validate already rejects duplicate column names for the JSON path;
// the Arrow path mirrors that so the error surfaces with the same shape.
func TestDataframe_DuplicateColumn(t *testing.T) {
t.Parallel()
schema := arrow.NewSchema([]arrow.Field{
{Name: "x", Type: arrow.BinaryTypes.String},
{Name: "x", Type: arrow.PrimitiveTypes.Int64},
}, nil)
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
b.Field(0).(*array.StringBuilder).Append("")
b.Field(1).(*array.Int64Builder).Append(0)
})
_, err := decodeArrowToSheet(buf, "S")
if err == nil || !strings.Contains(err.Error(), "duplicate") {
t.Errorf("err = %v, want duplicate-column error", err)
}
}
// TestDataframe_BadBytes rejects a non-Arrow blob with a hint pointing at
// pandas df.to_feather so users see what producer is expected without having
// to grep the docs.
func TestDataframe_BadBytes(t *testing.T) {
t.Parallel()
_, err := decodeArrowToSheet([]byte("not arrow"), "S")
if err == nil || !strings.Contains(err.Error(), "Arrow") {
t.Errorf("err = %v, want Arrow-decode error", err)
}
}
// TestDecodeArrowToSheet_RejectsTooManyColumns guards the per-sheet column cap.
// Without it a wide Arrow file (e.g. 201+ columns) would allocate a long
// tableColumnSpec slice + decode every batch before the backend's 200-column
// per-sheet ceiling rejected the first set_cell_range — wasting both CPU and
// memory before the failure surfaces. Cap matches the backend hard ceiling.
func TestDecodeArrowToSheet_RejectsTooManyColumns(t *testing.T) {
t.Parallel()
fields := make([]arrow.Field, dataframeMaxCols+1)
for i := range fields {
fields[i] = arrow.Field{Name: "c" + strings.TrimSpace(string(rune('0'+i%10))) + "_" + strings.Repeat("x", i/10+1), Type: arrow.BinaryTypes.String}
}
schema := arrow.NewSchema(fields, nil)
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
for i := range fields {
b.Field(i).(*array.StringBuilder).Append("")
}
})
_, err := decodeArrowToSheet(buf, "S")
if err == nil || !strings.Contains(err.Error(), "exceeds the per-sheet ceiling") {
t.Errorf("err = %v, want column-cap error", err)
}
}
// TestReadDataframeBytes_RejectsSecondStdinConsumer covers the case where another
// flag (e.g. --styles) has already consumed stdin via the common Input resolver:
// since --dataframe bypasses that resolver, the only thing keeping the two from
// racing for an empty stream is the explicit StdinConsumed() check in
// readDataframeBytes. Without that check, fangshuyu's report holds — both flags
// silently accept '-' and one of them sees empty bytes downstream.
func TestReadDataframeBytes_RejectsSecondStdinConsumer(t *testing.T) {
// process-wide cache must be reset so the test isn't served from a prior run.
saved := dataframeStdinCache
dataframeStdinCache = nil
t.Cleanup(func() { dataframeStdinCache = saved })
rctx := &common.RuntimeContext{}
rctx.MarkStdinConsumed()
_, err := readDataframeBytes(rctx, "-")
requireValidation(t, err, "stdin (-) can only be used by one flag")
}
// TestDataframe_EncodeRoundTrip checks --dataframe-out's encoder against its
// own decoder: build a +table-get-shaped sheet map (the same one
// readSheetAsSpec emits), encode to Arrow IPC, decode back via the put-side
// decoder, and require the column types / formats / row values to match. If
// any encoder choice drifts from what the decoder expects, the round-trip
// breaks here long before a real put → get round-trip in production would.
func TestDataframe_EncodeRoundTrip(t *testing.T) {
t.Parallel()
sheet := map[string]interface{}{
"name": "S1",
"columns": []interface{}{"name", "qty", "price", "active", "ts"},
"dtypes": map[string]interface{}{
"name": "object",
"qty": "float64",
"price": "float64",
"active": "bool",
"ts": "datetime64[ns]",
},
"formats": map[string]interface{}{
// `@` is the writer convention for string columns; readSheetAsSpec
// strips it via isTextNumberFormat, so an Arrow file built from a
// real read won't carry @ either. Keep it absent here to mirror
// the production wire shape.
"price": "$#,##0.00",
},
"data": []interface{}{
[]interface{}{"alice", json.Number("42"), json.Number("19.95"), true, "2024-01-15"},
[]interface{}{"bob", nil, json.Number("8.5"), false, "2024-02-02"},
},
}
blob, err := encodeSheetMapToArrowIPC(sheet)
if err != nil {
t.Fatalf("encodeSheetMapToArrowIPC: %v", err)
}
spec, err := decodeArrowToSheet(blob, "S1")
if err != nil {
t.Fatalf("decodeArrowToSheet: %v", err)
}
wantTypes := []string{"string", "number", "number", "bool", "date"}
wantFormats := []string{"@", "", "$#,##0.00", "", "yyyy-mm-dd"}
if len(spec.Columns) != len(wantTypes) {
t.Fatalf("got %d columns, want %d", len(spec.Columns), len(wantTypes))
}
for i, w := range wantTypes {
if spec.Columns[i].Type != w {
t.Errorf("columns[%d].Type = %q, want %q", i, spec.Columns[i].Type, w)
}
if spec.Columns[i].Format != wantFormats[i] {
t.Errorf("columns[%d].Format = %q, want %q", i, spec.Columns[i].Format, wantFormats[i])
}
}
if len(spec.Rows) != 2 {
t.Fatalf("got %d rows, want 2", len(spec.Rows))
}
if spec.Rows[0][0] != "alice" {
t.Errorf("row0[name] = %#v, want alice", spec.Rows[0][0])
}
if n, ok := spec.Rows[0][1].(json.Number); !ok || n.String() != "42" {
t.Errorf("row0[qty] = %#v, want json.Number(\"42\")", spec.Rows[0][1])
}
if spec.Rows[0][3] != true {
t.Errorf("row0[active] = %#v, want true", spec.Rows[0][3])
}
if spec.Rows[0][4] != "2024-01-15" {
t.Errorf("row0[ts] = %#v, want 2024-01-15", spec.Rows[0][4])
}
// qty is null on row1, must come back as nil (not a zero-valued
// json.Number that would later round-trip as 0).
if spec.Rows[1][1] != nil {
t.Errorf("row1[qty] = %#v, want nil (null arrow cell)", spec.Rows[1][1])
}
}
// TestDataframe_EncodeAcceptsBothRowShapes pins the encoder against the two
// shapes `sheet["data"]` actually arrives in: `[][]interface{}` from a live
// readSheetAsSpec call (production), and `[]interface{}` from a JSON
// unmarshal (round-trip / fixtures). Either must produce non-empty Arrow
// output — early on the production shape silently fell through the
// `[]interface{}` type assertion and we shipped a 0-row Arrow blob.
func TestDataframe_EncodeAcceptsBothRowShapes(t *testing.T) {
t.Parallel()
base := func(data interface{}) map[string]interface{} {
return map[string]interface{}{
"name": "S",
"columns": []interface{}{"city"},
"dtypes": map[string]interface{}{"city": "object"},
"data": data,
}
}
for label, data := range map[string]interface{}{
"production [][]interface{}": [][]interface{}{{"BJ"}, {"SH"}},
"unmarshal []interface{}": []interface{}{[]interface{}{"BJ"}, []interface{}{"SH"}},
} {
blob, err := encodeSheetMapToArrowIPC(base(data))
if err != nil {
t.Errorf("%s: encode: %v", label, err)
continue
}
spec, err := decodeArrowToSheet(blob, "S")
if err != nil {
t.Errorf("%s: decode: %v", label, err)
continue
}
if len(spec.Rows) != 2 {
t.Errorf("%s: got %d rows, want 2", label, len(spec.Rows))
}
}
}
// TestDataframe_DtypeToInternalType pins the inverse of typeToDtype so
// readSheetAsSpec's dtype labels recover the right internal type. Covers the
// dtype families +table-get emits today plus the safe fallback for unknown
// labels (string, lossless).
func TestDataframe_DtypeToInternalType(t *testing.T) {
t.Parallel()
cases := map[string]string{
"float64": "number",
"int64": "number",
"Int64": "number",
"bool": "bool",
"boolean": "bool",
"datetime64[ns]": "date",
"datetime64[ms]": "date",
"object": "string",
"": "string",
"weird-new-dtype": "string",
}
for in, want := range cases {
if got := dtypeToInternalType(in); got != want {
t.Errorf("dtypeToInternalType(%q) = %q, want %q", in, got, want)
}
}
}
// TestDataframe_BytesWriterSeeker confirms the in-memory WriteSeeker handles
// the Seek-and-overwrite pattern ipc.NewFileWriter uses to patch the footer
// offset: write some bytes, seek back to the middle, overwrite, end up with
// the buffer reflecting the overwritten bytes (not a tail-extended duplicate).
func TestDataframe_BytesWriterSeeker(t *testing.T) {
t.Parallel()
var w bytesWriterSeeker
if _, err := w.Write([]byte("hello world")); err != nil {
t.Fatal(err)
}
if _, err := w.Seek(6, 0); err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte("WORLD")); err != nil {
t.Fatal(err)
}
if got := string(w.buf); got != "hello WORLD" {
t.Errorf("buf = %q, want \"hello WORLD\"", got)
}
}

Some files were not shown because too many files have changed in this diff Show More