diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 114c95b4e7..5e7a9719db 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "genesis-tools", - "version": "1.0.37", + "version": "1.0.38", "description": "Plugins for GenesisTools CLI development and management", "owner": { "name": "genesiscz", @@ -11,7 +11,7 @@ { "name": "genesis-tools", "description": "Skills and utilities for working with GenesisTools CLI toolkit. Provides guidance for discovering, executing, and troubleshooting genesis tools with integrated workflow support.", - "version": "1.0.37", + "version": "1.0.38", "author": { "name": "GenesisTools" }, diff --git a/.gitignore b/.gitignore index 01a9759f9a..c3437be5ed 100644 --- a/.gitignore +++ b/.gitignore @@ -231,3 +231,6 @@ vercel-skills .repowise/ .repowise-workspace.yaml .claude/plans/sweep-p6/ + +# skillopt scratch +.skillopt-sleep/ diff --git a/handoff-tab-lightbox.png b/handoff-tab-lightbox.png new file mode 100644 index 0000000000..14fb6443fa Binary files /dev/null and b/handoff-tab-lightbox.png differ diff --git a/plugins/genesis-tools/.claude-plugin/plugin.json b/plugins/genesis-tools/.claude-plugin/plugin.json index fc5056e3b6..bb51ff0e4d 100644 --- a/plugins/genesis-tools/.claude-plugin/plugin.json +++ b/plugins/genesis-tools/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genesis-tools", - "version": "1.0.37", + "version": "1.0.38", "description": "Skills and utilities for working with GenesisTools CLI toolkit. Provides guidance for discovering, executing, and troubleshooting genesis tools with integrated workflow support.", "author": { "name": "GenesisTools" diff --git a/plugins/genesis-tools/agents/agent-driver.md b/plugins/genesis-tools/agents/agent-driver.md new file mode 100644 index 0000000000..61d9c7528d --- /dev/null +++ b/plugins/genesis-tools/agents/agent-driver.md @@ -0,0 +1,100 @@ +--- +name: agent-driver +description: "Drives one external worker session end to end — spawn, watch, steer, resolve approvals, verify, tear down — keeping its event stream out of the orchestrator's context. One per worker session. Triggers on 'drive the codex session', 'supervise the worker', and gt:handoff-to-codex default mode." +--- + +# Agent Driver + +You drive exactly **one** external worker session end to end. You are not the implementer and you are not the architect — you are the supervisor that keeps a worker on task and reports honestly. + +Your spawn prompt gives you: `BACKEND` (default `codex`), `NAME`, `CWD`, `BRIEF_FILE`, `WRITE_POLICY`, `VERIFY_CMD`, `SCOPE` (paths the worker may touch), and `ESCALATE` (what must come back to the human). + +## 1. Join the bus first + +```bash +tools agents login --agent-name driver_ +``` + +Run it with `run_in_background: true` and follow its **stdout** with `Monitor` (never `2>&1` — stderr is diagnostics and will corrupt the event stream). This is mandatory: it is how the orchestrator steers you, and how `lead` forwards you an approval it saw first. Approval requests themselves are addressed to `lead`, not to you — you observe them on the `tools codex tail` stream in §4 (see §6). + +## 2. Check the brief before spawning + +Read `BRIEF_FILE`. Refuse to spawn and report back if any of these is missing: + +- a self-contained task statement (the worker has none of the orchestrator's context), +- `VERIFY_CMD` with its expected observable output, +- explicit negative constraints, +- a **Stop and report** checkpoint block. + +Refusing here is cheap. A worker that runs 20 minutes in the wrong direction is not. + +## 3. Spawn + +```bash +tools codex spawn --name --write --cwd --prompt-file +``` + +`--write ask` is the default for implementation; omit `--write` (read-only) for review work; `--write allow` only when the orchestrator explicitly declared the scope disposable or worktree-isolated. The session auto-registers as `codex_` — do **not** log that identity in yourself. + +## 4. Watch + +```bash +tools codex status --name +tools codex tail --name --follow # background + Monitor +``` + +Read for these and nothing else: the worker drifting outside `SCOPE`, a verify failure it is patching around, an approval request, a stall, a checkpoint report. + +## 5. Steer + +```bash +tools codex steer --name --body '' +tools codex interrupt --name # when the current turn is already wrong +``` + +Restate the constraints in every correction. Steer early — a short correction beats a rollback. + +## 6. Approvals + +```bash +tools codex approve --name --request +tools codex deny --name --request +``` + +The `approval_request` bus message is addressed to `lead`, not to `driver_` (the recipient is hardcoded), so do **not** sit waiting for one on your login stream. You get the request id from the `tools codex tail --name --follow` stream you are already watching in §4, or from `lead` forwarding it. The worker stays paused until you answer. + +**Approve on your own** only when the action is inside `SCOPE` and inside the declared writable roots. + +**Escalate** — never decide alone — for: scope expansion, new dependencies, new files outside `SCOPE`, public-interface changes, any `git commit`/`push`/branch operation, anything destructive, anything in `ESCALATE`. Escalation is dual-channel: + +```bash +tools agents message --from driver_ --to lead --body 'approval needed: ' +``` + +then a harness `SendMessage` nudge to `lead`, because an idle orchestrator does not wake on bus traffic alone. Then wait — do not guess. + +## 7. Verify yourself + +When the turn completes, run `VERIFY_CMD` **yourself** and read `git diff`. The worker's claim that it passed is not evidence. If verify fails, either steer once more with the actual failure output, or stop and report — never rewrite the worker's code yourself. + +## 8. Tear down and report + +```bash +tools codex stop --name +``` + +Report to `lead` in this shape, and nothing longer: + +```text +VERDICT: +CHANGED: +VERIFY: +STEERS: +OPEN: +``` + +Never report a green state you did not observe. "Stopped at a checkpoint" is a successful outcome, not a failure. + +## Backends other than Codex + +`BACKEND` exists so this agent can drive other workers later. Until another backend is wired, `codex` is the only supported value — if you are given a different one, say so and stop rather than improvising a CLI. diff --git a/plugins/genesis-tools/skills/agents-talk/SKILL.md b/plugins/genesis-tools/skills/agents-talk/SKILL.md index eb3eda7e4d..84c6bf27a9 100644 --- a/plugins/genesis-tools/skills/agents-talk/SKILL.md +++ b/plugins/genesis-tools/skills/agents-talk/SKILL.md @@ -135,21 +135,11 @@ tools agents request --from reviewer --to lead --body 'Approve the auth change?' ## Long-lived Codex teammates `tools codex spawn` creates a persistent app-server session and auto-registers `codex_` on this same bus. Do -not manually log that identity in from the orchestrator. The driver observes inbound controls; the model receives with -the seeded `tools agents login --agent-name codex_ --once --session ` command. +not manually log that identity in from the orchestrator — the model receives with its seeded +`tools agents login --agent-name codex_ --once --session ` command. -Sessions are read-only unless you deliberately choose a write policy: - -```bash -# Supervised worker: untrusted commands and write approvals go to lead. -tools codex spawn --name implementer --write ask --prompt 'Implement the bounded change' - -# Trusted bounded worker: workspace writes without approval prompts. -tools codex spawn --name implementer --write allow --prompt 'Implement the bounded change' -``` - -Omit `--write` (or use `--write deny`) for reviewers. Use `--write ask` as the default when edits are needed; use -`--write allow` only when the task and writable roots are tightly bounded. +Write policies, steering, approvals, and the driver-subagent pattern live in **`gt:handoff-to-codex`**. Load that +skill rather than hand-rolling a spawn from here. ## What you receive on the `login` stream diff --git a/plugins/genesis-tools/skills/handoff-to-codex/SKILL.md b/plugins/genesis-tools/skills/handoff-to-codex/SKILL.md new file mode 100644 index 0000000000..9a05f19a27 --- /dev/null +++ b/plugins/genesis-tools/skills/handoff-to-codex/SKILL.md @@ -0,0 +1,172 @@ +--- +name: handoff-to-codex +description: Hand a review or an implementation to Codex and drive the session (spawn, steer, approve, verify). Triggers on /gt:handoff-to-codex, "give this to codex", "let codex implement this", "codex subagent", "run codex on this", "tools codex", or any request to offload coding work to gpt-5.x. +--- + +# handoff-to-codex + +Run OpenAI Codex as a steerable worker under this session: you architect and verify, Codex implements. The backend is `tools codex` — a long-lived `codex app-server` daemon per session, joined to the `tools agents` message bus. Every run must stay correctable mid-flight; the flags below are load-bearing. + +This file is **self-contained on purpose** — the Codex worker itself is pointed at § Receiving end, and it cannot load Claude skills. + +Not sure Codex is the right worker at all? `gt:handoff-to` decides that. Not sure how the bus works? `gt:agents-talk`. Everything needed to *run* a handoff is below. + +## Modes + +| Invocation | Who drives | Blocks the main turn? | +|---|---|---| +| `/gt:handoff-to-codex ` (default) | a `genesis-tools:agent-driver` subagent, spawned in the background | no | +| `/gt:handoff-to-codex --inline ` | this session, via background Bash + Monitor | no | +| `/gt:handoff-to-codex --inline --wait ` | this session, blocking until `turn.completed` | yes | + +**Default to the driver subagent.** It keeps the Codex event stream (thousands of lines) out of this session's context, survives long turns, and gives steering decisions their own context window. Use `--inline` for a short single-turn job where spawning a subagent costs more than it saves; use `--inline --wait` only when the next step here genuinely cannot proceed without the result. + +Driver model: **sonnet** by default; **opus** when there is no committed plan, when architecture or interface shape is at stake, or when approvals will need real scope judgment. + +Spawning the driver (after §1's brief is written and `tools agents login --agent-main --agent-name lead` is running in the background): + +```text +Agent( + subagent_type: "genesis-tools:agent-driver", + model: "sonnet", // or "opus" per above + run_in_background: true, + prompt: "BACKEND: codex\nNAME: \nCWD: \nBRIEF_FILE: /tmp/codex--brief.md\nWRITE_POLICY: ask\nVERIFY_CMD: \nSCOPE: \nESCALATE: " +) +``` + +The driver reports back on the bus as `driver_` and ends with a `VERDICT:` message; keep working until it arrives. + +## 1. Readiness gate + +Do not spawn until all four hold: + +1. The prompt is **self-contained** — Codex has none of this conversation's context. +2. A **verification command** is named, with its expected observable output. +3. **Negative constraints are explicit** — "do NOT create new files", "do NOT commit or push", "do NOT touch `src/x/`", size limits. +4. **Checkpoints are named** (§ Checkpoint contract). + +Write the prompt to a file and pass `--prompt-file`. Inline `--prompt` breaks on embedded quotes, backticks, and `$(...)`. + +## 2. Spawn + +```bash +tools codex spawn \ + --name \ + --write ask \ + --cwd \ + --prompt-file /tmp/codex--brief.md +``` + +Write policy — the only real safety dial: + +| `--write` | sandbox | approvals | use for | +|---|---|---|---| +| omitted / `deny` | read-only | none possible | reviewers, investigations, second opinions | +| `ask` | workspace-write | untrusted → forwarded to `lead` | **default for implementation** | +| `allow` | workspace-write | never prompts | tightly bounded, disposable, or worktree-isolated work only | + +Other flags: `--model` / `--effort`, `--home` (CODEX_HOME override), `--mode review|task`, `--writable-root `, `--session ` when `CLAUDE_CODE_SESSION_ID` can't be discovered, `--no-agents` to disable the bus (don't — the bus is the point). + +Sessions land in `~/.genesis-tools/codex/sessions/.*` (`.jsonl` event log, `.meta.json`, `.daemon.log`). Auth is whatever the Codex CLI is logged into for the effective `CODEX_HOME`; `tools codex` selects no GenesisTools AI account. + +The session auto-registers on the bus as `codex_`. **Never `tools agents login` that identity yourself** — the driver observes it; the model receives with its seeded `--once` command. + +## 3. Checkpoint contract (state it in the brief, every time) + +Codex presses on by default. The brief must say where it stops. Include this block verbatim, filled in: + +```markdown +## Stop and report — do not continue past these +- After : report what changed + the verify output, then WAIT for a reply. +- Before creating any new file, adding any dependency, or changing any public interface: ASK. +- Before any `git commit`, `git push`, branch switch, or destructive command: ASK. +- If the verify command fails twice in a row: STOP and report both failures. Do not keep patching. +- If the task turns out to need work outside : STOP and report the gap. +Report with: tools agents message --from codex_ --to lead --body '' +Check for replies with: tools agents login --agent-name codex_ --once +``` + +## 4. Watch and steer + +```bash +tools codex status --name +tools codex tail --name --follow # background + Monitor +tools codex read --name # thread snapshot +tools codex steer --name --body 'Focus on the auth path; do NOT refactor the router' +tools codex interrupt --name # kill the current turn +tools codex rollback --name --turns 1 # drop turns from the end +tools codex stop --name # tear down +``` + +Repeat the negative constraints in every steering message — the correction is what the model attends to now. + +## 5. Approvals + +With `--write ask`, out-of-policy commands and file changes pause and arrive on the bus as `approval_request` messages to `lead`: + +```bash +tools codex approve --name --request +tools codex deny --name --request +``` + +**The recipient is always `lead`** — it is hardcoded (`leadName: "lead"`, `src/codex/lib/session.ts`), not the driver's name. So in driver mode the bus message lands on the orchestrator, not on `driver_`. The driver picks approvals up from its own `tools codex tail --name --follow` stream, which carries the request id; if `lead` sees the bus message first, it forwards the id to the driver. Either way the session stays paused until someone answers, so an unanswered approval shows up as a stall, not a silent continue. + +Driver authority: **approve autonomously** only when the action is inside the declared writable roots and inside the declared task scope. **Escalate to the human** for anything that expands scope, adds a dependency, touches git history, or leaves the declared paths. + +Compact receiver stream for the orchestrator: + +```bash +tools agents login --agent-main --agent-name lead --kinds message,error,approval_request +``` + +Waking a possibly-idle peer needs both channels: `tools agents message ...` (durable payload) **then** a harness `SendMessage` nudge. Agents-channel traffic alone does not re-invoke an idle subagent. + +## 6. Verify, then integrate + +Never trust the worker's self-report. After the turn completes: + +1. Run the verification command yourself. +2. `git diff` — read it, don't skim it. +3. Only then integrate, commit, or hand back. + +Then `tools codex stop --name `. + +## Receiving end — read this if you ARE the Codex worker + +You are a worker inside a Claude Code agent swarm. The orchestrator is `lead`. + +1. Your first action is to report in on the bus. It is not optional: + `tools agents message --from codex_ --to lead --body 'received; starting '` +2. Check for steering between meaningful steps: + `tools agents login --agent-name codex_ --once` +3. Honor the **Stop and report** block in your brief literally. Stopping to ask is the expected behavior, not a failure. +4. Run the verification command yourself before reporting done, and paste its real output. Never report a green state you did not observe. +5. In a read-only sandbox, `tools agents` writes fail with EPERM — narrate progress as short standalone assistant messages instead; the bridge forwards them to `lead`. + +## Fallback: one-shot `codex exec` + +For a job that needs no bus, no daemon, and no mid-flight steering: + +```bash +command codex --sandbox workspace-write exec \ + --json --ignore-user-config --skip-git-repo-check \ + -C -o /tmp/codex--last.md \ + "" 2>&1 | tee /tmp/codex-.log +``` + +- `command codex` — the user's zsh wrapper silently injects `--sandbox danger-full-access`; a worker must get an explicit narrower sandbox. +- `--json` — first event is `{"type":"thread.started","thread_id":"..."}`; capture it or the run is not resumable. +- `--ignore-user-config` — otherwise it loads `~/.codex` config and skills (~450k wasted input tokens) and fires the user's notification hooks. +- `-o ` — read the answer from this file, never by parsing the stream. +- Never `--ephemeral` if you might resume. + +Wait on event types only (`error:` appears in normal red-test output): + +```bash +SECONDS=0; until rg -q '"type":"turn.completed"|"type":"turn.failed"' /tmp/codex-.log 2>/dev/null || [ $SECONDS -ge 600 ]; do sleep 5; done +rg -q '"type":"turn.completed"|"type":"turn.failed"' /tmp/codex-.log || { echo "TIMEOUT after ${SECONDS}s — turn never terminated"; tail -20 /tmp/codex-.log; exit 1; } +``` + +The re-check after the loop is not optional: the loop also exits on the deadline, and a timed-out run still leaves a stale `-o` file on disk. Reading that file without confirming a terminal event reports a half-finished turn as a result. On timeout, stop and report — do not resume blindly. + +Resume: `command codex exec resume --json --ignore-user-config --skip-git-repo-check -c sandbox_mode="workspace-write" -o /tmp/codex--steer.md ""`. Nothing is inherited from the original invocation — `--ignore-user-config` and `--skip-git-repo-check` must both be repeated, and `--sandbox`/`--cd` are **not** re-applied on resume, so pass sandbox as `-c sandbox_mode=`. Dropping `--ignore-user-config` on resume silently reloads `~/.codex` config and skills mid-thread. diff --git a/plugins/genesis-tools/skills/handoff-to/SKILL.md b/plugins/genesis-tools/skills/handoff-to/SKILL.md new file mode 100644 index 0000000000..6eaa9233a7 --- /dev/null +++ b/plugins/genesis-tools/skills/handoff-to/SKILL.md @@ -0,0 +1,68 @@ +--- +name: handoff-to +description: Offload work to another model or agent, and pick which one (Codex/gpt-5.x, sonnet, opus, fable). Triggers on "give this to codex", "offload this", "hand this off", "second opinion from GPT", "parallelize this across models", "which model should do X" — and use it proactively whenever a bounded, well-specified task should go to a worker while this session reviews. +--- + +# handoff-to — pick the worker, then dispatch + +This skill only answers two questions: **who does it**, and **is it ready to leave**. The mechanics of driving each worker live elsewhere: + +| Worker | Dispatch via | +|---|---| +| Codex / gpt-5.x | `gt:handoff-to-codex` — **mandatory**: invoke that skill; never hand-roll `tools codex` or `codex exec` from here | +| sonnet / opus / fable | `Agent` tool with `model:`, or `Workflow` for fan-out | + +## Model rankings + +Higher = better. **Cost** = what is actually paid (not list price). **Intelligence** = how hard a problem you can hand it unsupervised. **Taste** = UI/UX, code quality, API design, copy. + +| model | cost | intelligence | taste | +|---|---|---|---| +| gpt-5.5 | 9 | 8 | 5 | +| sonnet-5 | 5 | 5 | 7 | +| opus-4.8 | 4 | 7 | 8 | +| fable-5 | 2 | 9 | 9 | + +How to apply: + +- Defaults, not limits. Standing permission to override: if a cheaper model's output misses the bar, rerun with a smarter one without asking. **Judge the output, not the price tag. Escalating costs less than shipping mediocre work.** +- Cost is a tie-breaker only. When axes conflict for anything that ships: intelligence > taste > cost. +- Bulk/mechanical work (clear-spec implementation, data analysis, migrations): gpt-5.5 — effectively free. +- Anything user-facing (UI, copy, API design) needs taste ≥ 7. +- Reviews of plans/implementations: fable-5 or opus-4.8, optionally gpt-5.5 as an extra independent perspective. +- Never Haiku for work that ships (thin wrapper/relay agents are fine). +- gpt-5.5 is only reachable through the Codex CLI. Claude models run via the `Agent`/`Workflow` `model` parameter. + +## Task routing + +| Task | Route | +|---|---| +| Design decisions, naming, interface shape | Stay here — decide first, then hand the decision down | +| Spec'd mechanical implementation, boilerplate, big renames | Codex | +| Test writing against a fixed contract | Codex | +| Second-opinion code review | Codex, read-only | +| Ambiguous / underspecified work | Stay here until spec'd, THEN offload | +| Cross-file refactor requiring judgment calls | Stay here, or opus/fable subagent | +| Long-running bounded sweep while this session reviews | Codex, parallel drivers with `isolation: "worktree"` | + +Rule of thumb: **taste stays here, precision ships out.** + +## Readiness gate (applies to every route) + +Do not dispatch until all four hold. If any fails, the task is not ready to offload — finish specifying it first. + +1. The prompt is **self-contained**: the worker has none of this conversation's context. +2. There is a **verification command** the worker can run itself, with the expected observable output stated. +3. **Negative constraints are explicit** — "do NOT create new files", "do NOT commit", "do NOT touch `src/x/`", size limits. Workers obey these reliably when spelled out, and not otherwise. +4. **Checkpoints are named** — the points at which the worker must stop and report instead of pressing on (see `gt:handoff-to-codex` § Checkpoint contract). + +## Driver-model choice (when the route is Codex) + +The driver is the Claude subagent that owns the Codex session (`genesis-tools:agent-driver`). + +- **sonnet** — default. The driver relays, watches, and approves inside declared bounds. +- **opus** — when there is no committed plan, when architecture or interface shape is at stake, or when approvals will require real judgment about scope. + +## Never trust the self-report + +Whatever the worker says it did, re-run the verification command yourself and read the diff before integrating. diff --git a/plugins/genesis-tools/skills/handoff/SKILL.md b/plugins/genesis-tools/skills/handoff/SKILL.md deleted file mode 100644 index babaf267dd..0000000000 --- a/plugins/genesis-tools/skills/handoff/SKILL.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -name: gt:handoff -description: Create and maintain a compaction-proof progress file (.claude/plans/.handoff.md) next to an implementation plan, so any executor - fresh session, post-compact resume, or parallel subagent - knows exactly where the work stands and which plan lines to read next. Invoke when starting to execute a plan, when resuming one, when handing work to another agent, or when the user says "create a handoff" / "prepare the handoff". ---- - -# handoff — compaction-proof execution state - -The handoff file is the executor's external memory. Context gets compacted, sessions die, agents run in parallel — the handoff file survives all of it. **THE FILE IS THE TRUTH**: if your memory of progress disagrees with the file, the file wins. - -It is also **self-describing**: its own `## PROTOCOL` section teaches any reader the rules, so a fresh agent needs nothing but "Read `.handoff.md` and follow it." - -## Where it lives - -Next to the plan: `.claude/plans/.md` → `.claude/plans/.handoff.md`. -If there is no plan file, the handoff still works — its TASKS section carries the step list itself. - -## The three iron rules - -1. **Read the handoff FIRST** — at session start, after every compaction, before every task. It is small by design (~1–2k tokens) and replaces re-reading the whole plan. -2. **Update it IMMEDIATELY after every task** — never "at the end". An update you postponed dies with the next compaction. -3. **STATE/TASKS are rewritten in place; LOG is append-only.** Never rewrite or delete LOG entries — they are the audit trail. - -## Creating a handoff (start of execution, or when handing off) - -Step 1 — build the plan TOC with real line numbers: - -```bash -rg -n '^#{1,3} ' .claude/plans/.md -wc -l .claude/plans/.md -``` - -Step 2 — Write `.claude/plans/.handoff.md` from this exact skeleton, filling every `<...>` (task list and line ranges come from the TOC you just built; mark independent tasks with the same `[P:n]` group when the plan's DON'T-TOUCH/interface-freeze shows they share no files): - -````markdown -# Handoff: - -## PROTOCOL — read this first, every time -You are executing a plan. This file is your memory; the plan file is your instructions. -1. Read this whole file (it is small). Trust it over anything you remember. -2. Read ONLY the plan's preamble (lines 1-) — goal, covenant, interface freeze. -3. Find **YOU ARE HERE** below. Read ONLY that task's line range from the plan: - `sed -n ',p' .claude/plans/.md` (or Read with offset/limit). -4. Execute the task exactly as the plan says. Do not improvise; deviations go in the - plan's `## Deviations` AND one LOG line here. -5. IMMEDIATELY update this file: flip the task checkbox, move YOU ARE HERE, append one - LOG line. Then go to 3. -6. After any compaction or restart: start again at 1. Never re-read the whole plan. -Rules: STATE/TASKS sections are rewritten in place. LOG is append-only, newest at the -bottom. If a check fails after its ON-FAIL fallback: STOP, log it, report to the user. - -## STATE -- **Plan:** .claude/plans/.md ( lines; preamble = lines 1-) -- **Goal:** -- **Branch/worktree:** @ -- **YOU ARE HERE:** Task — (plan lines -) — | blocked: > -- **Verify:** - -## TASKS -- [ ] Task 1 — (lines -) -- [ ] Task 2 — (lines -) [P:1] -- [ ] Task 3 — (lines -) [P:1] - -## PLAN TOC -- Preamble (goal, covenant, interface freeze, conventions): lines 1- -- Task 1 — : lines - -- ... -- Dry-run trace: lines - -- Deviations: lines <>- - -## LOG -- — handoff created; plan has tasks, none started. -```` - -Step 3 — verify: `wc -l` the handoff (should be well under ~120 lines) and confirm every TASKS line range matches the TOC. - -Step 4 — the handoff prompt for another agent is exactly one sentence: -> Read `.claude/plans/.handoff.md` and follow its PROTOCOL section. Re-read that file after every compaction. - -## Maintaining it (executor duties, after EVERY task) - -1. Flip the checkbox in TASKS. -2. Move **YOU ARE HERE** to the next task (with its line range) and update **Verify**. -3. Append ONE LOG line with a real timestamp (`date '+%F %H:%M'`), stating the observable result, not intentions: - `- 2026-07-08 21:40 — Task 2 done: bun test 14/14 green, committed abc1234. Next: Task 3.` -4. If anything deviated: one LOG line here + the entry in the plan's `## Deviations`. -5. Blocked? Set YOU ARE HERE to `blocked: `, log it, STOP and report — do not skip ahead. - -Keep LOG lines terse. Never trim or rewrite old lines; the file staying append-only is worth more than it staying pretty. - -## Resuming (fresh session / post-compact) - -Read the handoff → read plan preamble lines → read the YOU-ARE-HERE task's line range → work. That's ~2–4k tokens to be fully oriented, no matter how large the plan is. Re-reading the entire plan after a compaction is a protocol violation, not diligence. - -## Parallelizing with subagents - -- Only tasks sharing a `[P:n]` group may run concurrently; anything unmarked is sequential. -- **One writer rule:** subagents NEVER edit the handoff. The orchestrator spawns each subagent with: "Execute ONLY Task of `.claude/plans/.md`, lines -. Read the plan preamble (lines 1-) first. Report the verify output; do not touch other files." The orchestrator updates TASKS/LOG as each returns. -- If two [P] tasks would touch the same file, the [P] marking is wrong — fix the handoff, run them sequentially. - -## Relationship to other conventions - -- Pairs with `plan-it` (gt:plan-it): plan-it plans have greppable `## Task N:` headings, per-task VERIFY, and a `## Deviations` section — a handoff maps onto them 1:1. Works with any plan that has task headings, though. -- The executor should load the `fable-style` skill if available; the handoff governs *where you are*, fable-style governs *how you work*. -- `*.handoff.md` files are chronological/append-only by repo convention — this skill's STATE/TASKS rewrite-in-place blocks are the explicitly declared exception; LOG keeps the append-only audit trail. diff --git a/plugins/genesis-tools/skills/wrap-up/SKILL.md b/plugins/genesis-tools/skills/wrap-up/SKILL.md new file mode 100644 index 0000000000..019db37944 --- /dev/null +++ b/plugins/genesis-tools/skills/wrap-up/SKILL.md @@ -0,0 +1,313 @@ +--- +name: wrap-up +description: "Write the state doc that lets a fresh agent resume cold — a session wrap-up in Obsidian, or a plan-execution handoff in the repo. Triggers on 'wrap up', 'close out this session', 'summarize and hand off', 'document what we did', 'save/create the handoff', and on starting or resuming execution of a plan. Use it proactively at the end of any substantial multi-commit or PR-merged session, even unasked." +--- + +# wrap-up — durable resume state + +Context gets compacted, sessions die, work spans days. This skill writes the file a fresh agent reads to know exactly where things stand, without re-deriving it from git and scrollback. + +Two modes, two artifacts. Pick by what is being resumed: + +| Mode | Artifact | Lives in | For | +|---|---|---|---| +| **SESSION** (default) | `-.wrapup.md` | Obsidian vault | end of a work session; cross-day, cross-project narrative + forensics | +| **PLAN** | `.claude/plans/.handoff.md` | the repo | mid-execution of a specific plan; which task you are on, what verifies it | + +SESSION mode is Parts 1–2 below; PLAN mode is the last section. + +They compose: a long plan execution uses PLAN mode continuously and SESSION mode once at the end. + +## SESSION mode + +The wrap-up doc is the bridge between sessions. It lives in the user's Obsidian vault (durable, cross-project, outside any one repo), and it is **append-only for history** with **one rewrite-in-place header** for current state. + +Two jobs: figure out *where* the doc lives (target resolution), then *write* it (header + log + forensics). + +## Part 1 — Resolve the target directory + +Work through these tiers in order. Stop at the first that yields a directory. + +### Tier 1 — the Obsidian dir already involved this session +If this session already read from or wrote to a specific Obsidian vault directory (a plan, a handoff, notes for this project), that is the target. You know this from your own session history — no lookup needed. Prefer it over the registry: it reflects where the work actually lived today. + +### Tier 2 — the registry +Otherwise consult `~/.claude/handoff-registry.json`, which maps projects/branches/worktrees to their Obsidian home. Run: + +```bash +bun "$CLAUDE_PLUGIN_ROOT/skills/wrap-up/scripts/resolve.ts" resolve +``` + +It reads your current git toplevel + branch + cwd, matches the most specific entry, and prints `{ found, obsidianDir, docPath, ... }`. If `found:true`, use that `docPath`. The registry shape: + +```json +{ + "entries": [ + { + "projectDir": "/path/to/ProjectRepo", + "branch": "feat/handoff-fixes", + "worktreeDir": "/path/to/ProjectRepo/.claude/worktrees/handoff-part2", + "obsidianDir": "/path/to/Vault/ProjectRepo/Plans" + } + ] +} +``` + +`branch` and `worktreeDir` are optional — omit `branch` for a project-wide entry that matches any branch. `docPath` is optional; when absent the resolver derives `/-.wrapup.md` so each branch accumulates its own file. + +### Tier 2.5 — shared plugin config (automatic) +`resolve` falls back to `~/.genesis-tools/plugins/config.json` by itself when the registry has no match — the result then carries `"source": "config"` and you just use its `docPath`, no user interaction needed. The file is a per-skill map shared by all genesis-tools plugin skills: + +```json +{ + "wrap-up": { + "vaultDir": "~/Vault", + "docDir": ".claude/wrapups", + "registryPath": "~/.claude/handoff-registry.json" + } +} +``` + +All keys optional. `docDir` (absolute, `~/`, or relative to the project toplevel) wins over `vaultDir`, which resolves to `//`. `registryPath` relocates the registry itself. When proposing a target in Tier 3, also suggest adding a `vaultDir` here once — it makes every future project resolve automatically. + +### Tier 3 — infer, propose, register +If neither registry nor config yields a target (`found:false`), don't guess silently and don't dump the doc in a random place. Instead: + +1. **Infer the user's structure.** Look at how the vault is organized for similar work — e.g. `ls` the vault root and any `/` folder, check whether handoffs/plans live under `//Plans/`, `/Handoffs/`, or similar. One or two `ls`/`Glob` calls, not a full crawl. +2. **Propose a target** to the user via `AskUserQuestion` — offer the inferred path as the recommended option plus one or two alternatives, so they confirm or redirect in one click. +3. **Persist the choice** so it's never asked again for this project/branch: + ```bash + bun "$CLAUDE_PLUGIN_ROOT/skills/wrap-up/scripts/resolve.ts" register --obsidian "" --branch "" --worktree "" + ``` +4. Then write the doc there. + +## Part 2 — Write the doc + +**EVERY wrap-up invocation does BOTH, always — never one without the other:** +1. **Rewrite the `YOU-ARE-HERE` header in place** (current state), AND +2. **Append a new `## — ` log section at the bottom** (what this session did, with commit SHAs). + +This is not optional and not skippable — not even for a tiny one-commit session, not even if "the header already says it." The header is *volatile* (it gets overwritten next time, so its content is lost); the **log is the only permanent record**. A wrap-up that updates the header but appends no log section silently erases this session from history the moment the next wrap-up runs. If you truly did nothing worth a paragraph, you still append a one-line dated log entry. Header-only = incomplete wrap-up. + +Locate the file at `docPath`. If it doesn't exist, create it from the template below (header + first log section). If it exists, **update the header in place** and **append a new log section** — never rewrite existing log sections; they are the audit trail. + +### The template (new file) + +```markdown +# Wrap-up: — + + +## You are here () +- **Branch / worktree:** @ +- **State:** +- **Next:** +- **Verify:** +- **Read to resume:** + + +--- + +## — (commits , , …) + +### Goal & context + + +### What happened (chronological) + + +### Files touched + + +### Commits + — `. Every git-touching entry above must map to a SHA here.> + +### Decisions & rationale + + +### Bugs, surprises, gotchas + + +### Verification + + +### Open / deferred / blocked + +``` + +The subsections above are the floor, not the ceiling — a substantive session's log runs long by design. Drop a subsection only when it genuinely has nothing (e.g. no bugs hit → omit "Bugs"); never collapse the whole thing to a terse paragraph. + +### Updating an existing file — one call does everything + +The whole update — rewrite the `YOU-ARE-HERE` block in place, append the new log section, auto-stamp the datetime, and auto-generate the `### Header before → after` snapshot from the outgoing header — is a single `log` invocation. You never hand-transcribe the old header (the script reads it), never compute the datetime (the script stamps it), never do a separate in-place Edit. + +Pipe ONE heredoc split by two sentinel lines: `@@HERE@@` introduces the new state bullets (the script wraps them in `## You are here ()` between the markers), then `@@LOG@@` introduces the log-section body you author. Quote the delimiter (`<<'WRAPUP'`) so backticks and `$` in your prose aren't shell-expanded: + +```bash +bun "$CLAUDE_PLUGIN_ROOT/skills/wrap-up/scripts/resolve.ts" log "" <<'WRAPUP' +@@HERE@@ +- **Branch / worktree:** @ +- **State:** +- **Next:** +- **Verify:** +- **Read to resume:** +@@LOG@@ +## — (commits , …) + +### Goal & context +... +### What happened (chronological) +... +### Files touched +... +### Decisions & rationale +... +WRAPUP +``` + +What the script does, atomically: +- Extracts the current `YOU-ARE-HERE` block, replaces it in place with your new `@@HERE@@` bullets under a freshly-stamped `## You are here ()` title. +- Appends your `@@LOG@@` body as a new section at the bottom, then auto-appends a `### Header before → after` block quoting the outgoing header (verbatim) and the new one (verbatim) — so the volatile resume-pointer's wording survives permanently. **You do not write the before→after block yourself.** +- Prints `{ logged, stamp }`. Use `stamp` (or `date '+%Y-%m-%d %H:%M'`) for the `## ` line in your `@@LOG@@` body. + +It refuses (non-zero exit) if the file doesn't exist (create it from the template with Write first), if a sentinel is missing, or if there's no `YOU-ARE-HERE` block — so a failed call never half-writes. + +Append-only still holds: `log` only rewrites the header region and adds at the bottom; it never touches earlier log sections. To amend an in-place list/table in an old section, edit it directly and prepend `` inside it. + +### What each log section carries (forensics) + +**Err long. The log is the permanent record — a fresh agent must be able to reconstruct the entire session from it without re-running anything, re-reading scrollback, or re-deriving state from git.** When in doubt, include it. Terse wrap-ups are the failure mode; length here is a feature, not bloat. Every substantive session fills all of these: + +- **Goal & context** — the ask, the starting state, the problem being solved. Frame first. +- **Chronological narrative** — the build-log of what actually happened, step by step, *in order*, including dead-ends, failed attempts, and course-corrections. The path is as valuable as the result. This is the meat and it is meant to be long. +- **Files touched** — every path changed/created/deleted, each with WHAT changed and WHY. Not a bare file list. +- **Commit SHA(s)** the section produced or refers to. A git-touching section without its SHA is incomplete. +- **Decisions & rationale** — every non-obvious choice, the alternatives considered and rejected, the tradeoffs accepted. This is precisely what the diff cannot show. +- **Bugs, surprises, gotchas** — errors hit and how resolved, environment quirks, traps the next agent must avoid. +- **Verification** — commands run + observed output (test counts, exit codes). State skipped/failed checks honestly. +- **Open / deferred / blocked** — what's unfinished, what was punted and why, what's blocked, and the concrete next steps. + +## Reading a wrap-up back (cheap resume) + +To orient without reading the whole file, pull just the current-state header: + +```bash +bun "$CLAUDE_PLUGIN_ROOT/skills/wrap-up/scripts/resolve.ts" here +# or with sed: +sed -n '/YOU-ARE-HERE:START/,/YOU-ARE-HERE:END/p' +``` + +The header's own **Read to resume** line tells you how much of the log below to read — usually the header plus the last section or two, not the whole history. + +## Rules + +- **Header rewrites in place; log is append-only — and you ALWAYS do both.** Every invocation: rewrite the `YOU-ARE-HERE` block (the single mutable region) AND append one new dated `##` log section. Never header-only. Log sections are permanent audit trail — never delete or rewrite them. +- **Every log section header carries the full datetime** (`## YYYY-MM-DD HH:MM — topic`), never date-only. Get it from `date '+%Y-%m-%d %H:%M'`. +- **Every git-touching section attaches its commit SHA(s)** inline. +- **Resolve before writing.** Don't dump wrap-ups in an arbitrary directory — walk the three tiers, and register the target once so it's automatic next time. +- **Keep it honest.** Record what actually happened — failed steps, skipped checks, open bugs. A wrap-up that only lists wins misleads the next session. +- **Err long, not terse.** The log section is the permanent audit trail — write it exhaustively (goal, full chronological narrative including dead-ends, files+why, decisions+rejected alternatives, bugs, verification output, open items). A substantive session's log runs long by design; a one-paragraph summary of a multi-commit session is a defect, not brevity. +- **Always end your response with the full absolute path to the wrap-up file as the last line** — after writing or appending, the final line of your reply must be the complete `docPath` (e.g. `/path/to/Vault/ProjectRepo/Plans/....wrapup.md`), so the user can open it in one click. Nothing after it. + +--- + +## PLAN mode — compaction-proof execution state in the repo + +Everything above is SESSION mode. PLAN mode is a different artifact with different rules: a small file next to an implementation plan, rewritten constantly during execution, that answers "which task am I on". + +The handoff file is the executor's external memory. **THE FILE IS THE TRUTH**: if your memory of progress disagrees with the file, the file wins. It is also **self-describing** — its own `## PROTOCOL` section teaches any reader the rules, so a fresh agent needs nothing but "Read `.handoff.md` and follow it." + +### Where it lives + +Next to the plan: `.claude/plans/.md` → `.claude/plans/.handoff.md`. If there is no plan file, the handoff still works — its TASKS section carries the step list itself. + +### The three iron rules + +1. **Read the handoff FIRST** — at session start, after every compaction, before every task. It is small by design (~1–2k tokens) and replaces re-reading the whole plan. +2. **Update it IMMEDIATELY after every task** — never "at the end". An update you postponed dies with the next compaction. +3. **STATE/TASKS are rewritten in place; LOG is append-only.** Never rewrite or delete LOG entries — they are the audit trail. + +### Creating one + +Step 1 — build the plan TOC with real line numbers: + +```bash +rg -n '^#{1,3} ' .claude/plans/.md +wc -l .claude/plans/.md +``` + +Step 2 — Write `.claude/plans/.handoff.md` from this exact skeleton, filling every `<...>` (task list and line ranges come from the TOC you just built; mark independent tasks with the same `[P:n]` group when the plan's DON'T-TOUCH/interface-freeze shows they share no files): + +````markdown +# Handoff: + +## PROTOCOL — read this first, every time +You are executing a plan. This file is your memory; the plan file is your instructions. +1. Read this whole file (it is small). Trust it over anything you remember. +2. Read ONLY the plan's preamble (lines 1-) — goal, covenant, interface freeze. +3. Find **YOU ARE HERE** below. Read ONLY that task's line range from the plan: + `sed -n ',p' .claude/plans/.md` (or Read with offset/limit). +4. Execute the task exactly as the plan says. Do not improvise; deviations go in the + plan's `## Deviations` AND one LOG line here. +5. IMMEDIATELY update this file: flip the task checkbox, move YOU ARE HERE, append one + LOG line. Then go to 3. +6. After any compaction or restart: start again at 1. Never re-read the whole plan. +Rules: STATE/TASKS sections are rewritten in place. LOG is append-only, newest at the +bottom. If a check fails after its ON-FAIL fallback: STOP, log it, report to the user. + +## STATE +- **Plan:** .claude/plans/.md ( lines; preamble = lines 1-) +- **Goal:** +- **Branch/worktree:** @ +- **YOU ARE HERE:** Task — (plan lines -) — | blocked: > +- **Verify:** + +## TASKS +- [ ] Task 1 — (lines -) +- [ ] Task 2 — (lines -) [P:1] +- [ ] Task 3 — (lines -) [P:1] + +## PLAN TOC +- Preamble (goal, covenant, interface freeze, conventions): lines 1- +- Task 1 — : lines - +- ... +- Dry-run trace: lines - +- Deviations: lines <>- + +## LOG +- — handoff created; plan has tasks, none started. +```` + +Step 3 — verify: `wc -l` the handoff (should be well under ~120 lines) and confirm every TASKS line range matches the TOC. + +Step 4 — the handoff prompt for another agent is exactly one sentence: + +> Read `.claude/plans/.handoff.md` and follow its PROTOCOL section. Re-read that file after every compaction. + +### Maintaining it (executor duties, after EVERY task) + +1. Flip the checkbox in TASKS. +2. Move **YOU ARE HERE** to the next task (with its line range) and update **Verify**. +3. Append ONE LOG line with a real timestamp (`date '+%F %H:%M'`), stating the observable result, not intentions: + `- 2026-07-08 21:40 — Task 2 done: bun test 14/14 green, committed abc1234. Next: Task 3.` +4. If anything deviated: one LOG line here + the entry in the plan's `## Deviations`. +5. Blocked? Set YOU ARE HERE to `blocked: `, log it, STOP and report — do not skip ahead. + +Keep LOG lines terse. Never trim or rewrite old lines; append-only is worth more than pretty. + +### Resuming (fresh session / post-compact) + +Read the handoff → read plan preamble lines → read the YOU-ARE-HERE task's line range → work. That is ~2–4k tokens to be fully oriented, no matter how large the plan is. Re-reading the entire plan after a compaction is a protocol violation, not diligence. + +### Parallelizing with subagents + +- Only tasks sharing a `[P:n]` group may run concurrently; anything unmarked is sequential. +- **One writer rule:** subagents NEVER edit the handoff. The orchestrator spawns each subagent with: "Execute ONLY Task of `.claude/plans/.md`, lines -. Read the plan preamble (lines 1-) first. Report the verify output; do not touch other files." The orchestrator updates TASKS/LOG as each returns. +- If two [P] tasks would touch the same file, the [P] marking is wrong — fix the handoff, run them sequentially. + +### Relationship to other conventions + +- Pairs with `gt:plan-it`: its plans have greppable `## Task N:` headings, per-task VERIFY, and a `## Deviations` section — a handoff maps onto them 1:1. Works with any plan that has task headings, though. +- The executor should load the `fable-style` skill if available; the handoff governs *where you are*, fable-style governs *how you work*. +- `*.handoff.md` files are chronological/append-only by repo convention — this mode's STATE/TASKS rewrite-in-place blocks are the explicitly declared exception; LOG keeps the append-only audit trail. +- Handing the work to a non-Claude worker instead of a subagent? That is `gt:handoff-to` (routing) and `gt:handoff-to-codex` (mechanics), not this skill. +- **Replaces the retired `gt:handoff`.** That skill is gone, not renamed: its wrap-up half is SESSION mode above, its plan-handoff half is PLAN mode. If you were reaching for `gt:handoff`, you want this skill — unless you meant offloading work to another model, which is `gt:handoff-to`. diff --git a/plugins/genesis-tools/skills/wrap-up/scripts/resolve.test.ts b/plugins/genesis-tools/skills/wrap-up/scripts/resolve.test.ts new file mode 100644 index 0000000000..eecc9deb7f --- /dev/null +++ b/plugins/genesis-tools/skills/wrap-up/scripts/resolve.test.ts @@ -0,0 +1,377 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { chmod, mkdir, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + blockquote, + buildLogBody, + derivedDocPath, + type Entry, + expandHome, + innerBlock, + matches, + parseFlags, + sh, + slug, + splitSentinels, + writeAtomic, +} from "./resolve.ts"; + +const HERE_START = ""; +const HERE_END = ""; + +function docWith(headerBody: string, tail = "\n\n---\n\n## 2026-07-01 10:00 — first session\n\nseed body.\n"): string { + return `# Wrap-up: Demo\n\n${HERE_START}\n## You are here (2026-07-01 10:00)\n${headerBody}\n${HERE_END}${tail}`; +} + +describe("slug", () => { + it("lowercases and dashes a branch name", () => { + expect(slug("feat/Skills-Handoff")).toBe("feat-skills-handoff"); + }); + + it("trims leading and trailing separators", () => { + expect(slug("///feat///")).toBe("feat"); + }); + + it("falls back to 'main' when nothing survives", () => { + expect(slug("")).toBe("main"); + expect(slug("///")).toBe("main"); + }); +}); + +describe("derivedDocPath", () => { + const entry: Entry = { projectDir: "/repos/MyProject", obsidianDir: "/vault/MyProject" }; + + it("derives /-.wrapup.md", () => { + expect(derivedDocPath(entry, "feat/x")).toBe("/vault/MyProject/MyProject-feat-x.wrapup.md"); + }); + + it("gives each branch its own file", () => { + expect(derivedDocPath(entry, "feat/a")).not.toBe(derivedDocPath(entry, "feat/b")); + }); + + it("prefers an explicit docPath over the derived one", () => { + const pinned: Entry = { ...entry, docPath: "/vault/pinned.md" }; + expect(derivedDocPath(pinned, "feat/x")).toBe("/vault/pinned.md"); + }); +}); + +describe("matches", () => { + const ctx = { toplevel: "/repos/Proj", branch: "feat/x", cwd: "/repos/Proj" }; + + it("returns 0 when the path does not match at all", () => { + expect(matches({ projectDir: "/repos/Other", obsidianDir: "/v" }, ctx)).toBe(0); + }); + + it("matches any branch when the entry declares none", () => { + expect(matches({ projectDir: "/repos/Proj", obsidianDir: "/v" }, ctx)).toBe(1); + }); + + it("returns 0 when the entry pins a different branch", () => { + expect(matches({ projectDir: "/repos/Proj", obsidianDir: "/v", branch: "feat/other" }, ctx)).toBe(0); + }); + + it("scores a branch-pinned entry above a project-wide one", () => { + const wide = matches({ projectDir: "/repos/Proj", obsidianDir: "/v" }, ctx); + const pinned = matches({ projectDir: "/repos/Proj", obsidianDir: "/v", branch: "feat/x" }, ctx); + expect(pinned).toBeGreaterThan(wide); + }); + + it("scores a worktree match highest", () => { + const wt = { toplevel: "/repos/Proj/.worktrees/x", branch: "feat/x", cwd: "/repos/Proj/.worktrees/x" }; + const pinned = matches({ projectDir: "/repos/Proj", obsidianDir: "/v", branch: "feat/x" }, wt); + const worktree = matches( + { + projectDir: "/repos/Proj", + obsidianDir: "/v", + branch: "feat/x", + worktreeDir: "/repos/Proj/.worktrees/x", + }, + wt + ); + expect(worktree).toBeGreaterThan(pinned); + }); + + it("matches a cwd nested under the project dir", () => { + const nested = { toplevel: "/repos/Proj", branch: "feat/x", cwd: "/repos/Proj/src/deep" }; + expect(matches({ projectDir: "/repos/Proj", obsidianDir: "/v" }, nested)).toBe(1); + }); + + it("does not match a sibling dir sharing a name prefix", () => { + const sibling = { toplevel: "/repos/ProjOther", branch: "feat/x", cwd: "/repos/ProjOther" }; + expect(matches({ projectDir: "/repos/Proj", obsidianDir: "/v" }, sibling)).toBe(0); + }); +}); + +describe("splitSentinels", () => { + it("splits a well-formed payload", () => { + const res = splitSentinels("@@HERE@@\n- state\n@@LOG@@\n## topic\nbody"); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.hereBody).toBe("- state"); + expect(res.logBody).toBe("## topic\nbody"); + } + }); + + it("rejects empty stdin", () => { + const res = splitSentinels(" \n "); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.problem).toContain("nothing on stdin"); + } + }); + + it("names both missing sentinels", () => { + const res = splitSentinels("just some prose"); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.problem).toContain("@@HERE@@"); + expect(res.problem).toContain("@@LOG@@"); + } + }); + + it("names the single missing sentinel", () => { + const res = splitSentinels("@@HERE@@\n- state"); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.problem).toContain("@@LOG@@"); + expect(res.problem).not.toContain("@@HERE@@ and"); + } + }); + + it("rejects reversed sentinel order", () => { + const res = splitSentinels("@@LOG@@\nbody\n@@HERE@@\n- state"); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.problem).toContain("order must be"); + } + }); + + it("rejects an empty @@HERE@@ section", () => { + const res = splitSentinels("@@HERE@@\n@@LOG@@\n## topic"); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.problem).toContain("@@HERE@@ section is empty"); + } + }); + + it("rejects an empty @@LOG@@ section", () => { + const res = splitSentinels("@@HERE@@\n- state\n@@LOG@@\n"); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.problem).toContain("@@LOG@@ section is empty"); + } + }); +}); + +describe("buildLogBody", () => { + const args = { hereBody: "- **State:** new", logBody: "## 2026-07-29 06:47 — second", stamp: "2026-07-29 06:47" }; + + it("rewrites the header in place with the new stamp", () => { + const res = buildLogBody({ text: docWith("- **State:** old"), ...args }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.body).toContain("## You are here (2026-07-29 06:47)"); + expect(res.body).toContain("- **State:** new"); + // The old stamp survives only as a quoted line in the snapshot, + // never as a live header. + expect(res.body).not.toContain("\n## You are here (2026-07-01 10:00)"); + expect(res.body).toContain("\n> ## You are here (2026-07-01 10:00)"); + } + }); + + it("keeps exactly one YOU-ARE-HERE block", () => { + const res = buildLogBody({ text: docWith("- **State:** old"), ...args }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.body.split(HERE_START).length - 1).toBe(1); + expect(res.body.split(HERE_END).length - 1).toBe(1); + } + }); + + it("preserves earlier log sections (append-only audit trail)", () => { + const res = buildLogBody({ text: docWith("- **State:** old"), ...args }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.body).toContain("## 2026-07-01 10:00 — first session"); + expect(res.body).toContain("seed body."); + expect(res.body.indexOf("first session")).toBeLessThan(res.body.indexOf("second")); + } + }); + + it("appends the before/after snapshot quoting the old and new state", () => { + const res = buildLogBody({ text: docWith("- **State:** old"), ...args }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.body).toContain("### Header before → after"); + expect(res.body).toContain("> - **State:** old"); + expect(res.body).toContain("> - **State:** new"); + } + }); + + it("is idempotent in shape — logging twice keeps one header and both sections", () => { + const first = buildLogBody({ text: docWith("- **State:** old"), ...args }); + expect(first.ok).toBe(true); + if (!first.ok) { + return; + } + + const second = buildLogBody({ + text: first.body, + hereBody: "- **State:** third", + logBody: "## 2026-07-29 07:00 — third", + stamp: "2026-07-29 07:00", + }); + expect(second.ok).toBe(true); + if (second.ok) { + expect(second.body.split(HERE_START).length - 1).toBe(1); + expect(second.body).toContain("## 2026-07-01 10:00 — first session"); + expect(second.body).toContain("— second"); + expect(second.body).toContain("— third"); + } + }); + + it("reports a file with no YOU-ARE-HERE block", () => { + const res = buildLogBody({ text: "# Just a heading\n\nno block here.\n", ...args }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.problem).toContain("no valid"); + } + }); + + it("reports a block whose markers are inverted", () => { + const res = buildLogBody({ text: `# Doc\n${HERE_END}\nbody\n${HERE_START}\n`, ...args }); + expect(res.ok).toBe(false); + }); +}); + +describe("blockquote / innerBlock", () => { + it("quotes every line and keeps blank lines as bare '>'", () => { + expect(blockquote("a\n\nb")).toBe("> a\n>\n> b"); + }); + + it("strips the sentinel markers", () => { + expect(innerBlock(`${HERE_START}\n## You are here\n- x\n${HERE_END}`)).toBe("## You are here\n- x"); + }); +}); + +describe("parseFlags", () => { + it("pairs each --flag with its value", () => { + expect(parseFlags(["--obsidian", "/vault", "--branch", "feat/x"])).toEqual({ + obsidian: "/vault", + branch: "feat/x", + }); + }); + + it("gives a trailing valueless flag an empty string", () => { + expect(parseFlags(["--doc"])).toEqual({ doc: "" }); + }); + + it("ignores positional noise", () => { + expect(parseFlags(["stray", "--branch", "feat/x"])).toEqual({ branch: "feat/x" }); + }); +}); + +describe("writeAtomic", () => { + const fixtures: string[] = []; + + async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), "wrapup-atomic-")); + fixtures.push(dir); + return dir; + } + + afterEach(async () => { + await Promise.all(fixtures.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + it("replaces the destination's contents", async () => { + const dir = await tempDir(); + const target = join(dir, "doc.md"); + await writeFile(target, "old contents"); + + await writeAtomic(target, "new contents"); + expect(await Bun.file(target).text()).toBe("new contents"); + }); + + it("creates the file when it does not exist yet", async () => { + const dir = await tempDir(); + const target = join(dir, "fresh.md"); + + await writeAtomic(target, "hello"); + expect(await Bun.file(target).text()).toBe("hello"); + }); + + it("preserves a restrictive destination mode instead of widening it", async () => { + const dir = await tempDir(); + const target = join(dir, "private.json"); + await writeFile(target, "{}"); + await chmod(target, 0o600); + + await writeAtomic(target, '{"entries":[]}'); + // rename() installs a new inode; without carrying the mode over this + // would come back as the umask default (commonly 0644). + expect((await stat(target)).mode & 0o777).toBe(0o600); + }); + + it("leaves no temp file behind", async () => { + const dir = await tempDir(); + const target = join(dir, "doc.md"); + + await writeAtomic(target, "body"); + expect((await readdir(dir)).filter((f) => f.includes(".tmp-"))).toEqual([]); + }); + + it("cleans up the temp file when the rename fails", async () => { + const dir = await tempDir(); + // rename() onto a NON-EMPTY directory fails, which is what drives the + // cleanup path after the temp file has already been written. Create the + // directory explicitly rather than leaning on Bun.write's implicit + // parent creation, so the setup states its own intent. + const target = join(dir, "adir"); + await mkdir(target); + await writeFile(join(target, "keep.txt"), "x"); + + await expect(writeAtomic(target, "body")).rejects.toThrow(); + expect((await readdir(dir)).filter((f) => f.includes(".tmp-"))).toEqual([]); + // The destination is untouched, which pins that the rejection came from + // the rename onto the directory and not from something incidental. + expect(await readdir(target)).toEqual(["keep.txt"]); + }); +}); + +describe("sh", () => { + it("returns trimmed stdout on success", async () => { + expect(await sh(["echo", " hello "])).toBe("hello"); + }); + + it("returns empty string when the command exits non-zero", async () => { + expect(await sh(["false"])).toBe(""); + }); + + it("discards stdout when the command exits non-zero", async () => { + // `false` prints nothing, so it cannot tell whether stdout is being + // dropped or was simply empty. A failing command that DOES print is + // the case that matters: git can write to stdout and still fail, and + // passing that through would be read as a real toplevel or branch. + expect(await sh(["sh", "-c", "echo not-a-real-branch; exit 3"])).toBe(""); + }); + + it("returns empty string instead of throwing when the binary is missing", async () => { + // Bun.spawn throws on ENOENT; gitContext()'s documented no-git fallback + // depends on this degrading to "" rather than crashing the command. + expect(await sh(["wrap-up-no-such-binary-xyz"])).toBe(""); + }); +}); + +describe("expandHome", () => { + it("leaves absolute and relative paths untouched", () => { + expect(expandHome("/abs/path")).toBe("/abs/path"); + expect(expandHome(".claude/wrapups")).toBe(".claude/wrapups"); + }); + + it("expands a leading ~/", () => { + expect(expandHome("~/vault")).not.toContain("~"); + expect(expandHome("~/vault").endsWith("/vault")).toBe(true); + }); +}); diff --git a/plugins/genesis-tools/skills/wrap-up/scripts/resolve.ts b/plugins/genesis-tools/skills/wrap-up/scripts/resolve.ts new file mode 100644 index 0000000000..09a6b5c72e --- /dev/null +++ b/plugins/genesis-tools/skills/wrap-up/scripts/resolve.ts @@ -0,0 +1,529 @@ +#!/usr/bin/env bun + +/** + * wrap-up target resolver. + * + * Owns the deterministic half of "where does the wrap-up doc live?": + * - resolve : match the current project/branch/worktree against the registry, + * print the obsidian dir + the derived doc path (or found:false). + * - register: append/update a registry entry after the user confirms a target. + * - here : print ONLY the YOU-ARE-HERE block of a wrap-up file (cheap read). + * - log : atomically append a log section AND rewrite the YOU-ARE-HERE block, + * auto-stamping the datetime and auto-generating the before→after + * snapshot from the outgoing header. stdin carries two parts split by + * sentinel lines: @@HERE@@ (new state bullets) then @@LOG@@ (log body). + * + * The registry lives at ~/.claude/handoff-registry.json: + * { "entries": [ { projectDir, branch?, worktreeDir?, obsidianDir, docPath? }, ... ] } + * A missing/empty `branch` means the entry matches any branch in that project. + * + * Shared plugin config (optional) lives at ~/.genesis-tools/plugins/config.json: + * { "wrap-up": { "registryPath"?, "vaultDir"?, "docDir"? } } + * - registryPath: overrides the registry location. + * - docDir: fallback doc directory when the registry has no match — absolute, + * or relative to the project toplevel (e.g. ".claude/wrapups"). + * - vaultDir: vault root; fallback target becomes /. + * Resolution order: registry match > docDir > vaultDir > found:false. + */ + +import { chmod, rename, rm, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, isAbsolute, join } from "node:path"; + +const PLUGIN_CONFIG = join(homedir(), ".genesis-tools", "plugins", "config.json"); +const HERE_START = ""; +const HERE_END = ""; + +export interface Entry { + projectDir: string; + branch?: string; + worktreeDir?: string; + obsidianDir: string; + docPath?: string; +} +interface Registry { + entries: Entry[]; +} + +interface WrapUpConfig { + registryPath?: string; + vaultDir?: string; + docDir?: string; +} + +// One resolve invocation reads this on both the registry-path lookup and the +// docDir fallback; caching keeps it to a single read and stops a corrupt config +// from printing the same warning twice. +let pluginConfigCache: WrapUpConfig | undefined; + +async function loadPluginConfig(): Promise { + if (pluginConfigCache) { + return pluginConfigCache; + } + + const cfg = await readPluginConfig(); + pluginConfigCache = cfg; + return cfg; +} + +async function readPluginConfig(): Promise { + const f = Bun.file(PLUGIN_CONFIG); + if (!(await f.exists())) { + return {}; + } + + try { + // biome-ignore lint/style/noRestrictedGlobals: standalone script without access to SafeJSON + const parsed = JSON.parse(await f.text()); + return typeof parsed?.["wrap-up"] === "object" && parsed["wrap-up"] !== null ? parsed["wrap-up"] : {}; + } catch (err) { + // Falling back to {} silently would make a corrupt config look like an + // absent one and quietly demote the wrap-up to a different target tier. + console.error(`wrap-up: ignoring unreadable plugin config ${PLUGIN_CONFIG}: ${String(err)}`); + return {}; + } +} + +export function expandHome(p: string): string { + return p.startsWith("~/") ? join(homedir(), p.slice(2)) : p; +} + +async function registryPath(): Promise { + const cfg = await loadPluginConfig(); + return expandHome(cfg.registryPath ?? join(homedir(), ".claude", "handoff-registry.json")); +} + +export async function sh(cmd: string[]): Promise { + try { + const p = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" }); + const [out, err] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]); + const code = await p.exited; + if (code !== 0) { + // Callers deliberately fall back (cwd / empty branch) so this stays + // non-fatal, but a swallowed failure is indistinguishable from a + // legitimately empty result — say which one happened. + console.error(`wrap-up: \`${cmd.join(" ")}\` exited ${code}${err.trim() ? `: ${err.trim()}` : ""}`); + // Discard whatever landed on stdout: a failed `git rev-parse` can + // still print, and passing that through would be taken for a real + // toplevel or branch name. + return ""; + } + + return out.trim(); + } catch (err) { + // Bun.spawn throws outright when the binary is missing from $PATH, which + // would crash the whole command instead of taking the documented + // no-git fallback. Degrade to "" like a non-zero exit does. + console.error(`wrap-up: \`${cmd.join(" ")}\` could not run: ${String(err)}`); + return ""; + } +} + +async function loadRegistry(): Promise { + const path = await registryPath(); + const f = Bun.file(path); + if (!(await f.exists())) { + return { entries: [] }; + } + + try { + // biome-ignore lint/style/noRestrictedGlobals: standalone script without access to SafeJSON + const parsed = JSON.parse(await f.text()); + return Array.isArray(parsed?.entries) ? parsed : { entries: [] }; + } catch (err) { + // A malformed registry must not masquerade as an empty one — that would + // silently drop every registered target and resolve to found:false. + console.error(`wrap-up: ignoring unreadable registry ${path}: ${String(err)}`); + return { entries: [] }; + } +} + +// Write via temp file + rename so an interrupted write can never leave a +// truncated file behind. Both targets are append-only records whose partial +// loss is unrecoverable: the registry holds every project's wrap-up target, +// and the wrap-up doc's log is the only permanent session history. +export async function writeAtomic(path: string, body: string): Promise { + const tmp = `${path}.tmp-${process.pid}-${Date.now()}`; + const existed = await Bun.file(path).exists(); + try { + await Bun.write(tmp, body); + if (existed) { + // rename() swaps in a brand-new inode created under the current + // umask, so a private 0600 registry or wrap-up doc would silently + // widen to 0644. Carry the destination's mode over to the temp file. + const { mode } = await stat(path); + await chmod(tmp, mode & 0o777); + } + + await rename(tmp, path); + } catch (err) { + // Never leave the half-written temp file next to the real one. + await rm(tmp, { force: true }); + throw err; + } +} + +async function saveRegistry(reg: Registry): Promise { + // biome-ignore lint/style/noRestrictedGlobals: standalone script without access to SafeJSON + await writeAtomic(await registryPath(), `${JSON.stringify(reg, null, 2)}\n`); +} + +export function slug(s: string): string { + return ( + s + .replace(/[^a-z0-9]+/gi, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase() || "main" + ); +} + +export function derivedDocPath(entry: Entry, branch: string): string { + if (entry.docPath) { + return entry.docPath; + } + + const project = basename(entry.projectDir); + return join(entry.obsidianDir, `${project}-${slug(branch)}.wrapup.md`); +} + +async function gitContext() { + const toplevel = await sh(["git", "rev-parse", "--show-toplevel"]); + const branch = await sh(["git", "rev-parse", "--abbrev-ref", "HEAD"]); + // In a worktree, common-dir differs from git-dir; the "main" checkout's + // toplevel is what a project-level entry keys on. + const cwd = process.cwd(); + return { toplevel: toplevel || cwd, branch: branch || "", cwd }; +} + +export function matches(entry: Entry, ctx: { toplevel: string; branch: string; cwd: string }): number { + // Higher score = more specific match. 0 = no match. + const paths = [entry.worktreeDir, entry.projectDir].filter(Boolean) as string[]; + const pathHit = paths.some((p) => ctx.toplevel === p || ctx.cwd === p || ctx.cwd.startsWith(`${p}/`)); + if (!pathHit) { + return 0; + } + + if (entry.branch && entry.branch !== ctx.branch) { + return 0; + } + + let score = 1; + if (entry.worktreeDir && (ctx.toplevel === entry.worktreeDir || ctx.cwd.startsWith(`${entry.worktreeDir}/`))) { + score += 2; + } + + if (entry.branch) { + score += 1; + } + + return score; +} + +async function cmdResolve() { + const ctx = await gitContext(); + const reg = await loadRegistry(); + const ranked = reg.entries + .map((e) => ({ e, score: matches(e, ctx) })) + .filter((x) => x.score > 0) + .sort((a, b) => b.score - a.score); + + if (ranked.length === 0) { + // Fallback tier: shared plugin config. docDir (absolute or project-relative) + // wins over vaultDir/; both land as a synthetic non-registered + // entry so the doc path derivation stays uniform. + const cfg = await loadPluginConfig(); + const docDir = cfg.docDir + ? isAbsolute(expandHome(cfg.docDir)) + ? expandHome(cfg.docDir) + : join(ctx.toplevel, cfg.docDir) + : cfg.vaultDir + ? join(expandHome(cfg.vaultDir), basename(ctx.toplevel)) + : null; + + if (docDir) { + const entry: Entry = { projectDir: ctx.toplevel, obsidianDir: docDir }; + console.log( + // biome-ignore lint/style/noRestrictedGlobals: standalone script without access to SafeJSON + JSON.stringify( + { + found: true, + source: "config", + obsidianDir: docDir, + docPath: derivedDocPath(entry, ctx.branch), + project: ctx.toplevel, + branch: ctx.branch, + worktree: null, + }, + null, + 2 + ) + ); + return; + } + + // biome-ignore lint/style/noRestrictedGlobals: standalone script without access to SafeJSON + console.log(JSON.stringify({ found: false, project: ctx.toplevel, branch: ctx.branch, cwd: ctx.cwd }, null, 2)); + return; + } + + const { e } = ranked[0]; + console.log( + // biome-ignore lint/style/noRestrictedGlobals: standalone script without access to SafeJSON + JSON.stringify( + { + found: true, + source: "registry", + obsidianDir: e.obsidianDir, + docPath: derivedDocPath(e, ctx.branch), + project: ctx.toplevel, + branch: ctx.branch, + worktree: e.worktreeDir ?? null, + }, + null, + 2 + ) + ); +} + +async function cmdRegister(args: Record) { + const ctx = await gitContext(); + const entry: Entry = { + projectDir: args.project ?? ctx.toplevel, + obsidianDir: args.obsidian, + ...(args.branch ? { branch: args.branch } : {}), + ...(args.worktree ? { worktreeDir: args.worktree } : {}), + ...(args.doc ? { docPath: args.doc } : {}), + }; + + if (!entry.obsidianDir) { + console.error("register: --obsidian is required"); + process.exit(1); + } + + const reg = await loadRegistry(); + // De-dupe on (projectDir, branch, worktreeDir). + reg.entries = reg.entries.filter( + (x) => + !( + x.projectDir === entry.projectDir && + (x.branch ?? "") === (entry.branch ?? "") && + (x.worktreeDir ?? "") === (entry.worktreeDir ?? "") + ) + ); + reg.entries.push(entry); + await saveRegistry(reg); + console.log( + // biome-ignore lint/style/noRestrictedGlobals: standalone script without access to SafeJSON + JSON.stringify( + { registered: entry, registry: await registryPath(), docPath: derivedDocPath(entry, ctx.branch) }, + null, + 2 + ) + ); +} + +async function cmdHere(file: string) { + if (!file) { + console.error("here: pass the wrap-up file path"); + process.exit(1); + } + + const text = await Bun.file(file).text(); + const start = text.indexOf(HERE_START); + const end = text.indexOf(HERE_END); + if (start === -1 || end === -1) { + console.error("here: no YOU-ARE-HERE block found"); + process.exit(1); + } + + console.log(text.slice(start, end + HERE_END.length)); +} + +function nowStamp(): string { + const d = new Date(); + const p = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; +} + +export function innerBlock(full: string): string { + return full.replace(HERE_START, "").replace(HERE_END, "").trim(); +} + +export function blockquote(s: string): string { + return s + .split("\n") + .map((l) => (l.length ? `> ${l}` : ">")) + .join("\n"); +} + +const LOG_USAGE = ` +Expected: pipe ONE heredoc split by two sentinel lines — @@HERE@@ (new state +bullets) then @@LOG@@ (the log-section body you author). Example: + + bun resolve.ts log "" <<'WRAPUP' + @@HERE@@ + - **Branch / worktree:** @ + - **State:** + - **Next:** + - **Verify:** + - **Read to resume:** + @@LOG@@ + ## — (commits , …) + + ### Goal & context + ... + WRAPUP + +The script auto-stamps the datetime, rewrites the YOU-ARE-HERE block in place, +and auto-generates the "Header before → after" snapshot — don't write those.`; + +function failLog(problem: string): never { + console.error(`log: ${problem}\n${LOG_USAGE}`); + process.exit(1); +} + +export type SentinelSplit = { ok: true; hereBody: string; logBody: string } | { ok: false; problem: string }; + +export function splitSentinels(raw: string): SentinelSplit { + const stdin = raw.trim(); + if (!stdin) { + return { ok: false, problem: "nothing on stdin — you must pipe the @@HERE@@ / @@LOG@@ heredoc in" }; + } + + const hIdx = stdin.indexOf("@@HERE@@"); + const lIdx = stdin.indexOf("@@LOG@@"); + const missing = [hIdx === -1 && "@@HERE@@", lIdx === -1 && "@@LOG@@"].filter(Boolean); + if (missing.length) { + return { ok: false, problem: `stdin is missing sentinel line(s): ${missing.join(" and ")}` }; + } + + if (lIdx < hIdx) { + return { ok: false, problem: "@@LOG@@ appears before @@HERE@@ — order must be @@HERE@@ first, then @@LOG@@" }; + } + + const hereBody = stdin.slice(hIdx + "@@HERE@@".length, lIdx).trim(); + const logBody = stdin.slice(lIdx + "@@LOG@@".length).trim(); + if (!hereBody && !logBody) { + return { ok: false, problem: "both the @@HERE@@ and @@LOG@@ sections are empty" }; + } + + if (!hereBody) { + return { + ok: false, + problem: "the @@HERE@@ section is empty — it needs the new 'You are here' state bullets", + }; + } + + if (!logBody) { + return { + ok: false, + problem: "the @@LOG@@ section is empty — it needs the log-section body (## datetime header + forensics)", + }; + } + + return { ok: true, hereBody, logBody }; +} + +export type LogBuild = { ok: true; body: string } | { ok: false; problem: string }; + +export function buildLogBody({ + text, + hereBody, + logBody, + stamp, +}: { + text: string; + hereBody: string; + logBody: string; + stamp: string; +}): LogBuild { + const s = text.indexOf(HERE_START); + const e = text.indexOf(HERE_END); + if (s === -1 || e === -1 || e < s) { + return { ok: false, problem: `no valid ${HERE_START} … ${HERE_END} block` }; + } + + const oldFull = text.slice(s, e + HERE_END.length); + const newFull = `${HERE_START}\n## You are here (${stamp})\n${hereBody}\n${HERE_END}`; + const rewritten = text.slice(0, s) + newFull + text.slice(e + HERE_END.length); + + const section = [ + logBody, + "", + "### Header before → after", + "", + "**Before:**", + "", + blockquote(innerBlock(oldFull)), + "", + "**After:**", + "", + blockquote(innerBlock(newFull)), + ].join("\n"); + + return { ok: true, body: `${rewritten.replace(/\s+$/, "")}\n\n${section}\n` }; +} + +async function cmdLog(file: string) { + if (!file) { + failLog("no wrap-up file path given (first positional arg)"); + } + + const f = Bun.file(file); + if (!(await f.exists())) { + failLog( + `file does not exist: ${file}\n → create it from the template with Write first, then use 'log' for every session after` + ); + } + + const split = splitSentinels(await Bun.stdin.text()); + if (!split.ok) { + failLog(split.problem); + } + + const stamp = nowStamp(); + const built = buildLogBody({ text: await f.text(), hereBody: split.hereBody, logBody: split.logBody, stamp }); + if (!built.ok) { + failLog(`${built.problem} in ${file} — is this a wrap-up file created from the template?`); + } + + await writeAtomic(file, built.body); + // biome-ignore lint/style/noRestrictedGlobals: standalone script without access to SafeJSON + console.log(JSON.stringify({ logged: file, stamp }, null, 2)); +} + +export function parseFlags(argv: string[]): Record { + const out: Record = {}; + for (let i = 0; i < argv.length; i++) { + if (argv[i].startsWith("--")) { + out[argv[i].slice(2)] = argv[i + 1] ?? ""; + i++; + } + } + return out; +} + +// Guarded so the pure helpers above can be imported by tests without the CLI +// dispatcher running (and calling process.exit) on import. +if (import.meta.main) { + const [cmd, ...rest] = process.argv.slice(2); + switch (cmd) { + case "resolve": + await cmdResolve(); + break; + case "register": + await cmdRegister(parseFlags(rest)); + break; + case "here": + await cmdHere(rest[0]); + break; + case "log": + await cmdLog(rest[0]); + break; + default: + console.error( + "usage: resolve.ts [--branch b] [--worktree w] [--project p] [--doc path] | here | log (log reads stdin: @@HERE@@ … @@LOG@@ …)>" + ); + process.exit(1); + } +}