rm-contextos 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.
contextos/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """ContextOS — a context operating system for AI coding agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["__version__"]
@@ -0,0 +1,3 @@
1
+ """CLI package."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,3 @@
1
+ """CLI command modules."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,270 @@
1
+ """export command — generate tool-specific context files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated, Protocol, cast
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ from contextos.core.context_selector import ContextSelection
12
+ from contextos.exporters.base import ExportConfig
13
+
14
+ app = typer.Typer(help="Export context packs for specific AI coding tools.")
15
+ console = Console()
16
+
17
+ _CONTEXTOS_DIR = ".contextos"
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Shared option types (re-declared in each sub-command for Typer compatibility)
22
+ # ---------------------------------------------------------------------------
23
+
24
+ _REPO_HELP = "Repository root (default: current directory)."
25
+ _TASK_HELP = "Task description for relevance ranking."
26
+ _BUDGET_HELP = "Token budget for context selection."
27
+ _TESTS_HELP = "Include test files in context selection."
28
+ _SOURCE_HELP = "Include only file summaries; never embed full source."
29
+ _TIMESTAMP_HELP = "Omit timestamp for reproducible output."
30
+ _OUT_HELP = "Also write output to this additional file path."
31
+ _SENSITIVE_HELP = (
32
+ "[DANGEROUS] Disable secret redaction. Secrets will appear in plain text. "
33
+ "Only use in fully isolated, private environments."
34
+ )
35
+
36
+
37
+ class _ExporterModule(Protocol):
38
+ TOOL_NAME: str
39
+ FILENAME: str
40
+
41
+ def export(
42
+ self,
43
+ task: str,
44
+ repo_root: Path,
45
+ contextos_dir: Path,
46
+ *,
47
+ config: ExportConfig,
48
+ ) -> tuple[str, ContextSelection]: ...
49
+
50
+
51
+ @app.callback()
52
+ def export_root(ctx: typer.Context) -> None: # noqa: ARG001
53
+ """Generate tool-specific context files from your repo and active task."""
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # claude
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ @app.command("claude")
62
+ def export_claude(
63
+ repo: Annotated[Path, typer.Option("--repo", "-r", help=_REPO_HELP)] = Path("."),
64
+ task: Annotated[str, typer.Option("--task", "-t", help=_TASK_HELP)] = ..., # type: ignore[assignment]
65
+ budget: Annotated[int, typer.Option("--budget", "-b", help=_BUDGET_HELP)] = 8000,
66
+ include_tests: Annotated[
67
+ bool, typer.Option("--include-tests/--no-tests", help=_TESTS_HELP)
68
+ ] = True,
69
+ no_source: Annotated[bool, typer.Option("--no-source", help=_SOURCE_HELP)] = False,
70
+ no_timestamp: Annotated[bool, typer.Option("--no-timestamp", help=_TIMESTAMP_HELP)] = False,
71
+ allow_sensitive: Annotated[
72
+ bool, typer.Option("--allow-sensitive", help=_SENSITIVE_HELP)
73
+ ] = False,
74
+ out: Annotated[Path | None, typer.Option("--out", "-o", help=_OUT_HELP)] = None,
75
+ ) -> None:
76
+ """Generate CLAUDE_CONTEXT.md for Claude Code sessions."""
77
+ from contextos.exporters import claude
78
+
79
+ _run_export(
80
+ tool_module=claude,
81
+ repo=repo,
82
+ task=task,
83
+ budget=budget,
84
+ include_tests=include_tests,
85
+ no_source=no_source,
86
+ no_timestamp=no_timestamp,
87
+ allow_sensitive=allow_sensitive,
88
+ out=out,
89
+ )
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # codex
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ @app.command("codex")
98
+ def export_codex(
99
+ repo: Annotated[Path, typer.Option("--repo", "-r", help=_REPO_HELP)] = Path("."),
100
+ task: Annotated[str, typer.Option("--task", "-t", help=_TASK_HELP)] = ..., # type: ignore[assignment]
101
+ budget: Annotated[int, typer.Option("--budget", "-b", help=_BUDGET_HELP)] = 8000,
102
+ include_tests: Annotated[
103
+ bool, typer.Option("--include-tests/--no-tests", help=_TESTS_HELP)
104
+ ] = True,
105
+ no_source: Annotated[bool, typer.Option("--no-source", help=_SOURCE_HELP)] = False,
106
+ no_timestamp: Annotated[bool, typer.Option("--no-timestamp", help=_TIMESTAMP_HELP)] = False,
107
+ allow_sensitive: Annotated[
108
+ bool, typer.Option("--allow-sensitive", help=_SENSITIVE_HELP)
109
+ ] = False,
110
+ out: Annotated[Path | None, typer.Option("--out", "-o", help=_OUT_HELP)] = None,
111
+ ) -> None:
112
+ """Generate CODEX_CONTEXT.md for OpenAI Codex / GPT-4 agent sessions."""
113
+ from contextos.exporters import codex
114
+
115
+ _run_export(
116
+ tool_module=codex,
117
+ repo=repo,
118
+ task=task,
119
+ budget=budget,
120
+ include_tests=include_tests,
121
+ no_source=no_source,
122
+ no_timestamp=no_timestamp,
123
+ allow_sensitive=allow_sensitive,
124
+ out=out,
125
+ )
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # cursor
130
+ # ---------------------------------------------------------------------------
131
+
132
+
133
+ @app.command("cursor")
134
+ def export_cursor(
135
+ repo: Annotated[Path, typer.Option("--repo", "-r", help=_REPO_HELP)] = Path("."),
136
+ task: Annotated[str, typer.Option("--task", "-t", help=_TASK_HELP)] = ..., # type: ignore[assignment]
137
+ budget: Annotated[int, typer.Option("--budget", "-b", help=_BUDGET_HELP)] = 8000,
138
+ include_tests: Annotated[
139
+ bool, typer.Option("--include-tests/--no-tests", help=_TESTS_HELP)
140
+ ] = True,
141
+ no_source: Annotated[bool, typer.Option("--no-source", help=_SOURCE_HELP)] = False,
142
+ no_timestamp: Annotated[bool, typer.Option("--no-timestamp", help=_TIMESTAMP_HELP)] = False,
143
+ allow_sensitive: Annotated[
144
+ bool, typer.Option("--allow-sensitive", help=_SENSITIVE_HELP)
145
+ ] = False,
146
+ out: Annotated[Path | None, typer.Option("--out", "-o", help=_OUT_HELP)] = None,
147
+ ) -> None:
148
+ """Generate CURSOR_CONTEXT.md for Cursor IDE sessions."""
149
+ from contextos.exporters import cursor
150
+
151
+ _run_export(
152
+ tool_module=cursor,
153
+ repo=repo,
154
+ task=task,
155
+ budget=budget,
156
+ include_tests=include_tests,
157
+ no_source=no_source,
158
+ no_timestamp=no_timestamp,
159
+ allow_sensitive=allow_sensitive,
160
+ out=out,
161
+ )
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # aider
166
+ # ---------------------------------------------------------------------------
167
+
168
+
169
+ @app.command("aider")
170
+ def export_aider(
171
+ repo: Annotated[Path, typer.Option("--repo", "-r", help=_REPO_HELP)] = Path("."),
172
+ task: Annotated[str, typer.Option("--task", "-t", help=_TASK_HELP)] = ..., # type: ignore[assignment]
173
+ budget: Annotated[int, typer.Option("--budget", "-b", help=_BUDGET_HELP)] = 8000,
174
+ include_tests: Annotated[
175
+ bool, typer.Option("--include-tests/--no-tests", help=_TESTS_HELP)
176
+ ] = True,
177
+ no_source: Annotated[bool, typer.Option("--no-source", help=_SOURCE_HELP)] = False,
178
+ no_timestamp: Annotated[bool, typer.Option("--no-timestamp", help=_TIMESTAMP_HELP)] = False,
179
+ allow_sensitive: Annotated[
180
+ bool, typer.Option("--allow-sensitive", help=_SENSITIVE_HELP)
181
+ ] = False,
182
+ out: Annotated[Path | None, typer.Option("--out", "-o", help=_OUT_HELP)] = None,
183
+ ) -> None:
184
+ """Generate AIDER_CONTEXT.md for Aider pair-programming sessions."""
185
+ from contextos.exporters import aider
186
+
187
+ _run_export(
188
+ tool_module=aider,
189
+ repo=repo,
190
+ task=task,
191
+ budget=budget,
192
+ include_tests=include_tests,
193
+ no_source=no_source,
194
+ no_timestamp=no_timestamp,
195
+ allow_sensitive=allow_sensitive,
196
+ out=out,
197
+ )
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # Shared runner (called by all sub-commands)
202
+ # ---------------------------------------------------------------------------
203
+
204
+
205
+ def _run_export(
206
+ *,
207
+ tool_module: object,
208
+ repo: Path,
209
+ task: str,
210
+ budget: int,
211
+ include_tests: bool,
212
+ no_source: bool,
213
+ no_timestamp: bool,
214
+ allow_sensitive: bool = False,
215
+ out: Path | None,
216
+ ) -> None:
217
+ """Validate inputs, call the tool exporter, and print a summary."""
218
+ from contextos.exporters.base import ExportConfig
219
+
220
+ root = repo.resolve()
221
+ if not root.is_dir():
222
+ console.print(f"[red]Error:[/red] {root} is not a directory.")
223
+ raise typer.Exit(code=1)
224
+
225
+ if budget <= 0:
226
+ console.print("[red]Error:[/red] --budget must be a positive integer.")
227
+ raise typer.Exit(code=1)
228
+
229
+ contextos_dir = root / _CONTEXTOS_DIR
230
+ exporter = cast(_ExporterModule, tool_module)
231
+ tool_name = getattr(exporter, "TOOL_NAME", "Unknown")
232
+ filename = getattr(exporter, "FILENAME", "CONTEXT.md")
233
+
234
+ console.print(f"[bold]Exporting[/bold] context for [cyan]{tool_name}[/cyan]")
235
+ console.print(f" Repo : {root}")
236
+ console.print(f" Task : {task}")
237
+ console.print(f" Budget : {budget:,} tokens")
238
+ if no_source:
239
+ console.print(" Source : [dim]summaries only[/dim]")
240
+ if not include_tests:
241
+ console.print(" Tests : [dim]excluded[/dim]")
242
+ if allow_sensitive:
243
+ console.print(" [bold red]⚠ --allow-sensitive: secret redaction DISABLED[/bold red]")
244
+
245
+ cfg = ExportConfig(
246
+ budget=budget,
247
+ include_tests=include_tests,
248
+ no_source=no_source,
249
+ add_timestamp=not no_timestamp,
250
+ allow_sensitive=allow_sensitive,
251
+ )
252
+
253
+ content, selection = exporter.export(task, root, contextos_dir, config=cfg)
254
+
255
+ if selection.secret_warnings and not allow_sensitive:
256
+ console.print(
257
+ f" [yellow]⚠ {len(selection.secret_warnings)} secret(s) detected and "
258
+ f"redacted with [REDACTED_*][/yellow]"
259
+ )
260
+
261
+ default_out = contextos_dir / filename
262
+ console.print(
263
+ f"\n[green]✓[/green] Wrote [bold]{filename}[/bold] "
264
+ f"({len(selection.selected)} files, ~{selection.used_tokens:,} tokens)"
265
+ )
266
+ console.print(f" Path: {default_out}")
267
+
268
+ if out is not None:
269
+ out.write_text(content, encoding="utf-8")
270
+ console.print(f"[green]✓[/green] Also written to [bold]{out}[/bold]")
@@ -0,0 +1,96 @@
1
+ """init command — initialize the .contextos/ project directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from contextos.core import initializer
13
+
14
+ app = typer.Typer(help="Initialize the .contextos/ directory in a project.")
15
+ console = Console()
16
+
17
+ _STATUS_STYLE: dict[str, str] = {
18
+ "created": "green",
19
+ "overwritten": "yellow",
20
+ "skipped": "dim",
21
+ "error": "red",
22
+ }
23
+
24
+ _STATUS_ICON: dict[str, str] = {
25
+ "created": "✓",
26
+ "overwritten": "↺",
27
+ "skipped": "–",
28
+ "error": "✗",
29
+ }
30
+
31
+
32
+ def init_command(
33
+ directory: Annotated[Path, typer.Argument(help="Project directory to initialize.")] = Path("."),
34
+ force: Annotated[
35
+ bool, typer.Option("--force", "-f", help="Overwrite all existing files.")
36
+ ] = False,
37
+ quiet: Annotated[
38
+ bool, typer.Option("--quiet", "-q", help="Suppress file-by-file output.")
39
+ ] = False,
40
+ ) -> None:
41
+ """Create the .contextos/ directory with template files.
42
+
43
+ Safe to run multiple times — existing memory files are skipped unless
44
+ --force is passed. Computed files (file_summaries.json, etc.) are also
45
+ skipped by init; they are overwritten by `scan` and `pack`.
46
+ """
47
+ root = directory.resolve()
48
+
49
+ if not root.exists():
50
+ console.print(f"[red]Error:[/red] directory does not exist: {root}")
51
+ raise typer.Exit(code=1)
52
+
53
+ result = initializer.run(root, force=force)
54
+
55
+ if not quiet:
56
+ table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1))
57
+ table.add_column("Status", width=2)
58
+ table.add_column("File")
59
+ table.add_column("Note", style="dim")
60
+
61
+ for f in result.files:
62
+ icon = _STATUS_ICON.get(f.status, "?")
63
+ style = _STATUS_STYLE.get(f.status, "")
64
+ note = f.message if f.status == "error" else ""
65
+ table.add_row(
66
+ f"[{style}]{icon}[/{style}]",
67
+ f"[{style}].contextos/{f.name}[/{style}]",
68
+ note,
69
+ )
70
+
71
+ console.print(table)
72
+ console.print()
73
+
74
+ created = len(result.created)
75
+ skipped = len(result.skipped)
76
+ overwritten = len(result.overwritten)
77
+ errors = len(result.errors)
78
+
79
+ parts: list[str] = []
80
+ if created:
81
+ parts.append(f"[green]{created} created[/green]")
82
+ if overwritten:
83
+ parts.append(f"[yellow]{overwritten} overwritten[/yellow]")
84
+ if skipped:
85
+ parts.append(f"[dim]{skipped} skipped[/dim]")
86
+ if errors:
87
+ parts.append(f"[red]{errors} error(s)[/red]")
88
+
89
+ summary = ", ".join(parts) if parts else "nothing to do"
90
+ console.print(f"[bold]{result.contextos_dir}[/bold] {summary}")
91
+
92
+ if errors:
93
+ raise typer.Exit(code=1)
94
+
95
+
96
+ app.command()(init_command)
@@ -0,0 +1,262 @@
1
+ """memory command — manage .contextos/MEMORY.md and .contextos/DECISIONS.md."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.markdown import Markdown
13
+
14
+ app = typer.Typer(help="Manage project memory and decision log in .contextos/.")
15
+ console = Console()
16
+
17
+ _CONTEXTOS_DIR = ".contextos"
18
+ _MEMORY_FILE = "MEMORY.md"
19
+ _DECISIONS_FILE = "DECISIONS.md"
20
+
21
+ # Patterns that suggest embedded secrets — reject notes containing these.
22
+ # Matches "keyword=<value>" or "keyword: <value>" (not bare mentions of the word).
23
+ _SECRET_RE: re.Pattern[str] = re.compile(
24
+ r"(?:password|secret|api[_\-.]?key|access[_\-.]?token|bearer|private[_\-.]?key)"
25
+ r"\s*[=:]\s*\S{4,}",
26
+ re.IGNORECASE,
27
+ )
28
+ # Long hex strings that look like raw API keys or hashes.
29
+ _HEX_RE: re.Pattern[str] = re.compile(r"[0-9a-fA-F]{40,}")
30
+ # Base64 blobs (common in JWT / bearer tokens).
31
+ _B64_RE: re.Pattern[str] = re.compile(r"[A-Za-z0-9+/]{60,}={0,2}")
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Sub-command group
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ @app.callback()
40
+ def memory_root(ctx: typer.Context) -> None: # noqa: ARG001
41
+ """Manage .contextos/MEMORY.md and .contextos/DECISIONS.md."""
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Commands
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ @app.command("add")
50
+ def memory_add(
51
+ note: Annotated[str, typer.Argument(help="Note text to append to MEMORY.md.")],
52
+ repo: Annotated[
53
+ Path,
54
+ typer.Option("--repo", "-r", help="Repository root (default: current directory)."),
55
+ ] = Path("."),
56
+ ) -> None:
57
+ """Append a timestamped note to .contextos/MEMORY.md."""
58
+ _impl_add(note, repo.resolve())
59
+
60
+
61
+ @app.command("update")
62
+ def memory_update(
63
+ note: Annotated[str, typer.Argument(help="Note text to append to MEMORY.md.")],
64
+ repo: Annotated[
65
+ Path,
66
+ typer.Option("--repo", "-r", help="Repository root (default: current directory)."),
67
+ ] = Path("."),
68
+ ) -> None:
69
+ """Append a timestamped note to .contextos/MEMORY.md (alias for add)."""
70
+ _impl_add(note, repo.resolve())
71
+
72
+
73
+ @app.command("decision")
74
+ def memory_decision(
75
+ text: Annotated[str, typer.Argument(help="Decision text to record.")],
76
+ repo: Annotated[
77
+ Path,
78
+ typer.Option("--repo", "-r", help="Repository root (default: current directory)."),
79
+ ] = Path("."),
80
+ status: Annotated[
81
+ str,
82
+ typer.Option("--status", "-s", help='Decision status (default: "accepted").'),
83
+ ] = "accepted",
84
+ ) -> None:
85
+ """Append a timestamped decision to .contextos/DECISIONS.md."""
86
+ _impl_decision(text, status, repo.resolve())
87
+
88
+
89
+ @app.command("list")
90
+ def memory_list(
91
+ repo: Annotated[
92
+ Path,
93
+ typer.Option("--repo", "-r", help="Repository root (default: current directory)."),
94
+ ] = Path("."),
95
+ show_decisions: Annotated[
96
+ bool,
97
+ typer.Option("--decisions/--no-decisions", help="Also show DECISIONS.md."),
98
+ ] = True,
99
+ ) -> None:
100
+ """Display .contextos/MEMORY.md and optionally DECISIONS.md."""
101
+ _impl_list(repo.resolve(), show_decisions=show_decisions)
102
+
103
+
104
+ @app.command("compact")
105
+ def memory_compact(
106
+ repo: Annotated[ # noqa: ARG001
107
+ Path,
108
+ typer.Option("--repo", "-r", help="Repository root (default: current directory)."),
109
+ ] = Path("."),
110
+ ) -> None:
111
+ """[Placeholder] Compact memory via LLM — not yet implemented."""
112
+ console.print("[yellow]Compaction is not yet implemented.[/yellow]")
113
+ console.print(
114
+ "To compact manually: edit [bold].contextos/MEMORY.md[/bold] and remove outdated entries."
115
+ )
116
+ console.print("Future versions will compress notes into concise summaries using an LLM.")
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Implementation helpers (also imported directly in tests)
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ def _now_iso() -> str:
125
+ return datetime.now(tz=UTC).isoformat(timespec="seconds")
126
+
127
+
128
+ def _memory_path(root: Path) -> Path:
129
+ return root / _CONTEXTOS_DIR / _MEMORY_FILE
130
+
131
+
132
+ def _decisions_path(root: Path) -> Path:
133
+ return root / _CONTEXTOS_DIR / _DECISIONS_FILE
134
+
135
+
136
+ def _contains_secret(text: str) -> bool:
137
+ """Return True if *text* appears to embed a secret or credential value."""
138
+ return bool(_SECRET_RE.search(text) or _HEX_RE.search(text) or _B64_RE.search(text))
139
+
140
+
141
+ def _impl_add(note: str, root: Path) -> None:
142
+ if _contains_secret(note):
143
+ console.print("[red]Error:[/red] Note appears to contain a secret or credential value.")
144
+ console.print("Remove secrets before saving to memory.")
145
+ raise typer.Exit(code=1)
146
+
147
+ path = _memory_path(root)
148
+ _ensure_memory(path)
149
+ ts = _now_iso()
150
+ entry = f"- **{ts}** — {note}\n"
151
+ _append_to_notes(path, entry)
152
+ console.print(f"[green]✓[/green] Note appended to [bold]{_CONTEXTOS_DIR}/{_MEMORY_FILE}[/bold]")
153
+ console.print(f" Set : {ts}")
154
+ console.print(f" Note: {note}")
155
+
156
+
157
+ def _impl_decision(text: str, status: str, root: Path) -> None:
158
+ if _contains_secret(text):
159
+ console.print(
160
+ "[red]Error:[/red] Decision text appears to contain a secret or credential value."
161
+ )
162
+ raise typer.Exit(code=1)
163
+
164
+ path = _decisions_path(root)
165
+ _ensure_decisions(path)
166
+ ts = _now_iso()
167
+ date = ts[:10]
168
+ entry = (
169
+ "\n---\n\n"
170
+ f"### [{date}] Decision\n\n"
171
+ f"**Status:** {status}\n\n"
172
+ f"**Decision:** {text}\n\n"
173
+ f"**Logged:** {ts}\n"
174
+ )
175
+ _append_raw(path, entry)
176
+ console.print(
177
+ f"[green]✓[/green] Decision appended to [bold]{_CONTEXTOS_DIR}/{_DECISIONS_FILE}[/bold]"
178
+ )
179
+ console.print(f" Status : {status}")
180
+ console.print(f" Decision : {text}")
181
+ console.print(f" Logged : {ts}")
182
+
183
+
184
+ def _impl_list(root: Path, *, show_decisions: bool = True) -> None:
185
+ mem = _memory_path(root)
186
+ dec = _decisions_path(root)
187
+ found_any = False
188
+
189
+ if mem.exists():
190
+ found_any = True
191
+ console.print(f"\n[bold cyan]── {_MEMORY_FILE} ──[/bold cyan]")
192
+ console.print(Markdown(mem.read_text(encoding="utf-8")))
193
+
194
+ if show_decisions and dec.exists():
195
+ found_any = True
196
+ console.print(f"\n[bold cyan]── {_DECISIONS_FILE} ──[/bold cyan]")
197
+ console.print(Markdown(dec.read_text(encoding="utf-8")))
198
+
199
+ if not found_any:
200
+ console.print(
201
+ "[yellow]No memory files found.[/yellow] "
202
+ "Run `contextos init` or `contextos memory add` first."
203
+ )
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # File helpers
208
+ # ---------------------------------------------------------------------------
209
+
210
+
211
+ def _ensure_memory(path: Path) -> None:
212
+ """Create MEMORY.md with a Notes section if it doesn't exist."""
213
+ path.parent.mkdir(parents=True, exist_ok=True)
214
+ if not path.exists():
215
+ path.write_text(
216
+ "# Project Memory\n\n"
217
+ "> Persistent notes managed by ContextOS. Append-only recommended.\n\n"
218
+ "## Notes\n\n",
219
+ encoding="utf-8",
220
+ )
221
+ elif "## Notes" not in path.read_text(encoding="utf-8"):
222
+ _append_raw(path, "\n## Notes\n\n")
223
+
224
+
225
+ def _ensure_decisions(path: Path) -> None:
226
+ """Create DECISIONS.md with a header if it doesn't exist."""
227
+ path.parent.mkdir(parents=True, exist_ok=True)
228
+ if not path.exists():
229
+ path.write_text(
230
+ "# Decision Log\n\n> Architectural and design decisions. Append-only recommended.\n\n",
231
+ encoding="utf-8",
232
+ )
233
+
234
+
235
+ def _append_to_notes(path: Path, entry: str) -> None:
236
+ """Append *entry* inside the ## Notes section (before next ## or EOF)."""
237
+ text = path.read_text(encoding="utf-8")
238
+ section = "## Notes"
239
+ if section not in text:
240
+ _append_raw(path, f"\n{section}\n\n{entry}")
241
+ return
242
+
243
+ idx = text.index(section)
244
+ after_section = text[idx + len(section) :]
245
+ # Find next top-level section heading after ## Notes
246
+ next_h2 = re.search(r"\n##\s", after_section)
247
+ if next_h2:
248
+ insert_pos = idx + len(section) + next_h2.start()
249
+ text = text[:insert_pos] + "\n" + entry + text[insert_pos:]
250
+ else:
251
+ if not text.endswith("\n"):
252
+ text += "\n"
253
+ text += entry
254
+ path.write_text(text, encoding="utf-8")
255
+
256
+
257
+ def _append_raw(path: Path, text: str) -> None:
258
+ """Append *text* verbatim to the end of *path*."""
259
+ existing = path.read_text(encoding="utf-8")
260
+ if existing and not existing.endswith("\n"):
261
+ existing += "\n"
262
+ path.write_text(existing + text, encoding="utf-8")