riskrails 4.0.1 → 4.1.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.
@@ -5,21 +5,33 @@ and whether superpowers is on (spec R9, R28, SQ5, SQ12 variant B).
5
5
  setup.py survey [--root DIR] the toolbox, the proposed verify_command, the superpowers line
6
6
  setup.py check [--root DIR] exit 1 VERIFY_COMMAND_MISSING unless verification.md names one
7
7
  setup.py verify [--root DIR] --set CMD write verify_command into verification.md (after the human confirmed)
8
-
9
- survey and check write nothing. The toolbox is production behaviour (C1): it is
8
+ setup.py options [--root DIR] [--all] [--skip KEY]… --json
9
+ what each of the four setup options is now, as one JSON object
10
+ setup.py options [--root DIR] [--all] [--skip KEY]… --open
11
+ the unset ones as one dialog. First line: OPTIONS_DIALOG D<n> …
12
+ (then state.py's two lines), OPTIONS_FALLBACK <why> (the questions
13
+ as prose) or OPTIONS_NONE
14
+
15
+ --skip KEY (memory|runtime|survey|verify, repeatable) leaves that option out of what is asked, for both outputs and
16
+ with --all; its entry in the --json facts stays. survey, check and options --json write nothing; options --open
17
+ writes only the decisions state.py opens, none in the fallback. The toolbox is production behaviour (C1): it is
10
18
  inventoried in place, never copied or moved; what to inventory is the constants
11
19
  FOREIGN_ROOTS and OWN_* below.
12
20
  The enabledPlugins line is printed, never written: .claude/settings.json is the
13
21
  human's.
14
22
 
15
23
  Exit codes: 0 done; 1 VERIFY_COMMAND_MISSING; 2 usage;
16
- 3 refused (the line is already set).
24
+ 3 refused (the line is already set); 6 state.py refused to open the dialog (its message is passed through).
17
25
  """
18
26
  import argparse
27
+ import hashlib
28
+ import importlib
19
29
  import json
20
30
  import os
21
31
  import re
32
+ import shlex
22
33
  import shutil
34
+ import subprocess
23
35
  import sys
24
36
 
25
37
  HERE = os.path.dirname(os.path.abspath(__file__))
@@ -31,6 +43,20 @@ UNKNOWN = "UNKNOWN — no description and no leading comment"
31
43
  VERIFY_RE = re.compile(r"(?m)^verify_command:[ \t]*(.*)$")
32
44
  MAKE_TARGET_RE = re.compile(r"^([A-Za-z0-9][A-Za-z0-9_. \t-]*?)\s*:(?![=:])(.*)$")
33
45
  BLOCK_SCALAR_RE = re.compile(r"^[|>][-+]?$")
46
+ PENDING_RE = re.compile(r"DIALOG_PENDING ((?:D\d+ )*D\d+) — ") # state.py's first line: the decisions it opened
47
+ # `options`: the sibling skills, the state.py of this install (the one the dialog's commands name), the files the
48
+ # survey signal compares, and the signals that scaffold_ai.runtimes() reads for `--runtime auto` as (instruction
49
+ # file, runtime directory).
50
+ SKILLS = os.path.dirname(HERE)
51
+ AI_TASK = os.path.join(SKILLS, "ai-task")
52
+ STATE_PY = os.path.join(AI_TASK, "state.py")
53
+ OPTION_KEYS = ("memory", "runtime", "survey", "verify") # the four options, in the order they are asked
54
+ OVERVIEW = os.path.join(".ai", "project", "overview.md")
55
+ OVERVIEW_TEMPLATE = os.path.join(SKILLS, "ai-init", "templates", OVERVIEW)
56
+ HISTORY_INDEX = os.path.join(SKILLS, "project-update", "history", "index.json")
57
+ HISTORY_KEY = "ai-init/.ai/project/overview.md"
58
+ RUNTIME_SIGNALS = {"claude": ("CLAUDE.md", ".claude"), "codex": ("AGENTS.md", ".codex"),
59
+ "gemini": ("GEMINI.md", ".gemini"), "junie": (None, ".junie")}
34
60
  # Paths a foreign AI tool owns (Spec Kit, Kiro, AI-DLC, Cursor, Copilot): no part of the project's own toolbox.
35
61
  FOREIGN_ROOTS = [
36
62
  ".specify/",
@@ -113,6 +139,14 @@ def read_text(path):
113
139
  return None
114
140
 
115
141
 
142
+ def read_bytes(path):
143
+ try:
144
+ with open(path, "rb") as fh:
145
+ return fh.read()
146
+ except OSError:
147
+ return None
148
+
149
+
116
150
  def is_crlf(path):
117
151
  try:
118
152
  with open(path, "rb") as fh:
@@ -402,6 +436,304 @@ def write(path, text):
402
436
  os.remove(tmp)
403
437
 
404
438
 
439
+ # ------------------------------------------------------------------ options
440
+
441
+ def sibling(directory, name):
442
+ """A module of this skill or of another, imported once its directory leads sys.path: `python3 -I` puts none
443
+ there, and the module's own imports are by name (memory_sync reaches memory_compact that way). Skills are
444
+ installed independently, so a missing module is an ImportError for the caller to answer."""
445
+ if directory not in sys.path:
446
+ sys.path.insert(0, directory)
447
+ sys.dont_write_bytecode = True # a read-only verb leaves no __pycache__ in the install
448
+ return importlib.import_module(name)
449
+
450
+
451
+ def digest(data):
452
+ """sha256 with LF line ends, as update.py's History.shipped reads a file a CRLF checkout changed."""
453
+ return hashlib.sha256(data.replace(b"\r\n", b"\n")).hexdigest()
454
+
455
+
456
+ def in_git_tree(root):
457
+ try:
458
+ done = subprocess.run(["git", "-C", root, "rev-parse", "--is-inside-work-tree"],
459
+ capture_output=True, text=True, check=False)
460
+ except OSError: # no git binary
461
+ return False
462
+ return done.returncode == 0 and done.stdout.strip() == "true"
463
+
464
+
465
+ def dialog_on(root):
466
+ """state.py's own answer (`consent --json`): the project's dialog.json on, the runtime's dialog tool verified and
467
+ a session id in this environment. False without dialog.json and when state.py cannot answer."""
468
+ if not os.path.isfile(os.path.join(root, ".ai", "policies", "dialog.json")):
469
+ return False
470
+ try:
471
+ done = subprocess.run([sys.executable, "-I", "-B", os.path.join(AI_TASK, "state.py"), "--root", root,
472
+ "consent", "--json"], capture_output=True, text=True, check=False)
473
+ data = json.loads(done.stdout) if done.returncode == 0 else None
474
+ except (OSError, ValueError):
475
+ return False
476
+ return isinstance(data, dict) and data.get("dialog") is True
477
+
478
+
479
+ def author_source(cfg):
480
+ """Where `cfg.author` comes from, in the order memory_sync.MemoryConfig.author tries them."""
481
+ if cfg.local.get("author"):
482
+ return "local config"
483
+ return next(("$" + name for name in ("USER", "USERNAME") if os.environ.get(name)), "default")
484
+
485
+
486
+ def fts5():
487
+ """FTS5 in this Python's sqlite3, by memory_store's own probe (AI_MEMORY_NO_FTS=1 turns it off);
488
+ False without the sqlite3 module."""
489
+ try:
490
+ return sibling(AI_TASK, "memory_store").fts5_available()
491
+ except ImportError:
492
+ return False
493
+
494
+
495
+ def memory_option(root, git):
496
+ """Packages-mode project memory: set once memory.json exists, its mode being `current`. `author` is what
497
+ `memory enable` writes without --author (the local config, else $USER): memory_sync's own answer, never git's."""
498
+ def unavailable(why):
499
+ return {"key": "memory", "state": "unavailable", "why": why}
500
+ if not git:
501
+ return unavailable("not a git work tree")
502
+ try:
503
+ sync = sibling(AI_TASK, "memory_sync")
504
+ except ImportError as exc:
505
+ return unavailable("the memory modules are not installed beside state.py (%s)" % exc)
506
+ try:
507
+ cfg = sync.load_config(root)
508
+ except sync.MemoryError_ as exc:
509
+ return unavailable(str(exc))
510
+ return {"key": "memory", "state": "set" if cfg.present else "unset", "current": cfg.mode if cfg.present else None,
511
+ "author": cfg.author, "author_from": author_source(cfg),
512
+ "period": None if cfg.present else sync.DEFAULTS["period"], "fts5": fts5()}
513
+
514
+
515
+ def runtime_option(root):
516
+ """`declared`: the runtimes the repository already names, by the signals scaffold_ai.runtimes() reads.
517
+ `auto`: what `--runtime auto` would scaffold now, as that function resolves it, in the spelling --runtime takes."""
518
+ declared = [name for name, (instruction, directory) in RUNTIME_SIGNALS.items()
519
+ if (instruction and os.path.exists(os.path.join(root, instruction)))
520
+ or os.path.isdir(os.path.join(root, directory))]
521
+ scaffold = sibling(HERE, "scaffold_ai")
522
+ chosen = scaffold.runtimes("auto", root)
523
+ return {"key": "runtime", "state": "set" if declared else "unset", "declared": declared,
524
+ "auto": ",".join(name for name in scaffold.RUNTIMES if name in chosen)}
525
+
526
+
527
+ def surveyed(root):
528
+ """No depth is recorded anywhere. A survey fills .ai/project/overview.md, so a copy that is none of the files
529
+ the plugin shipped for it (today's template, or a version in project-update's history index: update.py's
530
+ own test for an unedited file) has been written into."""
531
+ mine = read_bytes(os.path.join(root, OVERVIEW))
532
+ if mine is None:
533
+ return False
534
+ shipped = set()
535
+ template = read_bytes(OVERVIEW_TEMPLATE)
536
+ if template is not None:
537
+ shipped.add(digest(template))
538
+ try:
539
+ shipped.update(json.loads(read_text(HISTORY_INDEX) or "{}")[HISTORY_KEY])
540
+ except (ValueError, KeyError, TypeError):
541
+ pass
542
+ return digest(mine) not in shipped
543
+
544
+
545
+ def verify_option(root):
546
+ """Set by check's own rule (a value that is not `none`); else the proposal `survey` makes, and the others."""
547
+ current = verify_value(root)
548
+ if current and current.lower() != "none":
549
+ return {"key": "verify", "state": "set", "current": current}
550
+ found = [{"command": command, "from": source} for command, source in verify_candidates(root)]
551
+ return {"key": "verify", "state": "unset", "proposal": found[0] if found else None, "also": found[1:]}
552
+
553
+
554
+ def option_facts(root, show_all=False, skip=()):
555
+ """What each of the four setup options is now, read-only (spec interactive-setup-options R9, R16). `ask` lists
556
+ the unset ones in the order memory, runtime, survey, verify; `show_all` lists every one not unavailable; `skip`
557
+ leaves keys out of it that the caller has answered another way (/rr-setup --survey). The facts of a skipped
558
+ option stay as computed: they are still true, and only what is asked changes."""
559
+ git = in_git_tree(root)
560
+ options = [memory_option(root, git), runtime_option(root),
561
+ {"key": "survey", "state": "set" if surveyed(root) else "unset"}, verify_option(root)]
562
+ return {"schema": 1, "root": root, "git": git, "dialog": dialog_on(root),
563
+ "ask": [o["key"] for o in options if o["key"] not in skip
564
+ and (o["state"] == "unset" or (show_all and o["state"] == "set"))],
565
+ "options": options}
566
+
567
+
568
+ # ------------------------------------------------------------ options --open
569
+
570
+ def state_label(root, *words):
571
+ """The command that runs this install's state.py with `words` on this project, absolute and quoted as
572
+ rr_state.labels.state_label writes it: a pick and the call the agent makes are then one string. A copy, not an
573
+ import: rr_state.fs.ENTRY is only set by state.py's own entry."""
574
+ return "python3 %s %s" % (shlex.quote(STATE_PY), shlex.join(["--root", root, *words]))
575
+
576
+
577
+ def choice_option(label, value):
578
+ return {"kind": "choice", "label": label, "value": value}
579
+
580
+
581
+ def command_option(label, description):
582
+ return {"kind": "command", "label": label, "description": description}
583
+
584
+
585
+ def shown(text):
586
+ """`text` inside backticks, as a label shows a command, or None when the opener would refuse it: a backtick of its
587
+ own ends the span early, and a choice value is at most 200 printable characters (dialog_cmd.SETUP_VALUE_MAX), so
588
+ that "Use `…`" stays inside the labels' 400."""
589
+ return "`%s`" % text if text and "`" not in text and len(text) <= 200 and text.isprintable() else None
590
+
591
+
592
+ def decision(key, question, options):
593
+ return {"key": key, "header": key.capitalize(), "question": question, "options": options}
594
+
595
+
596
+ def memory_decision(option, root, name):
597
+ """Memory is asked first, so that its pick is the earliest the gate passes (spec B3). Its commands are `memory
598
+ enable` of this install's state.py: --period only while memory.json is absent (`period` is then set), never
599
+ --author, because enable writes the author the question names (D1): the local config's, else $USER's."""
600
+ period = ["--period", option["period"]] if option["period"] else []
601
+ who = "Author %s (from %s)" % (option["author"], option["author_from"])
602
+ if option["state"] == "set":
603
+ first = choice_option("Keep — %s mode" % option["current"], "keep")
604
+ question = ("Project memory for %s is in %s mode: off keeps closed tasks only in .ai/reports/. packages also writes "
605
+ "one compact committed file per developer per period under .ai/memory/, checked by a pre-push hook. "
606
+ "%s. Keep it, or run memory enable?" % (name, option["current"], who))
607
+ else:
608
+ first = choice_option("Off — keep files mode", "off")
609
+ question = ("Project memory for %s: off keeps closed tasks only in .ai/reports/. packages also writes one compact "
610
+ "committed file per developer per %s under .ai/memory/, checked by a pre-push hook. %s, period %s. "
611
+ "Turn it on?" % (name, option["period"], who, option["period"]))
612
+ return decision("memory", question, [
613
+ first,
614
+ command_option(state_label(root, "memory", "enable", *period),
615
+ "packages; installs .git/hooks/pre-push unless one exists or core.hooksPath is set"),
616
+ command_option(state_label(root, "memory", "enable", *period, "--index", "sqlite"),
617
+ "plus a local, git-ignored SQLite index; FTS5 %s — without it search scans: same results, slower"
618
+ % ("yes" if option["fts5"] else "no"))])
619
+
620
+
621
+ def runtime_decision(option, name):
622
+ """No "Not now": the four runtimes use all the options a question has. `auto` names what it resolves to: the
623
+ runtime this copy is installed for while the repository declares none, else what it declares."""
624
+ auto = "Auto — what the repository declares"
625
+ auto += " (%s)" % option["auto"] if option["declared"] else ", else %s" % option["auto"]
626
+ return decision("runtime", "Which agent runtimes work on %s? The scaffold adds their instruction files and directories "
627
+ "and never removes one." % name,
628
+ [choice_option(auto, "auto"), choice_option("Claude Code only", "claude"),
629
+ choice_option("Codex only", "codex"), choice_option("Claude Code and Codex", "both")])
630
+
631
+
632
+ def survey_decision(option, name):
633
+ options = [choice_option("Light — your 10-minute brain dump + 2 discovery agents", "light"),
634
+ choice_option("Full — inventory + 6 discovery agents", "full")]
635
+ if option["state"] == "set":
636
+ options.append(choice_option("Keep the current survey", "keep"))
637
+ return decision("survey", "How deep should the first survey of %s go?" % name, options)
638
+
639
+
640
+ def verify_decision(option):
641
+ """Unset: not now, the proposal, at most one other candidate, or typed in chat (a free-text answer is a decline,
642
+ so a command of the human's own is typed after the dialog: spec A3). A command the opener would refuse is not
643
+ offered. Set (--all): keep it, or edit it by hand — verify_command is the human's once written."""
644
+ intro = "verify_command — the fast suite every task runs, e2e excluded."
645
+ if option["state"] == "set":
646
+ current = shown(option["current"])
647
+ return decision("verify", "%s Current: %s." % (intro, current or "set in .ai/policies/verification.md"),
648
+ [choice_option("Keep %s" % current if current else "Keep the current command", "keep"),
649
+ choice_option("Change — I edit it myself", "edit")])
650
+ proposal = [option["proposal"]] if option["proposal"] and shown(option["proposal"]["command"]) else []
651
+ also = [c for c in option["also"] if shown(c["command"])][:1]
652
+ found = proposal + also
653
+ options = [choice_option("Not now — ask me in chat", "")]
654
+ options += [choice_option("Use %s" % shown(c["command"]), c["command"]) for c in found]
655
+ options.append(choice_option("Another — I type it in chat", "other"))
656
+ proposed = ("Proposed: %s (from %s)." % (shown(found[0]["command"]), found[0]["from"]) if found
657
+ else "No proposal: nothing found in the project's files.")
658
+ return decision("verify", "%s %s" % (intro, proposed), options)
659
+
660
+
661
+ def decision_for(option, root, name):
662
+ if option["key"] == "memory":
663
+ return memory_decision(option, root, name)
664
+ if option["key"] == "runtime":
665
+ return runtime_decision(option, name)
666
+ if option["key"] == "survey":
667
+ return survey_decision(option, name)
668
+ return verify_decision(option)
669
+
670
+
671
+ def dialog_off_why(root):
672
+ """Why state.py's switch is off here, as the first cause found in the order it tests them: the project's
673
+ dialog.json, an unattended run or session, then the two it cannot be told apart by from here (the runtime's dialog
674
+ tool the installer verified, a session id)."""
675
+ try:
676
+ with open(os.path.join(root, ".ai", "policies", "dialog.json"), encoding="utf-8") as fh:
677
+ enabled = (json.load(fh) or {}).get("enabled") is True
678
+ except (OSError, ValueError, AttributeError):
679
+ enabled = False
680
+ if not enabled:
681
+ return "no enabled .ai/policies/dialog.json"
682
+ if os.environ.get("AI_UNATTENDED", "").strip().lower() not in ("", "0", "false", "no"):
683
+ return "AI_UNATTENDED is set (an unattended run)"
684
+ if os.environ.get("CLAUDE_CODE_SESSION_ATTENDED") == "0":
685
+ return "CLAUDE_CODE_SESSION_ATTENDED=0 (an unattended session)"
686
+ return "the runtime's dialog tool is not verified (riskrails/profile.json dialog.tool) or this session has no id"
687
+
688
+
689
+ def open_dialog(root, decisions):
690
+ """The decisions piped to this install's state.py, which opens all of them or none. Returns (exit code, None) once
691
+ it answered: 0 with OPTIONS_DIALOG and its two lines unchanged, 6 with its refusal passed through (the skill
692
+ settles what is open first, or falls back); (None, why) when it found the dialog off after all, for the fallback."""
693
+ spec = json.dumps({"schema": 1, "decisions": decisions}) # ASCII only: no locale decides how it is read
694
+ try:
695
+ done = subprocess.run([sys.executable, "-I", "-B", "-X", "utf8", STATE_PY, "--root", root, "decision", "open",
696
+ "--kind", "setup", "--spec", "-"],
697
+ input=spec, capture_output=True, encoding="utf-8", errors="replace", check=False)
698
+ except OSError as exc:
699
+ sys.stderr.write("setup.py: cannot run %s (%s)\n" % (STATE_PY, exc))
700
+ return 6, None
701
+ if done.returncode == 1: # its die() default: the dialog off after all, or a decision.json it cannot read
702
+ last = ([line for line in done.stderr.splitlines() if line.strip()] or ["no message"])[-1]
703
+ text = " ".join(last.replace(root + os.sep, "").replace("state.py: ", "", 1).split())
704
+ return None, "state.py could not open the dialog: %s" % (text if len(text) <= 200 else text[:199] + "…")
705
+ if done.returncode != 0:
706
+ sys.stderr.write(done.stderr or "setup.py: state.py exited %d without a message\n" % done.returncode)
707
+ return 6, None
708
+ ids = PENDING_RE.match(done.stdout.split("\n", 1)[0])
709
+ if not ids:
710
+ sys.stderr.write("setup.py: state.py answered with no DIALOG_PENDING line; 'state.py decision show' lists "
711
+ "what it opened\n")
712
+ return 6, None
713
+ sys.stderr.write(done.stderr)
714
+ print("OPTIONS_DIALOG %s" % ids.group(1))
715
+ sys.stdout.write(done.stdout)
716
+ return 0, None
717
+
718
+
719
+ def fallback_lines(root, why, decisions):
720
+ """The questions as numbered prose, in the dialog's words (a command is its arguments after the project's root, and
721
+ its description), then, when memory is asked, the command the human runs: nothing is enabled from here, and
722
+ nothing is written."""
723
+ prefix = state_label(root) + " "
724
+ lines = ["OPTIONS_FALLBACK %s" % why]
725
+ for number, item in enumerate(decisions, 1):
726
+ lines.append("%d. %s: %s" % (number, item["header"], item["question"]))
727
+ lines += [" %s) %s" % (letter, o["label"] if o["kind"] == "choice"
728
+ else "%s — %s" % (o["label"][len(prefix):], o["description"]))
729
+ for letter, o in zip("abcd", item["options"])]
730
+ memory = next((d for d in decisions if d["key"] == "memory"), None)
731
+ if memory:
732
+ enable = next(o["label"] for o in memory["options"] if o["kind"] == "command")
733
+ lines.append("memory is the human's, in their own terminal: %s [--index sqlite]" % enable)
734
+ return lines
735
+
736
+
405
737
  # ----------------------------------------------------------------- commands
406
738
 
407
739
  def cmd_survey(root):
@@ -456,14 +788,45 @@ def cmd_verify(root, command):
456
788
  return 0
457
789
 
458
790
 
791
+ def cmd_options(root, show_all, skip):
792
+ print(json.dumps(option_facts(root, show_all, skip), ensure_ascii=False))
793
+ return 0
794
+
795
+
796
+ def cmd_options_open(root, show_all, skip):
797
+ """R10, R17: nothing to ask → OPTIONS_NONE; the dialog on → the questions opened through state.py; else the
798
+ questions as prose. Memory is only ever in `ask` inside a git work tree (R16)."""
799
+ facts = option_facts(root, show_all, skip)
800
+ if not facts["ask"]:
801
+ print("OPTIONS_NONE")
802
+ return 0
803
+ name = os.path.basename(root) or root
804
+ by_key = {o["key"]: o for o in facts["options"]}
805
+ decisions = [decision_for(by_key[key], root, name) for key in facts["ask"]]
806
+ if facts["dialog"]:
807
+ code, why = open_dialog(root, decisions)
808
+ if code is not None:
809
+ return code
810
+ else:
811
+ why = dialog_off_why(root)
812
+ print("\n".join(fallback_lines(root, why, decisions)))
813
+ return 0
814
+
815
+
459
816
  def main(argv=None):
460
817
  parser = argparse.ArgumentParser(prog="setup.py", description=__doc__.split("\n\n", 1)[0])
461
818
  sub = parser.add_subparsers(dest="cmd", required=True)
462
- for name in ("survey", "check", "verify"):
819
+ for name in ("survey", "check", "verify", "options"):
463
820
  p = sub.add_parser(name)
464
821
  p.add_argument("--root", default=".")
465
822
  if name == "verify":
466
823
  p.add_argument("--set", dest="command", required=True)
824
+ if name == "options":
825
+ p.add_argument("--all", action="store_true")
826
+ p.add_argument("--skip", action="append", default=[], choices=OPTION_KEYS)
827
+ output = p.add_mutually_exclusive_group(required=True)
828
+ output.add_argument("--json", action="store_true")
829
+ output.add_argument("--open", action="store_true")
467
830
  args = parser.parse_args(argv)
468
831
  root = os.path.abspath(args.root)
469
832
  if not os.path.isdir(root):
@@ -472,6 +835,9 @@ def main(argv=None):
472
835
  return cmd_survey(root)
473
836
  if args.cmd == "check":
474
837
  return cmd_check(root)
838
+ if args.cmd == "options":
839
+ run = cmd_options if args.json else cmd_options_open
840
+ return run(root, args.all, args.skip)
475
841
  return cmd_verify(root, args.command)
476
842
 
477
843