session-glue 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.
@@ -0,0 +1,11 @@
1
+ """Session Glue.
2
+
3
+ A zero-daemon, repo-local session-continuity protocol and CLI for coding agents.
4
+
5
+ Cut a bloated coding-agent session at a clean boundary, then glue the useful
6
+ context onto the next session.
7
+ """
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ __all__ = ["__version__"]
@@ -0,0 +1,8 @@
1
+ """Support ``python -m session_glue``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
session_glue/cli.py ADDED
@@ -0,0 +1,275 @@
1
+ """Command-line entry point for Session Glue.
2
+
3
+ This module wires up the ``glue`` (and fallback ``session-glue``) console
4
+ scripts. It implements ``glue create``, ``glue validate``, ``glue status``,
5
+ ``glue resume-prompt``, and ``glue install --dry-run`` (see
6
+ :mod:`session_glue.writer`, :mod:`session_glue.validator`,
7
+ :mod:`session_glue.reader`, and :mod:`session_glue.installer`). The CLI is built
8
+ on :mod:`argparse` from the standard library so the package has no required
9
+ runtime dependencies.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ from collections.abc import Sequence
17
+ from pathlib import Path
18
+
19
+ from . import __version__, installer, reader, validator, writer
20
+ from .schema import Handoff, HandoffParseError, parse_frontmatter
21
+
22
+
23
+ def build_parser() -> argparse.ArgumentParser:
24
+ """Construct the top-level argument parser for the ``glue`` CLI."""
25
+ parser = argparse.ArgumentParser(
26
+ prog="glue",
27
+ description=(
28
+ "Session Glue — cut a bloated coding-agent session at a clean "
29
+ "boundary and glue the useful context onto a clean new session."
30
+ ),
31
+ )
32
+ parser.add_argument(
33
+ "--version",
34
+ action="version",
35
+ version=f"session-glue {__version__}",
36
+ )
37
+
38
+ subparsers = parser.add_subparsers(dest="command", metavar="<command>")
39
+
40
+ create = subparsers.add_parser(
41
+ "create",
42
+ help="Archive a handoff into .agent-history/ and update pointers.",
43
+ description=(
44
+ "Write an agent-composed handoff (frontmatter + body) into "
45
+ ".agent-history/: archive a timestamped session file, refresh "
46
+ "LATEST.md, RESUME_PROMPT.txt, and INDEX.yaml."
47
+ ),
48
+ )
49
+ create.add_argument(
50
+ "--input",
51
+ "-i",
52
+ default="-",
53
+ metavar="PATH",
54
+ help="Handoff markdown file (frontmatter + body). Use '-' for stdin (default).",
55
+ )
56
+ create.add_argument(
57
+ "--repo-root",
58
+ default=".",
59
+ metavar="PATH",
60
+ help="Repository root that holds .agent-history/ (default: current directory).",
61
+ )
62
+ create.set_defaults(func=_cmd_create)
63
+
64
+ validate = subparsers.add_parser(
65
+ "validate",
66
+ help="Validate .agent-history/ handoff artifacts.",
67
+ description=(
68
+ "Validate LATEST.md frontmatter, the next_todo_items[0] lint, "
69
+ "RESUME_PROMPT.txt presence, and INDEX.yaml consistency. Exits "
70
+ "non-zero if any problem is found."
71
+ ),
72
+ )
73
+ validate.add_argument(
74
+ "--repo-root",
75
+ default=".",
76
+ metavar="PATH",
77
+ help="Repository root that holds .agent-history/ (default: current directory).",
78
+ )
79
+ validate.add_argument(
80
+ "--sessions",
81
+ action="store_true",
82
+ help="Also validate archived session files under sessions/.",
83
+ )
84
+ validate.set_defaults(func=_cmd_validate)
85
+
86
+ status = subparsers.add_parser(
87
+ "status",
88
+ help="Show compact .agent-history/ status.",
89
+ description=(
90
+ "Print compact metadata from INDEX.yaml (latest session/file, "
91
+ "branch, head commit, first next action) and a cheap validation "
92
+ "summary. Does not print the full session narrative."
93
+ ),
94
+ )
95
+ status.add_argument(
96
+ "--repo-root",
97
+ default=".",
98
+ metavar="PATH",
99
+ help="Repository root that holds .agent-history/ (default: current directory).",
100
+ )
101
+ status.set_defaults(func=_cmd_status)
102
+
103
+ resume_prompt = subparsers.add_parser(
104
+ "resume-prompt",
105
+ help="Print .agent-history/RESUME_PROMPT.txt exactly.",
106
+ description="Print the exact contents of .agent-history/RESUME_PROMPT.txt.",
107
+ )
108
+ resume_prompt.add_argument(
109
+ "--repo-root",
110
+ default=".",
111
+ metavar="PATH",
112
+ help="Repository root that holds .agent-history/ (default: current directory).",
113
+ )
114
+ resume_prompt.set_defaults(func=_cmd_resume_prompt)
115
+
116
+ install = subparsers.add_parser(
117
+ "install",
118
+ help="Show the managed instruction block for a coding agent (dry-run only).",
119
+ description=(
120
+ "Dry-run only: print the target instruction file and the managed "
121
+ "Session Glue block that would be added for a coding agent. Never "
122
+ "modifies user-home files; real installation is not implemented "
123
+ "(operator-gated)."
124
+ ),
125
+ )
126
+ install.add_argument(
127
+ "agent",
128
+ choices=(*installer.AGENT_ORDER, "all"),
129
+ help="Coding agent to show the install block for (or 'all').",
130
+ )
131
+ install.add_argument(
132
+ "--dry-run",
133
+ action="store_true",
134
+ help="Required: print the target path and proposed block without writing.",
135
+ )
136
+ install.set_defaults(func=_cmd_install)
137
+
138
+ return parser
139
+
140
+
141
+ def _read_input(source: str) -> str:
142
+ """Read handoff text from a file path or stdin (``-``)."""
143
+ if source == "-":
144
+ return sys.stdin.read()
145
+ return Path(source).read_text(encoding="utf-8")
146
+
147
+
148
+ def _cmd_create(args: argparse.Namespace) -> int:
149
+ """Implement ``glue create``."""
150
+ try:
151
+ text = _read_input(args.input)
152
+ except OSError as exc:
153
+ print(f"glue create: cannot read input: {exc}", file=sys.stderr)
154
+ return 1
155
+
156
+ try:
157
+ frontmatter, body = parse_frontmatter(text)
158
+ except HandoffParseError as exc:
159
+ print(f"glue create: invalid handoff: {exc}", file=sys.stderr)
160
+ return 2
161
+
162
+ handoff = Handoff.from_frontmatter(frontmatter, body)
163
+ errors = handoff.validate()
164
+ if errors:
165
+ print("glue create: handoff failed validation:", file=sys.stderr)
166
+ for error in errors:
167
+ print(f" - {error}", file=sys.stderr)
168
+ return 2
169
+
170
+ repo_root = Path(args.repo_root)
171
+ if not repo_root.is_dir():
172
+ print(f"glue create: repo root is not a directory: {repo_root}", file=sys.stderr)
173
+ return 1
174
+
175
+ try:
176
+ written = writer.create_handoff(
177
+ repo_root=repo_root,
178
+ frontmatter=frontmatter,
179
+ body=body,
180
+ handoff=handoff,
181
+ )
182
+ except writer.HandoffWriteError as exc:
183
+ print(f"glue create: {exc}", file=sys.stderr)
184
+ return 1
185
+ print("Wrote handoff for session " + str(handoff.session_id) + ":")
186
+ for label in ("archive", "latest", "resume_prompt", "index"):
187
+ print(f" {label}: {written[label]}")
188
+ return 0
189
+
190
+
191
+ def _cmd_validate(args: argparse.Namespace) -> int:
192
+ """Implement ``glue validate``."""
193
+ problems = validator.validate_history(
194
+ Path(args.repo_root), check_sessions=args.sessions
195
+ )
196
+ if problems:
197
+ print("glue validate: found problems:", file=sys.stderr)
198
+ for problem in problems:
199
+ print(f" - {problem}", file=sys.stderr)
200
+ return 1
201
+ print("glue validate: OK")
202
+ return 0
203
+
204
+
205
+ def _cmd_status(args: argparse.Namespace) -> int:
206
+ """Implement ``glue status``."""
207
+ status = reader.collect_status(Path(args.repo_root))
208
+ if not status.exists:
209
+ print(f"glue status: no .agent-history/ found at {status.history_dir}")
210
+ return 1
211
+
212
+ print(".agent-history: present")
213
+ index = status.index or {}
214
+ for label, key in reader.STATUS_FIELDS:
215
+ value = index.get(key)
216
+ print(f"{label}: {value if value not in (None, '') else '(unknown)'}")
217
+
218
+ if status.problems:
219
+ print(f"validation: {len(status.problems)} problem(s)")
220
+ for problem in status.problems:
221
+ print(f" - {problem}")
222
+ else:
223
+ print("validation: OK")
224
+ return 0
225
+
226
+
227
+ def _cmd_resume_prompt(args: argparse.Namespace) -> int:
228
+ """Implement ``glue resume-prompt``."""
229
+ text = reader.read_resume_prompt(Path(args.repo_root))
230
+ if text is None:
231
+ print("glue resume-prompt: no .agent-history/RESUME_PROMPT.txt found", file=sys.stderr)
232
+ return 1
233
+ # Print the file contents exactly (no added or stripped trailing newline).
234
+ sys.stdout.write(text)
235
+ return 0
236
+
237
+
238
+ def _cmd_install(args: argparse.Namespace) -> int:
239
+ """Implement ``glue install <agent> --dry-run`` (dry-run only)."""
240
+ if not args.dry_run:
241
+ print(
242
+ "glue install: only --dry-run is supported; real installation is "
243
+ "operator-gated and not implemented. Re-run with --dry-run.",
244
+ file=sys.stderr,
245
+ )
246
+ return 2
247
+
248
+ block = installer.managed_block()
249
+ for target in installer.resolve_agents(args.agent):
250
+ print(f"# {target.name} (dry-run — no files modified)")
251
+ print(f"target: {target.target}")
252
+ if target.note:
253
+ print(f"note: {target.note}")
254
+ print("proposed block:")
255
+ print(block)
256
+ print()
257
+ return 0
258
+
259
+
260
+ def main(argv: Sequence[str] | None = None) -> int:
261
+ """Program entry point. Returns the process exit code."""
262
+ parser = build_parser()
263
+ args = parser.parse_args(argv)
264
+
265
+ handler = getattr(args, "func", None)
266
+ if handler is None:
267
+ # No subcommand given: show help and exit successfully.
268
+ parser.print_help()
269
+ return 0
270
+
271
+ return handler(args)
272
+
273
+
274
+ if __name__ == "__main__": # pragma: no cover
275
+ raise SystemExit(main())
@@ -0,0 +1,92 @@
1
+ """Dry-run installer blocks for ``glue install <agent> --dry-run``.
2
+
3
+ This module only *describes* what Session Glue would add to a coding agent's
4
+ global instruction file — it renders the managed instruction block and the
5
+ target path. It never mutates user-home files, accesses the network or OS
6
+ clipboard, or performs a real install. Non-dry-run installation is intentionally
7
+ not implemented here (operator-gated).
8
+
9
+ The managed block is delimited by stable begin/end markers so a future updater
10
+ can replace it idempotently, and so existing installs can be detected.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+
17
+ # Stable managed-block markers. These are part of the on-disk contract: keep
18
+ # them byte-for-byte stable so idempotent updates and detection keep working.
19
+ BEGIN_MARKER = "<!-- BEGIN SESSION GLUE (managed) -->"
20
+ END_MARKER = "<!-- END SESSION GLUE (managed) -->"
21
+
22
+ # Agent-agnostic body of the managed block. Tells any file-writing coding agent
23
+ # how to respond to the glue triggers while preserving the v1 constraints.
24
+ _BLOCK_BODY = """\
25
+ ## Session Glue
26
+
27
+ When the user runs `/glue`, `/freeze`, `/handoff`, or `/checkpoint`, or asks in
28
+ natural language to glue / freeze / checkpoint / hand off the current session:
29
+
30
+ 1. Compose a handoff document: YAML frontmatter (session_id, session_date,
31
+ generated_at, schema_version, project_root, repo_root, current_branch,
32
+ head_commit, agent, status, active_context_files, completed_tasks,
33
+ next_todo_items, known_issues) plus a narrative body covering what changed,
34
+ what is still broken, and what to do next. `next_todo_items[0]` MUST be the
35
+ first productive work item, never a resume mechanic (do not write "read
36
+ LATEST.md", "paste the prompt", "start a new session", etc.).
37
+ 2. Run `glue create` to write the handoff into the repository-local
38
+ `.agent-history/` directory (LATEST.md, RESUME_PROMPT.txt, INDEX.yaml, and
39
+ sessions/<id>.md). Optionally run `glue validate` to check it.
40
+ 3. Print the contents of `.agent-history/RESUME_PROMPT.txt` in a fenced code
41
+ block so the operator can copy it into a fresh session.
42
+
43
+ Constraints:
44
+ - Write only the repository-local `.agent-history/` directory.
45
+ - Do not access the OS clipboard.
46
+ - Do not run a daemon, watcher, or any external/network service."""
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class AgentTarget:
51
+ """Where a given agent's global instruction block lives (display only)."""
52
+
53
+ name: str
54
+ target: str
55
+ note: str = ""
56
+
57
+
58
+ # Target instruction files per agent. Paths are shown in ``~`` form and are
59
+ # never opened or written during dry-run.
60
+ AGENT_TARGETS: dict[str, AgentTarget] = {
61
+ "codex": AgentTarget("codex", "~/.codex/AGENTS.md"),
62
+ "claude": AgentTarget("claude", "~/.claude/commands/glue.md"),
63
+ "cursor": AgentTarget(
64
+ "cursor",
65
+ "~/.cursor/rules/session-glue.md",
66
+ note="or paste the block into Cursor Settings -> Rules for AI",
67
+ ),
68
+ "gemini": AgentTarget("gemini", "~/.gemini/GEMINI.md"),
69
+ }
70
+
71
+ # Order used when installing "all".
72
+ AGENT_ORDER: tuple[str, ...] = ("codex", "claude", "cursor", "gemini")
73
+
74
+
75
+ def managed_block() -> str:
76
+ """Return the full managed instruction block, including begin/end markers."""
77
+ return f"{BEGIN_MARKER}\n{_BLOCK_BODY}\n{END_MARKER}"
78
+
79
+
80
+ def has_managed_block(text: str) -> bool:
81
+ """Return True if ``text`` already contains a Session Glue managed block."""
82
+ return BEGIN_MARKER in text and END_MARKER in text
83
+
84
+
85
+ def resolve_agents(agent: str) -> list[AgentTarget]:
86
+ """Resolve an agent name (or ``all``) to a list of targets.
87
+
88
+ Raises ``KeyError`` for an unknown agent name.
89
+ """
90
+ if agent == "all":
91
+ return [AGENT_TARGETS[name] for name in AGENT_ORDER]
92
+ return [AGENT_TARGETS[agent]]
session_glue/reader.py ADDED
@@ -0,0 +1,69 @@
1
+ """Read-only helpers for ``glue status`` and ``glue resume-prompt``.
2
+
3
+ These commands orient the operator without recreating context bloat: ``status``
4
+ reports compact metadata from ``INDEX.yaml`` plus a cheap validation summary —
5
+ it deliberately does **not** read or print the full session narrative — and
6
+ ``resume-prompt`` returns the exact contents of ``RESUME_PROMPT.txt``. Both are
7
+ strictly read-only: no writes, no network, no subprocess.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .schema import HandoffParseError, parse_mapping
17
+ from .validator import validate_history
18
+ from .writer import AGENT_HISTORY_DIRNAME, INDEX_FILENAME, RESUME_PROMPT_FILENAME
19
+
20
+ # Compact fields surfaced by ``glue status``, in display order, mapped from
21
+ # their ``INDEX.yaml`` keys. The full ``next_todo_items`` narrative is never
22
+ # included (token-economics requirement).
23
+ STATUS_FIELDS: tuple[tuple[str, str], ...] = (
24
+ ("latest session", "latest_session"),
25
+ ("latest file", "latest_file"),
26
+ ("current branch", "current_branch"),
27
+ ("head commit", "head_commit"),
28
+ ("first next action", "first_next_action"),
29
+ )
30
+
31
+
32
+ @dataclass
33
+ class Status:
34
+ """A compact, read-only snapshot of ``.agent-history/`` state."""
35
+
36
+ exists: bool
37
+ history_dir: Path
38
+ index: dict[str, Any] | None = None
39
+ problems: list[str] = field(default_factory=list)
40
+
41
+
42
+ def collect_status(repo_root: Path, run_validation: bool = True) -> Status:
43
+ """Gather compact status for ``<repo_root>/.agent-history/``.
44
+
45
+ Reads only ``INDEX.yaml`` (plus a cheap, non-``--sessions`` validation pass
46
+ when ``run_validation`` is true). Never reads archived session narratives.
47
+ """
48
+ history = Path(repo_root) / AGENT_HISTORY_DIRNAME
49
+ if not history.is_dir():
50
+ return Status(exists=False, history_dir=history)
51
+
52
+ index: dict[str, Any] | None = None
53
+ index_path = history / INDEX_FILENAME
54
+ if index_path.is_file():
55
+ try:
56
+ index = parse_mapping(index_path.read_text(encoding="utf-8"))
57
+ except HandoffParseError:
58
+ index = None
59
+
60
+ problems = validate_history(repo_root) if run_validation else []
61
+ return Status(exists=True, history_dir=history, index=index, problems=problems)
62
+
63
+
64
+ def read_resume_prompt(repo_root: Path) -> str | None:
65
+ """Return the exact contents of ``RESUME_PROMPT.txt``, or None if missing."""
66
+ path = Path(repo_root) / AGENT_HISTORY_DIRNAME / RESUME_PROMPT_FILENAME
67
+ if not path.is_file():
68
+ return None
69
+ return path.read_text(encoding="utf-8")