From be3c5fbe320b880a39980aae43ad62bf029831b3 Mon Sep 17 00:00:00 2001 From: "Anthony.Fan" Date: Wed, 22 Jul 2026 13:13:08 +0800 Subject: [PATCH] feat: prompt for post-standup automation on install --- .evozeus-wrapper/CHANGELOG.md | 29 ++ ...-install-post-standup-automation-v0.4.0.md | 87 ++++++ .evozeus-wrapper/wrapper.json | 8 +- SKILL.md | 14 +- agents/openai.yaml | 2 +- evals/evals.json | 54 +++- references/daily-operations.md | 28 +- scripts/configure_daily_automation.py | 288 ++++++++++++++++++ tests/test_automation_onboarding.py | 100 ++++++ tests/test_business_contracts.py | 25 ++ 10 files changed, 623 insertions(+), 12 deletions(-) create mode 100644 .evozeus-wrapper/docs/designs/2026-07-22-install-post-standup-automation-v0.4.0.md create mode 100644 scripts/configure_daily_automation.py create mode 100644 tests/test_automation_onboarding.py diff --git a/.evozeus-wrapper/CHANGELOG.md b/.evozeus-wrapper/CHANGELOG.md index 1460efa..18d93d0 100644 --- a/.evozeus-wrapper/CHANGELOG.md +++ b/.evozeus-wrapper/CHANGELOG.md @@ -8,6 +8,35 @@ Wrapper harness migrations are recorded under `.evozeus-wrapper/docs/migrations/ - No unreleased changes. +## [v0.4.0] - 2026-07-22 + +### Skill changes + +- Added required install and upgrade onboarding for one workday 10:45 `post_standup` automation. +- Added a stable automation marker, duplicate detection, configuration-version decisions, explicit decline and reset behavior, and first-invocation fallback when the installer does not execute onboarding. +- Added `scripts/configure_daily_automation.py` to plan, record, reset, and verify onboarding without directly writing Codex automation configuration. +- Updated the wrapper initialization contract to require the target-Skill onboarding plan and verification commands. +- Kept automation creation and updates behind user approval and the Codex automation API; scheduled behavior remains `proposal_only` and `private_draft`. +- Added four synthetic evaluation scenarios and isolated helper tests for fresh install, reuse, decline, reset, and missing-record recovery. + +### Feedback / Issues + +- MetaInFLow/metainflow-dev-tasks#7: prompt for the required 10:45 post-standup automation during installation. +- Design: `.evozeus-wrapper/docs/designs/2026-07-22-install-post-standup-automation-v0.4.0.md`. + +### Verification + +- `python3 -m unittest discover -s tests -p 'test_*.py'` +- JSON parsing for `evals/evals.json` and `.evozeus-wrapper/wrapper.json`. +- `python3 .evozeus-wrapper/scripts/evozeus_wrapper_preflight.py structure` +- `python3 .evozeus-wrapper/scripts/evozeus_wrapper_preflight.py doctor --repo MetaInFLow/metainflow-dev-tasks` +- Skill format, helper CLI, isolated Codex-home, source-contract, and public-data boundary checks. + +### Data boundary and rollback + +- Public artifacts contain synthetic automation names, markers, prompts, paths, and IDs only. No raw chats, customer records, internal links, Base tokens, or credentials are included. +- Roll back to `v0.3.0` to remove install-time automation onboarding while retaining manual daily management modes and checkpoints. + ## [v0.3.0] - 2026-07-22 ### Skill changes diff --git a/.evozeus-wrapper/docs/designs/2026-07-22-install-post-standup-automation-v0.4.0.md b/.evozeus-wrapper/docs/designs/2026-07-22-install-post-standup-automation-v0.4.0.md new file mode 100644 index 0000000..dbe5c5b --- /dev/null +++ b/.evozeus-wrapper/docs/designs/2026-07-22-install-post-standup-automation-v0.4.0.md @@ -0,0 +1,87 @@ +# Install-time Post-standup Automation Onboarding v0.4.0 + +## Related Issue + +Related issue: MetaInFLow/metainflow-dev-tasks#7. + +## Optimization Goal + +Make the required daily automation an explicit installation decision. A fresh install or version upgrade must detect whether the matching automation exists, explain its workday 10:45 post-standup behavior, ask for approval, create or update it through the Codex automation API, and verify the result without creating duplicates. + +Codex currently has no native `SkillInstalled` lifecycle event. The implementation therefore uses the wrapper onboarding initialization contract as the primary path and a first-invocation fallback in `SKILL.md`. + +## Direction + +1. Mark target-Skill initialization as required in `.evozeus-wrapper/wrapper.json`. +2. Add a public-safe initialization helper that plans, detects, records, and verifies the onboarding decision. It never writes Codex automation configuration directly. +3. Require the installing agent to call the Codex automation management API after user approval. +4. Use one stable automation marker and configuration version so installs and upgrades update or reuse a match. +5. Prompt once per configuration version. An explicit decline is valid and can be reset later. +6. Keep the scheduled job under `proposal_only` and `private_draft`; it cannot mutate Base, mark acceptance, rebaseline a project, or publish to a group. + +## Required Automation + +- Name: `源子开发任务|工作日10:45会后校准` +- Schedule: workdays at 10:45 in the user-selected local timezone; default `Asia/Shanghai` for this Skill. +- Mode: `post_standup` +- Run kind: `scheduled` +- Mutation policy: `proposal_only` +- Publication policy: `private_draft` +- Target: the user's selected Codex local project; prefer the daily-work project when uniquely available. +- Stable marker: `metainflow-dev-tasks:post_standup:weekday-1045:v1` + +The job resumes the daily checkpoint and reads live Base records, project arrangements for all relevant workstreams, the daily plan, previous-working-day and current work communication, the latest standup record, logs, delivery evidence, and leave or review responsibilities. It produces proposed mutations, project schedule health, weekly-to-daily assignments, workload gaps, and a private card draft. + +## Initialization Flow + +1. Run `plan --json` from the canonical Skill root. +2. If a matching active automation exists, return `already_installed` and skip the prompt. +3. If the current configuration version was explicitly declined, return `declined`; expose a reset command without prompting again. +4. Otherwise return `prompt_required` with the human schedule, behavior, boundaries, stable marker, and tool arguments. +5. Ask the user to install or skip. +6. On install, use the Codex automation API. Resolve the target project first and update a matching automation instead of creating a duplicate. +7. Record the decision, automation ID, target project, timezone, and configuration version. +8. Run `verify --json`. Installation succeeds when the matching automation is active or the user explicitly declined this configuration version. + +## Helper Contract + +`scripts/configure_daily_automation.py` supports: + +- `plan --json`: return `already_installed`, `declined`, or `prompt_required`. +- `record --decision installed --automation-id --project-id --timezone `: persist the API result. +- `record --decision declined`: persist an explicit user decision. +- `verify --json`: verify the active automation marker or current-version decline. +- `reset --json`: clear a decline or stale recorded result so onboarding can run again. + +State lives under the user's Codex home, outside the public repository. Automation detection reads the existing automation TOML files, while creation and updates remain exclusive to the Codex automation API. + +## Wrapper And Runtime Contract + +Set `onboarding.initialization.required=true`, owned by `target_skill`, with: + +- command: `python3 scripts/configure_daily_automation.py plan --json` +- verification: `python3 scripts/configure_daily_automation.py verify --json` + +The helper is part of the runtime bundle because `SKILL.md` references `scripts/`. The first-invocation fallback runs `plan --json` when onboarding state is absent for the current configuration version. + +## Implementation Plan + +1. Add the isolated onboarding helper and its temporary-Codex-home tests. +2. Change the wrapper target initialization contract from optional to required. +3. Add the install and first-invocation gates to `SKILL.md` and the detailed API boundary to `references/daily-operations.md`. +4. Update the default agent prompt, synthetic evals, changelog, and Skill release version. +5. Present the 10:45 automation through the Codex automation API after the release is installed; record and verify only after user approval creates the automation. + +## Verification Plan + +1. Add failing tests for the required wrapper initialization, 10:45 workday schedule, stable marker, prompt boundary, and first-invocation fallback. +2. Add helper tests with isolated temporary Codex homes for prompt-required, declined, matching-installed, and reset paths. +3. Add synthetic evals for fresh install, existing automation reuse, explicit decline, and upgrade re-prompt. +4. Run unit tests, JSON/YAML parsing, wrapper structure, source-contract checks, PR preflight, and public-data boundary checks. +5. Confirm the actual automation, when installed, uses the Codex automation API and does not require direct TOML writes. + +## Release Plan + +Release as Skill `v0.4.0` because this adds a required onboarding capability and a new runtime helper. Update `.evozeus-wrapper/CHANGELOG.md`, merge through a reviewed PR, publish a `v0.4.0` release referencing Issue #7, and verify that the canonical runtime symlink includes the new helper. + +Roll back to `v0.3.0` to remove install-time automation onboarding. Existing daily operating modes and checkpoints remain available for manual invocation. diff --git a/.evozeus-wrapper/wrapper.json b/.evozeus-wrapper/wrapper.json index 3f068a6..66f7a71 100644 --- a/.evozeus-wrapper/wrapper.json +++ b/.evozeus-wrapper/wrapper.json @@ -50,10 +50,12 @@ "verification": "Confirm the host selects the canonical 源子开发任务/SKILL.md and pass a consumer-project smoke test." }, "initialization": { - "required": false, + "required": true, "owner": "target_skill", - "command": null, - "verification": null + "command": "python3 scripts/configure_daily_automation.py plan --json", + "verification": "python3 scripts/configure_daily_automation.py verify --json", + "prompt_contract": "If plan returns prompt_required, explain the workday 10:45 post-standup automation and ask the user to install or decline. Use the Codex automation API after approval; never write automation TOML directly.", + "automation_marker": "metainflow-dev-tasks:post_standup:weekday-1045:v1" }, "generated_child_skills": { "supported": false, diff --git a/SKILL.md b/SKILL.md index 53fc64d..1939b06 100644 --- a/SKILL.md +++ b/SKILL.md @@ -12,7 +12,7 @@ description: 通过 lark-cli/LarkSuite CLI 处理「源子技术部管理系统 若当前只是 runtime-only install,缺少维护资产时不要把安装副本当作事实源,回 canonical repo 处理 wrapper harness 或 Skill release。 1. Skill release 状态 - - 当前记录版本:`v0.3.0` + - 当前记录版本:`v0.4.0` - 检查命令:`python3 .evozeus-wrapper/scripts/evozeus_wrapper_preflight.py version --repo MetaInFLow/metainflow-dev-tasks` - 如果 GitHub latest release 更新:先更新 canonical repo,并确认 runtime install 仍指向 canonical repo。 - 如果本地版本领先 GitHub release:先完成 changelog、验证和 `vMAJOR.MINOR.PATCH` release,再把它当作稳定运行版本。 @@ -42,6 +42,16 @@ description: 通过 lark-cli/LarkSuite CLI 处理「源子技术部管理系统 这个 skill 用于把开发组任务管理在飞书多维表这个事实源里,并用日计划、会议纪要和项目相关聊天解释当前优先级与最新协调结果。核心原则是:先确认或复用需求,再把具体 bug、开发、部署、测试、文档、排查、支持事项作为子任务绑定到需求。 +## 安装初始化 + +1. 安装、升级或当前配置版本的首次调用时,先在 canonical Skill 根目录运行 `python3 scripts/configure_daily_automation.py plan --json`。 +2. 返回 `already_installed` 时直接继续;返回 `declined` 时保留用户本版决定;返回 `prompt_required` 时必须提示安装“源子开发任务|工作日10:45会后校准”定时任务。 +3. 提示必须说明:工作日 10:45、用户本地时区、`post_standup`、只生成拟变更和私下卡片草稿、不自动翻状态或群发布。 +4. 用户批准安装后,先用 Codex 项目列表解析目标工作区,再用 Codex automation API 创建或更新带稳定 marker 的 cron 任务。检测到同 marker 任务时复用或更新,禁止重复创建。 +5. 创建成功后运行 `record --decision installed --automation-id --project-id --timezone --json`,然后运行 `verify --json`。用户选择跳过时运行 `record --decision declined --json`,再运行验证。 +6. 禁止脚本或 Agent 直接写入 `$CODEX_HOME/automations/*/automation.toml`。定时任务的新增、更新和删除只能通过 Codex automation API 执行。 +7. Codex 当前没有原生 `SkillInstalled` 事件。wrapper onboarding initialization 是主入口;安装器未执行该入口时,本 Skill 的首次调用必须补做检测和提示。 + ## 参考资料 - 需要表用途、必填字段、视图或路由规则时,读取 `references/schema.md`。 @@ -227,7 +237,7 @@ description: 通过 lark-cli/LarkSuite CLI 处理「源子技术部管理系统 Target repo: `MetaInFLow/metainflow-dev-tasks` Visibility: `public` -Current Skill version: `v0.3.0` +Current Skill version: `v0.4.0` Wrapper harness version: `v0.10.1` ## EvoZeus-wrapper diff --git a/agents/openai.yaml b/agents/openai.yaml index 4bf7794..761d42f 100644 --- a/agents/openai.yaml +++ b/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "源子开发任务" short_description: "闭环管理项目里程碑、任务产出、进度与负载" - default_prompt: "帮我结合项目任务安排、源子技术部管理系统、会前日计划、早会纪要和相关工作聊天管理开发任务。连续工作日使用每日项目管理闭环,从检查点恢复并进入会前扫描、会后校准、批准后写入/发布或日终收口阶段;定时调用只生成拟变更和私下草稿。先确定有效项目集和工作流,建立项目周里程碑、个人周产出、今日任务的映射;任务必须写清产出物、载体、验收、依赖和准确截止时间。区分原始基线、当前预测与批准后基线,用验收证据判断项目提前、按计划、有风险或已落后;把未映射口头任务纳入待同步闭环,并在发送消息前单独确认最终内容和目标会话。" + default_prompt: "首次调用时先检查安装初始化;缺少工作日 10:45 会后校准定时任务时,提示用户安装或跳过。帮我结合项目任务安排、源子技术部管理系统、会前日计划、早会纪要和相关工作聊天管理开发任务。连续工作日使用每日项目管理闭环,从检查点恢复并进入会前扫描、会后校准、批准后写入/发布或日终收口阶段;定时调用只生成拟变更和私下草稿。先确定有效项目集和工作流,建立项目周里程碑、个人周产出、今日任务的映射;任务必须写清产出物、载体、验收、依赖和准确截止时间。区分原始基线、当前预测与批准后基线,用验收证据判断项目提前、按计划、有风险或已落后;把未映射口头任务纳入待同步闭环,并在发送消息前单独确认最终内容和目标会话。" diff --git a/evals/evals.json b/evals/evals.json index 5a96a5f..f8fb6a5 100644 --- a/evals/evals.json +++ b/evals/evals.json @@ -1,6 +1,6 @@ { "skill": "metainflow-dev-tasks", - "target_release": "v0.3.0", + "target_release": "v0.4.0", "evidence_boundary": "Synthetic public scenarios only", "evals": [ { @@ -169,6 +169,58 @@ "Repeat Base mutations that were already read back successfully.", "Send a duplicate card because the previous request timed out." ] + }, + { + "id": "fresh-install-automation-prompt", + "prompt": "Install the Skill into a fresh Codex environment with no matching automation.", + "expected_behavior": [ + "Run the target-Skill onboarding plan and detect that the required automation is missing.", + "Prompt for one workday 10:45 post_standup automation and explain proposal_only and private_draft boundaries.", + "After approval, resolve the target project, create the task through the Codex automation API, record the result, and verify the stable marker." + ], + "forbidden_behavior": [ + "Create the automation before user approval.", + "Write an automation TOML file directly." + ] + }, + { + "id": "existing-install-automation-reuse", + "prompt": "Install or upgrade the Skill when an active automation with the stable marker already exists.", + "expected_behavior": [ + "Detect the active matching marker before prompting.", + "Return already_installed and reuse or update the matching automation when configuration changes.", + "Avoid creating another scheduled task." + ], + "forbidden_behavior": [ + "Prompt as though no automation exists.", + "Create a duplicate task with the same purpose." + ] + }, + { + "id": "declined-install-automation-decision", + "prompt": "The user declines the required automation during Skill onboarding.", + "expected_behavior": [ + "Record an explicit decline for the current configuration version.", + "Treat verification as complete for that version and expose a reset path.", + "Continue to allow manual daily management modes." + ], + "forbidden_behavior": [ + "Install the automation after the decline.", + "Repeat the same prompt on every invocation without a configuration change." + ] + }, + { + "id": "automation-config-upgrade-reprompt", + "prompt": "A later Skill release changes the required automation configuration version.", + "expected_behavior": [ + "Treat an older decline or stale install record as belonging to the previous configuration version.", + "Inspect existing marker-bearing automations and propose an update through the Codex automation API.", + "Ask for approval when the schedule or behavior materially changes." + ], + "forbidden_behavior": [ + "Silently change the scheduled task after a material configuration update.", + "Reuse an old decline as permanent consent for every future configuration." + ] } ] } diff --git a/references/daily-operations.md b/references/daily-operations.md index ddd1b6f..dafd5c6 100644 --- a/references/daily-operations.md +++ b/references/daily-operations.md @@ -160,6 +160,28 @@ checkpoint_path: optional consumer-workspace path 4. 来源未就绪:使用 `phase_status=waiting_input`,保留 `completed_phase` 的上一成功值。 5. 发布超时且结果未知:先查询目标会话和幂等 key,确认未发送后才重试。 +## 安装时定时任务 + +当前必要的默认自动化只有一条: + +- 名称:`源子开发任务|工作日10:45会后校准` +- 时间:工作日 10:45,使用用户本地时区;本 Skill 默认 `Asia/Shanghai` +- 模式:`post_standup` +- 策略:`run_kind=scheduled`、`mutation_policy=proposal_only`、`publication_policy=private_draft` +- 稳定标识:`metainflow-dev-tasks:post_standup:weekday-1045:v1` + +安装、升级或当前配置版本的首次调用时,运行 `python3 scripts/configure_daily_automation.py plan --json`。 + +- `already_installed`:同 marker 且状态为 `ACTIVE` 的任务已存在,完成查重后继续。 +- `prompt_required`:向用户展示时间、任务范围、安全边界和目标工作区,请用户选择安装或跳过。 +- `declined`:用户已对当前配置版本明确跳过,不重复提示;运行 `reset --json` 可重新开启。 + +用户批准安装后,Agent 先用 Codex 项目列表解析目标工作区,然后调用 Codex automation API。检测到同 marker 任务时更新或复用,新任务默认为 `ACTIVE`。创建或更新成功后记录 automation ID、项目 ID、时区和配置版本,再运行 `verify --json`。 + +初始化脚本只允许读取现有 automation TOML 用于检测 marker 和状态,禁止直接写入 automation TOML。新增、更新和删除必须经过 Codex automation API。 + +Codex 当前无原生 `SkillInstalled` 事件。wrapper onboarding initialization 负责安装主流程,Skill 首次调用负责补偿检测。 + ## 定时调用适配器 每条 scheduled 自动化的 prompt 至少要表达: @@ -176,11 +198,7 @@ Resume from the daily checkpoint. Read the required evidence for this phase, write the checkpoint, and return the phase output. Do not publish to a group. ``` -建议使用三个触发器: - -- 工作日会前:`pre_standup`;周一增加本周里程碑建立。 -- 工作日会后:`post_standup`。 -- 工作日日终:`eod_close`;周五增加本周里程碑收口。 +安装阶段只提示必要的工作日 10:45 `post_standup` 任务。`pre_standup` 和 `eod_close` 保留为人工或可选自动化入口,不在默认安装中追加。 具体时间由运行环境配置。缺失早会纪要、日计划或会议延迟时,自动化应返回等待条件和建议重试时间,不进入写入或群发布。 diff --git a/scripts/configure_daily_automation.py b/scripts/configure_daily_automation.py new file mode 100644 index 0000000..2fd431e --- /dev/null +++ b/scripts/configure_daily_automation.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +"""Plan and verify Codex automation onboarding for metainflow-dev-tasks.""" + +from __future__ import annotations + +import argparse +import json +import os +import tempfile +import tomllib +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +SKILL_NAME = "metainflow-dev-tasks" +CONFIG_VERSION = 1 +MARKER = "metainflow-dev-tasks:post_standup:weekday-1045:v1" +AUTOMATION_NAME = "源子开发任务|工作日10:45会后校准" +DEFAULT_TIMEZONE = "Asia/Shanghai" +WEEKDAYS = ["MO", "TU", "WE", "TH", "FR"] +AUTOMATION_PROMPT_TEMPLATE = f"""Use [$metainflow-dev-tasks]({{skill_path}}) to run the daily project-management loop. + +{MARKER} + +Run mode=post_standup, run_kind=scheduled, mutation_policy=proposal_only, and publication_policy=private_draft. Use the current local date as cycle_date and resume the daily checkpoint. + +Review the live task-system schema and records, the task arrangement for every relevant project and workstream, the current daily plan, the previous-working-day and current project communication between the manager and development team, project and development groups, the latest standup record, work logs, delivery evidence, leave, testing, review, and acceptance responsibilities. Opportunity projects must be checked against their project-specific plan or scope source. + +Produce: source coverage and gaps; project milestone health and variance; proposed task-state, owner, deadline, or mapping changes; orphan tasks and pending_sync ownership; the project-weekly-milestone to personal-weekly-outcome to daily-task map; workload conflicts; and a private message-card draft using the last confirmed visual style. Keep every proposed mutation uniquely identified with old value, new value, reason, evidence reference, and schedule impact. + +Do not write task-system changes, mark completion or acceptance, change a baseline, or publish to a group. End with a concise inbox item stating whether the draft is ready, waiting for evidence, or needs a management decision.""" + + +def codex_home() -> Path: + raw = os.environ.get("CODEX_HOME") + return Path(raw).expanduser() if raw else Path.home() / ".codex" + + +def state_path(home: Path) -> Path: + return home / "skill-onboarding" / SKILL_NAME / f"post-standup-v{CONFIG_VERSION}.json" + + +def automation_spec() -> dict[str, Any]: + skill_path = Path(__file__).resolve().parents[1] / "SKILL.md" + return { + "name": AUTOMATION_NAME, + "marker": MARKER, + "config_version": CONFIG_VERSION, + "kind": "cron", + "local_time": "10:45", + "weekdays": WEEKDAYS, + "default_timezone": DEFAULT_TIMEZONE, + "mode": "post_standup", + "run_kind": "scheduled", + "mutation_policy": "proposal_only", + "publication_policy": "private_draft", + "status": "ACTIVE", + "prompt": AUTOMATION_PROMPT_TEMPLATE.format(skill_path=skill_path), + "creation_boundary": "Use the Codex automation API; do not write automation TOML directly.", + } + + +def read_json(path: Path) -> dict[str, Any] | None: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + return data if isinstance(data, dict) else None + + +def atomic_write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + handle = tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) + temp_path = Path(handle.name) + try: + with handle: + json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True) + handle.write("\n") + os.replace(temp_path, path) + finally: + if temp_path.exists(): + temp_path.unlink() + + +def nested_status(value: Any) -> str | None: + if isinstance(value, dict): + for key, child in value.items(): + if str(key).lower() == "status" and isinstance(child, str): + return child + for child in value.values(): + found = nested_status(child) + if found: + return found + elif isinstance(value, list): + for child in value: + found = nested_status(child) + if found: + return found + return None + + +def matching_automations(home: Path) -> list[dict[str, str]]: + matches: list[dict[str, str]] = [] + root = home / "automations" + if not root.is_dir(): + return matches + for path in sorted(root.glob("*/automation.toml")): + try: + raw = path.read_text(encoding="utf-8") + except OSError: + continue + if MARKER not in raw: + continue + try: + parsed = tomllib.loads(raw) + except tomllib.TOMLDecodeError: + parsed = {} + status = (nested_status(parsed) or "UNKNOWN").upper() + matches.append( + { + "automation_id": path.parent.name, + "status": status, + "path": str(path), + } + ) + return matches + + +def active_matches(home: Path) -> list[dict[str, str]]: + return [item for item in matching_automations(home) if item["status"] == "ACTIVE"] + + +def base_payload(status: str, home: Path) -> dict[str, Any]: + return { + "skill": SKILL_NAME, + "status": status, + "config_version": CONFIG_VERSION, + "automation": automation_spec(), + "state_path": str(state_path(home)), + } + + +def plan(home: Path) -> tuple[dict[str, Any], int]: + matches = active_matches(home) + if matches: + payload = base_payload("already_installed", home) + payload["matches"] = matches + payload["prompt_user"] = False + return payload, 0 + + recorded = read_json(state_path(home)) + if recorded and recorded.get("config_version") == CONFIG_VERSION and recorded.get("decision") == "declined": + payload = base_payload("declined", home) + payload["decision"] = recorded + payload["prompt_user"] = False + payload["reset_command"] = "python3 scripts/configure_daily_automation.py reset --json" + return payload, 0 + + payload = base_payload("prompt_required", home) + payload["prompt_user"] = True + payload["prompt"] = ( + "建议安装“源子开发任务|工作日10:45会后校准”定时任务。" + "它会在工作日早会后检查项目、任务、日计划、聊天和交付证据," + "只生成拟变更和私下卡片草稿。是否安装?" + ) + payload["required_action"] = "Ask the user, then use the Codex automation API or record an explicit decline." + return payload, 0 + + +def record(home: Path, args: argparse.Namespace) -> tuple[dict[str, Any], int]: + if args.decision == "installed" and (not args.automation_id or not args.project_id): + return { + "skill": SKILL_NAME, + "status": "invalid_record", + "error": "installed decisions require --automation-id and --project-id", + }, 2 + payload = { + "skill": SKILL_NAME, + "config_version": CONFIG_VERSION, + "marker": MARKER, + "decision": args.decision, + "recorded_at": datetime.now(timezone.utc).isoformat(), + "automation_id": args.automation_id, + "project_id": args.project_id, + "timezone": args.timezone or DEFAULT_TIMEZONE, + } + atomic_write_json(state_path(home), payload) + result = base_payload(args.decision, home) + result["decision"] = payload + return result, 0 + + +def verify(home: Path) -> tuple[dict[str, Any], int]: + matches = active_matches(home) + if matches: + payload = base_payload("installed", home) + payload["matches"] = matches + return payload, 0 + recorded = read_json(state_path(home)) + if recorded and recorded.get("config_version") == CONFIG_VERSION: + if recorded.get("decision") == "declined": + payload = base_payload("declined", home) + payload["decision"] = recorded + return payload, 0 + if recorded.get("decision") == "installed": + payload = base_payload("recorded_automation_missing", home) + payload["decision"] = recorded + payload["required_action"] = "Inspect or recreate the automation through the Codex automation API." + return payload, 2 + payload = base_payload("prompt_required", home) + payload["required_action"] = "Run plan and complete the install-or-decline prompt." + return payload, 2 + + +def reset(home: Path) -> tuple[dict[str, Any], int]: + path = state_path(home) + removed = path.exists() + try: + path.unlink() + except FileNotFoundError: + pass + payload = base_payload("reset", home) + payload["removed"] = removed + return payload, 0 + + +def add_json_flag(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--json", action="store_true", help="Emit structured JSON.") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + plan_parser = subparsers.add_parser("plan", help="Inspect onboarding state and return the next action.") + add_json_flag(plan_parser) + + record_parser = subparsers.add_parser("record", help="Record an install or decline decision.") + record_parser.add_argument("--decision", choices=["installed", "declined"], required=True) + record_parser.add_argument("--automation-id") + record_parser.add_argument("--project-id") + record_parser.add_argument("--timezone") + add_json_flag(record_parser) + + verify_parser = subparsers.add_parser("verify", help="Verify automation installation or explicit decline.") + add_json_flag(verify_parser) + + reset_parser = subparsers.add_parser("reset", help="Clear the recorded onboarding decision.") + add_json_flag(reset_parser) + return parser + + +def emit(payload: dict[str, Any], as_json: bool) -> None: + if as_json: + print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)) + return + print(f"{payload['status']}: {AUTOMATION_NAME}") + if payload.get("prompt"): + print(payload["prompt"]) + if payload.get("required_action"): + print(payload["required_action"]) + + +def main() -> int: + args = build_parser().parse_args() + home = codex_home() + if args.command == "plan": + payload, code = plan(home) + elif args.command == "record": + payload, code = record(home, args) + elif args.command == "verify": + payload, code = verify(home) + else: + payload, code = reset(home) + emit(payload, args.json) + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_automation_onboarding.py b/tests/test_automation_onboarding.py new file mode 100644 index 0000000..c835f05 --- /dev/null +++ b/tests/test_automation_onboarding.py @@ -0,0 +1,100 @@ +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "configure_daily_automation.py" +MARKER = "metainflow-dev-tasks:post_standup:weekday-1045:v1" + + +class AutomationOnboardingTests(unittest.TestCase): + def run_helper(self, codex_home: Path, *args: str, expected_code: int = 0): + env = os.environ.copy() + env["CODEX_HOME"] = str(codex_home) + result = subprocess.run( + [sys.executable, str(SCRIPT), *args, "--json"], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + ) + self.assertEqual(expected_code, result.returncode, result.stderr or result.stdout) + return json.loads(result.stdout) + + def write_matching_automation(self, codex_home: Path, status: str = "ACTIVE") -> None: + target = codex_home / "automations" / "synthetic-post-standup" + target.mkdir(parents=True, exist_ok=True) + (target / "automation.toml").write_text( + "\n".join( + [ + 'name = "Synthetic daily management"', + f'status = "{status}"', + f'prompt = "{MARKER}"', + ] + ), + encoding="utf-8", + ) + + def test_clean_install_requires_prompt(self): + with tempfile.TemporaryDirectory() as temp_dir: + payload = self.run_helper(Path(temp_dir), "plan") + self.assertEqual("prompt_required", payload["status"]) + self.assertEqual("10:45", payload["automation"]["local_time"]) + self.assertEqual(["MO", "TU", "WE", "TH", "FR"], payload["automation"]["weekdays"]) + self.assertEqual(MARKER, payload["automation"]["marker"]) + self.assertEqual("proposal_only", payload["automation"]["mutation_policy"]) + self.assertEqual("private_draft", payload["automation"]["publication_policy"]) + self.assertIn(str(ROOT / "SKILL.md"), payload["automation"]["prompt"]) + + def test_matching_active_automation_is_reused(self): + with tempfile.TemporaryDirectory() as temp_dir: + codex_home = Path(temp_dir) + self.write_matching_automation(codex_home) + payload = self.run_helper(codex_home, "plan") + verified = self.run_helper(codex_home, "verify") + self.assertEqual("already_installed", payload["status"]) + self.assertEqual("installed", verified["status"]) + + def test_decline_is_recorded_and_resettable(self): + with tempfile.TemporaryDirectory() as temp_dir: + codex_home = Path(temp_dir) + recorded = self.run_helper(codex_home, "record", "--decision", "declined") + planned = self.run_helper(codex_home, "plan") + verified = self.run_helper(codex_home, "verify") + reset = self.run_helper(codex_home, "reset") + missing = self.run_helper(codex_home, "verify", expected_code=2) + self.assertEqual("declined", recorded["status"]) + self.assertEqual("declined", planned["status"]) + self.assertEqual("declined", verified["status"]) + self.assertEqual("reset", reset["status"]) + self.assertEqual("prompt_required", missing["status"]) + + def test_recorded_install_must_still_be_detectable(self): + with tempfile.TemporaryDirectory() as temp_dir: + codex_home = Path(temp_dir) + self.run_helper( + codex_home, + "record", + "--decision", + "installed", + "--automation-id", + "synthetic-id", + "--project-id", + "synthetic-project", + "--timezone", + "Asia/Shanghai", + ) + missing = self.run_helper(codex_home, "verify", expected_code=2) + self.write_matching_automation(codex_home) + verified = self.run_helper(codex_home, "verify") + self.assertEqual("recorded_automation_missing", missing["status"]) + self.assertEqual("installed", verified["status"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_business_contracts.py b/tests/test_business_contracts.py index 7110d58..e29c69a 100644 --- a/tests/test_business_contracts.py +++ b/tests/test_business_contracts.py @@ -10,6 +10,7 @@ SCHEMA_PATH = ROOT / "references" / "schema.md" EVALS_PATH = ROOT / "evals" / "evals.json" CHANGELOG_PATH = ROOT / ".evozeus-wrapper" / "CHANGELOG.md" +WRAPPER_PATH = ROOT / ".evozeus-wrapper" / "wrapper.json" class BusinessContractTests(unittest.TestCase): @@ -21,6 +22,7 @@ def setUpClass(cls): cls.schema = SCHEMA_PATH.read_text(encoding="utf-8") cls.evals = json.loads(EVALS_PATH.read_text(encoding="utf-8")) cls.changelog = CHANGELOG_PATH.read_text(encoding="utf-8") + cls.wrapper = json.loads(WRAPPER_PATH.read_text(encoding="utf-8")) def test_skill_routes_to_management_contract(self): self.assertIn("references/management-contracts.md", self.skill) @@ -74,6 +76,25 @@ def test_scheduled_runs_are_proposal_only_and_private_draft(self): self.assertIn(term, self.daily_operations) self.assertIn("定时调用", self.skill) + def test_installation_requires_post_standup_automation_decision(self): + initialization = self.wrapper["onboarding"]["initialization"] + self.assertTrue(initialization["required"]) + self.assertIn("configure_daily_automation.py plan", initialization["command"]) + self.assertIn("configure_daily_automation.py verify", initialization["verification"]) + for term in ["安装初始化", "10:45", "post_standup", "提示安装", "首次调用"]: + self.assertIn(term, self.skill) + + def test_installation_contract_has_stable_marker_and_api_boundary(self): + for term in [ + "metainflow-dev-tasks:post_standup:weekday-1045:v1", + "工作日 10:45", + "Codex automation API", + "proposal_only", + "private_draft", + "禁止直接写入", + ]: + self.assertIn(term, self.daily_operations) + def test_live_schema_has_a_structured_fallback(self): self.assertIn("[管理契约]", self.schema) for term in ["唯一任务身份", "个人周产出", "产出载体", "来源参考", "不得覆盖原备注"]: @@ -101,6 +122,10 @@ def test_eval_suite_covers_required_scenarios(self): "post-standup-proposed-mutations", "end-of-day-evidence-closure", "idempotent-scheduled-rerun", + "fresh-install-automation-prompt", + "existing-install-automation-reuse", + "declined-install-automation-decision", + "automation-config-upgrade-reprompt", } self.assertEqual(expected, ids) for item in self.evals["evals"]: