tenseleyflow/loader / c6feb00

Browse files

Add memory tools and resume entry points

Authored by espadonne
SHA
c6feb006cca80ccb9929314a8f67f88c8c3118f1
Parents
978ff51
Tree
84cc7ba

9 changed files

StatusFile+-
M src/loader/cli/main.py 30 8
A src/loader/cli/options.py 25 0
M src/loader/runtime/conversation.py 5 0
M src/loader/runtime/hooks.py 29 0
A src/loader/runtime/memory.py 232 0
M src/loader/tools/base.py 18 0
A src/loader/tools/memory_tools.py 300 0
A tests/test_cli_resume.py 19 0
A tests/test_memory_tools.py 127 0
src/loader/cli/main.pymodified
@@ -2,6 +2,7 @@
22
 
33
 import asyncio
44
 import re
5
+import sys
56
 
67
 import click
78
 import httpx
@@ -12,6 +13,7 @@ from rich.prompt import Confirm, Prompt
1213
 from rich.table import Table
1314
 
1415
 from ..runtime.permissions import PermissionMode
16
+from .options import inject_resume_target
1517
 from .rendering import (
1618
     format_dod_status,
1719
     format_permission_mode,
@@ -155,6 +157,7 @@ def clean_response(text: str) -> str:
155157
 @click.option("--no-context", is_flag=True, help="Skip auto-detecting project context")
156158
 @click.option("--plan", is_flag=True, help="Start the task in plan mode")
157159
 @click.option("--clarify", is_flag=True, help="Start the task in clarify mode")
160
+@click.option("--resume-target", hidden=True, default=None)
158161
 @click.option("--no-recover", is_flag=True, help="Disable auto-recovery from tool errors")
159162
 @click.option("--no-tui", is_flag=True, help="Use simple Rich output instead of full TUI")
160163
 @click.option("--ctx", type=int, default=8192, help="Context window size (default: 8192, smaller = faster)")
@@ -167,7 +170,7 @@ def clean_response(text: str) -> str:
167170
 @click.option("--verify", is_flag=True, help="Enable post-action verification (check results)")
168171
 @click.option("--reason", is_flag=True, help="Enable all reasoning stages (decompose + critique + confidence + verify)")
169172
 @click.argument("prompt", required=False)
170
-def main(
173
+def cli(
171174
     model: str | None,
172175
     select_model: bool,
173176
     backend: str,
@@ -177,6 +180,7 @@ def main(
177180
     no_context: bool,
178181
     plan: bool,
179182
     clarify: bool,
183
+    resume_target: str | None,
180184
     no_recover: bool,
181185
     no_tui: bool,
182186
     ctx: int,
@@ -191,11 +195,17 @@ def main(
191195
 ) -> None:
192196
     """Loader - Local AI coding assistant."""
193197
     asyncio.run(_main(
194
-        model, select_model, backend, yes, permission_mode, react, no_context, plan, clarify, no_recover,
198
+        model, select_model, backend, yes, permission_mode, react, no_context, plan, clarify, resume_target, no_recover,
195199
         no_tui, ctx, gpu, timeout, decompose, critique, confidence, verify, reason, prompt
196200
     ))
197201
 
198202
 
203
+def main() -> None:
204
+    """Entry-point wrapper that supports `--resume [session-id]` syntax."""
205
+
206
+    cli.main(args=inject_resume_target(sys.argv[1:]), prog_name="loader")
207
+
208
+
199209
 async def _main(
200210
     model: str | None,
201211
     select_model: bool,
@@ -206,6 +216,7 @@ async def _main(
206216
     no_context: bool,
207217
     plan: bool,
208218
     clarify: bool,
219
+    resume_target: str | None,
209220
     no_recover: bool,
210221
     no_tui: bool,
211222
     ctx: int | None,
@@ -289,6 +300,17 @@ async def _main(
289300
         reasoning=reasoning_config,
290301
     )
291302
     agent = Agent(backend=llm, registry=registry, config=config)
303
+    resumed = False
304
+    if resume_target is not None:
305
+        session_id = None if resume_target == "__latest__" else resume_target
306
+        resumed = agent.resume_session(session_id)
307
+        if not resumed and session_id is None:
308
+            console.print("[yellow]No previous session found; starting a new session.[/yellow]")
309
+        elif not resumed:
310
+            console.print(f"[red]Session not found:[/red] {session_id}")
311
+            return
312
+        else:
313
+            console.print(f"[dim]Resumed session: {agent.session.session_id}[/dim]")
292314
 
293315
     # Show reasoning status if enabled
294316
     reasoning_active = []
@@ -310,8 +332,8 @@ async def _main(
310332
         status_parts = [
311333
             f"Model: {model}",
312334
             f"Mode: {mode_str}",
313
-            f"Workflow: {format_workflow_mode(config.workflow_mode_override or 'execute')}",
314
-            f"Permissions: {format_permission_mode(permission_mode)}",
335
+            f"Workflow: {format_workflow_mode(agent.workflow_mode)}",
336
+            f"Permissions: {format_permission_mode(agent.active_permission_mode)}",
315337
         ]
316338
         if agent.project_context:
317339
             status_parts.append(f"Project: {agent.project_context.project_type}")
@@ -333,8 +355,8 @@ async def _main(
333355
         status_parts = [
334356
             f"Model: {model}",
335357
             f"Mode: {mode_str}",
336
-            f"Workflow: {format_workflow_mode(config.workflow_mode_override or 'execute')}",
337
-            f"Permissions: {format_permission_mode(permission_mode)}",
358
+            f"Workflow: {format_workflow_mode(agent.workflow_mode)}",
359
+            f"Permissions: {format_permission_mode(agent.active_permission_mode)}",
338360
         ]
339361
         if agent.project_context:
340362
             status_parts.append(f"Project: {agent.project_context.project_type}")
@@ -356,8 +378,8 @@ async def _main(
356378
             agent=agent,
357379
             model_name=model,
358380
             mode=mode_str,
359
-            workflow_mode=config.workflow_mode_override or "execute",
360
-            permission_mode=permission_mode,
381
+            workflow_mode=agent.workflow_mode,
382
+            permission_mode=agent.active_permission_mode,
361383
         )
362384
         await app.run_async()
363385
 
src/loader/cli/options.pyadded
@@ -0,0 +1,25 @@
1
+"""Reusable CLI argument helpers for Loader."""
2
+
3
+from __future__ import annotations
4
+
5
+
6
+def inject_resume_target(argv: list[str]) -> list[str]:
7
+    """Rewrite `--resume [session-id]` into a hidden click option."""
8
+
9
+    rewritten: list[str] = []
10
+    index = 0
11
+    while index < len(argv):
12
+        arg = argv[index]
13
+        if arg != "--resume":
14
+            rewritten.append(arg)
15
+            index += 1
16
+            continue
17
+
18
+        next_arg = argv[index + 1] if index + 1 < len(argv) else None
19
+        if next_arg and not next_arg.startswith("-"):
20
+            rewritten.extend(["--resume-target", next_arg])
21
+            index += 2
22
+        else:
23
+            rewritten.extend(["--resume-target", "__latest__"])
24
+            index += 1
25
+    return rewritten
src/loader/runtime/conversation.pymodified
@@ -31,6 +31,7 @@ from .dod import (
3131
 from .events import AgentEvent, TurnSummary
3232
 from .executor import ToolExecutionState, ToolExecutor
3333
 from .hooks import build_default_tool_hooks
34
+from .memory import MemoryStore
3435
 from .session import normalize_usage
3536
 from .tracing import RuntimeTracer
3637
 from .workflow import (
@@ -1443,6 +1444,10 @@ class ConversationRuntime:
14431444
             iterations=summary.iterations,
14441445
         )
14451446
         summary.session_id = self.agent.session.session_id
1447
+        if summary.definition_of_done and summary.definition_of_done.status == "done":
1448
+            MemoryStore(self.agent.project_root).capture_definition_of_done(
1449
+                build_verification_summary(summary.definition_of_done.evidence)
1450
+            )
14461451
         summary.trace = list(self.tracer.events)
14471452
         return summary
14481453
 
src/loader/runtime/hooks.pymodified
@@ -12,6 +12,7 @@ from ..agent.safeguards import ActionTracker, PreActionValidator
1212
 from ..llm.base import ToolCall
1313
 from ..tools.base import Tool, ToolRegistry
1414
 from ..tools.base import ToolResult as RegistryToolResult
15
+from .memory import MemoryStore
1516
 from .permissions import PermissionOverride, PermissionPolicy
1617
 
1718
 
@@ -258,6 +259,33 @@ class ActionHistoryHook(BaseToolHook):
258259
         return HookResult()
259260
 
260261
 
262
+class MemoryLifecycleHook(BaseToolHook):
263
+    """Mirror durable memory updates into the session notepad."""
264
+
265
+    async def post_tool_use(self, context: HookContext) -> HookResult:
266
+        if context.result is None or context.result.is_error:
267
+            return HookResult()
268
+
269
+        store = MemoryStore(context.permission_policy.workspace_root)
270
+        if context.tool_call.name == "project_memory_add_note":
271
+            category = str(context.tool_call.arguments.get("category", "")).strip()
272
+            content = str(context.tool_call.arguments.get("content", "")).strip()
273
+            if category and content:
274
+                store.append_notepad_working(
275
+                    f"Remembered note [{category}]: {content}"
276
+                )
277
+        elif context.tool_call.name == "project_memory_add_directive":
278
+            directive = str(context.tool_call.arguments.get("directive", "")).strip()
279
+            priority = str(
280
+                context.tool_call.arguments.get("priority", "normal")
281
+            ).strip()
282
+            if directive:
283
+                store.append_notepad_working(
284
+                    f"Remembered directive [{priority}]: {directive}"
285
+                )
286
+        return HookResult()
287
+
288
+
261289
 def build_default_tool_hooks(
262290
     *,
263291
     action_tracker: ActionTracker,
@@ -273,5 +301,6 @@ def build_default_tool_hooks(
273301
             ActionValidationHook(validator),
274302
             RollbackTrackingHook(registry, rollback_plan),
275303
             ActionHistoryHook(action_tracker),
304
+            MemoryLifecycleHook(),
276305
         ]
277306
     )
src/loader/runtime/memory.pyadded
@@ -0,0 +1,232 @@
1
+"""Durable project memory and working-notepad storage under `.loader/`."""
2
+
3
+from __future__ import annotations
4
+
5
+import json
6
+from dataclasses import dataclass
7
+from datetime import UTC, datetime
8
+from pathlib import Path
9
+from typing import Any
10
+
11
+
12
+def _utc_now() -> str:
13
+    return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
14
+
15
+
16
+@dataclass(slots=True)
17
+class ProjectMemoryNote:
18
+    """Categorized project-memory note."""
19
+
20
+    category: str
21
+    content: str
22
+    timestamp: str
23
+
24
+    def to_dict(self) -> dict[str, str]:
25
+        return {
26
+            "category": self.category,
27
+            "content": self.content,
28
+            "timestamp": self.timestamp,
29
+        }
30
+
31
+
32
+@dataclass(slots=True)
33
+class ProjectMemoryDirective:
34
+    """Persistent user or project directive."""
35
+
36
+    directive: str
37
+    priority: str
38
+    context: str | None
39
+    timestamp: str
40
+
41
+    def to_dict(self) -> dict[str, str | None]:
42
+        return {
43
+            "directive": self.directive,
44
+            "priority": self.priority,
45
+            "context": self.context,
46
+            "timestamp": self.timestamp,
47
+        }
48
+
49
+
50
+class MemoryStore:
51
+    """Manage project memory and the working notepad."""
52
+
53
+    def __init__(self, project_root: Path) -> None:
54
+        self.project_root = project_root
55
+        self.loader_root = project_root / ".loader"
56
+        self.project_memory_path = self.loader_root / "project-memory.json"
57
+        self.notepad_path = self.loader_root / "notepad.md"
58
+
59
+    def ensure_layout(self) -> None:
60
+        """Ensure the `.loader/` directory exists."""
61
+
62
+        self.loader_root.mkdir(parents=True, exist_ok=True)
63
+
64
+    def read_project_memory(self, section: str = "all") -> Any:
65
+        """Read the full project memory or one named section."""
66
+
67
+        memory = self._load_project_memory()
68
+        if section == "all":
69
+            return memory
70
+        return memory.get(section)
71
+
72
+    def write_project_memory(
73
+        self,
74
+        memory: dict[str, Any],
75
+        *,
76
+        merge: bool = True,
77
+    ) -> dict[str, Any]:
78
+        """Write or merge project memory state."""
79
+
80
+        existing = self._load_project_memory() if merge else {}
81
+        merged = {**existing, **memory}
82
+        self._save_project_memory(merged)
83
+        return merged
84
+
85
+    def add_project_note(self, category: str, content: str) -> dict[str, Any]:
86
+        """Append one categorized note to project memory."""
87
+
88
+        memory = self._load_project_memory()
89
+        notes = list(memory.get("notes", []))
90
+        note = ProjectMemoryNote(
91
+            category=category.strip(),
92
+            content=content.strip(),
93
+            timestamp=_utc_now(),
94
+        )
95
+        notes.append(note.to_dict())
96
+        memory["notes"] = notes
97
+        self._save_project_memory(memory)
98
+        return note.to_dict()
99
+
100
+    def add_project_directive(
101
+        self,
102
+        directive: str,
103
+        *,
104
+        priority: str = "normal",
105
+        context: str | None = None,
106
+    ) -> dict[str, Any]:
107
+        """Append one durable directive to project memory."""
108
+
109
+        memory = self._load_project_memory()
110
+        directives = list(memory.get("directives", []))
111
+        directive_entry = ProjectMemoryDirective(
112
+            directive=directive.strip(),
113
+            priority=(priority or "normal").strip(),
114
+            context=context.strip() if context else None,
115
+            timestamp=_utc_now(),
116
+        )
117
+        directives.append(directive_entry.to_dict())
118
+        memory["directives"] = directives
119
+        self._save_project_memory(memory)
120
+        return directive_entry.to_dict()
121
+
122
+    def read_notepad(self, section: str = "all") -> str:
123
+        """Read the full notepad or one named section."""
124
+
125
+        sections = self._load_notepad_sections()
126
+        if section == "all":
127
+            return self._render_notepad(sections)
128
+        return sections[section]
129
+
130
+    def write_notepad_priority(self, content: str) -> str:
131
+        """Replace the priority context section."""
132
+
133
+        sections = self._load_notepad_sections()
134
+        sections["priority"] = content.strip()
135
+        self._save_notepad_sections(sections)
136
+        return sections["priority"]
137
+
138
+    def append_notepad_working(self, content: str) -> str:
139
+        """Append one timestamped working-memory entry."""
140
+
141
+        sections = self._load_notepad_sections()
142
+        entry = f"- [{_utc_now()}] {content.strip()}"
143
+        sections["working"] = self._append_markdown_entry(sections["working"], entry)
144
+        self._save_notepad_sections(sections)
145
+        return entry
146
+
147
+    def append_notepad_manual(self, content: str) -> str:
148
+        """Append one manual note that should not be auto-pruned."""
149
+
150
+        sections = self._load_notepad_sections()
151
+        entry = f"- {content.strip()}"
152
+        sections["manual"] = self._append_markdown_entry(sections["manual"], entry)
153
+        self._save_notepad_sections(sections)
154
+        return entry
155
+
156
+    def capture_definition_of_done(self, evidence_summary: str) -> dict[str, Any] | None:
157
+        """Persist a useful evidence summary into project memory."""
158
+
159
+        normalized = evidence_summary.strip()
160
+        if not normalized or normalized == "Verification: skipped (no evidence required).":
161
+            return None
162
+        return self.add_project_note("definition_of_done", normalized)
163
+
164
+    def _load_project_memory(self) -> dict[str, Any]:
165
+        self.ensure_layout()
166
+        if not self.project_memory_path.exists():
167
+            return {}
168
+        try:
169
+            raw = json.loads(self.project_memory_path.read_text())
170
+        except json.JSONDecodeError:
171
+            return {}
172
+        return raw if isinstance(raw, dict) else {}
173
+
174
+    def _save_project_memory(self, memory: dict[str, Any]) -> None:
175
+        self.ensure_layout()
176
+        self.project_memory_path.write_text(json.dumps(memory, indent=2, sort_keys=True))
177
+
178
+    def _load_notepad_sections(self) -> dict[str, str]:
179
+        self.ensure_layout()
180
+        sections = {
181
+            "priority": "",
182
+            "working": "",
183
+            "manual": "",
184
+        }
185
+        if not self.notepad_path.exists():
186
+            return sections
187
+
188
+        current: str | None = None
189
+        for line in self.notepad_path.read_text().splitlines():
190
+            if line == "## Priority Context":
191
+                current = "priority"
192
+                continue
193
+            if line == "## Working Memory":
194
+                current = "working"
195
+                continue
196
+            if line == "## Manual Notes":
197
+                current = "manual"
198
+                continue
199
+            if current is None or line == "# Loader Notepad":
200
+                continue
201
+            sections[current] = (
202
+                f"{sections[current]}\n{line}".strip()
203
+                if sections[current]
204
+                else line
205
+            )
206
+        return sections
207
+
208
+    def _save_notepad_sections(self, sections: dict[str, str]) -> None:
209
+        self.ensure_layout()
210
+        self.notepad_path.write_text(self._render_notepad(sections))
211
+
212
+    @staticmethod
213
+    def _append_markdown_entry(existing: str, entry: str) -> str:
214
+        return f"{existing}\n{entry}".strip() if existing else entry
215
+
216
+    @staticmethod
217
+    def _render_notepad(sections: dict[str, str]) -> str:
218
+        return "\n".join(
219
+            [
220
+                "# Loader Notepad",
221
+                "",
222
+                "## Priority Context",
223
+                sections.get("priority", "").strip() or "_Empty_",
224
+                "",
225
+                "## Working Memory",
226
+                sections.get("working", "").strip() or "_Empty_",
227
+                "",
228
+                "## Manual Notes",
229
+                sections.get("manual", "").strip() or "_Empty_",
230
+                "",
231
+            ]
232
+        )
src/loader/tools/base.pymodified
@@ -179,6 +179,16 @@ def create_default_registry(
179179
 ) -> ToolRegistry:
180180
     """Create a registry with default tools."""
181181
     from .file_tools import EditTool, GlobTool, ReadTool, WriteTool
182
+    from .memory_tools import (
183
+        NotepadReadTool,
184
+        NotepadWriteManualTool,
185
+        NotepadWritePriorityTool,
186
+        NotepadWriteWorkingTool,
187
+        ProjectMemoryAddDirectiveTool,
188
+        ProjectMemoryAddNoteTool,
189
+        ProjectMemoryReadTool,
190
+        ProjectMemoryWriteTool,
191
+    )
182192
     from .search_tools import GrepTool
183193
     from .shell_tools import BashTool
184194
     from .workflow_tools import AskUserQuestionTool, TodoWriteTool
@@ -192,5 +202,13 @@ def create_default_registry(
192202
     registry.register(GrepTool())
193203
     registry.register(TodoWriteTool())
194204
     registry.register(AskUserQuestionTool())
205
+    registry.register(ProjectMemoryReadTool())
206
+    registry.register(ProjectMemoryWriteTool())
207
+    registry.register(ProjectMemoryAddNoteTool())
208
+    registry.register(ProjectMemoryAddDirectiveTool())
209
+    registry.register(NotepadReadTool())
210
+    registry.register(NotepadWritePriorityTool())
211
+    registry.register(NotepadWriteWorkingTool())
212
+    registry.register(NotepadWriteManualTool())
195213
 
196214
     return registry
src/loader/tools/memory_tools.pyadded
@@ -0,0 +1,300 @@
1
+"""Native Loader tools for project memory and working notes."""
2
+
3
+from __future__ import annotations
4
+
5
+import json
6
+from pathlib import Path
7
+from typing import Any
8
+
9
+from ..runtime.memory import MemoryStore
10
+from ..runtime.permissions import PermissionMode
11
+from .base import Tool, ToolResult
12
+
13
+
14
+class MemoryTool(Tool):
15
+    """Shared base class for `.loader/` memory tools."""
16
+
17
+    def __init__(self, workspace_root: Path | str | None = None) -> None:
18
+        self.workspace_root = (
19
+            Path(workspace_root).expanduser().resolve() if workspace_root else None
20
+        )
21
+
22
+    def set_workspace_root(self, workspace_root: Path | None) -> None:
23
+        self.workspace_root = workspace_root
24
+
25
+    def store(self) -> MemoryStore:
26
+        return MemoryStore(self.workspace_root or Path.cwd())
27
+
28
+
29
+class ProjectMemoryReadTool(MemoryTool):
30
+    """Read project memory."""
31
+
32
+    required_permission = PermissionMode.READ_ONLY
33
+
34
+    @property
35
+    def name(self) -> str:
36
+        return "project_memory_read"
37
+
38
+    @property
39
+    def description(self) -> str:
40
+        return "Read project memory from .loader/project-memory.json."
41
+
42
+    @property
43
+    def parameters(self) -> dict[str, Any]:
44
+        return {
45
+            "type": "object",
46
+            "properties": {
47
+                "section": {
48
+                    "type": "string",
49
+                    "enum": [
50
+                        "all",
51
+                        "techStack",
52
+                        "build",
53
+                        "conventions",
54
+                        "structure",
55
+                        "notes",
56
+                        "directives",
57
+                    ],
58
+                    "description": "Optional project-memory section to read.",
59
+                }
60
+            },
61
+        }
62
+
63
+    async def execute(self, section: str = "all", **kwargs: Any) -> ToolResult:
64
+        payload = self.store().read_project_memory(section=section or "all")
65
+        return ToolResult(
66
+            output=json.dumps(payload, indent=2, sort_keys=True),
67
+            metadata={"section": section or "all", "payload": payload},
68
+        )
69
+
70
+
71
+class ProjectMemoryWriteTool(MemoryTool):
72
+    """Write project memory."""
73
+
74
+    required_permission = PermissionMode.WORKSPACE_WRITE
75
+
76
+    @property
77
+    def name(self) -> str:
78
+        return "project_memory_write"
79
+
80
+    @property
81
+    def description(self) -> str:
82
+        return "Write or merge project memory in .loader/project-memory.json."
83
+
84
+    @property
85
+    def parameters(self) -> dict[str, Any]:
86
+        return {
87
+            "type": "object",
88
+            "properties": {
89
+                "memory": {
90
+                    "type": "object",
91
+                    "description": "Memory object to write.",
92
+                },
93
+                "merge": {
94
+                    "type": "boolean",
95
+                    "description": "Merge with existing memory when true.",
96
+                },
97
+            },
98
+            "required": ["memory"],
99
+        }
100
+
101
+    async def execute(
102
+        self,
103
+        memory: dict[str, Any],
104
+        merge: bool = True,
105
+        **kwargs: Any,
106
+    ) -> ToolResult:
107
+        payload = self.store().write_project_memory(memory, merge=merge)
108
+        return ToolResult(
109
+            output=json.dumps(payload, indent=2, sort_keys=True),
110
+            metadata={"memory": payload, "merge": merge},
111
+        )
112
+
113
+
114
+class ProjectMemoryAddNoteTool(MemoryTool):
115
+    """Append a categorized note to project memory."""
116
+
117
+    required_permission = PermissionMode.WORKSPACE_WRITE
118
+
119
+    @property
120
+    def name(self) -> str:
121
+        return "project_memory_add_note"
122
+
123
+    @property
124
+    def description(self) -> str:
125
+        return "Add a categorized note to project memory."
126
+
127
+    @property
128
+    def parameters(self) -> dict[str, Any]:
129
+        return {
130
+            "type": "object",
131
+            "properties": {
132
+                "category": {"type": "string"},
133
+                "content": {"type": "string"},
134
+            },
135
+            "required": ["category", "content"],
136
+        }
137
+
138
+    async def execute(self, category: str, content: str, **kwargs: Any) -> ToolResult:
139
+        payload = self.store().add_project_note(category, content)
140
+        return ToolResult(
141
+            output=json.dumps(payload, indent=2, sort_keys=True),
142
+            metadata=payload,
143
+        )
144
+
145
+
146
+class ProjectMemoryAddDirectiveTool(MemoryTool):
147
+    """Append a persistent directive to project memory."""
148
+
149
+    required_permission = PermissionMode.WORKSPACE_WRITE
150
+
151
+    @property
152
+    def name(self) -> str:
153
+        return "project_memory_add_directive"
154
+
155
+    @property
156
+    def description(self) -> str:
157
+        return "Add a durable directive to project memory."
158
+
159
+    @property
160
+    def parameters(self) -> dict[str, Any]:
161
+        return {
162
+            "type": "object",
163
+            "properties": {
164
+                "directive": {"type": "string"},
165
+                "priority": {
166
+                    "type": "string",
167
+                    "enum": ["high", "normal"],
168
+                },
169
+                "context": {"type": "string"},
170
+            },
171
+            "required": ["directive"],
172
+        }
173
+
174
+    async def execute(
175
+        self,
176
+        directive: str,
177
+        priority: str = "normal",
178
+        context: str | None = None,
179
+        **kwargs: Any,
180
+    ) -> ToolResult:
181
+        payload = self.store().add_project_directive(
182
+            directive,
183
+            priority=priority,
184
+            context=context,
185
+        )
186
+        return ToolResult(
187
+            output=json.dumps(payload, indent=2, sort_keys=True),
188
+            metadata=payload,
189
+        )
190
+
191
+
192
+class NotepadReadTool(MemoryTool):
193
+    """Read Loader's durable working notepad."""
194
+
195
+    required_permission = PermissionMode.READ_ONLY
196
+
197
+    @property
198
+    def name(self) -> str:
199
+        return "notepad_read"
200
+
201
+    @property
202
+    def description(self) -> str:
203
+        return "Read the Loader notepad from .loader/notepad.md."
204
+
205
+    @property
206
+    def parameters(self) -> dict[str, Any]:
207
+        return {
208
+            "type": "object",
209
+            "properties": {
210
+                "section": {
211
+                    "type": "string",
212
+                    "enum": ["all", "priority", "working", "manual"],
213
+                }
214
+            },
215
+        }
216
+
217
+    async def execute(self, section: str = "all", **kwargs: Any) -> ToolResult:
218
+        payload = self.store().read_notepad(section=section or "all")
219
+        return ToolResult(
220
+            output=payload,
221
+            metadata={"section": section or "all", "payload": payload},
222
+        )
223
+
224
+
225
+class NotepadWritePriorityTool(MemoryTool):
226
+    """Replace the priority context section in the notepad."""
227
+
228
+    required_permission = PermissionMode.WORKSPACE_WRITE
229
+
230
+    @property
231
+    def name(self) -> str:
232
+        return "notepad_write_priority"
233
+
234
+    @property
235
+    def description(self) -> str:
236
+        return "Replace the priority-context section in .loader/notepad.md."
237
+
238
+    @property
239
+    def parameters(self) -> dict[str, Any]:
240
+        return {
241
+            "type": "object",
242
+            "properties": {"content": {"type": "string"}},
243
+            "required": ["content"],
244
+        }
245
+
246
+    async def execute(self, content: str, **kwargs: Any) -> ToolResult:
247
+        payload = self.store().write_notepad_priority(content)
248
+        return ToolResult(output=payload, metadata={"content": payload})
249
+
250
+
251
+class NotepadWriteWorkingTool(MemoryTool):
252
+    """Append one timestamped working-memory note."""
253
+
254
+    required_permission = PermissionMode.WORKSPACE_WRITE
255
+
256
+    @property
257
+    def name(self) -> str:
258
+        return "notepad_write_working"
259
+
260
+    @property
261
+    def description(self) -> str:
262
+        return "Append a timestamped entry to the working-memory section."
263
+
264
+    @property
265
+    def parameters(self) -> dict[str, Any]:
266
+        return {
267
+            "type": "object",
268
+            "properties": {"content": {"type": "string"}},
269
+            "required": ["content"],
270
+        }
271
+
272
+    async def execute(self, content: str, **kwargs: Any) -> ToolResult:
273
+        payload = self.store().append_notepad_working(content)
274
+        return ToolResult(output=payload, metadata={"content": payload})
275
+
276
+
277
+class NotepadWriteManualTool(MemoryTool):
278
+    """Append one manual note."""
279
+
280
+    required_permission = PermissionMode.WORKSPACE_WRITE
281
+
282
+    @property
283
+    def name(self) -> str:
284
+        return "notepad_write_manual"
285
+
286
+    @property
287
+    def description(self) -> str:
288
+        return "Append a manual note to .loader/notepad.md."
289
+
290
+    @property
291
+    def parameters(self) -> dict[str, Any]:
292
+        return {
293
+            "type": "object",
294
+            "properties": {"content": {"type": "string"}},
295
+            "required": ["content"],
296
+        }
297
+
298
+    async def execute(self, content: str, **kwargs: Any) -> ToolResult:
299
+        payload = self.store().append_notepad_manual(content)
300
+        return ToolResult(output=payload, metadata={"content": payload})
tests/test_cli_resume.pyadded
@@ -0,0 +1,19 @@
1
+"""Tests for CLI resume argument rewriting."""
2
+
3
+from __future__ import annotations
4
+
5
+from loader.cli.options import inject_resume_target
6
+
7
+
8
+def test_inject_resume_target_supports_flag_and_named_session() -> None:
9
+    assert inject_resume_target([]) == []
10
+    assert inject_resume_target(["--resume"]) == ["--resume-target", "__latest__"]
11
+    assert inject_resume_target(["--resume", "session-123"]) == [
12
+        "--resume-target",
13
+        "session-123",
14
+    ]
15
+    assert inject_resume_target(["--resume", "session-123", "fix runtime"]) == [
16
+        "--resume-target",
17
+        "session-123",
18
+        "fix runtime",
19
+    ]
tests/test_memory_tools.pyadded
@@ -0,0 +1,127 @@
1
+"""Tests for project memory and notepad tools."""
2
+
3
+from __future__ import annotations
4
+
5
+import json
6
+from pathlib import Path
7
+
8
+import pytest
9
+
10
+from loader.agent.loop import AgentConfig
11
+from loader.llm.base import CompletionResponse, ToolCall
12
+from loader.runtime.executor import ToolExecutionState, ToolExecutor
13
+from loader.runtime.hooks import HookManager, MemoryLifecycleHook
14
+from loader.runtime.permissions import PermissionMode, build_permission_policy
15
+from loader.runtime.tracing import RuntimeTracer
16
+from loader.tools.base import create_default_registry
17
+from tests.helpers.runtime_harness import ScriptedBackend, run_scenario
18
+
19
+
20
+@pytest.mark.asyncio
21
+async def test_project_memory_tools_round_trip(temp_dir: Path) -> None:
22
+    registry = create_default_registry(temp_dir)
23
+
24
+    write_result = await registry.execute(
25
+        "project_memory_write",
26
+        memory={"techStack": "Python", "build": "uv run pytest -q"},
27
+        merge=True,
28
+    )
29
+    read_result = await registry.execute("project_memory_read", section="all")
30
+    directive_result = await registry.execute(
31
+        "project_memory_add_directive",
32
+        directive="Use uv, never pip.",
33
+        priority="high",
34
+    )
35
+
36
+    assert not write_result.is_error
37
+    assert not read_result.is_error
38
+    assert not directive_result.is_error
39
+    memory = json.loads(read_result.output)
40
+    assert memory["techStack"] == "Python"
41
+    assert registry.get("project_memory_read").required_permission == PermissionMode.READ_ONLY
42
+    assert registry.get("project_memory_write").required_permission == PermissionMode.WORKSPACE_WRITE
43
+
44
+
45
+@pytest.mark.asyncio
46
+async def test_notepad_tools_round_trip(temp_dir: Path) -> None:
47
+    registry = create_default_registry(temp_dir)
48
+
49
+    await registry.execute("notepad_write_priority", content="Keep Loader focused on runtime quality.")
50
+    await registry.execute("notepad_write_working", content="Audit session resume behavior.")
51
+    await registry.execute("notepad_write_manual", content="Never delete refs without asking.")
52
+    read_result = await registry.execute("notepad_read", section="all")
53
+
54
+    assert not read_result.is_error
55
+    assert "Keep Loader focused on runtime quality." in read_result.output
56
+    assert "Audit session resume behavior." in read_result.output
57
+    assert "Never delete refs without asking." in read_result.output
58
+
59
+
60
+@pytest.mark.asyncio
61
+async def test_memory_lifecycle_hook_mirrors_directives_into_notepad(temp_dir: Path) -> None:
62
+    registry = create_default_registry(temp_dir)
63
+    policy = build_permission_policy(
64
+        active_mode=PermissionMode.WORKSPACE_WRITE,
65
+        workspace_root=temp_dir,
66
+        tool_requirements=registry.get_tool_requirements(),
67
+    )
68
+    executor = ToolExecutor(
69
+        registry,
70
+        RuntimeTracer(),
71
+        policy,
72
+        hooks=HookManager([MemoryLifecycleHook()]),
73
+    )
74
+
75
+    outcome = await executor.execute_tool_call(
76
+        ToolCall(
77
+            id="directive-1",
78
+            name="project_memory_add_directive",
79
+            arguments={
80
+                "directive": "Use uv, never pip.",
81
+                "priority": "high",
82
+            },
83
+        ),
84
+        source="native",
85
+        skip_confirmation=True,
86
+    )
87
+
88
+    assert outcome.state == ToolExecutionState.EXECUTED
89
+    notepad = (temp_dir / ".loader" / "notepad.md").read_text()
90
+    assert "Remembered directive [high]: Use uv, never pip." in notepad
91
+
92
+
93
+@pytest.mark.asyncio
94
+async def test_definition_of_done_summary_is_captured_in_project_memory(
95
+    temp_dir: Path,
96
+) -> None:
97
+    target = temp_dir / "memory-proof.txt"
98
+    backend = ScriptedBackend(
99
+        completions=[
100
+            CompletionResponse(
101
+                content="I'll create the file.",
102
+                tool_calls=[
103
+                    ToolCall(
104
+                        id="write-1",
105
+                        name="write",
106
+                        arguments={"file_path": str(target), "content": "memory proof\n"},
107
+                    )
108
+                ],
109
+            ),
110
+            CompletionResponse(content="The file is in place."),
111
+        ]
112
+    )
113
+
114
+    await run_scenario(
115
+        "Create memory-proof.txt in the workspace root.",
116
+        backend,
117
+        config=AgentConfig(auto_context=False, stream=False),
118
+        project_root=temp_dir,
119
+    )
120
+
121
+    memory = json.loads((temp_dir / ".loader" / "project-memory.json").read_text())
122
+    notes = memory.get("notes", [])
123
+    assert any(
124
+        note.get("category") == "definition_of_done"
125
+        and "Verification:" in note.get("content", "")
126
+        for note in notes
127
+    )