feat(graceful-handoff)!: integrate with STATE.md continuity system (v3.0.0)
BREAKING: replace the NEXT-SESSION artifact + 3 hooks with a STATE.md-centric, skill-only design. /graceful-handoff now overwrites the nearest STATE.md (with the mandatory 👉 NESTE block) instead of writing a separate handover file. - Remove hooks/ entirely: Stop auto-trigger (operator choice), SessionStart loader (redundant with global session-start.sh), statusLine hint (dead — user settings win). - Invert the pipeline: the model writes STATE.md (only it has the context for 👉 NESTE); handoff-pipeline.mjs becomes a slim deterministic helper (--plan / --commit / --dry-run). - Remote-aware policy: STATE.md tracked on private remotes, local-only (gitignored) on public/open mirrors. Authoritative signal: git check-ignore STATE.md. - SKILL.md rewritten as the Session-Slutt ritual; dropped the Sonnet model pin. - Docs (README, CLAUDE.md, CHANGELOG), plugin.json 2.1.0→3.0.0, .gitignore cleanup. - Tests rewritten for --plan/--commit; no-`git add -A` regression preserved. 30/30 green. Release-cut (tag v3.0.0 + catalog ref bump) pending — separate gated action. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SiKr4c6GAzQH5n6E6f5NA
This commit is contained in:
parent
a6f3ad4e93
commit
2c4e5e425b
18 changed files with 694 additions and 1656 deletions
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,7 +4,6 @@ STATE.md
|
|||
REMEMBER.md
|
||||
ROADMAP.md
|
||||
TODO.md
|
||||
NEXT-SESSION-PROMPT*.local.md
|
||||
*.local.md
|
||||
*.local.json
|
||||
*.local.sh
|
||||
|
|
|
|||
22
CHANGELOG.md
22
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
|
||||
|
|
|
|||
95
CLAUDE.md
95
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 — <navn>` + 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: <source>]` 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 `<transcript_dir>/.handoff-lock-<session_id>` 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 <versjon>` (atomisk). Krever push-vindu + operatør-go.
|
||||
|
|
|
|||
333
README.md
333
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)*
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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 — <name>
|
||||
_Current state-of-play. Overwritten (not appended) at session end. History → git._
|
||||
|
||||
## 👉 NESTE — START HER
|
||||
**<one line: where we are + the next concrete action>**
|
||||
1. **(you/me)** <concrete step>
|
||||
2. **(you/me)** <concrete step>
|
||||
<pointers: which files / plans / memory MUST be read to stand on its own>
|
||||
|
||||
## 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<br/>60% hint, 70% urgent"]
|
||||
SH["Stop hook<br/>≥70% estimated"]
|
||||
end
|
||||
|
||||
subgraph Pipeline["Deterministic pipeline (Node, no LLM)"]
|
||||
direction LR
|
||||
P["handoff-pipeline.mjs<br/>classify → write → stage → commit"]
|
||||
end
|
||||
|
||||
subgraph Resumption["Resumption — auto-load"]
|
||||
direction LR
|
||||
SS["SessionStart hook<br/>resume / compact only"]
|
||||
AR["Archive after read<br/>*.archived.local.md"]
|
||||
end
|
||||
|
||||
subgraph Manual["Manual fallback"]
|
||||
direction LR
|
||||
SK["SKILL.md<br/>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<br/>3. WRITE STATE.md (👉 NESTE block)<br/>7. fixed closing line"]
|
||||
end
|
||||
|
||||
subgraph Helper["handoff-pipeline.mjs — deterministic, no LLM"]
|
||||
PLAN["--plan<br/>resolve nearest STATE.md<br/>classify remote · git facts"]
|
||||
COMMIT["--commit<br/>stage ONLY STATE.md (+ --also)<br/>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: <source>]` 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:** `<transcript_dir>/.handoff-lock-<session_id>` 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 <path>` 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/<repo>` (public Forgejo mirror) | public | local-only (gitignored), never committed |
|
||||
| `…/ktg/<repo>` (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-<slug>.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/<root>/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: <source>]` 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: <source>]` 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`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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: `<session-handoff source="${handoffPath}" loaded-by="graceful-handoff">\n${content}\n</session-handoff>`,
|
||||
},
|
||||
};
|
||||
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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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 <transcript_dir>/.handoff-lock-<session_id> 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();
|
||||
}
|
||||
|
|
@ -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 <repoRoot>/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/<repo>` is the public mirror namespace; `ktg/<repo>` 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 — <name>"
|
||||
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 <branch>)');
|
||||
} 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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <args>
|
||||
```
|
||||
## 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 — <state_name>
|
||||
_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)** <konkret steg>
|
||||
2. **(du/meg)** <konkret steg>
|
||||
<pekere: hvilke filer/planer/memory som MÅ leses for å stå på egne ben>
|
||||
|
||||
cd <absolute-WRITE_DIR>
|
||||
cat <NEXT-SESSION-filnavn>
|
||||
git log --oneline -5
|
||||
git status
|
||||
## <Oppdrag & kjøremodus> ← fast/uforanderlig: hva er målet, hvordan jobbes det
|
||||
## <Gotchas / ufravikelige regler> ← det neste sesjon MÅ vite for ikke å trå feil
|
||||
## <Push-status> ← hva er pushet vs parkert, og push-vindu-regel
|
||||
## <Repo & env> ← branch, remote-klasse, tester, sentrale tall
|
||||
## <Sesjonssekvens / historikk> ← KORT; det meste hører i git
|
||||
```
|
||||
|
||||
Fortsett fra <konkret neste handling — én setning>.
|
||||
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: <full sti til NEXT-SESSION-filen>
|
||||
Commit: <siste SHA eller "ingen endringer">
|
||||
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 <relatert-fil>...] [-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 <branch>` (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-<slug>.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.
|
||||
|
|
|
|||
|
|
@ -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 <scriptPath>` 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<string, string>} 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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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, /<session-handoff/);
|
||||
// File should be archived
|
||||
assert.ok(!existsSync(join(dir, 'NEXT-SESSION-PROMPT.local.md')), 'original should be renamed');
|
||||
assert.ok(existsSync(join(dir, 'NEXT-SESSION-PROMPT.archived.local.md')), 'archive should exist');
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('source: compact + handoff in cwd → injected and archived', async () => {
|
||||
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(), '');
|
||||
});
|
||||
|
|
@ -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(), '');
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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]\./);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 <repoRoot>/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 });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue