riskrails 3.1.0 → 3.2.1
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 +12 -11
- package/bin/riskrails.js +109 -18
- package/hooks/ai-path-guard-defaults.json +3 -2
- package/hooks/context-guard.py +14 -11
- package/hooks/dialog-gate.py +1 -1
- package/hooks/guard/dialog.py +107 -27
- package/hooks/guard/git.py +12 -3
- package/hooks/guard/paths.py +109 -7
- package/hooks/project-scaffold.py +2 -1
- package/hooks/runtime-gate.py +128 -27
- package/hooks/update_notice.py +3 -3
- package/package.json +1 -1
- package/skills/ai-init/templates/.ai/state/README.md +4 -4
- package/skills/ai-task/rr_state/__init__.py +1 -0
- package/skills/ai-task/rr_state/cli.py +210 -0
- package/skills/ai-task/rr_state/close.py +171 -0
- package/skills/ai-task/rr_state/dialog.py +541 -0
- package/skills/ai-task/rr_state/dialog_cmd.py +164 -0
- package/skills/ai-task/rr_state/fs.py +220 -0
- package/skills/ai-task/rr_state/gate.py +125 -0
- package/skills/ai-task/rr_state/gate_cmd.py +128 -0
- package/skills/ai-task/rr_state/handoff.py +226 -0
- package/skills/ai-task/rr_state/journal.py +126 -0
- package/skills/ai-task/rr_state/labels.py +99 -0
- package/skills/ai-task/rr_state/ledger.py +145 -0
- package/skills/ai-task/rr_state/locks.py +84 -0
- package/skills/ai-task/rr_state/memory_cmd.py +310 -0
- package/skills/ai-task/rr_state/policy.py +378 -0
- package/skills/ai-task/rr_state/profile.py +373 -0
- package/skills/ai-task/rr_state/questions.py +336 -0
- package/skills/ai-task/rr_state/questions_cmd.py +321 -0
- package/skills/ai-task/rr_state/report.py +271 -0
- package/skills/ai-task/rr_state/report_cmd.py +230 -0
- package/skills/ai-task/rr_state/review.py +165 -0
- package/skills/ai-task/rr_state/runtime.py +80 -0
- package/skills/ai-task/rr_state/start.py +257 -0
- package/skills/ai-task/rr_state/steps.py +131 -0
- package/skills/ai-task/rr_state/task.py +396 -0
- package/skills/ai-task/rr_state/verify.py +231 -0
- package/skills/ai-task/sensors.py +16 -3
- package/skills/ai-task/state.py +24 -5161
- package/skills/project-update/adopt.py +38 -24
- package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
- package/skills/project-update/history/index.json +2 -1
- package/skills/project-update/migrations/0003_context_diet.py +1 -1
- package/skills/project-update/migrations/0007_weight_model.py +130 -23
- package/skills/project-update/migrations/README.md +16 -1
- package/skills/project-update/update.py +202 -26
- package/skills/rr-setup/SKILL.md +9 -2
- package/skills/rr-status/status.py +36 -9
|
@@ -32,6 +32,12 @@ def test_mode():
|
|
|
32
32
|
return os.environ.get("RISKRAILS_TEST", os.environ.get("CLAUDE_AGENTIC_TEST")) == "1"
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def ai_unattended():
|
|
36
|
+
"""AI_UNATTENDED is set when it is non-empty and not 0, false or no (case
|
|
37
|
+
and blanks ignored): one rule in every reader (risk 0587)."""
|
|
38
|
+
return os.environ.get("AI_UNATTENDED", "").strip().lower() not in ("", "0", "false", "no")
|
|
39
|
+
|
|
40
|
+
|
|
35
41
|
def human_gate():
|
|
36
42
|
"""(passed, via, why) for --confirm-delete. A terminal outside any agent
|
|
37
43
|
session is a human: via "terminal". Otherwise a launcher that declared the
|
|
@@ -44,7 +50,7 @@ def human_gate():
|
|
|
44
50
|
markers = [name for name in AGENT_MARKERS if name in os.environ]
|
|
45
51
|
if os.isatty(0) and not markers:
|
|
46
52
|
return True, "terminal", ""
|
|
47
|
-
if
|
|
53
|
+
if ai_unattended():
|
|
48
54
|
return True, "unattended", ""
|
|
49
55
|
if markers:
|
|
50
56
|
return False, None, ("this run is inside an agent session (%s %s set); run it in a terminal "
|
|
@@ -175,7 +181,7 @@ def load_table(path=MAP_FILE):
|
|
|
175
181
|
try:
|
|
176
182
|
with open(path, encoding="utf-8") as fh:
|
|
177
183
|
table = json.load(fh)
|
|
178
|
-
except (OSError, ValueError) as exc:
|
|
184
|
+
except (OSError, ValueError, RecursionError) as exc:
|
|
179
185
|
raise TableError("%s: %s" % (path, exc)) from exc
|
|
180
186
|
tools = table.get("tools")
|
|
181
187
|
rows = table.get("rows")
|
|
@@ -455,7 +461,7 @@ def load_decisions(root):
|
|
|
455
461
|
try:
|
|
456
462
|
with open(os.path.join(root, where, "decisions.json"), encoding="utf-8") as fh:
|
|
457
463
|
return json.load(fh)
|
|
458
|
-
except (OSError, ValueError) as exc:
|
|
464
|
+
except (OSError, ValueError, RecursionError) as exc:
|
|
459
465
|
raise TableError("%s/decisions.json: %s" % (where, exc)) from exc
|
|
460
466
|
|
|
461
467
|
|
|
@@ -925,7 +931,7 @@ def state_line(root, shipped_block, skeleton_cap, table=None, instruction_files=
|
|
|
925
931
|
try:
|
|
926
932
|
return _state_line(root, shipped_block, skeleton_cap, table, instruction_files, shipped_files)
|
|
927
933
|
except (TableError, OSError, ValueError, KeyError, TypeError, AttributeError) as exc:
|
|
928
|
-
return 1, "adopt state unreadable (%s) — run /
|
|
934
|
+
return 1, "adopt state unreadable (%s) — run /rr-setup --adopt" % exc
|
|
929
935
|
|
|
930
936
|
|
|
931
937
|
def _state_line(root, shipped_block, skeleton_cap, table, instruction_files, shipped_files):
|
|
@@ -943,11 +949,11 @@ def _state_line(root, shipped_block, skeleton_cap, table, instruction_files, shi
|
|
|
943
949
|
return 0, "no foreign structure detected"
|
|
944
950
|
bits = ["%s (%d file(s))" % (t, sum(g.values())) for t, g in adoption.detected.items()]
|
|
945
951
|
bits += ["%s (split?)" % c.path for c, _ in adoption.candidates]
|
|
946
|
-
return 1, "foreign structure detected: %s — run /
|
|
952
|
+
return 1, "foreign structure detected: %s — run /rr-setup --adopt" % ", ".join(bits)
|
|
947
953
|
try:
|
|
948
954
|
with open(os.path.join(root, where, "adopt.json"), encoding="utf-8") as fh:
|
|
949
955
|
record_data = json.load(fh)
|
|
950
|
-
except (OSError, ValueError):
|
|
956
|
+
except (OSError, ValueError, RecursionError):
|
|
951
957
|
return 0, "no foreign structure detected"
|
|
952
958
|
date = where.rsplit("adopt-", 1)[-1]
|
|
953
959
|
regenerated = []
|
|
@@ -968,13 +974,13 @@ def _state_line(root, shipped_block, skeleton_cap, table, instruction_files, shi
|
|
|
968
974
|
if current != src.get("sha"):
|
|
969
975
|
regenerated.append(src["path"])
|
|
970
976
|
if regenerated:
|
|
971
|
-
return 1, ("foreign files regenerated since the adopt of %s: %s — run /
|
|
977
|
+
return 1, ("foreign files regenerated since the adopt of %s: %s — run /rr-setup --adopt"
|
|
972
978
|
% (date, ", ".join(sorted(regenerated))))
|
|
973
979
|
checks = record_data.get("checks", {})
|
|
974
980
|
failed = [name for name, c in checks.items() if c.get("status") not in ("pass", "not_applicable")]
|
|
975
981
|
if failed or record_data.get("status") == "partial":
|
|
976
982
|
what = failed[0].replace("_", "-") + " FAIL" if failed else "the apply was interrupted"
|
|
977
|
-
return 1, "adoption of %s incomplete: %s — run /
|
|
983
|
+
return 1, "adoption of %s incomplete: %s — run /rr-setup --adopt" % (date, what)
|
|
978
984
|
tools = ", ".join(sorted(record_data.get("tools", {})))
|
|
979
985
|
pending = sum(1 for s in record_data.get("sources", []) if s.get("cleanup") and safe_rel(s.get("path"))
|
|
980
986
|
and os.path.isfile(os.path.join(root, s["path"])))
|
|
@@ -1306,7 +1312,7 @@ def read_record(root, where):
|
|
|
1306
1312
|
try:
|
|
1307
1313
|
with open(os.path.join(root, where, "adopt.json"), encoding="utf-8") as fh:
|
|
1308
1314
|
return json.load(fh)
|
|
1309
|
-
except (OSError, ValueError):
|
|
1315
|
+
except (OSError, ValueError, RecursionError):
|
|
1310
1316
|
return None
|
|
1311
1317
|
|
|
1312
1318
|
|
|
@@ -1331,7 +1337,7 @@ def task_in_flight(root):
|
|
|
1331
1337
|
try:
|
|
1332
1338
|
with open(path, encoding="utf-8") as fh:
|
|
1333
1339
|
state = json.load(fh)
|
|
1334
|
-
except (OSError, ValueError):
|
|
1340
|
+
except (OSError, ValueError, RecursionError):
|
|
1335
1341
|
return None
|
|
1336
1342
|
stage = state.get("current_stage")
|
|
1337
1343
|
return None if stage in (None, "done") else "%s at stage %s" % (state.get("task_id", "a task"), stage)
|
|
@@ -1340,13 +1346,13 @@ def task_in_flight(root):
|
|
|
1340
1346
|
def gates(root, u, planned_writes):
|
|
1341
1347
|
"""R5, in the order a human fixes them. Returns a refusal (reason, how) or None."""
|
|
1342
1348
|
if not os.path.isdir(os.path.join(root, ".ai")):
|
|
1343
|
-
return "the project has no .ai/", "run /
|
|
1349
|
+
return "the project has no .ai/", "run /rr-setup first, then /rr-setup --adopt"
|
|
1344
1350
|
dirty = clean_tree(root)
|
|
1345
1351
|
if dirty is None:
|
|
1346
1352
|
return "the project is not a git work tree", "run `git init` and commit, so the adopt can be undone"
|
|
1347
1353
|
running = task_in_flight(root)
|
|
1348
1354
|
if running:
|
|
1349
|
-
return "a task is in flight (%s)" % running, "finish or abandon it with /
|
|
1355
|
+
return "a task is in flight (%s)" % running, "finish or abandon it with /rr-build first"
|
|
1350
1356
|
outside = [p for p in dirty if not p.startswith(ALLOWED_DIRT) and p not in planned_writes]
|
|
1351
1357
|
if outside:
|
|
1352
1358
|
more = " and %d more" % (len(outside) - 3) if len(outside) > 3 else ""
|
|
@@ -1356,7 +1362,7 @@ def gates(root, u, planned_writes):
|
|
|
1356
1362
|
"commit it or remove it")
|
|
1357
1363
|
return ("the tree has uncommitted changes: %s%s" % (", ".join(outside[:3]), more), how)
|
|
1358
1364
|
if u.plain_pending(root, ignore=planned_writes):
|
|
1359
|
-
return "the project is behind the installed plugin", "run /
|
|
1365
|
+
return "the project is behind the installed plugin", "run /rr-setup --update --apply first, then adopt"
|
|
1360
1366
|
return None
|
|
1361
1367
|
|
|
1362
1368
|
|
|
@@ -1403,7 +1409,8 @@ def write_json(u, root, rel, data):
|
|
|
1403
1409
|
|
|
1404
1410
|
def apply_run(plan, args, u, skeleton_cap, instruction_files=frozenset(), shipped_files=None):
|
|
1405
1411
|
"""`update.py --adopt --apply`: gates, originals, phased writes, the checks
|
|
1406
|
-
on disk, the record. Exit 0,
|
|
1412
|
+
on disk, the record. Exit 0, 2 (the routing table or a migration could not be
|
|
1413
|
+
read), 3 (aborted), 4 (incomplete) or 5 (refused)."""
|
|
1407
1414
|
root = plan.root
|
|
1408
1415
|
try:
|
|
1409
1416
|
table = load_table()
|
|
@@ -1413,7 +1420,11 @@ def apply_run(plan, args, u, skeleton_cap, instruction_files=frozenset(), shippe
|
|
|
1413
1420
|
tools = [t.strip() for t in args.tool.split(",")] if args.tool else None
|
|
1414
1421
|
resume_dir, resume = partial_record(root)
|
|
1415
1422
|
planned_writes = set(resume.get("planned_writes", [])) if resume else set()
|
|
1416
|
-
|
|
1423
|
+
try:
|
|
1424
|
+
refusal = gates(root, u, planned_writes)
|
|
1425
|
+
except u.SchemaError as exc: # plain_pending builds the plan: a migration's error is exit 2, as in update.py
|
|
1426
|
+
print("project-update: %s" % exc, file=sys.stderr)
|
|
1427
|
+
return 2
|
|
1417
1428
|
if refusal:
|
|
1418
1429
|
return refuse(*refusal)
|
|
1419
1430
|
plan.report_dir = plan.adopt_report_dir = resume_dir or ".ai/reports/adopt-%s" % today()
|
|
@@ -1577,7 +1588,9 @@ def report_md(plan, adoption, record):
|
|
|
1577
1588
|
# ---------------------------------------------------------------------------
|
|
1578
1589
|
|
|
1579
1590
|
CLEANUP_HEADING = "## Cleanup"
|
|
1580
|
-
REVIEW_LINE = "Run `/
|
|
1591
|
+
REVIEW_LINE = "Run `/rr-setup --adopt --cleanup` to review; a human confirms the deletion."
|
|
1592
|
+
# Reports written by v2.0.2-v3.1.0 carry this text; cleanup still replaces it.
|
|
1593
|
+
LEGACY_REVIEW_LINE = "Run `/project-update --adopt --cleanup` to review; a human confirms the deletion."
|
|
1581
1594
|
|
|
1582
1595
|
|
|
1583
1596
|
def plan_cleanup(plan, args, u, table, skeleton_cap, instruction_files, shipped_files):
|
|
@@ -1589,21 +1602,21 @@ def plan_cleanup(plan, args, u, table, skeleton_cap, instruction_files, shipped_
|
|
|
1589
1602
|
def no(reason, how, adoption=None):
|
|
1590
1603
|
return (reason, how), None, None, None, adoption
|
|
1591
1604
|
if not os.path.isdir(os.path.join(root, ".ai")):
|
|
1592
|
-
return no("the project has no .ai/", "run /
|
|
1605
|
+
return no("the project has no .ai/", "run /rr-setup first")
|
|
1593
1606
|
where = latest_with(root, "adopt.json")
|
|
1594
1607
|
record = read_record(root, where) if where else None
|
|
1595
1608
|
if record is None:
|
|
1596
|
-
return no("no adopt record to clean up", "run /
|
|
1609
|
+
return no("no adopt record to clean up", "run /rr-setup --adopt --apply first")
|
|
1597
1610
|
if args.mode == "coexist" or record.get("mode") == "coexist":
|
|
1598
1611
|
return no("coexist keeps the foreign files",
|
|
1599
|
-
"to move them, run /
|
|
1612
|
+
"to move them, run /rr-setup --adopt --apply (mode migrate), then --cleanup")
|
|
1600
1613
|
checks = record.get("checks", {})
|
|
1601
1614
|
if record.get("status") != "applied" or any(c.get("status") != "pass" for c in checks.values()):
|
|
1602
1615
|
return no("the adopt of %s is not complete (R12)" % where.rsplit("adopt-", 1)[-1],
|
|
1603
|
-
"run /
|
|
1616
|
+
"run /rr-setup --adopt --apply until both checks pass")
|
|
1604
1617
|
running = task_in_flight(root)
|
|
1605
1618
|
if running:
|
|
1606
|
-
return no("a task is in flight (%s)" % running, "finish or abandon it with /
|
|
1619
|
+
return no("a task is in flight (%s)" % running, "finish or abandon it with /rr-build first")
|
|
1607
1620
|
dirty = clean_tree(root)
|
|
1608
1621
|
if dirty is None:
|
|
1609
1622
|
return no("the project is not a git work tree", "cleanup deletes only what git can restore")
|
|
@@ -1642,7 +1655,7 @@ def plan_cleanup(plan, args, u, table, skeleton_cap, instruction_files, shipped_
|
|
|
1642
1655
|
what.append("%d unmapped, %d split(s) waiting" % (len(adoption.unmapped), len(adoption.split)))
|
|
1643
1656
|
if what:
|
|
1644
1657
|
return no("the adopt no longer holds on the current tree: %s" % "; ".join(what),
|
|
1645
|
-
"run /
|
|
1658
|
+
"run /rr-setup --adopt to see why, settle it, then --cleanup", adoption)
|
|
1646
1659
|
# Only what the adopt finds again as a cleanup source, and only what git
|
|
1647
1660
|
# can restore: an ignored file skipped from original/ would be gone for good.
|
|
1648
1661
|
found = {path for path, _t, _tr, _n, cleanup in adoption.sources if cleanup}
|
|
@@ -1773,7 +1786,8 @@ def cleanup_report_md(u, root, where, record):
|
|
|
1773
1786
|
except OSError:
|
|
1774
1787
|
text = ""
|
|
1775
1788
|
text = text.split("\n" + CLEANUP_HEADING + "\n", 1)[0].rstrip("\n") + "\n"
|
|
1776
|
-
|
|
1789
|
+
for line in (REVIEW_LINE, LEGACY_REVIEW_LINE):
|
|
1790
|
+
text = text.replace(line, "The deletion is recorded under Cleanup below.")
|
|
1777
1791
|
c = record["cleanup"]
|
|
1778
1792
|
runs = list(c.get("history") or []) + [c]
|
|
1779
1793
|
|
|
@@ -1900,7 +1914,7 @@ def find_proposal(root, cand, stale=False):
|
|
|
1900
1914
|
try:
|
|
1901
1915
|
with open(os.path.join(base, d, name), encoding="utf-8") as fh:
|
|
1902
1916
|
data = json.load(fh)
|
|
1903
|
-
except (OSError, ValueError):
|
|
1917
|
+
except (OSError, ValueError, RecursionError):
|
|
1904
1918
|
continue
|
|
1905
1919
|
if isinstance(data, dict) and data.get("source") == cand.path and (stale or data.get("source_sha") == cand.sha):
|
|
1906
1920
|
return ".ai/reports/%s/%s" % (d, name), data
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Task state
|
|
2
|
+
|
|
3
|
+
`state.json` is the machine-readable record of the task in flight. It is
|
|
4
|
+
written by `skills/ai-task/state.py` (atomically), read by `/rr-status`, and
|
|
5
|
+
enforced by `ai-scope-guard`, which derives the files the current step may touch
|
|
6
|
+
from it.
|
|
7
|
+
|
|
8
|
+
Two other writers exist, and only these two. A schema migration in
|
|
9
|
+
`/rr-setup --update` may change the state through `ctx.patch_state()`, with the
|
|
10
|
+
same atomic write and a `schema_migrated` history entry — that is how a task in
|
|
11
|
+
flight survives a change to the shape of this file. And `hooks/context-guard.py`
|
|
12
|
+
writes `session.json`, the sidecar beside this file, and nothing else: it never
|
|
13
|
+
touches `state.json`. Nothing else writes any of it — an agent never edits
|
|
14
|
+
them by hand, and the path guard refuses when one tries.
|
|
15
|
+
|
|
16
|
+
It is **not committed**: it describes a session, not the repository. The
|
|
17
|
+
`.gitignore` entry added by `/rr-setup` keeps it out of git.
|
|
18
|
+
|
|
19
|
+
When a task closes, `state.py close` writes the final state to
|
|
20
|
+
`.ai/state/logs/<task-id>/state.json`, beside the task's verify logs — local,
|
|
21
|
+
**not** committed. What is committed is `.ai/reports/<task-id>/events.jsonl`
|
|
22
|
+
and `task.md`, and in packages mode the task's compact record under
|
|
23
|
+
`.ai/memory/` — that is the audit trail: which stages ran, in what order, who
|
|
24
|
+
approved what, and when (the plugin's `docs/local-memory.md`).
|
|
25
|
+
|
|
26
|
+
It holds facts, never transcripts:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
task_id, goal, workflow, risk_tier, current_stage, affected_modules,
|
|
30
|
+
context_summary_ref, approved_plan { ref, current_step_id, steps[] },
|
|
31
|
+
completed_steps, test_status, e2e_status, review_status, security_status, open_risks,
|
|
32
|
+
next_action, owner_runtime, resume_point { stage, step_id, next_action, at, runtime },
|
|
33
|
+
questions { file, pending[] }, handoff { file, written_at, reason },
|
|
34
|
+
human_approval { required, granted, granted_by, granted_at,
|
|
35
|
+
requested_at, gate_id, requested_session, rejected_at, via, unattended },
|
|
36
|
+
created_at, updated_at, history[]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`state.py` applies the schema-2 keys in memory as it loads, so a project that has
|
|
40
|
+
not run `/rr-setup --update` yet still answers every command; `save()` then writes
|
|
41
|
+
them. A reader that opens this file directly sees what is on disk, not those
|
|
42
|
+
defaults.
|
|
43
|
+
|
|
44
|
+
## The other files beside it
|
|
45
|
+
|
|
46
|
+
| File | Written by | Committed |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `state.json` (legacy `current.json`, still read) | `state.py`, and `update.py` during a migration | no |
|
|
49
|
+
| `session.json` | `context-guard.py`, on SessionStart and each prompt | no |
|
|
50
|
+
| `handoff.md` | `state.py handoff` — never a model | no |
|
|
51
|
+
| `../reports/<task-id>/task.md` §Questions | `state.py ask/answer/questions` | yes |
|
|
52
|
+
| `../reports/<task-id>/events.jsonl` | `state.py` (`emit`), append-only | yes |
|
|
53
|
+
|
|
54
|
+
`handoff.md` is the ≤ 30-line frame a session that lost its context reads first:
|
|
55
|
+
the goal, the next action and its resume point, the pending questions, the latest
|
|
56
|
+
decisions, rejected options and failed attempts, and the user's last instruction
|
|
57
|
+
verbatim. It is rendered from the four sources above, so deleting it loses
|
|
58
|
+
nothing — the next stage-moving command writes it again.
|
|
59
|
+
|
|
60
|
+
`events.jsonl` is the journal: one line per state change, `history[]`'s facts
|
|
61
|
+
with types beside them. It is best effort by contract — a line that cannot be
|
|
62
|
+
written never fails the command that emitted it — so nothing depends on it being
|
|
63
|
+
complete, and readers skip a line they cannot parse.
|
|
64
|
+
|
|
65
|
+
If this file is corrupt, `state.py` refuses to read it rather than replacing it.
|
|
66
|
+
Inspect it by hand: the scope guard's boundaries come from here, and a silently
|
|
67
|
+
regenerated state file means an unbounded implementer.
|
|
@@ -244,7 +244,8 @@
|
|
|
244
244
|
"ad4c4f81855725f7fe6e0e3d8f557df0de5fc063465d428546f69c54020c69d9",
|
|
245
245
|
"19b1a6390e96136619d23bc2ef9afed8f15e62107de7c1052bc5ea23e5bb5692",
|
|
246
246
|
"23a42febbacaa7b52a802d428033a99c0694391c81d51fe3b6e1dff608a641d5",
|
|
247
|
-
"7ee99edafe7cec45ff577376c0d0178fdb2690134d4656488f46e129aba3cc5a"
|
|
247
|
+
"7ee99edafe7cec45ff577376c0d0178fdb2690134d4656488f46e129aba3cc5a",
|
|
248
|
+
"a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9"
|
|
248
249
|
],
|
|
249
250
|
"ai-init/.ai/templates/architecture-decision.md": [
|
|
250
251
|
"78619b522826ba8f5f0422e9deb7359c8002553d1aeb29a3719da5ab11ff13a1"
|
|
@@ -61,5 +61,5 @@ def plan(ctx):
|
|
|
61
61
|
"the managed block below it now says the same thing" % path)
|
|
62
62
|
if ctx.block_status(path) == "edited":
|
|
63
63
|
ctx.hint("%s: the managed block was edited here, so the plugin's stub cannot "
|
|
64
|
-
"replace it — move your lines below the block and run /
|
|
64
|
+
"replace it — move your lines below the block and run /rr-setup --update "
|
|
65
65
|
"again" % path)
|
|
@@ -54,7 +54,7 @@ TEST_KEYS = ("timeout_seconds", "env_retries", "log_max_bytes")
|
|
|
54
54
|
def _loads(raw):
|
|
55
55
|
try:
|
|
56
56
|
doc = json.loads(raw, object_pairs_hook=OrderedDict)
|
|
57
|
-
except (TypeError, ValueError):
|
|
57
|
+
except (TypeError, ValueError, RecursionError): # nested past the parser's limit: edit_json holds the schema
|
|
58
58
|
return None
|
|
59
59
|
return doc if isinstance(doc, dict) else None
|
|
60
60
|
|
|
@@ -111,21 +111,17 @@ def _hint(doc, axis, paths, note):
|
|
|
111
111
|
_add(hint.setdefault("paths", []), paths)
|
|
112
112
|
|
|
113
113
|
|
|
114
|
-
def
|
|
115
|
-
"""
|
|
116
|
-
|
|
117
|
-
with. A scalar is carried only when the old table changed it and `base`
|
|
118
|
-
still holds the shipped weight value — a project's own weight.json edit
|
|
119
|
-
wins. `base` is not changed, and converting twice adds nothing new."""
|
|
114
|
+
def _writes(old, base, shipped):
|
|
115
|
+
"""What converting `old` into `base` writes, in order: ("list", path, values) appends to a list,
|
|
116
|
+
("set", path, value) sets a scalar, ("hint", axis, (paths, note)) adds to a hint."""
|
|
120
117
|
shipped_examples, shipped_scopes, shipped_scalars, template = shipped
|
|
121
|
-
|
|
122
|
-
axes = new.setdefault("axes", OrderedDict())
|
|
118
|
+
out = []
|
|
123
119
|
for tier, entry in (old.get("tiers") or {}).items():
|
|
124
120
|
if tier in EXAMPLE_MODE and isinstance(entry, dict):
|
|
125
121
|
extra = [e for e in entry.get("examples") or []
|
|
126
122
|
if isinstance(e, str) and e not in shipped_examples.get(tier, set())]
|
|
127
123
|
if extra:
|
|
128
|
-
|
|
124
|
+
out.append(("list", ("examples", EXAMPLE_MODE[tier]), extra))
|
|
129
125
|
for scope in old.get("path_scopes") or []:
|
|
130
126
|
if not isinstance(scope, dict):
|
|
131
127
|
continue
|
|
@@ -136,23 +132,41 @@ def convert(old, base, shipped):
|
|
|
136
132
|
continue
|
|
137
133
|
if name in DOMAIN or (name not in HINTS and name not in INFRA | NEEDS_STEP
|
|
138
134
|
and scope.get("min_tier") in ("T5", "T6")):
|
|
139
|
-
|
|
135
|
+
out.append(("list", ("axes", "domain", "modules"), paths))
|
|
140
136
|
elif name in HINTS:
|
|
141
|
-
|
|
137
|
+
out.append(("hint", HINTS[name], (paths, "%s (from risk-tiers.json)" % name)))
|
|
142
138
|
elif name in INFRA | NEEDS_STEP:
|
|
143
|
-
rev = axes.setdefault("reversibility", OrderedDict())
|
|
144
139
|
two = [p for p in paths if name in INFRA and "prod" in p]
|
|
145
|
-
|
|
146
|
-
|
|
140
|
+
out.append(("list", ("axes", "reversibility", "irreversible"), two))
|
|
141
|
+
out.append(("list", ("axes", "reversibility", "needs_step"), [p for p in paths if p not in two]))
|
|
147
142
|
for key, value in _scalars(old).items():
|
|
148
143
|
if value in shipped_scalars.get(key, []) or _get(base, key) != _get(template, key):
|
|
149
144
|
continue
|
|
150
145
|
if key == "tests.max_suite_runs" and isinstance(value, int):
|
|
151
146
|
value = min(value, 2)
|
|
152
|
-
|
|
153
|
-
|
|
147
|
+
out.append(("set", tuple(key.split(".")), value))
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def convert(old, base, shipped):
|
|
152
|
+
"""The weight table `base` plus what the old table `old` added on top of the
|
|
153
|
+
`shipped` (examples, scopes, scalars, weight template) it was installed
|
|
154
|
+
with. A scalar is carried only when the old table changed it and `base`
|
|
155
|
+
still holds the shipped weight value — a project's own weight.json edit
|
|
156
|
+
wins. `base` is not changed, and converting twice adds nothing new."""
|
|
157
|
+
new = json.loads(json.dumps(base), object_pairs_hook=OrderedDict)
|
|
158
|
+
new.setdefault("axes", OrderedDict())
|
|
159
|
+
for kind, path, value in _writes(old, base, shipped):
|
|
160
|
+
if kind == "hint":
|
|
161
|
+
_hint(new, path, *value)
|
|
162
|
+
continue
|
|
163
|
+
node = new
|
|
164
|
+
for part in path[:-1]:
|
|
165
|
+
node = node.setdefault(part, OrderedDict())
|
|
166
|
+
if kind == "list":
|
|
167
|
+
_add(node.setdefault(path[-1], []), value)
|
|
154
168
|
else:
|
|
155
|
-
|
|
169
|
+
node[path[-1]] = value
|
|
156
170
|
return new
|
|
157
171
|
|
|
158
172
|
|
|
@@ -168,19 +182,112 @@ def _mode(state):
|
|
|
168
182
|
return state
|
|
169
183
|
|
|
170
184
|
|
|
185
|
+
def _kind(value):
|
|
186
|
+
"""the JSON name of a value's type"""
|
|
187
|
+
if value is None:
|
|
188
|
+
return "null"
|
|
189
|
+
if isinstance(value, bool): # before int: True is an int
|
|
190
|
+
return "boolean"
|
|
191
|
+
for types, name in (((int, float), "number"), (str, "string"), (list, "list"), (dict, "object")):
|
|
192
|
+
if isinstance(value, types):
|
|
193
|
+
return name
|
|
194
|
+
return type(value).__name__
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _an(word):
|
|
198
|
+
return word if word == "null" else ("an " if word[0] in "aeiou" else "a ") + word
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _bad(doc, key, want, what):
|
|
202
|
+
"""A reason when `doc[key]`, present and not null, is not a `want`; else None."""
|
|
203
|
+
value = doc.get(key)
|
|
204
|
+
if value is not None and not isinstance(value, want):
|
|
205
|
+
return "%s is %s, not %s" % (what, _an(_kind(value)), _an("object" if want is dict else "list"))
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _old_problem(old):
|
|
210
|
+
"""Why the old table cannot be converted (a wrong type where convert reads), or None."""
|
|
211
|
+
if not isinstance(old, dict):
|
|
212
|
+
return "risk-tiers.json is %s, not an object" % _an(_kind(old))
|
|
213
|
+
why = _bad(old, "tiers", dict, "tiers") if old.get("tiers") else None # falsy: convert reads no tiers
|
|
214
|
+
for tier, entry in (old.get("tiers") or {}).items() if why is None else ():
|
|
215
|
+
if tier in EXAMPLE_MODE and isinstance(entry, dict) and entry.get("examples"): # convert reads only these
|
|
216
|
+
why = why or _bad(entry, "examples", (list, dict), "tiers.%s.examples" % tier) # an object iterates by key
|
|
217
|
+
scopes = old.get("path_scopes")
|
|
218
|
+
if scopes and not isinstance(scopes, (list, dict, str)): # an object or a string iterates, its members skipped
|
|
219
|
+
why = why or "path_scopes is %s, not a list" % _an(_kind(scopes))
|
|
220
|
+
for scope in scopes if isinstance(scopes, list) and not why else ():
|
|
221
|
+
if isinstance(scope, dict):
|
|
222
|
+
name = scope.get("scope")
|
|
223
|
+
if scope.get("paths"):
|
|
224
|
+
why = _bad(scope, "paths", (list, dict), "a path scope's paths") # an object iterates by key
|
|
225
|
+
# the name is hashed only against a str path
|
|
226
|
+
if not why and isinstance(name, (list, dict)) and any(isinstance(p, str) for p in scope.get("paths") or []):
|
|
227
|
+
why = "a path scope's name is %s, not a string" % _an(_kind(name))
|
|
228
|
+
if why:
|
|
229
|
+
break
|
|
230
|
+
return why
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _new_problem(doc, writes):
|
|
234
|
+
"""Why the weight table `doc` cannot take the `writes` of convert (a wrong type where convert writes), or None."""
|
|
235
|
+
if not isinstance(doc, dict):
|
|
236
|
+
return "weight.json is %s, not an object" % _an(_kind(doc))
|
|
237
|
+
for kind, path, value in writes:
|
|
238
|
+
if kind == "hint":
|
|
239
|
+
if "hints" not in doc:
|
|
240
|
+
continue
|
|
241
|
+
if not isinstance(doc["hints"], list):
|
|
242
|
+
return "hints is %s, not a list" % _an(_kind(doc["hints"]))
|
|
243
|
+
for i, hint in enumerate(doc["hints"]):
|
|
244
|
+
if isinstance(hint, dict) and hint.get("axis") == path:
|
|
245
|
+
if "paths" in hint and not isinstance(hint["paths"], list):
|
|
246
|
+
return "hints[%d].paths is %s, not a list" % (i, _an(_kind(hint["paths"])))
|
|
247
|
+
break
|
|
248
|
+
continue
|
|
249
|
+
node = doc
|
|
250
|
+
for i, part in enumerate(path):
|
|
251
|
+
if part not in node:
|
|
252
|
+
break
|
|
253
|
+
node = node[part]
|
|
254
|
+
what = ".".join(path[:i + 1])
|
|
255
|
+
if i < len(path) - 1:
|
|
256
|
+
if not isinstance(node, dict):
|
|
257
|
+
return "%s is %s, not an object" % (what, _an(_kind(node)))
|
|
258
|
+
elif kind == "list" and value and not isinstance(node, list):
|
|
259
|
+
return "%s is %s, not a list" % (what, _an(_kind(node)))
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
171
263
|
def plan(ctx):
|
|
172
264
|
old = ctx.read_json(OLD)
|
|
265
|
+
why = _old_problem(old) if old is not None else None
|
|
266
|
+
if why:
|
|
267
|
+
ctx.conflict(OLD, "%s, so this migration could not run" % why)
|
|
268
|
+
old = None
|
|
173
269
|
shipped = _shipped(ctx)
|
|
174
270
|
before = ctx.read(NEW)
|
|
175
271
|
if before is None:
|
|
176
272
|
template = ctx.template(NEW)
|
|
177
273
|
doc = convert(old, _loads(template), shipped) if old is not None else None
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
274
|
+
try:
|
|
275
|
+
body = None if doc is None or doc == _loads(template) else \
|
|
276
|
+
(json.dumps(doc, indent=2, ensure_ascii=False) + "\n").encode()
|
|
277
|
+
except RecursionError: # a deep value carried from the old table: the default beside it holds the schema
|
|
278
|
+
ctx.conflict(OLD, "nested too deeply, so this migration could not run")
|
|
279
|
+
body = None
|
|
280
|
+
ctx.create(NEW, template if body is None else body)
|
|
182
281
|
elif old is not None:
|
|
183
|
-
|
|
282
|
+
try:
|
|
283
|
+
current = json.loads(before)
|
|
284
|
+
except (ValueError, RecursionError): # invalid or too deep: edit_json holds the schema
|
|
285
|
+
current = {}
|
|
286
|
+
why = _new_problem(current, _writes(old, current, shipped))
|
|
287
|
+
if why:
|
|
288
|
+
ctx.conflict(NEW, "%s, so this migration could not run" % why)
|
|
289
|
+
else:
|
|
290
|
+
ctx.edit_json(NEW, lambda new: convert(old, new, shipped))
|
|
184
291
|
after = ctx.read(NEW)
|
|
185
292
|
if old is not None and _loads(after) != _loads(before or ctx.template(NEW)):
|
|
186
293
|
ctx.hint("%s now carries this project's risk-tier edits: run `state.py weight --mirror` to "
|
|
@@ -21,7 +21,17 @@ this directory, named `NNNN_slug.py`, numbered contiguously from `0001`, with
|
|
|
21
21
|
moves a path a task's step may touch must patch it, or the scope guard will
|
|
22
22
|
stop that task on a file that no longer exists.
|
|
23
23
|
- Operations are planned, never written: the dry run must list everything.
|
|
24
|
-
Paths are project-relative and normalised, and content is `bytes
|
|
24
|
+
Paths are project-relative and normalised, and content is `bytes`; the
|
|
25
|
+
`note` of `ctx.conflict` / `ctx.edit_text` and the `reason` of `ctx.delete` are
|
|
26
|
+
`str`. The type is checked when the operation is planned: any other type stops
|
|
27
|
+
the update with a SchemaError (exit 2). A call that changes nothing (an
|
|
28
|
+
`edit_text` whose `fn` returns its input, a `delete` of a path that is not
|
|
29
|
+
there) plans nothing and is not checked.
|
|
30
|
+
- `ctx.edit_json(path, fn)` hands `fn` a deep copy of the document and takes the
|
|
31
|
+
new one back. `fn` must not recurse in Python over the document (a file that
|
|
32
|
+
passed the deep copy can still be ~470-490 levels deep), or it guards
|
|
33
|
+
`RecursionError` itself; an unguarded `fn`'s `RecursionError` fails the run as
|
|
34
|
+
the migration's own bug, naming no file.
|
|
25
35
|
- `plan(ctx)` runs again on every run until the schema version advances, and a
|
|
26
36
|
conflict or an unconfirmed deletion holds it back. Every `fn` must therefore be
|
|
27
37
|
idempotent: append a line only when it is not already there, or the next run
|
|
@@ -45,6 +55,11 @@ own text instead of matching a pattern against the human's prose:
|
|
|
45
55
|
else.
|
|
46
56
|
- `ctx.hint(text)` — say something to the human that is not an operation on a
|
|
47
57
|
file. The right answer whenever the text is no longer the plugin's.
|
|
58
|
+
- `ctx.conflict(path, note)` — a file the migration cannot handle (wrong JSON
|
|
59
|
+
types, say) is a conflict: it holds the schema version, so the next run tries
|
|
60
|
+
again. `note` says why, and must be `str`: any other type stops the update
|
|
61
|
+
with a SchemaError (`%04d: <path> note must be str, not <type>`, exit 2), as
|
|
62
|
+
content that is not `bytes` does.
|
|
48
63
|
- `ctx.template(path)` — the bytes the plugin ships today at that path, from the
|
|
49
64
|
scaffold or the shared tree. `ctx.is_shipped(path, data)` — whether these bytes
|
|
50
65
|
are a version ever shipped there (CRLF counts; `index.json` alone answers).
|