memorykit 0.6.0__tar.gz → 0.8.0__tar.gz

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.
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 — 2026-08-30
4
+
5
+ - Add safe automatic project detection for GitHub Copilot plugin installs.
6
+ `SessionStart` accepts only the host-supplied `cwd` and `sessionId`, requires
7
+ `sessionId` to exactly match `COPILOT_AGENT_SESSION_ID`, resolves the Git
8
+ top-level and a canonical `github.com/owner/repository` origin through the
9
+ provider's existing normalizer, and fails closed for missing, foreign-host,
10
+ deep-namespace, invalid, or conflicting repository context.
11
+ - Persist only `session_id` and normalized `project` in a private, atomic,
12
+ idempotent binding under `CONTEXT_KIT_MEMORY_HOME/session-bindings`. Session
13
+ IDs are validated as safe filenames, same-session cross-project reuse is
14
+ refused, explicit configuration keeps precedence, and `SessionEnd` removes
15
+ the matching binding. Crash leftovers cannot cross sessions because lookup is
16
+ keyed by the host-generated session ID.
17
+ - Fail closed on non-POSIX hosts, where the standard library cannot verify
18
+ owner-only ACLs for the binding root. Windows and other unsupported platforms
19
+ retain the full explicit project configuration path.
20
+ - Keep project resolution in the provider used by both CLI and MCP. MCP tool
21
+ arguments, prompt content, MCP working directory, and `PWD` remain ineligible
22
+ scope inputs. Hosts without Copilot's matching hook/session-ID support,
23
+ including APM, still require explicit project configuration.
24
+
25
+ ## 0.7.0 — 2026-08-29
26
+
27
+ - Make plugin-installed MCP safe and configurable in GitHub Copilot: declare
28
+ `stdio` explicitly and forward the operator-set
29
+ `CONTEXT_KIT_MEMORY_PROJECT` (or deprecated
30
+ `PRODUCTIVITY_SKILLS_MEMORY_PROJECT`) into the child process. The server still
31
+ refuses when no explicit scope is configured; prompt content cannot select
32
+ another project store.
33
+ - Move MCP proposal-only and absolute-source enforcement into the provider, so
34
+ quoted frontmatter follows the same parser as CLI capture and policy cannot
35
+ drift between surfaces. Provider validation now refuses a missing or
36
+ non-regular evidence source instead of skipping hash verification.
37
+ - Bound incoming JSON-RPC frames before decoding and reconcile the MCP server
38
+ version in packaging tests.
39
+ - Add MCP tool safety annotations and collapse missing-source versus
40
+ hash-mismatch failures into one capture refusal, avoiding a path-existence
41
+ distinction while preserving explicit failure.
42
+
3
43
  ## 0.6.0 — 2026-08-09
4
44
 
5
45
  - **Package the memory contract, validator, and MCP server as `memorykit`, ready
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: memorykit
3
- Version: 0.6.0
3
+ Version: 0.8.0
4
4
  Summary: Provenance-bound durable memory: an immutable record contract, a validator, append-only review state, and a standard-library stdio MCP server. No Python dependencies; requires git on PATH.
5
5
  Project-URL: Homepage, https://github.com/mbeacom/context-kit
6
6
  Project-URL: Repository, https://github.com/mbeacom/context-kit
@@ -77,20 +77,16 @@ Installing `memory` also installs `context-handoff`, `verify`, and
77
77
  The contract, validator, and MCP server are also packaged as **`memorykit`**, a
78
78
  pure-standard-library Python package (ADR-0002, ADR-0009).
79
79
 
80
- > **Not on PyPI yet.** The package is publish-ready but the name has not been
81
- > claimed, so `pip install memorykit` does **not** currently install this — it
82
- > will fail, or, if someone else claims the name first, install something else
83
- > entirely. Until the release workflow runs, install from a clone.
84
-
85
80
  ```bash
86
- # From a clone of https://github.com/mbeacom/context-kit
87
- pip install ./plugins/memory # or: uv tool install ./plugins/memory
81
+ pip install memorykit # or: uv tool install memorykit
88
82
  ```
89
83
 
90
- Once published, that becomes:
84
+ To work from a clone instead — for contributing, or to run an unreleased
85
+ revision:
91
86
 
92
87
  ```bash
93
- pip install memorykit # or: uv tool install memorykit
88
+ # From a clone of https://github.com/mbeacom/context-kit
89
+ pip install ./plugins/memory # or: uv tool install ./plugins/memory
94
90
  ```
95
91
 
96
92
  Either way that is the whole install: no bootstrap step, no plugin runtime, no
@@ -130,7 +126,8 @@ is the reverse of the `indexkit` launcher's preference, and deliberate.
130
126
 
131
127
  ## Local-only reviewed records
132
128
 
133
- Python 3 is the only requirement. Configure an explicit project and plugin root:
129
+ Python 3.10+ and Git on `PATH` are required. Configure an explicit project and
130
+ plugin root:
134
131
 
135
132
  ```bash
136
133
  export CONTEXT_KIT_MEMORY_PROJECT=owner/repository
@@ -223,12 +220,13 @@ authoring a `memory-v1` record from a candidate stays an explicit judgment step.
223
220
 
224
221
  See [`references/session-mining.md`](skills/memory-workflows/references/session-mining.md).
225
222
 
226
- ## MCP surface for non-plugin hosts
223
+ ## MCP surface
227
224
 
228
225
  An optional stdio MCP server exposes `memory_recall`, `memory_capture`, and
229
- `memory_review` so hosts that consume skills plus MCP can use durable memory
230
- without a plugin runtime. It is standard library only and shells out to the
231
- same provider, so the CLI and MCP paths cannot drift.
226
+ `memory_review` so hosts that consume skills plus MCP can use durable memory.
227
+ GitHub Copilot and Claude Code plugin installs discover it automatically;
228
+ package-only hosts can launch it directly. It is standard library only and
229
+ shells out to the same provider, so the CLI and MCP paths cannot drift.
232
230
 
233
231
  ```bash
234
232
  # From the plugin:
@@ -239,6 +237,24 @@ CONTEXT_KIT_MEMORY_PROJECT=owner/repository \
239
237
  CONTEXT_KIT_MEMORY_PROJECT=owner/repository memorykit-mcp
240
238
  ```
241
239
 
240
+ The bundled `.mcp.json` forwards explicit project/home configuration into the
241
+ plugin process. GitHub Copilot Desktop can additionally bind the MCP child to
242
+ the active project through trusted host data: `SessionStart` requires payload
243
+ `sessionId` to exactly match `COPILOT_AGENT_SESSION_ID`, then resolves payload
244
+ `cwd` to the Git top-level and accepts only a canonical
245
+ `github.com/owner/repository` origin on POSIX. It stores only that session ID and
246
+ project under the private memory home and removes the binding on `SessionEnd`.
247
+
248
+ Explicit `--project`, `CONTEXT_KIT_MEMORY_PROJECT`, deprecated
249
+ `PRODUCTIVITY_SKILLS_MEMORY_PROJECT`, and `CLAUDE_PLUGIN_OPTION_PROJECT` take
250
+ precedence, in that order, over the binding. MCP cwd, `PWD`, prompts, and tool
251
+ arguments never select scope. APM, package-only use, and hosts without the
252
+ matching Copilot hook/session ID still require explicit configuration. The same
253
+ is true on Windows and for other Git hosts or deeper repository namespaces,
254
+ whose privacy or identity cannot be represented safely by this automatic path.
255
+ MCP capture records must use an absolute `source` path because plugin hosts may
256
+ launch the server outside the active repository.
257
+
242
258
  The surface can propose memory but **cannot activate it**: a record whose
243
259
  frontmatter is not `review: proposed` is refused, and proposals stay out of
244
260
  active recall until promoted with the append-only `record-state` CLI.
@@ -248,7 +264,7 @@ See [`references/mcp-server.md`](skills/memory-workflows/references/mcp-server.m
248
264
 
249
265
  ## Opt-in lifecycle queue
250
266
 
251
- Claude hooks are inert until enabled:
267
+ Recall and payload-queue behavior is inert until enabled:
252
268
 
253
269
  ```bash
254
270
  export CONTEXT_KIT_MEMORY_PROJECT=owner/repository
@@ -260,6 +276,11 @@ memory records or mutate a provider store. Claude Code and GitHub Copilot CLI
260
276
  both load `hooks/hooks.json`; APM does not deploy hooks, so capture stays an
261
277
  explicit command there.
262
278
 
279
+ Copilot's routing-only `SessionStart` binding is the bounded exception: it is
280
+ created even when both switches are off, contains no transcript or secret, and
281
+ is cleaned at `SessionEnd` (not `Stop`/`agentStop`). It is ephemeral routing
282
+ metadata, not automatic memory capture.
283
+
263
284
  ## Components
264
285
 
265
286
  | Component | Purpose |
@@ -270,7 +291,7 @@ explicit command there.
270
291
  | `/review-memory` | Review freshness, conflicts, and consolidation proposals. |
271
292
  | `/archive-handoff` | Explicitly preserve a validated handoff as historical memory. |
272
293
  | `memory-provider.py` | Launcher for the `memorykit` provider: stdlib validator, local store, MemPalace adapter, and hook dispatcher. |
273
- | `src/memorykit/` | The packaged engine (`memorykit`, not yet on PyPI): contract, validator, provider, MCP server. |
294
+ | `src/memorykit/` | The packaged `memorykit` engine: contract, validator, provider, MCP server. |
274
295
 
275
296
  ## Safety boundaries
276
297
 
@@ -34,20 +34,16 @@ Installing `memory` also installs `context-handoff`, `verify`, and
34
34
  The contract, validator, and MCP server are also packaged as **`memorykit`**, a
35
35
  pure-standard-library Python package (ADR-0002, ADR-0009).
36
36
 
37
- > **Not on PyPI yet.** The package is publish-ready but the name has not been
38
- > claimed, so `pip install memorykit` does **not** currently install this — it
39
- > will fail, or, if someone else claims the name first, install something else
40
- > entirely. Until the release workflow runs, install from a clone.
41
-
42
37
  ```bash
43
- # From a clone of https://github.com/mbeacom/context-kit
44
- pip install ./plugins/memory # or: uv tool install ./plugins/memory
38
+ pip install memorykit # or: uv tool install memorykit
45
39
  ```
46
40
 
47
- Once published, that becomes:
41
+ To work from a clone instead — for contributing, or to run an unreleased
42
+ revision:
48
43
 
49
44
  ```bash
50
- pip install memorykit # or: uv tool install memorykit
45
+ # From a clone of https://github.com/mbeacom/context-kit
46
+ pip install ./plugins/memory # or: uv tool install ./plugins/memory
51
47
  ```
52
48
 
53
49
  Either way that is the whole install: no bootstrap step, no plugin runtime, no
@@ -87,7 +83,8 @@ is the reverse of the `indexkit` launcher's preference, and deliberate.
87
83
 
88
84
  ## Local-only reviewed records
89
85
 
90
- Python 3 is the only requirement. Configure an explicit project and plugin root:
86
+ Python 3.10+ and Git on `PATH` are required. Configure an explicit project and
87
+ plugin root:
91
88
 
92
89
  ```bash
93
90
  export CONTEXT_KIT_MEMORY_PROJECT=owner/repository
@@ -180,12 +177,13 @@ authoring a `memory-v1` record from a candidate stays an explicit judgment step.
180
177
 
181
178
  See [`references/session-mining.md`](skills/memory-workflows/references/session-mining.md).
182
179
 
183
- ## MCP surface for non-plugin hosts
180
+ ## MCP surface
184
181
 
185
182
  An optional stdio MCP server exposes `memory_recall`, `memory_capture`, and
186
- `memory_review` so hosts that consume skills plus MCP can use durable memory
187
- without a plugin runtime. It is standard library only and shells out to the
188
- same provider, so the CLI and MCP paths cannot drift.
183
+ `memory_review` so hosts that consume skills plus MCP can use durable memory.
184
+ GitHub Copilot and Claude Code plugin installs discover it automatically;
185
+ package-only hosts can launch it directly. It is standard library only and
186
+ shells out to the same provider, so the CLI and MCP paths cannot drift.
189
187
 
190
188
  ```bash
191
189
  # From the plugin:
@@ -196,6 +194,24 @@ CONTEXT_KIT_MEMORY_PROJECT=owner/repository \
196
194
  CONTEXT_KIT_MEMORY_PROJECT=owner/repository memorykit-mcp
197
195
  ```
198
196
 
197
+ The bundled `.mcp.json` forwards explicit project/home configuration into the
198
+ plugin process. GitHub Copilot Desktop can additionally bind the MCP child to
199
+ the active project through trusted host data: `SessionStart` requires payload
200
+ `sessionId` to exactly match `COPILOT_AGENT_SESSION_ID`, then resolves payload
201
+ `cwd` to the Git top-level and accepts only a canonical
202
+ `github.com/owner/repository` origin on POSIX. It stores only that session ID and
203
+ project under the private memory home and removes the binding on `SessionEnd`.
204
+
205
+ Explicit `--project`, `CONTEXT_KIT_MEMORY_PROJECT`, deprecated
206
+ `PRODUCTIVITY_SKILLS_MEMORY_PROJECT`, and `CLAUDE_PLUGIN_OPTION_PROJECT` take
207
+ precedence, in that order, over the binding. MCP cwd, `PWD`, prompts, and tool
208
+ arguments never select scope. APM, package-only use, and hosts without the
209
+ matching Copilot hook/session ID still require explicit configuration. The same
210
+ is true on Windows and for other Git hosts or deeper repository namespaces,
211
+ whose privacy or identity cannot be represented safely by this automatic path.
212
+ MCP capture records must use an absolute `source` path because plugin hosts may
213
+ launch the server outside the active repository.
214
+
199
215
  The surface can propose memory but **cannot activate it**: a record whose
200
216
  frontmatter is not `review: proposed` is refused, and proposals stay out of
201
217
  active recall until promoted with the append-only `record-state` CLI.
@@ -205,7 +221,7 @@ See [`references/mcp-server.md`](skills/memory-workflows/references/mcp-server.m
205
221
 
206
222
  ## Opt-in lifecycle queue
207
223
 
208
- Claude hooks are inert until enabled:
224
+ Recall and payload-queue behavior is inert until enabled:
209
225
 
210
226
  ```bash
211
227
  export CONTEXT_KIT_MEMORY_PROJECT=owner/repository
@@ -217,6 +233,11 @@ memory records or mutate a provider store. Claude Code and GitHub Copilot CLI
217
233
  both load `hooks/hooks.json`; APM does not deploy hooks, so capture stays an
218
234
  explicit command there.
219
235
 
236
+ Copilot's routing-only `SessionStart` binding is the bounded exception: it is
237
+ created even when both switches are off, contains no transcript or secret, and
238
+ is cleaned at `SessionEnd` (not `Stop`/`agentStop`). It is ephemeral routing
239
+ metadata, not automatic memory capture.
240
+
220
241
  ## Components
221
242
 
222
243
  | Component | Purpose |
@@ -227,7 +248,7 @@ explicit command there.
227
248
  | `/review-memory` | Review freshness, conflicts, and consolidation proposals. |
228
249
  | `/archive-handoff` | Explicitly preserve a validated handoff as historical memory. |
229
250
  | `memory-provider.py` | Launcher for the `memorykit` provider: stdlib validator, local store, MemPalace adapter, and hook dispatcher. |
230
- | `src/memorykit/` | The packaged engine (`memorykit`, not yet on PyPI): contract, validator, provider, MCP server. |
251
+ | `src/memorykit/` | The packaged `memorykit` engine: contract, validator, provider, MCP server. |
231
252
 
232
253
  ## Safety boundaries
233
254
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "memorykit"
3
- version = "0.6.0"
3
+ version = "0.8.0"
4
4
  description = "Provenance-bound durable memory: an immutable record contract, a validator, append-only review state, and a standard-library stdio MCP server. No Python dependencies; requires git on PATH."
5
5
  requires-python = ">=3.10"
6
6
  keywords = [
@@ -19,6 +19,6 @@ Two entry points, one implementation:
19
19
 
20
20
  from __future__ import annotations
21
21
 
22
- __version__ = "0.6.0"
22
+ __version__ = "0.8.0"
23
23
 
24
24
  __all__ = ["__version__"]
@@ -26,7 +26,7 @@ from pathlib import Path
26
26
  from typing import Any
27
27
 
28
28
  SERVER_NAME = "context-kit-memory"
29
- SERVER_VERSION = "0.3.0"
29
+ SERVER_VERSION = "0.8.0"
30
30
  # Newest first. The client's requested version is echoed when supported.
31
31
  SUPPORTED_PROTOCOLS = ("2025-06-18", "2025-03-26", "2024-11-05")
32
32
  # The provider is this package's sibling module, so one path is correct in both
@@ -38,6 +38,11 @@ SUPPORTED_PROTOCOLS = ("2025-06-18", "2025-03-26", "2024-11-05")
38
38
  PROVIDER = Path(__file__).resolve().with_name("provider.py")
39
39
  CALL_TIMEOUT_SECONDS = 120.0
40
40
  MAX_RECORD_BYTES = 32 * 1024
41
+ MAX_FRAME_CHARS = MAX_RECORD_BYTES * 8
42
+ EVIDENCE_ERROR_MARKERS = (
43
+ "referenced source is not a readable file",
44
+ "source_hash does not match the referenced source file",
45
+ )
41
46
 
42
47
  PARSE_ERROR = -32700
43
48
  INVALID_REQUEST = -32600
@@ -70,14 +75,15 @@ TOOLS: list[dict[str, Any]] = [
70
75
  "required": ["query"],
71
76
  "additionalProperties": False,
72
77
  },
78
+ "annotations": {"readOnlyHint": True, "openWorldHint": False},
73
79
  },
74
80
  {
75
81
  "name": "memory_capture",
76
82
  "description": (
77
83
  "Persist a context-kit/memory-v1 record as review: proposed. The "
78
- "record must cite real evidence: its source_hash is verified "
79
- "against the source file. Proposed records are inert until a human "
80
- "accepts them with the record-state CLI."
84
+ "record must cite real evidence at an absolute source path: its "
85
+ "source_hash is verified against the source file. Proposed records "
86
+ "are inert until a human accepts them with the record-state CLI."
81
87
  ),
82
88
  "inputSchema": {
83
89
  "type": "object",
@@ -94,6 +100,12 @@ TOOLS: list[dict[str, Any]] = [
94
100
  "required": ["record"],
95
101
  "additionalProperties": False,
96
102
  },
103
+ "annotations": {
104
+ "readOnlyHint": False,
105
+ "destructiveHint": False,
106
+ "idempotentHint": True,
107
+ "openWorldHint": False,
108
+ },
97
109
  },
98
110
  {
99
111
  "name": "memory_review",
@@ -106,6 +118,7 @@ TOOLS: list[dict[str, Any]] = [
106
118
  "properties": {},
107
119
  "additionalProperties": False,
108
120
  },
121
+ "annotations": {"readOnlyHint": True, "openWorldHint": False},
109
122
  },
110
123
  ]
111
124
 
@@ -119,26 +132,10 @@ def _log(message: str) -> None:
119
132
  print(f"{SERVER_NAME}: {message}", file=sys.stderr, flush=True)
120
133
 
121
134
 
122
- def _project_scope() -> str:
123
- for name in ("CONTEXT_KIT_MEMORY_PROJECT", "CLAUDE_PLUGIN_OPTION_PROJECT"):
124
- # Portable first, then the Claude userConfig fallback the CLI accepts.
125
- # Checking only the portable name would make every tool refuse on a
126
- # normal Claude install configured through the plugin's option.
127
- project = os.environ.get(name, "").strip()
128
- if project:
129
- return project
130
- raise ToolError(
131
- "no memory project is configured; set CONTEXT_KIT_MEMORY_PROJECT to "
132
- "an explicit owner/repository. Memory is never read from or written "
133
- "to an inferred global store."
134
- )
135
-
136
-
137
- def _run_provider(argv: list[str]) -> str:
135
+ def _run_provider(argv: list[str], *, redact_evidence_errors: bool = False) -> str:
138
136
  if not PROVIDER.is_file():
139
137
  raise ToolError(f"memory provider script is missing: {PROVIDER}")
140
- project = _project_scope()
141
- command = [sys.executable, str(PROVIDER), *argv, "--project", project]
138
+ command = [sys.executable, str(PROVIDER), *argv]
142
139
  try:
143
140
  result = subprocess.run(
144
141
  command,
@@ -154,23 +151,14 @@ def _run_provider(argv: list[str]) -> str:
154
151
  raise ToolError(f"memory command could not run: {exc}") from exc
155
152
  if result.returncode != 0:
156
153
  detail = result.stderr.decode("utf-8", errors="replace").strip()
154
+ if redact_evidence_errors and any(
155
+ marker in detail for marker in EVIDENCE_ERROR_MARKERS
156
+ ):
157
+ detail = "memory capture refused: cited source evidence did not validate"
157
158
  raise ToolError(detail or f"memory command exited {result.returncode}")
158
159
  return result.stdout.decode("utf-8", errors="replace").strip()
159
160
 
160
161
 
161
- def _frontmatter_value(record: str, field: str) -> str | None:
162
- lines = record.splitlines()
163
- if not lines or lines[0].strip() != "---":
164
- return None
165
- for line in lines[1:]:
166
- if line.strip() == "---":
167
- break
168
- key, separator, value = line.partition(":")
169
- if separator and key.strip() == field:
170
- return value.strip()
171
- return None
172
-
173
-
174
162
  def _tool_memory_recall(arguments: dict[str, Any]) -> str:
175
163
  query = arguments.get("query")
176
164
  if not isinstance(query, str) or not query.strip():
@@ -192,38 +180,21 @@ def _tool_memory_capture(arguments: dict[str, Any]) -> str:
192
180
  raise ToolError(
193
181
  f"record exceeds {MAX_RECORD_BYTES} bytes; keep a memory atomic"
194
182
  )
195
- review = _frontmatter_value(record, "review")
196
- if review is None:
197
- raise ToolError("record is missing flat YAML frontmatter with a `review` field")
198
- if review != "proposed":
199
- # `capture` takes the initial state from frontmatter, so without this
200
- # guard an agent could write `review: accepted` and activate a memory
201
- # with no human review at all.
202
- raise ToolError(
203
- "this surface can only propose memory, but the record declares "
204
- f"review: {review}. Set `review: proposed` and promote it later "
205
- "with `memory-provider.py record-state <id> --review accepted "
206
- "--reason ...` after the evidence has been checked."
207
- )
208
- # `validate_memory` verifies `source_hash` only when the source is a
209
- # *regular file* (`source.is_file()`), so `exists()` here would be a weaker
210
- # gate than the one it exists to mirror: a record citing a directory would
211
- # pass this check, skip hash verification entirely, and persist with any
212
- # 64-character hash and unverifiable provenance. Match the provider.
213
- source = _frontmatter_value(record, "source")
214
- if not source:
215
- raise ToolError("record is missing a `source` field citing its evidence")
216
- if not Path(source).expanduser().is_file():
217
- raise ToolError(
218
- f"the cited source is not a readable file: {source}. A memory must "
219
- "point at evidence that can be re-read and hashed."
220
- )
221
183
  handle, temporary = tempfile.mkstemp(prefix="memory-capture-", suffix=".md")
222
184
  path = Path(temporary)
223
185
  try:
224
186
  with os.fdopen(handle, "wb") as stream:
225
187
  stream.write(raw)
226
- return _run_provider(["capture", str(path)])
188
+ return _run_provider(
189
+ [
190
+ "capture",
191
+ str(path),
192
+ "--require-review",
193
+ "proposed",
194
+ "--require-absolute-source",
195
+ ],
196
+ redact_evidence_errors=True,
197
+ )
227
198
  finally:
228
199
  path.unlink(missing_ok=True)
229
200
 
@@ -326,7 +297,22 @@ def _write(sink: Any, payload: dict[str, Any]) -> None:
326
297
  def serve(stdin: Any = None, stdout: Any = None) -> int:
327
298
  source = stdin if stdin is not None else sys.stdin
328
299
  sink = stdout if stdout is not None else sys.stdout
329
- for line in source:
300
+ while True:
301
+ line = source.readline(MAX_FRAME_CHARS + 1)
302
+ if not line:
303
+ break
304
+ if len(line) > MAX_FRAME_CHARS:
305
+ while line and not line.endswith("\n"):
306
+ line = source.readline(MAX_FRAME_CHARS + 1)
307
+ _write(
308
+ sink,
309
+ _error(
310
+ None,
311
+ INVALID_REQUEST,
312
+ f"JSON-RPC frame exceeds {MAX_FRAME_CHARS} characters",
313
+ ),
314
+ )
315
+ continue
330
316
  line = line.strip()
331
317
  if not line:
332
318
  continue
@@ -17,6 +17,7 @@ import json
17
17
  import os
18
18
  import re
19
19
  import shutil
20
+ import stat
20
21
  import subprocess
21
22
  import sys
22
23
  import tempfile
@@ -25,6 +26,7 @@ import uuid
25
26
  from dataclasses import dataclass
26
27
  from datetime import datetime, timezone
27
28
  from pathlib import Path
29
+ from urllib.parse import urlsplit
28
30
 
29
31
  SCHEMA = "context-kit/memory-v1"
30
32
  MAX_BYTES = 32 * 1024
@@ -54,9 +56,13 @@ WAKE_SCHEMA = "context-kit/memory-wake-v1"
54
56
  # Session mining recognizes GitHub Copilot CLI event logs
55
57
  # (`~/.copilot/session-state/<session-id>/events.jsonl`).
56
58
  SESSION_PRODUCER = "github-copilot-cli"
57
- # `session_id` arrives from the session log and is used to name a candidate
58
- # file, so it is validated as a single safe path component before use.
59
+ # Session identifiers arrive from Copilot logs and lifecycle payloads and are
60
+ # used in filenames, so they must remain one bounded path component.
59
61
  SESSION_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
62
+ COPILOT_SESSION_ID_ENV = "COPILOT_AGENT_SESSION_ID"
63
+ SESSION_BINDING_DIRNAME = "session-bindings"
64
+ SESSION_BINDING_MAX_BYTES = 512
65
+ SESSION_BINDING_KEYS = frozenset({"session_id", "project"})
60
66
  MAX_TURN_CHARS = 2000
61
67
  MAX_CANDIDATE_TURNS = 400
62
68
  # A wake digest primes a session, so it competes with real work for context.
@@ -437,11 +443,12 @@ def _indexkit_home() -> Path:
437
443
  return default_root / "indexkit"
438
444
 
439
445
 
440
- def _config(args: argparse.Namespace) -> Config:
446
+ def _config(args: argparse.Namespace, *, allow_session_binding: bool = True) -> Config:
441
447
  provider = (
442
448
  getattr(args, "provider", None)
443
449
  or _first_env(
444
450
  "CONTEXT_KIT_MEMORY_PROVIDER",
451
+ "PRODUCTIVITY_SKILLS_MEMORY_PROVIDER",
445
452
  "CLAUDE_PLUGIN_OPTION_PROVIDER",
446
453
  )
447
454
  or "none"
@@ -453,6 +460,7 @@ def _config(args: argparse.Namespace) -> Config:
453
460
  getattr(args, "home", None)
454
461
  or _first_env(
455
462
  "CONTEXT_KIT_MEMORY_HOME",
463
+ "PRODUCTIVITY_SKILLS_MEMORY_HOME",
456
464
  "CLAUDE_PLUGIN_OPTION_MEMORY_HOME",
457
465
  )
458
466
  or "~/.local/share/context-kit/memory"
@@ -460,14 +468,19 @@ def _config(args: argparse.Namespace) -> Config:
460
468
  home = Path(home_value).expanduser().resolve()
461
469
  project = getattr(args, "project", None) or _first_env(
462
470
  "CONTEXT_KIT_MEMORY_PROJECT",
471
+ "PRODUCTIVITY_SKILLS_MEMORY_PROJECT",
463
472
  "CLAUDE_PLUGIN_OPTION_PROJECT",
464
473
  )
474
+ if not project and allow_session_binding:
475
+ project = _copilot_session_binding_project(home)
465
476
  auto_value = _first_env(
466
477
  "CONTEXT_KIT_MEMORY_AUTO_CAPTURE",
478
+ "PRODUCTIVITY_SKILLS_MEMORY_AUTO_CAPTURE",
467
479
  "CLAUDE_PLUGIN_OPTION_AUTO_CAPTURE",
468
480
  )
469
481
  recall_value = _first_env(
470
482
  "CONTEXT_KIT_MEMORY_RECALL_ON_START",
483
+ "PRODUCTIVITY_SKILLS_MEMORY_RECALL_ON_START",
471
484
  "CLAUDE_PLUGIN_OPTION_RECALL_ON_START",
472
485
  )
473
486
  return Config(
@@ -611,7 +624,13 @@ def _read_bounded(
611
624
  return raw, text
612
625
 
613
626
 
614
- def validate_memory(path: Path, *, verify_source: bool = True) -> dict[str, object]:
627
+ def validate_memory(
628
+ path: Path,
629
+ *,
630
+ verify_source: bool = True,
631
+ require_review: str | None = None,
632
+ require_absolute_source: bool = False,
633
+ ) -> dict[str, object]:
615
634
  raw, text = _read_bounded(path)
616
635
  fields, body = _parse_frontmatter(text)
617
636
  missing = [field for field in REQUIRED_FIELDS if field not in fields]
@@ -633,6 +652,11 @@ def validate_memory(path: Path, *, verify_source: bool = True) -> dict[str, obje
633
652
  raise Refusal(f"freshness must be one of {sorted(FRESHNESS_STATES)}")
634
653
  if fields["review"] not in REVIEW_STATES:
635
654
  raise Refusal(f"review must be one of {sorted(REVIEW_STATES)}")
655
+ if require_review is not None and fields["review"] != require_review:
656
+ raise Refusal(
657
+ f"memory review must be {require_review!r} for this operation; "
658
+ f"got {fields['review']!r}"
659
+ )
636
660
  if not HASH_RE.fullmatch(fields["source_hash"]):
637
661
  raise Refusal("source_hash must be a lowercase SHA-256 digest")
638
662
  _validate_timestamp(fields["observed_at"], "observed_at")
@@ -666,8 +690,18 @@ def validate_memory(path: Path, *, verify_source: bool = True) -> dict[str, obje
666
690
  _nonempty_section(sections["## Review Notes"], "Review Notes")
667
691
 
668
692
  source = Path(fields["source"]).expanduser()
669
- if verify_source and source.is_file():
670
- actual = hashlib.sha256(source.read_bytes()).hexdigest()
693
+ if require_absolute_source and not source.is_absolute():
694
+ raise Refusal("memory source must be an absolute path for this operation")
695
+ if verify_source:
696
+ if not source.is_file():
697
+ raise Refusal(f"referenced source is not a readable file: {source}")
698
+ try:
699
+ source_bytes = source.read_bytes()
700
+ except OSError as exc:
701
+ raise Refusal(
702
+ f"referenced source is not a readable file: {source}"
703
+ ) from exc
704
+ actual = hashlib.sha256(source_bytes).hexdigest()
671
705
  if actual != fields["source_hash"]:
672
706
  raise Refusal("source_hash does not match the referenced source file")
673
707
  return {
@@ -746,6 +780,288 @@ def _normalize_repository(remote: str) -> str:
746
780
  return "/".join(parts[-2:])
747
781
 
748
782
 
783
+ def _normalize_copilot_origin(remote: str) -> str:
784
+ """Return an unambiguous project identity for automatic Copilot binding.
785
+
786
+ The memory contract represents repositories as `owner/name`, so automatic
787
+ detection cannot safely collapse a different host or a deeper namespace
788
+ onto that shape. Those repositories remain available through explicit
789
+ project configuration.
790
+ """
791
+ value = remote.strip()
792
+ host = ""
793
+ path = ""
794
+ if value.startswith("git@") and ":" in value:
795
+ authority, path = value.split(":", 1)
796
+ host = authority.rsplit("@", 1)[-1]
797
+ elif "://" in value:
798
+ parsed = urlsplit(value)
799
+ host = parsed.hostname or ""
800
+ path = parsed.path
801
+ if parsed.query or parsed.fragment:
802
+ raise Refusal(
803
+ "automatic Copilot project detection refuses origin queries "
804
+ "and fragments"
805
+ )
806
+ else:
807
+ raise Refusal(
808
+ "automatic Copilot project detection requires a canonical GitHub origin"
809
+ )
810
+ if host.lower() != "github.com":
811
+ raise Refusal(
812
+ "automatic Copilot project detection requires a github.com origin"
813
+ )
814
+ parts = [part for part in path.strip("/").split("/") if part]
815
+ if parts and parts[-1].endswith(".git"):
816
+ parts[-1] = parts[-1][:-4]
817
+ if len(parts) != 2 or not all(parts):
818
+ raise Refusal(
819
+ "automatic Copilot project detection requires exactly owner/repository"
820
+ )
821
+ project = _normalize_repository(value)
822
+ if project != "/".join(parts) or not REPOSITORY_RE.fullmatch(project):
823
+ raise Refusal("repository origin is not a concrete owner/name identity")
824
+ return project
825
+
826
+
827
+ def _validate_session_id(value: object) -> str:
828
+ if not isinstance(value, str) or not SESSION_ID_RE.fullmatch(value):
829
+ raise Refusal("Copilot session id is not a safe path component")
830
+ return value
831
+
832
+
833
+ def _copilot_session_id() -> str | None:
834
+ value = os.environ.get(COPILOT_SESSION_ID_ENV)
835
+ if not value:
836
+ return None
837
+ return _validate_session_id(value)
838
+
839
+
840
+ def _private_session_bindings_supported() -> bool:
841
+ return os.name == "posix"
842
+
843
+
844
+ def _session_binding_directory(home: Path, *, create: bool) -> Path | None:
845
+ if not _private_session_bindings_supported():
846
+ raise Refusal(
847
+ "automatic Copilot project detection requires verifiable POSIX "
848
+ "owner-only permissions; set CONTEXT_KIT_MEMORY_PROJECT explicitly"
849
+ )
850
+ directory = home / SESSION_BINDING_DIRNAME
851
+ if create:
852
+ home.mkdir(parents=True, exist_ok=True)
853
+ try:
854
+ os.mkdir(directory, 0o700)
855
+ except FileExistsError:
856
+ pass
857
+ try:
858
+ metadata = directory.lstat()
859
+ except FileNotFoundError:
860
+ return None
861
+ if not stat.S_ISDIR(metadata.st_mode):
862
+ raise Refusal(f"session binding root is not a real directory: {directory}")
863
+ if os.name == "posix":
864
+ if metadata.st_uid != os.getuid():
865
+ raise Refusal(f"session binding root is owned by another user: {directory}")
866
+ if stat.S_IMODE(metadata.st_mode) & 0o077:
867
+ raise Refusal(f"session binding root permissions must be 0700: {directory}")
868
+ return directory
869
+
870
+
871
+ def _session_binding_path(
872
+ home: Path, session_id: str, *, create_directory: bool
873
+ ) -> Path | None:
874
+ safe_id = _validate_session_id(session_id)
875
+ directory = _session_binding_directory(home, create=create_directory)
876
+ if directory is None:
877
+ return None
878
+ return directory / f"{safe_id}.json"
879
+
880
+
881
+ def _load_session_binding(home: Path, session_id: str) -> dict[str, object] | None:
882
+ path = _session_binding_path(home, session_id, create_directory=False)
883
+ if path is None:
884
+ return None
885
+ flags = os.O_RDONLY
886
+ if hasattr(os, "O_NOFOLLOW"):
887
+ flags |= os.O_NOFOLLOW
888
+ try:
889
+ descriptor = os.open(path, flags)
890
+ except FileNotFoundError:
891
+ return None
892
+ except OSError as exc:
893
+ raise Refusal(f"cannot safely open Copilot session binding: {path}") from exc
894
+ try:
895
+ metadata = os.fstat(descriptor)
896
+ if not stat.S_ISREG(metadata.st_mode):
897
+ raise Refusal(f"Copilot session binding is not a regular file: {path}")
898
+ if os.name == "posix":
899
+ if metadata.st_uid != os.getuid():
900
+ raise Refusal(
901
+ f"Copilot session binding is owned by another user: {path}"
902
+ )
903
+ if stat.S_IMODE(metadata.st_mode) & 0o077:
904
+ raise Refusal(
905
+ f"Copilot session binding permissions must be 0600: {path}"
906
+ )
907
+ if metadata.st_size > SESSION_BINDING_MAX_BYTES:
908
+ raise Refusal(f"Copilot session binding is unexpectedly large: {path}")
909
+ with os.fdopen(descriptor, "rb") as stream:
910
+ descriptor = -1
911
+ raw = stream.read(SESSION_BINDING_MAX_BYTES + 1)
912
+ finally:
913
+ if descriptor >= 0:
914
+ os.close(descriptor)
915
+ if len(raw) > SESSION_BINDING_MAX_BYTES:
916
+ raise Refusal(f"Copilot session binding is unexpectedly large: {path}")
917
+ try:
918
+ payload = json.loads(raw)
919
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
920
+ raise Refusal(f"Copilot session binding is invalid JSON: {path}") from exc
921
+ if not isinstance(payload, dict) or set(payload) != SESSION_BINDING_KEYS:
922
+ raise Refusal(f"Copilot session binding has unexpected fields: {path}")
923
+ if payload.get("session_id") != session_id:
924
+ raise Refusal(f"Copilot session binding id does not match its filename: {path}")
925
+ return payload
926
+
927
+
928
+ def _read_session_binding(home: Path, session_id: str) -> str | None:
929
+ payload = _load_session_binding(home, session_id)
930
+ if payload is None:
931
+ return None
932
+ path = _session_binding_path(home, session_id, create_directory=False)
933
+ assert path is not None
934
+ project = payload.get("project")
935
+ if not isinstance(project, str) or not REPOSITORY_RE.fullmatch(project):
936
+ raise Refusal(f"Copilot session binding has an invalid project: {path}")
937
+ return project
938
+
939
+
940
+ def _session_binding_bytes(session_id: str, project: str) -> bytes:
941
+ raw = (
942
+ json.dumps(
943
+ {"session_id": session_id, "project": project},
944
+ sort_keys=True,
945
+ separators=(",", ":"),
946
+ )
947
+ + "\n"
948
+ ).encode("utf-8")
949
+ if len(raw) > SESSION_BINDING_MAX_BYTES:
950
+ raise Refusal("Copilot session binding exceeds its size limit")
951
+ return raw
952
+
953
+
954
+ def _replace_session_binding(path: Path, *, session_id: str, project: str) -> None:
955
+ raw = _session_binding_bytes(session_id, project)
956
+ with tempfile.NamedTemporaryFile(
957
+ dir=path.parent,
958
+ prefix=f".{path.name}.",
959
+ delete=False,
960
+ ) as handle:
961
+ handle.write(raw)
962
+ handle.flush()
963
+ os.fsync(handle.fileno())
964
+ temporary = Path(handle.name)
965
+ os.chmod(temporary, 0o600)
966
+ try:
967
+ os.replace(temporary, path)
968
+ finally:
969
+ temporary.unlink(missing_ok=True)
970
+
971
+
972
+ def _write_session_binding(home: Path, session_id: str, project: str) -> str:
973
+ if not REPOSITORY_RE.fullmatch(project):
974
+ raise Refusal("memory project must be a concrete owner/name identity")
975
+ path = _session_binding_path(home, session_id, create_directory=True)
976
+ assert path is not None
977
+ raw = _session_binding_bytes(session_id, project)
978
+ with tempfile.NamedTemporaryFile(
979
+ dir=path.parent,
980
+ prefix=f".{path.name}.",
981
+ delete=False,
982
+ ) as handle:
983
+ handle.write(raw)
984
+ handle.flush()
985
+ os.fsync(handle.fileno())
986
+ temporary = Path(handle.name)
987
+ os.chmod(temporary, 0o600)
988
+ try:
989
+ try:
990
+ os.link(temporary, path, follow_symlinks=False)
991
+ except FileExistsError:
992
+ existing_payload = _load_session_binding(home, session_id)
993
+ assert existing_payload is not None
994
+ existing = existing_payload.get("project")
995
+ if existing == project:
996
+ return "unchanged"
997
+ if not isinstance(existing, str) or not REPOSITORY_RE.fullmatch(existing):
998
+ raise Refusal("Copilot session binding is already conflicted")
999
+ # A conflicting SessionStart must not leave the old project
1000
+ # addressable through the same session id. Keep only the two-field
1001
+ # minimum payload, but make the project deliberately invalid so all
1002
+ # later resolution fails closed until matching SessionEnd cleanup.
1003
+ _replace_session_binding(path, session_id=session_id, project="")
1004
+ raise Refusal(
1005
+ "refusing to reuse a Copilot session binding for another project"
1006
+ ) from None
1007
+ finally:
1008
+ temporary.unlink(missing_ok=True)
1009
+ return "created"
1010
+
1011
+
1012
+ def _remove_session_binding(home: Path, session_id: str) -> bool:
1013
+ path = _session_binding_path(home, session_id, create_directory=False)
1014
+ if path is None:
1015
+ return False
1016
+ if _load_session_binding(home, session_id) is None:
1017
+ return False
1018
+ path.unlink()
1019
+ return True
1020
+
1021
+
1022
+ def _copilot_session_binding_project(home: Path) -> str | None:
1023
+ session_id = _copilot_session_id()
1024
+ if session_id is None:
1025
+ return None
1026
+ return _read_session_binding(home, session_id)
1027
+
1028
+
1029
+ def _decode_hook_payload(payload: bytes) -> dict[str, object]:
1030
+ try:
1031
+ decoded = json.loads(payload)
1032
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
1033
+ raise Refusal(f"hook payload must be valid JSON: {exc}") from exc
1034
+ if not isinstance(decoded, dict):
1035
+ raise Refusal("hook payload must be a JSON object")
1036
+ return decoded
1037
+
1038
+
1039
+ def _matching_copilot_payload_session(
1040
+ payload: dict[str, object],
1041
+ ) -> str | None:
1042
+ session_id = _copilot_session_id()
1043
+ if session_id is None:
1044
+ return None
1045
+ payload_session_id = _validate_session_id(payload.get("sessionId"))
1046
+ if payload_session_id != session_id:
1047
+ raise Refusal("Copilot environment and hook payload session ids do not match")
1048
+ return session_id
1049
+
1050
+
1051
+ def _project_from_session_start(payload: dict[str, object]) -> str:
1052
+ cwd = payload.get("cwd")
1053
+ if not isinstance(cwd, str) or not cwd or not Path(cwd).is_absolute():
1054
+ raise Refusal("Copilot SessionStart cwd must be an absolute path")
1055
+ repository = Path(cwd).resolve(strict=True)
1056
+ if not repository.is_dir():
1057
+ raise Refusal("Copilot SessionStart cwd must be a directory")
1058
+ top_level = Path(_git(repository, "rev-parse", "--show-toplevel"))
1059
+ if not top_level.is_absolute():
1060
+ raise Refusal("git returned a non-absolute repository top-level")
1061
+ root = top_level.resolve(strict=True)
1062
+ return _normalize_copilot_origin(_git(root, "remote", "get-url", "origin"))
1063
+
1064
+
749
1065
  def _assert_project_matches(metadata: dict[str, object], config: Config) -> None:
750
1066
  project = config.project
751
1067
  if not project:
@@ -1407,7 +1723,11 @@ def _provider_version(config: Config) -> tuple[str, str]:
1407
1723
 
1408
1724
  def _capture_memory(args: argparse.Namespace, config: Config) -> int:
1409
1725
  source = Path(args.artifact).expanduser().resolve()
1410
- metadata = validate_memory(source)
1726
+ metadata = validate_memory(
1727
+ source,
1728
+ require_review=args.require_review,
1729
+ require_absolute_source=args.require_absolute_source,
1730
+ )
1411
1731
  _assert_project_matches(metadata, config)
1412
1732
  raw = source.read_bytes()
1413
1733
  destination = config.records_path / f"{metadata['id']}.md"
@@ -2709,32 +3029,86 @@ def _hook_recall(config: Config) -> int:
2709
3029
  return 0
2710
3030
 
2711
3031
 
3032
+ def _hook_session_start(config: Config, payload: bytes) -> int:
3033
+ effective = config
3034
+ try:
3035
+ decoded = _decode_hook_payload(payload)
3036
+ session_id = _matching_copilot_payload_session(decoded)
3037
+ if session_id is not None:
3038
+ project = _project_from_session_start(decoded)
3039
+ _write_session_binding(config.home, session_id, project)
3040
+ if not config.project:
3041
+ effective = Config(
3042
+ provider=config.provider,
3043
+ home=config.home,
3044
+ project=project,
3045
+ auto_capture=config.auto_capture,
3046
+ recall_on_start=config.recall_on_start,
3047
+ )
3048
+ except (OSError, Refusal) as exc:
3049
+ # Routing metadata must fail closed without breaking session startup.
3050
+ print(f"memory session binding skipped: {exc}", file=sys.stderr)
3051
+ return _hook_recall(effective)
3052
+
3053
+
2712
3054
  def _run_hook(event: str, config: Config, payload: bytes) -> int:
2713
3055
  if event == "session-start":
2714
- return _hook_recall(config)
2715
- if not config.auto_capture:
2716
- print("{}")
2717
- return 0
3056
+ return _hook_session_start(config, payload)
3057
+
3058
+ decoded: dict[str, object] | None = None
3059
+ cleanup_session_id: str | None = None
3060
+ effective = config
3061
+ if event == "session-end" and os.environ.get(COPILOT_SESSION_ID_ENV):
3062
+ try:
3063
+ decoded = _decode_hook_payload(payload)
3064
+ cleanup_session_id = _matching_copilot_payload_session(decoded)
3065
+ except (OSError, Refusal) as exc:
3066
+ print(f"memory session binding cleanup skipped: {exc}", file=sys.stderr)
3067
+ cleanup_session_id = None
3068
+ if cleanup_session_id is not None and not config.project:
3069
+ try:
3070
+ bound_project = _read_session_binding(config.home, cleanup_session_id)
3071
+ if bound_project:
3072
+ effective = Config(
3073
+ provider=config.provider,
3074
+ home=config.home,
3075
+ project=bound_project,
3076
+ auto_capture=config.auto_capture,
3077
+ recall_on_start=config.recall_on_start,
3078
+ )
3079
+ except (OSError, Refusal):
3080
+ # Cleanup below still removes a private conflicted binding, but
3081
+ # no memory operation may resolve through it.
3082
+ pass
3083
+
3084
+ response: dict[str, object] = {}
2718
3085
  try:
2719
- decoded = json.loads(payload)
2720
- except (UnicodeDecodeError, json.JSONDecodeError) as exc:
2721
- raise Refusal(f"hook payload must be valid JSON: {exc}") from exc
2722
- if not isinstance(decoded, dict):
2723
- raise Refusal("hook payload must be a JSON object")
2724
- pending_dir = config.home / "pending-hooks" / config.project_slug
2725
- pending = _new_write_once_path(pending_dir, f"-{event}.json")
2726
- if _write_once(pending, payload) != "created":
2727
- raise Refusal(f"refusing to reuse a generated hook payload path: {pending}")
2728
- print(
2729
- json.dumps(
2730
- {
3086
+ if effective.auto_capture:
3087
+ if decoded is None:
3088
+ decoded = _decode_hook_payload(payload)
3089
+ pending_dir = effective.home / "pending-hooks" / effective.project_slug
3090
+ pending = _new_write_once_path(pending_dir, f"-{event}.json")
3091
+ if _write_once(pending, payload) != "created":
3092
+ raise Refusal(
3093
+ f"refusing to reuse a generated hook payload path: {pending}"
3094
+ )
3095
+ response = {
2731
3096
  "status": "queued-for-review",
2732
3097
  "event": event,
2733
3098
  "pending": str(pending),
2734
3099
  "provider_invoked": False,
2735
3100
  }
2736
- )
2737
- )
3101
+ finally:
3102
+ if cleanup_session_id is not None:
3103
+ try:
3104
+ _remove_session_binding(effective.home, cleanup_session_id)
3105
+ except (OSError, Refusal) as exc:
3106
+ # An unsafe or changed binding is left untouched for inspection.
3107
+ print(
3108
+ f"memory session binding cleanup skipped: {exc}",
3109
+ file=sys.stderr,
3110
+ )
3111
+ print(json.dumps(response))
2738
3112
  return 0
2739
3113
 
2740
3114
 
@@ -2761,6 +3135,8 @@ def _parser() -> argparse.ArgumentParser:
2761
3135
  capture = sub.add_parser("capture")
2762
3136
  capture.add_argument("artifact")
2763
3137
  capture.add_argument("--local-only", action="store_true")
3138
+ capture.add_argument("--require-review", choices=sorted(REVIEW_STATES))
3139
+ capture.add_argument("--require-absolute-source", action="store_true")
2764
3140
  _add_config_args(capture)
2765
3141
 
2766
3142
  archive = sub.add_parser("archive-handoff")
@@ -2870,7 +3246,10 @@ def main(argv: list[str] | None = None) -> int:
2870
3246
  print(json.dumps({"status": "valid", "id": metadata["id"]}))
2871
3247
  return 0
2872
3248
 
2873
- config = _config(args)
3249
+ allow_session_binding = not (
3250
+ args.command == "hook" and args.event in {"session-start", "session-end"}
3251
+ )
3252
+ config = _config(args, allow_session_binding=allow_session_binding)
2874
3253
  if args.command == "capture":
2875
3254
  return _capture_memory(args, config)
2876
3255
  if args.command == "archive-handoff":
File without changes
File without changes