corkern 2.0.0rc2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- corkern/__init__.py +1 -0
- corkern/claude_commands/corkern-distill.md +12 -0
- corkern/claude_commands/corkern-harvest.md +13 -0
- corkern/claude_commands/corkern-memory-prune.md +15 -0
- corkern/claude_commands/corkern-status.md +6 -0
- corkern/claude_print.py +30 -0
- corkern/commands/__init__.py +0 -0
- corkern/commands/add_cmd.py +104 -0
- corkern/commands/ai_cmd.py +238 -0
- corkern/commands/crg_cmd.py +60 -0
- corkern/commands/distill_cmd.py +106 -0
- corkern/commands/export_cmd.py +305 -0
- corkern/commands/graphify_cmd.py +128 -0
- corkern/commands/harvest_cmd.py +412 -0
- corkern/commands/init_cmd.py +30 -0
- corkern/commands/init_device_cmd.py +396 -0
- corkern/commands/iwe_cmd.py +122 -0
- corkern/commands/list_cmd.py +105 -0
- corkern/commands/metrics_cmd.py +81 -0
- corkern/commands/migrate_cmd.py +69 -0
- corkern/commands/prune_cmd.py +127 -0
- corkern/commands/reminders.py +145 -0
- corkern/commands/remove_cmd.py +32 -0
- corkern/commands/secrets_cmd.py +177 -0
- corkern/commands/sessions_cmd.py +52 -0
- corkern/commands/setup_cmd.py +92 -0
- corkern/commands/signing_check.py +216 -0
- corkern/commands/signing_cmd.py +275 -0
- corkern/commands/status_cmd.py +421 -0
- corkern/commands/trust_cmd.py +342 -0
- corkern/config.py +352 -0
- corkern/devices.py +74 -0
- corkern/export_format.py +93 -0
- corkern/guard.py +630 -0
- corkern/hooks/corkern-commit-memory.sh +35 -0
- corkern/hooks/corkern-pull.sh +12 -0
- corkern/hooks/corkern-push.sh +20 -0
- corkern/hooks/corkern-session-notify.sh +15 -0
- corkern/hooks/corkern.sh +47 -0
- corkern/hooks/lib.sh +553 -0
- corkern/hooks_install.py +64 -0
- corkern/instance.py +134 -0
- corkern/knowledge_repo.py +246 -0
- corkern/main.py +112 -0
- corkern/memory_index.py +36 -0
- corkern/migrate.py +211 -0
- corkern/secret_scan.py +86 -0
- corkern-2.0.0rc2.dist-info/METADATA +110 -0
- corkern-2.0.0rc2.dist-info/RECORD +52 -0
- corkern-2.0.0rc2.dist-info/WHEEL +4 -0
- corkern-2.0.0rc2.dist-info/entry_points.txt +2 -0
- corkern-2.0.0rc2.dist-info/licenses/LICENSE +21 -0
corkern/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.0.0rc2"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Run `corkern distill` to synthesise cross-project insights from all corkern memory files.
|
|
2
|
+
|
|
3
|
+
Steps:
|
|
4
|
+
1. Ask the user whether to run interactively or auto-save:
|
|
5
|
+
- **Auto** (saves to `memory/global/synthesis.md`): run `corkern distill --auto`
|
|
6
|
+
- **Interactive** (review with Claude): run `corkern distill`
|
|
7
|
+
2. Run the chosen command via Bash.
|
|
8
|
+
3. If `--auto` was used, read `memory/global/synthesis.md` in the knowledge repo (`$CORKERN_MEMORY_DIR`) and summarise the findings to the user.
|
|
9
|
+
4. For any **Stale Candidates** identified: offer to add `status: stale` to their frontmatter — one file at a time, with user confirmation for each.
|
|
10
|
+
5. For any **Universal Principles** identified (rules appearing in 2+ projects): offer to add them to `memory/global/principles.md` and remove them from the individual project memory files — with user confirmation.
|
|
11
|
+
|
|
12
|
+
Always confirm with the user before modifying any memory file.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Run `corkern harvest` to extract memories from past Claude conversations for the current project.
|
|
2
|
+
|
|
3
|
+
Steps:
|
|
4
|
+
1. Run `corkern prune` first to show which conversations are new, changed or already harvested.
|
|
5
|
+
2. Run `corkern harvest` (detects the project from the current directory). With several conversations it asks which to process — answer `all` or numbers.
|
|
6
|
+
3. Review the new memory files it lists in `memory/<slug>/` of the knowledge repo; they are already added to `MEMORY.md`. Existing files are never overwritten (a new version gets a `.harvested-<time>` suffix) — merge those with the user.
|
|
7
|
+
4. If it saved a `harvested-<date>.md` bulk file instead, run `corkern refine`.
|
|
8
|
+
|
|
9
|
+
Flags:
|
|
10
|
+
- `corkern harvest --interactive` — opens an interactive Claude session with the conversations as context, so the user can add their own recollections.
|
|
11
|
+
- `corkern harvest --all` — reprocesses already harvested conversations.
|
|
12
|
+
|
|
13
|
+
Secrets in transcripts are replaced by a marker before anything is committed. After harvesting, run `/corkern-distill` to check whether any extracted memories should be promoted to global.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Run `corkern memory-prune` to archive expired or stale memory files for the current project.
|
|
2
|
+
|
|
3
|
+
Steps:
|
|
4
|
+
1. Run `corkern memory-prune --dry-run` to preview candidates without touching anything.
|
|
5
|
+
2. Show the user the list of expired/stale files found.
|
|
6
|
+
3. Explain what each candidate is and why it was flagged (expired date, status: stale marker).
|
|
7
|
+
4. Ask the user to confirm before proceeding.
|
|
8
|
+
5. If confirmed, run `corkern memory-prune` (which asks for confirmation again in the terminal).
|
|
9
|
+
|
|
10
|
+
Notes:
|
|
11
|
+
- Files with `expires: YYYY-MM-DD` in frontmatter are candidates after that date passes.
|
|
12
|
+
- Files with `status: stale` in frontmatter are candidates regardless of date.
|
|
13
|
+
- Archived files go to `memory/<slug>/archive/` — never deleted, always recoverable.
|
|
14
|
+
- To mark a file stale: add `status: stale` inside its frontmatter `metadata:` block.
|
|
15
|
+
- To mark a file with an expiry: add `expires: YYYY-MM-DD` inside `metadata:`.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
Run `corkern status` to check corkern's health.
|
|
2
|
+
|
|
3
|
+
- `corkern status` — summary: sync, signing and trust, secrets removed, memory index sizes, hooks, projects.
|
|
4
|
+
- `corkern status -v` — full diagnostics: every project's wiring, hook scripts, slash commands, Claude Code memory folders not synced into corkern, knowledge graphs per project, IWE.
|
|
5
|
+
|
|
6
|
+
Run this when something seems off with memory sync, or after setting up on a new machine. Report problems (✗) and warnings (!) to the user with the fix each line suggests.
|
corkern/claude_print.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Text-in/text-out `claude -p` calls (distill --auto, harvest, refine).
|
|
2
|
+
|
|
3
|
+
The prompt carries memory text that teammates wrote, so these calls get nothing beyond the
|
|
4
|
+
text: no tools at all (`--tools ""`), only the user's own settings (`--setting-sources user`:
|
|
5
|
+
no project or local settings, so no hooks from a repo), no MCP servers (`--strict-mcp-config`
|
|
6
|
+
without `--mcp-config`), and they start in a fresh private empty folder instead of the
|
|
7
|
+
knowledge repo (guard.capture_tool_isolated). Flags checked against Claude Code 2.1.285 and
|
|
8
|
+
2.1.287 (`claude --help`).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from corkern import guard
|
|
15
|
+
|
|
16
|
+
ISOLATION = ("--setting-sources", "user", "--strict-mcp-config", "--tools", "")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ask(claude: str, prompt: str, model: str | None = None) -> "guard.subprocess.CompletedProcess":
|
|
20
|
+
argv = [claude, "-p", *ISOLATION, *(["--model", model] if model else [])]
|
|
21
|
+
return guard.capture_tool_isolated(argv, prompt)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def interactive_cwd(preferred: Path | None = None) -> Path:
|
|
25
|
+
"""Where an interactive claude session starts: `preferred` (default: the current folder)
|
|
26
|
+
unless it is missing or inside the knowledge repo — then $HOME."""
|
|
27
|
+
where = Path(preferred) if preferred is not None else Path.cwd()
|
|
28
|
+
if not where.is_dir() or guard.in_knowledge_repo(where):
|
|
29
|
+
return Path.home()
|
|
30
|
+
return where
|
|
File without changes
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import typer
|
|
4
|
+
from corkern import guard
|
|
5
|
+
from corkern.commands.signing_cmd import _ask
|
|
6
|
+
from corkern.config import (
|
|
7
|
+
UnstorablePath, _parse_conf, check_projects, confirm_projects, conf_entries, load_config,
|
|
8
|
+
resolve_project_path, same_path, store_project_path, valid_slug,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _fail(message: str) -> None:
|
|
13
|
+
typer.echo(message, err=True)
|
|
14
|
+
raise typer.Exit(1)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def add_project(
|
|
18
|
+
slug: str = typer.Argument(None, help="Project slug (default: the folder name, like v1)"),
|
|
19
|
+
path: Path = typer.Argument(None, help="Project path (default: cwd)"),
|
|
20
|
+
confirm_all: bool = typer.Option(False, "--confirm-all",
|
|
21
|
+
help="List every project not confirmed on this machine; confirm them after one question"),
|
|
22
|
+
confirm_listed: bool = typer.Option(False, "--confirm-listed",
|
|
23
|
+
help="Confirm the projects whose path matches the slug:path lines on stdin "
|
|
24
|
+
"(this machine's own v1 registry; used by migrate/3)"),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Add a project to tracking — or confirm on this machine a project that is already registered.
|
|
27
|
+
|
|
28
|
+
corkern.conf is shared: a project's path is used to read its Claude Code conversations
|
|
29
|
+
(harvest, prune) or to wire its folder (init-device) only once this machine confirmed it.
|
|
30
|
+
"""
|
|
31
|
+
if confirm_all or confirm_listed:
|
|
32
|
+
_confirm_many(confirm_listed)
|
|
33
|
+
return
|
|
34
|
+
if slug is None:
|
|
35
|
+
slug = (path or Path(".")).resolve().name
|
|
36
|
+
if not valid_slug(slug):
|
|
37
|
+
_fail(f"Invalid slug {slug!r}: use letters, digits, '.', '_' or '-', "
|
|
38
|
+
"starting with a letter or digit.")
|
|
39
|
+
resolved = (path or Path(".")).resolve()
|
|
40
|
+
try:
|
|
41
|
+
stored = store_project_path(resolved)
|
|
42
|
+
except UnstorablePath as e:
|
|
43
|
+
_fail(f"Invalid path {str(resolved)!r}: {e}.")
|
|
44
|
+
|
|
45
|
+
repo, projects = load_config()
|
|
46
|
+
# Like v1: any line for the slug counts, including other devices' (e.g. Windows) lines
|
|
47
|
+
if slug in {s for s, _ in conf_entries(guard.read_text(repo.conf_file))}:
|
|
48
|
+
check = check_projects(repo.path).get(slug)
|
|
49
|
+
match = next((p for p in (check.candidates if check else ()) if same_path(p, resolved)), None)
|
|
50
|
+
if match is None:
|
|
51
|
+
_fail(f"Slug '{slug}' already exists. Remove it first.")
|
|
52
|
+
_confirm(repo.path, {slug: match})
|
|
53
|
+
typer.echo(f"Confirmed {slug} → {match} on this machine")
|
|
54
|
+
return
|
|
55
|
+
for other, other_path in projects.items():
|
|
56
|
+
if other_path.resolve() == resolved:
|
|
57
|
+
_fail(f"Path '{resolved}' is already tracked under slug '{other}'.")
|
|
58
|
+
|
|
59
|
+
_confirm(repo.path, {slug: resolve_project_path(stored)})
|
|
60
|
+
guard.append_text(repo.conf_file, f"{slug}:{stored}\n")
|
|
61
|
+
typer.echo(f"Added {slug} → {resolved}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _confirm(repo: Path, items: dict[str, Path]) -> None:
|
|
65
|
+
try:
|
|
66
|
+
confirm_projects(repo, items)
|
|
67
|
+
except (OSError, guard.V1ProtectedError) as e:
|
|
68
|
+
_fail(f"Could not record the confirmation in {repo}/.git/corkern: {e}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _confirm_many(listed: bool) -> None:
|
|
72
|
+
repo, _ = load_config()
|
|
73
|
+
open_ = [c for c in check_projects(repo.path).values() if not c.confirmed]
|
|
74
|
+
if listed:
|
|
75
|
+
theirs = _parse_conf(sys.stdin.read(), "stdin")
|
|
76
|
+
found: dict[str, Path] = {}
|
|
77
|
+
for c in open_:
|
|
78
|
+
if c.slug not in theirs:
|
|
79
|
+
continue
|
|
80
|
+
match = next((p for p in c.candidates if same_path(p, theirs[c.slug])), None)
|
|
81
|
+
if match is None:
|
|
82
|
+
typer.echo(f" ! {c.slug}: the registry's path differs from the listed one "
|
|
83
|
+
f"({', '.join(map(str, c.candidates))} ≠ {theirs[c.slug]}) — not confirmed")
|
|
84
|
+
else:
|
|
85
|
+
found[c.slug] = match
|
|
86
|
+
_confirm(repo.path, found)
|
|
87
|
+
for slug, p in sorted(found.items()):
|
|
88
|
+
typer.echo(f" ✓ {slug} → {p} confirmed on this machine")
|
|
89
|
+
return
|
|
90
|
+
single = [c for c in open_ if not c.duplicate]
|
|
91
|
+
if not open_:
|
|
92
|
+
typer.echo("✓ Every project is confirmed on this machine.")
|
|
93
|
+
return
|
|
94
|
+
typer.echo("Not confirmed on this machine (corkern.conf is shared — check each path is yours):")
|
|
95
|
+
for c in single:
|
|
96
|
+
typer.echo(f" {c.slug} → {c.candidates[0]} ({c.lines[0]})")
|
|
97
|
+
for c in open_:
|
|
98
|
+
if c.duplicate:
|
|
99
|
+
typer.echo(f" ! {c.explain()}")
|
|
100
|
+
if single and _ask(f"Confirm these {len(single)} project(s) on this machine? [y/N] ", False):
|
|
101
|
+
_confirm(repo.path, {c.slug: c.candidates[0] for c in single})
|
|
102
|
+
typer.echo(f"✓ {len(single)} project(s) confirmed")
|
|
103
|
+
elif single:
|
|
104
|
+
typer.echo("Nothing confirmed.")
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""get-AIinstructions and session-context — what Claude sees at the start of a session.
|
|
2
|
+
|
|
3
|
+
`session-context` is called by the SessionStart hook; its stdout is injected into Claude's
|
|
4
|
+
context, so it prints only useful text and never fails loudly.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from corkern import devices, guard
|
|
16
|
+
from corkern.commands import metrics_cmd
|
|
17
|
+
from corkern.commands.reminders import GLOBAL, format_items, open_items, read_todo
|
|
18
|
+
from corkern.commands.secrets_cmd import log_entries
|
|
19
|
+
from corkern.commands.trust_cmd import UNTRUSTED, held_entries
|
|
20
|
+
from corkern.config import NOT_CONFIRMED, check_projects, conf_entries, load_config, valid_slug
|
|
21
|
+
from corkern.knowledge_repo import repo_symlinks, shown, tracked_machine_local
|
|
22
|
+
from corkern.memory_index import usage
|
|
23
|
+
|
|
24
|
+
PROG = "corkern"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def instructions_text() -> str:
|
|
28
|
+
repo, _ = load_config()
|
|
29
|
+
rows = []
|
|
30
|
+
for slug, raw in conf_entries(repo.read_conf()):
|
|
31
|
+
if valid_slug(slug):
|
|
32
|
+
rows.append(f"- `{slug}` → {raw.strip() or '~ (home)'}")
|
|
33
|
+
projects = "\n".join(rows) or "- (none yet)"
|
|
34
|
+
kr = repo.path
|
|
35
|
+
return f"""# Corkern — AI Instructions
|
|
36
|
+
|
|
37
|
+
Corkern is this developer's memory sync system for Claude Code. A private git
|
|
38
|
+
"knowledge repo" keeps Claude's memory files in sync across machines automatically.
|
|
39
|
+
|
|
40
|
+
## What it does
|
|
41
|
+
|
|
42
|
+
1. Pulls other devices' memories when a session starts, and between replies
|
|
43
|
+
2. Commits each memory write immediately, with the file name in the commit message
|
|
44
|
+
3. Pushes memories (global ones right away, project ones at session end)
|
|
45
|
+
4. Never blocks Claude and never loses text: conflicting edits are kept side by side
|
|
46
|
+
as `<name>.conflict-<host>-<time>.md` — mention these to the user if you see them
|
|
47
|
+
|
|
48
|
+
## Knowledge repo
|
|
49
|
+
|
|
50
|
+
`{kr}`
|
|
51
|
+
|
|
52
|
+
Memory files live under `{kr}/memory/<slug>/`. Global (cross-project) memories live
|
|
53
|
+
under `{kr}/memory/global/`.
|
|
54
|
+
|
|
55
|
+
## Tracked projects
|
|
56
|
+
|
|
57
|
+
{projects}
|
|
58
|
+
|
|
59
|
+
## CLI ({PROG})
|
|
60
|
+
|
|
61
|
+
{PROG} list | ls Tracked projects and whether their paths exist
|
|
62
|
+
{PROG} add <slug> [path] Track a project (default path: current directory)
|
|
63
|
+
{PROG} remove | rm <slug> Stop tracking a project
|
|
64
|
+
{PROG} status | doctor Knowledge repo and project overview
|
|
65
|
+
{PROG} remind "text" [slug] Add a reminder (default: current project, else global)
|
|
66
|
+
{PROG} todos | todo [slug|--all] List open reminders
|
|
67
|
+
{PROG} done <n> [slug] Mark reminder n as done
|
|
68
|
+
{PROG} sessions [slug] Recent session notes
|
|
69
|
+
{PROG} log [slug] Git history of a project's memory
|
|
70
|
+
{PROG} signing This machine's commit-signing key
|
|
71
|
+
{PROG} trust Which devices' memories this machine accepts
|
|
72
|
+
{PROG} secrets [--scan] Secrets removed before commit / check the repo
|
|
73
|
+
{PROG} init-device Point Claude Code's memory for each project here
|
|
74
|
+
{PROG} iwe [--setup|--install] IWE knowledge graph over the memories (MCP server)
|
|
75
|
+
{PROG} graphify [slug] [--install] [--full] Build/update the project knowledge graph
|
|
76
|
+
{PROG} crg [slug] [--install] code-review-graph (fast, AST-only)
|
|
77
|
+
{PROG} metrics Memory size, context pressure, growth
|
|
78
|
+
{PROG} harvest [slug] [--all] [-i] Memories from past Claude conversations
|
|
79
|
+
{PROG} refine [slug] Split harvested-*.md bulk files
|
|
80
|
+
{PROG} prune [slug] Conversation inventory (harvested / pending)
|
|
81
|
+
{PROG} distill [--auto] Cross-project synthesis with Claude
|
|
82
|
+
(confirm with the user first)
|
|
83
|
+
{PROG} memory-prune [slug] [--dry-run] Archive expired/stale memories (never deletes;
|
|
84
|
+
confirm with the user first)
|
|
85
|
+
{PROG} get-AIinstructions | ai This document
|
|
86
|
+
|
|
87
|
+
## Secrets
|
|
88
|
+
|
|
89
|
+
Never write passwords, tokens or keys into memory files. If one slips in, corkern replaces it
|
|
90
|
+
with `[secret removed by corkern: <kind>]` before the commit; the original stays on this
|
|
91
|
+
machine only. Tell the user when you see such a marker.
|
|
92
|
+
|
|
93
|
+
## Reminders
|
|
94
|
+
|
|
95
|
+
Add a reminder when work starts but won't finish this session, or when the user wants
|
|
96
|
+
to come back to something; clear it with `{PROG} done <n> [slug]` when the work is done.
|
|
97
|
+
Open reminders are shown at the start of every session.
|
|
98
|
+
|
|
99
|
+
## Session notes
|
|
100
|
+
|
|
101
|
+
At the end of significant work, write a short note (5–10 bullets: done, next, decisions):
|
|
102
|
+
|
|
103
|
+
`{kr}/memory/<slug>/sessions/YYYY-MM-DD-HHmm.md`
|
|
104
|
+
|
|
105
|
+
It is committed and synced automatically; the next session on any machine sees it.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_ai_instructions() -> None:
|
|
110
|
+
"""Print the corkern context document for AI assistants."""
|
|
111
|
+
typer.echo(instructions_text())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _box(title: str, rows: list[str], footer: str | None = None) -> str:
|
|
115
|
+
lines = [f"╔═ Corkern: {title} " + "═" * max(3, 50 - len(title))]
|
|
116
|
+
lines += [f"║{row}" for row in rows]
|
|
117
|
+
if footer:
|
|
118
|
+
lines.append(f"║ {footer}")
|
|
119
|
+
lines.append("╚" + "═" * 55)
|
|
120
|
+
return "\n".join(lines)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _todo_box(repo, slug: str) -> str | None:
|
|
124
|
+
lines = read_todo(repo, slug)
|
|
125
|
+
if not lines or not open_items(lines):
|
|
126
|
+
return None
|
|
127
|
+
return _box(f"open reminders [{slug}]", format_items(lines),
|
|
128
|
+
f"→ clear with: {PROG} done <n> {slug}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _surfacing_files(repo, slug: str) -> str | None:
|
|
132
|
+
"""Memory files marked `surfaces: true` in their frontmatter (v1 'actionable' files)."""
|
|
133
|
+
rows = []
|
|
134
|
+
folder = repo.memory_dir / slug
|
|
135
|
+
for name, _ in sorted(guard.list_files(folder, ".md")):
|
|
136
|
+
if name in ("MEMORY.md", "todo.md"):
|
|
137
|
+
continue
|
|
138
|
+
head = guard.read_text(folder / name).split("\n")[:10]
|
|
139
|
+
if any("".join(line.split()) == "surfaces:true" for line in head):
|
|
140
|
+
desc = next((line.split(":", 1)[1].strip().strip('"') for line in head
|
|
141
|
+
if line.startswith("description:")), "")
|
|
142
|
+
rows.append(f" → {name}" + (f" — {desc}" if desc else ""))
|
|
143
|
+
return _box(f"actionable memory files [{slug}]", rows) if rows else None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _unconfirmed_box(checks) -> list[str]:
|
|
147
|
+
open_ = sorted(s for s, c in checks.items() if not c.confirmed)
|
|
148
|
+
if not open_:
|
|
149
|
+
return []
|
|
150
|
+
return [_box(f"{len(open_)} project(s) {NOT_CONFIRMED}", [
|
|
151
|
+
f" {' '.join(open_[:10])}" + (" …" if len(open_) > 10 else ""),
|
|
152
|
+
" corkern.conf is shared; until this machine confirms a project's path, corkern does not",
|
|
153
|
+
" read its conversations, wire its folder or show its reminders. Tell the user."],
|
|
154
|
+
f"→ {PROG} add <slug> <path>, or {PROG} init-device to confirm")]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _safety_boxes(repo, slug: str | None) -> list[str]:
|
|
158
|
+
"""Things the user must know at session start: held-back commits, removed secrets,
|
|
159
|
+
an index Claude Code no longer loads completely."""
|
|
160
|
+
boxes = []
|
|
161
|
+
links = repo_symlinks(repo.path)
|
|
162
|
+
if links:
|
|
163
|
+
boxes.append(_box("symlinks in the knowledge repo — held/ignored", [
|
|
164
|
+
" These synced files are symlinks; corkern never follows them and treats them",
|
|
165
|
+
" as absent (a link can point at any file on this machine):",
|
|
166
|
+
*[f" {shown(rel)}" for rel in links[:10]],
|
|
167
|
+
*([f" … and {len(links) - 10} more"] if len(links) > 10 else []),
|
|
168
|
+
" Tell the user. Do not read or write them; replace them with regular files."],
|
|
169
|
+
f"→ {PROG} status lists them"))
|
|
170
|
+
tracked = tracked_machine_local(repo.path)
|
|
171
|
+
if tracked:
|
|
172
|
+
boxes.append(_box("per-machine files tracked in the knowledge repo", [
|
|
173
|
+
*[f" tracked per-machine file: {shown(rel)} — remove it on the remote" for rel in tracked[:10]],
|
|
174
|
+
" corkern never stages them again (this machine's copy stays local). Tell the user."],
|
|
175
|
+
f"→ {PROG} status"))
|
|
176
|
+
entries = held_entries(repo.path)
|
|
177
|
+
if entries:
|
|
178
|
+
untrusted = len({sha for sha, reason in entries if reason == UNTRUSTED})
|
|
179
|
+
reasons = list(dict.fromkeys(reason for sha, reason in entries if reason != UNTRUSTED))
|
|
180
|
+
boxes.append(_box("memories held back", [
|
|
181
|
+
" Nothing new from the remote was loaded this session. Reasons:",
|
|
182
|
+
*([f" - {untrusted} commit(s) not signed by a device this machine trusts"] if untrusted else []),
|
|
183
|
+
*[f" - {shown(reason)}" for reason in reasons[:8]],
|
|
184
|
+
" Tell the user; never recreate held content by hand. Review with:"],
|
|
185
|
+
f"→ {PROG} trust"))
|
|
186
|
+
recent = [e for e in log_entries()
|
|
187
|
+
if e[0] >= f"{datetime.now() - timedelta(days=3):%Y-%m-%d %H:%M:%S}"]
|
|
188
|
+
if recent:
|
|
189
|
+
rows = [f" {when[:16]} {rel} ({kinds})" for when, rel, kinds, _ in recent[-5:]]
|
|
190
|
+
boxes.append(_box("secrets removed before commit", [
|
|
191
|
+
" A secret was written into a memory and replaced by a marker before syncing:",
|
|
192
|
+
*rows, " Tell the user, and do not write secrets into memory files."],
|
|
193
|
+
f"→ originals stay on this machine: {PROG} secrets"))
|
|
194
|
+
index = guard.read_shared(repo.memory_dir / slug / "MEMORY.md") if slug else None
|
|
195
|
+
if index is not None:
|
|
196
|
+
u = usage(index)
|
|
197
|
+
if u.over or u.near:
|
|
198
|
+
state = "OVER" if u.over else "close to"
|
|
199
|
+
boxes.append(_box(f"MEMORY.md {state} Claude Code's read limit [{slug}]", [
|
|
200
|
+
f" {u.describe()}" + (" — entries past it are not loaded." if u.over else "."),
|
|
201
|
+
" Please shorten it now: one line per entry, details in topic files,",
|
|
202
|
+
" merge or drop stale entries (lines also arrive from other devices)."]))
|
|
203
|
+
return boxes
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _cwd_from_hook_stdin() -> Path:
|
|
207
|
+
if not sys.stdin.isatty():
|
|
208
|
+
try:
|
|
209
|
+
event = json.loads(sys.stdin.read() or "{}")
|
|
210
|
+
if isinstance(event, dict) and event.get("cwd"):
|
|
211
|
+
return Path(event["cwd"])
|
|
212
|
+
except (json.JSONDecodeError, OSError):
|
|
213
|
+
pass
|
|
214
|
+
return Path.cwd()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def session_context() -> None:
|
|
218
|
+
"""(For the SessionStart hook) instructions, open reminders, actionable files."""
|
|
219
|
+
repo, projects = load_config()
|
|
220
|
+
cwd = _cwd_from_hook_stdin().resolve()
|
|
221
|
+
here = next((s for s, p in projects.items() if p.resolve() == cwd), None)
|
|
222
|
+
checks = check_projects(repo.path)
|
|
223
|
+
# only a path this machine confirmed brings a project's reminders and files into the session
|
|
224
|
+
slug = next((s for s, c in checks.items() if c.confirmed and c.path.resolve() == cwd), None)
|
|
225
|
+
text = instructions_text()
|
|
226
|
+
try:
|
|
227
|
+
devices.update(repo, projects, touch=here) # this device's manifest: last seen here
|
|
228
|
+
except (OSError, guard.SymlinkInRepoError):
|
|
229
|
+
pass # never break session start
|
|
230
|
+
if here:
|
|
231
|
+
try:
|
|
232
|
+
metrics_cmd.record(repo, here, text) # a session count, no project content
|
|
233
|
+
except (OSError, guard.SymlinkInRepoError):
|
|
234
|
+
pass # metrics must never break session start
|
|
235
|
+
parts = [text, *_safety_boxes(repo, slug), *_unconfirmed_box(checks)]
|
|
236
|
+
for s in [GLOBAL] + ([slug] if slug and slug != GLOBAL else []):
|
|
237
|
+
parts += [box for box in (_todo_box(repo, s), _surfacing_files(repo, s)) if box]
|
|
238
|
+
typer.echo("\n".join(parts))
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""crg — build or install code-review-graph for a project (v1 `corkern crg` parity).
|
|
2
|
+
|
|
3
|
+
Same safety rules as graphify: projects inside v1 / ~/.claude are refused, and --install is
|
|
4
|
+
refused when it would write a protected place (code-review-graph install writes the
|
|
5
|
+
project's .claude/settings.json, .mcp.json and CLAUDE.md).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from corkern import guard
|
|
12
|
+
from corkern.commands.reminders import fail, slug_for_cwd
|
|
13
|
+
from corkern.config import check_projects, load_config
|
|
14
|
+
|
|
15
|
+
TOOL = "code-review-graph"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def crg(
|
|
19
|
+
slug: str = typer.Argument(None, help="Project slug (default: current project)"),
|
|
20
|
+
install: bool = typer.Option(False, "--install", help="Install MCP server, hooks, CLAUDE.md"),
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Build/update the project's code-review-graph (fast, AST-only, no LLM)."""
|
|
23
|
+
repo, projects = load_config()
|
|
24
|
+
slug = slug or slug_for_cwd(projects)
|
|
25
|
+
if not slug:
|
|
26
|
+
fail("Could not detect project slug. Usage: corkern crg [slug] [--install]\n"
|
|
27
|
+
f" Known slugs: {' '.join(sorted(projects))}")
|
|
28
|
+
if slug not in projects:
|
|
29
|
+
fail(f"Slug '{slug}' not found in corkern.conf")
|
|
30
|
+
check = check_projects(repo.path)[slug] # corkern.conf is shared: only a path confirmed here
|
|
31
|
+
if not check.confirmed:
|
|
32
|
+
fail(f"Refusing to run code-review-graph in the folder of {check.explain()}")
|
|
33
|
+
project = check.path
|
|
34
|
+
if not project.is_dir():
|
|
35
|
+
fail(f"Project directory not found: {project}")
|
|
36
|
+
guard.assert_safe(project)
|
|
37
|
+
exe = guard.which(TOOL)
|
|
38
|
+
if not exe:
|
|
39
|
+
fail(f"{TOOL} is not installed.\n Install it with: pipx install {TOOL}")
|
|
40
|
+
|
|
41
|
+
typer.echo(f"\ncorkern crg: {slug}\n Project: {project}")
|
|
42
|
+
if install:
|
|
43
|
+
try:
|
|
44
|
+
guard.assert_safe(project / ".claude")
|
|
45
|
+
except guard.V1ProtectedError:
|
|
46
|
+
fail(f"Refusing --install for '{slug}': {TOOL} would write {project / '.claude'}, "
|
|
47
|
+
"which is protected (global Claude settings).")
|
|
48
|
+
if guard.run_tool(project, [exe, "install"]) != 0:
|
|
49
|
+
fail(f"{TOOL} install failed")
|
|
50
|
+
typer.echo(f" ✓ {TOOL} installed (MCP server + hooks + CLAUDE.md) — restart Claude Code")
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
db = project / ".code-review-graph"
|
|
54
|
+
typer.echo(f" {'Updating' if db.exists() else 'Building'} the graph (AST only, no LLM)...")
|
|
55
|
+
rc = guard.run_tool(project, [exe, "build", "--repo", str(project)])
|
|
56
|
+
if rc != 0:
|
|
57
|
+
fail(f"{TOOL} build exited with code {rc}")
|
|
58
|
+
typer.echo(f" ✓ Graph: {db}")
|
|
59
|
+
if not (project / ".mcp.json").exists():
|
|
60
|
+
typer.echo(f" Next step — register it with Claude Code: corkern crg {slug} --install")
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""distill — synthesise cross-project insights from all memories with Claude (v1 parity).
|
|
2
|
+
|
|
3
|
+
corkern distill build the prompt, open an interactive Claude session with it
|
|
4
|
+
corkern distill --auto run Claude non-interactively → memory/global/synthesis.md
|
|
5
|
+
|
|
6
|
+
Fixes vs v1 distill.sh:
|
|
7
|
+
- the previous synthesis.md is only replaced when Claude succeeds (v1 truncated it before
|
|
8
|
+
running Claude, so a failed run destroyed the last synthesis)
|
|
9
|
+
- no crash after saving (v1 referenced $BRAIN — a variable left over from the tool's old name
|
|
10
|
+
"brain" that is never set — under `set -u`)
|
|
11
|
+
- the project list in the prompt comes from corkern.conf (v1 hard-coded it)
|
|
12
|
+
- the prompt file (a copy of every memory) is written to this machine's state dir (0600), never
|
|
13
|
+
into the knowledge repo, where a synced symlink could redirect the write
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
from corkern import claude_print, guard
|
|
22
|
+
from corkern.commands.reminders import fail
|
|
23
|
+
from corkern.config import conf_entries, load_config, state_dir, valid_slug
|
|
24
|
+
|
|
25
|
+
PROMPT_NAME = ".distill-prompt.md"
|
|
26
|
+
|
|
27
|
+
HEADER = """You are analyzing a personal knowledge base ("corkern") that captures everything
|
|
28
|
+
Claude Code has learned working with this developer across multiple projects.
|
|
29
|
+
|
|
30
|
+
Memory files are organized as: memory/<project>/<topic>.md
|
|
31
|
+
Projects present: {projects}
|
|
32
|
+
|
|
33
|
+
Please produce a structured synthesis with these sections:
|
|
34
|
+
|
|
35
|
+
## Universal Principles
|
|
36
|
+
Rules or preferences that appear across 2+ projects (verbatim or in spirit).
|
|
37
|
+
For each: quote the source project(s) and note if it should be added to global/CLAUDE.md.
|
|
38
|
+
|
|
39
|
+
## Cross-Project Opportunities
|
|
40
|
+
Insights from one project that would benefit others where NOT yet recorded.
|
|
41
|
+
|
|
42
|
+
## Stale Candidates
|
|
43
|
+
Memory files that appear outdated, superseded, or about completed work.
|
|
44
|
+
For each: state why it may be stale.
|
|
45
|
+
|
|
46
|
+
## Contradictions
|
|
47
|
+
Rules or preferences that conflict between projects.
|
|
48
|
+
For each: surface the tension and suggest resolution.
|
|
49
|
+
|
|
50
|
+
## Who This Developer Is
|
|
51
|
+
A short paragraph synthesising working style, values, and instincts from all evidence combined.
|
|
52
|
+
Be specific — avoid generic statements.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
MEMORY FILES:
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_prompt(repo) -> tuple[str, int]:
|
|
60
|
+
slugs = list(dict.fromkeys(s for s, _ in conf_entries(repo.read_conf())
|
|
61
|
+
if valid_slug(s)))
|
|
62
|
+
files = guard.walk_files(repo.memory_dir, ".md")
|
|
63
|
+
parts = [HEADER.format(projects=", ".join([*slugs, "global"]))]
|
|
64
|
+
for f in files:
|
|
65
|
+
parts.append(f"\n=== {f.relative_to(repo.path)} ===\n{guard.read_text(f)}\n")
|
|
66
|
+
return "".join(parts), len(files)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def distill(
|
|
70
|
+
auto: bool = typer.Option(False, "--auto", help="Run non-interactively → memory/global/synthesis.md"),
|
|
71
|
+
model: str = typer.Option(None, "--model", help="Claude model (default: Claude Code's default)"),
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Synthesise cross-project insights from all memories with Claude."""
|
|
74
|
+
repo, _ = load_config()
|
|
75
|
+
typer.echo("=== Corkern Distill ===\n\nCollecting memories...")
|
|
76
|
+
prompt, count = build_prompt(repo)
|
|
77
|
+
prompt_file = state_dir() / PROMPT_NAME
|
|
78
|
+
guard.write_private(prompt_file, prompt)
|
|
79
|
+
typer.echo(f"✓ Collected {count} files ({len(prompt.encode())} bytes).\n")
|
|
80
|
+
claude = guard.which("claude")
|
|
81
|
+
model_args = ["--model", model] if model else []
|
|
82
|
+
|
|
83
|
+
if auto:
|
|
84
|
+
if not claude:
|
|
85
|
+
fail("'claude' CLI not found — install Claude Code or run without --auto.")
|
|
86
|
+
typer.echo("Running Claude analysis...")
|
|
87
|
+
result = claude_print.ask(claude, prompt, model)
|
|
88
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
89
|
+
fail(f"Claude failed (exit {result.returncode}) — previous synthesis.md left unchanged.")
|
|
90
|
+
now = datetime.now()
|
|
91
|
+
front = (f"---\nname: synthesis\ndescription: Cross-project synthesis — {now:%Y-%m-%d}\n"
|
|
92
|
+
f"metadata:\n type: reference\n generated: {now:%Y-%m-%dT%H:%M}\n---\n\n")
|
|
93
|
+
guard.write_text(repo.memory_dir / "global" / "synthesis.md", front + result.stdout)
|
|
94
|
+
typer.echo("✓ Saved: memory/global/synthesis.md")
|
|
95
|
+
typer.echo(" (Committed and synced by the next corkern hook run.)")
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
typer.echo(f"Prompt saved: {prompt_file}\n")
|
|
99
|
+
if not claude:
|
|
100
|
+
typer.echo(f"Claude CLI not found. To analyse manually, paste {prompt_file} into any Claude session.")
|
|
101
|
+
return
|
|
102
|
+
typer.echo("Starting interactive Claude analysis session...\n"
|
|
103
|
+
"(Claude will open with all your memories as context.)\n")
|
|
104
|
+
# from where the user is — unless that is inside the knowledge repo (its .claude/ is shared)
|
|
105
|
+
guard.exec_tool([claude, "--append-system-prompt-file", str(prompt_file), *model_args],
|
|
106
|
+
cwd=claude_print.interactive_cwd())
|