qhaway 0.1.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.
qhaway-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,167 @@
1
+ Metadata-Version: 2.4
2
+ Name: qhaway
3
+ Version: 0.1.0
4
+ Summary: A truncation-proof projection of a Markdown memory index: regenerate MEMORY.md to fit the loader's budget and declare what it sets aside, instead of being silently cut.
5
+ Keywords: memory,index,markdown,llm,agent,sqlite,mcp
6
+ Author: Tony
7
+ Author-email: Tony <fsgeek@cs.ubc.ca>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 2 - Pre-Alpha
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Utilities
12
+ Requires-Dist: mcp[cli]>=1.28.0
13
+ Requires-Dist: pyyaml
14
+ Requires-Python: >=3.14
15
+ Description-Content-Type: text/markdown
16
+
17
+ # qhaway
18
+
19
+ *Quechua: "to see / to watch over."* The name states the cure — make the whole
20
+ memory record **visible** instead of silently truncated.
21
+
22
+ `qhaway` keeps a Markdown memory index from being silently cut off when it grows
23
+ past the size limit of the system that loads it.
24
+
25
+ ## The problem
26
+
27
+ Some agents and tools maintain memory as a directory of small Markdown files plus
28
+ a single curated index (`MEMORY.md`) that points at them. The index is loaded into
29
+ context on startup so the agent boots with a map of what it knows.
30
+
31
+ That index grows. When it grows past the loader's size limit, it is **silently
32
+ truncated** — cut off with no error raised. The agent boots a *partial self* and
33
+ doesn't know it: everything past the cut is invisible, and a pointer to a file
34
+ that no longer exists rides along just as silently. The honest record is there on
35
+ disk; the loaded view of it is a lie of omission.
36
+
37
+ This was observed live: a 36.8KB / 137-entry index against a ~24.4KB load limit,
38
+ with the entire latest section — including the pointer to the most recent state —
39
+ falling past the cut.
40
+
41
+ ## The fix
42
+
43
+ qhaway regenerates `MEMORY.md` itself as a **truncation-proof projection** of the
44
+ memory files:
45
+
46
+ - **Files stay the write surface.** You keep writing topic `.md` files exactly as
47
+ you do today. There is no schema to learn and no "save" API to call. qhaway only
48
+ changes *who writes the index* — a machine, not a hand.
49
+ - **It fits the budget.** The regenerated index is guaranteed to come in under the
50
+ loader's limit, so it is never silently cut.
51
+ - **No silent loss — ever.** When the index can't fit everything, it doesn't drop
52
+ entries quietly. It **declares the omission**:
53
+
54
+ ```
55
+ +47 project memories not shown — run: qhaway index --type project
56
+ ```
57
+
58
+ Truncation becomes *visible selection*. You always know what was set aside and
59
+ how to see it.
60
+
61
+ The loader keeps reading `MEMORY.md` exactly as before — now complete-for-what-it-
62
+ claims and guaranteed under budget. Nothing downstream changes.
63
+
64
+ ## Install
65
+
66
+ ```sh
67
+ uv tool install qhaway
68
+ # or
69
+ pipx install qhaway
70
+ ```
71
+
72
+ Embedded and zero-infra: it uses stdlib SQLite (WAL mode) as a single local
73
+ file. No server, no database to provision, no credentials.
74
+
75
+ ## Usage
76
+
77
+ ```sh
78
+ # Regenerate MEMORY.md from the memory directory (the main command)
79
+ qhaway index
80
+
81
+ # See a specific slice — including entries the default index declared as omitted
82
+ qhaway index --type project
83
+ qhaway index --role <role>
84
+ qhaway index --status superseded
85
+
86
+ # Set a custom budget
87
+ qhaway index --budget <bytes>
88
+
89
+ # Inspect without writing: would it overflow? any broken links? any leftover files?
90
+ qhaway index --check
91
+
92
+ # Print the projection without writing the file
93
+ qhaway index --dry-run
94
+ ```
95
+
96
+ To record a memory: **write a topic `.md` file, then run `qhaway index`.** Don't
97
+ hand-edit `MEMORY.md` — it is fully derived, and any hand edit is preserved (see
98
+ below) but won't survive into the index unless it lives in a topic file.
99
+
100
+ ## MCP spine (remember / recall)
101
+
102
+ The spine lets a Claude Code instance reach its memory through MCP tools instead
103
+ of hand-writing files. `MEMORY.md` becomes a managed, read-only **redirect** into
104
+ the SQLite-derived index; the topic files stay the source of truth.
105
+
106
+ ```sh
107
+ # Run the MCP server over a memory directory (reconciles once at startup)
108
+ qhaway serve --dir <memory_dir>
109
+
110
+ # Sync the index from the files (alias: qhaway index)
111
+ qhaway reconcile --dir <memory_dir>
112
+
113
+ # Inspect: broken wikilinks, orphan backups, low topic count, would-overflow
114
+ qhaway check --dir <memory_dir>
115
+ ```
116
+
117
+ Two verbs are exposed to the model:
118
+
119
+ - `recall(type?, role?, status?)` — pure read; returns the budgeted projection
120
+ (omit args for the working set).
121
+ - `remember(type, title, body, description?, links?)` — writes a topic file then
122
+ reconciles. Files stay truth; the DB is a derived, rebuildable view.
123
+
124
+ `MEMORY.md` is written born-read-only (`0o444`) as a friction signal — not a hard
125
+ barrier — so the reflexive hand-edit is deflected toward the tools. qhaway's own
126
+ writer updates it via atomic temp-file + replace.
127
+
128
+ ## How it works
129
+
130
+ ```
131
+ qhaway index
132
+ → scan the memory directory
133
+ → parse each file into a node (frontmatter type, filename role, links, body)
134
+ → build an index of nodes + links in SQLite
135
+ → project the working set under the byte budget,
136
+ appending a declared-omissions footer for anything set aside
137
+ → write MEMORY.md
138
+ ```
139
+
140
+ The memory files are the single source of truth. The index is rebuilt from scratch
141
+ on every run, so it can never drift from the files. The same files always produce
142
+ a byte-identical index.
143
+
144
+ ### What's preserved
145
+
146
+ `MEMORY.md` is fully machine-derived — there are no hand-maintained regions. If
147
+ qhaway ever finds that the index was edited by hand since it last wrote it, it does
148
+ **not** overwrite the edit: it renames the existing file to a timestamped
149
+ `MEMORY-<timestamp>.md` and writes a fresh index. Your edit is preserved verbatim;
150
+ the index rebuilds from the files. Nothing is interpreted, merged, or lost.
151
+
152
+ ## Design philosophy
153
+
154
+ One pain, fixed completely: **truncation**. Full-text search, deep audit, write
155
+ tooling, and ranking sophistication are deliberately *not* in this version — each
156
+ is a real later idea, none is this version's job.
157
+
158
+ The wager is simple: a structured index built *over* an existing pile of files —
159
+ without replacing the pile — makes the whole thing measurably work better. The
160
+ proof is use. If it removes felt pain for skeptical users who'll drop it the moment
161
+ it's more friction than value, it ships; if it removes the same pain for strangers
162
+ feeling the same sprawl, it spreads. Propagation is the measurement.
163
+
164
+ ## Status
165
+
166
+ Early (`v0.1.0`). The design is specified in
167
+ [`docs/superpowers/specs/2026-06-20-qhaway-mvp-design.md`](docs/superpowers/specs/2026-06-20-qhaway-mvp-design.md).
qhaway-0.1.0/README.md ADDED
@@ -0,0 +1,151 @@
1
+ # qhaway
2
+
3
+ *Quechua: "to see / to watch over."* The name states the cure — make the whole
4
+ memory record **visible** instead of silently truncated.
5
+
6
+ `qhaway` keeps a Markdown memory index from being silently cut off when it grows
7
+ past the size limit of the system that loads it.
8
+
9
+ ## The problem
10
+
11
+ Some agents and tools maintain memory as a directory of small Markdown files plus
12
+ a single curated index (`MEMORY.md`) that points at them. The index is loaded into
13
+ context on startup so the agent boots with a map of what it knows.
14
+
15
+ That index grows. When it grows past the loader's size limit, it is **silently
16
+ truncated** — cut off with no error raised. The agent boots a *partial self* and
17
+ doesn't know it: everything past the cut is invisible, and a pointer to a file
18
+ that no longer exists rides along just as silently. The honest record is there on
19
+ disk; the loaded view of it is a lie of omission.
20
+
21
+ This was observed live: a 36.8KB / 137-entry index against a ~24.4KB load limit,
22
+ with the entire latest section — including the pointer to the most recent state —
23
+ falling past the cut.
24
+
25
+ ## The fix
26
+
27
+ qhaway regenerates `MEMORY.md` itself as a **truncation-proof projection** of the
28
+ memory files:
29
+
30
+ - **Files stay the write surface.** You keep writing topic `.md` files exactly as
31
+ you do today. There is no schema to learn and no "save" API to call. qhaway only
32
+ changes *who writes the index* — a machine, not a hand.
33
+ - **It fits the budget.** The regenerated index is guaranteed to come in under the
34
+ loader's limit, so it is never silently cut.
35
+ - **No silent loss — ever.** When the index can't fit everything, it doesn't drop
36
+ entries quietly. It **declares the omission**:
37
+
38
+ ```
39
+ +47 project memories not shown — run: qhaway index --type project
40
+ ```
41
+
42
+ Truncation becomes *visible selection*. You always know what was set aside and
43
+ how to see it.
44
+
45
+ The loader keeps reading `MEMORY.md` exactly as before — now complete-for-what-it-
46
+ claims and guaranteed under budget. Nothing downstream changes.
47
+
48
+ ## Install
49
+
50
+ ```sh
51
+ uv tool install qhaway
52
+ # or
53
+ pipx install qhaway
54
+ ```
55
+
56
+ Embedded and zero-infra: it uses stdlib SQLite (WAL mode) as a single local
57
+ file. No server, no database to provision, no credentials.
58
+
59
+ ## Usage
60
+
61
+ ```sh
62
+ # Regenerate MEMORY.md from the memory directory (the main command)
63
+ qhaway index
64
+
65
+ # See a specific slice — including entries the default index declared as omitted
66
+ qhaway index --type project
67
+ qhaway index --role <role>
68
+ qhaway index --status superseded
69
+
70
+ # Set a custom budget
71
+ qhaway index --budget <bytes>
72
+
73
+ # Inspect without writing: would it overflow? any broken links? any leftover files?
74
+ qhaway index --check
75
+
76
+ # Print the projection without writing the file
77
+ qhaway index --dry-run
78
+ ```
79
+
80
+ To record a memory: **write a topic `.md` file, then run `qhaway index`.** Don't
81
+ hand-edit `MEMORY.md` — it is fully derived, and any hand edit is preserved (see
82
+ below) but won't survive into the index unless it lives in a topic file.
83
+
84
+ ## MCP spine (remember / recall)
85
+
86
+ The spine lets a Claude Code instance reach its memory through MCP tools instead
87
+ of hand-writing files. `MEMORY.md` becomes a managed, read-only **redirect** into
88
+ the SQLite-derived index; the topic files stay the source of truth.
89
+
90
+ ```sh
91
+ # Run the MCP server over a memory directory (reconciles once at startup)
92
+ qhaway serve --dir <memory_dir>
93
+
94
+ # Sync the index from the files (alias: qhaway index)
95
+ qhaway reconcile --dir <memory_dir>
96
+
97
+ # Inspect: broken wikilinks, orphan backups, low topic count, would-overflow
98
+ qhaway check --dir <memory_dir>
99
+ ```
100
+
101
+ Two verbs are exposed to the model:
102
+
103
+ - `recall(type?, role?, status?)` — pure read; returns the budgeted projection
104
+ (omit args for the working set).
105
+ - `remember(type, title, body, description?, links?)` — writes a topic file then
106
+ reconciles. Files stay truth; the DB is a derived, rebuildable view.
107
+
108
+ `MEMORY.md` is written born-read-only (`0o444`) as a friction signal — not a hard
109
+ barrier — so the reflexive hand-edit is deflected toward the tools. qhaway's own
110
+ writer updates it via atomic temp-file + replace.
111
+
112
+ ## How it works
113
+
114
+ ```
115
+ qhaway index
116
+ → scan the memory directory
117
+ → parse each file into a node (frontmatter type, filename role, links, body)
118
+ → build an index of nodes + links in SQLite
119
+ → project the working set under the byte budget,
120
+ appending a declared-omissions footer for anything set aside
121
+ → write MEMORY.md
122
+ ```
123
+
124
+ The memory files are the single source of truth. The index is rebuilt from scratch
125
+ on every run, so it can never drift from the files. The same files always produce
126
+ a byte-identical index.
127
+
128
+ ### What's preserved
129
+
130
+ `MEMORY.md` is fully machine-derived — there are no hand-maintained regions. If
131
+ qhaway ever finds that the index was edited by hand since it last wrote it, it does
132
+ **not** overwrite the edit: it renames the existing file to a timestamped
133
+ `MEMORY-<timestamp>.md` and writes a fresh index. Your edit is preserved verbatim;
134
+ the index rebuilds from the files. Nothing is interpreted, merged, or lost.
135
+
136
+ ## Design philosophy
137
+
138
+ One pain, fixed completely: **truncation**. Full-text search, deep audit, write
139
+ tooling, and ranking sophistication are deliberately *not* in this version — each
140
+ is a real later idea, none is this version's job.
141
+
142
+ The wager is simple: a structured index built *over* an existing pile of files —
143
+ without replacing the pile — makes the whole thing measurably work better. The
144
+ proof is use. If it removes felt pain for skeptical users who'll drop it the moment
145
+ it's more friction than value, it ships; if it removes the same pain for strangers
146
+ feeling the same sprawl, it spreads. Propagation is the measurement.
147
+
148
+ ## Status
149
+
150
+ Early (`v0.1.0`). The design is specified in
151
+ [`docs/superpowers/specs/2026-06-20-qhaway-mvp-design.md`](docs/superpowers/specs/2026-06-20-qhaway-mvp-design.md).
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "qhaway"
3
+ version = "0.1.0"
4
+ description = "A truncation-proof projection of a Markdown memory index: regenerate MEMORY.md to fit the loader's budget and declare what it sets aside, instead of being silently cut."
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ license = "MIT"
8
+ authors = [{ name = "Tony", email = "fsgeek@cs.ubc.ca" }]
9
+ keywords = ["memory", "index", "markdown", "llm", "agent", "sqlite", "mcp"]
10
+ classifiers = [
11
+ "Development Status :: 2 - Pre-Alpha",
12
+ "Programming Language :: Python :: 3",
13
+ "Topic :: Utilities",
14
+ ]
15
+ dependencies = [
16
+ "mcp[cli]>=1.28.0",
17
+ "pyyaml",
18
+ ]
19
+
20
+ [project.scripts]
21
+ qhaway = "qhaway.cli:main"
22
+
23
+ [build-system]
24
+ requires = ["uv_build>=0.10,<0.11"]
25
+ build-backend = "uv_build"
26
+
27
+ [tool.uv.build-backend]
28
+ module-root = "src"
29
+
30
+ # Publishing (deferred — no release until the implementation exists and its tests
31
+ # pass). When ready, tokens are read from the environment at publish time and are
32
+ # NEVER stored in this file or committed:
33
+ # TestPyPI: uv publish --index testpypi (token: PYPI_DEPLOY_KEY_TEST)
34
+ # PyPI: uv publish (token: PYPI_DEPLOY_KEY_REAL)
@@ -0,0 +1,6 @@
1
+ """qhaway — a truncation-proof projection of a Markdown memory index.
2
+
3
+ See the design spec: docs/superpowers/specs/2026-06-20-qhaway-mvp-design.md
4
+ """
5
+
6
+ __version__ = "0.1.0"
@@ -0,0 +1,238 @@
1
+ """Entry point for the `qhaway` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from qhaway import model, parse, project, server
11
+ from qhaway import reconcile as reconcile_mod
12
+ from qhaway.reconcile import reconcile
13
+
14
+ MEMORY_NAME = "MEMORY.md"
15
+
16
+
17
+ def main(args: list[str] | None = None) -> int:
18
+ parser = argparse.ArgumentParser(prog="qhaway")
19
+ sub = parser.add_subparsers(dest="command", required=True)
20
+ for name in ("reconcile", "check", "serve", "index", "exit"):
21
+ p = sub.add_parser(name)
22
+ p.add_argument("--dir")
23
+ p.add_argument("--budget", type=int, default=project.DEFAULT_BUDGET)
24
+ p.add_argument("--type", dest="content_type")
25
+ p.add_argument("--role")
26
+ p.add_argument("--status", default="live")
27
+ p.add_argument("--dry-run", action="store_true")
28
+ p.add_argument("--check", action="store_true") # deprecated alias on index
29
+ p.add_argument("--emit", action="store_true")
30
+
31
+ ns = parser.parse_args(args)
32
+ directory = _resolve_dir(ns)
33
+
34
+ if ns.command == "serve":
35
+ return _serve(directory)
36
+ if ns.command == "exit":
37
+ return _exit(directory, ns.budget)
38
+ if ns.command == "check" or (ns.command == "index" and ns.check):
39
+ return _check(directory, ns.budget)
40
+ if ns.command == "index" and ns.dry_run:
41
+ return _dry_run(directory, ns)
42
+
43
+ # reconcile, and index-as-reconcile-alias
44
+ try:
45
+ reconcile(directory)
46
+ except FileNotFoundError as exc:
47
+ sys.stderr.write(f"{exc}\n")
48
+ return 1
49
+ if getattr(ns, "emit", False):
50
+ conn = model.get_connection(directory)
51
+ try:
52
+ sys.stdout.write(project.project_slice(conn, budget=ns.budget))
53
+ finally:
54
+ conn.close()
55
+ return 0
56
+
57
+
58
+ def _resolve_dir(ns) -> str:
59
+ return ns.dir or os.environ.get("QHAWAY_MEMORY_DIR") or "."
60
+
61
+
62
+ def _serve(directory: str) -> int:
63
+ if not os.path.isdir(directory):
64
+ sys.stderr.write(f"memory directory is not readable: {directory}\n")
65
+ return 1
66
+ server.run(directory)
67
+ return 0
68
+
69
+
70
+ def _dry_run(directory: str, ns) -> int:
71
+ if not os.path.isdir(directory):
72
+ sys.stderr.write(f"memory directory is not readable: {directory}\n")
73
+ return 1
74
+ conn = model.get_connection(directory)
75
+ try:
76
+ output = project.project_slice(
77
+ conn,
78
+ budget=ns.budget,
79
+ content_type=ns.content_type,
80
+ role=ns.role,
81
+ status=ns.status,
82
+ )
83
+ finally:
84
+ conn.close()
85
+ sys.stdout.write(output)
86
+ return 0
87
+
88
+
89
+ def _exit(directory: str, budget: int) -> int:
90
+ """SessionEnd: leave a current, self-sufficient, truncation-proof index in
91
+ place — NOT the pre-install original. qhaway borrows MEMORY.md while enabled
92
+ and returns a current honest index when it leaves; the original is preserved
93
+ under its distinguished name (MEMORY.preinstall.md) for an explicit uninstall,
94
+ never handed back here. Worst case (a future loader truncates the file), the
95
+ index degrades gracefully and declares what it set aside; the raw original
96
+ would truncate into silent staleness — the exact failure qhaway prevents.
97
+
98
+ The index is budgeted (the footer's bytes are reserved within the budget, not
99
+ appended past it) and carries no recall()/remember() instructions, since the
100
+ hooks are not guaranteed to run once the plugin is disabled.
101
+ """
102
+ memory_dir = Path(directory)
103
+ if not memory_dir.is_dir():
104
+ sys.stderr.write(f"memory directory is not readable: {memory_dir}\n")
105
+ return 1
106
+
107
+ reconcile(directory)
108
+ conn = model.get_connection(directory)
109
+ try:
110
+ total = len(model.topic_files(memory_dir))
111
+
112
+ def compose_footer(omitted: int) -> str:
113
+ return (
114
+ f"\n\n---\n_qhaway exit index — {total} memories under {budget} "
115
+ f"bytes; {omitted} set aside. Self-sufficient static index "
116
+ "(qhaway disabled)._\n"
117
+ )
118
+
119
+ # Probe at full budget only to SIZE the reserve (footer + signature bytes);
120
+ # the displayed "set aside" count comes from the FINAL reduced-budget
121
+ # projection, so the footer reports what the shipped file actually omits —
122
+ # honest declaration is the whole point. The count's digit width is bounded
123
+ # (a few hundred memories at most), so any drift between probe and final
124
+ # count is sub-byte against the reserve and never pushes over budget.
125
+ probe = project.project_slice_with_overflow(conn, budget=budget)
126
+ reserve = (
127
+ len(compose_footer(sum(probe.overflow.omitted_counts.values())).encode("utf-8"))
128
+ + len(reconcile_mod.signature_line(""))
129
+ + 2
130
+ )
131
+ result = project.project_slice_with_overflow(conn, budget=max(0, budget - reserve))
132
+ footer = compose_footer(sum(result.overflow.omitted_counts.values()))
133
+ finally:
134
+ conn.close()
135
+ reconcile_mod.write_readonly(
136
+ memory_dir / MEMORY_NAME, reconcile_mod.embed_signature(result.markdown + footer)
137
+ )
138
+ return 0
139
+
140
+
141
+ def _check(directory: str, budget: int) -> int:
142
+ memory_dir = Path(directory)
143
+ if not memory_dir.is_dir():
144
+ sys.stderr.write(f"memory directory is not readable: {memory_dir}\n")
145
+ return 1
146
+
147
+ exit_code = 0
148
+ topic_count = len(model.topic_files(memory_dir))
149
+ if topic_count <= 2:
150
+ sys.stderr.write(f"warning: low topic file count ({topic_count}) in {memory_dir}\n")
151
+
152
+ orphans = _orphan_files(memory_dir)
153
+ if orphans:
154
+ sys.stdout.write(f"{len(orphans)} orphan MEMORY backups found:\n")
155
+ for orphan in orphans:
156
+ sys.stdout.write(f"- {orphan.name}\n")
157
+
158
+ conn = model.get_connection(directory)
159
+ try:
160
+ dangling = _dangling_links(conn)
161
+ stale_drift = _stale_drift(conn)
162
+ full_projection = project.project_slice(conn, budget=10**12)
163
+ finally:
164
+ conn.close()
165
+
166
+ if dangling:
167
+ exit_code = 1
168
+ sys.stdout.write("dangling topic wikilinks found:\n")
169
+ for src_file, dst_slug in dangling:
170
+ sys.stdout.write(f"- {src_file} -> [[{dst_slug}]]\n")
171
+
172
+ if stale_drift:
173
+ exit_code = 1
174
+ sys.stdout.write(
175
+ "live memories whose body announces supersession but whose name: was "
176
+ "never redirected (they leak into the working set):\n"
177
+ )
178
+ for file_name, marker in stale_drift:
179
+ sys.stdout.write(f"- {file_name} (body says {marker}; retire it: set name: 'SUPERSEDED — see ...')\n")
180
+
181
+ if len(full_projection.encode("utf-8")) > budget:
182
+ overflow = len(full_projection.encode("utf-8")) - budget
183
+ exit_code = 1
184
+ sys.stderr.write(f"corpus exceeds budget by {overflow} bytes before projection\n")
185
+
186
+ if exit_code == 0 and not orphans and topic_count > 2:
187
+ sys.stdout.write("qhaway check passed\n")
188
+ return exit_code
189
+
190
+
191
+ def _stale_drift(conn) -> list[tuple[str, str]]:
192
+ """Find live nodes whose body announces supersession but whose name: was
193
+ never rewritten to the redirect form — so parse left status=live and the
194
+ projector serves them as current. This is the silent-staleness leak: the
195
+ conscientious in-body 'SUPERSEDED' annotation never reaches the one field
196
+ the retire path keys on. Conservative by design (a tombstone word as a
197
+ leading/emphasized token on its own line, not a passing mention) so a
198
+ correctly-live memory that merely discusses supersession is not nagged.
199
+ """
200
+ drift: list[tuple[str, str]] = []
201
+ for file_name, status, body in conn.execute(
202
+ "SELECT file, status, body FROM nodes ORDER BY file"
203
+ ).fetchall():
204
+ if status != "live":
205
+ continue
206
+ marker = _body_supersession_marker(body or "")
207
+ if marker:
208
+ drift.append((file_name, marker))
209
+ return drift
210
+
211
+
212
+ def _body_supersession_marker(body: str) -> str | None:
213
+ for raw in body.splitlines():
214
+ line = raw.strip().lstrip("*_# ").strip()
215
+ upper = line.upper()
216
+ for word in parse.TOMBSTONE_NAMES:
217
+ if upper.startswith(word):
218
+ return word
219
+ return None
220
+
221
+
222
+ def _dangling_links(conn) -> list[tuple[str, str]]:
223
+ stems = {row[0].removesuffix(".md") for row in conn.execute("SELECT file FROM nodes").fetchall()}
224
+ dangling: list[tuple[str, str]] = []
225
+ for src_file, dst_slug in conn.execute(
226
+ "SELECT src_file, dst_slug FROM edges ORDER BY src_file, dst_slug"
227
+ ).fetchall():
228
+ if dst_slug not in stems:
229
+ dangling.append((src_file, dst_slug))
230
+ return dangling
231
+
232
+
233
+ def _orphan_files(memory_dir: Path) -> list[Path]:
234
+ return sorted(memory_dir.glob("MEMORY-*.md"), key=lambda path: path.name)
235
+
236
+
237
+ if __name__ == "__main__":
238
+ raise SystemExit(main())