cigar-break 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cigar/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Cigar: notices when a coding agent's session is slipping, and hands off across a reset."""
2
+
3
+ __version__ = "0.1.0"
cigar/auto.py ADDED
@@ -0,0 +1,70 @@
1
+ """Auto mode markers (D-007, D-021, D-026).
2
+
3
+ In `auto` mode, once a handoff is saved for the session, the hook writes
4
+ <CIGAR_HOME>/auto/<session id>.json with state "ready" and the handoff's path.
5
+ The mod in mods/auto.ts reads it at the end of the turn, marks it "fired" and
6
+ runs /clear. With no marker, or one without a handoff path, it never clears.
7
+ Only these markers cross between the two.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import time
14
+ from pathlib import Path
15
+
16
+ MAX_AGE_SECONDS = 24 * 3600
17
+ FIRED_MAX_AGE_SECONDS = 600 # a clear that fired longer ago than this is not "just now"
18
+
19
+
20
+ def marker_path(home: Path, session_id: str) -> Path:
21
+ safe = "".join(c for c in session_id if c.isalnum() or c in "-_")
22
+ return home / "auto" / f"{safe}.json"
23
+
24
+
25
+ def mark_ready(home: Path, session_id: str, handoff: str) -> Path:
26
+ path = marker_path(home, session_id)
27
+ path.parent.mkdir(parents=True, exist_ok=True)
28
+ tmp = path.with_suffix(".tmp")
29
+ marker = {"state": "ready", "session": session_id, "handoff": handoff, "ts": time.time()}
30
+ tmp.write_text(json.dumps(marker), encoding="utf-8")
31
+ tmp.replace(path)
32
+ return path
33
+
34
+
35
+ def take_fired(home: Path, max_age: float = FIRED_MAX_AGE_SECONDS) -> str | None:
36
+ """The handoff path of a clear the mod just fired, removing its marker."""
37
+ folder = home / "auto"
38
+ if not folder.is_dir():
39
+ return None
40
+ found: tuple[float, str] | None = None
41
+ cutoff = time.time() - max_age
42
+ for path in folder.glob("*.json"):
43
+ try:
44
+ marker = json.loads(path.read_text(encoding="utf-8"))
45
+ if marker.get("state") != "fired" or path.stat().st_mtime < cutoff:
46
+ continue
47
+ handoff = marker.get("handoff")
48
+ if isinstance(handoff, str) and handoff:
49
+ stamp = path.stat().st_mtime
50
+ if found is None or stamp > found[0]:
51
+ found = (stamp, handoff)
52
+ path.unlink()
53
+ except (OSError, ValueError, AttributeError):
54
+ continue
55
+ return found[1] if found else None
56
+
57
+
58
+ def prune(home: Path, max_age: float = MAX_AGE_SECONDS) -> None:
59
+ """Remove fired markers and any older than max_age."""
60
+ folder = home / "auto"
61
+ if not folder.is_dir():
62
+ return
63
+ cutoff = time.time() - max_age
64
+ for path in folder.glob("*.json"):
65
+ try:
66
+ fired = json.loads(path.read_text(encoding="utf-8")).get("state") == "fired"
67
+ if fired or path.stat().st_mtime < cutoff:
68
+ path.unlink()
69
+ except (OSError, ValueError, AttributeError):
70
+ path.unlink(missing_ok=True)
cigar/cli.py ADDED
@@ -0,0 +1,128 @@
1
+ """`cigar` command line: status, report, share and mode (FR-C6)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from cigar import __version__, config, status
12
+ from cigar.store import Store, project_hash
13
+
14
+ MODE_HELP = {
15
+ "suggest": "Cigar nudges the agent and you when the session shows fatigue (default).",
16
+ "observe": "Cigar records signals only, with no nudges.",
17
+ "auto": (
18
+ "After a handoff is saved, Cigar clears the context itself at the end of the turn and "
19
+ "restores the handoff. Needs plugin mods (Claude Code 2.1.287 or newer); tested in the "
20
+ "terminal CLI only."
21
+ ),
22
+ }
23
+
24
+
25
+ def cmd_status(args: argparse.Namespace) -> int:
26
+ cfg = config.load()
27
+ project_dir = args.project or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
28
+ project = project_hash(str(Path(project_dir)))
29
+ with Store.open(cfg.db_path) as store:
30
+ info = status.collect(store, cfg, args.session, project)
31
+ print(json.dumps(info, indent=2) if args.json else status.render(info))
32
+ return 0
33
+
34
+
35
+ def cmd_mode(args: argparse.Namespace) -> int:
36
+ cfg = config.load()
37
+ mode = (args.mode or "").strip().lower()
38
+ if not mode:
39
+ print(f"Mode: {cfg.mode}. {MODE_HELP[cfg.mode]}")
40
+ return 0
41
+ if mode not in config.MODES:
42
+ print(f"Unknown mode {mode!r}. Choose one of: {', '.join(config.MODES)}.")
43
+ return 2
44
+ args.mode = mode
45
+ path = config.write_value(cfg.home, "mode", args.mode)
46
+ note = ""
47
+ if os.environ.get("CIGAR_MODE") and os.environ["CIGAR_MODE"] != args.mode:
48
+ note = f" Note: CIGAR_MODE={os.environ['CIGAR_MODE']} is set and overrides it."
49
+ print(f"Mode set to {args.mode} in {path}. {MODE_HELP[args.mode]}{note}")
50
+ return 0
51
+
52
+
53
+ def cmd_report(args: argparse.Namespace) -> int:
54
+ from cigar import report, track
55
+
56
+ cfg = config.load()
57
+ with Store.open(cfg.db_path) as store:
58
+ fits = track.refresh_knees(store, force=True) or []
59
+ compactions = store.compaction_tokens()
60
+ print(report.render_text(fits, compactions))
61
+ if args.html or args.open:
62
+ path = report.write_html(cfg.home, report.render_html(fits, compactions))
63
+ print(f"\nHTML report: {path}")
64
+ if args.open:
65
+ import webbrowser
66
+
67
+ webbrowser.open(path.as_uri())
68
+ return 0
69
+
70
+
71
+ def cmd_share(args: argparse.Namespace) -> int:
72
+ from cigar import share, track
73
+
74
+ cfg = config.load()
75
+ with Store.open(cfg.db_path) as store:
76
+ fits = track.refresh_knees(store, force=True) or []
77
+ harnesses = track.harnesses(store)
78
+ return share.run(
79
+ cfg,
80
+ fits,
81
+ harnesses,
82
+ model=args.model,
83
+ window=args.window,
84
+ dry_run=args.dry_run,
85
+ yes=args.yes,
86
+ skill=args.skill,
87
+ )
88
+
89
+
90
+ def build_parser() -> argparse.ArgumentParser:
91
+ parser = argparse.ArgumentParser(
92
+ prog="cigar", description="Cigar: take a break before the context rots."
93
+ )
94
+ parser.add_argument("--version", action="version", version=f"cigar {__version__}")
95
+ sub = parser.add_subparsers(dest="command", required=True)
96
+
97
+ p_status = sub.add_parser("status", help="score, context size, knee, recent signals and mode")
98
+ p_status.add_argument("--session", help="session ID (default: the latest in this project)")
99
+ p_status.add_argument("--project", help="project directory (default: current directory)")
100
+ p_status.add_argument("--json", action="store_true", help="print JSON")
101
+ p_status.set_defaults(func=cmd_status)
102
+
103
+ p_report = sub.add_parser("report", help="your signal rate by context size, per model")
104
+ p_report.add_argument("--html", action="store_true", help="also write the HTML report")
105
+ p_report.add_argument("--open", action="store_true", help="write the HTML and open it")
106
+ p_report.set_defaults(func=cmd_report)
107
+
108
+ p_share = sub.add_parser("share", help="share your anonymous curve (opt-in, shows it first)")
109
+ p_share.add_argument("--dry-run", action="store_true", help="print the payload only")
110
+ p_share.add_argument("--yes", action="store_true", help="skip the confirmation question")
111
+ p_share.add_argument("--model", help="which model's curve (default: the most data)")
112
+ p_share.add_argument("--window", choices=["200k", "1m"], help="which context window")
113
+ p_share.add_argument("--skill", help=argparse.SUPPRESS) # /cigar:share passes its argument
114
+ p_share.set_defaults(func=cmd_share)
115
+
116
+ p_mode = sub.add_parser("mode", help="show or set the mode")
117
+ p_mode.add_argument("mode", nargs="?", help="suggest, observe or auto")
118
+ p_mode.set_defaults(func=cmd_mode)
119
+ return parser
120
+
121
+
122
+ def main(argv: list[str] | None = None) -> int:
123
+ args = build_parser().parse_args(argv)
124
+ return int(args.func(args))
125
+
126
+
127
+ if __name__ == "__main__":
128
+ sys.exit(main())
cigar/config.py ADDED
@@ -0,0 +1,120 @@
1
+ """Configuration: ~/.cigar/config.toml, overridden by environment variables.
2
+
3
+ Precedence (highest first): environment, config.toml, defaults.py.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from dataclasses import dataclass, field, replace
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from cigar import defaults
14
+
15
+ MODES = ("suggest", "observe", "auto")
16
+
17
+
18
+ def cigar_home() -> Path:
19
+ return Path(os.environ.get("CIGAR_HOME") or "~/.cigar").expanduser()
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Config:
24
+ home: Path
25
+ mode: str = "suggest"
26
+ min_tokens: int = defaults.MIN_TOKENS
27
+ knee_tokens: int | None = None
28
+ cooldown_events: int = defaults.COOLDOWN_EVENTS
29
+ max_nudges: int = defaults.MAX_NUDGES
30
+ check_patterns: tuple[str, ...] = field(default=defaults.CHECK_PATTERNS)
31
+ context_window: str | None = None # "200k" or "1m"; None means detect (D-026)
32
+
33
+ @property
34
+ def db_path(self) -> Path:
35
+ return self.home / "cigar.db"
36
+
37
+ @property
38
+ def config_path(self) -> Path:
39
+ return self.home / "config.toml"
40
+
41
+ @property
42
+ def log_dir(self) -> Path:
43
+ return self.home / "logs"
44
+
45
+
46
+ def _read_toml(path: Path) -> dict[str, Any]:
47
+ if not path.is_file():
48
+ return {}
49
+ import tomllib
50
+
51
+ with path.open("rb") as f:
52
+ return tomllib.load(f)
53
+
54
+
55
+ def _window(*values: Any) -> str | None:
56
+ """The first value that names a known window ("200k" or "1m")."""
57
+ for value in values:
58
+ label = str(value).strip().lower() if value is not None else ""
59
+ if label in defaults.WINDOWS:
60
+ return label
61
+ return None
62
+
63
+
64
+ def _int(*values: Any) -> int | None:
65
+ """The first value that is set, as an int."""
66
+ for value in values:
67
+ if value is not None and value != "":
68
+ return int(value)
69
+ return None
70
+
71
+
72
+ def load(env: dict[str, str] | None = None) -> Config:
73
+ env = dict(os.environ) if env is None else env
74
+ home = Path(env.get("CIGAR_HOME") or "~/.cigar").expanduser()
75
+ cfg = Config(home=home)
76
+ data = _read_toml(cfg.config_path)
77
+
78
+ mode = env.get("CIGAR_MODE") or data.get("mode") or cfg.mode
79
+ if mode not in MODES:
80
+ mode = "suggest"
81
+ min_tokens = _int(env.get("CIGAR_MIN_TOKENS"), data.get("min_tokens"))
82
+ knee = _int(env.get("CIGAR_KNEE_TOKENS"), data.get("knee_tokens"))
83
+ cooldown = _int(data.get("cooldown_events"))
84
+ max_nudges = _int(data.get("max_nudges"))
85
+ patterns = data.get("check_patterns")
86
+
87
+ return replace(
88
+ cfg,
89
+ mode=mode,
90
+ min_tokens=min_tokens if min_tokens is not None else cfg.min_tokens,
91
+ knee_tokens=knee,
92
+ cooldown_events=cooldown if cooldown is not None else cfg.cooldown_events,
93
+ max_nudges=max_nudges if max_nudges is not None else cfg.max_nudges,
94
+ check_patterns=tuple(patterns) if isinstance(patterns, list) else cfg.check_patterns,
95
+ context_window=_window(env.get("CIGAR_CONTEXT_WINDOW"), data.get("context_window")),
96
+ )
97
+
98
+
99
+ def _toml_value(value: Any) -> str:
100
+ if isinstance(value, bool):
101
+ return "true" if value else "false"
102
+ if isinstance(value, int | float):
103
+ return str(value)
104
+ if isinstance(value, list | tuple):
105
+ return "[" + ", ".join(_toml_value(v) for v in value) + "]"
106
+ s = str(value).replace("\\", "\\\\").replace('"', '\\"')
107
+ return f'"{s}"'
108
+
109
+
110
+ def write_value(home: Path, key: str, value: Any) -> Path:
111
+ """Set one top-level key in config.toml, keeping the other keys."""
112
+ path = home / "config.toml"
113
+ data = _read_toml(path)
114
+ data[key] = value
115
+ home.mkdir(parents=True, exist_ok=True)
116
+ lines = [f"{k} = {_toml_value(v)}" for k, v in data.items() if not isinstance(v, dict)]
117
+ tmp = path.with_suffix(".toml.tmp")
118
+ tmp.write_text("\n".join(lines) + "\n", encoding="utf-8")
119
+ tmp.replace(path)
120
+ return path
cigar/defaults.py ADDED
@@ -0,0 +1,100 @@
1
+ """Tuning constants. This is the only module where tuning numbers live.
2
+
3
+ Every value here is a starting point. The learned knee (Session 2) replaces
4
+ the default knees, and config.toml or environment variables can override the
5
+ rest (architecture §12). The reasoning for the default knees is in D-017.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ # --- Signals (architecture §6) ---------------------------------------------
13
+ WINDOW_EVENTS = 20
14
+ # Rules (a) and (c) judge behavior only over at least this many events (D-020).
15
+ MIN_WINDOW_EVENTS = 10
16
+ UNDO_LOOKBACK_EVENTS = 50
17
+
18
+ WEIGHTS: dict[str, float] = {
19
+ "failure": 1.0,
20
+ "repeat": 0.8,
21
+ "reread": 0.5,
22
+ "undo": 1.0,
23
+ "check_fail": 0.7,
24
+ }
25
+
26
+ READ_TOOLS = frozenset({"Read", "NotebookRead"})
27
+ EDIT_TOOLS = frozenset({"Edit", "MultiEdit", "Write", "NotebookEdit"})
28
+ SHELL_TOOLS = frozenset({"Bash", "PowerShell"})
29
+
30
+ # Shell commands that count as a test, build, lint or type check.
31
+ CHECK_PATTERNS: tuple[str, ...] = (
32
+ r"\b(pytest|py\.test|tox|nox|unittest)\b",
33
+ r"\b(npm|pnpm|yarn|bun)\s+(run\s+)?(test|build|lint|typecheck|type-check|check)\b",
34
+ r"\b(jest|vitest|mocha|playwright\s+test|cypress\s+run)\b",
35
+ r"\b(tsc|eslint|biome|prettier\s+--check)\b",
36
+ r"\b(ruff|flake8|pylint|mypy|pyright|black\s+--check)\b",
37
+ r"\bcargo\s+(test|build|check|clippy)\b",
38
+ r"\bgo\s+(test|build|vet)\b|\bgolangci-lint\b",
39
+ r"\b(make|ninja|cmake\s+--build|bazel\s+(build|test)|gradle\w*|mvn)\b",
40
+ r"\b(rspec|rubocop|phpunit|swift\s+(build|test)|dotnet\s+(build|test))\b",
41
+ )
42
+
43
+ # --- Score and nudge policy (architecture §6) -------------------------------
44
+ DEFAULT_BASELINE = 0.08
45
+ BASELINE_MIN_EVENTS = 200
46
+ BASELINE_KNEE_FRACTION = 0.4
47
+ BASELINE_CACHE_SECONDS = 3600
48
+
49
+ BEHAVIOR_MARGIN = 0.10 # rule (a): behavior >= baseline + margin, past the knee
50
+ PRESSURE_KNEE = 1.0 # rule (a)
51
+ PRESSURE_HARD = 1.3 # rule (b): well past the knee, whatever the behavior
52
+ DISTRESS_BEHAVIOR = 0.45 # rule (c)
53
+ DISTRESS_PRESSURE = 0.6 # rule (c)
54
+ FATIGUE_PRESSURE_CAP = 1.5
55
+
56
+ MIN_TOKENS = 40_000
57
+ COOLDOWN_EVENTS = 25
58
+ MAX_NUDGES = 3
59
+
60
+ # --- Handoffs ---------------------------------------------------------------
61
+ HANDOFF_INJECT_MAX_CHARS = 8_000
62
+ HANDOFF_MAX_AGE_SECONDS = 24 * 3600
63
+
64
+ # --- Transcript tail reading --------------------------------------------------
65
+ TAIL_BLOCK_BYTES = 64 * 1024
66
+ TAIL_MAX_BYTES = 4 * 1024 * 1024
67
+
68
+ # --- Knees (D-017) -------------------------------------------------------------
69
+ STANDARD_WINDOW = 200_000
70
+ EXTENDED_WINDOW = 1_000_000
71
+ # Window labels stored per session and used in shared curves (D-026).
72
+ WINDOWS: dict[str, int] = {"200k": STANDARD_WINDOW, "1m": EXTENDED_WINDOW}
73
+
74
+ # (pattern on the model ID, knee for a 200k window, knee for a 1M window)
75
+ KNEES: tuple[tuple[str, int, int], ...] = (
76
+ (r"opus", 120_000, 160_000),
77
+ (r"sonnet", 100_000, 140_000),
78
+ (r"haiku", 70_000, 70_000),
79
+ )
80
+ FALLBACK_KNEE_CAP = 120_000
81
+ FALLBACK_KNEE_FRACTION = 0.5
82
+
83
+ # A model ID marked like this runs with a 1M window. Claude Code does not mark
84
+ # transcript model IDs (D-014), so seeing more tokens than the standard window
85
+ # also counts as evidence of an extended window.
86
+ EXTENDED_MARKERS = re.compile(r"\[1m\]|-1m\b|context-1m", re.IGNORECASE)
87
+
88
+ # --- Knee learning (architecture §9, D-029) -------------------------------------
89
+ KNEE_BUCKET_TOKENS = 10_000
90
+ KNEE_MIN_BUCKET_EVENTS = 50 # a bucket counts with this many events...
91
+ KNEE_MIN_BUCKET_SESSIONS = 3 # ...from at least this many sessions
92
+ KNEE_LOW_FRACTION = 0.4 # baseline: buckets holding the lowest 40% of events
93
+ KNEE_BASELINE_FLOOR = 0.02 # so a near-zero baseline doesn't make any blip a knee
94
+ KNEE_RISE = 1.5 # knee: the 3-bucket mean reaches 1.5 x baseline...
95
+ KNEE_RUN_BUCKETS = 3
96
+ KNEE_MEDIUM = (5, 6) # (sessions, counting buckets) for medium confidence
97
+ KNEE_HIGH = (15, 10) # ...and for high
98
+ KNEE_REFRESH_SECONDS = 3600 # SessionStart refits at most this often
99
+
100
+ LOG_RETENTION_DAYS = 7
cigar/handoff.py ADDED
@@ -0,0 +1,176 @@
1
+ """Handoffs: validate, render, truncate for injection, save and load (FR-H1 to FR-H5).
2
+
3
+ `validate`, `render` and `for_injection` are pure. `save` and `load` do I/O.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import datetime as dt
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from cigar import defaults
14
+
15
+ HANDOFF_DIR = Path(".cigar") / "handoffs"
16
+ INJECTION_PREAMBLE = (
17
+ "This session continues earlier work. The handoff written before the context reset follows."
18
+ )
19
+ LIST_FIELDS = ("done", "decisions", "next_steps", "open_questions", "gotchas")
20
+
21
+
22
+ class HandoffError(ValueError):
23
+ pass
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Handoff:
28
+ goal: str
29
+ status: str
30
+ done: tuple[str, ...] = ()
31
+ decisions: tuple[str, ...] = ()
32
+ files: tuple[tuple[str, str], ...] = ()
33
+ next_steps: tuple[str, ...] = ()
34
+ open_questions: tuple[str, ...] = ()
35
+ gotchas: tuple[str, ...] = ()
36
+
37
+
38
+ def _text(data: dict[str, Any], key: str) -> str:
39
+ value = data.get(key)
40
+ if not isinstance(value, str) or not value.strip():
41
+ raise HandoffError(f"`{key}` is required and must be a non-empty string")
42
+ return value.strip()
43
+
44
+
45
+ def _items(data: dict[str, Any], key: str) -> tuple[str, ...]:
46
+ value = data.get(key)
47
+ if value is None:
48
+ return ()
49
+ if isinstance(value, str):
50
+ value = [value]
51
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
52
+ raise HandoffError(f"`{key}` must be a list of strings")
53
+ return tuple(v.strip() for v in value if v.strip())
54
+
55
+
56
+ def _split_note(path: str, note: str) -> tuple[str, str]:
57
+ """Agents often write "path — note" in one string; keep the two apart."""
58
+ if not note and " — " in path:
59
+ path, note = (part.strip() for part in path.split(" — ", 1))
60
+ return path, note
61
+
62
+
63
+ def _files(data: dict[str, Any]) -> tuple[tuple[str, str], ...]:
64
+ value = data.get("files")
65
+ if value is None:
66
+ return ()
67
+ if not isinstance(value, list):
68
+ raise HandoffError("`files` must be a list of {path, note}")
69
+ out = []
70
+ for item in value:
71
+ if isinstance(item, str) and item.strip():
72
+ out.append(_split_note(item.strip(), ""))
73
+ elif isinstance(item, dict) and isinstance(item.get("path"), str) and item["path"].strip():
74
+ note = item.get("note")
75
+ out.append(
76
+ _split_note(item["path"].strip(), note.strip() if isinstance(note, str) else "")
77
+ )
78
+ else:
79
+ raise HandoffError("each entry of `files` needs a `path` string")
80
+ return tuple(out)
81
+
82
+
83
+ def validate(data: Any) -> Handoff:
84
+ if not isinstance(data, dict):
85
+ raise HandoffError("the handoff must be an object")
86
+ fields = {key: _items(data, key) for key in LIST_FIELDS}
87
+ handoff = Handoff(
88
+ goal=_text(data, "goal"), status=_text(data, "status"), files=_files(data), **fields
89
+ )
90
+ if not handoff.next_steps:
91
+ raise HandoffError("`next_steps` needs at least one step (the immediate action first)")
92
+ return handoff
93
+
94
+
95
+ def _bullets(items: tuple[str, ...]) -> str:
96
+ return "\n".join(f"- {item}" for item in items) if items else "- (none)"
97
+
98
+
99
+ def render(handoff: Handoff, project_name: str, when: dt.datetime) -> str:
100
+ files = (
101
+ "\n".join(f"- `{path}` — {note}" if note else f"- `{path}`" for path, note in handoff.files)
102
+ if handoff.files
103
+ else "- (none)"
104
+ )
105
+ steps = "\n".join(f"{i}. {step}" for i, step in enumerate(handoff.next_steps, 1))
106
+ return (
107
+ f"# Cigar handoff · {project_name} · {when.isoformat(timespec='minutes')}\n"
108
+ f"**Goal:** {handoff.goal}\n"
109
+ f"**Status:** {handoff.status}\n\n"
110
+ f"## Done\n{_bullets(handoff.done)}\n\n"
111
+ f"## Decisions\n{_bullets(handoff.decisions)}\n\n"
112
+ f"## Files (path — note)\n{files}\n\n"
113
+ f"## Next steps\n{steps}\n\n"
114
+ f"## Open questions\n{_bullets(handoff.open_questions)}\n\n"
115
+ f"## Gotchas\n{_bullets(handoff.gotchas)}\n"
116
+ )
117
+
118
+
119
+ def for_injection(
120
+ markdown: str, rel_path: str, limit: int = defaults.HANDOFF_INJECT_MAX_CHARS
121
+ ) -> str:
122
+ """The text injected after a reset: under `limit` chars (FR-H5).
123
+
124
+ A long handoff is cut to its goal, status and next steps, followed by the
125
+ path to the full version.
126
+ """
127
+ full = f"{INJECTION_PREAMBLE}\n\n{markdown.strip()}"
128
+ if len(full) <= limit:
129
+ return full
130
+ sections = markdown.strip().split("\n## ")
131
+ head = sections[0]
132
+ steps = next((s for s in sections[1:] if s.startswith("Next steps")), "")
133
+ tail = f"\n\n(Cut to fit. The full handoff is at {rel_path}.)"
134
+ body = f"{INJECTION_PREAMBLE}\n\n{head}\n\n## {steps}".rstrip()
135
+ room = limit - len(tail)
136
+ if len(body) > room:
137
+ body = body[: max(0, room - 1)].rstrip() + "…"
138
+ return body + tail
139
+
140
+
141
+ def _ensure_dir(project_dir: Path) -> Path:
142
+ root = project_dir / ".cigar"
143
+ target = project_dir / HANDOFF_DIR
144
+ target.mkdir(parents=True, exist_ok=True)
145
+ ignore = root / ".gitignore"
146
+ if not ignore.exists():
147
+ ignore.write_text("*\n", encoding="utf-8")
148
+ return target
149
+
150
+
151
+ def save(
152
+ project_dir: Path, markdown: str, session_id: str | None, when: dt.datetime
153
+ ) -> tuple[Path, str]:
154
+ """Write the handoff file. Returns (absolute path, path relative to the project)."""
155
+ target = _ensure_dir(project_dir)
156
+ stem = f"{when:%Y-%m-%dT%H%M}-{(session_id or 'nosession')[:4]}"
157
+ path = target / f"{stem}.md"
158
+ n = 2
159
+ while path.exists():
160
+ path = target / f"{stem}-{n}.md"
161
+ n += 1
162
+ tmp = path.with_suffix(".md.tmp")
163
+ tmp.write_text(markdown, encoding="utf-8")
164
+ tmp.replace(path)
165
+ return path, path.relative_to(project_dir).as_posix()
166
+
167
+
168
+ def load(project_dir: Path, rel_path: str) -> str | None:
169
+ path = (project_dir / rel_path).resolve()
170
+ handoffs = (project_dir / HANDOFF_DIR).resolve()
171
+ if handoffs not in path.parents:
172
+ return None
173
+ try:
174
+ return path.read_text(encoding="utf-8")
175
+ except OSError:
176
+ return None