riskrails 4.0.1 → 4.2.0
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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +22 -13
- package/bin/menu.js +116 -0
- package/bin/riskrails.js +119 -20
- package/hooks/AGENTS.md +0 -6
- package/hooks/CLAUDE.md +0 -6
- package/hooks/guard/dialog.py +14 -2
- package/hooks/guard/paths.py +48 -1406
- package/hooks/guard/paths_base.py +59 -0
- package/hooks/guard/paths_bash.py +176 -0
- package/hooks/guard/paths_classify.py +205 -0
- package/hooks/guard/paths_fs.py +137 -0
- package/hooks/guard/paths_human.py +105 -0
- package/hooks/guard/paths_interp.py +162 -0
- package/hooks/guard/paths_movers.py +393 -0
- package/hooks/guard/paths_shell.py +288 -0
- package/package.json +1 -1
- package/skills/AGENTS.md +0 -6
- package/skills/CLAUDE.md +0 -6
- package/skills/ai-init/scaffold-ai.sh +2 -1
- package/skills/ai-init/templates/.ai/policies/safety.md +4 -3
- package/skills/ai-init/templates/gitignore.snippet +2 -0
- package/skills/ai-task/memory_cleanup.py +213 -0
- package/skills/ai-task/memory_compact.py +25 -0
- package/skills/ai-task/memory_config.py +120 -0
- package/skills/ai-task/memory_export.py +246 -0
- package/skills/ai-task/memory_format.py +7 -3
- package/skills/ai-task/memory_hook.py +172 -0
- package/skills/ai-task/memory_io.py +122 -0
- package/skills/ai-task/memory_migrate.py +187 -0
- package/skills/ai-task/memory_packages.py +83 -0
- package/skills/ai-task/memory_risks.py +84 -0
- package/skills/ai-task/memory_status.py +76 -0
- package/skills/ai-task/memory_sync.py +20 -1481
- package/skills/ai-task/memory_verify.py +316 -0
- package/skills/ai-task/rr_state/cli.py +2 -1
- package/skills/ai-task/rr_state/close.py +2 -2
- package/skills/ai-task/rr_state/dialog_cmd.py +183 -6
- package/skills/ai-task/rr_state/labels.py +14 -2
- package/skills/ai-task/rr_state/memory_cmd.py +91 -68
- package/skills/project-update/history/blobs/8ec171f0fbdf38cc6996d496f01491fd1f21f04a435a118ad979b127a8be06f1 +85 -0
- package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
- package/skills/project-update/history/index.json +4 -2
- package/skills/project-update/rr_update/__init__.py +1 -0
- package/skills/project-update/rr_update/apply.py +188 -0
- package/skills/project-update/rr_update/blocks.py +38 -0
- package/skills/project-update/rr_update/build.py +96 -0
- package/skills/project-update/rr_update/cleanup.py +194 -0
- package/skills/project-update/rr_update/cli.py +102 -0
- package/skills/project-update/rr_update/files.py +106 -0
- package/skills/project-update/rr_update/gate.py +60 -0
- package/skills/project-update/rr_update/hints.py +128 -0
- package/skills/project-update/rr_update/history.py +117 -0
- package/skills/project-update/rr_update/kept.py +120 -0
- package/skills/project-update/rr_update/paths.py +108 -0
- package/skills/project-update/rr_update/plan.py +123 -0
- package/skills/project-update/rr_update/report.py +51 -0
- package/skills/project-update/rr_update/rules.py +178 -0
- package/skills/project-update/rr_update/shared.py +74 -0
- package/skills/project-update/rr_update/siblings.py +37 -0
- package/skills/project-update/rr_update/weights.py +171 -0
- package/skills/project-update/update.py +8 -1682
- package/skills/rr-setup/SKILL.md +79 -21
- package/skills/rr-setup/scaffold_ai.py +8 -2
- package/skills/rr-setup/setup.py +371 -4
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""The configuration of packages mode: the policy file's schema and defaults, MemoryConfig, loading and
|
|
2
|
+
checking .ai/policies/memory.json, and the mode a project runs in."""
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import memory_format as mf
|
|
8
|
+
import memory_io as mio
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SCHEMA = 1
|
|
12
|
+
DEFAULTS = {"schema": 1, "mode": "files", "period": "quarter", "package_max_bytes": 1048576, "cleanup": False,
|
|
13
|
+
"excerpt_max_bytes": 1024, "keep_rules": 1, "recall": {"budget_tokens": 1500, "max_items": 40}}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ------------------------------------------------------------------ configuration (§D11, §I6)
|
|
17
|
+
def author_slug(name):
|
|
18
|
+
slug = re.sub(r"[^a-z0-9-]+", "-", (name or "").casefold()).strip("-")[:32].strip("-")
|
|
19
|
+
return slug if slug and mf.AUTHOR_RE.match(slug) else "unknown"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MemoryConfig:
|
|
23
|
+
"""The project policy (.ai/policies/memory.json) and the developer's own
|
|
24
|
+
choices (.ai/local/memory/config.json)."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, root, policy, local, present):
|
|
27
|
+
self.root, self.policy, self.local, self.present = root, policy, local, present
|
|
28
|
+
|
|
29
|
+
def __getattr__(self, name):
|
|
30
|
+
if name in ("mode", "period", "package_max_bytes", "cleanup", "excerpt_max_bytes", "keep_rules"):
|
|
31
|
+
return self.policy.get(name, DEFAULTS[name])
|
|
32
|
+
raise AttributeError(name)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def recall_budget(self):
|
|
36
|
+
return int((self.policy.get("recall") or {}).get("budget_tokens", 1500))
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def recall_max_items(self):
|
|
40
|
+
return int((self.policy.get("recall") or {}).get("max_items", 40))
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def author(self):
|
|
44
|
+
return self.local.get("author") or author_slug(os.environ.get("USER") or os.environ.get("USERNAME"))
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def index(self):
|
|
48
|
+
return self.local.get("index") or "none"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def packages(self):
|
|
52
|
+
return self.mode == "packages"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_config(root):
|
|
56
|
+
path = os.path.join(root, mio.POLICY_REL)
|
|
57
|
+
present = os.path.exists(path)
|
|
58
|
+
policy = dict(DEFAULTS)
|
|
59
|
+
if present:
|
|
60
|
+
try:
|
|
61
|
+
loaded = mio.read_json(path)
|
|
62
|
+
except (OSError, ValueError) as exc:
|
|
63
|
+
raise mio.MemoryError_("%s is not valid JSON (%s): fix it, or 'state.py memory disable'"
|
|
64
|
+
% (mio.POLICY_REL, exc)) from exc
|
|
65
|
+
if not isinstance(loaded, dict):
|
|
66
|
+
raise mio.MemoryError_("%s must be a JSON object" % mio.POLICY_REL)
|
|
67
|
+
schema = loaded.get("schema", 1)
|
|
68
|
+
if isinstance(schema, int) and not isinstance(schema, bool) and schema > SCHEMA:
|
|
69
|
+
raise mio.MemoryError_("%s has schema %s; this state.py knows %d — update the plugin"
|
|
70
|
+
% (mio.POLICY_REL, schema, SCHEMA))
|
|
71
|
+
policy.update(loaded)
|
|
72
|
+
check_policy(policy)
|
|
73
|
+
local = {}
|
|
74
|
+
with contextlib.suppress(OSError, ValueError):
|
|
75
|
+
found = mio.read_json(os.path.join(root, mio.LOCAL_REL, "config.json"))
|
|
76
|
+
if isinstance(found, dict):
|
|
77
|
+
local = found
|
|
78
|
+
where = os.path.join(mio.LOCAL_REL, "config.json")
|
|
79
|
+
if local.get("author") is not None and not (isinstance(local["author"], str)
|
|
80
|
+
and mf.AUTHOR_RE.match(local["author"])):
|
|
81
|
+
raise mio.MemoryError_("%s: author %r is not a slug of [a-z0-9-]" % (where, local["author"]))
|
|
82
|
+
if local.get("index", "none") not in ("none", "sqlite"):
|
|
83
|
+
raise mio.MemoryError_("%s: index takes none or sqlite" % where)
|
|
84
|
+
return MemoryConfig(root, policy, local, present)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def check_policy(policy):
|
|
88
|
+
"""Every key of memory.json with its type (§I6): a wrong one is a refusal
|
|
89
|
+
naming the key, never a traceback inside close."""
|
|
90
|
+
def is_int(value, low):
|
|
91
|
+
return isinstance(value, int) and not isinstance(value, bool) and value >= low
|
|
92
|
+
problems = []
|
|
93
|
+
if not is_int(policy.get("schema"), 1):
|
|
94
|
+
problems.append("schema must be an integer (1)")
|
|
95
|
+
if policy.get("mode") not in ("files", "packages"):
|
|
96
|
+
problems.append('mode must be "files" or "packages"')
|
|
97
|
+
if policy.get("period") not in mf.PERIODS:
|
|
98
|
+
problems.append("period must be month, quarter or year")
|
|
99
|
+
if not is_int(policy.get("package_max_bytes"), 1024):
|
|
100
|
+
problems.append("package_max_bytes must be an integer of at least 1024")
|
|
101
|
+
if not isinstance(policy.get("cleanup"), bool):
|
|
102
|
+
problems.append("cleanup must be true or false")
|
|
103
|
+
if not is_int(policy.get("excerpt_max_bytes"), 0):
|
|
104
|
+
problems.append("excerpt_max_bytes must be an integer >= 0")
|
|
105
|
+
if policy.get("keep_rules") != 1:
|
|
106
|
+
problems.append("keep_rules must be 1")
|
|
107
|
+
recall = policy.get("recall")
|
|
108
|
+
if not (isinstance(recall, dict) and is_int(recall.get("budget_tokens", 1500), 1)
|
|
109
|
+
and is_int(recall.get("max_items", 40), 1)):
|
|
110
|
+
problems.append('recall must be {"budget_tokens": N, "max_items": N}')
|
|
111
|
+
if problems:
|
|
112
|
+
raise mio.MemoryError_("%s: %s" % (mio.POLICY_REL, "; ".join(problems)))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def mode_of(root):
|
|
116
|
+
"""'files' or 'packages' without raising: close must archive whatever happens."""
|
|
117
|
+
try:
|
|
118
|
+
return load_config(root).mode
|
|
119
|
+
except mio.MemoryError_:
|
|
120
|
+
return "files"
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Export at close (§D5): the outbox, the compaction of a task into a package, the export itself, the
|
|
2
|
+
lines it reports, the pending ids and the close hook."""
|
|
3
|
+
import collections
|
|
4
|
+
import contextlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import memory_cleanup as mclean
|
|
9
|
+
import memory_compact as mc
|
|
10
|
+
import memory_config as mcfg
|
|
11
|
+
import memory_format as mf
|
|
12
|
+
import memory_io as mio
|
|
13
|
+
import memory_packages as mpkg
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ------------------------------------------------------------------ export (§D5)
|
|
17
|
+
def outbox_path(root, key):
|
|
18
|
+
task, inst = key.rsplit("#", 1)
|
|
19
|
+
return mio.local_path(root, "outbox", "%s~%s.jsonl" % (task, inst))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def exportable(compactions):
|
|
23
|
+
return [c for c in compactions if c.seal and c.seal["result"] in ("closed", "abandoned", "restarted")]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
ExportResult = collections.namedtuple("ExportResult",
|
|
27
|
+
"package added sealed coverage failures conflicts already added_by_key")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def compact_for_export(root, task_id, cfg):
|
|
31
|
+
found = mc.compact_instances(mc.FsReader(root), task_id, "close", cfg.excerpt_max_bytes)
|
|
32
|
+
problems = [p for c in found for p in c.problems if "torn journal" not in p]
|
|
33
|
+
if problems:
|
|
34
|
+
raise mio.MemoryError_(problems[0], 3)
|
|
35
|
+
ready = exportable(found)
|
|
36
|
+
if not ready:
|
|
37
|
+
raise mio.MemoryError_("%s has no closed instance to export" % task_id, 3)
|
|
38
|
+
return ready
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def write_outbox(root, compaction):
|
|
42
|
+
key = mf.key_of(compaction.seal)
|
|
43
|
+
lines = [mf.canonical(e) for e in compaction.entries + [compaction.seal]]
|
|
44
|
+
mio.atomic_write(outbox_path(root, key), b"\n".join(lines) + b"\n")
|
|
45
|
+
return key
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_outbox(root):
|
|
49
|
+
base = mio.local_path(root, "outbox")
|
|
50
|
+
out = {}
|
|
51
|
+
for name in sorted(os.listdir(base)) if os.path.isdir(base) else []:
|
|
52
|
+
if not name.endswith(".jsonl"):
|
|
53
|
+
continue
|
|
54
|
+
entries = []
|
|
55
|
+
for raw in (mio.read_bytes(os.path.join(base, name)) or b"").split(b"\n"):
|
|
56
|
+
if raw.strip():
|
|
57
|
+
with contextlib.suppress(ValueError, UnicodeDecodeError):
|
|
58
|
+
entries.append(json.loads(raw.decode("utf-8")))
|
|
59
|
+
seals = [e for e in entries if e.get("kind") == "task"]
|
|
60
|
+
if seals:
|
|
61
|
+
out[mf.key_of(seals[-1])] = entries
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def export(root, ids, cfg=None, stamp=None):
|
|
66
|
+
"""B2–B4 for every id (or the pending ones); returns ExportResult. A
|
|
67
|
+
failure of one task keeps its outbox and is reported, never raised."""
|
|
68
|
+
cfg = cfg or mcfg.load_config(root)
|
|
69
|
+
if not cfg.packages:
|
|
70
|
+
raise mio.MemoryError_("files mode: nothing is exported ('state.py memory enable' turns packages on)")
|
|
71
|
+
failures, coverage, sealed, already, added_by_key = [], {}, [], [], {}
|
|
72
|
+
if ids == "pending":
|
|
73
|
+
ids = pending_ids(root, cfg)
|
|
74
|
+
ids = list(ids)
|
|
75
|
+
# A key with a seal in the tree is never sealed again from other sources: a
|
|
76
|
+
# second compaction of changed local sources (a restarted id, a cleaned-up
|
|
77
|
+
# directory) would add a degraded seal to an append-only record (R8, R9).
|
|
78
|
+
# The same seal is a repair: the rids it lacks come back, nothing else (V4, V5).
|
|
79
|
+
sealed_here = mpkg.seals_by_key(mpkg.load_packages(root))
|
|
80
|
+
for task_id in ids:
|
|
81
|
+
try:
|
|
82
|
+
for compaction in compact_for_export(root, task_id, cfg):
|
|
83
|
+
key = mf.key_of(compaction.seal)
|
|
84
|
+
coverage[key] = compaction.coverage
|
|
85
|
+
stored = {s["rid"] for _, s in sealed_here.get(key, [])}
|
|
86
|
+
if stored and compaction.seal["rid"] not in stored:
|
|
87
|
+
already.append(key)
|
|
88
|
+
continue
|
|
89
|
+
write_outbox(root, compaction)
|
|
90
|
+
except mio.MemoryError_ as exc:
|
|
91
|
+
failures.append((task_id, str(exc)))
|
|
92
|
+
except OSError as exc:
|
|
93
|
+
failures.append((task_id, "the outbox could not be written (%s)" % exc))
|
|
94
|
+
mio.crash("B2")
|
|
95
|
+
added, conflicts, name = 0, [], None
|
|
96
|
+
try:
|
|
97
|
+
with mio.locked(root):
|
|
98
|
+
outbox = read_outbox(root) # under the lock: another close may have taken it
|
|
99
|
+
packages = mpkg.load_packages(root)
|
|
100
|
+
have = mpkg.all_rids(packages)
|
|
101
|
+
seals = mpkg.seals_by_key(packages)
|
|
102
|
+
recorded = {e.get("key"): set(e.get("rids") or []) for _, entries, _ in packages.values()
|
|
103
|
+
for e in entries if e.get("kind") == "conflict"}
|
|
104
|
+
new, keep = [], {}
|
|
105
|
+
for key, entries in outbox.items():
|
|
106
|
+
seal_rid = [e for e in entries if e.get("kind") == "task"][-1]["rid"]
|
|
107
|
+
stored = {s["rid"] for _, s in seals.get(key, [])}
|
|
108
|
+
missing = [e for e in entries if e["rid"] not in have]
|
|
109
|
+
if (stored and seal_rid not in stored) or not missing:
|
|
110
|
+
# Sealed meanwhile from other sources, or nothing lacking (a re-run, B4).
|
|
111
|
+
os.remove(outbox_path(root, key))
|
|
112
|
+
if key not in already:
|
|
113
|
+
already.append(key)
|
|
114
|
+
continue
|
|
115
|
+
new += missing
|
|
116
|
+
added_by_key[key] = len(missing)
|
|
117
|
+
keep[key] = entries
|
|
118
|
+
for key in coverage:
|
|
119
|
+
if key in seals and key not in keep and key not in already:
|
|
120
|
+
already.append(key) # another close took this outbox and sealed it (review L3)
|
|
121
|
+
for key, found in sorted(seals.items()):
|
|
122
|
+
# Two seals that arrived by a merge (one id closed in two clones):
|
|
123
|
+
# recorded side by side, never merged (R9).
|
|
124
|
+
rids = sorted({s["rid"] for _, s in found} - {s.get("supersedes") for _, s in found})
|
|
125
|
+
if len(rids) < 2 or key.rsplit("#", 1)[0] not in ids or set(rids) <= recorded.get(key, set()):
|
|
126
|
+
continue
|
|
127
|
+
seal = found[-1][1]
|
|
128
|
+
conflict = mf.with_rid({
|
|
129
|
+
"v": mf.FORMAT, "task": seal["task"], "inst": seal["inst"], "seq": seal["seq"] + 1,
|
|
130
|
+
"kind": "conflict", "ts": max(s["ts"] for _, s in found), "actor": "agent", "by": None,
|
|
131
|
+
"runtime": seal["runtime"], "src": ["export"], "key": key, "rids": rids,
|
|
132
|
+
"reason": "two different seals for one key — kept side by side, never merged"})
|
|
133
|
+
if conflict["rid"] not in have:
|
|
134
|
+
new.append(conflict)
|
|
135
|
+
conflicts.append(key)
|
|
136
|
+
if not new:
|
|
137
|
+
return ExportResult(None, 0, sealed, coverage, failures, conflicts, already, added_by_key)
|
|
138
|
+
name, branch, books = mpkg.open_batch(root, cfg, stamp)
|
|
139
|
+
path = os.path.join(mio.memory_dir(root), name)
|
|
140
|
+
header, current, _ = packages.get(name, (None, [], []))
|
|
141
|
+
parts = mf.parse_package_name(name)
|
|
142
|
+
header = header or mf.package_header(parts["period"], cfg.author, parts["batch"], stamp or mio.now())
|
|
143
|
+
current = [mf.strip_private(e) for e in current]
|
|
144
|
+
outbox = keep
|
|
145
|
+
if new:
|
|
146
|
+
data = mf.render_package(header, current + new)
|
|
147
|
+
mio.crash("B3")
|
|
148
|
+
mio.atomic_write(path, data)
|
|
149
|
+
added = len(new)
|
|
150
|
+
books["open"][branch] = name
|
|
151
|
+
mpkg.write_batches(root, books)
|
|
152
|
+
mio.crash("B4")
|
|
153
|
+
check = mpkg.load_packages(root)
|
|
154
|
+
present = mpkg.all_rids(check)
|
|
155
|
+
for key, entries in outbox.items():
|
|
156
|
+
seal = [e for e in entries if e.get("kind") == "task"][-1]
|
|
157
|
+
if all(e["rid"] in present for e in entries):
|
|
158
|
+
os.remove(outbox_path(root, key))
|
|
159
|
+
sealed.append(key)
|
|
160
|
+
else:
|
|
161
|
+
failures.append((seal["task"], "read-back found %d of %d entries" % (
|
|
162
|
+
sum(e["rid"] in present for e in entries), len(entries))))
|
|
163
|
+
except OSError as exc:
|
|
164
|
+
failures.append((",".join(sorted(coverage)) or "the outbox",
|
|
165
|
+
"%s (%s)" % (exc.strerror or exc, getattr(exc, "filename", ""))))
|
|
166
|
+
if added and cfg.index == "sqlite":
|
|
167
|
+
mio.crash("B5")
|
|
168
|
+
index_import(root, cfg)
|
|
169
|
+
return ExportResult(name, added, sealed, coverage, failures, conflicts, already, added_by_key)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def record_lines(result):
|
|
173
|
+
"""What close and `memory export` print: each key newly sealed with what it
|
|
174
|
+
added, each key that was already sealed, each conflict recorded (review L5)."""
|
|
175
|
+
out = []
|
|
176
|
+
for key in result.sealed:
|
|
177
|
+
cov = result.coverage.get(key) or {}
|
|
178
|
+
out.append("record: %s (+%d entries, %s sealed, coverage %s/%s lines)" % (
|
|
179
|
+
os.path.join(mio.MEMORY_REL, result.package or "-"), result.added_by_key.get(key, 0),
|
|
180
|
+
key.rsplit("#", 1)[0], cov.get("accounted", "?"), cov.get("lines", "?")))
|
|
181
|
+
for key in result.already:
|
|
182
|
+
out.append("record: %s already sealed in %s — nothing added" % (key, mio.MEMORY_REL))
|
|
183
|
+
for key in result.conflicts:
|
|
184
|
+
out.append("conflict: %s has two different seals — recorded side by side, never merged "
|
|
185
|
+
"('state.py memory show %s')" % (key, key.rsplit("#", 1)[0]))
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def index_import(root, cfg):
|
|
190
|
+
"""B5: the index learns the new package; any failure marks it stale."""
|
|
191
|
+
try:
|
|
192
|
+
import memory_store # pylint: disable=import-outside-toplevel
|
|
193
|
+
memory_store.SqliteHistory(root, cfg).refresh()
|
|
194
|
+
except Exception: # pylint: disable=broad-except # a stale index is rebuilt by the next read
|
|
195
|
+
with contextlib.suppress(Exception):
|
|
196
|
+
import memory_store # pylint: disable=import-outside-toplevel
|
|
197
|
+
memory_store.mark_stale(root)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def pending_ids(root, cfg=None):
|
|
201
|
+
"""Closed new-shape tasks (and anything in the outbox) without a seal:
|
|
202
|
+
B1 left them closed but not exported. Legacy ids wait for the migration."""
|
|
203
|
+
sealed = mpkg.sealed_task_ids(mpkg.load_packages(root))
|
|
204
|
+
found = set(k.rsplit("#", 1)[0] for k in read_outbox(root))
|
|
205
|
+
logs = os.path.join(root, ".ai", "state", "logs")
|
|
206
|
+
for name in os.listdir(logs) if os.path.isdir(logs) else []:
|
|
207
|
+
if not mf.NEW_ID_RE.match(name) or name in sealed:
|
|
208
|
+
continue
|
|
209
|
+
with contextlib.suppress(OSError, ValueError):
|
|
210
|
+
state = mio.read_json(os.path.join(logs, name, "state.json"))
|
|
211
|
+
if state.get("result") in ("closed", "abandoned"):
|
|
212
|
+
found.add(name)
|
|
213
|
+
del cfg
|
|
214
|
+
return sorted(found - sealed)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def after_close(root, task_id, out=print):
|
|
218
|
+
"""The one call close makes after archive_v7. Packages mode only; every
|
|
219
|
+
failure is one printed line and close still exits 0 (B4)."""
|
|
220
|
+
try:
|
|
221
|
+
cfg = mcfg.load_config(root)
|
|
222
|
+
except mio.MemoryError_ as exc:
|
|
223
|
+
out("memory: %s — the task is closed in files mode" % exc)
|
|
224
|
+
return None
|
|
225
|
+
if not cfg.packages:
|
|
226
|
+
return None
|
|
227
|
+
try:
|
|
228
|
+
pending = [t for t in pending_ids(root, cfg) if t != task_id][:mio.PENDING_MAX]
|
|
229
|
+
result = export(root, [task_id] + pending, cfg)
|
|
230
|
+
except (mio.MemoryError_, ValueError, OSError) as exc:
|
|
231
|
+
out("memory: export of %s failed (%s) — the task is closed; 'state.py memory export %s' retries"
|
|
232
|
+
% (task_id, exc, task_id))
|
|
233
|
+
return None
|
|
234
|
+
for failed, why in result.failures:
|
|
235
|
+
out("memory: export of %s failed (%s) — the task is closed; 'state.py memory export %s' retries"
|
|
236
|
+
% (failed, why, failed))
|
|
237
|
+
for line in record_lines(result):
|
|
238
|
+
out(line)
|
|
239
|
+
if cfg.cleanup and any(k.rsplit("#", 1)[0] == task_id for k in result.sealed + result.already):
|
|
240
|
+
mio.crash("B6")
|
|
241
|
+
try:
|
|
242
|
+
done = mclean.cleanup_task(root, task_id, cfg)
|
|
243
|
+
out("cleanup: %s" % done)
|
|
244
|
+
except mio.MemoryError_ as exc:
|
|
245
|
+
out("cleanup: %s kept in .ai/reports/ (%s)" % (task_id, exc))
|
|
246
|
+
return result
|
|
@@ -275,7 +275,9 @@ def sort_entries(entries):
|
|
|
275
275
|
|
|
276
276
|
def parse_package(data, name=None):
|
|
277
277
|
"""(header, entries, errors): errors are (line, what) pairs; a line that
|
|
278
|
-
fails is left out of entries, so a reader still sees the rest.
|
|
278
|
+
fails is left out of entries, so a reader still sees the rest. A line over
|
|
279
|
+
LINE_MAX_BYTES is an error, and a valid entry on it is still kept, with its
|
|
280
|
+
byte length in `_over` (GitHub #74)."""
|
|
279
281
|
errors, entries, header = [], [], None
|
|
280
282
|
if isinstance(data, str):
|
|
281
283
|
data = data.encode("utf-8")
|
|
@@ -293,7 +295,6 @@ def parse_package(data, name=None):
|
|
|
293
295
|
raw = raw[:-1]
|
|
294
296
|
if len(raw) > LINE_MAX_BYTES:
|
|
295
297
|
errors.append((number, "line is %d bytes, over %d" % (len(raw), LINE_MAX_BYTES)))
|
|
296
|
-
continue
|
|
297
298
|
try:
|
|
298
299
|
obj = json.loads(raw.decode("utf-8"))
|
|
299
300
|
except (UnicodeDecodeError, ValueError) as exc:
|
|
@@ -318,6 +319,8 @@ def parse_package(data, name=None):
|
|
|
318
319
|
errors.append((number, "; ".join(problems)))
|
|
319
320
|
continue
|
|
320
321
|
obj["_line"] = number
|
|
322
|
+
if len(raw) > LINE_MAX_BYTES:
|
|
323
|
+
obj["_over"] = len(raw)
|
|
321
324
|
entries.append(obj)
|
|
322
325
|
if header is None and not errors:
|
|
323
326
|
errors.append((1, "empty package"))
|
|
@@ -688,7 +691,8 @@ def render_task_md(entries):
|
|
|
688
691
|
facts = seal.get("facts") or {}
|
|
689
692
|
if facts:
|
|
690
693
|
out += ["facts: (a) %s · (b) %s" % tuple(_fact(facts.get(k)) for k in ("a", "b")), ""]
|
|
691
|
-
files = seal.get("changes")
|
|
694
|
+
files = seal.get("changes")
|
|
695
|
+
files = files if isinstance(files, list) else []
|
|
692
696
|
out += ["## Files", ""]
|
|
693
697
|
if files:
|
|
694
698
|
out += ["- %s (%s, %s)" % (c.get("path"), "binary" if c.get("binary") else "+%s −%s" % (
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""The git hook (R17, §D10): the installed shim and where it points, the policy file, and the enable and
|
|
2
|
+
disable commands that write and remove them."""
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import memory_config as mcfg
|
|
8
|
+
import memory_format as mf
|
|
9
|
+
import memory_io as mio
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ------------------------------------------------------------------ status, show
|
|
13
|
+
def hook_state(root):
|
|
14
|
+
code, out, _ = mio.git(root, "config", "--get", "core.hooksPath")
|
|
15
|
+
if code == 0 and out.strip():
|
|
16
|
+
return "hookspath"
|
|
17
|
+
path = hooks_path(root)
|
|
18
|
+
if not path:
|
|
19
|
+
return "absent"
|
|
20
|
+
hook = os.path.join(path, "pre-push")
|
|
21
|
+
text = mio.read_bytes(hook)
|
|
22
|
+
if text is None:
|
|
23
|
+
return "absent"
|
|
24
|
+
if mio.HOOK_MARKER.encode() in text:
|
|
25
|
+
decoded = text.decode("utf-8", "replace")
|
|
26
|
+
if not any(os.path.isfile(p) for p in shim_paths(decoded)):
|
|
27
|
+
return "installed, validator missing — pushes are NOT checked ('state.py memory enable' rewrites it)"
|
|
28
|
+
return "current" if decoded == shim_text() else "installed (stale)"
|
|
29
|
+
return "foreign"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ------------------------------------------------------------------ the git hook (R17, §D10)
|
|
33
|
+
def hooks_path(root):
|
|
34
|
+
code, out, _ = mio.git(root, "rev-parse", "--git-path", "hooks")
|
|
35
|
+
if code != 0 or not out.strip():
|
|
36
|
+
return None
|
|
37
|
+
path = out.strip()
|
|
38
|
+
return path if os.path.isabs(path) else os.path.join(root, path)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def shim_candidates(validator=None):
|
|
42
|
+
"""Where the shim looks for the validator, in order: the hook beside this
|
|
43
|
+
module, the hook in either runtime home, then this module itself — a Codex
|
|
44
|
+
install copies skills/ai-task/ but not every hook."""
|
|
45
|
+
return [validator or mio.VALIDATOR, "$HOME/.claude/hooks/memory-pre-push.py",
|
|
46
|
+
"${CODEX_HOME:-$HOME/.codex}/hooks/memory-pre-push.py", mio.SELF,
|
|
47
|
+
"$HOME/.claude/skills/ai-task/memory_sync.py",
|
|
48
|
+
"${CODEX_HOME:-$HOME/.codex}/skills/ai-task/memory_sync.py"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def shim_text(validator=None):
|
|
52
|
+
return ("#!/bin/sh\n%s — written by 'state.py memory enable'; 'state.py memory disable' removes it\n"
|
|
53
|
+
"for v in %s; do\n"
|
|
54
|
+
" [ -f \"$v\" ] && exec python3 -I \"$v\" \"$@\"\n"
|
|
55
|
+
"done\n"
|
|
56
|
+
"echo \"riskrails memory: validator not found — this push is not checked\" >&2; exit 0\n"
|
|
57
|
+
% (mio.HOOK_MARKER, " ".join('"%s"' % c for c in shim_candidates(validator))))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def shim_paths(text):
|
|
61
|
+
"""The shim's candidates as paths on this machine."""
|
|
62
|
+
match = re.search(r"^for v in (.+); do$", text, re.M)
|
|
63
|
+
home = os.path.expanduser("~")
|
|
64
|
+
codex = os.environ.get("CODEX_HOME") or os.path.join(home, ".codex")
|
|
65
|
+
return [c.replace("${CODEX_HOME:-$HOME/.codex}", codex).replace("$HOME", home)
|
|
66
|
+
for c in re.findall(r'"([^"]+)"', match.group(1) if match else "")]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def snippet_text(validator=None):
|
|
70
|
+
validator = validator or mio.VALIDATOR
|
|
71
|
+
return ("# riskrails memory: add to your own pre-push hook (stdin can be read only once)\n"
|
|
72
|
+
"rr_in=$(mktemp) && cat > \"$rr_in\"\n"
|
|
73
|
+
"python3 -I \"%s\" \"$@\" < \"$rr_in\" || { rr=$?; rm -f \"$rr_in\"; exit $rr; }\n"
|
|
74
|
+
"# … then your hook, reading \"$rr_in\" where it read stdin …\n"
|
|
75
|
+
"rm -f \"$rr_in\"\n" % validator)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def install_hook(root):
|
|
79
|
+
"""installed | current | foreign | hookspath (R17)."""
|
|
80
|
+
code, out, _ = mio.git(root, "config", "--get", "core.hooksPath")
|
|
81
|
+
if code == 0 and out.strip():
|
|
82
|
+
return "hookspath"
|
|
83
|
+
path = hooks_path(root)
|
|
84
|
+
if not path:
|
|
85
|
+
raise mio.MemoryError_("not a git repository: no hooks path", 2)
|
|
86
|
+
hook = os.path.join(path, "pre-push")
|
|
87
|
+
text = mio.read_bytes(hook)
|
|
88
|
+
if text is not None and mio.HOOK_MARKER.encode() not in text:
|
|
89
|
+
return "foreign"
|
|
90
|
+
if text is not None and text.decode("utf-8", "replace") == shim_text():
|
|
91
|
+
return "current"
|
|
92
|
+
os.makedirs(path, exist_ok=True)
|
|
93
|
+
mio.atomic_write(hook, shim_text())
|
|
94
|
+
os.chmod(hook, 0o755)
|
|
95
|
+
return "installed"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def remove_hook(root):
|
|
99
|
+
path = hooks_path(root)
|
|
100
|
+
hook = os.path.join(path, "pre-push") if path else None
|
|
101
|
+
text = mio.read_bytes(hook) if hook else None
|
|
102
|
+
if text is not None and mio.HOOK_MARKER.encode() in text:
|
|
103
|
+
os.remove(hook)
|
|
104
|
+
return "removed"
|
|
105
|
+
return "foreign" if text is not None else "absent"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def write_policy(root, policy):
|
|
109
|
+
mio.atomic_write(os.path.join(root, mio.POLICY_REL), json.dumps(policy, indent=2, ensure_ascii=False) + "\n")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
GITIGNORE_LOCAL = (".ai/memory/", ".ai/reports/")
|
|
113
|
+
GITIGNORE_SHARED = ("!.ai/memory/", "!.ai/reports/")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def append_gitignore(root, lines):
|
|
117
|
+
"""Append `lines` to .gitignore (created when absent), after a newline if the file lacks one; never rewrites."""
|
|
118
|
+
path = os.path.join(root, ".gitignore")
|
|
119
|
+
current = mio.read_bytes(path) or b""
|
|
120
|
+
sep = b"\n" if current and not current.endswith(b"\n") else b""
|
|
121
|
+
with open(path, "ab") as fh:
|
|
122
|
+
fh.write(sep + "".join(line + "\n" for line in lines).encode())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def apply_share(root, share):
|
|
126
|
+
"""--share git: the negations (the record is shared through git); --share local: the ignore lines. Append only."""
|
|
127
|
+
if share == "git":
|
|
128
|
+
have = (mio.read_bytes(os.path.join(root, ".gitignore")) or b"").decode("utf-8", "replace").splitlines()
|
|
129
|
+
add = [line for line in GITIGNORE_SHARED if line not in have]
|
|
130
|
+
if not add and not mio.shared(root):
|
|
131
|
+
add = list(GITIGNORE_SHARED)
|
|
132
|
+
if add:
|
|
133
|
+
append_gitignore(root, add)
|
|
134
|
+
elif share == "local" and mio.shared(root):
|
|
135
|
+
append_gitignore(root, GITIGNORE_LOCAL)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def enable(root, author=None, period=None, index=None, share=None):
|
|
139
|
+
"""Packages mode on. The hook is installed only while the record is shared (.ai/memory/ not git-ignored);
|
|
140
|
+
a local record has no hook of ours and the state is reported as `local`."""
|
|
141
|
+
cfg = mcfg.load_config(root)
|
|
142
|
+
policy = dict(mcfg.DEFAULTS)
|
|
143
|
+
policy.update(cfg.policy if cfg.present else {})
|
|
144
|
+
policy["mode"] = "packages"
|
|
145
|
+
if period:
|
|
146
|
+
if period not in mf.PERIODS:
|
|
147
|
+
raise mio.MemoryError_("--period takes one of %s" % ", ".join(mf.PERIODS))
|
|
148
|
+
policy["period"] = period
|
|
149
|
+
write_policy(root, policy)
|
|
150
|
+
local = dict(cfg.local)
|
|
151
|
+
local["schema"] = 1
|
|
152
|
+
local["author"] = mcfg.author_slug(author) if author else cfg.author
|
|
153
|
+
if index:
|
|
154
|
+
if index not in ("none", "sqlite"):
|
|
155
|
+
raise mio.MemoryError_("--index takes none or sqlite")
|
|
156
|
+
local["index"] = index
|
|
157
|
+
local.setdefault("index", "none")
|
|
158
|
+
mio.atomic_write(mio.local_path(root, "config.json"), json.dumps(local, sort_keys=True) + "\n")
|
|
159
|
+
if share:
|
|
160
|
+
apply_share(root, share)
|
|
161
|
+
if not mio.shared(root):
|
|
162
|
+
remove_hook(root)
|
|
163
|
+
return "local"
|
|
164
|
+
return install_hook(root)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def disable(root):
|
|
168
|
+
cfg = mcfg.load_config(root)
|
|
169
|
+
policy = dict(cfg.policy)
|
|
170
|
+
policy["mode"] = "files"
|
|
171
|
+
write_policy(root, policy)
|
|
172
|
+
return remove_hook(root)
|