diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 00000000..a8abf88b --- /dev/null +++ b/.cursor/hooks.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "hooks": { + "beforeShellExecution": [ + { + "command": ".cursor/hooks/guard-git.sh", + "matcher": "^git ", + "failClosed": true + } + ] + } +} diff --git a/.cursor/hooks/guard-git.sh b/.cursor/hooks/guard-git.sh new file mode 100755 index 00000000..6bbfcf1f --- /dev/null +++ b/.cursor/hooks/guard-git.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Fail-closed red-line gate for destructive / unauthorized git. Does not replace agentctl leases. +set -euo pipefail +input=$(cat) +command=$(printf '%s' "$input" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("command") or "")') + +ask() { + local user_msg="$1" + local agent_msg="$2" + python3 -c 'import json,sys; print(json.dumps({"permission":"ask","user_message":sys.argv[1],"agent_message":sys.argv[2]}))' "$user_msg" "$agent_msg" + exit 0 +} + +allow() { + echo '{"permission":"allow"}' + exit 0 +} + +# Normalize whitespace for matching +cmd=$(printf '%s' "$command" | tr '\n' ' ') + +# Unauthorized push (user must confirm) +if printf '%s' "$cmd" | grep -Eq '(^|[[:space:]])git([[:space:]]+-[^[:space:]]+)*[[:space:]]+push([[:space:]]|$)'; then + ask "git push 需要显式用户授权(AGENTS remote_sync)。请确认后再继续。" \ + "Hook blocked git push pending user authorization." +fi + +# Destructive / force operations +if printf '%s' "$cmd" | grep -Eq 'git[[:space:]]+.*(--force|-f)([[:space:]]|$)' \ + || printf '%s' "$cmd" | grep -Eq 'git[[:space:]]+push[[:space:]].*\+'; then + ask "检测到 git --force / 强推。破坏性操作须用户明确授权。" \ + "Hook requires confirmation for force git operation." +fi + +if printf '%s' "$cmd" | grep -Eq 'git[[:space:]]+reset[[:space:]]+.*--hard'; then + ask "git reset --hard 会丢弃工作区。须用户明确授权。" \ + "Hook requires confirmation for git reset --hard." +fi + +if printf '%s' "$cmd" | grep -Eq 'git[[:space:]]+clean[[:space:]]+.*-[a-zA-Z]*f'; then + ask "git clean -f 会删除未跟踪文件。须用户明确授权。" \ + "Hook requires confirmation for git clean -f." +fi + +if printf '%s' "$cmd" | grep -Eq 'git[[:space:]]+branch[[:space:]]+(-D|--delete[[:space:]]+--force)'; then + ask "强制删分支 (-D) 须用户明确授权;优先 git branch -d。" \ + "Hook requires confirmation for git branch -D." +fi + +if printf '%s' "$cmd" | grep -Eq 'git[[:space:]]+worktree[[:space:]]+remove[[:space:]]+.*--force'; then + ask "git worktree remove --force 被 AGENTS 禁止升级。须用户明确授权。" \ + "Hook requires confirmation for forced worktree remove." +fi + +if printf '%s' "$cmd" | grep -Eq 'git[[:space:]]+checkout[[:space:]]+--\.($|[[:space:]])'; then + ask "git checkout -- . 会丢弃全部本地修改。须用户明确授权。" \ + "Hook requires confirmation for discarding all local changes." +fi + +allow diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 00000000..8ce9b5e2 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "agentctl": { + "command": "node", + "args": ["${workspaceFolder}/scripts/mcp/agentctl-mcp.mjs"] + } + } +} diff --git a/.cursor/rules/agentctl-mandatory.mdc b/.cursor/rules/agentctl-mandatory.mdc new file mode 100644 index 00000000..18124b69 --- /dev/null +++ b/.cursor/rules/agentctl-mandatory.mdc @@ -0,0 +1,23 @@ +--- +description: Agent-System 写操作必须经 agentctl(租约、docs apply、候选合入);禁止绕过治理直接改共享文档或 push。 +alwaysApply: true +--- + +# agentctl 强制治理 + +本仓库由 agentctl 管理面协调(SQLite + launchd daemon)。细则见 [AGENTS.md](../../AGENTS.md) 与 [操作规范](../../.doc/ops/2026-09-21-agent-worktree-workflow.md);流程技能见 `.cursor/skills/agentctl-*`。 + +## 硬边界 + +1. **写仓库**:必须 `agentctl with` / MCP `lease_run`。裸 `git commit` 会被 agentctl pre-commit hook 拒绝。 +2. **共享文档**:`.doc/ops/CURRENT.md`、`.doc/status/FEATURE-MATRIX.md` 只能 `docs apply`(行级 CAS;MATRIX 键为 `record_id`)。 +3. **合入 local dev**:`validate` 后 `queue enqueue`(daemon `--ff-only`)或受控 `integrate`;禁止直接改主目录 `dev` tip。 +4. **禁止默认**:未授权 `git push`、晋升 test/main、`--force` 清理、卸载 launchd coordinator、改 gap-matrix / 升 MATRIX 状态值冒充发布。 + +## 会话路径 + +- 启动 → skill `agentctl-session-start` +- 建场 → `agentctl-worktree-setup` +- 写回 → `agentctl-lease-write` +- 合入/收口 → `agentctl-integrate-closeout` +- MCP 工具定义见 `.cursor/mcp.json` → `scripts/mcp/agentctl-mcp.mjs` diff --git a/.cursor/skills/agentctl-integrate-closeout/SKILL.md b/.cursor/skills/agentctl-integrate-closeout/SKILL.md new file mode 100644 index 00000000..a482f248 --- /dev/null +++ b/.cursor/skills/agentctl-integrate-closeout/SKILL.md @@ -0,0 +1,53 @@ +--- +name: agentctl-integrate-closeout +description: Validate a merge candidate, enqueue for daemon ff-only integrate, and close out five-state. Use when ready to land a task on local dev. +--- + +# agentctl 集成与收口 + +权威:[操作规范](../../../.doc/ops/2026-09-21-agent-worktree-workflow.md)、[验证契约](../../../.doc/ops/2026-09-21-validation-contract.md)。常驻 daemon 已装载时走 **enqueue**,勿手动 `integrate`(除非 daemon 停)。 + +## 候选 + +```bash +D=$(git -C /path/to/Agent-System rev-parse HEAD) # 冻结 expected-dev +ID=integrate--YYYY-MM-DD +git worktree add -b feature/$ID .worktrees/$ID "$D" +cd .worktrees/$ID +python3 ../../scripts/agentctl.py task register --id "$ID" --owner Cursor --scope . --capability git:dev +python3 ../../scripts/agentctl.py with --task "$ID" \ + --resource path:. --resource git:index --resource git:dev -- bash -lc ' + git merge --no-ff -m "merge: candidate into dev" feature/ +' +``` + +第一父必须等于 `$D`。 + +## validate → enqueue(daemon ff) + +```bash +python3 ../../scripts/agentctl.py validate --task "$ID" -- <验收命令> +python3 ../../scripts/agentctl.py queue enqueue \ + --task "$ID" --expected-dev "$D" --head "$(git rev-parse HEAD)" +# 在任务 worktree 内 enqueue;数秒内 daemon 合入 +python3 ../../scripts/agentctl.py queue list +python3 ../../scripts/agentctl.py daemon status +``` + +验收按验证契约选行;治理脚本用 `unittest discover`。 + +## 五态收口 + +| 字段 | 典型值 | +|---|---| +| review_state | accepted | +| integration_state | merged_local_dev | +| deployment | not_required 或 loaded_local_launchd | +| cleanup | pending(独立维护者;禁止 `--force`) | +| remote_sync | not_requested | + +CURRENT 行 → `done`(`docs apply`);`task close --status done`。 + +## 清理前 + +确认 tip 为 local_dev 祖先、证据已持久化、无 tracked/untracked 遗留、无进程占用。唯一日志/对话归档进 `.doc/ops/evidence/` 后再 `worktree remove`。 diff --git a/.cursor/skills/agentctl-lease-write/SKILL.md b/.cursor/skills/agentctl-lease-write/SKILL.md new file mode 100644 index 00000000..c0d4726f --- /dev/null +++ b/.cursor/skills/agentctl-lease-write/SKILL.md @@ -0,0 +1,45 @@ +--- +name: agentctl-lease-write +description: Write under agentctl leases and docs apply CAS. Use before any git write, shared-doc edit, or when hitting UNMANAGED_DOCUMENT_CHANGE / EVIDENCE_REQUIRED. +--- + +# agentctl 租约写回 + +权威:[AGENTS §3–5](../../../AGENTS.md)、[操作规范](../../../.doc/ops/2026-09-21-agent-worktree-workflow.md)。 + +## 原则 + +- **任何仓库写**(含 `git add/commit`)必须在 `agentctl with` / MCP `lease_run` 租约内 +- CURRENT / FEATURE-MATRIX **只能** `docs apply`(行级 CAS;MATRIX 键为 `record_id`) +- 直接 `git commit` 会被 agentctl-managed pre-commit hook 拒绝 + +## lease_run(CLI) + +```bash +python3 scripts/agentctl.py with \ + --task "$ID" \ + --resource path:scripts --resource path:.doc --resource git:index \ + -- bash -lc 'git add … && git commit -m "…"' +``` + +MCP:`lease_run` 的 `command` 为 **argv 数组**(无额外 shell)。 + +## docs apply(行级) + +```bash +python3 scripts/agentctl.py docs snapshot --file .doc/ops/CURRENT.md --record "$RID" +# expected_sha256 = snapshot.sha256;改 replacement 后: +python3 scripts/agentctl.py docs apply --task "$ID" --request /tmp/req.json +``` + +FEATURE-MATRIX 升 `done` 需要 `docs:approve` + 可核验 evidence(`git cat-file -e`),否则 `EVIDENCE_REQUIRED`。 + +## 常见拒绝 + +| 码 | 处置 | +|---|---| +| `UNMANAGED_DOCUMENT_CHANGE` | 保留现场,先 snapshot/reconcile,勿覆盖 | +| `BASE_CHANGED` | 重新 snapshot,基于新 sha 再 apply | +| `AMBIGUOUS_RECORD` | 修重复 record_id / 模块名 | +| `CAPABILITY_REQUIRED` | 任务缺 docs:admin / docs:approve | +| `DOCUMENT_RECEIPT_REQUIRED` | 共享文档须经 apply 收据后再 commit | diff --git a/.cursor/skills/agentctl-session-start/SKILL.md b/.cursor/skills/agentctl-session-start/SKILL.md new file mode 100644 index 00000000..4c5792b0 --- /dev/null +++ b/.cursor/skills/agentctl-session-start/SKILL.md @@ -0,0 +1,30 @@ +--- +name: agentctl-session-start +description: Start an Agent-System session under agentctl governance. Use at session start, when opening a worktree, or before any write to this repo. +--- + +# agentctl 会话启动 + +权威规范:[AGENTS.md](../../../AGENTS.md)、[操作规范](../../../.doc/ops/2026-09-21-agent-worktree-workflow.md)、[CURRENT](../../../.doc/ops/CURRENT.md)。 + +## 步骤 + +1. 读 `AGENTS.md`、`PROJECT.md`、`.doc/ops/CURRENT.md` 活跃摘要(勿跳过)。 +2. 核对管理面: + +```bash +python3 scripts/agentctl.py daemon status +# 期望:status=ok;launchd 已装载时 manager.lock.held=true +``` + +或 MCP 工具 `status`。 + +3. 若任务已存在:在注册 worktree 内工作,确认 `tasks.state=active`。 +4. 若新任务:跟 [agentctl-worktree-setup](../agentctl-worktree-setup/SKILL.md)。 +5. 任何仓库写 → [agentctl-lease-write](../agentctl-lease-write/SKILL.md);合入 → [agentctl-integrate-closeout](../agentctl-integrate-closeout/SKILL.md)。 + +## 禁止 + +- 主目录直接开发提交;未授权 `git push` / 晋升 test/main +- 绕过 `docs apply` 改 CURRENT / FEATURE-MATRIX +- 卸载或重启 launchd coordinator(除非用户明示) diff --git a/.cursor/skills/agentctl-worktree-setup/SKILL.md b/.cursor/skills/agentctl-worktree-setup/SKILL.md new file mode 100644 index 00000000..287ce775 --- /dev/null +++ b/.cursor/skills/agentctl-worktree-setup/SKILL.md @@ -0,0 +1,48 @@ +--- +name: agentctl-worktree-setup +description: Create an agentctl task worktree, register scopes/capabilities, and claim CURRENT. Use when starting a new feature/fix task in Agent-System. +--- + +# agentctl 建场 + +权威:[操作规范](../../../.doc/ops/2026-09-21-agent-worktree-workflow.md)。 + +## 命令序列 + +```bash +cd /path/to/Agent-System # 主目录(dev) +D=$(git rev-parse HEAD) +ID= +WT=.worktrees/$ID +git worktree add -b feature/$ID "$WT" "$D" +cd "$WT" +python3 ../../scripts/agentctl.py task register \ + --id "$ID" --owner \ + --scope scripts --scope .doc \ + --capability docs:publish +# 需要整文/行级 admin 时再加 --capability docs:admin;证据升 done 加 docs:approve +``` + +## CURRENT 认领(docs apply) + +```bash +python3 ../../scripts/agentctl.py docs snapshot \ + --file .doc/ops/CURRENT.md --record "$ID" > /tmp/claim-snap.json +# 新建行:expected_sha256=null +cat > /tmp/claim.json < | $ID | feature/$ID | $WT | | in_progress |" +}]} +EOF +python3 ../../scripts/agentctl.py docs apply --task "$ID" --request /tmp/claim.json +``` + +## short-term 模板字段 + +`status`、`write_scope`、`mutex`、`base_dev_sha`、`review_state`/`integration_state`/`deployment`/`cleanup`/`remote_sync`。命名 `.doc/ops/short-term/YYYY-MM-DD-.md`。 + +## 注意 + +- 从确切 `dev` SHA 创建,不从他人 feature 分支启动 +- 重叠 path/mutex 只允许一个写者 diff --git a/.doc/ops/2026-09-23-agentctl-cursor-integration.md b/.doc/ops/2026-09-23-agentctl-cursor-integration.md new file mode 100644 index 00000000..d8eb6563 --- /dev/null +++ b/.doc/ops/2026-09-23-agentctl-cursor-integration.md @@ -0,0 +1,38 @@ +--- +title: "agentctl Cursor 能力层(skills / MCP / rules / hooks)" +status: active +created: 2026-09-23 +updated: 2026-09-23 +owner: Cursor +task_id: agentctl-cursor-integration-2026-09-23 +--- + +# agentctl Cursor 能力层 + +把已上线的 agentctl 管理面包装为 Cursor 原生能力,使 agent 默认走租约 / docs 收据 / 候选合入。 + +## 能力清单 + +| 层 | 路径 | 作用 | +|---|---|---| +| MCP | [`.cursor/mcp.json`](../../.cursor/mcp.json) → [`scripts/mcp/agentctl-mcp.mjs`](../../scripts/mcp/agentctl-mcp.mjs) | `status` / `task_*` / `lease_run` / `docs_*` / `validate` / `queue_enqueue` / `quarantine_status` | +| Skills | [`.cursor/skills/agentctl-*`](../../.cursor/skills/) | 会话启动、建场、租约写、集成收口 | +| Rule | [`.cursor/rules/agentctl-mandatory.mdc`](../../.cursor/rules/agentctl-mandatory.mdc) | alwaysApply:强制走 agentctl | +| Hooks | [`.cursor/hooks.json`](../../.cursor/hooks.json) + `guard-git.sh` | `git push` / 破坏性 git → `permission: ask` | + +权威规则仍是 [AGENTS.md](../../AGENTS.md) 与 [操作规范](./2026-09-21-agent-worktree-workflow.md);本目录不复制全文。 + +## 启用 + +1. 合入 local `dev` 后,在 Cursor 中 **Reload Window** 或重载 MCP。 +2. 确认 MCP 服务器 `agentctl` 出现在工具列表;hooks 出现在 Cursor Hooks 设置页。 +3. 会话开始跟 skill `agentctl-session-start`(核对 `daemon status`)。 + +## 与管理面关系 + +- launchd `com.agentctl.coordinator` 持续 `queue schedule`;本能力层只封装 CLI,不替代 daemon。 +- git hooks(pre-commit / pre-merge / reference-transaction)仍由 `agentctl hooks` 管理,与 Cursor `beforeShellExecution` 互补:前者执行租约/候选门禁,后者拦未授权 push/强推。 + +## 非目标 + +不 push;不晋升 test/main;不改 gap-matrix;不卸载 daemon。 diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index a0faa743..acf375a2 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -18,6 +18,7 @@ | owner | task | branch | worktree | mutex | status | |---|---|---|---|---|---| +| Cursor | agentctl-cursor-integration-2026-09-23 | feature/agentctl-cursor-integration-2026-09-23 | .worktrees/agentctl-cursor-integration-2026-09-23 | agentctl-cursor-integration | in_progress | | Cursor | agentctl-mgmt-go-live-close-2026-09-23 | feature/agentctl-mgmt-go-live-close-2026-09-23 | .worktrees/agentctl-mgmt-go-live-close-2026-09-23 | agentctl-mgmt-go-live-close | done | | Cursor | integrate-agentctl-mgmt-go-live-2026-09-23 | feature/integrate-agentctl-mgmt-go-live-2026-09-23 | .worktrees/integrate-agentctl-mgmt-go-live-2026-09-23 | git:dev | done | | Cursor | agentctl-mgmt-go-live-2026-09-23 | feature/agentctl-mgmt-go-live-2026-09-23 | .worktrees/agentctl-mgmt-go-live-2026-09-23 | agentctl-mgmt-go-live | done | diff --git a/.doc/ops/evidence/2026-09-23-agentctl-cursor-integration/hooks-check.txt b/.doc/ops/evidence/2026-09-23-agentctl-cursor-integration/hooks-check.txt new file mode 100644 index 00000000..37f7ffb0 --- /dev/null +++ b/.doc/ops/evidence/2026-09-23-agentctl-cursor-integration/hooks-check.txt @@ -0,0 +1,6 @@ +OK status -> allow +OK push -> ask +OK reset -> ask +OK clean -> ask +OK force-wt -> ask +hooks OK diff --git a/.doc/ops/evidence/2026-09-23-agentctl-cursor-integration/mcp-summary.json b/.doc/ops/evidence/2026-09-23-agentctl-cursor-integration/mcp-summary.json new file mode 100644 index 00000000..626c49c3 --- /dev/null +++ b/.doc/ops/evidence/2026-09-23-agentctl-cursor-integration/mcp-summary.json @@ -0,0 +1,20 @@ +{ + "initialize": { + "name": "agentctl", + "version": "1.0.0" + }, + "tools": [ + "status", + "task_register", + "task_close", + "lease_run", + "docs_snapshot", + "docs_apply", + "validate", + "queue_enqueue", + "quarantine_status" + ], + "status_ok": true, + "daemon_ok": true, + "daemon_held": true +} \ No newline at end of file diff --git a/.doc/ops/evidence/2026-09-23-agentctl-cursor-integration/queue-retry-note.txt b/.doc/ops/evidence/2026-09-23-agentctl-cursor-integration/queue-retry-note.txt new file mode 100644 index 00000000..4338afa4 --- /dev/null +++ b/.doc/ops/evidence/2026-09-23-agentctl-cursor-integration/queue-retry-note.txt @@ -0,0 +1 @@ +queue retry after parking untracked promotion receipts (DIRTY_TREE) at /tmp/ci-hold-receipts diff --git a/.doc/ops/evidence/2026-09-23-agentctl-cursor-integration/skills-check.txt b/.doc/ops/evidence/2026-09-23-agentctl-cursor-integration/skills-check.txt new file mode 100644 index 00000000..6351522a --- /dev/null +++ b/.doc/ops/evidence/2026-09-23-agentctl-cursor-integration/skills-check.txt @@ -0,0 +1,5 @@ +OK agentctl-session-start lines 30 +OK agentctl-worktree-setup lines 48 +OK agentctl-lease-write lines 45 +OK agentctl-integrate-closeout lines 53 +skills OK diff --git a/.doc/ops/evidence/2026-09-23-agentctl-cursor-integration/unittest-summary.txt b/.doc/ops/evidence/2026-09-23-agentctl-cursor-integration/unittest-summary.txt new file mode 100644 index 00000000..c791d3f6 --- /dev/null +++ b/.doc/ops/evidence/2026-09-23-agentctl-cursor-integration/unittest-summary.txt @@ -0,0 +1,2 @@ +146:Ran 134 tests in 122.988s +148:OK (skipped=3) diff --git a/.doc/ops/short-term/2026-09-23-agentctl-cursor-integration.md b/.doc/ops/short-term/2026-09-23-agentctl-cursor-integration.md new file mode 100644 index 00000000..476048e7 --- /dev/null +++ b/.doc/ops/short-term/2026-09-23-agentctl-cursor-integration.md @@ -0,0 +1,57 @@ +--- +title: "agentctl Cursor 能力层集成" +task_id: agentctl-cursor-integration-2026-09-23 +status: in_progress +created: 2026-09-23 +updated: 2026-09-23 +owner: Cursor +task_type: implementation +task_tier: single +policy_revision: "agent-governance-2026-09-21-r1 @ 357e4ddb0fe62e12cf6d1321382376257b0fe674" +branch: feature/agentctl-cursor-integration-2026-09-23 +worktree: .worktrees/agentctl-cursor-integration-2026-09-23 +base_dev_sha: "357e4ddb0fe62e12cf6d1321382376257b0fe674" +write_scope: [".cursor", "scripts", ".doc", ".gitignore"] +allowed_environments: [] +mutex: agentctl-cursor-integration +review_state: pending +integration_state: pending +deployment: not_required +cleanup: pending +cleanup_owner: "独立维护者" +cleanup_trigger: "合入 local_dev 且无进程占用后复查" +remote_sync: not_requested +--- + +# agentctl Cursor 能力层集成 + +## 用户目标 + +为 agentctl 规范建 Cursor skills / MCP / rules / hooks,并用本系统管理本项目。 + +## 范围 + +- `scripts/mcp/agentctl-mcp.mjs` + `.cursor/mcp.json` +- `.cursor/skills/agentctl-{session-start,worktree-setup,lease-write,integrate-closeout}/` +- `.cursor/rules/agentctl-mandatory.mdc` +- `.cursor/hooks.json` + `guard-git.sh` +- `.gitignore` 放行 skills/hooks/mcp +- `.doc/ops/2026-09-23-agentctl-cursor-integration.md` + +## 验收 + +1. MCP tools/list + status +2. skills/rules frontmatter +3. guard-git:push/reset --hard → ask;status → allow +4. unittest discover 无回归 +5. enqueue → daemon 合入 + +## 收口 + +| 字段 | 值 | +|---|---| +| review_state | pending | +| integration_state | pending | +| deployment | not_required | +| cleanup | pending | +| remote_sync | not_requested | diff --git a/.gitignore b/.gitignore index 67a7b4e1..85d58d3d 100644 --- a/.gitignore +++ b/.gitignore @@ -42,10 +42,16 @@ temp/ *.swp *.swo *~ -# Note: Keep .cursor/rules tracked for team/project rules +# Note: Keep project Cursor governance tracked (rules/skills/hooks/mcp) .cursor/* !.cursor/rules/ !.cursor/rules/** +!.cursor/skills/ +!.cursor/skills/** +!.cursor/hooks.json +!.cursor/hooks/ +!.cursor/hooks/** +!.cursor/mcp.json # Build artifacts & Binaries dist/ diff --git a/scripts/mcp/README.md b/scripts/mcp/README.md new file mode 100644 index 00000000..8edb4b61 --- /dev/null +++ b/scripts/mcp/README.md @@ -0,0 +1,12 @@ +# agentctl MCP (stdio) + +Zero-dependency Node MCP server wrapping `scripts/agentctl.py`. + +```bash +# from repo root +node scripts/mcp/agentctl-mcp.mjs +``` + +Cursor loads it via [`.cursor/mcp.json`](../../.cursor/mcp.json). Reload MCP after checkout. + +`lease_run` / `validate` take `command` as an argv array (no shell). Set `AGENTCTL_REPO` only when the script is not under the repo tree. diff --git a/scripts/mcp/agentctl-mcp.mjs b/scripts/mcp/agentctl-mcp.mjs new file mode 100755 index 00000000..4a9b25da --- /dev/null +++ b/scripts/mcp/agentctl-mcp.mjs @@ -0,0 +1,310 @@ +#!/usr/bin/env node +/** + * agentctl MCP (stdio, zero deps). Thin argv wrappers around scripts/agentctl.py. + * lease_run passes command as argv after `--` — no extra shell. + */ +import { spawnSync } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import fs from 'node:fs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO = process.env.AGENTCTL_REPO + ? path.resolve(process.env.AGENTCTL_REPO) + : path.resolve(__dirname, '../..'); +const AGENTCTL = path.join(REPO, 'scripts', 'agentctl.py'); + +function runAgentctl(args, { cwd = REPO, timeout = 120000 } = {}) { + if (!fs.existsSync(AGENTCTL)) { + return { ok: false, code: 127, stdout: '', stderr: `agentctl missing: ${AGENTCTL}` }; + } + const r = spawnSync('python3', ['-B', AGENTCTL, ...args], { + cwd, + encoding: 'utf8', + timeout, + maxBuffer: 8 * 1024 * 1024, + env: process.env, + }); + return { + ok: r.status === 0, + code: r.status ?? 1, + stdout: r.stdout || '', + stderr: r.stderr || '', + error: r.error ? String(r.error) : undefined, + }; +} + +function textResult(payload) { + const body = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2); + return { content: [{ type: 'text', text: body }] }; +} + +function errResult(msg) { + return { content: [{ type: 'text', text: msg }], isError: true }; +} + +const TOOLS = [ + { + name: 'status', + description: 'agentctl status + daemon status for session start checks', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + { + name: 'task_register', + description: 'Register a task with scopes and optional capabilities', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string' }, + owner: { type: 'string' }, + scopes: { type: 'array', items: { type: 'string' } }, + capabilities: { type: 'array', items: { type: 'string' } }, + }, + required: ['id', 'owner', 'scopes'], + additionalProperties: false, + }, + }, + { + name: 'task_close', + description: 'Close a task as done or cancelled', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string' }, + status: { type: 'string', enum: ['done', 'cancelled'] }, + }, + required: ['id'], + additionalProperties: false, + }, + }, + { + name: 'lease_run', + description: 'Run argv under agentctl with lease (resources). Command is argv array, no shell.', + inputSchema: { + type: 'object', + properties: { + task: { type: 'string' }, + resources: { type: 'array', items: { type: 'string' } }, + command: { type: 'array', items: { type: 'string' }, minItems: 1 }, + cwd: { type: 'string', description: 'optional worktree absolute path' }, + }, + required: ['task', 'resources', 'command'], + additionalProperties: false, + }, + }, + { + name: 'docs_snapshot', + description: 'Snapshot a shared doc row for CAS apply', + inputSchema: { + type: 'object', + properties: { + file: { type: 'string' }, + record: { type: 'string' }, + }, + required: ['file', 'record'], + additionalProperties: false, + }, + }, + { + name: 'docs_apply', + description: 'Apply a docs request JSON file under a task', + inputSchema: { + type: 'object', + properties: { + task: { type: 'string' }, + request: { type: 'string', description: 'path to request JSON' }, + }, + required: ['task', 'request'], + additionalProperties: false, + }, + }, + { + name: 'validate', + description: 'Validate candidate HEAD with an explicit command argv', + inputSchema: { + type: 'object', + properties: { + task: { type: 'string' }, + command: { type: 'array', items: { type: 'string' }, minItems: 1 }, + cwd: { type: 'string' }, + }, + required: ['task', 'command'], + additionalProperties: false, + }, + }, + { + name: 'queue_enqueue', + description: 'Enqueue a validated candidate for daemon ff-only integrate', + inputSchema: { + type: 'object', + properties: { + task: { type: 'string' }, + expected_dev: { type: 'string' }, + head: { type: 'string' }, + priority: { type: 'number' }, + }, + required: ['task', 'expected_dev'], + additionalProperties: false, + }, + }, + { + name: 'quarantine_status', + description: 'List quarantine / suspect runs', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, +]; + +function callTool(name, args = {}) { + switch (name) { + case 'status': { + const a = runAgentctl(['status']); + const b = runAgentctl(['daemon', 'status']); + return textResult({ + repo: REPO, + status: { ok: a.ok, code: a.code, stdout: a.stdout.trim(), stderr: a.stderr.trim() }, + daemon: { ok: b.ok, code: b.code, stdout: b.stdout.trim(), stderr: b.stderr.trim() }, + }); + } + case 'task_register': { + const argv = ['task', 'register', '--id', args.id, '--owner', args.owner]; + for (const s of args.scopes || []) argv.push('--scope', s); + for (const c of args.capabilities || []) argv.push('--capability', c); + const r = runAgentctl(argv); + return r.ok ? textResult(r.stdout.trim() || r.stderr.trim()) : errResult(r.stderr || r.stdout || r.error); + } + case 'task_close': { + const argv = ['task', 'close', '--id', args.id, '--status', args.status || 'done']; + const r = runAgentctl(argv); + return r.ok ? textResult(r.stdout.trim() || r.stderr.trim()) : errResult(r.stderr || r.stdout || r.error); + } + case 'lease_run': { + if (!Array.isArray(args.command) || args.command.length < 1) { + return errResult('lease_run.command must be a non-empty argv array'); + } + const argv = ['with', '--task', args.task]; + for (const res of args.resources || []) argv.push('--resource', res); + argv.push('--', ...args.command); + const r = runAgentctl(argv, { cwd: args.cwd || REPO, timeout: 600000 }); + const body = [r.stdout, r.stderr].filter(Boolean).join('\n').trim(); + return r.ok ? textResult(body) : errResult(body || r.error || `exit ${r.code}`); + } + case 'docs_snapshot': { + const r = runAgentctl(['docs', 'snapshot', '--file', args.file, '--record', args.record]); + return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error); + } + case 'docs_apply': { + const r = runAgentctl(['docs', 'apply', '--task', args.task, '--request', args.request]); + return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error); + } + case 'validate': { + if (!Array.isArray(args.command) || args.command.length < 1) { + return errResult('validate.command must be a non-empty argv array'); + } + const argv = ['validate', '--task', args.task, '--', ...args.command]; + const r = runAgentctl(argv, { cwd: args.cwd || REPO, timeout: 600000 }); + const body = [r.stdout, r.stderr].filter(Boolean).join('\n').trim(); + return r.ok ? textResult(body) : errResult(body || r.error || `exit ${r.code}`); + } + case 'queue_enqueue': { + const argv = ['queue', 'enqueue', '--task', args.task, '--expected-dev', args.expected_dev]; + if (args.head) argv.push('--head', args.head); + if (args.priority != null) argv.push('--priority', String(args.priority)); + const r = runAgentctl(argv, { cwd: args.cwd || REPO }); + return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error); + } + case 'quarantine_status': { + const r = runAgentctl(['quarantine', 'list']); + return r.ok ? textResult(r.stdout.trim() || '{"items":[]}') : errResult(r.stderr || r.stdout || r.error); + } + default: + return errResult(`unknown tool: ${name}`); + } +} + +function send(msg) { + const body = JSON.stringify(msg); + process.stdout.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`); +} + +function handle(msg) { + if (!msg || typeof msg !== 'object') return; + if (msg.method && msg.id === undefined) { + // notification — ignore + return; + } + const { id, method, params } = msg; + try { + if (method === 'initialize') { + send({ + jsonrpc: '2.0', + id, + result: { + protocolVersion: params?.protocolVersion || '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: 'agentctl', version: '1.0.0' }, + }, + }); + return; + } + if (method === 'tools/list') { + send({ jsonrpc: '2.0', id, result: { tools: TOOLS } }); + return; + } + if (method === 'tools/call') { + const name = params?.name; + const args = params?.arguments || {}; + const result = callTool(name, args); + send({ jsonrpc: '2.0', id, result }); + return; + } + if (method === 'ping') { + send({ jsonrpc: '2.0', id, result: {} }); + return; + } + send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } }); + } catch (e) { + send({ jsonrpc: '2.0', id, error: { code: -32000, message: String(e?.stack || e) } }); + } +} + +// Support both Content-Length framed MCP and newline-delimited JSON (for tests). +let buf = Buffer.alloc(0); +process.stdin.on('data', (chunk) => { + buf = Buffer.concat([buf, chunk]); + while (true) { + const headerEnd = buf.indexOf('\r\n\r\n'); + if (headerEnd !== -1) { + const header = buf.slice(0, headerEnd).toString('utf8'); + const m = /Content-Length:\s*(\d+)/i.exec(header); + if (!m) { + buf = buf.slice(headerEnd + 4); + continue; + } + const len = Number(m[1]); + const start = headerEnd + 4; + if (buf.length < start + len) return; + const body = buf.slice(start, start + len).toString('utf8'); + buf = buf.slice(start + len); + try { + handle(JSON.parse(body)); + } catch (e) { + process.stderr.write(`parse error: ${e}\n`); + } + continue; + } + // newline-delimited fallback for harness + const nl = buf.indexOf('\n'); + if (nl === -1) return; + const line = buf.slice(0, nl).toString('utf8').trim(); + buf = buf.slice(nl + 1); + if (!line || line.startsWith('Content-Length')) continue; + try { + handle(JSON.parse(line)); + } catch (e) { + process.stderr.write(`ndjson parse error: ${e}\n`); + } + } +}); + +process.stdin.on('end', () => process.exit(0));