From 344db3f47fafa9e23c174e307321598474711d82 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Tue, 17 Mar 2026 16:47:32 +0900 Subject: [PATCH 1/3] feat(workflow): add per-repository workflow override support Implement a two-layer workflow resolution system where a service-level WORKFLOW.md defines global defaults and individual repositories can provide their own WORKFLOW.md to override agent behaviour on a per-repo basis. Key changes: - types.ts: add RepoOverridesConfig interface - workflow.ts: add loadRepoWorkflow() and mergeWorkflows() functions - workflow.test.ts: 13 new tests for merge logic and repo workflow loading - config.ts: add parseRepoOverridesSetting() to read repo_overrides config - config.test.ts: 7 new tests for repo overrides config parsing - orchestrator.ts: add resolveEffectiveWorkflow() and resolveEffectiveConfig(); update executeAgentRun() and runAgentTurns() to use per-issue effective workflow - ARCHITECTURE.md: document two-layer workflow resolution in Configuration section - README.md / README.ko.md / README.ja.md / README.zh-CN.md: add repo_overrides schema, override rules, security boundary, and "Per-Repository Workflow Override" section across all four translations Override rules: - agent_prompt, tools, and max_turns are overridable per repository - Service-level sections (tracker, hooks, workspace) are never overridable - Feature is opt-in via repo_overrides: true in the service WORKFLOW.md --- ARCHITECTURE.md | 22 ++-- README.ja.md | 104 +++++++++++++++ README.ko.md | 103 +++++++++++++++ README.md | 104 +++++++++++++++ README.zh-CN.md | 102 +++++++++++++++ apps/work-please/src/config.test.ts | 50 ++++++- apps/work-please/src/config.ts | 25 +++- apps/work-please/src/orchestrator.ts | 66 ++++++++-- apps/work-please/src/types.ts | 5 + apps/work-please/src/workflow.test.ts | 182 +++++++++++++++++++++++++- apps/work-please/src/workflow.ts | 92 +++++++++++++ 11 files changed, 829 insertions(+), 26 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 26d8a6a3..21c2621a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -38,7 +38,7 @@ work-please/ # Monorepo root (Bun + Turborepo) │ ├── cli.ts # CLI parsing and startup (Commander) │ ├── orchestrator.ts # Core loop: poll → reconcile → dispatch → retry │ ├── config.ts # YAML front matter → typed ServiceConfig with env-var resolution -│ ├── workflow.ts # WORKFLOW.md parser (YAML front matter + Liquid body) +│ ├── workflow.ts # WORKFLOW.md parser + repo override merge (YAML front matter + Liquid body) │ ├── prompt-builder.ts # Liquid template rendering (issue → prompt string) │ ├── agent-runner.ts # Claude Code agent session via @anthropic-ai/claude-agent-sdk │ ├── workspace.ts # Per-issue directory management, git worktrees, lifecycle hooks @@ -195,13 +195,19 @@ simple for daemon operation. ### Configuration -Single-file configuration via `WORKFLOW.md` in the target repository: - -- **YAML front matter** — Tracker connection, polling interval, workspace root, hooks, agent - limits, Claude CLI settings -- **Liquid template body** — Prompt template rendered with issue context variables -- **Live reload** — File watcher triggers re-parse; invalid configs are rejected with the last - known-good config retained +Two-layer configuration via `WORKFLOW.md`: + +- **Global WORKFLOW.md** (operator) — Defines service-level settings (tracker, polling, workspace) + and default agent config. Read at startup and watched for live reload. +- **Repo WORKFLOW.md** (target repository, optional) — Overrides agent-level settings (`agent`, + `claude`, `hooks.before_run`, `hooks.after_run`, `env`) and prompt template for issues from that + repo. Only read when `repo_overrides: true` is set in the global workflow. Service-level sections + are never overridable (security boundary). +- **Merge semantics** — Allowed config sections are deep-merged (repo values win); prompt template + is replaced if repo provides a non-empty one. Merge happens per-issue at dispatch time via + `resolveEffectiveWorkflow()` in the orchestrator. +- **Live reload** — File watcher triggers re-parse of the global workflow; invalid configs are + rejected with the last known-good config retained. ### Authentication diff --git a/README.ja.md b/README.ja.md index 00379efd..008fc0f6 100644 --- a/README.ja.md +++ b/README.ja.md @@ -23,6 +23,10 @@ Work Pleaseはイシュートラッカーのタスクを隔離された自律的 - [WORKFLOW.md設定](#workflowmd設定) - [完全なFront Matterスキーマ](#完全なfront-matterスキーマ) - [テンプレート変数](#テンプレート変数) +- [リポジトリごとのワークフローオーバーライド](#リポジトリごとのワークフローオーバーライド) + - [仕組み](#仕組み) + - [オーバーライドルール](#オーバーライドルール) + - [例](#例) - [CLI使用方法](#cli使用方法) - [GitHub App認証](#github-app認証) - [GitHub App資格情報の設定](#github-app資格情報の設定) @@ -75,6 +79,10 @@ GitHub Projects v2 / AsanaおよびClaude Codeに適応されています(Line - **動的設定リロード** — `WORKFLOW.md`を編集すると、サービス再起動なしで変更が適用されます。 - **ワークスペースフック** — `after_create`、`before_run`、`after_run`、`before_remove`ライフサイクル イベントでシェルスクリプトを実行します。 +- **リポジトリごとのワークフローオーバーライド** — ターゲットリポジトリが独自の`WORKFLOW.md`を + 提供して、エージェント設定とプロンプトテンプレートをカスタマイズできます。グローバルワークフローで + `repo_overrides: true`で有効化します。サービスレベルの設定(tracker、polling、workspace)は + オーバーライドされません。 - **構造化ロギング** — 安定した`key=value`形式のオペレーター可視ログを提供します。 - **オプションHTTPダッシュボード** — `--port`で有効化して、ランタイム状態とJSON APIを確認できます。 @@ -455,6 +463,12 @@ claude: commit: "🙏 Generated with [Work Please](https://github.com/pleaseai/work-please)" # 任意: gitコミットメッセージに追加。デフォルトはWork Pleaseリンク。 pr: "🙏 Generated with [Work Please](https://github.com/pleaseai/work-please)" # 任意: PR説明に追加。デフォルトはWork Pleaseリンク。 +repo_overrides: true # 任意: ターゲットリポジトリが独自のWORKFLOW.mdでワークフローをオーバーライドできるようにする。 + # デフォルト: false(リポジトリのWORKFLOW.mdファイルは無視される)。 + # きめ細かい制御のためにオブジェクトも使用可能: + # repo_overrides: + # allow: [agent, claude, env, hooks] # リポジトリがオーバーライドできるセクションを制限 + server: port: 3000 # 任意: このポートでHTTPダッシュボードを有効化 --- @@ -510,6 +524,96 @@ server: {% endif %} ``` +## リポジトリごとのワークフローオーバーライド + +複数のリポジトリにまたがるGitHub Projects v2プロジェクトを管理する場合、各ターゲットリポジトリが +独自の`WORKFLOW.md`を提供して、グローバル設定を変更せずにエージェントの動作をカスタマイズできます。 + +### 仕組み + +1. オペレーターのグローバル`WORKFLOW.md`がサービスを起動し、サービスレベルの設定(tracker、polling、 + workspace)を定義します。この機能を有効にするには`repo_overrides: true`を含める必要があります。 +2. イシューがディスパッチされると、Work Pleaseはグローバル設定を使用してワークスペース + (クローン/ワークツリー)を作成します。 +3. ワークスペースの準備が完了すると、Work Pleaseはリポジトリルートの`WORKFLOW.md`を確認します。 +4. 見つかった場合、リポジトリの設定セクションがグローバル設定にディープマージされ(許可されたセクション + のみ)、リポジトリのプロンプトテンプレートがグローバルのものを置き換えます(空でない場合)。 +5. 有効な(マージされた)ワークフローがエージェントセッションに使用されます。 + +### オーバーライドルール + +| セクション | オーバーライド可能 | 理由 | +|-----------|-------------------|------| +| `tracker` | 不可 | サービス資格情報 — セキュリティ境界 | +| `polling` | 不可 | サービスレベルの関心事 | +| `workspace` | 不可 | セキュリティ境界(パストラバーサル) | +| `server` | 不可 | サービスレベルの関心事 | +| `agent` | **可能** | `max_turns`、リトライ、同時実行数 | +| `claude` | **可能** | `model`、`effort`、`allowed_tools`、`system_prompt`、`permission_mode` | +| `hooks.before_run` | **可能** | リポジトリごとのエージェント前セットアップ | +| `hooks.after_run` | **可能** | リポジトリごとのエージェント後クリーンアップ | +| `hooks.after_create` | 不可 | リポジトリWORKFLOW.mdが利用可能になる前に実行される | +| `hooks.before_remove` | 不可 | ワークスペースライフサイクル、エージェントの関心事ではない | +| `env` | **可能** | エージェント用の追加環境変数 | +| プロンプトテンプレート | **可能** | リポジトリごとのプロンプトカスタマイズ | + +きめ細かい形式でリポジトリがオーバーライドできるセクションを制限できます: + +```yaml +repo_overrides: + allow: [agent, claude, env] # hooks除外 +``` + +### 例 + +**グローバルWORKFLOW.md(オペレーター):** + +```yaml +--- +tracker: + kind: github_projects + api_key: $GITHUB_TOKEN + owner: myorg + project_number: 5 +repo_overrides: true +agent: + max_turns: 20 +claude: + effort: high +--- +全リポジトリのデフォルトプロンプト... +{{ issue.title }} +``` + +**ターゲットリポジトリのWORKFLOW.md(リポジトリチーム):** + +```yaml +--- +agent: + max_turns: 40 +claude: + model: claude-sonnet-4-20250514 + effort: max +env: + DATABASE_URL: $DATABASE_URL +--- +{{ issue.identifier }}に取り組むバックエンドスペシャリストです。 + +注力事項: +- データベースマイグレーション +- APIエンドポイントの実装 +{{ issue.description }} +``` + +**そのリポジトリのイシューに対する有効な結果:** + +- `tracker` — グローバルから(オーバーライド不可) +- `agent.max_turns` — 40(リポジトリから) +- `claude.model` — `claude-sonnet-4-20250514`(リポジトリから) +- `claude.effort` — `max`(リポジトリから) +- `env.DATABASE_URL` — `$DATABASE_URL`から解決(リポジトリから) +- プロンプトテンプレート — リポジトリのカスタムテンプレート + ## CLI使用方法 ```bash diff --git a/README.ko.md b/README.ko.md index 26da4be4..17e923a0 100644 --- a/README.ko.md +++ b/README.ko.md @@ -23,6 +23,10 @@ Work Please는 이슈 트래커의 작업을 격리된 자율 구현 실행으 - [WORKFLOW.md 설정](#workflowmd-설정) - [전체 Front Matter 스키마](#전체-front-matter-스키마) - [템플릿 변수](#템플릿-변수) +- [저장소별 워크플로우 오버라이드](#저장소별-워크플로우-오버라이드) + - [동작 방식](#동작-방식) + - [오버라이드 규칙](#오버라이드-규칙) + - [예시](#예시) - [CLI 사용법](#cli-사용법) - [GitHub App 인증](#github-app-인증) - [GitHub App 자격 증명 설정](#github-app-자격-증명-설정) @@ -75,6 +79,9 @@ GitHub Projects v2 / Asana 및 Claude Code에 맞게 적용되었습니다 (Line - **동적 설정 리로드** — `WORKFLOW.md`를 편집하면 서비스 재시작 없이 변경사항이 적용됩니다. - **워크스페이스 훅** — `after_create`, `before_run`, `after_run`, `before_remove` 라이프사이클 이벤트에서 셸 스크립트를 실행합니다. +- **저장소별 워크플로우 오버라이드** — 대상 저장소가 자체 `WORKFLOW.md`를 제공하여 에이전트 설정과 + 프롬프트 템플릿을 커스터마이즈할 수 있습니다. 글로벌 워크플로우에서 `repo_overrides: true`로 + 활성화합니다. 서비스 수준 설정(tracker, polling, workspace)은 절대 오버라이드되지 않습니다. - **구조화된 로깅** — 안정적인 `key=value` 형식의 운영자 가시 로그를 제공합니다. - **선택적 HTTP 대시보드** — `--port`로 활성화하여 런타임 상태와 JSON API를 확인할 수 있습니다. @@ -455,6 +462,12 @@ claude: commit: "🙏 Generated with [Work Please](https://github.com/pleaseai/work-please)" # 선택: git 커밋 메시지에 추가. 기본값은 Work Please 링크. pr: "🙏 Generated with [Work Please](https://github.com/pleaseai/work-please)" # 선택: PR 설명에 추가. 기본값은 Work Please 링크. +repo_overrides: true # 선택: 대상 저장소가 자체 WORKFLOW.md로 워크플로우를 오버라이드할 수 있도록 허용. + # 기본값: false (저장소 WORKFLOW.md 파일은 무시됨). + # 세분화된 제어를 위해 객체로도 설정 가능: + # repo_overrides: + # allow: [agent, claude, env, hooks] # 저장소가 오버라이드할 수 있는 섹션 제한 + server: port: 3000 # 선택: 이 포트에서 HTTP 대시보드 활성화 --- @@ -510,6 +523,96 @@ server: {% endif %} ``` +## 저장소별 워크플로우 오버라이드 + +여러 저장소에 걸쳐 있는 GitHub Projects v2 프로젝트를 관리할 때, 각 대상 저장소가 자체 +`WORKFLOW.md`를 제공하여 에이전트 동작을 커스터마이즈할 수 있습니다 — 글로벌 설정을 변경하지 않고도 +가능합니다. + +### 동작 방식 + +1. 운영자의 글로벌 `WORKFLOW.md`가 서비스를 시작하고 서비스 수준 설정(tracker, polling, workspace)을 + 정의합니다. 이 기능을 활성화하려면 `repo_overrides: true`를 포함해야 합니다. +2. 이슈가 디스패치되면, Work Please는 글로벌 설정을 사용하여 워크스페이스(클론/워크트리)를 생성합니다. +3. 워크스페이스가 준비되면, Work Please는 저장소 루트에서 `WORKFLOW.md`를 확인합니다. +4. 발견되면, 저장소의 설정 섹션이 글로벌 설정에 deep-merge되고(허용된 섹션만), 저장소의 프롬프트 + 템플릿이 글로벌 템플릿을 대체합니다(비어있지 않은 경우). +5. 유효한(병합된) 워크플로우가 에이전트 세션에 사용됩니다. + +### 오버라이드 규칙 + +| 섹션 | 오버라이드 가능 | 이유 | +|------|----------------|------| +| `tracker` | 불가 | 서비스 자격 증명 — 보안 경계 | +| `polling` | 불가 | 서비스 수준 관심사 | +| `workspace` | 불가 | 보안 경계 (경로 탐색) | +| `server` | 불가 | 서비스 수준 관심사 | +| `agent` | **가능** | `max_turns`, 재시도, 동시성 | +| `claude` | **가능** | `model`, `effort`, `allowed_tools`, `system_prompt`, `permission_mode` | +| `hooks.before_run` | **가능** | 저장소별 에이전트 전 설정 | +| `hooks.after_run` | **가능** | 저장소별 에이전트 후 정리 | +| `hooks.after_create` | 불가 | 저장소 WORKFLOW.md를 사용할 수 있기 전에 실행됨 | +| `hooks.before_remove` | 불가 | 워크스페이스 라이프사이클, 에이전트 관심사 아님 | +| `env` | **가능** | 에이전트용 추가 환경 변수 | +| 프롬프트 템플릿 | **가능** | 저장소별 프롬프트 커스터마이즈 | + +세분화된 형태로 저장소가 오버라이드할 수 있는 섹션을 제한할 수 있습니다: + +```yaml +repo_overrides: + allow: [agent, claude, env] # hooks 제외 +``` + +### 예시 + +**글로벌 WORKFLOW.md (운영자):** + +```yaml +--- +tracker: + kind: github_projects + api_key: $GITHUB_TOKEN + owner: myorg + project_number: 5 +repo_overrides: true +agent: + max_turns: 20 +claude: + effort: high +--- +모든 저장소의 기본 프롬프트... +{{ issue.title }} +``` + +**대상 저장소의 WORKFLOW.md (저장소 팀):** + +```yaml +--- +agent: + max_turns: 40 +claude: + model: claude-sonnet-4-20250514 + effort: max +env: + DATABASE_URL: $DATABASE_URL +--- +{{ issue.identifier }}에 대해 작업하는 백엔드 전문가입니다. + +집중 사항: +- 데이터베이스 마이그레이션 +- API 엔드포인트 구현 +{{ issue.description }} +``` + +**해당 저장소의 이슈에 대한 유효 결과:** + +- `tracker` — 글로벌에서 (오버라이드 불가) +- `agent.max_turns` — 40 (저장소에서) +- `claude.model` — `claude-sonnet-4-20250514` (저장소에서) +- `claude.effort` — `max` (저장소에서) +- `env.DATABASE_URL` — `$DATABASE_URL`에서 해석됨 (저장소에서) +- 프롬프트 템플릿 — 저장소의 커스텀 템플릿 + ## CLI 사용법 ```bash diff --git a/README.md b/README.md index 4f9de5ec..313cb87e 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ instead of supervising coding agents. - [WORKFLOW.md Configuration](#workflowmd-configuration) - [Full Front Matter Schema](#full-front-matter-schema) - [Template Variables](#template-variables) +- [Per-Repository Workflow Override](#per-repository-workflow-override) + - [How It Works](#how-it-works) + - [Override Rules](#override-rules) + - [Example](#example) - [CLI Usage](#cli-usage) - [GitHub App Authentication](#github-app-authentication) - [Setting up GitHub App credentials](#setting-up-github-app-credentials) @@ -79,6 +83,9 @@ For full technical details, see [SPEC.md](SPEC.md). - **Dynamic config reload** — Edit `WORKFLOW.md` and changes apply without restarting the service. - **Workspace hooks** — Shell scripts run at `after_create`, `before_run`, `after_run`, and `before_remove` lifecycle events. +- **Per-repository workflow override** — Target repositories can provide their own `WORKFLOW.md` + to customize agent config and prompt template. Opt-in via `repo_overrides: true` in the global + workflow. Service-level settings (tracker, polling, workspace) are never overridden. - **Structured logging** — Operator-visible logs with stable `key=value` format. - **Optional HTTP dashboard** — Enable with `--port` for runtime status and JSON API. @@ -473,6 +480,12 @@ claude: # refresh_ms: 1000 # Dashboard data refresh interval, default 1000 # render_interval_ms: 16 # TUI render interval, default 16 +repo_overrides: true # Optional: allow target repos to override workflow via their own WORKFLOW.md. + # Default: false (repo WORKFLOW.md files are ignored). + # Can also be an object for granular control: + # repo_overrides: + # allow: [agent, claude, env, hooks] # restrict which sections repos can override + server: port: 3000 # Optional: enable HTTP dashboard on this port host: "127.0.0.1" # Optional: bind address, default "127.0.0.1" @@ -536,6 +549,97 @@ Retry attempt: {{ attempt }} {% endif %} ``` +## Per-Repository Workflow Override + +When managing a GitHub Projects v2 project that spans multiple repositories, each target repository +can provide its own `WORKFLOW.md` to customize agent behavior — without changing the global +configuration. + +### How It Works + +1. The operator's global `WORKFLOW.md` starts the service and defines service-level settings + (tracker, polling, workspace). It must include `repo_overrides: true` to enable this feature. +2. When an issue is dispatched, Work Please creates a workspace (clone/worktree) using the global + config. +3. After the workspace is ready, Work Please checks for a `WORKFLOW.md` in the repository root. +4. If found, the repo's config sections are deep-merged over the global config (allowed sections + only), and the repo's prompt template replaces the global one (if non-empty). +5. The effective (merged) workflow is used for the agent session. + +### Override Rules + +| Section | Overridable | Reason | +|---------|-------------|--------| +| `tracker` | No | Service credentials — security boundary | +| `polling` | No | Service-level concern | +| `workspace` | No | Security boundary (path traversal) | +| `server` | No | Service-level concern | +| `agent` | **Yes** | `max_turns`, retry, concurrency | +| `claude` | **Yes** | `model`, `effort`, `allowed_tools`, `system_prompt`, `permission_mode` | +| `hooks.before_run` | **Yes** | Per-repo pre-agent setup | +| `hooks.after_run` | **Yes** | Per-repo post-agent cleanup | +| `hooks.after_create` | No | Runs before repo WORKFLOW.md is available | +| `hooks.before_remove` | No | Workspace lifecycle, not agent concern | +| `env` | **Yes** | Additional env vars for agent | +| Prompt template | **Yes** | Per-repo prompt customization | + +Use the granular form to restrict which sections repos can override: + +```yaml +repo_overrides: + allow: [agent, claude, env] # hooks excluded +``` + +### Example + +**Global WORKFLOW.md (operator):** + +```yaml +--- +tracker: + kind: github_projects + api_key: $GITHUB_TOKEN + owner: myorg + project_number: 5 +repo_overrides: true +agent: + max_turns: 20 +claude: + effort: high +--- +Default prompt for all repos... +{{ issue.title }} +``` + +**Target repo's WORKFLOW.md (repo team):** + +```yaml +--- +agent: + max_turns: 40 +claude: + model: claude-sonnet-4-20250514 + effort: max +env: + DATABASE_URL: $DATABASE_URL +--- +You are a backend specialist working on {{ issue.identifier }}. + +Focus on: +- Database migrations +- API endpoint implementation +{{ issue.description }} +``` + +**Effective result for issues from that repo:** + +- `tracker` — from global (not overridable) +- `agent.max_turns` — 40 (from repo) +- `claude.model` — `claude-sonnet-4-20250514` (from repo) +- `claude.effort` — `max` (from repo) +- `env.DATABASE_URL` — resolved from `$DATABASE_URL` (from repo) +- Prompt template — repo's custom template + ## CLI Usage ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 409f8304..a52071df 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -23,6 +23,10 @@ Work Please 将问题追踪器中的任务转化为隔离的自主实现运行 - [WORKFLOW.md 配置](#workflowmd-配置) - [完整 Front Matter 模式](#完整-front-matter-模式) - [模板变量](#模板变量) +- [仓库级工作流覆盖](#仓库级工作流覆盖) + - [工作原理](#工作原理) + - [覆盖规则](#覆盖规则) + - [示例](#示例-1) - [CLI 用法](#cli-用法) - [GitHub App 认证](#github-app-认证) - [设置 GitHub App 凭据](#设置-github-app-凭据) @@ -75,6 +79,9 @@ Work Please 是一个长期运行的 TypeScript 服务,它: - **动态配置重载** —— 编辑 `WORKFLOW.md` 后无需重启服务即可应用更改。 - **工作区钩子** —— 在 `after_create`、`before_run`、`after_run` 和 `before_remove` 生命周期事件中运行 shell 脚本。 +- **仓库级工作流覆盖** —— 目标仓库可以提供自己的 `WORKFLOW.md` 来自定义代理配置和提示词模板。 + 在全局工作流中通过 `repo_overrides: true` 启用。服务级别设置(tracker、polling、workspace) + 永远不会被覆盖。 - **结构化日志** —— 提供稳定的 `key=value` 格式的操作员可见日志。 - **可选 HTTP 仪表板** —— 使用 `--port` 启用,查看运行时状态和 JSON API。 @@ -455,6 +462,12 @@ claude: commit: "🙏 Generated with [Work Please](https://github.com/pleaseai/work-please)" # 可选: 附加到 git 提交消息。默认为 Work Please 链接。 pr: "🙏 Generated with [Work Please](https://github.com/pleaseai/work-please)" # 可选: 附加到 PR 描述。默认为 Work Please 链接。 +repo_overrides: true # 可选: 允许目标仓库通过自己的 WORKFLOW.md 覆盖工作流。 + # 默认: false(仓库 WORKFLOW.md 文件被忽略)。 + # 也可以使用对象进行精细控制: + # repo_overrides: + # allow: [agent, claude, env, hooks] # 限制仓库可以覆盖的部分 + server: port: 3000 # 可选: 在此端口启用 HTTP 仪表板 --- @@ -510,6 +523,95 @@ server: {% endif %} ``` +## 仓库级工作流覆盖 + +当管理跨多个仓库的 GitHub Projects v2 项目时,每个目标仓库可以提供自己的 `WORKFLOW.md` +来自定义代理行为 —— 无需更改全局配置。 + +### 工作原理 + +1. 运维人员的全局 `WORKFLOW.md` 启动服务并定义服务级别设置(tracker、polling、workspace)。 + 必须包含 `repo_overrides: true` 才能启用此功能。 +2. 当问题被分发时,Work Please 使用全局配置创建工作区(克隆/工作树)。 +3. 工作区就绪后,Work Please 检查仓库根目录中的 `WORKFLOW.md`。 +4. 如果找到,仓库的配置部分将深度合并到全局配置中(仅允许的部分),仓库的提示词模板将替换 + 全局模板(如果非空)。 +5. 有效的(合并后的)工作流用于代理会话。 + +### 覆盖规则 + +| 部分 | 可覆盖 | 原因 | +|------|--------|------| +| `tracker` | 否 | 服务凭据 —— 安全边界 | +| `polling` | 否 | 服务级别关注点 | +| `workspace` | 否 | 安全边界(路径遍历) | +| `server` | 否 | 服务级别关注点 | +| `agent` | **是** | `max_turns`、重试、并发 | +| `claude` | **是** | `model`、`effort`、`allowed_tools`、`system_prompt`、`permission_mode` | +| `hooks.before_run` | **是** | 仓库级代理前设置 | +| `hooks.after_run` | **是** | 仓库级代理后清理 | +| `hooks.after_create` | 否 | 在仓库 WORKFLOW.md 可用之前运行 | +| `hooks.before_remove` | 否 | 工作区生命周期,非代理关注点 | +| `env` | **是** | 代理的额外环境变量 | +| 提示词模板 | **是** | 仓库级提示词自定义 | + +使用精细形式限制仓库可以覆盖的部分: + +```yaml +repo_overrides: + allow: [agent, claude, env] # 排除 hooks +``` + +### 示例 + +**全局 WORKFLOW.md(运维人员):** + +```yaml +--- +tracker: + kind: github_projects + api_key: $GITHUB_TOKEN + owner: myorg + project_number: 5 +repo_overrides: true +agent: + max_turns: 20 +claude: + effort: high +--- +所有仓库的默认提示词... +{{ issue.title }} +``` + +**目标仓库的 WORKFLOW.md(仓库团队):** + +```yaml +--- +agent: + max_turns: 40 +claude: + model: claude-sonnet-4-20250514 + effort: max +env: + DATABASE_URL: $DATABASE_URL +--- +你是一名后端专家,正在处理 {{ issue.identifier }}。 + +重点关注: +- 数据库迁移 +- API 端点实现 +{{ issue.description }} +``` + +**该仓库问题的有效结果:** + +- `tracker` —— 来自全局(不可覆盖) +- `agent.max_turns` —— 40(来自仓库) +- `claude.model` —— `claude-sonnet-4-20250514`(来自仓库) +- `claude.effort` —— `max`(来自仓库) +- `env.DATABASE_URL` —— 从 `$DATABASE_URL` 解析(来自仓库) +- 提示词模板 —— 仓库的自定义模板 + ## CLI 用法 ```bash diff --git a/apps/work-please/src/config.test.ts b/apps/work-please/src/config.test.ts index 29bb8d09..b1e8823f 100644 --- a/apps/work-please/src/config.test.ts +++ b/apps/work-please/src/config.test.ts @@ -2,7 +2,7 @@ import type { WorkflowDefinition } from './types' import process from 'node:process' import { describe, expect, it } from 'bun:test' -import { buildConfig, getActiveStates, getTerminalStates, getWatchedStates, maxConcurrentForState, normalizeState, validateConfig } from './config' +import { buildConfig, getActiveStates, getTerminalStates, getWatchedStates, maxConcurrentForState, normalizeState, parseRepoOverridesSetting, validateConfig } from './config' function makeWorkflow(config: Record): WorkflowDefinition { return { config, prompt_template: '' } @@ -792,3 +792,51 @@ describe('buildConfig - env section', () => { expect(Object.keys(config.env)).toHaveLength(2) }) }) + +describe('parseRepoOverridesSetting', () => { + it('returns disabled when repo_overrides is absent', () => { + const result = parseRepoOverridesSetting({}) + expect(result.enabled).toBe(false) + }) + + it('returns disabled when repo_overrides is false', () => { + const result = parseRepoOverridesSetting({ repo_overrides: false }) + expect(result.enabled).toBe(false) + }) + + it('returns enabled with default sections when repo_overrides is true', () => { + const result = parseRepoOverridesSetting({ repo_overrides: true }) + expect(result.enabled).toBe(true) + expect(result.allowed_sections).toEqual(['agent', 'claude', 'env', 'hooks']) + }) + + it('returns enabled with custom allow list', () => { + const result = parseRepoOverridesSetting({ + repo_overrides: { allow: ['agent', 'claude'] }, + }) + expect(result.enabled).toBe(true) + expect(result.allowed_sections).toEqual(['agent', 'claude']) + }) + + it('filters out disallowed sections from allow list', () => { + const result = parseRepoOverridesSetting({ + repo_overrides: { allow: ['agent', 'tracker', 'polling', 'workspace', 'server'] }, + }) + expect(result.enabled).toBe(true) + expect(result.allowed_sections).toEqual(['agent']) + }) + + it('returns disabled when allow list results in empty array', () => { + const result = parseRepoOverridesSetting({ + repo_overrides: { allow: ['tracker', 'polling'] }, + }) + expect(result.enabled).toBe(false) + }) + + it('handles repo_overrides as object with empty allow', () => { + const result = parseRepoOverridesSetting({ + repo_overrides: { allow: [] }, + }) + expect(result.enabled).toBe(false) + }) +}) diff --git a/apps/work-please/src/config.ts b/apps/work-please/src/config.ts index 58086525..858939be 100644 --- a/apps/work-please/src/config.ts +++ b/apps/work-please/src/config.ts @@ -1,4 +1,4 @@ -import type { ClaudeEffort, IssueFilter, ServiceConfig, SettingSource, SystemPromptConfig, WorkflowDefinition } from './types' +import type { ClaudeEffort, IssueFilter, RepoOverridesConfig, ServiceConfig, SettingSource, SystemPromptConfig, WorkflowDefinition } from './types' import { tmpdir } from 'node:os' import { join, sep } from 'node:path' import process from 'node:process' @@ -465,6 +465,29 @@ function buildEnvConfig(raw: Record): Record { return result } +const OVERRIDABLE_SECTIONS = new Set(['agent', 'claude', 'env', 'hooks', 'prompt_template']) +const DEFAULT_ALLOWED_SECTIONS = ['agent', 'claude', 'env', 'hooks'] + +export function parseRepoOverridesSetting(config: Record): RepoOverridesConfig { + const val = config.repo_overrides + + if (val === true) { + return { enabled: true, allowed_sections: [...DEFAULT_ALLOWED_SECTIONS] } + } + + if (val && typeof val === 'object' && !Array.isArray(val)) { + const obj = val as Record + const allow = Array.isArray(obj.allow) + ? obj.allow.filter((s): s is string => typeof s === 'string' && OVERRIDABLE_SECTIONS.has(s)) + : [] + return allow.length > 0 + ? { enabled: true, allowed_sections: allow } + : { enabled: false, allowed_sections: [] } + } + + return { enabled: false, allowed_sections: [] } +} + function normalizeTrackerKind(kind: string | null): string | null { if (!kind) return null diff --git a/apps/work-please/src/orchestrator.ts b/apps/work-please/src/orchestrator.ts index 3fb096f8..a7e1ad0f 100644 --- a/apps/work-please/src/orchestrator.ts +++ b/apps/work-please/src/orchestrator.ts @@ -1,14 +1,14 @@ import type { LabelService } from './label' -import type { Issue, OrchestratorState, RetryEntry, RunningEntry, ServiceConfig, WorkflowDefinition } from './types' +import type { Issue, OrchestratorState, RepoOverridesConfig, RetryEntry, RunningEntry, ServiceConfig, WorkflowDefinition } from './types' import { watch } from 'node:fs' import { resolveAgentEnv } from './agent-env' import { AppServerClient } from './agent-runner' -import { buildConfig, getActiveStates, getTerminalStates, getWatchedStates, maxConcurrentForState, normalizeState, validateConfig } from './config' +import { buildConfig, getActiveStates, getTerminalStates, getWatchedStates, maxConcurrentForState, normalizeState, parseRepoOverridesSetting, validateConfig } from './config' import { createLabelService } from './label' import { createLogger } from './logger' import { buildContinuationPrompt, buildPrompt, isPromptBuildError } from './prompt-builder' import { createTrackerAdapter, formatTrackerError, isTrackerError } from './tracker/index' -import { isWorkflowError, loadWorkflow } from './workflow' +import { isWorkflowError, loadRepoWorkflow, loadWorkflow, mergeWorkflows } from './workflow' import { createWorkspace, removeWorkspace, runAfterRunHook, runBeforeRunHook } from './workspace' const log = createLogger('orchestrator') @@ -21,6 +21,7 @@ export class Orchestrator { private config: ServiceConfig private workflow: WorkflowDefinition private workflowPath: string + private repoOverridesConfig: RepoOverridesConfig private pollTimer: ReturnType | null = null private fileWatcher: ReturnType | null = null private labelService: LabelService | null = null @@ -34,6 +35,7 @@ export class Orchestrator { } this.workflow = wf this.config = buildConfig(wf) + this.repoOverridesConfig = parseRepoOverridesSetting(wf.config) this.labelService = createLabelService(this.config) this.state = { @@ -240,41 +242,45 @@ export class Orchestrator { } private async executeAgentRun(issue: Issue, attempt: number | null): Promise { - // Create/reuse workspace + // Create/reuse workspace with global config const wsResult = await createWorkspace(this.config, issue.identifier, issue) if (wsResult instanceof Error) { throw wsResult } log.debug(`workspace ready issue_id=${issue.id} path=${wsResult.path} created_now=${wsResult.created_now}`) - // Before-run hook - const beforeRunErr = await runBeforeRunHook(this.config, wsResult.path, issue) + // Resolve effective workflow (may include repo-level overrides) + const effectiveWorkflow = this.resolveEffectiveWorkflow(wsResult.path) + const effectiveConfig = this.resolveEffectiveConfig(effectiveWorkflow) + + // Before-run hook (may be overridden by repo workflow) + const beforeRunErr = await runBeforeRunHook(effectiveConfig, wsResult.path, issue) if (beforeRunErr) { log.warn(`before_run hook failed issue_id=${issue.id}: ${beforeRunErr}`) - await runAfterRunHook(this.config, wsResult.path, issue) + await runAfterRunHook(effectiveConfig, wsResult.path, issue) throw beforeRunErr } // Resolve agent environment variables (including runtime tokens) - const client = new AppServerClient(this.config, wsResult.path) - const agentEnv = await resolveAgentEnv(this.config, this.buildTokenProvider()) + const client = new AppServerClient(effectiveConfig, wsResult.path) + const agentEnv = await resolveAgentEnv(effectiveConfig, this.buildTokenProvider()) client.setAgentEnv(agentEnv) // Start agent session const session = await client.startSession() if (session instanceof Error) { - await runAfterRunHook(this.config, wsResult.path, issue) + await runAfterRunHook(effectiveConfig, wsResult.path, issue) throw session } try { // Resolve project status field metadata for prompt context await this.populateProjectContext(issue) - await this.runAgentTurns(client, session, issue, attempt) + await this.runAgentTurns(client, session, issue, attempt, effectiveWorkflow, effectiveConfig) } finally { client.stopSession() - await runAfterRunHook(this.config, wsResult.path, issue) + await runAfterRunHook(effectiveConfig, wsResult.path, issue) } } @@ -304,15 +310,19 @@ export class Orchestrator { session: import('./agent-runner').AgentSession, issue: Issue, attempt: number | null, + effectiveWorkflow?: WorkflowDefinition, + effectiveConfig?: ServiceConfig, ): Promise { - const maxTurns = this.config.agent.max_turns + const workflow = effectiveWorkflow ?? this.workflow + const config = effectiveConfig ?? this.config + const maxTurns = config.agent.max_turns let currentIssue = issue let turnNumber = 1 while (true) { // Build prompt const promptResult = turnNumber === 1 - ? await buildPrompt(this.workflow, currentIssue, attempt) + ? await buildPrompt(workflow, currentIssue, attempt) : buildContinuationPrompt(turnNumber, maxTurns) if (isPromptBuildError(promptResult)) { @@ -667,6 +677,33 @@ export class Orchestrator { return buildTokenProvider(this.config.tracker) } + private resolveEffectiveWorkflow(workspacePath: string): WorkflowDefinition { + if (!this.repoOverridesConfig.enabled) + return this.workflow + + const repoWorkflow = loadRepoWorkflow(workspacePath) + if (!repoWorkflow) + return this.workflow + + log.info(`repo workflow override detected at ${workspacePath}`) + return mergeWorkflows(this.workflow, repoWorkflow, this.repoOverridesConfig.allowed_sections) + } + + private resolveEffectiveConfig(effectiveWorkflow: WorkflowDefinition): ServiceConfig { + if (effectiveWorkflow === this.workflow) + return this.config + + const merged = buildConfig(effectiveWorkflow) + // Preserve service-level sections from global config (security boundary) + return { + ...merged, + tracker: this.config.tracker, + polling: this.config.polling, + workspace: this.config.workspace, + server: this.config.server, + } + } + private reloadWorkflow(): void { const wf = loadWorkflow(this.workflowPath) if (isWorkflowError(wf)) { @@ -683,6 +720,7 @@ export class Orchestrator { this.workflow = wf this.config = newConfig + this.repoOverridesConfig = parseRepoOverridesSetting(wf.config) this.labelService = createLabelService(newConfig) this.state.poll_interval_ms = newConfig.polling.interval_ms this.state.max_concurrent_agents = newConfig.agent.max_concurrent_agents diff --git a/apps/work-please/src/types.ts b/apps/work-please/src/types.ts index ccdee39b..e6cded15 100644 --- a/apps/work-please/src/types.ts +++ b/apps/work-please/src/types.ts @@ -129,6 +129,11 @@ export interface ServiceConfig { } } +export interface RepoOverridesConfig { + enabled: boolean + allowed_sections: string[] +} + export interface Workspace { path: string workspace_key: string diff --git a/apps/work-please/src/workflow.test.ts b/apps/work-please/src/workflow.test.ts index 319343a8..22f22a2e 100644 --- a/apps/work-please/src/workflow.test.ts +++ b/apps/work-please/src/workflow.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from 'bun:test' -import { loadWorkflow, parseWorkflow } from './workflow' +import type { WorkflowDefinition } from './types' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { loadRepoWorkflow, loadWorkflow, mergeWorkflows, parseWorkflow } from './workflow' describe('loadWorkflow', () => { it('returns missing_workflow_file for nonexistent path (Section 17.1)', () => { @@ -110,3 +114,177 @@ Hello world.` expect(result.prompt_template).toBe('Hello world.') }) }) + +describe('mergeWorkflows', () => { + const base: WorkflowDefinition = { + config: { + tracker: { kind: 'github_projects', api_key: 'secret' }, + polling: { interval_ms: 30000 }, + workspace: { root: '/tmp/ws' }, + server: { port: 8080 }, + agent: { max_turns: 20, max_concurrent_agents: 10 }, + claude: { model: 'claude-sonnet-4-20250514', effort: 'high' }, + hooks: { after_create: 'echo setup', before_run: 'echo before', after_run: 'echo after', before_remove: 'echo rm' }, + env: { EXISTING: 'val' }, + }, + prompt_template: 'Default prompt {{ issue.title }}', + } + + it('returns base unchanged when override is null', () => { + const result = mergeWorkflows(base, null, ['agent', 'claude', 'env', 'hooks']) + expect(result).toEqual(base) + }) + + it('merges allowed config sections from override', () => { + const override: WorkflowDefinition = { + config: { agent: { max_turns: 40 } }, + prompt_template: '', + } + const result = mergeWorkflows(base, override, ['agent', 'claude', 'env', 'hooks']) + expect((result.config.agent as Record).max_turns).toBe(40) + // preserves base values not overridden + expect((result.config.agent as Record).max_concurrent_agents).toBe(10) + }) + + it('ignores disallowed config sections from override', () => { + const override: WorkflowDefinition = { + config: { + tracker: { kind: 'asana', api_key: 'stolen' }, + polling: { interval_ms: 1 }, + workspace: { root: '/etc/evil' }, + server: { port: 9999 }, + agent: { max_turns: 40 }, + }, + prompt_template: '', + } + const result = mergeWorkflows(base, override, ['agent', 'claude', 'env', 'hooks']) + // tracker, polling, workspace, server must remain from base + expect(result.config.tracker).toEqual(base.config.tracker) + expect(result.config.polling).toEqual(base.config.polling) + expect(result.config.workspace).toEqual(base.config.workspace) + expect(result.config.server).toEqual(base.config.server) + // agent is allowed + expect((result.config.agent as Record).max_turns).toBe(40) + }) + + it('replaces prompt_template when override provides a non-empty one', () => { + const override: WorkflowDefinition = { + config: {}, + prompt_template: 'Custom repo prompt', + } + const result = mergeWorkflows(base, override, ['agent']) + expect(result.prompt_template).toBe('Custom repo prompt') + }) + + it('keeps base prompt_template when override prompt is empty', () => { + const override: WorkflowDefinition = { + config: { agent: { max_turns: 50 } }, + prompt_template: '', + } + const result = mergeWorkflows(base, override, ['agent']) + expect(result.prompt_template).toBe('Default prompt {{ issue.title }}') + }) + + it('deep-merges nested config sections', () => { + const override: WorkflowDefinition = { + config: { claude: { effort: 'max' } }, + prompt_template: '', + } + const result = mergeWorkflows(base, override, ['claude']) + // effort overridden + expect((result.config.claude as Record).effort).toBe('max') + // model preserved from base + expect((result.config.claude as Record).model).toBe('claude-sonnet-4-20250514') + }) + + it('merges env additively', () => { + const override: WorkflowDefinition = { + config: { env: { NEW_VAR: 'new' } }, + prompt_template: '', + } + const result = mergeWorkflows(base, override, ['env']) + const env = result.config.env as Record + expect(env.EXISTING).toBe('val') + expect(env.NEW_VAR).toBe('new') + }) + + it('only merges hooks.before_run and hooks.after_run, not after_create or before_remove', () => { + const override: WorkflowDefinition = { + config: { + hooks: { + after_create: 'echo hacked', + before_run: 'echo repo-before', + after_run: 'echo repo-after', + before_remove: 'echo hacked-rm', + }, + }, + prompt_template: '', + } + const result = mergeWorkflows(base, override, ['hooks']) + const hooks = result.config.hooks as Record + // before_run and after_run replaced + expect(hooks.before_run).toBe('echo repo-before') + expect(hooks.after_run).toBe('echo repo-after') + // after_create and before_remove preserved from base + expect(hooks.after_create).toBe('echo setup') + expect(hooks.before_remove).toBe('echo rm') + }) + + it('does not mutate the base workflow', () => { + const baseCopy = JSON.parse(JSON.stringify(base)) + const override: WorkflowDefinition = { + config: { agent: { max_turns: 99 } }, + prompt_template: 'New prompt', + } + mergeWorkflows(base, override, ['agent']) + expect(base).toEqual(baseCopy) + }) + + it('strips repo_overrides key from the merged config', () => { + const override: WorkflowDefinition = { + config: { repo_overrides: true, agent: { max_turns: 30 } }, + prompt_template: '', + } + const result = mergeWorkflows(base, override, ['agent']) + expect(result.config.repo_overrides).toBeUndefined() + }) +}) + +describe('loadRepoWorkflow', () => { + let testDir: string + + beforeEach(() => { + testDir = join(tmpdir(), `workflow-test-${Date.now()}`) + mkdirSync(testDir, { recursive: true }) + }) + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }) + }) + + it('returns null when WORKFLOW.md does not exist in workspace', () => { + const result = loadRepoWorkflow(testDir) + expect(result).toBeNull() + }) + + it('parses a valid WORKFLOW.md from workspace', () => { + writeFileSync(join(testDir, 'WORKFLOW.md'), `--- +agent: + max_turns: 50 +--- +Custom prompt.`) + const result = loadRepoWorkflow(testDir) + expect(result).not.toBeNull() + expect(result!.config).toMatchObject({ agent: { max_turns: 50 } }) + expect(result!.prompt_template).toBe('Custom prompt.') + }) + + it('returns null for invalid YAML and does not throw', () => { + writeFileSync(join(testDir, 'WORKFLOW.md'), `--- +agent: [unclosed +--- +prompt`) + const result = loadRepoWorkflow(testDir) + expect(result).toBeNull() + }) +}) diff --git a/apps/work-please/src/workflow.ts b/apps/work-please/src/workflow.ts index 3d72792f..defc86df 100644 --- a/apps/work-please/src/workflow.ts +++ b/apps/work-please/src/workflow.ts @@ -3,6 +3,9 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import process from 'node:process' import { load as parseYaml } from 'js-yaml' +import { createLogger } from './logger' + +const log = createLogger('workflow') const NEWLINE_RE = /\r?\n/ @@ -87,3 +90,92 @@ function parseFrontMatter(lines: string[]): { config: Record } export function isWorkflowError(result: WorkflowDefinition | WorkflowError): result is WorkflowError { return 'code' in result } + +const HOOK_OVERRIDABLE_KEYS = new Set(['before_run', 'after_run']) + +export function loadRepoWorkflow(workspacePath: string): WorkflowDefinition | null { + const filePath = join(workspacePath, WORKFLOW_FILE_NAME) + + let content: string + try { + content = readFileSync(filePath, 'utf-8') + } + catch { + return null + } + + const result = parseWorkflow(content) + if (isWorkflowError(result)) { + log.warn(`repo WORKFLOW.md parse error at ${filePath}: ${result.code} — using global workflow`) + return null + } + + return result +} + +export function mergeWorkflows( + base: WorkflowDefinition, + repoOverride: WorkflowDefinition | null, + allowedSections: string[], +): WorkflowDefinition { + if (!repoOverride) + return base + + const allowed = new Set(allowedSections) + const merged: Record = {} + + // Copy all base config sections + for (const [key, value] of Object.entries(base.config)) { + merged[key] = deepClone(value) + } + + // Override allowed sections from repo workflow + for (const [key, value] of Object.entries(repoOverride.config)) { + if (key === 'repo_overrides') + continue + + if (!allowed.has(key)) + continue + + // Special handling for hooks: only allow before_run and after_run + if (key === 'hooks') { + const baseHooks = (merged.hooks ?? {}) as Record + const overrideHooks = value as Record + for (const [hookKey, hookVal] of Object.entries(overrideHooks)) { + if (HOOK_OVERRIDABLE_KEYS.has(hookKey)) { + baseHooks[hookKey] = hookVal + } + // lifecycle hooks (after_create, before_remove) are silently ignored + } + merged.hooks = baseHooks + continue + } + + // Deep-merge for object sections (agent, claude, env) + if (isPlainObject(merged[key]) && isPlainObject(value)) { + merged[key] = { ...(merged[key] as Record), ...(value as Record) } + } + else { + merged[key] = deepClone(value) + } + } + + // Strip repo_overrides from merged result + delete merged.repo_overrides + + const prompt_template = repoOverride.prompt_template + ? repoOverride.prompt_template + : base.prompt_template + + return { config: merged, prompt_template } +} + +function isPlainObject(val: unknown): val is Record { + return val !== null && typeof val === 'object' && !Array.isArray(val) +} + +function deepClone(val: T): T { + if (val === null || typeof val !== 'object') + return val + return JSON.parse(JSON.stringify(val)) +} From 9651660fa3ccb6d2faa046684023a2964131d34d Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Tue, 17 Mar 2026 18:25:04 +0900 Subject: [PATCH 2/3] fix(workflow): harden repo override security and add recursive deep merge - Replace shallow merge with recursive deepMerge for nested config sections (fixes claude.settings.attribution loss) - Remove hooks from default allowed override sections (security boundary) - Make prompt_template override respect allowed_sections list - Update all 4 README translations with corrected override rules - Add deep merge test for nested objects --- ARCHITECTURE.md | 7 ++--- README.ja.md | 11 +++----- README.ko.md | 11 +++----- README.md | 11 +++----- README.zh-CN.md | 11 +++----- apps/work-please/src/config.test.ts | 2 +- apps/work-please/src/config.ts | 2 +- apps/work-please/src/workflow.test.ts | 40 ++++++++++++++++++++++++--- apps/work-please/src/workflow.ts | 20 ++++++++++++-- 9 files changed, 75 insertions(+), 40 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 21c2621a..01e37084 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -199,10 +199,9 @@ Two-layer configuration via `WORKFLOW.md`: - **Global WORKFLOW.md** (operator) — Defines service-level settings (tracker, polling, workspace) and default agent config. Read at startup and watched for live reload. -- **Repo WORKFLOW.md** (target repository, optional) — Overrides agent-level settings (`agent`, - `claude`, `hooks.before_run`, `hooks.after_run`, `env`) and prompt template for issues from that - repo. Only read when `repo_overrides: true` is set in the global workflow. Service-level sections - are never overridable (security boundary). +- **Repo WORKFLOW.md** (target repository, optional) — Overrides selected agent-level settings and + the prompt template for issues from that repo. Only read when `repo_overrides: true` is set in + the global workflow. Service-level sections are never overridable (security boundary). - **Merge semantics** — Allowed config sections are deep-merged (repo values win); prompt template is replaced if repo provides a non-empty one. Merge happens per-issue at dispatch time via `resolveEffectiveWorkflow()` in the orchestrator. diff --git a/README.ja.md b/README.ja.md index 008fc0f6..1befe31c 100644 --- a/README.ja.md +++ b/README.ja.md @@ -467,7 +467,7 @@ repo_overrides: true # 任意: ターゲットリポジトリ # デフォルト: false(リポジトリのWORKFLOW.mdファイルは無視される)。 # きめ細かい制御のためにオブジェクトも使用可能: # repo_overrides: - # allow: [agent, claude, env, hooks] # リポジトリがオーバーライドできるセクションを制限 + # allow: [agent, claude, env, prompt_template] # リポジトリがオーバーライドできるセクションを制限 server: port: 3000 # 任意: このポートでHTTPダッシュボードを有効化 @@ -550,18 +550,15 @@ server: | `server` | 不可 | サービスレベルの関心事 | | `agent` | **可能** | `max_turns`、リトライ、同時実行数 | | `claude` | **可能** | `model`、`effort`、`allowed_tools`、`system_prompt`、`permission_mode` | -| `hooks.before_run` | **可能** | リポジトリごとのエージェント前セットアップ | -| `hooks.after_run` | **可能** | リポジトリごとのエージェント後クリーンアップ | -| `hooks.after_create` | 不可 | リポジトリWORKFLOW.mdが利用可能になる前に実行される | -| `hooks.before_remove` | 不可 | ワークスペースライフサイクル、エージェントの関心事ではない | +| `hooks` | 不可 | シェルスクリプト実行 — セキュリティ境界 | | `env` | **可能** | エージェント用の追加環境変数 | | プロンプトテンプレート | **可能** | リポジトリごとのプロンプトカスタマイズ | -きめ細かい形式でリポジトリがオーバーライドできるセクションを制限できます: +きめ細かい形式でリポジトリがオーバーライドできる設定セクションを制限できます。リポジトリのプロンプトテンプレートは、`prompt_template`がallowリストから除外されない限り適用されます: ```yaml repo_overrides: - allow: [agent, claude, env] # hooks除外 + allow: [agent, claude, env, prompt_template] ``` ### 例 diff --git a/README.ko.md b/README.ko.md index 17e923a0..7ef30a3c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -466,7 +466,7 @@ repo_overrides: true # 선택: 대상 저장소가 자체 WORKF # 기본값: false (저장소 WORKFLOW.md 파일은 무시됨). # 세분화된 제어를 위해 객체로도 설정 가능: # repo_overrides: - # allow: [agent, claude, env, hooks] # 저장소가 오버라이드할 수 있는 섹션 제한 + # allow: [agent, claude, env, prompt_template] # 저장소가 오버라이드할 수 있는 섹션 제한 server: port: 3000 # 선택: 이 포트에서 HTTP 대시보드 활성화 @@ -549,18 +549,15 @@ server: | `server` | 불가 | 서비스 수준 관심사 | | `agent` | **가능** | `max_turns`, 재시도, 동시성 | | `claude` | **가능** | `model`, `effort`, `allowed_tools`, `system_prompt`, `permission_mode` | -| `hooks.before_run` | **가능** | 저장소별 에이전트 전 설정 | -| `hooks.after_run` | **가능** | 저장소별 에이전트 후 정리 | -| `hooks.after_create` | 불가 | 저장소 WORKFLOW.md를 사용할 수 있기 전에 실행됨 | -| `hooks.before_remove` | 불가 | 워크스페이스 라이프사이클, 에이전트 관심사 아님 | +| `hooks` | 불가 | 셸 스크립트 실행 — 보안 경계 | | `env` | **가능** | 에이전트용 추가 환경 변수 | | 프롬프트 템플릿 | **가능** | 저장소별 프롬프트 커스터마이즈 | -세분화된 형태로 저장소가 오버라이드할 수 있는 섹션을 제한할 수 있습니다: +세분화된 형태로 저장소가 오버라이드할 수 있는 config 섹션을 제한할 수 있습니다. 저장소 프롬프트 템플릿은 `prompt_template`이 allow 목록에서 제외되지 않는 한 적용됩니다: ```yaml repo_overrides: - allow: [agent, claude, env] # hooks 제외 + allow: [agent, claude, env, prompt_template] ``` ### 예시 diff --git a/README.md b/README.md index 313cb87e..e6ac4e7e 100644 --- a/README.md +++ b/README.md @@ -484,7 +484,7 @@ repo_overrides: true # Optional: allow target repos to override # Default: false (repo WORKFLOW.md files are ignored). # Can also be an object for granular control: # repo_overrides: - # allow: [agent, claude, env, hooks] # restrict which sections repos can override + # allow: [agent, claude, env, prompt_template] # restrict which sections repos can override server: port: 3000 # Optional: enable HTTP dashboard on this port @@ -576,18 +576,15 @@ configuration. | `server` | No | Service-level concern | | `agent` | **Yes** | `max_turns`, retry, concurrency | | `claude` | **Yes** | `model`, `effort`, `allowed_tools`, `system_prompt`, `permission_mode` | -| `hooks.before_run` | **Yes** | Per-repo pre-agent setup | -| `hooks.after_run` | **Yes** | Per-repo post-agent cleanup | -| `hooks.after_create` | No | Runs before repo WORKFLOW.md is available | -| `hooks.before_remove` | No | Workspace lifecycle, not agent concern | +| `hooks` | No | Shell script execution — security boundary | | `env` | **Yes** | Additional env vars for agent | | Prompt template | **Yes** | Per-repo prompt customization | -Use the granular form to restrict which sections repos can override: +Use the granular form to restrict which config sections repos can override. Repo prompt templates are still applied whenever the repo `WORKFLOW.md` provides a non-empty body, unless `prompt_template` is omitted from the allow list: ```yaml repo_overrides: - allow: [agent, claude, env] # hooks excluded + allow: [agent, claude, env, prompt_template] ``` ### Example diff --git a/README.zh-CN.md b/README.zh-CN.md index a52071df..f1435a30 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -466,7 +466,7 @@ repo_overrides: true # 可选: 允许目标仓库通过自己 # 默认: false(仓库 WORKFLOW.md 文件被忽略)。 # 也可以使用对象进行精细控制: # repo_overrides: - # allow: [agent, claude, env, hooks] # 限制仓库可以覆盖的部分 + # allow: [agent, claude, env, prompt_template] # 限制仓库可以覆盖的部分 server: port: 3000 # 可选: 在此端口启用 HTTP 仪表板 @@ -548,18 +548,15 @@ server: | `server` | 否 | 服务级别关注点 | | `agent` | **是** | `max_turns`、重试、并发 | | `claude` | **是** | `model`、`effort`、`allowed_tools`、`system_prompt`、`permission_mode` | -| `hooks.before_run` | **是** | 仓库级代理前设置 | -| `hooks.after_run` | **是** | 仓库级代理后清理 | -| `hooks.after_create` | 否 | 在仓库 WORKFLOW.md 可用之前运行 | -| `hooks.before_remove` | 否 | 工作区生命周期,非代理关注点 | +| `hooks` | 否 | Shell 脚本执行 —— 安全边界 | | `env` | **是** | 代理的额外环境变量 | | 提示词模板 | **是** | 仓库级提示词自定义 | -使用精细形式限制仓库可以覆盖的部分: +使用精细形式限制仓库可以覆盖的配置部分。仓库提示词模板在仓库 `WORKFLOW.md` 提供非空内容时仍会应用,除非 `prompt_template` 从 allow 列表中排除: ```yaml repo_overrides: - allow: [agent, claude, env] # 排除 hooks + allow: [agent, claude, env, prompt_template] ``` ### 示例 diff --git a/apps/work-please/src/config.test.ts b/apps/work-please/src/config.test.ts index b1e8823f..1fb87f61 100644 --- a/apps/work-please/src/config.test.ts +++ b/apps/work-please/src/config.test.ts @@ -807,7 +807,7 @@ describe('parseRepoOverridesSetting', () => { it('returns enabled with default sections when repo_overrides is true', () => { const result = parseRepoOverridesSetting({ repo_overrides: true }) expect(result.enabled).toBe(true) - expect(result.allowed_sections).toEqual(['agent', 'claude', 'env', 'hooks']) + expect(result.allowed_sections).toEqual(['agent', 'claude', 'env', 'prompt_template']) }) it('returns enabled with custom allow list', () => { diff --git a/apps/work-please/src/config.ts b/apps/work-please/src/config.ts index 858939be..c24e5886 100644 --- a/apps/work-please/src/config.ts +++ b/apps/work-please/src/config.ts @@ -466,7 +466,7 @@ function buildEnvConfig(raw: Record): Record { } const OVERRIDABLE_SECTIONS = new Set(['agent', 'claude', 'env', 'hooks', 'prompt_template']) -const DEFAULT_ALLOWED_SECTIONS = ['agent', 'claude', 'env', 'hooks'] +const DEFAULT_ALLOWED_SECTIONS = ['agent', 'claude', 'env', 'prompt_template'] export function parseRepoOverridesSetting(config: Record): RepoOverridesConfig { const val = config.repo_overrides diff --git a/apps/work-please/src/workflow.test.ts b/apps/work-please/src/workflow.test.ts index 22f22a2e..beb02d53 100644 --- a/apps/work-please/src/workflow.test.ts +++ b/apps/work-please/src/workflow.test.ts @@ -167,21 +167,30 @@ describe('mergeWorkflows', () => { expect((result.config.agent as Record).max_turns).toBe(40) }) - it('replaces prompt_template when override provides a non-empty one', () => { + it('replaces prompt_template when override provides a non-empty one and prompt_template is allowed', () => { const override: WorkflowDefinition = { config: {}, prompt_template: 'Custom repo prompt', } - const result = mergeWorkflows(base, override, ['agent']) + const result = mergeWorkflows(base, override, ['agent', 'prompt_template']) expect(result.prompt_template).toBe('Custom repo prompt') }) + it('keeps base prompt_template when prompt_template is not in allowed sections', () => { + const override: WorkflowDefinition = { + config: {}, + prompt_template: 'Custom repo prompt', + } + const result = mergeWorkflows(base, override, ['agent']) + expect(result.prompt_template).toBe('Default prompt {{ issue.title }}') + }) + it('keeps base prompt_template when override prompt is empty', () => { const override: WorkflowDefinition = { config: { agent: { max_turns: 50 } }, prompt_template: '', } - const result = mergeWorkflows(base, override, ['agent']) + const result = mergeWorkflows(base, override, ['agent', 'prompt_template']) expect(result.prompt_template).toBe('Default prompt {{ issue.title }}') }) @@ -197,6 +206,29 @@ describe('mergeWorkflows', () => { expect((result.config.claude as Record).model).toBe('claude-sonnet-4-20250514') }) + it('recursively deep-merges nested objects (e.g. claude.settings.attribution)', () => { + const baseWithSettings: WorkflowDefinition = { + config: { + claude: { model: 'opus', settings: { attribution: { commit: 'base-commit', pr: 'base-pr' } } }, + }, + prompt_template: '', + } + const override: WorkflowDefinition = { + config: { claude: { settings: { attribution: { commit: 'repo-commit' } } } }, + prompt_template: '', + } + const result = mergeWorkflows(baseWithSettings, override, ['claude']) + const claude = result.config.claude as Record + const settings = claude.settings as Record + const attribution = settings.attribution as Record + // commit overridden by repo + expect(attribution.commit).toBe('repo-commit') + // pr preserved from base + expect(attribution.pr).toBe('base-pr') + // model preserved from base + expect(claude.model).toBe('opus') + }) + it('merges env additively', () => { const override: WorkflowDefinition = { config: { env: { NEW_VAR: 'new' } }, @@ -236,7 +268,7 @@ describe('mergeWorkflows', () => { config: { agent: { max_turns: 99 } }, prompt_template: 'New prompt', } - mergeWorkflows(base, override, ['agent']) + mergeWorkflows(base, override, ['agent', 'prompt_template']) expect(base).toEqual(baseCopy) }) diff --git a/apps/work-please/src/workflow.ts b/apps/work-please/src/workflow.ts index defc86df..1747fce5 100644 --- a/apps/work-please/src/workflow.ts +++ b/apps/work-please/src/workflow.ts @@ -153,7 +153,7 @@ export function mergeWorkflows( // Deep-merge for object sections (agent, claude, env) if (isPlainObject(merged[key]) && isPlainObject(value)) { - merged[key] = { ...(merged[key] as Record), ...(value as Record) } + merged[key] = deepMerge(merged[key] as Record, value as Record) } else { merged[key] = deepClone(value) @@ -163,7 +163,8 @@ export function mergeWorkflows( // Strip repo_overrides from merged result delete merged.repo_overrides - const prompt_template = repoOverride.prompt_template + // Prompt template: only override if allowed and non-empty + const prompt_template = (allowed.has('prompt_template') && repoOverride.prompt_template) ? repoOverride.prompt_template : base.prompt_template @@ -174,6 +175,21 @@ function isPlainObject(val: unknown): val is Record { return val !== null && typeof val === 'object' && !Array.isArray(val) } +function deepMerge(target: Record, source: Record): Record { + const output: Record = { ...target } + for (const key of Object.keys(source)) { + const sourceValue = source[key] + const targetValue = output[key] + if (isPlainObject(sourceValue) && isPlainObject(targetValue)) { + output[key] = deepMerge(targetValue, sourceValue) + } + else { + output[key] = sourceValue + } + } + return output +} + function deepClone(val: T): T { if (val === null || typeof val !== 'object') return val From 94369f56d171b7b08091c23e72fa8b4d082d6966 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Wed, 18 Mar 2026 16:05:27 +0900 Subject: [PATCH 3/3] docs(workflow): clarify hooks override as opt-in only in override rules tables Hooks are not included in default allowed sections but can be explicitly enabled via repo_overrides.allow: ['hooks']. Updated all 4 README translations to accurately describe this behavior. --- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.ja.md b/README.ja.md index 1befe31c..f27b1146 100644 --- a/README.ja.md +++ b/README.ja.md @@ -550,7 +550,7 @@ server: | `server` | 不可 | サービスレベルの関心事 | | `agent` | **可能** | `max_turns`、リトライ、同時実行数 | | `claude` | **可能** | `model`、`effort`、`allowed_tools`、`system_prompt`、`permission_mode` | -| `hooks` | 不可 | シェルスクリプト実行 — セキュリティ境界 | +| `hooks` | 明示的opt-inのみ | デフォルトには含まれません。`allow`に`hooks`を追加すると`before_run`/`after_run`のオーバーライドが可能 | | `env` | **可能** | エージェント用の追加環境変数 | | プロンプトテンプレート | **可能** | リポジトリごとのプロンプトカスタマイズ | diff --git a/README.ko.md b/README.ko.md index 7ef30a3c..1882ecf1 100644 --- a/README.ko.md +++ b/README.ko.md @@ -549,7 +549,7 @@ server: | `server` | 불가 | 서비스 수준 관심사 | | `agent` | **가능** | `max_turns`, 재시도, 동시성 | | `claude` | **가능** | `model`, `effort`, `allowed_tools`, `system_prompt`, `permission_mode` | -| `hooks` | 불가 | 셸 스크립트 실행 — 보안 경계 | +| `hooks` | 명시적 opt-in만 | 기본값에 미포함; `allow`에 `hooks` 추가 시 `before_run`/`after_run` 오버라이드 허용 | | `env` | **가능** | 에이전트용 추가 환경 변수 | | 프롬프트 템플릿 | **가능** | 저장소별 프롬프트 커스터마이즈 | diff --git a/README.md b/README.md index e6ac4e7e..c68dd6ca 100644 --- a/README.md +++ b/README.md @@ -576,7 +576,7 @@ configuration. | `server` | No | Service-level concern | | `agent` | **Yes** | `max_turns`, retry, concurrency | | `claude` | **Yes** | `model`, `effort`, `allowed_tools`, `system_prompt`, `permission_mode` | -| `hooks` | No | Shell script execution — security boundary | +| `hooks` | Opt-in only | Not in defaults; add `hooks` to `allow` to permit `before_run`/`after_run` override | | `env` | **Yes** | Additional env vars for agent | | Prompt template | **Yes** | Per-repo prompt customization | diff --git a/README.zh-CN.md b/README.zh-CN.md index f1435a30..0b5b428c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -548,7 +548,7 @@ server: | `server` | 否 | 服务级别关注点 | | `agent` | **是** | `max_turns`、重试、并发 | | `claude` | **是** | `model`、`effort`、`allowed_tools`、`system_prompt`、`permission_mode` | -| `hooks` | 否 | Shell 脚本执行 —— 安全边界 | +| `hooks` | 仅限显式 opt-in | 默认不包含;将 `hooks` 添加到 `allow` 后可覆盖 `before_run`/`after_run` | | `env` | **是** | 代理的额外环境变量 | | 提示词模板 | **是** | 仓库级提示词自定义 |