Compare commits

..

28 Commits

Author SHA1 Message Date
xzcong0820
c0091b3ed9 emlbuilder: add LMSReplyType method for reply/forward type header
Add LMSReplyType method to the EML builder that sets the
X-LMS-Reply-Type header. This allows reply, reply-all, and forward
shortcuts to tag the EML with REPLY or FORWARD so that downstream
services can label the original message accordingly.

Call LMSReplyType("REPLY") in mail_reply.go and mail_reply_all.go,
and LMSReplyType("FORWARD") in mail_forward.go, immediately after
the existing LMSReplyToMessageID call.
2026-06-26 22:03:30 +08:00
liangshuo-1
8a268aa2d2 chore: release v1.0.59 (#1617) 2026-06-26 20:46:51 +08:00
ethan-zhx
39d60cb706 feat: add slides replace-pages and xml-get shortcuts (#1585)
* feat: add slides replace-pages shortcut

* feat: add slides xml get shortcut

* fix: stop advertising slides screenshot scope

* feat: expose slides presentation url
2026-06-26 15:56:55 +08:00
SunPeiYang996
d9330b7ab3 fix(docs): hide docs api-version compat flag (#1580) 2026-06-26 14:32:09 +08:00
hugang-lark
6b833257c7 fix: optimize calendar,vc,minutes,note shortcut and skill (#1571) 2026-06-26 12:24:03 +08:00
zhangjun-bytedance
ba51d4874e feat: support speaker list and nolark speaker replace (#1594) 2026-06-26 11:41:32 +08:00
liangshuo-1
40a09c8957 chore: release v1.0.58 (#1586) 2026-06-25 21:57:36 +08:00
taojieyeta-design
806e8679f6 feat: sync approval skill for meta api commands (#1499)
* feat: sync approval skill for meta api commands

* docs: fix approval skill reference links

* docs: restore approval reference links

* docs: align approval skill with review guidelines

* docs: clarify approval skill boundaries

* docs: remove implementation detail from approval description
2026-06-25 20:40:59 +08:00
Public Content Screenshot
d69761e205 fix(ci): reduce public content false positives 2026-06-25 20:23:15 +08:00
SunPeiYang996
7346de30b1 docs(lark-doc): restore style requirements (#1579)
Change-Id: I5c75a06ccac07586615c40db69b94d515f85d200
2026-06-25 19:18:05 +08:00
hanshaoshuai
cf93ee051c feat(ci): add public content safeguards 2026-06-25 19:03:14 +08:00
SunPeiYang996
fe32a6e0a9 feat(docs): support create title option (#1536)
* feat: support docs create title option

Change-Id: I6fd840fe813e5e664ea9ec680765fd41375cdebf

* docs: refine docs title guidance

Change-Id: I2f986a4606729bc791a1bff6c03aaa198b0798dc

* docs: keep lark doc skill create example

Change-Id: Ic7005e015c9e71a4582c1f4a8ac8222d552426d4

* test: allow docs create title flag in help

Change-Id: I0226e20c6bf2187eb6c4f0d2d5e37ab9225d4171
2026-06-25 18:05:47 +08:00
zhaojiaxing-coding
af9835c288 feat(drive): add +member-add shortcut with wiki space member collection collaborator support (#1204) 2026-06-25 17:45:42 +08:00
shifengjuan-dev
2e3073a532 docs(im): document chat.nickname get/update/delete (#1378) 2026-06-25 17:04:31 +08:00
liujiashu-shiro
1c92ed8841 feat: add im-markdown output for doc fetch (#1550)
* feat: add docs im-markdown fetch format

* refactor: tune docs im-markdown conversion

* test: expand docs im-markdown conversion coverage

* refactor: simplify docs im-markdown handlers

* test: cover docs im-markdown edge cases

* fix: expand doc im markdown tag downgrades

* fix: preserve blockquote paragraph breaks

* fix: handle im markdown nested tables and urls

* docs: document im markdown skill usage

* test: cover doc im markdown fetch

* test: strengthen doc fetch error coverage

* fix: fetch doc skill typo
2026-06-25 11:49:58 +08:00
cl900811
644c3c77dd doc(whiteboard):support export whiteboard as SVG and update whiteboard via SVG (#1559)
* feat(lark-whiteboard): update shortcut, support query or update whiteboard by svg

* feat(whiteboard): pin whiteboard-cli to v0.2.12 in lark-whiteboard skill

* fix(whiteboard): whiteboard shortcuts unit test

* fix(whiteboard): add whiteboard query shortcut unit test
2026-06-25 11:08:16 +08:00
xiongyuanwen-byted
bd898a1d74 feat(sheets): typed table I/O & error contract, workbook import/export, skill refresh (#1355)
* feat(sheets): add +sheet-show-gridline / +sheet-hide-gridline shortcuts

* 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

* 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)

* 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).

* 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.

* 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.

* 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.

* 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.

* 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

* 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.

* docs: add lark sheets financial modeling guidance

* 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.

* 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.

* 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.

* 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.

* 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).

* fix(sheets): regenerate flag defs and fix asasalint in table io

* 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.

* 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.

* 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)

* 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)

* 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.

* 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.

* 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.

* 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.

* 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).

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* docs(lark-sheets): sync from spec — +csv-put 含逗号公式正例 + 收敛警示标签

源同步自 sheet-skill-spec:write-cells 补含逗号公式 RFC 4180 转义正例与结构化写入优先指引;全 reference 收敛「高频致命错误」类标签。

* docs(lark-sheets): sync from spec — --max-chars 放出为可见 flag + 落盘优先指引

源同步自 sheet-skill-spec:--max-chars 放出(默认 500000,可调小避免大输出被 Bash/终端转存为文件、改 has_more 分页);read-data 增「大数据优先落盘」指引。

* 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 零改动

* docs(lark-sheets): sync from spec — set+H 改单引号 / 速查表补臆造命令名 / workbook-import 引导

* 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)

* docs(lark-sheets): sync from spec — set+H 告诫通则化(移入 stdin 段)

* 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 提示。

* 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).

* docs(sheets): note --url accepts wiki URLs (synced from spec)

* 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.

* 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).

* 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.

* 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.

* 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.

* 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).

* 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.

* 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.

* 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(...).

* 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.

* 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.

* 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.

* 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.

* 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

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* Revert "sync(sheets): pick up sheets_df.py — pandas ↔ JSON skill script from spec"

This reverts commit 2964983b92.

* 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.

* 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.

* refactor(sheets): drop --dataframe / --dataframe-out + apache/arrow dep

Per the design review at NcmxdRo2yoZ4OXxoMUZcxRZ7nHd, the Arrow IPC binary
input/output channel adds a heavy columnar runtime to the CLI for no new
capability — the typed JSON --sheets path already covers everything, and
the column-major / zero-copy advantages collapse the moment the CLI re-
encodes into the row-oriented sheets OpenAPI JSON body. Removing it also
lets us drop the `--ignore github.com/apache/arrow/go/v17` license-check
escape hatch.

Deleted:
- shortcuts/sheets/lark_sheet_dataframe.go (+ test)
- --dataframe branches in +table-put / +workbook-create
- --dataframe-out branch in +table-get
- StdinConsumed / MarkStdinConsumed exported methods (the binary stdin
  reader was the only out-of-band consumer); internal stdinConsumed
  guard against duplicate `-` input flags stays
- apache/arrow/go/v17 + transitive deps via `go mod tidy`
- CI go-licenses --ignore for arrow and the LICENSE.txt assertion step
- --dataframe / --dataframe-out coverage in skill references

Pandas users keep the round-trip via the existing skill script
skills/lark-sheets/scripts/sheets_df.py over the JSON path.

The full pre-removal state is preserved on branch feat/sheets-arrow-stash.

Upstream sheet-skill-spec follow-up: the two flag rows in the canonical
spec + base table tblV2F6fqIjyCFQW must also be dropped so the next sync
does not re-add them.

* sync(sheets): pick up --sheets one-liner fix from spec

Mirrors sheet-skill-spec 5562f83. The +table-put / +workbook-create
--sheets flag descriptions (and the --print-schema description on the
sheets array) now point at the existing df_to_sheet helper instead of
the previous misleading one-liner that produced a dict missing the
outer {"sheets":[...]} envelope and the per-sheet `name`. Agents that
copy-paste the description verbatim now build a valid payload.

Auto-synced via spec's generate:cli + sync:consumers; go generate
./shortcuts/sheets/... regenerated flag_defs_gen.go so its embedded
flagDefs stays byte-equal to data/flag-defs.json.

* test(sheets/e2e): close E2E coverage gaps for newly added shortcuts

AGENTS.md requires both dry-run and live E2E for every newly registered
shortcut, and behavior-changing refactors need at least the matching
half. Three gaps remained on feat/lark-sheets-develop:

- +sheet-show-gridline / +sheet-hide-gridline (new): only dry-run E2E.
  Add sheets_gridline_workflow_test.go — create a real spreadsheet,
  toggle hide then show against a live sub-sheet, assert ok=true on
  both (gridline state is write-only — there is no read-back field on
  +sheet-info / +workbook-info — so a successful envelope is the
  meaningful signal; the dry-run E2E already pins the wire shape).

- +workbook-import (new): only dry-run E2E. Add
  sheets_workbook_import_workflow_test.go — write a local CSV, run
  the full upload → create-task → poll, assert ready=true with a
  sheet token, +info confirms the imported workbook is reachable,
  cleanup deletes the spreadsheet.

- +workbook-export refactor (no-download default changed): had live
  E2E but no dry-run E2E in tests/cli_e2e/. Add
  sheets_workbook_export_dryrun_test.go — pin the three sheet-
  specific differences vs drive +export: type=sheet hard-coded,
  csv mode routes --sheet-id onto sub_id (xlsx mode omits it), and
  --output-path maps onto the dry-run plan's top-level output_dir.
  Also pins the csv-without-sheet-id validation error.

* refactor(sheets): unify workbookCreatedButFillFailed with OutPartialFailure

Three "made it halfway and stopped" exits in the sheets domain previously
disagreed on shape, which made the post-failure recovery flow hard for
agents to predict from one command to another:

- +table-put partial write           → exit 1, stdout ok:false envelope
- +table-put zero-sheet write        → exit 1, stderr api/server_error
- +workbook-create create-but-fill   → exit 2, stderr validation/failed_precondition

OutPartialFailure exists exactly for "the side effect landed but the
follow-up didn't" — it stamps an ok:false result envelope on stdout
(carrying the state the caller needs to recover) and returns the bare
partial-failure exit signal. The workbook-create fill-failure path was
the odd one out: it surfaced as a typed failed_precondition error on
stderr, which agents couldn't tell apart from a plain validation refusal
even though the spreadsheet really did exist and a retry / cleanup was
possible.

Migrate workbookCreatedButFillFailed onto OutPartialFailure so the four
call sites in +workbook-create's Execute (sheet-resolve failure, initial
fill failure, style-only resolve failure, style-only apply failure) emit
the same envelope shape +table-put's partial write does:

  {
    "ok": false,
    "data": {
      "spreadsheet_token": "shtNEW",
      "reason": "spreadsheet shtNEW created but initial fill failed",
      "hint":   "the spreadsheet exists; retry the fill … or delete it",
      "cause":  {"category": "...", "subtype": "...", "message": "..."}
    }
  }

The inner failure's typed problem (category / subtype / message) is
flattened into the `cause` field so agents stay diagnosable from the JSON
envelope alone, instead of having to errors.Unwrap a Go error.

Updated TestExecute_WorkbookCreate_FillFailureKeepsToken to assert the
new shape (ok:false envelope on stdout, *output.PartialFailureError exit
signal, structured cause carrying the underlying invalid_response
subtype) — preserving the original test intent (token must survive for
recovery; inner cause must stay diagnosable) under the new contract.

* chore(sheets): three review nits — WithCause + stale comment + unexport

- shortcuts/sheets/flag_schema_validate.go:106 — composite-JSON shape
  validation was wrapping vErr's message into a typed sheets validation
  error without preserving vErr as the typed cause; add the missing
  .WithCause(vErr) so errors.Unwrap and ProblemOf still find the
  underlying validator error (matches every other typed-error chain
  helper in the file).

- shortcuts/sheets/lark_sheet_batch_update.go:92 — comment claimed
  batchUpdateInput returns "FlagErrorf-typed errors", but FlagErrorf no
  longer exists (the typed-error migration replaced it with
  common.ValidationErrorf / errs.ValidationError); update the comment
  to reflect what is actually returned.

- shortcuts/drive/drive_export.go:121 — drop the ValidateExport public
  alias and rename to validateExport. sheets +workbook-export reuses
  RunExport / PlanExportDryRun from this package but inlines its own
  (sheet-specific) Validate, so there is no cross-package call site —
  ValidateExport was a misleading sibling of the genuinely-shared
  ValidateImport. Comment added to record the asymmetry so future
  readers do not export it back.

* chore(deps): drop stale indirect bumps left by the arrow removal

The earlier --dataframe / --dataframe-out + apache/arrow/go/v17 removal
deleted the arrow consumer but left two indirect lines in go.mod pinned
to the versions arrow had pulled in:

  - github.com/kr/text                   v0.2.0
  - golang.org/x/exp  v0.0.0-20240222234643-814bf88cf225

With arrow gone, larksuite/cli was the only requirer of those exact
versions; every real consumer needs lower ones (kr/pretty wants
kr/text v0.1.0; charmbracelet/huh wants x/exp …20231006; xo/terminfo
wants x/exp …20220909). Removing the two indirect lines and running
`go mod tidy` lets MVS pick the real-consumer versions and drops the
explicit indirect entries entirely — go.mod net-diff against main is
now zero for this branch.

Verified locally: go build ./...; go test ./shortcuts/sheets/...
./shortcuts/drive/... ./shortcuts/common/... ./internal/auth/...
./cmd/auth/... — all green.

---------

Co-authored-by: zhengzhijie <zhengzhijie.j@bytedance.com>
Co-authored-by: Chenweifeng-bd <chenweifeng.1534@bytedance.com>
2026-06-25 10:48:13 +08:00
ZEden0
898e6d4b3b fix: prefix docs resource shortcuts (#1564) 2026-06-24 22:39:57 +08:00
zgz2048
7df37ed715 feat(base): Add Base URL and title resolve shortcuts (#1338)
* feat(base): add URL and title resolve shortcuts

* docs: clarify base coordinate resolution

* fix(base): address resolve shortcut ci

* fix(base): format resolved record share hint

* fix(base): simplify record share hint data

* fix(base): use field ids in resolved record data

* fix(base): guide record share resolve to update record

* fix(base): include record upsert example in resolve hint

* fix(base): reject add-record urls in resolver

* fix(base): validate title resolve query length

* fix(base): hide resolve alias flags from help

* fix(base): prefer title flag for title resolve

* docs(base): clarify token resolution wording
2026-06-24 22:26:29 +08:00
91-enjoy
3f9ace8af5 feat: support card action trigger (#1528)
Add support for card.action.trigger, the event fired when a user interacts with an
interactive card (button click, form submit, dropdown, checkbox, input, date picker, etc.).
The handler flattens the V2 envelope into a structured output and auto-fetches the original
card content (card_content) at consume time, enabling a complete read-then-update workflow
without extra API calls.
2026-06-24 22:00:08 +08:00
Zhang-986
b3514e5519 fix(binding): skip unix mode audit on windows (#1525) 2026-06-24 20:54:24 +08:00
ILUO
b46e60c156 feat: add task event consumer (#1510)
* feat: add task event consumer

* fix: address task event review feedback

* feat: remove legacy task event subscription shortcut

* test: strengthen task preconsume error assertions
2026-06-24 17:32:02 +08:00
chenxingtong-bytedance
d71bab0061 docs(im): clarify audio message opus requirement (#1271)
Fix IM shortcut behavior for audio messages to match the Feishu/Lark file upload API: --audio is for voice messages and supports only Opus audio. Non-Opus local/URL inputs such as mp3 and wav are now rejected before upload with an actionable typed validation error. Users can still send those files as attachments with --file
2026-06-24 14:41:54 +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
378 changed files with 30488 additions and 13898 deletions

View File

@@ -5,6 +5,7 @@ on:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened, edited]
workflow_dispatch:
permissions:
@@ -70,6 +71,7 @@ jobs:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
@@ -87,6 +89,23 @@ jobs:
- name: Run errs/ lint guards (lintcheck)
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
script-test:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
- name: Run script tests
run: make script-test
deterministic-gate:
needs: fast-gate
runs-on: ubuntu-latest
@@ -109,8 +128,28 @@ jobs:
env:
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
- name: Write public content metadata
if: ${{ github.event_name == 'pull_request' }}
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_BRANCH: ${{ github.head_ref }}
run: |
mkdir -p .tmp/quality-gate
python3 - <<'PY'
import json
import os
with open(".tmp/quality-gate/public-content-metadata.json", "w", encoding="utf-8") as f:
json.dump({
"title": os.environ.get("PR_TITLE", ""),
"body": os.environ.get("PR_BODY", ""),
"branch": os.environ.get("PR_BRANCH", ""),
}, f)
f.write("\n")
PY
- name: Run CLI deterministic gate
run: make quality-gate
run: PUBLIC_CONTENT_METADATA=.tmp/quality-gate/public-content-metadata.json make quality-gate
- name: Upload quality gate facts
if: ${{ always() && github.event_name == 'pull_request' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
@@ -220,7 +259,7 @@ jobs:
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
e2e-dry-run:
needs: [unit-test, lint, deterministic-gate]
needs: [unit-test, lint, script-test, deterministic-gate]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
@@ -241,7 +280,7 @@ jobs:
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
e2e-live:
needs: [unit-test, lint, deterministic-gate]
needs: [unit-test, lint, script-test, deterministic-gate]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
permissions:
@@ -333,7 +372,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -345,6 +384,7 @@ jobs:
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | script-test | ${{ needs.script-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deterministic-gate | ${{ needs.deterministic-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
@@ -361,6 +401,7 @@ jobs:
"${{ needs.fast-gate.result }}" \
"${{ needs.unit-test.result }}" \
"${{ needs.lint.result }}" \
"${{ needs.script-test.result }}" \
"${{ needs.deterministic-gate.result }}" \
"${{ needs.coverage.result }}" \
"${{ needs.deadcode.result }}" \

28
.github/workflows/comment-audit.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Comment Audit
on:
issue_comment:
types: [created, edited]
pull_request_review:
types: [submitted, edited]
pull_request_review_comment:
types: [created, edited]
permissions:
contents: read
jobs:
public-content-comment-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- name: Post-publication comment audit
run: |
mkdir -p .tmp/comment-audit
cp "$GITHUB_EVENT_PATH" .tmp/comment-audit/event.json
go run ./internal/qualitygate/cmd/comment-audit --event .tmp/comment-audit/event.json --kind "$GITHUB_EVENT_NAME"

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) {
@@ -85,31 +88,44 @@ jobs:
commit_sha: targetHeadSha,
});
const candidatePRs = associatedPRs.filter((candidate) =>
candidate.state === "open" &&
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
if (candidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (candidatePRs.length === 1) {
prNumber = candidatePRs[0].number;
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
state: "all",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
if (candidatePRs.length !== 1) {
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
} else {
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
}
prNumber = candidatePRs[0].number;
}
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
const { data: pr } = await github.rest.pulls.get({
@@ -118,12 +134,17 @@ jobs:
pull_number: prNumber,
});
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
if (pr.state !== "open") {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
if (pr.head.sha !== targetHeadSha) {
core.notice("PR quality summary skipped: workflow_run is stale for this PR head");
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 +276,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 +303,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) {
@@ -293,31 +317,44 @@ jobs:
commit_sha: targetHeadSha,
});
const candidatePRs = associatedPRs.filter((candidate) =>
candidate.state === "open" &&
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
if (candidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (candidatePRs.length === 1) {
prNumber = candidatePRs[0].number;
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
state: "all",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
if (candidatePRs.length !== 1) {
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
} else {
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
}
prNumber = candidatePRs[0].number;
}
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
const { data: pr } = await github.rest.pulls.get({
@@ -326,12 +363,22 @@ jobs:
pull_number: prNumber,
});
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
if (pr.state !== "open") {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
if (!pr.head.repo) {
core.notice("semantic review skipped: workflow_run target PR head repository is unavailable");
core.setOutput("stale", "true");
return;
}
if (pr.head.sha !== targetHeadSha) {
core.notice("semantic review skipped: workflow_run is stale for this PR head");
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");
@@ -383,6 +430,10 @@ jobs:
repo: context.repo.repo,
pull_number: pr,
});
if (pull.state !== "open") {
core.notice("semantic review skipped infrastructure failure check: PR is no longer open");
return;
}
if (pull.head.sha !== headSha) {
core.notice("semantic review skipped infrastructure failure check: PR head changed");
return;

6
.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
@@ -46,3 +51,4 @@ app.log
cover*.out
lark-env.sh
/automations/

View File

@@ -2,6 +2,71 @@
All notable changes to this project will be documented in this file.
## [v1.0.59] - 2026-06-26
### Features
- **slides**: Add `+replace-pages` and `xml get` shortcuts, and expose the presentation URL (#1585)
- **minutes**: Support speaker list and no-Lark speaker replace (#1594)
- **calendar/vc/minutes**: Optimize and extend calendar, vc, minutes, and note shortcuts and skills (#1571)
### Bug Fixes
- **docs**: Hide docs `api-version` compat flag (#1580)
## [v1.0.58] - 2026-06-25
### Features
- **sheets**: Typed table I/O and error contract, workbook import/export, and skill refresh (#1355)
- **base**: Add Base URL and title resolve shortcuts (#1338)
- **drive**: Add `+member-add` shortcut with wiki space member collection collaborator support (#1204)
- **doc**: Support `create` title option (#1536)
- **doc**: Add `im-markdown` output format for doc fetch (#1550)
- **whiteboard**: Export whiteboard as SVG and update whiteboard via SVG (#1559)
- **card**: Support `card.action.trigger` event with auto-fetched card content (#1528)
- **task**: Add task event consumer (#1510)
### Bug Fixes
- **doc**: Prefix docs resource shortcuts (#1564)
- **binding**: Skip unix mode audit on Windows (#1525)
### Documentation
- **approval**: Sync approval skill for meta API commands (#1499)
- **doc**: Restore lark-doc style requirements (#1579)
- **im**: Document `chat.nickname` get/update/delete (#1378)
- **im**: Clarify audio message opus requirement (#1271)
### Build
- **ci**: Add public content safeguards and reduce false positives
## [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 +1277,9 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
[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

View File

@@ -12,6 +12,7 @@ QUALITY_GATE_DIR ?= .tmp/quality-gate
QUALITY_GATE_MANIFEST_OUT ?= $(QUALITY_GATE_DIR)/command-manifest.json
QUALITY_GATE_COMMAND_INDEX_OUT ?= $(QUALITY_GATE_DIR)/command-index.json
QUALITY_GATE_FACTS_OUT ?= $(QUALITY_GATE_DIR)/facts.json
PUBLIC_CONTENT_METADATA ?= $(QUALITY_GATE_DIR)/public-content-metadata.json
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
PREFIX ?= /usr/local
@@ -69,7 +70,8 @@ integration-test: build
test: vet fmt-check script-test unit-test examples-build integration-test
quality-gate: build
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT))
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT)) $(dir $(PUBLIC_CONTENT_METADATA))
test -f $(PUBLIC_CONTENT_METADATA) || printf '{}\n' > $(PUBLIC_CONTENT_METADATA)
LARKSUITE_CLI_REMOTE_META=off \
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
@@ -89,6 +91,7 @@ quality-gate: build
--changed-from $(QUALITY_GATE_CHANGED_FROM_RESOLVED) \
--manifest $(QUALITY_GATE_MANIFEST_OUT) \
--command-index $(QUALITY_GATE_COMMAND_INDEX_OUT) \
--public-content-metadata $(PUBLIC_CONTENT_METADATA) \
--facts-out $(QUALITY_GATE_FACTS_OUT)
install: build

View File

@@ -198,7 +198,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
lark-cli docs +create --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
```
Run `lark-cli <service> --help` to see all shortcut commands.

View File

@@ -199,7 +199,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
lark-cli docs +create --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
```
运行 `lark-cli <service> --help` 查看所有快捷命令。

View File

@@ -26,6 +26,7 @@ func TestRunList_TextOutput(t *testing.T) {
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
"im.message.receive_v1",
"im.message.message_read_v1",
"task.task.update_user_access_v2",
} {
if !strings.Contains(out, want) {
t.Errorf("list output missing %q; full output:\n%s", want, out)
@@ -55,4 +56,17 @@ func TestRunList_JSONOutput(t *testing.T) {
}
}
}
var foundTask bool
for _, row := range rows {
if row["key"] == "task.task.update_user_access_v2" {
foundTask = true
if row["single_consumer"] != true {
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
}
}
}
if !foundTask {
t.Fatal("event list JSON missing task.task.update_user_access_v2")
}
}

View File

@@ -96,6 +96,34 @@ func TestRunSchema_JSONOutput(t *testing.T) {
}
}
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
if payload["jq_root_path"] != ".event" {
t.Errorf("jq_root_path = %v, want .event", payload["jq_root_path"])
}
if payload["single_consumer"] != true {
t.Errorf("single_consumer = %v, want true", payload["single_consumer"])
}
resolved := payload["resolved_output_schema"].(map[string]interface{})
props := resolved["properties"].(map[string]interface{})
eventProps := props["event"].(map[string]interface{})["properties"].(map[string]interface{})
if got := eventProps["task_guid"].(map[string]interface{})["format"]; got != "task_guid" {
t.Errorf("task_guid format = %v, want task_guid", got)
}
if _, ok := eventProps["event_types"].(map[string]interface{})["items"].(map[string]interface{})["enum"]; !ok {
t.Fatalf("event_types enum missing in schema: %#v", eventProps["event_types"])
}
}
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
const syntheticKey = "test.evt_sub"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })

132
events/im/card_action.go Normal file
View File

@@ -0,0 +1,132 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"strings"
"github.com/larksuite/cli/internal/event"
)
// CardActionTriggerOutput is the flattened shape for card.action.trigger.
type CardActionTriggerOutput struct {
Type string `json:"type" desc:"Event type; always card.action.trigger"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string)" kind:"timestamp_ms"`
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id" kind:"open_id"`
MessageID string `json:"message_id,omitempty" desc:"Message ID of the card" kind:"message_id"`
ChatID string `json:"chat_id,omitempty" desc:"Chat ID" kind:"chat_id"`
Host string `json:"host,omitempty" desc:"Host type: im_message / im_top_notice"`
Token string `json:"token,omitempty" desc:"Token for delay card update (valid 30 min, max 2 updates)"`
ActionTag string `json:"action_tag,omitempty" desc:"Triggered element type: button/select_static/input/checker/etc"`
ActionValue string `json:"action_value,omitempty" desc:"Developer-defined action value as JSON string"`
ActionName string `json:"action_name,omitempty" desc:"Element name attribute"`
FormValue string `json:"form_value,omitempty" desc:"Form submission values as JSON string (only on form submit)"`
InputValue string `json:"input_value,omitempty" desc:"Input field value (only for input elements)"`
Option string `json:"option,omitempty" desc:"Selected option value (for single-select dropdown)"`
Options string `json:"options,omitempty" desc:"Selected options, comma-separated (for multi-select)"`
Checked bool `json:"checked" desc:"Checkbox state (for checkbox elements)"`
Timezone string `json:"timezone,omitempty" desc:"User timezone for date/time picker interactions"`
CardContent string `json:"card_content,omitempty" desc:"Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails"`
}
func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
Operator struct {
OpenID string `json:"open_id"`
} `json:"operator"`
Token string `json:"token"`
Host string `json:"host"`
Action struct {
Tag string `json:"tag"`
Value map[string]interface{} `json:"value"`
Name string `json:"name"`
FormValue map[string]interface{} `json:"form_value"`
InputValue string `json:"input_value"`
Option string `json:"option"`
Options []string `json:"options"`
Checked bool `json:"checked"`
Timezone string `json:"timezone"`
} `json:"action"`
Context struct {
OpenMessageID string `json:"open_message_id"`
OpenChatID string `json:"open_chat_id"`
} `json:"context"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload
}
actionValue := marshalToString(envelope.Event.Action.Value)
formValue := marshalToString(envelope.Event.Action.FormValue)
options := strings.Join(envelope.Event.Action.Options, ",")
out := &CardActionTriggerOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
OperatorID: envelope.Event.Operator.OpenID,
MessageID: envelope.Event.Context.OpenMessageID,
ChatID: envelope.Event.Context.OpenChatID,
Host: envelope.Event.Host,
Token: envelope.Event.Token,
ActionTag: envelope.Event.Action.Tag,
ActionValue: actionValue,
ActionName: envelope.Event.Action.Name,
FormValue: formValue,
InputValue: envelope.Event.Action.InputValue,
Option: envelope.Event.Action.Option,
Options: options,
Checked: envelope.Event.Action.Checked,
Timezone: envelope.Event.Action.Timezone,
}
if out.MessageID != "" && rt != nil {
out.CardContent = fetchCardUserDSL(ctx, rt, out.MessageID)
}
return json.Marshal(out)
}
// fetchCardUserDSL gets the card message content via message get API.
// Returns empty string on any failure — never blocks event consumption.
func fetchCardUserDSL(ctx context.Context, rt event.APIClient, messageID string) string {
path := "/open-apis/im/v1/messages/" + messageID + "?card_msg_content_type=user_card_content"
resp, err := rt.CallAPI(ctx, "GET", path, nil)
if err != nil {
return ""
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Items []struct {
Body struct {
Content string `json:"content"`
} `json:"body"`
} `json:"items"`
} `json:"data"`
}
if json.Unmarshal(resp, &result) != nil || result.Code != 0 || len(result.Data.Items) == 0 {
return ""
}
return result.Data.Items[0].Body.Content
}
func marshalToString(m map[string]interface{}) string {
if len(m) == 0 {
return ""
}
b, _ := json.Marshal(m)
return string(b)
}

View File

@@ -0,0 +1,432 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestCardActionTriggerRegistered(t *testing.T) {
def, ok := event.Lookup("card.action.trigger")
if !ok {
t.Fatal("card.action.trigger should be registered via Keys()")
}
if def.Schema.Custom == nil {
t.Error("card.action.trigger must set Schema.Custom")
}
if def.Process == nil {
t.Error("card.action.trigger must set Process")
}
if len(def.Scopes) == 0 {
t.Error("Scopes must not be empty")
}
}
func TestProcessCardAction_Button(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_btn_001",
"event_type": "card.action.trigger",
"create_time": "1776409469273"
},
"event": {
"operator": {"open_id": "ou_operator"},
"token": "c-token-btn",
"host": "im_message",
"action": {
"tag": "button",
"value": {"key": "approve"},
"name": "approve_btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_msg_001",
"open_chat_id": "oc_chat_001"
}
}
}`
out := runCardAction(t, payload, nil)
if out.Type != "card.action.trigger" {
t.Errorf("Type = %q, want card.action.trigger", out.Type)
}
if out.EventID != "ev_btn_001" {
t.Errorf("EventID = %q", out.EventID)
}
if out.OperatorID != "ou_operator" {
t.Errorf("OperatorID = %q", out.OperatorID)
}
if out.ActionTag != "button" {
t.Errorf("ActionTag = %q, want button", out.ActionTag)
}
if out.ActionValue != `{"key":"approve"}` {
t.Errorf("ActionValue = %q", out.ActionValue)
}
if out.ActionName != "approve_btn" {
t.Errorf("ActionName = %q", out.ActionName)
}
if out.Token != "c-token-btn" {
t.Errorf("Token = %q", out.Token)
}
if out.MessageID != "om_msg_001" {
t.Errorf("MessageID = %q", out.MessageID)
}
if out.ChatID != "oc_chat_001" {
t.Errorf("ChatID = %q", out.ChatID)
}
if out.Host != "im_message" {
t.Errorf("Host = %q", out.Host)
}
if out.Timestamp != "1776409469273" {
t.Errorf("Timestamp = %q", out.Timestamp)
}
}
func TestProcessCardAction_FormSubmit(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_form_001",
"event_type": "card.action.trigger",
"create_time": "1776409469274"
},
"event": {
"operator": {"open_id": "ou_form_user"},
"token": "c-token-form",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "submit_btn",
"form_value": {"name": "test-user", "reason": "testing"},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_form_001",
"open_chat_id": "oc_chat_002"
}
}
}`
out := runCardAction(t, payload, nil)
if out.FormValue != `{"name":"test-user","reason":"testing"}` {
t.Errorf("FormValue = %q", out.FormValue)
}
if out.ActionTag != "button" {
t.Errorf("ActionTag = %q, want button", out.ActionTag)
}
}
func TestProcessCardAction_MultiSelect(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_ms_001",
"event_type": "card.action.trigger",
"create_time": "1776409469275"
},
"event": {
"operator": {"open_id": "ou_ms_user"},
"token": "c-token-ms",
"host": "im_message",
"action": {
"tag": "multi_select_static",
"value": {},
"name": "multi_select",
"options": ["opt_1", "opt_3"],
"checked": false
},
"context": {
"open_message_id": "om_ms_001",
"open_chat_id": "oc_chat_003"
}
}
}`
out := runCardAction(t, payload, nil)
if out.Options != "opt_1,opt_3" {
t.Errorf("Options = %q, want opt_1,opt_3", out.Options)
}
if out.ActionTag != "multi_select_static" {
t.Errorf("ActionTag = %q", out.ActionTag)
}
}
func TestProcessCardAction_Input(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_input_001",
"event_type": "card.action.trigger",
"create_time": "1776409469276"
},
"event": {
"operator": {"open_id": "ou_input_user"},
"token": "c-token-input",
"host": "im_message",
"action": {
"tag": "input",
"value": {},
"name": "text_input",
"input_value": "hello world",
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_input_001",
"open_chat_id": "oc_chat_004"
}
}
}`
out := runCardAction(t, payload, nil)
if out.InputValue != "hello world" {
t.Errorf("InputValue = %q", out.InputValue)
}
if out.ActionTag != "input" {
t.Errorf("ActionTag = %q", out.ActionTag)
}
}
func TestProcessCardAction_DatePicker(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_date_001",
"event_type": "card.action.trigger",
"create_time": "1776409469277"
},
"event": {
"operator": {"open_id": "ou_date_user"},
"token": "c-token-date",
"host": "im_message",
"action": {
"tag": "date_picker",
"value": {},
"name": "date_selector",
"option": "2024-04-01 +0800",
"timezone": "Asia/Shanghai",
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_date_001",
"open_chat_id": "oc_chat_005"
}
}
}`
out := runCardAction(t, payload, nil)
if out.Option != "2024-04-01 +0800" {
t.Errorf("Option = %q", out.Option)
}
if out.Timezone != "Asia/Shanghai" {
t.Errorf("Timezone = %q", out.Timezone)
}
}
func TestProcessCardAction_MalformedPayload(t *testing.T) {
raw := &event.RawEvent{
EventID: "ev_bad",
EventType: "card.action.trigger",
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := processCardAction(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
}
func TestProcessCardAction_MessageGetSuccess(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_mg_ok",
"event_type": "card.action.trigger",
"create_time": "1776409469278"
},
"event": {
"operator": {"open_id": "ou_mg_user"},
"token": "c-token-mg",
"host": "im_message",
"action": {
"tag": "button",
"value": {"key": "click"},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_mg_001",
"open_chat_id": "oc_chat_mg"
}
}
}`
cardContent := `{"header":{"title":{"tag":"plain_text","content":"A card"}}}`
mock := &mockAPIClient{resp: `{
"code": 0,
"msg": "success",
"data": {
"items": [{
"body": {"content": "` + escapeJSON(cardContent) + `"}
}]
}
}`}
out := runCardAction(t, payload, mock)
if out.CardContent == "" {
t.Error("CardContent should not be empty when message get succeeds")
}
}
func TestProcessCardAction_MessageGetErrorCode(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_mg_ec",
"event_type": "card.action.trigger",
"create_time": "1776409469279"
},
"event": {
"operator": {"open_id": "ou_mg_user2"},
"token": "c-token-mg2",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_mg_002",
"open_chat_id": "oc_chat_mg2"
}
}
}`
mock := &mockAPIClient{resp: `{"code": 1, "msg": "error", "data": {"items": []}}`}
out := runCardAction(t, payload, mock)
if out.CardContent != "" {
t.Errorf("CardContent should be empty when code != 0, got %q", out.CardContent)
}
}
func TestProcessCardAction_MessageGetFailure(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_mg_fail",
"event_type": "card.action.trigger",
"create_time": "1776409469280"
},
"event": {
"operator": {"open_id": "ou_mg_user3"},
"token": "c-token-mg3",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_mg_003",
"open_chat_id": "oc_chat_mg3"
}
}
}`
mock := &mockAPIClient{errResp: true}
out := runCardAction(t, payload, mock)
if out.CardContent != "" {
t.Errorf("CardContent should be empty when message get fails, got %q", out.CardContent)
}
}
func TestProcessCardAction_EmptyMessageID(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_no_msg",
"event_type": "card.action.trigger",
"create_time": "1776409469281"
},
"event": {
"operator": {"open_id": "ou_no_msg"},
"token": "c-token-nm",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "",
"open_chat_id": "oc_chat_nm"
}
}
}`
out := runCardAction(t, payload, nil)
if out.CardContent != "" {
t.Errorf("CardContent should be empty when message_id is absent, got %q", out.CardContent)
}
}
type mockAPIClient struct {
resp string
errResp bool
}
func (m *mockAPIClient) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
if m.errResp {
return nil, context.DeadlineExceeded
}
return json.RawMessage(m.resp), nil
}
func runCardAction(t *testing.T, payload string, rt event.APIClient) CardActionTriggerOutput {
t.Helper()
raw := &event.RawEvent{
EventID: "ev_test",
EventType: "card.action.trigger",
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processCardAction(context.Background(), rt, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
var out CardActionTriggerOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid CardActionTriggerOutput JSON: %v\nraw=%s", err, string(got))
}
return out
}
func escapeJSON(s string) string {
b, _ := json.Marshal(s)
return string(b[1 : len(b)-1])
}

View File

@@ -27,6 +27,21 @@ func Keys() []event.KeyDefinition {
AuthTypes: []string{"bot"},
RequiredConsoleEvents: []string{"im.message.receive_v1"},
},
{
Key: "card.action.trigger",
DisplayName: "Card action",
Description: "Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).",
EventType: "card.action.trigger",
SubscriptionType: event.SubTypeCallback,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(CardActionTriggerOutput{})},
},
Process: processCardAction,
Scopes: []string{"im:message:readonly"},
AuthTypes: []string{"bot"},
SingleConsumer: true,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
for _, rk := range nativeIMKeys {

View File

@@ -7,6 +7,7 @@ package events
import (
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
"github.com/larksuite/cli/events/task"
"github.com/larksuite/cli/events/vc"
"github.com/larksuite/cli/events/whiteboard"
"github.com/larksuite/cli/internal/event"
@@ -17,6 +18,7 @@ func init() {
all := [][]event.KeyDefinition{
im.Keys(),
minutes.Keys(),
task.Keys(),
vc.Keys(),
whiteboard.Keys(),
}

23
events/task/native.go Normal file
View File

@@ -0,0 +1,23 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
// TaskUpdateUserAccessV2Data is the Task v2 update event payload under the
// standard Lark V2 event envelope.
type TaskUpdateUserAccessV2Data struct {
EventTypes []string `json:"event_types,omitempty" desc:"Task commit types included in this event" enum:"task_create,task_deleted,task_summary_update,task_desc_update,task_assignees_update,task_followers_update,task_reminders_update,task_start_due_update,task_completed_update"`
TaskGUID string `json:"task_guid,omitempty" desc:"Task GUID that changed" kind:"task_guid"`
}
var taskUpdateUserAccessCommitTypes = []string{
"task_create",
"task_deleted",
"task_summary_update",
"task_desc_update",
"task_assignees_update",
"task_followers_update",
"task_reminders_update",
"task_start_due_update",
"task_completed_update",
}

32
events/task/preconsume.go Normal file
View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
const taskSubscriptionPath = "/open-apis/task/v2/task_v2/task_subscription?user_id_type=open_id"
func taskSubscriptionPreConsume(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
if _, err := rt.CallAPI(ctx, "POST", taskSubscriptionPath, nil); err != nil {
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewNetworkError(
errs.SubtypeNetworkTransport,
"failed to subscribe task event",
).WithCause(err)
}
return nil, nil
}

View File

@@ -0,0 +1,119 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/larksuite/cli/errs"
)
type stubAPIClient struct {
err error
method string
path string
body interface{}
calls int
}
func (s *stubAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
s.method = method
s.path = path
s.body = body
s.calls++
if s.err != nil {
return nil, s.err
}
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
}
func TestTaskSubscriptionPreConsumeCallsSubscribeAPI(t *testing.T) {
rt := &stubAPIClient{}
cleanup, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("taskSubscriptionPreConsume error = %v", err)
}
if cleanup != nil {
t.Fatal("cleanup = non-nil, want nil because task subscription has no unsubscribe API")
}
if rt.calls != 1 {
t.Fatalf("calls = %d, want 1", rt.calls)
}
if rt.method != "POST" {
t.Errorf("method = %q, want POST", rt.method)
}
if rt.path != taskSubscriptionPath {
t.Errorf("path = %q, want %q", rt.path, taskSubscriptionPath)
}
if rt.body != nil {
t.Errorf("body = %#v, want nil", rt.body)
}
}
func TestTaskSubscriptionPreConsumeRequiresRuntime(t *testing.T) {
_, err := taskSubscriptionPreConsume(context.Background(), nil, nil)
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
if p.Subtype != errs.SubtypeUnknown {
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeUnknown)
}
}
func TestTaskSubscriptionPreConsumePassesThroughAPIError(t *testing.T) {
wantErr := errs.NewValidationError(errs.SubtypeFailedPrecondition, "subscription already exists")
rt := &stubAPIClient{err: wantErr}
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
if err != wantErr {
t.Fatalf("err identity changed: got %T %v, want original %T %v", err, err, wantErr, wantErr)
}
if !errors.Is(err, wantErr) {
t.Fatalf("err = %v, want %v", err, wantErr)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeFailedPrecondition)
}
}
func TestTaskSubscriptionPreConsumeWrapsUntypedAPIError(t *testing.T) {
cause := errors.New("connection reset")
rt := &stubAPIClient{err: cause}
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, cause) {
t.Fatalf("err = %v, want cause %v", err, cause)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryNetwork {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryNetwork)
}
if p.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeNetworkTransport)
}
}

33
events/task/register.go Normal file
View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package task registers Task-domain EventKeys.
package task
import (
"reflect"
"github.com/larksuite/cli/internal/event"
)
const eventTypeTaskUpdateUserAccessV2 = "task.task.update_user_access_v2"
// Keys returns all Task-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeTaskUpdateUserAccessV2,
DisplayName: "Task updated",
Description: "Triggered when tasks visible to the current user or app are created, deleted, or updated",
EventType: eventTypeTaskUpdateUserAccessV2,
Schema: event.SchemaDef{
Native: &event.SchemaSpec{Type: reflect.TypeOf(TaskUpdateUserAccessV2Data{})},
},
PreConsume: taskSubscriptionPreConsume,
Scopes: []string{"task:task:read"},
AuthTypes: []string{"user", "bot"},
RequiredConsoleEvents: []string{eventTypeTaskUpdateUserAccessV2},
SingleConsumer: true,
},
}
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"encoding/json"
"reflect"
"testing"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
)
func TestKeysTaskUpdateUserAccessMetadata(t *testing.T) {
keys := Keys()
if len(keys) != 1 {
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
}
def := keys[0]
if def.Key != eventTypeTaskUpdateUserAccessV2 {
t.Errorf("Key = %q, want %q", def.Key, eventTypeTaskUpdateUserAccessV2)
}
if def.EventType != eventTypeTaskUpdateUserAccessV2 {
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeTaskUpdateUserAccessV2)
}
if def.Schema.Native == nil {
t.Fatal("Schema.Native is nil")
}
if def.Schema.Native.Type != reflect.TypeOf(TaskUpdateUserAccessV2Data{}) {
t.Errorf("native type = %v, want TaskUpdateUserAccessV2Data", def.Schema.Native.Type)
}
if def.Process != nil {
t.Fatal("Native Task EventKey must not set Process")
}
if def.PreConsume == nil {
t.Fatal("PreConsume is nil")
}
if !def.SingleConsumer {
t.Fatal("SingleConsumer = false, want true")
}
if !reflect.DeepEqual(def.Scopes, []string{"task:task:read"}) {
t.Errorf("Scopes = %#v", def.Scopes)
}
if !reflect.DeepEqual(def.AuthTypes, []string{"user", "bot"}) {
t.Errorf("AuthTypes = %#v", def.AuthTypes)
}
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeTaskUpdateUserAccessV2}) {
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
}
}
func TestTaskUpdateUserAccessSchemaAnnotations(t *testing.T) {
raw := schemas.WrapV2Envelope(schemas.FromType(reflect.TypeOf(TaskUpdateUserAccessV2Data{})))
var schema map[string]interface{}
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatalf("unmarshal schema: %v", err)
}
eventProps := schema["properties"].(map[string]interface{})["event"].(map[string]interface{})["properties"].(map[string]interface{})
taskGUID := eventProps["task_guid"].(map[string]interface{})
if got := taskGUID["format"]; got != "task_guid" {
t.Errorf("task_guid format = %v, want task_guid", got)
}
eventTypes := eventProps["event_types"].(map[string]interface{})
items := eventTypes["items"].(map[string]interface{})
rawEnum, ok := items["enum"].([]interface{})
if !ok {
t.Fatalf("event_types item enum missing: %#v", items["enum"])
}
got := make(map[string]bool, len(rawEnum))
for _, v := range rawEnum {
got[v.(string)] = true
}
for _, want := range taskUpdateUserAccessCommitTypes {
if !got[want] {
t.Errorf("event_types enum missing %q; enum=%v", want, rawEnum)
}
}
}
func TestTaskUpdateUserAccessRegistersCleanly(t *testing.T) {
const key = eventTypeTaskUpdateUserAccessV2
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
for _, def := range Keys() {
event.RegisterKey(def)
}
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
}
}

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

@@ -131,31 +131,3 @@ func requireInTrustedDirs(effectivePath string, trustedDirs []string, label stri
}
return fmt.Errorf("%s: path %q is not inside any trusted directory", label, effectivePath)
}
// auditFilePermissions rejects world/group-writable modes (always) and
// world/group-readable modes (unless allowReadableByOthers is true, which
// exec commands typically need for their usual 755 mode).
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
info, err := vfs.Stat(effectivePath)
if err != nil {
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
}
mode := info.Mode().Perm()
if mode&0o002 != 0 {
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
}
if mode&0o020 != 0 {
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
}
if allowReadableByOthers {
return nil
}
if mode&0o004 != 0 {
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
}
if mode&0o040 != 0 {
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
}
return nil
}

View File

@@ -29,3 +29,31 @@ func checkOwnerUID(path, label string) error {
}
return nil
}
// auditFilePermissions rejects world/group-writable modes (always) and
// world/group-readable modes (unless allowReadableByOthers is true, which
// exec commands typically need for their usual 755 mode).
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
info, err := vfs.Stat(effectivePath)
if err != nil {
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
}
mode := info.Mode().Perm()
if mode&0o002 != 0 {
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
}
if mode&0o020 != 0 {
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
}
if allowReadableByOthers {
return nil
}
if mode&0o004 != 0 {
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
}
if mode&0o040 != 0 {
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
}
return nil
}

View File

@@ -5,7 +5,22 @@
package binding
import (
"fmt"
"github.com/larksuite/cli/internal/vfs"
)
// checkOwnerUID is a no-op on Windows where Unix UID semantics don't apply.
func checkOwnerUID(path, label string) error {
return nil
}
// auditFilePermissions skips POSIX permission-bit auditing on Windows because
// Go synthesizes mode bits from file attributes rather than NTFS ACLs.
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
if _, err := vfs.Stat(effectivePath); err != nil {
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
}
return nil
}

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package binding
import (
"os"
"path/filepath"
"testing"
)
func TestAssertSecurePath_WindowsIgnoresSyntheticUnixPermissionBits(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "secrets-getter.cmd")
if err := os.WriteFile(p, []byte("@echo off\r\n"), 0o600); err != nil {
t.Fatalf("write temp command: %v", err)
}
got, err := AssertSecurePath(AuditParams{
TargetPath: p,
Label: "exec provider command",
AllowInsecurePath: false,
AllowReadableByOthers: true,
})
if err != nil {
t.Fatalf("unexpected error for Windows synthetic mode bits: %v", err)
}
if got != p {
t.Errorf("got %q, want %q", got, p)
}
}

View File

@@ -0,0 +1,92 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
"github.com/larksuite/cli/internal/qualitygate/report"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
type eventPayload struct {
Comment *struct {
Body string `json:"body"`
} `json:"comment"`
Review *struct {
Body string `json:"body"`
} `json:"review"`
}
func main() {
eventPath := flag.String("event", os.Getenv("GITHUB_EVENT_PATH"), "GitHub event payload path")
kind := flag.String("kind", os.Getenv("GITHUB_EVENT_NAME"), "GitHub event kind")
flag.Parse()
if *eventPath == "" {
fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required")
os.Exit(2)
}
body, err := commentBody(*eventPath)
if err != nil {
fmt.Fprintf(os.Stderr, "comment-audit: %v\n", err)
os.Exit(2)
}
diags := diagnostics(publiccontent.ScanComment(*kind, body))
if len(diags) > 0 {
fmt.Fprintln(os.Stderr, auditFailureSummary(len(diags)))
}
report.Print(os.Stderr, diags)
os.Exit(report.ExitCode(diags))
}
func auditFailureSummary(count int) string {
return fmt.Sprintf("post-publication audit found public content findings: %d", count)
}
func commentBody(path string) (string, error) {
safePath, err := validate.SafeInputPath(path)
if err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
WithParam("--event").
WithCause(err)
}
data, err := vfs.ReadFile(safePath)
if err != nil {
return "", err
}
var payload eventPayload
if err := json.Unmarshal(data, &payload); err != nil {
return "", err
}
switch {
case payload.Comment != nil:
return payload.Comment.Body, nil
case payload.Review != nil:
return payload.Review.Body, nil
default:
return "", nil
}
}
func diagnostics(items []publiccontent.Finding) []report.Diagnostic {
out := make([]report.Diagnostic, 0, len(items))
for _, item := range items {
out = append(out, report.Diagnostic{
Rule: item.Rule,
Action: item.Action,
File: item.File,
Line: item.Line,
Message: item.Message,
Suggestion: item.Suggestion,
})
}
return out
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package main
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
)
func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
dir := t.TempDir()
if err := writeTestFile(filepath.Join(dir, "event.json"), `{"comment":{"body":"clean comment"}}`); err != nil {
t.Fatal(err)
}
origDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = os.Chdir(origDir)
})
got, err := commentBody("event.json")
if err != nil {
t.Fatalf("commentBody() error = %v", err)
}
if got != "clean comment" {
t.Fatalf("comment body = %q", got)
}
}
func TestCommentBodyRejectsUnsafeEventPath(t *testing.T) {
path := filepath.Join(t.TempDir(), "event.json")
if err := writeTestFile(path, `{"comment":{"body":"clean"}}`); err != nil {
t.Fatal(err)
}
_, err := commentBody(path)
problem, ok := errs.ProblemOf(err)
if err == nil || !ok {
t.Fatalf("commentBody(%q) error = %v, want unsafe path validation error", path, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("commentBody(%q) problem = %#v, want invalid argument validation", path, problem)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--event" {
t.Fatalf("commentBody(%q) error = %v, want --event validation param", path, err)
}
}
func TestAuditFailureSummaryStatesPostPublicationAudit(t *testing.T) {
got := auditFailureSummary(2)
want := "post-publication audit found public content findings: 2"
if got != want {
t.Fatalf("auditFailureSummary() = %q, want %q", got, want)
}
}
func writeTestFile(path, data string) error {
return os.WriteFile(path, []byte(data), 0o644)
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/report"
"github.com/larksuite/cli/internal/qualitygate/rules"
"github.com/larksuite/cli/internal/validate"
)
func main() {
@@ -41,6 +42,7 @@ func runCheck(args []string) int {
fs.StringVar(&opts.FactsOut, "facts-out", "", "write facts JSON to this path")
fs.StringVar(&opts.ManifestPath, "manifest", "", "hand-authored command manifest JSON")
fs.StringVar(&opts.CommandIndexPath, "command-index", "", "full command index JSON")
fs.StringVar(&opts.PublicContentMetadataPath, "public-content-metadata", "", "PR title/body metadata JSON for public content checks")
fs.BoolVar(&printLegacyCommandCandidates, "print-legacy-command-candidates", false, "print current non-kebab-case hand-authored command candidates")
fs.BoolVar(&printLegacyFlagCandidates, "print-legacy-flag-candidates", false, "print current non-kebab-case flag candidates")
if err := fs.Parse(args); err != nil {
@@ -48,6 +50,15 @@ func runCheck(args []string) int {
return 2
}
if opts.PublicContentMetadataPath != "" {
safePath, err := validate.SafeInputPath(opts.PublicContentMetadataPath)
if err != nil {
fmt.Fprintf(os.Stderr, "quality-gate check: --public-content-metadata: %v\n", err)
return 2
}
opts.PublicContentMetadataPath = safePath
}
if opts.ManifestPath == "" || opts.CommandIndexPath == "" {
fmt.Fprintln(os.Stderr, "quality-gate check: --manifest and --command-index are required")
return 2

View File

@@ -37,6 +37,37 @@ func TestCheckRequiresManifestInputs(t *testing.T) {
}
}
func TestCheckAcceptsPublicContentMetadataFlag(t *testing.T) {
code, stderr := runCheckCaptureStderr(t, []string{
"--repo", t.TempDir(),
"--cli-bin", "./lark-cli",
"--public-content-metadata", ".tmp/quality-gate/pr.json",
})
if code != 2 {
t.Fatalf("exit code = %d, stderr=%s", code, stderr)
}
if strings.Contains(stderr, "flag provided but not defined") {
t.Fatalf("public content metadata flag was not registered: %s", stderr)
}
if !strings.Contains(stderr, "--manifest and --command-index are required") {
t.Fatalf("stderr = %s", stderr)
}
}
func TestCheckRejectsUnsafePublicContentMetadataPath(t *testing.T) {
code, stderr := runCheckCaptureStderr(t, []string{
"--repo", t.TempDir(),
"--cli-bin", "./lark-cli",
"--public-content-metadata", filepath.Join(t.TempDir(), "pr.json"),
})
if code != 2 {
t.Fatalf("exit code = %d, stderr=%s", code, stderr)
}
if !strings.Contains(stderr, "--public-content-metadata") || !strings.Contains(stderr, "--file") {
t.Fatalf("stderr = %s, want unsafe public content metadata path error", stderr)
}
}
func TestCheckReportsManifestReadErrorsWithFlagName(t *testing.T) {
dir := t.TempDir()
manifestPath := filepath.Join(dir, "command-manifest.json")

View File

@@ -56,6 +56,14 @@ func run(args []string) int {
_ = semantic.WriteMarkdown(markdownOut, decision)
return 0
}
if reviewPath == "" && !semantic.BuildInputView(f).HasReviewableFacts() {
decision := finalizeDecision(block, waiverDiags, semantic.Decision{})
if err := writeSemanticOutputs(decisionOut, markdownOut, decision); err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
return 2
}
return decisionExitCode(decision)
}
review, err := semantic.LoadOrReviewWithConfig(context.Background(), f, reviewPath, modelConfig)
if err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
@@ -72,6 +80,15 @@ func run(args []string) int {
return 0
}
decision := semantic.DecideWithWaivers(f, review, policy, waivers)
decision = finalizeDecision(block, waiverDiags, decision)
if err := writeSemanticOutputs(decisionOut, markdownOut, decision); err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
return 2
}
return decisionExitCode(decision)
}
func finalizeDecision(block bool, waiverDiags []report.Diagnostic, decision semantic.Decision) semantic.Decision {
decision.BlockMode = block
if !block && len(decision.Blockers) > 0 {
for i := range decision.Blockers {
@@ -81,15 +98,21 @@ func run(args []string) int {
decision.Blockers = nil
}
decision.SystemWarnings = append(diagnosticSystemWarnings(waiverDiags), decision.SystemWarnings...)
return decision
}
func writeSemanticOutputs(decisionOut, markdownOut string, decision semantic.Decision) error {
if err := semantic.WriteDecision(decisionOut, decision); err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: write decision: %v\n", err)
return 2
return fmt.Errorf("write decision: %w", err)
}
if err := semantic.WriteMarkdown(markdownOut, decision); err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: write markdown: %v\n", err)
return 2
return fmt.Errorf("write markdown: %w", err)
}
if block && len(decision.Blockers) > 0 {
return nil
}
func decisionExitCode(decision semantic.Decision) int {
if decision.BlockMode && len(decision.Blockers) > 0 {
return 1
}
return 0

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/qualitygate/facts"
@@ -211,7 +212,19 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) {
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
}`, "")
factsPath := filepath.Join(t.TempDir(), "facts.json")
if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil {
f := facts.Facts{
SchemaVersion: 1,
Skills: []facts.SkillFact{{
SourceFile: "skills/lark-wiki/SKILL.md",
Line: 30,
Changed: true,
ReferencesInvalidCommand: true,
}},
}
if !semantic.BuildInputView(f).HasReviewableFacts() {
t.Fatal("test setup must contain reviewable facts")
}
if err := f.WriteFile(factsPath); err != nil {
t.Fatalf("write facts: %v", err)
}
decisionPath := filepath.Join(t.TempDir(), "decision.json")
@@ -228,6 +241,71 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) {
}
}
func TestRunShortCircuitsEmptySemanticInputWithoutReviewer(t *testing.T) {
t.Setenv("ARK_API_KEY", "")
t.Setenv("ARK_BASE_URL", "")
t.Setenv("ARK_MODEL", "")
repo := t.TempDir()
writeSemanticConfig(t, repo, `{
"schema_version": 1,
"default_enforcement": "observe",
"block_categories": ["skill_quality"]
}`, `{
"allowed": ["semantic-review-v1"],
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
}`, "")
factsPath := filepath.Join(t.TempDir(), "facts.json")
f := facts.Facts{
SchemaVersion: 1,
Commands: []facts.CommandFact{{
Path: "service command 1",
Domain: "service",
Changed: true,
Source: "service",
}},
Outputs: []facts.OutputFact{{
Command: "service command 1",
Domain: "service",
Changed: true,
Source: "service",
IsList: true,
HasDefaultLimit: true,
HasDecisionField: true,
}},
}
if semantic.BuildInputView(f).HasReviewableFacts() {
t.Fatal("test setup must not contain reviewable facts")
}
if err := f.WriteFile(factsPath); err != nil {
t.Fatalf("write facts: %v", err)
}
decisionPath := filepath.Join(t.TempDir(), "decision.json")
markdownPath := filepath.Join(t.TempDir(), "semantic.md")
code := run([]string{"--repo", repo, "--facts", factsPath, "--decision-out", decisionPath, "--markdown-out", markdownPath, "--block"})
if code != 0 {
t.Fatalf("run() = %d, want clean pass", code)
}
decision := readDecision(t, decisionPath)
if decision.Skipped || decision.Degraded || decision.InfrastructureFailure || !decision.BlockMode {
t.Fatalf("expected non-degraded pass decision: %#v", decision)
}
if len(decision.SystemWarnings) != 0 || len(decision.Warnings) != 0 || len(decision.Blockers) != 0 {
t.Fatalf("empty semantic view should not produce findings: %#v", decision)
}
data, err := os.ReadFile(markdownPath)
if err != nil {
t.Fatalf("read markdown: %v", err)
}
markdown := string(data)
if !strings.Contains(markdown, "No semantic blockers.") {
t.Fatalf("markdown missing pass summary: %s", markdown)
}
if strings.Contains(strings.ToLower(markdown), "skipped") || strings.Contains(strings.ToLower(markdown), "degraded") {
t.Fatalf("markdown should not report semantic review as skipped/degraded: %s", markdown)
}
}
func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testing.T) {
t.Setenv("ARK_API_KEY", "test-key")
t.Setenv("ARK_BASE_URL", "")
@@ -243,7 +321,19 @@ func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testi
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
}`, "")
factsPath := filepath.Join(t.TempDir(), "facts.json")
if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil {
f := facts.Facts{
SchemaVersion: 1,
Skills: []facts.SkillFact{{
SourceFile: "skills/lark-wiki/SKILL.md",
Line: 30,
Changed: true,
ReferencesInvalidCommand: true,
}},
}
if !semantic.BuildInputView(f).HasReviewableFacts() {
t.Fatal("test setup must contain reviewable facts")
}
if err := f.WriteFile(factsPath); err != nil {
t.Fatalf("write facts: %v", err)
}
decisionPath := filepath.Join(t.TempDir(), "decision.json")

View File

@@ -5,7 +5,8 @@
"error_hint",
"default_output",
"naming",
"skill_quality"
"skill_quality",
"public_content_leakage"
],
"rollout_groups": [
{
@@ -16,7 +17,8 @@
},
"categories": [
"error_hint",
"skill_quality"
"skill_quality",
"public_content_leakage"
],
"owner": "cli-owner",
"reason": "first semantic blocking rollout only affects changed facts"

View File

@@ -13,14 +13,15 @@ import (
)
type Facts struct {
SchemaVersion int `json:"schema_version"`
Commands []CommandFact `json:"commands,omitempty"`
Skills []SkillFact `json:"skills,omitempty"`
SkillQuality []SkillQualityFact `json:"skill_quality,omitempty"`
Errors []ErrorFact `json:"errors,omitempty"`
Outputs []OutputFact `json:"outputs,omitempty"`
Examples []CommandExample `json:"examples,omitempty"`
Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"`
SchemaVersion int `json:"schema_version"`
Commands []CommandFact `json:"commands,omitempty"`
Skills []SkillFact `json:"skills,omitempty"`
SkillQuality []SkillQualityFact `json:"skill_quality,omitempty"`
Errors []ErrorFact `json:"errors,omitempty"`
Outputs []OutputFact `json:"outputs,omitempty"`
Examples []CommandExample `json:"examples,omitempty"`
PublicContent []PublicContentFact `json:"public_content,omitempty"`
Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"`
}
type CommandFact struct {
@@ -109,6 +110,17 @@ type OutputFact struct {
HasDecisionField bool `json:"has_decision_field,omitempty"`
}
type PublicContentFact struct {
Rule string `json:"rule"`
Action report.Action `json:"action"`
File string `json:"file"`
Line int `json:"line"`
Source string `json:"source,omitempty"`
Excerpt string `json:"excerpt,omitempty"`
Message string `json:"message,omitempty"`
Suggestion string `json:"suggestion,omitempty"`
}
type DryRunRequest struct {
Method string `json:"method"`
URL string `json:"url"`
@@ -206,6 +218,11 @@ func BuildWithCommandLookup(m manifest.Manifest, commandLookup manifest.Manifest
}
}
func WithPublicContent(f Facts, publicContent []PublicContentFact) Facts {
f.PublicContent = publicContent
return f
}
type commandScope struct {
Domain string
Source string

View File

@@ -34,6 +34,7 @@ func TestFactsSchemaCarriesGatekeeperFields(t *testing.T) {
Errors: []ErrorFact{{Code: "invalid_input", Message: "bad path", Hint: "pass --file", Retryable: false, HintActionCount: 1, RequiredHint: true}},
Outputs: []OutputFact{{Command: "im messages list", Fields: []string{"message_id", "sender", "create_time"}, IsList: true, HasDefaultLimit: true, HasDecisionField: true}},
Skills: []SkillFact{{SourceFile: "skills/lark-doc/SKILL.md", Line: 1, DestructiveWithoutGuard: true, ScopeConflict: true}},
PublicContent: []PublicContentFact{{Rule: "public_content_generic_credential", Action: report.ActionReject, File: "docs/public.md", Line: 4, Excerpt: "api_key = <redacted>"}},
}
data, err := json.Marshal(f)
if err != nil {
@@ -43,7 +44,10 @@ func TestFactsSchemaCarriesGatekeeperFields(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal facts: %v", err)
}
if !got.Errors[0].RequiredHint || got.Outputs[0].Fields[0] != "message_id" || !got.Skills[0].ScopeConflict {
if !got.Errors[0].RequiredHint ||
got.Outputs[0].Fields[0] != "message_id" ||
!got.Skills[0].ScopeConflict ||
got.PublicContent[0].Rule != "public_content_generic_credential" {
t.Fatalf("facts lost gatekeeper fields: %#v", got)
}
}

View File

@@ -0,0 +1,343 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
)
func Collect(ctx context.Context, opts Options) ([]Finding, error) {
metadata, err := LoadMetadata(opts.MetadataPath)
if err != nil {
return nil, err
}
var out []Finding
changedFiles, base, err := changedFiles(ctx, opts.Repo, opts.ChangedFrom)
if err != nil {
return nil, err
}
patches := map[string][]changedChunk{}
if base != "" {
patches, err = changedPatches(ctx, opts.Repo, base)
if err != nil {
return nil, err
}
}
for _, file := range changedFiles {
if !scanChangedFile(file) {
continue
}
for _, chunk := range patches[file] {
findings := scanText(file, "file", chunk.Text, isDetectorRuleFile(file))
for i := range findings {
findings[i].Line += chunk.StartLine - 1
}
out = append(out, findings...)
out = append(out, semanticCandidate(file, "file", chunk.Text, chunk.StartLine)...)
}
privateKeyFindings, err := scanTouchedPrivateKeyBlocks(ctx, opts.Repo, file, patches[file])
if err != nil {
return nil, err
}
out = appendUniqueFindings(out, privateKeyFindings...)
}
if base != "" {
commitFindings, err := scanCommitMessages(ctx, opts.Repo, base)
if err != nil {
return nil, err
}
out = append(out, commitFindings...)
}
branchName := opts.BranchName
if branchName == "" {
branchName = metadata.Branch
}
if branchName == "" {
branchName = branchFromEnv()
}
if branchName == "" {
branchName = currentBranch(ctx, opts.Repo)
}
if branchName != "" {
out = append(out, scanText("branch", "branch", branchName, false)...)
}
out = append(out, scanMetadata(metadata)...)
sort.SliceStable(out, func(i, j int) bool {
if out[i].File != out[j].File {
return out[i].File < out[j].File
}
if out[i].Line != out[j].Line {
return out[i].Line < out[j].Line
}
return out[i].Rule < out[j].Rule
})
return out, nil
}
func currentBranch(ctx context.Context, repo string) string {
data, err := gitOutput(ctx, repo, "branch", "--show-current")
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func branchFromEnv() string {
for _, key := range []string{"PR_BRANCH", "GITHUB_HEAD_REF", "GITHUB_REF_NAME"} {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
}
return ""
}
func changedFiles(ctx context.Context, repo, changedFrom string) ([]string, string, error) {
if changedFrom == "" {
return nil, "", nil
}
baseBytes, err := gitOutput(ctx, repo, "merge-base", changedFrom, "HEAD")
if err != nil {
return nil, "", err
}
base := strings.TrimSpace(string(baseBytes))
files, err := diffFileNames(ctx, repo, base)
if err != nil {
return nil, "", err
}
sort.Strings(files)
return files, base, nil
}
func diffFileNames(ctx context.Context, repo, base string) ([]string, error) {
data, err := gitOutput(ctx, repo, "diff", "--name-only", "-z", "--diff-filter=ACMR", base+"..HEAD")
if err != nil {
return nil, err
}
var files []string
for _, file := range bytes.Split(data, []byte{0}) {
if len(file) == 0 {
continue
}
files = append(files, filepath.ToSlash(string(file)))
}
return files, nil
}
var detectorFixtureExclusions = map[string]bool{
"internal/qualitygate/publiccontent/collect_test.go": true,
"internal/qualitygate/publiccontent/rules.go": true,
"internal/qualitygate/publiccontent/scan.go": true,
"internal/qualitygate/publiccontent/scan_test.go": true,
}
func scanChangedFile(file string) bool {
normalized := strings.TrimPrefix(strings.ReplaceAll(file, "\\", "/"), "./")
return !detectorFixtureExclusions[normalized]
}
type changedChunk struct {
StartLine int
Text string
}
func (c changedChunk) endLine() int {
lines := strings.Count(strings.TrimRight(c.Text, "\n"), "\n") + 1
if lines < 1 {
lines = 1
}
return c.StartLine + lines - 1
}
func changedPatches(ctx context.Context, repo, base string) (map[string][]changedChunk, error) {
files, err := diffFileNames(ctx, repo, base)
if err != nil {
return nil, err
}
data, err := gitOutput(ctx, repo, "diff", "--no-ext-diff", "--unified=0", "--diff-filter=ACMR", base+"..HEAD")
if err != nil {
return nil, err
}
out := map[string][]changedChunk{}
var file string
var chunk *changedChunk
nextLine := 0
nextFile := 0
flush := func() {
if file == "" || chunk == nil || chunk.Text == "" {
chunk = nil
return
}
out[file] = append(out[file], *chunk)
chunk = nil
}
for _, raw := range strings.Split(string(data), "\n") {
switch {
case strings.HasPrefix(raw, "diff --git "):
flush()
file = ""
if nextFile < len(files) {
file = files[nextFile]
nextFile++
}
case strings.HasPrefix(raw, "@@ "):
flush()
start, ok := parseNewHunkStart(raw)
if !ok {
nextLine = 0
continue
}
nextLine = start
chunk = &changedChunk{StartLine: start}
case strings.HasPrefix(raw, "+") && !strings.HasPrefix(raw, "+++"):
if chunk == nil {
chunk = &changedChunk{StartLine: max(nextLine, 1)}
}
chunk.Text += strings.TrimPrefix(raw, "+") + "\n"
nextLine++
case strings.HasPrefix(raw, "-"):
continue
default:
if chunk != nil && strings.HasPrefix(raw, `\ No newline at end of file`) {
continue
}
flush()
}
}
flush()
return out, nil
}
func parseNewHunkStart(header string) (int, bool) {
parts := strings.Split(header, " ")
for _, part := range parts {
if !strings.HasPrefix(part, "+") {
continue
}
raw := strings.TrimPrefix(part, "+")
if before, _, ok := strings.Cut(raw, ","); ok {
raw = before
}
start, err := strconv.Atoi(raw)
return start, err == nil && start > 0
}
return 0, false
}
func scanCommitMessages(ctx context.Context, repo, base string) ([]Finding, error) {
data, err := gitOutput(ctx, repo, "log", "--format=%H%x00%B%x00", base+"..HEAD")
if err != nil {
return nil, err
}
parts := bytes.Split(data, []byte{0})
var out []Finding
for i := 0; i+1 < len(parts); i += 2 {
sha := strings.TrimSpace(string(parts[i]))
body := string(parts[i+1])
if sha == "" || body == "" {
continue
}
short := sha
if len(short) > 12 {
short = short[:12]
}
out = append(out, scanText("commit:"+short, "commit", body, false)...)
out = append(out, semanticCandidate("commit:"+short, "commit", body, 1)...)
}
return out, nil
}
type lineRange struct {
Start int
End int
}
func scanTouchedPrivateKeyBlocks(ctx context.Context, repo, file string, chunks []changedChunk) ([]Finding, error) {
if len(chunks) == 0 {
return nil, nil
}
data, err := gitOutput(ctx, repo, "show", "HEAD:"+file)
if err != nil {
return nil, err
}
var added []lineRange
for _, chunk := range chunks {
added = append(added, lineRange{Start: chunk.StartLine, End: chunk.endLine()})
}
var out []Finding
for _, block := range privateKeyBlocks(string(data)) {
if !rangesIntersectAny(block, added) {
continue
}
out = append(out, newFinding("public_content_private_key_block", file, block.Start, "file", "private key block"))
}
return out, nil
}
func privateKeyBlocks(text string) []lineRange {
lines := strings.Split(text, "\n")
var out []lineRange
inPrivateKey := false
start := 0
for i, line := range lines {
lineNo := i + 1
if !inPrivateKey && strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
inPrivateKey = true
start = lineNo
}
if inPrivateKey && strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
out = append(out, lineRange{Start: start, End: lineNo})
inPrivateKey = false
}
}
return out
}
func rangesIntersectAny(block lineRange, ranges []lineRange) bool {
for _, r := range ranges {
if block.Start <= r.End && r.Start <= block.End {
return true
}
}
return false
}
func appendUniqueFindings(items []Finding, additions ...Finding) []Finding {
for _, addition := range additions {
duplicate := false
for _, item := range items {
if item.Rule == addition.Rule &&
item.File == addition.File &&
item.Line == addition.Line &&
item.Source == addition.Source {
duplicate = true
break
}
}
if !duplicate {
items = append(items, addition)
}
}
return items
}
func gitOutput(ctx context.Context, repo string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = repo
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, stderr.Bytes())
}
return stdout.Bytes(), nil
}

View File

@@ -0,0 +1,885 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
writeFile(t, filepath.Join(repo, "baseline.md"), `BASE_`+`TOKEN="baseline-only"
`)
runGit(t, repo, "add", "baseline.md")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "public.md"), `# Public change
api_`+`key = "example-public-key"
`)
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{"title":"publish public docs","body":"Reviewed`+`-on: https://review.example.test/c/project/+/123"}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
rules := findingRules(got)
for _, want := range []string{
"public_content_generic_credential",
"public_content_change_id_trailer",
"public_content_reviewed_on_trailer",
} {
if !rules[want] {
t.Fatalf("missing rule %s in findings %#v", want, got)
}
}
for _, item := range got {
if item.File == "baseline.md" {
t.Fatalf("collector scanned unchanged baseline file: %#v", got)
}
}
}
func TestCollectScansOnlyChangedLinesInChangedFiles(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
writeFile(t, filepath.Join(repo, "docs", "workflow.md"), "SECRET_TOKEN=legacy-example\npublic baseline\n")
runGit(t, repo, "add", "docs/workflow.md")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "workflow.md"), "SECRET_TOKEN=legacy-example\npublic baseline\nnew public line\n")
runGit(t, repo, "add", "docs/workflow.md")
runGit(t, repo, "commit", "-m", "add public line")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
for _, item := range got {
if item.Rule == "public_content_generic_credential" && item.File == "docs/workflow.md" {
t.Fatalf("collector scanned unchanged legacy line in changed file: %#v", got)
}
}
}
func TestCollectSemanticCandidatesStoreSanitizedReviewText(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "base")
raw := "private launch plan for alpha-service rollout on Friday with SERVICE_" + "TOKEN=real-" + "secret-value"
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add semantic candidate")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
var found bool
for _, item := range got {
if item.Rule != "public_content_semantic_candidate" || item.File != "docs/public.md" {
continue
}
found = true
if !strings.Contains(item.Excerpt, "alpha-service rollout on Friday") {
t.Fatalf("semantic candidate should include sanitized review text, got %#v", item)
}
if strings.Contains(item.Excerpt, "real-"+"secret-value") {
t.Fatalf("semantic candidate leaked credential value: %#v", item)
}
if !strings.Contains(item.Excerpt, "SERVICE_TOKEN=<redacted>") {
t.Fatalf("semantic candidate should redact credentials in review text, got %#v", item)
}
if !strings.Contains(item.Excerpt, "semantic signals") || !strings.Contains(item.Excerpt, "roadmap_timing") {
t.Fatalf("semantic candidate excerpt should preserve semantic signals, got %#v", item)
}
}
if !found {
t.Fatalf("missing semantic candidate in findings %#v", got)
}
}
func TestCollectSemanticCandidatesDoNotLeakWhitespaceCredentialTail(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "base")
raw := "private launch plan for internal rollout on Friday with SERVICE_" + "TOKEN=\"real " + "secret value\""
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add semantic candidate")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.Rule != "public_content_semantic_candidate" || item.File != "docs/public.md" {
continue
}
if strings.Contains(item.Excerpt, "secret value") || strings.Contains(item.Excerpt, "real "+"secret value") {
t.Fatalf("semantic candidate leaked credential tail: %#v", item)
}
if !strings.Contains(item.Excerpt, "SERVICE_TOKEN=<redacted>") {
t.Fatalf("semantic candidate should redact full credential assignment, got %#v", item)
}
return
}
t.Fatalf("missing semantic candidate in findings %#v", got)
}
func TestCollectJSONBearerHeadersDoNotLeakIntoSemanticCandidates(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "base")
token := "abcdefghijklmnopqrstuvwxyz"
raw := "private launch plan for internal rollout on Friday with " +
`{"headers":{"Authorization":"Bearer ` + token + `"}}`
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add json bearer")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/public.md", "public_content_bearer_header")
for _, item := range got {
if item.File != "docs/public.md" {
continue
}
if strings.Contains(item.Excerpt, token) {
t.Fatalf("finding leaked JSON bearer token: %#v", item)
}
}
}
func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
`{"access_` + `token":"real-json-token"}`,
`{"client_` + `secret": "real ` + `secret value"}`,
`{"tenantAccess` + `Token":"real-tenant-camel-token"}`,
`{"github` + `Token":"real-github-token"}`,
`{"vendorApi` + `Key":"real-vendor-key"}`,
`{"slackBot` + `Token":"xoxb-real-token"}`,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "add json config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
count++
for _, forbidden := range []string{
"real-json-token",
"real secret value",
"real-tenant-camel-token",
"real-github-token",
"real-vendor-key",
"xoxb-real-token",
} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
}
}
}
if count != 6 {
t.Fatalf("JSON credential findings = %d, want 6: %#v", count, got)
}
}
func TestCollectAllowsBenignJSONTokenFields(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
`{"tokenizer":"cl100k_base"}`,
`{"token_count": 42}`,
`{"page_token":"next"}`,
`{"next_page_token":"next"}`,
`{"file_token":"file-example"}`,
`{"doc_token":"doc-example"}`,
`{"node_token":"node-example"}`,
`{"wiki_token":"wikcn_public_doc_example"}`,
`{"folder_token":"folder-example"}`,
`{"obj_token":"obj-example"}`,
`{"spreadsheet_token":"sheet-example"}`,
`{"parent_node_token":"parent-example"}`,
`{"origin_node_token":"origin-example"}`,
`{"drive_route_token":"route-example"}`,
`{"token":"<wiki_token>"}`,
`{"token":"wiki_token"}`,
`{"token_url":"https://example.com/oauth/token"}`,
`{"token_endpoint":"https://example.com/oauth/token"}`,
`{"token_format":"Bearer"}`,
`{"secret_name":"public-example-secret"}`,
`{"base_token":"base-example"}`,
`{"app_token":"app-example"}`,
`{"sync_token":"sync-example"}`,
`{"parent_token":"parent-example"}`,
`{"target_token":"target-example"}`,
`{"parent_file_token":"parent-file-example"}`,
`{"refresh_token_expires_in": 7200}`,
`{"access_token_expires_in": 7200}`,
`{"token_expires_in": 7200}`,
`{"token_status":"active"}`,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "add benign json token fields")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
t.Fatalf("benign JSON token field should not be credential finding: %#v", got)
}
}
}
func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY: <" + stripeLike + ">",
"SECRET_TOKEN: <" + patLike + ">",
"CLIENT_SECRET: <real-client-secret-value>",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 3 {
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
}
}
func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "base")
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
`{"access_token_expires_in":"` + patLike + `"}`,
`{"refresh_token_expires_in":"` + stripeLike + `"}`,
`{"client_secret_status":"real-client-secret-value"}`,
`{"client_secret_name":"real-client-secret-value"}`,
`{"app_token":"` + patLike + `"}`,
`{"sync_token":"` + stripeLike + `"}`,
`{"target_token":"real-client-secret-value"}`,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "add credential-shaped benign fields")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 7 {
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
}
}
func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY_NAME: prod_key",
"CLIENT_SECRET_NAME: prod_secret",
"SECRET_STATUS: prod_secret",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 3 {
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
}
}
func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"AWS_ACCESS_KEY_ID: " + accessKey,
"ACCESS_KEY_ID: " + accessKey,
"ACCESS_KEY: " + accessKey,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add access key config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
continue
}
count++
if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") {
t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt)
}
}
if count != 3 {
t.Fatalf("access key credential findings = %d, want 3: %#v", count, got)
}
}
func TestCollectDetectsPrivateKeyAssignments(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
privateKey := "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0t"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"PRIVATE_KEY: " + privateKey,
"SSH_PRIVATE_KEY: " + privateKey,
"JWT_PRIVATE_KEY: " + privateKey,
"SIGNING_PRIVATE_KEY: " + privateKey,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add private key config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
continue
}
count++
if strings.Contains(item.Excerpt, privateKey) {
t.Fatalf("private key finding leaked value in excerpt %q", item.Excerpt)
}
}
if count != 4 {
t.Fatalf("private key assignment findings = %d, want 4: %#v", count, got)
}
}
func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY_OPENAI: prod_key",
"CLIENT_SECRET_GOOGLE: prod_secret",
"TOKEN_GITHUB: github_token",
"APP_PASSWORD_PROD: prod_password",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 4 {
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
}
}
func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"tokens: 128",
"token_type: bearer",
"max_tokens: 2000",
"completion_tokens: 200",
"prompt_tokens: 100",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add benign token config")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
t.Fatalf("benign unquoted token field should not be credential finding: %#v", got)
}
}
}
func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY_OPENAI: real-openai-key",
"TOKEN_GITHUB: real-github-token",
"CLIENT_SECRET_GOOGLE: real-google-secret",
"SECRET_KEY_BASE: real-secret-key-base",
"APP_PASSWORD_PROD: real-prod-password",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
continue
}
count++
for _, forbidden := range []string{
"real-openai-key",
"real-github-token",
"real-google-secret",
"real-secret-key-base",
"real-prod-password",
} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
}
}
if count != 5 {
t.Fatalf("credential suffix variants findings = %d, want 5: %#v", count, got)
}
}
func TestCollectDetectsPrivateKeyWhenOnlyEndIsAdded(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n")
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\nnew-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "complete key")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
}
func TestCollectDetectsPrivateKeyWhenOnlyBeginIsAdded(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), "legacy-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "complete key")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
}
func TestCollectDetectsPrivateKeyWhenOnlyBodyIsAdded(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"new-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "add body")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
}
func TestCollectIgnoresUntouchedHistoricalPrivateKey(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
writeFile(t, filepath.Join(repo, "docs", "public.md"), "public docs update\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "docs update")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.File == "docs/key.pem" && item.Rule == "public_content_private_key_block" {
t.Fatalf("collector reported untouched historical private key: %#v", got)
}
}
}
func TestCollectIgnoresDeletedPrivateKeyLine(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "remove body")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.File == "docs/key.pem" && item.Rule == "public_content_private_key_block" {
t.Fatalf("collector reported delete-only private key cleanup: %#v", got)
}
}
}
func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
runGit(t, repo, "add", "README.md")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "collect_test.go"), "SECRET_TOKEN=fixture\n")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan_test.go"), "SECRET_TOKEN=fixture\n")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan.go"), "const privateKeyFixture = \""+privateKeyBeginPrefix+privateKeyMarker+"\"\n")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "rules.go"), "markers := []string{\"generated with automation\"}\n")
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN=real-leak\n")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "add scanner fixtures")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
var foundOrdinaryTestLeak bool
for _, item := range got {
switch item.File {
case "internal/qualitygate/publiccontent/collect_test.go",
"internal/qualitygate/publiccontent/scan.go",
"internal/qualitygate/publiccontent/scan_test.go",
"internal/qualitygate/publiccontent/rules.go":
t.Fatalf("collector scanned known fixture or detector implementation file: %#v", got)
}
if item.File == "tests/e2e/new-public-workflow.test.sh" && item.Rule == "public_content_generic_credential" {
foundOrdinaryTestLeak = true
}
}
if !foundOrdinaryTestLeak {
t.Fatalf("collector should still scan ordinary test files for real leaks: %#v", got)
}
}
func TestScanChangedFileDocumentsFixtureExclusions(t *testing.T) {
excluded := []string{
"internal/qualitygate/publiccontent/collect_test.go",
"internal/qualitygate/publiccontent/rules.go",
"internal/qualitygate/publiccontent/scan.go",
"internal/qualitygate/publiccontent/scan_test.go",
}
for _, file := range excluded {
if scanChangedFile(file) {
t.Fatalf("scanChangedFile(%q) = true, want false for detector fixture/implementation path", file)
}
}
included := []string{
"internal/qualitygate/publiccontent/new_test.go",
"tests/e2e/new-public-workflow.test.sh",
"docs/public.md",
}
for _, file := range included {
if !scanChangedFile(file) {
t.Fatalf("scanChangedFile(%q) = false, want true", file)
}
}
}
func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "old.md"), "base\n")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN=space-value\n")
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN=quote-value\n")
runGit(t, repo, "mv", "docs/old.md", "docs/new name.md")
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN=rename-value\n")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "add special paths")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/has space.md", "public_content_generic_credential")
requireFinding(t, got, `weird"quote.md`, "public_content_generic_credential")
requireFinding(t, got, "docs/new name.md", "public_content_generic_credential")
}
func TestCollectScansBranchNameAsWarning(t *testing.T) {
repo := t.TempDir()
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{"branch":"bot/public-doc-update"}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
if len(got) != 1 || got[0].Rule != "public_content_automation_branch" {
t.Fatalf("branch findings = %#v", got)
}
}
func TestCollectUsesExplicitBranchNameWhenDetached(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
runGit(t, repo, "add", "README.md")
runGit(t, repo, "commit", "-m", "base")
runGit(t, repo, "checkout", "-b", "bot/public-doc-update")
writeFile(t, filepath.Join(repo, "docs.md"), "safe docs\n")
runGit(t, repo, "add", "docs.md")
runGit(t, repo, "commit", "-m", "docs")
head := strings.TrimSpace(string(runGitOutput(t, repo, "rev-parse", "HEAD")))
runGit(t, repo, "checkout", "--detach", head)
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
MetadataPath: metadataPath,
BranchName: "bot/public-doc-update",
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
requireFinding(t, got, "branch", "public_content_automation_branch")
}
func TestCollectUsesBranchEnvironmentWhenDetached(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
runGit(t, repo, "add", "README.md")
runGit(t, repo, "commit", "-m", "base")
runGit(t, repo, "checkout", "-b", "bot/public-env-update")
writeFile(t, filepath.Join(repo, "docs.md"), "safe docs\n")
runGit(t, repo, "add", "docs.md")
runGit(t, repo, "commit", "-m", "docs")
head := strings.TrimSpace(string(runGitOutput(t, repo, "rev-parse", "HEAD")))
runGit(t, repo, "checkout", "--detach", head)
t.Setenv("GITHUB_HEAD_REF", "bot/public-env-update")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
requireFinding(t, got, "branch", "public_content_automation_branch")
}
func TestCollectPreservesFindingAttributionForChangedLines(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "auth.md"), "intro\n")
runGit(t, repo, "add", "docs/auth.md")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "auth.md"), "intro\nAuthorization: Bearer abcdefghijklmnopqrstuvwxyz\n")
runGit(t, repo, "add", "docs/auth.md")
runGit(t, repo, "commit", "-m", "add auth docs")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.Rule == "public_content_bearer_header" {
if item.File != "docs/auth.md" || item.Line != 2 || item.Source != "file" {
t.Fatalf("changed-line attribution = %#v", item)
}
return
}
}
t.Fatalf("missing bearer finding: %#v", got)
}
func TestAppendUniqueFindingsDeduplicatesByRuleFileLineAndSource(t *testing.T) {
base := []Finding{newFinding("public_content_private_key_block", "docs/key.pem", 1, "file", "private key block")}
got := appendUniqueFindings(base,
newFinding("public_content_private_key_block", "docs/key.pem", 1, "file", "private key block"),
newFinding("public_content_private_key_block", "docs/key.pem", 2, "file", "private key block"),
)
if len(got) != 2 {
t.Fatalf("appendUniqueFindings len = %d, want 2: %#v", len(got), got)
}
}
func newGitRepo(t *testing.T) string {
t.Helper()
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
return repo
}
func privateKeyBegin() string {
return privateKeyBeginPrefix + privateKeyMarker + "\n"
}
func privateKeyEnd() string {
return privateKeyEndPrefix + privateKeyMarker + "\n"
}
func collectFromPreviousCommit(t *testing.T, repo string) []Finding {
t.Helper()
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
return got
}
func requireFinding(t *testing.T, got []Finding, file, rule string) {
t.Helper()
for _, item := range got {
if item.File == file && item.Rule == rule {
return
}
}
t.Fatalf("missing %s in %s findings: %#v", rule, file, got)
}
func TestCollectRequiresValidMetadataJSON(t *testing.T) {
repo := t.TempDir()
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{"title":`)
_, err := Collect(context.Background(), Options{Repo: repo, MetadataPath: metadataPath})
if err == nil || !strings.Contains(err.Error(), "public content metadata") {
t.Fatalf("Collect() error = %v, want metadata parse error", err)
}
}
func runGit(t *testing.T, repo string, args ...string) {
t.Helper()
if len(args) > 0 && args[0] == "commit" {
args = append([]string{"commit", "--no-verify"}, args[1:]...)
}
cmd := exec.Command("git", args...)
cmd.Dir = repo
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
}
}
func runGitOutput(t *testing.T, repo string, args ...string) []byte {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = repo
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
}
return out
}
func writeFile(t *testing.T, path, data string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
func ScanComment(kind, body string) []Finding {
if kind == "" {
kind = "comment"
}
return scanText(kind, "comment", body, false)
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import "testing"
func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
got := ScanComment("issue_comment", `The published comment included /tmp/harness`+`-agent/run and CCM`+`-Harness: stage-4`)
rules := findingRules(got)
if !rules["public_content_harness_metadata"] || !rules["public_content_ccm_harness_trailer"] {
t.Fatalf("comment audit findings = %#v", got)
}
for _, item := range got {
if item.File != "issue_comment" {
t.Fatalf("comment finding file = %q, want issue_comment", item.File)
}
}
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/vfs"
)
func LoadMetadata(path string) (Metadata, error) {
if path == "" {
return Metadata{}, nil
}
data, err := vfs.ReadFile(path)
if err != nil {
return Metadata{}, fmt.Errorf("public content metadata: %w", err)
}
if len(data) == 0 {
return Metadata{}, nil
}
var out Metadata
if err := json.Unmarshal(data, &out); err != nil {
return Metadata{}, fmt.Errorf("public content metadata: %w", err)
}
return out, nil
}
func scanMetadata(m Metadata) []Finding {
text := ""
if m.Title != "" {
text += "title: " + m.Title + "\n"
}
if m.Body != "" {
text += "body:\n" + m.Body + "\n"
}
if text == "" {
return nil
}
out := scanText("pull_request_metadata", "metadata", text, false)
out = append(out, semanticCandidate("pull_request_metadata", "metadata", text, 1)...)
return out
}

View File

@@ -0,0 +1,22 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"path/filepath"
"testing"
)
func TestLoadMetadataReadsTitleAndBody(t *testing.T) {
path := filepath.Join(t.TempDir(), "metadata.json")
writeFile(t, path, `{"title":"public change","body":"pass`+`word = \"example-password\""}`)
got, err := LoadMetadata(path)
if err != nil {
t.Fatalf("LoadMetadata() error = %v", err)
}
if got.Title != "public change" || got.Body == "" {
t.Fatalf("metadata = %#v", got)
}
}

View File

@@ -0,0 +1,441 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"net/url"
"path/filepath"
"regexp"
"strings"
"github.com/larksuite/cli/internal/qualitygate/report"
)
var (
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*[:=]\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\s,}\]]+))`)
jwtLikeRE = regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`)
credentialURLRE = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]*:[^@\s/]+@[^)\s]+`)
bearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+|["']Authorization["']\s*:\s*["']Bearer\s+)[A-Za-z0-9._+/=-]{12,}`)
semanticBearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+[^"'\s,}\]]+|["']Authorization["']\s*:\s*["']Bearer\s+[^"'\\\s,}\]]+)`)
changeIDTrailerRE = regexp.MustCompile(`(?i)^\s*Change-Id:\s*\S+`)
reviewedOnTrailerRE = regexp.MustCompile(`(?i)^\s*Reviewed-on:\s*\S+`)
ccmHarnessTrailerRE = regexp.MustCompile(`(?i)\bCCM-Harness:\s*\S+`)
privateIPv4RE = regexp.MustCompile(`\b(?:10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|172\.(?:1[6-9]|2[0-9]|3[0-1])\.[0-9]{1,3}\.[0-9]{1,3})\b`)
automationBranchRE = regexp.MustCompile(`(?i)(^|/)(bot|automation)[-/]`)
)
func actionForRule(rule string) report.Action {
switch rule {
case "public_content_generic_credential",
"public_content_private_key_block",
"public_content_jwt_like_token",
"public_content_bearer_header",
"public_content_credential_url",
"public_content_change_id_trailer",
"public_content_reviewed_on_trailer",
"public_content_provenance_marker",
"public_content_detector_fingerprint",
"public_content_harness_metadata",
"public_content_ccm_harness_trailer":
return report.ActionReject
case "public_content_private_ipv4",
"public_content_automation_branch":
return report.ActionWarning
default:
return report.ActionWarning
}
}
func isPlaceholderValue(value string) bool {
trimmed := strings.Trim(value, `"'`)
normalized := strings.ToLower(trimmed)
if normalized == "" ||
normalized == "=" ||
percentWrappedPlaceholder(normalized) ||
angleWrappedPlaceholder(normalized) ||
urlWithAnglePlaceholder(normalized) ||
isCredentialReferenceValue(trimmed) {
return true
}
return namedPlaceholderValue(normalized)
}
func namedPlaceholderValue(value string) bool {
switch value {
case "...", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret":
return true
}
return strings.Contains(value, "cli_example") || allXPlaceholder(value)
}
func allXPlaceholder(value string) bool {
if len(value) < 4 {
return false
}
for _, r := range value {
if r != 'x' {
return false
}
}
return true
}
func urlWithAnglePlaceholder(value string) bool {
if !strings.Contains(value, "://") ||
!strings.Contains(value, "<") ||
!strings.Contains(value, ">") {
return false
}
return !urlRemainderLooksCredentialLike(removeAnglePlaceholders(value))
}
func removeAnglePlaceholders(value string) string {
var out strings.Builder
for len(value) > 0 {
start := strings.Index(value, "<")
if start < 0 {
out.WriteString(value)
break
}
out.WriteString(value[:start])
end := strings.Index(value[start+1:], ">")
if end < 0 {
out.WriteString(value[start:])
break
}
value = value[start+end+2:]
}
return out.String()
}
func urlRemainderLooksCredentialLike(value string) bool {
normalized := strings.ToLower(value)
for _, marker := range []string{
"secret",
"token",
"password",
"passwd",
"api_key",
"apikey",
"private_key",
"privatekey",
"client_secret",
"clientsecret",
} {
if strings.Contains(normalized, marker) {
return true
}
}
for _, part := range strings.FieldsFunc(normalized, func(r rune) bool {
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-')
}) {
if credentialShapedIdentifier(part) || longCredentialSegment(part) {
return true
}
}
return false
}
func longCredentialSegment(value string) bool {
if len(value) < 16 {
return false
}
var hasLetter, hasDigit bool
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
hasLetter = true
case r >= '0' && r <= '9':
hasDigit = true
case r == '_' || r == '-':
default:
return false
}
}
return hasLetter || hasDigit
}
func isCredentialReferenceValue(value string) bool {
normalized := strings.ToLower(value)
switch {
case strings.HasPrefix(normalized, "${{"):
return githubExpressionReference(normalized)
case strings.HasPrefix(normalized, "$("):
return !commandSubstitutionLooksCredentialLike(normalized)
case strings.HasPrefix(normalized, "process.env."):
return credentialReferenceIdentifier(strings.TrimPrefix(normalized, "process.env."))
case strings.HasPrefix(normalized, "${"):
return credentialReferenceIdentifier(strings.TrimSuffix(strings.TrimPrefix(normalized, "${"), "}"))
case strings.HasPrefix(value, "$"):
return credentialReferenceIdentifier(strings.TrimPrefix(normalized, "$"))
default:
return false
}
}
func commandSubstitutionLooksCredentialLike(value string) bool {
if !strings.HasPrefix(value, "$(") || !strings.HasSuffix(value, ")") {
return false
}
inner := strings.TrimSuffix(strings.TrimPrefix(value, "$("), ")")
for _, part := range strings.FieldsFunc(inner, func(r rune) bool {
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-')
}) {
if credentialShapedIdentifier(part) || longCredentialSegment(part) {
return true
}
}
return false
}
func githubExpressionReference(value string) bool {
if !strings.HasPrefix(value, "${{") || !strings.HasSuffix(value, "}}") {
return false
}
expr := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
switch {
case strings.HasPrefix(expr, "secrets."):
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "secrets."))
case strings.HasPrefix(expr, "env."):
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "env."))
case strings.HasPrefix(expr, "vars."):
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "vars."))
case expr == "github.token":
return true
default:
return false
}
}
func dottedReferenceIdentifier(value string) bool {
if value == "" {
return false
}
for _, part := range strings.Split(value, ".") {
if !referenceIdentifier(part) {
return false
}
}
return true
}
func credentialReferenceIdentifier(value string) bool {
return referenceIdentifier(value) && !credentialShapedIdentifier(value)
}
func referenceIdentifier(value string) bool {
if value == "" {
return false
}
for i, r := range value {
switch {
case r >= 'a' && r <= 'z':
case r >= '0' && r <= '9' && i > 0:
case r == '_' && i > 0:
default:
return false
}
}
return true
}
func angleWrappedPlaceholder(value string) bool {
if len(value) < 3 || !strings.HasPrefix(value, "<") || !strings.HasSuffix(value, ">") {
return false
}
return anglePlaceholderIdentifier(strings.Trim(value, "<>"))
}
func percentWrappedPlaceholder(value string) bool {
if len(value) < 3 || !strings.HasPrefix(value, "%") || !strings.HasSuffix(value, "%") {
return false
}
inner := strings.Trim(value, "%")
return delimitedPlaceholderIdentifier(inner) && !credentialShapedIdentifier(inner)
}
func delimitedPlaceholderIdentifier(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
continue
}
return false
}
return true
}
func anglePlaceholderIdentifier(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
continue
}
return false
}
if credentialShapedIdentifier(value) {
return false
}
switch value {
case "token",
"id",
"userid",
"openid",
"key",
"secret",
"password",
"api-key",
"user-id",
"open-id",
"client-secret",
"access-token",
"refresh-token",
"auth-token",
"bearer-token",
"session-token",
"service-token":
return true
}
for _, suffix := range []string{"_token", "_id", "_key", "_secret", "_password"} {
if strings.HasSuffix(value, suffix) {
return true
}
}
for _, suffix := range []string{"-token", "-id", "-key", "-secret", "-password"} {
if strings.HasSuffix(value, suffix) {
return true
}
}
return false
}
func credentialShapedValue(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
return credentialShapedIdentifier(normalized)
}
func credentialShapedIdentifier(value string) bool {
switch {
case strings.HasPrefix(value, "sk_live_"),
strings.HasPrefix(value, "sk_test_"),
strings.HasPrefix(value, "ghp_"),
strings.HasPrefix(value, "gho_"),
strings.HasPrefix(value, "ghu_"),
strings.HasPrefix(value, "github_pat_"),
strings.HasPrefix(value, "xoxb_"),
strings.HasPrefix(value, "xoxp_"),
strings.HasPrefix(value, "xoxa_"):
return true
case strings.HasPrefix(value, "real-") &&
(strings.Contains(value, "secret") ||
strings.Contains(value, "token") ||
strings.Contains(value, "key") ||
strings.Contains(value, "password")):
return true
default:
return false
}
}
func resourceTokenPlaceholderValue(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
switch normalized {
case "wiki_token",
"folder_token",
"obj_token",
"spreadsheet_token",
"file_token",
"doc_token",
"node_token",
"parent_node_token",
"origin_node_token",
"drive_route_token":
return true
default:
return minuteTokenFixturePlaceholder(normalized)
}
}
func minuteTokenFixturePlaceholder(value string) bool {
if value == "minute_no_meta" {
return true
}
suffix, ok := strings.CutPrefix(value, "minute_")
if !ok || suffix == "" {
return false
}
for _, r := range suffix {
if r < '0' || r > '9' {
return false
}
}
return true
}
func provenanceMarker(line string) bool {
normalized := strings.ToLower(line)
markers := []string{
"generat" + "ed by tool",
"creat" + "ed by tool",
"generat" + "ed by automation",
"creat" + "ed by automation",
"machine-" + "generated",
"generated with automated",
"generated with automation",
"🤖 generated",
}
for _, marker := range markers {
if strings.Contains(normalized, marker) {
return true
}
}
if strings.HasPrefix(normalized, "co-authored-by:") &&
(strings.Contains(normalized, "<bot@") ||
strings.Contains(normalized, " bot@") ||
strings.Contains(normalized, "[bot]") ||
strings.Contains(normalized, "automation") ||
strings.Contains(normalized, "automated-code-assistant")) {
return true
}
return false
}
// Detector fingerprint checks are intentionally scoped to public rule/config
// files. They do not try to hide this package's implementation; they prevent
// publishing reusable detector identifiers in external-facing rule bundles.
func isDetectorRuleFile(path string) bool {
normalized := filepath.ToSlash(path)
base := filepath.Base(normalized)
return base == ".gitleaks.toml" ||
strings.Contains(normalized, "public-rules/") ||
strings.Contains(normalized, "public_rules/")
}
func detectorFingerprint(line string) bool {
normalized := strings.ToLower(line)
fingerprints := []string{
strings.Join([]string{"public", "content", "leakage"}, "-"),
strings.Join([]string{"public", "content", "detector"}, "-"),
"publiccontent",
}
for _, fingerprint := range fingerprints {
if strings.Contains(normalized, fingerprint) {
return true
}
}
return false
}
func redactCredentialURL(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.User == nil {
return "<credential-url>"
}
u.User = url.UserPassword("<user>", "<redacted>")
return u.String()
}

View File

@@ -0,0 +1,797 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"fmt"
"path/filepath"
"sort"
"strings"
"unicode"
)
const (
privateKeyBeginPrefix = "-----" + "BEGIN "
privateKeyEndPrefix = "-----" + "END "
privateKeyMarker = "PRIVATE " + "KEY-----"
)
func ScanFile(path string, data []byte) []Finding {
return scanText(filepath.ToSlash(path), "file", string(data), isDetectorRuleFile(path))
}
func semanticCandidate(file, source, text string, line int) []Finding {
excerpt := redactedSemanticExcerpt(text)
if excerpt == "" {
return nil
}
return []Finding{newFinding("public_content_semantic_candidate", file, line, source, excerpt)}
}
func scanText(file, source, text string, detectorFile bool) []Finding {
var out []Finding
lines := strings.Split(text, "\n")
inPrivateKey := false
privateKeyLine := 0
for i, line := range lines {
lineNo := i + 1
if strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
inPrivateKey = true
privateKeyLine = lineNo
}
if inPrivateKey && strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
out = append(out, newFinding("public_content_private_key_block", file, privateKeyLine, source, "private key block"))
inPrivateKey = false
}
for _, match := range credentialAssignmentRE.FindAllStringSubmatch(line, -1) {
if !isCredentialAssignmentMatch(match[0]) {
continue
}
value := credentialAssignmentValue(match)
keyName, _ := normalizedCredentialAssignmentKey(match[0])
if value == "" ||
isNonSecretLiteralValue(value) ||
isBenignCodeCredentialExpression(file, value) ||
isPlaceholderValue(value) ||
isResourceTokenPlaceholderAssignment(keyName, value) {
continue
}
if looksLikeEqualityComparison(value) {
continue
}
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
}
for _, match := range jwtLikeRE.FindAllString(line, -1) {
if isSchemaDottedIdentifier(line, match) {
continue
}
out = append(out, newFinding("public_content_jwt_like_token", file, lineNo, source, redactToken(match)))
}
for range bearerHeaderRE.FindAllString(line, -1) {
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
}
for _, match := range credentialURLRE.FindAllString(line, -1) {
if isPlaceholderCredentialURL(match) {
continue
}
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
}
for _, match := range privateIPv4RE.FindAllString(line, -1) {
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
}
if source == "branch" && automationBranchRE.MatchString(line) {
out = append(out, newFinding("public_content_automation_branch", file, lineNo, source, "automation branch marker"))
}
switch {
case changeIDTrailerRE.MatchString(line):
out = append(out, newFinding("public_content_change_id_trailer", file, lineNo, source, "Change-Id: <redacted>"))
case reviewedOnTrailerRE.MatchString(line):
out = append(out, newFinding("public_content_reviewed_on_trailer", file, lineNo, source, "Reviewed-on: <redacted>"))
case ccmHarnessTrailerRE.MatchString(line):
out = append(out, newFinding("public_content_ccm_harness_trailer", file, lineNo, source, "CCM-Harness: <redacted>"))
}
if provenanceMarker(line) {
out = append(out, newFinding("public_content_provenance_marker", file, lineNo, source, "provenance marker"))
}
if strings.Contains(line, "/tmp/harness-agent") {
out = append(out, newFinding("public_content_harness_metadata", file, lineNo, source, "/tmp/harness-agent"))
}
if detectorFile && detectorFingerprint(line) {
out = append(out, newFinding("public_content_detector_fingerprint", file, lineNo, source, "public detector fingerprint"))
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].File != out[j].File {
return out[i].File < out[j].File
}
if out[i].Line != out[j].Line {
return out[i].Line < out[j].Line
}
return out[i].Rule < out[j].Rule
})
return out
}
func isCredentialAssignmentMatch(match string) bool {
name, value, ok := normalizedCredentialAssignment(match)
if !ok {
return false
}
if isWebhookCredentialKey(name) && webhookAssignmentValueLooksCredentialLike(value) {
return true
}
if isBenignTokenField(name) && !credentialShapedValue(value) {
return false
}
return isExplicitCredentialKey(name)
}
func normalizedCredentialAssignmentKey(match string) (string, bool) {
key, _, ok := normalizedCredentialAssignment(match)
return key, ok
}
func normalizedCredentialAssignment(match string) (string, string, bool) {
key, ok := credentialAssignmentKey(match)
if !ok {
return "", "", false
}
key = strings.TrimSpace(key)
if key == "" {
return "", "", false
}
submatches := credentialAssignmentRE.FindStringSubmatch(match)
return normalizedCredentialKey(strings.Trim(key, `"'`)), credentialAssignmentValue(submatches), true
}
func normalizedCredentialKey(key string) string {
key = strings.TrimSpace(key)
var out []rune
var prev rune
for i, r := range key {
if r == '-' {
r = '_'
}
if i > 0 && isCredentialKeyBoundary(prev, r) {
out = append(out, '_')
}
out = append(out, unicode.ToLower(r))
prev = r
}
key = string(out)
key = strings.ReplaceAll(key, "-", "_")
return key
}
func isCredentialKeyBoundary(prev, current rune) bool {
if prev == '_' || current == '_' {
return false
}
return (unicode.IsLower(prev) || unicode.IsDigit(prev)) && unicode.IsUpper(current)
}
func isBenignTokenField(key string) bool {
if isTokenMetricField(key) ||
isTokenMetadataField(key) ||
isResourceTokenField(key) ||
isPaginationOrSyncTokenField(key) {
return true
}
return false
}
func isTokenMetricField(key string) bool {
switch key {
case "tokenizer",
"token_count",
"tokens",
"max_tokens",
"completion_tokens",
"prompt_tokens":
return true
default:
return false
}
}
func isTokenMetadataField(key string) bool {
switch key {
case "access_token_expires_in",
"refresh_token_expires_in",
"token_expires_in",
"token_status",
"token_type",
"token_url",
"token_endpoint",
"token_format",
"secret_name":
return true
default:
return false
}
}
func isPaginationOrSyncTokenField(key string) bool {
switch key {
case "page_token",
"next_page_token",
"sync_token":
return true
default:
return false
}
}
func isResourceTokenField(key string) bool {
if !strings.HasSuffix(key, "_token") {
return false
}
prefix := strings.TrimSuffix(key, "_token")
switch prefix {
case "app",
"base",
"board",
"doc",
"drive_route",
"file",
"folder",
"host_node",
"minute",
"node",
"obj",
"origin_node",
"parent",
"parent_file",
"parent_node",
"share",
"spreadsheet",
"target",
"wiki":
return true
default:
return false
}
}
func isResourceTokenPlaceholderAssignment(key, value string) bool {
switch {
case key == "client_token" && idempotencyTokenPlaceholderValue(value):
return true
case key == "retry_without_token" && numericStringPlaceholderValue(value):
return true
case tokenLikePlaceholderKey(key):
return tokenLikePlaceholderValue(value)
default:
return false
}
}
func tokenLikePlaceholderKey(key string) bool {
return key == "token" ||
strings.HasSuffix(key, "_token") ||
strings.HasSuffix(key, "-token")
}
func tokenLikePlaceholderValue(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
if normalized == "" || credentialShapedIdentifier(normalized) {
return false
}
return resourceTokenPlaceholderValue(value) ||
isPlaceholderValue(value) ||
normalized == "token" ||
strings.Contains(normalized, "...") ||
strings.Contains(normalized, "xxx") ||
strings.Contains(normalized, "_or_") ||
strings.HasSuffix(normalized, "_token") ||
strings.HasPrefix(normalized, ".")
}
func idempotencyTokenPlaceholderValue(value string) bool {
return numericStringPlaceholderValue(value) || uuidStringPlaceholderValue(value)
}
func uuidStringPlaceholderValue(value string) bool {
normalized := strings.Trim(value, `"'`)
parts := strings.Split(normalized, "-")
if len(parts) != 5 {
return false
}
for i, part := range parts {
want := []int{8, 4, 4, 4, 12}[i]
if len(part) != want {
return false
}
for _, r := range part {
if (r >= '0' && r <= '9') ||
(r >= 'a' && r <= 'f') ||
(r >= 'A' && r <= 'F') {
continue
}
return false
}
}
return true
}
func numericStringPlaceholderValue(value string) bool {
normalized := strings.Trim(value, `"'`)
if normalized == "" {
return false
}
for _, r := range normalized {
if r < '0' || r > '9' {
return false
}
}
return true
}
func isBenignCodeCredentialExpression(file, value string) bool {
normalized := strings.TrimSpace(value)
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
return true
}
if !sourceCodeFile(file) || quotedLiteral(value) || credentialShapedValue(value) {
return false
}
return codeReferenceExpression(normalized)
}
func sourceCodeFile(file string) bool {
switch filepath.Ext(file) {
case ".go", ".py":
return true
default:
return false
}
}
func quotedLiteral(value string) bool {
normalized := strings.TrimSpace(value)
return len(normalized) >= 2 &&
((strings.HasPrefix(normalized, `"`) && strings.HasSuffix(normalized, `"`)) ||
(strings.HasPrefix(normalized, `'`) && strings.HasSuffix(normalized, `'`)))
}
func codeReferenceExpression(value string) bool {
if value == "" {
return false
}
for _, marker := range []string{".", "(", ")", "[", "]", "{"} {
if strings.Contains(value, marker) {
return true
}
}
return codeIdentifier(value) && !credentialNameFragment(value)
}
func codeIdentifier(value string) bool {
for i, r := range value {
switch {
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r == '_' && i > 0:
case r >= '0' && r <= '9' && i > 0:
default:
return false
}
}
return true
}
func credentialNameFragment(value string) bool {
normalized := strings.ToLower(value)
for _, marker := range []string{"secret", "token", "password", "passwd", "key"} {
if strings.Contains(normalized, marker) {
return true
}
}
return false
}
func isSchemaDottedIdentifier(line, match string) bool {
return strings.Contains(line, "schema ") && strings.Contains(match, "_")
}
func isNonSecretLiteralValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
case "true", "false", "null", "nil", "{", "[":
return true
default:
return false
}
}
func isWebhookCredentialKey(key string) bool {
return strings.Contains(strings.ReplaceAll(key, "_", ""), "webhook")
}
func webhookAssignmentValueLooksCredentialLike(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
if normalized == "" || isPlaceholderValue(normalized) || isNonSecretLiteralValue(normalized) {
return false
}
return urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)) ||
credentialShapedIdentifier(strings.Trim(normalized, "$"))
}
func isExplicitCredentialKey(key string) bool {
compact := strings.ReplaceAll(key, "_", "")
switch compact {
case "token",
"accesstoken",
"refreshtoken",
"authtoken",
"bearertoken",
"sessiontoken",
"servicetoken",
"apikey",
"accesskey",
"privatekey",
"apisecret",
"secret",
"secretkey",
"clientsecret",
"password",
"passwd":
return true
}
for _, phrase := range []string{
"accesstoken",
"refreshtoken",
"authtoken",
"bearertoken",
"sessiontoken",
"servicetoken",
"bottoken",
"apikey",
"accesskey",
"privatekey",
"apisecret",
"clientsecret",
"secretkey",
} {
if strings.Contains(compact, phrase) {
return true
}
}
parts := credentialKeyParts(key)
for _, phrase := range [][2]string{
{"access", "token"},
{"refresh", "token"},
{"auth", "token"},
{"bearer", "token"},
{"session", "token"},
{"service", "token"},
{"bot", "token"},
{"api", "key"},
{"access", "key"},
{"private", "key"},
{"api", "secret"},
{"client", "secret"},
{"secret", "key"},
} {
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
return true
}
}
for _, part := range parts {
switch part {
case "token", "secret", "password", "passwd":
return true
}
}
for _, suffix := range []string{
"token",
"accesstoken",
"refreshtoken",
"authtoken",
"bearertoken",
"sessiontoken",
"servicetoken",
"bottoken",
"apikey",
"accesskey",
"privatekey",
"apisecret",
"clientsecret",
"secret",
"secretkey",
"password",
"passwd",
} {
if strings.HasSuffix(compact, suffix) {
return true
}
}
for _, suffix := range []string{
"_access_token",
"_refresh_token",
"_auth_token",
"_bearer_token",
"_session_token",
"_service_token",
"_api_key",
"_access_key",
"_private_key",
"_api_secret",
"_client_secret",
"_secret",
"_secret_key",
"_password",
"_passwd",
} {
if strings.HasSuffix(key, suffix) {
return true
}
}
return false
}
func credentialKeyParts(key string) []string {
var parts []string
for _, part := range strings.Split(key, "_") {
if part != "" {
parts = append(parts, part)
}
}
return parts
}
func hasAdjacentCredentialParts(parts []string, first, second string) bool {
for i := 0; i+1 < len(parts); i++ {
if parts[i] == first && parts[i+1] == second {
return true
}
}
return false
}
func credentialAssignmentValue(match []string) string {
for _, value := range match[1:] {
if value != "" {
return value
}
}
return ""
}
func looksLikeEqualityComparison(value string) bool {
return strings.HasPrefix(strings.TrimSpace(value), "=")
}
func isPlaceholderCredentialURL(raw string) bool {
userInfo, ok := credentialURLUserInfo(raw)
if !ok {
return false
}
_, password, ok := strings.Cut(userInfo, ":")
if !ok {
return false
}
return credentialURLPasswordPlaceholder(password)
}
func credentialURLPasswordPlaceholder(password string) bool {
normalized := strings.ToLower(password)
decoded := strings.ReplaceAll(normalized, "%3c", "<")
decoded = strings.ReplaceAll(decoded, "%3e", ">")
switch decoded {
case "placeholder", "redacted", "<redacted>", "xxxx":
return true
}
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
}
func credentialURLUserInfo(raw string) (string, bool) {
schemeIdx := strings.Index(raw, "://")
if schemeIdx < 0 {
return "", false
}
rest := raw[schemeIdx+len("://"):]
atIdx := strings.Index(rest, "@")
if atIdx < 0 {
return "", false
}
return rest[:atIdx], true
}
func newFinding(rule, file string, line int, source, excerpt string) Finding {
return Finding{
Rule: rule,
Action: actionForRule(rule),
File: file,
Line: line,
Source: source,
Excerpt: excerpt,
Message: messageForRule(rule),
Suggestion: suggestionForRule(rule),
}
}
func messageForRule(rule string) string {
switch rule {
case "public_content_generic_credential":
return "public contribution contains a generic credential assignment"
case "public_content_private_key_block":
return "public contribution contains a private key block"
case "public_content_jwt_like_token":
return "public contribution contains a JWT-like token"
case "public_content_bearer_header":
return "public contribution contains an Authorization bearer token"
case "public_content_credential_url":
return "public contribution contains credentials embedded in a URL"
case "public_content_private_ipv4":
return "public contribution contains a private-network IP address"
case "public_content_automation_branch":
return "public contribution uses an automation-shaped branch name"
case "public_content_change_id_trailer":
return "public contribution contains a Change-Id trailer"
case "public_content_reviewed_on_trailer":
return "public contribution contains a Reviewed-on trailer"
case "public_content_provenance_marker":
return "public contribution contains a prohibited provenance marker"
case "public_content_detector_fingerprint":
return "public rule/config content exposes public detector fingerprints"
case "public_content_harness_metadata":
return "public contribution contains visible harness pipeline metadata"
case "public_content_ccm_harness_trailer":
return "public contribution contains a CCM-Harness trailer"
case "public_content_semantic_candidate":
return "public contribution contains text for semantic public content review"
default:
return "public contribution contains content that should not be published"
}
}
func suggestionForRule(rule string) string {
switch actionForRule(rule) {
case "REJECT":
return "remove the value from the public contribution and replace it with a non-sensitive placeholder"
default:
return "remove private workflow metadata before publishing the public contribution"
}
}
func redactAssignment(match string) string {
key, ok := credentialAssignmentKey(match)
if !ok {
return "<credential-assignment>"
}
return fmt.Sprintf("%s= <redacted>", strings.TrimSpace(key))
}
func credentialAssignmentKey(match string) (string, bool) {
idx := -1
for _, sep := range []string{":", "="} {
if candidate := strings.Index(match, sep); candidate >= 0 && (idx < 0 || candidate < idx) {
idx = candidate
}
}
if idx < 0 {
return "", false
}
return match[:idx], true
}
func redactToken(_ string) string {
return "<jwt-like-token>"
}
func redactedSemanticExcerpt(text string) string {
normalized := strings.Join(strings.Fields(text), " ")
if normalized == "" {
return ""
}
signals := semanticSignals(normalized)
if len(signals) == 0 {
return ""
}
sanitized := truncateRunes(sanitizeSemanticExcerpt(text), 600)
return fmt.Sprintf("semantic signals: %s; excerpt: %q", strings.Join(signals, ","), sanitized)
}
func semanticSignals(normalized string) []string {
lower := strings.ToLower(normalized)
var signals []string
add := func(signal string) {
for _, existing := range signals {
if existing == signal {
return
}
}
signals = append(signals, signal)
}
hasPrivateScope := strings.Contains(lower, "private") || strings.Contains(lower, "internal-only")
hasRequestMetadata := strings.Contains(lower, "request header") || strings.Contains(lower, "request headers") || strings.Contains(lower, "authorization header") || strings.Contains(lower, "metadata header")
hasTrustBoundary := strings.Contains(lower, "spoof") || strings.Contains(lower, "trust") || strings.Contains(lower, "risk scoring") || strings.Contains(lower, "classification")
hasRoadmap := strings.Contains(lower, "roadmap") || strings.Contains(lower, "migration") || strings.Contains(lower, "rollout") || strings.Contains(lower, "cutover") || strings.Contains(lower, "unpublished")
hasTiming := strings.Contains(lower, "target date") || strings.Contains(lower, "friday") || strings.Contains(lower, "monday") || strings.Contains(lower, "tuesday") || strings.Contains(lower, "wednesday") || strings.Contains(lower, "thursday") || strings.Contains(lower, "customer-visible")
hasImplementation := strings.Contains(lower, "server-side") || strings.Contains(lower, "implementation")
if hasPrivateScope && hasRequestMetadata && hasTrustBoundary {
add("private_scope")
add("request_metadata")
add("trust_boundary_detail")
}
if hasRoadmap && (hasPrivateScope || hasTiming) {
add("roadmap_detail")
if hasPrivateScope {
add("private_scope")
}
if hasTiming {
add("roadmap_timing")
}
}
if hasPrivateScope && hasImplementation && hasTrustBoundary {
add("private_scope")
add("implementation_detail")
add("trust_boundary_detail")
}
return signals
}
func sanitizeSemanticExcerpt(text string) string {
text = redactPrivateKeyBlocks(text)
text = credentialAssignmentRE.ReplaceAllStringFunc(text, sanitizeCredentialAssignment)
text = strings.ReplaceAll(text, `<redacted>"`, `<redacted>`)
text = strings.ReplaceAll(text, `<redacted>'`, `<redacted>`)
text = semanticBearerHeaderRE.ReplaceAllString(text, "Authorization: Bearer <redacted>")
text = jwtLikeRE.ReplaceAllString(text, "<jwt-like-token>")
text = credentialURLRE.ReplaceAllStringFunc(text, sanitizeCredentialURL)
return strings.Join(strings.Fields(text), " ")
}
func redactPrivateKeyBlocks(text string) string {
lines := strings.Split(text, "\n")
var out []string
inPrivateKey := false
for _, line := range lines {
if strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
out = append(out, "<private-key-block>")
inPrivateKey = true
if strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
inPrivateKey = false
}
continue
}
if inPrivateKey {
if strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
inPrivateKey = false
}
continue
}
out = append(out, line)
}
return strings.Join(out, "\n")
}
func sanitizeCredentialAssignment(match string) string {
key, ok := credentialAssignmentKey(match)
if !ok {
return "<credential-assignment>"
}
return strings.TrimSpace(key) + "=<redacted>"
}
func sanitizeCredentialURL(raw string) string {
redacted := redactCredentialURL(raw)
redacted = strings.ReplaceAll(redacted, "%3Cuser%3E", "<user>")
redacted = strings.ReplaceAll(redacted, "%3Credacted%3E", "<redacted>")
return redacted
}
func truncateRunes(text string, limit int) string {
if limit <= 0 {
return ""
}
runes := []rune(text)
if len(runes) <= limit {
return text
}
return string(runes[:limit]) + "..."
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import "github.com/larksuite/cli/internal/qualitygate/report"
type Options struct {
Repo string
ChangedFrom string
MetadataPath string
BranchName string
}
type Metadata struct {
Title string `json:"title"`
Body string `json:"body"`
Branch string `json:"branch"`
}
type Finding struct {
Rule string
Action report.Action
File string
Line int
Source string
Excerpt string
Message string
Suggestion string
}

View File

@@ -174,8 +174,9 @@ type materializedExample struct {
}
type placeholderContext struct {
FlagName string
FlagUsage string
FlagName string
FlagUsage string
FlagDefault string
}
func materializePlaceholderExample(raw string, cmd manifest.Command) (materializedExample, bool) {
@@ -247,6 +248,7 @@ func placeholderContextForFlag(name string, flag *manifest.Flag) placeholderCont
ctx := placeholderContext{FlagName: name}
if flag != nil {
ctx.FlagUsage = flag.Usage
ctx.FlagDefault = flag.DefValue
}
return ctx
}
@@ -309,11 +311,17 @@ func fakeValueForPlaceholder(raw string, ctx placeholderContext) (string, bool)
if name == "" {
return "", false
}
if value, ok := fakeNumericValueForPlaceholder(name, ctx); ok {
return value, true
}
if value, ok := fakeContextualURLValueForPlaceholder(name, ctx); ok {
return value, true
}
if value, ok := fakeValueFromPlaceholderName(name); ok {
return value, true
}
if isGenericPlaceholderName(name) {
return fakeValueFromUsageHint(ctx.FlagUsage)
return fakeValueFromContextHint(ctx)
}
return "", false
}
@@ -336,16 +344,26 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
return "file_test123", true
case hasPlaceholderToken(tokens, "file") && hasPlaceholderToken(tokens, "token"):
return "file_test123", true
case hasPlaceholderToken(tokens, "folder") && hasPlaceholderToken(tokens, "token"):
return "fld_test123", true
case hasPlaceholderToken(tokens, "image", "img"):
return "img_test123", true
case hasPlaceholderToken(tokens, "app"):
return "app_test123", true
case hasPlaceholderToken(tokens, "draft"):
return "draft_test123", true
case hasPlaceholderToken(tokens, "label"):
return "label_test123", true
case hasPlaceholderToken(tokens, "share"):
return "share_test123", true
case hasPlaceholderToken(tokens, "doc", "document"):
return "doc_test123", true
case hasPlaceholderToken(tokens, "sheet", "spreadsheet"):
return "shtcn_test123", true
case hasPlaceholderToken(tokens, "base"):
return "base_test123", true
case hasPlaceholderToken(tokens, "space"):
return "space_test123", true
case hasPlaceholderToken(tokens, "table"):
return "tbl_test123", true
case hasPlaceholderToken(tokens, "view"):
@@ -377,17 +395,98 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
}
}
func fakeValueFromUsageHint(usage string) (string, bool) {
match := placeholderValuePattern.FindStringSubmatch(strings.ToLower(usage))
func fakeValueFromContextHint(ctx placeholderContext) (string, bool) {
if value, ok := fakeNumericValueForPlaceholder("", ctx); ok {
return value, true
}
if value, ok := fakeContextualURLValueForPlaceholder("", ctx); ok {
return value, true
}
match := placeholderValuePattern.FindStringSubmatch(strings.ToLower(ctx.FlagUsage))
if len(match) != 2 || !knownTokenPrefix(match[1]) {
return "", false
}
return match[1] + "_test123", true
}
func fakeContextualURLValueForPlaceholder(name string, ctx placeholderContext) (string, bool) {
nameTokens := placeholderTokenSet(name)
flagName := strings.ReplaceAll(strings.ToLower(ctx.FlagName), "-", "_")
flagTokens := placeholderTokenSet(flagName)
if !hasPlaceholderToken(nameTokens, "url", "link") && !hasPlaceholderToken(flagTokens, "url", "link") {
return "", false
}
usage := strings.ToLower(ctx.FlagUsage)
if strings.Contains(usage, "lark") || strings.Contains(usage, "feishu") || strings.Contains(usage, "document url") {
return "https://example.feishu.cn/docx/doc_test123", true
}
return "", false
}
func fakeNumericValueForPlaceholder(name string, ctx placeholderContext) (string, bool) {
nameTokens := placeholderTokenSet(name)
flagName := strings.ReplaceAll(strings.ToLower(ctx.FlagName), "-", "_")
flagTokens := placeholderTokenSet(flagName)
usage := strings.ToLower(ctx.FlagUsage)
switch {
case placeholderTokenPair(nameTokens, "meeting", "id") || placeholderTokenPair(flagTokens, "meeting", "id"):
return "400000000001", true
case placeholderTokenPair(nameTokens, "meeting", "ids") || placeholderTokenPair(flagTokens, "meeting", "ids"):
return "400000000001", true
case placeholderTokenPair(nameTokens, "meeting", "no") || placeholderTokenPair(flagTokens, "meeting", "no"):
return "123456789", true
case placeholderTokenPair(nameTokens, "meeting", "number") || placeholderTokenPair(flagTokens, "meeting", "number"):
return "123456789", true
case hasPlaceholderToken(nameTokens, "timestamp") || hasPlaceholderToken(flagTokens, "timestamp") || strings.Contains(usage, "unix timestamp"):
return defaultPositiveInteger(ctx.FlagDefault, "1893456000"), true
case placeholderTokenPair(nameTokens, "page", "size") || placeholderTokenPair(flagTokens, "page", "size"):
return defaultPositiveInteger(ctx.FlagDefault, "20"), true
case placeholderTokenPair(nameTokens, "page", "limit") || placeholderTokenPair(flagTokens, "page", "limit"):
return defaultPositiveInteger(ctx.FlagDefault, "10"), true
case numericPlaceholderName(nameTokens) || numericPlaceholderName(flagTokens) || numericUsageHint(usage):
return defaultPositiveInteger(ctx.FlagDefault, "20"), true
default:
return "", false
}
}
func numericPlaceholderName(tokens map[string]bool) bool {
if len(tokens) == 0 || hasPlaceholderToken(tokens, "token", "format", "type", "status", "mode") {
return false
}
return hasPlaceholderToken(tokens,
"amount", "count", "depth", "height", "index", "length", "limit", "max",
"number", "revision", "size", "width",
)
}
func numericUsageHint(usage string) bool {
if usage == "" {
return false
}
return strings.Contains(usage, "positive integer") ||
strings.Contains(usage, "decimal integer") ||
strings.Contains(usage, "number of ") ||
strings.Contains(usage, "(number)")
}
func defaultPositiveInteger(raw, fallback string) string {
raw = strings.TrimSpace(raw)
if raw == "" || strings.HasPrefix(raw, "-") || raw == "0" {
return fallback
}
for _, r := range raw {
if r < '0' || r > '9' {
return fallback
}
}
return raw
}
func knownTokenPrefix(prefix string) bool {
switch prefix {
case "app", "base", "doc", "file", "fld", "img", "item", "meeting", "obcn", "oc", "od", "om", "ou", "page", "rec", "shtcn", "task", "tbl", "token", "viw", "wiki":
case "app", "base", "doc", "draft", "file", "fld", "img", "item", "label", "meeting", "obcn", "oc", "od", "om", "ou", "page", "rec", "share", "shtcn", "space", "task", "tbl", "token", "viw", "wiki":
return true
default:
return false
@@ -431,6 +530,10 @@ func hasPlaceholderToken(tokens map[string]bool, wants ...string) bool {
return false
}
func placeholderTokenPair(tokens map[string]bool, first, second string) bool {
return tokens[first] && tokens[second]
}
func hasUnresolvedDryRunPlaceholder(value string) bool {
if skillscan.HasPlaceholder(value) {
return true
@@ -623,6 +726,7 @@ func appendDryRunArg(raw string) ([]string, error) {
return nil, fmt.Errorf("not a lark-cli command")
}
argv = truncateShellTail(argv)
argv = forceDryRunJSONFormat(argv)
hasDryRunArg := false
dryRunEnabled := false
for _, arg := range argv[1:] {
@@ -642,6 +746,23 @@ func appendDryRunArg(raw string) ([]string, error) {
return append(argv[1:], "--dry-run"), nil
}
func forceDryRunJSONFormat(argv []string) []string {
for i := 1; i < len(argv); i++ {
arg := argv[i]
if arg == "--format" {
if i+1 < len(argv) && argv[i+1] == "pretty" {
argv[i+1] = "json"
}
return argv
}
if arg == "--format=pretty" {
argv[i] = "--format=json"
return argv
}
}
return argv
}
func truncateShellTail(argv []string) []string {
for i, arg := range argv {
if i == 0 {

View File

@@ -305,6 +305,161 @@ func TestRunDryRunsMaterializesInlinePlaceholderFlagValues(t *testing.T) {
}
}
func TestRunDryRunsMaterializesNumericPlaceholderFlagValues(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/vc/v1/bots/events","params":{"meeting_id":"400000000001","page_size":50}}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "vc +meeting-events",
Runnable: true,
Flags: []manifest.Flag{
{Name: "meeting-id", TakesValue: true, Usage: "meeting ID to query; must be a long positive integer, not a 9-digit meeting number"},
{Name: "page-size", TakesValue: true, Usage: "page size, 20-100 (default 50)", DefValue: "50"},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: "lark-cli vc +meeting-events --meeting-id <meeting_id> --page-size <page_size>",
SourceFile: "skills/lark-vc-agent/SKILL.md",
Line: 120,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("numeric placeholder example should be executable after materialization: %#v", facts)
}
wantArgs := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--page-size", "50", "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsMaterializesNumericPlaceholdersInsideJSONFlags(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/test","params":{"timestamp":"1893456000","count":"20"}}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "api GET",
Runnable: true,
Flags: []manifest.Flag{
{Name: "params", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: `lark-cli api GET /open-apis/test --params '{"timestamp":"<timestamp>","count":"<count>"}'`,
SourceFile: "skills/lark-demo/SKILL.md",
Line: 20,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("JSON numeric placeholder example should be executable after materialization: %#v", facts)
}
wantArgs := []string{"api", "GET", "/open-apis/test", "--params", `{"timestamp":"1893456000","count":"20"}`, "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsMaterializesLarkDocumentURLPlaceholders(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/drive/v1/metas/batch_query"}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "drive +inspect",
Runnable: true,
Flags: []manifest.Flag{
{Name: "url", TakesValue: true, Usage: "Lark/Feishu document URL (docx, doc, sheet, bitable, wiki, file, folder, mindnote, slides)"},
{Name: "format", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: "lark-cli drive +inspect --url '<url>' --format json",
SourceFile: "skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md",
Line: 15,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("Lark URL placeholder example should be executable after materialization: %#v", facts)
}
wantArgs := []string{"drive", "+inspect", "--url", "https://example.feishu.cn/docx/doc_test123", "--format", "json", "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsMaterializesResourceIDPlaceholderFlagValues(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/wiki/v2/spaces/space_test123/nodes"}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "wiki +node-list",
Runnable: true,
Flags: []manifest.Flag{
{Name: "space-id", TakesValue: true, Usage: "wiki space ID"},
{Name: "page-token", TakesValue: true, Usage: "page token"},
{Name: "format", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: "lark-cli wiki +node-list --space-id <space_id> --page-token <PAGE_TOKEN> --format json",
SourceFile: "skills/lark-wiki/references/lark-wiki-node-list.md",
Line: 24,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("resource ID placeholder example should be executable after materialization: %#v", facts)
}
wantArgs := []string{"wiki", "+node-list", "--space-id", "space_test123", "--page-token", "page_test123", "--format", "json", "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsMaterializesResourcePlaceholdersInsideJSONFlags(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"POST","url":"/open-apis/mail/v1/user_mailboxes/me/drafts/draft_test123/send"}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "mail user_mailbox.drafts send",
Runnable: true,
Flags: []manifest.Flag{
{Name: "params", TakesValue: true},
{Name: "data", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: `lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}' --data '{"send_time":"<unix_timestamp>"}'`,
SourceFile: "skills/lark-mail/references/lark-mail-send.md",
Line: 172,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("JSON resource placeholder example should be executable after materialization: %#v", facts)
}
wantArgs := []string{"mail", "user_mailbox.drafts", "send", "--params", `{"user_mailbox_id":"me","draft_id":"draft_test123"}`, "--data", `{"send_time":"1893456000"}`, "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsSkipsUnknownFlagsBeforeDryRun(t *testing.T) {
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "im +chat-messages-list",
@@ -600,6 +755,51 @@ func TestAppendDryRunArgDoesNotDuplicate(t *testing.T) {
}
}
func TestAppendDryRunArgForcesJSONFormat(t *testing.T) {
got, err := appendDryRunArg("lark-cli vc +meeting-events --meeting-id 400000000001 --format pretty")
if err != nil {
t.Fatalf("appendDryRunArg() error = %v", err)
}
want := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--format", "json", "--dry-run"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("appendDryRunArg() = %#v, want %#v", got, want)
}
}
func TestAppendDryRunArgForcesInlineJSONFormat(t *testing.T) {
got, err := appendDryRunArg("lark-cli vc +meeting-events --meeting-id 400000000001 --format=pretty --dry-run")
if err != nil {
t.Fatalf("appendDryRunArg() error = %v", err)
}
want := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--format=json", "--dry-run"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("appendDryRunArg() = %#v, want %#v", got, want)
}
}
func TestAppendDryRunArgPreservesNonPrettyFormat(t *testing.T) {
for _, raw := range []string{
"lark-cli mail +watch --format data --dry-run",
"lark-cli export +events --format=ndjson --dry-run",
"lark-cli docs +fetch --format table",
} {
got, err := appendDryRunArg(raw)
if err != nil {
t.Fatalf("appendDryRunArg(%q) error = %v", raw, err)
}
for _, arg := range got {
if arg == "--format=json" {
t.Fatalf("appendDryRunArg(%q) unexpectedly rewrote inline format: %#v", raw, got)
}
}
for i, arg := range got {
if arg == "--format" && i+1 < len(got) && got[i+1] == "json" {
t.Fatalf("appendDryRunArg(%q) unexpectedly rewrote split format: %#v", raw, got)
}
}
}
}
func TestAppendDryRunArgForcesDryRunWhenExplicitlyDisabled(t *testing.T) {
got, err := appendDryRunArg("lark-cli docs +fetch --dry-run=false --doc abc")
if err != nil {

View File

@@ -15,18 +15,20 @@ import (
manifestexamples "github.com/larksuite/cli/internal/qualitygate/examples"
"github.com/larksuite/cli/internal/qualitygate/facts"
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
"github.com/larksuite/cli/internal/qualitygate/report"
"github.com/larksuite/cli/internal/qualitygate/skillscan"
"github.com/larksuite/cli/internal/vfs"
)
type Options struct {
Repo string
CLIBin string
ChangedFrom string
FactsOut string
ManifestPath string
CommandIndexPath string
Repo string
CLIBin string
ChangedFrom string
FactsOut string
ManifestPath string
CommandIndexPath string
PublicContentMetadataPath string
}
func Run(ctx context.Context, opts Options) ([]report.Diagnostic, facts.Facts, error) {
@@ -98,9 +100,60 @@ func Run(ctx context.Context, opts Options) ([]report.Diagnostic, facts.Facts, e
if opts.ChangedFrom != "" {
diags = append(diags, errorDiags...)
}
publicContent, err := publiccontent.Collect(ctx, publiccontent.Options{
Repo: opts.Repo,
ChangedFrom: opts.ChangedFrom,
MetadataPath: opts.PublicContentMetadataPath,
})
if err != nil {
return nil, facts.Facts{}, err
}
diags = append(diags, publicContentDiagnostics(publicContent)...)
diags = filterPRDiagnostics(opts.Repo, opts.ChangedFrom, scope, m, diags)
return diags, facts.BuildWithCommandLookup(m, commandIndex, skillFacts, skillQualityFacts, errorFacts, exampleFacts, outputFacts, diags, scope.Files), nil
builtFacts := facts.BuildWithCommandLookup(m, commandIndex, skillFacts, skillQualityFacts, errorFacts, exampleFacts, outputFacts, diags, scope.Files)
return diags, facts.WithPublicContent(builtFacts, publicContentFacts(publicContent)), nil
}
func publicContentDiagnostics(items []publiccontent.Finding) []report.Diagnostic {
if len(items) == 0 {
return nil
}
out := make([]report.Diagnostic, 0, len(items))
for _, item := range items {
if item.Rule == "public_content_semantic_candidate" {
continue
}
out = append(out, report.Diagnostic{
Rule: item.Rule,
Action: item.Action,
File: item.File,
Line: item.Line,
Message: item.Message,
Suggestion: item.Suggestion,
})
}
return out
}
func publicContentFacts(items []publiccontent.Finding) []facts.PublicContentFact {
if len(items) == 0 {
return nil
}
out := make([]facts.PublicContentFact, 0, len(items))
for _, item := range items {
out = append(out, facts.PublicContentFact{
Rule: item.Rule,
Action: item.Action,
File: item.File,
Line: item.Line,
Source: item.Source,
Excerpt: item.Excerpt,
Message: item.Message,
Suggestion: item.Suggestion,
})
}
return out
}
func readManifestInput(path, kind, flag string) (manifest.Manifest, error) {
@@ -167,6 +220,9 @@ func filterPRDiagnostics(repo, changedFrom string, scope qdiff.Scope, m manifest
}
func prDiagnosticRelevant(repo string, changedFiles map[string]bool, commandScope diagnosticCommandScope, m manifest.Manifest, diag report.Diagnostic) bool {
if strings.HasPrefix(diag.Rule, "public_content_") {
return true
}
file := normalizeDiagnosticFile(repo, diag.File)
if file != "" && changedFiles[file] {
return true

View File

@@ -189,6 +189,99 @@ description: Manage Drive comments with service command references.
}
}
func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
if err := vfs.WriteFile(filepath.Join(repo, "README.md"), []byte("# test\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, repo, "add", "README.md")
runGit(t, repo, "commit", "-m", "base")
if err := vfs.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil {
t.Fatal(err)
}
publicDoc := "api_" + "key = \"example-public-key\"\n" +
"Public docs describe a pri" + "vate request header and trust classification detail.\n"
if err := vfs.WriteFile(filepath.Join(repo, "docs", "public.md"), []byte(publicDoc), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add public doc")
metadataPath := filepath.Join(repo, "pr-metadata.json")
if err := vfs.WriteFile(metadataPath, []byte(`{"title":"public docs","body":"Change`+`-Id: I0123456789abcdef0123456789abcdef01234567"}`), 0o644); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(repo, "command-manifest.json")
indexPath := filepath.Join(repo, "command-index.json")
m := manifest.Manifest{SchemaVersion: 1, Commands: []manifest.Command{{
Path: "docs +fetch",
CanonicalPath: "docs +fetch",
Domain: "docs",
Source: manifest.SourceShortcut,
}}}
if err := manifest.WriteFile(manifestPath, manifest.KindCommandManifest, m); err != nil {
t.Fatal(err)
}
idx := manifest.Manifest{SchemaVersion: 1, Commands: append([]manifest.Command{}, m.Commands...)}
idx.Commands = append(idx.Commands, manifest.Command{
Path: "drive files get",
CanonicalPath: "drive files get",
Domain: "drive",
Source: manifest.SourceService,
Generated: true,
Runnable: true,
})
if err := manifest.WriteFile(indexPath, manifest.KindCommandIndex, idx); err != nil {
t.Fatal(err)
}
diags, gotFacts, err := Run(context.Background(), Options{
Repo: repo,
CLIBin: "./lark-cli",
ChangedFrom: "HEAD~1",
ManifestPath: manifestPath,
CommandIndexPath: indexPath,
PublicContentMetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
actions := map[string]report.Action{}
for _, diag := range diags {
actions[diag.Rule] = diag.Action
}
if actions["public_content_generic_credential"] != report.ActionReject {
t.Fatalf("generic credential diagnostic action = %q, diagnostics=%#v", actions["public_content_generic_credential"], diags)
}
if actions["public_content_change_id_trailer"] != report.ActionReject {
t.Fatalf("change-id diagnostic action = %q, diagnostics=%#v", actions["public_content_change_id_trailer"], diags)
}
if actions["public_content_semantic_candidate"] != "" {
t.Fatalf("semantic candidates should not become deterministic diagnostics: %#v", diags)
}
factRules := map[string]bool{}
for _, item := range gotFacts.PublicContent {
factRules[item.Rule] = true
}
for _, want := range []string{
"public_content_generic_credential",
"public_content_change_id_trailer",
"public_content_semantic_candidate",
} {
if !factRules[want] {
t.Fatalf("missing public content fact %s: %#v", want, gotFacts.PublicContent)
}
}
if len(gotFacts.PublicContent) < 3 {
t.Fatalf("public content facts = %#v", gotFacts.PublicContent)
}
}
func TestLoadBaseReferenceManifestReadsCommandGolden(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
@@ -506,7 +599,7 @@ func TestNormalizeDiagnosticFileHandlesAbsoluteRepo(t *testing.T) {
func runGit(t *testing.T, repo string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
cmd := exec.Command("git", append([]string{"-c", "core.hooksPath=/dev/null", "-C", repo}, args...)...)
cmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE=2026-06-17T00:00:00Z", "GIT_COMMITTER_DATE=2026-06-17T00:00:00Z")
out, err := cmd.CombinedOutput()
if err != nil {

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