robo-cortex 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. robo_cortex/__init__.py +5 -0
  2. robo_cortex/cli/__init__.py +5 -0
  3. robo_cortex/cli/__main__.py +6 -0
  4. robo_cortex/cli/_common.py +72 -0
  5. robo_cortex/cli/_output.py +76 -0
  6. robo_cortex/cli/app.py +74 -0
  7. robo_cortex/cli/commands/__init__.py +36 -0
  8. robo_cortex/cli/commands/affected.py +40 -0
  9. robo_cortex/cli/commands/completion.py +94 -0
  10. robo_cortex/cli/commands/evidence.py +110 -0
  11. robo_cortex/cli/commands/hooks.py +149 -0
  12. robo_cortex/cli/commands/init.py +76 -0
  13. robo_cortex/cli/commands/link.py +47 -0
  14. robo_cortex/cli/commands/list_cmd.py +57 -0
  15. robo_cortex/cli/commands/mcp.py +37 -0
  16. robo_cortex/cli/commands/record.py +156 -0
  17. robo_cortex/cli/commands/retrieve.py +88 -0
  18. robo_cortex/cli/commands/search.py +48 -0
  19. robo_cortex/cli/commands/show.py +62 -0
  20. robo_cortex/cli/commands/status.py +83 -0
  21. robo_cortex/cli/commands/transfer.py +140 -0
  22. robo_cortex/core/__init__.py +0 -0
  23. robo_cortex/core/assumptions.py +39 -0
  24. robo_cortex/core/db.py +67 -0
  25. robo_cortex/core/errors.py +32 -0
  26. robo_cortex/core/evidence.py +166 -0
  27. robo_cortex/core/git.py +162 -0
  28. robo_cortex/core/hooks.py +214 -0
  29. robo_cortex/core/init.py +75 -0
  30. robo_cortex/core/invalidate.py +436 -0
  31. robo_cortex/core/lifecycle.py +165 -0
  32. robo_cortex/core/memory.py +394 -0
  33. robo_cortex/core/retrieve.py +429 -0
  34. robo_cortex/core/semantic.py +48 -0
  35. robo_cortex/core/store.py +72 -0
  36. robo_cortex/core/text.py +27 -0
  37. robo_cortex/core/transfer.py +246 -0
  38. robo_cortex/gitea.py +140 -0
  39. robo_cortex/mcp_server.py +263 -0
  40. robo_cortex/migrations/0001_init.sql +101 -0
  41. robo_cortex/migrations/0002_add_usage_tracking.sql +2 -0
  42. robo_cortex/migrations/0003_add_meta_table.sql +4 -0
  43. robo_cortex/sdk.py +196 -0
  44. robo_cortex-0.4.0.dist-info/METADATA +367 -0
  45. robo_cortex-0.4.0.dist-info/RECORD +48 -0
  46. robo_cortex-0.4.0.dist-info/WHEEL +4 -0
  47. robo_cortex-0.4.0.dist-info/entry_points.txt +3 -0
  48. robo_cortex-0.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,5 @@
1
+ __version__ = "0.4.0"
2
+
3
+ from robo_cortex.sdk import RoboCortex, retrieve, record, search
4
+
5
+ __all__ = ["RoboCortex", "retrieve", "record", "search"]
@@ -0,0 +1,5 @@
1
+ """CLI module — entry point for robo-cortex command."""
2
+
3
+ from robo_cortex.cli.app import main
4
+
5
+ __all__ = ["main"]
@@ -0,0 +1,6 @@
1
+ """Allow running CLI as: python -m robo_cortex.cli"""
2
+
3
+ import sys
4
+ from robo_cortex.cli import main
5
+
6
+ sys.exit(main())
@@ -0,0 +1,72 @@
1
+ """CLI common utilities: context managers, decorators, constants."""
2
+
3
+ import sys
4
+ from contextlib import contextmanager
5
+ from pathlib import Path
6
+
7
+ from robo_cortex.core.errors import RoboCortexError
8
+ from robo_cortex.core.store import open_global_store, open_store
9
+
10
+
11
+ def _get_cmd_name():
12
+ """Extract the command name from sys.argv[0]"""
13
+ cmd = Path(sys.argv[0]).name
14
+ if cmd.endswith('.py') or '/' in cmd:
15
+ return 'robo-cortex'
16
+ return cmd
17
+
18
+
19
+ @contextmanager
20
+ def _store(repo_arg):
21
+ repo_root, conn = open_store(repo_arg)
22
+ try:
23
+ yield repo_root, conn
24
+ finally:
25
+ conn.close()
26
+
27
+
28
+ @contextmanager
29
+ def _global_store():
30
+ conn = open_global_store()
31
+ try:
32
+ yield conn
33
+ finally:
34
+ conn.close()
35
+
36
+
37
+ @contextmanager
38
+ def _store_conn(repo_arg):
39
+ """Same as `_store`, but yields only the connection, not (repo_root, conn).
40
+
41
+ Exists for call sites (currently: import) that need a connection-only
42
+ context manager to pass around -- e.g. as one of two interchangeable
43
+ factories alongside `_global_store`. Using `_store` directly there would
44
+ silently yield a 2-tuple where a connection is expected (that mismatch
45
+ was exactly the `'tuple' object has no attribute 'execute'` bug in the
46
+ original `roco import` implementation).
47
+ """
48
+ with _store(repo_arg) as (_repo_root, conn):
49
+ yield conn
50
+
51
+
52
+ LINK_TYPE_ARGS = {"contradicts": "contradicts", "duplicate-of": "duplicate_of"}
53
+
54
+
55
+ def cli_command(name: str):
56
+ """Decorator: wrap command handler to catch RoboCortexError and return exit code.
57
+
58
+ Usage:
59
+ @cli_command("record")
60
+ def run(args) -> int:
61
+ ...result = record_memory(...)...
62
+ return 0
63
+ """
64
+ def wrap(fn):
65
+ def inner(args):
66
+ try:
67
+ return fn(args)
68
+ except RoboCortexError as error:
69
+ print(f"{_get_cmd_name()} {name}: {error}", file=sys.stderr)
70
+ return error.exit_code
71
+ return inner
72
+ return wrap
@@ -0,0 +1,76 @@
1
+ """CLI output formatting utilities."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ _ANSI_CODES = {
7
+ "green": "\033[32m",
8
+ "yellow": "\033[33m",
9
+ "red": "\033[31m",
10
+ }
11
+ _ANSI_RESET = "\033[0m"
12
+
13
+ # Terminal (superseded/invalidated/abandoned/archived) statuses render red;
14
+ # active is green; everything still under review (provisional/needs_review)
15
+ # is yellow. Unknown/future status strings pass through uncolored rather
16
+ # than raising -- this is display-only, never a source of truth.
17
+ _STATUS_COLORS = {
18
+ "active": "green",
19
+ "provisional": "yellow",
20
+ "needs_review": "yellow",
21
+ "superseded": "red",
22
+ "invalidated": "red",
23
+ "abandoned": "red",
24
+ "archived": "red",
25
+ }
26
+
27
+
28
+ def _color_enabled(stream) -> bool:
29
+ """NO_COLOR (no-color.org: disable on presence, regardless of value)
30
+ and non-tty output (pipes, redirects, captured subprocess output in
31
+ tests) both suppress color -- only an interactive terminal gets ANSI
32
+ codes.
33
+ """
34
+ if "NO_COLOR" in os.environ:
35
+ return False
36
+ try:
37
+ return stream.isatty()
38
+ except (AttributeError, ValueError):
39
+ return False
40
+
41
+
42
+ def format_status(status: str, *, width: int | None = None, stream=None) -> str:
43
+ """Color-wrap a memory status for terminal display.
44
+
45
+ `width` pads the plain text first, then wraps it in the ANSI codes --
46
+ padding after coloring would count the invisible escape bytes toward
47
+ the field width and break column alignment in `list`'s output.
48
+ """
49
+ text = f"{status:<{width}s}" if width else status
50
+ if not _color_enabled(stream or sys.stdout):
51
+ return text
52
+ color = _STATUS_COLORS.get(status)
53
+ if color is None:
54
+ return text
55
+ return f"{_ANSI_CODES[color]}{text}{_ANSI_RESET}"
56
+
57
+
58
+ def _format_score_breakdown(components: dict) -> str:
59
+ """Human-readable --explain / --explain-against output. assumptions_gate
60
+ (present only for scope='global' items, ARCHITECTURE.md §5.4) is a
61
+ nested dict, not a float -- formatted on its own, never blindly run
62
+ through the same `:.3f` as the six numeric components.
63
+ """
64
+ numeric = " ".join(
65
+ f"{key}={value:.3f}" for key, value in components.items()
66
+ if key not in ("total", "assumptions_gate")
67
+ )
68
+ parts = [numeric]
69
+ if "assumptions_gate" in components:
70
+ gate = components["assumptions_gate"]
71
+ phrases = ", ".join(
72
+ f"{p['text']!r}:{'matched' if p['matched'] else 'not matched'}"
73
+ for p in gate["phrases"]
74
+ )
75
+ parts.append(f"assumptions_gate=[{phrases}] passed={gate['passed']}")
76
+ return " ".join(parts) + f" total={components['total']:.3f}"
robo_cortex/cli/app.py ADDED
@@ -0,0 +1,74 @@
1
+ """CLI main application and dispatcher."""
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from robo_cortex import __version__
9
+ from robo_cortex.cli._common import _get_cmd_name
10
+ from robo_cortex.cli.commands import ALL_COMMANDS
11
+
12
+
13
+ def build_parser(cmd_name: str) -> argparse.ArgumentParser:
14
+ """Construct the full argument parser, every subcommand registered.
15
+
16
+ Extracted from `main()` so anything that needs the real command
17
+ surface -- currently `roco completion` -- introspects the same parser
18
+ tree the CLI actually dispatches through, instead of a second,
19
+ driftable description of it.
20
+ """
21
+ parser = argparse.ArgumentParser(prog=cmd_name)
22
+ parser.add_argument(
23
+ "--version", action="version", version=f"{cmd_name} {__version__}"
24
+ )
25
+ subparsers = parser.add_subparsers(dest="command")
26
+
27
+ for command in ALL_COMMANDS:
28
+ command.register(subparsers)
29
+
30
+ return parser
31
+
32
+
33
+ def main(argv=None):
34
+ """Main CLI entry point."""
35
+ # Extract command name (roco or robo-cortex) from argv[0]
36
+ if argv is None:
37
+ argv = sys.argv[1:]
38
+ cmd_name = Path(sys.argv[0]).name
39
+ else:
40
+ cmd_name = Path(argv[0]).name if argv else "robo-cortex"
41
+
42
+ # Fallback if invoked as a Python script
43
+ if cmd_name.endswith('.py') or '/' in cmd_name:
44
+ cmd_name = 'robo-cortex'
45
+
46
+ parser = build_parser(cmd_name)
47
+ args = parser.parse_args(argv)
48
+
49
+ if not hasattr(args, "func"):
50
+ parser.print_help(sys.stderr)
51
+ return 1
52
+
53
+ try:
54
+ return args.func(args)
55
+ except Exception as error:
56
+ # Last-resort net: every expected failure mode is already a
57
+ # RoboCortexError caught by @cli_command inside args.func, printed
58
+ # cleanly with the right exit code. Anything that reaches here is
59
+ # unexpected -- still no raw traceback by default (that's an
60
+ # implementation detail leaking into a CLI's stdout/stderr contract),
61
+ # but ROBO_CORTEX_DEBUG=1 gets the real one back for debugging.
62
+ if os.environ.get("ROBO_CORTEX_DEBUG"):
63
+ raise
64
+ print(f"{cmd_name}: internal error: {error}", file=sys.stderr)
65
+ print(
66
+ f"{cmd_name}: this is unexpected -- set ROBO_CORTEX_DEBUG=1 for "
67
+ "a full traceback, and please report it",
68
+ file=sys.stderr,
69
+ )
70
+ return 1
71
+
72
+
73
+ if __name__ == "__main__":
74
+ sys.exit(main())
@@ -0,0 +1,36 @@
1
+ """CLI commands registry."""
2
+
3
+ # Import all command modules
4
+ from robo_cortex.cli.commands import (
5
+ init,
6
+ record,
7
+ show,
8
+ list_cmd,
9
+ retrieve,
10
+ search,
11
+ affected,
12
+ status,
13
+ evidence,
14
+ link,
15
+ mcp,
16
+ transfer,
17
+ hooks,
18
+ completion,
19
+ )
20
+
21
+ ALL_COMMANDS = [
22
+ init,
23
+ record,
24
+ show,
25
+ list_cmd,
26
+ retrieve,
27
+ search,
28
+ affected,
29
+ status,
30
+ evidence,
31
+ link,
32
+ mcp,
33
+ transfer,
34
+ hooks,
35
+ completion,
36
+ ]
@@ -0,0 +1,40 @@
1
+ """Command: roco affected"""
2
+
3
+ import json
4
+ import sys
5
+
6
+ from robo_cortex.core.invalidate import affected
7
+ from robo_cortex.cli._common import _get_cmd_name, _store, cli_command
8
+
9
+
10
+ @cli_command("affected")
11
+ def run(args) -> int:
12
+ with _store(args.repo) as (repo_root, conn):
13
+ result = affected(
14
+ conn,
15
+ repo_root,
16
+ diff_range=args.diff_range,
17
+ staged=args.staged,
18
+ working=args.working,
19
+ )
20
+
21
+ if args.json:
22
+ print(json.dumps(result))
23
+ else:
24
+ if not result["data"]:
25
+ print("No memories affected.")
26
+ for item in result["data"]:
27
+ print(f"[{item['id']}] {item['reason']} {item['statement']}")
28
+ return 0
29
+
30
+
31
+ def register(subparsers):
32
+ p = subparsers.add_parser(
33
+ "affected", help="List memories put at risk by recent changes or a diff"
34
+ )
35
+ p.add_argument("--repo", default=None)
36
+ p.add_argument("--diff-range", default=None)
37
+ p.add_argument("--staged", action="store_true")
38
+ p.add_argument("--working", action="store_true")
39
+ p.add_argument("--json", action="store_true")
40
+ p.set_defaults(func=run)
@@ -0,0 +1,94 @@
1
+ """Command: roco completion"""
2
+
3
+ import argparse
4
+
5
+ from robo_cortex.cli._common import cli_command
6
+
7
+ _TEMPLATE = """# roco / robo-cortex bash completion
8
+ # Generated by `roco completion bash`. Install with:
9
+ # roco completion bash >> ~/.bashrc
10
+ # or system-wide (Debian/Ubuntu):
11
+ # roco completion bash | sudo tee /etc/bash_completion.d/roco
12
+ # Regenerate after upgrading robo-cortex, to pick up new commands or flags.
13
+ _roco_completions()
14
+ {
15
+ local cur commands
16
+ COMPREPLY=()
17
+ cur="${COMP_WORDS[COMP_CWORD]}"
18
+
19
+ commands="__COMMANDS__"
20
+
21
+ if [ "$COMP_CWORD" -eq 1 ]; then
22
+ COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
23
+ return 0
24
+ fi
25
+
26
+ case "${COMP_WORDS[1]}" in
27
+ __CASES__
28
+ esac
29
+ }
30
+ complete -F _roco_completions roco
31
+ complete -F _roco_completions robo-cortex
32
+ """
33
+
34
+
35
+ def _bash_completion_script() -> str:
36
+ """Introspect the real argparse tree (same one `main()` dispatches
37
+ through -- see `cli.app.build_parser`) rather than hand-maintaining a
38
+ second, driftable list of commands and flags.
39
+
40
+ Only two levels are completed: the subcommand name, and that
41
+ subcommand's own flags -- not nested sub-subcommands (`hooks install`,
42
+ `evidence add`) or flag *values* (paths, statuses). Documented
43
+ limitation, not an oversight: extend only if it proves to matter in
44
+ practice (KISS).
45
+ """
46
+ from robo_cortex.cli.app import build_parser # deferred: app.py imports
47
+ # cli.commands at module load time, which imports this module -- a
48
+ # top-level import here would be circular.
49
+
50
+ parser = build_parser("roco")
51
+ subparsers_action = next(
52
+ action for action in parser._actions
53
+ if isinstance(action, argparse._SubParsersAction)
54
+ )
55
+ command_names = sorted(subparsers_action.choices)
56
+
57
+ case_blocks = []
58
+ for name in command_names:
59
+ subparser = subparsers_action.choices[name]
60
+ flags = sorted({
61
+ option
62
+ for action in subparser._actions
63
+ for option in action.option_strings
64
+ })
65
+ flags_str = " ".join(flags)
66
+ case_blocks.append(
67
+ f' {name})\n'
68
+ f' COMPREPLY=( $(compgen -W "{flags_str}" -- "$cur") )\n'
69
+ f' ;;'
70
+ )
71
+
72
+ return (
73
+ _TEMPLATE
74
+ .replace("__COMMANDS__", " ".join(command_names))
75
+ .replace("__CASES__", "\n".join(case_blocks))
76
+ )
77
+
78
+
79
+ @cli_command("completion")
80
+ def run(args) -> int:
81
+ if args.shell == "bash":
82
+ print(_bash_completion_script(), end="")
83
+ return 0
84
+
85
+
86
+ def register(subparsers):
87
+ p = subparsers.add_parser(
88
+ "completion", help="Generate a shell completion script"
89
+ )
90
+ p.add_argument(
91
+ "shell", choices=["bash"],
92
+ help="Shell to generate a completion script for",
93
+ )
94
+ p.set_defaults(func=run)
@@ -0,0 +1,110 @@
1
+ """Command: roco evidence (add / verify)"""
2
+
3
+ import json
4
+ import sys
5
+
6
+ from robo_cortex.core.errors import NotFoundError, RoboCortexError
7
+ from robo_cortex.core.evidence import EVIDENCE_KINDS, attach_evidence, verify_evidence
8
+ from robo_cortex.core.memory import find_memory_store
9
+ from robo_cortex.cli._common import _get_cmd_name, _store, _global_store, cli_command
10
+
11
+
12
+ @cli_command("evidence add")
13
+ def run_add(args) -> int:
14
+ with _store(args.repo) as (repo_root, conn), _global_store() as global_conn:
15
+ store = find_memory_store(conn, global_conn, args.id, scope=args.scope)
16
+ result = attach_evidence(
17
+ store,
18
+ repo_root,
19
+ args.id,
20
+ kind=args.kind,
21
+ description=args.description,
22
+ command=args.evidence_command_text,
23
+ expected_outcome=args.expected,
24
+ ref=args.ref,
25
+ cold_storage_content=args.cold_storage_content,
26
+ )
27
+
28
+ if args.json:
29
+ print(json.dumps(result))
30
+ else:
31
+ print(
32
+ f"Attached evidence {result['evidence_id']} "
33
+ f"(memory evidence strength: {result['memory_evidence_strength']:.2f})"
34
+ )
35
+ return 0
36
+
37
+
38
+ @cli_command("evidence verify")
39
+ def run_verify(args) -> int:
40
+ with _store(args.repo) as (repo_root, conn), _global_store() as global_conn:
41
+ # evidence rows live in whichever store their memory lives in;
42
+ # id sequences are independent per store. --scope disambiguates
43
+ # an id that collides between stores, same as for memory ids.
44
+ # repo_root is passed regardless of which store the evidence
45
+ # lives in: it's this invocation's ambient repo context, used to
46
+ # resolve the Gitea owner/repo via the local git remote
47
+ # (Stage 10) for gitea_pr/gitea_issue evidence specifically.
48
+ if args.scope == "repo":
49
+ result = verify_evidence(conn, args.evidence_id, repo_root)
50
+ elif args.scope == "global":
51
+ result = verify_evidence(global_conn, args.evidence_id, repo_root)
52
+ else:
53
+ try:
54
+ result = verify_evidence(conn, args.evidence_id, repo_root)
55
+ except NotFoundError:
56
+ result = verify_evidence(global_conn, args.evidence_id, repo_root)
57
+
58
+ print(json.dumps(result) if args.json else json.dumps(result, indent=2))
59
+ return 0
60
+
61
+
62
+ def run_no_subcommand(args) -> int:
63
+ """Called when 'evidence' is invoked without add/verify."""
64
+ # This will be set as the default func for the evidence parser itself
65
+ print("error: evidence requires a subcommand (add or verify)", file=sys.stderr)
66
+ return 1
67
+
68
+
69
+ def register(subparsers):
70
+ p = subparsers.add_parser("evidence", help="Attach or verify evidence")
71
+ evidence_subparsers = p.add_subparsers(dest="evidence_command")
72
+
73
+ # add subcommand
74
+ p_add = evidence_subparsers.add_parser("add", help="Attach evidence to a memory")
75
+ p_add.add_argument("id", type=int)
76
+ p_add.add_argument("--kind", choices=sorted(EVIDENCE_KINDS), required=False, default=None)
77
+ p_add.add_argument("--description", default=None)
78
+ # dest="evidence_command_text": argparse shares one flat Namespace across
79
+ # nested subparsers, so a plain --command flag here would silently
80
+ # overwrite the top-level dest="command" used for dispatch (every
81
+ # parser's defaults get written into the same object, whether or not
82
+ # the flag was actually passed) -- caught by a real CLI invocation
83
+ # returning the top-level help instead of running, not by inspection.
84
+ p_add.add_argument(
85
+ "--command", dest="evidence_command_text", default=None
86
+ )
87
+ p_add.add_argument("--expected", default=None)
88
+ p_add.add_argument("--ref", default=None)
89
+ p_add.add_argument("--cold-storage-content", dest="cold_storage_content", default=None)
90
+ p_add.add_argument("--repo", default=None)
91
+ p_add.add_argument(
92
+ "--scope", choices=["repo", "global"], default=None,
93
+ help="Disambiguate a memory id that collides between the repo and global stores",
94
+ )
95
+ p_add.add_argument("--json", action="store_true")
96
+ p_add.set_defaults(func=run_add)
97
+
98
+ # verify subcommand
99
+ p_verify = evidence_subparsers.add_parser("verify", help="Re-verify one evidence row")
100
+ p_verify.add_argument("evidence_id", type=int)
101
+ p_verify.add_argument("--repo", default=None)
102
+ p_verify.add_argument(
103
+ "--scope", choices=["repo", "global"], default=None,
104
+ help="Disambiguate an evidence id that collides between the repo and global stores",
105
+ )
106
+ p_verify.add_argument("--json", action="store_true")
107
+ p_verify.set_defaults(func=run_verify)
108
+
109
+ # Default handler when no subcommand is given
110
+ p.set_defaults(func=run_no_subcommand)
@@ -0,0 +1,149 @@
1
+ """Command: roco hooks (install / uninstall / status / check)"""
2
+
3
+ import json
4
+ import sys
5
+
6
+ from robo_cortex.core.hooks import HOOK_FILENAME, POST_COMMIT_HOOK_FILENAME
7
+ from robo_cortex.core.hooks import install as core_install
8
+ from robo_cortex.core.hooks import status as core_status
9
+ from robo_cortex.core.hooks import uninstall as core_uninstall
10
+ from robo_cortex.core.invalidate import affected as core_affected
11
+ from robo_cortex.cli._common import _get_cmd_name, _store, cli_command
12
+
13
+
14
+ def _hook_name(args) -> str:
15
+ return POST_COMMIT_HOOK_FILENAME if args.post_commit else HOOK_FILENAME
16
+
17
+
18
+ @cli_command("hooks install")
19
+ def run_install(args) -> int:
20
+ hook_name = _hook_name(args)
21
+ with _store(args.repo) as (repo_root, _conn):
22
+ result = core_install(repo_root, force=args.force, hook_name=hook_name)
23
+
24
+ if args.json:
25
+ print(json.dumps(result))
26
+ else:
27
+ print(f"Installed {hook_name} hook at {result['path']}")
28
+ if result["chained"]:
29
+ print(f"Chained after your existing {hook_name} hook (it still runs first).")
30
+ if hook_name == POST_COMMIT_HOOK_FILENAME:
31
+ print("Informational only -- reports affected memories after each commit, never blocks.")
32
+ else:
33
+ print("Bypass any time with: git commit --no-verify")
34
+ return 0
35
+
36
+
37
+ @cli_command("hooks uninstall")
38
+ def run_uninstall(args) -> int:
39
+ hook_name = _hook_name(args)
40
+ with _store(args.repo) as (repo_root, _conn):
41
+ result = core_uninstall(repo_root, hook_name=hook_name)
42
+
43
+ if args.json:
44
+ print(json.dumps(result))
45
+ else:
46
+ if not result["uninstalled"]:
47
+ print(f"Nothing to remove: {result['reason']}")
48
+ elif result["deleted"]:
49
+ print(f"Removed {hook_name} hook at {result['path']}")
50
+ else:
51
+ print(f"Removed robo-cortex's block from {result['path']} (your own hook content remains)")
52
+ return 0
53
+
54
+
55
+ @cli_command("hooks status")
56
+ def run_status(args) -> int:
57
+ hook_name = _hook_name(args)
58
+ with _store(args.repo) as (repo_root, _conn):
59
+ result = core_status(repo_root, hook_name=hook_name)
60
+
61
+ if args.json:
62
+ print(json.dumps(result))
63
+ else:
64
+ if not result["installed"]:
65
+ reason = result.get("note", "not installed")
66
+ print(f"{hook_name} hook: {reason}")
67
+ else:
68
+ chained = " (chained after another hook)" if result["chained"] else ""
69
+ print(f"{hook_name} hook: installed at {result['path']}{chained}")
70
+ return 0
71
+
72
+
73
+ @cli_command("hooks check")
74
+ def run_check(args) -> int:
75
+ """Run by the installed pre-commit hook itself -- not usually invoked
76
+ by hand. Checks staged changes against `affected`; exits nonzero (and
77
+ prints a review block to stderr) only if something needs review.
78
+ """
79
+ with _store(args.repo) as (repo_root, conn):
80
+ result = core_affected(conn, repo_root, staged=True)
81
+
82
+ if not result["data"]:
83
+ return 0
84
+
85
+ print(
86
+ "robo-cortex: this commit touches code linked to memories that need review:",
87
+ file=sys.stderr,
88
+ )
89
+ for item in result["data"]:
90
+ print(f" [{item['id']}] {item['reason']} {item['statement']}", file=sys.stderr)
91
+ print("", file=sys.stderr)
92
+ print("Review with: roco show <id>", file=sys.stderr)
93
+ print(
94
+ "Then either re-verify (roco status <id> activate --reason \"...\") "
95
+ "or supersede it, and commit again.",
96
+ file=sys.stderr,
97
+ )
98
+ print("To bypass this check: git commit --no-verify", file=sys.stderr)
99
+ return 1
100
+
101
+
102
+ def register(subparsers):
103
+ p = subparsers.add_parser("hooks", help="Manage robo-cortex's git hooks (pre-commit, post-commit)")
104
+ hooks_subparsers = p.add_subparsers(dest="hooks_command")
105
+
106
+ p_install = hooks_subparsers.add_parser(
107
+ "install", help="Install the pre-commit hook (blocks commits touching unreviewed memories)"
108
+ )
109
+ p_install.add_argument("--repo", default=None)
110
+ p_install.add_argument(
111
+ "--force", action="store_true",
112
+ help="Chain after an existing non-robo-cortex hook instead of refusing to install",
113
+ )
114
+ p_install.add_argument(
115
+ "--post-commit", action="store_true",
116
+ help="Install the post-commit hook instead (informational report after each commit, never blocks)",
117
+ )
118
+ p_install.add_argument("--json", action="store_true")
119
+ p_install.set_defaults(func=run_install)
120
+
121
+ p_uninstall = hooks_subparsers.add_parser("uninstall", help="Remove robo-cortex's pre-commit hook")
122
+ p_uninstall.add_argument("--repo", default=None)
123
+ p_uninstall.add_argument(
124
+ "--post-commit", action="store_true",
125
+ help="Remove the post-commit hook instead",
126
+ )
127
+ p_uninstall.add_argument("--json", action="store_true")
128
+ p_uninstall.set_defaults(func=run_uninstall)
129
+
130
+ p_status = hooks_subparsers.add_parser("status", help="Show whether the pre-commit hook is installed")
131
+ p_status.add_argument("--repo", default=None)
132
+ p_status.add_argument(
133
+ "--post-commit", action="store_true",
134
+ help="Report on the post-commit hook instead",
135
+ )
136
+ p_status.add_argument("--json", action="store_true")
137
+ p_status.set_defaults(func=run_status)
138
+
139
+ p_check = hooks_subparsers.add_parser(
140
+ "check", help="Check staged changes against affected memories (used by the installed hook itself)"
141
+ )
142
+ p_check.add_argument("--repo", default=None)
143
+ p_check.set_defaults(func=run_check)
144
+
145
+ def _no_subcommand(args) -> int:
146
+ print("error: hooks requires a subcommand (install, uninstall, status, or check)", file=sys.stderr)
147
+ return 1
148
+
149
+ p.set_defaults(func=_no_subcommand)