riskrails 4.1.0 → 4.3.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 +25 -15
- package/agents/ai-reviewer.md.tmpl +14 -1
- package/ai/agents/implementer.md +6 -0
- package/ai/agents/reviewer.md +8 -1
- package/bin/ansi.js +12 -0
- package/bin/menu.js +71 -12
- package/bin/riskrails.js +240 -14
- package/bin/uninstall.js +115 -0
- package/bin/wizard.js +72 -0
- package/hooks/AGENTS.md +0 -6
- package/hooks/CLAUDE.md +0 -6
- package/hooks/context-guard.py +11 -1
- package/hooks/dialog-gate.py +3 -2
- package/hooks/guard/decision.py +4 -3
- package/hooks/guard/dialog.py +12 -2
- package/hooks/guard/ere.py +29 -10
- package/hooks/guard/git.py +2 -1
- package/hooks/guard/paths.py +48 -1406
- package/hooks/guard/paths_base.py +66 -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/hooks/runtime-gate.py +87 -0
- package/hooks/update_notice.py +23 -8
- package/package.json +1 -1
- package/skills/AGENTS.md +0 -6
- package/skills/CLAUDE.md +0 -6
- package/skills/ai-init/templates/.ai/policies/coding.md +9 -0
- 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 +1 -0
- package/skills/ai-task/rr_state/close.py +2 -2
- package/skills/ai-task/rr_state/dialog.py +32 -7
- package/skills/ai-task/rr_state/dialog_cmd.py +38 -5
- package/skills/ai-task/rr_state/gate.py +10 -4
- package/skills/ai-task/rr_state/labels.py +33 -4
- package/skills/ai-task/rr_state/memory_cmd.py +84 -64
- package/skills/ai-task/rr_state/questions_cmd.py +5 -4
- package/skills/ai-task/rr_state/report.py +73 -10
- package/skills/ai-task/rr_state/report_cmd.py +2 -0
- package/skills/ai-task/rr_state/review.py +18 -14
- package/skills/ai-task/rr_state/start.py +12 -9
- package/skills/ai-task/rr_state/steps.py +16 -7
- package/skills/ai-task/rr_state/verify.py +17 -12
- package/skills/project-update/history/blobs/dac90781793b8a1c8e5f3e0307665b6394d922140c3831c5508d019f79e1ed0a +41 -0
- package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
- package/skills/project-update/history/index.json +8 -4
- 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-build/SKILL.md +5 -2
- package/skills/rr-setup/SKILL.md +4 -2
- package/skills/rr-setup/setup.py +13 -12
- package/skills/rr-ship/SKILL.md +2 -2
- package/skills/rr-spec/SKILL.md +6 -0
- package/skills/rr-status/SKILL.md +2 -0
- package/skills/rr-status/status.py +5 -3
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Verification (§D6, §D10): the findings of the package and journal checks, verify and its fresh-clone form,
|
|
2
|
+
the git tree reader and the range check the pre-push validator runs."""
|
|
3
|
+
import collections
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import memory_compact as mc
|
|
12
|
+
import memory_config as mcfg
|
|
13
|
+
import memory_format as mf
|
|
14
|
+
import memory_io as mio
|
|
15
|
+
import memory_packages as mpkg
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ------------------------------------------------------------------ verify (§D6)
|
|
19
|
+
Finding = collections.namedtuple("Finding", "check path line what fix")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def kept_lines(events, schema=None):
|
|
23
|
+
"""The journal lines V5 requires to be the src of an entry."""
|
|
24
|
+
out = []
|
|
25
|
+
for number, event in events:
|
|
26
|
+
if not event:
|
|
27
|
+
continue
|
|
28
|
+
if event.get("event") == "task_started":
|
|
29
|
+
schema = (event.get("data") or {}).get("schema", schema)
|
|
30
|
+
rule = mf.classify(event.get("event"), schema)
|
|
31
|
+
if rule not in ("seal", "summary", "drop", "verify"):
|
|
32
|
+
out.append(number)
|
|
33
|
+
return out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def check_packages(packages, max_bytes, sizes=None, changed=None, ref_name=None):
|
|
37
|
+
"""V1, V3, V4, V8 over one tree's packages."""
|
|
38
|
+
findings = []
|
|
39
|
+
sizes = sizes or {}
|
|
40
|
+
for name, (_, kept, errors) in sorted(packages.items()):
|
|
41
|
+
if changed is not None and name not in changed:
|
|
42
|
+
continue
|
|
43
|
+
over = {e["_line"]: e for e in kept if "_over" in e}
|
|
44
|
+
for line, what in errors:
|
|
45
|
+
fix = ("restore the line from git ('git checkout -- %s') or 'state.py memory verify'"
|
|
46
|
+
% os.path.join(mio.MEMORY_REL, name))
|
|
47
|
+
if line in over and what.startswith("line is "):
|
|
48
|
+
entry = over[line]
|
|
49
|
+
sizes_of = {k: len(mf.canonical(v)) for k, v in entry.items()
|
|
50
|
+
if k not in mf.ENVELOPE and not k.startswith("_")}
|
|
51
|
+
field = max(sizes_of, key=sizes_of.get, default="changes")
|
|
52
|
+
fix = ("shorten it: 'state.py memory redact %s --field %s --why \"…\"' (the human's route)"
|
|
53
|
+
% (entry["rid"], field))
|
|
54
|
+
findings.append(Finding("V1", os.path.join(mio.MEMORY_REL, name), line, what, fix))
|
|
55
|
+
by_key = collections.defaultdict(list)
|
|
56
|
+
conflicts = set()
|
|
57
|
+
redacted = {}
|
|
58
|
+
for name, (_, entries, _) in packages.items():
|
|
59
|
+
for e in entries:
|
|
60
|
+
by_key[mf.key_of(e)].append((name, e))
|
|
61
|
+
if e.get("kind") == "conflict":
|
|
62
|
+
conflicts.add((e.get("key"), tuple(e.get("rids") or [])))
|
|
63
|
+
if e.get("kind") == "redaction" and e.get("replacement"):
|
|
64
|
+
redacted[e["replacement"]] = e.get("target")
|
|
65
|
+
for key, items in sorted(by_key.items()):
|
|
66
|
+
seals = [(n, e) for n, e in items if e.get("kind") == "task"]
|
|
67
|
+
unique = {e["rid"]: (n, e) for n, e in seals}
|
|
68
|
+
chained = {e.get("supersedes") for _, e in unique.values() if e.get("supersedes")}
|
|
69
|
+
live = [rid for rid in unique if rid not in chained]
|
|
70
|
+
if len(live) > 1:
|
|
71
|
+
task = key.rsplit("#", 1)[0]
|
|
72
|
+
recorded = any(k == key and set(live) <= set(r) for k, r in conflicts)
|
|
73
|
+
if mf.NEW_ID_RE.match(task) and not recorded:
|
|
74
|
+
findings.append(Finding("V3", os.path.join(mio.MEMORY_REL, unique[live[0]][0]), None,
|
|
75
|
+
"%s has %d seals not chained by supersedes" % (key, len(live)),
|
|
76
|
+
"'state.py memory export %s' records the two as a conflict" % task))
|
|
77
|
+
elif not recorded:
|
|
78
|
+
findings.append(Finding("V3", os.path.join(mio.MEMORY_REL, unique[live[0]][0]), None,
|
|
79
|
+
"legacy %s has %d seals and no conflict entry" % (key, len(live)),
|
|
80
|
+
"'state.py memory export %s' records the conflict" % task))
|
|
81
|
+
rids = {e["rid"] for _, e in items if e.get("kind") not in ("task", "conflict", "redaction")}
|
|
82
|
+
if len(live) > 1:
|
|
83
|
+
continue # a recorded conflict: which entries are whose is not knowable
|
|
84
|
+
for rid in live:
|
|
85
|
+
name, seal = unique[rid]
|
|
86
|
+
own = sorted({redacted.get(r, r) for r in rids if _belongs(items, r, seal)})
|
|
87
|
+
if len(own) != seal.get("entries") or mf.seal_digest(own) != seal.get("digest"):
|
|
88
|
+
findings.append(Finding("V4", os.path.join(mio.MEMORY_REL, name), seal.get("_line"),
|
|
89
|
+
"%s: the seal names %s entries (%s), the package has %d" % (
|
|
90
|
+
key, seal.get("entries"), (seal.get("digest") or "")[:19], len(own)),
|
|
91
|
+
"'state.py memory export %s' re-exports it" % seal.get("task")))
|
|
92
|
+
for name, size in sorted(sizes.items()):
|
|
93
|
+
if size > 2 * max_bytes:
|
|
94
|
+
findings.append(Finding("V8", os.path.join(mio.MEMORY_REL, name), None,
|
|
95
|
+
"%d bytes, over twice package_max_bytes (%d)" % (size, max_bytes),
|
|
96
|
+
"a new batch opens on the next close; 'state.py memory pack' folds old ones"))
|
|
97
|
+
elif size > max_bytes:
|
|
98
|
+
findings.append(Finding("V8-warn", os.path.join(mio.MEMORY_REL, name), None,
|
|
99
|
+
"%d bytes, over package_max_bytes (%d)" % (size, max_bytes),
|
|
100
|
+
"nothing to do now; the next close opens a new batch"))
|
|
101
|
+
del ref_name
|
|
102
|
+
return findings
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _belongs(items, rid, seal):
|
|
106
|
+
"""Entries of a key that came before its seal (seq below the seal's)."""
|
|
107
|
+
for _, e in items:
|
|
108
|
+
if e["rid"] == rid:
|
|
109
|
+
return (e.get("seq") or 0) < (seal.get("seq") or 0)
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def check_journals(read, paths, packages, cleanup_on=False):
|
|
114
|
+
"""V5 (and V7 with cleanup) against the committed journals."""
|
|
115
|
+
findings = []
|
|
116
|
+
seals = mpkg.seals_by_key(packages)
|
|
117
|
+
sealed_tasks = {k.rsplit("#", 1)[0] for k in seals}
|
|
118
|
+
srcs = collections.defaultdict(set)
|
|
119
|
+
for _, entries, _ in packages.values():
|
|
120
|
+
for e in entries:
|
|
121
|
+
for s in e.get("src") or []:
|
|
122
|
+
srcs[mf.key_of(e)].add(s)
|
|
123
|
+
journals = sorted(p for p in paths if re.match(r"^\.ai/reports/[^/]+/events\.jsonl$", p))
|
|
124
|
+
for path in journals:
|
|
125
|
+
task = path.split("/")[2]
|
|
126
|
+
data = read(path) or b""
|
|
127
|
+
if b'"task_closed"' not in data:
|
|
128
|
+
continue
|
|
129
|
+
if not (mf.NEW_ID_RE.match(task) or task in sealed_tasks):
|
|
130
|
+
continue # an unmigrated legacy task: 'memory status' lists it
|
|
131
|
+
events = mc.parse_journal(data)
|
|
132
|
+
instances = mc.split_instances(events)
|
|
133
|
+
for inst, lines in enumerate(instances, 1):
|
|
134
|
+
key = "%s#%d" % (task, inst)
|
|
135
|
+
closed = any(e and e.get("event") == "task_closed" for _, e in lines)
|
|
136
|
+
if not closed and inst == len(instances):
|
|
137
|
+
continue
|
|
138
|
+
if key not in seals:
|
|
139
|
+
findings.append(Finding("V5", path, None, "%s is closed but has no seal" % key,
|
|
140
|
+
"state.py memory export %s" % task))
|
|
141
|
+
continue
|
|
142
|
+
missing = [n for n in kept_lines(lines) if "events.jsonl:%d" % n not in srcs[key]]
|
|
143
|
+
if missing:
|
|
144
|
+
findings.append(Finding("V5", path, missing[0], "%s: %d kept line(s) are no entry's src" % (
|
|
145
|
+
key, len(missing)), "state.py memory export %s" % task))
|
|
146
|
+
if cleanup_on and task in sealed_tasks:
|
|
147
|
+
findings.append(Finding("V7", "/".join(path.split("/")[:3]) + "/", None,
|
|
148
|
+
"cleanup is on and %s is sealed" % task, "state.py memory cleanup %s" % task))
|
|
149
|
+
return findings
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def verify(root, scope="all", ids=None):
|
|
153
|
+
"""Findings for --all or ids over the working tree; [] is green."""
|
|
154
|
+
cfg = mcfg.load_config(root)
|
|
155
|
+
packages = mpkg.load_packages(root)
|
|
156
|
+
sizes = {n: os.path.getsize(os.path.join(mio.memory_dir(root), n)) for n in packages}
|
|
157
|
+
findings = check_packages(packages, cfg.package_max_bytes, sizes)
|
|
158
|
+
reports = os.path.join(root, ".ai", "reports")
|
|
159
|
+
paths = []
|
|
160
|
+
for name in sorted(os.listdir(reports)) if os.path.isdir(reports) else []:
|
|
161
|
+
if ids and name not in ids:
|
|
162
|
+
continue
|
|
163
|
+
if os.path.exists(os.path.join(reports, name, "events.jsonl")):
|
|
164
|
+
paths.append(".ai/reports/%s/events.jsonl" % name)
|
|
165
|
+
findings += check_journals(lambda rel: mio.read_bytes(os.path.join(root, rel)), paths, packages, False)
|
|
166
|
+
if ids:
|
|
167
|
+
findings = [f for f in findings if any(i in (f.path or "") or i in f.what for i in ids)]
|
|
168
|
+
del scope
|
|
169
|
+
return findings
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def verify_fresh_clone(root):
|
|
173
|
+
"""§D6: a local clone of HEAD, rebuilt from packages alone, shows every
|
|
174
|
+
sealed key as this clone does."""
|
|
175
|
+
import memory_store # pylint: disable=import-outside-toplevel
|
|
176
|
+
findings = []
|
|
177
|
+
here = memory_store.FileHistory(root, mcfg.load_config(root))
|
|
178
|
+
keys = sorted({mf.key_of(e) for _, entries, _ in mpkg.load_packages(root).values() for e in entries
|
|
179
|
+
if e.get("kind") == "task"})
|
|
180
|
+
with tempfile.TemporaryDirectory(prefix="ai-memory-clone-") as tmp:
|
|
181
|
+
clone = os.path.join(tmp, "clone")
|
|
182
|
+
code, _, err = mio.git(root, "clone", "-q", "--no-hardlinks", root, clone)
|
|
183
|
+
if code != 0:
|
|
184
|
+
raise mio.MemoryError_("git clone failed: %s" % err.strip(), 3)
|
|
185
|
+
there = memory_store.FileHistory(clone, mcfg.load_config(clone))
|
|
186
|
+
for key in keys:
|
|
187
|
+
a = "\n".join(mf.render_task_md(here.entries(key)))
|
|
188
|
+
b = "\n".join(mf.render_task_md(there.entries(key)))
|
|
189
|
+
if a != b:
|
|
190
|
+
findings.append(Finding("fresh-clone", key, None, "renders differently in a fresh clone",
|
|
191
|
+
"commit .ai/memory/ (the clone sees HEAD only)"))
|
|
192
|
+
return findings
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def format_finding(f, ref=None):
|
|
196
|
+
where = f.path + (":%s" % f.line if f.line else "")
|
|
197
|
+
return "%s%s: %s: %s — fix: %s" % ("%s: " % ref if ref else "", f.check, where, f.what, f.fix)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ------------------------------------------------------------------ git trees (§D10)
|
|
201
|
+
class GitTreeReader:
|
|
202
|
+
"""Blobs of one commit, through one `git cat-file --batch`."""
|
|
203
|
+
|
|
204
|
+
def __init__(self, repo, sha):
|
|
205
|
+
self.repo, self.sha = repo, sha
|
|
206
|
+
self._cache = {}
|
|
207
|
+
self._paths = None
|
|
208
|
+
|
|
209
|
+
def paths(self):
|
|
210
|
+
if self._paths is None:
|
|
211
|
+
code, out, _ = mio.git(self.repo, "ls-tree", "-r", "-z", "--name-only", self.sha, binary=True)
|
|
212
|
+
self._paths = [p.decode("utf-8", "replace") for p in out.split(b"\0") if p] if code == 0 else []
|
|
213
|
+
return self._paths
|
|
214
|
+
|
|
215
|
+
def read_many(self, rels):
|
|
216
|
+
wanted = [r for r in rels if r not in self._cache]
|
|
217
|
+
if wanted:
|
|
218
|
+
stdin = "".join("%s:%s\n" % (self.sha, r) for r in wanted).encode("utf-8")
|
|
219
|
+
done = subprocess.run(["git", "-C", self.repo, "cat-file", "--batch"], input=stdin,
|
|
220
|
+
capture_output=True, check=False)
|
|
221
|
+
data, pos = done.stdout, 0
|
|
222
|
+
for rel in wanted:
|
|
223
|
+
end = data.find(b"\n", pos)
|
|
224
|
+
head = data[pos:end].split(b" ")
|
|
225
|
+
pos = end + 1
|
|
226
|
+
if len(head) == 2 and head[1] == b"missing" or len(head) < 3:
|
|
227
|
+
self._cache[rel] = None
|
|
228
|
+
continue
|
|
229
|
+
size = int(head[2])
|
|
230
|
+
self._cache[rel] = data[pos:pos + size]
|
|
231
|
+
pos += size + 1
|
|
232
|
+
return {r: self._cache.get(r) for r in rels}
|
|
233
|
+
|
|
234
|
+
def read(self, rel):
|
|
235
|
+
return self.read_many([rel])[rel]
|
|
236
|
+
|
|
237
|
+
def listdir(self, rel):
|
|
238
|
+
prefix = rel.rstrip("/") + "/"
|
|
239
|
+
return sorted({p[len(prefix):].split("/")[0] for p in self.paths() if p.startswith(prefix)})
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def rev(repo, name):
|
|
243
|
+
code, out, _ = mio.git(repo, "rev-parse", "--verify", "-q", name + "^{commit}")
|
|
244
|
+
return out.strip() if code == 0 and out.strip() else None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def policy_at(repo, sha):
|
|
248
|
+
code, out, _ = mio.git(repo, "show", "%s:%s" % (sha, mio.POLICY_REL.replace(os.sep, "/")))
|
|
249
|
+
if code != 0:
|
|
250
|
+
return None
|
|
251
|
+
try:
|
|
252
|
+
data = json.loads(out)
|
|
253
|
+
except ValueError:
|
|
254
|
+
return {"mode": "invalid"}
|
|
255
|
+
return data if isinstance(data, dict) else {"mode": "invalid"}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def validate_range(repo, old, new, local=False):
|
|
259
|
+
"""(findings, stats) for the commits O..T (§D10). O may be None: then only
|
|
260
|
+
the structure of T is checked."""
|
|
261
|
+
started = time.time()
|
|
262
|
+
policy = policy_at(repo, new)
|
|
263
|
+
if policy is None or policy.get("mode", "files") == "files":
|
|
264
|
+
return None, {}
|
|
265
|
+
if policy.get("mode") == "invalid":
|
|
266
|
+
return [Finding("V1", mio.POLICY_REL, None, "memory.json at %s is not valid JSON" % new[:9],
|
|
267
|
+
"fix the file, or 'state.py memory disable'")], {}
|
|
268
|
+
max_bytes = int(policy.get("package_max_bytes", mcfg.DEFAULTS["package_max_bytes"]))
|
|
269
|
+
tree = GitTreeReader(repo, new)
|
|
270
|
+
paths = tree.paths()
|
|
271
|
+
pkg_paths = [p for p in paths if p.startswith(".ai/memory/") and p.endswith(".jsonl")]
|
|
272
|
+
blobs = tree.read_many(pkg_paths)
|
|
273
|
+
packages = {p.split("/", 2)[2]: mf.parse_package(blobs[p] or b"", p.split("/", 2)[2]) for p in pkg_paths}
|
|
274
|
+
sizes = {p.split("/", 2)[2]: len(blobs[p] or b"") for p in pkg_paths}
|
|
275
|
+
changed, added = None, []
|
|
276
|
+
findings = []
|
|
277
|
+
if old:
|
|
278
|
+
code, out, _ = mio.git(repo, "diff", "--name-status", "--no-renames", old, new, "--", ".ai")
|
|
279
|
+
rows = [line.split("\t") for line in out.splitlines() if "\t" in line] if code == 0 else []
|
|
280
|
+
changed = {r[1].split("/", 2)[2] for r in rows if r[1].startswith(".ai/memory/")}
|
|
281
|
+
added = [r[1] for r in rows if r[0].startswith("A")]
|
|
282
|
+
before = GitTreeReader(repo, old)
|
|
283
|
+
old_pkgs = [p for p in before.paths() if p.startswith(".ai/memory/") and p.endswith(".jsonl")]
|
|
284
|
+
old_blobs = before.read_many(old_pkgs)
|
|
285
|
+
old_rids = set()
|
|
286
|
+
for p in old_pkgs:
|
|
287
|
+
_, entries, _ = mf.parse_package(old_blobs[p] or b"")
|
|
288
|
+
old_rids |= {e["rid"] for e in entries}
|
|
289
|
+
new_rids = mpkg.all_rids(packages)
|
|
290
|
+
redacted = {e.get("target") for _, entries, _ in packages.values() for e in entries
|
|
291
|
+
if e.get("kind") == "redaction"}
|
|
292
|
+
lost = sorted(old_rids - new_rids - redacted)
|
|
293
|
+
if lost:
|
|
294
|
+
findings.append(Finding("V2", ".ai/memory/", None, "%d record(s) of %s are gone (%s…)" % (
|
|
295
|
+
len(lost), old[:9], lost[0]), "git checkout %s -- .ai/memory/ and merge the lines back" % old[:9]))
|
|
296
|
+
findings = check_packages(packages, max_bytes, sizes, changed) + findings
|
|
297
|
+
journals = [p for p in paths if re.match(r"^\.ai/reports/[^/]+/events\.jsonl$", p)]
|
|
298
|
+
journal_blobs = tree.read_many(journals) # one cat-file --batch for every journal (§D10)
|
|
299
|
+
findings += check_journals(journal_blobs.get, journals, packages, bool(policy.get("cleanup")))
|
|
300
|
+
for path in added:
|
|
301
|
+
if path.startswith(".ai/reports/") and path.endswith(".log"):
|
|
302
|
+
findings.append(Finding("V6", path, None, "a log is committed under .ai/reports/",
|
|
303
|
+
"git rm --cached %s (logs live in .ai/state/logs/)" % path))
|
|
304
|
+
for path in paths:
|
|
305
|
+
# The index lives under .ai/local/; a project's own SQLite file elsewhere is not ours.
|
|
306
|
+
if path.startswith(".ai/local/") or path.startswith(".ai/state/logs/") or \
|
|
307
|
+
(path.startswith(".ai/") and ".sqlite3" in path):
|
|
308
|
+
findings.append(Finding("V6", path, None, "local data is committed",
|
|
309
|
+
"git rm --cached %s (.ai/local/ and .ai/state/logs/ stay local)" % path))
|
|
310
|
+
if local:
|
|
311
|
+
outbox = mio.local_path(repo, "outbox")
|
|
312
|
+
if os.path.isdir(outbox) and any(n.endswith(".jsonl") for n in os.listdir(outbox)):
|
|
313
|
+
findings.append(Finding("V9", os.path.join(mio.LOCAL_REL, "outbox"), None, "an export is pending",
|
|
314
|
+
"state.py memory export --pending, commit, push again"))
|
|
315
|
+
stats = {"packages": len(packages), "tasks": len(journals), "ms": int((time.time() - started) * 1000)}
|
|
316
|
+
return findings, stats
|
|
@@ -34,6 +34,7 @@ def memory_parsers(sub):
|
|
|
34
34
|
m = msub.add_parser("status"); m.add_argument("--json", action="store_true")
|
|
35
35
|
m = msub.add_parser("enable"); m.add_argument("--author"); m.add_argument("--period", choices=["month", "quarter", "year"])
|
|
36
36
|
m.add_argument("--index", choices=["none", "sqlite"])
|
|
37
|
+
m.add_argument("--share", choices=["git", "local"])
|
|
37
38
|
msub.add_parser("disable")
|
|
38
39
|
m = msub.add_parser("export"); m.add_argument("ids", nargs="*"); m.add_argument("--pending", action="store_true")
|
|
39
40
|
m = msub.add_parser("verify"); m.add_argument("ids", nargs="*"); m.add_argument("--all", action="store_true")
|
|
@@ -165,7 +165,7 @@ def memory_after_close(root, task_id):
|
|
|
165
165
|
if not os.path.exists(os.path.join(root, ".ai", "policies", "memory.json")):
|
|
166
166
|
return
|
|
167
167
|
try:
|
|
168
|
-
import
|
|
168
|
+
import memory_export # pylint: disable=import-outside-toplevel
|
|
169
169
|
except ImportError:
|
|
170
170
|
return
|
|
171
|
-
|
|
171
|
+
memory_export.after_close(root, task_id)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""dialog: the human decision dialog and the authority that tells a human from the agent -- decisions, consent labels, picks, the approval offer.
|
|
2
2
|
|
|
3
|
-
shell: human_route, human_turn, refuse_approval, DIALOG_PICK, dialog_policy, dialog_tool_verified, dialog_on, open_decision, print_dialog, offer_decision, settle_decision, settle_written, prune_decisions, mirror_ledger, sync_dialog, dialog_pick, valid_decisions, retire_approvals, task_facts, audit_pick, approve_command, offer_approval
|
|
4
|
-
core: DIALOG_TOOL, SESSION_ENV, DECISIONS_MAX, QUESTION_MAX, HEADER_MAX, JSON_DEPTH_MAX, json_loads_capped, dialog_questions, option_description, part_question, dialog_payload, MIRROR, pick_data
|
|
3
|
+
shell: human_route, human_turn, open_approval_id, refuse_approval, DIALOG_PICK, dialog_policy, dialog_tool_verified, dialog_on, open_decision, print_dialog, offer_decision, settle_decision, settle_written, prune_decisions, mirror_ledger, sync_dialog, dialog_pick, valid_decisions, retire_approvals, task_facts, audit_pick, approve_command, offer_approval
|
|
4
|
+
core: DIALOG_TOOL, SESSION_ENV, DECISIONS_MAX, QUESTION_MAX, HEADER_MAX, JSON_DEPTH_MAX, json_loads_capped, dialog_questions, option_description, human_refusal, part_question, dialog_payload, MIRROR, pick_data
|
|
5
5
|
"""
|
|
6
6
|
import json
|
|
7
7
|
import os
|
|
@@ -77,16 +77,29 @@ def human_turn(root, state):
|
|
|
77
77
|
return True, ""
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
def open_approval_id(root, rt=rr_runtime.Runtime()):
|
|
81
|
+
"""The id of the open approval decision, or None with the dialog off or none open: the refusals name the dialog then (R7)."""
|
|
82
|
+
if not dialog_on(root, runtime=rt.name):
|
|
83
|
+
return None
|
|
84
|
+
try:
|
|
85
|
+
with open(fs.decision_path(root), encoding="utf-8") as fh:
|
|
86
|
+
return next((d["id"] for d in json.load(fh)["open"] if d.get("kind") == "approval"), None)
|
|
87
|
+
except (OSError, ValueError, KeyError, TypeError, AttributeError, RecursionError):
|
|
88
|
+
return None # an unreadable decision.json names no decision: the refusal stays today's
|
|
89
|
+
|
|
90
|
+
|
|
80
91
|
def refuse_approval(root, state, gate_id, by, why):
|
|
81
92
|
# Quoted: this project's own path has a space in it, and the command the
|
|
82
93
|
# refusal prints is the only route a human without the flag has.
|
|
83
|
-
|
|
84
|
-
|
|
94
|
+
cmd = "python3 %s --root %s approve --by %s" % (shlex.quote(fs.ENTRY), shlex.quote(root), shlex.quote(by))
|
|
95
|
+
asked = None if state["mode"] == "full_irreversible" else human_refusal("'state.py approve'", cmd, why, open_approval_id(root))
|
|
96
|
+
questions = os.path.relpath(fs.questions_path(root, state, None), root)
|
|
97
|
+
if asked:
|
|
98
|
+
fs.die("%s Or set [Answer]: A on %s in %s and tell the session to sync." % (asked, gate_id, questions), 5)
|
|
99
|
+
fs.die("APPROVAL_REFUSED — approval happens outside the agent. Run in your own terminal:\n %s\n"
|
|
85
100
|
"or set [Answer]: A on %s in %s and tell the session to sync. An unattended run exports "
|
|
86
101
|
"AI_UNATTENDED=1 in the launcher's environment; the journal then records the approval as "
|
|
87
|
-
"unattended.\nRefused because %s."
|
|
88
|
-
% (shlex.quote(fs.ENTRY), shlex.quote(root), shlex.quote(by), gate_id,
|
|
89
|
-
os.path.relpath(fs.questions_path(root, state, None), root), why), 5)
|
|
102
|
+
"unattended.\nRefused because %s." % (cmd, gate_id, questions, why), 5)
|
|
90
103
|
|
|
91
104
|
|
|
92
105
|
# ---------------------------------------------- the dialog (R36, step 7c)
|
|
@@ -254,6 +267,9 @@ def open_decision(root, state, kind, header, question, options, opened_by, multi
|
|
|
254
267
|
"question": (question or "").strip()[:QUESTION_MAX],
|
|
255
268
|
"options": [dict(option, n=n) for n, option in enumerate(options, 1)],
|
|
256
269
|
}, **extra)
|
|
270
|
+
for option in decision["options"]:
|
|
271
|
+
if option["kind"] == "command" and not option.get("description"):
|
|
272
|
+
option["description"] = labels.describe(shlex.split(option["label"]))
|
|
257
273
|
data["open"].append(decision)
|
|
258
274
|
rr_ledger.write_decisions(root, data)
|
|
259
275
|
if state and state.get("task_id"):
|
|
@@ -270,6 +286,15 @@ def option_description(option):
|
|
|
270
286
|
return "the session runs exactly this, once" if option["kind"] == "command" else "nothing runs"
|
|
271
287
|
|
|
272
288
|
|
|
289
|
+
def human_refusal(what, cmd, why, decision):
|
|
290
|
+
"""The refusal text that names the dialog first, or None without a decision: the site prints its own text then (R7)."""
|
|
291
|
+
if not decision:
|
|
292
|
+
return None
|
|
293
|
+
return ("APPROVAL_REFUSED — %s is the human's. Ask them in the dialog: send DIALOG_PENDING %s (printed above), "
|
|
294
|
+
"then run the command they pick, verbatim, once. Or they run, in their own terminal:\n %s\n"
|
|
295
|
+
"Refused because %s." % (what, decision, cmd, why))
|
|
296
|
+
|
|
297
|
+
|
|
273
298
|
def part_question(question, i, n):
|
|
274
299
|
"""The question text of part i of n: a split decision's parts must differ,
|
|
275
300
|
Claude keys tool_response.answers by text; cut so the whole fits QUESTION_MAX."""
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"""dialog_cmd: the consent and decision commands -- decision open/show/settle/fallback, the pick applied, and the read-only consent view.
|
|
2
2
|
|
|
3
|
-
core: SETTLE_EFFECT, SETUP_VALUES, SETUP_VALUE_MAX, SETUP_SPEC_MAX, setup_option_why, setup_decision_why, setup_spec_why
|
|
4
|
-
shell: cmd_decision, decision_open, read_setup_spec, setup_command_why, check_setup_labels, setup_open, decision_settle, apply_choice, cmd_consent
|
|
3
|
+
core: SETTLE_EFFECT, INSTALL_RE, SETUP_VALUES, SETUP_VALUE_MAX, SETUP_SPEC_MAX, setup_option_why, setup_decision_why, setup_spec_why
|
|
4
|
+
shell: cmd_decision, decision_open, install_open, read_setup_spec, setup_command_why, check_setup_labels, setup_open, decision_settle, apply_choice, cmd_consent
|
|
5
5
|
"""
|
|
6
6
|
# pylint: disable=unused-argument # uniform cli dispatch: cmd_consent takes rt like every command
|
|
7
7
|
import argparse
|
|
8
8
|
import json
|
|
9
9
|
import os
|
|
10
|
+
import re
|
|
10
11
|
import shlex
|
|
11
12
|
import sys
|
|
12
13
|
|
|
@@ -22,6 +23,7 @@ SETUP_VALUES = {"memory": ("off", "keep"), "runtime": ("auto", "claude", "codex"
|
|
|
22
23
|
"survey": ("light", "full", "keep"), "verify": ("", "other", "keep", "edit")}
|
|
23
24
|
SETUP_VALUE_MAX = 200
|
|
24
25
|
SETUP_SPEC_MAX = 262144
|
|
26
|
+
INSTALL_RE = re.compile(r"^npx riskrails@(\d+\.\d+\.\d+) --yes$") # the one command an install decision offers
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
def cmd_decision(args, root, rt=rr_runtime.Runtime()):
|
|
@@ -61,13 +63,17 @@ def cmd_decision(args, root, rt=rr_runtime.Runtime()):
|
|
|
61
63
|
|
|
62
64
|
|
|
63
65
|
def decision_open(args, root, rt=rr_runtime.Runtime()):
|
|
64
|
-
"""The openers the model may call: --kind commit (git commit and git push)
|
|
65
|
-
questions: choices that set a value, and this project's `memory enable`)
|
|
66
|
+
"""The openers the model may call: --kind commit (git commit and git push), --kind setup (the /rr-setup
|
|
67
|
+
questions: choices that set a value, and this project's `memory enable`) and --kind install (one
|
|
68
|
+
`npx riskrails@X.Y.Z --yes`); nothing else."""
|
|
66
69
|
if args.kind == "setup":
|
|
67
70
|
setup_open(args, root, rt=rt)
|
|
68
71
|
return
|
|
72
|
+
if args.kind == "install":
|
|
73
|
+
install_open(args, root, rt=rt)
|
|
74
|
+
return
|
|
69
75
|
if args.kind != "commit":
|
|
70
|
-
fs.die("decision open takes --kind commit or --kind setup; every other decision is opened by the "
|
|
76
|
+
fs.die("decision open takes --kind commit or --kind setup (or --kind install, one npx riskrails@X.Y.Z --yes); every other decision is opened by the "
|
|
71
77
|
"command that needs it (close, ask, the budgets, start --mode)")
|
|
72
78
|
if args.spec is not None:
|
|
73
79
|
fs.die("decision open --kind commit takes --command, not --spec", 2)
|
|
@@ -97,6 +103,33 @@ def decision_open(args, root, rt=rr_runtime.Runtime()):
|
|
|
97
103
|
dialog.print_dialog([decision], rt=rt)
|
|
98
104
|
|
|
99
105
|
|
|
106
|
+
def install_open(args, root, rt=rr_runtime.Runtime()):
|
|
107
|
+
"""decision open --kind install --command "npx riskrails@X.Y.Z --yes": the update offered in the dialog (R4).
|
|
108
|
+
The command is exactly that, once; no task may be in flight, because the install rewrites the hooks."""
|
|
109
|
+
match = INSTALL_RE.match(args.commands[0]) if len(args.commands) == 1 else None
|
|
110
|
+
if args.spec is not None or not match:
|
|
111
|
+
fs.die("CONSENT_LABEL_INVALID — decision open --kind install takes one --command, exactly "
|
|
112
|
+
"\"npx riskrails@X.Y.Z --yes\" (X.Y.Z a plain version); found: %s"
|
|
113
|
+
% (" | ".join(args.commands)[:120] or "none"), 6)
|
|
114
|
+
if not dialog.dialog_on(root, runtime=rt.name):
|
|
115
|
+
fs.die("the dialog is off here (.ai/policies/dialog.json, the runtime's dialog tool, a session id): "
|
|
116
|
+
"suggest the command; the human runs it outside the agent")
|
|
117
|
+
state = task.in_flight(root, rt=rt)
|
|
118
|
+
if state:
|
|
119
|
+
fs.die("INSTALL_DURING_TASK — an install rewrites the hooks and skills this task runs on, and task %s is in "
|
|
120
|
+
"flight; finish or abandon it, then open the install decision again" % state["task_id"], 6)
|
|
121
|
+
runtime = rr_runtime.own_runtime(runtime=rt.name)
|
|
122
|
+
have = profile.read_profile(runtime, rt=rt).get("plugin_version") or "unknown"
|
|
123
|
+
question = ("Install RiskRails %s (installed %s)? It rewrites the hooks, skills and settings under %s; "
|
|
124
|
+
"restart the session afterwards." % (match.group(1), have, rr_runtime.runtime_home(runtime)))
|
|
125
|
+
options = [{"kind": "choice", "effect": "defer", "label": "Not now — keep %s" % have},
|
|
126
|
+
{"kind": "command", "label": args.commands[0]}]
|
|
127
|
+
decision, why = dialog.open_decision(root, None, "install", "Install", question, options, "decision open", rt=rt)
|
|
128
|
+
if decision is None:
|
|
129
|
+
fs.die(why, 6)
|
|
130
|
+
dialog.print_dialog([decision], rt=rt)
|
|
131
|
+
|
|
132
|
+
|
|
100
133
|
def read_setup_spec(path):
|
|
101
134
|
"""--spec FILE|- as JSON: a spec that cannot be read, is longer than SETUP_SPEC_MAX characters, is not JSON or
|
|
102
135
|
nests deeper than the dialog's own cap is a bad spec (exit 2)."""
|
|
@@ -5,8 +5,9 @@ shell: guard_pending, request_gate, close_gate
|
|
|
5
5
|
"""
|
|
6
6
|
import os
|
|
7
7
|
import re
|
|
8
|
+
import shlex
|
|
8
9
|
|
|
9
|
-
from rr_state import fs
|
|
10
|
+
from rr_state import dialog, fs
|
|
10
11
|
from rr_state import journal
|
|
11
12
|
from rr_state import locks
|
|
12
13
|
from rr_state import questions as questions_mod # the bodies keep a local `questions`; the module is aliased
|
|
@@ -36,12 +37,17 @@ def guard_pending(root, command, rt=rr_runtime.Runtime()):
|
|
|
36
37
|
if any(q["kind"] == "G" for q in blocking):
|
|
37
38
|
# `answer` refuses a G id, so the general text would send the human to a
|
|
38
39
|
# command that cannot work. A gate is closed from a terminal.
|
|
40
|
+
gates = ", ".join(q["id"] for q in blocking if q["kind"] == "G")
|
|
41
|
+
cmd = "python3 %s --root %s approve --by \"<name>\"" % (shlex.quote(fs.ENTRY), shlex.quote(root))
|
|
42
|
+
asked = dialog.human_refusal("the approval gate", cmd, "approve and reject are the human's, and %s is open" % gates,
|
|
43
|
+
dialog.open_approval_id(root, rt=rt))
|
|
44
|
+
if asked:
|
|
45
|
+
fs.die("QUESTIONS_PENDING — %d unanswered in %s: %s. Stage stays at %s.\n%s"
|
|
46
|
+
% (len(blocking), os.path.relpath(path, root), ", ".join(ids), state.get("current_stage"), asked), 4)
|
|
39
47
|
fs.die("QUESTIONS_PENDING — %d unanswered in %s: %s. %s is the approval gate: run "
|
|
40
48
|
"'state.py approve --by \"<name>\"' or 'state.py reject --by \"<name>\" --why \"<text>\"' "
|
|
41
49
|
"in your own terminal. Stage stays at %s."
|
|
42
|
-
% (len(blocking), os.path.relpath(path, root), ", ".join(ids),
|
|
43
|
-
", ".join(q["id"] for q in blocking if q["kind"] == "G"),
|
|
44
|
-
state.get("current_stage")), 4)
|
|
50
|
+
% (len(blocking), os.path.relpath(path, root), ", ".join(ids), gates, state.get("current_stage")), 4)
|
|
45
51
|
fs.die("QUESTIONS_PENDING — %d unanswered in %s: %s. Answer with 'state.py answer %s' or fill "
|
|
46
52
|
"the [Answer]: lines and run 'state.py questions --sync'. Stage stays at %s."
|
|
47
53
|
% (len(blocking), os.path.relpath(path, root), ", ".join(ids),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""labels: the consent labels a decision may offer as commands -- the one-simple-command rule, the state.py command a label runs, and the absolute command the agent and the human share.
|
|
2
2
|
|
|
3
3
|
shell: validate_label, human_name, state_label
|
|
4
|
-
core: LABEL_MAX, LABEL_FORBIDDEN, INTERPRETER_RE, PUSH_REF_RE, INTERPRETER_FLAG_RE, NPX_PACKAGE_RE, label_argv, subcommand, state_label_ok, subcommand_prefix
|
|
4
|
+
core: BATCH_ON, DESCRIPTIONS, describe, LABEL_MAX, LABEL_FORBIDDEN, INTERPRETER_RE, PUSH_REF_RE, INTERPRETER_FLAG_RE, NPX_PACKAGE_RE, label_argv, subcommand, state_label_ok, subcommand_prefix
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import os
|
|
@@ -16,6 +16,17 @@ INTERPRETER_RE = re.compile(r"^python3?$") # a bare name: a path could be any
|
|
|
16
16
|
PUSH_REF_RE = re.compile(r"^[A-Za-z0-9._/-]+$") # no '+' (a force push), no ':' (a refspec), no option
|
|
17
17
|
INTERPRETER_FLAG_RE = re.compile(r"^-[IS]+$")
|
|
18
18
|
NPX_PACKAGE_RE = re.compile(r"^riskrails@[0-9A-Za-z.+-]+$")
|
|
19
|
+
BATCH_ON = {"--by": True, "--why": False} # `batch on` options, each at most once; True = required. hooks/guard/dialog.py keeps the same table
|
|
20
|
+
DESCRIPTIONS = { # what each offered command does, in words a person without a terminal reads
|
|
21
|
+
("approve",): "Approve the task's result as it is now, so it can be closed.",
|
|
22
|
+
("reject",): "Reject the task's result and send it back for changes.",
|
|
23
|
+
("profile", "set"): "Change the plan profile, which sets the models the work runs on.",
|
|
24
|
+
("verify",): "Run the test suite once more, past the number of runs this task allows.",
|
|
25
|
+
("review",): "Record the next review verdict as yours, past the review passes this task allows.",
|
|
26
|
+
("start",): "Start the task in the mode the command names instead of the one its weight gives.",
|
|
27
|
+
("memory", "enable"): "Turn on the project's compact record of tasks.",
|
|
28
|
+
("batch", "on"): "Turn the batch loop on for this task: more test runs and review passes before you are asked.",
|
|
29
|
+
}
|
|
19
30
|
|
|
20
31
|
|
|
21
32
|
def label_argv(label):
|
|
@@ -44,10 +55,15 @@ def subcommand(argv):
|
|
|
44
55
|
|
|
45
56
|
def state_label_ok(argv):
|
|
46
57
|
"""approve | reject | profile set | verify --by | review --by | start --mode … --by |
|
|
47
|
-
memory enable [--author S] [--period month|quarter|year] [--index none|sqlite]: each option at most
|
|
58
|
+
batch on --by S [--why S] | memory enable [--author S] [--period month|quarter|year] [--index none|sqlite]: each option at most
|
|
48
59
|
once, as two words, in any order, and no other memory verb. hooks/guard/dialog.py keeps the same
|
|
49
60
|
rule; tests/test_dialog_contract.py pins the two equal."""
|
|
50
61
|
rest = subcommand(argv)
|
|
62
|
+
if rest[:2] == ["batch", "on"]:
|
|
63
|
+
names, values = rest[2::2], rest[3::2]
|
|
64
|
+
return (len(names) == len(values) == len(set(names)) and set(names) <= set(BATCH_ON)
|
|
65
|
+
and all(n in names for n, need in BATCH_ON.items() if need)
|
|
66
|
+
and all(v[:1] not in ("", "-") for v in values))
|
|
51
67
|
if rest[:1] in (["approve"], ["reject"]) or rest[:2] == ["profile", "set"]:
|
|
52
68
|
return True
|
|
53
69
|
if rest[:1] in (["verify"], ["review"]):
|
|
@@ -89,8 +105,8 @@ def validate_label(label):
|
|
|
89
105
|
and state_label_ok(script[1]):
|
|
90
106
|
return None
|
|
91
107
|
return ("a label starts with git commit, git push <remote> <branch>, state.py approve|reject|"
|
|
92
|
-
"profile set|verify --by|review --by|start --mode --by|
|
|
93
|
-
"[--period P] [--index none|sqlite], or npx riskrails@<v> … --yes")
|
|
108
|
+
"profile set|verify --by|review --by|start --mode --by|batch on --by S [--why S]|memory enable "
|
|
109
|
+
"[--author S] [--period P] [--index none|sqlite], or npx riskrails@<v> … --yes")
|
|
94
110
|
|
|
95
111
|
|
|
96
112
|
def human_name(root):
|
|
@@ -109,3 +125,16 @@ def state_label(root, argv):
|
|
|
109
125
|
def subcommand_prefix(argv):
|
|
110
126
|
"""The global options in front of the subcommand."""
|
|
111
127
|
return list(argv)[:len(argv) - len(subcommand(argv))]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def describe(argv):
|
|
131
|
+
"""One plain-words line for the command a label runs; argv is the label split into words."""
|
|
132
|
+
if argv[:2] == ["git", "commit"]:
|
|
133
|
+
return "Save the finished work as a commit in git."
|
|
134
|
+
if argv[:2] == ["git", "push"]:
|
|
135
|
+
return "Send the committed work to the remote repository."
|
|
136
|
+
if argv[:1] == ["npx"]:
|
|
137
|
+
return "Install this RiskRails version into your runtime's home; start a new session afterwards."
|
|
138
|
+
script = label_argv(shlex.join(argv))
|
|
139
|
+
rest = subcommand(script[1]) if script else []
|
|
140
|
+
return DESCRIPTIONS.get(tuple(rest[:2])) or DESCRIPTIONS.get(tuple(rest[:1])) or "Run a command you have read."
|