Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
10c449f
docs(specs): 트리아지 Task 1 스파이크 — TS 유지 결정(resumeThread) + SDK 표면 확정
SSFSKIM Jul 9, 2026
27476be
feat(triaging-feedback): 스킬 스캐폴드 + config 로더(kill switch·기본값)
SSFSKIM Jul 9, 2026
63ef4e0
feat(triaging-feedback): 픽스 게이트(리스크표면·크기·테스트·인용) — 안전 핵심 유닛 TDD
SSFSKIM Jul 9, 2026
d0c7737
feat(triaging-feedback): 펜스드-JSON verdict 파서(스키마 검증·malformed→null)
SSFSKIM Jul 9, 2026
7851412
feat(triaging-feedback): 카테고리 프리라우터(idea/question→ticket) + 공유 Feedba…
SSFSKIM Jul 9, 2026
881e4ce
feat(triaging-feedback): Codex-SDK 어댑터 seam(runTurn) — 스파이크 확정 형태 반영
SSFSKIM Jul 9, 2026
1d83ea4
feat(triaging-feedback): Supabase 어댑터(원자적 claim·findActionable·writeb…
SSFSKIM Jul 9, 2026
6d2cb0f
feat(triaging-feedback): GitHub/board 사이드이펙트(PR·needs-human 티켓·마커 멱등)
SSFSKIM Jul 9, 2026
b46c2e2
feat(triaging-feedback): 디스패처 오케스트레이션(멱등·라우팅·게이트·PR/티켓) TDD
SSFSKIM Jul 9, 2026
2799808
feat(triaging-feedback): 워커 프로토콜·git 헬퍼·폴러 엔트리·launchd·SKILL.md
SSFSKIM Jul 9, 2026
ce15da6
fix(triaging-feedback): buildAndTest maxBuffer(빌드 출력 1MiB 초과 오탐 방지) +…
SSFSKIM Jul 9, 2026
c70a388
fix(triaging-feedback): 게이트 diff 신규파일 포함 + claim 리클레임 활성화 + 워크트리 멱등 +…
SSFSKIM Jul 9, 2026
82f3e04
docs(specs): 트리아지 Plan B 코드 완료 — Outcomes(게이트/리클레임 버그·수정, Task 11 핸드오프)
SSFSKIM Jul 9, 2026
82b1b73
fix(triaging-feedback): 프리머지 리뷰 F1-F3+F5-F7 — 시크릿 격리·fresh thread·타임아…
SSFSKIM Jul 9, 2026
3f468d8
docs(triaging-feedback): F4 failed=터미널 정정 + F3/F5 운영 문서 동기화
SSFSKIM Jul 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 87 additions & 1 deletion docs/doperpowers/specs/2026-07-09-feedback-ci-triage-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,34 @@ tune `K`, gate thresholds, category handling.
diff size and scans changed paths against risk-surface globs itself.
- **Track = controlled** (approaches → design → spec → writing-plans), chosen for
a novel, cross-repo, taste-heavy, high-stakes build.
- **Driver language = TypeScript (not the Python fallback).** The Task 1 spike
found the shipped TS `@openai/codex-sdk@0.143.0` has **no per-turn `sandbox`
field** on `thread.run()` (only `outputSchema`/`signal`) — sandbox is a
thread-level `ThreadOptions.sandboxMode`. The plan's Task 1 pre-wrote a
"pivot to the Python SDK" branch for exactly this case. **We did not pivot.**
The same spike surfaced that `codex.resumeThread(id, { sandboxMode })` returns a
fresh `Thread` bound to the *same conversation id* with a *different* sandbox,
and since every `run()` spawns its own `codex exec` child regardless, resuming
costs nothing extra. So the read-only-diagnose → workspace-write-fix flow is
fully expressible in TS: `startThread({sandboxMode:'read-only'})` then
`resumeThread(thread.id, {sandboxMode:'workspace-write'})`. Staying in TS keeps
9 of 10 code tasks intact — only Task 6's adapter *internals* absorb the
start/resume choice and the value mapping; its `runTurn` seam signature (which
every other task depends on) is unchanged. Rejected: *full Python rewrite* —
the larger, more surprising deviation, discarding the plan's TS test suites for
no capability gain.
- **Write 턴 = fresh thread(외부 리뷰 F2), resumeThread 설계는 폐기.** 프리머지
리뷰(gpt-5.5)가 지적: turn 1(read-only 진단)에 들어간 신뢰불가 피드백 본문이
`resumeThread`로 이어진 turn 2(workspace-write)의 대화 컨텍스트에 그대로
남아, 본문에 심긴 지시가 write-capable 턴에 영향을 줄 수 있었다. 수정:
`codexAdapter.ts`의 `thread`/`resumeThread`/`CodexThread` export를 제거하고
매 턴을 독립된 fresh thread로 시작; `dispatch.ts`가 turn 2 프롬프트에
verdict의 검증된 필드(`resolved_category`/`root_cause`)만 넘기고
`row.body`는 배제; `parseVerdict` 직후 `verdict.feedback_id !== row.id`면
실패 처리해 모델의 행 id 참칭/혼동도 방어한다. **"변경파일 ⊆ 인용파일"
검증(수정이 실제로 root_cause가 인용한 파일에만 닿는지)은 v1에서는 보류** —
G1–G6 게이트 + 사람의 PR 리뷰가 백스톱 역할을 하므로 당장 필수는 아니라고
판단; Task 11(하드닝) 후보로 남긴다.

## Surprises & Discoveries

Expand All @@ -327,10 +355,60 @@ tune `K`, gate thresholds, category handling.
- **The feedback triage columns already half-existed in spirit** — `status`
(new/seen/done) + `hq_note` — but they're human-owned, so the bot gets its own
parallel `triage_state` rather than overloading them.
- **Codex-SDK (TS) surface, pinned by the Task 1 spike (2026-07-10, static read
of `@openai/codex-sdk@0.143.0`'s shipped `.d.ts` + `developers.openai.com/codex/sdk`;
no live call — `OPENAI_API_KEY` absent headlessly):**
- **Sandbox values are hyphenated** — `"read-only" | "workspace-write" |
"danger-full-access"`, on `ThreadOptions.sandboxMode` (maps 1:1 to the CLI
`--sandbox` flag). The plan's `read_only`/`workspace_write` (underscores) are
**wrong** — the adapter maps the dispatcher's underscore vocabulary to hyphens.
- **Working dir** = `ThreadOptions.workingDirectory` (→ `--cd`); a CI checkout
also needs **`skipGitRepoCheck: true`** (Codex refuses a non-git cwd otherwise).
- **Assistant text** = `(await thread.run(prompt)).finalResponse` (a plain
`string`, the last `agent_message` item's `.text`) — regex the fenced ```` ```json ````
verdict from it. `TurnOptions.outputSchema` is an available structured-output
alternative (deferred).
- **Auth** flows through the child's inherited `process.env` (so an exported
`OPENAI_API_KEY`/`CODEX_API_KEY` passes through), or `new Codex({ apiKey })`
which the SDK wires to **`CODEX_API_KEY`** specifically.
- **`thread.id` is `null` until the first `run()` completes** — read it *after*
turn 1 to resume for turn 2.
- **Errors** are normalized: a `turn.failed` event makes `run()` reject with
`Error(message)`; the dispatcher's `try/finally` + `parseVerdict(...)===null`
guard already cover both the throw and a malformed-verdict return.

## Outcomes & Retrospective

Pending — written at finish.
**Plan B (the `triaging-feedback` skill) is code-complete** (2026-07-10, branch
`feat/triaging-feedback-skill`, 11 task commits, 46 unit tests green, `tsc`
clean). Built via subagent-driven-development: a spike + nine TDD/glue tasks,
each with an independent task review, then a whole-branch review on Opus.

What the process caught that the per-task reviews could not — two integration-seam
bugs surfaced only by the whole-branch review:
- **Gate evasion (Critical):** `git diff --numstat` measured only modified
*tracked* files, so a fix that *added* a new file (e.g. a `sql/pNN.sql`
migration or `app/api/cron/*` — both risk surfaces) reached the gate as an
empty diff and would have been committed ungated. Fixed by staging first
(`git add -A` → `git diff --cached --numstat`) so the gate sees exactly what
the PR will commit.
- **Dead reclaim (Important):** the atomic `claim` guarded on `pending` only, so
the stale-`claimed` rows that `findActionable` surfaces for crash recovery
could never be re-claimed — the whole `reclaimMs` window was inert. Fixed by
giving `claim` the same `pending OR stale-claimed` predicate as
`findActionable`.

Both were the direct consequence of the plan deliberately leaving `git.ts`/`db.ts`
partially untested (I/O glue) — the missing `findActionable` unit test is exactly
what hid the reclaim bug; coverage was added with the fix.

**Not yet done:** Task 11 (live shadow run) is a handoff — it needs Plan A's `p86`
migration live and `OPENAI_API_KEY` + service-role creds on the self-hosted Mac,
so it validates the three untested seams (`codexAdapter` two-turn flow,
`git.ts` worktree/build, `poll.ts` end-to-end) against real infrastructure.
Also deferred to that hardening pass: `findExisting`'s `gh pr list` fails *open*
(a `gh` error → no PR found → possible duplicate), backstopped for now by the
body marker and human PR review.

## Revision Notes

Expand All @@ -348,3 +426,11 @@ Pending — written at finish.
(3) Ticket priority is `P2`-only in v1; P1 escalation deferred. (4) The Codex-SDK
TS surface (per-turn sandbox, resume, text recovery) is pinned by a spike task
before any dependent code — see Plan B Task 1.
- 2026-07-10 — Plan B Task 1 spike executed. **Language stays TypeScript** (not
the Python fallback): the TS SDK lacks a per-turn `run()` sandbox, but
`resumeThread(id, {sandboxMode})` gives the same read→write flow at no cost — see
the new Decision Log entry and the Surprises bullet pinning the concrete SDK
facts (hyphenated sandbox values, `skipGitRepoCheck`, `.finalResponse`,
`CODEX_API_KEY`, `thread.id`-after-first-run). Task 6's adapter absorbs it; the
`runTurn` seam and Tasks 2–5/7–10 are unchanged. Live write-confirmation deferred
to Task 11's shadow run (needs credentials on the Mac).
3 changes: 3 additions & 0 deletions skills/triaging-feedback/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
.env
spike/
109 changes: 109 additions & 0 deletions skills/triaging-feedback/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
---
name: triaging-feedback
description: Use when operating or adopting the feedback→triage loop — a poller that reads pending rows from the product's in-app feedback table, drives a Codex-SDK worker per row through diagnose→decide→act, and either opens a fix PR or files a needs-human ticket. Covers the triage worker protocol, the fix gate, the fix-vs-ticket route, the config/kill switches, and repo setup.
---

# Triaging feedback — the feedback→triage loop

## Overview

An in-app "피드백" button lets users (students, parents, academy admins)
report bugs, ask questions, or drop ideas straight into a `feedback` table.
Left alone, every row is a human triage chore. This loop turns most of them
into either a merge-ready fix PR or a well-scoped ticket, unattended: a
poller (`src/poll.ts`, launchd-cron'd) claims pending rows, drives a
Codex-SDK worker through the Triage Worker Protocol
(`references/triage-worker-protocol.md`) in a disposable git worktree, and
lets the dispatcher act on the worker's verdict.

**There is no orchestrator beyond the poller itself.** Each row is
independent, claimed atomically (so two poller instances can run
concurrently without double-processing), and dispatched sequentially within
one tick. `failed` is a **terminal** state — `findActionable`/`claim` only
ever pick up `pending` or stale-`claimed` rows, never `failed`, so a failed
row is not silently retried. To retry, an operator resets that row's
`triage_state` back to `pending`.

Full design + rationale:
`docs/doperpowers/specs/2026-07-09-feedback-ci-triage-design.md`.

## The pieces

| piece | what |
|---|---|
| `src/config.ts` | `loadConfig(env)` — required `SUPABASE_URL`/`SUPABASE_SERVICE_ROLE_KEY`/`OPENAI_API_KEY`/`TRIAGE_REPO_PATH`/`TRIAGE_BASE_BRANCH`/`TRIAGE_BOARD_SCRIPTS_DIR`, plus `TRIAGE_K`/`TRIAGE_TIMEOUT_MS`/`TRIAGE_RECLAIM_MS` and the two kill switches below |
| `src/route.ts` | `preRoute(category)` — the cheap category prior: `idea`/`question` always route to a ticket before any diagnosis runs |
| `src/verdict.ts` | `parseVerdict(text)` — extracts the single fenced ```json block the worker emits; malformed/missing → treated as a failure, not a guess |
| `src/gate.ts` | `enforceGate(...)` — re-checks G1–G6 against the **real diff** (the worker's self-report in the verdict is advisory only) |
| `src/db.ts` | `makeDb(cfg)` — Supabase adapter: `findActionable` (pending + stale-claimed rows), `claim` (atomic `pending→claimed` update, returns false if lost the race), `writeback` (final `triage_state` + PR/issue URL) |
| `src/sideEffects.ts` | `makeSideEffects(cfg, sh)` — the only place that touches the outside world: `findExisting` (idempotency guard via a `feedback:<id>` marker in PR/issue bodies), `openFixPr`, `registerTicket` (needs-human, via the board scripts) |
| `src/codexAdapter.ts` | `makeCodexRunner(openaiApiKey, timeoutMs)` — the Codex SDK seam: builds a runner that starts a **fresh** thread per turn at a given sandbox (`read_only` diagnose turn, `workspace_write` fix turn, never resumed — see F2 in the spec's Decision Log), with a locked-down child-process env (`buildCodexOptions`: PATH/HOME only, no inherited secrets) and a per-turn abort timeout |
| `src/git.ts` | `makeGit(repoPath, baseBranch)` — per-feedback disposable worktree (`addWorktree`/`removeWorktree`), `diffStat` (feeds G3/G4), `buildAndTest` (feeds G5, `npm run build`, 15-min timeout). Symlinks the base checkout's `node_modules` into every new worktree — see `references/setup.md` §1 |
| `src/dispatch.ts` | `dispatchRow(row, deps)` — the orchestration: idempotency check → diagnose turn → pre-route/verdict-route decision → (if fixing) fix turn → gate → PR-or-ticket → writeback. This is where the phases in the protocol actually get enforced in code |
| `src/poll.ts` | the entry: `loadConfig` → exit early if `TRIAGE_ENABLED=false` → `findActionable` → per-row `claim` + `dispatchRow`, sequential, catch → writeback `failed` |
| `references/triage-worker-protocol.md` | the Triage Worker Protocol — rendered (`{{PLACEHOLDERS}}`) into every diagnose-turn prompt by `src/prompt.ts` |
| `scripts/feedback-poll.sh` | launchd entry point: loads the skill dir's `.env`, runs `npx tsx src/poll.ts` |

## Model-proposes, dispatcher-disposes

The Codex thread runs inside a disposable worktree with two capabilities and
nothing else: `read_only` filesystem access to diagnose, and — only if the
gate is later satisfied — `workspace_write` access to edit files in that
same worktree. It has **no credentials**: no Supabase key, no `gh` auth, no
board-script access. It cannot open a PR, register a ticket, or write to the
`feedback` table — it can only emit a diagnosis and a structured verdict
(the fenced JSON block) that the dispatcher parses.

The dispatcher (`dispatch.ts`, running with real credentials) is the only
thing that ever performs a side effect: it decides route (pre-route by
category, then the worker's verdict), re-enforces the fix gate (G1–G6)
against the **actual diff** — not the worker's self-report — and only then
commits, pushes, opens the PR, or files the ticket. A worker that claims its
diff is small/safe/tested is not trusted; `git.ts`'s `diffStat` and
`buildAndTest` are the ground truth the gate checks against.

## Kill switches

- **`TRIAGE_ENABLED=false`** — the top-level stop. `poll.ts` exits 0 before
touching the DB or spawning any worker. Use this to pause the whole loop.
- **`TRIAGE_FIX_ENABLED`** — shadow mode is the default: `loadConfig` only
turns fix mode on when the env var is the literal string `'true'` (unset,
empty, or any typo defaults to off). While off, `dispatch.ts`'s `wantsFix`
is forced false, so every row that would have become a fix PR becomes a
needs-human ticket carrying the diagnosis instead — no `workspace_write`
turn ever runs, no code is ever written. Flip it on (`TRIAGE_FIX_ENABLED=true`)
only after watching ticket quality for a while (`references/setup.md` §5).

## Relationship to `reviewing-prs`

This loop does **not** self-review or self-merge the fix PRs it opens — that
would be double review by the same untrusted-input class of worker that
wrote the fix. Deliberately, a fix PR from `triaging-feedback` is just
another PR: the existing `reviewing-prs --sweep` loop picks it up, reviews
it against the codebase, and either self-merges (small/simple tier) or
escalates to a human, exactly as it does for any human-authored PR. Keep the
two loops separate rather than teaching this one to trust its own diff.

## Adopting a repo (checklist)

Full step-by-step in `references/setup.md`. Summary:

1. **Prerequisite: Plan A must be live** — the `feedback.triage_state`/`host`
migration (`sql/p86_*.sql` in ida-solution) applied in Supabase, including
the historical-row `skipped` backfill. Without it, claim/writeback fail
every tick.
2. `npm install` in the target repo's base checkout — worktrees symlink to
its `node_modules` rather than reinstalling per feedback row.
3. Write `.env` (never committed) with the `SUPABASE_*`/`OPENAI_API_KEY`
secrets and all `TRIAGE_*` config, in particular `TRIAGE_REPO_PATH`
pointing at that base checkout and `TRIAGE_BASE_BRANCH` set to the
integration branch (not the default branch) fix PRs should target.
4. Confirm `TRIAGE_BOARD_SCRIPTS_DIR` + a `TRIAGE_REPO_PATH` that resolves
`BOARD_REPO` to the **target repo**, not this skill's repo — tickets must
file into the target's own board.
5. One-time: `gh label create source:user-feedback` and
`gh label create type:question` on the target repo.
6. **Start in shadow mode**: `TRIAGE_FIX_ENABLED=false` until ticket quality
earns trust.
7. Register the launchd agent for `scripts/feedback-poll.sh` on a ~10-minute
`StartInterval`.
Loading