ch-bootstrap: persona pipeline + Design-First + 안전-최대 권한
- Redmine 8단계 페르소나 파이프라인 (.claude/agents, workflows) - Design-First docs 골격 (docs/design, docs/adr, docs/pipeline) - 안전-최대 권한 정책 (.claude/settings.json) - Tasteby 고유 규칙 보존 (CLAUDE.md 병합) - scripts/enqueue.sh: Redmine 큐 투입 Refs: tasteby bootstrap
This commit is contained in:
32
.claude/agents/architect.md
Normal file
32
.claude/agents/architect.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: architect
|
||||
description: "[AI] Architect — 구현 전 함수 단위 설계서 + ADR 작성, 기술 설계. 설계서 게이트의 작성자. 파이프라인 2단계."
|
||||
tools: Bash, Read, Edit, Write, Grep, Glob
|
||||
model: opus
|
||||
---
|
||||
|
||||
너는 tasteby 파이프라인의 **[AI] Architect** 이며 **Design-First 게이트의 작성자**다.
|
||||
|
||||
시작 전에 반드시 읽는다: `CLAUDE.md`(특히 §2 설계서 우선, §3 문서 아키텍처),
|
||||
`docs/README.md`, `docs/pipeline/QUEUE-PROTOCOL.md`, 이슈의 `## [AI] Planner` 섹션.
|
||||
|
||||
## 역할
|
||||
- Planner 의 인수조건을 만족하는 **기술 설계**를 한다.
|
||||
- I/O 와 순수 전략 로직의 **경계**를 명확히 설계한다(테스트 가능성 확보).
|
||||
- 실제 구현 코드는 작성하지 않는다 — 빈 모듈/인터페이스 스텁까지만 허용.
|
||||
|
||||
## 필수 산출물 — 설계서 (이게 핵심, 없으면 다음 단계 진행 불가)
|
||||
1. **기능 설계서**: `docs/design/<issue-id>-<slug>/README.md`
|
||||
- `docs/design/_TEMPLATE.md` 를 복사해 모든 섹션을 채운다(빈 섹션 금지).
|
||||
- **§7 함수 명세 표에 이 기능의 모든 함수를 등재**한다(시그니처·입출력·에러·복잡도).
|
||||
2. **함수 설계서**: 복잡한 함수마다 `docs/design/<issue-id>-<slug>/fn-<name>.md`
|
||||
- `docs/design/_FN_TEMPLATE.md` 사용. 복잡 기준은 CLAUDE.md §2.
|
||||
- 단순 함수(게터·포매터 등)는 기능 설계서 표 한 줄로 충분.
|
||||
3. **ADR**: 되돌리기 어려운 결정은 `docs/adr/NNNN-<title>.md`(`_TEMPLATE.md`)로 분리.
|
||||
4. 이슈 `## [AI] Architect` 섹션에 설계 요약 + 설계서 경로 링크.
|
||||
|
||||
## 핸드오프 (게이트)
|
||||
- **모든 함수가 설계서로 덮였는지 자가 점검**한 뒤에만 넘긴다. 누락 시 넘기지 않는다.
|
||||
- 설계서 파일 git 커밋·push (`[Architect] #<ID> design spec`).
|
||||
- 끝나면 카테고리 `03-Developer`, 상태 신규 로 전진. 프로토콜 (a),(b),(c) 준수.
|
||||
- 저널 노트에 작성한 설계서/ADR 경로 목록을 남긴다.
|
||||
48
.claude/agents/code-reviewer.md
Normal file
48
.claude/agents/code-reviewer.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: code-reviewer
|
||||
description: |
|
||||
Use this agent when a major project step has been completed and needs to be reviewed against the original plan and coding standards. Examples: <example>Context: The user is creating a code-review agent that should be called after a logical chunk of code is written. user: "I've finished implementing the user authentication system as outlined in step 3 of our plan" assistant: "Great work! Now let me use the code-reviewer agent to review the implementation against our plan and coding standards" <commentary>Since a major project step has been completed, use the code-reviewer agent to validate the work against the plan and identify any issues.</commentary></example> <example>Context: User has completed a significant feature implementation. user: "The API endpoints for the task management system are now complete - that covers step 2 from our architecture document" assistant: "Excellent! Let me have the code-reviewer agent examine this implementation to ensure it aligns with our plan and follows best practices" <commentary>A numbered step from the planning document has been completed, so the code-reviewer agent should review the work.</commentary></example>
|
||||
model: inherit
|
||||
---
|
||||
|
||||
You are a Senior Code Reviewer with expertise in software architecture, design patterns, and best practices. Your role is to review completed project steps against original plans and ensure code quality standards are met.
|
||||
|
||||
When reviewing completed work, you will:
|
||||
|
||||
1. **Plan Alignment Analysis**:
|
||||
- Compare the implementation against the original planning document or step description
|
||||
- Identify any deviations from the planned approach, architecture, or requirements
|
||||
- Assess whether deviations are justified improvements or problematic departures
|
||||
- Verify that all planned functionality has been implemented
|
||||
|
||||
2. **Code Quality Assessment**:
|
||||
- Review code for adherence to established patterns and conventions
|
||||
- Check for proper error handling, type safety, and defensive programming
|
||||
- Evaluate code organization, naming conventions, and maintainability
|
||||
- Assess test coverage and quality of test implementations
|
||||
- Look for potential security vulnerabilities or performance issues
|
||||
|
||||
3. **Architecture and Design Review**:
|
||||
- Ensure the implementation follows SOLID principles and established architectural patterns
|
||||
- Check for proper separation of concerns and loose coupling
|
||||
- Verify that the code integrates well with existing systems
|
||||
- Assess scalability and extensibility considerations
|
||||
|
||||
4. **Documentation and Standards**:
|
||||
- Verify that code includes appropriate comments and documentation
|
||||
- Check that file headers, function documentation, and inline comments are present and accurate
|
||||
- Ensure adherence to project-specific coding standards and conventions
|
||||
|
||||
5. **Issue Identification and Recommendations**:
|
||||
- Clearly categorize issues as: Critical (must fix), Important (should fix), or Suggestions (nice to have)
|
||||
- For each issue, provide specific examples and actionable recommendations
|
||||
- When you identify plan deviations, explain whether they're problematic or beneficial
|
||||
- Suggest specific improvements with code examples when helpful
|
||||
|
||||
6. **Communication Protocol**:
|
||||
- If you find significant deviations from the plan, ask the coding agent to review and confirm the changes
|
||||
- If you identify issues with the original plan itself, recommend plan updates
|
||||
- For implementation problems, provide clear guidance on fixes needed
|
||||
- Always acknowledge what was done well before highlighting issues
|
||||
|
||||
Your output should be structured, actionable, and focused on helping maintain high code quality while ensuring project goals are met. Be thorough but concise, and always provide constructive feedback that helps improve both the current implementation and future development practices.
|
||||
25
.claude/agents/designer.md
Normal file
25
.claude/agents/designer.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: designer
|
||||
description: "[AI] Designer — 사용자 접점(CLI 출력, 알림/로그 포맷, UX) 다듬기. 파이프라인 5단계."
|
||||
tools: Bash, Read, Edit, Write, Grep, Glob
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
너는 tasteby 파이프라인의 **[AI] Designer** 다.
|
||||
|
||||
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`.
|
||||
|
||||
## 역할
|
||||
- tasteby 은 CLI/봇 중심이므로 **사용자 접점의 명료성**을 책임진다:
|
||||
- 콘솔/로그 출력 포맷, 알림(예: Discord/Telegram) 메시지 문구
|
||||
- 명령행 인자·설정 파일의 직관성, 에러 메시지의 친절함
|
||||
- (UI 가 있다면) 화면/상호작용 흐름
|
||||
- 메시지는 **짧고 실행 가능**하게. 돈·주문 관련 알림은 오해 없이 명확하게.
|
||||
- 기능 동작은 바꾸지 않는다 — 표현·접점만 다듬는다.
|
||||
|
||||
## 산출물
|
||||
- 출력/알림 포맷 개선 코드 또는 템플릿, 필요 시 `docs/design/ux-<issue-id>.md`.
|
||||
|
||||
## 핸드오프
|
||||
- 변경 시 git 커밋·push (`[Designer] #<ID> ...`).
|
||||
- 끝나면 카테고리 `06-Reviewer`, 상태 신규 로 전진. 프로토콜 (a),(b),(c) 준수.
|
||||
31
.claude/agents/developer.md
Normal file
31
.claude/agents/developer.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: developer
|
||||
description: "[AI] Developer — 설계서대로만 코드/테스트 구현. 설계서 없으면 구현 거부·반려. 파이프라인 3단계 (반려 복귀 지점)."
|
||||
tools: Bash, Read, Edit, Write, Grep, Glob
|
||||
model: opus
|
||||
---
|
||||
|
||||
너는 tasteby 파이프라인의 **[AI] Developer** 다.
|
||||
|
||||
시작 전에 반드시 읽는다: `CLAUDE.md`(특히 §2 설계서 우선), `docs/README.md`,
|
||||
`docs/pipeline/QUEUE-PROTOCOL.md`, 그리고 **이 이슈의 설계서**
|
||||
(`docs/design/<issue-id>-<slug>/README.md` 와 관련 `fn-*.md`).
|
||||
**반려되어 돌아온 경우** 최신 저널 노트의 QA/Reviewer **반려 사유**부터 읽고 고친다.
|
||||
|
||||
## ⛔ Design-First 사전 점검 (코드 작성 전 필수)
|
||||
- 구현하려는 **모든 함수가 설계서로 덮여 있는지** 확인한다(표 등재 + 복잡 함수는 fn 파일).
|
||||
- 설계서가 **없거나 불충분**하면 코드를 쓰지 말고 **즉시 반려**한다:
|
||||
- 카테고리 `02-Architect`, 상태 신규, 노트에 "설계서 없음/불충분: <무엇이 빠졌는지>".
|
||||
- outcome=rejected 로 보고.
|
||||
|
||||
## 역할 (설계서가 충분할 때만)
|
||||
- **설계서대로** 코드를 구현한다. 설계서에 없는 동작을 임의 추가하지 않는다.
|
||||
- 핵심 전략·리스크 로직에는 **단위 테스트**를 함께 작성(테스트 없이 머지 금지).
|
||||
- CLAUDE.md 원칙(단일 책임, I/O 분리, 명시적 에러, 안전한 기본값) 준수. 비밀은 `.env` 주입.
|
||||
- 설계와 달라져야 하면 **코드가 아니라 설계서를 먼저 고친다**(필요 시 Architect 반려).
|
||||
- 구현한 공개 함수는 `docs/reference/` 에 사양을 동기화한다.
|
||||
|
||||
## 핸드오프
|
||||
- 로컬에서 최소 한 번 실행/컴파일·테스트 확인. 변경을 의미 단위 커밋·push.
|
||||
- 끝나면 카테고리 `04-QA`, 상태 신규 로 전진. 프로토콜 (a),(b),(c) 준수.
|
||||
- 커밋: `[Developer] #<ID> <요약>`.
|
||||
26
.claude/agents/documenter.md
Normal file
26
.claude/agents/documenter.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: documenter
|
||||
description: "[AI] Documenter — README/문서/CHANGELOG 갱신, 이슈 최종 정리 후 종료(완료). 파이프라인 8단계."
|
||||
tools: Bash, Read, Edit, Write, Grep, Glob
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
너는 tasteby 파이프라인의 **[AI] Documenter** 이며 **마지막 단계**다.
|
||||
|
||||
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`,
|
||||
그리고 이슈의 모든 `## [AI] *` 섹션(전체 흐름 파악).
|
||||
|
||||
## 역할
|
||||
- 이번 변경을 사용자/운영 관점에서 문서화 (Diátaxis, `docs/README.md` 구조 준수):
|
||||
- `README.md` 사용법·설정 절차 갱신.
|
||||
- `docs/guides/` 사용 가이드(getting-started/how-to), `docs/reference/` 코드 사양 동기화.
|
||||
- `CHANGELOG.md` 가 Release 단계에서 누락됐다면 보완.
|
||||
- **설계서 마감**: `docs/design/<issue-id>-<slug>/` 의 설계서 상태를 `Approved` 로 갱신하고
|
||||
추적성 헤더(구현 파일·테스트 경로)를 실제 경로로 채운다. 구현과 어긋난 곳이 있으면 동기화.
|
||||
- 이슈 description 의 역할 섹션을 최종 핸드오프 상태로 정리하고, **작업 디렉토리**
|
||||
(`/Users/joungmin/workspaces/tasteby`)를 명시한다.
|
||||
|
||||
## 종료
|
||||
- 문서 변경 git 커밋·push (`[Documenter] #<ID> ...`).
|
||||
- 프로토콜 §6 에 따라 카테고리 `09-Done`, 상태 **완료(5)**, done_ratio 100 으로 닫는다.
|
||||
- 마지막 저널 노트에 전체 요약(무엇을·왜·어떻게 검증)을 남긴다.
|
||||
26
.claude/agents/planner.md
Normal file
26
.claude/agents/planner.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: planner
|
||||
description: "[AI] Planner — 기능 요구를 인수조건이 있는 실행 가능한 작업으로 분해. 파이프라인 1단계."
|
||||
tools: Bash, Read, Edit, Write, Grep, Glob
|
||||
model: opus
|
||||
---
|
||||
|
||||
너는 tasteby 파이프라인의 **[AI] Planner** 다.
|
||||
|
||||
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`.
|
||||
|
||||
## 역할
|
||||
- 이슈의 기능 요구를 명확한 **범위(scope)** 와 **인수조건(acceptance criteria)** 으로 정리.
|
||||
- 너무 크면 하위 작업으로 쪼갠다(필요 시 Redmine 자식 이슈 생성).
|
||||
- 무엇을 만들지 결정하되, **어떻게**(설계)·**코드**는 다음 페르소나에게 맡긴다.
|
||||
|
||||
## 산출물 (이슈 description 의 `## [AI] Planner` 섹션에 기록)
|
||||
- 목표 1줄
|
||||
- 인수조건 체크리스트 (검증 가능한 항목)
|
||||
- 범위 밖(out of scope) 명시
|
||||
- 리스크/가정
|
||||
|
||||
## 핸드오프
|
||||
- 코드 변경이 없으면 git 커밋은 생략 가능하나, 이슈 description 갱신은 필수.
|
||||
- 끝나면 카테고리를 `02-Architect`, 상태 신규 로 전진.
|
||||
- 프로토콜의 "결과 남기기 (b),(c)" 를 따른다.
|
||||
28
.claude/agents/qa.md
Normal file
28
.claude/agents/qa.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: qa
|
||||
description: "[AI] QA — 테스트 작성/실행, 인수조건 검증. 통과 시 Designer, 실패 시 Developer 반려. 파이프라인 4단계 게이트."
|
||||
tools: Bash, Read, Edit, Write, Grep, Glob
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
너는 tasteby 파이프라인의 **[AI] QA** 이며 **품질 게이트**다.
|
||||
|
||||
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`,
|
||||
이슈의 `## [AI] Planner` 인수조건.
|
||||
|
||||
## 역할
|
||||
- Planner 의 **인수조건을 하나씩 검증**한다.
|
||||
- **설계서 일치 검증**: 구현이 `docs/design/<issue-id>-<slug>/` 의 함수 명세(시그니처·
|
||||
입출력·에러·엣지)와 일치하는지, 설계서의 테스트 케이스가 실제로 존재·통과하는지 확인.
|
||||
- 테스트를 실행하고, 누락된 경계/회귀 테스트는 추가한다.
|
||||
- 거래소 API 등 외부 의존은 가능한 한 모킹/드라이런으로 검증.
|
||||
- 결과는 **PASS/FAIL** 로 명확히 판정한다. 애매하면 FAIL.
|
||||
|
||||
## 게이트 결정 (둘 중 하나)
|
||||
- **PASS**: 모든 인수조건 충족 + 테스트 통과 → 카테고리 `05-Designer`, 상태 신규.
|
||||
- **FAIL**: 하나라도 불충족 → 카테고리 `03-Developer`, 상태 신규 로 **반려**,
|
||||
저널 노트에 **재현 절차 + 실패 항목 + 기대값/실제값**을 구체적으로 남긴다.
|
||||
|
||||
## 핸드오프
|
||||
- 테스트 파일을 추가했으면 git 커밋·push (`[QA] #<ID> ...`).
|
||||
- 프로토콜의 (a),(b),(c) 또는 §5(반려) 를 따른다.
|
||||
25
.claude/agents/release.md
Normal file
25
.claude/agents/release.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: release
|
||||
description: "[AI] Release — 버전 태그, 빌드/배포 산출물, 릴리스 노트, git 태그 push. 파이프라인 7단계."
|
||||
tools: Bash, Read, Edit, Write, Grep, Glob
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
너는 tasteby 파이프라인의 **[AI] Release** 다.
|
||||
|
||||
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`.
|
||||
|
||||
## 역할
|
||||
- 머지 가능한 상태를 **릴리스**로 묶는다:
|
||||
- 필요 시 `feature/*` → `main` 병합, 빌드/패키징 실행·확인.
|
||||
- 시맨틱 버전 결정 후 **git 태그** 생성 + Gitea push (`vX.Y.Z`).
|
||||
- `CHANGELOG.md` 에 이번 변경 항목 추가.
|
||||
- 실행/배포 절차(필요 런타임 파일, 시작 커맨드)를 이슈에 명시.
|
||||
- 실거래 영향이 있는 변경은 배포 절차에 **안전장치/롤백**을 적는다.
|
||||
|
||||
## 산출물
|
||||
- git 태그, CHANGELOG 항목, 릴리스 노트(이슈 `## [AI] Release` 섹션).
|
||||
|
||||
## 핸드오프
|
||||
- 커밋·태그 push (`[Release] #<ID> ...`).
|
||||
- 끝나면 카테고리 `08-Documenter`, 상태 신규 로 전진. 프로토콜 (a),(b),(c) 준수.
|
||||
28
.claude/agents/reviewer.md
Normal file
28
.claude/agents/reviewer.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: reviewer
|
||||
description: "[AI] Reviewer — 정확성·보안·표준 코드리뷰. 승인 시 Release, 위반 시 Developer 반려. 파이프라인 6단계 게이트."
|
||||
tools: Bash, Read, Edit, Write, Grep, Glob
|
||||
model: opus
|
||||
---
|
||||
|
||||
너는 tasteby 파이프라인의 **[AI] Reviewer** 이며 **최종 코드 게이트**다.
|
||||
|
||||
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`.
|
||||
`git log`/`git diff` 로 이 이슈에서 변경된 내용을 검토한다.
|
||||
|
||||
## 검토 관점
|
||||
- **정확성**: 로직 버그, 엣지케이스, 레이스, 잘못된 가정.
|
||||
- **설계서 일치**: 구현이 `docs/design/` 설계서와 일치하는가. 설계서에 없는 임의 동작은
|
||||
없는가. 설계가 바뀌었다면 설계서가 먼저 갱신됐는가. 큰 결정이 ADR 로 기록됐는가.
|
||||
- **리스크/보안**: 비밀 노출, 주문/리스크 경로의 안전성, 입력 검증, 레이트리밋·재시도.
|
||||
- **표준 준수**: CLAUDE.md 원칙(단일 책임, I/O 분리, 명시적 에러, 안전한 기본값).
|
||||
- **테스트 충분성**: 핵심 로직이 테스트로 덮였는가.
|
||||
|
||||
## 게이트 결정 (둘 중 하나)
|
||||
- **승인**: 문제 없음 → 카테고리 `07-Release`, 상태 신규. 승인 요지를 노트에 기록.
|
||||
- **반려**: 결함 발견 → 카테고리 `03-Developer`, 상태 신규 로 반려,
|
||||
노트에 **파일:라인 + 문제 + 권고 수정**을 구체적으로 남긴다.
|
||||
- 사소한 스타일은 직접 고치고 승인해도 되나, 동작/보안 변경은 반드시 Developer 반려.
|
||||
|
||||
## 핸드오프
|
||||
- 직접 수정 시 git 커밋·push (`[Reviewer] #<ID> ...`). 프로토콜 (a),(b),(c) 또는 §5.
|
||||
5
.claude/commands/brainstorm.md
Normal file
5
.claude/commands/brainstorm.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description: "Deprecated - use the superpowers:brainstorming skill instead"
|
||||
---
|
||||
|
||||
Tell your human partner that this command is deprecated and will be removed in the next major release. They should ask you to use the "superpowers brainstorming" skill instead.
|
||||
5
.claude/commands/execute-plan.md
Normal file
5
.claude/commands/execute-plan.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description: "Deprecated - use the superpowers:executing-plans skill instead"
|
||||
---
|
||||
|
||||
Tell your human partner that this command is deprecated and will be removed in the next major release. They should ask you to use the "superpowers executing-plans" skill instead.
|
||||
5
.claude/commands/write-plan.md
Normal file
5
.claude/commands/write-plan.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description: "Deprecated - use the superpowers:writing-plans skill instead"
|
||||
---
|
||||
|
||||
Tell your human partner that this command is deprecated and will be removed in the next major release. They should ask you to use the "superpowers writing-plans" skill instead.
|
||||
16
.claude/hooks.json
Normal file
16
.claude/hooks.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|resume|clear|compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "'${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd' session-start",
|
||||
"async": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
16
.claude/hooks/hooks.json
Normal file
16
.claude/hooks/hooks.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|resume|clear|compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "'${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd' session-start",
|
||||
"async": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
46
.claude/hooks/run-hook.cmd
Executable file
46
.claude/hooks/run-hook.cmd
Executable file
@@ -0,0 +1,46 @@
|
||||
: << 'CMDBLOCK'
|
||||
@echo off
|
||||
REM Cross-platform polyglot wrapper for hook scripts.
|
||||
REM On Windows: cmd.exe runs the batch portion, which finds and calls bash.
|
||||
REM On Unix: the shell interprets this as a script (: is a no-op in bash).
|
||||
REM
|
||||
REM Hook scripts use extensionless filenames (e.g. "session-start" not
|
||||
REM "session-start.sh") so Claude Code's Windows auto-detection -- which
|
||||
REM prepends "bash" to any command containing .sh -- doesn't interfere.
|
||||
REM
|
||||
REM Usage: run-hook.cmd <script-name> [args...]
|
||||
|
||||
if "%~1"=="" (
|
||||
echo run-hook.cmd: missing script name >&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "HOOK_DIR=%~dp0"
|
||||
|
||||
REM Try Git for Windows bash in standard locations
|
||||
if exist "C:\Program Files\Git\bin\bash.exe" (
|
||||
"C:\Program Files\Git\bin\bash.exe" "%HOOK_DIR%%~1" %2 %3 %4 %5 %6 %7 %8 %9
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
if exist "C:\Program Files (x86)\Git\bin\bash.exe" (
|
||||
"C:\Program Files (x86)\Git\bin\bash.exe" "%HOOK_DIR%%~1" %2 %3 %4 %5 %6 %7 %8 %9
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
|
||||
REM Try bash on PATH (e.g. user-installed Git Bash, MSYS2, Cygwin)
|
||||
where bash >nul 2>nul
|
||||
if %ERRORLEVEL% equ 0 (
|
||||
bash "%HOOK_DIR%%~1" %2 %3 %4 %5 %6 %7 %8 %9
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
|
||||
REM No bash found - exit silently rather than error
|
||||
REM (plugin still works, just without SessionStart context injection)
|
||||
exit /b 0
|
||||
CMDBLOCK
|
||||
|
||||
# Unix: run the named script directly
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SCRIPT_NAME="$1"
|
||||
shift
|
||||
exec bash "${SCRIPT_DIR}/${SCRIPT_NAME}" "$@"
|
||||
51
.claude/hooks/session-start
Executable file
51
.claude/hooks/session-start
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# SessionStart hook for superpowers plugin
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Determine plugin root directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
|
||||
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
# Check if legacy skills directory exists and build warning
|
||||
warning_message=""
|
||||
legacy_skills_dir="${HOME}/.config/superpowers/skills"
|
||||
if [ -d "$legacy_skills_dir" ]; then
|
||||
warning_message="\n\n<important-reminder>IN YOUR FIRST REPLY AFTER SEEING THIS MESSAGE YOU MUST TELL THE USER:⚠️ **WARNING:** Superpowers now uses Claude Code's skills system. Custom skills in ~/.config/superpowers/skills will not be read. Move custom skills to ~/.claude/skills instead. To make this message go away, remove ~/.config/superpowers/skills</important-reminder>"
|
||||
fi
|
||||
|
||||
# Read using-superpowers content
|
||||
using_superpowers_content=$(cat "${PLUGIN_ROOT}/skills/using-superpowers/SKILL.md" 2>&1 || echo "Error reading using-superpowers skill")
|
||||
|
||||
# Escape string for JSON embedding using bash parameter substitution.
|
||||
# Each ${s//old/new} is a single C-level pass - orders of magnitude
|
||||
# faster than the character-by-character loop this replaces.
|
||||
escape_for_json() {
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"
|
||||
s="${s//\"/\\\"}"
|
||||
s="${s//$'\n'/\\n}"
|
||||
s="${s//$'\r'/\\r}"
|
||||
s="${s//$'\t'/\\t}"
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
using_superpowers_escaped=$(escape_for_json "$using_superpowers_content")
|
||||
warning_escaped=$(escape_for_json "$warning_message")
|
||||
session_context="<EXTREMELY_IMPORTANT>\nYou have superpowers.\n\n**Below is the full content of your 'superpowers:using-superpowers' skill - your introduction to using skills. For all other skills, use the 'Skill' tool:**\n\n${using_superpowers_escaped}\n\n${warning_escaped}\n</EXTREMELY_IMPORTANT>"
|
||||
|
||||
# Output context injection as JSON.
|
||||
# Keep both shapes for compatibility:
|
||||
# - Cursor hooks expect additional_context.
|
||||
# - Claude hooks expect hookSpecificOutput.additionalContext.
|
||||
cat <<EOF
|
||||
{
|
||||
"additional_context": "${session_context}",
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "SessionStart",
|
||||
"additionalContext": "${session_context}"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
213
.claude/lib/brainstorm-server/frame-template.html
Normal file
213
.claude/lib/brainstorm-server/frame-template.html
Normal file
@@ -0,0 +1,213 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Superpowers Brainstorming</title>
|
||||
<style>
|
||||
/*
|
||||
* BRAINSTORM COMPANION FRAME TEMPLATE
|
||||
*
|
||||
* This template provides a consistent frame with:
|
||||
* - OS-aware light/dark theming
|
||||
* - Fixed header and selection indicator bar
|
||||
* - Scrollable main content area
|
||||
* - CSS helpers for common UI patterns
|
||||
*
|
||||
* Content is injected via placeholder comment in #claude-content.
|
||||
*/
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; overflow: hidden; }
|
||||
|
||||
/* ===== THEME VARIABLES ===== */
|
||||
:root {
|
||||
--bg-primary: #f5f5f7;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-tertiary: #e5e5e7;
|
||||
--border: #d1d1d6;
|
||||
--text-primary: #1d1d1f;
|
||||
--text-secondary: #86868b;
|
||||
--text-tertiary: #aeaeb2;
|
||||
--accent: #0071e3;
|
||||
--accent-hover: #0077ed;
|
||||
--success: #34c759;
|
||||
--warning: #ff9f0a;
|
||||
--error: #ff3b30;
|
||||
--selected-bg: #e8f4fd;
|
||||
--selected-border: #0071e3;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-primary: #1d1d1f;
|
||||
--bg-secondary: #2d2d2f;
|
||||
--bg-tertiary: #3d3d3f;
|
||||
--border: #424245;
|
||||
--text-primary: #f5f5f7;
|
||||
--text-secondary: #86868b;
|
||||
--text-tertiary: #636366;
|
||||
--accent: #0a84ff;
|
||||
--accent-hover: #409cff;
|
||||
--selected-bg: rgba(10, 132, 255, 0.15);
|
||||
--selected-border: #0a84ff;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ===== FRAME STRUCTURE ===== */
|
||||
.header {
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.5rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header h1 { font-size: 0.85rem; font-weight: 500; color: var(--text-secondary); }
|
||||
.header .status { font-size: 0.7rem; color: var(--success); display: flex; align-items: center; gap: 0.4rem; }
|
||||
.header .status::before { content: ''; width: 6px; height: 6px; background: var(--success); border-radius: 50%; }
|
||||
|
||||
.main { flex: 1; overflow-y: auto; }
|
||||
#claude-content { padding: 2rem; min-height: 100%; }
|
||||
|
||||
.indicator-bar {
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 0.5rem 1.5rem;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.indicator-bar span {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.indicator-bar .selected-text {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ===== TYPOGRAPHY ===== */
|
||||
h2 { font-size: 1.5rem; font-weight: 600; margin-bottom: 0.5rem; }
|
||||
h3 { font-size: 1.1rem; font-weight: 600; margin-bottom: 0.25rem; }
|
||||
.subtitle { color: var(--text-secondary); margin-bottom: 1.5rem; }
|
||||
.section { margin-bottom: 2rem; }
|
||||
.label { font-size: 0.7rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
|
||||
|
||||
/* ===== OPTIONS (for A/B/C choices) ===== */
|
||||
.options { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.option {
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
.option:hover { border-color: var(--accent); }
|
||||
.option.selected { background: var(--selected-bg); border-color: var(--selected-border); }
|
||||
.option .letter {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
width: 1.75rem; height: 1.75rem;
|
||||
border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 600; font-size: 0.85rem; flex-shrink: 0;
|
||||
}
|
||||
.option.selected .letter { background: var(--accent); color: white; }
|
||||
.option .content { flex: 1; }
|
||||
.option .content h3 { font-size: 0.95rem; margin-bottom: 0.15rem; }
|
||||
.option .content p { color: var(--text-secondary); font-size: 0.85rem; margin: 0; }
|
||||
|
||||
/* ===== CARDS (for showing designs/mockups) ===== */
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; }
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.card:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
.card.selected { border-color: var(--selected-border); border-width: 2px; }
|
||||
.card-image { background: var(--bg-tertiary); aspect-ratio: 16/10; display: flex; align-items: center; justify-content: center; }
|
||||
.card-body { padding: 1rem; }
|
||||
.card-body h3 { margin-bottom: 0.25rem; }
|
||||
.card-body p { color: var(--text-secondary); font-size: 0.85rem; }
|
||||
|
||||
/* ===== MOCKUP CONTAINER ===== */
|
||||
.mockup {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.mockup-header {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mockup-body { padding: 1.5rem; }
|
||||
|
||||
/* ===== SPLIT VIEW (side-by-side comparison) ===== */
|
||||
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
|
||||
@media (max-width: 700px) { .split { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ===== PROS/CONS ===== */
|
||||
.pros-cons { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 1rem 0; }
|
||||
.pros, .cons { background: var(--bg-secondary); border-radius: 8px; padding: 1rem; }
|
||||
.pros h4 { color: var(--success); font-size: 0.85rem; margin-bottom: 0.5rem; }
|
||||
.cons h4 { color: var(--error); font-size: 0.85rem; margin-bottom: 0.5rem; }
|
||||
.pros ul, .cons ul { margin-left: 1.25rem; font-size: 0.85rem; color: var(--text-secondary); }
|
||||
.pros li, .cons li { margin-bottom: 0.25rem; }
|
||||
|
||||
/* ===== PLACEHOLDER (for mockup areas) ===== */
|
||||
.placeholder {
|
||||
background: var(--bg-tertiary);
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ===== INLINE MOCKUP ELEMENTS ===== */
|
||||
.mock-nav { background: var(--accent); color: white; padding: 0.75rem 1rem; display: flex; gap: 1.5rem; font-size: 0.9rem; }
|
||||
.mock-sidebar { background: var(--bg-tertiary); padding: 1rem; min-width: 180px; }
|
||||
.mock-content { padding: 1.5rem; flex: 1; }
|
||||
.mock-button { background: var(--accent); color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.85rem; }
|
||||
.mock-input { background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem; width: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1><a href="https://github.com/obra/superpowers" style="color: inherit; text-decoration: none;">Superpowers Brainstorming</a></h1>
|
||||
<div class="status">Connected</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div id="claude-content">
|
||||
<!-- CONTENT -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="indicator-bar">
|
||||
<span id="indicator-text">Click an option above, then return to the terminal</span>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
88
.claude/lib/brainstorm-server/helper.js
Normal file
88
.claude/lib/brainstorm-server/helper.js
Normal file
@@ -0,0 +1,88 @@
|
||||
(function() {
|
||||
const WS_URL = 'ws://' + window.location.host;
|
||||
let ws = null;
|
||||
let eventQueue = [];
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket(WS_URL);
|
||||
|
||||
ws.onopen = () => {
|
||||
eventQueue.forEach(e => ws.send(JSON.stringify(e)));
|
||||
eventQueue = [];
|
||||
};
|
||||
|
||||
ws.onmessage = (msg) => {
|
||||
const data = JSON.parse(msg.data);
|
||||
if (data.type === 'reload') {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setTimeout(connect, 1000);
|
||||
};
|
||||
}
|
||||
|
||||
function sendEvent(event) {
|
||||
event.timestamp = Date.now();
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(event));
|
||||
} else {
|
||||
eventQueue.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Capture clicks on choice elements
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target.closest('[data-choice]');
|
||||
if (!target) return;
|
||||
|
||||
sendEvent({
|
||||
type: 'click',
|
||||
text: target.textContent.trim(),
|
||||
choice: target.dataset.choice,
|
||||
id: target.id || null
|
||||
});
|
||||
|
||||
// Update indicator bar (defer so toggleSelect runs first)
|
||||
setTimeout(() => {
|
||||
const indicator = document.getElementById('indicator-text');
|
||||
if (!indicator) return;
|
||||
const container = target.closest('.options') || target.closest('.cards');
|
||||
const selected = container ? container.querySelectorAll('.selected') : [];
|
||||
if (selected.length === 0) {
|
||||
indicator.textContent = 'Click an option above, then return to the terminal';
|
||||
} else if (selected.length === 1) {
|
||||
const label = selected[0].querySelector('h3, .content h3, .card-body h3')?.textContent?.trim() || selected[0].dataset.choice;
|
||||
indicator.innerHTML = '<span class="selected-text">' + label + ' selected</span> — return to terminal to continue';
|
||||
} else {
|
||||
indicator.innerHTML = '<span class="selected-text">' + selected.length + ' selected</span> — return to terminal to continue';
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Frame UI: selection tracking
|
||||
window.selectedChoice = null;
|
||||
|
||||
window.toggleSelect = function(el) {
|
||||
const container = el.closest('.options') || el.closest('.cards');
|
||||
const multi = container && container.dataset.multiselect !== undefined;
|
||||
if (container && !multi) {
|
||||
container.querySelectorAll('.option, .card').forEach(o => o.classList.remove('selected'));
|
||||
}
|
||||
if (multi) {
|
||||
el.classList.toggle('selected');
|
||||
} else {
|
||||
el.classList.add('selected');
|
||||
}
|
||||
window.selectedChoice = el.dataset.choice;
|
||||
};
|
||||
|
||||
// Expose API for explicit use
|
||||
window.brainstorm = {
|
||||
send: sendEvent,
|
||||
choice: (value, metadata = {}) => sendEvent({ type: 'choice', value, ...metadata })
|
||||
};
|
||||
|
||||
connect();
|
||||
})();
|
||||
141
.claude/lib/brainstorm-server/index.js
Normal file
141
.claude/lib/brainstorm-server/index.js
Normal file
@@ -0,0 +1,141 @@
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const WebSocket = require('ws');
|
||||
const chokidar = require('chokidar');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PORT = process.env.BRAINSTORM_PORT || (49152 + Math.floor(Math.random() * 16383));
|
||||
const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1';
|
||||
const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST);
|
||||
const SCREEN_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';
|
||||
|
||||
if (!fs.existsSync(SCREEN_DIR)) {
|
||||
fs.mkdirSync(SCREEN_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Load frame template and helper script once at startup
|
||||
const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');
|
||||
const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
|
||||
const helperInjection = `<script>\n${helperScript}\n</script>`;
|
||||
|
||||
// Detect whether content is a full HTML document or a bare fragment
|
||||
function isFullDocument(html) {
|
||||
const trimmed = html.trimStart().toLowerCase();
|
||||
return trimmed.startsWith('<!doctype') || trimmed.startsWith('<html');
|
||||
}
|
||||
|
||||
// Wrap a content fragment in the frame template
|
||||
function wrapInFrame(content) {
|
||||
return frameTemplate.replace('<!-- CONTENT -->', content);
|
||||
}
|
||||
|
||||
// Find the newest .html file in the directory by mtime
|
||||
function getNewestScreen() {
|
||||
const files = fs.readdirSync(SCREEN_DIR)
|
||||
.filter(f => f.endsWith('.html'))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: path.join(SCREEN_DIR, f),
|
||||
mtime: fs.statSync(path.join(SCREEN_DIR, f)).mtime.getTime()
|
||||
}))
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
return files.length > 0 ? files[0].path : null;
|
||||
}
|
||||
|
||||
const WAITING_PAGE = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Brainstorm Companion</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
|
||||
h1 { color: #333; }
|
||||
p { color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Brainstorm Companion</h1>
|
||||
<p>Waiting for Claude to push a screen...</p>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocket.Server({ server });
|
||||
|
||||
const clients = new Set();
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
clients.add(ws);
|
||||
ws.on('close', () => clients.delete(ws));
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const event = JSON.parse(data.toString());
|
||||
console.log(JSON.stringify({ source: 'user-event', ...event }));
|
||||
// Write user events to .events file for Claude to read
|
||||
if (event.choice) {
|
||||
const eventsFile = path.join(SCREEN_DIR, '.events');
|
||||
fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Serve newest screen with helper.js injected
|
||||
app.get('/', (req, res) => {
|
||||
const screenFile = getNewestScreen();
|
||||
let html;
|
||||
|
||||
if (!screenFile) {
|
||||
html = WAITING_PAGE;
|
||||
} else {
|
||||
const raw = fs.readFileSync(screenFile, 'utf-8');
|
||||
html = isFullDocument(raw) ? raw : wrapInFrame(raw);
|
||||
}
|
||||
|
||||
// Inject helper script
|
||||
if (html.includes('</body>')) {
|
||||
html = html.replace('</body>', `${helperInjection}\n</body>`);
|
||||
} else {
|
||||
html += helperInjection;
|
||||
}
|
||||
|
||||
res.type('html').send(html);
|
||||
});
|
||||
|
||||
// Watch for new or changed .html files
|
||||
chokidar.watch(SCREEN_DIR, { ignoreInitial: true })
|
||||
.on('add', (filePath) => {
|
||||
if (filePath.endsWith('.html')) {
|
||||
// Clear events from previous screen
|
||||
const eventsFile = path.join(SCREEN_DIR, '.events');
|
||||
if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
|
||||
console.log(JSON.stringify({ type: 'screen-added', file: filePath }));
|
||||
clients.forEach(ws => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'reload' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.on('change', (filePath) => {
|
||||
if (filePath.endsWith('.html')) {
|
||||
console.log(JSON.stringify({ type: 'screen-updated', file: filePath }));
|
||||
clients.forEach(ws => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'reload' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(JSON.stringify({
|
||||
type: 'server-started',
|
||||
port: PORT,
|
||||
host: HOST,
|
||||
url_host: URL_HOST,
|
||||
url: `http://${URL_HOST}:${PORT}`,
|
||||
screen_dir: SCREEN_DIR
|
||||
}));
|
||||
});
|
||||
1036
.claude/lib/brainstorm-server/package-lock.json
generated
Normal file
1036
.claude/lib/brainstorm-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
11
.claude/lib/brainstorm-server/package.json
Normal file
11
.claude/lib/brainstorm-server/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "brainstorm-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Visual brainstorming companion server for Claude Code",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.5.3",
|
||||
"express": "^4.18.2",
|
||||
"ws": "^8.14.2"
|
||||
}
|
||||
}
|
||||
129
.claude/lib/brainstorm-server/start-server.sh
Executable file
129
.claude/lib/brainstorm-server/start-server.sh
Executable file
@@ -0,0 +1,129 @@
|
||||
#!/bin/bash
|
||||
# Start the brainstorm server and output connection info
|
||||
# Usage: start-server.sh [--project-dir <path>] [--host <bind-host>] [--url-host <display-host>] [--foreground] [--background]
|
||||
#
|
||||
# Starts server on a random high port, outputs JSON with URL.
|
||||
# Each session gets its own directory to avoid conflicts.
|
||||
#
|
||||
# Options:
|
||||
# --project-dir <path> Store session files under <path>/.superpowers/brainstorm/
|
||||
# instead of /tmp. Files persist after server stops.
|
||||
# --host <bind-host> Host/interface to bind (default: 127.0.0.1).
|
||||
# Use 0.0.0.0 in remote/containerized environments.
|
||||
# --url-host <host> Hostname shown in returned URL JSON.
|
||||
# --foreground Run server in the current terminal (no backgrounding).
|
||||
# --background Force background mode (overrides Codex auto-foreground).
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Parse arguments
|
||||
PROJECT_DIR=""
|
||||
FOREGROUND="false"
|
||||
FORCE_BACKGROUND="false"
|
||||
BIND_HOST="127.0.0.1"
|
||||
URL_HOST=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--project-dir)
|
||||
PROJECT_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--host)
|
||||
BIND_HOST="$2"
|
||||
shift 2
|
||||
;;
|
||||
--url-host)
|
||||
URL_HOST="$2"
|
||||
shift 2
|
||||
;;
|
||||
--foreground|--no-daemon)
|
||||
FOREGROUND="true"
|
||||
shift
|
||||
;;
|
||||
--background|--daemon)
|
||||
FORCE_BACKGROUND="true"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "{\"error\": \"Unknown argument: $1\"}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$URL_HOST" ]]; then
|
||||
if [[ "$BIND_HOST" == "127.0.0.1" || "$BIND_HOST" == "localhost" ]]; then
|
||||
URL_HOST="localhost"
|
||||
else
|
||||
URL_HOST="$BIND_HOST"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Codex environments may reap detached/background processes. Prefer foreground by default.
|
||||
if [[ -n "${CODEX_CI:-}" && "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then
|
||||
FOREGROUND="true"
|
||||
fi
|
||||
|
||||
# Generate unique session directory
|
||||
SESSION_ID="$$-$(date +%s)"
|
||||
|
||||
if [[ -n "$PROJECT_DIR" ]]; then
|
||||
SCREEN_DIR="${PROJECT_DIR}/.superpowers/brainstorm/${SESSION_ID}"
|
||||
else
|
||||
SCREEN_DIR="/tmp/brainstorm-${SESSION_ID}"
|
||||
fi
|
||||
|
||||
PID_FILE="${SCREEN_DIR}/.server.pid"
|
||||
LOG_FILE="${SCREEN_DIR}/.server.log"
|
||||
|
||||
# Create fresh session directory
|
||||
mkdir -p "$SCREEN_DIR"
|
||||
|
||||
# Kill any existing server
|
||||
if [[ -f "$PID_FILE" ]]; then
|
||||
old_pid=$(cat "$PID_FILE")
|
||||
kill "$old_pid" 2>/dev/null
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Foreground mode for environments that reap detached/background processes.
|
||||
if [[ "$FOREGROUND" == "true" ]]; then
|
||||
echo "$$" > "$PID_FILE"
|
||||
env BRAINSTORM_DIR="$SCREEN_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" node index.js
|
||||
exit $?
|
||||
fi
|
||||
|
||||
# Start server, capturing output to log file
|
||||
# Use nohup to survive shell exit; disown to remove from job table
|
||||
nohup env BRAINSTORM_DIR="$SCREEN_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" node index.js > "$LOG_FILE" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
disown "$SERVER_PID" 2>/dev/null
|
||||
echo "$SERVER_PID" > "$PID_FILE"
|
||||
|
||||
# Wait for server-started message (check log file)
|
||||
for i in {1..50}; do
|
||||
if grep -q "server-started" "$LOG_FILE" 2>/dev/null; then
|
||||
# Verify server is still alive after a short window (catches process reapers)
|
||||
alive="true"
|
||||
for _ in {1..20}; do
|
||||
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
alive="false"
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
if [[ "$alive" != "true" ]]; then
|
||||
echo "{\"error\": \"Server started but was killed. Retry in a persistent terminal with: $SCRIPT_DIR/start-server.sh${PROJECT_DIR:+ --project-dir $PROJECT_DIR} --host $BIND_HOST --url-host $URL_HOST --foreground\"}"
|
||||
exit 1
|
||||
fi
|
||||
grep "server-started" "$LOG_FILE" | head -1
|
||||
exit 0
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
# Timeout - server didn't start
|
||||
echo '{"error": "Server failed to start within 5 seconds"}'
|
||||
exit 1
|
||||
31
.claude/lib/brainstorm-server/stop-server.sh
Executable file
31
.claude/lib/brainstorm-server/stop-server.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
# Stop the brainstorm server and clean up
|
||||
# Usage: stop-server.sh <screen_dir>
|
||||
#
|
||||
# Kills the server process. Only deletes session directory if it's
|
||||
# under /tmp (ephemeral). Persistent directories (.superpowers/) are
|
||||
# kept so mockups can be reviewed later.
|
||||
|
||||
SCREEN_DIR="$1"
|
||||
|
||||
if [[ -z "$SCREEN_DIR" ]]; then
|
||||
echo '{"error": "Usage: stop-server.sh <screen_dir>"}'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PID_FILE="${SCREEN_DIR}/.server.pid"
|
||||
|
||||
if [[ -f "$PID_FILE" ]]; then
|
||||
pid=$(cat "$PID_FILE")
|
||||
kill "$pid" 2>/dev/null
|
||||
rm -f "$PID_FILE" "${SCREEN_DIR}/.server.log"
|
||||
|
||||
# Only delete ephemeral /tmp directories
|
||||
if [[ "$SCREEN_DIR" == /tmp/* ]]; then
|
||||
rm -rf "$SCREEN_DIR"
|
||||
fi
|
||||
|
||||
echo '{"status": "stopped"}'
|
||||
else
|
||||
echo '{"status": "not_running"}'
|
||||
fi
|
||||
208
.claude/lib/skills-core.js
Normal file
208
.claude/lib/skills-core.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
/**
|
||||
* Extract YAML frontmatter from a skill file.
|
||||
* Current format:
|
||||
* ---
|
||||
* name: skill-name
|
||||
* description: Use when [condition] - [what it does]
|
||||
* ---
|
||||
*
|
||||
* @param {string} filePath - Path to SKILL.md file
|
||||
* @returns {{name: string, description: string}}
|
||||
*/
|
||||
function extractFrontmatter(filePath) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
let inFrontmatter = false;
|
||||
let name = '';
|
||||
let description = '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '---') {
|
||||
if (inFrontmatter) break;
|
||||
inFrontmatter = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inFrontmatter) {
|
||||
const match = line.match(/^(\w+):\s*(.*)$/);
|
||||
if (match) {
|
||||
const [, key, value] = match;
|
||||
switch (key) {
|
||||
case 'name':
|
||||
name = value.trim();
|
||||
break;
|
||||
case 'description':
|
||||
description = value.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { name, description };
|
||||
} catch (error) {
|
||||
return { name: '', description: '' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all SKILL.md files in a directory recursively.
|
||||
*
|
||||
* @param {string} dir - Directory to search
|
||||
* @param {string} sourceType - 'personal' or 'superpowers' for namespacing
|
||||
* @param {number} maxDepth - Maximum recursion depth (default: 3)
|
||||
* @returns {Array<{path: string, name: string, description: string, sourceType: string}>}
|
||||
*/
|
||||
function findSkillsInDir(dir, sourceType, maxDepth = 3) {
|
||||
const skills = [];
|
||||
|
||||
if (!fs.existsSync(dir)) return skills;
|
||||
|
||||
function recurse(currentDir, depth) {
|
||||
if (depth > maxDepth) return;
|
||||
|
||||
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Check for SKILL.md in this directory
|
||||
const skillFile = path.join(fullPath, 'SKILL.md');
|
||||
if (fs.existsSync(skillFile)) {
|
||||
const { name, description } = extractFrontmatter(skillFile);
|
||||
skills.push({
|
||||
path: fullPath,
|
||||
skillFile: skillFile,
|
||||
name: name || entry.name,
|
||||
description: description || '',
|
||||
sourceType: sourceType
|
||||
});
|
||||
}
|
||||
|
||||
// Recurse into subdirectories
|
||||
recurse(fullPath, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recurse(dir, 0);
|
||||
return skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a skill name to its file path, handling shadowing
|
||||
* (personal skills override superpowers skills).
|
||||
*
|
||||
* @param {string} skillName - Name like "superpowers:brainstorming" or "my-skill"
|
||||
* @param {string} superpowersDir - Path to superpowers skills directory
|
||||
* @param {string} personalDir - Path to personal skills directory
|
||||
* @returns {{skillFile: string, sourceType: string, skillPath: string} | null}
|
||||
*/
|
||||
function resolveSkillPath(skillName, superpowersDir, personalDir) {
|
||||
// Strip superpowers: prefix if present
|
||||
const forceSuperpowers = skillName.startsWith('superpowers:');
|
||||
const actualSkillName = forceSuperpowers ? skillName.replace(/^superpowers:/, '') : skillName;
|
||||
|
||||
// Try personal skills first (unless explicitly superpowers:)
|
||||
if (!forceSuperpowers && personalDir) {
|
||||
const personalPath = path.join(personalDir, actualSkillName);
|
||||
const personalSkillFile = path.join(personalPath, 'SKILL.md');
|
||||
if (fs.existsSync(personalSkillFile)) {
|
||||
return {
|
||||
skillFile: personalSkillFile,
|
||||
sourceType: 'personal',
|
||||
skillPath: actualSkillName
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try superpowers skills
|
||||
if (superpowersDir) {
|
||||
const superpowersPath = path.join(superpowersDir, actualSkillName);
|
||||
const superpowersSkillFile = path.join(superpowersPath, 'SKILL.md');
|
||||
if (fs.existsSync(superpowersSkillFile)) {
|
||||
return {
|
||||
skillFile: superpowersSkillFile,
|
||||
sourceType: 'superpowers',
|
||||
skillPath: actualSkillName
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a git repository has updates available.
|
||||
*
|
||||
* @param {string} repoDir - Path to git repository
|
||||
* @returns {boolean} - True if updates are available
|
||||
*/
|
||||
function checkForUpdates(repoDir) {
|
||||
try {
|
||||
// Quick check with 3 second timeout to avoid delays if network is down
|
||||
const output = execSync('git fetch origin && git status --porcelain=v1 --branch', {
|
||||
cwd: repoDir,
|
||||
timeout: 3000,
|
||||
encoding: 'utf8',
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
// Parse git status output to see if we're behind
|
||||
const statusLines = output.split('\n');
|
||||
for (const line of statusLines) {
|
||||
if (line.startsWith('## ') && line.includes('[behind ')) {
|
||||
return true; // We're behind remote
|
||||
}
|
||||
}
|
||||
return false; // Up to date
|
||||
} catch (error) {
|
||||
// Network down, git error, timeout, etc. - don't block bootstrap
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip YAML frontmatter from skill content, returning just the content.
|
||||
*
|
||||
* @param {string} content - Full content including frontmatter
|
||||
* @returns {string} - Content without frontmatter
|
||||
*/
|
||||
function stripFrontmatter(content) {
|
||||
const lines = content.split('\n');
|
||||
let inFrontmatter = false;
|
||||
let frontmatterEnded = false;
|
||||
const contentLines = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '---') {
|
||||
if (inFrontmatter) {
|
||||
frontmatterEnded = true;
|
||||
continue;
|
||||
}
|
||||
inFrontmatter = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (frontmatterEnded || !inFrontmatter) {
|
||||
contentLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return contentLines.join('\n').trim();
|
||||
}
|
||||
|
||||
export {
|
||||
extractFrontmatter,
|
||||
findSkillsInDir,
|
||||
resolveSkillPath,
|
||||
checkForUpdates,
|
||||
stripFrontmatter
|
||||
};
|
||||
35
.claude/settings.json
Normal file
35
.claude/settings.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash",
|
||||
"Read",
|
||||
"Edit",
|
||||
"Write",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
"mcp__searxng__web_search",
|
||||
"mcp__oracle-26ai-vector__search_similar"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(rm -rf /)",
|
||||
"Bash(rm -rf /*)",
|
||||
"Bash(rm -rf ~)",
|
||||
"Bash(rm -rf ~/*)",
|
||||
"Bash(sudo rm -rf *)",
|
||||
"Edit(//Users/joungmin/.claude/settings.json)",
|
||||
"Write(//Users/joungmin/.claude/settings.json)",
|
||||
"Edit(//Users/joungmin/.claude/settings.local.json)",
|
||||
"Write(//Users/joungmin/.claude/settings.local.json)",
|
||||
"Edit(//Users/joungmin/.claude/agents/**)",
|
||||
"Write(//Users/joungmin/.claude/agents/**)",
|
||||
"Edit(//Users/joungmin/.claude/commands/**)",
|
||||
"Write(//Users/joungmin/.claude/commands/**)",
|
||||
"Edit(//Users/joungmin/.claude/CLAUDE.md)",
|
||||
"Write(//Users/joungmin/.claude/CLAUDE.md)",
|
||||
"Edit(//Users/joungmin/.gitconfig)",
|
||||
"Write(//Users/joungmin/.gitconfig)",
|
||||
"Edit(.git/**)",
|
||||
"Write(.git/**)"
|
||||
]
|
||||
}
|
||||
}
|
||||
89
.claude/workflows/persona-pipeline.js
Normal file
89
.claude/workflows/persona-pipeline.js
Normal file
@@ -0,0 +1,89 @@
|
||||
export const meta = {
|
||||
name: 'persona-pipeline',
|
||||
description: 'tasteby: Redmine 큐의 열린 이슈를 8개 AI 페르소나 단계로 자동 통과시킨다 (Planner→...→Documenter, QA/Reviewer 반려 루프 포함)',
|
||||
phases: [
|
||||
{ title: 'Scan', detail: 'Redmine tasteby 의 열린 이슈와 현재 단계 수집' },
|
||||
{ title: 'Pipeline', detail: '각 이슈를 현재 단계 페르소나부터 Done 까지 구동' },
|
||||
],
|
||||
}
|
||||
|
||||
// ── 단계 정의 ──────────────────────────────────────────────
|
||||
const ORDER = ['01-Planner','02-Architect','03-Developer','04-QA','05-Designer','06-Reviewer','07-Release','08-Documenter']
|
||||
const PERSONA = {
|
||||
'01-Planner':'planner', '02-Architect':'architect', '03-Developer':'developer', '04-QA':'qa',
|
||||
'05-Designer':'designer', '06-Reviewer':'reviewer', '07-Release':'release', '08-Documenter':'documenter',
|
||||
}
|
||||
const DONE = '09-Done'
|
||||
const nextOf = (s) => { const i = ORDER.indexOf(s); return i < 0 ? '01-Planner' : (i+1 < ORDER.length ? ORDER[i+1] : DONE) }
|
||||
|
||||
const SCAN_SCHEMA = {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['issues'],
|
||||
properties: { issues: { type: 'array', items: {
|
||||
type: 'object', additionalProperties: false, required: ['id','subject','currentStage'],
|
||||
properties: { id: {type:'integer'}, subject: {type:'string'}, currentStage: {type:'string'} },
|
||||
} } },
|
||||
}
|
||||
|
||||
const STAGE_SCHEMA = {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['issueId','persona','outcome','nextStage','summary'],
|
||||
properties: {
|
||||
issueId: { type: 'integer' },
|
||||
persona: { type: 'string' },
|
||||
outcome: { type: 'string', enum: ['advanced','rejected','done','blocked'] },
|
||||
nextStage: { type: 'string', description: '다음 Redmine 카테고리명 (예: 04-QA, 03-Developer, 09-Done)' },
|
||||
summary: { type: 'string' },
|
||||
commitSha: { type: 'string' },
|
||||
},
|
||||
}
|
||||
|
||||
// ── 1. 큐 스캔 ─────────────────────────────────────────────
|
||||
phase('Scan')
|
||||
const scan = await agent(
|
||||
`너는 tasteby 파이프라인 디스패처다. \`.env\` 를 로드(set -a; . ./.env; set +a)해서 ` +
|
||||
`REDMINE_URL/REDMINE_API_KEY 를 얻은 뒤, 프로젝트 tasteby 에서 **열린(open)** 이슈를 모두 조회한다:\n` +
|
||||
` curl -s -H "X-Redmine-API-Key: $REDMINE_API_KEY" "$REDMINE_URL/issues.json?project_id=tasteby&status_id=open&limit=100"\n` +
|
||||
`각 이슈의 현재 단계 = 카테고리 이름(category.name). 카테고리가 없으면 "01-Planner" 로 본다.\n` +
|
||||
`09-Done 이거나 닫힌 이슈는 제외한다. id·subject·currentStage 목록을 반환하라.`,
|
||||
{ schema: SCAN_SCHEMA, phase: 'Scan', label: 'scan-queue' }
|
||||
)
|
||||
|
||||
const queue = (scan && scan.issues) ? scan.issues : []
|
||||
if (!queue.length) {
|
||||
log('큐가 비어 있다. 처리할 열린 이슈가 없음. Redmine 에 작업 이슈를 추가한 뒤 다시 실행하라.')
|
||||
return { processed: 0, message: 'empty queue' }
|
||||
}
|
||||
log(`큐에 ${queue.length}개 이슈: ` + queue.map(i => `#${i.id}(${i.currentStage})`).join(', '))
|
||||
|
||||
// ── 2. 각 이슈를 단계별로 구동 (이슈끼리는 병렬) ─────────────
|
||||
phase('Pipeline')
|
||||
const MAX_STEPS = 24 // 반려 핑퐁 등 무한루프 방지
|
||||
|
||||
const results = await parallel(queue.map((issue) => async () => {
|
||||
let stage = issue.currentStage || '01-Planner'
|
||||
if (!ORDER.includes(stage)) stage = '01-Planner'
|
||||
const trail = []
|
||||
for (let step = 0; step < MAX_STEPS && stage !== DONE; step++) {
|
||||
const persona = PERSONA[stage]
|
||||
if (!persona) { log(`#${issue.id}: 알 수 없는 단계 ${stage} — 중단`); break }
|
||||
const res = await agent(
|
||||
`Redmine 이슈 #${issue.id} ("${issue.subject}") 를 처리하라. 현재 단계: ${stage}.\n` +
|
||||
`너의 역할 정의와 docs/pipeline/QUEUE-PROTOCOL.md 를 따라 작업하고, ` +
|
||||
`결과(파일 변경 git 커밋/push, Redmine 저널 노트, 다음 단계로 카테고리·상태 전진/반려)를 모두 수행하라.\n` +
|
||||
`완료 후 구조화 결과를 반환하라. nextStage 는 네가 실제로 Redmine 에 설정한 카테고리명이어야 한다.`,
|
||||
{ agentType: persona, schema: STAGE_SCHEMA, phase: 'Pipeline', label: `${persona}#${issue.id}` }
|
||||
)
|
||||
if (!res) { log(`#${issue.id}: ${persona} 단계 결과 없음 — 중단`); break }
|
||||
trail.push(`${persona}:${res.outcome}`)
|
||||
log(`#${issue.id} ${persona} → ${res.outcome} (${res.summary || ''})`.slice(0, 200))
|
||||
if (res.outcome === 'blocked') { log(`#${issue.id}: ${stage} 에서 블록됨 — ${res.summary}`); break }
|
||||
const reported = (res.nextStage || '').trim()
|
||||
stage = (ORDER.includes(reported) || reported === DONE) ? reported : nextOf(stage)
|
||||
}
|
||||
return { id: issue.id, finalStage: stage, done: stage === DONE, trail }
|
||||
}))
|
||||
|
||||
const summary = results.filter(Boolean)
|
||||
log('완료: ' + summary.map(r => `#${r.id}=${r.done ? 'DONE' : r.finalStage}`).join(', '))
|
||||
return { processed: summary.length, results: summary }
|
||||
Reference in New Issue
Block a user