diff --git a/skills/ack/README.md b/skills/ack/README.md index 91a0b2a..3feff9e 100644 --- a/skills/ack/README.md +++ b/skills/ack/README.md @@ -49,6 +49,10 @@ docs/ack/ 不会在项目中复制或链接 ACK Skill。通用规范、模板和脚本始终从已安装的 Skill 目录读取。 +ACK 从当前命令指定的 `--project-root/docs/ack/` 定位项目状态,不要求在 +`tasks.yaml` 中持久化 `repoPath` 或 `devWorktree`。自动 worker 的实际工作目录仍由 +`--worktree` 指定,并且必须命中绝对路径白名单 `allowedWorktrees`。 + ## Skill 结构 ```text diff --git a/skills/ack/SKILL.md b/skills/ack/SKILL.md index 4b4dd72..2886b22 100644 --- a/skills/ack/SKILL.md +++ b/skills/ack/SKILL.md @@ -53,6 +53,9 @@ description: >- - 只写项目差异,不复制 `references/` 中的通用规范。 6. 完善 `docs/ack/tasks.yaml` 的项目信息。纯初始化且用户没有提供真实任务时, 删除模板示例任务并保留 `tasks: []`;不要虚构需求或缺陷。 + 项目状态固定从当前项目根的 `docs/ack/` 推导,不写入 `repoPath` 或 `devWorktree`; + worker 的绝对路径只保存在 `project.orchestration.allowedWorktrees` 并在派发时通过 + `--worktree` 显式选择。旧任务板中的 `repoPath`、`devWorktree` 仅兼容读取。 7. 检查 `docs/ack/knowledge.yaml`。新项目没有已验证的项目经验时保留 `verificationRegistry: {}` 与 `entries: []`,不从聊天、README 或单次失败中 猜测并激活知识。 @@ -90,7 +93,7 @@ description: >- `kitVersion` 时仍可读取,但建议迁移为 `ackVersion`。`ackVersion` 必须是合法 SemVer;从 `0.10.0` 起 `project.orchestration` 与顶层 `workerReceipts` 必须同时 存在。 -3. 查找未替换占位符,并核对项目路径、覆盖层路径、Developer 白盒命令、Test +3. 查找未替换占位符,并核对项目根、覆盖层路径、Developer 白盒命令、Test 黑盒命令和 Base URL。 4. 使用 `/scripts/validate_tasks.py` 校验任务板,使用 `/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks diff --git a/skills/ack/VERSION b/skills/ack/VERSION index a803cc2..930e300 100644 --- a/skills/ack/VERSION +++ b/skills/ack/VERSION @@ -1 +1 @@ -0.14.0 +0.14.1 diff --git a/skills/ack/examples/project.example.md b/skills/ack/examples/project.example.md index cec888c..340573e 100644 --- a/skills/ack/examples/project.example.md +++ b/skills/ack/examples/project.example.md @@ -1,6 +1,6 @@ # notes-web Agent 协作协议(示例,项目覆盖层) -> 本项目基于 ack v0.14.0。 +> 本项目基于 ack v0.14.1。 > 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。 > 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。 > ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 diff --git a/skills/ack/examples/tasks.example.yaml b/skills/ack/examples/tasks.example.yaml index 7605a62..9a2503d 100644 --- a/skills/ack/examples/tasks.example.yaml +++ b/skills/ack/examples/tasks.example.yaml @@ -3,12 +3,10 @@ version: 1 updatedAt: "2026-07-06T09:40:00+08:00" source: "Coordinator (PM) Agent" -ackVersion: "0.14.0" +ackVersion: "0.14.1" project: name: "notes-web" - repoPath: "/home/dev/notes-web" baseUrl: "http://localhost:5173" - devWorktree: "/home/dev/notes-web-wt/fix-preview" overlayFile: "docs/ack/project.md" knowledgeFile: "docs/ack/knowledge.yaml" deliveryFile: "docs/ack/delivery.yaml" diff --git a/skills/ack/references/adoption-checklist.md b/skills/ack/references/adoption-checklist.md index 44b6727..9a5d34d 100644 --- a/skills/ack/references/adoption-checklist.md +++ b/skills/ack/references/adoption-checklist.md @@ -24,6 +24,9 @@ - [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目 allowlist,默认 profile 与角色/档位一致。 - [ ] `allowedWorktrees` 只列出同一 Git common-dir 下已经核对的绝对 worktree。 +- [ ] `tasks.yaml` 不需要保存 `repoPath` 或 `devWorktree`;项目状态从当前 + `--project-root/docs/ack/` 推导,worker 路径由 `--worktree` 与 + `allowedWorktrees` 共同约束。 ## 路径权限 diff --git a/skills/ack/references/init-new-project.md b/skills/ack/references/init-new-project.md index 8ae9b0a..3e799d2 100644 --- a/skills/ack/references/init-new-project.md +++ b/skills/ack/references/init-new-project.md @@ -83,11 +83,13 @@ docs/ack/ - 从 `0.11.0` 起的新项目初始化包含默认关闭的交付契约;旧项目不要求为了版本号升级 自动补交付配置。 - `updatedAt` 使用当前带时区时间。 -- `project.name`、`repoPath`、`devWorktree`、`overlayFile` 和 `knowledgeFile` 使用 - 真实值。 +- `project.name` 使用真实值;`overlayFile` 和 `knowledgeFile` 使用项目内相对路径。 + ACK 从命令行 `--project-root` 下固定的 `docs/ack/` 布局解析项目状态,不把 + `repoPath` 或 `devWorktree` 绝对路径写入任务板。旧任务板中的这两个字段仅兼容读取, + 不再参与路径绑定。 - 新项目的 `project.deliveryFile` 固定为 `docs/ack/delivery.yaml`,并保留顶层 `deliveryRuns: []`。旧项目只有在采用交付能力时才补这两个字段。 -- `project.orchestration.allowedWorktrees` 使用已核对的绝对 worktree;模型 +- `project.orchestration.allowedWorktrees` 使用已核对的绝对 worker worktree;模型 allowlist、profiles 和 defaults 使用项目实际允许值。不要把完整启动命令、 `extraArgs`、`env` 或任意 executable 写进任务板。 - 非服务项目的 `baseUrl` 写为 `n/a`。 diff --git a/skills/ack/references/model-routing.md b/skills/ack/references/model-routing.md index 9c2bc0b..7ff4214 100644 --- a/skills/ack/references/model-routing.md +++ b/skills/ack/references/model-routing.md @@ -127,6 +127,11 @@ python3 /scripts/launch_worker.py launch \ worker 实际工作的绝对路径,两者可以不同。后者仍必须属于同一项目并命中 `project.orchestration.allowedWorktrees`。 +项目状态文件固定从 `--project-root/docs/ack/` 解析;任务板不需要保存 `repoPath` 或 +`devWorktree`。旧任务板中的这两个字段仅作兼容信息,launcher 不使用它们授权或定位。 +`projectRoot`、任务板内容摘要和 worker worktree identity 都会进入 launch fingerprint, +因此切换权威项目根、任务板内容或 worker 路径后必须重新生成并审阅 plan。 + - `profile-hash` 读取 `tasks.yaml` 中的 profile,输出确定性 hash。 - `plan` 只校验并展示将使用的结构化计划,不创建终端。 - `launch` 是唯一允许创建 worker 的入口;它要求传入刚审阅的 diff --git a/skills/ack/scripts/launch_worker.py b/skills/ack/scripts/launch_worker.py index 467d7be..d43de30 100755 --- a/skills/ack/scripts/launch_worker.py +++ b/skills/ack/scripts/launch_worker.py @@ -260,16 +260,6 @@ def load_authoritative_board(project_root_value: str) -> tuple[Path, dict]: if errors: formatted = "\n".join(f" - {error}" for error in dict.fromkeys(errors)) raise LaunchError(f"任务板未通过 worker 路由校验:\n{formatted}") - - project = board.get("project") - repo_path = project.get("repoPath") if isinstance(project, dict) else None - if not isinstance(repo_path, str): - raise LaunchError("project.repoPath 必须配置为项目根绝对路径") - configured_root = canonical_directory(repo_path, "project.repoPath") - if configured_root != project_root: - raise LaunchError( - f"project.repoPath 与 --project-root 不一致: {configured_root}" - ) return project_root, board @@ -641,6 +631,7 @@ def build_plan( raise LaunchError("slot 必须是 1..99 的整数") project_root, board = load_authoritative_board(project_root_value) + board_hash = canonical_sha256(board) find_task(board, task_id) project = board["project"] orchestration = project.get("orchestration") @@ -696,6 +687,8 @@ def build_plan( { "protocolVersion": PROTOCOL_VERSION, "backend": "orca", + "projectRoot": str(project_root), + "boardHash": board_hash, "profileId": profile_id, "profileHash": current_profile_hash, "createdFor": created_for, @@ -715,6 +708,7 @@ def build_plan( "protocolVersion": PROTOCOL_VERSION, "backend": "orca", "projectRoot": str(project_root), + "boardHash": board_hash, "taskId": task_id, "attemptId": attempt_id, "role": role, diff --git a/skills/ack/scripts/validate_knowledge.py b/skills/ack/scripts/validate_knowledge.py index 2815c59..39f066c 100755 --- a/skills/ack/scripts/validate_knowledge.py +++ b/skills/ack/scripts/validate_knowledge.py @@ -240,20 +240,24 @@ def _validate_registry_target( def _tasks_project_root( tasks_data: dict[str, Any], tasks_path: Path | None ) -> Path | None: + inferred = infer_project_root(tasks_path) if tasks_path else None + if inferred is not None and inferred.is_dir(): + return inferred + + # Legacy task boards may still declare repoPath. It is only a fallback for + # non-standard layouts; docs/ack location is authoritative when available. project = tasks_data.get("project") repo_path = project.get("repoPath") if isinstance(project, dict) else None if _nonempty(repo_path): candidate = Path(repo_path).expanduser() if not candidate.is_absolute(): - inferred = infer_project_root(tasks_path) if tasks_path else None if inferred is None: return None candidate = inferred / candidate if not candidate.is_dir(): return None return candidate.resolve(strict=True) - inferred = infer_project_root(tasks_path) if tasks_path else None - return inferred if inferred is not None and inferred.is_dir() else None + return None def _unknown_keys(value: dict[str, Any], allowed: set[str], where: str) -> list[str]: @@ -808,8 +812,8 @@ def _validate_knowledge_file_binding( binding_root = project_root or declared_root if binding_root is None or not binding_root.is_dir(): errors.append( - "[tasks] 无法确定现有项目根目录;请修正 project.repoPath " - "或传入 --project-root" + "[tasks] 无法从 docs/ack 布局确定现有项目根目录;" + "请传入 --project-root" ) return errors try: @@ -1067,7 +1071,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--tasks", help="可选 tasks.yaml,用于跨文件引用校验") parser.add_argument( "--project-root", - help="可选项目根目录;默认从 tasks.project.repoPath 或 docs/ack 布局推断", + help="可选项目根目录;默认从 tasks.yaml 的 docs/ack 布局推断", ) args = parser.parse_args(argv) diff --git a/skills/ack/templates/tasks.schema.json b/skills/ack/templates/tasks.schema.json index e5165cb..74caff8 100644 --- a/skills/ack/templates/tasks.schema.json +++ b/skills/ack/templates/tasks.schema.json @@ -25,9 +25,17 @@ "additionalProperties": true, "properties": { "name": { "type": "string", "minLength": 1, "pattern": "\\S" }, - "repoPath": { "type": "string" }, + "repoPath": { + "type": "string", + "deprecated": true, + "description": "Legacy informational field; ACK derives project files from --project-root/docs/ack" + }, "baseUrl": { "type": "string" }, - "devWorktree": { "type": "string" }, + "devWorktree": { + "type": "string", + "deprecated": true, + "description": "Legacy informational field; pass the actual worker path with --worktree" + }, "overlayFile": { "type": "string", "description": "项目覆盖层文件路径,默认 docs/ack/project.md,可自定义" diff --git a/skills/ack/templates/tasks.template.yaml b/skills/ack/templates/tasks.template.yaml index 6695809..4d20add 100644 --- a/skills/ack/templates/tasks.template.yaml +++ b/skills/ack/templates/tasks.template.yaml @@ -5,9 +5,7 @@ source: "Coordinator (PM) Agent" ackVersion: "<接入时的 ack skill 版本>" project: name: "" - repoPath: "" baseUrl: "" - devWorktree: "" overlayFile: "docs/ack/project.md" knowledgeFile: "docs/ack/knowledge.yaml" deliveryFile: "docs/ack/delivery.yaml" diff --git a/tests/test_ack_knowledge.py b/tests/test_ack_knowledge.py index 77cd053..e791fb8 100644 --- a/tests/test_ack_knowledge.py +++ b/tests/test_ack_knowledge.py @@ -612,7 +612,7 @@ class AckKnowledgeTests(unittest.TestCase): ) self.assertFalse(any("knowledgeFile" in error for error in errors)) - def test_cli_fails_closed_for_missing_repo_root_and_wrong_binding(self) -> None: + def test_cli_uses_tasks_layout_instead_of_stale_repo_path(self) -> None: data = example_data() task = { "id": "BUG-002", @@ -683,7 +683,7 @@ class AckKnowledgeTests(unittest.TestCase): ) self.assertEqual(inferred.returncode, 1) - self.assertIn("project.repoPath", inferred.stderr) + self.assertNotIn("project.repoPath", inferred.stderr) self.assertIn("与当前知识文件", inferred.stderr) self.assertIn("symlink", inferred.stderr) self.assertEqual(explicit.returncode, 1) diff --git a/tests/test_ack_launch_worker.py b/tests/test_ack_launch_worker.py index 0520ebe..95aceab 100644 --- a/tests/test_ack_launch_worker.py +++ b/tests/test_ack_launch_worker.py @@ -65,12 +65,20 @@ def orchestration() -> dict: def board(project_root: Path) -> dict: return { + "version": 1, "project": { + "name": "test-project", "repoPath": str(project_root), "orchestration": orchestration(), }, "workerReceipts": [], - "tasks": [{"id": "TASK-001"}], + "tasks": [ + { + "id": "TASK-001", + "title": "test task", + "status": "open", + } + ], } @@ -90,6 +98,8 @@ def worktree_identity(path: Path) -> dict: def plan_for(path: Path, executable: Path) -> dict: profile = orchestration()["profiles"]["codex-dev-standard"] + task_board = board(path) + board_hash = worker_profiles.canonical_sha256(task_board) identity = worktree_identity(path) executable_metadata = executable.stat() argv = worker_profiles.render_worker_argv( @@ -121,6 +131,8 @@ def plan_for(path: Path, executable: Path) -> dict: { "protocolVersion": 1, "backend": "orca", + "projectRoot": str(path), + "boardHash": board_hash, "profileId": "codex-dev-standard", "profileHash": profile_digest, "createdFor": created_for, @@ -134,6 +146,7 @@ def plan_for(path: Path, executable: Path) -> dict: "protocolVersion": 1, "backend": "orca", "projectRoot": str(path), + "boardHash": board_hash, "taskId": "TASK-001", "attemptId": "TASK-001-A1", "role": "developer", @@ -362,6 +375,44 @@ class EnvironmentAndExecutableTests(unittest.TestCase): class PlanTests(unittest.TestCase): + def test_authoritative_board_is_derived_from_project_root_without_repo_path(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + project = Path(temporary).resolve() + ack_dir = project / "docs" / "ack" + ack_dir.mkdir(parents=True) + task_board = board(project) + del task_board["project"]["repoPath"] + (ack_dir / "tasks.yaml").write_text( + json.dumps(task_board), + encoding="utf-8", + ) + + loaded_root, loaded_board = launch_worker.load_authoritative_board( + str(project) + ) + + self.assertEqual(loaded_root, project) + self.assertEqual(loaded_board, task_board) + + def test_authoritative_board_ignores_legacy_repo_path(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + project = Path(temporary).resolve() + ack_dir = project / "docs" / "ack" + ack_dir.mkdir(parents=True) + task_board = board(project) + task_board["project"]["repoPath"] = "/legacy/other-worktree" + (ack_dir / "tasks.yaml").write_text( + json.dumps(task_board), + encoding="utf-8", + ) + + loaded_root, loaded_board = launch_worker.load_authoritative_board( + str(project) + ) + + self.assertEqual(loaded_root, project) + self.assertEqual(loaded_board, task_board) + def test_plan_uses_exact_renderer_and_binds_created_for(self) -> None: with tempfile.TemporaryDirectory() as temporary: project = Path(temporary).resolve() @@ -410,6 +461,8 @@ class PlanTests(unittest.TestCase): { "protocolVersion": 1, "backend": "orca", + "projectRoot": str(project), + "boardHash": worker_profiles.canonical_sha256(task_board), "profileId": plan["profileId"], "profileHash": plan["profileHash"], "createdFor": { diff --git a/tests/test_ack_skill.py b/tests/test_ack_skill.py index 128902f..6966be8 100644 --- a/tests/test_ack_skill.py +++ b/tests/test_ack_skill.py @@ -67,7 +67,7 @@ class AckSkillContentTests(unittest.TestCase): ): self.assertTrue((ack_dir / relative_path).is_file(), relative_path) version = (ack_dir / "VERSION").read_text(encoding="utf-8").strip() - self.assertEqual(version, "0.14.0") + self.assertEqual(version, "0.14.1") self.assertIn( f'ackVersion: "{version}"', (ack_dir / "examples" / "tasks.example.yaml").read_text(encoding="utf-8"), @@ -84,6 +84,16 @@ class AckSkillContentTests(unittest.TestCase): ) self.assertTrue((ack_dir / "references" / "feishu-bug-intake.md").is_file()) + def test_task_template_derives_project_files_without_persisted_root_paths(self) -> None: + template = ( + REPO_ROOT / "skills" / "ack" / "templates" / "tasks.template.yaml" + ).read_text(encoding="utf-8") + + self.assertNotIn("repoPath:", template) + self.assertNotIn("devWorktree:", template) + self.assertIn("allowedWorktrees:", template) + self.assertIn('knowledgeFile: "docs/ack/knowledge.yaml"', template) + if __name__ == "__main__": unittest.main()