diff --git a/skills/lark-workflow-standup-report/.gitattributes b/skills/lark-workflow-standup-report/.gitattributes new file mode 100644 index 000000000..dfdb8b771 --- /dev/null +++ b/skills/lark-workflow-standup-report/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/skills/lark-workflow-standup-report/.gitignore b/skills/lark-workflow-standup-report/.gitignore new file mode 100644 index 000000000..49f736ca1 --- /dev/null +++ b/skills/lark-workflow-standup-report/.gitignore @@ -0,0 +1,3 @@ +*.tmp +Thumbs.db +.DS_Store diff --git a/skills/lark-workflow-standup-report/SKILL.md b/skills/lark-workflow-standup-report/SKILL.md index 4903553ca..d0669af10 100644 --- a/skills/lark-workflow-standup-report/SKILL.md +++ b/skills/lark-workflow-standup-report/SKILL.md @@ -1,122 +1,206 @@ --- name: lark-workflow-standup-report -version: 1.0.0 -description: "日程待办摘要:编排 calendar +agenda 和 task +get-my-tasks,生成指定日期的日程与未完成任务摘要。适用于了解今天/明天/本周的安排。" -metadata: - requires: - bins: ["lark-cli"] +description: "工作日报/周报/WBS 生成:综合飞书日历、视频会议、群聊、云文档、Codex 或 Claude Code 本地会话、项目知识记录、WBS Base 表结构等多源数据,生成含来源标注的日报/周报,或生成可写入研发 WBS 多维表格的条目草稿。用于生成今日日报、当天日报、工作日报、本周周报、工作简报、周工作总结、飞书周报,以及根据聊天和会议总结 WBS、填研发 WBS、按 WBS 表格要求整理周报;当用户希望直接产出飞书云文档而不是只要本地 Markdown 时,也必须使用这个 skill。" --- -# 日程待办摘要工作流 +# 工作日报 / 周报 / WBS + +开始前必须先读取 `../lark-shared/SKILL.md`,并使用 `--as user`。当需要创建或更新飞书云文档时,同时读取 `../lark-doc/SKILL.md`。 + +## 模式选择 + +| 用户意图 | 模式 | 默认动作 | +| --- | --- | --- | +| 日报、今日、当天、今天总结 | 日报模式 | 先本地 Markdown,再按需创建飞书文档 | +| 周报、工作简报、周总结 | 周报简报模式 | 先本地 Markdown,再按需创建飞书文档 | +| WBS、研发 WBS、多维表格、按条目填表 | WBS 填报模式 | 先分析表结构,再生成 WBS 草稿 | +| 明确写入/新增/加到表格 | WBS 写入 | 先生成或读取写入计划,再写表并核验 | + +日报默认时间范围为用户所在时区当天 `00:00:00` 到当前时刻。周报默认自然周。日报只覆盖当天,除非用户明确要求补写其他日期。 + +## 跨平台脚本要求 + +Windows/PowerShell 使用 `.ps1` 脚本。macOS/Linux 使用 `.sh` 脚本,并要求 `bash`、`jq`、`python3`、`perl`、`lark-cli` 可用。 + +macOS 可用 Homebrew 补齐依赖:`brew install jq python3`。脚本避免依赖 GNU-only 命令;若运行环境缺少 `timeout`,采集命令会降级为无外层超时,但仍会保留 lark-cli 自身错误输出。 + +## 日报强制流程 + +日报模式必须走脚本化采集和候选审查,避免漏翻页、复用旧证据或把他人群聊事项误归因为本人工作。 + +1. 确定日期、起止时间、输出目录: + - 本地 Markdown:`outputs/工作日报-YYYY-MM-DD.md` + - 工作目录:`work/daily_YYYY-MM-DD/` +2. 运行采集脚本采集飞书数据、分页、脱敏,并生成 `source_manifest.json`。 + - macOS/Linux 优先使用 `scripts/collect_lark_daily.sh`。 + - Windows/PowerShell 使用 `scripts/collect_lark_daily.ps1`。 + - 会议证据不能只停留在 `vc +search` 列表;脚本应优先补抓会议详情,以及可用的逐字稿/纪要/妙记元数据。 + - 妙记 AI 总结、待办、章节只可作为“候选线索”,不能直接当高可信主结论;只要拿到了逐字稿,后续必须以逐字稿为主,由 Agent 自己归纳。 + - 私聊证据不能在候选审查里退化成泛化的 `本人 p2p 消息`;后续审查和正文应尽量落到“与谁私聊”“哪个群”“哪场会议”“哪个文档”。 +3. 运行 Agent 证据脚本采集 Codex、Claude Code 和本地项目证据,并生成 `agent_session_evidence_YYYY-MM-DD.md` 与 `agent_evidence_YYYY-MM-DD.json`。 + - macOS/Linux 优先使用 `scripts/collect_agent_evidence.sh`。 + - Windows/PowerShell 使用 `scripts/collect_agent_evidence.ps1`。 + - 本地 Codex / Claude Code 会话不是默认低一级证据。若事项本身是本地方案、代码、测试、文档、构建或知识沉淀产出,且有当天可验证文件,则本地 Agent 证据可作为该工作包主证据。 + - 只有在“引用会议内容下结论”时,同主题本地 Agent 会话才默认作为会议证据的补强,不能替代逐字稿、纪要正文或会议元数据。 +4. 运行候选审查脚本生成 `candidate_review_YYYY-MM-DD.md`。写日报前必须阅读该文件。 + - macOS/Linux 优先使用 `scripts/build_candidate_review.sh`。 + - Windows/PowerShell 使用 `scripts/build_candidate_review.ps1`。 +5. 若会议拿到了逐字稿,写日报前必须先阅读逐字稿并自行做摘要;不得直接复述飞书内置 AI 的妙记摘要。若当天存在同主题 Codex / Claude Code 会话、本地调研文档或知识记录,只在会议事项归因环节把这些材料作为补强证据,与逐字稿交叉校验。 +6. 按 `references/daily-report-rules.md` 生成 Markdown。自动化日报、飞书日报和用户未要求简版的日报正文必须使用五段式: + - `今日口述摘要` + - `今日推进明细` + - `风险与阻塞` + - `明日计划` + - `数据来源说明` + - 每个工作包、风险或计划默认只写一行 `来源:`,把飞书会议、群聊、云文档和本地证据合并到同一行,避免一条事项拆成多行来源。 +7. 只有用户明确要求“简版/只要核心进展”时,才可省略 `今日口述摘要` 和 `数据来源说明`;省略原因仍写入执行记录、memory 或最终回复。 +8. 若需要飞书文档,使用文档创建脚本创建到日报父 Wiki 节点,并保存响应到 `work/daily_YYYY-MM-DD/feishu_doc_create_daily_YYYY-MM-DD.json`。 + - macOS/Linux 优先使用 `scripts/create_daily_doc.sh`。 + - Windows/PowerShell 使用 `scripts/create_daily_doc.ps1`。 +9. 在 `knowledge/` 和 automation memory 中记录文档 URL、覆盖范围、未完整获取的数据源、候选审查结论和未纳入的重要本地项目。 + +示例脚本调用: + +macOS/Linux: -**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理** +```bash +date="2026-06-18" +start="2026-06-18T00:00:00+08:00" +end="2026-06-18T23:00:00+08:00" +dir="work/daily_2026-06-18" +skill_dir="$HOME/.agents/skills/lark-workflow-standup-report" + +bash "$skill_dir/scripts/collect_lark_daily.sh" \ + --date "$date" --start "$start" --end "$end" --out-dir "$dir" + +bash "$skill_dir/scripts/collect_agent_evidence.sh" \ + --date "$date" --start "$start" --end "$end" --out-dir "$dir" + +bash "$skill_dir/scripts/build_candidate_review.sh" \ + --date "$date" \ + --source-manifest-path "$dir/source_manifest.json" \ + --agent-evidence-json-path "$dir/agent_evidence_$date.json" \ + --out-file "$dir/candidate_review_$date.md" +``` -## 适用场景 +Windows/PowerShell: -- "今天有什么安排" / "今天的日程和待办" -- "明天有什么会" / "明日日程与未完成任务" -- "帮我看看今天要做什么" / "早报摘要" -- "开工摘要" / "standup report" -- "这周还有哪些安排" +```powershell +$date = "2026-06-18" +$start = "2026-06-18T00:00:00+08:00" +$end = "2026-06-18T23:00:00+08:00" +$dir = "work/daily_2026-06-18" -## 前置条件 +& "C:\Users\Leo\.agents\skills\lark-workflow-standup-report\scripts\collect_lark_daily.ps1" ` + -Date $date -Start $start -End $end -OutDir $dir -仅支持 **user 身份**。执行前确保已授权: +& "C:\Users\Leo\.agents\skills\lark-workflow-standup-report\scripts\collect_agent_evidence.ps1" ` + -Date $date -Start $start -End $end -OutDir $dir -```bash -lark-cli auth login --domain calendar,task +& "C:\Users\Leo\.agents\skills\lark-workflow-standup-report\scripts\build_candidate_review.ps1" ` + -Date $date ` + -SourceManifestPath "$dir/source_manifest.json" ` + -AgentEvidenceJsonPath "$dir/agent_evidence_$date.json" ` + -OutFile "$dir/candidate_review_$date.md" ``` -## 工作流 +## 日报归因红线 -``` -{date} ─┬─► calendar +agenda [--start/--end] ──► 日程列表(会议/事件) - └─► task +get-my-tasks --complete=false [--due-end] ──► 未完成待办列表 - │ - ▼ - AI 汇总(时间转换 + 冲突检测 + 排序)──► 摘要 -``` +写日报前必须执行候选审查: -### Step 1: 获取日程 +- 只写本人主导、本人明确推进、本人实际产出、本人需要继续跟进的事项。 +- 全量群聊只作上下文;无本人发言/响应/会议参与/本地产出/用户显式归属时,不纳入正文。 +- 他人原型更新、Bug 修复、技术讨论不得写成本人推进事项,只能作为背景或风险来源。 +- 普通日报采集、整理、创建文档不作为 `今日推进明细` 的业务工作项,只能放在 `数据来源说明`、执行记录或 memory。 +- 妙记 AI 摘要、待办、章节不是高可信主证据;如果逐字稿可用,必须以逐字稿为准,由 Agent 自己总结,不得把飞书 AI 的表述直接当作正式结论。 +- 本地 Codex / Claude Code 会话只有在“会议事项归因”场景中才默认作为补强证据,不能替代逐字稿或会议元数据本身;若会议内容只靠本地会话单边推断,正文必须降级表述为“本地分析判断”而不是“会议已确认”。 +- 若事项本身是本地 Agent 工作产出,且有当天 knowledge、代码、脚本、测试、文档、构建产物等可验证落地,本地 Codex / Claude Code 会话和产物可作为该工作包的主证据。 +- AIEXCEL、Swimlane、Wardrobe 等历史重点项目即使未纳入,也要在证据或执行记录中写明原因。 -```bash -# 今天(默认,无需额外参数) -lark-cli calendar +agenda +详细规则见 `references/daily-report-rules.md`。 -# 指定日期范围(必须使用 ISO 8601 格式,不支持 "tomorrow" 等自然语言) -lark-cli calendar +agenda --start "2026-03-26T00:00:00+08:00" --end "2026-03-26T23:59:59+08:00" -``` +## 周报与 WBS -> **注意**:`--start` / `--end` 仅支持 ISO 8601 格式(如 `2026-01-01` 或 `2026-01-01T15:04:05+08:00`)和 Unix timestamp,**不支持** `"tomorrow"`、`"next monday"` 等自然语言。需要 AI 根据当前日期自行计算目标日期。 +周报简报、WBS 草稿、WBS 写入护栏见 `references/weekly-wbs-rules.md`。 -输出包含:event\_id、summary、start\_time(含 timestamp + timezone)、end\_time、free\_busy\_status、self\_rsvp\_status。 +关键约束: -### Step 2: 获取未完成待办 +- WBS 草稿必须包含 `口述汇报摘要` 和 `候选 WBS 条目`,否则不能作为写表依据。 +- 用户说“先在本地 MD 写一下”时禁止写表。 +- 用户明确要求写入时,默认写入全部候选条目;若要减少条目,必须先列出跳过原因并等待用户认可。 +- WBS 写入/推送到 Base 时,`任务级别` 必须统一写入 `二级`;即使草稿或候选条目里出现 `一级`,构造 `record-upsert` payload 前也必须覆盖为 `二级`。 +- WBS 排除只影响候选条目和实际写表,不影响日报/周报正文,除非用户明确说“日报也不要写”。 -```bash -# 默认 pending 摘要:必须显式过滤未完成任务(最多 20 条) -lark-cli task +get-my-tasks --complete=false +## 飞书命令参考 -# 只看指定日期前到期的未完成任务(推荐用于摘要场景,减少数据量) -lark-cli task +get-my-tasks --complete=false --due-end "2026-03-27T23:59:59+08:00" +优先使用本 skill 的脚本。脚本不可用或需要调试时,读取 `references/lark-source-commands.md`。 -# 获取全部未完成任务(超过 20 条时) -lark-cli task +get-my-tasks --complete=false --page-all -``` +权限缺失时按 `../lark-shared/SKILL.md` 处理: -> **注意**:`+get-my-tasks` 不带 `--complete` 时会**同时返回已完成和未完成任务**,会把已完成任务当成"待办"展示进摘要里。站会/日报这种 pending 汇总场景**必须**显式带上 `--complete=false`,不要省略。 -> -> 数据量层面也建议加过滤: -> - 用 `--due-end` 过滤出目标日期前到期的任务 -> - 如果也需要无截止日期的任务,可不加 `--due-end`,但 AI 汇总时只展示**近 30 天内创建的**,其余折叠为"其他 N 项历史待办" +- 日历:`calendar:calendar.event:read` +- 视频会议搜索:`vc:meeting.search:read` +- 会议详情:`vc:meeting.meetingevent:read` +- 会议纪要:`vc:note:read` +- 妙记元信息/AI 产物(按需):`minutes:minutes:readonly minutes:minutes.artifacts:read minutes:minutes.transcript:export` +- 群聊消息:`search:message` +- 群聊消息详情(user 身份):基础权限 `im:message:readonly`(或 `im:message`)+ 补充权限 `im:message.group_msg:get_as_user im:message.p2p_msg:get_as_user` +- 当前用户信息(user 身份):`contact:user.basic_profile:readonly` +- 云文档:`search:docs:read` -### Step 3: AI 汇总 +## 失败分支 -将 Step 1 和 Step 2 的结果整合,按以下结构输出: +| 触发条件 | 一线处理 | 仍失败时 | +| --- | --- | --- | +| `lark-cli` 或 Node 运行时不可用 | 读取 `references/lark-source-commands.md`,定位 CLI 或设置 `LARK_CLI_NODE` / `LARK_CLI_RUNJS` | 不创建飞书文档,只输出本地 Markdown,并在执行记录写明阻塞 | +| 飞书权限不足 | 按 `../lark-shared/SKILL.md` 使用最小 scope 重新授权 | 跳过该数据源,正文不编造结论,执行记录标明未获取 | +| 群聊/云文档分页失败 | 保留已获取页面,记录 `source_manifest.json` 的错误项 | 候选审查中降低置信度,不能把缺证据事项写成确定推进 | +| 会议纪要/妙记获取失败 | 先保留 `vc +search` 和 `meeting get` 结果,再单独记录 notes/minutes 错误 | 仍可生成日报,但必须在 `数据来源说明` 和执行记录中写明会议内容仅使用元数据/链接,未拿到逐字稿或纪要正文 | +| 只有妙记 AI 摘要、没有逐字稿 | 把 AI 摘要当候选线索,再用本人消息、云文档、本地 Agent 产物交叉验证 | 若仍缺逐字稿和交叉证据,不得把 AI 摘要写成确定结论;正文降级为背景、风险或待确认线索 | +| 群聊消息搜索超时 | 优先保留本人消息;macOS/Linux 用 `collect_lark_daily.sh --im-chunk-hours 1 --im-request-timeout-seconds 45 --im-max-failures-per-search 4`,Windows 用 `collect_lark_daily.ps1 -ImChunkHours 1 -ImRequestTimeoutSeconds 45 -ImMaxFailuresPerSearch 4` 分片采集并隔离失败 | 跳过超时的全量群聊分片,在 `数据来源说明` 标明缺口,不阻塞日报生成 | +| 本地 Agent 证据脚本无候选 | 检查 `CODEX_HOME`、Claude `projects`、项目根目录参数 | 在执行记录写“未发现当天本地证据”,不要复用旧证据 | +| 候选审查表缺失 | 先运行 `build_candidate_review.sh` 或 `build_candidate_review.ps1` | 停止写日报正文,直到审查表生成 | +| 飞书文档创建失败 | 保存失败响应,保留本地 Markdown | 最终回复给出本地路径、失败原因和重试命令 | -``` -## {日期}摘要({YYYY-MM-DD 星期X}) - -### 日程安排 -| 时间 | 事件 | 组织者 | 状态 | -|------|------|--------|------| -| 09:00-10:00 | 产品需求评审 | 张三 | 已接受 | -| 14:00-15:00 | 技术方案讨论 | 李四 | 待确认 | - -### 待办事项 -- [ ] {task_summary}(截止:{due_date}) -- [ ] {task_summary} - -### 小结 -- 共 {n} 场会议,{m} 项待办 -- 冲突提醒:{列出时间重叠的日程} -- 空闲时段:{free_slots}(根据日程推算) -``` +## 检查点 + +🔴 CHECKPOINT · 写入 WBS 前必须停下:只有用户明确要求“写入/新增/加到表格”,并且本地草稿存在 `候选 WBS 条目` 与 `实际写入计划`,才允许写 Base。 + +🔴 CHECKPOINT · 覆盖或更新已有飞书日报前必须停下:除非用户明确要求覆盖更新,否则默认创建新文档或只返回本地 Markdown。 + +## 反例黑名单 + +不要做这些事: + +- 不要跳过 `candidate_review_YYYY-MM-DD.md` 直接写日报。 +- 不要把私聊候选统一写成 `本人p2p消息` 这种不可读标签;优先写“与某某私聊”或“未命名私聊联系人”。 +- 不要把他人群聊推进、机器人 Bug 通知、仅浏览文档写成本人工作。 +- 不要把普通日报自动化采集/创建文档写成业务工作项。 +- 不要复用旧的 Agent 证据文件当作当天完整证据。 +- 不要展示裸 `message_id`、敏感凭据、token、连接串。 +- 不要在未确认候选条目数量和跳过原因时写入 WBS。 +- 不要把飞书来源集中堆到附录。 +- 不要把一条事项拆成 3-5 行零散来源;默认合并成一行 `来源:链接 A;链接 B;本地证据 C`。 +- 不要把妙记 AI 自动摘要、待办或章节原文直接抄进日报当正式结论。 +- 不要在已有逐字稿时仍优先引用 AI 摘要;逐字稿优先,由 Agent 自己总结。 +- 不要把本地 Codex / Claude Code 分析误写成“会议已确认”;它只能补强会议事项,不能替代会议原始证据。 +- 不要把“会议事项的补强证据”泛化成“所有 Codex / Claude Code 会话都只能补强”;本地 Agent 实际产出有落地文件时可作为主证据。 + +## 输出与记录 + +每条正式结论必须包含: + +1. 事项描述。 +2. 背景/目标、价值或原因。 +3. 直接内联来源标注。 + +禁止: + +- 时间流水账。 +- 无来源支撑的结论。 +- 裸 `message_id`。 +- 敏感凭据、token、连接串。 +- 把来源集中放到附录。 + +日报飞书标题建议:`工作日报 YYYY-MM-DD 杜励承`。正文不要重复一级标题。 -**数据处理规则:** - -1. **时间转换**:API 返回 Unix timestamp,需根据 `timezone` 字段(通常为 `Asia/Shanghai`)转换为 `HH:mm` 格式 -2. **RSVP 状态映射**: - | API 值 | 显示文案 | - |--------|---------| - | `accept` | 已接受 | - | `decline` | 已拒绝 | - | `needs_action` | 待确认 | - | `tentative` | 暂定 | -3. **日程排序**:按开始时间升序排列 -4. **冲突检测**:按时间排序后,检查相邻日程是否有时间重叠(前一个 end\_time > 后一个 start\_time),有则在小结中列出冲突组 -5. **已拒绝日程**:标注"已拒绝"但不计入忙碌时段和冲突检测 -6. **待办排序**:按截止时间升序,已过期的标注"已过期",无截止时间的排在最后 - -## 权限表 - -| 命令 | 所需 scope | -|------|-----------| -| `calendar +agenda` | `calendar:calendar.event:read` | -| `task +get-my-tasks` | `task:task:read` | - -## 参考 - -- [lark-shared](../lark-shared/SKILL.md) — 认证、权限(必读) -- [lark-calendar](../lark-calendar/SKILL.md) — `+agenda` 详细用法 -- [lark-task](../lark-task/SKILL.md) — `+get-my-tasks` 详细用法 +创建飞书文档后,必须保存创建响应,并在 workspace `knowledge/` 追加中文执行记录。 diff --git a/skills/lark-workflow-standup-report/agents/openai.yaml b/skills/lark-workflow-standup-report/agents/openai.yaml new file mode 100644 index 000000000..e96f88315 --- /dev/null +++ b/skills/lark-workflow-standup-report/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Lark Standup Report" + short_description: "Generate sourced Lark daily and weekly reports" + default_prompt: "Use $lark-workflow-standup-report to generate today's sourced work report and create the Feishu document." diff --git a/skills/lark-workflow-standup-report/references/daily-report-rules.md b/skills/lark-workflow-standup-report/references/daily-report-rules.md new file mode 100644 index 000000000..6c80c36d5 --- /dev/null +++ b/skills/lark-workflow-standup-report/references/daily-report-rules.md @@ -0,0 +1,90 @@ +# 日报模式规则 + +## 正文结构 + +自动化日报、飞书日报和用户未要求简版的日报正文必须包含: + +1. `今日口述摘要`:用 3-5 句话概括本人当天真实推进、主要产出、关键风险和明日重心;不写没有证据的项目。 +2. `今日推进明细`:按工作包列出事项、进展、产出、来源、状态;每个工作包默认只保留一行 `来源:`,把会议、群聊、云文档和本地证据合并到这一行。 +3. `风险与阻塞`:没有明确阻塞时写“暂无明确阻塞”;每条风险默认只保留一行 `来源:`。 +4. `明日计划`:最多 3 条,来自当天未闭环事项或用户明确计划;每条计划默认只保留一行 `来源:`。 +5. `数据来源说明`:说明覆盖的日历、视频会议、群聊、云文档、本地 Agent/项目证据;列出未完整获取的数据源和未纳入的重要本地项目原因。 + +只有用户明确要求“简版/只要核心进展”时,才可省略 `今日口述摘要` 和 `数据来源说明`。数据覆盖情况仍必须写入本地执行记录、automation memory 或最终回复。 + +## 归因门槛 + +日报正文只写“本人主导、本人明确推进、本人实际产出、本人需要继续跟进”的事项。 + +一个飞书事项进入 `今日推进明细` 至少满足一项: + +- 本人发出明确行动消息或决策消息。 +- 本人组织或深度参与会议。 +- 本人会议关联的逐字稿、纪要正文,或经逐字稿核验后的 Agent 摘要直接支撑事项。 +- 本人创建或编辑相关文档。 +- 本地 Agent 或项目目录有可验证产出。 +- 用户显式说明该事项归自己负责。 + +全量群聊搜索只用于发现上下文和待关注事项。若没有本人发言、本人被 @ 后明确响应、会议参与证据或本地产出,默认放弃,不写入正式日报。 + +他人更新原型、修复 Bug、讨论技术路径时,即使与本人所在项目有关,也只能作为现有本人事项的背景/风险来源;不能单独写成“我推进了某某功能”。 + +对归属不确定的候选事项,降级到本地执行记录的“未纳入/待确认线索”,不要写入飞书日报正文。 + +## 自动化任务边界 + +日报自动化本身通常不是业务工作项。普通采集、整理、创建日报文档只写入执行记录或 `数据来源说明`,不得写入 `今日推进明细`。 + +只有当本次自动化产生以下内容时,才可写入 `今日推进明细`: + +- 可复用流程改进。 +- skill 规则修订。 +- 跨团队材料。 +- 自动化缺陷修复。 +- 用户明确要求展示的自动化产出。 + +## 来源链接规则 + +- 日历、视频会议、云文档有 URL 或 AppLink 时,必须在相关事项的 `来源` 行直接用 Markdown 链接展示。 +- 会议相关来源优先级:`逐字稿正文` > `Agent 基于逐字稿形成的摘要 + 本地补强证据` > `纪要正文/妙记链接` > `妙记 AI 总结/待办/章节(仅候选线索)` > `meeting app_link / 会议详情`。只要拿到了逐字稿,就不要再把 AI 摘要当主证据。 +- 群聊来源也必须在相关事项下直接展示。若只有群聊入口链接而没有单条消息直达链接,使用 `来源:[群聊:群名](群聊入口URL)(时间范围;内容摘要:...)`。 +- 私聊来源不要写成抽象的 `本人p2p消息`;正文和候选审查里优先写 `来源:与张三私聊(时间范围;内容摘要:...)`。如果拿不到联系人姓名,再退化为“未命名私聊联系人”。 +- 不要把链接集中放在 `参考链接`、`来源索引`、`附录` 等章节。 +- 不要在日报正文展示裸 `message_id`。若 CLI/API 不能获取单条消息直达链接,改为关键消息内容摘要。 +- 禁止根据 `chat_id`/`message_id` 猜造消息直达链接。 +- 同一事项的来源默认合并成一行,格式示例:`来源:[会议纪要](...);[群聊:Fiber产品](...)(17:06;确认 A/B 两端都可关联);D:\AICODING\AIEXCEL\docs\...`。 + +## 本地 Agent 证据 + +本地 Agent 线索可能对应独立工作包,也可能只是补充现有飞书事项的证据。不得只写“使用了 Agent”,必须提炼事项、进展、产出、验证和状态。 + +若事项本身是本地 Agent 工作产出,并且有当天 `knowledge`、代码、脚本、测试、文档、构建产物、发布记录或可复核文件路径,本地 Codex / Claude Code 会话和这些产物可以作为该工作包主证据。 + +本地 Agent 来源在日报正文中应引用可读的证据摘要文件、项目 knowledge 文件、产物路径或可打开的线上地址;不要把完整 JSONL 日志或冗长命令输出贴入日报正文。 + +若本地 Agent 会话与当天某场会议直接相关,并且正文要写“会议讨论/会议确认/会议推进”的结论,才默认把它当作“会议事项补强证据”,用于: + +- 帮助解释逐字稿中的术语、方案推演和后续产物。 +- 交叉验证会议后是否形成了真实输出。 + +但它不能单独替代会议原始证据。没有逐字稿或会议元数据时,不能把本地 Agent 会话写成“会议已确认”的事实。 + +## 会议证据优先级 + +日报模式下,飞书会议不应天然弱于本地证据。默认按下列顺序使用会议证据: + +1. 逐字稿正文,以及 Agent 基于逐字稿自行提炼的摘要。 +2. 与该会议同主题的本地 Codex / Claude Code 会话、knowledge、方案文档,在会议事项归因场景中用于补强逐字稿结论。 +3. 纪要文档、逐字稿文档或妙记链接。 +4. 妙记 AI 总结、待办、章节,只能作为候选线索或检索导航,不应直接当最终结论。 +5. `vc meeting get` 的会议主题、组织者、参会人、开始时间、共享文档。 +6. `vc +search` 的会议搜索元数据。 + +若 1 获取失败,但 3-6 成功,正文可继续使用会议元数据;同时在 `数据来源说明` 中明确写“未获取逐字稿,仅使用会议元数据/链接,AI 摘要未作为主结论”。 + +## 会议 AI 摘要使用红线 + +- 妙记 AI 摘要、待办、章节属于飞书内置模型的二次总结,不是完全可靠的一手信源。 +- 只要逐字稿可用,必须优先读逐字稿,由 Agent 自己总结,不得直接复述 AI 摘要原文。 +- 若只有 AI 摘要没有逐字稿,必须再用本人消息、相关文档或本地 Agent 产物交叉验证;没有补强证据时,只能写成背景线索、风险或待确认项。 +- 若 AI 摘要与逐字稿或本地产物冲突,以逐字稿和可验证产物为准。 diff --git a/skills/lark-workflow-standup-report/references/lark-source-commands.md b/skills/lark-workflow-standup-report/references/lark-source-commands.md new file mode 100644 index 000000000..97317a50f --- /dev/null +++ b/skills/lark-workflow-standup-report/references/lark-source-commands.md @@ -0,0 +1,163 @@ +# 飞书数据源与命令参考 + +执行前必须读取 `../lark-shared/SKILL.md` 了解 user/bot 身份和权限处理。本 skill 仅使用 `--as user`。 + +## 推荐脚本 + +日报优先运行。macOS/Linux 使用 `.sh`,Windows/PowerShell 使用 `.ps1`。 + +macOS/Linux 依赖 `bash`、`jq`、`python3`、`perl`、`lark-cli`。若 macOS 自带 Bash 版本过旧,可使用 Homebrew 安装新版 bash:`brew install bash jq python3`。 + +macOS/Linux: + +```bash +scripts/collect_lark_daily.sh \ + --date "YYYY-MM-DD" \ + --start "YYYY-MM-DDT00:00:00+08:00" \ + --end "YYYY-MM-DDTHH:mm:ss+08:00" \ + --out-dir "work/daily_YYYY-MM-DD" \ + --im-chunk-hours 1 \ + --im-request-timeout-seconds 45 \ + --im-max-failures-per-search 4 +``` + +Windows/PowerShell: + +```powershell +scripts/collect_lark_daily.ps1 ` + -Date "YYYY-MM-DD" ` + -Start "YYYY-MM-DDT00:00:00+08:00" ` + -End "YYYY-MM-DDTHH:mm:ss+08:00" ` + -OutDir "work/daily_YYYY-MM-DD" ` + -ImChunkHours 1 ` + -ImRequestTimeoutSeconds 45 ` + -ImMaxFailuresPerSearch 4 +``` + +脚本会采集日历、视频会议、会议详情、可用的会议纪要/妙记、当前用户、全量群聊、本人群聊、云文档,处理分页,脱敏原始 JSON,并生成 `source_manifest.json`。 + +群聊搜索慢时,脚本先采本人消息,再采全量群聊;全量群聊按时间片隔离失败。某个时间片超时只写入 `source_manifest.json.errors`,不得阻塞日历、会议、本人消息、云文档和本地 Agent 证据采集。 + +## 手工命令 + +仅在脚本不可用或需要调试时手工执行。 + +### 日历 + +```bash +lark-cli calendar +agenda --as user \ + --start "" --end "" --format json +``` + +### 视频会议 + +```bash +lark-cli vc +search --as user \ + --start "" --end "" --format json --page-size 30 +``` + +`has_more=true` 时使用 `--page-token` 翻页。对高信号会议默认继续读取详情: + +```bash +lark-cli vc meeting get --as user \ + --params '{"meeting_id":"","with_participants":true}' +``` + +若会议存在纪要或妙记,再继续读取: + +```bash +lark-cli vc +notes --as user --meeting-ids "" --format json +``` + +若已拿到妙记链接或 minute token,再继续读取: + +```bash +lark-cli vc +notes --as user --minute-tokens "" --format json +``` + +日报模式下,会议证据默认优先级是: + +1. 逐字稿正文。 +2. Agent 基于逐字稿形成的自主摘要,以及同主题本地 Codex / Claude Code 会话、knowledge、产物在会议事项归因场景中形成的补强证据。 +3. 纪要/逐字稿/妙记链接。 +4. 妙记 AI 总结、待办、章节,仅作候选线索。 +5. `meeting get` 的会议详情。 +6. `vc +search` 搜索元数据。 + +`vc +notes` 或妙记拉取失败时不阻塞,直接降级到 `meeting get` 结果,但必须在 `source_manifest.json.errors` 和 `数据来源说明` 里写明。 + +只要拿到了逐字稿,就必须阅读逐字稿并自行总结,不要直接把飞书内置 AI 的妙记总结、待办或章节原文抄进日报。若只有 AI 摘要没有逐字稿,必须再用本人消息、云文档或本地 Agent 产物交叉验证;没有补强证据时,不得把 AI 摘要写成确定结论。本地 Agent 产出只有在引用会议结论时是补强;若它本身就是当天工作产出,仍可作为本地工作包主证据。 + +### 群聊消息 + +```bash +lark-cli im +messages-search --as user \ + --sender "ou_" \ + --start "" --end "" \ + --page-size 50 --format json +``` + +```bash +lark-cli im +messages-search --as user \ + --start "" --end "" \ + --page-size 50 --format json +``` + +两条路径都必须翻页。重点提取本人发出的含链接、文档、结论、行动项的消息。忽略机器人消息、表情回复、纯社交闲聊。 + +如果搜索结果中只有 `message_ids`,并出现 `failed to fetch message details`,说明 user token 具备搜索权限但缺少消息详情读取权限。不要切换到 bot 身份;user 身份读取正文需要“基础读消息权限 + 按会话类型补充权限”。优先使用最小只读授权: + +```bash +lark-cli auth login --scope "im:message:readonly im:message.group_msg:get_as_user im:message.p2p_msg:get_as_user" +``` + +授权后重新运行采集脚本。只有 ID 无正文时,日报不能把群聊内容写成确定结论。 + +### 云文档 + +```bash +lark-cli docs +search --as user --query "" \ + --filter '{"open_time":{"start":"","end":""}}' \ + --page-size 20 --format json +``` + +筛选重点: + +- 本人创建/更新的文档。 +- 与本周或当天主线相关的文档标题。 + +## 文档创建 + +优先运行。macOS/Linux 使用 `.sh`,Windows/PowerShell 使用 `.ps1`。 + +macOS/Linux: + +```bash +scripts/create_daily_doc.sh \ + --markdown-path "outputs/工作日报-YYYY-MM-DD.md" \ + --title "工作日报 YYYY-MM-DD 杜励承" \ + --wiki-node "<日报父 Wiki 节点 URL>" \ + --response-path "work/daily_YYYY-MM-DD/feishu_doc_create_daily_YYYY-MM-DD.json" +``` + +Windows/PowerShell: + +```powershell +scripts/create_daily_doc.ps1 ` + -MarkdownPath "outputs/工作日报-YYYY-MM-DD.md" ` + -Title "工作日报 YYYY-MM-DD 杜励承" ` + -WikiNode "<日报父 Wiki 节点 URL>" ` + -ResponsePath "work/daily_YYYY-MM-DD/feishu_doc_create_daily_YYYY-MM-DD.json" +``` + +手工命令(v2): + +```bash +lark-cli docs +create --api-version v2 --as user \ + --parent-token "<日报父 Wiki 节点 token>" \ + --doc-format markdown \ + --content "@work/daily_YYYY-MM-DD/daily_doc_create_content.md" \ + --format json +``` + +正文不要重复一级标题。 diff --git a/skills/lark-workflow-standup-report/references/weekly-wbs-rules.md b/skills/lark-workflow-standup-report/references/weekly-wbs-rules.md new file mode 100644 index 000000000..9163bc453 --- /dev/null +++ b/skills/lark-workflow-standup-report/references/weekly-wbs-rules.md @@ -0,0 +1,80 @@ +# 周报与 WBS 规则 + +## 周报简报模式 + +使用三段式: + +```markdown +## 本周工作简报(YYYY-MM-DD ~ YYYY-MM-DD) + +### 【本周核心】 +3-8 条;每条含事项和背景/目标。 + +### 【关键产出】 +最多 5 条;每条含产出和价值。 + +### 【待跟进】 +最多 5 条;每条含阻塞/下周重点和原因。 +``` + +每条结论必须附带来源标注: + +```markdown +- 来源:[会议:<会议主题>](URL)(YYYY-MM-DD) +- 来源:[群聊:<群名>](群聊入口URL)(YYYY-MM-DD HH:mm;内容摘要:<关键结论/行动项>) +- 来源:[文档:<文档标题>](URL)(YYYY-MM-DD 更新) +- 来源:[日历:<日程标题>](URL)(YYYY-MM-DD) +``` + +若周报需要创建飞书文档,必须先生成本地 Markdown,再创建到周报父节点下,并保存响应到 `work/weekly_YYYY-MM-DD_YYYY-MM-DD/`。 + +## WBS 填报模式 + +当用户提到 WBS、研发 WBS、多维表格、按条目填表,或提供 WBS Base/wiki 链接时进入 WBS 填报模式。 + +本地 Markdown 草稿必须包含: + +1. `口述汇报摘要`:保留可口述的周报内容,每条保留来源标注。 +2. `目标表格要求分析`:Base/table/view、字段要求、可写字段、只读字段、视图筛选限制。 +3. `本周证据`:会议/妙记、群聊、本人创建或更新的云文档、关键打开文档。 +4. `候选 WBS 条目`:逐条列出任务名称、任务描述、负责人、分工、任务级别、起止日期、进度、预计工时、实际工时、相关文档链接、待确认字段。 +5. `建议合并/拆分`:只作为建议,不等同于实际写表清单。 +6. `实际写入计划`:用户要求写表时列出完整写入条目。 + +缺少 `候选 WBS 条目` 时,不能作为写表依据。缺少 `口述汇报摘要` 时,视为不完整草稿。 + +## WBS 任务级别 + +- 候选 WBS 条目和实际写入计划中的 `任务级别` 默认填写 `二级`。 +- 写入/推送到 Base 时,`任务级别` 必须统一写入 `二级`;不得因为任务重要性、项目层级、周报表述或旧草稿值写入 `一级`。 +- 如果草稿或历史计划里已有 `一级`,构造 `lark-cli base +record-upsert` payload 前必须覆盖为 `二级`,并在执行记录中说明已按规则统一。 + +## WBS 默认入口 + +若用户未提供 WBS 链接,默认入口: + +`https://fiber-doctor.feishu.cn/wiki/YOlrw2Ir1imq7QkLsspcKIZ3nig?table=tblvypvD754NJFgh&view=vewa0doZVe` + +执行时必须解析真实 `obj_token`、校验字段结构和视图限制,不能直接假设 URL 参数可写。 + +## 写表护栏 + +- 用户说“不要直接写”“先在本地 MD 写一下”时,禁止写入 Base。 +- 用户明确要求“直接加到表格”时,才可新增记录。 +- 本地草稿有 N 条候选,默认写入 N 条;不得擅自只写“推荐精简版”。 +- 写表前必须明确区分候选条目、建议精简版、实际准备写入条目。 +- 写表前必须检查实际 payload:所有 `任务级别` 均为 `二级`,否则禁止执行 `record-upsert`。 +- 用户要求“不填关联项目”时,关联字段必须留空。 +- 写入后必须读取目标视图或记录核验,并报告新增记录 ID、写入条数、留空字段和核验文件路径。 + +## WBS 排除 + +用户可声明 `不写入 WBS` / `排除项目` / `填 WBS 时不要写...`。 + +排除清单只影响 WBS 候选条目和实际写表,不影响日报/周报正文,除非用户明确说“日报也不要写”。 + +写入前对候选条目的任务名称、任务描述、项目名、相关文档、群聊、会议、URL、Codex 线程名、工作目录和摘要做匹配;命中项不得写入 Base。 + +执行记录写明: + +`WBS 写入前已按用户要求排除:(命中:<字段>,候选条目:<任务名称>)` diff --git a/skills/lark-workflow-standup-report/scripts/build_candidate_review.ps1 b/skills/lark-workflow-standup-report/scripts/build_candidate_review.ps1 new file mode 100644 index 000000000..90c1887d4 --- /dev/null +++ b/skills/lark-workflow-standup-report/scripts/build_candidate_review.ps1 @@ -0,0 +1,277 @@ +param( + [Parameter(Mandatory = $true)] + [string] $Date, + + [Parameter(Mandatory = $true)] + [string] $SourceManifestPath, + + [Parameter(Mandatory = $true)] + [string] $AgentEvidenceJsonPath, + + [Parameter(Mandatory = $true)] + [string] $OutFile +) + +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +. (Join-Path $PSScriptRoot 'lark_common.ps1') + +function Get-ChatKey { + param($Message) + $chatName = $Message.PSObject.Properties['chat_name'] + if ($chatName -and $chatName.Value) { return $chatName.Value } + $chatType = $Message.PSObject.Properties['chat_type'] + if ($chatType -and $chatType.Value) { return $chatType.Value } + $chatId = $Message.PSObject.Properties['chat_id'] + if ($chatId -and $chatId.Value) { return $chatId.Value } + return 'unknown_chat' +} + +function Get-ChatLabel { + param( + $Message, + [hashtable] $P2pPartnerNames + ) + + $chatName = $Message.PSObject.Properties['chat_name'] + if ($chatName -and $chatName.Value) { return [string]$chatName.Value } + + $chatType = if ($Message.PSObject.Properties['chat_type']) { [string]$Message.chat_type } else { $null } + $chatId = if ($Message.PSObject.Properties['chat_id']) { [string]$Message.chat_id } else { $null } + if ($chatType -eq 'p2p') { + $partnerName = $null + if ($chatId -and $P2pPartnerNames.ContainsKey($chatId)) { $partnerName = $P2pPartnerNames[$chatId] } + if ($partnerName) { return "与${partnerName}私聊" } + return '未命名私聊联系人' + } + if ($chatType) { return $chatType } + if ($chatId) { return $chatId } + return 'unknown_chat' +} + +$manifest = Get-Content -LiteralPath $SourceManifestPath -Raw | ConvertFrom-Json +$agent = Get-Content -LiteralPath $AgentEvidenceJsonPath -Raw | ConvertFrom-Json +$sourceDir = Split-Path -Parent $SourceManifestPath +$rows = New-Object System.Collections.Generic.List[object] + +function Get-NestedValue { + param( + $Object, + [Parameter(Mandatory = $true)][string[]] $Path, + $Default = $null + ) + + $current = $Object + foreach ($part in $Path) { + if ($null -eq $current) { return $Default } + $prop = $current.PSObject.Properties[$part] + if ($null -eq $prop) { return $Default } + $current = $prop.Value + } + if ($null -eq $current) { return $Default } + return $current +} + +$selfFilesValue = Get-NestedValue -Object $manifest -Path @('files', 'im_self') -Default @() +if ($selfFilesValue -is [array]) { $selfFiles = $selfFilesValue } else { $selfFiles = @($selfFilesValue) } + +$allFilesValue = Get-NestedValue -Object $manifest -Path @('files', 'im_all') -Default @() +if ($allFilesValue -is [array]) { $allFiles = $allFilesValue } else { $allFiles = @($allFilesValue) } + +$allMessages = @() +foreach ($file in $allFiles) { + $j = Read-JsonFileOrNull -Path $file + if ($j -and $j.PSObject.Properties['data'] -and $j.data.PSObject.Properties['messages'] -and $j.data.messages) { + $allMessages += $j.data.messages + } +} + +$currentUserName = Get-NestedValue -Object $manifest -Path @('current_user_name') -Default $null +$p2pPartnerNames = @{} +foreach ($message in $allMessages) { + $chatType = if ($message.PSObject.Properties['chat_type']) { [string]$message.chat_type } else { $null } + $chatId = if ($message.PSObject.Properties['chat_id']) { [string]$message.chat_id } else { $null } + if ($chatType -ne 'p2p' -or -not $chatId) { continue } + + $senderName = $null + if ($message.PSObject.Properties['sender'] -and $message.sender -and $message.sender.PSObject.Properties['name']) { + $senderName = [string]$message.sender.name + } + if ($senderName -and $currentUserName -and $senderName -ne $currentUserName) { + $p2pPartnerNames[$chatId] = $senderName + continue + } + + if ($message.PSObject.Properties['chat_partner'] -and $message.chat_partner -and $message.chat_partner.PSObject.Properties['name']) { + $partnerName = [string]$message.chat_partner.name + if ($partnerName) { $p2pPartnerNames[$chatId] = $partnerName } + } +} + +$selfMessages = @() +foreach ($file in $selfFiles) { + $j = Read-JsonFileOrNull -Path $file + if ($j -and $j.PSObject.Properties['data'] -and $j.data.PSObject.Properties['messages'] -and $j.data.messages) { + $selfMessages += $j.data.messages + } +} + +$selfMessages | + Where-Object { + $msgType = if ($_.PSObject.Properties['msg_type']) { $_.msg_type } else { $null } + $content = if ($_.PSObject.Properties['content']) { [string]$_.content } else { $null } + $msgType -ne 'image' -and $content -and $content.Trim().Length -gt 0 + } | + Group-Object { Get-ChatLabel -Message $_ -P2pPartnerNames $p2pPartnerNames } | + Sort-Object Count -Descending | + Select-Object -First 30 | + ForEach-Object { + $sample = ($_.Group | Select-Object -First 3 | ForEach-Object { + $content = if ($_.PSObject.Properties['content']) { [string]$_.content } else { '' } + $text = Protect-Text -Text $content + if ($text.Length -gt 80) { $text.Substring(0, 80) + '...' } else { $text } + }) -join ' / ' + $rows.Add([pscustomobject]@{ + item = $_.Name + source = '飞书本人消息' + evidence = "本人发言 $($_.Count) 条;样例:$sample" + recommendation = '待纳入判断' + reason = '满足本人相关的最低证据,但仍需判断是否为工作事项、是否有产出或后续责任。' + }) + } + +$vcDetailFilesValue = Get-NestedValue -Object $manifest -Path @('files', 'vc_meeting_details') -Default @() +if ($vcDetailFilesValue -is [array]) { $vcDetailFiles = $vcDetailFilesValue } else { $vcDetailFiles = @($vcDetailFilesValue) } +foreach ($file in $vcDetailFiles) { + $j = Read-JsonFileOrNull -Path $file + if (-not $j -or -not $j.data) { continue } + $topic = $null + foreach ($key in @('topic', 'title', 'meeting_topic', 'name')) { + if ($j.data.PSObject.Properties[$key] -and $j.data.$key) { + $topic = [string]$j.data.$key + break + } + } + $organizer = $null + foreach ($key in @('organizer_name', 'owner_name', 'host_name')) { + if ($j.data.PSObject.Properties[$key] -and $j.data.$key) { + $organizer = [string]$j.data.$key + break + } + } + $timeText = $null + foreach ($key in @('start_time', 'start_time_iso', 'meeting_start_time')) { + if ($j.data.PSObject.Properties[$key] -and $j.data.$key) { + $timeText = [string]$j.data.$key + break + } + } + $itemName = if ($topic) { $topic } else { '飞书会议' } + $evidence = @() + if ($organizer) { $evidence += "组织者:$organizer" } + if ($timeText) { $evidence += "时间:$timeText" } + if ($j.data.PSObject.Properties['meeting_id'] -and $j.data.meeting_id) { $evidence += "meeting_id 已采集" } + $rows.Add([pscustomobject]@{ + item = $itemName + source = '飞书会议' + evidence = ($evidence -join ';') + recommendation = '待纳入判断' + reason = '会议是高价值证据源;应优先结合会议纪要、妙记和相关文档判断是否纳入。' + }) +} + +$docFilesValue = Get-NestedValue -Object $manifest -Path @('files', 'docs') -Default @() +if ($docFilesValue -is [array]) { $docFiles = $docFilesValue } else { $docFiles = @($docFilesValue) } +foreach ($file in $docFiles) { + $j = Read-JsonFileOrNull -Path $file + foreach ($result in @($j.data.results) | Select-Object -First 20) { + $meta = if ($result.PSObject.Properties['result_meta']) { $result.result_meta } else { $null } + $title = if ($result.PSObject.Properties['title_highlighted'] -and $result.title_highlighted) { [string]$result.title_highlighted } else { $null } + if (-not $title -and $meta -and $meta.PSObject.Properties['title']) { $title = [string]$meta.title } + if (-not $title) { $title = '飞书文档' } + $owner = if ($meta -and $meta.PSObject.Properties['owner_name']) { [string]$meta.owner_name } else { $null } + $lastOpen = if ($meta -and $meta.PSObject.Properties['last_open_time_iso']) { [string]$meta.last_open_time_iso } else { $null } + $docType = if ($result.PSObject.Properties['entity_type']) { [string]$result.entity_type } else { $null } + $evidenceParts = @() + if ($docType) { $evidenceParts += "类型:$docType" } + if ($owner) { $evidenceParts += "所有者:$owner" } + if ($lastOpen) { $evidenceParts += "最近打开:$lastOpen" } + $rows.Add([pscustomobject]@{ + item = $title + source = '飞书文档' + evidence = ($evidenceParts -join ';') + recommendation = '待纳入判断' + reason = '文档打开或编辑本身不是结论,但若标题和时间与当天主线一致,应优先纳入候选审查。' + }) + } +} + +$projectCandidatesValue = Get-NestedValue -Object $agent -Path @('project_candidates') -Default @() +foreach ($p in @($projectCandidatesValue)) { + $recommendation = if ($p.status -eq 'has_today_files') { '待纳入判断' } else { '默认不纳入' } + $reason = switch ($p.status) { + 'has_today_files' { '有当天本地文件证据;需结合会话与产物判断是否为正式工作包。' } + 'project_timestamp_only' { '仅目录时间变化,缺少产物证据。' } + 'important_project_no_today_evidence' { '历史重点项目,但无当天证据。' } + default { '缺少当天证据。' } + } + $rows.Add([pscustomobject]@{ + item = $p.name + source = '本地项目' + evidence = "$($p.path);状态:$($p.status);当天文件数:$($p.recent_files.Count)" + recommendation = $recommendation + reason = $reason + }) +} + +$codexSessionsValue = Get-NestedValue -Object $agent -Path @('codex_sessions') -Default @() +foreach ($s in @($codexSessionsValue) | Select-Object -First 40) { + $name = if ($s.thread_name) { $s.thread_name } elseif ($s.path) { $s.path } else { $s.id } + $rows.Add([pscustomobject]@{ + item = $name + source = 'Codex 会话' + evidence = "更新时间:$($s.updated_at)" + recommendation = '待纳入判断' + reason = '需要读取会话摘要和产物;不能只因会话存在就写入日报。' + }) +} + +$lines = New-Object System.Collections.Generic.List[string] +$lines.Add("## 日报候选事项审查($Date)") +$lines.Add("") +$lines.Add("### 数据覆盖") +$lines.Add("") +$lines.Add("- 日历:$(Get-NestedValue -Object $manifest -Path @('counts', 'calendar') -Default 0)") +$lines.Add("- 视频会议:$(Get-NestedValue -Object $manifest -Path @('counts', 'vc') -Default 0)") +$lines.Add("- 群聊全量:$(Get-NestedValue -Object $manifest -Path @('counts', 'im_all') -Default 0)") +$lines.Add("- 本人发言:$(Get-NestedValue -Object $manifest -Path @('counts', 'im_self') -Default 0)") +$lines.Add("- 云文档:$(Get-NestedValue -Object $manifest -Path @('counts', 'docs') -Default 0)") +$errorsValue = Get-NestedValue -Object $manifest -Path @('errors') -Default @() +$errorCount = @($errorsValue).Count +if ($errorCount -gt 0) { + $lines.Add("- 采集错误:$errorCount 项,见 source_manifest.json") +} +$lines.Add("") +$lines.Add("### 纳入门槛") +$lines.Add("") +$lines.Add("- 纳入日报必须有本人主导、本人明确推进、本人实际产出或本人后续责任。") +$lines.Add("- 全量群聊只作为上下文;无本人相关证据时默认不纳入。") +$lines.Add("- 日报自动化普通采集/创建文档不作为业务工作项。") +$lines.Add("") +$lines.Add("### 候选审查表") +$lines.Add("") +$lines.Add("| 候选事项 | 来源类型 | 本人相关证据 | 建议 | 未纳入/待确认原因 |") +$lines.Add("| --- | --- | --- | --- | --- |") +foreach ($r in $rows) { + $item = ([string]$r.item).Replace('|', '/') + $evidence = ([string]$r.evidence).Replace('|', '/').Replace("`r", ' ').Replace("`n", ' ') + $reason = ([string]$r.reason).Replace('|', '/').Replace("`r", ' ').Replace("`n", ' ') + $lines.Add("| $item | $($r.source) | $evidence | $($r.recommendation) | $reason |") +} +if ($rows.Count -eq 0) { + $lines.Add("| 无 | - | - | 默认不纳入 | 未发现候选事项。 |") +} + +Set-Content -LiteralPath $OutFile -Value ($lines -join "`r`n") -Encoding UTF8 +Write-Output $OutFile diff --git a/skills/lark-workflow-standup-report/scripts/build_candidate_review.sh b/skills/lark-workflow-standup-report/scripts/build_candidate_review.sh new file mode 100644 index 000000000..4ea1a4927 --- /dev/null +++ b/skills/lark-workflow-standup-report/scripts/build_candidate_review.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +set -euo pipefail + +DATE="" +SOURCE_MANIFEST="" +AGENT_EVIDENCE="" +OUT_FILE="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --date|-Date) DATE=$2; shift 2 ;; + --source-manifest-path|-SourceManifestPath) SOURCE_MANIFEST=$2; shift 2 ;; + --agent-evidence-json-path|-AgentEvidenceJsonPath) AGENT_EVIDENCE=$2; shift 2 ;; + --out-file|-OutFile) OUT_FILE=$2; shift 2 ;; + *) echo "Unknown argument: $1" >&2; exit 1 ;; + esac +done + +[[ -n "$DATE" && -n "$SOURCE_MANIFEST" && -n "$AGENT_EVIDENCE" && -n "$OUT_FILE" ]] || { + echo "Required: --date --source-manifest-path --agent-evidence-json-path --out-file" >&2 + exit 1 +} + +python3 - "$DATE" "$SOURCE_MANIFEST" "$AGENT_EVIDENCE" "$OUT_FILE" <<'PY' +from __future__ import annotations +import json, re, sys +from pathlib import Path + +date, manifest_path, agent_path, out_file = sys.argv[1:5] +manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8")) +agent = json.loads(Path(agent_path).read_text(encoding="utf-8")) + +def as_list(v): + if not v: + return [] + return v if isinstance(v, list) else [v] + +def load_json(path): + try: + return json.loads(Path(path).read_text(encoding="utf-8")) + except Exception: + return {} + +def nested(obj, path, default=None): + cur = obj + for p in path: + if not isinstance(cur, dict) or p not in cur: + return default + cur = cur[p] + return cur + +def clean_cell(s): + return re.sub(r"[\r\n|]+", " ", str(s or "")).strip() + +def chat_label(message, p2p_names): + if message.get("chat_name"): + return message["chat_name"] + if message.get("chat_type") == "p2p": + return "与%s私聊" % p2p_names.get(message.get("chat_id"), "未命名联系人") + return message.get("chat_type") or message.get("chat_id") or "unknown_chat" + +source_dir = Path(manifest_path).parent +all_messages = [] +for f in as_list(nested(manifest, ["files", "im_all"], [])): + j = load_json(f) + all_messages.extend(nested(j, ["data", "messages"], []) or []) + +current_user_name = manifest.get("current_user_name") +p2p_names = {} +for m in all_messages: + if m.get("chat_type") != "p2p" or not m.get("chat_id"): + continue + sender_name = nested(m, ["sender", "name"]) + if sender_name and current_user_name and sender_name != current_user_name: + p2p_names[m["chat_id"]] = sender_name + elif nested(m, ["chat_partner", "name"]): + p2p_names[m["chat_id"]] = nested(m, ["chat_partner", "name"]) + +rows = [] +self_messages = [] +for f in as_list(nested(manifest, ["files", "im_self"], [])): + j = load_json(f) + self_messages.extend(nested(j, ["data", "messages"], []) or []) + +groups = {} +for m in self_messages: + content = str(m.get("content") or "").strip() + if m.get("msg_type") == "image" or not content: + continue + groups.setdefault(chat_label(m, p2p_names), []).append(m) +for name, messages in sorted(groups.items(), key=lambda kv: len(kv[1]), reverse=True)[:30]: + sample = " / ".join(clean_cell(m.get("content"))[:80] for m in messages[:3]) + rows.append([name, "飞书本人消息", f"本人发言 {len(messages)} 条;样例:{sample}", "待纳入判断", "满足本人相关的最低证据,但仍需判断是否为工作事项、是否有产出或后续责任。"]) + +for f in as_list(nested(manifest, ["files", "vc_meeting_details"], [])): + j = load_json(f) + data = j.get("data") or {} + topic = next((data.get(k) for k in ["topic", "title", "meeting_topic", "name"] if data.get(k)), "飞书会议") + evidence = [] + for label, keys in [("组织者", ["organizer_name", "owner_name", "host_name"]), ("时间", ["start_time", "start_time_iso", "meeting_start_time"])]: + value = next((data.get(k) for k in keys if data.get(k)), None) + if value: + evidence.append(f"{label}:{value}") + if data.get("meeting_id"): + evidence.append("meeting_id 已采集") + rows.append([topic, "飞书会议", ";".join(evidence), "待纳入判断", "会议是高价值证据源;应优先结合会议纪要、妙记和相关文档判断是否纳入。"]) + +for f in as_list(nested(manifest, ["files", "docs"], [])): + j = load_json(f) + for result in (nested(j, ["data", "results"], []) or [])[:20]: + meta = result.get("result_meta") or {} + title = result.get("title_highlighted") or meta.get("title") or "飞书文档" + evidence = [] + if result.get("entity_type"): + evidence.append("类型:" + str(result["entity_type"])) + if meta.get("owner_name"): + evidence.append("所有者:" + str(meta["owner_name"])) + if meta.get("last_open_time_iso"): + evidence.append("最近打开:" + str(meta["last_open_time_iso"])) + rows.append([title, "飞书文档", ";".join(evidence), "待纳入判断", "文档打开或编辑本身不是结论,但若标题和时间与当天主线一致,应优先纳入候选审查。"]) + +for p in agent.get("project_candidates", []): + status = p.get("status") + recommendation = "待纳入判断" if status == "has_today_files" else "默认不纳入" + reason = { + "has_today_files": "有当天本地文件证据;需结合会话与产物判断是否为正式工作包。", + "project_timestamp_only": "仅目录时间变化,缺少产物证据。", + "important_project_no_today_evidence": "历史重点项目,但无当天证据。", + }.get(status, "缺少当天证据。") + rows.append([p.get("name"), "本地项目", f"{p.get('path')};状态:{status};当天文件数:{len(p.get('recent_files') or [])}", recommendation, reason]) + +for s in agent.get("codex_sessions", [])[:40]: + name = s.get("thread_name") or s.get("path") or s.get("id") + rows.append([name, "Codex 会话", "更新时间:" + str(s.get("updated_at")), "待纳入判断", "需要读取会话摘要和产物;不能只因会话存在就写入日报。"]) + +lines = [ + f"## 日报候选事项审查({date})", + "", + "### 数据覆盖", + "", + f"- 日历:{nested(manifest, ['counts', 'calendar'], 0)}", + f"- 视频会议:{nested(manifest, ['counts', 'vc'], 0)}", + f"- 群聊全量:{nested(manifest, ['counts', 'im_all'], 0)}", + f"- 本人发言:{nested(manifest, ['counts', 'im_self'], 0)}", + f"- 云文档:{nested(manifest, ['counts', 'docs'], 0)}", +] +if manifest.get("errors"): + lines.append(f"- 采集错误:{len(manifest['errors'])} 项,见 source_manifest.json") +lines += [ + "", + "### 纳入门槛", + "", + "- 纳入日报必须有本人主导、本人明确推进、本人实际产出或本人后续责任。", + "- 全量群聊只作为上下文;无本人相关证据时默认不纳入。", + "- 日报自动化普通采集/创建文档不作为业务工作项。", + "", + "### 候选审查表", + "", + "| 候选事项 | 来源类型 | 本人相关证据 | 建议 | 未纳入/待确认原因 |", + "| --- | --- | --- | --- | --- |", +] +if rows: + for row in rows: + lines.append("| " + " | ".join(clean_cell(c) for c in row) + " |") +else: + lines.append("| 无 | - | - | 默认不纳入 | 未发现候选事项。 |") + +Path(out_file).parent.mkdir(parents=True, exist_ok=True) +Path(out_file).write_text("\n".join(lines), encoding="utf-8") +print(out_file) +PY diff --git a/skills/lark-workflow-standup-report/scripts/collect_agent_evidence.ps1 b/skills/lark-workflow-standup-report/scripts/collect_agent_evidence.ps1 new file mode 100644 index 000000000..a88baf25f --- /dev/null +++ b/skills/lark-workflow-standup-report/scripts/collect_agent_evidence.ps1 @@ -0,0 +1,364 @@ +param( + [Parameter(Mandatory = $true)] + [string] $Date, + + [Parameter(Mandatory = $true)] + [string] $Start, + + [Parameter(Mandatory = $true)] + [string] $End, + + [Parameter(Mandatory = $true)] + [string] $OutDir, + + [string[]] $ProjectRoots = @('D:\AICODING', 'D:\除二\AI code'), + + [string[]] $ImportantProjects = @('AIEXCEL', 'Swimlane', 'Wardrobe'), + + [int] $MaxFilesPerProject = 40 +) + +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null + +$startDto = [DateTimeOffset]::Parse($Start) +$endDto = [DateTimeOffset]::Parse($End) +$startLocal = $startDto.DateTime +$endLocal = $endDto.DateTime +$codexHome = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path $env:USERPROFILE '.codex' } +$claudeHome = Join-Path $env:USERPROFILE '.claude' + +function Test-InRange { + param([datetime] $Time) + return ($Time -ge $startLocal -and $Time -le $endLocal) +} + +function Read-JsonLine { + param([string] $Line) + try { return $Line | ConvertFrom-Json } catch { return $null } +} + +function Limit-Text { + param( + [AllowNull()][string] $Text, + [int] $Max = 240 + ) + if (-not $Text) { return $null } + $clean = ($Text -replace '\s+', ' ').Trim() + if ($clean.Length -le $Max) { return $clean } + return $clean.Substring(0, $Max) + '...' +} + +function Get-CodexPayloadText { + param($Payload) + if ($null -eq $Payload) { return $null } + $messageProp = $Payload.PSObject.Properties['message'] + if ($messageProp -and $messageProp.Value) { return [string]$messageProp.Value } + $contentProp = $Payload.PSObject.Properties['content'] + if ($contentProp -and $contentProp.Value) { + $parts = @() + foreach ($c in @($contentProp.Value)) { + $textProp = $c.PSObject.Properties['text'] + $typeProp = $c.PSObject.Properties['type'] + if ($textProp -and $textProp.Value) { $parts += [string]$textProp.Value } + elseif ($typeProp -and $typeProp.Value -eq 'output_text' -and $textProp -and $textProp.Value) { $parts += [string]$textProp.Value } + } + if ($parts.Count -gt 0) { return ($parts -join ' ') } + } + return $null +} + +function Read-CodexSessionSummary { + param([string] $Path) + + $meta = $null + $userMessages = New-Object System.Collections.Generic.List[string] + $assistantMessages = New-Object System.Collections.Generic.List[string] + $commands = New-Object System.Collections.Generic.List[string] + $mentionedPaths = New-Object System.Collections.Generic.List[string] + + if (-not (Test-Path -LiteralPath $Path)) { + return [pscustomobject]@{ + path = $Path + cwd = $null + session_id = $null + user_messages = @() + assistant_messages = @() + commands = @() + mentioned_paths = @() + } + } + + Get-Content -LiteralPath $Path -ErrorAction SilentlyContinue | ForEach-Object { + $j = Read-JsonLine $_ + if ($null -eq $j) { return } + + if ($j.type -eq 'session_meta') { + $meta = $j.payload + return + } + + if ($j.type -ne 'response_item' -or $null -eq $j.payload) { return } + $payload = $j.payload + + if ($payload.type -eq 'function_call') { + $cmd = $payload.name + if ($payload.arguments) { + $argText = Limit-Text -Text ([string]$payload.arguments) -Max 180 + if ($argText) { $cmd = "$cmd $argText" } + } + if ($cmd -and $commands.Count -lt 20) { $commands.Add($cmd) } + return + } + + if ($payload.type -ne 'message') { return } + $text = Get-CodexPayloadText -Payload $payload + $short = Limit-Text -Text $text -Max 260 + if (-not $short) { return } + + foreach ($m in [regex]::Matches($short, '[A-Za-z]:\\[^`"''\)\]\s]+')) { + if ($mentionedPaths.Count -lt 20) { $mentionedPaths.Add($m.Value) } + } + + if ($payload.role -eq 'user') { + if ($short -match 'AGENTS\.md instructions') { return } + if ($userMessages.Count -lt 8) { $userMessages.Add($short) } + } elseif ($payload.role -eq 'assistant') { + if ($assistantMessages.Count -lt 16) { $assistantMessages.Add($short) } + } + } + + return [pscustomobject]@{ + path = $Path + cwd = if ($meta -and $meta.cwd) { $meta.cwd } else { $null } + session_id = if ($meta -and $meta.id) { $meta.id } else { $null } + user_messages = @($userMessages) + assistant_messages = @($assistantMessages | Select-Object -Last 6) + commands = @($commands) + mentioned_paths = @($mentionedPaths | Select-Object -Unique) + } +} + +$codexSessions = @() +$sessionIndex = Join-Path $codexHome 'session_index.jsonl' +if (Test-Path -LiteralPath $sessionIndex) { + Get-Content -LiteralPath $sessionIndex | ForEach-Object { + $j = Read-JsonLine $_ + if ($null -ne $j -and $j.updated_at) { + try { + $updated = ([DateTimeOffset]::Parse($j.updated_at)).ToLocalTime().DateTime + if (Test-InRange $updated) { + $codexSessions += [pscustomobject]@{ + source = 'codex_index' + thread_name = $j.thread_name + id = $j.id + updated_at = $updated.ToString('yyyy-MM-dd HH:mm:ss') + } + } + } catch {} + } + } +} + +$codexSessionFiles = @() +$dateParts = $Date -split '-' +$codexDayDir = Join-Path $codexHome ("sessions\{0}\{1}\{2}" -f $dateParts[0], $dateParts[1], $dateParts[2]) +if (Test-Path -LiteralPath $codexDayDir) { + $codexSessionFiles += @(Get-ChildItem -LiteralPath $codexDayDir -Filter '*.jsonl' -File) +} +$codexArchiveDir = Join-Path $codexHome 'archived_sessions' +if (Test-Path -LiteralPath $codexArchiveDir) { + $codexSessionFiles += @(Get-ChildItem -LiteralPath $codexArchiveDir -Filter '*.jsonl' -File | + Where-Object { Test-InRange $_.LastWriteTime }) +} + +foreach ($file in ($codexSessionFiles | Sort-Object FullName -Unique)) { + $summary = Read-CodexSessionSummary -Path $file.FullName + $codexSessions += [pscustomobject]@{ + source = if ($file.FullName -like '*\archived_sessions\*') { 'codex_archived_session_file' } else { 'codex_session_file' } + thread_name = $null + id = if ($summary.session_id) { $summary.session_id } else { $file.BaseName } + path = $file.FullName + cwd = $summary.cwd + updated_at = $file.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss') + user_messages = $summary.user_messages + assistant_messages = $summary.assistant_messages + commands = $summary.commands + mentioned_paths = $summary.mentioned_paths + } +} + +$claudeSessions = @() +$claudeProjects = Join-Path $claudeHome 'projects' +if (Test-Path -LiteralPath $claudeProjects) { + Get-ChildItem -LiteralPath $claudeProjects -Recurse -Filter '*.jsonl' -File -ErrorAction SilentlyContinue | + Where-Object { Test-InRange $_.LastWriteTime } | + Select-Object -First 200 | + ForEach-Object { + $sample = Get-Content -LiteralPath $_.FullName -TotalCount 12 -ErrorAction SilentlyContinue + $cwd = $null + $branch = $null + foreach ($line in $sample) { + $j = Read-JsonLine $line + if ($null -ne $j) { + if (-not $cwd -and $j.cwd) { $cwd = $j.cwd } + if (-not $branch -and $j.gitBranch) { $branch = $j.gitBranch } + } + } + $claudeSessions += [pscustomobject]@{ + source = 'claude_project' + path = $_.FullName + cwd = $cwd + git_branch = $branch + updated_at = $_.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss') + } + } +} + +$projectCandidates = @() +foreach ($root in $ProjectRoots) { + if (-not (Test-Path -LiteralPath $root)) { continue } + Get-ChildItem -LiteralPath $root -Directory -ErrorAction SilentlyContinue | ForEach-Object { + $isImportant = $ImportantProjects -contains $_.Name + $recentProject = Test-InRange $_.LastWriteTime + $recentFiles = @() + if ($recentProject -or $isImportant) { + try { + $recentFiles = @(Get-ChildItem -LiteralPath $_.FullName -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { + (Test-InRange $_.LastWriteTime) -and + ($_.FullName -notmatch '\\node_modules\\|\\.git\\|\\dist\\cache\\|\\__pycache__\\') + } | + Sort-Object LastWriteTime -Descending | + Select-Object -First $MaxFilesPerProject | + ForEach-Object { + [pscustomobject]@{ + path = $_.FullName + last_write_time = $_.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss') + } + }) + } catch {} + } + + $status = if ($recentFiles.Count -gt 0) { + 'has_today_files' + } elseif ($recentProject) { + 'project_timestamp_only' + } elseif ($isImportant) { + 'important_project_no_today_evidence' + } else { + 'not_recent' + } + + if ($recentProject -or $isImportant -or $recentFiles.Count -gt 0) { + $projectCandidates += [pscustomobject]@{ + name = $_.Name + path = $_.FullName + last_write_time = $_.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss') + status = $status + recent_files = $recentFiles + } + } + } +} + +$evidence = [ordered]@{ + date = $Date + start = $Start + end = $End + generated_at = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss zzz') + codex_home = $codexHome + claude_home = $claudeHome + codex_sessions = $codexSessions + claude_sessions = $claudeSessions + project_candidates = $projectCandidates +} + +$jsonPath = Join-Path $OutDir "agent_evidence_$Date.json" +$mdPath = Join-Path $OutDir "agent_session_evidence_$Date.md" +$evidence | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $jsonPath -Encoding UTF8 + +$lines = New-Object System.Collections.Generic.List[string] +$tick = [char]96 +$lines.Add("## 本地 Agent 证据摘要($Date)") +$lines.Add("") +$lines.Add("### 候选本地项目") +$lines.Add("") +$lines.Add("| 项目 | 路径 | 状态 | 当天文件数 | 说明 |") +$lines.Add("| --- | --- | --- | ---: | --- |") +foreach ($p in $projectCandidates) { + $reason = switch ($p.status) { + 'has_today_files' { '有当天修改文件,必须由 Agent 判断是否纳入日报。' } + 'project_timestamp_only' { '目录时间有变化,但未定位到可用文件证据,默认待确认。' } + 'important_project_no_today_evidence' { '历史重点项目,本次未发现当天文件证据,默认不纳入。' } + default { '无当天证据。' } + } + $pathCell = "$tick$($p.path)$tick" + $lines.Add("| $($p.name) | $pathCell | $($p.status) | $($p.recent_files.Count) | $reason |") +} +if ($projectCandidates.Count -eq 0) { + $lines.Add("| 无 | - | no_candidates | 0 | 未发现当天本地项目证据。 |") +} +$lines.Add("") +$lines.Add("### 纳入日报的本地工作包") +$lines.Add("") +$lines.Add("> 由 Agent 根据候选项目、会话摘要和实际产物判断。脚本只提供证据,不直接写最终结论。") +$lines.Add("") +$lines.Add("### 未纳入原因") +$lines.Add("") +$lines.Add("| 项目/会话 | 原因 |") +$lines.Add("| --- | --- |") +foreach ($p in $projectCandidates | Where-Object { $_.status -eq 'important_project_no_today_evidence' }) { + $lines.Add("| $($p.name) | 历史重点项目,但本次未发现当天文件证据;除非会话或用户补充证明当天有产出,否则不纳入。 |") +} +$lines.Add("") +$lines.Add("### Codex 会话候选") +$lines.Add("") +$lines.Add("| 来源 | 线程/文件 | 更新时间 |") +$lines.Add("| --- | --- | --- |") +foreach ($s in $codexSessions | Select-Object -First 80) { + $name = if ($s.thread_name) { $s.thread_name } elseif ($s.path) { $s.path } else { $s.id } + $nameCell = "$tick$name$tick" + $lines.Add("| $($s.source) | $nameCell | $($s.updated_at) |") +} +if ($codexSessions.Count -eq 0) { $lines.Add("| 无 | - | - |") } +$lines.Add("") +$lines.Add("### Codex 会话摘要") +$lines.Add("") +foreach ($s in $codexSessions | Where-Object { $_.PSObject.Properties['path'] -and $_.path } | Select-Object -First 40) { + $threadName = if ($s.PSObject.Properties['thread_name']) { $s.thread_name } else { $null } + $cwdValue = if ($s.PSObject.Properties['cwd']) { $s.cwd } else { $null } + $title = if ($threadName) { $threadName } elseif ($cwdValue) { $cwdValue } else { $s.path } + $lines.Add("#### $title") + $lines.Add("") + $lines.Add("- 会话文件:$tick$($s.path)$tick") + if ($cwdValue) { $lines.Add("- 工作目录:$tick$cwdValue$tick") } + if ($s.PSObject.Properties['user_messages'] -and $s.user_messages -and $s.user_messages.Count -gt 0) { + $lines.Add("- 用户请求:$((@($s.user_messages) | Select-Object -First 3) -join ' / ')") + } + if ($s.PSObject.Properties['assistant_messages'] -and $s.assistant_messages -and $s.assistant_messages.Count -gt 0) { + $lines.Add("- 处理摘要:$((@($s.assistant_messages) | Select-Object -Last 3) -join ' / ')") + } + if ($s.PSObject.Properties['mentioned_paths'] -and $s.mentioned_paths -and $s.mentioned_paths.Count -gt 0) { + $lines.Add("- 提到路径:$((@($s.mentioned_paths) | Select-Object -First 8) -join ';')") + } + $lines.Add("") +} +$lines.Add("") +$lines.Add("### Claude Code 会话候选") +$lines.Add("") +$lines.Add("| 路径 | cwd | 分支 | 更新时间 |") +$lines.Add("| --- | --- | --- | --- |") +foreach ($s in $claudeSessions | Select-Object -First 80) { + $pathCell = "$tick$($s.path)$tick" + $cwdCell = "$tick$($s.cwd)$tick" + $branchCell = "$tick$($s.git_branch)$tick" + $lines.Add("| $pathCell | $cwdCell | $branchCell | $($s.updated_at) |") +} +if ($claudeSessions.Count -eq 0) { $lines.Add("| 无 | - | - | - |") } + +Set-Content -LiteralPath $mdPath -Value ($lines -join "`r`n") -Encoding UTF8 +Write-Output $jsonPath +Write-Output $mdPath diff --git a/skills/lark-workflow-standup-report/scripts/collect_agent_evidence.sh b/skills/lark-workflow-standup-report/scripts/collect_agent_evidence.sh new file mode 100644 index 000000000..42c6f99bf --- /dev/null +++ b/skills/lark-workflow-standup-report/scripts/collect_agent_evidence.sh @@ -0,0 +1,306 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lark_common.sh +. "$SCRIPT_DIR/lark_common.sh" + +DATE="" +START="" +END="" +OUT_DIR="" +PROJECT_ROOTS=() +IMPORTANT_PROJECTS=(AIEXCEL Swimlane Wardrobe) +MAX_FILES_PER_PROJECT=40 + +while [[ $# -gt 0 ]]; do + case "$1" in + --date|-Date) DATE=$2; shift 2 ;; + --start|-Start) START=$2; shift 2 ;; + --end|-End) END=$2; shift 2 ;; + --out-dir|-OutDir) OUT_DIR=$2; shift 2 ;; + --project-root|--project-roots|-ProjectRoots) PROJECT_ROOTS+=("$2"); shift 2 ;; + --important-project|-ImportantProjects) IMPORTANT_PROJECTS+=("$2"); shift 2 ;; + --max-files-per-project|-MaxFilesPerProject) MAX_FILES_PER_PROJECT=$2; shift 2 ;; + *) die "Unknown argument: $1" ;; + esac +done + +[[ -n "$DATE" && -n "$START" && -n "$END" && -n "$OUT_DIR" ]] || die "Required: --date --start --end --out-dir" +need_cmd jq +need_cmd python3 + +mkdir -p "$OUT_DIR" +[[ ${#PROJECT_ROOTS[@]} -eq 0 ]] && PROJECT_ROOTS=("$PWD") +CODEX_HOME_DIR="${CODEX_HOME:-$HOME/.codex}" +CLAUDE_HOME_DIR="$HOME/.claude" +JSON_PATH="$OUT_DIR/agent_evidence_$DATE.json" +MD_PATH="$OUT_DIR/agent_session_evidence_$DATE.md" + +python3 - "$DATE" "$START" "$END" "$OUT_DIR" "$CODEX_HOME_DIR" "$CLAUDE_HOME_DIR" "$MAX_FILES_PER_PROJECT" "$JSON_PATH" "$MD_PATH" "${PROJECT_ROOTS[@]}" -- "${IMPORTANT_PROJECTS[@]}" <<'PY' +from __future__ import annotations +import json, os, re, sys +from datetime import datetime +from pathlib import Path + +date, start, end, out_dir, codex_home, claude_home, max_files, json_path, md_path = sys.argv[1:10] +sep = sys.argv.index("--") +project_roots = sys.argv[10:sep] +important_projects = set(sys.argv[sep+1:]) +max_files = int(max_files) + +def parse_dt(s): + if s.endswith("Z"): + s = s[:-1] + "+00:00" + return datetime.fromisoformat(s).astimezone() + +start_dt = parse_dt(start) +end_dt = parse_dt(end) + +def in_range_ts(ts): + try: + return start_dt.timestamp() <= ts <= end_dt.timestamp() + except Exception: + return False + +def in_range_path(path: Path): + try: + return in_range_ts(path.stat().st_mtime) + except Exception: + return False + +def read_json_line(line): + try: + return json.loads(line) + except Exception: + return None + +def limit_text(text, max_len=240): + if not text: + return None + clean = re.sub(r"\s+", " ", str(text)).strip() + return clean if len(clean) <= max_len else clean[:max_len] + "..." + +def payload_text(payload): + if not isinstance(payload, dict): + return None + if payload.get("message"): + return str(payload["message"]) + content = payload.get("content") + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, dict) and item.get("text"): + parts.append(str(item["text"])) + return " ".join(parts) if parts else None + return None + +def read_codex_session(path: Path): + meta = {} + users, assistants, commands, mentioned = [], [], [], [] + try: + with path.open("r", encoding="utf-8", errors="ignore") as fh: + for line in fh: + j = read_json_line(line) + if not j: + continue + if j.get("type") == "session_meta": + meta = j.get("payload") or {} + continue + if j.get("type") != "response_item": + continue + payload = j.get("payload") or {} + if payload.get("type") == "function_call": + cmd = payload.get("name") or "" + if payload.get("arguments"): + arg = limit_text(payload.get("arguments"), 180) + cmd = f"{cmd} {arg}" if arg else cmd + if cmd and len(commands) < 20: + commands.append(cmd) + continue + if payload.get("type") != "message": + continue + text = limit_text(payload_text(payload), 260) + if not text: + continue + mentioned += re.findall(r"[A-Za-z]:\\[^`\"')\]\s]+|/Users/[^`\"')\]\s]+", text)[:20] + if payload.get("role") == "user" and "AGENTS.md instructions" not in text and len(users) < 8: + users.append(text) + elif payload.get("role") == "assistant" and len(assistants) < 16: + assistants.append(text) + except Exception: + pass + return { + "path": str(path), + "cwd": meta.get("cwd"), + "session_id": meta.get("id") or path.stem, + "user_messages": users, + "assistant_messages": assistants[-6:], + "commands": commands, + "mentioned_paths": sorted(set(mentioned))[:20], + } + +codex_sessions = [] +session_index = Path(codex_home) / "session_index.jsonl" +if session_index.exists(): + for line in session_index.read_text(encoding="utf-8", errors="ignore").splitlines(): + j = read_json_line(line) + if not j or not j.get("updated_at"): + continue + try: + updated = parse_dt(j["updated_at"]) + except Exception: + continue + if start_dt <= updated <= end_dt: + codex_sessions.append({ + "source": "codex_index", + "thread_name": j.get("thread_name"), + "id": j.get("id"), + "updated_at": updated.strftime("%Y-%m-%d %H:%M:%S"), + }) + +day_parts = date.split("-") +codex_day = Path(codex_home) / "sessions" / day_parts[0] / day_parts[1] / day_parts[2] +for base in [codex_day, Path(codex_home) / "archived_sessions"]: + if not base.exists(): + continue + for path in sorted(base.glob("*.jsonl")): + if not in_range_path(path): + continue + summary = read_codex_session(path) + codex_sessions.append({ + "source": "codex_session_file", + "thread_name": None, + "id": summary.get("session_id") or path.stem, + "path": str(path), + "cwd": summary.get("cwd"), + "updated_at": datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"), + "user_messages": summary["user_messages"], + "assistant_messages": summary["assistant_messages"], + "commands": summary["commands"], + "mentioned_paths": summary["mentioned_paths"], + }) + +claude_sessions = [] +claude_projects = Path(claude_home) / "projects" +if claude_projects.exists(): + count = 0 + for path in claude_projects.rglob("*.jsonl"): + if count >= 200 or not in_range_path(path): + continue + cwd = branch = None + try: + with path.open("r", encoding="utf-8", errors="ignore") as fh: + for _, line in zip(range(12), fh): + j = read_json_line(line) + if isinstance(j, dict): + cwd = cwd or j.get("cwd") + branch = branch or j.get("gitBranch") + except Exception: + pass + claude_sessions.append({ + "source": "claude_project", + "path": str(path), + "cwd": cwd, + "git_branch": branch, + "updated_at": datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"), + }) + count += 1 + +def skip_path(path: Path): + s = str(path) + return any(part in s for part in ["/node_modules/", "/.git/", "/dist/", "/cache/", "/__pycache__/"]) + +project_candidates = [] +for root_str in project_roots: + root = Path(root_str).expanduser() + if not root.exists(): + continue + children = [root] if root.is_file() else list(root.iterdir()) + for project in children: + if not project.is_dir(): + continue + is_important = project.name in important_projects + recent_project = in_range_path(project) + recent_files = [] + if recent_project or is_important: + try: + files = [p for p in project.rglob("*") if p.is_file() and not skip_path(p) and in_range_path(p)] + files.sort(key=lambda p: p.stat().st_mtime, reverse=True) + for p in files[:max_files]: + recent_files.append({"path": str(p), "last_write_time": datetime.fromtimestamp(p.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")}) + except Exception: + pass + if recent_files: + status = "has_today_files" + elif recent_project: + status = "project_timestamp_only" + elif is_important: + status = "important_project_no_today_evidence" + else: + continue + project_candidates.append({ + "name": project.name, + "path": str(project), + "last_write_time": datetime.fromtimestamp(project.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"), + "status": status, + "recent_files": recent_files, + }) + +evidence = { + "date": date, + "start": start, + "end": end, + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S %z"), + "codex_home": codex_home, + "claude_home": claude_home, + "codex_sessions": codex_sessions, + "claude_sessions": claude_sessions, + "project_candidates": project_candidates, +} +Path(json_path).write_text(json.dumps(evidence, ensure_ascii=False, indent=2), encoding="utf-8") + +tick = "`" +lines = [f"## 本地 Agent 证据摘要({date})", "", "### 候选本地项目", "", "| 项目 | 路径 | 状态 | 当天文件数 | 说明 |", "| --- | --- | --- | ---: | --- |"] +if project_candidates: + for p in project_candidates: + reason = { + "has_today_files": "有当天修改文件,必须由 Agent 判断是否纳入日报。", + "project_timestamp_only": "目录时间有变化,但未定位到可用文件证据,默认待确认。", + "important_project_no_today_evidence": "历史重点项目,本次未发现当天文件证据,默认不纳入。", + }.get(p["status"], "无当天证据。") + lines.append(f"| {p['name']} | {tick}{p['path']}{tick} | {p['status']} | {len(p['recent_files'])} | {reason} |") +else: + lines.append("| 无 | - | no_candidates | 0 | 未发现当天本地项目证据。 |") +lines += ["", "### 纳入日报的本地工作包", "", "> 由 Agent 根据候选项目、会话摘要和实际产物判断。脚本只提供证据,不直接写最终结论。", "", "### 未纳入原因", "", "| 项目/会话 | 原因 |", "| --- | --- |"] +for p in project_candidates: + if p["status"] == "important_project_no_today_evidence": + lines.append(f"| {p['name']} | 历史重点项目,但本次未发现当天文件证据;除非会话或用户补充证明当天有产出,否则不纳入。 |") +lines += ["", "### Codex 会话候选", "", "| 来源 | 线程/文件 | 更新时间 |", "| --- | --- | --- |"] +for s in codex_sessions[:80]: + name = s.get("thread_name") or s.get("path") or s.get("id") + lines.append(f"| {s.get('source')} | {tick}{name}{tick} | {s.get('updated_at')} |") +if not codex_sessions: + lines.append("| 无 | - | - |") +lines += ["", "### Codex 会话摘要", ""] +for s in [x for x in codex_sessions if x.get("path")][:40]: + title = s.get("thread_name") or s.get("cwd") or s.get("path") + lines += [f"#### {title}", "", f"- 会话文件:{tick}{s.get('path')}{tick}"] + if s.get("cwd"): + lines.append(f"- 工作目录:{tick}{s.get('cwd')}{tick}") + if s.get("user_messages"): + lines.append("- 用户请求:" + " / ".join(s["user_messages"][:3])) + if s.get("assistant_messages"): + lines.append("- 处理摘要:" + " / ".join(s["assistant_messages"][-3:])) + if s.get("mentioned_paths"): + lines.append("- 提到路径:" + ";".join(s["mentioned_paths"][:8])) + lines.append("") +lines += ["", "### Claude Code 会话候选", "", "| 路径 | cwd | 分支 | 更新时间 |", "| --- | --- | --- | --- |"] +for s in claude_sessions[:80]: + lines.append(f"| {tick}{s.get('path')}{tick} | {tick}{s.get('cwd')}{tick} | {tick}{s.get('git_branch')}{tick} | {s.get('updated_at')} |") +if not claude_sessions: + lines.append("| 无 | - | - | - |") +Path(md_path).write_text("\n".join(lines), encoding="utf-8") +print(json_path) +print(md_path) +PY diff --git a/skills/lark-workflow-standup-report/scripts/collect_lark_daily.ps1 b/skills/lark-workflow-standup-report/scripts/collect_lark_daily.ps1 new file mode 100644 index 000000000..0d9569b06 --- /dev/null +++ b/skills/lark-workflow-standup-report/scripts/collect_lark_daily.ps1 @@ -0,0 +1,427 @@ +param( + [Parameter(Mandatory = $true)] + [string] $Date, + + [Parameter(Mandatory = $true)] + [string] $Start, + + [Parameter(Mandatory = $true)] + [string] $End, + + [Parameter(Mandatory = $true)] + [string] $OutDir, + + [int] $RequestTimeoutSeconds = 120, + + [int] $ImRequestTimeoutSeconds = 45, + + [int] $ImChunkHours = 1, + + [int] $ImMinChunkMinutes = 60, + + [int] $ImMaxFailuresPerSearch = 4, + + [switch] $DryRun +) + +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +. (Join-Path $PSScriptRoot 'lark_common.ps1') + +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null +$script:imFailureCounts = @{} + +function Test-AuthError { + param([AllowNull()][string] $Message) + return ($Message -match 'not logged in|auth login|ok=false \(auth\)') +} + +$manifest = [ordered]@{ + date = $Date + start = $Start + end = $End + generated_at = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss zzz') + dry_run = [bool]$DryRun + files = [ordered]@{} + counts = [ordered]@{} + errors = @() +} + +$script:meetingIdSet = New-Object 'System.Collections.Generic.HashSet[string]' +$script:minuteTokenSet = New-Object 'System.Collections.Generic.HashSet[string]' + +function Add-ErrorRecord { + param([string] $Source, [string] $Message) + $script:manifest.errors += [pscustomobject]@{ source = $Source; message = $Message } +} + +function Save-Manifest { + $manifestPath = Join-Path $OutDir 'source_manifest.json' + $manifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $manifestPath -Encoding UTF8 + Write-Output $manifestPath +} + +function Format-LarkDateTime { + param([Parameter(Mandatory = $true)][DateTimeOffset] $Value) + return $Value.ToString('yyyy-MM-ddTHH:mm:sszzz') +} + +function Add-ImFailure { + param( + [Parameter(Mandatory = $true)][string] $Prefix, + [Parameter(Mandatory = $true)][string] $Message + ) + if (-not $script:imFailureCounts.ContainsKey($Prefix)) { + $script:imFailureCounts[$Prefix] = 0 + } + $script:imFailureCounts[$Prefix]++ + Add-ErrorRecord $Prefix $Message +} + +function Test-ImFailureLimitReached { + param([Parameter(Mandatory = $true)][string] $Prefix) + return ($script:imFailureCounts.ContainsKey($Prefix) -and $script:imFailureCounts[$Prefix] -ge $ImMaxFailuresPerSearch) +} + +function Get-MeetingIdFromVcItem { + param($Item) + if ($null -eq $Item) { return $null } + if ($Item.PSObject.Properties['id'] -and $Item.id) { return [string]$Item.id } + return $null +} + +function Get-MinuteTokenFromUrl { + param([AllowNull()][string] $Url) + if (-not $Url) { return $null } + $match = [regex]::Match($Url, '/minutes/([^/?#]+)') + if ($match.Success) { return $match.Groups[1].Value } + return $null +} + +if ($DryRun) { + $manifest.plan = @( + 'calendar +agenda', + 'vc +search with pagination', + 'contact +get-user', + 'im +messages-search all with pagination', + 'im +messages-search sender=current_user with pagination', + 'docs +search with pagination', + 'redact raw json files', + 'write source_manifest.json' + ) + Save-Manifest + exit 0 +} + +try { + $file = Join-Path $OutDir "calendar_agenda_$Date.json" + Invoke-LarkCliJson -Args @('calendar', '+agenda', '--as', 'user', '--start', $Start, '--end', $End, '--format', 'json') -OutFile $file -TimeoutSeconds $RequestTimeoutSeconds | Out-Null + $manifest.files.calendar = $file + $j = Read-JsonFileOrNull -Path $file + $manifest.counts.calendar = Count-JsonArray $j.data +} catch { + if (Test-AuthError $_.Exception.Message) { Save-Manifest | Out-Null; throw } + Add-ErrorRecord 'calendar' $_.Exception.Message +} +Save-Manifest | Out-Null + +try { + $page = 1 + $vcFiles = @() + $pageToken = $null + $hasMore = $true + while ($hasMore -and $page -le 20) { + $file = Join-Path $OutDir "vc_search_${Date}_page$page.json" + $args = @('vc', '+search', '--as', 'user', '--start', $Start, '--end', $End, '--format', 'json', '--page-size', '30') + if ($pageToken) { $args += @('--page-token', $pageToken) } + Invoke-LarkCliJson -Args $args -OutFile $file -TimeoutSeconds $RequestTimeoutSeconds | Out-Null + $vcFiles += $file + $j = Read-JsonFileOrNull -Path $file + $hasMore = [bool]($j.data.has_more) + $pageToken = $j.data.page_token + $page++ + } + $manifest.files.vc = $vcFiles + $manifest.counts.vc = ($vcFiles | ForEach-Object { + $j = Read-JsonFileOrNull -Path $_ + foreach ($item in @($j.data.items)) { + $meetingId = Get-MeetingIdFromVcItem -Item $item + if ($meetingId) { [void]$script:meetingIdSet.Add($meetingId) } + $displayInfo = if ($item.PSObject.Properties['display_info']) { [string]$item.display_info } else { '' } + foreach ($u in [regex]::Matches($displayInfo, 'https?://\S+')) { + $minuteToken = Get-MinuteTokenFromUrl -Url $u.Value + if ($minuteToken) { [void]$script:minuteTokenSet.Add($minuteToken) } + } + } + Count-JsonArray $j.data.items + } | Measure-Object -Sum).Sum +} catch { + if (Test-AuthError $_.Exception.Message) { Save-Manifest | Out-Null; throw } + Add-ErrorRecord 'vc' $_.Exception.Message +} +Save-Manifest | Out-Null + +try { + $detailFiles = @() + $index = 1 + foreach ($meetingId in $script:meetingIdSet) { + $file = Join-Path $OutDir "vc_meeting_${Date}_detail_${index}.json" + Invoke-LarkCliJson -Args @('vc', 'meeting', 'get', '--as', 'user', '--params', "{""meeting_id"":""$meetingId"",""with_participants"":true}") -OutFile $file -TimeoutSeconds $RequestTimeoutSeconds | Out-Null + $detailFiles += $file + $j = Read-JsonFileOrNull -Path $file + $noteDocToken = $null + $verbatimDocToken = $null + $minuteToken = $null + if ($j -and $j.data) { + if ($j.data.PSObject.Properties['note_doc_token']) { $noteDocToken = $j.data.note_doc_token } + if ($j.data.PSObject.Properties['verbatim_doc_token']) { $verbatimDocToken = $j.data.verbatim_doc_token } + if ($j.data.PSObject.Properties['minute_token']) { $minuteToken = $j.data.minute_token } + if (-not $minuteToken -and $j.data.PSObject.Properties['url']) { $minuteToken = Get-MinuteTokenFromUrl -Url ([string]$j.data.url) } + if (-not $minuteToken -and $j.data.PSObject.Properties['meeting_url']) { $minuteToken = Get-MinuteTokenFromUrl -Url ([string]$j.data.meeting_url) } + } + if ($minuteToken) { [void]$script:minuteTokenSet.Add([string]$minuteToken) } + $index++ + } + if ($detailFiles.Count -gt 0) { + $manifest.files.vc_meeting_details = $detailFiles + $manifest.counts.vc_meeting_details = $detailFiles.Count + } +} catch { + if (Test-AuthError $_.Exception.Message) { Save-Manifest | Out-Null; throw } + Add-ErrorRecord 'vc_meeting_details' $_.Exception.Message +} +Save-Manifest | Out-Null + +try { + $notesFiles = @() + if ($script:meetingIdSet.Count -gt 0) { + $chunks = @($script:meetingIdSet) | ForEach-Object -Begin { $bucket = @() } -Process { + $bucket += $_ + if ($bucket.Count -ge 50) { + ,$bucket + $bucket = @() + } + } -End { + if ($bucket.Count -gt 0) { ,$bucket } + } + + $index = 1 + foreach ($chunk in $chunks) { + $file = Join-Path $OutDir "vc_notes_${Date}_meeting_chunk${index}.json" + Invoke-LarkCliJson -Args @('vc', '+notes', '--as', 'user', '--meeting-ids', ($chunk -join ','), '--format', 'json') -OutFile $file -TimeoutSeconds $RequestTimeoutSeconds | Out-Null + $notesFiles += $file + $index++ + } + } + if ($notesFiles.Count -gt 0) { + $manifest.files.vc_notes = $notesFiles + $manifest.counts.vc_notes = $notesFiles.Count + } +} catch { + if (Test-AuthError $_.Exception.Message) { Save-Manifest | Out-Null; throw } + Add-ErrorRecord 'vc_notes' $_.Exception.Message +} +Save-Manifest | Out-Null + +try { + $minuteFiles = @() + if ($script:minuteTokenSet.Count -gt 0) { + $chunks = @($script:minuteTokenSet) | ForEach-Object -Begin { $bucket = @() } -Process { + $bucket += $_ + if ($bucket.Count -ge 50) { + ,$bucket + $bucket = @() + } + } -End { + if ($bucket.Count -gt 0) { ,$bucket } + } + + $index = 1 + foreach ($chunk in $chunks) { + $file = Join-Path $OutDir "vc_notes_${Date}_minute_chunk${index}.json" + Invoke-LarkCliJson -Args @('vc', '+notes', '--as', 'user', '--minute-tokens', ($chunk -join ','), '--format', 'json') -OutFile $file -TimeoutSeconds $RequestTimeoutSeconds | Out-Null + $minuteFiles += $file + $index++ + } + } + if ($minuteFiles.Count -gt 0) { + $manifest.files.vc_minutes = $minuteFiles + $manifest.counts.vc_minutes = $minuteFiles.Count + } +} catch { + if (Test-AuthError $_.Exception.Message) { Save-Manifest | Out-Null; throw } + Add-ErrorRecord 'vc_minutes' $_.Exception.Message +} +Save-Manifest | Out-Null + +$currentUserOpenId = $null +try { + $file = Join-Path $OutDir "current_user_$Date.json" + Invoke-LarkCliJson -Args @('contact', '+get-user', '--as', 'user', '--format', 'json') -OutFile $file -TimeoutSeconds $RequestTimeoutSeconds | Out-Null + $manifest.files.current_user = $file + $j = Read-JsonFileOrNull -Path $file + $currentUserOpenId = $j.data.user.open_id + $manifest.current_user_name = $j.data.user.name + $manifest.current_user_open_id = $currentUserOpenId +} catch { + if (Test-AuthError $_.Exception.Message) { Save-Manifest | Out-Null; throw } + Add-ErrorRecord 'current_user' $_.Exception.Message +} +Save-Manifest | Out-Null + +function Collect-ImRangePages { + param( + [Parameter(Mandatory = $true)][string] $Prefix, + [string] $Sender, + [Parameter(Mandatory = $true)][DateTimeOffset] $RangeStart, + [Parameter(Mandatory = $true)][DateTimeOffset] $RangeEnd, + [Parameter(Mandatory = $true)][string] $ChunkLabel + ) + + $page = 1 + $files = @() + $pageToken = $null + $hasMore = $true + while ($hasMore -and $page -le 50) { + if (Test-ImFailureLimitReached -Prefix $Prefix) { return $files } + + $file = Join-Path $OutDir "${Prefix}_${ChunkLabel}_page$page.json" + $args = @( + 'im', '+messages-search', + '--as', 'user', + '--start', (Format-LarkDateTime $RangeStart), + '--end', (Format-LarkDateTime $RangeEnd), + '--page-size', '50', + '--format', 'json' + ) + if ($Sender) { $args += @('--sender', $Sender) } + if ($pageToken) { $args += @('--page-token', $pageToken) } + + try { + Invoke-LarkCliJson -Args $args -OutFile $file -TimeoutSeconds $ImRequestTimeoutSeconds | Out-Null + $files += $file + $j = Read-JsonFileOrNull -Path $file + $hasMore = [bool]($j.data.has_more) + $pageToken = $j.data.page_token + $page++ + } catch { + if (Test-AuthError $_.Exception.Message) { Save-Manifest | Out-Null; throw } + $minutes = ($RangeEnd - $RangeStart).TotalMinutes + if ($page -eq 1 -and $minutes -gt $ImMinChunkMinutes) { + $mid = $RangeStart.AddMinutes($minutes / 2) + $leftLabel = "${ChunkLabel}a" + $rightLabel = "${ChunkLabel}b" + $left = Collect-ImRangePages -Prefix $Prefix -Sender $Sender -RangeStart $RangeStart -RangeEnd $mid -ChunkLabel $leftLabel + $right = Collect-ImRangePages -Prefix $Prefix -Sender $Sender -RangeStart $mid -RangeEnd $RangeEnd -ChunkLabel $rightLabel + return @($left) + @($right) + } + + Add-ImFailure -Prefix $Prefix -Message "IM search failed for $ChunkLabel page $page ($((Format-LarkDateTime $RangeStart)) ~ $((Format-LarkDateTime $RangeEnd))): $($_.Exception.Message)" + return $files + } + } + return $files +} + +function Collect-ImPages { + param( + [Parameter(Mandatory = $true)][string] $Prefix, + [string] $Sender + ) + + $rangeStart = [DateTimeOffset]::Parse($Start) + $rangeEnd = [DateTimeOffset]::Parse($End) + $cursor = $rangeStart + $chunkIndex = 1 + $files = @() + $chunkHours = [Math]::Max(1, $ImChunkHours) + + while ($cursor -lt $rangeEnd) { + if (Test-ImFailureLimitReached -Prefix $Prefix) { + Add-ErrorRecord $Prefix "Stopped IM search after $ImMaxFailuresPerSearch failures; remaining time range was skipped." + break + } + + $chunkEnd = $cursor.AddHours($chunkHours) + if ($chunkEnd -gt $rangeEnd) { $chunkEnd = $rangeEnd } + $label = ('chunk{0:D2}' -f $chunkIndex) + $files += @(Collect-ImRangePages -Prefix $Prefix -Sender $Sender -RangeStart $cursor -RangeEnd $chunkEnd -ChunkLabel $label) + Save-Manifest | Out-Null + $cursor = $chunkEnd + $chunkIndex++ + } + + return $files +} + +if ($currentUserOpenId) { + try { + $files = Collect-ImPages -Prefix "im_messages_self_$Date" -Sender $currentUserOpenId + $manifest.files.im_self = $files + $detailsMissing = $false + $manifest.counts.im_self = ($files | ForEach-Object { + $j = Read-JsonFileOrNull -Path $_ + if (Test-LarkImDetailsMissing $j) { $detailsMissing = $true } + Count-JsonArray (Get-LarkImItems $j) + } | Measure-Object -Sum).Sum + if ($detailsMissing) { + Add-ErrorRecord 'im_self_details' 'IM search returned message IDs only; message content enrichment is unavailable. Confirm user scopes include im:message:readonly (or im:message) plus im:message.group_msg:get_as_user and im:message.p2p_msg:get_as_user.' + } + } catch { + if (Test-AuthError $_.Exception.Message) { Save-Manifest | Out-Null; throw } + Add-ErrorRecord 'im_self' $_.Exception.Message + } +} else { + Add-ErrorRecord 'im_self' 'Skipped because current user open_id was unavailable.' +} +Save-Manifest | Out-Null + +try { + $files = Collect-ImPages -Prefix "im_messages_all_$Date" + $manifest.files.im_all = $files + $detailsMissing = $false + $manifest.counts.im_all = ($files | ForEach-Object { + $j = Read-JsonFileOrNull -Path $_ + if (Test-LarkImDetailsMissing $j) { $detailsMissing = $true } + Count-JsonArray (Get-LarkImItems $j) + } | Measure-Object -Sum).Sum + if ($detailsMissing) { + Add-ErrorRecord 'im_all_details' 'IM search returned message IDs only; message content enrichment is unavailable. Confirm user scopes include im:message:readonly (or im:message) plus im:message.group_msg:get_as_user and im:message.p2p_msg:get_as_user.' + } +} catch { + if (Test-AuthError $_.Exception.Message) { Save-Manifest | Out-Null; throw } + Add-ErrorRecord 'im_all' $_.Exception.Message +} +Save-Manifest | Out-Null + +try { + $page = 1 + $docFiles = @() + $pageToken = $null + $hasMore = $true + $filter = @{ open_time = @{ start = $Start; end = $End } } | ConvertTo-Json -Compress + while ($hasMore -and $page -le 10) { + $file = Join-Path $OutDir "docs_search_${Date}_page$page.json" + $args = @('docs', '+search', '--as', 'user', '--query', '', '--filter', $filter, '--page-size', '20', '--format', 'json') + if ($pageToken) { $args += @('--page-token', $pageToken) } + Invoke-LarkCliJson -Args $args -OutFile $file -TimeoutSeconds $RequestTimeoutSeconds | Out-Null + $docFiles += $file + $j = Read-JsonFileOrNull -Path $file + $hasMore = [bool]($j.data.has_more) + $pageToken = $j.data.page_token + $page++ + } + $manifest.files.docs = $docFiles + $manifest.counts.docs = ($docFiles | ForEach-Object { + $j = Read-JsonFileOrNull -Path $_ + Count-JsonArray $j.data.results + } | Measure-Object -Sum).Sum +} catch { + if (Test-AuthError $_.Exception.Message) { Save-Manifest | Out-Null; throw } + Add-ErrorRecord 'docs' $_.Exception.Message +} +Save-Manifest | Out-Null + +Protect-JsonFiles -Directory $OutDir +Save-Manifest diff --git a/skills/lark-workflow-standup-report/scripts/collect_lark_daily.sh b/skills/lark-workflow-standup-report/scripts/collect_lark_daily.sh new file mode 100644 index 000000000..506c30650 --- /dev/null +++ b/skills/lark-workflow-standup-report/scripts/collect_lark_daily.sh @@ -0,0 +1,356 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lark_common.sh +. "$SCRIPT_DIR/lark_common.sh" + +DATE="" +START="" +END="" +OUT_DIR="" +REQUEST_TIMEOUT_SECONDS=120 +IM_REQUEST_TIMEOUT_SECONDS=45 +IM_CHUNK_HOURS=1 +IM_MIN_CHUNK_MINUTES=60 +IM_MAX_FAILURES_PER_SEARCH=4 +DRY_RUN=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --date|-Date) DATE=$2; shift 2 ;; + --start|-Start) START=$2; shift 2 ;; + --end|-End) END=$2; shift 2 ;; + --out-dir|-OutDir) OUT_DIR=$2; shift 2 ;; + --request-timeout-seconds|-RequestTimeoutSeconds) REQUEST_TIMEOUT_SECONDS=$2; shift 2 ;; + --im-request-timeout-seconds|-ImRequestTimeoutSeconds) IM_REQUEST_TIMEOUT_SECONDS=$2; shift 2 ;; + --im-chunk-hours|-ImChunkHours) IM_CHUNK_HOURS=$2; shift 2 ;; + --im-min-chunk-minutes|-ImMinChunkMinutes) IM_MIN_CHUNK_MINUTES=$2; shift 2 ;; + --im-max-failures-per-search|-ImMaxFailuresPerSearch) IM_MAX_FAILURES_PER_SEARCH=$2; shift 2 ;; + --dry-run|-DryRun) DRY_RUN=true; shift ;; + *) die "Unknown argument: $1" ;; + esac +done + +[[ -n "$DATE" && -n "$START" && -n "$END" && -n "$OUT_DIR" ]] || die "Required: --date --start --end --out-dir" +need_cmd jq +need_cmd lark-cli +need_cmd python3 + +mkdir -p "$OUT_DIR" +MANIFEST="$OUT_DIR/source_manifest.json" +manifest_init "$MANIFEST" "$DATE" "$START" "$END" "$DRY_RUN" + +if [[ "$DRY_RUN" == true ]]; then + jq '.plan=[ + "calendar +agenda", + "vc +search with pagination", + "contact +get-user", + "im +messages-search all with pagination", + "im +messages-search sender=current_user with pagination", + "docs +search with pagination", + "redact raw json files", + "write source_manifest.json" + ]' "$MANIFEST" >"${MANIFEST}.tmp" + mv "${MANIFEST}.tmp" "$MANIFEST" + printf '%s\n' "$MANIFEST" + exit 0 +fi + +MEETING_IDS_FILE="$OUT_DIR/.meeting_ids" +MINUTE_TOKENS_FILE="$OUT_DIR/.minute_tokens" +IM_FAILURES_DIR="$OUT_DIR/.im_failures" +: >"$MEETING_IDS_FILE" +: >"$MINUTE_TOKENS_FILE" +mkdir -p "$IM_FAILURES_DIR" + +add_unique_line() { + local file=$1 value=$2 + [[ -n "$value" ]] || return 0 + grep -Fxq "$value" "$file" 2>/dev/null || printf '%s\n' "$value" >>"$file" +} + +extract_minute_tokens_from_text() { + grep -Eo 'https?://[^[:space:]"'\'')]+/minutes/[^/?#[:space:]"'\'')]+' 2>/dev/null | sed -E 's#.*\/minutes\/([^/?#]+).*#\1#' || true +} + +try_lark() { + local source=$1 outfile=$2 timeout_seconds=$3 + shift 3 + if ! run_lark_json "$outfile" "$timeout_seconds" "$@"; then + if is_auth_error_file "$outfile"; then + manifest_add_error "$MANIFEST" "$source" "$(jq -r '.error.message // "auth error"' "$outfile" 2>/dev/null)" + printf '%s\n' "$MANIFEST" + exit 1 + fi + manifest_add_error "$MANIFEST" "$source" "$(cat "$outfile" | limit_text 360)" + return 1 + fi +} + +calendar_file="$OUT_DIR/calendar_agenda_$DATE.json" +if try_lark calendar "$calendar_file" "$REQUEST_TIMEOUT_SECONDS" calendar +agenda --as user --start "$START" --end "$END" --format json; then + manifest_set_file "$MANIFEST" calendar "$calendar_file" + manifest_set_count "$MANIFEST" calendar "$(json_count "$calendar_file" '.data')" +fi + +vc_files=() +vc_page=1 +vc_page_token="" +vc_has_more=true +while [[ "$vc_has_more" == true && $vc_page -le 20 ]]; do + vc_file="$OUT_DIR/vc_search_${DATE}_page${vc_page}.json" + args=(vc +search --as user --start "$START" --end "$END" --format json --page-size 30) + [[ -n "$vc_page_token" ]] && args+=(--page-token "$vc_page_token") + if ! try_lark vc "$vc_file" "$REQUEST_TIMEOUT_SECONDS" "${args[@]}"; then + break + fi + vc_files+=("$vc_file") + jq -r '.data.items[]? | .id // empty' "$vc_file" | while IFS= read -r id; do add_unique_line "$MEETING_IDS_FILE" "$id"; done + jq -r '.. | strings? // empty' "$vc_file" | extract_minute_tokens_from_text | while IFS= read -r token; do add_unique_line "$MINUTE_TOKENS_FILE" "$token"; done + vc_has_more="$(jq -r '.data.has_more // false' "$vc_file")" + vc_page_token="$(jq -r '.data.page_token // empty' "$vc_file")" + vc_page=$((vc_page + 1)) +done +if [[ ${#vc_files[@]} -gt 0 ]]; then + manifest_set_files_array "$MANIFEST" vc "${vc_files[@]}" + vc_count=0 + for f in "${vc_files[@]}"; do vc_count=$((vc_count + $(json_count "$f" '.data.items'))); done + manifest_set_count "$MANIFEST" vc "$vc_count" +fi + +detail_files=() +detail_index=1 +while IFS= read -r meeting_id; do + [[ -n "$meeting_id" ]] || continue + detail_file="$OUT_DIR/vc_meeting_${DATE}_detail_${detail_index}.json" + params=$(jq -nc --arg meeting_id "$meeting_id" '{meeting_id:$meeting_id,with_participants:true}') + if try_lark vc_meeting_details "$detail_file" "$REQUEST_TIMEOUT_SECONDS" vc meeting get --as user --params "$params"; then + detail_files+=("$detail_file") + jq -r '[.data.note_doc_token?, .data.verbatim_doc_token?, .data.minute_token?, .data.url?, .data.meeting_url?] | .[]? // empty' "$detail_file" | + extract_minute_tokens_from_text | while IFS= read -r token; do add_unique_line "$MINUTE_TOKENS_FILE" "$token"; done + minute_token="$(jq -r '.data.minute_token // empty' "$detail_file")" + add_unique_line "$MINUTE_TOKENS_FILE" "$minute_token" + fi + detail_index=$((detail_index + 1)) +done <"$MEETING_IDS_FILE" +if [[ ${#detail_files[@]} -gt 0 ]]; then + manifest_set_files_array "$MANIFEST" vc_meeting_details "${detail_files[@]}" + manifest_set_count "$MANIFEST" vc_meeting_details "${#detail_files[@]}" +fi + +notes_files=() +if [[ -s "$MEETING_IDS_FILE" ]]; then + chunk_index=1 + while IFS= read -r chunk; do + [[ -n "$chunk" ]] || continue + notes_file="$OUT_DIR/vc_notes_${DATE}_meeting_chunk${chunk_index}.json" + if try_lark vc_notes "$notes_file" "$REQUEST_TIMEOUT_SECONDS" vc +notes --as user --meeting-ids "$chunk" --format json; then + notes_files+=("$notes_file") + fi + chunk_index=$((chunk_index + 1)) + done < <(paste -sd, "$MEETING_IDS_FILE" | fold -s -w 3000) +fi +if [[ ${#notes_files[@]} -gt 0 ]]; then + manifest_set_files_array "$MANIFEST" vc_notes "${notes_files[@]}" + manifest_set_count "$MANIFEST" vc_notes "${#notes_files[@]}" +fi + +minute_files=() +if [[ -s "$MINUTE_TOKENS_FILE" ]]; then + chunk_index=1 + while IFS= read -r chunk; do + [[ -n "$chunk" ]] || continue + minute_file="$OUT_DIR/vc_notes_${DATE}_minute_chunk${chunk_index}.json" + if try_lark vc_minutes "$minute_file" "$REQUEST_TIMEOUT_SECONDS" vc +notes --as user --minute-tokens "$chunk" --format json; then + minute_files+=("$minute_file") + fi + chunk_index=$((chunk_index + 1)) + done < <(paste -sd, "$MINUTE_TOKENS_FILE" | fold -s -w 3000) +fi +if [[ ${#minute_files[@]} -gt 0 ]]; then + manifest_set_files_array "$MANIFEST" vc_minutes "${minute_files[@]}" + manifest_set_count "$MANIFEST" vc_minutes "${#minute_files[@]}" +fi + +current_user_open_id="" +current_user_file="$OUT_DIR/current_user_$DATE.json" +if try_lark current_user "$current_user_file" "$REQUEST_TIMEOUT_SECONDS" contact +get-user --as user --format json; then + manifest_set_file "$MANIFEST" current_user "$current_user_file" + current_user_open_id="$(jq -r '.data.user.open_id // .data.open_id // empty' "$current_user_file")" + current_user_name="$(jq -r '.data.user.name // .data.name // empty' "$current_user_file")" + manifest_set_value "$MANIFEST" current_user_open_id "$current_user_open_id" + manifest_set_value "$MANIFEST" current_user_name "$current_user_name" +fi + +format_iso_offset() { + python3 - "$1" <<'PY' +from datetime import datetime +import sys +s=sys.argv[1] +if s.endswith('Z'): s=s[:-1]+'+00:00' +print(datetime.fromisoformat(s).isoformat(timespec='seconds')) +PY +} + +add_im_failure() { + local prefix=$1 message=$2 + local file="$IM_FAILURES_DIR/$prefix" + local count=0 + [[ -f "$file" ]] && count="$(cat "$file" 2>/dev/null || printf '0')" + count=$((count + 1)) + printf '%s\n' "$count" >"$file" + manifest_add_error "$MANIFEST" "$prefix" "$message" +} + +im_failure_limit_reached() { + local prefix=$1 + local file="$IM_FAILURES_DIR/$prefix" + local count=0 + [[ -f "$file" ]] && count="$(cat "$file" 2>/dev/null || printf '0')" + [[ "$count" -ge "$IM_MAX_FAILURES_PER_SEARCH" ]] +} + +collect_im_range_pages() { + local prefix=$1 sender=$2 range_start=$3 range_end=$4 chunk_label=$5 + local page=1 page_token="" has_more=true files=() + while [[ "$has_more" == true && $page -le 50 ]]; do + im_failure_limit_reached "$prefix" && break + local file="$OUT_DIR/${prefix}_${chunk_label}_page${page}.json" + local args=(im +messages-search --as user --start "$(format_iso_offset "$range_start")" --end "$(format_iso_offset "$range_end")" --page-size 50 --format json) + [[ -n "$sender" ]] && args+=(--sender "$sender") + [[ -n "$page_token" ]] && args+=(--page-token "$page_token") + if run_lark_json "$file" "$IM_REQUEST_TIMEOUT_SECONDS" "${args[@]}"; then + files+=("$file") + has_more="$(jq -r '.data.has_more // false' "$file")" + page_token="$(jq -r '.data.page_token // empty' "$file")" + page=$((page + 1)) + continue + fi + if is_auth_error_file "$file"; then + manifest_add_error "$MANIFEST" "$prefix" "$(jq -r '.error.message // "auth error"' "$file" 2>/dev/null)" + printf '%s\n' "$MANIFEST" + exit 1 + fi + local start_epoch end_epoch minutes + start_epoch=$(iso_to_epoch "$range_start") + end_epoch=$(iso_to_epoch "$range_end") + minutes=$(python3 - "$start_epoch" "$end_epoch" <<'PY' +import sys +print((float(sys.argv[2])-float(sys.argv[1]))/60) +PY +) + if [[ $page -eq 1 ]] && python3 - "$minutes" "$IM_MIN_CHUNK_MINUTES" <<'PY' +import sys +sys.exit(0 if float(sys.argv[1]) > float(sys.argv[2]) else 1) +PY + then + local mid + mid=$(python3 - "$start_epoch" "$end_epoch" <<'PY' +from datetime import datetime, timezone +import sys +mid=(float(sys.argv[1])+float(sys.argv[2]))/2 +print(datetime.fromtimestamp(mid, timezone.utc).astimezone().isoformat(timespec='seconds')) +PY +) + collect_im_range_pages "$prefix" "$sender" "$range_start" "$mid" "${chunk_label}a" + collect_im_range_pages "$prefix" "$sender" "$mid" "$range_end" "${chunk_label}b" + return + fi + add_im_failure "$prefix" "IM search failed for $chunk_label page $page ($(format_iso_offset "$range_start") ~ $(format_iso_offset "$range_end")): $(cat "$file" | limit_text 360)" + break + done + printf '%s\n' "${files[@]}" +} + +collect_im_pages() { + local prefix=$1 sender=${2:-} + local cursor="$START" chunk_index=1 files=() + local range_end_epoch cursor_epoch chunk_end + range_end_epoch=$(iso_to_epoch "$END") + while :; do + cursor_epoch=$(iso_to_epoch "$cursor") + python3 - "$cursor_epoch" "$range_end_epoch" <<'PY' || break +import sys +sys.exit(0 if float(sys.argv[1]) < float(sys.argv[2]) else 1) +PY + if im_failure_limit_reached "$prefix"; then + manifest_add_error "$MANIFEST" "$prefix" "Stopped IM search after $IM_MAX_FAILURES_PER_SEARCH failures; remaining time range was skipped." + break + fi + chunk_end=$(python3 - "$cursor" "$END" "$IM_CHUNK_HOURS" <<'PY' +from datetime import datetime, timedelta +import sys +cur=sys.argv[1]; end=sys.argv[2]; hours=int(sys.argv[3]) +def parse(s): + if s.endswith('Z'): s=s[:-1]+'+00:00' + return datetime.fromisoformat(s) +c=parse(cur); e=parse(end) +n=min(c+timedelta(hours=max(1,hours)), e) +print(n.isoformat(timespec='seconds')) +PY +) + label=$(printf 'chunk%02d' "$chunk_index") + while IFS= read -r f; do [[ -n "$f" ]] && files+=("$f"); done < <(collect_im_range_pages "$prefix" "$sender" "$cursor" "$chunk_end" "$label") + cursor="$chunk_end" + chunk_index=$((chunk_index + 1)) + done + printf '%s\n' "${files[@]}" +} + +if [[ -n "$current_user_open_id" ]]; then + self_files=() + while IFS= read -r f; do [[ -n "$f" ]] && self_files+=("$f"); done < <(collect_im_pages "im_messages_self_$DATE" "$current_user_open_id") + manifest_set_files_array "$MANIFEST" im_self "${self_files[@]}" + self_count=0 + details_missing=false + for f in "${self_files[@]}"; do + self_count=$((self_count + $(json_count "$f" '.data.messages // .data.message_ids // .data.items'))) + jq -e '((.data.message_ids? != null) and (.data.messages? == null)) or ((.data.note // "") | test("failed to fetch message details"))' "$f" >/dev/null 2>&1 && details_missing=true + done + manifest_set_count "$MANIFEST" im_self "$self_count" + [[ "$details_missing" == true ]] && manifest_add_error "$MANIFEST" im_self_details "IM search returned message IDs only; message content enrichment is unavailable." +else + manifest_add_error "$MANIFEST" im_self "Skipped because current user open_id was unavailable." +fi + +all_files=() +while IFS= read -r f; do [[ -n "$f" ]] && all_files+=("$f"); done < <(collect_im_pages "im_messages_all_$DATE") +manifest_set_files_array "$MANIFEST" im_all "${all_files[@]}" +all_count=0 +details_missing=false +for f in "${all_files[@]}"; do + all_count=$((all_count + $(json_count "$f" '.data.messages // .data.message_ids // .data.items'))) + jq -e '((.data.message_ids? != null) and (.data.messages? == null)) or ((.data.note // "") | test("failed to fetch message details"))' "$f" >/dev/null 2>&1 && details_missing=true +done +manifest_set_count "$MANIFEST" im_all "$all_count" +[[ "$details_missing" == true ]] && manifest_add_error "$MANIFEST" im_all_details "IM search returned message IDs only; message content enrichment is unavailable." + +doc_files=() +doc_page=1 +doc_page_token="" +doc_has_more=true +doc_filter=$(jq -nc --arg start "$START" --arg end "$END" '{open_time:{start:$start,end:$end}}') +while [[ "$doc_has_more" == true && $doc_page -le 10 ]]; do + doc_file="$OUT_DIR/docs_search_${DATE}_page${doc_page}.json" + args=(docs +search --as user --query "" --filter "$doc_filter" --page-size 20 --format json) + [[ -n "$doc_page_token" ]] && args+=(--page-token "$doc_page_token") + if ! try_lark docs "$doc_file" "$REQUEST_TIMEOUT_SECONDS" "${args[@]}"; then + break + fi + doc_files+=("$doc_file") + doc_has_more="$(jq -r '.data.has_more // false' "$doc_file")" + doc_page_token="$(jq -r '.data.page_token // empty' "$doc_file")" + doc_page=$((doc_page + 1)) +done +if [[ ${#doc_files[@]} -gt 0 ]]; then + manifest_set_files_array "$MANIFEST" docs "${doc_files[@]}" + doc_count=0 + for f in "${doc_files[@]}"; do doc_count=$((doc_count + $(json_count "$f" '.data.results'))); done + manifest_set_count "$MANIFEST" docs "$doc_count" +fi + +redact_json_dir "$OUT_DIR" +rm -f "$MEETING_IDS_FILE" "$MINUTE_TOKENS_FILE" +rm -rf "$IM_FAILURES_DIR" +printf '%s\n' "$MANIFEST" diff --git a/skills/lark-workflow-standup-report/scripts/create_daily_doc.ps1 b/skills/lark-workflow-standup-report/scripts/create_daily_doc.ps1 new file mode 100644 index 000000000..a2e82f705 --- /dev/null +++ b/skills/lark-workflow-standup-report/scripts/create_daily_doc.ps1 @@ -0,0 +1,62 @@ +param( + [Parameter(Mandatory = $true)] + [string] $MarkdownPath, + + [Parameter(Mandatory = $true)] + [string] $Title, + + [Parameter(Mandatory = $true)] + [string] $WikiNode, + + [Parameter(Mandatory = $true)] + [string] $ResponsePath, + + [switch] $DryRun +) + +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $MarkdownPath)) { + throw "MarkdownPath not found: $MarkdownPath" +} + +$parent = Split-Path -Parent $ResponsePath +if ($parent) { New-Item -ItemType Directory -Force -Path $parent | Out-Null } + +if ($DryRun) { + [ordered]@{ + dry_run = $true + title = $Title + wiki_node = $WikiNode + markdown_path = $MarkdownPath + response_path = $ResponsePath + } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $ResponsePath -Encoding UTF8 + Write-Output $ResponsePath + exit 0 +} + +$markdown = Get-Content -LiteralPath $MarkdownPath -Raw +$content = if ($markdown -match '^\s*#\s+') { + $markdown +} else { + "# $Title`r`n`r`n$markdown" +} + +$tempContentPath = Join-Path $parent 'daily_doc_create_content.md' +Set-Content -LiteralPath $tempContentPath -Value $content -Encoding UTF8 + +try { + $payload = & lark-cli docs +create --as user --parent-token $WikiNode --doc-format markdown --content "@$tempContentPath" 2>&1 | Out-String + Set-Content -LiteralPath $ResponsePath -Value $payload -Encoding UTF8 + + if ($LASTEXITCODE -ne 0) { + throw "lark-cli create failed: $payload" + } +} finally { + if (Test-Path -LiteralPath $tempContentPath) { + Remove-Item -LiteralPath $tempContentPath -Force + } +} + +Write-Output $ResponsePath diff --git a/skills/lark-workflow-standup-report/scripts/create_daily_doc.sh b/skills/lark-workflow-standup-report/scripts/create_daily_doc.sh new file mode 100644 index 000000000..44202a616 --- /dev/null +++ b/skills/lark-workflow-standup-report/scripts/create_daily_doc.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +MARKDOWN_PATH="" +TITLE="" +WIKI_NODE="" +RESPONSE_PATH="" +DRY_RUN=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --markdown-path|-MarkdownPath) MARKDOWN_PATH=$2; shift 2 ;; + --title|-Title) TITLE=$2; shift 2 ;; + --wiki-node|-WikiNode) WIKI_NODE=$2; shift 2 ;; + --response-path|-ResponsePath) RESPONSE_PATH=$2; shift 2 ;; + --dry-run|-DryRun) DRY_RUN=true; shift ;; + *) echo "Unknown argument: $1" >&2; exit 1 ;; + esac +done + +[[ -n "$MARKDOWN_PATH" && -n "$TITLE" && -n "$WIKI_NODE" && -n "$RESPONSE_PATH" ]] || { + echo "Required: --markdown-path --title --wiki-node --response-path" >&2 + exit 1 +} +command -v jq >/dev/null 2>&1 || { + echo "Missing required command: jq" >&2 + exit 1 +} +command -v lark-cli >/dev/null 2>&1 || { + echo "Missing required command: lark-cli" >&2 + exit 1 +} +command -v python3 >/dev/null 2>&1 || { + echo "Missing required command: python3" >&2 + exit 1 +} +[[ -f "$MARKDOWN_PATH" ]] || { + echo "MarkdownPath not found: $MARKDOWN_PATH" >&2 + exit 1 +} + +mkdir -p "$(dirname "$RESPONSE_PATH")" + +if [[ "$DRY_RUN" == true ]]; then + jq -n --arg title "$TITLE" --arg wiki_node "$WIKI_NODE" --arg markdown_path "$MARKDOWN_PATH" --arg response_path "$RESPONSE_PATH" \ + '{dry_run:true,title:$title,wiki_node:$wiki_node,markdown_path:$markdown_path,response_path:$response_path}' >"$RESPONSE_PATH" + printf '%s\n' "$RESPONSE_PATH" + exit 0 +fi + +temp_content_dir="$(dirname "$RESPONSE_PATH")" +temp_content="$temp_content_dir/daily_doc_create_content.md" +if grep -Eq '^[[:space:]]*#[[:space:]]+' "$MARKDOWN_PATH"; then + cp "$MARKDOWN_PATH" "$temp_content" +else + { + printf '# %s\n\n' "$TITLE" + cat "$MARKDOWN_PATH" + } >"$temp_content" +fi + +parent_token="$WIKI_NODE" +if [[ "$parent_token" =~ /wiki/([^/?#]+) ]]; then + parent_token="${BASH_REMATCH[1]}" +fi + +content_arg_path="$temp_content" +case "$content_arg_path" in + /*) + content_arg_path="$(python3 - "$PWD" "$temp_content" <<'PY' +from pathlib import Path +import os, sys +try: + print(os.path.relpath(Path(sys.argv[2]).resolve(), Path(sys.argv[1]).resolve())) +except Exception: + print(sys.argv[2]) +PY +)" + ;; +esac + +set +e +lark-cli docs +create --api-version v2 --as user --parent-token "$parent_token" --doc-format markdown --content "@$content_arg_path" --format json >"$RESPONSE_PATH" 2>&1 +status=$? +set -e +rm -f "$temp_content" + +if [[ $status -ne 0 ]] || jq -e '.ok == false' "$RESPONSE_PATH" >/dev/null 2>&1; then + echo "lark-cli create failed; response saved to $RESPONSE_PATH" >&2 + exit 1 +fi + +printf '%s\n' "$RESPONSE_PATH" diff --git a/skills/lark-workflow-standup-report/scripts/lark_common.ps1 b/skills/lark-workflow-standup-report/scripts/lark_common.ps1 new file mode 100644 index 000000000..3201b5f77 --- /dev/null +++ b/skills/lark-workflow-standup-report/scripts/lark_common.ps1 @@ -0,0 +1,154 @@ +Set-StrictMode -Version 3.0 + +function Resolve-LarkCliInvocation { + $bundledNode = 'C:\Users\Leo\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe' + $runJs = 'C:\Users\Leo\AppData\Roaming\npm\node_modules\@larksuite\cli\scripts\run.js' + + if ($env:LARK_CLI_NODE -and $env:LARK_CLI_RUNJS -and + (Test-Path -LiteralPath $env:LARK_CLI_NODE) -and + (Test-Path -LiteralPath $env:LARK_CLI_RUNJS)) { + return @{ + Mode = 'node' + Exe = $env:LARK_CLI_NODE + PrefixArgs = @($env:LARK_CLI_RUNJS) + } + } + + if ((Test-Path -LiteralPath $bundledNode) -and (Test-Path -LiteralPath $runJs)) { + return @{ + Mode = 'node' + Exe = $bundledNode + PrefixArgs = @($runJs) + } + } + + $cmd = Get-Command lark-cli -ErrorAction SilentlyContinue + if ($cmd) { + return @{ + Mode = 'native' + Exe = $cmd.Source + PrefixArgs = @() + } + } + + throw 'Cannot locate lark-cli. Set LARK_CLI_NODE and LARK_CLI_RUNJS, or install lark-cli in PATH.' +} + +function Invoke-LarkCliJson { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string[]] $Args, + + [Parameter(Mandatory = $true)] + [string] $OutFile, + + [int] $TimeoutSeconds = 120 + ) + + $invocation = Resolve-LarkCliInvocation + $allArgs = @($invocation.PrefixArgs) + $Args + + $psi = [System.Diagnostics.ProcessStartInfo]::new() + $psi.FileName = $invocation.Exe + foreach ($arg in $allArgs) { + [void]$psi.ArgumentList.Add($arg) + } + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + + $proc = [System.Diagnostics.Process]::Start($psi) + $stdoutTask = $proc.StandardOutput.ReadToEndAsync() + $stderrTask = $proc.StandardError.ReadToEndAsync() + if (-not $proc.WaitForExit($TimeoutSeconds * 1000)) { + try { $proc.Kill() } catch {} + try { $proc.WaitForExit() } catch {} + throw "lark-cli timed out: $($Args -join ' ')" + } + + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() + $payload = if ($stdout.Trim()) { $stdout } else { $stderr } + + Set-Content -LiteralPath $OutFile -Value $payload -Encoding UTF8 + + try { + $json = $payload | ConvertFrom-Json + if ($json -and $json.PSObject.Properties['ok'] -and $json.ok -eq $false) { + $errorType = if ($json.PSObject.Properties['error'] -and $json.error.PSObject.Properties['type']) { $json.error.type } else { 'unknown' } + $errorMessage = if ($json.PSObject.Properties['error'] -and $json.error.PSObject.Properties['message']) { $json.error.message } else { 'lark-cli returned ok=false' } + throw "lark-cli returned ok=false ($errorType): $errorMessage" + } + } catch { + if ($_.Exception.Message -like 'lark-cli returned ok=false*') { throw } + } + + return [pscustomobject]@{ + ExitCode = $proc.ExitCode + Stdout = $stdout + Stderr = $stderr + OutFile = $OutFile + } +} + +function Read-JsonFileOrNull { + param([Parameter(Mandatory = $true)][string] $Path) + if (-not (Test-Path -LiteralPath $Path)) { return $null } + $raw = Get-Content -LiteralPath $Path -Raw + if (-not $raw.Trim()) { return $null } + try { return $raw | ConvertFrom-Json } catch { return $null } +} + +function Protect-Text { + param([Parameter(Mandatory = $true)][AllowEmptyString()][string] $Text) + + $safe = $Text + $safe = $safe -replace '(?i)(Key:\s*)[A-Za-z0-9+/=_-]{16,}', '$1[REDACTED]' + $safe = $safe -replace '(?i)(api[_ -]?key["''\s:=]+)[A-Za-z0-9+/=_-]{12,}', '$1[REDACTED]' + $safe = $safe -replace '(?i)((?:access|refresh|tenant|user|app|authorization)[_-]?token["''\s:=]+)[A-Za-z0-9._+/=_-]{16,}', '$1[REDACTED]' + $safe = $safe -replace '(?i)(secret["''\s:=]+)[A-Za-z0-9._+/=_-]{12,}', '$1[REDACTED]' + $safe = $safe -replace '(?i)(password["''\s:=]+)[^\s,''"}]{6,}', '$1[REDACTED]' + $safe = $safe -replace '(?i)(private[-_ ]?key["''\s:=]+)[A-Za-z0-9._+/=_-]{16,}', '$1[REDACTED]' + return $safe +} + +function Protect-JsonFiles { + param([Parameter(Mandatory = $true)][string] $Directory) + + Get-ChildItem -LiteralPath $Directory -Filter '*.json' -File -ErrorAction SilentlyContinue | ForEach-Object { + $raw = Get-Content -LiteralPath $_.FullName -Raw + $safe = Protect-Text -Text $raw + if ($safe -ne $raw) { + Set-Content -LiteralPath $_.FullName -Value $safe -Encoding UTF8 + } + } +} + +function Count-JsonArray { + param($Value) + if ($null -eq $Value) { return 0 } + if ($Value -is [array]) { return $Value.Count } + return 1 +} + +function Get-LarkImItems { + param($Json) + + if ($null -eq $Json -or -not $Json.PSObject.Properties['data']) { return @() } + if ($Json.data.PSObject.Properties['messages'] -and $Json.data.messages) { return @($Json.data.messages) } + if ($Json.data.PSObject.Properties['message_ids'] -and $Json.data.message_ids) { return @($Json.data.message_ids) } + if ($Json.data.PSObject.Properties['items'] -and $Json.data.items) { return @($Json.data.items) } + return @() +} + +function Test-LarkImDetailsMissing { + param($Json) + + if ($null -eq $Json -or -not $Json.PSObject.Properties['data']) { return $false } + $hasMessageIds = $Json.data.PSObject.Properties['message_ids'] -and $Json.data.message_ids + $hasMessages = $Json.data.PSObject.Properties['messages'] -and $Json.data.messages + $note = if ($Json.data.PSObject.Properties['note']) { [string]$Json.data.note } else { '' } + return ($hasMessageIds -and -not $hasMessages) -or ($note -match 'failed to fetch message details') +} diff --git a/skills/lark-workflow-standup-report/scripts/lark_common.sh b/skills/lark-workflow-standup-report/scripts/lark_common.sh new file mode 100644 index 000000000..421c36118 --- /dev/null +++ b/skills/lark-workflow-standup-report/scripts/lark_common.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1" +} + +json_count() { + local file=$1 + local expr=$2 + jq -r "$expr | if type == \"array\" then length elif . == null then 0 else 1 end" "$file" 2>/dev/null || printf '0\n' +} + +json_get() { + local file=$1 + local expr=$2 + jq -r "$expr // empty" "$file" 2>/dev/null || true +} + +run_lark_json() { + local outfile=$1 + local timeout_seconds=$2 + shift 2 + mkdir -p "$(dirname "$outfile")" + + local tmp + tmp="${outfile}.tmp" + if command -v timeout >/dev/null 2>&1; then + timeout "$timeout_seconds" lark-cli "$@" >"$tmp" 2>&1 || { + local status=$? + mv "$tmp" "$outfile" + return "$status" + } + else + lark-cli "$@" >"$tmp" 2>&1 || { + local status=$? + mv "$tmp" "$outfile" + return "$status" + } + fi + + mv "$tmp" "$outfile" + if jq -e '.ok == false' "$outfile" >/dev/null 2>&1; then + return 1 + fi +} + +is_auth_error_file() { + local file=$1 + jq -e ' + (.error.message // "" | test("not logged in|auth login|keychain|permission|authorize"; "i")) or + (.error.type // "" | test("auth|authentication"; "i")) + ' "$file" >/dev/null 2>&1 +} + +redact_file() { + local file=$1 + perl -0pi \ + -e 's/(Key:\s*)[A-Za-z0-9+\/=_-]{16,}/$1[REDACTED]/gi' \ + -e 's/(api[_ -]?key["'\''\s:=]+)[A-Za-z0-9+\/=_-]{12,}/$1[REDACTED]/gi' \ + -e 's/((?:access|refresh|tenant|user|app|authorization)[_-]?token["'\''\s:=]+)[A-Za-z0-9._+\/=_-]{16,}/$1[REDACTED]/gi' \ + -e 's/(secret["'\''\s:=]+)[A-Za-z0-9._+\/=_-]{12,}/$1[REDACTED]/gi' \ + -e 's/(password["'\''\s:=]+)[^\s,'\''"}]{6,}/$1[REDACTED]/gi' \ + -e 's/(private[-_ ]?key["'\''\s:=]+)[A-Za-z0-9._+\/=_-]{16,}/$1[REDACTED]/gi' \ + "$file" 2>/dev/null || true +} + +redact_json_dir() { + local dir=$1 + find "$dir" -maxdepth 1 -type f -name '*.json' -print0 2>/dev/null | while IFS= read -r -d '' file; do + redact_file "$file" + done +} + +manifest_init() { + local file=$1 date=$2 start=$3 end=$4 dry_run=${5:-false} + jq -n \ + --arg date "$date" \ + --arg start "$start" \ + --arg end "$end" \ + --arg generated_at "$(date '+%Y-%m-%d %H:%M:%S %z')" \ + --argjson dry_run "$dry_run" \ + '{date:$date,start:$start,end:$end,generated_at:$generated_at,dry_run:$dry_run,files:{},counts:{},errors:[]}' >"$file" +} + +manifest_set_file() { + local manifest=$1 key=$2 value=$3 + jq --arg key "$key" --arg value "$value" '.files[$key]=$value' "$manifest" >"${manifest}.tmp" + mv "${manifest}.tmp" "$manifest" +} + +manifest_set_files_array() { + local manifest=$1 key=$2 + shift 2 + jq --arg key "$key" --args '.files[$key]=$ARGS.positional' "$manifest" "$@" >"${manifest}.tmp" + mv "${manifest}.tmp" "$manifest" +} + +manifest_set_count() { + local manifest=$1 key=$2 value=$3 + jq --arg key "$key" --argjson value "${value:-0}" '.counts[$key]=$value' "$manifest" >"${manifest}.tmp" + mv "${manifest}.tmp" "$manifest" +} + +manifest_set_value() { + local manifest=$1 key=$2 value=$3 + jq --arg key "$key" --arg value "$value" '.[$key]=$value' "$manifest" >"${manifest}.tmp" + mv "${manifest}.tmp" "$manifest" +} + +manifest_add_error() { + local manifest=$1 source=$2 message=$3 + jq --arg source "$source" --arg message "$message" '.errors += [{source:$source,message:$message}]' "$manifest" >"${manifest}.tmp" + mv "${manifest}.tmp" "$manifest" +} + +limit_text() { + local max=${2:-240} + python3 - "$max" <<'PY' +import re, sys +max_len = int(sys.argv[1]) +text = sys.stdin.read() +text = re.sub(r"\s+", " ", text).strip() +print(text if len(text) <= max_len else text[:max_len] + "...") +PY +} + +iso_to_epoch() { + python3 - "$1" <<'PY' +from datetime import datetime +import sys +s=sys.argv[1] +if s.endswith('Z'): + s=s[:-1] + '+00:00' +print(datetime.fromisoformat(s).timestamp()) +PY +} diff --git a/skills/lark-workflow-standup-report/test-prompts.json b/skills/lark-workflow-standup-report/test-prompts.json new file mode 100644 index 000000000..f615a73ee --- /dev/null +++ b/skills/lark-workflow-standup-report/test-prompts.json @@ -0,0 +1,27 @@ +[ + { + "id": "daily-create", + "prompt": "生成今天的工作日报并创建飞书云文档,时间范围按 Asia/Shanghai 当天 00:00 到当前时刻。", + "expected": "读取 lark-shared/lark-doc,运行飞书采集、本地 Agent 证据、候选审查脚本,生成 outputs/工作日报-YYYY-MM-DD.md,创建飞书文档并保存响应。" + }, + { + "id": "daily-attribution-correction", + "prompt": "今天日报里不要把与我无关的群聊事项写成我的工作,AIEXCEL、Swimlane、Wardrobe 这些本地项目也要判断是否有当天证据。", + "expected": "运行候选审查,使用本人归因门槛过滤群聊噪声,并在证据/执行记录中说明历史重点本地项目纳入或未纳入原因。" + }, + { + "id": "wbs-plan-no-write", + "prompt": "根据本周飞书会议和聊天,先生成研发 WBS 待填条目草稿,不要直接写入表格。", + "expected": "进入 WBS 填报模式,生成包含口述汇报摘要和候选 WBS 条目的本地 Markdown,不调用 Base 写入;如需排除项目,只影响 WBS 候选/写表;任务级别默认二级,后续写入 Base 时必须统一推送二级。" + }, + { + "id": "meeting-transcript-codex-boundary", + "prompt": "今天有一场飞书妙记会议,妙记 AI 摘要说客户已确认方案,但逐字稿只显示还在讨论;同主题 Codex 会话里有后续方案草稿。生成日报时怎么归因?", + "expected": "逐字稿优先,妙记 AI 摘要只能作为候选线索;Codex 会话只作为会议事项补强,不能替代会议原始证据,不能写成客户已确认。" + }, + { + "id": "local-codex-primary-evidence", + "prompt": "今天没有相关会议,但 Codex 会话里完成了本地方案文档、代码修改和测试验证。生成日报时 Codex 会话是不是只能作为补强证据?", + "expected": "不是。若事项本身是本地 Agent 工作产出,并有 knowledge、代码、脚本、测试或构建产物,则 Codex/Claude 会话和落地产物可作为该工作包主证据。" + } +]