diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 50bd0998..335736a4 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -4,8 +4,8 @@ "name": "YoungjaeDev" }, "metadata": { - "description": "Personal Claude Code plugin collection with 24 specialized plugins. Codex 0.135 + Hermes Agent native — generated `.agents/plugins/marketplace.json` + per-plugin `.codex-plugin/plugin.json` (`scripts/sync-codex-manifests.mjs`) and Hermes `plugin.yaml` + `__init__.py` adapters (`scripts/sync-hermes-manifests.mjs`).", - "version": "2.7.4" + "description": "Personal Claude Code plugin collection with 25 specialized plugins. Codex 0.135 + Hermes Agent native — generated `.agents/plugins/marketplace.json` + per-plugin `.codex-plugin/plugin.json` (`scripts/sync-codex-manifests.mjs`) and Hermes `plugin.yaml` + `__init__.py` adapters (`scripts/sync-hermes-manifests.mjs`).", + "version": "2.8.0" }, "plugins": [ { @@ -134,6 +134,13 @@ "version": "1.3.2", "category": "ai-models" }, + { + "name": "council", + "source": "./plugins/council", + "description": "Cross-vendor model council. codex (GPT), agy (Gemini), and a Claude Opus seat answer independently, surface follow-up questions to the user, then rebut each other before the chair synthesizes. Seat models live in a weekly-TTL registry at ~/.claude/council-models.json. Claude-only; excluded from Codex sync.", + "version": "0.1.0", + "category": "ai-models" + }, { "name": "anti-slop-design", "source": "./plugins/anti-slop-design", diff --git a/.claude/settings.json b/.claude/settings.json index 0701fd71..ee09a877 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -21,6 +21,7 @@ "./plugins/tally-form", "./plugins/ppt-yeong-style", "./plugins/codex-image", + "./plugins/council", "./plugins/brightdata-guide", "./plugins/gws-sync", "./plugins/mem0-ops", diff --git a/.claude/spec/2026-07-29-council.md b/.claude/spec/2026-07-29-council.md new file mode 100644 index 00000000..c6ddea3d --- /dev/null +++ b/.claude/spec/2026-07-29-council.md @@ -0,0 +1,349 @@ +# Feature Specification: council 플러그인 + +## 한눈에 보기 + +서로 다른 회사가 만든 세 개의 언어 모델에게 같은 질문을 던지고, 그 셋이 서로의 답을 읽고 반박한 뒤, +합의된 결론과 남은 이견을 하나의 문서로 정리해 주는 플러그인이다. 세 모델은 각각 OpenAI 의 codex, +Google 의 agy(Antigravity), Anthropic 의 Claude Opus 이며, 회의를 진행하고 최종 정리를 맡는 +의장 역할은 사용자가 평소에 쓰는 메인 세션(Claude Opus)이 맡는다. + +이 플러그인이 존재하는 이유는 하나다. Claude 세션이 자기 자신을 하위 에이전트로 여러 개 띄우는 것은 +관점을 늘려 주지 않는다. 같은 모델이 같은 방식으로 틀리기 때문이다. 진짜로 다른 판단을 보려면 +다른 회사가 다른 데이터로 훈련한 모델에게 물어야 하고, 이 플러그인은 그 과정을 매번 손으로 +명령어를 조립하지 않고도 반복할 수 있게 만든다. + +부수적으로 두 가지 문제를 같이 해결한다. 첫째, 모델 이름은 몇 달마다 바뀌는데 사용자가 그걸 매번 +기억할 수 없으므로 이름을 파일에 고정해 두고 일주일에 한 번만 확인한다. 둘째, codex 와 agy 는 +Claude Code 만큼 주변 설정이 갖춰져 있지 않으므로, 질문을 넘기기 전에 그들이 스스로 찾을 수 없는 +정보를 미리 모아서 프롬프트에 실어 준다. + +## 구성 요소 관계도 + +``` + 사용자 요청 (자연어) + | + v + +-------------------------------------------+ + | 의장 = 메인 Claude 세션 (Opus) | + | /council:convene 스킬이 이 절차를 지시함 | + +-------------------------------------------+ + | + (0) 모델 레지스트리 확인 -- ~/.claude/council-models.json + TTL 만료 시에만 사용자에게 질문 + | + (1) 사전 컨텍스트 수집 -- 파일로 환원 불가능한 것만 + mem0 기억 / Serena 심볼 그래프 / code-scout 외부 리서치 + | + v + +----------+ +----------+ +--------------+ + | codex | | agy | | Claude 좌석 | <- 라운드 1: 독립 의견 + | gpt-5.6 | | gemini | | Agent 도구 | + 사용자에게 물을 + | -sol | | -3.6-... | | model=opus | 재질문 제출 + +----------+ +----------+ +--------------+ + \ | / + +------------+--------------+ + | + (2) 의장이 재질문을 병합 -> AskUserQuestion 으로 사용자에게 + | + v + 라운드 2: 서로의 라운드 1 답변 + 사용자 답변을 읽고 상호 반박 + | + v + (3) 의장이 합성 -> .council/<날짜>-<주제>/ 에 기록 (git 추적) + consensus.md (합의 + 이견 + 미해결) + 모델별 원문 로그 +``` + +## 각 절이 답하는 질문 + +| 절 | 답하는 질문 | +|---|---| +| Part 1 — 배치와 이름 | 이 기능이 저장소 어디에 어떤 이름으로 들어가는가 | +| Part 2 — 의석과 모델 고정 | 누가 회의에 참여하고, 그 모델 이름은 어떻게 최신으로 유지되는가 | +| Part 3 — 회의 진행 절차 | 토론이 몇 라운드로 어떤 순서로 흘러가는가 | +| Part 4 — 사전 컨텍스트 수집 | 외부 모델에게 무엇을 미리 담아서 보내는가 | +| Part 5 — 산출물 | 회의 결과가 어떤 파일로 어디에 남는가 | +| Part 6 — 실패 처리 | 한 모델이 죽었을 때 회의가 어떻게 되는가 | +| Part 7 — 저장소 동기화 의무 | 이 플러그인을 추가하면서 같이 고쳐야 하는 파일은 무엇인가 | +| 검증된 사실 | 설계의 근거가 된, 실제로 실행해서 확인한 사실은 무엇인가 | +| 용어 사전 | 처음 보는 낱말의 뜻 | + +--- + +## Part 1 — 배치와 이름 + +- 새 플러그인 `plugins/council/` 을 만든다. 기존 플러그인 안의 스킬로 넣지 않는다. +- 스킬 이름은 `convene`(소집) 이고, 따라서 진입점은 `/council:convene` 이다. +- 플러그인 이름과 스킬 이름을 똑같이 `council` 로 두지 않은 이유는, 나중에 모델 레지스트리만 + 손보는 `/council:models` 같은 형제 스킬이 생겼을 때 계층이 어색해지지 않게 하기 위해서다. + +## Part 2 — 의석과 모델 고정 + +### 의석 구성 + +회의에는 세 자리가 있고, 의장은 자리에 포함되지 않는다. + +| 자리 | 실행 수단 | 고정 값 | +|---|---|---| +| codex | `codex exec` 명령 | 모델 `gpt-5.6-sol`, 추론 강도 `xhigh`, 속도 등급 `fast` | +| agy | `agy --print` 명령 | 모델 `gemini-3.6-flash-high` | +| Claude | Agent 도구에 `model` 지정 | `opus` | +| 의장 | 메인 세션 (Opus) | 사용자가 쓰고 있는 세션 그대로 | + +세 자리의 모델 이름은 **모두 레지스트리 파일에 적는다.** Claude 자리만 스킬 본문에 박아 두면 +나중에 좌석 모델을 바꿀 때 플러그인 버전을 올려야 하므로, 셋을 같은 파일에서 관리한다. + +사용자의 전역 codex 설정(`~/.codex/config.toml`)은 현재 추론 강도가 `max` 로 되어 있으나, +council 의 codex 자리는 레지스트리에 적힌 `xhigh` 를 호출할 때마다 `-c` 인자로 덮어써서 쓴다. +즉 전역 설정을 바꾸지 않고 회의용 값만 따로 쓴다. + +### Claude 자리를 Opus 로 두는 대가와 그 방어 + +Claude 자리는 의장과 같은 모델이다. 세션이 분리되어도 가중치는 분리되지 않으므로, 이 자리가 +의장에게 동의할 때 그것은 독립된 두 번째 판단이 아니라 같은 편향의 반복일 수 있다. 그대로 두면 +의장은 자기 생각이 3표 중 2표의 지지를 받았다고 잘못 읽는다. 이를 **가짜 합의** 라 부르고, 두 가지로 +막는다. + +- **동족 합의 할인** — 합성 단계에서 Claude 자리가 의장과 같은 결론을 냈다는 사실만으로는 표를 + 세지 않는다. 그 자리가 의장이 쓰지 않은 새로운 근거를 제시했을 때만 가산한다. +- **적대 역할 부여** — Claude 자리의 프롬프트에 다른 두 자리 중 더 강한 논증을 우선 반박하라는 + 역할을 명시한다. 동의를 기본값으로 두지 않는다. + +그럼에도 Opus 를 쓰는 이유는 라운드 2 의 임무가 codex 와 agy 의 논증을 공격하는 것이기 때문이다. +이는 능력에 비례하는 작업이라, 약한 자리는 약한 반박을 내고 약한 반박은 의장이 걸러 버리므로 +그 자리가 사실상 기권하게 된다. + +### 모델 레지스트리 파일 + +- 위치는 `~/.claude/council-models.json` 이며 사용자 홈에 하나만 둔다. 저장소마다 두지 않는다. + 모델 이름은 저장소의 성질이 아니라 이 컴퓨터의 성질이기 때문이고, 저장소마다 두면 새 저장소에서 + 일할 때마다 확인 질문이 처음부터 다시 뜬다. +- 갱신 주기는 7일이다. 파일에 마지막 확인 시각을 적어 두고, 7일이 지났으면 회의를 시작하기 전에 + 사용자에게 확인 질문을 한 번 던진다. +- 만료되었을 때는 **변화 유무와 상관없이 항상 질문한다.** 자동으로 최신 모델로 올려 버리지 않는다. + 새 모델이 실제로 좋아졌는지, 어떤 용도에 맞는지에 대한 판단과 최신 소식은 사용자가 쥐고 있고, + 그 판단을 플러그인이 대신하면 의도하지 않은 비싼 모델로 조용히 갈아탈 수 있다. +- 질문할 때는 현재 고정 값과 함께, 기계가 읽어 온 실제 사용 가능 목록을 같이 보여 준다. + codex 목록은 `~/.codex/models_cache.json` 의 `models[].slug` 에서, agy 목록은 `agy models` + 명령의 출력에서 얻는다. 사용자가 자연어로 다른 모델을 지정하면 그 값으로 파일을 갱신한다. + +### codex 자동 업데이트 설정 + +- `~/.codex/config.toml` 에 `check_for_update_on_startup = true` 를 직접 기록한다. 이 값은 + 실재하는 설정 항목임을 실행으로 확인했다(아래 "검증된 사실" 참조). +- 실제 `codex update` 실행은 위의 주간 확인 질문과 같은 시점에만 제안하고 실행한다. 회의를 + 시작할 때마다 업데이트를 돌리지 않는다. `codex update` 는 지금 돌고 있는 실행 파일을 + 교체하므로, 토론 도중에 실행하면 codex 자리가 회의 중간에 통째로 사라질 수 있다. + +## Part 3 — 회의 진행 절차 + +라운드는 두 번이며, 그 사이에 사용자에게 되묻는 관문이 하나 있다. + +1. **라운드 1 — 독립 의견.** 세 자리에 같은 질문과 같은 사전 컨텍스트를 동시에 보낸다. 각 + 모델은 자기 답변과 함께 "이 요청에서 불명확해서 사용자에게 확인이 필요한 것" 목록을 같이 낸다. + 이 시점에는 서로의 답을 보지 못한다. +2. **재질문 관문.** 의장이 세 모델이 낸 확인 요청을 모아 중복을 걷어내고, 실제로 답이 달라지는 + 것만 골라 `AskUserQuestion` 으로 사용자에게 한 번에 묻는다. 저장소나 도구로 확인할 수 있는 + 것은 사용자에게 묻지 않고 의장이 직접 확인한다. +3. **라운드 2 — 상호 반박.** 각 모델에게 다른 두 모델의 라운드 1 답변 전문과 사용자의 답변을 + 넘기고, 어디에 동의하고 어디에 반대하는지 근거와 함께 쓰게 한다. +4. **합성.** 의장이 합의된 것, 끝내 갈린 것, 아무도 답하지 못한 것을 나누어 정리한다. 이때 + 위의 동족 합의 할인을 적용한다 — Claude 자리가 의장과 같은 결론을 냈다는 사실만으로는 + 합의의 근거로 세지 않는다. + +합의 결과를 코드에 자동으로 적용하지 않는다. 결론을 제시하고 멈춘다. 이는 사용자의 전역 지침 +("위임 결과를 자동 적용하지 않는다")을 그대로 따르는 것이다. + +## Part 4 — 사전 컨텍스트 수집 + +수집 대상을 고르는 기준은 하나다. **파일 경로를 알려 주면 상대 모델이 스스로 읽을 수 있는 것은 +수집하지 않는다.** codex 와 agy 모두 파일을 읽을 수 있으므로 `AGENTS.md`, `.llmwiki/` 문서, +소스 파일 따위는 경로만 프롬프트에 적어 주면 된다. 미리 모아서 넣어야 하는 것은 파일 경로로 +환원되지 않는 것, 즉 Claude 쪽 플러그인을 거쳐야만 나오는 것뿐이다. + +수집하는 것은 다음 세 가지다. + +| 대상 | 왜 파일로 대체할 수 없는가 | +|---|---| +| mem0 기억 (결정 이력, 작업 학습) | 저장소가 아니라 원격 기억 서비스 뒤에 있어 애초에 파일이 아니다. 과거에 이미 검토하고 기각한 선택지를 외부 모델이 새 제안이라며 다시 내놓는 것을 막는다. | +| Serena 심볼 그래프 | 어떤 함수를 고치면 어디가 영향을 받는지는 언어 서버 색인을 돌려야만 나온다. 파일 목록으로는 표현되지 않으며, agy 는 이 색인을 스스로 만들지 못한다. | +| code-scout 외부 리서치 | 저장소 바깥의 최신 사실(라이브러리 변경, 공식 문서, 유사 구현)이다. 비용과 지연이 가장 크므로 주제가 실제로 외부 사실에 달려 있을 때만 켠다. | + +저장소 가드 실행 결과(`sync-codex-manifests.mjs --check` 등)는 이번 범위에서 제외했다. + +## Part 5 — 산출물 + +- 회의마다 `.council/-<주제-슬러그>/` 디렉터리를 하나 만든다. 같은 날 여러 번 + 회의할 수 있으므로 주제 슬러그로 구분한다. +- 디렉터리 안에는 모델별 원문 기록(라운드 1과 라운드 2)과 최종 `consensus.md` 를 둔다. +- 이 디렉터리는 **git 으로 추적한다.** 결정의 근거를 남기는 것이 목적이므로 `.gitignore` 에 + 넣지 않는다. +- 대화창에는 합의, 갈린 이견, 미해결 항목만 짧게 내보내고 전문은 파일로 미룬다. + +## Part 6 — 실패 처리 + +agy 는 headless(터미널 없이 프로그램에서 호출) 환경에서 알려진 고장이 여럿 있으므로 실패를 +전제로 설계한다. 정책은 **한 번만 다시 부르고, 그래도 실패하면 그 자리를 결석 처리하고 회의를 +계속한다.** 결석 사실과 사유는 `consensus.md` 와 대화창 요약에 반드시 적는다. + +알려진 고장과 대응은 다음과 같다. + +| 증상 | 원인 | 대응 | +|---|---|---| +| 표준 입력을 닫지 않으면 무한 대기 | agy 가 터미널 입력을 기다린다. `--print-timeout` 도 이를 막지 못한다 | 모든 호출 끝에 `< /dev/null` 을 반드시 붙인다 | +| 정상 종료했는데 출력이 비어 있음 | agy 의 알려진 결함(#76). 출력이 터미널이 아니면 내용을 버린다 | 답을 화면이 아니라 파일에 쓰라고 프롬프트에 지시하고, 그 파일을 읽는다 | +| 인증 시간 초과 | 백그라운드 인증이 만료 | 다시 부르지 않는다. 사용자에게 터미널에서 `agy` 를 한 번 직접 실행해 재인증하라고 알린다 | + +codex 쪽은 잘못된 모델 이름을 주더라도 매달리지 않고 몇 초 만에 오류로 끝나므로, 같은 수준의 +방어가 필요하지 않다. + +## Part 7 — 저장소 동기화 의무 + +플러그인을 하나 추가하는 변경이므로 다음 파일이 같은 변경 안에서 함께 움직여야 한다. 이 저장소는 +문서에 박힌 숫자를 기계적으로 검사하므로(`scripts/check-doc-consistency.mjs`), 빠뜨리면 커밋이 +막힌다. + +- `plugins/council/.claude-plugin/plugin.json` — 새 매니페스트 +- `.claude-plugin/marketplace.json` — 항목 추가, `metadata.version` 을 `2.7.4` 에서 올림 +- `AGENTS.md` — `## Plugins (24)` 를 `(25)` 로, 플러그인 표에 행 추가 +- `README.md` — 플러그인 수, 구조 트리, 목록 +- `scripts/manifest-eligibility.mjs` — `CODEX_EXCLUDED` 에 `'council'` 추가 + +`council` 을 Codex 매니페스트에서 제외하는 이유는 순환이다. Codex 세션에서 council 을 돌리면 +codex 가 자기 자신을 회의 참석자로 부르게 되고, Claude 전용인 Agent 도구가 없어 Claude 자리도 +만들 수 없다. `codex-image` 가 이미 같은 이유로 제외되어 있다. Hermes 는 대상이 아니다 +(`HERMES_ELIGIBLE` 목록에 넣지 않는다). + +--- + +## 요구사항 + +### 반드시 (P0) + +- [ ] `plugins/council/` 플러그인과 `convene` 스킬을 만든다 +- [ ] 세 자리(codex, agy, Claude/opus)를 병렬로 호출하고 결과를 모은다 +- [ ] 세 자리의 모델 이름을 모두 `~/.claude/council-models.json` 에서 읽는다 +- [ ] 합성 단계에 동족 합의 할인을 적용하고, Claude 자리에 적대 역할을 부여한다 +- [ ] `~/.claude/council-models.json` 레지스트리를 읽고 쓰며, 7일 만료 시 항상 사용자에게 묻는다 +- [ ] 만료 질문에 codex 와 agy 의 실제 사용 가능 모델 목록을 같이 보여 준다 +- [ ] 라운드 1 이후 재질문을 병합해 `AskUserQuestion` 으로 사용자에게 되묻는다 +- [ ] 라운드 2에서 각 모델에게 다른 모델의 답변을 넘겨 상호 반박시킨다 +- [ ] `.council/<날짜>-<슬러그>/` 에 모델별 로그와 `consensus.md` 를 남긴다 +- [ ] agy 호출은 `--print` 를 마지막 플래그로 두고 `< /dev/null` 을 붙이며 파일 쓰기로 결과를 받는다 +- [ ] agy 실패 시 한 번 재시도 후 결석 처리하고, 결석 사실을 산출물에 적는다 +- [ ] 저장소 동기화 의무(Part 7) 다섯 파일을 같은 변경에 포함한다 + +### 하는 편이 좋음 (P1) + +- [ ] `~/.codex/config.toml` 에 `check_for_update_on_startup = true` 를 기록한다 +- [ ] 주간 확인 질문 시점에 `codex update` 실행 여부를 함께 제안한다 +- [ ] 사전 컨텍스트로 mem0 기억, Serena 심볼 그래프, code-scout 리서치를 모아 프롬프트에 싣는다 +- [ ] 레지스트리에 적힌 codex 설정이 지금 깔린 codex 버전에서 유효한지 `--strict-config` 로 미리 확인한다 + +### 있으면 좋음 (P2) + +- [ ] 레지스트리만 손보는 형제 스킬 `/council:models` +- [ ] code-scout 리서치를 켤지 여부를 주제에 따라 자동 판단 + +## 기술적 제약 + +- codex 호출은 `codex exec` 를 쓴다. 모델은 `-m`, 추론 강도는 `-c model_reasoning_effort=...`, + 결과 회수는 `-o <파일>`(마지막 메시지를 파일로 받음)을 쓴다. 표준 출력에는 훅 로그와 토큰 + 사용량이 섞이므로 표준 출력을 파싱하지 않는다. +- agy 호출은 `agy --dangerously-skip-permissions --add-dir <쓰기 디렉터리> --print-timeout <시간> + --print "<프롬프트>" < /dev/null` 형태를 쓴다. `--print` 뒤에 다른 플래그를 두면 그 플래그가 + 프롬프트로 먹힌다. +- Claude 자리는 Agent 도구에 `model: "opus"` 를 지정해 만든다. 이 도구가 받는 값은 + `sonnet` / `opus` / `haiku` / `fable` 넷이다. +- 스킬 본문의 설명문은 영어로 쓴다(저장소 규약). 사용자에게 보이는 산출물과 질문은 한국어다. +- 스킬 frontmatter 의 `description` 은 1024자 미만이어야 하고, 콜론 뒤 공백이 들어가면 + 따옴표로 감싸야 한다. + +## 예외 상황 + +| 상황 | 기대 동작 | +|---|---| +| 레지스트리 파일이 없음 | 처음 실행으로 보고 사용자에게 초기 고정 값을 확인한 뒤 파일을 만든다 | +| `codex` 가 PATH 에 없음 | codex 자리를 결석 처리하고 사유를 남긴 채 계속한다 | +| `agy` 가 PATH 에 없음 | agy 자리를 결석 처리하고 사유를 남긴 채 계속한다 | +| 세 자리가 모두 실패 | 회의를 성립시키지 않고 사용자에게 보고하고 멈춘다 | +| 레지스트리의 모델이 목록에서 사라짐 | 만료 전이라도 사용자에게 알리고 대체 모델을 묻는다 | +| 같은 날 같은 주제로 재실행 | 슬러그가 겹치면 기존 디렉터리를 지우지 않고 접미사를 붙여 새로 만든다 | +| 사용자가 재질문 관문에서 답하지 않음 | 라운드 1 결과만으로 합성하고, 답하지 않은 항목을 미해결로 표시한다 | + +## 범위 밖 + +- 합의 결과를 코드에 자동 적용하는 것. 결론 제시까지가 이 플러그인의 일이다. +- 저장소 가드 실행 결과를 사전 컨텍스트로 넣는 것. +- Codex 런타임과 Hermes 런타임 지원. Claude Code 전용이다. +- agy 의 알려진 결함을 근본적으로 고치는 것. 우회만 한다. +- 회의 자리를 네 개 이상으로 늘리는 것. +- **자율 토론 모드(agent teams).** 아래에 따로 적는다. + +### 보류된 갈래 — 자율 토론 모드 + +의장이 라운드를 몰지 않고 참가자들끼리 직접 메시지를 주고받으며 토론한 뒤 결과만 넘겨받는 +방식을 검토했고, 이번 범위에서 **의도적으로 뺐다.** 다시 꺼낼 때 처음부터 조사하지 않도록 +확인한 사실과 걸림돌을 남긴다. + +기술적으로는 가능하다. `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` 가 이 환경에 이미 켜져 있고, +`SendMessage` 의 수신자 항목이 팀원 이름을 직접 받으므로 참가자끼리 부모를 거치지 않고 대화한다. +공식 문서가 바로 이 용례(여러 참가자가 서로의 가설을 반증하는 토론)를 예시로 싣고 있고, +팀원 타입으로 플러그인 subagent 정의를 지정할 수 있어 codex 와 agy 자리도 만들 수 있다. + +빼기로 한 이유는 다음과 같다. + +- **재질문 관문과 정면으로 충돌한다.** 팀원은 사용자에게 질문을 걸 수 없다. 권한 요청조차 + 의장 세션으로 올라오고 팀원이 사용자 동의를 대신 받을 수 없으므로, 되묻으려면 의장이 반드시 + 한 번은 개입해야 한다. 그러면 "결과만 받는다" 는 전제가 깨진다. +- **기존 rescue 정의를 그대로 쓸 수 없다.** `codex:codex-rescue` 는 본문에 "한 번만 전달하고 + 후속 작업을 하지 마라" 가 박혀 있고, 팀원으로 쓰면 그 본문이 시스템 프롬프트에 붙는다. + 여러 라운드를 도는 참가자와 모순이므로 전용 좌석 정의를 따로 만들어야 한다. +- **종료 보장 장치가 따로 필요하다.** 공식 문서가 "의장이 일이 끝나기 전에 종료를 선언한다" 와 + "팀원이 작업 완료 표시를 빠뜨려 의존 작업이 멈춘다" 를 알려진 결함으로 명시한다. +- 그 밖에 팀원은 팀원을 낳지 못하고(중첩 불가), 세션을 재개해도 팀이 복원되지 않으며, + 토큰 사용량이 참가자 수에 비례해 늘어난다. + +## 미해결 항목 + +- 주간 확인 질문에서 codex 와 agy 를 한 번에 물을지, 따로 물을지. 구현하며 정한다. +- 라운드 2에서 다른 모델의 답변 전문을 그대로 넘길지, 요약해서 넘길지. 전문이 길어지면 + 프롬프트 한계에 닿을 수 있다. 우선 전문으로 하고 길이 문제가 실제로 생기면 그때 줄인다. +- `.council/` 디렉터리가 커지면 오래된 회의록을 어떻게 정리할지. 지금은 정하지 않는다. + +--- + +## 검증된 사실 + +설계의 근거로 실제 실행해 확인한 것들이다. 추측이 아니다. + +| 사실 | 확인 방법 | +|---|---| +| codex 0.145.0, agy 1.1.7 이 PATH 에 있다 | `codex --version`, `agy --version` | +| codex 의 사용 가능 모델과 추론 강도가 기계 판독 가능하다 | `~/.codex/models_cache.json` 의 `models[].slug` 및 `supported_reasoning_levels[].effort` | +| `gpt-5.6-sol` 은 `low` 부터 `ultra` 까지 지원하고 `fast` 속도 등급이 있다 | 같은 파일 | +| agy 의 사용 가능 모델이 기계 판독 가능하다 | `agy models` 출력. `gemini-3.6-flash-high` 가 실제 항목으로 존재 | +| `check_for_update_on_startup` 은 실재하는 codex 설정 항목이다 | `--strict-config` 와 함께 넘겼을 때 통과. 대조군으로 가짜 항목을 넘겼을 때는 `unknown configuration field` 로 거부됨 | +| 잘못된 모델 이름은 행잉이 아니라 즉시 오류다 | `codex exec -m gpt-5.9-nonexistent` 가 몇 초 만에 HTTP 400 으로 종료 | +| agy 가 이 컴퓨터에서 headless 로 정상 동작한다 | 임시 디렉터리에 `pong` 을 쓰게 하는 시험을 `--model gemini-3.6-flash-high` 로 실행해 파일 생성과 표준 출력 양쪽 모두 성공. 문서에 적힌 출력 유실 결함은 이 버전(1.1.7)에서 관측되지 않았으나 우회는 그대로 유지한다 | +| Agent 도구의 모델 지정은 `sonnet`/`opus`/`haiku`/`fable` 넷을 받는다 | 도구 스키마 | +| 팀원끼리 직접 메시지를 주고받을 수 있다 | `SendMessage` 의 수신자 항목이 팀원 이름을 받음. 공식 문서의 팀 구성 설명과 일치 | +| 현재 저장소 상태 | 플러그인 24개, `metadata.version` 2.7.4, `CODEX_EXCLUDED` 는 `codex-image` 하나 | + +확인하지 못한 것: 사용자가 겪었다는 codex 행잉의 실제 원인. 모델 이름 문제가 아님은 위에서 +확인했으나, 어떤 상황에서 멈췄는지는 재현하지 못했다. 이 스펙은 그 행잉을 고치는 것이 아니라 +`check_for_update_on_startup` 을 명시적으로 기록하는 것까지만 다룬다. + +## 용어 사전 + +- **의석(seat)** — 회의에 참여하는 하나의 모델 자리. 이 스펙에서는 codex, agy, Claude Opus 세 개다. +- **의장** — 회의를 진행하고 최종 정리를 하는 주체. 사용자의 메인 Claude 세션이다. 의석에는 포함되지 않는다. +- **재질문 관문** — 라운드 1과 라운드 2 사이에서, 모델들이 낸 확인 요청을 사용자에게 한 번에 되묻는 단계. +- **결석 처리** — 한 자리가 실패했을 때 회의를 멈추지 않고 그 자리를 빼고 진행하는 것. +- **가짜 합의** — 서로 다른 판단이 모인 것처럼 보이지만 실은 같은 모델이 같은 편향으로 두 번 + 말한 것에 불과한 상태. 의장과 Claude 자리가 같은 모델이기 때문에 생긴다. +- **동족 합의 할인** — 가짜 합의를 막기 위해, 의장과 같은 모델인 자리가 의장과 같은 결론을 + 냈다는 사실만으로는 합의의 근거로 세지 않는 규칙. +- **레지스트리** — 어떤 모델을 어떤 설정으로 쓸지 적어 둔 파일. 여기서는 `~/.claude/council-models.json`. +- **headless** — 사람이 보는 터미널 없이 다른 프로그램이 명령을 실행하는 상황. agy 의 알려진 결함 대부분이 이 상황에서 나온다. +- **추론 강도(reasoning effort)** — 모델이 답하기 전에 얼마나 오래 생각할지의 단계. codex 는 `low` 부터 `ultra` 까지 있다. +- **속도 등급(service tier)** — codex 의 응답 속도 등급. `fast` 는 더 빠른 대신 사용량을 더 쓴다. diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 954acfcb..91a9cd62 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -41,3 +41,8 @@ fi # fails, so nothing exercised them until they broke in production (issue #105). # Fixture-driven, no network, sub-second. bash "$repo_root/plugins/github-dev/skills/cr-fix/tests/run-tests.sh" + +# convene has no bundled scripts — its runner contracts live as shell inside +# SKILL.md, where nothing executes them. Dropping `< /dev/null` from the agy call +# makes the seat block forever, and check-shell-portability does not cover it. +bash "$repo_root/plugins/council/skills/convene/tests/run-tests.sh" diff --git a/.github/workflows/validate-codex.yml b/.github/workflows/validate-codex.yml index 4f07e90a..970e6f8d 100644 --- a/.github/workflows/validate-codex.yml +++ b/.github/workflows/validate-codex.yml @@ -40,6 +40,12 @@ jobs: # primary path fails, so nothing exercised them until they broke in # production (issues #105, #110). Fixture-driven, no network, sub-second. - run: bash plugins/github-dev/skills/cr-fix/tests/run-tests.sh + # convene ships no scripts -- its runner contracts are shell inside SKILL.md, + # so nothing executes them. A dropped `< /dev/null` on the agy call blocks + # that seat forever (--print-timeout does not bound it), and parsing codex + # stdout instead of its -o file reads hook lines as the answer. Neither is + # a GNU-only construct, so check-shell-portability cannot see them. + - run: bash plugins/council/skills/convene/tests/run-tests.sh # code_review.md P1 forbids unguarded GNU-only shell constructs, and until # now nothing enforced it -- md5sum shipped in translator and collapsed every # downloaded image to one filename on macOS (issue #172). Flags a construct @@ -97,3 +103,5 @@ jobs: # suite under 5 would let a bash-4-only construct pass here and break for them. - name: cr-fix suite under /bin/bash (3.2) + BSD tools run: /bin/bash plugins/github-dev/skills/cr-fix/tests/run-tests.sh + - name: convene suite under /bin/bash (3.2) + BSD tools + run: /bin/bash plugins/council/skills/convene/tests/run-tests.sh diff --git a/AGENTS.md b/AGENTS.md index a0520a46..202fd57e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ 플러그인 트리 하나를 Claude Code, Codex 0.135(`scripts/sync-codex-manifests.mjs`), Hermes Agent(`scripts/sync-hermes-manifests.mjs`)가 함께 읽습니다 — one source, three runtimes. -## Plugins (24) +## Plugins (25) ### Core | Plugin | Description | @@ -44,6 +44,7 @@ | Plugin | Description | |--------|-------------| | `codex-image` | Claude->Codex image generation bridge (delegates to Codex CLI image gen via ChatGPT OAuth, no OpenAI API key). Claude-only — excluded from Codex sync | +| `council` | Cross-vendor deliberation (`/council:convene`). Three seats — codex (`gpt-5.6-sol`), agy (`gemini-3.6-flash-high`), Claude (`opus`) — answer independently, hand their follow-up questions to the user through a re-question gate, then rebut each other; the chair (main session, not a seat) synthesizes. Seat models pinned in a weekly-TTL registry at `~/.claude/council-models.json`; expiry always asks rather than auto-upgrading. Same-family consensus discount offsets the chair and Claude seat sharing weights. Output to git-tracked `.council/-/`. Claude-only — excluded from Codex sync (seating codex under Codex is circular, and the Claude seat needs the Agent tool) | ### Development Tools | Plugin | Description | @@ -229,7 +230,7 @@ node scripts/sync-codex-manifests.mjs --check # CI drift guard - Skill `description` frontmatter 에 콜론+공백(`: `) 이 들어가면 반드시 따옴표로 감싸세요 (또는 `>-` block scalar). 안 하면 YAML frontmatter 가 nested mapping 으로 파싱돼 `mapping values are not allowed here` 로 실패하고 skill 이 양쪽 런타임에서 silent 하게 로드 안 됩니다. `plugin.json` / `marketplace.json` 은 JSON 이라 무관; lenient 매니페스트 생성기와 `--check` 는 못 잡습니다. - Codex 0.135 manifest top-level 은 `skills` / `hooks` / `mcpServers` / `apps` 만 지원합니다 (참조: `~/.codex/skills/.system/plugin-creator/references/plugin-json-spec.md`). `commands` / `agents` 는 생성기가 emit 하지 않습니다 — Claude 만 인식하는 필드입니다. - **번들 Codex hooks**: 플러그인이 소스 관리되는 `hooks/codex-hooks.json` 디스크립터를 실으면 생성기가 이를 매니페스트 top-level `hooks: "./hooks/codex-hooks.json"` 로 배선합니다 (Codex 는 이 파일명을 기본 탐색 `hooks/hooks.json` 로 자동 발견하지 못하므로 매니페스트 선언이 필수). 디스크립터 shape 는 `{ "hooks": { : [ { "matcher"?, "hooks": [ { "type":"command", "command": "bash \"$PLUGIN_ROOT/…\"" } ] } ] } }` — 이벤트명은 Codex hook 이벤트 집합 (`UserPromptSubmit` / `SessionStart` / `Stop` / `SubagentStop` / `PostToolUse` 등), plugin-root env-var 는 `PLUGIN_ROOT` (`CLAUDE_PLUGIN_ROOT` 는 호환 alias), 경로에 공백이 있을 수 있어 따옴표로 감쌉니다. `--check` 가 디스크립터 파싱·shape·참조 스크립트 존재·orphan (`hooks` 선언 있는데 소스 없음) 을 검증합니다 (`scripts/sync-codex-manifests.test.mjs` 가 fixture 로 RED/GREEN 커버). Codex hook 은 여전히 `/hooks` trust 승인이 필요합니다. `UserPromptSubmit`/`PostToolUse` 훅은 plain stdout 이 아니라 `hookSpecificOutput.additionalContext` JSON 을 내보내야 Codex 가 읽습니다 (공유 스크립트는 `codex` 인자로 분기: core-config `prompt_inject.sh`, llm-wiki `wiki_stale_check.sh` / `wiki_post_commit_hint.sh`). -- Codex 에서 제외할 플러그인은 `scripts/manifest-eligibility.mjs` 의 `CODEX_EXCLUDED` 셋에 등록하세요 (현재: `codex-image` 하나뿐). `codex-image` 는 Claude->Codex 브리지라 Codex 로 sync 하면 순환입니다. `core-config` 는 skill 이 없지만 이제 번들 Codex hooks (`hooks/codex-hooks.json`) 를 실어 hooks-only 매니페스트로 sync 되므로 더 이상 제외 대상이 아닙니다 (native Codex `UserPromptSubmit` 훅). 이후 marketplace 에서 제거된 플러그인은 EXCLUDED 에 남길 필요 없습니다 — drift 가드의 orphan 감지가 매니페스트 잔존을 잡아냅니다. +- Codex 에서 제외할 플러그인은 `scripts/manifest-eligibility.mjs` 의 `CODEX_EXCLUDED` 셋에 등록하세요 (현재: `codex-image`, `council` 둘). `codex-image` 는 Claude->Codex 브리지라 Codex 로 sync 하면 순환입니다. `council` 은 codex 를 의석으로 앉히므로 Codex 에서 돌리면 codex 가 자기 자신을 소환하는 순환이고, Claude 의석이 Agent 도구를 필요로 하는데 Codex 에는 대응 표면이 없습니다. `core-config` 는 skill 이 없지만 이제 번들 Codex hooks (`hooks/codex-hooks.json`) 를 실어 hooks-only 매니페스트로 sync 되므로 더 이상 제외 대상이 아닙니다 (native Codex `UserPromptSubmit` 훅). 이후 marketplace 에서 제거된 플러그인은 EXCLUDED 에 남길 필요 없습니다 — drift 가드의 orphan 감지가 매니페스트 잔존을 잡아냅니다. - 생성기는 Node 18+ built-in 만 사용합니다. 런타임 의존성을 추가하지 마세요. ## Hermes 통합 (shared-source) diff --git a/README.md b/README.md index 9d2f28e4..53dfeb84 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ # my-claude-plugins -Claude Code를 위한 24개 플러그인 모음 - GitHub 워크플로우부터 AI 이미지 생성까지. Codex 0.135 와 Hermes Agent 도 동일한 소스 트리를 네이티브로 로드합니다 (shared source). +Claude Code를 위한 25개 플러그인 모음 - GitHub 워크플로우부터 AI 이미지 생성까지. Codex 0.135 와 Hermes Agent 도 동일한 소스 트리를 네이티브로 로드합니다 (shared source). -[![Plugins](https://img.shields.io/badge/plugins-24-blue.svg)](https://github.com/YoungjaeDev/my-claude-plugins) +[![Plugins](https://img.shields.io/badge/plugins-25-blue.svg)](https://github.com/YoungjaeDev/my-claude-plugins) [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) [![Claude Code](https://img.shields.io/badge/Claude%20Code-compatible-purple.svg)](https://docs.anthropic.com/claude-code) @@ -91,6 +91,7 @@ rm -rf ~/.claude/plugins/cache/my-claude-plugins/ | | `paper-search-tools` | arXiv, PubMed 등 8개 플랫폼 논문 검색 | | | `brightdata-guide` | Bright Data 웹 데이터 (MCP 툴 + CLI) — 스크래핑(Web Unlocker), SERP, 구조화 web_data_* 추출, 브라우저 자동화. operator 가 BRIGHTDATA_API_KEY 설정 | | **AI Models** | `codex-image` | Claude->Codex 이미지 생성 브리지 (ChatGPT OAuth, OpenAI API key 불필요) | +| | `council` | 이종 벤더 심의 (`/council:convene`) — codex(GPT) + agy(Gemini) + Claude(Opus) 3인이 독립 의견 → 사용자 재질문 관문 → 상호 반박 → 의장 합성. 좌석 모델은 `~/.claude/council-models.json` 에 주간 TTL 로 고정 | | **Dev Tools** | `notebook` | Jupyter 노트북 안전 편집 | | | `ml-toolkit` | ML/멀티모달 개발 원칙, GPU 병렬 처리, Gradio CV 앱 | | **Content** | `translator` | 웹 아티클 한국어 번역 | @@ -310,6 +311,33 @@ Bright Data 플랫폼으로 웹 데이터 작업을 수행하는 가이드 스 +
+council - 이종 벤더 3인 심의 + +`/council:convene` 으로 하나의 질문을 서로 다른 회사가 만든 세 모델에게 동시에 던지고, 그 셋이 서로의 답을 읽고 반박하게 한 뒤, 합의된 것과 끝내 갈린 것을 문서로 남깁니다. Claude 서브에이전트를 여러 개 띄우는 것은 가중치를 공유하므로 관점이 늘지 않는다는 문제를 정면으로 겨냥합니다. + +**의석 (의장은 의석이 아님):** + +| 좌석 | 실행 | 기본 고정값 | +|---|---|---| +| codex | `codex exec` | `gpt-5.6-sol` / effort `xhigh` / tier `fast` | +| agy | `agy --print` | `gemini-3.6-flash-high` | +| claude | `Agent` 도구 model 오버라이드 | `opus` | + +**Features:** +- 2라운드 프로토콜 — 독립 의견 → 사용자 재질문 관문 → 상호 반박 → 의장 합성 +- 주간 TTL 모델 레지스트리 (`~/.claude/council-models.json`) — 만료 시 `agy models` 와 `~/.codex/models_cache.json` 의 실제 목록을 근거로 제시하며 **항상 질문**, 자동 승급 없음 +- 동족 합의 할인 — 의장과 Claude 좌석이 가중치를 공유하므로, 동의 사실만으로는 합의 근거로 세지 않고 새 논거를 가져왔을 때만 계수 +- 사전 컨텍스트는 파일로 환원 불가능한 것만 — mem0 기억, Serena 심볼 그래프, code-scout 리서치 (파일은 경로만 전달) +- 산출물은 git 추적되는 `.council/<날짜>-<슬러그>/` — 좌석별 로그 + `consensus.md` +- 좌석은 독립적으로 실패하며 결석은 반드시 명시 (agy 는 1회 재시도 후 결석) +- 합의 결과를 코드에 자동 적용하지 않음 — 결론 제시 후 정지 +- Claude-only — Codex sync 에서 제외 (순환 방지 + Agent 도구 부재) + +**Requirements:** `codex` / `agy` CLI (없는 좌석은 결석 처리되고 나머지로 진행), `jq` + +
+ ### Development Tools
@@ -676,7 +704,7 @@ codex plugin marketplace add ~/.claude/plugins/marketplaces/my-claude-plugins codex plugin add llm-wiki@my-claude-plugins ``` -Codex 에서 제외되는 플러그인은 `codex-image` 하나뿐입니다 (Claude->Codex 브리지 — Codex 로 sync 하면 순환). `core-config` 는 skill 이 없지만 번들 Codex hooks (`hooks/codex-hooks.json`) 를 실어 hooks-only 매니페스트로 Codex 에 sync 됩니다 (native `UserPromptSubmit` 훅). 즉 23 / 24 플러그인이 Codex 로 sync 되며 (core-config 는 hooks-only, 나머지는 skill 단위), `deepwiki` 와 `project-init` 은 1.41.0 부터 Claude 에서는 command + skill 양쪽으로, Codex 에서는 skill 로만 동작합니다 (Codex 는 command surface 를 로드하지 않음). +Codex 에서 제외되는 플러그인은 `codex-image` 와 `council` 둘입니다 (`codex-image` 는 Claude->Codex 브리지라 Codex 로 sync 하면 순환, `council` 은 codex 를 의석으로 앉히므로 Codex 에서 돌리면 자기 자신을 소환하는 순환이고 Claude 의석이 Agent 도구를 필요로 함). `core-config` 는 skill 이 없지만 번들 Codex hooks (`hooks/codex-hooks.json`) 를 실어 hooks-only 매니페스트로 Codex 에 sync 됩니다 (native `UserPromptSubmit` 훅). 즉 23 / 25 플러그인이 Codex 로 sync 되며 (core-config 는 hooks-only, 나머지는 skill 단위), `deepwiki` 와 `project-init` 은 1.41.0 부터 Claude 에서는 command + skill 양쪽으로, Codex 에서는 skill 로만 동작합니다 (Codex 는 command surface 를 로드하지 않음). Codex 0.135 manifest top-level은 `skills` / `hooks` / `mcpServers` / `apps` 만 지원하므로, command-bearing 플러그인(`docs-forge`, `deepwiki` 등)도 Codex 측에는 skill만 노출됩니다 — Claude 측 commands 는 그대로 동작합니다. `github-dev` 는 모든 워크플로가 skill 로 전환돼 command surface 가 없으므로 Claude·Codex 양쪽에서 동일하게 동작합니다. @@ -729,12 +757,14 @@ shared-source 배선은 6개 가드가 매 PR 과 매 커밋(`.githooks/pre-comm - `sync-codex-manifests.mjs --check` — Codex 매니페스트 drift + skill `description` 1024자 초과(Codex silent skip) + 번들 hook 디스크립터 shape·참조 스크립트 존재·orphan. - `sync-hermes-manifests.mjs --check` — Hermes 어댑터 drift + orphan. -- `check-doc-consistency.mjs` — 플러그인 트리·표·카운트(총 24 / Codex-eligible 23 / Hermes 7)가 `manifest-eligibility.mjs` SoT 와 일치. +- `check-doc-consistency.mjs` — 플러그인 트리·표·카운트(총 25 / Codex-eligible 23 / Hermes 7)가 `manifest-eligibility.mjs` SoT 와 일치. - `check-skill-tool-portability.mjs --check` — 공유 스킬 본문의 `AskUserQuestion` 사용이 파일럿 표준 매핑 또는 baseline 에 등록됐는지(미등록 크로스런타임 상호작용 경로 차단). - `check-shell-portability.mjs` — GNU 전용 셸 구문(`md5sum`·`sed -i`·`grep -P`·`date -d`·`stat -c`·`timeout`·`${VAR,,}`·`mapfile`·`declare -A` 등)이 **폴백도 capability probe 도 없이** 쓰인 경우 차단. 정상 폴백 쌍(`stat -c … || stat -f …`)과 probe 분기는 통과하고, 증거는 코드만 인정합니다(대체재를 언급하는 주석은 폴백이 아님). 예외는 `# portability-ok: <사유>`. - `check-skill-prose.mjs` — 500줄 초과·깊은 참조 경로에 대한 정보성 경고(비차단, 항상 exit 0). -CI 는 여기에 더해 **macOS 레그**(`validate-codex.yml` 의 `macos` job)를 돌립니다. cr-fix 스위트가 품은 BSD 폴백들은 GNU 러너에서 절반만 실행되므로, macOS 레그가 그 나머지 절반이 실제로 도는 유일한 지점입니다. `env -i PATH=/usr/bin:/bin` 는 쓰지 않습니다 — macOS 에서 `jq` 가 Homebrew 경로에 있어 스위트가 도구 부재로 죽습니다. 대신 `sed`/`date`/`stat` 이 BSD 빌드인지 assert 하고(Homebrew coreutils 가 시스템 도구를 가리면 실패), 스위트는 `/bin/bash` 로 돌려 bash 3.2 를 강제합니다. +가드와 별개로 두 개의 픽스처 스위트가 같은 자리에서 돕니다 — `plugins/github-dev/skills/cr-fix/tests/run-tests.sh` (1차 경로가 실패한 뒤에만 실행되는 CLI 폴백·CR 상태 경로)와 `plugins/council/skills/convene/tests/run-tests.sh` (스킬 본문에만 존재해 아무도 실행하지 않는 codex/agy 호출 계약 + 레지스트리 TTL 산술). 후자가 지키는 것은 이식성 가드가 볼 수 없는 종류입니다 — agy 호출에서 `< /dev/null` 이 빠지면 그 의석이 영원히 멈추는데, 그건 GNU 전용 구문이 아니라서 `check-shell-portability` 의 관심사가 아닙니다. + +CI 는 여기에 더해 **macOS 레그**(`validate-codex.yml` 의 `macos` job)를 돌립니다. 두 스위트가 품은 BSD 폴백들은 GNU 러너에서 절반만 실행되므로, macOS 레그가 그 나머지 절반이 실제로 도는 유일한 지점입니다. `env -i PATH=/usr/bin:/bin` 는 쓰지 않습니다 — macOS 에서 `jq` 가 Homebrew 경로에 있어 스위트가 도구 부재로 죽습니다. 대신 `sed`/`date`/`stat` 이 BSD 빌드인지 assert 하고(Homebrew coreutils 가 시스템 도구를 가리면 실패), 스위트는 `/bin/bash` 로 돌려 bash 3.2 를 강제합니다. drift·길이·shape·이식성 위반은 **차단**(exit 1)이고, prose 경고는 측정치일 뿐 커밋을 막지 않습니다. 어느 가드도 소스를 자동 수정하지 않습니다 — 위반을 보고할 뿐이니, 로컬에서 generator 를 재실행해 파생물을 맞춘 뒤 다시 커밋하세요. @@ -786,6 +816,7 @@ node scripts/install-skills.mjs # 또는 hermes plugins install │ ├── ml-toolkit/ # ML 개발 │ ├── translator/ # 번역 │ ├── codex-image/ # Claude->Codex 이미지 생성 브리지 +│ ├── council/ # 이종 벤더 3인 심의 (codex + agy + Opus) │ ├── interview/ # 요구사항 수집 │ ├── docs-forge/ # README/CHANGELOG + 배포 문서 + MOC 생성 │ ├── rules-forge/ # write-rules 스킬 (자동 모드 감지) diff --git a/plugins/council/.claude-plugin/plugin.json b/plugins/council/.claude-plugin/plugin.json new file mode 100644 index 00000000..eb5a5c06 --- /dev/null +++ b/plugins/council/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "council", + "version": "0.1.0", + "description": "Cross-vendor model council. codex (GPT), agy (Gemini), and a Claude Opus seat answer independently, surface follow-up questions to the user, then rebut each other before the chair synthesizes. Seat models live in a weekly-TTL registry at ~/.claude/council-models.json. Claude-only; excluded from Codex sync." +} diff --git a/plugins/council/CLAUDE.md b/plugins/council/CLAUDE.md new file mode 100644 index 00000000..ce6f5f0c --- /dev/null +++ b/plugins/council/CLAUDE.md @@ -0,0 +1,68 @@ +# council + +A cross-vendor deliberation plugin. One skill, `convene`, puts the same question to three +models built by three different companies, has them read and rebut each other, and writes the +agreement, the surviving disagreement, and the unanswered questions to a tracked file. + +## Why this exists + +Spawning several Claude subagents does not add perspective. They share weights, so they share +their systematic errors. Real divergence needs a model trained by someone else on something +else. `core-config` already injects a one-line `[council]` reminder that another model is on +PATH, but nothing executes the delegation — until this plugin, every handoff was hand-assembled. + +## Shape + +| Piece | Where | +|---|---| +| Entry point | `/council:convene` | +| Seat roster + pinned models | `~/.claude/council-models.json` (global, 7-day TTL) | +| Per-run output | `.council/-/` in the current repo, git-tracked | +| Design record | `.claude/spec/2026-07-29-council.md` | + +Three seats, plus a chair that is not a seat: + +| Seat | Runner | Pinned by default | +|---|---|---| +| codex | `codex exec` | `gpt-5.6-sol`, effort `xhigh`, service tier `fast` | +| agy | `agy --print` | `gemini-3.6-flash-high` | +| claude | `Agent` tool with a `model` override | `opus` | +| chair | the main session | whatever the user is running | + +## Design decisions worth not re-litigating + +- **The chair is not a seat.** It composes prompts, relays the user's answers, and synthesizes. + It does not vote. +- **The Claude seat is Opus, and that costs something.** It shares weights with the chair, so + its agreement is not an independent second judgment. Two rules pay that back: a + *same-family consensus discount* (agreement alone is not evidence; only a new argument + counts) and an explicit adversarial role in the seat's prompt. Opus is still chosen because + round 2 is an attack task, and a weaker seat produces attacks the chair simply filters out. +- **TTL expiry always asks.** Both CLIs expose machine-readable model lists, so the skill + *could* auto-upgrade. It does not. Judging whether a new model is actually better is the + user's call, and silent upgrades drift into unintended spend. +- **Only unfetchable context is pre-collected.** codex and agy read files themselves, so file + paths are passed as paths. What gets packed into the prompt is what a path cannot carry: + mem0 memories, Serena symbol graphs, code-scout research. +- **Claude-only.** Listed in `CODEX_EXCLUDED` (`scripts/manifest-eligibility.mjs`). Running + this under Codex would summon codex as its own seat, and Codex has no `Agent` tool for the + Claude seat. Same reasoning that excludes `codex-image`. +- **Autonomous agent-team debate was deliberately deferred.** It works technically, but + teammates cannot question the user, which breaks the re-question gate. The full finding is + in the spec's "보류된 갈래" section. + +## Runner contracts that bite + +- **codex** takes its prompt on stdin via `codex exec ... -` and returns the final message with + `-o `. Do not parse stdout: hook lines and token counts are mixed into it. +- **agy** takes its prompt as a shell argument, so `--print` must be the last flag and the + command must end with `< /dev/null`. Without that redirect it blocks forever waiting on a TTY + and `--print-timeout` does not bound it. Its answer is also read from a file rather than + stdout, because agy has a history of dropping stdout when it is not a terminal. +- A wrong codex model name is not a hang. It fails in seconds with an HTTP 400. + +## Portability + +No bundled scripts, so no `PLUGIN_ROOT` resolver is needed. The inline shell sticks to +`date +%s`, `date -u +%Y-%m-%d`, `jq`, and `mktemp -d`, all of which behave the same on GNU and +BSD. `scripts/check-shell-portability.mjs` enforces this. diff --git a/plugins/council/skills/convene/SKILL.md b/plugins/council/skills/convene/SKILL.md new file mode 100644 index 00000000..fa2846d4 --- /dev/null +++ b/plugins/council/skills/convene/SKILL.md @@ -0,0 +1,638 @@ +--- +name: convene +description: Convene a cross-vendor model council — codex (GPT), agy (Gemini) and a Claude Opus seat answer independently, hand their follow-up questions back to the user, then rebut each other before the chair synthesizes agreement and surviving disagreement. Use when a decision needs a genuinely different model's judgment rather than more Claude sampling, or when the user asks to hear from another model. Korean triggers — "council", "카운슬", "심의", "다른 모델 의견", "codex랑 agy한테 물어봐", "토론시켜", "합의 봐줘", "2차 의견". English triggers — "convene a council", "second opinion from another model", "cross-model debate", "ask codex and gemini", "have the models argue". +--- + +# Council — cross-vendor deliberation + +Put one question to three models from three different vendors, make them read and rebut each +other, and write down what they agreed on, what stayed contested, and what nobody could answer. + +The point is divergence. Several Claude subagents share weights and therefore share their +systematic mistakes; a real second opinion has to come from a model somebody else trained. + +## Cross-runtime interactive input + +Every question below runs through a **capability-aware** interactive-input gate rather than one +hardcoded tool: + +- **Claude Code** — use `AskUserQuestion`. +- **Codex** — use `request_user_input` when that tool is exposed. When it is not, ask ONE + concise blocking question only where a wrong assumption would be costly; otherwise proceed on + a documented safe default and state the assumption. +- **Hermes** — use `clarify`. + +This plugin is Claude-only in practice (it is listed in `CODEX_EXCLUDED`, and the Claude seat +needs the `Agent` tool), so the Claude row is the operative one. The other rows are kept so the +body never asserts that a single interactive tool exists. + +## Roles + +The **chair** is the main session. It composes prompts, relays the user's answers, and +synthesizes. **The chair is not a seat and does not vote.** + +| Seat | Runner | Registry key | +|---|---|---| +| codex | `codex exec` | `seats.codex` | +| agy | `agy --print` | `seats.agy` | +| claude | `Agent` tool with a `model` override | `seats.claude` | + +--- + +## Step 0 — resolve the model registry + +Seat models are pinned in `~/.claude/council-models.json` so they survive across repos and are +confirmed at most once a week. + +The seven-day window is a **constant here, never a field read back from the registry.** A TTL +taken from the file it governs is not a guarantee — one hand-edited `ttl_days` and the pins never +come up for confirmation again, which is exactly the "always ask on expiry" property this design +was chosen for. A timestamp that is missing, non-numeric, or in the future is treated as expired +for the same reason: those are the shapes a corrupted or tampered registry takes, and none of +them should buy indefinite freshness. + +Freshness also requires the pins to actually be there. A registry whose `checked_at_epoch` is +recent but whose `seats` block is missing, malformed, or empty would otherwise read as `fresh`, +and the seats would then run with a model name of `null`. Anything that fails to parse or is +missing a seat pin is `expired` — that routes it back through confirmation instead of forward +into a broken call. + +```bash +REG="$HOME/.claude/council-models.json" +TTL=$(( 7 * 86400 )) # policy constant — not configurable from the file +if [ ! -f "$REG" ]; then + echo "STATE=missing" +elif ! jq -e '(.seats.codex.model // "") != "" and (.seats.codex.effort // "") != "" + and (.seats.codex.service_tier // "") != "" + and (.seats.agy.model // "") != "" and (.seats.claude.model // "") != ""' \ + "$REG" >/dev/null 2>&1; then + # covers unparseable JSON (jq exits non-zero) and any absent/empty seat pin + echo "STATE=expired" +else + NOW=$(date +%s) + CHECKED=$(jq -r '.checked_at_epoch // empty' "$REG") + case "$CHECKED" in + ''|*[!0-9]*) echo "STATE=expired" ;; # absent or not a number + *) + AGE=$(( NOW - CHECKED )) + # -ge, not -gt: at exactly seven days the pin has reached its stated life + # and is due. A negative age means a future timestamp — also expired. + if [ "$AGE" -lt 0 ] || [ "$AGE" -ge "$TTL" ]; then echo "STATE=expired"; else echo "STATE=fresh"; fi ;; + esac + jq -r '.seats | to_entries[] | "\(.key)=\(.value | tostring)"' "$REG" +fi +``` + +- `STATE=fresh` — the pins are still current; run the validity check below and, if it passes, go + to Step 1 without asking anything. +- `STATE=missing` or `STATE=expired` — confirm the pins with the user before convening. Expiry + **always** asks, even when nothing changed. Deciding whether a newer model is actually better + belongs to the user, and a silent upgrade drifts into unintended spend. + +### Fresh does not mean valid + +A CLI update can retire a model inside the seven-day window. The failure table says a pin that +has vanished from the candidate list is asked about **before** expiry, so `fresh` cannot skip +straight to Step 1 — otherwise the run reaches Round 1 with a dead pin and merely records the +seat as absent, which reads as "the seat failed" rather than "your pin is gone". The check is +local and cheap: + +**Read the list first, then test membership against what you read.** Folding both steps into one +negated command makes a truncated cache or a failed `agy` call indistinguishable from a retired +model, and the user gets sent to pick a replacement with no trustworthy list in front of them. +Each list is fetched exactly once, into a variable, and only a *successful* read is allowed to +accuse a pin: + +```bash +CODEX_DIR="${CODEX_HOME:-$HOME/.codex}" # codex honors CODEX_HOME; see project_state.sh +REG="$HOME/.claude/council-models.json" +CM=$(jq -r '.seats.codex.model' "$REG"); AM=$(jq -r '.seats.agy.model' "$REG") + +# codex — one read; a parse failure is a read failure, never a retirement. +if [ ! -f "$CODEX_DIR/models_cache.json" ]; then + echo "LIST_UNREAD=codex reason=cache-absent" +elif ! codex_slugs=$(jq -r '.models[].slug' "$CODEX_DIR/models_cache.json" 2>/dev/null); then + echo "LIST_UNREAD=codex reason=cache-unparseable" +elif ! printf '%s\n' "$codex_slugs" | grep -qxF "$CM"; then + echo "STALE_PIN=codex:$CM" +fi + +# agy — one invocation, captured; the earlier version called `agy models` twice +# and let the second call's failure read as a missing pin. +if ! command -v agy >/dev/null 2>&1; then + echo "LIST_UNREAD=agy reason=binary-absent" +elif ! agy_slugs=$(agy models 2>/dev/null); then + echo "LIST_UNREAD=agy reason=listing-failed" +elif ! printf '%s\n' "$agy_slugs" | grep -qxF "$AM"; then + echo "STALE_PIN=agy:$AM" +fi +``` + +A `STALE_PIN=` line means: tell the user which pin disappeared and ask for a replacement, even +though the TTL has not elapsed. A `LIST_UNREAD=` line means the opposite — say nothing about that +seat's pin, because a list you could not read is not evidence of anything. + +Gather the real candidate lists first so the question carries evidence rather than guesses: + +```bash +# Absence and failure are different answers and must not collapse into one. +# `cmd && list || echo absent` swallows every non-zero exit — an expired agy +# login or an unparseable cache would be reported as "not installed", and the +# pin question would then be asked with no real candidate list behind it. + +# codex: slugs plus the reasoning levels each one accepts. +# Resolve through CODEX_HOME — codex layers `$CODEX_HOME/.config.toml`, so on a +# machine that sets it, `$HOME/.codex` is a directory the running CLI never reads. +# Same rule the repo's own detector applies (plugins/project-init/scripts/project_state.sh). +CODEX_DIR="${CODEX_HOME:-$HOME/.codex}" +if [ ! -f "$CODEX_DIR/models_cache.json" ]; then + echo "(codex model cache absent)" +elif ! jq -r ' + .models[] | select(.visibility != "hide") + | "\(.slug) efforts=\([.supported_reasoning_levels[].effort] | join(",")) speed=\(.additional_speed_tiers // [] | join(","))" +' "$CODEX_DIR/models_cache.json"; then + echo "(codex model cache unreadable — present but unparseable)" >&2 +fi + +# agy: one slug per line, effort already folded into the slug +if ! command -v agy >/dev/null 2>&1; then + echo "(agy absent)" +elif ! agy models; then + echo "(agy model listing failed — auth or network, not absence)" >&2 +fi +``` + +When either list comes back as a *failure* rather than an absence, say so in the pin question and +do not present the missing side as "no models available". A pin confirmed against a list that +failed to load is a pin confirmed against nothing. + +Show the current pins next to those lists and ask through the interactive-input gate whether to +keep or change them. Accept a natural-language answer ("codex를 luna로 바꿔줘"), and resolve it +against the candidate list you just printed — a name that is not on the list goes back to the +user rather than into the registry. Then write the file; `checked_at_epoch` is what keeps the TTL +arithmetic off `date -d` and portable. + +**The confirmed pins never pass through shell source.** Two failure modes sit on either side of +that rule: hard-coding defaults into the block throws the user's actual choice away, and pasting +their answer into a quoted string hands `$(…)` and stray quotes to the shell. Neither is +necessary — the pins are data, so write them as data. + +First record the confirmed answer with the **`Write` tool** (not a heredoc, not `echo`) to +`.claude/state/council-pins.json`. Nothing in that path is parsed by a shell, so no value the +user typed can become a command: + +```json +{ + "codex": {"model": "gpt-5.6-sol", "effort": "xhigh", "service_tier": "fast"}, + "agy": {"model": "gemini-3.6-flash-high"}, + "claude": {"model": "opus"} +} +``` + +Then this block reads that file, checks every pin against a conservative charset, and hands the +values to `jq` through `--arg` (argv, never re-parsed). A pin outside the charset or missing +altogether stops the write rather than landing a registry that looks valid: + +```bash +PINS=".claude/state/council-pins.json" +REG="$HOME/.claude/council-models.json" +jq -e '.' "$PINS" >/dev/null 2>&1 || { echo "council: $PINS missing or unparseable" >&2; exit 1; } + +for k in codex.model codex.effort codex.service_tier agy.model claude.model; do + v=$(jq -r ".$k // empty" "$PINS") + case "$v" in + ''|*[!a-zA-Z0-9._-]*) + echo "council: pin $k is empty or has characters outside [a-zA-Z0-9._-]: '$v'" >&2; exit 1 ;; + esac +done + +# The Claude seat has no CLI to query, so its candidate list is a fixed enum — the +# four values the Agent tool accepts. Without this, a plausible-looking answer like +# "claude-opus-4" is charset-clean, gets written, and only fails when the seat launches. +case "$(jq -r '.claude.model' "$PINS")" in + sonnet|opus|haiku|fable) ;; + *) echo "council: claude pin must be one of sonnet|opus|haiku|fable" >&2; exit 1 ;; +esac + +mkdir -p "$HOME/.claude" +tmp=$(mktemp "${TMPDIR:-/tmp}/council-reg-XXXXXX") +if jq -n --slurpfile p "$PINS" \ + --argjson now "$(date +%s)" --arg today "$(date -u +%Y-%m-%d)" ' + {schema: "council-models/v1", + checked_at: $today, checked_at_epoch: $now, + seats: { + codex: {model: $p[0].codex.model, effort: $p[0].codex.effort, service_tier: $p[0].codex.service_tier}, + agy: {model: $p[0].agy.model}, + claude: {model: $p[0].claude.model} + }, + codex_config: {check_for_update_on_startup: true}} +' > "$tmp"; then + mv "$tmp" "$REG" +else + rm -f "$tmp" + echo "council: registry write failed — previous pins left intact" >&2 + exit 1 +fi +``` + +Write it through the temp file, as above. A bare `>` truncates the registry before `jq` runs, so +a failing `jq` would destroy a perfectly good set of pins and silently send the next run back to +first-run defaults. + +Defaults on first run: codex `gpt-5.6-sol` / `xhigh` / `fast`, agy `gemini-3.6-flash-high`, +claude `opus`. + +### codex update setting + +When writing the registry, also make sure `~/.codex/config.toml` carries +`check_for_update_on_startup = true`. Only write it when the key is absent — never rewrite keys +the user set. In the same breath as the weekly pin question, offer to run `codex update`. +**Never run it mid-council**: it replaces the running binary, so a seat can vanish in the middle +of a debate. + +**Insert before the first table header, never append.** `check_for_update_on_startup` is a +top-level key, and a real `config.toml` ends inside a table (`[projects."…"]`, +`[hooks.state."…"]`). A line appended to the end belongs to that last table, so the setting +silently does nothing and `--strict-config` may reject the file outright. + +A machine with no `config.toml` yet still needs the setting, so create the file rather than +skipping. And **verify after writing** — a write that failed while the run continued would let +the probe below pass with the key still absent, which reads as "configured" when it is not. + +Both the presence check and the verification must be **TOML-scope aware**. A plain `grep` for the +key matches one nested under `[projects."…"]` just as happily as a top-level one, so it would +report "already configured" while the effective setting is still absent — and it would accept +`= false` as success. The `awk` below only counts a `true` assignment that appears before the +first table header. + +**"Not true" and "not there" are different, and only one of them may be written.** A user who +deliberately set `check_for_update_on_startup = false` has made a decision; inserting a second +assignment above it leaves a duplicate key in their global Codex config — invalid TOML that can +break every later `codex` invocation, and it survives even when the verification aborts. Absent +means insert; present-but-not-true means report and leave it alone. + +```bash +CODEX_DIR="${CODEX_HOME:-$HOME/.codex}" # codex honors CODEX_HOME (project_state.sh) +CFG="$CODEX_DIR/config.toml" +KEY='check_for_update_on_startup' + +# top_level_true -> exit 0 only when the key is set to true ABOVE the first [table]. +# The status is decided in a single END exit: an `exit 0` inside a main rule jumps +# to END, and an `exit` there would overwrite it — the awk trap that made an +# earlier version reject a correctly-configured file every time. +top_level_true() { + awk -v key="$KEY" ' + done_scan { next } + /^[[:space:]]*\[/ { done_scan = 1; next } # first table ends top level + $0 ~ "^[[:space:]]*" key "[[:space:]]*=" { + v = $0 + sub(/^[^=]*=[[:space:]]*/, "", v) # drop "key =" + sub(/#.*$/, "", v) # drop an inline comment + sub(/[[:space:]]+$/, "", v) + if (v == "true") ok = 1 + done_scan = 1; next + } + END { exit ok ? 0 : 1 }' "$1" +} + +# top_level_present -> exit 0 when the key appears at all above the first [table], +# whatever its value. Same single-END-exit discipline as top_level_true. +top_level_present() { + awk -v key="$KEY" ' + done_scan { next } + /^[[:space:]]*\[/ { done_scan = 1; next } + $0 ~ "^[[:space:]]*" key "[[:space:]]*=" { found = 1; done_scan = 1; next } + END { exit found ? 0 : 1 }' "$1" +} + +mkdir -p "$CODEX_DIR"; [ -f "$CFG" ] || : > "$CFG" +if top_level_true "$CFG"; then + : # already what we want +elif top_level_present "$CFG"; then + # Set, but not to true — the user's own choice. Never append a second key. + echo "council: $KEY is already set to a non-true value in $CFG; leaving it untouched." >&2 + echo "council: codex will not auto-check for updates. Change it yourself if that is not intended." >&2 +else + tmp=$(mktemp "${TMPDIR:-/tmp}/codex-cfg-XXXXXX") + # Emit the key before the first `[table]` line; if the file has no table at + # all, every line is already top-level and the key goes at the end. + if awk -v key="$KEY" 'BEGIN{done=0} + !done && /^[[:space:]]*\[/ {print key " = true"; done=1} + {print} + END{if(!done) print key " = true"}' "$CFG" > "$tmp" && mv "$tmp" "$CFG"; then + # Verify rather than assume — this is the line that makes "configured" mean something. + top_level_true "$CFG" \ + || { echo "council: $KEY is not a top-level true after the write — treat it as NOT set" >&2; exit 1; } + else + rm -f "$tmp"; echo "council: codex config write failed — $KEY NOT set" >&2; exit 1 + fi +fi +``` + +Confirm the result parses before relying on it — `codex exec --strict-config` rejects a malformed +or unknown-key config, so one probe call proves the edit landed as a top-level key. + +Verify a pin is still valid on the installed CLI before relying on it. This costs one fast call +and catches a config key that a codex upgrade removed: + +Three things this block must get right, each of which was wrong in an earlier draft. It +re-reads the pins (this is a separate tool call, so nothing from the write block survives). It +guards on `codex` being installed — the failure policy makes a missing binary an absent seat, and +an unguarded probe would kill the run with `command not found` before that path is reached. And +it **branches on the probe's exit status**: a probe whose result is never checked is not a check. + +```bash +REG="$HOME/.claude/council-models.json" +CODEX_MODEL=$(jq -r '.seats.codex.model' "$REG") +CODEX_EFFORT=$(jq -r '.seats.codex.effort' "$REG") +CODEX_TIER=$(jq -r '.seats.codex.service_tier' "$REG") +out=$(mktemp "${TMPDIR:-/tmp}/council-probe-XXXXXX") + +# The probe must carry the SAME configuration the Round 1 call will use, service +# tier included. Leaving the tier out lets a pin whose model and effort are still +# accepted pass here and fail at the seat, which is the one thing a preflight is +# supposed to prevent. +if ! command -v codex >/dev/null 2>&1; then + echo "SEAT=codex ABSENT reason=binary-not-on-path" +elif codex exec --strict-config -s read-only --skip-git-repo-check \ + -m "$CODEX_MODEL" -c model_reasoning_effort="\"$CODEX_EFFORT\"" \ + -c service_tier="\"$CODEX_TIER\"" \ + -o "$out" - <<'PROBE' +reply with exactly: OK +PROBE +then + echo "SEAT=codex OK" +else + echo "SEAT=codex FAILED reason=probe-rejected-model-or-config" +fi +rm -f "$out" +``` + +`SEAT=codex FAILED` is **not** the same as absent — the binary is there and rejected the pinned +model or the config. Surface it and offer the registry question rather than silently seating a +model that will fail again in Round 1. An unknown key fails with `unknown configuration field`; +an unknown model fails in seconds with an HTTP 400. Neither hangs. + +--- + +## Step 1 — set up the run and pre-collect context + +`$SLUG` is a short kebab-case topic name you derive from the question. **Validate it before it +reaches a path** — it comes from free text, and a `/` or a `..` segment would place the run +directory, and every prompt and log written into it, outside `.council/`. + +```bash +case "$SLUG" in + ''|*[!a-z0-9-]*|-*|*-) + echo "council: SLUG must be non-empty kebab-case [a-z0-9-], no leading/trailing dash" >&2 + exit 1 ;; +esac + +# Shell variables do NOT survive between tool calls — each bash block below is a +# separate process. Park the run directory where later blocks can re-read it. +# .claude/state/ is gitignored and machine-local, which is what a run pointer is. +# Key it by session: two councils running in the same repo would otherwise share +# one pointer, and the first session would start writing its prompts and answers +# into the second's git-tracked decision record, corrupting both. +# +# The key lands in a filename, so it is validated exactly like $SLUG. A session id +# carrying `/` or `..` would otherwise place the pointer outside .claude/state/. +RUN_KEY="${CLAUDE_SESSION_ID:-${CODEX_COMPANION_SESSION_ID:-}}" +case "$RUN_KEY" in + *[!A-Za-z0-9._-]*) echo "council: session id has characters outside [A-Za-z0-9._-]" >&2; exit 1 ;; +esac + +mkdir -p .claude/state .council +if [ -z "$RUN_KEY" ]; then + # No runtime session id: two concurrent councils cannot be told apart, so one of + # them must lose. `mkdir` is the claim because it is atomic — a check-then-write + # on a pointer file lets both runs observe "free" and then both write it. + RUN_KEY=shared + LOCK=".claude/state/council-lock-shared" + if ! mkdir "$LOCK" 2>/dev/null; then + echo "council: another run holds $LOCK and this runtime exposes no session id to" >&2 + echo "separate them. Remove that directory once the other council has finished." >&2 + exit 1 + fi +fi + +# Allocate the run directory atomically for the same reason: `mkdir -p` succeeds on +# a directory that already exists, so two runs racing on the same date and slug +# would both "win" the [ -e ] check and then share one directory, overwriting each +# other's prompts and answers. Plain `mkdir` fails when the name is taken, which +# makes the creation itself the loop condition. +BASE=".council/$(date -u +%Y-%m-%d)-$SLUG"; DIR="$BASE"; n=2 +until mkdir "$DIR" 2>/dev/null; do + [ "$n" -gt 999 ] && { echo "council: cannot allocate a run directory under $BASE" >&2; exit 1; } + DIR="$BASE-$n"; n=$((n+1)) +done + +printf '%s\n' "$DIR" > ".claude/state/council-run-$RUN_KEY" \ + || { echo "council: failed to record the run pointer" >&2; exit 1; } +echo "DIR=$DIR RUN_KEY=$RUN_KEY" +``` + +**Release the lock when the council ends.** The `shared` lock is held for the whole run, and +nothing else clears it — a finished council would otherwise block every later invocation, and +telling the user to "finish that council" cannot help because it already did. Step 5 removes it +on both the success and the give-up path: + +```bash +RUN_KEY="${CLAUDE_SESSION_ID:-${CODEX_COMPANION_SESSION_ID:-shared}}" +case "$RUN_KEY" in *[!A-Za-z0-9._-]*) echo "council: invalid session id" >&2; exit 1 ;; esac +rm -f ".claude/state/council-run-$RUN_KEY" +[ "$RUN_KEY" = shared ] && rmdir ".claude/state/council-lock-shared" 2>/dev/null +exit 0 +``` + +The directory is **git-tracked** — it is the decision record, not scratch. The run pointer under +`.claude/state/` is not. + +### Re-hydrating state in every later block + +Every bash block from here on starts with this, because nothing set in an earlier block is still +in scope. Skipping it hands empty model names and an empty output path to the first seat call: + +```bash +REG="$HOME/.claude/council-models.json" +RUN_KEY="${CLAUDE_SESSION_ID:-${CODEX_COMPANION_SESSION_ID:-shared}}" +case "$RUN_KEY" in *[!A-Za-z0-9._-]*) echo "council: invalid session id" >&2; exit 1 ;; esac +DIR=$(cat ".claude/state/council-run-$RUN_KEY") +CODEX_MODEL=$(jq -r '.seats.codex.model' "$REG") +CODEX_EFFORT=$(jq -r '.seats.codex.effort' "$REG") +CODEX_TIER=$(jq -r '.seats.codex.service_tier' "$REG") +AGY_MODEL=$(jq -r '.seats.agy.model' "$REG") +CLAUDE_MODEL=$(jq -r '.seats.claude.model' "$REG") +[ -n "$DIR" ] && [ -d "$DIR" ] || { echo "council: no active run directory" >&2; exit 1; } +``` + +The charset check repeats on every read, not just the write. `RUN_KEY` is interpolated into the +path being read, so an id containing `..` would pull an arbitrary file's contents into `$DIR` — +and `$DIR` is where every prompt and answer then gets written. + +### What to pre-collect, and what not to + +codex and agy read files on their own. **Anything reachable by a path is passed as a path**, not +pasted. Only collect what a path cannot carry: + +| Source | Why a path will not do | +|---|---| +| mem0 memories | Behind an MCP service, not a file at all. Two searches (decisions, task learnings) keep the seats from re-proposing something already rejected. | +| Serena symbol graph | `find_referencing_symbols` output needs a language-server index. agy cannot build one. | +| code-scout research | Facts outside the repo. Most expensive — only when the question actually turns on external facts. | + +Do **not** paste `AGENTS.md`, `.llmwiki/` pages, or source files. Cite their paths. + +Write the shared brief to `$DIR/brief.md`: the question, the resolved facts, the pre-collected +context, and the file paths worth reading. + +--- + +## Step 2 — Round 1, independent opinions + +Compose `$DIR/r1-prompt.md` from the brief plus these instructions to every seat: + +1. Answer the question directly with reasoning. +2. Separately list **open questions for the user** — only things that would change the answer + and that the repo cannot settle. + +The Claude seat additionally gets the adversarial role described in Step 4. + +Run the three seats. They must not see each other's answers in this round. + +```bash +# Re-hydrate first — see "Re-hydrating state in every later block" in Step 1. +# Without it $CODEX_MODEL and $DIR are empty here and the call fails immediately. +REG="$HOME/.claude/council-models.json" +RUN_KEY="${CLAUDE_SESSION_ID:-${CODEX_COMPANION_SESSION_ID:-shared}}" +case "$RUN_KEY" in *[!A-Za-z0-9._-]*) echo "council: invalid session id" >&2; exit 1 ;; esac +DIR=$(cat ".claude/state/council-run-$RUN_KEY") +CODEX_MODEL=$(jq -r '.seats.codex.model' "$REG") +CODEX_EFFORT=$(jq -r '.seats.codex.effort' "$REG") +CODEX_TIER=$(jq -r '.seats.codex.service_tier' "$REG") + +# codex — prompt on stdin (no shell quoting of user text), answer to a file. +# Do NOT parse stdout: hook lines and token counts are interleaved into it. +codex exec -s read-only --skip-git-repo-check \ + -m "$CODEX_MODEL" \ + -c model_reasoning_effort="\"$CODEX_EFFORT\"" \ + -c service_tier="\"$CODEX_TIER\"" \ + -o "$DIR/r1-codex.md" - < "$DIR/r1-prompt.md" +``` + +```bash +# Re-hydrate first (Step 1) — a separate tool call means a separate process. +REG="$HOME/.claude/council-models.json" +RUN_KEY="${CLAUDE_SESSION_ID:-${CODEX_COMPANION_SESSION_ID:-shared}}" +case "$RUN_KEY" in *[!A-Za-z0-9._-]*) echo "council: invalid session id" >&2; exit 1 ;; esac +DIR=$(cat ".claude/state/council-run-$RUN_KEY") +AGY_MODEL=$(jq -r '.seats.agy.model' "$REG") + +# agy — prompt is a shell argument, so quote the expansion and keep --print LAST. +# The trailing < /dev/null is mandatory: without it agy waits on a TTY forever and +# --print-timeout does not bound it. +AGY_PROMPT="$(cat "$DIR/r1-prompt.md") +OUTPUT INSTRUCTION: do not print the answer to chat. Write it with the write_file tool to: + $PWD/$DIR/r1-agy.md +After writing, confirm the path. That is your only deliverable." +agy --dangerously-skip-permissions --add-dir "$PWD/$DIR" --print-timeout 10m0s \ + --model "$AGY_MODEL" --print "$AGY_PROMPT" < /dev/null +``` + +The Claude seat runs through the `Agent` tool with `model` set to `seats.claude.model` read from +the registry (`opus` by default; the tool accepts `sonnet`, `opus`, `haiku`, `fable`). Have it +write its answer to `$DIR/r1-claude.md`. + +Verify each file exists and is non-empty before moving on. A missing file is a failure, not an +empty opinion — see the failure policy. + +--- + +## Step 3 — re-question gate + +Merge the seats' open questions. Drop duplicates, and drop anything the chair can answer itself +from the repo or by running a read-only command — **facts are the chair's job, decisions are the +user's**. Ask what remains through the interactive-input gate, in the user's language, in one +batch. + +Record the questions and the user's answers in `$DIR/questions.md`. + +If the user declines to answer, carry those items forward as unresolved and say so in the +final document rather than guessing. + +--- + +## Step 4 — Round 2, mutual rebuttal + +Compose `$DIR/r2-prompt.md` containing, for each seat: the other two seats' full Round 1 +answers, the user's answers from Step 3, and the instruction to state where it agrees, where it +disagrees, and why — with reasons, not verdicts. + +The Claude seat's prompt carries an extra line: **attack the strongest argument among the other +seats first.** It shares weights with the chair, so agreement is its cheapest and least useful +move; the role exists to stop it defaulting there. + +Run the same three commands as Step 2 against `r2-prompt.md`, writing `r2-codex.md`, +`r2-agy.md`, `r2-claude.md`. Each round is a fresh CLI invocation carrying the debate in its +prompt — deterministic, and no dependence on `--resume-last` picking the right session. + +--- + +## Step 5 — synthesis and output + +The chair writes `$DIR/consensus.md`: + +- **합의** — what the seats converged on, and on what grounds. +- **갈린 이견** — positions that survived rebuttal, each with its holder and its strongest + argument. Do not average them into a fake middle. +- **미해결** — what nobody could answer, plus questions the user left open. +- **결석** — any seat that did not participate, and why. + +Apply the **same-family consensus discount**. The Claude seat shares weights with the chair, so +its agreement is not an independent second judgment. Count it only when it brought an argument +the chair had not already made; otherwise record the agreement without treating it as support. + +Print 합의 / 갈린 이견 / 미해결 to the conversation, and **결석 whenever any seat was absent** — +leave the rest of the record in the files. Dropping the absence line is what makes a two-seat +result read as a full three-seat council. + +**Do not apply anything to the code.** Present the conclusion and stop. Acting on a council +result is a separate, explicit request. + +--- + +## Failure policy + +Seats fail independently and the council continues without them. Every absence is named in +`consensus.md` and in the chat summary — a quiet absence would make a two-seat result look like +a three-seat one. + +| Situation | Action | +|---|---| +| `codex` or `agy` missing from PATH | Mark the seat absent. Continue. | +| agy produced no file, or an empty one | Retry once with the same prompt, then mark absent. | +| agy log shows `auth timed out` / `silent auth failed` | Do not retry — the model never ran. Tell the user to run `agy` once interactively to re-authenticate. | +| codex returns HTTP 400 on the model | The pin is stale. Surface it and offer the Step 0 registry question. | +| All three seats fail | There is no council. Report and stop; do not synthesize from nothing. | + +Triage an agy failure by matching its most recent log against the three known signatures. Match +and report the classification, not the log body — the log sits in an agent configuration +directory and its lines carry paths, settings, and auth diagnostics that the triage decision does +not need. Grepping for the three patterns answers the question with none of that exposure: + +```bash +LOG=$(ls -t "$HOME/.gemini/antigravity-cli/log/"cli-*.log 2>/dev/null | head -1) +if [ -z "$LOG" ]; then + echo "agy-triage: no log found" +elif grep -qE 'auth timed out|silent auth failed|keyringAuth: timed out' "$LOG"; then + echo "agy-triage: auth-timeout" # the model never ran — do not retry +elif grep -qE 'rename .*Access is denied' "$LOG"; then + echo "agy-triage: file-lock" # transient — the one retry is worth it +elif grep -qE 'text_drip.*length=' "$LOG"; then + echo "agy-triage: output-dropped" # generated but not delivered — retry +else + echo "agy-triage: unclassified" +fi +``` + +Show the raw log only if the user asks for it after seeing the classification. diff --git a/plugins/council/skills/convene/tests/run-tests.sh b/plugins/council/skills/convene/tests/run-tests.sh new file mode 100755 index 00000000..23311293 --- /dev/null +++ b/plugins/council/skills/convene/tests/run-tests.sh @@ -0,0 +1,328 @@ +#!/usr/bin/env bash +# Usage: bash plugins/council/skills/convene/tests/run-tests.sh +# +# The convene skill has no bundled scripts — its runner contracts and its +# registry arithmetic live as shell inside SKILL.md, where nothing executes them. +# Two classes of breakage are silent and expensive, so they are asserted here: +# +# 1. Runner contracts (grep the document). Dropping `< /dev/null` from the agy +# call makes it block forever on a TTY that never arrives, and +# `--print-timeout` does not bound it. Parsing codex stdout instead of its +# `-o` file picks up hook lines and token counts as if they were the answer. +# Neither failure is caught by check-shell-portability. +# 2. Registry behavior (execute it). The blocks are EXTRACTED FROM SKILL.md and +# run against a throwaway HOME — never re-typed here. A copy would let a +# regression in the real Step 0 block leave this suite green, which is the +# one outcome that makes the suite worse than having none. +# +# No network and no CLI calls. +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL="$HERE/../SKILL.md" +pass=0; fail=0 + +ok() { pass=$((pass+1)); printf ' ok %s\n' "$1"; } +bad() { fail=$((fail+1)); printf ' FAIL %s\n expected: %s\n actual: %s\n' "$1" "$2" "$3"; } +is() { [ "$2" = "$3" ] && ok "$1" || bad "$1" "$3" "$2"; } +has() { grep -qF -- "$2" "$SKILL" && ok "$1" || bad "$1" "present: $2" "absent"; } + +# Pull the first ```bash fence whose body contains $1, verbatim. Anchoring on +# content rather than an ordinal is deliberate: inserting one new fence earlier in +# the document would silently shift a positional index onto the wrong block, and +# the suite would then test something other than what it claims to. +# This is what keeps the executable cases honest — the bytes under test are the +# bytes the skill instructs an agent to run. +extract_bash_block_with() { + awk -v marker="$1" ' + /^```bash$/ { inb=1; n=0; hit=0; next } + inb && /^```$/ { + if (hit) { for (i = 1; i <= n; i++) print buf[i]; exit } + inb=0; next + } + inb { buf[++n] = $0; if (index($0, marker)) hit=1 } + ' "$SKILL" +} + +echo "document structure" + +# An unclosed fence swallows the prose that follows it, and the extractor then +# hands that prose to bash as if it were script. Caught exactly that in review. +is "code fences are balanced" "$(( $(grep -c '^```' "$SKILL") % 2 ))" 0 + +# Every ```bash fence must PARSE as shell. A pattern hunt for prose is a losing +# game — Korean text, bullets and headings all slip past a `**Uppercase` regex, +# and `#`-prefixed markdown is indistinguishable from a shell comment. `bash -n` +# is the actual parser, so it rejects leaked prose and plain syntax errors alike +# without guessing what prose looks like. +# Split each fence into its own file rather than piping — `base64 -w0` is GNU-only +# and the macOS leg runs this same suite. +FT=$(mktemp -d "${TMPDIR:-/tmp}/council-fences-XXXXXX") +awk -v dir="$FT" ' + /^```bash$/ { inb=1; n++; f=sprintf("%s/fence-%03d.sh", dir, n); next } + inb && /^```$/ { inb=0; close(f); next } + inb { print > f } +' "$SKILL" +bad_fences=0; fence_no=0 +for f in "$FT"/fence-*.sh; do + [ -e "$f" ] || break + fence_no=$((fence_no + 1)) + bash -n "$f" 2>/dev/null || { bad_fences=$((bad_fences + 1)); echo " unparseable: $f" >&2; } +done +rm -rf "$FT" +is "every bash fence parses as shell" "$bad_fences" 0 +is "the document has bash fences to check" "$([ "$fence_no" -ge 5 ] && echo enough || echo "only $fence_no")" enough + +echo +echo "runner contracts in SKILL.md" + +# agy blocks forever without this redirect; --print-timeout does not bound it. +has "agy call closes stdin with < /dev/null" '--print "$AGY_PROMPT" < /dev/null' +# Go's flag parser eats the next token as the prompt, so --print must come last. +has "--print is the last flag before the prompt" '--model "$AGY_MODEL" --print' + +# codex stdout interleaves hook lines and a token count; -o carries the answer. +has "codex captures its answer with -o" '-o "$DIR/r1-codex.md"' +# `-` reads the prompt from stdin, so no user text is ever re-parsed by the shell. +has "codex takes its prompt on stdin" '- < "$DIR/r1-prompt.md"' +has "codex effort is quoted as TOML" 'model_reasoning_effort="\"$CODEX_EFFORT\""' + +# GNU-only date math would break on macOS; epoch seconds behave the same on both. +# Only the positive intent is asserted here — check-shell-portability.mjs already +# owns "no unguarded GNU-only construct", and it distinguishes a real invocation +# from prose that merely names one. Re-implementing that distinction with grep +# would just false-flag this file's own explanation of why epochs are used. +has "TTL uses epoch arithmetic" 'checked_at_epoch' + +# Nothing set in one bash block survives into the next; the seat blocks must say so. +has "seat blocks re-hydrate their pins" 'DIR=$(cat ".claude/state/council-run-$RUN_KEY")' +# Two concurrent councils in one repo must not share a run pointer. +has "run pointer is keyed by session" 'DIR=$(cat ".claude/state/council-run-$RUN_KEY")' +# RUN_KEY lands in a filename, so it needs the same gate $SLUG gets — in EVERY +# fence that interpolates it, and BEFORE the first use. Two weaker versions of this +# assertion shipped before: one grepped the document (a sibling fence's copy kept it +# green) and one matched the charset anywhere in the fence (a comment mentioning the +# charset kept it green). Track the guard statement itself, and require it earlier +# in the fence than the first interpolation. +is "every RUN_KEY fence validates before use" \ + "$(awk ' + /^```bash$/ { inb=1; gate=0; bad_here=0; next } + inb && /^```$/ { bad += bad_here; inb=0; next } + inb { + if ($0 ~ /case[[:space:]]+"\$RUN_KEY"[[:space:]]+in/) gate=1 + if ((index($0, "council-run-$RUN_KEY") || index($0, "council-run-shared")) && !gate) bad_here=1 + } + END { print bad+0 }' "$SKILL")" 0 +# With no session id there is no safe way to separate two runs — refuse the second. +# The claim must be atomic: a check-then-write pointer lets both runs see "free". +has "shared-run claim uses an atomic mkdir" 'if ! mkdir "$LOCK" 2>/dev/null; then' +# A finished run must release the lock or it blocks every later invocation forever. +has "shared lock is released at the end" 'rmdir ".claude/state/council-lock-shared"' +# mkdir -p succeeds on an existing directory, so it cannot arbitrate a race. +has "run directory is allocated atomically" 'until mkdir "$DIR" 2>/dev/null; do' +# The preflight must probe the same config the seat call uses. +has "probe carries the service tier" '-c service_tier="\"$CODEX_TIER\"" \' +# The Claude seat has no CLI list — its candidate set is the Agent tool's enum. +has "claude pin is checked against the enum" 'sonnet|opus|haiku|fable) ;;' +# A read failure must not masquerade as a retired model. +has "unread lists are reported separately" 'LIST_UNREAD=codex reason=cache-unparseable' +# A user's deliberate `false` must not gain a duplicate key above it. +has "an existing non-true setting is preserved" 'leaving it untouched' +# The writer requires service_tier, so freshness must require it too. +has "service_tier is a required pin" '(.seats.codex.service_tier // "") != ""' +# codex reads $CODEX_HOME when set; $HOME/.codex is then a directory it never opens. +has "codex paths resolve through CODEX_HOME" 'CODEX_DIR="${CODEX_HOME:-$HOME/.codex}"' +# A probe whose exit status is never inspected is not a check. +has "codex probe branches on its result" 'SEAT=codex FAILED reason=probe-rejected-model-or-config' +# Confirmed pins are data, never shell source. +has "pins are read from a JSON file" 'PINS=".claude/state/council-pins.json"' +# $SLUG reaches a path and comes from free text. +has "SLUG is validated before path use" '*[!a-z0-9-]*' +# A bare `>` truncates the registry before jq runs. +has "registry is written through a temp file" 'mv "$tmp" "$REG"' +# check_for_update_on_startup is top-level; appending nests it under the last table. +has "codex config key goes before the first table" 'print key " = true"; done=1' +# A codex-less machine is an absent seat, not a dead run. +has "codex probe is guarded on the binary" 'if ! command -v codex >/dev/null 2>&1; then' +# A TTL read back from the file it governs is not a guarantee. +has "TTL is a constant, not a registry field" 'TTL=$(( 7 * 86400 ))' +# The chair must not silently field a two-seat council as if it were three. +has "absences reach the chat summary" '결석 whenever any seat was absent' +# Registry is global, not per-repo — a new repo must not re-ask on day one. +has "registry is global" '$HOME/.claude/council-models.json' + +echo +echo "registry behavior (blocks extracted from SKILL.md, not re-typed)" + +READER=$(extract_bash_block_with 'TTL=$(( 7 * 86400 ))') +WRITER=$(extract_bash_block_with 'council-models/v1') +CFGBLOCK=$(extract_bash_block_with 'top_level_true') + +# A renamed heading or a reordered fence must fail loudly, not silently extract +# nothing and let every case below "pass" against an empty script. +case "$READER" in *checked_at_epoch*) ok "reader block extracted from SKILL.md" ;; + *) bad "reader block extracted from SKILL.md" "block containing checked_at_epoch" "$(printf '%.60s' "$READER")" ;; esac +case "$WRITER" in *'council-models/v1'*) ok "writer block extracted from SKILL.md" ;; + *) bad "writer block extracted from SKILL.md" "block containing council-models/v1" "$(printf '%.60s' "$WRITER")" ;; esac + +T=$(mktemp -d "${TMPDIR:-/tmp}/council-tests-XXXXXX") +trap 'rm -rf "$T"' EXIT +mkdir -p "$T/home" "$T/cwd/.claude/state" +REG="$T/home/.claude/council-models.json" +PINS="$T/cwd/.claude/state/council-pins.json" + +# The writer resolves $PINS relative to the working directory, so both the reader +# and the writer run from a throwaway cwd as well as a throwaway HOME. +seed_pins() { + cat > "$PINS" <<'PINEOF' +{"codex":{"model":"gpt-5.6-sol","effort":"xhigh","service_tier":"fast"}, + "agy":{"model":"gemini-3.6-flash-high"}, + "claude":{"model":"opus"}} +PINEOF +} +seed_pins + +# Run the extracted reader against a throwaway HOME and report just its verdict. +state_of() { HOME="$T/home" bash -c "$READER" 2>/dev/null | sed -n 's/^STATE=//p'; } +# Run the extracted writer with NOTHING injected. Feeding the pins in through the +# environment is what previously hid a real defect: the documented block never +# re-assigned them, so following the skill literally wrote five empty strings +# while this suite stayed green. The pins now arrive as a FILE the agent wrote +# with the Write tool, so no user value is ever parsed by a shell. +write_registry() { ( cd "$T/cwd" && HOME="$T/home" bash -c "$WRITER" ) >/dev/null 2>&1; } +# Rewind checked_at_epoch by $1 days without touching anything else. +age_registry() { + jq --argjson o "$(( $(date +%s) - $1 * 86400 ))" '.checked_at_epoch=$o' "$REG" > "$T/x" \ + && mv "$T/x" "$REG" +} + +is "absent registry reads as missing" "$(state_of)" missing + +write_registry +is "writer creates the registry" "$([ -f "$REG" ] && echo yes || echo no)" yes +is "freshly written registry is fresh" "$(state_of)" fresh +is "all three seats are pinned" "$(jq -r '.seats | keys | join(",")' "$REG")" "agy,claude,codex" +is "claude seat defaults to opus" "$(jq -r '.seats.claude.model' "$REG")" opus +is "codex seat carries effort and tier" "$(jq -r '.seats.codex | "\(.effort)/\(.service_tier)"' "$REG")" "xhigh/fast" +is "codex update setting is recorded" "$(jq -r '.codex_config.check_for_update_on_startup' "$REG")" true + +age_registry 6; is "6 days old is still fresh" "$(state_of)" fresh +# The boundary itself: at exactly seven days the pin is due, not good one more second. +age_registry 7; is "exactly 7 days has expired" "$(state_of)" expired +age_registry 8; is "8 days old has expired" "$(state_of)" expired + +# The shapes a corrupted or tampered registry takes must not buy freshness. +jq 'del(.checked_at_epoch)' "$REG" > "$T/x" && mv "$T/x" "$REG" +is "registry without a timestamp expires" "$(state_of)" expired +write_registry +jq '.checked_at_epoch="not-a-number"' "$REG" > "$T/x" && mv "$T/x" "$REG" +is "non-numeric timestamp expires" "$(state_of)" expired +write_registry +age_registry -3 # three days in the FUTURE +is "future timestamp expires" "$(state_of)" expired +# A registry that inflates its own TTL must not extend the window. +write_registry +jq '.ttl_days=3650' "$REG" > "$T/x" && mv "$T/x" "$REG" +age_registry 8 +is "injected ttl_days cannot extend the window" "$(state_of)" expired + +# A failed write must leave the previous pins intact rather than truncate them. +# This is the whole point of the temp-file indirection: with a bare `>` the +# registry is already gone by the time jq reports failure. Assert the failure +# actually happened — otherwise a shim that silently did not take would leave the +# registry byte-identical and this case would "pass" having proven nothing. +write_registry # restore a good registry +before=$(cat "$REG") +mkdir -p "$T/nojq" +printf '#!/bin/sh\nexit 1\n' > "$T/nojq/jq"; chmod +x "$T/nojq/jq" +if ( cd "$T/cwd" && HOME="$T/home" PATH="$T/nojq:$PATH" bash -c "$WRITER" ) >/dev/null 2>&1; then rc=0; else rc=1; fi +is "writer reports failure when jq fails" "$rc" 1 +is "failed write keeps the old registry" "$(cat "$REG")" "$before" + +# The pin file is the trust boundary — every bad shape must stop the write. +try_pins() { # $1 = pin-file body; echoes the writer's exit code + printf '%s\n' "$1" > "$PINS" + if ( cd "$T/cwd" && HOME="$T/home" bash -c "$WRITER" ) >/dev/null 2>&1; then echo 0; else echo 1; fi +} +is "empty pin value is refused" "$(try_pins '{"codex":{"model":"","effort":"xhigh","service_tier":"fast"},"agy":{"model":"a"},"claude":{"model":"opus"}}')" 1 +is "missing seat is refused" "$(try_pins '{"codex":{"model":"m","effort":"xhigh","service_tier":"fast"},"claude":{"model":"opus"}}')" 1 +is "unparseable pin file is refused" "$(try_pins 'not json at all')" 1 +# Shell metacharacters must be rejected by the charset gate, not executed. +is "command substitution is refused" "$(try_pins '{"codex":{"model":"$(touch '"$T"'/pwned)","effort":"xhigh","service_tier":"fast"},"agy":{"model":"a"},"claude":{"model":"opus"}}')" 1 +is "injection did not execute" "$([ -e "$T/pwned" ] && echo yes || echo no)" no +is "quote injection is refused" "$(try_pins '{"codex":{"model":"a\"; rm -rf /tmp/x; \"","effort":"xhigh","service_tier":"fast"},"agy":{"model":"a"},"claude":{"model":"opus"}}')" 1 +# The Claude seat has no CLI to query, so a plausible-but-wrong name is charset-clean +# and would only fail when the seat launches. The enum is the candidate list. +is "off-enum claude pin is refused" "$(try_pins '{"codex":{"model":"m","effort":"xhigh","service_tier":"fast"},"agy":{"model":"a"},"claude":{"model":"claude-opus-4"}}')" 1 +# Every refusal above must have been a no-op on the registry. This has to run +# BEFORE any case that writes successfully, or it compares against a stale copy. +is "every refused write left the registry alone" "$(cat "$REG")" "$before" + +# ...and the four legal values must actually get through, so the gate is a filter +# rather than a wall. +is "every enum value is accepted" "$(for m in sonnet opus haiku fable; do + try_pins '{"codex":{"model":"m","effort":"xhigh","service_tier":"fast"},"agy":{"model":"a"},"claude":{"model":"'"$m"'"}}' + done | sort -u | tr -d '\n')" 0 +seed_pins # restore good pins for any later case + +echo +echo "codex config block — TOML scope (extracted from SKILL.md, executed)" + +case "$CFGBLOCK" in *check_for_update_on_startup*) ok "config block extracted from SKILL.md" ;; + *) bad "config block extracted from SKILL.md" "block defining top_level_true" "$(printf '%.60s' "$CFGBLOCK")" ;; esac + +# Run the real block against a throwaway CODEX_HOME seeded with $1, then report +# whether the setting ends up recognised as a top-level true. Grepping for the +# awk's presence is not enough — an `exit 0` inside a main rule jumps to END, +# where a second `exit` would silently overwrite the status. Only executing it +# catches that. +cfg_verdict() { + local home="$T/codex-$RANDOM$$"; mkdir -p "$home" + printf '%s\n' "$1" > "$home/config.toml" + if ( CODEX_HOME="$home" HOME="$T/home" bash -c "$CFGBLOCK" ) >/dev/null 2>&1; then + # block succeeded — report what the file now says at top level + awk '/^[[:space:]]*\[/{exit} /^[[:space:]]*check_for_update_on_startup[[:space:]]*=/{ + sub(/^[^=]*=[[:space:]]*/,""); sub(/#.*$/,""); sub(/[[:space:]]+$/,""); print; exit}' \ + "$home/config.toml" + else + echo "BLOCKED" + fi +} + +# How many times the key ends up in the file — a duplicate TOML key is the damage +# an append-when-not-true would leave behind in the user's global config. +cfg_count_key() { + local home="$T/codex-count-$RANDOM$$"; mkdir -p "$home" + printf '%s\n' "$1" > "$home/config.toml" + ( CODEX_HOME="$home" HOME="$T/home" bash -c "$CFGBLOCK" ) >/dev/null 2>&1 + grep -cE '^[[:space:]]*check_for_update_on_startup[[:space:]]*=' "$home/config.toml" +} + +is "already-true config is accepted as-is" \ + "$(cfg_verdict 'model = "x" +check_for_update_on_startup = true +[projects."a"]')" true +# A user who set it to false made a decision. Inserting a second assignment above +# it leaves a duplicate key in their global config — invalid TOML that outlives +# the abort. The block must leave the file exactly as it found it. +is "an explicit false is left untouched" \ + "$(cfg_verdict 'check_for_update_on_startup = false')" false +is "an explicit false gains no duplicate key" \ + "$(cfg_count_key 'check_for_update_on_startup = false')" 1 +is "inline comment after true is accepted" \ + "$(cfg_verdict 'check_for_update_on_startup = true # keep me')" true +# The key nested under a table is NOT the effective setting — the block must add +# a real top-level one rather than declaring success. +is "table-scoped key gets a top-level one added" \ + "$(cfg_verdict 'model = "x" +[projects."a"] +check_for_update_on_startup = true')" true +is "an empty config gains the key" "$(cfg_verdict '')" true +# A config that ends inside a table is the shape that made a naive append nest the key. +is "config ending in a table still gets top level" \ + "$(cfg_verdict '[hooks.state."a"] +enabled = true')" true + +echo +echo "$pass passed, $fail failed" +[ "$fail" -eq 0 ] diff --git a/scripts/check-skill-tool-portability.mjs b/scripts/check-skill-tool-portability.mjs index 7cede013..ce1f285e 100644 --- a/scripts/check-skill-tool-portability.mjs +++ b/scripts/check-skill-tool-portability.mjs @@ -34,6 +34,7 @@ const CODEX_TOOL = 'request_user_input'; const PILOTS = [ 'plugins/interview/skills/interview-methodology/SKILL.md', 'plugins/github-dev/skills/decompose-issue/SKILL.md', + 'plugins/council/skills/convene/SKILL.md', ]; // Reviewed baseline debt — skills that still hardcode AskUserQuestion, migration deferred. diff --git a/scripts/manifest-eligibility.mjs b/scripts/manifest-eligibility.mjs index ef1a8d6c..13ab562f 100644 --- a/scripts/manifest-eligibility.mjs +++ b/scripts/manifest-eligibility.mjs @@ -6,9 +6,12 @@ // Plugins intentionally not bridged to Codex. // codex-image — the Claude->Codex bridge itself (syncing it to Codex is circular) +// council — seats codex as a council member, so running it under Codex summons +// codex as its own seat; its Claude seat also needs the Agent tool, +// which Codex has no equivalent for // core-config was here but is now Codex-eligible: Codex plugins support bundled hooks // (hooks/codex-hooks.json), so its prompt_inject UserPromptSubmit hook ships natively. -export const CODEX_EXCLUDED = new Set(['codex-image']); +export const CODEX_EXCLUDED = new Set(['codex-image', 'council']); // Plugins that get generated Hermes adapters (plugin.yaml + __init__.py). export const HERMES_ELIGIBLE = new Set([