diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 95359c0..a769352 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "graceful-handoff", - "version": "2.1.0", - "description": "Auto-trigger session handoff at context-threshold (Stop hook + statusLine hint), with manual /graceful-handoff fallback. Skill-architecture (disable-model-invocation: true) + JSON pipeline + auto-load on session resume.", + "version": "3.0.0", + "description": "One-command session handoff into the STATE.md continuity system. /graceful-handoff reaches a natural stopping point, overwrites the nearest STATE.md with a complete state-of-play (mandatory '👉 NESTE' block), and commits per remote policy (private → tracked, public mirror → local-only). Skill-only, deterministic STATE helper, no hooks.", "author": { "name": "Kjell Tore Guttormsen" }, @@ -10,11 +10,12 @@ "keywords": [ "session", "handoff", + "state-md", + "session-handoff", "context-management", - "opus-4.7", + "continuity", "git", "workflow", - "auto-trigger", "skills" ] } diff --git a/.gitignore b/.gitignore index 687aa2f..a52fd00 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ STATE.md REMEMBER.md ROADMAP.md TODO.md -NEXT-SESSION-PROMPT*.local.md *.local.md *.local.json *.local.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 982c909..22de6dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ All notable changes to graceful-handoff are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [3.0.0] - 2026-06-23 + +### BREAKING + +- **Full integration with the STATE.md continuity system.** The plugin no longer writes its own `NEXT-SESSION-PROMPT.local.md` artifact. `/graceful-handoff` now overwrites the **nearest STATE.md** (cwd → repo root) with a complete state-of-play in the mandatory format — title + subtitle + a `## 👉 NESTE — START HER` block on top, then fixed sections and short history. STATE.md is the one continuity layer; local handover inventions (`NEXT-SESSION`, `HANDOVER.md`, `ROADMAP.md`) are gone. +- **Inverted architecture: the model writes, the script assists.** Only the session model has the context to fill the `👉 NESTE` block meaningfully — a deterministic `git log` snapshot never could. `scripts/handoff-pipeline.mjs` is now a slim, deterministic STATE helper: `--plan` (resolve nearest STATE.md + classify remote + git facts, read-only), `--commit` (stage **only** STATE.md when tracked, plus explicit `--also` paths — never `git add -A`), `--dry-run`. It no longer renders artifacts. +- **All three hooks removed.** The Stop auto-trigger, the statusLine hint, and the SessionStart auto-loader are deleted (`hooks/` is gone): + - *SessionStart loader* was redundant — the global `~/.claude/hooks/session-start.sh` already injects the nearest STATE.md. + - *statusLine* was dead code — a user's `~/.claude/settings.json` statusLine always wins over a plugin's, and it already shows context %. + - *Stop auto-trigger* would overwrite a good, model-written STATE.md with a poor snapshot. Removed in favour of a purely manual regime. +- **Removed the `model: claude-sonnet-4-6` pin.** Writing a self-standing STATE.md is human-facing synthesis → the skill now inherits the session model (Opus-class quality). + +### Changed + +- **Remote-aware STATE policy.** STATE.md must never reach a public mirror. The helper classifies the `origin` remote (`github.com` or the `open/` Forgejo namespace → public → STATE local-only/gitignored; otherwise → private → STATE tracked). The authoritative commit signal is `git check-ignore STATE.md`; `remote_class` only drives a `leak_warning` when the two disagree. +- SKILL.md is now the operationalised "Session Slutt" ritual: reach a natural stopping point → `--plan` → write STATE.md → `--commit` → push only inside the window → fixed closing line. +- Tests rewritten around `--plan`/`--commit`; the no-`git add -A` regression guard is preserved. Hook tests removed. + +### Migration from v2.x + +No automatic migration. Reinstall to drop the hooks. `/graceful-handoff` now writes STATE.md instead of a NEXT-SESSION artifact; any stale `NEXT-SESSION-*.local.md` files can be deleted. STATE.md auto-loads at session start via the global hook — the plugin no longer ships its own loader. + ## [2.1.0] - 2026-05-01 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 8ee7386..f7906d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,65 +1,78 @@ -# graceful-handoff (v2.1) +# graceful-handoff (v3.0) -Auto-trigger sesjonsoverlevering ved kontekst-terskel, med manuell `/graceful-handoff` som backup. Skill-arkitektur (`disable-model-invocation: true`), deterministisk JSON-pipeline, og tre hooks som dekker hint, auto-eksekvering, og auto-load. +Én-kommandos sesjonsoverlevering inn i **STATE.md-kontinuitetssystemet**. `/graceful-handoff` +når et naturlig stoppunkt, overskriver nærmeste `STATE.md` med en komplett state-of-play +(obligatorisk «👉 NESTE — START HER»-blokk øverst), og committer per remote-policy. Skill-only +arkitektur (`disable-model-invocation: true`) + ett slankt deterministisk hjelpeskript. **Ingen hooks.** -## Når brukes den +## Hva den gjør -- **Automatisk:** Stop hook fyrer ved estimert ≥70% kontekst-bruk. Skriver artefakt + commit. Push gjenstår manuell. -- **Manuelt:** `/graceful-handoff` ved 60-70% (eller når som helst). statusLine viser hint ved 60% og urgent ved 70%. -- **Ny sesjon:** SessionStart hook auto-leser handoff-fil ved `source: resume` eller `source: compact` og injiserer i kontekst. +`/graceful-handoff` er operasjonaliseringen av den globale «Session Slutt»-rituelen, sentrert +på STATE.md (ett av tre kontinuitets-lag: `STATE.md` + auto-memory `MEMORY.md` + `CLAUDE.md`). +Den skriver IKKE lenger en egen `NEXT-SESSION`-artefakt — det var nettopp den typen lokale +handover-påfunn regimet forbyr. STATE.md ER overleveringen. -## Komponenter +- **Manuelt, når du vil:** `/graceful-handoff`. Sesjonen avslutter ved første naturlige punkt og + etterlater en selvtilstrekkelig STATE.md. +- **Ny sesjon:** den globale `~/.claude/hooks/session-start.sh` injiserer nærmeste STATE.md + automatisk. Pluginen trenger ingen egen auto-load. + +## Arkitektur — modellen skriver, skriptet assisterer + +Bare sesjonsmodellen har konteksten til å fylle «👉 NESTE»-blokken meningsfullt. Et deterministisk +`git log`-snapshot kan det aldri. Derfor er ansvaret invertert mot v2: | Fil | Rolle | |-----|-------| -| `skills/graceful-handoff/SKILL.md` | Slash-command-handler. Frontmatter: `disable-model-invocation: true`, `model: claude-sonnet-4-6`, sub-scoped `allowed-tools`. Body orkestrerer pipeline-skriptet. | -| `scripts/handoff-pipeline.mjs` | Deterministisk Node-skript. Klassifiserer handoff-type, skriver artefakt, håndterer commit-bekreftelse, returnerer JSON. | -| `hooks/scripts/statusline-monitor.mjs` | Display-only hint. Leser `context_window.used_percentage` fra payload. | -| `hooks/scripts/stop-context-monitor.mjs` | Estimerer kontekst fra transcript-størrelse. Spawner pipeline ved ≥70%. | -| `hooks/scripts/session-start-load-handoff.mjs` | Auto-leser NEXT-SESSION-fil ved resume/compact, archiverer etter load. | -| `hooks/hooks.json` | Registrerer alle tre hooks + statusLine. | +| `skills/graceful-handoff/SKILL.md` | Rituelet, modell-drevet (full kontekst). Frontmatter: `disable-model-invocation: true`, **ingen `model:`-pin** (arver sesjonsmodell — STATE.md er human-facing syntese → Opus-kvalitet), sub-scoped `allowed-tools` inkl. `Write`. | +| `scripts/handoff-pipeline.mjs` | Slank deterministisk STATE-hjelper. `--plan` (resolver nærmeste STATE.md + klassifiser remote + git-fakta, read-only), `--commit` (stager KUN STATE.md når tracked + eksplisitte `--also`-stier; aldri `git add -A`), `--dry-run`. Returnerer JSON. Testbar uten LLM. | -## Arkitektur-prinsipper +Rituelet (SKILL.md): nå naturlig stoppunkt → `--plan` → skriv/overskriv STATE.md i fast format +→ `--commit` → push kun i vindu → fast avslutningslinje. -- **Hard cut fra commands/ til skills/.** v2.0 har ingen bakoverkompatibilitet. -- **disable-model-invocation: true.** Modellen kan IKKE invokere skill-en autonomt — bruker trigger manuelt eller hooks kaller pipeline-skriptet direkte. -- **Pipeline er deterministisk.** Tester kjører mot pipeline-skriptet uten LLM. Driftvariasjoner mellom Opus/Sonnet/Haiku elimineres for selve handoff-arbeidet. -- **Push aldri automatisk.** Reversibel handling (commit) auto-eksekveres; irreversibel (push) krever bruker. -- **Eksplisitt staging.** Pipeline stager kun artefakten (+ REMEMBER.md/TODO.md hvis de finnes). ALDRI `git add -A` — det scoopper opp ubeslektet WIP. Regression-test håndhever dette. +## STATE.md-format (ufravikelig) -## Auto-trigger-mekanikk +`# STATE — ` + undertittel → **`## 👉 NESTE — START HER`** ØVERST (hvor vi er + nummererte +neste steg + pekere til hva som må leses) → faste seksjoner (oppdrag/kjøremodus, gotchas, +push-status, repo/env) + kort historikk UNDER. Maks ~60 linjer. Overskriv, ikke append. -Claude Code eksponerer ikke real-time kontekst-prosent direkte til Stop hook (Anthropic har closed feature requests #16988, #27969, #34340). v2.1 bruker en **4-stegs resolution-kjede** (`resolveContextSource()` i `stop-context-monitor.mjs`): +## Remote-policy (STATE må aldri nå et offentlig speil) -1. `payload.context_window.used_percentage` — autoritativ, modell-agnostisk (kilde: `direct`) -2. `payload.context_window.context_window_size` + `chars/3.5`-estimat (kilde: `payload-size`) -3. `MODEL_WINDOWS[payload.model.id]` + estimat — Opus 4.7=1M, Sonnet 4.6=200k, Haiku=200k (kilde: `model-map`) -4. `FALLBACK_WINDOW = 1_000_000` + estimat — oppdatert 2026-default (kilde: `default-1m`) +Hjelperen klassifiserer `origin`: +- `github.com` eller `open/`-Forgejo-namespace → **public** → STATE.md local-only (gitignored), committes aldri. +- ellers (privat Forgejo `ktg/…`) → **private** → STATE.md tracked + committet. -Ved ≥ 70% (estimert): spawn pipeline med `--auto --no-push --non-interactive`. additionalContext-meldingen inkluderer `[kilde: ]` for innsyn. +Den autoritative commit-beslutningen er `git check-ignore STATE.md`; `remote_class` driver kun et +`leak_warning` når de to er inkonsistente (offentlig remote, men STATE.md ikke gitignored). -Lock-fil `/.handoff-lock-` hindrer repeat-firing innen samme sesjon. +**Denne repoen har `open/`-remote → STATE.md er gitignored (local-only).** Plugin-koden er offentlig; +state-of-play er det ikke. + +## Eksplisitt staging (ufravikelig) + +`--commit` stager KUN STATE.md (+ eksplisitte `--also`-stier). ALDRI `git add -A` — det scoopper opp +ubeslektet WIP. Regresjonstest håndhever dette. Pre-commit hooks respekteres uten `--no-verify`. ## Tester ```bash -node --test plugins/graceful-handoff/tests/ +node --test 'tests/**/*.test.mjs' ``` -36+ tester på tvers av 6 test-filer. Stop hook-tester bruker stub pipeline (genererer en mid-test fake `scripts/handoff-pipeline.mjs` i temp dir) for å unngå reelle git-operasjoner mot marketplace-repoet. - -## Tidsbudsjett - -< 60 sekunder totalt for hele pipelinen. Pipeline-skriptet er testbart med `node:test` uten LLM-kall. - -## Åpne antakelser (verifiseres ved smoke-test) - -- **statusLine-plassering i `hooks/hooks.json`** vs `~/.claude/settings.json`. Vi setter den i hooks.json som første-prioritet design. -- **Token-estimering ±10%** mot Claude's reelle telling. -- **Issue #26251** (`disable-model-invocation: true` regression). Smoke-test at `/graceful-handoff` fungerer etter installasjon. +30 tester på tvers av 3 filer (`skill-structure`, `scripts/handoff-pipeline`, `plugin-manifest`). +Pipelinen er deterministisk og testes uten LLM-kall: `--plan`/`--commit`-JSON, nærmeste-STATE-resolusjon, +remote-klassifisering, staging-disiplin (no-`git add -A`-regresjon), gitignored-STATE-skip, detached-HEAD, +`--dry-run`. ## Versjonering -- v1.0.0 (2026-04-19): initial declarative command +- v1.0.0 (2026-04-19): deklarativ command, NEXT-SESSION-artefakt - v2.0.0 (2026-05-01): skill-arkitektur + JSON-pipeline + 3 hooks + auto-trigger (BREAKING) -- v2.1.0 (2026-05-01): modell-bevisst kontekstvindu — 4-stegs resolution-kjede (used_percentage → payload-size → model-map → 1M default). Fikser for-tidlig auto-handoff på Opus 4.7 +- v2.1.0 (2026-05-01): modell-bevisst kontekstvindu (4-stegs resolution-kjede) +- v3.0.0 (2026-06-23): **STATE.md-integrasjon (BREAKING).** Fjernet NEXT-SESSION-artefakt + alle 3 hooks; invertert pipeline (modellen skriver STATE.md, skriptet assisterer); remote-aware tracked/local-only-policy; fjernet Sonnet-pin. + +## Release (polyrepo — egen gated handling) + +En versjonsbump er ikke fullført før: (1) `v3.0.0`-tag laget + pushet i denne repoen, OG (2) +katalogens `ref` bumpet til samme tag med `check-versions.mjs` grønn. Bruk +`../catalog/scripts/release-plugin.mjs graceful-handoff ` (atomisk). Krever push-vindu + operatør-go. diff --git a/README.md b/README.md index 2523d3a..a4e1095 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,20 @@ # Graceful Handoff Plugin for Claude Code -> Auto-trigger session handoff at the context threshold so long-running work survives the next session boundary. Manual `/graceful-handoff` always works as a backup. +> One command to end a session cleanly and hand it to the next one — `/graceful-handoff` reaches a natural stopping point and overwrites the nearest `STATE.md` with a complete, self-standing state-of-play. > **Solo-maintained, fork-and-own.** This plugin is a starting point, not a vendor product. Issues are welcome as signals; pull requests are not accepted. See [GOVERNANCE.md](GOVERNANCE.md) for the full model and what upstream provides. *AI-generated: all code produced by Claude Code through dialog-driven development. [Full disclosure →](../../README.md#ai-generated-code-disclosure)* -![Version](https://img.shields.io/badge/version-2.1.0-blue) +![Version](https://img.shields.io/badge/version-3.0.0-blue) ![Platform](https://img.shields.io/badge/platform-Claude_Code_Plugin-purple) ![Skill](https://img.shields.io/badge/skill-1-green) -![Hooks](https://img.shields.io/badge/hooks-3-red) -![Pipeline](https://img.shields.io/badge/pipeline-deterministic-cyan) -![Tests](https://img.shields.io/badge/tests-57-success) +![Hooks](https://img.shields.io/badge/hooks-0-lightgrey) +![Pipeline](https://img.shields.io/badge/STATE--helper-deterministic-cyan) +![Tests](https://img.shields.io/badge/tests-30-success) ![License](https://img.shields.io/badge/license-MIT-lightgrey) -A Claude Code plugin that solves a structural problem with long sessions: the context window fills (often within ~5 minutes of real work on Opus 4.7), and the user is forced to summarize, commit, and write a continuation prompt under time pressure — or skip steps and lose continuity. This plugin removes those steps from the user's hands using a deterministic JSON pipeline plus three hooks that detect the threshold, auto-execute the reversible work, and auto-load the artifact in the next session. +A Claude Code plugin that makes session handoff a single deliberate command, fully integrated with a `STATE.md`-based continuity system. When a session is filling up — or you simply want to stop — `/graceful-handoff` reaches a natural stopping point, **overwrites the nearest `STATE.md`** with where you are, the next concrete step, and what to read, then commits per your remote policy. The next session reads that `STATE.md` and stands on its own. --- @@ -22,16 +22,17 @@ A Claude Code plugin that solves a structural problem with long sessions: the co - [What Is This?](#what-is-this) - [The Problem](#the-problem) +- [The STATE.md Model](#the-statemd-model) - [Quick Start](#quick-start) - [Architecture](#architecture) -- [How auto-trigger works](#how-auto-trigger-works) +- [The Handoff Ritual](#the-handoff-ritual) - [Components](#components) +- [Remote Policy — tracked vs local-only](#remote-policy--tracked-vs-local-only) - [Commands & Arguments](#commands--arguments) -- [Workflow Examples](#workflow-examples) - [Safety Guarantees](#safety-guarantees) - [Testing](#testing) +- [What Changed in v3.0 (BREAKING)](#what-changed-in-v30-breaking) - [Limitations & Open Assumptions](#limitations--open-assumptions) -- [Version History](#version-history) - [License](#license) - [Feedback & Contributing](#feedback--contributing) @@ -39,36 +40,65 @@ A Claude Code plugin that solves a structural problem with long sessions: the co ## What Is This? -Three hooks plus one skill that handle session handoff for you: +One skill — `/graceful-handoff` — plus a slim deterministic helper script. No hooks. -- **statusLine hint** at 60% and an urgent reminder at 70% — display only, always safe -- **Stop-hook auto-execute** at estimated ≥70% — writes the artifact + creates a commit. Push remains user-triggered -- **SessionStart auto-load** on `source: resume`/`compact` — handoff content is injected into the new session automatically; no `cat` needed -- **Manual `/graceful-handoff`** — always works as a backup, with the same arguments +Invoke it whenever you want to hand off. The session: -The skill itself is `disable-model-invocation: true`. The model cannot autonomously invoke handoff — only the user (via the slash command) or the Stop hook (which calls the pipeline script directly, not the skill) can trigger it. This is intentional: handoff is a moment that should be deliberate. +1. **Reaches the first natural stopping point** — finishes the current logical unit, leaves the code working. +2. **Overwrites the nearest `STATE.md`** with a complete state-of-play in a fixed format, headed by a mandatory `👉 NESTE — START HER` block (where we are + the next concrete step + what to read). +3. **Commits per remote policy** — staging *only* `STATE.md` (when tracked), never `git add -A`. +4. **Leaves push to you**, gated by your push window. -> [!TIP] -> Install the plugin and forget about it. The first time the Stop hook fires, the artifact appears, a commit lands, and `git push` is yours to run when ready. +The skill is `disable-model-invocation: true`: the model cannot trigger a handoff on its own. Handoff is a deliberate, user-initiated moment. --- ## The Problem -Opus 4.7 fills the context window quickly. On real work — file reads, tool output, agent results — a session can hit 60–70% in five minutes. When it happens, three manual steps become rushed or skipped: +Long sessions fill the context window. The classic failure mode is summarizing, committing, and writing a continuation prompt under time pressure — or skipping steps and losing continuity. Earlier versions of this plugin attacked that with auto-trigger hooks that wrote a separate `NEXT-SESSION-PROMPT` artifact. -1. Summarize the state of the work (commits, local changes, what was tested) -2. Commit and push finished work (otherwise it is lost when the session ends) -3. Write a copy-paste prompt that lets the next session continue without context loss +That turned out to be the wrong shape for a continuity system built on `STATE.md`: -Doing these three things at 65% context, with the model already forgetting earlier turns, is exactly when mistakes happen. This plugin moves all three out of the critical path: +- A separate `NEXT-SESSION` file **is exactly the kind of local handover invention** a single-source continuity system forbids. `STATE.md` already *is* the handoff. +- The valuable part of a handoff — *where are we, what's the next step, what should I read* — **requires the session's understanding**. A deterministic `git log` snapshot can't write it; only the model can. +- An auto-trigger hook has no session context, so it could only ever produce a poor snapshot — and would **overwrite a good, model-written `STATE.md`**. -- **Detection** — the Stop hook estimates context usage and fires at ≥70% -- **Reversible execution** — the artifact is written and committed automatically -- **Irreversible execution** — `git push` stays in your hands; the plugin will never push for you -- **Continuation** — on the next session, the artifact is auto-loaded into context +v3.0 fixes the shape: the model writes `STATE.md`; the script does the deterministic mechanics around it. -The ~10% gap between the 60% statusLine hint and the 70% Stop-hook trigger gives you a window to invoke `/graceful-handoff` manually if you want to control the slug or skip the commit. +--- + +## The STATE.md Model + +`STATE.md` is one layer of a three-layer continuity system: + +| Layer | Job | +|-------|-----| +| **`STATE.md`** | Current state-of-play *where you work*. Overwritten (not appended) at session end. The nearest one (cwd → repo root) is auto-injected at session start by a global hook. | +| auto-memory **`MEMORY.md`** | Durable facts about the user and preferences. | +| **`CLAUDE.md`** | Invariants — rules, commands. | + +Git history is the long-term log. This plugin owns the **write** side of `STATE.md` at session end. The **read** side (injecting the nearest `STATE.md` into a new session) is handled by the user's global `session-start.sh` — so the plugin ships no SessionStart loader of its own. + +### Mandatory STATE.md format + +```markdown +# STATE — +_Current state-of-play. Overwritten (not appended) at session end. History → git._ + +## 👉 NESTE — START HER +**** +1. **(you/me)** +2. **(you/me)** + + +## Mission & run-mode +## Gotchas / inviolable rules +## Push status +## Repo & env +## Session sequence (short — most of it lives in git) +``` + +The `👉 NESTE` block is always first, right after the title — it carries the actionable part so the operator never has to hunt for it. --- @@ -77,8 +107,8 @@ The ~10% gap between the 60% statusLine hint and the 70% Stop-hook trigger gives ### Prerequisites - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) v2.x+ -- Node.js (any recent LTS — required for hook and pipeline scripts) -- Git repository (the pipeline detects detached HEAD and missing upstream and reports gracefully — it never crashes) +- Node.js (any recent LTS — for the helper script) +- A git repository (the helper detects detached HEAD / missing upstream and reports gracefully) ### Install @@ -96,25 +126,13 @@ Or enable directly in `~/.claude/settings.json`: } ``` -The three hooks activate immediately on install. No further configuration needed. - -### First handoff - -Manual: +### Hand off ``` > /graceful-handoff ``` -Or just keep working — when context crosses the estimated 70% threshold, the Stop hook fires automatically: - -``` -⚠️ Auto-handoff utført ved estimert 72% [kilde: direct]: - artefakt /path/NEXT-SESSION-PROMPT.local.md. - Push gjenstår — kjør `git push` når du er klar. -``` - -Start the next session with `claude --resume` and the artifact is loaded into context automatically. +The session finishes the current unit, writes `STATE.md`, commits (if your remote policy allows), and prints a fixed closing line: **STATE.md status · next session's first action · commit/push status.** Start the next session and the nearest `STATE.md` is loaded into context automatically by your global session-start hook. --- @@ -122,59 +140,38 @@ Start the next session with `claude --resume` and the artifact is loaded into co ```mermaid flowchart TB - subgraph Detection["Detection — display + auto-trigger"] - direction LR - SL["statusLine
60% hint, 70% urgent"] - SH["Stop hook
≥70% estimated"] - end - - subgraph Pipeline["Deterministic pipeline (Node, no LLM)"] - direction LR - P["handoff-pipeline.mjs
classify → write → stage → commit"] - end - - subgraph Resumption["Resumption — auto-load"] - direction LR - SS["SessionStart hook
resume / compact only"] - AR["Archive after read
*.archived.local.md"] - end - - subgraph Manual["Manual fallback"] - direction LR - SK["SKILL.md
disable-model-invocation: true"] - end - - SL -.display only.-> User - SH -->|spawns| P - SK -->|invokes| P - P -->|writes| AR - SS -->|reads| AR User((user)) -->|/graceful-handoff| SK - User -->|git push| Done((done)) + + subgraph Skill["SKILL.md — model-driven ritual (full session context)"] + SK["1. reach natural stopping point
3. WRITE STATE.md (👉 NESTE block)
7. fixed closing line"] + end + + subgraph Helper["handoff-pipeline.mjs — deterministic, no LLM"] + PLAN["--plan
resolve nearest STATE.md
classify remote · git facts"] + COMMIT["--commit
stage ONLY STATE.md (+ --also)
never git add -A"] + end + + SK -->|2. fetch facts| PLAN + PLAN -->|JSON| SK + SK -->|4. safe commit| COMMIT + User -->|5. git push, in-window| Done((done)) ``` -Three independent layers: **detection** (hooks watching context), **pipeline** (deterministic script that does the work), **resumption** (hook that loads the artifact in the next session). Each layer is testable in isolation. The pipeline has no LLM dependencies — `node:test` runs it against fixtures in <8 s. +Two pieces, one responsibility each: the **skill** synthesizes `STATE.md` (only it has the context); the **helper** does the deterministic mechanics (path resolution, remote classification, safe staging). The helper has no LLM dependencies and runs under `node:test` in seconds. --- -## How auto-trigger works +## The Handoff Ritual -Claude Code does not expose real-time context-percentage to hooks (Anthropic has closed feature requests [#16988](https://github.com/anthropics/claude-code/issues/16988), [#27969](https://github.com/anthropics/claude-code/issues/27969), [#34340](https://github.com/anthropics/claude-code/issues/34340)). Instead, the Stop hook uses a **4-step resolution chain** (v2.1, in `resolveContextSource()`): +`SKILL.md` instructs the session to, in order: -| Step | Source | When used | Source label | -|------|--------|-----------|--------------| -| 1 | `payload.context_window.used_percentage` | If the field is present and > 0 | `direct` | -| 2 | `payload.context_window.context_window_size` + transcript estimate (`chars / 3.5`) | If size > 0 but no `used_percentage` | `payload-size` | -| 3 | `MODEL_WINDOWS[payload.model.id]` + transcript estimate | Opus 4.7 = 1 M, Sonnet 4.6 = 200 K, Haiku = 200 K | `model-map` | -| 4 | `FALLBACK_WINDOW = 1_000_000` + transcript estimate | Last-resort default (2026-aware) | `default-1m` | - -When the resolved percentage is ≥ 70%, the Stop hook spawns `handoff-pipeline.mjs --auto --no-push --non-interactive` synchronously (25 s timeout, fits within the 30 s Stop-hook budget). The `additionalContext` message includes `[kilde: ]` so the source path is always visible. - -**Estimation drift:** Steps 2–4 use `chars / 3.5` to approximate tokens, which can drift ±10% from Claude's internal counting. The 70% threshold is conservative buffer. Step 1 (`direct`) has no drift. - -**Lock file:** `/.handoff-lock-` is created on first trigger to prevent repeat firing within the same session. Touch happens *before* spawning to win races on rapid Stop events. - -**Why 70% (not 65%)?** Earlier designs targeted 65%, but estimation drift and Stop-hook latency make 70% safer. Lower thresholds risk false positives that block normal continuation. +1. **Reach the first natural stopping point.** Finish the current logical unit; leave the code working. Don't cut mid-change; don't start new work. +2. **`--plan`** — fetch deterministic facts (resolved `STATE.md` path, remote class, git status, recent commits). +3. **Write / overwrite `STATE.md`** in the mandatory format (title → `👉 NESTE` block → fixed sections → short history). Max ~60 lines. Overwrite, never append. +4. **`--commit`** — stage only `STATE.md` (when tracked) plus any explicit `--also` paths the model judges belong to the unit. Never `git add -A`. +5. **Push** — check the window (`date '+%u %H:%M'`). In-window: push (Forgejo only, never GitHub). Out-of-window: park it and say so explicitly. +6. **MEMORY.md check** — if it exceeds 200 lines, warn (don't auto-edit). +7. **Fixed closing line** — STATE.md status (path) · next session's first action (= the `👉 NESTE` block) · commit/push status. --- @@ -185,160 +182,106 @@ When the resolved percentage is ≥ 70%, the Stop hook spawns `handoff-pipeline. ```yaml --- name: graceful-handoff -description: Produser handoff-artefakt, commit+push, og copy-paste-prompt for neste sesjon. +description: Reach a natural stopping point, overwrite the nearest STATE.md (👉 NESTE block), commit per remote policy, write the fixed closing line. +argument-hint: "[--no-commit] [--dry-run]" disable-model-invocation: true -model: claude-sonnet-4-6 -allowed-tools: Bash(git:*) Bash(jq:*) Bash(node:*) Bash(find:*) Bash(pwd:*) Read Write Glob +allowed-tools: Bash(git:*) Bash(node:*) Bash(date:*) Read Write Edit Glob --- ``` -Thin orchestration wrapper around the pipeline script. Pinned to Sonnet 4.6 to free Opus budget for the next session. `disable-model-invocation: true` prevents the model from calling the skill on its own — handoff is always user- or hook-triggered. +No `model:` pin — the skill inherits the session model, because writing a self-standing `STATE.md` is human-facing synthesis that warrants top-tier quality. > [!NOTE] -> `allowed-tools` is *pre-approval*, not restriction. It removes permission prompts for the listed tools but does not block other tools from being invoked. For real sandboxing, use project-level `permissions.deny` rules. +> `allowed-tools` is *pre-approval*, not a sandbox. It removes permission prompts for the listed tools but does not block others. For real sandboxing, use project-level `permissions.deny` rules. -### Pipeline — `scripts/handoff-pipeline.mjs` +### Helper — `scripts/handoff-pipeline.mjs` -Deterministic Node script. Returns structured JSON. No LLM dependencies. Handles: +Deterministic Node script, structured JSON output, no LLM dependencies: -- Classification of handoff type (`multi-sesjon` / `plugin-arbeid` / `enkelt-oppgave`) based on cwd -- Writing the NEXT-SESSION artifact in the correct directory -- **Explicit staging** of only the artifact (+ `REMEMBER.md` / `TODO.md` if present) — *never* `git add -A`, enforced by a regression test -- Commit-message generation from `git diff --stat` (Conventional Commits) -- Push (unless `--no-push`) with detached-HEAD and no-upstream detection -- Idempotency check: 60 s cooldown on a clean tree with a recent artifact is a no-op +- **`--plan`** (default, read-only): resolve the nearest `STATE.md` (cwd → repo root), classify the `origin` remote, gather git facts, and report whether `STATE.md` is gitignored (the authoritative commit signal) plus a `leak_warning` on misconfiguration. +- **`--commit`**: stage `STATE.md` *only* when it is tracked, plus explicit `--also ` paths; commit with a generated or supplied message. **Never `git add -A`. Never pushes.** Detached-HEAD guard. +- **`--dry-run`**: emit the plan JSON, write nothing, touch no git. -### Hooks — `hooks/scripts/` +--- -| Event | Script | What it does | -|-------|--------|--------------| -| `statusLine` | `statusline-monitor.mjs` | Reads `context_window.used_percentage` from payload. <60% silent, 60–69% hint, ≥70% urgent reminder. Display only — never runs git (statusLine scripts are cancellable mid-flight per official docs) | -| `Stop` | `stop-context-monitor.mjs` | Resolves context via the 4-step chain. At ≥70% spawns the pipeline with `--auto --no-push --non-interactive`. Uses a lock file to prevent repeat firing | -| `SessionStart` | `session-start-load-handoff.mjs` | On `source: resume` or `source: compact`, finds the most recent `NEXT-SESSION-*.local.md` (cwd + 3 levels up), injects the content via `additionalContext`, archives the file (`*.archived.local.md`) to prevent stale-load on subsequent sessions | +## Remote Policy — tracked vs local-only -Registered in `hooks/hooks.json`. +`STATE.md` must never reach a public mirror. The helper classifies `origin`: + +| Remote | Class | STATE.md | +|--------|-------|----------| +| `github.com` / `github.io` | public | local-only (gitignored), never committed | +| `…/open/` (public Forgejo mirror) | public | local-only (gitignored), never committed | +| `…/ktg/` (private Forgejo) | private | tracked + committed | +| no remote | none | local-only | + +The authoritative commit decision is `git check-ignore STATE.md` — so a misclassified remote can never cause a leak; it only produces a `leak_warning` when the remote looks public but `STATE.md` is *not* gitignored. **This plugin's own repo has an `open/` remote, so its `STATE.md` is local-only.** --- ## Commands & Arguments ``` -/graceful-handoff [topic-slug] [flags] +/graceful-handoff [flags] ``` | Argument | Description | |----------|-------------| -| `[topic-slug]` | Kebab-case slug. With slug: `NEXT-SESSION-.local.md`. Without: `NEXT-SESSION-PROMPT.local.md` | -| `--no-commit` | Skip commit + push. Artifact is written; user handles git manually | -| `--no-push` | Commit OK, but skip push (the Stop hook always uses this) | -| `--dry-run` | No files written, no git operations; print what would happen | -| `--auto` | Non-interactive, auto-Y on commit confirmation. Intended for hooks | -| `--non-interactive` | Without `--auto`: error. With `--auto`: run without any prompts | +| `--no-commit` | Write `STATE.md`, skip commit/push (you handle git manually) | +| `--dry-run` | Write nothing, no git — show the `--plan` JSON for inspection | -The pipeline script accepts the same flags directly (`node scripts/handoff-pipeline.mjs ...`) — useful for debugging without going through the skill. - ---- - -## Workflow Examples - -### Plugin work (auto-trigger) - -``` -cd plugins/llm-security -# ... work until ~70% context ... -# Stop hook fires automatically: -# → writes plugins/llm-security/NEXT-SESSION-PROMPT.local.md -# → stages ONLY the artifact (not other dirty files) -# → commits with auto-generated Conventional Commits message -# → does NOT push -git push # when you are ready -``` - -### Manual trigger with slug - -``` -/graceful-handoff refactor-auth --no-commit -# → writes plugins//NEXT-SESSION-refactor-auth.local.md -# → no git operations -``` - -### New session - -``` -claude --resume -# SessionStart hook auto-injects handoff content into context -# Continue working immediately -# The artifact is renamed to NEXT-SESSION-*.archived.local.md -# so it cannot stale-load on a third session -``` - -### Dry run before committing the workflow - -``` -/graceful-handoff --dry-run -# Pipeline prints the JSON it would produce — file paths, commit message, -# next steps — without writing anything or touching git -``` +The helper accepts the same modes directly (`node scripts/handoff-pipeline.mjs --plan` / `--commit` / `--dry-run`) — useful for debugging without going through the skill. --- ## Safety Guarantees -These properties are enforced by tests, not by convention: +Enforced by tests, not convention: -- **Push is never automatic.** The auto-execute path always passes `--no-push`. Irreversible operations stay in the user's hands. (`stop-context-monitor.test.mjs`) -- **Staging is explicit.** The pipeline stages *only* the handoff artifact (and `REMEMBER.md` / `TODO.md` if present). `git add -A` is never used — a regression test (`pipeline never stages unrelated dirty files`) enforces this. -- **Pre-commit hooks are respected.** The pipeline never bypasses with `--no-verify`. If a pre-commit hook (gitleaks, pathguard) blocks, the handoff fails and the user fixes the underlying issue. -- **Artifacts are gitignored.** All output files match `*.local.md`, which existing repos in this marketplace already gitignore via `.gitignore` patterns. -- **No network calls.** No WebSearch, no Agent delegation, no MCP. The pipeline is fully local. -- **Bash sub-scoped.** Skill `allowed-tools` enumerates `Bash(git:*) Bash(jq:*) Bash(node:*) Bash(find:*) Bash(pwd:*)` — pre-approval is narrow even though it is not a sandbox. -- **Lock file scoped to transcript directory.** Lock path is based on `dirname(transcript_path)`, not `cwd`, so it survives `cd` mid-session. +- **Staging is explicit.** `--commit` stages *only* `STATE.md` (plus explicit `--also` paths). `git add -A` is never used — a regression test (`never stages unrelated dirty files`) enforces this. +- **STATE.md never leaks to a public mirror.** On a public/`open/` remote `STATE.md` is gitignored and the helper refuses to commit it (`local-only-skipped`), verified by test. +- **Push is never automatic.** The helper never pushes; push stays user-triggered and window-gated. +- **Pre-commit hooks are respected.** The helper never uses `--no-verify`. +- **No network calls.** No WebSearch, no Agent delegation, no MCP — fully local. +- **Bash sub-scoped.** Skill `allowed-tools` enumerates `Bash(git:*) Bash(node:*) Bash(date:*)`. --- ## Testing ```bash -node --test 'plugins/graceful-handoff/tests/**/*.test.mjs' +node --test 'tests/**/*.test.mjs' ``` -57 tests across 6 files: +30 tests across 3 files: | File | Coverage | |------|----------| -| `tests/skill-structure.test.mjs` | SKILL.md frontmatter, model pin, allowed-tools shape, removal of legacy `commands/` | -| `tests/scripts/handoff-pipeline.test.mjs` | Pipeline JSON schema, idempotency, **no-staging-regression**, detached HEAD, no-upstream, interactive y/n | -| `tests/hooks/statusline-monitor.test.mjs` | Threshold transitions, null payload, malformed JSON, no side effects | -| `tests/hooks/stop-context-monitor.test.mjs` | 4-step context resolution, lock file behavior, stub-pipeline isolation, env-var failure modes | -| `tests/hooks/session-start-load-handoff.test.mjs` | Source filter (`resume`/`compact` only), multi-level search, archive after read | -| `tests/plugin-manifest.test.mjs` | Plugin.json schema, version pin, CHANGELOG entries | +| `tests/skill-structure.test.mjs` | SKILL.md frontmatter (no `model:` pin, `Write` in allowed-tools, `disable-model-invocation`), `👉 NESTE` mandate, removal of `commands/` and `hooks/` | +| `tests/scripts/handoff-pipeline.test.mjs` | `--plan`/`--commit`/`--dry-run`, nearest-STATE resolution, remote classification, **no-`git add -A` regression**, gitignored-STATE skip, detached HEAD | +| `tests/plugin-manifest.test.mjs` | plugin.json version + description, CHANGELOG `[3.0.0]` BREAKING + preserved history | -Stop-hook tests use a **stub pipeline** (a fake `handoff-pipeline.mjs` written into a temp dir) so test runs do not invoke real git operations against the marketplace repo. +The suite runs in a few seconds — no LLM involvement. -The pipeline runs in <8 s on a 2025 Mac. The full test suite runs in ~10 s. +--- + +## What Changed in v3.0 (BREAKING) + +- **Writes `STATE.md`, not a `NEXT-SESSION` artifact.** Full integration with the three-layer continuity system; the separate handover file is gone. +- **All three hooks removed.** Stop auto-trigger, statusLine hint, and SessionStart loader deleted — redundant (global hook already injects `STATE.md`), dead (user statusLine wins), or harmful (would overwrite a good `STATE.md`). +- **Inverted architecture.** The model writes `STATE.md`; the script is a slim deterministic helper. +- **Remote-aware tracked/local-only policy** so `STATE.md` never leaks to a public mirror. +- **Removed the Sonnet pin** — handoff synthesis inherits the session model. + +Full history in [`CHANGELOG.md`](CHANGELOG.md). --- ## Limitations & Open Assumptions -- **Token estimation drifts ±10%** against Claude's internal counting (steps 2–4 of the resolution chain). The 70% threshold is set conservatively to absorb this. Step 1 (`direct`) has no drift but requires the payload field to be present. -- **Stop-hook payload schema is undocumented.** It is not officially confirmed that Stop payloads include `used_percentage` or `model.id` (statusLine payloads do). If both are missing, the resolver falls through to `default-1m`. The `[kilde: ]` label in `additionalContext` reveals which path was actually used — first real session reveals this. -- **statusLine placement in `hooks/hooks.json` is an open assumption.** Smoke-test before relying on it; the fallback is to move it to global `~/.claude/settings.json`. -- **Issue [#26251](https://github.com/anthropics/claude-code/issues/26251)** — `disable-model-invocation: true` may regress and block user-invocation in some Claude Code versions. Manual smoke-test before relying on it. -- **Auto-execute does not push.** Irreversible operations stay user-triggered, by design. -- **Compaction events are out of scope.** PreCompact fires too late (~95%) and is not configurable. The plugin targets the 60–70% window where the user can still benefit from a clean handoff. - ---- - -## Version History - -| Version | Date | Highlights | -|---------|------|------------| -| **2.1.0** | 2026-05-01 | **Model-aware context window detection.** Replaces 200 K fallback with 4-step resolution chain (`used_percentage` → `payload-size` → `model-map` → 1 M default). Fixes 5–7× premature firing on Opus 4.7 (1 M window). All `additionalContext` messages include `[kilde: ]` for transparency. 6 new tests (57 total). | -| **2.0.0** | 2026-05-01 | **Hard cut from `commands/` to `skills/`.** New deterministic pipeline (`handoff-pipeline.mjs`), three hooks (statusLine, Stop, SessionStart), `disable-model-invocation: true`, sub-scoped `allowed-tools`, explicit staging discipline (no more `git add -A`), pinned to Sonnet 4.6 (BREAKING). | -| **1.0.0** | 2026-04-19 | Initial release — single declarative `/graceful-handoff` command, 6-phase prose workflow, three handoff types, pre-commit hook respect, <60 s time budget. | - -Full history in [`CHANGELOG.md`](CHANGELOG.md). +- **`disable-model-invocation: true`** has open issue [#26251](https://github.com/anthropics/claude-code/issues/26251) — it may regress and block user-invocation in some Claude Code versions. Smoke-test after install. +- **Remote classification is heuristic** (`open/` / `github` → public). The authoritative commit decision is `git check-ignore STATE.md`, so misclassification cannot cause a leak — only an advisory `leak_warning`. +- **STATE.md read-side depends on the global session-start hook.** This plugin only writes; injecting the nearest `STATE.md` at session start is the user's global `~/.claude/hooks/session-start.sh`. --- diff --git a/hooks/hooks.json b/hooks/hooks.json deleted file mode 100644 index a89fe5c..0000000 --- a/hooks/hooks.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/session-start-load-handoff.mjs", - "timeout": 5 - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/stop-context-monitor.mjs", - "timeout": 30 - } - ] - } - ] - }, - "statusLine": { - "type": "command", - "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/statusline-monitor.mjs", - "padding": 0 - } -} diff --git a/hooks/scripts/session-start-load-handoff.mjs b/hooks/scripts/session-start-load-handoff.mjs deleted file mode 100644 index 1522f2a..0000000 --- a/hooks/scripts/session-start-load-handoff.mjs +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env node -// session-start-load-handoff.mjs — graceful-handoff v2.0 -// SessionStart hook: on `source: resume` or `source: compact`, find -// NEXT-SESSION-PROMPT.local.md (or NEXT-SESSION-*.local.md) in cwd and up -// to 3 levels above, inject contents into the new session's context, then -// archive the file to prevent stale-load in subsequent sessions. - -import { readFileSync, existsSync, readdirSync, renameSync } from 'node:fs'; -import { dirname, join } from 'node:path'; - -function readStdin() { - try { - return readFileSync(0, 'utf-8'); - } catch { - return ''; - } -} - -// Find NEXT-SESSION-*.local.md in dir; returns path or null -function findHandoffIn(dir) { - try { - const entries = readdirSync(dir); - // Prefer NEXT-SESSION-PROMPT.local.md, then any NEXT-SESSION-*.local.md - const exact = entries.find(e => e === 'NEXT-SESSION-PROMPT.local.md'); - if (exact) return join(dir, exact); - const match = entries.find(e => /^NEXT-SESSION-.*\.local\.md$/.test(e) && !/\.archived\./.test(e)); - if (match) return join(dir, match); - } catch { /* ignore */ } - return null; -} - -// Walk up from start, max 3 levels, looking for handoff -function findHandoffUpwards(start) { - let cur = start; - for (let i = 0; i < 4; i++) { // 0,1,2,3 — start + 3 ancestors - const found = findHandoffIn(cur); - if (found) return found; - const parent = dirname(cur); - if (parent === cur) break; - cur = parent; - } - return null; -} - -function main() { - const raw = readStdin(); - if (!raw.trim()) process.exit(0); - let payload; - try { - payload = JSON.parse(raw); - } catch { - process.exit(0); - } - - const source = payload?.source; - if (source !== 'resume' && source !== 'compact') { - process.exit(0); - } - - const cwd = payload?.cwd || process.cwd(); - const handoffPath = findHandoffUpwards(cwd); - if (!handoffPath) { - process.exit(0); - } - - let content; - try { - content = readFileSync(handoffPath, 'utf-8'); - } catch { - process.exit(0); - } - - // Inject via additionalContext for clean structured output - const output = { - hookSpecificOutput: { - hookEventName: 'SessionStart', - additionalContext: `\n${content}\n`, - }, - }; - process.stdout.write(JSON.stringify(output)); - - // Archive to prevent stale-load in subsequent sessions - try { - const archived = handoffPath.replace(/\.local\.md$/, '.archived.local.md'); - if (!existsSync(archived)) { - renameSync(handoffPath, archived); - } - } catch { /* archival is best-effort, never block injection */ } - - process.exit(0); -} - -main(); diff --git a/hooks/scripts/statusline-monitor.mjs b/hooks/scripts/statusline-monitor.mjs deleted file mode 100644 index 7e2f37c..0000000 --- a/hooks/scripts/statusline-monitor.mjs +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env node -// statusline-monitor.mjs — graceful-handoff v2.0 -// statusLine hook: prints a context-percent hint, never runs git. -// -// Reads JSON from stdin (Claude Code statusLine payload). If -// context_window.used_percentage is available: -// < 60% → no output -// 60-69% → "kontekst NN% — vurder /graceful-handoff" -// ≥ 70% → "kontekst NN% — kjør /graceful-handoff NÅ" -// -// Exit 0 always. statusLine is display-only — never run git here -// (research/03 — statusLine scripts can be cancelled mid-flight). - -import { readFileSync } from 'node:fs'; - -function readStdin() { - try { - return readFileSync(0, 'utf-8'); - } catch { - return ''; - } -} - -function main() { - const raw = readStdin(); - if (!raw.trim()) { - process.exit(0); - } - let payload; - try { - payload = JSON.parse(raw); - } catch { - process.exit(0); - } - - const ctx = payload?.context_window; - const pct = ctx?.used_percentage; - if (typeof pct !== 'number' || isNaN(pct)) { - process.exit(0); - } - - if (pct >= 70) { - process.stdout.write(`kontekst ${Math.round(pct)}% — kjør /graceful-handoff NÅ`); - } else if (pct >= 60) { - process.stdout.write(`kontekst ${Math.round(pct)}% — vurder /graceful-handoff`); - } - // < 60 → no output (silent) - process.exit(0); -} - -main(); diff --git a/hooks/scripts/stop-context-monitor.mjs b/hooks/scripts/stop-context-monitor.mjs deleted file mode 100644 index 1141e13..0000000 --- a/hooks/scripts/stop-context-monitor.mjs +++ /dev/null @@ -1,216 +0,0 @@ -#!/usr/bin/env node -// stop-context-monitor.mjs — graceful-handoff v2.0 (Hybrid Option C from research/06) -// -// Stop hook fires after each model response. Estimates context usage from -// transcript size; at ≥70% (estimated), spawns handoff-pipeline.mjs --auto -// --no-push to write artifact + commit. Push remains user-triggered. -// -// Reconciliation with disable-model-invocation: the spawn calls the script -// DIRECTLY, not the skill. The skill stays manual-only. -// -// Lock file at /.handoff-lock- prevents repeat -// firing in the same session. -// -// Context resolution (4-step fallback, v2.1): -// 1. payload.context_window.used_percentage → authoritative, model-agnostic -// 2. payload.context_window.context_window_size + transcript estimate -// 3. MODEL_WINDOWS[payload.model.id] + transcript estimate -// 4. FALLBACK_WINDOW (1M, 2026 default) + transcript estimate -// Token estimation (steps 2-4): char_count / 3.5. Approximation drifts ±10%; -// 70% threshold is conservative buffer. - -import { readFileSync, statSync, writeFileSync, existsSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { spawnSync } from 'node:child_process'; - -const THRESHOLD = 0.70; -const FALLBACK_WINDOW = 1_000_000; -const CHARS_PER_TOKEN = 3.5; - -// Model → context window mapping. Sonnet 4.6 has an opt-in 1M tier that is -// not always active and not exposed in payload — use the safer 200k default. -const MODEL_WINDOWS = { - 'claude-opus-4-7': 1_000_000, - 'claude-sonnet-4-6': 200_000, - 'claude-haiku-4-5-20251001': 200_000, -}; - -// Test injection: tests can override these by setting on the export. -export const __testHooks = { - spawn: spawnSync, - fsRead: readFileSync, - fsStat: statSync, - fsWrite: writeFileSync, - fsExists: existsSync, -}; - -function readStdin() { - try { - return readFileSync(0, 'utf-8'); - } catch { - return ''; - } -} - -function estimateUsedPct(transcriptPath, windowSize) { - try { - const stat = __testHooks.fsStat(transcriptPath); - const tokens = stat.size / CHARS_PER_TOKEN; - return tokens / windowSize; - } catch { - return null; - } -} - -// Resolve context usage via the 4-step fallback chain documented above. -// Returns { pct, source } or null if pct cannot be computed. -export function resolveContextSource(payload, transcriptPath) { - const ctx = payload?.context_window; - const direct = ctx?.used_percentage; - if (typeof direct === 'number' && !isNaN(direct) && direct > 0) { - return { pct: direct / 100, source: 'direct' }; - } - - const payloadSize = ctx?.context_window_size; - if (typeof payloadSize === 'number' && payloadSize > 0) { - const pct = estimateUsedPct(transcriptPath, payloadSize); - return pct == null ? null : { pct, source: 'payload-size' }; - } - - const modelId = payload?.model?.id; - const mapped = modelId ? MODEL_WINDOWS[modelId] : undefined; - if (mapped) { - const pct = estimateUsedPct(transcriptPath, mapped); - return pct == null ? null : { pct, source: 'model-map' }; - } - - const pct = estimateUsedPct(transcriptPath, FALLBACK_WINDOW); - return pct == null ? null : { pct, source: 'default-1m' }; -} - -function emit(output) { - process.stdout.write(JSON.stringify(output)); -} - -function main() { - const raw = readStdin(); - if (!raw.trim()) { - process.exit(0); - } - let payload; - try { - payload = JSON.parse(raw); - } catch { - process.exit(0); - } - - const transcriptPath = payload?.transcript_path; - const sessionId = payload?.session_id || 'unknown'; - if (!transcriptPath) { - process.exit(0); - } - - // 4-step resolution: used_percentage → payload-size → model-map → 1M fallback - const resolved = resolveContextSource(payload, transcriptPath); - if (resolved == null) { - process.exit(0); - } - const { pct: pctRaw, source } = resolved; - const pct = Math.round(pctRaw * 100); - - if (pctRaw < THRESHOLD) { - process.exit(0); - } - - // Lock file path: based on transcript directory (session-stable), - // NOT cwd (which can change). See plan revisions #6. - const lockPath = join(dirname(transcriptPath), `.handoff-lock-${sessionId}`); - if (__testHooks.fsExists(lockPath)) { - process.exit(0); // already triggered this session - } - - // Touch lock first to prevent races on rapid Stop hook firing - try { - __testHooks.fsWrite(lockPath, `${sessionId}\n${new Date().toISOString()}\n`, 'utf-8'); - } catch { - process.exit(0); // can't lock, give up silently - } - - // Spawn pipeline synchronously (NOT detached) so we can capture output. - // 25s timeout fits within Stop hook 30s timeout budget. - const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT; - if (!pluginRoot) { - emit({ - hookSpecificOutput: { - hookEventName: 'Stop', - additionalContext: `⚠️ Auto-handoff aborted at est. ${pct}% [kilde: ${source}]: CLAUDE_PLUGIN_ROOT not set, cannot locate handoff-pipeline.mjs.`, - }, - }); - process.exit(0); - } - - const pipelineScript = join(pluginRoot, 'scripts', 'handoff-pipeline.mjs'); - const result = __testHooks.spawn( - 'node', - [pipelineScript, '--auto', '--no-push', '--non-interactive'], - { encoding: 'utf-8', timeout: 25_000 } - ); - - if (result.error) { - emit({ - hookSpecificOutput: { - hookEventName: 'Stop', - additionalContext: `⚠️ Auto-handoff FAILED at est. ${pct}% [kilde: ${source}]: ${result.error.message}. Run /graceful-handoff manually.`, - }, - }); - process.exit(0); - } - - if (result.status !== 0) { - emit({ - hookSpecificOutput: { - hookEventName: 'Stop', - additionalContext: `⚠️ Auto-handoff pipeline exited ${result.status} at est. ${pct}% [kilde: ${source}]. stderr: ${(result.stderr || '').slice(0, 300)}. Run /graceful-handoff manually.`, - }, - }); - process.exit(0); - } - - // Parse pipeline JSON; report status to user via additionalContext - let pipelineResult; - try { - pipelineResult = JSON.parse(result.stdout); - } catch { - emit({ - hookSpecificOutput: { - hookEventName: 'Stop', - additionalContext: `⚠️ Auto-handoff at est. ${pct}% [kilde: ${source}]: pipeline output unparseable. Run /graceful-handoff manually.`, - }, - }); - process.exit(0); - } - - const errors = pipelineResult.errors || []; - if (errors.length > 0) { - emit({ - hookSpecificOutput: { - hookEventName: 'Stop', - additionalContext: `⚠️ Auto-handoff at est. ${pct}% [kilde: ${source}] partially completed with errors: ${errors.join('; ')}. Artifact: ${pipelineResult.artifact_path || 'not written'}. Run git push manually.`, - }, - }); - process.exit(0); - } - - emit({ - hookSpecificOutput: { - hookEventName: 'Stop', - additionalContext: `⚠️ Auto-handoff utført ved estimert ${pct}% [kilde: ${source}]: artefakt ${pipelineResult.artifact_path}. Push gjenstår — kjør \`git push\` når du er klar.`, - }, - }); - process.exit(0); -} - -// Only run main() when invoked as script, not when imported by tests -if (import.meta.url === `file://${process.argv[1]}`) { - main(); -} diff --git a/scripts/handoff-pipeline.mjs b/scripts/handoff-pipeline.mjs index 08b77de..4a64bd7 100644 --- a/scripts/handoff-pipeline.mjs +++ b/scripts/handoff-pipeline.mjs @@ -1,53 +1,50 @@ #!/usr/bin/env node -// handoff-pipeline.mjs — Deterministic JSON pipeline for graceful-handoff v2.0. +// handoff-pipeline.mjs — Deterministic STATE.md helper for graceful-handoff v3.0. // -// Detects handoff type, classifies state, writes NEXT-SESSION artifact, optionally -// commits and pushes. Returns structured JSON to stdout. Designed to be called both -// by the SKILL.md (interactive) and the Stop hook (auto-execute). +// graceful-handoff is now a thin executor of the global "Session Slutt" ritual, +// centred on STATE.md (the three-layer continuity system). The MODEL writes the +// STATE.md content — only it has the session context needed to fill the mandatory +// "👉 NESTE — START HER" block. This script does ONLY the deterministic parts: // -// Usage: -// node handoff-pipeline.mjs [topic-slug] [--dry-run] [--no-commit] [--no-push] -// [--auto] [--non-interactive] [--json] +// --plan (default): resolve the nearest STATE.md, classify the remote +// (private → tracked, public/open → local-only), gather git +// facts. Read-only. Output JSON. The model uses this to write +// STATE.md. +// --commit : stage STATE.md (ONLY if it is NOT gitignored) plus any +// explicit --also paths, then commit. NEVER `git add -A`. +// Never pushes (push stays window-gated + user-triggered). +// --dry-run : never writes, never touches git. // -// Output (JSON to stdout): -// { -// "handoff_type": "multi-sesjon | plugin-arbeid | enkelt-oppgave", -// "write_dir": "/abs/path", -// "artifact_path": "/abs/path/NEXT-SESSION-...", -// "next_steps": [...], -// "git_status": { branch, dirty, ahead }, -// "commit_message": "...", -// "actions_taken": [...], -// "errors": [...] -// } +// Why STATE.md must never be auto-committed on a public mirror: a repo whose only +// remote is the public `open/` Forgejo would leak internal state-of-play on push. +// Per the global rule, such repos keep STATE.md local-only (gitignored). The +// authoritative signal here is `git check-ignore STATE.md`; remote_class is the +// advisory that warns on misconfiguration (public repo, STATE.md NOT gitignored). // +// Output (JSON to stdout): see buildPlan() / commit result below. // Exit codes: 0 = success (even if errors[] non-empty); 1 = unrecoverable internal error. -import { execSync, execFileSync, spawnSync } from 'node:child_process'; -import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, readdirSync } from 'node:fs'; -import { dirname, join, basename, resolve } from 'node:path'; -import { createInterface } from 'node:readline'; +import { execSync, execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join, basename } from 'node:path'; // ---------- Argument parsing ---------- function parseArgs(argv) { const args = { - slug: null, + mode: 'plan', // 'plan' | 'commit' dryRun: false, - noCommit: false, - noPush: false, - auto: false, - nonInteractive: false, - json: true, + message: null, + also: [], }; - for (const a of argv) { - if (a === '--dry-run') args.dryRun = true; - else if (a === '--no-commit') args.noCommit = true; - else if (a === '--no-push') args.noPush = true; - else if (a === '--auto') args.auto = true; - else if (a === '--non-interactive') args.nonInteractive = true; - else if (a === '--json') args.json = true; - else if (!a.startsWith('--') && !args.slug) args.slug = a; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--plan') args.mode = 'plan'; + else if (a === '--commit') args.mode = 'commit'; + else if (a === '--dry-run') args.dryRun = true; + else if (a === '--message' || a === '-m') args.message = argv[++i] || null; + else if (a === '--also') args.also.push(argv[++i]); + // unknown flags ignored for forward-compat } return args; } @@ -62,320 +59,190 @@ function gitOk(cmd, opts = {}) { } } -function gitStatus() { - const branch = gitOk('git branch --show-current') || gitOk('git rev-parse --abbrev-ref HEAD'); - const porcelain = gitOk('git status --porcelain') || ''; +function gitStatus(cwd) { + const o = { cwd }; + const branch = gitOk('git branch --show-current', o) || gitOk('git rev-parse --abbrev-ref HEAD', o); + const porcelain = gitOk('git status --porcelain', o) || ''; const dirty = porcelain.length > 0; let ahead = 0; - const upstream = gitOk('git rev-parse --abbrev-ref @{u} 2>/dev/null'); + const upstream = gitOk('git rev-parse --abbrev-ref @{u} 2>/dev/null', o); if (upstream) { - const counts = gitOk(`git rev-list --left-right --count ${upstream}...HEAD`); + const counts = gitOk(`git rev-list --left-right --count ${upstream}...HEAD`, o); if (counts) ahead = parseInt(counts.split(/\s+/)[1] || '0', 10); } const detached = !branch || branch === 'HEAD'; - return { branch, dirty, ahead, upstream, detached, porcelain }; + const dirtyFiles = porcelain.split('\n').filter(Boolean).map(line => line.slice(3)); + return { branch, dirty, ahead, upstream, detached, porcelain, dirtyFiles }; } -// ---------- Plugin-root detection ---------- +function repoRoot(cwd) { + return gitOk('git rev-parse --show-toplevel', { cwd }); +} -function findPluginRoot(startDir) { - let cur = startDir; - for (let i = 0; i < 5; i++) { - if (existsSync(join(cur, '.claude-plugin', 'plugin.json'))) return cur; +// ---------- STATE.md resolution ---------- + +// Walk from cwd up to (and including) the git repo root, returning the nearest +// existing STATE.md. Mirrors the global session-start hook's "nearest STATE.md +// from cwd up to repo root". If none exists, the target is /STATE.md. +function resolveStatePath(cwd, root) { + const stop = root || cwd; + let cur = cwd; + for (let i = 0; i < 12; i++) { + const candidate = join(cur, 'STATE.md'); + if (existsSync(candidate)) return { path: candidate, exists: true }; + if (cur === stop) break; const parent = dirname(cur); if (parent === cur) break; cur = parent; } - return null; + return { path: join(stop, 'STATE.md'), exists: false }; } -// ---------- Multi-session detection ---------- +// ---------- Remote classification ---------- + +// STATE.md must never reach a public mirror. Private remote → STATE tracked. +// Public (`github.com`, or the `open/` Forgejo mirror) or no remote → local-only. +export function classifyRemote(url) { + if (!url) return 'none'; + if (/github\.com|github\.io/i.test(url)) return 'public'; + // `open/` is the public mirror namespace; `ktg/` etc. is private. + if (/[:/]open\//i.test(url)) return 'public'; + return 'private'; +} + +// ---------- Plan (read-only) ---------- + +function buildPlan(cwd, errors) { + const root = repoRoot(cwd); + if (!root) { + errors.push('ikke et git-repo (git rev-parse --show-toplevel feilet) — kan ikke resolvere STATE.md-sti'); + } + const base = root || cwd; + const { path: statePath, exists: stateExists } = resolveStatePath(cwd, base); + + const remoteUrl = gitOk('git remote get-url origin 2>/dev/null', { cwd }) + || gitOk('git remote get-url --all origin 2>/dev/null', { cwd }); + const remoteClass = classifyRemote(remoteUrl); + const shouldBeLocalOnly = remoteClass !== 'private'; + + // Authoritative commit signal: is STATE.md gitignored? + const stateGitignored = !!gitOk( + `git check-ignore -- ${JSON.stringify(basename(statePath))}`, + { cwd: dirname(statePath) } + ); + + const leakWarning = (shouldBeLocalOnly && !stateGitignored) + ? `STATE.md er IKKE gitignored, men remote er ${remoteClass} (${remoteUrl || 'ingen'}) — ` + + `legg STATE.md i .gitignore for å unngå å lekke intern state-of-play ved push.` + : null; + + const git = gitStatus(cwd); + const recentCommits = (gitOk('git log --oneline -8', { cwd }) || '').split('\n').filter(Boolean); + + return { + mode: 'plan', + repo_root: root, + state_path: statePath, + state_name: basename(dirname(statePath)), // dir holding STATE.md → title "# STATE — " + state_exists: stateExists, + state_gitignored: stateGitignored, + remote_url: remoteUrl, + remote_class: remoteClass, + should_be_local_only: shouldBeLocalOnly, + should_commit_state: !stateGitignored, + leak_warning: leakWarning, + git_status: { branch: git.branch, dirty: git.dirty, ahead: git.ahead, detached: git.detached, upstream: git.upstream }, + dirty_files: git.dirtyFiles, + recent_commits: recentCommits, + line_budget: 60, + errors, + }; +} + +// ---------- Commit (write) ---------- + +function generateCommitMessage(statePath, root) { + const name = root ? basename(root) : basename(dirname(statePath)); + return `docs(${name}): oppdater STATE.md (session handoff)`; +} + +function doCommit(cwd, args, errors) { + const actions = []; + const root = repoRoot(cwd); + const base = root || cwd; + const { path: statePath } = resolveStatePath(cwd, base); + + const git = gitStatus(cwd); + if (git.detached) { + errors.push('detached HEAD — hopper over commit (ingen branch å committe på)'); + return { mode: 'commit', actions_taken: actions, errors, git_status: git }; + } + + const stateGitignored = !!gitOk( + `git check-ignore -- ${JSON.stringify(basename(statePath))}`, + { cwd: dirname(statePath) } + ); + + // Build the stage list. CRITICAL: never `git add -A` — stage ONLY STATE.md + // (when it is tracked, i.e. not gitignored) plus any explicit --also paths. + const stageList = []; + if (!stateGitignored) { + if (existsSync(statePath)) stageList.push(statePath); + else errors.push(`STATE.md finnes ikke på ${statePath} — skriv den før commit`); + } else { + actions.push('state-local-only-skipped (STATE.md gitignored — committes ikke)'); + } + for (const p of args.also) { + const abs = p.startsWith('/') ? p : join(cwd, p); + if (existsSync(abs)) stageList.push(abs); + else errors.push(`--also sti finnes ikke: ${p}`); + } + + if (stageList.length === 0) { + actions.push('intet-å-committe (STATE.md gitignored og ingen --also-stier)'); + return { mode: 'commit', actions_taken: actions, errors, git_status: git }; + } + + const message = args.message || generateCommitMessage(statePath, root); -function findActiveProject(cwd) { - // Look for .claude/projects/*/progress.json that is not completed try { - const out = execSync( - `find . -maxdepth 5 -path '*/.claude/projects/*/progress.json' 2>/dev/null | sort -r`, - { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] } - ).trim().split('\n').filter(Boolean); - for (const rel of out) { - const abs = resolve(cwd, rel); - try { - const data = JSON.parse(readFileSync(abs, 'utf-8')); - if (data.status && data.status !== 'completed' && data.status !== 'failed') { - return { progressPath: abs, projectDir: dirname(abs), status: data.status }; - } - } catch { /* skip malformed */ } - } - } catch { /* find failed */ } - return null; -} + execFileSync('git', ['add', '--', ...stageList], { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); + execFileSync('git', ['commit', '-m', message, '--', ...stageList], { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); + actions.push('committed'); + } catch (e) { + errors.push(`commit feilet: ${(e.stderr || e.message || '').toString().slice(0, 200)}`); + } -// ---------- Classification ---------- - -function classifyHandoff(cwd) { - const project = findActiveProject(cwd); - if (project) return { type: 'multi-sesjon', writeDir: project.projectDir, projectDir: project.projectDir }; - - const pluginRoot = findPluginRoot(cwd); - if (pluginRoot) return { type: 'plugin-arbeid', writeDir: pluginRoot, pluginRoot }; - - return { type: 'enkelt-oppgave', writeDir: cwd }; -} - -// ---------- Commit-message generation ---------- - -function generateCommitMessage(status) { - const files = status.porcelain.split('\n').filter(Boolean).map(line => line.slice(3)); - const tests = files.filter(f => f.includes('/tests/') || f.endsWith('.test.mjs') || f.endsWith('.test.js')).length; - const docs = files.filter(f => /\.(md|mdx)$/i.test(f) && !f.includes('/tests/')).length; - const code = files.length - tests - docs; - - let type = 'chore'; - if (code > 0 && code >= tests + docs) type = 'feat'; - else if (tests > 0 && tests >= code) type = 'test'; - else if (docs > 0 && docs >= code) type = 'docs'; - - // Scope = plugin name if all files in single plugin - const pluginMatch = files - .map(f => f.match(/^plugins\/([^/]+)/)) - .filter(Boolean) - .map(m => m[1]); - const uniquePlugins = [...new Set(pluginMatch)]; - const scope = uniquePlugins.length === 1 ? uniquePlugins[0] : ''; - - const subject = `wip: pågående arbeid (${files.length} fil${files.length === 1 ? '' : 'er'})`; - return scope ? `${type}(${scope}): ${subject}` : `${type}: ${subject}`; -} - -// ---------- Artifact rendering ---------- - -function renderArtifact(state, classification) { - const today = new Date().toISOString().slice(0, 10); - const branch = state.git.branch || 'HEAD'; - const lastCommits = (gitOk('git log --oneline -5') || '').split('\n').filter(Boolean); - - const lines = []; - lines.push(`# NEXT-SESSION-PROMPT — ${basename(classification.writeDir)} ${today}`); - lines.push(''); - lines.push('## Hvorfor dette eksisterer'); - lines.push(''); - lines.push(`Sesjons-handoff produsert av graceful-handoff v2.0 ${state.auto ? '(auto-trigget av Stop hook)' : '(manuell trigger)'}.`); - lines.push(`Type: \`${classification.type}\`. Branch: \`${branch}\`.`); - if (state.git.dirty) lines.push('Hadde ucommitted endringer ved handoff-tidspunkt.'); - lines.push(''); - lines.push('## Status ved sesjonshåndoff'); - lines.push(''); - lines.push('### ✅ Ferdig'); - lines.push(''); - if (lastCommits.length === 0) lines.push('- Ingen commits funnet.'); - else for (const c of lastCommits) lines.push(`- \`${c}\``); - lines.push(''); - lines.push('### ⏳ Ikke startet / delvis'); - lines.push(''); - lines.push('- Fyll inn av neste sesjon (graceful-handoff v2.0 pipeline genererer ikke dette automatisk; bruk manuell trigger for spesifikk plan-progresjon).'); - lines.push(''); - lines.push('### ⚠️ Brutt / kjent risiko'); - lines.push(''); - lines.push(state.git.dirty ? '- Uncommitted endringer ved handoff-tidspunkt — sjekk `git status`.' : '- Ingen kjente broken tester ved handoff.'); - lines.push(''); - lines.push('## Slik fortsetter du'); - lines.push(''); - lines.push(`1. \`cd ${classification.writeDir}\``); - lines.push(`2. \`cat ${state.artifactName}\` — les denne filen igjen`); - lines.push('3. `git log --oneline -5` og `git status`'); - lines.push('4. Fortsett fra siste pågående arbeid'); - lines.push(''); - lines.push('## Push-policy'); - lines.push(''); - lines.push('- Direkte push til `main` på Forgejo er pre-autorisert'); - lines.push('- Aldri GitHub — kun Forgejo (`git.fromaitochitta.com`)'); - lines.push(''); - lines.push('## Verifiseringskommandoer'); - lines.push(''); - lines.push('```bash'); - lines.push('git log --oneline -5'); - lines.push('git status'); - lines.push('```'); - lines.push(''); - lines.push('## Husk'); - lines.push(''); - lines.push('- Opus 4.7 fyller kontekst raskt — auto-trigger ved estimert 70% er enabled i graceful-handoff v2.0'); - lines.push('- Push gjenstår hvis dette var auto-handoff (Stop hook bruker `--no-push`)'); - lines.push(''); - return lines.join('\n'); + return { mode: 'commit', actions_taken: actions, committed_paths: stageList, commit_message: message, errors, git_status: git }; } // ---------- Main ---------- -async function main() { +function main() { const args = parseArgs(process.argv.slice(2)); const cwd = process.cwd(); const errors = []; - const actionsTaken = []; - // Validate flag combos - if (args.nonInteractive && !args.auto && !args.dryRun && !args.noCommit) { - errors.push('--non-interactive uten --auto er ikke gyldig (commit-bekreftelse må enten være interaktiv, auto-godkjent, eller skipped via --no-commit)'); - output({ args, cwd, classification: null, errors, actionsTaken }); + if (args.dryRun) { + const plan = buildPlan(cwd, errors); + plan.mode = 'dry-run'; + plan.note = 'dry-run: ingen skriving, ingen git-mutasjon'; + process.stdout.write(JSON.stringify(plan, null, 2) + '\n'); return; } - // 1. Get git state - const git = gitStatus(); - if (!git.branch && !args.dryRun) { - errors.push('Kunne ikke detektere git-state — er denne mappen et git-repo?'); - output({ args, cwd, classification: null, git, errors, actionsTaken }); - return; - } - - // 2. Classify handoff - const classification = classifyHandoff(cwd); - - // 3. Determine artifact path - const artifactName = args.slug ? `NEXT-SESSION-${args.slug}.local.md` : 'NEXT-SESSION-PROMPT.local.md'; - const artifactPath = join(classification.writeDir, artifactName); - - // 4. Idempotency check: if artifact exists and was modified < 60s ago, and no new git changes, no-op - if (!args.dryRun && existsSync(artifactPath) && !git.dirty) { - try { - const stat = statSync(artifactPath); - const ageMs = Date.now() - stat.mtimeMs; - if (ageMs < 60_000) { - output({ - args, cwd, classification, git, - artifactPath, commitMessage: '', - errors, actionsTaken: ['idempotent-no-op (recent artifact, clean tree)'], - nextSteps: nextStepsFor(classification, artifactName), - }); - return; - } - } catch { /* statSync failed; proceed */ } - } - - // 5. Generate commit message - const commitMessage = git.dirty ? generateCommitMessage(git) : ''; - - // 6. Build state for rendering - const state = { - git, - auto: args.auto, - artifactName, - }; - - // 7. Write artifact - const artifactContent = renderArtifact(state, classification); - if (!args.dryRun) { - try { - mkdirSync(classification.writeDir, { recursive: true }); - writeFileSync(artifactPath, artifactContent, 'utf-8'); - actionsTaken.push(`wrote-artifact: ${artifactPath}`); - } catch (e) { - errors.push(`artifact write failed: ${e.message}`); - } + let result; + if (args.mode === 'commit') { + result = doCommit(cwd, args, errors); } else { - actionsTaken.push(`dry-run: would write artifact to ${artifactPath}`); + result = buildPlan(cwd, errors); } - - // 8. Commit (unless --no-commit / --dry-run / nothing to commit) - if (!args.dryRun && !args.noCommit && (git.dirty || existsSync(artifactPath))) { - // Check robustness: detached HEAD, no remote - if (git.detached) { - errors.push('detached HEAD — skipping commit (no branch to commit on)'); - } else { - // Confirmation gate - let proceed = false; - if (args.auto) { - proceed = true; - } else if (args.nonInteractive) { - errors.push('non-interactive without --auto cannot confirm commit'); - } else { - // Interactive: print message to stderr, read y/n from stdin - process.stderr.write(`\nCommit-melding:\n---\n${commitMessage}\n---\nFortsett med commit? (y/n): `); - proceed = await readYesNo(); - } - if (proceed) { - try { - // CRITICAL: never `git add -A` — that scoops up unrelated work-in-progress. - // Stage ONLY the handoff artifact + optional REMEMBER.md/TODO.md if present. - // Other dirty files stay in working tree for the user. - const stageList = [artifactPath]; - for (const candidate of ['REMEMBER.md', 'TODO.md']) { - const p = join(classification.writeDir, candidate); - if (existsSync(p)) stageList.push(p); - } - execFileSync('git', ['add', '--', ...stageList], { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); - // git commit with -- pathspec limits commit to those paths from index. - execFileSync('git', ['commit', '-m', commitMessage, '--', ...stageList], { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); - actionsTaken.push('committed'); - } catch (e) { - errors.push(`commit failed: ${(e.stderr || e.message || '').toString().slice(0, 200)}`); - } - } else { - actionsTaken.push('commit-cancelled-by-user'); - } - } - } - - // 9. Push (unless --no-push / --dry-run / no commit happened) - if (!args.dryRun && !args.noPush && actionsTaken.includes('committed')) { - if (!git.upstream) { - errors.push('no upstream branch — skipping push (set with: git push -u origin )'); - } else { - try { - execFileSync('git', ['push', 'origin', git.branch], { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); - actionsTaken.push('pushed'); - } catch (e) { - errors.push(`push failed: ${(e.stderr || e.message || '').toString().slice(0, 200)}`); - } - } - } - - output({ - args, cwd, classification, git, - artifactPath, commitMessage, - errors, actionsTaken, - nextSteps: nextStepsFor(classification, artifactName), - }); -} - -function nextStepsFor(classification, artifactName) { - return [ - `cd ${classification.writeDir}`, - `cat ${artifactName}`, - 'git log --oneline -5', - 'git status', - 'Fortsett fra siste pågående arbeid (se artefakt-fil).', - ]; -} - -function output({ args, cwd, classification, git, artifactPath, commitMessage, errors, actionsTaken, nextSteps }) { - const result = { - handoff_type: classification?.type || 'unknown', - write_dir: classification?.writeDir || cwd, - artifact_path: artifactPath || null, - next_steps: nextSteps || [], - git_status: git ? { branch: git.branch, dirty: git.dirty, ahead: git.ahead, detached: git.detached } : null, - commit_message: commitMessage || '', - actions_taken: actionsTaken, - errors, - args: { dryRun: args.dryRun, noCommit: args.noCommit, noPush: args.noPush, auto: args.auto, slug: args.slug }, - }; process.stdout.write(JSON.stringify(result, null, 2) + '\n'); } -function readYesNo() { - return new Promise((resolveP) => { - const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: false }); - rl.question('', (answer) => { - rl.close(); - const normalized = (answer || '').trim().toLowerCase(); - resolveP(normalized === 'y' || normalized === 'yes' || normalized === 'ja' || normalized === 'j'); - }); - }); -} - -main().catch((e) => { +try { + main(); +} catch (e) { process.stderr.write(`pipeline-fatal: ${e.message}\n${e.stack}\n`); process.exit(1); -}); +} diff --git a/skills/graceful-handoff/SKILL.md b/skills/graceful-handoff/SKILL.md index 79c2865..3ddbe78 100644 --- a/skills/graceful-handoff/SKILL.md +++ b/skills/graceful-handoff/SKILL.md @@ -1,98 +1,127 @@ --- name: graceful-handoff -description: Produser handoff-artefakt, commit+push, og copy-paste-prompt for neste sesjon. Bruk når du nærmer deg 60-70% kontekst og må fortsette arbeidet i en ny sesjon uten tap. -argument-hint: "[topic-slug] [--no-commit] [--dry-run]" +description: Avslutt sesjonen kontrollert og overlever til neste. Nå første naturlige stoppunkt, overskriv nærmeste STATE.md komplett (med «👉 NESTE — START HER»-blokk), commit per remote-policy, og skriv den faste avslutningslinjen. Bruk når du vil overlevere uten kontekst-tap. +argument-hint: "[--no-commit] [--dry-run]" disable-model-invocation: true -model: claude-sonnet-4-6 -allowed-tools: Bash(git:*) Bash(jq:*) Bash(node:*) Bash(find:*) Bash(pwd:*) Read Write Glob +allowed-tools: Bash(git:*) Bash(node:*) Bash(date:*) Read Write Edit Glob --- -# Graceful Handoff — sesjonsoverlevering v2.0 +# Graceful Handoff — STATE.md-sesjonsoverlevering (v3.0) -Orkestrerer JSON-pipeline-skriptet og fyller copy-paste-template-en for neste sesjon. Selve pipelinen (state-deteksjon, classification, fil-skriving, commit, push) er deterministisk og lever i `scripts/handoff-pipeline.mjs`. Denne skill-en er en tynn wrapper. +Dette er **én-kommandos-eksekvering av «Session Slutt»-rituelen** fra den globale +CLAUDE.md, sentrert på STATE.md. Du (modellen) har full sesjonskontekst — bare du +kan fylle den obligatoriske «👉 NESTE — START HER»-blokken meningsfullt. Et slankt +deterministisk skript (`scripts/handoff-pipeline.mjs`) gjør kun de mekaniske bitene: +resolverer nærmeste STATE.md, klassifiserer remoten (privat → tracked, offentlig/`open/` +→ local-only), og committer trygt (KUN STATE.md, aldri `git add -A`). -**Tidsbudsjett:** Hele kjøringen skal ligge under 60 sekunder reell tid. Bruker er typisk på 60-70% kontekst når de trigger dette — ingen Agent-delegering, ingen WebSearch. +**Tidsbudsjett:** hele rituelet under 60 sekunder. Ingen Agent-delegering, ingen WebSearch. -## Hvordan kjøres +## Hvorfor STATE.md (ikke en NEXT-SESSION-artefakt) -1. **Parse `$ARGUMENTS`** (kombinert streng). Støtt flag i vilkårlig rekkefølge. - - `[topic-slug]` — kebab-case, styrer filnavnet - - `--no-commit` — hopp over commit/push, bruker håndterer manuelt - - `--dry-run` — print hva som ville skjedd, ingen filer/git - - `--no-push` — commit OK men ikke push (Stop hook bruker dette i auto-eksekvering) - - `--auto` — non-interactive, auto-Y på commit-bekreftelse (kun for hooks) - - `--non-interactive` — uten `--auto`: feil; med `--auto`: kjør uten prompts +Regimet har ÉTT kontinuitets-system: tre lag (`STATE.md` + auto-memory `MEMORY.md` + +`CLAUDE.md`). Lokale handover-påfunn (`NEXT-SESSION-PROMPT`, `HANDOVER.md`, `ROADMAP.md`) +er forbudt. `STATE.md` ER overleveringen. Den globale `session-start.sh` injiserer +nærmeste STATE.md automatisk ved neste sesjonsstart — denne skill-en skriver den, ingen +egen auto-load trengs. -2. **Kjør pipeline-skriptet:** - ```bash - node ${CLAUDE_PLUGIN_ROOT}/scripts/handoff-pipeline.mjs - ``` +## Rituelet (utfør i rekkefølge) -3. **Parse JSON-output** fra stdout. Forventet schema: - ```json - { - "handoff_type": "multi-sesjon | plugin-arbeid | enkelt-oppgave", - "write_dir": "/abs/path", - "artifact_path": "/abs/path/NEXT-SESSION-...", - "next_steps": ["..."], - "git_status": { "branch": "...", "dirty": true, "ahead": 2 }, - "commit_message": "...", - "actions_taken": ["wrote artifact", "committed", "pushed"], - "errors": [] - } - ``` +### 1. Nå første naturlige stoppunkt +Ikke kutt midt i en endring. Fullfør gjeldende logiske enhet, la koden i fungerende +tilstand (tester grønne om mulig). «Avslutt ved første naturlige tidspunkt» — ikke +tving fram et kunstig stopp, men ikke start nytt arbeid heller. -4. **Hvis `errors[]` non-empty:** rapporter feilene til bruker, ikke fortsett. Foreslå manuelle skritt fra `next_steps`. +### 2. Hent deterministiske fakta +```bash +node ${CLAUDE_PLUGIN_ROOT}/scripts/handoff-pipeline.mjs --plan +``` +Parse JSON: +```json +{ + "state_path": "/abs/STATE.md", "state_exists": true, + "state_gitignored": true, "should_commit_state": false, + "remote_class": "private|public|none", "should_be_local_only": false, + "leak_warning": null, + "git_status": { "branch": "...", "dirty": true, "ahead": 1, "detached": false, "upstream": "..." }, + "dirty_files": ["..."], "recent_commits": ["..."], "line_budget": 60, + "errors": [] +} +``` +Hvis `errors[]` ikke er tom: rapporter og stopp. Hvis `leak_warning` er satt: nevn det +for bruker (STATE.md burde vært gitignored på denne remoten). -5. **Hvis interaktiv (default):** Skriptet skriver commit-bekreftelses-prompten til stderr. Modellen leser stderr-output og presenterer Y/n-valget til bruker via AskUserQuestion. Send svaret tilbake til skriptet via stdin. (NB: I denne skill-konteksten kan modellen også vise commit-meldingen direkte og spørre — fleksibelt.) +### 3. Skriv/overskriv STATE.md — UFRAVIKELIG FORMAT +Skriv `state_path` (Write, overskriv — ALDRI append). Maks ~60 linjer (kun første 160 +injiseres). Struktur, i denne rekkefølgen: -6. **Når ferdig:** Print copy-paste-prompt fra `next_steps` JSON til bruker: +```markdown +# STATE — +_Current state-of-play. Overskrives (ikke append) ved sesjonsslutt. Historikk → git._ - ``` - ════════════════════════════════════════════════════════════ - NESTE SESJON — copy-paste til ny Claude: - ════════════════════════════════════════════════════════════ +## 👉 NESTE — START HER +**<én setning: hvor vi er + hva som er neste konkrete handling>** +1. **(du/meg)** +2. **(du/meg)** + - cd - cat - git log --oneline -5 - git status +## ← fast/uforanderlig: hva er målet, hvordan jobbes det +## ← det neste sesjon MÅ vite for ikke å trå feil +## ← hva er pushet vs parkert, og push-vindu-regel +## ← branch, remote-klasse, tester, sentrale tall +## ← KORT; det meste hører i git +``` - Fortsett fra . +Krav til innholdet: +- **👉 NESTE øverst, alltid** — rett etter tittel+undertittel, FØR alt annet. Den bærer + det handlingsrettede. Operatøren skal aldri måtte lete. +- Konkret, ikke vagt. «Kjør `node --test tests/`, bekreft grønn, så release-cut» — ikke + «fortsett arbeidet». +- Skill mellom verifisert og antatt. Aldri skriv «gå til X» med mindre X eksisterer. +- `[[memory-slug]]`-lenker til relevante auto-memory-fakta der det hjelper. +- Selvtilstrekkelig for kald start: en fersk sesjon som KUN leser STATE.md skal vite nok. - ════════════════════════════════════════════════════════════ - Artefakt: - Commit: - Push: <"pushet til Forgejo" eller "skippet (flag / ingenting)"> - ════════════════════════════════════════════════════════════ - ``` +### 4. Commit (med mindre `--no-commit` eller `--dry-run`) +```bash +node ${CLAUDE_PLUGIN_ROOT}/scripts/handoff-pipeline.mjs --commit [--also ...] [-m "melding"] +``` +- Stager **KUN STATE.md** (hvis ikke gitignored) + eksplisitte `--also`-stier du + vurderer hører til denne enheten. **ALDRI `git add -A`.** +- På repo med `open/`/offentlig remote er STATE.md gitignored → den committes IKKE + (local-only). Skriptet rapporterer `state-local-only-skipped`. Da committer du kun + ev. kode-endringer (via `--also`) og sier fra at STATE er local-only. +- Pre-commit hooks respekteres — aldri `--no-verify`. -## Når brukes den +### 5. Push — vindu-gated, aldri automatisk +```bash +date '+%u %H:%M' # ukedag 1-7 + tid, norsk lokaltid +``` +Sperret vindu: **man–tor 08:00–17:00** og **fre 08:00–16:00**. Ellers fritt. +- I vindu: `git push origin ` (kun Forgejo, ALDRI GitHub). +- Utenfor vindu: IKKE push. Hold commiten lokalt og si eksplisitt at pushen er parkert + til neste vindu åpner. -- **Manuelt:** kjør `/graceful-handoff` selv ved 60-70% kontekst -- **Automatisk:** Stop hook kaller `handoff-pipeline.mjs --auto --no-push` ved estimert ≥70%. Skill-en invokeres IKKE i auto-modus — hook-en kaller skriptet direkte for å bevare `disable-model-invocation: true`. +### 6. MEMORY.md-sjekk (lett) +Hvis auto-memory `MEMORY.md` finnes og er > 200 linjer: varsle bruker (flytt detalj til +topic-filer). Ikke auto-rediger den her. -## Hva blir skrevet +### 7. Avslutningslinje (FAST — droppes aldri) +Avslutt svaret med eksakt tre ting (speiler den globale avslutningsinstruksen): +- **STATE.md:** oppdatert ✅ + sti (eller «ikke endret» + hvorfor). +- **Neste sesjon:** den konkrete første handlingen (samme tekst som 👉 NESTE-blokken). +- **Commit/push:** hash + pushet/parkert-status. -- `NEXT-SESSION-PROMPT.local.md` (eller `NEXT-SESSION-.local.md`) i riktig WRITE_DIR -- `REMEMBER.md` oppdatert hvis den finnes -- `TODO.md` oppdatert hvis den finnes -- Git commit + push (med mindre `--no-commit` eller `--no-push`) +## Flagg -## Push-policy +| Flagg | Effekt | +|-------|--------| +| `--no-commit` | Skriv STATE.md, men hopp over commit/push (du committer manuelt) | +| `--dry-run` | Skriv ingenting, ingen git — vis `--plan`-JSON for inspeksjon | -- Direkte push til `main` på Forgejo er pre-autorisert -- Aldri GitHub — kun Forgejo (`git.fromaitochitta.com`) -- Pre-commit hooks respekteres uten `--no-verify` +## Begrensninger / antakelser (smoke-test) -## Begrensninger (v2.0) - -- Auto-eksekvering ved kontekst-terskel er approksimasjon basert på transcript-størrelse, ikke Claude's reelle kontekst-måling. Estimat kan avvike ±10% — terskel satt konservativt til 70%. -- statusLine-plassering i `hooks/hooks.json` er antakelse; smoke-test før release. -- `disable-model-invocation: true` har en åpen issue (#26251) som potensielt kan blokkere user-invocation. Verifiser med smoke-test. - -## Feilsøking - -- Pipeline-skriptet feiler: kjør med `--dry-run` for å se hva det ville gjort -- Git-state uvanlig (detached HEAD, ingen remote): pipeline returnerer `errors[]`, ikke crash -- Stop hook trigger for tidlig/sent: terskel kan justeres i `hooks/scripts/stop-context-monitor.mjs` (look for `0.70`) +- `disable-model-invocation: true` har en åpen issue (#26251) som potensielt kan blokkere + user-invocation. Smoke-test at `/graceful-handoff` fungerer etter installasjon. +- Remote-klassifisering er heuristisk (`open/`/`github` → offentlig). Den autoritative + commit-beslutningen er likevel `git check-ignore STATE.md` — så feilklassifisering + fører ikke til lekkasje, kun til et `leak_warning` hvis konfigurasjonen er inkonsistent. diff --git a/tests/hooks/hook-helper.mjs b/tests/hooks/hook-helper.mjs deleted file mode 100644 index 8c22a8c..0000000 --- a/tests/hooks/hook-helper.mjs +++ /dev/null @@ -1,42 +0,0 @@ -// hook-helper.mjs — Shared test helper for hook scripts. -// Spawns a hook as a child process and feeds it JSON via stdin. - -import { execFile } from 'node:child_process'; - -/** - * Run a hook script by spawning `node ` and piping `input` to stdin. - * - * @param {string} scriptPath - Absolute path to the hook .mjs file - * @param {object|string} input - JSON payload (object will be stringified) - * @returns {Promise<{ code: number, stdout: string, stderr: string }>} - */ -export function runHook(scriptPath, input) { - return runHookWithEnv(scriptPath, input, {}); -} - -/** - * Run a hook script with custom environment variables. - * - * @param {string} scriptPath - Absolute path to the hook .mjs file - * @param {object|string} input - JSON payload (object will be stringified) - * @param {Record} envOverrides - Extra env vars to set - * @returns {Promise<{ code: number, stdout: string, stderr: string }>} - */ -export function runHookWithEnv(scriptPath, input, envOverrides) { - return new Promise((resolve) => { - const env = { ...process.env, ...envOverrides }; - const child = execFile( - 'node', - [scriptPath], - { timeout: 5000, env }, - (err, stdout, stderr) => { - resolve({ - code: child.exitCode ?? (err && err.code === 'ERR_CHILD_PROCESS_STDIO_FINAL' ? 0 : 1), - stdout: stdout || '', - stderr: stderr || '', - }); - } - ); - child.stdin.end(typeof input === 'string' ? input : JSON.stringify(input)); - }); -} diff --git a/tests/hooks/session-start-load-handoff.test.mjs b/tests/hooks/session-start-load-handoff.test.mjs deleted file mode 100644 index 6d6d2bb..0000000 --- a/tests/hooks/session-start-load-handoff.test.mjs +++ /dev/null @@ -1,108 +0,0 @@ -// session-start-load-handoff.test.mjs - -import { test } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { runHook } from './hook-helper.mjs'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const HOOK = join(__dirname, '..', '..', 'hooks', 'scripts', 'session-start-load-handoff.mjs'); - -function makeFixture() { - return mkdtempSync(join(tmpdir(), 'sessionstart-')); -} - -test('source: startup → silent (no injection)', async () => { - const dir = makeFixture(); - writeFileSync(join(dir, 'NEXT-SESSION-PROMPT.local.md'), 'should not load\n'); - const res = await runHook(HOOK, { source: 'startup', cwd: dir }); - assert.equal(res.code, 0); - assert.equal(res.stdout.trim(), '', 'startup source should not inject'); - assert.ok(existsSync(join(dir, 'NEXT-SESSION-PROMPT.local.md')), 'file should not be archived'); - rmSync(dir, { recursive: true, force: true }); -}); - -test('source: clear → silent (no injection)', async () => { - const dir = makeFixture(); - writeFileSync(join(dir, 'NEXT-SESSION-PROMPT.local.md'), 'should not load\n'); - const res = await runHook(HOOK, { source: 'clear', cwd: dir }); - assert.equal(res.code, 0); - assert.equal(res.stdout.trim(), ''); - rmSync(dir, { recursive: true, force: true }); -}); - -test('source: resume + handoff in cwd → injected and archived', async () => { - const dir = makeFixture(); - writeFileSync(join(dir, 'NEXT-SESSION-PROMPT.local.md'), '# my handoff\n\nimportant content\n'); - const res = await runHook(HOOK, { source: 'resume', cwd: dir }); - assert.equal(res.code, 0); - // Stdout should be JSON with additionalContext containing the file - const json = JSON.parse(res.stdout); - assert.equal(json.hookSpecificOutput.hookEventName, 'SessionStart'); - assert.match(json.hookSpecificOutput.additionalContext, /important content/); - assert.match(json.hookSpecificOutput.additionalContext, / { - const dir = makeFixture(); - writeFileSync(join(dir, 'NEXT-SESSION-PROMPT.local.md'), '# compact handoff\n'); - const res = await runHook(HOOK, { source: 'compact', cwd: dir }); - assert.equal(res.code, 0); - const json = JSON.parse(res.stdout); - assert.match(json.hookSpecificOutput.additionalContext, /compact handoff/); - rmSync(dir, { recursive: true, force: true }); -}); - -test('source: resume + handoff 2 levels above cwd → found and injected', async () => { - const root = makeFixture(); - const sub = join(root, 'a', 'b'); - mkdirSync(sub, { recursive: true }); - writeFileSync(join(root, 'NEXT-SESSION-PROMPT.local.md'), '# parent handoff\n'); - const res = await runHook(HOOK, { source: 'resume', cwd: sub }); - assert.equal(res.code, 0); - const json = JSON.parse(res.stdout); - assert.match(json.hookSpecificOutput.additionalContext, /parent handoff/); - // Archived in the original parent location - assert.ok(existsSync(join(root, 'NEXT-SESSION-PROMPT.archived.local.md'))); - rmSync(root, { recursive: true, force: true }); -}); - -test('source: resume + no handoff anywhere → silent', async () => { - const dir = makeFixture(); - const res = await runHook(HOOK, { source: 'resume', cwd: dir }); - assert.equal(res.code, 0); - assert.equal(res.stdout.trim(), ''); - rmSync(dir, { recursive: true, force: true }); -}); - -test('source: resume + topic-slug variant NEXT-SESSION-foo.local.md → found', async () => { - const dir = makeFixture(); - writeFileSync(join(dir, 'NEXT-SESSION-feature-x.local.md'), '# topic handoff\n'); - const res = await runHook(HOOK, { source: 'resume', cwd: dir }); - assert.equal(res.code, 0); - const json = JSON.parse(res.stdout); - assert.match(json.hookSpecificOutput.additionalContext, /topic handoff/); - rmSync(dir, { recursive: true, force: true }); -}); - -test('archived files are not re-loaded on subsequent runs', async () => { - const dir = makeFixture(); - writeFileSync(join(dir, 'NEXT-SESSION-PROMPT.archived.local.md'), 'stale - should not load\n'); - const res = await runHook(HOOK, { source: 'resume', cwd: dir }); - assert.equal(res.code, 0); - assert.equal(res.stdout.trim(), '', 'archived files must be ignored'); - rmSync(dir, { recursive: true, force: true }); -}); - -test('malformed JSON payload: silent exit 0', async () => { - const res = await runHook(HOOK, '{not valid'); - assert.equal(res.code, 0); - assert.equal(res.stdout.trim(), ''); -}); diff --git a/tests/hooks/statusline-monitor.test.mjs b/tests/hooks/statusline-monitor.test.mjs deleted file mode 100644 index 533a878..0000000 --- a/tests/hooks/statusline-monitor.test.mjs +++ /dev/null @@ -1,78 +0,0 @@ -// statusline-monitor.test.mjs — Tests statusLine hook display thresholds. - -import { test } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { runHook } from './hook-helper.mjs'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const HOOK = join(__dirname, '..', '..', 'hooks', 'scripts', 'statusline-monitor.mjs'); - -function payload(usedPercentage) { - return { - context_window: { - used_percentage: usedPercentage, - remaining_percentage: usedPercentage == null ? null : 100 - usedPercentage, - context_window_size: 200000, - }, - model: { id: 'claude-opus-4-7', display_name: 'Opus' }, - session_id: 'test-session', - }; -} - -test('< 60%: silent, no output', async () => { - const res = await runHook(HOOK, payload(45)); - assert.equal(res.code, 0); - assert.equal(res.stdout.trim(), '', `expected empty stdout, got: "${res.stdout}"`); -}); - -test('60-69%: prints "vurder /graceful-handoff" hint with "60" or "kontekst" substring', async () => { - const res = await runHook(HOOK, payload(63)); - assert.equal(res.code, 0); - assert.match(res.stdout, /kontekst/); - assert.match(res.stdout, /vurder.*graceful-handoff/); -}); - -test('≥ 70%: prints stronger hint with "kjør NÅ"', async () => { - const res = await runHook(HOOK, payload(75)); - assert.equal(res.code, 0); - assert.match(res.stdout, /kontekst/); - assert.match(res.stdout, /kjør.*graceful-handoff.*NÅ/i); -}); - -test('exact threshold 60%: shows hint (not silent)', async () => { - const res = await runHook(HOOK, payload(60)); - assert.equal(res.code, 0); - assert.match(res.stdout, /60/); -}); - -test('exact threshold 70%: shows urgent hint', async () => { - const res = await runHook(HOOK, payload(70)); - assert.equal(res.code, 0); - assert.match(res.stdout, /NÅ/); -}); - -test('null used_percentage: silent (early session before first API call)', async () => { - const res = await runHook(HOOK, payload(null)); - assert.equal(res.code, 0); - assert.equal(res.stdout.trim(), ''); -}); - -test('missing context_window field: silent', async () => { - const res = await runHook(HOOK, { model: { id: 'foo' }, session_id: 'x' }); - assert.equal(res.code, 0); - assert.equal(res.stdout.trim(), ''); -}); - -test('empty stdin: silent', async () => { - const res = await runHook(HOOK, ''); - assert.equal(res.code, 0); - assert.equal(res.stdout.trim(), ''); -}); - -test('malformed JSON: silent (no crash)', async () => { - const res = await runHook(HOOK, '{not json'); - assert.equal(res.code, 0); - assert.equal(res.stdout.trim(), ''); -}); diff --git a/tests/hooks/stop-context-monitor.test.mjs b/tests/hooks/stop-context-monitor.test.mjs deleted file mode 100644 index 2ebeb8c..0000000 --- a/tests/hooks/stop-context-monitor.test.mjs +++ /dev/null @@ -1,236 +0,0 @@ -// stop-context-monitor.test.mjs — Tests for Stop hook auto-execute logic. -// Uses runHook to spawn the script as a subprocess and inspect its behavior -// via temporary fixture files (real fs) — simpler than mocking imports. - -import { test } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { mkdtempSync, writeFileSync, existsSync, rmSync, statSync, mkdirSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { runHookWithEnv } from './hook-helper.mjs'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const HOOK = join(__dirname, '..', '..', 'hooks', 'scripts', 'stop-context-monitor.mjs'); -const PLUGIN_ROOT = join(__dirname, '..', '..'); - -function setup(transcriptSize) { - const dir = mkdtempSync(join(tmpdir(), 'stop-hook-')); - const transcriptPath = join(dir, 'transcript.jsonl'); - // Generate transcript content of exact size (chars) - writeFileSync(transcriptPath, 'a'.repeat(transcriptSize), 'utf-8'); - return { dir, transcriptPath }; -} - -// Build a stub plugin root with a fake handoff-pipeline.mjs that returns -// canned JSON. This prevents tests from invoking the real pipeline (which -// does git operations against whatever repo the test process happens to be in). -function makeStubPluginRoot() { - const dir = mkdtempSync(join(tmpdir(), 'stub-plugin-root-')); - const scriptsDir = join(dir, 'scripts'); - mkdirSync(scriptsDir); - const stub = `#!/usr/bin/env node -process.stdout.write(JSON.stringify({ - handoff_type: 'plugin-arbeid', - write_dir: '/tmp/stub', - artifact_path: '/tmp/stub/NEXT-SESSION-PROMPT.local.md', - next_steps: [], - git_status: { branch: 'main', dirty: false, ahead: 0 }, - commit_message: '', - actions_taken: ['stub-no-op'], - errors: [], -})); -process.exit(0); -`; - writeFileSync(join(scriptsDir, 'handoff-pipeline.mjs'), stub, 'utf-8'); - return dir; -} - -function cleanup(dir) { - rmSync(dir, { recursive: true, force: true }); -} - -test('estimated < 70%: no spawn, no lock file', async () => { - // 200k window × 70% threshold = 140k tokens × 3.5 chars = 490k chars - // Use 400k chars (~57%) — well under threshold - const { dir, transcriptPath } = setup(400_000); - const res = await runHookWithEnv(HOOK, { - transcript_path: transcriptPath, - session_id: 'test-1', - context_window: { context_window_size: 200_000 }, - }, { CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT }); - assert.equal(res.code, 0); - assert.equal(res.stdout.trim(), '', `expected silent, got: ${res.stdout}`); - assert.ok(!existsSync(join(dir, '.handoff-lock-test-1')), 'no lock should be written below threshold'); - cleanup(dir); -}); - -test('estimated ≥ 70% + no lock: lock created, stub pipeline spawned', async () => { - // 600k chars / 3.5 = 171k tokens / 200k = 86% — well above threshold - const { dir, transcriptPath } = setup(600_000); - const stubRoot = makeStubPluginRoot(); - const res = await runHookWithEnv(HOOK, { - transcript_path: transcriptPath, - session_id: 'test-2', - context_window: { context_window_size: 200_000 }, - }, { CLAUDE_PLUGIN_ROOT: stubRoot }); - assert.equal(res.code, 0); - // Lock file must exist - assert.ok(existsSync(join(dir, '.handoff-lock-test-2')), 'lock file should be created'); - // additionalContext should mention auto-handoff (stub returns no errors → success path) - assert.match(res.stdout, /Auto-handoff utført/i); - cleanup(dir); - cleanup(stubRoot); -}); - -test('estimated ≥ 70% + lock exists: no spawn, no output', async () => { - const { dir, transcriptPath } = setup(600_000); - // Pre-create the lock file - writeFileSync(join(dir, '.handoff-lock-test-3'), 'pre-existing', 'utf-8'); - const res = await runHookWithEnv(HOOK, { - transcript_path: transcriptPath, - session_id: 'test-3', - context_window: { context_window_size: 200_000 }, - }, { CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT }); - assert.equal(res.code, 0); - assert.equal(res.stdout.trim(), '', `expected silent (lock exists), got: ${res.stdout}`); - cleanup(dir); -}); - -test('missing transcript_path: silent exit 0', async () => { - const res = await runHookWithEnv(HOOK, { session_id: 'test-4' }, { CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT }); - assert.equal(res.code, 0); - assert.equal(res.stdout.trim(), ''); -}); - -test('non-existent transcript file: silent exit 0', async () => { - const res = await runHookWithEnv(HOOK, { - transcript_path: '/tmp/does-not-exist-' + Date.now() + '.jsonl', - session_id: 'test-5', - }, { CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT }); - assert.equal(res.code, 0); - assert.equal(res.stdout.trim(), ''); -}); - -test('uses context_window_size from payload (not hardcoded 200k)', async () => { - // 1M context window × 70% = 700k tokens × 3.5 = 2.45M chars to trigger - // 600k chars on a 1M window is only ~17% — should NOT trigger - const { dir, transcriptPath } = setup(600_000); - const res = await runHookWithEnv(HOOK, { - transcript_path: transcriptPath, - session_id: 'test-6', - context_window: { context_window_size: 1_000_000 }, - }, { CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT }); - assert.equal(res.code, 0); - assert.equal(res.stdout.trim(), '', `expected silent on 1M window, got: ${res.stdout}`); - assert.ok(!existsSync(join(dir, '.handoff-lock-test-6'))); - cleanup(dir); -}); - -test('CLAUDE_PLUGIN_ROOT missing: graceful error message', async () => { - const { dir, transcriptPath } = setup(600_000); - const res = await runHookWithEnv(HOOK, { - transcript_path: transcriptPath, - session_id: 'test-7', - context_window: { context_window_size: 200_000 }, - }, {}); // no CLAUDE_PLUGIN_ROOT - assert.equal(res.code, 0); - assert.match(res.stdout, /CLAUDE_PLUGIN_ROOT not set/); - cleanup(dir); -}); - -// --- v2.1: 4-step context resolution ----------------------------------- - -test('prefers used_percentage from payload over transcript estimate', async () => { - // Big transcript that would trigger via size-estimate (600k chars / 200k window ≈ 86%), - // but used_percentage says 25% — direct path must win. - const { dir, transcriptPath } = setup(600_000); - const res = await runHookWithEnv(HOOK, { - transcript_path: transcriptPath, - session_id: 'test-8', - context_window: { context_window_size: 200_000, used_percentage: 25 }, - }, { CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT }); - assert.equal(res.code, 0); - assert.equal(res.stdout.trim(), '', `expected silent (used_percentage=25), got: ${res.stdout}`); - assert.ok(!existsSync(join(dir, '.handoff-lock-test-8')), 'no lock should be written when used_percentage is below threshold'); - cleanup(dir); -}); - -test('used_percentage triggers above threshold even with tiny transcript', async () => { - // Tiny transcript would never trigger via size-estimate, but used_percentage=75 must. - const { dir, transcriptPath } = setup(1_000); - const stubRoot = makeStubPluginRoot(); - const res = await runHookWithEnv(HOOK, { - transcript_path: transcriptPath, - session_id: 'test-9', - context_window: { context_window_size: 200_000, used_percentage: 75 }, - }, { CLAUDE_PLUGIN_ROOT: stubRoot }); - assert.equal(res.code, 0); - assert.ok(existsSync(join(dir, '.handoff-lock-test-9')), 'lock file should be created when used_percentage ≥ 70%'); - assert.match(res.stdout, /Auto-handoff utført/i); - assert.match(res.stdout, /kilde: direct/, 'message should label source as direct'); - cleanup(dir); - cleanup(stubRoot); -}); - -test('model-mapping: Opus 4.7 resolves to 1M window (no trigger at 17%)', async () => { - // 600k chars / 3.5 = 171k tokens / 1M = 17% — well under threshold. - // No context_window in payload — must fall through to model-map. - const { dir, transcriptPath } = setup(600_000); - const res = await runHookWithEnv(HOOK, { - transcript_path: transcriptPath, - session_id: 'test-10', - model: { id: 'claude-opus-4-7' }, - }, { CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT }); - assert.equal(res.code, 0); - assert.equal(res.stdout.trim(), '', `expected silent on Opus 4.7 1M window at 17%, got: ${res.stdout}`); - assert.ok(!existsSync(join(dir, '.handoff-lock-test-10'))); - cleanup(dir); -}); - -test('model-mapping: Haiku resolves to 200k window (triggers at 86%)', async () => { - // 600k chars / 3.5 = 171k tokens / 200k = 86% — above threshold. - const { dir, transcriptPath } = setup(600_000); - const stubRoot = makeStubPluginRoot(); - const res = await runHookWithEnv(HOOK, { - transcript_path: transcriptPath, - session_id: 'test-11', - model: { id: 'claude-haiku-4-5-20251001' }, - }, { CLAUDE_PLUGIN_ROOT: stubRoot }); - assert.equal(res.code, 0); - assert.ok(existsSync(join(dir, '.handoff-lock-test-11')), 'lock should fire on Haiku 200k window at 86%'); - assert.match(res.stdout, /kilde: model-map/, 'message should label source as model-map'); - cleanup(dir); - cleanup(stubRoot); -}); - -test('default fallback (1M) when neither used_percentage nor model is in payload', async () => { - // 600k chars / 3.5 = 171k tokens / 1M = 17% — must NOT trigger with new 1M default. - const { dir, transcriptPath } = setup(600_000); - const res = await runHookWithEnv(HOOK, { - transcript_path: transcriptPath, - session_id: 'test-12', - // intentionally no context_window, no model - }, { CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT }); - assert.equal(res.code, 0); - assert.equal(res.stdout.trim(), '', `expected silent on default 1M fallback at 17%, got: ${res.stdout}`); - assert.ok(!existsSync(join(dir, '.handoff-lock-test-12'))); - cleanup(dir); -}); - -test('null used_percentage falls through to size-based path', async () => { - // Early-session payloads may have used_percentage: null. We must NOT treat that - // as 0 and skip the size-estimate. With size=200k and 600k chars (~86%) we trigger. - const { dir, transcriptPath } = setup(600_000); - const stubRoot = makeStubPluginRoot(); - const res = await runHookWithEnv(HOOK, { - transcript_path: transcriptPath, - session_id: 'test-13', - context_window: { context_window_size: 200_000, used_percentage: null }, - }, { CLAUDE_PLUGIN_ROOT: stubRoot }); - assert.equal(res.code, 0); - assert.ok(existsSync(join(dir, '.handoff-lock-test-13')), 'lock should fire via size-fallback when used_percentage is null'); - assert.match(res.stdout, /kilde: payload-size/, 'message should label source as payload-size'); - cleanup(dir); - cleanup(stubRoot); -}); diff --git a/tests/plugin-manifest.test.mjs b/tests/plugin-manifest.test.mjs index ae8347c..fa34d9f 100644 --- a/tests/plugin-manifest.test.mjs +++ b/tests/plugin-manifest.test.mjs @@ -1,4 +1,4 @@ -// plugin-manifest.test.mjs — verify plugin.json schema for v2.1 +// plugin-manifest.test.mjs — verify plugin.json schema + CHANGELOG for v3.0. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; @@ -10,17 +10,14 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const MANIFEST = join(__dirname, '..', '.claude-plugin', 'plugin.json'); const CHANGELOG = join(__dirname, '..', 'CHANGELOG.md'); -test('plugin.json version is 2.1.0', () => { +test('plugin.json version is 3.0.0', () => { const m = JSON.parse(readFileSync(MANIFEST, 'utf-8')); - assert.equal(m.version, '2.1.0'); + assert.equal(m.version, '3.0.0'); }); -test('CHANGELOG has [2.1.0] entry mentioning model-aware fix', () => { - const c = readFileSync(CHANGELOG, 'utf-8'); - assert.match(c, /## \[2\.1\.0\]/); - const match = c.match(/## \[2\.1\.0\][\s\S]*?(?=## \[2\.0\.0\]|$)/); - assert.ok(match, '[2.1.0] section missing'); - assert.match(match[0], /modell-bevisst|model-aware|resolveContextSource/i); +test('plugin.json description mentions STATE.md', () => { + const m = JSON.parse(readFileSync(MANIFEST, 'utf-8')); + assert.match(m.description, /STATE\.md/); }); test('plugin.json does NOT include auto_discover (not in documented schema)', () => { @@ -28,27 +25,23 @@ test('plugin.json does NOT include auto_discover (not in documented schema)', () assert.ok(!('auto_discover' in m), 'auto_discover field should be removed'); }); -test('plugin.json description mentions auto-trigger or context-threshold', () => { - const m = JSON.parse(readFileSync(MANIFEST, 'utf-8')); - assert.match(m.description, /auto-trigger|context-threshold/i); -}); - -test('CHANGELOG has [2.0.0] entry', () => { +test('CHANGELOG has [3.0.0] entry with BREAKING section mentioning STATE.md', () => { const c = readFileSync(CHANGELOG, 'utf-8'); - assert.match(c, /## \[2\.0\.0\]/); -}); - -test('CHANGELOG [2.0.0] entry has BREAKING section', () => { - const c = readFileSync(CHANGELOG, 'utf-8'); - // Get content from [2.0.0] until next ## or end - const match = c.match(/## \[2\.0\.0\][\s\S]*?(?=## \[1\.0\.0\]|$)/); - assert.ok(match, '[2.0.0] section missing'); + const match = c.match(/## \[3\.0\.0\][\s\S]*?(?=## \[2\.1\.0\]|$)/); + assert.ok(match, '[3.0.0] section missing'); assert.match(match[0], /### BREAKING/); + assert.match(match[0], /STATE\.md/); }); -test('No source files reference version 1.0.0', () => { - const m = JSON.parse(readFileSync(MANIFEST, 'utf-8')); - // Manifest is the canonical source — check it doesn't accidentally still say 1.0.0 - const raw = readFileSync(MANIFEST, 'utf-8'); - assert.doesNotMatch(raw, /"version":\s*"1\.0\.0"/); +test('CHANGELOG preserves [2.1.0] and [2.0.0] history', () => { + const c = readFileSync(CHANGELOG, 'utf-8'); + assert.match(c, /## \[2\.1\.0\]/); + assert.match(c, /## \[2\.0\.0\]/); + const v20 = c.match(/## \[2\.0\.0\][\s\S]*?(?=## \[1\.0\.0\]|$)/); + assert.ok(v20 && /### BREAKING/.test(v20[0]), '[2.0.0] BREAKING section should remain'); +}); + +test('No source files reference version 1.0.0 / 2.x as current', () => { + const raw = readFileSync(MANIFEST, 'utf-8'); + assert.doesNotMatch(raw, /"version":\s*"[12]\./); }); diff --git a/tests/scripts/handoff-pipeline.test.mjs b/tests/scripts/handoff-pipeline.test.mjs index 5416ee1..b2556f5 100644 --- a/tests/scripts/handoff-pipeline.test.mjs +++ b/tests/scripts/handoff-pipeline.test.mjs @@ -1,184 +1,202 @@ -// handoff-pipeline.test.mjs — Tests for scripts/handoff-pipeline.mjs. +// handoff-pipeline.test.mjs — Tests for scripts/handoff-pipeline.mjs (v3.0 STATE helper). import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { execFileSync, spawn } from 'node:child_process'; -import { existsSync, mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs'; +import { existsSync, writeFileSync, rmSync, mkdtempSync, realpathSync } from 'node:fs'; import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { tmpdir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); const SCRIPT = join(__dirname, '..', '..', 'scripts', 'handoff-pipeline.mjs'); -function makeTempRepo() { - const dir = mkdtempSync(join(tmpdir(), 'gh-pipeline-')); +function makeTempRepo({ remote = null, gitignoreState = false } = {}) { + // realpath: on macOS tmpdir() is a /var → /private/var symlink, but + // `git rev-parse --show-toplevel` returns the canonical path. Canonicalise + // here so derived expected paths match the script's git-resolved output. + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'gh-pipeline-'))); execFileSync('git', ['init', '-q'], { cwd: dir }); execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir }); execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir }); - // Initial commit so HEAD exists + if (remote) execFileSync('git', ['remote', 'add', 'origin', remote], { cwd: dir }); writeFileSync(join(dir, 'README.md'), '# test\n', 'utf-8'); + if (gitignoreState) writeFileSync(join(dir, '.gitignore'), 'STATE.md\n', 'utf-8'); execFileSync('git', ['add', '.'], { cwd: dir }); execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir }); return dir; } -function runPipeline(repo, args = [], { stdin = '' } = {}) { +function runPipeline(cwd, args = []) { return new Promise((resolveP) => { - const child = spawn('node', [SCRIPT, ...args], { cwd: repo, stdio: ['pipe', 'pipe', 'pipe'] }); + const child = spawn('node', [SCRIPT, ...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; child.stdout.on('data', (d) => (stdout += d.toString())); child.stderr.on('data', (d) => (stderr += d.toString())); child.on('close', (code) => resolveP({ code, stdout, stderr })); - if (stdin) child.stdin.write(stdin); - child.stdin.end(); }); } -test('--dry-run returns valid JSON with required keys', async () => { +// ---------- classifyRemote (unit) ---------- + +test('classifyRemote: none / public / private', async () => { + const { classifyRemote } = await import(pathToFileURL(SCRIPT).href); + assert.equal(classifyRemote(null), 'none'); + assert.equal(classifyRemote(''), 'none'); + assert.equal(classifyRemote('https://github.com/foo/bar.git'), 'public'); + assert.equal(classifyRemote('ssh://git@git.fromaitochitta.com/open/graceful-handoff.git'), 'public'); + assert.equal(classifyRemote('ssh://git@git.fromaitochitta.com/ktg/secret.git'), 'private'); +}); + +// ---------- --plan ---------- + +test('--plan returns JSON with required keys', async () => { const repo = makeTempRepo(); - const result = await runPipeline(repo, ['--dry-run']); + const result = await runPipeline(repo, ['--plan']); assert.equal(result.code, 0, `non-zero exit: ${result.stderr}`); const json = JSON.parse(result.stdout); - assert.ok(json.handoff_type, 'handoff_type missing'); - assert.ok(json.write_dir, 'write_dir missing'); - assert.ok(Array.isArray(json.next_steps), 'next_steps missing'); - assert.ok(Array.isArray(json.actions_taken), 'actions_taken missing'); - assert.ok(Array.isArray(json.errors), 'errors missing'); - assert.ok(json.git_status, 'git_status missing'); + for (const k of ['state_path', 'state_exists', 'state_gitignored', 'remote_class', + 'should_be_local_only', 'should_commit_state', 'git_status', 'dirty_files', + 'recent_commits', 'line_budget', 'errors']) { + assert.ok(k in json, `missing key: ${k}`); + } + assert.ok(json.git_status.branch, 'branch missing'); rmSync(repo, { recursive: true, force: true }); }); -test('--dry-run is idempotent (two runs produce same JSON shape)', async () => { +test('--plan (default mode) targets /STATE.md when none exists', async () => { const repo = makeTempRepo(); - const a = await runPipeline(repo, ['--dry-run']); - const b = await runPipeline(repo, ['--dry-run']); - const aJson = JSON.parse(a.stdout); - const bJson = JSON.parse(b.stdout); - assert.equal(aJson.handoff_type, bJson.handoff_type); - assert.equal(aJson.write_dir, bJson.write_dir); - assert.deepEqual(aJson.next_steps, bJson.next_steps); - rmSync(repo, { recursive: true, force: true }); -}); - -test('--non-interactive without --auto is invalid', async () => { - const repo = makeTempRepo(); - // Add dirty state so commit phase would activate - writeFileSync(join(repo, 'foo.txt'), 'change\n'); - const result = await runPipeline(repo, ['--non-interactive']); - assert.equal(result.code, 0); // pipeline always exits 0 on logical errors + const result = await runPipeline(repo, []); // default = plan const json = JSON.parse(result.stdout); - assert.ok(json.errors.some(e => /non-interactive/i.test(e)), `expected non-interactive error, got: ${JSON.stringify(json.errors)}`); + assert.equal(json.state_exists, false); + assert.equal(json.state_path, join(repo, 'STATE.md')); rmSync(repo, { recursive: true, force: true }); }); -test('--auto on dirty repo writes artifact and commits without prompting', async () => { +test('--plan resolves nearest existing STATE.md (subdir wins over root)', async () => { const repo = makeTempRepo(); - writeFileSync(join(repo, 'foo.txt'), 'change\n'); - // No upstream — push will be skipped via no-upstream error, but commit should succeed - const result = await runPipeline(repo, ['--auto', '--non-interactive', '--no-push']); - assert.equal(result.code, 0); + const sub = join(repo, 'plugins', 'x'); + execFileSync('mkdir', ['-p', sub]); + writeFileSync(join(repo, 'STATE.md'), '# root\n'); + writeFileSync(join(sub, 'STATE.md'), '# sub\n'); + const result = await runPipeline(sub, ['--plan']); const json = JSON.parse(result.stdout); - assert.ok(json.actions_taken.some(a => a.startsWith('wrote-artifact')), `expected wrote-artifact, got: ${JSON.stringify(json.actions_taken)}`); - assert.ok(json.actions_taken.includes('committed'), `expected committed, got: ${JSON.stringify(json.actions_taken)}`); - // Verify artifact file actually exists on disk - assert.ok(existsSync(json.artifact_path), `artifact path ${json.artifact_path} should exist`); + assert.equal(json.state_path, join(sub, 'STATE.md')); + assert.equal(json.state_exists, true); rmSync(repo, { recursive: true, force: true }); }); -test('--no-commit skips git operations even when dirty', async () => { - const repo = makeTempRepo(); - writeFileSync(join(repo, 'foo.txt'), 'change\n'); - const result = await runPipeline(repo, ['--no-commit', '--auto']); +test('--plan: public open/ remote → should_be_local_only, leak_warning when STATE not gitignored', async () => { + const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/open/foo.git' }); + const result = await runPipeline(repo, ['--plan']); const json = JSON.parse(result.stdout); - assert.ok(!json.actions_taken.includes('committed'), 'should not commit with --no-commit'); - assert.ok(!json.actions_taken.includes('pushed'), 'should not push without commit'); + assert.equal(json.remote_class, 'public'); + assert.equal(json.should_be_local_only, true); + assert.ok(json.leak_warning, 'expected leak_warning when public remote and STATE not gitignored'); rmSync(repo, { recursive: true, force: true }); }); -test('idempotency: second --auto run on clean tree with recent artifact is no-op', async () => { - const repo = makeTempRepo(); - writeFileSync(join(repo, 'foo.txt'), 'change\n'); - // First run: dirty, writes artifact and commits ONLY the artifact (not foo.txt) - await runPipeline(repo, ['--auto', '--non-interactive', '--no-push']); - // Clean up the unrelated dirty file so second run sees a CLEAN tree. - // The pipeline must NEVER auto-stage user's other dirty files (CLAUDE.md - // anti-pattern) — the test explicitly removes it to isolate idempotency. - rmSync(join(repo, 'foo.txt')); - // Second run: clean tree, recent artifact exists → idempotent no-op - const result = await runPipeline(repo, ['--auto', '--non-interactive', '--no-push']); +test('--plan: public remote + STATE gitignored → no leak_warning, should_commit_state false', async () => { + const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/open/foo.git', gitignoreState: true }); + const result = await runPipeline(repo, ['--plan']); const json = JSON.parse(result.stdout); - assert.ok( - json.actions_taken.some(a => a.includes('idempotent')), - `expected idempotent no-op, got: ${JSON.stringify(json.actions_taken)}` - ); + assert.equal(json.remote_class, 'public'); + assert.equal(json.state_gitignored, true); + assert.equal(json.should_commit_state, false); + assert.equal(json.leak_warning, null); rmSync(repo, { recursive: true, force: true }); }); -test('pipeline never stages unrelated dirty files (no git add -A regression)', async () => { - const repo = makeTempRepo(); - // Two unrelated dirty files — pipeline should NOT commit them +test('--plan: private remote → should_commit_state true, not local-only', async () => { + const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' }); + const result = await runPipeline(repo, ['--plan']); + const json = JSON.parse(result.stdout); + assert.equal(json.remote_class, 'private'); + assert.equal(json.should_be_local_only, false); + assert.equal(json.should_commit_state, true); + rmSync(repo, { recursive: true, force: true }); +}); + +// ---------- --commit ---------- + +test('--commit on private repo stages and commits ONLY STATE.md', async () => { + const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' }); + writeFileSync(join(repo, 'STATE.md'), '# STATE\n'); + writeFileSync(join(repo, 'unrelated.txt'), 'user work\n'); + const result = await runPipeline(repo, ['--commit']); + const json = JSON.parse(result.stdout); + assert.ok(json.actions_taken.includes('committed'), `expected committed, got ${JSON.stringify(json.actions_taken)}`); + const head = execFileSync('git', ['show', '--name-only', '--pretty=', 'HEAD'], { cwd: repo, encoding: 'utf-8' }) + .trim().split('\n').filter(Boolean); + assert.deepEqual(head, ['STATE.md'], `HEAD should contain only STATE.md, got ${JSON.stringify(head)}`); + rmSync(repo, { recursive: true, force: true }); +}); + +test('--commit never stages unrelated dirty files (no git add -A regression)', async () => { + const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' }); + writeFileSync(join(repo, 'STATE.md'), '# STATE\n'); writeFileSync(join(repo, 'unrelated-1.txt'), 'user work\n'); - writeFileSync(join(repo, 'unrelated-2.md'), '# user notes\n'); - await runPipeline(repo, ['--auto', '--non-interactive', '--no-push']); - // After commit, unrelated files must STILL be in working tree (not committed) - const { execFileSync } = await import('node:child_process'); - const lastCommit = execFileSync('git', ['show', '--name-only', '--pretty=', 'HEAD'], { - cwd: repo, encoding: 'utf-8', - }).trim().split('\n').filter(Boolean); - assert.ok(!lastCommit.includes('unrelated-1.txt'), `unrelated-1.txt should NOT be in HEAD commit, got: ${lastCommit}`); - assert.ok(!lastCommit.includes('unrelated-2.md'), `unrelated-2.md should NOT be in HEAD commit, got: ${lastCommit}`); - // The artifact SHOULD be in HEAD - assert.ok(lastCommit.some(f => f.includes('NEXT-SESSION')), `artifact should be in HEAD, got: ${lastCommit}`); - // unrelated files still untracked + writeFileSync(join(repo, 'unrelated-2.md'), '# notes\n'); + await runPipeline(repo, ['--commit']); const status = execFileSync('git', ['status', '--porcelain'], { cwd: repo, encoding: 'utf-8' }); assert.match(status, /unrelated-1\.txt/); assert.match(status, /unrelated-2\.md/); rmSync(repo, { recursive: true, force: true }); }); -test('detached HEAD is detected and reported (no commit attempted)', async () => { - const repo = makeTempRepo(); - // Detach HEAD +test('--commit with --also includes the explicit related path', async () => { + const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' }); + writeFileSync(join(repo, 'STATE.md'), '# STATE\n'); + writeFileSync(join(repo, 'code.mjs'), 'export const x = 1;\n'); + writeFileSync(join(repo, 'untouched.txt'), 'leave me\n'); + await runPipeline(repo, ['--commit', '--also', 'code.mjs']); + const head = execFileSync('git', ['show', '--name-only', '--pretty=', 'HEAD'], { cwd: repo, encoding: 'utf-8' }) + .trim().split('\n').filter(Boolean).sort(); + assert.deepEqual(head, ['STATE.md', 'code.mjs']); + const status = execFileSync('git', ['status', '--porcelain'], { cwd: repo, encoding: 'utf-8' }); + assert.match(status, /untouched\.txt/); + rmSync(repo, { recursive: true, force: true }); +}); + +test('--commit on public repo does NOT commit gitignored STATE.md', async () => { + const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/open/foo.git', gitignoreState: true }); + writeFileSync(join(repo, 'STATE.md'), '# STATE local-only\n'); + const result = await runPipeline(repo, ['--commit']); + const json = JSON.parse(result.stdout); + assert.ok( + json.actions_taken.some(a => /local-only-skipped|intet-å-committe/.test(a)), + `expected local-only skip, got ${JSON.stringify(json.actions_taken)}` + ); + assert.ok(!json.actions_taken.includes('committed'), 'should not commit gitignored STATE.md'); + // STATE.md stays untracked/ignored, working tree otherwise clean + const tracked = execFileSync('git', ['ls-files', 'STATE.md'], { cwd: repo, encoding: 'utf-8' }).trim(); + assert.equal(tracked, '', 'STATE.md must not become tracked on a public repo'); + rmSync(repo, { recursive: true, force: true }); +}); + +test('--commit on detached HEAD is detected (no commit attempted)', async () => { + const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' }); const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf-8' }).trim(); execFileSync('git', ['checkout', '-q', sha], { cwd: repo }); - writeFileSync(join(repo, 'foo.txt'), 'change\n'); - const result = await runPipeline(repo, ['--auto', '--non-interactive', '--no-push']); + writeFileSync(join(repo, 'STATE.md'), '# STATE\n'); + const result = await runPipeline(repo, ['--commit']); const json = JSON.parse(result.stdout); - assert.ok(json.errors.some(e => /detached HEAD/i.test(e)), `expected detached HEAD error, got: ${JSON.stringify(json.errors)}`); + assert.ok(json.errors.some(e => /detached HEAD/i.test(e)), `expected detached HEAD error, got ${JSON.stringify(json.errors)}`); assert.ok(!json.actions_taken.includes('committed'), 'should not commit on detached HEAD'); rmSync(repo, { recursive: true, force: true }); }); -test('no-upstream branch is detected on push attempt', async () => { - const repo = makeTempRepo(); - writeFileSync(join(repo, 'foo.txt'), 'change\n'); - // No remote/upstream — pipeline tries to push, gets no-upstream error - const result = await runPipeline(repo, ['--auto', '--non-interactive']); - const json = JSON.parse(result.stdout); - assert.ok(json.errors.some(e => /upstream/i.test(e)), `expected upstream error, got: ${JSON.stringify(json.errors)}`); - rmSync(repo, { recursive: true, force: true }); -}); +// ---------- --dry-run ---------- -test('interactive: stdin "n" cancels commit', async () => { - const repo = makeTempRepo(); - writeFileSync(join(repo, 'foo.txt'), 'change\n'); - const result = await runPipeline(repo, [], { stdin: 'n\n' }); +test('--dry-run writes nothing and creates no commit', async () => { + const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' }); + const before = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf-8' }).trim(); + const result = await runPipeline(repo, ['--dry-run']); const json = JSON.parse(result.stdout); - assert.ok( - json.actions_taken.some(a => /cancelled/i.test(a)), - `expected commit-cancelled-by-user, got: ${JSON.stringify(json.actions_taken)}` - ); - assert.ok(!json.actions_taken.includes('committed'), 'should not commit when user says n'); - rmSync(repo, { recursive: true, force: true }); -}); - -test('interactive: stdin "y" confirms commit', async () => { - const repo = makeTempRepo(); - writeFileSync(join(repo, 'foo.txt'), 'change\n'); - const result = await runPipeline(repo, ['--no-push'], { stdin: 'y\n' }); - const json = JSON.parse(result.stdout); - assert.ok(json.actions_taken.includes('committed'), `expected committed, got: ${JSON.stringify(json.actions_taken)}`); + assert.equal(json.mode, 'dry-run'); + assert.ok(!existsSync(join(repo, 'STATE.md')), 'dry-run must not write STATE.md'); + const after = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf-8' }).trim(); + assert.equal(before, after, 'dry-run must not create a commit'); rmSync(repo, { recursive: true, force: true }); }); diff --git a/tests/skill-structure.test.mjs b/tests/skill-structure.test.mjs index 1e16f0a..3c41b40 100644 --- a/tests/skill-structure.test.mjs +++ b/tests/skill-structure.test.mjs @@ -1,4 +1,4 @@ -// skill-structure.test.mjs — Verifies SKILL.md frontmatter and commands/ deletion. +// skill-structure.test.mjs — Verifies SKILL.md frontmatter (v3.0) and commands/ deletion. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; @@ -8,54 +8,62 @@ import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = join(__dirname, '..'); +const SKILL = join(PLUGIN_ROOT, 'skills', 'graceful-handoff', 'SKILL.md'); + +function skill() { + return readFileSync(SKILL, 'utf-8'); +} test('SKILL.md exists at expected path', () => { - const skillPath = join(PLUGIN_ROOT, 'skills', 'graceful-handoff', 'SKILL.md'); - assert.ok(existsSync(skillPath), `SKILL.md missing at ${skillPath}`); + assert.ok(existsSync(SKILL), `SKILL.md missing at ${SKILL}`); }); test('commands/ directory is deleted (hard cut to skills/)', () => { - const commandsDir = join(PLUGIN_ROOT, 'commands'); - assert.ok(!existsSync(commandsDir), 'commands/ directory still exists — should be deleted in v2.0'); + assert.ok(!existsSync(join(PLUGIN_ROOT, 'commands')), 'commands/ should be deleted'); +}); + +test('hooks/ directory is deleted (v3.0 removed all hooks)', () => { + assert.ok(!existsSync(join(PLUGIN_ROOT, 'hooks')), 'hooks/ should be deleted in v3.0'); }); test('SKILL.md has disable-model-invocation: true', () => { - const skillPath = join(PLUGIN_ROOT, 'skills', 'graceful-handoff', 'SKILL.md'); - const content = readFileSync(skillPath, 'utf-8'); - assert.match(content, /^disable-model-invocation: true$/m); + assert.match(skill(), /^disable-model-invocation: true$/m); }); -test('SKILL.md has model: claude-sonnet-4-6', () => { - const skillPath = join(PLUGIN_ROOT, 'skills', 'graceful-handoff', 'SKILL.md'); - const content = readFileSync(skillPath, 'utf-8'); - assert.match(content, /^model: claude-sonnet-4-6$/m); +test('SKILL.md has NO model: pin (inherits session model for quality synthesis)', () => { + const fm = skill().match(/^---\n[\s\S]*?\n---/)[0]; + assert.doesNotMatch(fm, /^model:/m); }); -test('SKILL.md has Bash sub-scoped allowed-tools', () => { - const skillPath = join(PLUGIN_ROOT, 'skills', 'graceful-handoff', 'SKILL.md'); - const content = readFileSync(skillPath, 'utf-8'); - assert.match(content, /Bash\(git:\*\)/); - assert.match(content, /Bash\(node:\*\)/); +test('SKILL.md allowed-tools is Bash sub-scoped and includes Write', () => { + const line = skill().match(/^allowed-tools:.*$/m); + assert.ok(line, 'allowed-tools line missing'); + assert.match(line[0], /Bash\(git:\*\)/); + assert.match(line[0], /Bash\(node:\*\)/); + assert.match(line[0], /\bWrite\b/); }); test('SKILL.md does not pre-approve curl or wget', () => { - const skillPath = join(PLUGIN_ROOT, 'skills', 'graceful-handoff', 'SKILL.md'); - const content = readFileSync(skillPath, 'utf-8'); - // Frontmatter only — find the allowed-tools line - const allowedToolsLine = content.match(/^allowed-tools:.*$/m); - assert.ok(allowedToolsLine, 'allowed-tools line missing'); - assert.doesNotMatch(allowedToolsLine[0], /\bcurl\b/); - assert.doesNotMatch(allowedToolsLine[0], /\bwget\b/); + const line = skill().match(/^allowed-tools:.*$/m); + assert.ok(line, 'allowed-tools line missing'); + assert.doesNotMatch(line[0], /\bcurl\b/); + assert.doesNotMatch(line[0], /\bwget\b/); }); test('SKILL.md body references handoff-pipeline.mjs', () => { - const skillPath = join(PLUGIN_ROOT, 'skills', 'graceful-handoff', 'SKILL.md'); - const content = readFileSync(skillPath, 'utf-8'); - assert.match(content, /handoff-pipeline\.mjs/); + assert.match(skill(), /handoff-pipeline\.mjs/); +}); + +test('SKILL.md body mandates the 👉 NESTE — START HER block', () => { + assert.match(skill(), /👉 NESTE — START HER/); }); test('SKILL.md body has Tidsbudsjett (time budget) note', () => { - const skillPath = join(PLUGIN_ROOT, 'skills', 'graceful-handoff', 'SKILL.md'); - const content = readFileSync(skillPath, 'utf-8'); - assert.match(content, /Tidsbudsjett/); + assert.match(skill(), /Tidsbudsjett/); +}); + +test('SKILL.md is STATE.md-centric (overwrites the nearest STATE.md)', () => { + const s = skill(); + assert.match(s, /STATE\.md/); + assert.match(s, /overskriv/i); });