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.
- robo_cortex/__init__.py +5 -0
- robo_cortex/cli/__init__.py +5 -0
- robo_cortex/cli/__main__.py +6 -0
- robo_cortex/cli/_common.py +72 -0
- robo_cortex/cli/_output.py +76 -0
- robo_cortex/cli/app.py +74 -0
- robo_cortex/cli/commands/__init__.py +36 -0
- robo_cortex/cli/commands/affected.py +40 -0
- robo_cortex/cli/commands/completion.py +94 -0
- robo_cortex/cli/commands/evidence.py +110 -0
- robo_cortex/cli/commands/hooks.py +149 -0
- robo_cortex/cli/commands/init.py +76 -0
- robo_cortex/cli/commands/link.py +47 -0
- robo_cortex/cli/commands/list_cmd.py +57 -0
- robo_cortex/cli/commands/mcp.py +37 -0
- robo_cortex/cli/commands/record.py +156 -0
- robo_cortex/cli/commands/retrieve.py +88 -0
- robo_cortex/cli/commands/search.py +48 -0
- robo_cortex/cli/commands/show.py +62 -0
- robo_cortex/cli/commands/status.py +83 -0
- robo_cortex/cli/commands/transfer.py +140 -0
- robo_cortex/core/__init__.py +0 -0
- robo_cortex/core/assumptions.py +39 -0
- robo_cortex/core/db.py +67 -0
- robo_cortex/core/errors.py +32 -0
- robo_cortex/core/evidence.py +166 -0
- robo_cortex/core/git.py +162 -0
- robo_cortex/core/hooks.py +214 -0
- robo_cortex/core/init.py +75 -0
- robo_cortex/core/invalidate.py +436 -0
- robo_cortex/core/lifecycle.py +165 -0
- robo_cortex/core/memory.py +394 -0
- robo_cortex/core/retrieve.py +429 -0
- robo_cortex/core/semantic.py +48 -0
- robo_cortex/core/store.py +72 -0
- robo_cortex/core/text.py +27 -0
- robo_cortex/core/transfer.py +246 -0
- robo_cortex/gitea.py +140 -0
- robo_cortex/mcp_server.py +263 -0
- robo_cortex/migrations/0001_init.sql +101 -0
- robo_cortex/migrations/0002_add_usage_tracking.sql +2 -0
- robo_cortex/migrations/0003_add_meta_table.sql +4 -0
- robo_cortex/sdk.py +196 -0
- robo_cortex-0.4.0.dist-info/METADATA +367 -0
- robo_cortex-0.4.0.dist-info/RECORD +48 -0
- robo_cortex-0.4.0.dist-info/WHEEL +4 -0
- robo_cortex-0.4.0.dist-info/entry_points.txt +3 -0
- robo_cortex-0.4.0.dist-info/licenses/LICENSE +21 -0
robo_cortex/__init__.py
ADDED
|
@@ -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)
|