From b5a4102826a9c3e0555fbf22abdcdf5eefb27bf1 Mon Sep 17 00:00:00 2001 From: idan Date: Sat, 5 Sep 2026 00:02:42 +0800 Subject: [PATCH] feat: sync generic context capabilities --- .agents/skills/plan-go/scripts/loop_spec.sh | 8 +-- .agents/skills/plan-go/tests/test_plan_go.py | 7 +++ .agents/skills/task-board/SKILL.md | 2 + .agents/skills/task-board/scripts/task | 57 +++++++++++++++++++ .../skills/task-board/tests/test_task_cli.py | 47 ++++++++++++++- .gitignore | 1 + init.sh | 3 + tests/test_init.py | 14 +++++ 8 files changed, 134 insertions(+), 5 deletions(-) diff --git a/.agents/skills/plan-go/scripts/loop_spec.sh b/.agents/skills/plan-go/scripts/loop_spec.sh index 62682aa..864b390 100755 --- a/.agents/skills/plan-go/scripts/loop_spec.sh +++ b/.agents/skills/plan-go/scripts/loop_spec.sh @@ -26,9 +26,9 @@ $goal - Confirm the plan is Aligned and execution is allowed. - Treat that registered aligned plan as the goal source and implementation facts as owned by the target repository. - If the gate fails, route to task-plan or stop for alignment before substantive work. -- Record goal tooling as an object with a non-empty runtime, `used=true`, and `mechanism=task-board-plan` when the registered task/plan is the goal. -- Use `mechanism=native-goal+task-board-plan` only when a native runtime goal mirrors that registered task/plan. -- Keep `task_id` as the auditable registered goal identifier; the runtime name does not imply native goal usage. +- Record goal tooling as an object with a non-empty runtime, 'used=true', and 'mechanism=task-board-plan' when the registered task/plan is the goal. +- Use 'mechanism=native-goal+task-board-plan' only when a native runtime goal mirrors that registered task/plan. +- Keep 'task_id' as the auditable registered goal identifier; the runtime name does not imply native goal usage. - Reject chat-only, verbal, or arbitrary goal-mechanism claims. ## Acceptance Checks @@ -42,7 +42,7 @@ $goal ## Role Independence Policy - Main orchestrates and integrates: confirm the goal, hand off prompts, integrate accepted output, run mechanical checks, record evidence, and choose routes. - Executor produces the smallest sufficient change and reports artifacts, commands/results, validation entry points, and uncertainties. -- Executor artifacts must identify either a safe existing repository-relative `local_path` or an HTTP(S) `external_url`; bare strings are not evidence. +- Executor artifacts must identify either a safe existing repository-relative 'local_path' or an HTTP(S) 'external_url'; bare strings are not evidence. - Evaluator pressure-tests the result against the original goal and reports categorized findings with evidence. - Main must not claim executor or evaluator output as its own. - If independent agent tools are available, use separate executor and evaluator agents. Main-thread implementation plus self-review is not a complete adversarial loop. diff --git a/.agents/skills/plan-go/tests/test_plan_go.py b/.agents/skills/plan-go/tests/test_plan_go.py index f9b5b66..09aa703 100644 --- a/.agents/skills/plan-go/tests/test_plan_go.py +++ b/.agents/skills/plan-go/tests/test_plan_go.py @@ -209,9 +209,16 @@ def test_adapted_source_retains_mit_notice(self): def test_loop_spec_is_repository_relative_and_names_roles(self): result = run(SPEC, "Execute the plan") self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("", result.stderr) self.assertIn("Execute the plan", result.stdout) self.assertIn("Executor Prompt", result.stdout) self.assertIn("Evaluator Prompt", result.stdout) + self.assertIn("'used=true'", result.stdout) + self.assertIn("'mechanism=task-board-plan'", result.stdout) + self.assertIn("'mechanism=native-goal+task-board-plan'", result.stdout) + self.assertIn("'task_id'", result.stdout) + self.assertIn("'local_path'", result.stdout) + self.assertIn("'external_url'", result.stdout) self.assertIn("out-of-scope findings as follow-up work", result.stdout.lower()) self.assertIn("acceptance conditions are satisfied", result.stdout.lower()) self.assertNotIn("~/.codex", result.stdout) diff --git a/.agents/skills/task-board/SKILL.md b/.agents/skills/task-board/SKILL.md index 26ad787..585ca83 100644 --- a/.agents/skills/task-board/SKILL.md +++ b/.agents/skills/task-board/SKILL.md @@ -28,10 +28,12 @@ scripts/task validate scripts/task list [--status STATUS] [--format table|yaml|json] [--tree] scripts/task show TASK_ID [--format yaml|json] scripts/task add-task --id ID --title TITLE --domain DOMAIN [options] +scripts/task set-title TASK_ID TITLE scripts/task register-plan PLAN --id ID --domain DOMAIN --repo REPO [options] scripts/task set-status TASK_ID STATUS scripts/task set-plan-metadata TASK_ID PLAN [routing options] scripts/task add-note TASK_ID NOTE +scripts/task remove-note TASK_ID NOTE scripts/task add-evidence TASK_ID --type TYPE --status STATUS --summary TEXT [--path PATH] scripts/task link-sub-task PARENT_TASK_ID CHILD_TASK_ID scripts/task unlink-sub-task PARENT_TASK_ID CHILD_TASK_ID diff --git a/.agents/skills/task-board/scripts/task b/.agents/skills/task-board/scripts/task index 258d2c6..52fe7c5 100755 --- a/.agents/skills/task-board/scripts/task +++ b/.agents/skills/task-board/scripts/task @@ -665,6 +665,29 @@ def cmd_set_status(args: argparse.Namespace) -> int: return 0 +def cmd_set_title(args: argparse.Namespace) -> int: + board, yaml, tasks, _statuses = checked_board(args.board) + task = find_task(tasks, args.task_id) + title = args.title.strip() + if not title: + raise TaskError("title must be non-empty") + old_title = task.get("title") + if old_title == title: + print(f"unchanged: {task['id']} title already matches") + return 0 + now = utc_now() + task["title"] = title + task["updated_at"] = now + board["updated_at"] = now + _tasks, _statuses, errors = validate_board(board) + if errors: + task["title"] = old_title + raise TaskError("title change would make board invalid:\n" + "\n".join(f"- {error}" for error in errors)) + write_board(args.board, board, yaml) + print(f"updated: {task['id']} title") + return 0 + + def cmd_set_plan_metadata(args: argparse.Namespace) -> int: board, yaml, tasks, statuses = checked_board(args.board) task = find_task(tasks, args.task_id) @@ -874,6 +897,28 @@ def cmd_add_note(args: argparse.Namespace) -> int: return 0 +def cmd_remove_note(args: argparse.Namespace) -> int: + board, yaml, tasks, _statuses = checked_board(args.board) + task = find_task(tasks, args.task_id) + notes = task.setdefault("notes", []) + if args.note not in notes: + print(f"unchanged: {task['id']} does not have this note") + return 0 + notes.remove(args.note) + now = utc_now() + task["updated_at"] = now + board["updated_at"] = now + _tasks, _statuses, errors = validate_board(board) + if errors: + raise TaskError( + "note removal would make board invalid:\n" + + "\n".join(f"- {error}" for error in errors) + ) + write_board(args.board, board, yaml) + print(f"updated: {task['id']} notes -= 1") + return 0 + + def cmd_link_sub_task(args: argparse.Namespace) -> int: board, yaml, tasks, _statuses = checked_board(args.board) parent = find_task(tasks, args.parent_task_id) @@ -1069,6 +1114,12 @@ def build_parser() -> argparse.ArgumentParser: set_status.add_argument("status") set_status.set_defaults(func=cmd_set_status) + set_title = subparsers.add_parser("set-title", help="Rename a task title.") + add_board_arg(set_title) + set_title.add_argument("task_id") + set_title.add_argument("title") + set_title.set_defaults(func=cmd_set_title) + set_plan_metadata = subparsers.add_parser( "set-plan-metadata", help="Refresh an existing plan link/status and atomically update its execution routing.", @@ -1149,6 +1200,12 @@ def build_parser() -> argparse.ArgumentParser: add_note.add_argument("note") add_note.set_defaults(func=cmd_add_note) + remove_note = subparsers.add_parser("remove-note", help="Remove an exact note from a task.") + add_board_arg(remove_note) + remove_note.add_argument("task_id") + remove_note.add_argument("note") + remove_note.set_defaults(func=cmd_remove_note) + link_sub_task = subparsers.add_parser( "link-sub-task", help="Link an existing task as a child of another task." ) diff --git a/.agents/skills/task-board/tests/test_task_cli.py b/.agents/skills/task-board/tests/test_task_cli.py index 2a5003e..b555232 100644 --- a/.agents/skills/task-board/tests/test_task_cli.py +++ b/.agents/skills/task-board/tests/test_task_cli.py @@ -202,7 +202,19 @@ def test_add_note_evidence_and_status_transitions_are_idempotent(self) -> None: "add-note", "example.release", "Synthetic review note." ) self.assertEqual(result, 0, stderr) - self.assertEqual(self.task("example.release")["notes"], ["Synthetic review note."]) + self.assertEqual(self.task("example.release")["notes"], ["Synthetic review note."]) + + for _ in range(2): + result, _stdout, stderr = self.run_cli( + "remove-note", "example.release", "Synthetic review note." + ) + self.assertEqual(result, 0, stderr) + self.assertEqual(self.task("example.release")["notes"], []) + + result, _stdout, stderr = self.run_cli( + "add-note", "example.release", "Synthetic review note." + ) + self.assertEqual(result, 0, stderr) evidence_args = ( "add-evidence", @@ -230,6 +242,25 @@ def test_add_note_evidence_and_status_transitions_are_idempotent(self) -> None: self.assertEqual(result, 0, stderr) self.assertEqual(self.task("example.release")["status"], "done") + def test_set_title_is_validated_and_idempotent(self) -> None: + self.add_task("example.rename") + result, _stdout, stderr = self.run_cli( + "set-title", "example.rename", "Renamed task" + ) + self.assertEqual(result, 0, stderr) + self.assertEqual(self.task("example.rename")["title"], "Renamed task") + + result, stdout, stderr = self.run_cli( + "set-title", "example.rename", "Renamed task" + ) + self.assertEqual(result, 0, stderr) + self.assertIn("unchanged", stdout) + + result, _stdout, stderr = self.run_cli("set-title", "example.rename", " ") + self.assertEqual(result, 1) + self.assertIn("non-empty", stderr) + self.assertEqual(self.task("example.rename")["title"], "Renamed task") + def test_status_failure_and_blocked_intake_are_atomic(self) -> None: self.add_task("example.unplanned") before = self.board_path.read_bytes() @@ -252,6 +283,20 @@ def test_status_failure_and_blocked_intake_are_atomic(self) -> None: self.assertEqual(result, 0, stderr) self.assertTrue(self.task("example.blocked")["notes"]) + def test_remove_sole_blocker_note_is_rejected_without_writes(self) -> None: + self.add_task("example.blocked-note", status="blocked") + notes_before = list(self.task("example.blocked-note")["notes"]) + self.assertEqual(len(notes_before), 1) + before = self.board_path.read_bytes() + + result, _stdout, stderr = self.run_cli( + "remove-note", "example.blocked-note", notes_before[0] + ) + self.assertEqual(result, 1) + self.assertIn("would make board invalid", stderr) + self.assertEqual(self.board_path.read_bytes(), before) + self.assertEqual(self.task("example.blocked-note")["notes"], notes_before) + def test_register_plan_creates_and_attaches_with_strict_pairing(self) -> None: aligned = self.make_plan("register.md", title="Checkout registration") result, _stdout, stderr = self.run_cli( diff --git a/.gitignore b/.gitignore index 2da9dc7..76223e8 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ tmp/ *.swp # Local-only material must not be published. +.playwright-mcp/ .env .env.* !.env.example diff --git a/init.sh b/init.sh index 02df557..42a6b61 100755 --- a/init.sh +++ b/init.sh @@ -32,6 +32,9 @@ files=( scripts/task tasks/board.yaml tasks/task.schema.json + tests/__init__.py + tests/helpers.py + tests/test_claude_compat.py .agents/skills/task-board/SKILL.md .agents/skills/task-board/scripts/task .agents/skills/task-plan/SKILL.md diff --git a/tests/test_init.py b/tests/test_init.py index 19ffc0b..b1ed72b 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import sys import tempfile import unittest from pathlib import Path @@ -26,6 +27,9 @@ def test_initializes_an_empty_workspace_with_spaces(self) -> None: "scripts/task", "tasks/board.yaml", "tasks/task.schema.json", + "tests/__init__.py", + "tests/helpers.py", + "tests/test_claude_compat.py", ".agents/skills/task-board/SKILL.md", ".agents/skills/task-plan/SKILL.md", ".agents/skills/plan-go/SKILL.md", @@ -65,6 +69,16 @@ def test_initializes_an_empty_workspace_with_spaces(self) -> None: validation = run(destination / "scripts/task", "validate", cwd=destination) self.assertEqual(0, validation.returncode, validation.stderr) + compatibility = run( + sys.executable, + "-m", + "unittest", + "tests.test_claude_compat", + "-v", + cwd=destination, + ) + self.assertEqual(0, compatibility.returncode, compatibility.stderr) + def test_repeated_initialization_is_idempotent(self) -> None: with tempfile.TemporaryDirectory() as directory: destination = Path(directory) / "project"