diff --git a/omega/README.md b/omega/README.md index bb78c6c..63248d6 100644 --- a/omega/README.md +++ b/omega/README.md @@ -282,7 +282,7 @@ Dashboard または Agents Window から環境を再 Build する。 ## 由来 - `genai/` — Next.js / Supabase コーディング規約 -- `plugins-main/pstack/` — エンジニアリングワークフロー([cursor/plugins](https://github.com/cursor/plugins) のミラー **0.15.15**、`poteto-*` は `forge-*` に改名)。omega の forge 系は **0.15.15 相当**を統合方針に沿って port(genai 差し替え・Intent gate・日本語ガイドは omega 独自) +- `plugins-main/pstack/` — エンジニアリングワークフロー([cursor/plugins](https://github.com/cursor/plugins) のミラー **0.15.25**、`poteto-*` は `forge-*` に改名)。omega の forge 系は **0.15.25 相当**を統合方針に沿って port(genai 差し替え・Intent gate・日本語ガイドは omega 独自) - `yomiyasu/` — AI 生成日本語の推敲([nanaism/yomiyasu](https://github.com/nanaism/yomiyasu) を **git submodule**)。Cursor 向けは `omega/skills/yomiyasu/` に抜粋同梱。同期: `scripts/sync-from-upstream.sh`([README.md](../README.md)) - `mattpocock-skills/` — [mattpocock/skills](https://github.com/mattpocock/skills/tree/main/skills) の `skills/` ミラー(repo 直下。omega 更新は上流 main を直接参照可)。omega 採用: **`grilling`** / **`grill-me`**、`tdd`、`teach`(ワークスペース部)、**`diagnosing-bugs`**、**`domain-modeling`**。grilling 同期: `omega/skills/grilling/scripts/sync-from-upstream.sh` + 各スキル `references/UPSTREAM.md` diff --git a/omega/scripts/port-pstack-skills.py b/omega/scripts/port-pstack-skills.py index 9135e7d..facd491 100644 --- a/omega/scripts/port-pstack-skills.py +++ b/omega/scripts/port-pstack-skills.py @@ -10,6 +10,9 @@ ROOT = Path(__file__).resolve().parents[2] PSTACK = ROOT / "plugins-main/pstack/skills" OMEGA = ROOT / "omega/skills" +# Upstream uses poteto-mode; omega uses forge-mode. +MODE_UPSTREAM = "poteto-mode" +MODE_OMEGA = "forge-mode" REPLACEMENTS: list[tuple[str, str]] = [ (r"~/.cursor/rules/pstack-models\.mdc", "~/.cursor/rules/forge-models.mdc"), @@ -47,22 +50,8 @@ SKIP_PLAYBOOKS = {"opening-a-pr.md"} -WORKFLOW_SKILLS = [ - "architect", - "swarm", - "how", - "why", - "reflect", - "recall", - "teach", - "automate-me", - "bro", - "figure-it-out", - "tdd", - "blast-radius", - "maintain-verification-skill", - "create-verification-skill", -] +# omega ワークフロー SKILL.md は日本語を正とする。上流からの SKILL 本文は手マージ(本スクリプトでは上書きしない)。 +WORKFLOW_SKILLS: list[str] = [] def port_text(text: str) -> str: @@ -85,16 +74,16 @@ def copy_ported(src: Path, dst: Path) -> None: def main() -> None: # Playbooks (except opening-a-pr) - pb_src = PSTACK / "forge-mode/playbooks" - pb_dst = OMEGA / "forge-mode/playbooks" + pb_src = PSTACK / MODE_UPSTREAM / "playbooks" + pb_dst = OMEGA / MODE_OMEGA / "playbooks" for path in sorted(pb_src.glob("*.md")): if path.name in SKIP_PLAYBOOKS: continue copy_ported(path, pb_dst / path.name) # Scripts tree - scripts_src = PSTACK / "forge-mode/scripts" - scripts_dst = OMEGA / "forge-mode/scripts" + scripts_src = PSTACK / MODE_UPSTREAM / "scripts" + scripts_dst = OMEGA / MODE_OMEGA / "scripts" if scripts_src.is_dir(): for path in scripts_src.rglob("*"): if path.is_dir(): @@ -106,7 +95,6 @@ def main() -> None: for ref in (PSTACK / "architect/references").glob("*.md"): copy_ported(ref, OMEGA / "architect/references" / ref.name) - # Workflow SKILL.md files for name in WORKFLOW_SKILLS: src = PSTACK / name / "SKILL.md" if src.is_file(): diff --git a/omega/skills/README.md b/omega/skills/README.md index 4bb17bf..a1c8c00 100644 --- a/omega/skills/README.md +++ b/omega/skills/README.md @@ -55,7 +55,9 @@ Next.js / Supabase プロジェクト向けの書き方・配置規約。 | `diagnosing-bugs` | 難しいバグ・性能退行の診断ループ(feedback loop 優先) | | `domain-modeling` | `GLOSSARY.md` / ADR でドメインモデルを能動的に磨く | | `recall` | チャット履歴からコンテキスト再構築 | -| `ketchup` | このチャットでユーザー最後のメッセージ以降を平易にキャッチアップ(action item ごとに判断材料) | +| `ketchup` | このチャットでユーザー最後のメッセージ以降を平易にキャッチアップ(画像・図・action item ごとに判断材料) | +| `sharpen` | 着手前に専門家視点・本当のゴール・指示の過不足を研ぐ(手動専用) | +| `in-your-own-words` | ユーザーのゴールと問題を自分の言葉で言い直す(Align 補助・手動専用) | | `teach` | 学習ワークスペース(lessons)または変更・サブシステムの説明(`how` + `why`) | | `automate-me` | チャット履歴と質問から personal `-mode` スキルを起草・更新 | | `bro` | 直前のエージェント返信を専門用語なしで言い直す | diff --git a/omega/skills/forge-help/SKILL.md b/omega/skills/forge-help/SKILL.md index 3151700..9366761 100644 --- a/omega/skills/forge-help/SKILL.md +++ b/omega/skills/forge-help/SKILL.md @@ -54,10 +54,12 @@ disable-model-invocation: true |---|---| | 厳密な非自明タスク | [`/forge-mode`](../forge-mode/SKILL.md) | | 何を・なぜ・用語(Align) | [`/plan-interview`](../plan-interview/SKILL.md) | +| ゴールを自分の言葉で言い直す(Align 補助) | [`/in-your-own-words`](../in-your-own-words/SKILL.md) | +| 着手前にタスクを研ぐ | [`/sharpen`](../sharpen/SKILL.md) | | コードの動き・配置 | [`/how`](../how/SKILL.md) | | 設計 rationale・経緯 | [`/why`](../why/SKILL.md) | | 最近のチャットから文脈 | [`/recall`](../recall/SKILL.md) | -| このチャットで最後の自分のメッセージ以降をキャッチアップ(各 action item の判断材料付き) | [`/ketchup`](../ketchup/SKILL.md) | +| このチャットで最後の自分のメッセージ以降をキャッチアップ(画像・図・各 action item の判断材料) | [`/ketchup`](../ketchup/SKILL.md) | | 仕組み・経緯を人向けに説明 | [`/teach`](../teach/SKILL.md)(`how` + `why`) | | 個人の `-mode` スキルを生成・更新 | [`/automate-me`](../automate-me/SKILL.md) | | 直前の返信を平易に言い直す | [`/bro`](../bro/SKILL.md) | diff --git a/omega/skills/forge-mode/SKILL.md b/omega/skills/forge-mode/SKILL.md index 595a2db..1696aff 100644 --- a/omega/skills/forge-mode/SKILL.md +++ b/omega/skills/forge-mode/SKILL.md @@ -165,6 +165,8 @@ forge-mode から他スキル・コマンドへ委譲するときの参照。 **すべての `Task` 呼び出しのデフォルト。** `run_in_background: true`、agent mode(readonly は MCP を strip)、インライン context ではなく file pointer、ロールごとの明示的 model(`/setup-forge` または `forge-models.mdc`。行を削除するとスキル内デフォルトにフォールバック。デフォルト: コード系は `cursor-grok-4.6-medium`、散文・判断は `claude-opus-5.5-thinking-medium`)。難度で tier 分け。横断設計・並行・微妙なアルゴリズムは **`hardest tasks`** 行(無ければ judgment と同じ)。些末な mechanical 編集は fast コードモデル。ルーティングスキル(`how`、`why`、`reflect`、`swarm`、`architect`)は各 SKILL のロール行が `forge-models.mdc` を上書き。`inherit` のロールは Task の `model` を省略し親モデルで走る。 +**仕事に合わせて model を選ぶ。** 各 spawn の前にロール行または default から始め、仕事に要る reasoning level と fast / standard を選ぶ。`/setup-forge` の budget は各ロールの開始 level(default xhigh)。最難・最微妙な work は 1 段上げ(max まで)。判断は 1 段下げ、ルーティンは 2 段以上下げ。fast mode は urgent だけ(CI か trunk が赤、人が結果で止まっている)。それ以外は standard(同 family で `-fast` なし)。verification は例外で `swarm workers` の model と speed を維持。変えるのは effort level と fast かどうかだけ。family はロールが名指すものを保ち diverse panel は diverse のまま。level と speed に slug が無ければ最も近い slug。`inherit` は親 model のまま。Task が調整後 slug を拒否したら別の調整 slug は試さない。ロール行または default に戻しそう言う。 + サブエージェントの作業はすべて自分が所有する。diff をレビューし自分の要約を書く。言ったことをそのまま通さない。セカンドオピニオンは別モデルに同じプロンプト。一致は high-signal。 **デフォルトは fresh subagent。** 新しい work は consolidated scope(元 brief、後続 directive、前 agent の report と branch)を渡した fresh subagent に任せる。fix round、follow-up、retry、次の queue 項目も同様。既存 subagent の resume / message / queue follow-up は、移すコストが高い state だけ(local checkout、未 commit 変更、その agent がまだ動かす process:dev server、simulator、babysit watcher)。running agent への stop / hold は reuse ではない。PR owner などの role は agent より長生きし、return 後は fresh agent が次の round を取る。interrupt 連鎖 resume は directive を黙って drop するので、「完了」要約を信じず fresh を起動する。 diff --git a/omega/skills/forge-mode/playbooks/autopilot-full.md b/omega/skills/forge-mode/playbooks/autopilot-full.md index 3226749..1549f81 100644 --- a/omega/skills/forge-mode/playbooks/autopilot-full.md +++ b/omega/skills/forge-mode/playbooks/autopilot-full.md @@ -1,13 +1,25 @@ ### Autopilot-full -**verdict を自分が持ち、PR は never。PR ごとに 1 owner が build から merge まで走り、clean swarm verdict なしでは merge しない。** 「autopilot this queue」「full autopilot」、one-owner-per-PR program 向け。Orchestrate は coordinator が verified work を自分で land する standing program。worker は merge しない。ここでは各 PR の owner が lifecycle 全体を merge まで担い、root は verification、countersign、audit だけ。 +**verdict を自分が持ち、PR は never。PR ごとに 1 owner が build から merge まで走り、code を書かなかった swarm が land する code を証明するまで merge しない。** -1. **operator の item を mark し state-then-wait を honor。** operator が名指した item は operator に残る。operator が review して click。owner は merge しない。operator が protocol か plan の述べを求めたら statement を deliver して stop。execution は operator の明示 go だけで始まる。 -2. **PR ごとに 1 owner を spawn。full lifecycle と early trail。** program 用に forge を 1 回 resolve。GitHub CLI(`gh`)が default。`command -v origin` が成功し Origin が repo を解決できるなら PR create、edit、view、watch、merge に `origin pr ...`。なければ `gh` に留まり fallback を記録。Graphite(`gt`)は要求しない。PR あたり 1 Cursor cloud agent が build、first push、ready PR、real artifact 上の self-proof(**prove-it-works** principle skill)、`../references/bugbot-triage.md` に従う skeptical Bugbot triage、slop-strip(`cursor-team-kit` plugin の `deslop` skill(`/deslop`))、`/no-comments`(**no-comments** skill)、current trunk への rebase、green までの babysit loop(`playbooks/babysit.md`)、merge 自体を own。約 15 分以内に各 owner は **decision-log** skill に従い `decisions.tsv` trail を開始、first branch snapshot を push、PR を ready で開く(draft never)。その後 owner は verifiable unit ごとに branch を再 push(hooks on、WIP commit は可)。self-proof 前に PR を開き URL、decisions、checks が durable trail になる。`decisions.tsv` は uncommitted、report と一緒に返す。subagent が始まったら owner は ID、expected runtime(その kind の過去 run の longest 以上)、state を同様に keep する `children.tsv` に追加。owner は code-ready report と babysit の前に最初の rebase。trunk が drift していなくても。fix round では owner はその merge base を keep。owner が再 rebase するのは merge prep(step 5)、trunk との `git merge-tree` conflict、trunk の change 由来の CI failure だけ。shipped code が final になったら slop-strip と `/no-comments` の後 code-ready head SHA を report。patch を変える各 later push の SHA も report。self-proof、CI、babysit は swarm と parallel。owner は self-proof、CI、babysit が finish したら merge-ready と head SHA を report。round を始める push の前に、触った path に対して repo の AGENTS.md と rules が名指す pre-review checks。committed head で実行。hook pass は proof ではない。各 rebase を publish するには `ls-remote` check の後 owner 自身の branch を `git push --force-with-lease`。shared branch を force-push しない。merge は owner が単独で取れない唯一の step。step 4 が gate。 -3. **owner を true parallel で走らせ、stack しない。** PR が self-contained なら多数同時: branch あたり 1 writer、disjoint files、cross-PR drift は rebase で absorb。本当に overlap する work だけ serialize。self-contained PR は main から straight branch。sequenced work は merge-then-branch。1 例外: genuinely dependent change を split しなければならない owner は短い private base-branch stack を hold しうる。 -4. **各 round の merge 前に swarm-verify。** round は owner の code-ready head SHA と patch を変える各 later push で始まる。その SHA で **swarm** skill に従い parallel independent verifier に fan out し 1 verdict に aggregate。merge には merge-ready head と patch が一致する round の clean verdict が要る。merge-ready report の receipt を verdict 前に audit。lane: その SHA で gate を再実行。change が触る real surface で load-bearing behavior を live prove(matching control skill、`cursor-team-kit` の `control-cli` か `control-ui`、または無ければ named driver)。diff を audit、PR body を distrust。audit は full brief で 2 以上の review lane。**Regression lane against trunk。** current trunk で同じ load-bearing scenario。trunk に feature がなければその fact を記録し、diff が足す behavior と user が待つ end state を gate。trunk が出せるふりをしない。live lane が floor。それなしの verdict は clean ではない。root の clean verdict なし merge なし。lane が返ったら proven finding を 1 fix-forward で owner に送る。lane が note と filed した defect も finding。behavior finding ごとに同じ defect の全 site を cover する red test を求める。test で defect を示せないなら repro receipt。次 round の review brief にその defect を追加。新 head は fresh swarm と fresh verdict。`playbooks/shipping.md` の patch-id rule で valid な lane result は除く。 -5. **clean verdict で owner が merge。fresh owner が次 item。** owner は current trunk に freshly rebased した head からだけ merge。merge prep は round の lane 開始前にならない。merge 直前に current trunk への rebase で終わる。merge-prep rebase の後 owner は新 head SHA を report。その head で merge 前に CI pass。patch-id rule が round の verdict をまだ hold するか決める。その head が green で patch-id が `playbooks/shipping.md` の patch-id rule で verdict と一致したら、後の trunk move は別 rebase を強制しない。merge 直前に trunk fetch し head と current trunk の `git merge-tree` が clean か確認。`git diff --name-only $(git merge-base HEAD origin/main) origin/main` の path が PR が変える path、またはその PR にどの CI が走るか決める path(repo の CI config path 等)かも確認。どちらか fail なら再 rebase、新 head SHA report、その head で CI pass を待ち、check を repeat。新 head は patch-id unchanged でなければ verdict を void。owner は resolved forge で自分の PR を squash-merge して return。fresh owner が queue から次の self-contained item(forge-mode の Subagents section)。operator の full-autonomy grant と root の clean verdict が babysit だけではない merge authorization。operator-named item は merge-ready で stop し operator の click を待つ。 -6. **root layer を走らせる。** pinned gate か budget value の genuinely new raise(CI が tighten だけ許す limit)は verifier proof の後に grant される fresh countersign が要る。operator の grant か standing orders が approval をカバーするなら countersign が approval。owner は tool の approval contract が許す form で記録し root の countersign への pointer。lane がその countersign に対して record を check。root は forge が enforce する approval を give や bypass しない。main に既に land した value の absorb は drift で raise ではない。全 owner に 1 時間ごと audit tick。operator の go で `/loop 1h` をこの tick を走らせる prompt で arm。`/loop` は local と cloud root で動く。cadence を memory や lossy completion notification に任せない。各 tick で trunk から `git show origin/main:.cursor/skills/forge-mode/playbooks/autopilot-full.md` でこの playbook を再読し operation を audit。その tick で drift を fix。各 owner に generic liveness か status check で probe。decision trail を collect。side effect だけ progress: commit、push、PR か check delta、store report。error した lane、または expected runtime を過ぎて side effect がない lane は stuck。即 stand down し replacement dispatch。polite return を待たない。tick は step 2 が要求する pushed branch と decision trail で owner を judge。turn を開始できない agent の owner を replace。各 tick は program の agent list(platform にあれば)と全 owner の `children.tsv` で lane stuck test も実行。stop が効くかどうかに関わらず root は owner に stuck subagent を stuck と記録し、work がまだ要るなら replace。stall した各 replacement に同じ step。owner ができないとき root が両方。stall は work を prove も drop もしない。merge が batch なら retro pass と post-merge bot-comment sweep。delegated work が残らないときだけ tick を終える。最後の merge の後も。 -7. **operator の stop で即 stand down。** operator の hold か stand-down は全 owner に即 zero-writes order。owner は operator が release するまで brief を hold。 +verdict は 1 patch を覆い、green CI は 1 head を覆う。step 4 は code-ready head で full swarm を 1 回。大きな変更のときだけもう 1 回。`playbooks/shipping.md` の patch-id rule が lane 結果を引き継げる。新 head ごとに green CI が要る。 -**Reply:** queue と各 PR の owner、state、head SHA。各 verdict とそれを出した swarm。merge したものと各 fresh owner が次に取ったもの。grant した countersign と理由。open operator gate。collect した decision trail の場所。 +1. **operator の gate が先。** grant は queue の item だけ。operator が自分用と名指した item と queue 外 PR は merge-ready で stop。plan の述べを求めるだけは go ではない。 +2. **PR ごとに 1 owner を spawn。置き換え可能な場所に work を置く。** program 用に forge を 1 回 resolve。GitHub CLI(`gh`)が default。Origin が使えるなら PR 操作はすべて `origin pr ...`。なければ `gh` と fallback を記録。`gt` は要求しない。各 owner は Cursor cloud agent。`playbooks/opening-a-pr.md` で PR を開き、real artifact で証明し、`playbooks/babysit.md` で green まで、step 4–5 が clear したら merge。 + + PR は早く開き CI と bot を並行させる。code-ready は slop-strip(`cursor-team-kit` の `/deslop`)と `/no-comments` の後。full swarm は finished code に走る。そのあと current trunk に rebase し code-ready head を report。self-proof は patch が land する base で走る。later head と merge-ready(self-proof、CI、babysit 完了)を report。fix round は trunk conflict か CI break までその base に留まる。rebase は CI を再起動する。code-ready push と verifier が要る later push の前に、触った path で repo の check を committed head で走らせる。 + + VM は死ぬと仮定。すぐ push し root agent の store(system prompt の path)で **decision-log** スキルに従い trail を開始。保持する unit ごとに push。subagent を spawn した者は id、state、過去 run の stuck-after runtime を store に記録し parent が死んでも root が probe できる。force-push は自分の branch だけ `--force-with-lease` を最後に push した SHA に pin。自分が作っていない push では stop し root に report。 +3. **owner を trunk から parallel、stack しない。** 各 PR は単独 land。 +4. **swarm-verify は 1 回。** code-ready head で full swarm 1 回。新 behavior、再設計、大きな diff のときだけもう 1 回。rebase、conflict 解消、小 fix は新 head で single verifier。**swarm** スキルで code-ready SHA に independent verifier を fan out。gate を再実行し、control skill(`control-ui` / `control-cli` 等)か named driver で real surface の load-bearing behavior を live prove し diff を audit。live lane が無い、merge-ready report の receipt を audit していない verdict は clean ではない。 + + lane が何と label したかに関わらず proven finding を owner に 1 fix-forward で送る。同じ defect の全 site を cover する red test を付ける。trunk だけに出る defect は trunk に記録し PR を gate しない。PR がコピーまたは新たに届ける defect は PR 自身のもの。fix は自分で push しない。branch の writer は 1 人。stuck owner は replace。 +5. **owner は verify したものだけ merge。land する trunk で test 済み。** green CI だけでは merge 不可。swarm lane 開始後、merge prep は current trunk に rebase、新 head を report、green CI を待つ。 + + merge 直前に trunk を fetch し、その変更が patch に効くか聞く。trunk の毎回 rebase は終わらない。land する code が未 test なら rebase して CI 待つ(conflict、両側変更 file、PR に走る check の変更)。lockfile 両側変更は、merge 結果で frozen install が pass し PR が変える package が head と同じに resolve するなら rebase 不要。fail なら rebase で直す。trunk が patch の caller か依存(呼ぶ code、依存 version)を変えたら merge 結果で patch の check を走らせ pass で merge。それ以外は merge。merge prep 後 3 回目の rebase を trunk が強制するなら hold し root に report。root が衝突 work を sequence する。 +6. **root layer を走らせる。** gate を緩めるもの(budget 引き上げ、allowlist)は verifier proof のあと fresh countersign が要る。owner が green を欲しい側。grant が approval をカバーするなら countersign が approval。owner の記録は lane が check する countersign を指す。disabled / soft-failed check は必ず出す。forge が enforce する approval は give も bypass もしない。 + + operator の go で `/loop 1h` を arm し delegated work が無くなるまで audit。長 run は drift し agent は黙って stall する。各 tick は trunk から本 playbook を再読み、unreported head や unrecorded agent など drift を owner に記録させて直す。drift は record の gap であって stall ではない。leaf lane まで全 agent を probe し side effect で judge する。status 文と summary は stuck child を隠す。error、turn 開始不可、stuck-after を過ぎて新しいものが無い agent は stuck。最初の tick まで push も trail 行も無い owner も stuck。operator かあなた待ちは stuck ではない。答える。stuck agent は stop の land を待たず即 replace。operator に変化があった tick は **ketchup** でキャッチアップ。変化なしは黙る。 +7. **operator の stop で即 stand down。** hold は全 owner に zero-writes order。各々 stand を report し operator が release するまで brief を hold。 + +**Reply:** **ketchup** スキルで、各 PR の state と verdict、merge したもの、countersign と理由、open operator gate、各 PR と trail への link を含めて operator をキャッチアップ。 diff --git a/omega/skills/forge-mode/playbooks/autopilot-stack.md b/omega/skills/forge-mode/playbooks/autopilot-stack.md index f7b94f2..6116364 100644 --- a/omega/skills/forge-mode/playbooks/autopilot-stack.md +++ b/omega/skills/forge-mode/playbooks/autopilot-stack.md @@ -1,16 +1,12 @@ ### Autopilot-stack -**stack を自分が持ち、landing は never。queue を full autonomy で build と verify し、operator に review と land 用の 1 本の linear base-branch stack を渡す。** **Autopilot-full** の sibling。 +**stack は自分が持ち、landing は operator。** operator が review して land する、base でつながった linear stack を 1 本渡す。**Autopilot-full** をベースにするが、verified work の出し方だけ異なる。各 tick で trunk から両 playbook を再読み、食い違うところは本 playbook が勝つ。 -1. **owner loop はそのまま。** program 用に forge を 1 回 resolve。GitHub CLI(`gh`)が default。`command -v origin` が成功し Origin が repo を解決できるなら PR create、edit、view、watch、merge に `origin pr ...`。なければ `gh` に留まり fallback を記録。Graphite(`gt`)は要求しない。PR あたり 1 Cursor cloud agent が change を end-to-end own: build、first push、self-proof 前に ready PR、self-proof(gates、CI、receipts)、`../references/bugbot-triage.md` に従う skeptical Bugbot triage、`deslop`(`cursor-team-kit` plugin の `deslop` skill(`/deslop`))、`/no-comments`(**no-comments** skill)、`playbooks/babysit.md` に従う green まで babysit。work が self-contained なら owner は parallelize。約 15 分以内に各 owner は **decision-log** skill に従い `decisions.tsv` trail を開始、first branch snapshot を push、PR を ready で開く(draft never)。その後 owner は verifiable unit ごとに branch を再 push(hooks on、WIP commit は可)。trail は uncommitted、report で返す。owner は Autopilot-full step 2 の `children.tsv` も維持。 -2. **real loop で audit。** root は 1 時間ごとに audit tick。operator の go で root は Autopilot-full step 6 に従いこの tick を走らせる prompt で `/loop 1h` を arm。cadence を memory や lossy completion notification に任せない。各 tick で trunk から `git show origin/main:.cursor/skills/forge-mode/playbooks/autopilot-stack.md` でこの playbook を再読し、operation を audit。その tick で drift を fix。各 owner に generic liveness か status check で probe。side effect だけを progress と数える: commit、push、PR か check delta、store report。expected runtime を過ぎて side effect がない lane は stuck。即 stand down し replacement を dispatch。polite return を待たない。全 subagent を probe し Autopilot-full step 6 に従って tick を終える。 -3. **operator gate を hold。** state-then-wait。plan を述べる依頼は go ではない。operator の stop で全 owner は即 zero-writes hold。 -4. **各 round を verify。** owner は shipped code が final になったら code-ready head SHA を 1 回 report。loop が green なら STACK-READY と exact head SHA。root は Autopilot-full step 4 に従い各 round を verify。merge-ready の代わりに STACK-READY。unverified は stack に入れない。 -5. **clean verdict で append、ship は never。** owner は merge、auto-merge arm、close しない。clean verdict は verified order、または operator が指定した order で 1 本の linear base-branch stack に PR を append。 -6. **topology は single writer、build は parallel writer。** owner は自分の branch だけ push し tip、current base、intended parent を report。root だけが topology writer。PR を append するには intended parent を fetch、child branch をその exact parent tip に rebase、`ls-remote` check の後だけ `--force-with-lease` で push、PR base を parent branch に。resolved forge に従い `origin pr create --status open --base ` か `gh pr create --base ` で create。既存 PR は `origin pr edit --base ` か `gh pr edit --base ` で retarget。root PR だけ trunk を target。`gt` で chain を submit や register しない。 -7. **drift は root で absorb、その後動いたものを re-verify。** root は current trunk を fetch し chain を bottom から top に rebase。rebase が owner の file で conflict を出したら owner が自分の slice を fix し root が result を push。rebase は上の全 SHA を書き換え古い SHA の verdict を void。各 verdict SHA で `playbooks/shipping.md` の patch-id rule を適用。もはや valid でないものは delivery 前にこの playbook の step 4 に戻す。patch-id が unchanged でも rewritten push の後は mergeability と CI を再実行。countersign rule は Autopilot-full と同じ。genuinely new pin は root の fresh countersign で stop を raise。land 済み value の drift absorb は raise ではない。 -8. **chain を deliver。** deliverable は verified PR の 1 本の linear chain。resolved forge で bottom-up に review 可能。各 link は PR body か comment に verifier verdict。operator が review し land。自分の click か merge-when-ready の arm。 +1. **Autopilot-full の owner を走らせ、landing は除く。** owner は merge-ready の代わりに STACK-READY を report。他 branch(trunk 含む)への rebase、retarget、merge、PR close、auto-merge の arm はしない。root が chain の topology を持ち operator が landing を持つ。item が別 item に依存するなら owner は trunk ではなくその item の branch から branch する。 +2. **topology の writer は 1 つ。** owner は自分の branch だけ push し、各 tip と intended parent を report。link の rebase / retarget は自分だけ。chain 上の writer が 2 つは互いに undo する。依存順に clean PR を append し base を親 branch に(`origin pr edit --base ` か `gh pr edit`)。`gt` は要求しない。owner はあなたの push を新 base と lease として受け取り、conflict は自分の slice を直してあなたが push する。 +3. **root で drift を吸収。** landing 開始前、各 link に Autopilot-full step 5 の test を適用し、要るところだけ bottom-up で rebase。そのあと link ごとに patch-id rule。link の base は親。 +4. **chain を渡し、その後手を離す。** 各 link の verdict は PR に載る。landing 開始後、`playbooks/shipping.md` が許す post-merge retarget 以外は chain に書かない。landing 中の第二 writer は landing を壊す。途中の問題は operator 経由であなたが扱い、fix は operator の go まで待つ。 -**autopilot の選択。** PR が独立で landing authority があるなら Autopilot-full。operator が land 前に review を欲する、work が sequenced か coupled、merge authority が withhold されているなら Autopilot-stack。 +**どちらの autopilot か。** PR が独立し landing 権限がある → Autopilot-full。operator が landing 前に review したい、作業が順序付き・結合している、merge 権限がない → Autopilot-stack。 -**Reply:** stack root と tip の link、link ごと 1 行 verdict summary、park か exclude したものと理由。 +**Reply:** **ketchup** スキルで operator をキャッチアップ。stack の root と tip への link、link ごとの 1 行 verdict、park または除外したものと理由。 diff --git a/omega/skills/forge-mode/playbooks/orchestrate.md b/omega/skills/forge-mode/playbooks/orchestrate.md index 9359db7..eb64327 100644 --- a/omega/skills/forge-mode/playbooks/orchestrate.md +++ b/omega/skills/forge-mode/playbooks/orchestrate.md @@ -58,7 +58,7 @@ dependency は ordering だけではなく context relay。undeclared upstream c #### Steps 1. **Frame.** done predicate を countable に述べる(「126 unit すべて merged、各 ledger-verified `unit-test-verified` 以上」)。scope を quantify: unit、rough effort、expected stack、wall-clock budget。1 agent がその budget 内で finish できるならここで stop し Autonomous run。collapse は別 document の存在に依存しない。この session で直接 work、plain worker で助け、verification inline、land しながら、下の store、register、pilot machinery なし。landing を budget に対して schedule。おおよそ 70% で spawn を止め verified を land。track を project ごとに名指す。contested decomposition か one-way door は pilot 前に multi-agent-candidates skill。framing を 1 回 present。reversible prep は待たず proceed。 -2. **Install the runtime.** `orch init`。decision-log skill で trail を開き、spawn 前に standing orders を書き、既存 PR から `orch frontier set --repo ` で `frontier.json` を seed。 +2. **Install the runtime.** `orch init`。decision-log skill で trail を開き、spawn 前に standing orders を書き、既存 PR から `orch frontier set --repo ` で `frontier.json` を seed。forge が Origin なら `--forge origin` を付ける。 3. **Pilot.** 1 unit を path 全体に: brief、worker、verification、stack entry、ledger row、merge。pilot は brief template、verify recipe、unit size を falsify するため。50 ではなく 1 agent のコストのとき。pilot evidence から contract を fix して fan-out 前。pilot を unit に scale。near-identical cheap unit の program では first unit が pilot。verify command inline の normal unit として走らせ、land した瞬間 fan-out。dedicated pilot pipeline(別 verifier agent、audit gate)は expensive か novel unit shape 向け。serialized pilot が falsify するものがない clone-unit 向けではない。 4. **Scale.** in-flight cap まで worker の rolling window を spawn。child finish で refill。blocking batch は毎 batch の slowest child を支払う。Roles の one-drain threshold を超えたら track sub-coordinator を spawn。各 drain の後 ready work を recompute。upstream report を downstream brief に relay。sibling communication は上向きのみ。sampled brief audit は sample する wave と並行。fail は次 refill を stop。current は止めない。 5. **Drain.** 各 drain point で下の queue discipline。 @@ -76,9 +76,9 @@ dependency は ordering だけではなく context relay。undeclared upstream c #### Stack safety -- frontier は computed object。narrative ではない。各 merge と stack mutation の後 `gt` から `frontier.json` を recompute。GitHub base ref は mid-restack で drift、gt tracking が authoritative: ordered PR list、branch name、head SHA、generation number、lowest unmerged PR。gt が stack を知る場所で resolve。通常 stacker の clone。gt metadata が submit を見ていない checkout は PR なしを報告し guess せず error。 -- stack あたり stacker は 1 つだけ `gt` を走らせ、stack 内 serialize。holder を standing orders に記録。restack は cloud。この scale の local restack は laptop を落とす。 -- worker は rebase も `gt` もしない。babysitter は `playbooks/babysit.md`、stack あたり 1、immutable frontier generation に scope。conflict は restack せず stacker に report。 +- frontier は computed object。narrative ではない。各 merge と stack mutation の後 `frontier.json` を recompute: ordered PR list、branch name、head SHA、generation number、lowest unmerged PR。コマンドは checkout の current branch から PR base chain を歩き、chain 順と PR state は forge から取る。default は `gh`。program が Origin を resolve したら `--forge origin`。head SHA は local branch ref から。stacker の clone から resolve。PR の無い branch は guess せず error。 +- stack あたり topology を mutate できる stacker は 1 つ(chain rebase、force-push、base retarget、merge)。stack 内 serialize。holder を standing orders に記録。chain rebase は cloud。この scale の local restack は laptop を落とす。 +- worker は rebase も PR retarget / merge もしない。babysitter は `playbooks/babysit.md`、stack あたり 1、immutable frontier generation に scope。conflict は rebasing せず stacker に report。 - PR close と retarget は stacker だけ。base PR を close すると上の全 chain が orphan。merge と stack surgery は他と同様 brief 付き unit。 - merged PR を follow する 1 retro watcher: revert、post-merge CI break、orphaned follow-up。 diff --git a/omega/skills/forge-mode/playbooks/shipping.md b/omega/skills/forge-mode/playbooks/shipping.md index 708983c..b1ed530 100644 --- a/omega/skills/forge-mode/playbooks/shipping.md +++ b/omega/skills/forge-mode/playbooks/shipping.md @@ -1,17 +1,12 @@ ### Shipping -**land するものを自分が持つ。各 PR を独立に verify、verified run は root からだけ land、その後 queue に手を出さない。** +**land するものを自分が持つ。各 PR を独立に verify し、root からの verified run だけ land し、その後 stack に手を出さない。** -`playbooks/babysit.md` の後半。 +1. **forge を resolve し、land 前に各 PR を独立に verify。** GitHub CLI(`gh`)が default。`command -v origin` が成功し Origin が repo を解決できるなら PR 操作はすべて `origin pr ...`。なければ `gh` に留まり fallback を記録。Graphite(`gt`)は要求しない。PR あたり 1 verifier、各々 Cursor cloud agent。matching control skill(`cursor-team-kit` の `control-ui` や `control-cli` 等)でユーザーと同じように parent vs head で変更を走らせ、`PASS`、`PASS+NOTES`、`FAIL` をその PR に post。コードを書かなかった agent からの verdict だけが有効。作者は「意図したものを作ったか」を確認する。green CI と approving bot review は verdict ではない。 +2. **bottom で root した contiguous verified run だけ land。** 最下 unmerged PR から上へ歩き、`PASS` か `PASS+NOTES` のない最初の PR で stop。その下が run、上端が ceiling。unverified の下に verified PR があっても、その下の code が無いので land できない。branch がその code を持たず要らない PR は trunk に載せて単独 land できるが、fresh verdict が要る。古い verdict は下にその code があった状態で走ったため。 +3. **verdict がまだ code を述べているか再確認。** verdict は patch を覆う。chain rebase は check を fail せず上の PR を新 SHA に動かす。古い verdict を信じる前に verdict SHA と current head で `git patch-id` を比較。差が tests、docs、lint config だけなら、両 SHA で同じものを build でき noise 以外同じなら lane 結果は有効。noise は verdict SHA の 2 build 間にも出る差、または埋め込み commit SHA。各 noise の種類と file を report し読者が判断できるようにする。変更本体は fresh check と review が要る。lane に build 出力が無い(dev server 等)なら rerun。他に drift があれば re-verify。 +4. **bottom-up で 1 PR ずつ land。** run の最下 unmerged PR を `origin pr merge ` か `gh pr merge --squash` で merge。Origin の plain merge は指定 PR より下も merge する。GitHub で base が親 branch の PR は trunk ではなくその branch に merge される。結果は `origin pr view ` か `gh pr view --json state,mergedAt,baseRefName` で読み戻す。merge queue が queued しただけで成功に見えることがある。auto-merge 要求は merge ではない。land 済み PR は base が trunk で merged と読める。`--auto`(Origin merge-when-ready か GitHub auto-merge)は current head を覆う verdict がある PR だけ。merge 後、親 branch が base だった次 PR を trunk に retarget(`origin pr edit --base ` か `gh pr edit`)、patch を merge 結果に載せ `--force-with-lease` で push。verdict は patch を覆う。step 3 の patch-id rule が verdict の生存を決める。check が pass し verdict が生きていれば merge。 +5. **land 中は stack を触らない。** step 4 の次 PR の移動だけが branch / base の変更。merge はその瞬間の branch と base を land する。GitHub では `playbooks/babysit.md` の queued watcher(`scripts/watch-pr/watch-pr --queued-stack --stack-prs `)を `/loop` で ceiling の `COMPLETE` まで。Origin では `origin pr checks --watch` の各 return で `origin pr view --checks` を読み ceiling が merged になるまで。親 merge で base が trunk に動くのは想定内。stall したら mutate 前に理由を調べる。 +6. **ceiling で stop。** run を延ばすには新 verdict が要り、step 1 の新 pass になる。 -1. **forge を resolve し、全 PR を独立に verify。** GitHub CLI(`gh`)が default。`command -v origin` が成功し Origin が repo を解決できるなら PR view、watch、edit、merge に `origin pr ...`。なければ `gh` に留まり fallback を記録。Graphite(`gt`)は要求しない。PR あたり 1 subagent、batch しない。各々 Cursor cloud agent。real surface を matching control skill(`cursor-team-kit` の `control-ui` や `control-cli` 等)で parent vs head に対して exercise。各々 `PASS`、`PASS+NOTES`、または `FAIL` を返し、その verdict を自分の PR に post。Safe は code を書かなかった agent からの verdict。CI green は verdict ではない。approving bot review も verdict ではない。 -2. **bottom で root した contiguous verified run だけ land。** 最下 unmerged PR から上へ歩き、passing verdict のない最初の PR で stop。`PASS` と `PASS+NOTES` は両方 pass。unverified の下に verified PR があっても landable ではない。ceiling を PR number で report し chain を壊すものを言う。 -3. **各 verdict がまだ patch を述べているか再確認。** verdict head SHA、base SHA、その PR の base-to-head diff の stable `git patch-id` を記録。rebase や base retarget は SHA を書き換え、check に触れず verdict を無効化しうる。land 前に記録 patch-id と現在の base-to-head patch-id を比較。2 patch が test、docs、lint config だけ違うとき、各 lane が走らせたものを build。verdict SHA で 2 回、current head で 1 回。差は verdict SHA で 2 build も示すか embedded commit SHA なら noise。file ではなく各 difference を judgeし、noise 各 kind と file を report。noise だけ違うならその lane の結果は valid。checks と change の review は fresh。dev server や build output のないものから lane result を reuse しない。その lane を rerun。patch が変わったら他も re-verify。変わらなければ code verdict は keep、current head で mergeability と CI を再実行。古い SHA の一致 commit message や green check を substitute にしない。 -4. **bottom PR だけ prepare。** current trunk を fetch。要れば lowest verified branch を exact trunk tip に rebase、push、`origin pr edit --base ` か `gh pr edit --base ` でその PR だけ trunk に retarget。push 後 step 3 を再実行。descendant はまだ retarget、arm、merge しない。 -5. **PR を 1 つずつ land。** bottom PR が今 mergeable なら `origin pr merge --squash` か `gh pr merge --squash`。requirements がまだ走り user が merge-when-ready を求めたならその PR だけ `origin pr merge --squash --auto` か `gh pr merge --squash --auto` で arm。Origin の `--auto` は Origin merge-when-ready。GitHub の `--auto` は GitHub auto-merge。次を prepare する前にその PR の merge を待つ。 -6. **GitHub `autoMergeRequest` を stack readiness と読まない。** 多くとも 1 GitHub PR に GitHub auto-merge が要求されたこと。Origin merge-when-ready が arm 済み、descendant が queued、patch verdict が current、contiguous stack が safe を証明しない。current bottom PR の active forge state を確認。active forge が報告できなければ state unknown と言う。 -7. **各 merge の後に recompute。** trunk fetch、merged SHA の存在確認、frozen bottom-to-top list から merged PR を drop、新 bottom PR の base、head、checks、patch-id を inspect。host が child を自動 retarget しうるが assume しない。その 1 PR で step 3–6 を repeat。独立 work はこの chain 外で独自に ship。 -8. **current frontier が merge か fail まで watch。queue 周りを mutate しない。** Origin では `origin pr view --checks --comments` と `origin pr checks --watch`、merged か blocked まで PR を再読。GitHub では `scripts/watch-pr/watch-pr --queued-stack --stack-prs ` は event wake だけ。各 wake 後 `gh pr view --json state,mergedAt,mergeStateStatus,statusCheckRollup,autoMergeRequest` を poll。`mergedAt` が non-null か `state` が `MERGED` まで `READY` を無視。その後だけ step 7。`state` が `CLOSED` で `mergedAt` なし、required check が `FAILURE` か `CANCELLED` で auto-merge pending 後 merge を block、または `mergeStateStatus` が `UNSTABLE` か `DIRTY` で auto-merge pending なしのときだけ hard-fail。checks pending か auto-merge armed の `BLOCKED` は failure ではない。Babysit の queued `WAITING`/`merge-queue` stop condition はここでは使わない。watch は `/loop` dynamic mode で hold。各 merge と新 ceiling を report。queue が stall なら mutate 前に diagnose。 -9. **ceiling で stop。** verified run が merge 済みなら land したもの、次の unverified PR、verify に何が要るかを report。run を延ばすのは step 1 の新 pass。 - -**Reply:** verified run と ceiling、各 PR の verdict と producer、arm したものと確認方法、land したもの、次の gap に要るもの。 +**Reply:** verified run と ceiling、各 PR の verdict と producer、land したものと確認方法、次の gap に要るもの。 diff --git a/omega/skills/forge-mode/playbooks/worktree-cleanup.md b/omega/skills/forge-mode/playbooks/worktree-cleanup.md index 9b374e9..e058d8e 100644 --- a/omega/skills/forge-mode/playbooks/worktree-cleanup.md +++ b/omega/skills/forge-mode/playbooks/worktree-cleanup.md @@ -2,7 +2,7 @@ **disk と safety gate を自分が持つ。** merged か abandoned の git worktree と stale iOS simulator を prune して space を reclaim。deletion は不可逆。各 step は in-use や uncommitted work を持つものの削除を防ぐ。 -1. Snapshot と audit。`df -h /` を記録し `scripts/worktree-audit.sh`(principle-build-the-lever)。path は `git worktree list` から読み、手打ちしない。手打ち `myrepo-worktrees/x` は `.cursor/worktrees/myrepo/x` にあるものを miss しうる(principle-encode-lessons-in-structure)。各 worktree を size、age、merge state、uncommitted work、PR state、その worktree に触れた newest chat で classify し bucket を suggest。transcript scan は遅いので background。 +1. Snapshot と audit。`df -h /` を記録し `scripts/worktree-audit.sh [-b ] [repo-path]`(principle-build-the-lever)。`-b` 無しは `origin/HEAD` 対 `MERGED` を見る。PR の base が別 branch なら `-b origin/` を渡す。path は `git worktree list` から読み、手打ちしない。手打ち `myrepo-worktrees/x` は `.cursor/worktrees/myrepo/x` にあるものを miss しうる(principle-encode-lessons-in-structure)。各 worktree を size、age、merge state、uncommitted work、PR state、その worktree に触れた newest chat で classify し bucket を suggest。transcript scan は遅いので background。 2. bucket は advice で permission ではない。pinned と active chat が real artifact(principle-prove-it-works)。user か sidebar からその set を得て各 candidate を cross-check。lever が user が pin していた worktree を `safe` と mark しうる。pinned set が勝つ。 3. delete 前に usage を verify。各 `verify-recent-chat` 行、または doubt するものは subagent に fan out して transcript を読み、chat が pinned か ongoing か、触る worktree を report(principle-guard-the-context-window、transcript は bulk)。pinned chat は background subagent で sibling worktree に multi-agent-candidates と repro tree を spawn し、sidebar に名前が出なくても in-use。 4. 不可逆 loss で pause。`wip:N` は N 個の tracked uncommitted edit。diff を見せて先に decision。clean worktree の remove は branch から recover 可能だが uncommitted work は消える。`scratch:N` は untracked throwaway、drop safe だが file を名指す。Autonomy に従い clean で merged で not-in-use は proceed。`wip` と in-use は pause。 diff --git a/omega/skills/forge-mode/scripts/bun.lock b/omega/skills/forge-mode/scripts/bun.lock index 45aaed5..65f0c66 100644 --- a/omega/skills/forge-mode/scripts/bun.lock +++ b/omega/skills/forge-mode/scripts/bun.lock @@ -3,7 +3,7 @@ "configVersion": 1, "workspaces": { "": { - "name": "@cursor-skill/forge-mode-tools", + "name": "@cursor-skill/poteto-mode-tools", "dependencies": { "commander": "14.0.0", }, diff --git a/omega/skills/forge-mode/scripts/check-plan.mjs b/omega/skills/forge-mode/scripts/check-plan.mjs index d242f28..c862676 100644 --- a/omega/skills/forge-mode/scripts/check-plan.mjs +++ b/omega/skills/forge-mode/scripts/check-plan.mjs @@ -44,13 +44,33 @@ if (raw[0] === "---") { } const lines = []; -let fence = false; +let fence = null; +const openingFence = (text) => { + const match = text.match(/^ {0,3}(`{3,}|~{3,})(.*)$/); + if (!match || (match[1][0] === "`" && match[2].includes("`"))) return null; + return { char: match[1][0], length: match[1].length }; +}; +const closingFence = (text, opening) => { + const match = text.match(/^ {0,3}(`+|~+)[ \t]*$/); + return Boolean( + match && + match[1][0] === opening.char && + match[1].length >= opening.length, + ); +}; for (let i = start; i < raw.length; i++) { const text = raw[i]; const n = i + 1; - if (/^```/.test(text)) fence = !fence; - lines.push({ n, text, code: fence }); - if (fence) continue; + let code = Boolean(fence); + if (fence && closingFence(text, fence)) { + code = false; + fence = null; + } else if (!fence) { + fence = openingFence(text); + code = Boolean(fence); + } + lines.push({ n, text, code }); + if (code) continue; const prose = text .replace(/`[^`]*`/g, "`") .replace(/!\[[^\]]*\]\([^)]*\)/g, "") diff --git a/omega/skills/forge-mode/scripts/check-plan.test.ts b/omega/skills/forge-mode/scripts/check-plan.test.ts new file mode 100644 index 0000000..fd966c2 --- /dev/null +++ b/omega/skills/forge-mode/scripts/check-plan.test.ts @@ -0,0 +1,96 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { expect, test } from "bun:test"; + +const checker = join(import.meta.dir, "check-plan.mjs"); +const rule = + "Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked."; + +const validPlan = `# Demo + +## How to read this +One box is one unit of work and names the evidence. Check a box only when its evidence exists. See playbooks/. +${rule} + +## Program checklist +### Arm the program +### Spawn owners +### PR mechanics +### Verdict and merge +### Boot recipe +\`git show origin/main:\` /loop 1h status message + +## PR 1 +**Depends on.** None. +**Files.** +- [ ] Do the thing. +**Build.** +- [ ] Do the thing. +**You see.** +- [ ] Do the thing. +**Verify, unit.** ${rule} +- [ ] Check the thing. +**Verify, live.** ${rule} Ten lanes on \`example-model\` at the PR head +${Array.from({ length: 10 }, (_, i) => `- [ ] Lane ${i + 1}. Save \`lane-${i + 1}.png\`. Pass when evidence exists.`).join("\n")} +**Verify, perf.** ${rule} +- [ ] Metric. Recorded. +- [ ] Probe. Recorded. +- [ ] Baseline. Recorded. +- [ ] Rule. Recorded. +**Review gate.** None. +**Merge.** +- [ ] Merge when approved. + +## Close the program +Close. + +## Appendix Prototype evidence +Recorded. +`; + +const runPlan = (example: string, expectedExit: number) => { + const directory = mkdtempSync(join(tmpdir(), "check-plan-test-")); + const file = join(directory, "plan.md"); + writeFileSync(file, validPlan + example); + try { + const result = spawnSync("node", [checker, file], { encoding: "utf8" }); + expect(result.status).toBe(expectedExit); + return result; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}; + +test("accepts ordinary, indented, and longer closing fences", () => { + for (const indent of ["", " ", " ", " "]) + runPlan(`${indent}\`\`\`markdown\n## Example only\n${indent}\`\`\`\n`, 0); + runPlan("```markdown\n## Example only\n````\n", 0); +}); + +test("accepts nested backticks and tilde info strings", () => { + runPlan("````markdown\n```markdown\n## Example only\n```\n````\n", 0); + runPlan("~~~markdown `info`\n## Example only\n ~~~\n", 0); +}); + +test("keeps mismatched, shorter, and suffixed closers inside the fence", () => { + runPlan("````markdown\n~~~\n## Example only\n````\n", 0); + runPlan("````markdown\n```\n## Example only\n````\n", 0); + runPlan("````markdown\n``` info\n## Example only\n````\n", 0); +}); + +test("rejects headings and prose outside a proper close", () => { + for (const suffix of [" ", "\t"]) { + const heading = runPlan(`\`\`\`markdown\n## Example only\n\`\`\`${suffix}\n## Example only\n`, 1); + expect(heading.stderr).toContain('"## Example only" after Close the program is not an appendix'); + } + + const prose = runPlan("```markdown\nThis prose has a mid-sentence colon: inside code\n```\nThis prose has a mid-sentence colon: bad.\n", 1); + expect(prose.stderr).toContain("mid-sentence colon"); +}); + +test("rejects backtick info strings containing a backtick", () => { + const result = runPlan("```markdown `invalid`\n## Example only\n```\n", 1); + expect(result.stderr).toContain('"## Example only" after Close the program is not an appendix'); +}); diff --git a/omega/skills/forge-mode/scripts/orch/orch.test.ts b/omega/skills/forge-mode/scripts/orch/orch.test.ts index 4316fc3..32c26d7 100644 --- a/omega/skills/forge-mode/scripts/orch/orch.test.ts +++ b/omega/skills/forge-mode/scripts/orch/orch.test.ts @@ -16,6 +16,7 @@ import { UserError, openStore, parseVerdict, + type Forge, type OpenStoreOptions, type Store, } from "./store.ts"; @@ -76,6 +77,7 @@ async function makeGitStack(directory: string): Promise<{ readonly mergedSha: string; readonly closedSha: string; readonly openSha: string; + readonly draftSha: string; }> { const repo = join(directory, "repo"); await mkdir(repo); @@ -86,70 +88,154 @@ async function makeGitStack(directory: string): Promise<{ git({ repo, args: ["add", "."] }); git({ repo, args: ["commit", "-m", "main"] }); - const branches = ["stack/merged", "stack/closed", "stack/open"]; + const branches = [ + "stack/merged", + "stack/closed", + "stack/open", + "stack/draft", + ]; for (const [index, branch] of branches.entries()) { git({ repo, args: ["checkout", "-b", branch] }); await writeFile(join(repo, `stack-${index}.txt`), `${branch}\n`); git({ repo, args: ["add", "."] }); git({ repo, args: ["commit", "-m", branch] }); } + git({ repo, args: ["checkout", "stack/open"] }); return { repo, mergedSha: git({ repo, args: ["rev-parse", "stack/merged"] }), closedSha: git({ repo, args: ["rev-parse", "stack/closed"] }), openSha: git({ repo, args: ["rev-parse", "stack/open"] }), + draftSha: git({ repo, args: ["rev-parse", "stack/draft"] }), }; } -async function withFakeGt({ +type FixtureState = "open" | "draft" | "merged" | "closed"; + +const FORGE_FIXTURES: Readonly< + Record< + Forge, + { + readonly fields: string; + readonly row: (pr: { + number: number; + state: FixtureState; + head: string; + base: string; + }) => Record; + } + > +> = { + gh: { + fields: "number,state,headRefName,baseRefName", + row: ({ number, state, head, base }) => ({ + number, + state: state === "draft" ? "OPEN" : state.toUpperCase(), + headRefName: head, + baseRefName: base, + }), + }, + origin: { + fields: "number,status,headRef,baseRef", + row: ({ number, state, head, base }) => ({ + number, + status: state, + headRef: head, + baseRef: base, + }), + }, +}; + +function forgeRows( + forge: Forge, + prs: readonly { + number: number; + state: FixtureState; + head: string; + base: string; + }[] +): string { + return JSON.stringify(prs.map((pr) => FORGE_FIXTURES[forge].row(pr))); +} + +async function withFakeForge({ directory, + forge, operation, - output, + currentRows, }: { directory: string; - operation: (outputPath: string) => Promise; - output: string; + forge: Forge; + operation: () => Promise; + currentRows?: string; }): Promise { const bin = join(directory, "bin"); - const outputPath = join(directory, "gt-output.txt"); - await mkdir(bin); - await writeFile(outputPath, output); - const gt = join(bin, "gt"); + const outputPath = join(directory, "forge-output.txt"); + await mkdir(bin, { recursive: true }); + await writeFile( + outputPath, + `${ + currentRows ?? + forgeRows(forge, [ + { + number: 11, + state: "open", + head: "stack/open", + base: "stack/closed", + }, + ]) + }\n` + ); + const { fields } = FORGE_FIXTURES[forge]; + const list = (filter: string, branch: string): string => + `pr list ${filter} ${branch} --state all --json ${fields}`; + const fake = join(bin, forge); await writeFile( - gt, + fake, `#!/usr/bin/env bash set -euo pipefail if [ "$(pwd -P)" != "${realpathSync(join(directory, "repo"))}" ]; then - printf 'gt ran outside the fixture repo: %s\\n' "$(pwd -P)" >&2 + printf '${forge} ran outside the fixture repo: %s\\n' "$(pwd -P)" >&2 exit 2 fi case "$*" in - "--no-interactive log short --stack --reverse") + "${list("--head", "stack/open")}") cat "${outputPath}" ;; - "--no-interactive info stack/merged") - printf 'stack/merged\\nPR #10 (Merged) merged change\\n' + "${list("--head", "stack/closed")}") + printf '%s\\n' '${forgeRows(forge, [{ number: 13, state: "closed", head: "stack/closed", base: "stack/merged" }])}' + ;; + "${list("--head", "stack/merged")}") + printf '%s\\n' '${forgeRows(forge, [{ number: 10, state: "merged", head: "stack/merged", base: "main" }])}' ;; - "--no-interactive info stack/closed") - printf 'stack/closed\\nPR #13 (Closed) closed change\\n' + "${list("--base", "stack/open")}") + printf '%s\\n' '${forgeRows(forge, [ + { number: 14, state: "draft", head: "stack/draft", base: "stack/open" }, + { + number: 15, + state: "closed", + head: "stack/abandoned", + base: "stack/open", + }, + ])}' ;; - "--no-interactive info stack/open") - printf 'stack/open\\nPR #11 (Needs approvals) open change\\n' + "${list("--base", "stack/draft")}") + printf '[]\\n' ;; *) - printf 'unexpected gt arguments: %s\\n' "$*" >&2 + printf 'unexpected ${forge} arguments: %s\\n' "$*" >&2 exit 2 ;; esac ` ); - await chmod(gt, 0o755); + await chmod(fake, 0o755); const originalPath = process.env.PATH; process.env.PATH = `${bin}:${originalPath ?? ""}`; try { - return await operation(outputPath); + return await operation(); } finally { if (originalPath === undefined) { delete process.env.PATH; @@ -406,90 +492,117 @@ describe("Store", () => { ]); }); - it("resolves the ordered Graphite frontier and validates an optional pin", async () => { - const { directory, store } = await initializedStore(); - const stack = await makeGitStack(directory); - const output = `◯ main -◯ stack/merged -◯ stack/closed -◉ stack/open (current) -`; - - await withFakeGt({ - directory, - output, - operation: async () => { - expect(await store.frontier.set({ repo: stack.repo })).toEqual({ - generation: 1, - prs: [ - { - pr: 10, - branches: "stack/merged", - sha: stack.mergedSha, - state: "MERGED", - }, - { - pr: 13, - branches: "stack/closed", - sha: stack.closedSha, - state: "CLOSED", - }, - { - pr: 11, - branches: "stack/open", - sha: stack.openSha, - state: "OPEN", - }, - ], - lowestUnmerged: 11, - }); - expect( - ( - await store.frontier.set({ + for (const forge of ["gh", "origin"] as const) { + it(`resolves the ordered frontier from the ${forge} base chain and validates an optional pin`, async () => { + const { directory, store } = await initializedStore(); + const stack = await makeGitStack(directory); + const forgeParams = forge === "gh" ? {} : { forge }; + + await withFakeForge({ + directory, + forge, + operation: async () => { + expect( + await store.frontier.set({ repo: stack.repo, ...forgeParams }) + ).toEqual({ + generation: 1, + prs: [ + { + pr: 10, + branches: "stack/merged", + sha: stack.mergedSha, + state: "MERGED", + }, + { + pr: 13, + branches: "stack/closed", + sha: stack.closedSha, + state: "CLOSED", + }, + { + pr: 11, + branches: "stack/open", + sha: stack.openSha, + state: "OPEN", + }, + { + pr: 14, + branches: "stack/draft", + sha: stack.draftSha, + state: "OPEN", + }, + ], + lowestUnmerged: 11, + }); + expect( + ( + await store.frontier.set({ + repo: stack.repo, + ...forgeParams, + prs: [10, 13, 11, 14], + }) + ).generation + ).toBe(2); + expect((await store.frontier.show()).generation).toBe(2); + await expect( + store.frontier.set({ repo: stack.repo, - prs: [10, 13, 11], + ...forgeParams, + prs: [10, 11, 12], }) - ).generation - ).toBe(2); - expect((await store.frontier.show()).generation).toBe(2); - await expect( - store.frontier.set({ - repo: stack.repo, - prs: [10, 11, 12], - }) - ).rejects.toThrow( - "frontier pin mismatch: missing from gt: 12; extra in gt: 13" - ); - await expect( - store.frontier.set({ - repo: stack.repo, - prs: [13, 10, 11], - }) - ).rejects.toThrow( - "frontier pin mismatch: order differs: expected 13,10,11; gt 10,13,11" - ); - await expect( - store.frontier.set({ - repo: stack.repo, - prs: [10, 10], - }) - ).rejects.toThrow("--prs must not contain duplicates"); - }, + ).rejects.toThrow( + "frontier pin mismatch: missing from resolved chain: 12; extra in resolved chain: 13,14" + ); + await expect( + store.frontier.set({ + repo: stack.repo, + ...forgeParams, + prs: [13, 10, 11, 14], + }) + ).rejects.toThrow( + "frontier pin mismatch: order differs: expected 13,10,11,14; resolved 10,13,11,14" + ); + await expect( + store.frontier.set({ + repo: stack.repo, + ...forgeParams, + prs: [10, 10], + }) + ).rejects.toThrow("--prs must not contain duplicates"); + }, + }); }); - }); - it("rejects unparseable Graphite output loudly", async () => { + it(`rejects unparseable ${forge} output loudly`, async () => { + const { directory, store } = await initializedStore(); + const stack = await makeGitStack(directory); + + await withFakeForge({ + directory, + forge, + currentRows: "this line is not forge output", + operation: async () => { + await expect( + store.frontier.set({ repo: stack.repo, forge }) + ).rejects.toThrow( + `${forge} pr list --head stack/open output is not valid JSON` + ); + }, + }); + }); + } + + it("names the missing pull request when a chain branch has none", async () => { const { directory, store } = await initializedStore(); const stack = await makeGitStack(directory); - await withFakeGt({ + await withFakeForge({ directory, - output: "◯ main\nthis line is not Graphite output\n", + forge: "gh", + currentRows: "[]", operation: async () => { - await expect( - store.frontier.set({ repo: stack.repo }) - ).rejects.toThrow( - 'gt log short output has an unparseable line 2: "this line is not Graphite output"' + await expect(store.frontier.set({ repo: stack.repo })).rejects.toThrow( + "branch stack/open has no pull request; open one with gh pr create" ); }, }); @@ -548,12 +661,26 @@ describe("orch CLI", () => { const frontierHelp = runCli(["frontier", "set", "--help"]); expect(frontierHelp.code).toBe(0); expect(frontierHelp.stdout).toContain("--repo "); + expect(frontierHelp.stdout).toContain("--forge "); expect(frontierHelp.stdout).toContain("--prs "); const directory = await makeDirectory(); const invalid = runCli(["--store", directory, "unit", "add", "u1"]); expect(invalid.code).toBe(1); expect(invalid.stderr).toContain("required option '--track '"); + + const badForge = runCli([ + "--store", + directory, + "frontier", + "set", + "--repo", + directory, + "--forge", + "gt", + ]); + expect(badForge.code).toBe(1); + expect(badForge.stderr).toContain("Allowed choices are gh, origin"); }); it("accepts ORCH_STORE and emits complete JSON", async () => { diff --git a/omega/skills/forge-mode/scripts/orch/orch.ts b/omega/skills/forge-mode/scripts/orch/orch.ts index 219ac17..14cb513 100755 --- a/omega/skills/forge-mode/scripts/orch/orch.ts +++ b/omega/skills/forge-mode/scripts/orch/orch.ts @@ -7,6 +7,7 @@ import { openStore, parseVerdict, type Counts, + type Forge, type Frontier, type InboxPointer, type OpenGate, @@ -81,6 +82,7 @@ interface GateResolveOptions { interface FrontierSetOptions { readonly repo?: string; + readonly forge: Forge; readonly prs?: readonly number[]; } @@ -472,15 +474,24 @@ function createProgram(io: Io): Command { const frontier = program .command("frontier") - .description("manage the Graphite stack frontier") + .description("manage the stack frontier") .action(() => requireSubcommand(program)); - leaf(frontier, "set", "discover the Graphite stack and set the frontier") + leaf( + frontier, + "set", + "resolve the pull request base chain and set the frontier" + ) .addOption( new Option( "--repo ", "repository directory (or ORCH_REPO)" ).env("ORCH_REPO") ) + .addOption( + new Option("--forge ", "pull request forge") + .choices(["gh", "origin"]) + .default("gh") + ) .option( "--prs ", "optional expected pull request order pin", @@ -493,6 +504,7 @@ function createProgram(io: Io): Command { (store) => store.frontier.set({ repo: frontierRepo(options), + forge: options.forge, prs: options.prs, }), frontierLine diff --git a/omega/skills/forge-mode/scripts/orch/store.ts b/omega/skills/forge-mode/scripts/orch/store.ts index 5e6c602..4173689 100644 --- a/omega/skills/forge-mode/scripts/orch/store.ts +++ b/omega/skills/forge-mode/scripts/orch/store.ts @@ -167,6 +167,7 @@ export interface ResolveGateParams { export interface SetFrontierParams { readonly repo: string; + readonly forge?: Forge; readonly prs?: readonly number[]; } @@ -967,168 +968,187 @@ function countLine(value: Counts): string { : entries.map(([name, count]) => `${name}=${count}`).join(", "); } -const OPEN_GT_PR_STATUSES = new Set([ - "Trunk branch locked", - "Changes requested", - "Waiting on PRs in this stack to merge", - "Waiting on downstack merge state", - "Draft", - "Required checks failed", - "Undergoing failure detection", - "Merge queue failed on current head commit", - "Handed off to merge queue...", - "Waiting on downstack", - "Merge conflicts", - "Needs reviewers", - "Needs approvals", - "Needs restack", - "Queued to merge...", - "Ready to merge", - "Ready to merge as stack", - "Rebasing...", - "Waiting on CI...", - "Stale, needs rebase onto trunk", - "Unresolved comments", - "Waiting on required CI", - "Waiting to merge...", -]); - -interface GtPullRequest { - readonly pr: number; - readonly state: FrontierPrState; +export type Forge = "gh" | "origin"; + +interface ForgePullRequestQuery { + readonly stateField: string; + readonly headField: string; + readonly baseField: string; + readonly states: ReadonlyMap; +} + +const FORGE_PULL_REQUEST_QUERIES: Readonly< + Record +> = { + gh: { + stateField: "state", + headField: "headRefName", + baseField: "baseRefName", + states: new Map([ + ["OPEN", "OPEN"], + ["MERGED", "MERGED"], + ["CLOSED", "CLOSED"], + ]), + }, + origin: { + stateField: "status", + headField: "headRef", + baseField: "baseRef", + states: new Map([ + ["open", "OPEN"], + ["draft", "OPEN"], + ["merged", "MERGED"], + ["closed", "CLOSED"], + ]), + }, +}; + +export function parseForge(value: string): Forge { + if (value === "gh" || value === "origin") { + return value; + } + throw new UserError("forge must be gh or origin"); } -interface GtFrontierEntry extends GtPullRequest { - readonly branches: string; +interface ForgePullRequest { + readonly pr: number; + readonly state: FrontierPrState; + readonly branch: string; + readonly base: string; } -function parseGtPullRequest({ - branch, - detail, +function runTool({ + command, + args, + repo, }: { - branch: string; - detail: string; -}): GtPullRequest { - const match = - /^(?:\[origin\] )?PR #([1-9]\d*)(?: \(([^)\r\n]+)\))?(?: .+)?$/.exec( - detail - ); - const pr = Number(match?.[1] ?? 0); - if (match === null || !Number.isSafeInteger(pr)) { + command: string; + args: readonly string[]; + repo: string; +}): string { + try { + return execFileSync(command, args, { + cwd: repo, + encoding: "utf8", + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { throw new UserError( - `gt info output has an invalid PR row for branch ${branch}: ${detail}` + `${command} ${args.join(" ")} failed: ${errorMessage(error)}` ); } - const status = match[2]; - if (status === "Merged") { - return { pr, state: "MERGED" }; - } - if (status === "Closed") { - return { pr, state: "CLOSED" }; - } - if (status === undefined || OPEN_GT_PR_STATUSES.has(status)) { - return { pr, state: "OPEN" }; - } - throw new UserError( - `gt info output has an unknown PR state for branch ${branch}: ${status}` - ); } -function parseGtBranches(raw: string): readonly string[] { - const branches: string[] = []; - const lines = raw.replace(/\r/g, "").split("\n"); - for (const [index, line] of lines.entries()) { - if (line.length === 0) { - continue; - } - const branchMatch = - /^(?:│ )*[◯◉] +([^\s]+)((?: \([^()\r\n]*\))*)$/.exec(line); - if (branchMatch === null) { - throw new UserError( - `gt log short output has an unparseable line ${index + 1}: ${JSON.stringify(line)}` - ); - } - const branch = branchMatch[1] ?? ""; - if (branches.includes(branch)) { - throw new UserError( - `gt log short output contains duplicate branch ${branch}` - ); - } - branches.push(branch); +function listPullRequests({ + filter, + branch, + forge, + repo, +}: { + filter: "--head" | "--base"; + branch: string; + forge: Forge; + repo: string; +}): readonly ForgePullRequest[] { + const query = FORGE_PULL_REQUEST_QUERIES[forge]; + const command = `${forge} pr list ${filter} ${branch}`; + // Every state is fetched because a merged or closed PR still anchors the + // chain, and a draft counts as OPEN. + const raw = runTool({ + command: forge, + args: [ + "pr", + "list", + filter, + branch, + "--state", + "all", + "--json", + ["number", query.stateField, query.headField, query.baseField].join( + "," + ), + ], + repo, + }); + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + throw new UserError(`${command} output is not valid JSON`); } - const trunk = branches[0]; - if (trunk === undefined) { - throw new UserError("gt log short output did not contain a stack"); + if (!isUnknownArray(value)) { + throw new UserError(`${command} output is not a JSON array`); } - return branches.slice(1); + return value.map((row) => { + const status = isRecord(row) ? row[query.stateField] : undefined; + const state = + typeof status === "string" ? (query.states.get(status) ?? null) : null; + const head = isRecord(row) ? row[query.headField] : undefined; + const base = isRecord(row) ? row[query.baseField] : undefined; + if ( + !isRecord(row) || + typeof row.number !== "number" || + !Number.isSafeInteger(row.number) || + row.number < 1 || + state === null || + typeof head !== "string" || + head.length === 0 || + typeof base !== "string" || + base.length === 0 + ) { + throw new UserError(`${command} output has an invalid PR row`); + } + return { pr: row.number, state, branch: head, base }; + }); } -function graphitePullRequest({ +function branchPullRequest({ branch, + forge, repo, }: { branch: string; + forge: Forge; repo: string; -}): GtPullRequest { - let raw: string; - try { - raw = execFileSync("gt", ["--no-interactive", "info", branch], { - cwd: repo, - encoding: "utf8", - env: { ...process.env, NO_COLOR: "1" }, - stdio: ["ignore", "pipe", "pipe"], - }); - } catch (error) { - throw new UserError( - `gt info ${branch} failed: ${errorMessage(error)}` - ); - } - const rows = raw - .replace(/\r/g, "") - .split("\n") - .filter( - (line) => - line.startsWith("PR #") || line.startsWith("[origin] PR #") - ); - if (rows.length === 0) { +}): ForgePullRequest { + const rows = listPullRequests({ filter: "--head", branch, forge, repo }); + const row = rows[0]; + if (row === undefined) { throw new UserError( - `gt info output branch ${branch} has no pull request; this clone's gt metadata may predate the submit, so resolve the frontier from the stacker's clone or after gt sync` + `branch ${branch} has no pull request; open one with ${forge} pr create, or resolve the frontier from a branch whose chain is fully submitted` ); } if (rows.length > 1) { throw new UserError( - `gt info output contains multiple PRs for branch ${branch}` + `branch ${branch} has multiple pull requests (${rows.map((item) => `#${item.pr}`).join(", ")}); close the stale ones first` ); } - return parseGtPullRequest({ branch, detail: rows[0] ?? "" }); + return row; } -function graphiteFrontier(repo: string): readonly GtFrontierEntry[] { - let raw: string; - try { - raw = execFileSync( - "gt", - ["--no-interactive", "log", "short", "--stack", "--reverse"], - { - cwd: repo, - encoding: "utf8", - env: { ...process.env, NO_COLOR: "1" }, - stdio: ["ignore", "pipe", "pipe"], - } - ); - } catch (error) { - throw new UserError( - `gt log short --stack --reverse failed: ${errorMessage(error)}` - ); - } - const result = parseGtBranches(raw).map((branch) => ({ - branches: branch, - ...graphitePullRequest({ branch, repo }), - })); - if (new Set(result.map((row) => row.pr)).size !== result.length) { - throw new UserError("gt info output contains duplicate pull requests"); - } - return result; +function openChildren({ + branch, + forge, + repo, +}: { + branch: string; + forge: Forge; + repo: string; +}): readonly ForgePullRequest[] { + return listPullRequests({ filter: "--base", branch, forge, repo }).filter( + (row) => row.state === "OPEN" + ); +} + +function runGit({ + args, + repo, +}: { + args: readonly string[]; + repo: string; +}): string { + return runTool({ command: "git", args, repo }).trim(); } function branchSha({ @@ -1138,30 +1158,79 @@ function branchSha({ branch: string; repo: string; }): string { - let raw: string; - try { - raw = execFileSync("git", ["rev-parse", branch], { - cwd: repo, - encoding: "utf8", - env: process.env, - stdio: ["ignore", "pipe", "pipe"], - }); - } catch (error) { - throw new UserError( - `git rev-parse ${branch} failed: ${errorMessage(error)}` - ); - } - const sha = raw.trim(); + const sha = runGit({ args: ["rev-parse", branch], repo }); if (!/^[0-9a-f]{40,64}$/i.test(sha)) { throw new UserError(`git rev-parse ${branch} returned an invalid SHA`); } return sha; } -function resolveFrontier(repo: string): readonly FrontierPr[] { - return graphiteFrontier(repo).map((row) => ({ - ...row, - sha: branchSha({ branch: row.branches, repo }), +function currentBranch(repo: string): string { + return runGit({ args: ["symbolic-ref", "--short", "HEAD"], repo }); +} + +function trunkBranch(repo: string): string { + try { + return runGit({ + args: ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], + repo, + }).replace(/^origin\//, ""); + } catch { + // Init-plus-fetch checkouts and local fixtures lack origin/HEAD. + return "main"; + } +} + +function resolveFrontier(repo: string, forge: Forge): readonly FrontierPr[] { + const trunk = trunkBranch(repo); + const current = currentBranch(repo); + if (current === trunk) { + return []; + } + const seen = new Set([current]); + let bottom = branchPullRequest({ branch: current, forge, repo }); + const chain = [bottom]; + while (bottom.base !== trunk) { + if (seen.has(bottom.base)) { + throw new UserError( + `pull request base chain loops at branch ${bottom.base}` + ); + } + seen.add(bottom.base); + bottom = branchPullRequest({ branch: bottom.base, forge, repo }); + chain.unshift(bottom); + } + let tip = current; + for (;;) { + const children = openChildren({ branch: tip, forge, repo }); + const child = children[0]; + if (child === undefined) { + break; + } + if (children.length > 1) { + throw new UserError( + `stack is not linear: branches ${children.map((row) => row.branch).join(", ")} all target ${tip}` + ); + } + if (seen.has(child.branch)) { + throw new UserError( + `pull request base chain loops at branch ${child.branch}` + ); + } + seen.add(child.branch); + chain.push(child); + tip = child.branch; + } + if (new Set(chain.map((row) => row.pr)).size !== chain.length) { + throw new UserError( + "pull request base chain contains duplicate pull requests" + ); + } + return chain.map((row) => ({ + pr: row.pr, + branches: row.branch, + sha: branchSha({ branch: row.branch, repo }), + state: row.state, })); } @@ -1184,14 +1253,14 @@ function validateFrontierPin({ const extra = actual.filter((pr) => !expectedSet.has(pr)); const drift: string[] = []; if (missing.length > 0) { - drift.push(`missing from gt: ${missing.join(",")}`); + drift.push(`missing from resolved chain: ${missing.join(",")}`); } if (extra.length > 0) { - drift.push(`extra in gt: ${extra.join(",")}`); + drift.push(`extra in resolved chain: ${extra.join(",")}`); } if (missing.length === 0 && extra.length === 0) { drift.push( - `order differs: expected ${expected.join(",")}; gt ${actual.join(",")}` + `order differs: expected ${expected.join(",")}; resolved ${actual.join(",")}` ); } throw new UserError(`frontier pin mismatch: ${drift.join("; ")}`); @@ -1484,6 +1553,7 @@ export function openStore( set: async (params) => { await beginWrite(); const repo = resolve(requiredLine(params.repo, "repo directory")); + const forge = parseForge(params.forge ?? "gh"); const pin = params.prs === undefined ? undefined @@ -1492,7 +1562,7 @@ export function openStore( throw new UserError("--prs must not contain duplicates"); } const old = await readFrontier(store); - const prs = resolveFrontier(repo); + const prs = resolveFrontier(repo, forge); if (pin !== undefined) { validateFrontierPin({ actual: prs.map((row) => row.pr), diff --git a/omega/skills/forge-mode/scripts/package.json b/omega/skills/forge-mode/scripts/package.json index 12d522d..f6792cf 100644 --- a/omega/skills/forge-mode/scripts/package.json +++ b/omega/skills/forge-mode/scripts/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "test": "bun test orch watch-pr", + "test": "bun test orch watch-pr check-plan.test.ts", "typecheck": "tsc --project watch-pr/tsconfig.json --noEmit --strict" }, "dependencies": { diff --git a/omega/skills/forge-mode/scripts/watch-pr/fakes.test-helper.ts b/omega/skills/forge-mode/scripts/watch-pr/fakes.test-helper.ts index 2312522..84ccf59 100644 --- a/omega/skills/forge-mode/scripts/watch-pr/fakes.test-helper.ts +++ b/omega/skills/forge-mode/scripts/watch-pr/fakes.test-helper.ts @@ -7,7 +7,9 @@ import type { PrContext, PullRequestFacts, Repository, + RawReviewThread, ReviewThread, + ReviewThreadPage, RollupPage, } from "./types.ts"; import { parsePrNumber } from "./types.ts"; @@ -17,12 +19,17 @@ export interface FakeReaderOptions { readonly fastPath?: ChecksFastPath; readonly rollupPages?: readonly RollupPage[]; readonly threads?: readonly ReviewThread[]; + readonly threadPages?: readonly ReviewThreadPage[]; readonly commitRollups?: readonly CommitRollup[]; readonly openPullRequests?: readonly OpenPullRequest[]; readonly origin?: Repository | null; readonly current?: PrContext; } +function toRawThread(thread: ReviewThread): RawReviewThread { + return { id: thread.id, firstComment: thread.firstComment, resolved: false }; +} + export function passingCheck(name = "ci"): Check { return { kind: "passed", @@ -78,6 +85,7 @@ export function fakeReader( isDraft: false, }; let page = 0; + let threadPage = 0; return { calls, async originRepo() { @@ -106,9 +114,12 @@ export function fakeReader( calls.push(`checkRollupPage:${after ?? "null"}`); return options.rollupPages?.[page++] ?? { checks: [], endCursor: null }; }, - async reviewThreads() { - calls.push("reviewThreads"); - return options.threads ?? []; + async reviewThreadPage(_requested, after) { + calls.push(`reviewThreadPage:${after ?? "null"}`); + return options.threadPages?.[threadPage++] ?? { + threads: (options.threads ?? []).map(toRawThread), + endCursor: null, + }; }, async commitRollups() { calls.push("commitRollups"); diff --git a/omega/skills/forge-mode/scripts/watch-pr/github.test.ts b/omega/skills/forge-mode/scripts/watch-pr/github.test.ts index 87f4048..54722a9 100644 --- a/omega/skills/forge-mode/scripts/watch-pr/github.test.ts +++ b/omega/skills/forge-mode/scripts/watch-pr/github.test.ts @@ -2,12 +2,15 @@ import { describe, expect, it } from "bun:test"; import { ChecksUnavailable, WatcherQueryError, + buildReviewThreads, mapRollupNode, orderStack, + parseFastCheck, parsePullRequest, - parseReviewThreads, + parseReviewThreadPage, resolveChecks, resolveContext, + resolveReviewThreads, } from "./github.ts"; import { fakeReader, @@ -119,6 +122,37 @@ describe("rollup node mapping", () => { ).toMatchObject({ kind: "code-review-gate" }); }); + it("classifies a Code Review Gate waiting on an owner approval as the gate", () => { + expect( + mapRollupNode({ + __typename: "CheckRun", + name: "Code Review Gate", + status: "COMPLETED", + conclusion: "ACTION_REQUIRED", + description: "1 of 2 policies", + }) + ).toMatchObject({ + kind: "code-review-gate", + reportedState: "ACTION_REQUIRED", + }); + }); + + it("classifies a fast-path Code Review Gate waiting on an owner approval as the gate", () => { + expect( + parseFastCheck({ + name: "Code Review Gate", + state: "ACTION_REQUIRED", + bucket: "fail", + }) + ).toMatchObject({ + kind: "code-review-gate", + reportedState: "ACTION_REQUIRED", + }); + expect( + parseFastCheck({ name: "ci", state: "ACTION_REQUIRED", bucket: "fail" }) + ).toMatchObject({ kind: "failed", reportedState: "ACTION_REQUIRED" }); + }); + it("maps StatusContext states and drops unknown typenames", () => { expect( mapRollupNode({ @@ -198,6 +232,7 @@ it("annotates Bugbot threads with distinct review-pass counts", () => { repository: { pullRequest: { reviewThreads: { + pageInfo: { hasNextPage: false, endCursor: null }, nodes: [ { id: "one", @@ -250,12 +285,107 @@ it("annotates Bugbot threads with distinct review-pass counts", () => { }, }, }; - const threads = parseReviewThreads(response); + const threads = buildReviewThreads(parseReviewThreadPage(response).threads); expect(threads).toHaveLength(2); expect(threads.map((thread) => thread.isBugbot)).toEqual([true, true]); expect(threads.map((thread) => thread.bugbotReviewPasses)).toEqual([3, 3]); }); +describe("review thread pagination", () => { + const thread = (id: string, resolved = false) => ({ + id, + firstComment: { + authorLogin: "someone", + body: "please fix", + path: "a.ts", + line: 1, + createdAt: "now", + }, + resolved, + }); + + it("keeps walking pages until the cursor is exhausted", async () => { + const reader = fakeReader({ + threadPages: [ + { threads: [thread("one")], endCursor: "next" }, + { threads: [thread("two")], endCursor: "last" }, + { threads: [thread("three")], endCursor: null }, + ], + }); + const threads = await resolveReviewThreads(reader, context); + expect(threads.map((item) => item.id)).toEqual(["one", "two", "three"]); + expect(reader.calls).toEqual([ + "reviewThreadPage:null", + "reviewThreadPage:next", + "reviewThreadPage:last", + ]); + }); + + it("sees an unresolved thread that sits past the first page", async () => { + const reader = fakeReader({ + threadPages: [ + { threads: [thread("early", true)], endCursor: "next" }, + { threads: [thread("late")], endCursor: null }, + ], + }); + const threads = await resolveReviewThreads(reader, context); + expect(threads.map((item) => item.id)).toEqual(["late"]); + }); + + it("stops after a single page when the cursor is exhausted", async () => { + const reader = fakeReader({ + threadPages: [{ threads: [thread("only")], endCursor: null }], + }); + expect((await resolveReviewThreads(reader, context)).length).toBe(1); + expect(reader.calls).toEqual(["reviewThreadPage:null"]); + }); + + it("counts Bugbot review passes across every page", async () => { + const bugbot = (id: string, runId: string) => ({ + id, + firstComment: { + authorLogin: "bugbot", + body: `RUN_ID: ${runId}`, + path: "a.ts", + line: 1, + createdAt: "now", + }, + resolved: false, + }); + const reader = fakeReader({ + threadPages: [ + { threads: [bugbot("one", "run-1")], endCursor: "next" }, + { threads: [bugbot("two", "run-2")], endCursor: null }, + ], + }); + const threads = await resolveReviewThreads(reader, context); + expect(threads.map((item) => item.bugbotReviewPasses)).toEqual([2, 2]); + }); + + it("drops resolved threads from later pages too", async () => { + const reader = fakeReader({ + threadPages: [ + { threads: [thread("one")], endCursor: "next" }, + { threads: [thread("two", true)], endCursor: null }, + ], + }); + expect( + (await resolveReviewThreads(reader, context)).map((item) => item.id) + ).toEqual(["one"]); + }); + + it("rejects a page that omits pageInfo instead of assuming one page", async () => { + const response = { + data: { + repository: { + pullRequest: { reviewThreads: { nodes: [] } }, + }, + }, + }; + expect(() => parseReviewThreadPage(response)).toThrow(WatcherQueryError); + }); +}); + describe("context and stack discovery", () => { it("returns a fully explicit context without any reader call", async () => { const reader = fakeReader(); diff --git a/omega/skills/forge-mode/scripts/watch-pr/github.ts b/omega/skills/forge-mode/scripts/watch-pr/github.ts index 79bc06f..4828b02 100644 --- a/omega/skills/forge-mode/scripts/watch-pr/github.ts +++ b/omega/skills/forge-mode/scripts/watch-pr/github.ts @@ -2,7 +2,7 @@ import { spawn } from "node:child_process"; import type * as T from "./types.ts"; import { nonEmpty, parsePrNumber } from "./types.ts"; export const REVIEW_THREADS_QUERY = - "\nquery ReviewThreads($owner: String!, $repo: String!, $pr: Int!) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $pr) {\n reviewThreads(first: 100) {\n nodes {\n id\n isResolved\n comments(first: 10) {\n nodes {\n body\n createdAt\n path\n line\n author { login }\n }\n }\n }\n }\n }\n }\n}\n"; + "\nquery ReviewThreads($owner: String!, $repo: String!, $pr: Int!, $after: String) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $pr) {\n reviewThreads(first: 100, after: $after) {\n pageInfo {\n hasNextPage\n endCursor\n }\n nodes {\n id\n isResolved\n comments(first: 10) {\n nodes {\n body\n createdAt\n path\n line\n author { login }\n }\n }\n }\n }\n }\n }\n}\n"; export const PR_COMMIT_STATUS_QUERY = "\nquery PrCommitStatuses($owner: String!, $repo: String!, $pr: Int!) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $pr) {\n commits(last: 50) {\n nodes {\n commit {\n oid\n statusCheckRollup {\n state\n }\n }\n }\n }\n }\n }\n}\n"; export const PR_CHECK_ROLLUP_QUERY = @@ -237,22 +237,21 @@ export function parseFastCheck(value: unknown): T.Check { const details = checkDetails(object, "name"); const state = string(object.state, "check.state").toUpperCase(); const bucket = string(object.bucket, "check.bucket"); - if ( - bucket === "fail" || - ["FAILURE", "ERROR", "ACTION_REQUIRED"].includes(state) - ) + if (state === "ACTION_REQUIRED") return gateOr("failed", details, state); + if (bucket === "fail" || ["FAILURE", "ERROR"].includes(state)) return { ...details, kind: "failed", reportedState: state }; - if (bucket === "pending") return pendingOrGate(details, state); + if (bucket === "pending") return gateOr("pending", details, state); if (bucket === "pass") return { ...details, kind: "passed", reportedState: state }; if (bucket === "skipping") return { ...details, kind: "skipped", reportedState: state }; return { ...details, kind: "failed", reportedState: state }; } -// The owner-approval gate is excluded from pending everywhere, so the rule has -// one home. Classifying it as pending on either read path makes the watcher -// wait on a human, which is the behaviour #172004 removed from the Python. -function pendingOrGate( +// Every read path classifies the owner-approval gate here, so the rule has one +// home. A gate that is pending or waits on an owner approval must not count as +// pending or failed, or the watcher waits on a human. +function gateOr( + kind: "pending" | "failed", details: { readonly name: string; readonly description: string; @@ -268,7 +267,7 @@ function pendingOrGate( name: "Code Review Gate", reportedState, } - : { ...details, kind: "pending", reportedState }; + : { ...details, kind, reportedState }; } export function mapRollupNode(value: unknown): T.Check | null { const object = record(value, "rollup node"); @@ -288,22 +287,19 @@ export function mapRollupNode(value: unknown): T.Check | null { ? object.conclusion.toUpperCase() : ""; if (status !== "COMPLETED") - return pendingOrGate({ ...details, link }, "PENDING"); + return gateOr("pending", { ...details, link }, "PENDING"); if (conclusion === "SUCCESS") return { ...details, link, kind: "passed", reportedState: "SUCCESS" }; if (conclusion === "NEUTRAL" || conclusion === "SKIPPED") return { ...details, link, kind: "skipped", reportedState: conclusion }; - return { - ...details, - link, - kind: "failed", - reportedState: conclusion === "ACTION_REQUIRED" ? conclusion : "FAILURE", - }; + if (conclusion === "ACTION_REQUIRED") + return gateOr("failed", { ...details, link }, conclusion); + return { ...details, link, kind: "failed", reportedState: "FAILURE" }; } const state = typeof object.state === "string" ? object.state.toUpperCase() : ""; if (state === "PENDING" || state === "EXPECTED") - return pendingOrGate({ ...details, link }, "PENDING"); + return gateOr("pending", { ...details, link }, "PENDING"); return state === "SUCCESS" ? { ...details, link, kind: "passed", reportedState: state } : { ...details, link, kind: "failed", reportedState: state || "FAILURE" }; @@ -357,16 +353,13 @@ function passKey(comment: T.ReviewComment | null): string | null { } return null; } -export function parseReviewThreads(value: unknown): readonly T.ReviewThread[] { - const nodes = list( - at(value, ["data", "repository", "pullRequest", "reviewThreads", "nodes"]), - "reviewThreads.nodes" +export function parseReviewThreadPage(value: unknown): T.ReviewThreadPage { + const reviewThreads = record( + at(value, ["data", "repository", "pullRequest", "reviewThreads"]), + "reviewThreads" ); - const threads: { - readonly id: string; - readonly firstComment: T.ReviewComment | null; - readonly resolved: boolean; - }[] = []; + const nodes = list(reviewThreads.nodes, "reviewThreads.nodes"); + const threads: T.RawReviewThread[] = []; for (const node of nodes) { const thread = record(node, "review thread"); if (typeof thread.isResolved !== "boolean") @@ -381,6 +374,18 @@ export function parseReviewThreads(value: unknown): readonly T.ReviewThread[] { resolved: thread.isResolved, }); } + const page = record(reviewThreads.pageInfo, "reviewThreads.pageInfo"); + if (typeof page.hasNextPage !== "boolean") + missing("reviewThreads.pageInfo.hasNextPage", page.hasNextPage); + const cursor = optionalString( + page.endCursor, + "reviewThreads.pageInfo.endCursor" + ); + return { threads, endCursor: page.hasNextPage && cursor ? cursor : null }; +} +export function buildReviewThreads( + threads: readonly T.RawReviewThread[] +): readonly T.ReviewThread[] { const keys = new Set(); let keyless = false; for (const thread of threads) { @@ -568,12 +573,13 @@ export class GhGitHubReader implements T.GitHubReader { ); return { checks, endCursor: page.hasNextPage && cursor ? cursor : null }; } - async reviewThreads( - context: T.PrContext - ): Promise { - return parseReviewThreads( - await runJson(graphqlArgs(REVIEW_THREADS_QUERY, context)) - ); + async reviewThreadPage( + context: T.PrContext, + after: string | null + ): Promise { + const argv = graphqlArgs(REVIEW_THREADS_QUERY, context); + if (after !== null) argv.push("-f", `after=${after}`); + return parseReviewThreadPage(await runJson(argv)); } async commitRollups( context: T.PrContext @@ -623,6 +629,19 @@ export async function resolveChecks( : "fast path and GraphQL rollup were empty"; throw new ChecksUnavailable(`could not read PR checks: ${suffix}`); } +export async function resolveReviewThreads( + reader: T.GitHubReader, + context: T.PrContext +): Promise { + const threads: T.RawReviewThread[] = []; + let after: string | null = null; + do { + const page = await reader.reviewThreadPage(context, after); + threads.push(...page.threads); + after = page.endCursor; + } while (after !== null); + return buildReviewThreads(threads); +} export async function resolveContext(args: { readonly reader: T.GitHubReader; readonly owner: string | null; diff --git a/omega/skills/forge-mode/scripts/watch-pr/policy.test.ts b/omega/skills/forge-mode/scripts/watch-pr/policy.test.ts index 0620dde..6dae16d 100644 --- a/omega/skills/forge-mode/scripts/watch-pr/policy.test.ts +++ b/omega/skills/forge-mode/scripts/watch-pr/policy.test.ts @@ -106,7 +106,7 @@ describe("snapshot query planning", () => { expect(snapshot.ci.kind).toBe("ci-pending"); expect(reader.calls).toEqual([ "pullRequest", - "reviewThreads", + "reviewThreadPage:null", "checksFastPath", ]); }); diff --git a/omega/skills/forge-mode/scripts/watch-pr/policy.ts b/omega/skills/forge-mode/scripts/watch-pr/policy.ts index 4665be3..cabadb9 100644 --- a/omega/skills/forge-mode/scripts/watch-pr/policy.ts +++ b/omega/skills/forge-mode/scripts/watch-pr/policy.ts @@ -1,4 +1,8 @@ -import { WatcherQueryError, resolveChecks } from "./github.ts"; +import { + WatcherQueryError, + resolveChecks, + resolveReviewThreads, +} from "./github.ts"; import type * as T from "./types.ts"; import { nonEmpty } from "./types.ts"; export function assessGitHubMerge(args: { @@ -63,7 +67,7 @@ export async function readSnapshot(args: { return { kind: "merged", context: args.context, facts }; if (facts.state === "CLOSED") return { kind: "closed", context: args.context, facts }; - const threads = await args.reader.reviewThreads(args.context); + const threads = await resolveReviewThreads(args.reader, args.context); const checks = await resolveChecks(args.reader, args.context); const failed = nonEmpty( checks.checks.filter( diff --git a/omega/skills/forge-mode/scripts/watch-pr/types.ts b/omega/skills/forge-mode/scripts/watch-pr/types.ts index b67144e..6c94e0a 100644 --- a/omega/skills/forge-mode/scripts/watch-pr/types.ts +++ b/omega/skills/forge-mode/scripts/watch-pr/types.ts @@ -379,6 +379,15 @@ export interface RollupPage { readonly checks: readonly Check[]; readonly endCursor: string | null; } +export interface RawReviewThread { + readonly id: string; + readonly firstComment: ReviewComment | null; + readonly resolved: boolean; +} +export interface ReviewThreadPage { + readonly threads: readonly RawReviewThread[]; + readonly endCursor: string | null; +} export interface GitHubReader { originRepo(): Promise; currentPr(pr: PrNumber | null): Promise; @@ -389,7 +398,10 @@ export interface GitHubReader { context: PrContext, after: string | null ): Promise; - reviewThreads(context: PrContext): Promise; + reviewThreadPage( + context: PrContext, + after: string | null + ): Promise; commitRollups(context: PrContext): Promise; } export interface PollingOptions { diff --git a/omega/skills/forge-mode/scripts/worktree-audit.sh b/omega/skills/forge-mode/scripts/worktree-audit.sh index b9af081..743c4e9 100755 --- a/omega/skills/forge-mode/scripts/worktree-audit.sh +++ b/omega/skills/forge-mode/scripts/worktree-audit.sh @@ -4,29 +4,69 @@ # operated in it. Emits a table sorted by size with a suggested bucket. Never # deletes anything; deletion stays a human-gated step in the playbook. # -# Usage: worktree-audit.sh [repo-path] (defaults to the current repo) +# Usage: worktree-audit.sh [-b|--base ] [repo-path] +# repo-path defaults to the current repo. +# -b sets the ref the MERGED column checks against (e.g. origin/integration). +# Without it the base is origin/HEAD, then origin/main, then origin/master. +# origin/HEAD is the remote's default branch, not necessarily the branch PRs +# merge into; pass -b when they differ. set -u +base="" +while [ $# -gt 0 ]; do + case "$1" in + -b|--base) [ $# -ge 2 ] || { echo "error: $1 needs a ref" >&2; exit 2; }; base="$2"; shift 2 ;; + -h|--help) sed -n '2,/^set -u/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;; + -*) echo "error: unknown option: $1" >&2; exit 2 ;; + *) break ;; + esac +done + repo="${1:-$(git rev-parse --show-toplevel 2>/dev/null)}" [ -z "$repo" ] && { echo "not in a git repo; pass a repo path" >&2; exit 1; } cd "$repo" || exit 1 +git rev-parse --git-dir >/dev/null 2>&1 || { echo "error: not a git repo: $repo" >&2; exit 1; } # Main worktree is the first entry; everything else is a candidate. main_wt=$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}') -# origin/main drives the merge check. Best-effort; stale is fine for a first pass. -git fetch origin main --quiet 2>/dev/null || echo "warn: could not fetch origin/main; merged column may be stale" >&2 - -# PR state by branch, fetched once. Empty if gh is unavailable. +if [ -z "$base" ]; then + for cand in "$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null)" origin/main origin/master; do + [ -n "$cand" ] && git rev-parse --verify --quiet "$cand^{commit}" >/dev/null && { base="$cand"; break; } + done + [ -z "$base" ] && { echo "error: no base ref (origin/HEAD, origin/main, origin/master all missing); pass -b " >&2; exit 1; } + echo "warn: no -b given; MERGED checks against $base" >&2 +fi + +# The base drives the merge check. The fetch is best-effort (stale is fine for +# a first pass) and runs before the check, since -b may name a branch not +# fetched yet. The explicit refspec creates it even in a single-branch clone. +case "$base" in +origin/*) git fetch origin "+refs/heads/${base#origin/}:refs/remotes/$base" --quiet 2>/dev/null \ + || echo "warn: could not fetch $base; merged column may be stale" >&2 ;; +esac +git rev-parse --verify --quiet "$base^{commit}" >/dev/null 2>&1 || { echo "error: base ref not found: $base" >&2; exit 1; } + +# PR state by branch, fetched once as [{number, state, head}] with a lowercase +# state. Origin when its CLI can read this repo's PRs, otherwise gh. Empty when +# neither can. prs=$(mktemp) -gh pr list --author "@me" --state all --limit 1000 \ - --json number,state,headRefName 2>/dev/null > "$prs" || echo "[]" > "$prs" +if command -v origin >/dev/null 2>&1 \ + && raw=$(origin pr list --author "@me" --state all --limit 1000 \ + --json number,status,headRef 2>/dev/null); then + printf '%s' "$raw" | jq '[.[] | {number, state: .status, head: .headRef}]' > "$prs" 2>/dev/null +elif raw=$(gh pr list --author "@me" --state all --limit 1000 \ + --json number,state,headRefName 2>/dev/null); then + printf '%s' "$raw" | jq '[.[] | {number, state: (.state | ascii_downcase), head: .headRefName}]' > "$prs" 2>/dev/null +fi +[ -s "$prs" ] || echo "[]" > "$prs" # Transcripts dir: ~/.cursor/projects//agent-transcripts. slug=$(printf '%s' "$main_wt" | sed 's#^/##; s#/#-#g') transcripts="$HOME/.cursor/projects/$slug/agent-transcripts" now=$(date +%s) +echo "# base: $base" printf "SIZE\tAGE\tMERGED\tDIRTY\tREMOTE\tPR\tLAST_CHAT\tBUCKET\tWORKTREE\n" git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; do @@ -37,9 +77,9 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; head_ts=$(git -C "$wt" log -1 --format='%ct' HEAD 2>/dev/null || echo 0) age=$([ "$head_ts" -gt 0 ] 2>/dev/null && echo "$(( (now - head_ts) / 86400 ))d" || echo "?") - # Squash-merged branches are not ancestors of main, so PR state is the + # Squash-merged branches are not ancestors of the base, so PR state is the # real signal; merge-base only catches fast-forward/rebase merges. - git merge-base --is-ancestor "$head" origin/main 2>/dev/null && merged=YES || merged=no + git merge-base --is-ancestor "$head" "$base" 2>/dev/null && merged=YES || merged=no # Distinguish real WIP (tracked edits) from disposable untracked scratch. porcelain=$(git -C "$wt" status --porcelain 2>/dev/null) @@ -57,7 +97,7 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; else remote=no-remote; fi pr=$([ -n "$branch" ] && jq -r --arg b "$branch" \ - '.[] | select(.headRefName==$b) | "#\(.number)/\(.state)"' "$prs" 2>/dev/null | head -1) + '.[] | select(.head==$b) | "#\(.number)/\(.state)"' "$prs" 2>/dev/null | head -1) [ -z "$pr" ] && pr="-" # Most recent chat whose transcript operated in this worktree. Match path @@ -72,7 +112,7 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; recent=$([ "$last_ts" -gt 0 ] 2>/dev/null && [ $(( (now - last_ts) / 86400 )) -le 4 ] && echo yes || echo no) case "$dirty" in wip:*) bucket=hold-wip ;; *) - case "$pr" in *OPEN*) bucket=hold-open-pr ;; *) + case "$pr" in */open|*/draft) bucket=hold-open-pr ;; *) if [ "$recent" = yes ]; then bucket=verify-recent-chat elif [ "$merged" = YES ] || [ "$pr" != "-" ]; then bucket=safe else bucket=review; fi ;; diff --git a/omega/skills/in-your-own-words/SKILL.md b/omega/skills/in-your-own-words/SKILL.md new file mode 100644 index 0000000..2e73c43 --- /dev/null +++ b/omega/skills/in-your-own-words/SKILL.md @@ -0,0 +1,7 @@ +--- +name: in-your-own-words +description: "ユーザーのゴールと解こうとしている問題を、自分の言葉で言い直す。/in-your-own-words に使用。" +disable-model-invocation: true +--- + +ユーザーのゴールと、解こうとしている問題が何だと理解しているかを、自分の言葉で言い直す。確認待ちはしない。言い直したあと、その理解で作業を続けるか、ずれがあればユーザーに短く訂正を求める。 diff --git a/omega/skills/ketchup/SKILL.md b/omega/skills/ketchup/SKILL.md index f09c811..6a092b3 100644 --- a/omega/skills/ketchup/SKILL.md +++ b/omega/skills/ketchup/SKILL.md @@ -1,23 +1,37 @@ --- name: ketchup -description: "このチャットでユーザー最後のメッセージ以降の出来事を平易な言葉でキャッチアップする。各 action item に判断できる executive summary を付ける。/ketchup に使用。" +description: "このチャットでユーザー最後のメッセージ以降の変化を平易な言葉と小さなビジュアルでキャッチアップする。各 action item に判断できるだけの文脈を付ける。/ketchup に使用。" disable-model-invocation: true --- # Ketchup -ユーザーが `/ketchup` の直前に書いた最後のメッセージ以降、このチャットで起きたことをキャッチアップする。通知とサブエージェント結果はユーザーのメッセージではない。[`/bro`](../bro/SKILL.md) の平易な声で書き、その restating ステップはスキップする。ユーザーは自分の最後のメッセージだけ覚えていて、その後は何も知らないと仮定する。PR・branch・エージェントは作業中に付けたラベルではなく、ユーザーが知っている名前で呼ぶ。 +ユーザーが `/ketchup` の直前に書いた最後のメッセージ以降、このチャットで起きたことをキャッチアップし、各変化の「今どこまでか」だけでなく「何が変わったか」も伝える。通知とサブエージェント結果はユーザーのメッセージではない。ユーザーは自分の最後のメッセージだけ覚えていて、その後は何も知らないと仮定する。 -## Gather what changed +[`/bro`](../bro/SKILL.md) の声を使い、その restating タスクはしない。平易な言葉、短い文、専門用語なし。一人がもう一人に追いつかせる口調。ユーザーにとって何が変わったかを書き、どうやったかは書かない。「CI は green」ではなく「テストは通った」と言う。ユーザーが動くために必要なとき以外は hash、パス、コマンド、ビルド番号、テスト件数は省く。PR・branch・エージェントは作業中に付けたラベルではなく、ユーザーが知っている名前で呼ぶ。文脈がないと意味が通じない事実は文脈を付けるか、その事実ごと省く。 -このターンは read-only。自分の作業、サブエージェントの報告、このチャットの他者メッセージを集める。このチャットが触れた各 PR・CI・エージェントの現状を確認する。まだ走っているもの、確認できなかったものを述べる。 +## 何が変わったか集める -action item はユーザーだけが取れる決定またはステップ(プロダクト判断、承認、不可逆操作、アクセス不足)。ユーザー最後のメッセージより前から待っているものも含める。自分がまだできる作業は action item ではない。 +このターンは read-only。ビジュアルは既にあるものだけから作る。コードを変え、アプリやジョブを起動し、何かを post しない。そのメッセージ以降の自分の作業、サブエージェントの報告、このチャットの他者メッセージを集める。このチャットが触れた各 PR・CI・エージェントの現状を確認する。示す数字は根拠と照合し、直したときはそう言う。 -## Write the reply +action item はユーザーだけが取れる決定またはステップ(プロダクト判断、承認、不可逆操作、アクセス不足)。ユーザー最後のメッセージより前から待っているものも含める。自分がまだできる作業は action item ではない。返信を埋めるための判断を捏造しない。ユーザーが求めておらず作業にも不要なステップは action item ではない。不可逆でユーザー待ちのものだけは action item 1 行(ビジュアルなし)。答えない場合の結果は書かない。 -action item の件数を 1 文で開く。0 件ならそう言う。何も起きておらずユーザー待ちもなければそう言って終える。 +## 返信を書く -次に起きたことを重要度順に述べる。各結果とユーザーへの意味を述べる。変化のないステップはスキップ。自分がまだできる残作業を 1 行で名指す。 +返信全体はビジュアルを除き 150 語以内。設計やシステムの仕組み、PR stack や依存チェーン(各 PR の状態付き)、修正の before/after、選択肢の並列など、文より図の方が伝わるときは描く。状態リストやつながりのない箱は描かない。テキストでもビジュアル間でも同じ事実を二度書かない。ユーザーに変化がなかった作業は省く。語数が足りないときは action item を削る前に「起きたこと」側を削る。 -各 action item に短い executive summary を付ける。決めること、今重要な理由、選択肢と各コスト、推奨、ユーザーが答えない場合の結果。分からない部分は推測せずそう言う。詳細があればリンクし、開かずに判断できる要約にする。 +冒頭 1 文でユーザー判断が要る件数を言う。0 件ならそう言う。何も起きておらずユーザー待ちもなければそう言って終える。 + +次に起きたことを示す。ビジュアルを先に。作業で得たスクリーンショットと動画を本文に埋め込み、変化を説明する図があれば続ける。そのあとユーザーが求めたことごとに 1 短い bullet で要約。何になったか、完了・実行中・失敗・未確認か。声に出して言う形で書く。途中で見つけて直したバグは名指す。各行は単独で真実。action item と矛盾する完了表示はしない。要約の下に、ユーザーの知識や行動を変える行を最大 3 行。自分がまだできる残作業を 1 行。 + +action item は平易な bullet のみ(図や箱にしない)。各 2 行、または未依頼の不可逆だけ 1 行。決定内容と推奨と理由、そのあとユーザーが答えない場合の結果。ユーザーが持つ判断(merge、approve、選択)を聞く。自分が先に取るステップは推奨に書く。先に必要なこと、不可逆なことも推奨に含める。分からない部分は「unknown」と書く。詳細はリンクする。 + +リストのあと選択肢を示す。上のスクリーンショットが既に選択肢を示しているなら、各画像の下に gain と cost を書き、新しいビジュアルは描かない。そうでなければ選択肢を並べ、現状があれば含める。検討した全選択肢を載せ、各 gain と cost を書く。本文の他の部分には選択肢を混ぜない。 + +## ビジュアル + +ビジュアルはスクリーンショット、動画、Mermaid 図、`/visualize` のチャート、生成画像。Markdown 表はビジュアルではない(before/after 列があっても)。 + +各ビジュアルは一目で読める大きさ。箱は最大 6、各ラベルは口に出す言葉で最大 8 語。「Tests: pass」のようなタグは使わない。文が要る事実は本文に書くか書かない。 + +画面に見える変化はリンクだけでなくインラインで埋め込む。見える変化なのにキャプチャが無い行は「No screenshot yet,」と書き、画面上何が変わったかを言う。持っている事実から Mermaid や `/visualize` を描く。数字を捏造しない。キャプチャしていない画面の絵を描いたり生成したりしない。存在する成果物だけリンクする。 diff --git a/omega/skills/sharpen/SKILL.md b/omega/skills/sharpen/SKILL.md new file mode 100644 index 0000000..9084215 --- /dev/null +++ b/omega/skills/sharpen/SKILL.md @@ -0,0 +1,7 @@ +--- +name: sharpen +description: "着手前にタスクを研ぐ。一流の専門家ならどう進めるか、ユーザーが本当に欲しいものは何か、指示が過不足ないか。/sharpen に使用。" +disable-model-invocation: true +--- + +着手前に、一流の専門家がこのタスクをどう進めるか(一次情報、実際の手法、結果が正しいかの外側チェック)、ユーザーが本当に欲しいものは何か、タスクが過剰指定か不足指定かを考える。目標に役立たない制約は緩め、ユーザーに残すべき選択は決めずに開いたままにする。作業を大きくするのではなく良くするものだけ取り込むが、結果が正しいかの外側チェックは常に 1 つ残す。枠組みの実況はせず、そのまま作業する。書き直したプロンプトを返さない。