riskrails 3.2.2 → 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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +50 -84
- package/agents/ai-reviewer.md.tmpl +1 -1
- package/ai/using-riskrails.md +0 -3
- package/bin/menu.js +116 -0
- package/bin/riskrails.js +132 -79
- package/hooks/ai-path-guard-defaults.json +1 -1
- package/hooks/context-guard.py +2 -4
- package/hooks/guard/dialog.py +14 -2
- package/hooks/guard/paths.py +3 -6
- package/hooks/guard/scope.py +1 -3
- package/hooks/runtime-gate.py +18 -58
- package/instructions/routing.md +2 -2
- package/package.json +1 -1
- package/scripts/ai-path +1 -1
- package/scripts/resolve-profile.py +0 -3
- package/skills/ai-audit/templates/ai-sdlc-adoption-plan.md +1 -1
- package/skills/ai-audit/templates/play-scoring-rubric.md +1 -1
- package/skills/ai-init/scaffold-ai.sh +2 -1
- package/skills/ai-init/templates/.ai/AGENTS.md +6 -6
- package/skills/ai-init/templates/.ai/VERSION +1 -1
- package/skills/ai-init/templates/.ai/policies/safety.md +4 -3
- package/skills/ai-init/templates/.ai/policies/security.md +1 -2
- package/skills/ai-init/templates/.ai/policies/testing.md +5 -12
- package/skills/ai-init/templates/.ai/policies/verification.md +18 -0
- package/skills/ai-init/templates/.ai/policies/weight.json +0 -1
- package/skills/ai-init/templates/.ai/policies/weight.md +1 -2
- package/skills/ai-init/templates/.ai/policies/weight.project.json +4 -0
- package/skills/ai-init/templates/.ai/project/architecture.md +1 -1
- package/skills/ai-init/templates/.ai/project/initial-assessment.md +2 -2
- package/skills/ai-init/templates/.ai/project/overview.md +1 -1
- package/skills/ai-init/templates/.ai/rules/README.md +2 -2
- package/skills/ai-init/templates/.ai/state/README.md +1 -1
- package/skills/ai-task/memory_store.py +8 -9
- package/skills/ai-task/rr_state/cli.py +2 -2
- package/skills/ai-task/rr_state/dialog.py +2 -4
- package/skills/ai-task/rr_state/dialog_cmd.py +183 -6
- package/skills/ai-task/rr_state/fs.py +5 -13
- package/skills/ai-task/rr_state/labels.py +14 -2
- package/skills/ai-task/rr_state/memory_cmd.py +7 -4
- package/skills/ai-task/rr_state/profile.py +2 -4
- package/skills/ai-task/rr_state/questions_cmd.py +1 -1
- package/skills/ai-task/rr_state/report.py +3 -4
- package/skills/ai-task/rr_state/runtime.py +1 -1
- package/skills/ai-task/rr_state/start.py +7 -7
- package/skills/ai-task/sensors.py +10 -11
- package/skills/ai-task/state.py +4 -24
- package/skills/ai-task/weight.py +17 -17
- package/skills/ai-task/weight_overlay.py +38 -0
- package/skills/project-init/templates/constitution.md +2 -2
- package/skills/project-update/history/blobs/00f5e27ce2d66e3a1d8b222b013ae8fa1fed32612d91a551e0c661cee3ef9c2d +34 -0
- package/skills/project-update/history/blobs/0a6b1b6dd06a35582b2e9989a1f41c40abc29254b8e482714fcc6f0a7b16c406 +32 -0
- package/skills/project-update/history/blobs/0aeb04b04262683cac6513545683486a73dc473b447bba43b5aaab18403957a6 +34 -0
- package/skills/project-update/history/blobs/0ce913a28996f546f72d570b0e5a376362e993ce4d805bc4aae57d38caa44212 +41 -0
- package/skills/project-update/history/blobs/0ea9e36446b624b73b2a821231b2ee66a357b43d584a5b23da48b3c800855e65 +41 -0
- package/skills/project-update/history/blobs/0fb1d68678a1349195b2e79433bb20174d25874e3bfd0b70410bb4e428b4e4b8 +35 -0
- package/skills/project-update/history/blobs/119b2b9d0f9c647a243540724edac6b29afa020c69aa205797bcfa9eb06bc021 +35 -0
- package/skills/project-update/history/blobs/1d0fe3ee5fb8260ba610942d4bbdfff7410fbe80ed826ac07f32985031dc2b3f +34 -0
- package/skills/project-update/history/blobs/1de9a37ed6402893dcab6e61ddbf15ffd35f15a6d84b18e675a0c5bf4e981f85 +42 -0
- package/skills/project-update/history/blobs/1f44e1a236233dcbab58064f39b69f798b35d1d9a29f8c615b5b12854ce633cb +15 -0
- package/skills/project-update/history/blobs/1f824645299579cbdd8141642ddbb4bca9fd5fe9d3112f036d8221f0811e0abf +40 -0
- package/skills/project-update/history/blobs/235fb88393cfe20dd58b35fe9785cc45baf5ffe21a13d926652d76ec68cea8b7 +35 -0
- package/skills/project-update/history/blobs/3918b4ee011d9477b9d2779e2893ab599749c406d4b09439a9748b64fbff58f3 +18 -0
- package/skills/project-update/history/blobs/449273200dfec2a56885c881ff34d40e8ecc00da53c24c2d09b7fbeb1004d255 +40 -0
- package/skills/project-update/history/blobs/4c45aee9b9fc5f6b4e1d9739e434c4094975a5a8dec6b9703fb25f3080680c77 +124 -0
- package/skills/project-update/history/blobs/4dd3fa12bc45acc8c836ab65d790248ca7504a021bca6677499d17bb3d798ed9 +64 -0
- package/skills/project-update/history/blobs/51e3f15095d7123ea8566b512479ee10a6cb2f336b7352a259a083a887bd7116 +33 -0
- package/skills/project-update/history/blobs/5497a204967bea7dab29079a6e1dfa3d8713b2d2cb294c2779378039f03a55fc +36 -0
- package/skills/project-update/history/blobs/60c4d7e7fde7049d92d84295fc13ca9a91eb2de423be08f093b3ee5d5e817ff8 +64 -0
- package/skills/project-update/history/blobs/6221b7c14cc04634e485e562d464c6860d45666a7f63e293c06bb2944ff556cc +27 -0
- package/skills/project-update/history/blobs/637971cfb844ef4285e2886a4b1c834fb2ed5c46e773adb88c2c147fea626ad3 +33 -0
- package/skills/project-update/history/blobs/65af2282eea2a51fcc27a4acf2c26c727b0961c30e1be13176369200c460c219 +34 -0
- package/skills/project-update/history/blobs/66ecd73e303e7bbc1aa8ee72435d0d10812ff7b2eff0adac59bbf0a303e2cc01 +41 -0
- package/skills/project-update/history/blobs/6a43bf32461b22b87fafe2865529dc04fa66cd00182a2a7598aa071667ca255a +35 -0
- package/skills/project-update/history/blobs/6d6dee8634abf9ac057bb206828b83a893a2afcf4f86bbe69e839a3b47d0adde +48 -0
- package/skills/project-update/history/blobs/7224ede803ea81e65a456e21b6028a0e848e8a880456c6503eebe7534ca4f6aa +74 -0
- package/skills/project-update/history/blobs/765a39a202617096c232017822ae738d37e4405430c71700dc7bc14bf1ba794e +33 -0
- package/skills/project-update/history/blobs/77577440a933d1cabd8c6f4a6401cf361a784aa09a25500a6f7b44ead8ab5e55 +41 -0
- package/skills/project-update/history/blobs/7bae90789625d6fb9071ecb412d14a8d1eb0bc5659d8d284c4eaf213e1b5de56 +67 -0
- package/skills/project-update/history/blobs/7bcdf9a0e6b85277b9b60669150224b9414d2e32821ff06f4d148eb1d18785bb +42 -0
- package/skills/project-update/history/blobs/7f834103e9d97aea44e76717bcdcbbe349c560e5b81a42ce85a1cec341041281 +4 -0
- package/skills/project-update/history/blobs/7fb3acb0211818efebc2234863db0b8c846022a7280a6d2d69f267dbf63db48c +34 -0
- package/skills/project-update/history/blobs/87f0855859ff68cc6c988cc0b631ee7f56af7fbba062172bf80e01f4982cc054 +35 -0
- package/skills/project-update/history/blobs/8abab74759766d02b5cb7b0c3e59a3d440b25a454216cd8fea325c23fd822d36 +33 -0
- package/skills/project-update/history/blobs/8ec171f0fbdf38cc6996d496f01491fd1f21f04a435a118ad979b127a8be06f1 +85 -0
- package/skills/project-update/history/blobs/a1fb50e6c86fae1679ef3351296fd6713411a08cf8dd1790a4fd05fae8688164 +1 -0
- package/skills/project-update/history/blobs/a3df7268feb0780be0d2cf9acced6ed81780a7790f36fe024b6ea0b1bd6eb7a6 +35 -0
- package/skills/project-update/history/blobs/b28721136482dafb9fd2801af340364cb97e9f6eaa987908606c786045b8d334 +28 -0
- package/skills/project-update/history/blobs/b3726422d767f9219fea633bea77f0c99ad9af843c591e6e928e1ccd2a47f374 +27 -0
- package/skills/project-update/history/blobs/b3839425f9d0ab2efa42a6f5e9488ec2d26ca5fb8dcd694b8ab098e10743ca46 +32 -0
- package/skills/project-update/history/blobs/ba14a457e83f9dde65e4190fdfab83b912f7398c1442a2a1cb3d89bfac741cf3 +8 -0
- package/skills/project-update/history/blobs/bceb7189444bb6291b262ca08e34db01010dda2f6e64d696231ec3c343c95c66 +34 -0
- package/skills/project-update/history/blobs/bf82a2bb4fce1bd349ff66118930a01d61a7af8e2bd8d200c721d43afc58c713 +34 -0
- package/skills/project-update/history/blobs/c4dbeb180292a6199ad73a3be861ece07c1f8bf6bf0cc7ca204412bc8e1bb1d8 +31 -0
- package/skills/project-update/history/blobs/c644838866df2001a42501a988dd7b77aacf862bf09d22b465f3cd8a22c6b6eb +33 -0
- package/skills/project-update/history/blobs/c980d74f76a4bdfb38c94b573ac10a54eb50b850b052e15fa6f2442889b36ccb +32 -0
- package/skills/project-update/history/blobs/ceed2f3bd937f862d0e6ca59ce1b1e3603e19a0d7f85d90d6dfeb7bf690153be +29 -0
- package/skills/project-update/history/blobs/d51ea2e893482a20c5882056cf8043327ca1dc1c957927f6746929a47ee8368e +45 -0
- package/skills/project-update/history/blobs/d78f5e0bb1380259fce2cce009785a9a11f6de8b87a160080b9872c059f95060 +34 -0
- package/skills/project-update/history/blobs/d97f46a2cca020406252b1f7baa4184617fa71faca3885e2379c44ae4e1d9327 +67 -0
- package/skills/project-update/history/blobs/da3ecd4c4c414c93d04488685931df39a56bf959bfeb9c45cb1aea2c13bd991b +38 -0
- package/skills/project-update/history/blobs/e129bf71a5a922e24b2c1bc8daa8a5d2b5589b0069770e17ce17e7db5ed4e7a4 +27 -0
- package/skills/project-update/history/blobs/e504529519a371892838725f8b89b5d32e3300e345bcbf2643a3a1a48434b3dd +24 -0
- package/skills/project-update/history/blobs/e9e62d03ca70b3dffccef8dceeb214173352cfef005145e519f0e037993f1c03 +36 -0
- package/skills/project-update/history/blobs/effefbaaf4beb8f1ba55081d6b7e74bf25ebb91045dd10613418f17b95fa7182 +33 -0
- package/skills/project-update/history/blobs/f6af4581bafbf62b257d57e06c3ae2c5998aeca608ee7df037fcc3c3e4b563a9 +34 -0
- package/skills/project-update/history/blobs/fc35045777d29630303430752a651b3e553580a581036a02cc103eb96a46f4b0 +34 -0
- package/skills/project-update/history/blobs/fdc21345c77f92a42d244aa104f393f439a0ff9981a9fd462bd837e8a47931bb +48 -0
- package/skills/project-update/history/index.json +34 -14
- package/skills/project-update/render_instructions.py +12 -45
- package/skills/project-update/update.py +757 -1172
- package/skills/rr-setup/SKILL.md +114 -97
- package/skills/rr-setup/scaffold_ai.py +15 -9
- package/skills/rr-setup/setup.py +462 -164
- package/skills/rr-status/status.py +7 -10
- package/skills/usage-report/usage-report.py +4 -5
- package/skills/ai-audit/SKILL.md +0 -9
- package/skills/ai-init/SKILL.md +0 -9
- package/skills/ai-status/SKILL.md +0 -9
- package/skills/ai-task/SKILL.md +0 -9
- package/skills/project-init/SKILL.md +0 -9
- package/skills/project-update/SKILL.md +0 -9
- package/skills/project-update/adopt-map.json +0 -91
- package/skills/project-update/adopt.py +0 -2225
- package/skills/project-update/migrations/0001_schema_version.py +0 -13
- package/skills/project-update/migrations/0002_task_journal.py +0 -123
- package/skills/project-update/migrations/0003_context_diet.py +0 -65
- package/skills/project-update/migrations/0004_deterministic_gates.py +0 -57
- package/skills/project-update/migrations/0005_runtimes_and_plans.py +0 -35
- package/skills/project-update/migrations/0006_shared_files.py +0 -154
- package/skills/project-update/migrations/0007_weight_model.py +0 -295
- package/skills/project-update/migrations/0008_renames.py +0 -43
- package/skills/project-update/migrations/0009_state_file.py +0 -25
- package/skills/project-update/migrations/0010_dialog_switch.py +0 -30
- package/skills/project-update/migrations/0011_retire_risk_tiers.py +0 -37
- package/skills/project-update/migrations/README.md +0 -76
- package/skills/project-update/migrations/__init__.py +0 -186
- package/skills/sdlc-intent/SKILL.md +0 -9
- package/skills/sdlc-plan/SKILL.md +0 -9
- package/skills/sdlc-spec/SKILL.md +0 -9
- package/skills/usage-report/SKILL.md +0 -10
- /package/{ai/workflows/refactoring.md → skills/project-update/history/blobs/36cb593293496fbcf38df828a9b9b2bc0d5a53333f584a5319ebc3d799016e24} +0 -0
- /package/{ai/workflows/feature.md → skills/project-update/history/blobs/56e16072c2f3edc6c8897c690d09a11d12fc414c3e3f361f359f7dc609a21282} +0 -0
- /package/{ai/workflows/hotfix.md → skills/project-update/history/blobs/8cbefbf1aa412bae3e5019acd40b8fe0598fceba6c781906b3bc6dbd014f6b23} +0 -0
- /package/{ai/workflows/bugfix.md → skills/project-update/history/blobs/af56dc16e44ae1d8c3dac4c3ca82e2163b719553c02ea455e77430bab9a14089} +0 -0
- /package/{ai/workflows/investigation.md → skills/project-update/history/blobs/b9e09cb24958cd6831589997558974bd6356305306e8f9685e226bcdc30d60ac} +0 -0
- /package/{ai/agents/manager.md → skills/project-update/history/blobs/d0cf6388f35a92a0cf607d33909278382f6d7b6a4c32e281b9f9e0ea652c7b91} +0 -0
package/skills/rr-setup/setup.py
CHANGED
|
@@ -3,32 +3,39 @@
|
|
|
3
3
|
and whether superpowers is on (spec R9, R28, SQ5, SQ12 variant B).
|
|
4
4
|
|
|
5
5
|
setup.py survey [--root DIR] the toolbox, the proposed verify_command, the superpowers line
|
|
6
|
-
setup.py check [--root DIR] exit 1 VERIFY_COMMAND_MISSING unless
|
|
7
|
-
setup.py
|
|
8
|
-
setup.py
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
setup.py check [--root DIR] exit 1 VERIFY_COMMAND_MISSING unless verification.md names one
|
|
7
|
+
setup.py verify [--root DIR] --set CMD write verify_command into verification.md (after the human confirmed)
|
|
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
|
|
18
|
+
inventoried in place, never copied or moved; what to inventory is the constants
|
|
19
|
+
FOREIGN_ROOTS and OWN_* below.
|
|
14
20
|
The enabledPlugins line is printed, never written: .claude/settings.json is the
|
|
15
21
|
human's.
|
|
16
22
|
|
|
17
|
-
Exit codes: 0 done; 1 VERIFY_COMMAND_MISSING; 2 usage
|
|
18
|
-
3 refused (
|
|
23
|
+
Exit codes: 0 done; 1 VERIFY_COMMAND_MISSING; 2 usage;
|
|
24
|
+
3 refused (the line is already set); 6 state.py refused to open the dialog (its message is passed through).
|
|
19
25
|
"""
|
|
20
26
|
import argparse
|
|
27
|
+
import hashlib
|
|
28
|
+
import importlib
|
|
21
29
|
import json
|
|
22
30
|
import os
|
|
23
31
|
import re
|
|
32
|
+
import shlex
|
|
24
33
|
import shutil
|
|
34
|
+
import subprocess
|
|
25
35
|
import sys
|
|
26
36
|
|
|
27
37
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
28
|
-
|
|
29
|
-
TESTING = os.path.join(".ai", "policies", "testing.md")
|
|
30
|
-
TOOLBOX = os.path.join(".ai", "policies", "toolbox.md")
|
|
31
|
-
ROUTER_FILE = os.path.join(".ai", "AGENTS.md")
|
|
38
|
+
VERIFICATION = os.path.join(".ai", "policies", "verification.md")
|
|
32
39
|
SUPERPOWERS_KEY = "superpowers@superpowers-marketplace"
|
|
33
40
|
SKIP_DIRS = {".git", ".ai", "vendor", "node_modules", "__pycache__", ".venv"}
|
|
34
41
|
PURPOSE_MAX = 120
|
|
@@ -36,6 +43,86 @@ UNKNOWN = "UNKNOWN — no description and no leading comment"
|
|
|
36
43
|
VERIFY_RE = re.compile(r"(?m)^verify_command:[ \t]*(.*)$")
|
|
37
44
|
MAKE_TARGET_RE = re.compile(r"^([A-Za-z0-9][A-Za-z0-9_. \t-]*?)\s*:(?![=:])(.*)$")
|
|
38
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")}
|
|
60
|
+
# Paths a foreign AI tool owns (Spec Kit, Kiro, AI-DLC, Cursor, Copilot): no part of the project's own toolbox.
|
|
61
|
+
FOREIGN_ROOTS = [
|
|
62
|
+
".specify/",
|
|
63
|
+
"specs/",
|
|
64
|
+
".claude/commands/speckit*",
|
|
65
|
+
".claude/skills/speckit-*",
|
|
66
|
+
".github/prompts/speckit*",
|
|
67
|
+
".github/skills/speckit-*",
|
|
68
|
+
".github/agents/speckit*",
|
|
69
|
+
".codex/prompts/speckit*",
|
|
70
|
+
".agents/skills/speckit-*",
|
|
71
|
+
".gemini/commands/speckit*",
|
|
72
|
+
".cursor/commands/speckit*",
|
|
73
|
+
".cursor/skills/speckit-*",
|
|
74
|
+
".kiro/",
|
|
75
|
+
"aidlc-docs/",
|
|
76
|
+
".amazonq/rules/aws-aidlc-rules/",
|
|
77
|
+
".amazonq/aws-aidlc-rule-details/",
|
|
78
|
+
".cursorrules",
|
|
79
|
+
".cursor/",
|
|
80
|
+
".github/copilot-instructions.md",
|
|
81
|
+
".github/instructions/",
|
|
82
|
+
]
|
|
83
|
+
# What `survey` inventories in place (SQ5): globs by kind, the guards' name words, the verify-command sources.
|
|
84
|
+
OWN_INVENTORY = [
|
|
85
|
+
{"glob": ".claude/skills/*/SKILL.md", "kind": "skill", "router": "skill"},
|
|
86
|
+
{"glob": ".claude/commands/**/*.md", "kind": "command", "router": "command"},
|
|
87
|
+
{"glob": ".claude/rules/**/*.md", "kind": "rule"},
|
|
88
|
+
{"glob": ".claude/agents/*.md", "kind": "agent"},
|
|
89
|
+
{"glob": ".claude/hooks/*", "kind": "hook"},
|
|
90
|
+
{"glob": ".agents/skills/*/SKILL.md", "kind": "skill", "router": "skill"},
|
|
91
|
+
{"glob": ".codex/skills/*/SKILL.md", "kind": "skill", "router": "skill"},
|
|
92
|
+
{"glob": ".codex/prompts/*.md", "kind": "command", "router": "command"},
|
|
93
|
+
{"glob": ".codex/agents/*", "kind": "agent"},
|
|
94
|
+
{"glob": ".codex/hooks/*", "kind": "hook"},
|
|
95
|
+
{"glob": "bin/*.sh", "kind": "script"},
|
|
96
|
+
{"glob": "Makefile", "kind": "make"},
|
|
97
|
+
]
|
|
98
|
+
OWN_GUARDS = {
|
|
99
|
+
"ai-git-guard": [
|
|
100
|
+
"git",
|
|
101
|
+
"commit",
|
|
102
|
+
"push",
|
|
103
|
+
"merge"
|
|
104
|
+
],
|
|
105
|
+
"ai-path-guard": [
|
|
106
|
+
"path",
|
|
107
|
+
"protect",
|
|
108
|
+
"protected",
|
|
109
|
+
"secret",
|
|
110
|
+
"secrets",
|
|
111
|
+
"dotenv"
|
|
112
|
+
],
|
|
113
|
+
"ai-scope-guard": [
|
|
114
|
+
"scope"
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
OWN_VERIFY = [
|
|
118
|
+
{"file": "Makefile", "make_target": ["test", "tests", "check", "qa"], "command": "make {name}"},
|
|
119
|
+
{"file": "composer.json", "script": ["test", "tests", "qa", "check"], "command": "composer {name}"},
|
|
120
|
+
{"file": "package.json", "script": ["test"], "command": "npm test"},
|
|
121
|
+
{"file": "bin/*.sh", "stem": ["test", "tests", "run-tests", "check"], "command": "bin/{name}.sh"},
|
|
122
|
+
{"file": "phpunit.xml.dist", "command": "vendor/bin/phpunit"},
|
|
123
|
+
{"file": "phpunit.xml", "command": "vendor/bin/phpunit"},
|
|
124
|
+
{"file": "pytest.ini", "command": "python3 -m pytest"},
|
|
125
|
+
]
|
|
39
126
|
|
|
40
127
|
|
|
41
128
|
def die(message, code=2):
|
|
@@ -52,28 +139,24 @@ def read_text(path):
|
|
|
52
139
|
return None
|
|
53
140
|
|
|
54
141
|
|
|
55
|
-
def
|
|
142
|
+
def read_bytes(path):
|
|
56
143
|
try:
|
|
57
144
|
with open(path, "rb") as fh:
|
|
58
|
-
return
|
|
145
|
+
return fh.read()
|
|
59
146
|
except OSError:
|
|
60
|
-
return
|
|
147
|
+
return None
|
|
61
148
|
|
|
62
149
|
|
|
63
|
-
def
|
|
150
|
+
def is_crlf(path):
|
|
64
151
|
try:
|
|
65
|
-
with open(
|
|
66
|
-
|
|
67
|
-
except
|
|
68
|
-
|
|
69
|
-
own = table.get("own")
|
|
70
|
-
if not isinstance(own, dict) or not isinstance(own.get("inventory"), list):
|
|
71
|
-
die("%s: needs an own section with an inventory list" % MAP_FILE)
|
|
72
|
-
return table
|
|
152
|
+
with open(path, "rb") as fh:
|
|
153
|
+
return b"\r\n" in fh.read()
|
|
154
|
+
except OSError:
|
|
155
|
+
return False
|
|
73
156
|
|
|
74
157
|
|
|
75
158
|
def glob_re(pattern):
|
|
76
|
-
"""The
|
|
159
|
+
"""The glob dialect: `*` stops at `/`, `**` crosses it, `**/` may match nothing."""
|
|
77
160
|
out, i = [], 0
|
|
78
161
|
while i < len(pattern):
|
|
79
162
|
if pattern.startswith("**/", i):
|
|
@@ -91,12 +174,6 @@ def glob_re(pattern):
|
|
|
91
174
|
return re.compile("".join(out) + r"\Z")
|
|
92
175
|
|
|
93
176
|
|
|
94
|
-
def foreign_roots(table):
|
|
95
|
-
"""Paths a foreign tool owns (Spec Kit skills under .claude/ and the like):
|
|
96
|
-
update.py --adopt handles those, so they are no part of the own toolbox."""
|
|
97
|
-
return [r for spec in (table.get("tools") or {}).values() for r in spec.get("roots") or []]
|
|
98
|
-
|
|
99
|
-
|
|
100
177
|
def under_root(path, root):
|
|
101
178
|
if root.endswith("/"):
|
|
102
179
|
return path.startswith(root)
|
|
@@ -218,13 +295,11 @@ def make_targets(root, rel="Makefile"):
|
|
|
218
295
|
|
|
219
296
|
# ---------------------------------------------------------------- inventory
|
|
220
297
|
|
|
221
|
-
def inventory(root
|
|
298
|
+
def inventory(root):
|
|
222
299
|
"""[{path, kind, line, purpose, name, router}] in table order, then path order."""
|
|
223
|
-
|
|
224
|
-
skip = foreign_roots(table)
|
|
225
|
-
paths = [p for p in walk(root) if not any(under_root(p, r) for r in skip)]
|
|
300
|
+
paths = [p for p in walk(root) if not any(under_root(p, r) for r in FOREIGN_ROOTS)]
|
|
226
301
|
items, seen = [], set()
|
|
227
|
-
for row in
|
|
302
|
+
for row in OWN_INVENTORY:
|
|
228
303
|
pattern = glob_re(row["glob"])
|
|
229
304
|
for rel in paths:
|
|
230
305
|
if rel in seen or not pattern.match(rel):
|
|
@@ -255,15 +330,14 @@ def command_name(rel, row):
|
|
|
255
330
|
return os.path.basename(rel)
|
|
256
331
|
|
|
257
332
|
|
|
258
|
-
def guard_overlap(items
|
|
333
|
+
def guard_overlap(items):
|
|
259
334
|
"""{guard: [hook paths doing its job]} by the hook file name's words."""
|
|
260
|
-
|
|
261
|
-
found = {g: [] for g in guards}
|
|
335
|
+
found = {g: [] for g in OWN_GUARDS}
|
|
262
336
|
for item in items:
|
|
263
337
|
if item["kind"] != "hook":
|
|
264
338
|
continue
|
|
265
339
|
words = set(re.split(r"[^a-z0-9]+", os.path.basename(item["path"]).lower()))
|
|
266
|
-
for guard, keys in
|
|
340
|
+
for guard, keys in OWN_GUARDS.items():
|
|
267
341
|
if words & set(keys):
|
|
268
342
|
found[guard].append(item["path"])
|
|
269
343
|
return found
|
|
@@ -271,10 +345,10 @@ def guard_overlap(items, table):
|
|
|
271
345
|
|
|
272
346
|
# ------------------------------------------------------------------- verify
|
|
273
347
|
|
|
274
|
-
def verify_candidates(root
|
|
348
|
+
def verify_candidates(root):
|
|
275
349
|
"""[(command, source)] in table order; the first is the proposal. Nothing is run."""
|
|
276
350
|
out = []
|
|
277
|
-
for row in
|
|
351
|
+
for row in OWN_VERIFY:
|
|
278
352
|
pattern = row["file"]
|
|
279
353
|
if "*" in pattern:
|
|
280
354
|
regex = glob_re(pattern)
|
|
@@ -305,9 +379,9 @@ def verify_candidates(root, table):
|
|
|
305
379
|
|
|
306
380
|
|
|
307
381
|
def verify_value(root):
|
|
308
|
-
"""None when
|
|
382
|
+
"""None when verification.md is missing or has no verify_command line; else the
|
|
309
383
|
value, '' for the template's comment-only line (sensors.read_commands' rule)."""
|
|
310
|
-
text = read_text(os.path.join(root,
|
|
384
|
+
text = read_text(os.path.join(root, VERIFICATION))
|
|
311
385
|
if text is None:
|
|
312
386
|
return None
|
|
313
387
|
match = VERIFY_RE.search(text)
|
|
@@ -344,76 +418,6 @@ def superpowers_line(root):
|
|
|
344
418
|
"\"enabledPlugins\": {\"%s\": true}" % (" (disabled in %s)" % where if where else "", SUPERPOWERS_KEY))
|
|
345
419
|
|
|
346
420
|
|
|
347
|
-
# ----------------------------------------------------------------- rendering
|
|
348
|
-
|
|
349
|
-
def toolbox_text(items, overlap):
|
|
350
|
-
lines = ["# Toolbox — this project's own skills, rules, agents, hooks and scripts", "",
|
|
351
|
-
"<!-- Written by `setup.py adopt` (/rr-setup --adopt). An inventory, not copies: each line links",
|
|
352
|
-
" the file in place, which stays the source of truth (C1). Edit freely; adopt never",
|
|
353
|
-
" overwrites a toolbox.md that differs from what it would write. -->", ""]
|
|
354
|
-
kinds = [("skill", "Skills"), ("command", "Commands"), ("rule", "Rules"), ("agent", "Agents"),
|
|
355
|
-
("hook", "Hooks"), ("script", "Scripts"), ("make", "Make targets")]
|
|
356
|
-
for kind, title in kinds:
|
|
357
|
-
rows = [i for i in items if i["kind"] == kind]
|
|
358
|
-
if not rows:
|
|
359
|
-
continue
|
|
360
|
-
lines += ["## %s" % title, ""]
|
|
361
|
-
for item in rows:
|
|
362
|
-
label = item["name"] if kind in ("skill", "command", "make") else item["path"]
|
|
363
|
-
if kind in ("skill", "command"):
|
|
364
|
-
label = "/" + label
|
|
365
|
-
elif kind == "make":
|
|
366
|
-
label = "make " + label
|
|
367
|
-
purpose = ("%s — KNOWN FACT `%s:%d`" % (clip(item["purpose"]), item["path"], item["line"])
|
|
368
|
-
if item["purpose"] else UNKNOWN)
|
|
369
|
-
lines.append("- [%s](../../%s) — %s" % (label, item["path"], purpose))
|
|
370
|
-
lines.append("")
|
|
371
|
-
if overlap:
|
|
372
|
-
lines += ["## Guards", ""]
|
|
373
|
-
for guard, paths in overlap.items():
|
|
374
|
-
if paths:
|
|
375
|
-
lines.append("- `%s`: this project's %s does the same job — kept; the plugin's guard is not "
|
|
376
|
-
"installed into the project" % (guard, ", ".join("`%s`" % p for p in paths)))
|
|
377
|
-
else:
|
|
378
|
-
lines.append("- `%s`: no project hook does its job — the plugin's guard applies" % guard)
|
|
379
|
-
lines.append("")
|
|
380
|
-
if not items:
|
|
381
|
-
lines += ["No skills, rules, agents, hooks, scripts or Make targets found.", ""]
|
|
382
|
-
return "\n".join(lines)
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
def router_rows(items):
|
|
386
|
-
"""One row per `/name`: a skill mirrored under .claude/ and .agents/ or
|
|
387
|
-
.codex/, or a command of the same name, is one route (the first found)."""
|
|
388
|
-
rows = ["| this project's own skills, hooks, agents and scripts | policies/toolbox.md |"]
|
|
389
|
-
named = set()
|
|
390
|
-
for item in items:
|
|
391
|
-
if not item["router"] or item["name"] in named:
|
|
392
|
-
continue
|
|
393
|
-
named.add(item["name"])
|
|
394
|
-
job = clip(item["purpose"] or "the project's %s" % item["name"]).replace("|", "\\|")
|
|
395
|
-
rows.append("| %s | `/%s` |" % (job, item["name"]))
|
|
396
|
-
return rows
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
def last_cell(row):
|
|
400
|
-
cells = row.rstrip().rstrip("|").rsplit("|", 1)
|
|
401
|
-
return cells[-1].strip() if len(cells) == 2 else None
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
def add_rows(text, rows):
|
|
405
|
-
"""Rows appended after the last table row; a row whose target cell is
|
|
406
|
-
already some table row's last cell is skipped, so a human's rewording of the
|
|
407
|
-
job survives and prose that mentions `/<name>` does not count."""
|
|
408
|
-
lines = text.split("\n")
|
|
409
|
-
last = max((i for i, l in enumerate(lines) if l.startswith("|")), default=None)
|
|
410
|
-
if last is None:
|
|
411
|
-
return None, []
|
|
412
|
-
targets = {last_cell(l) for l in lines if l.startswith("|")}
|
|
413
|
-
new = [r for r in rows if last_cell(r) not in targets]
|
|
414
|
-
return "\n".join(lines[:last + 1] + new + lines[last + 1:]), new
|
|
415
|
-
|
|
416
|
-
|
|
417
421
|
def write(path, text):
|
|
418
422
|
"""In place through a symlink, keeping the file's mode and a CRLF file's
|
|
419
423
|
line ends; a failed write leaves no temporary file behind."""
|
|
@@ -432,22 +436,319 @@ def write(path, text):
|
|
|
432
436
|
os.remove(tmp)
|
|
433
437
|
|
|
434
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
|
+
|
|
435
737
|
# ----------------------------------------------------------------- commands
|
|
436
738
|
|
|
437
739
|
def cmd_survey(root):
|
|
438
|
-
|
|
439
|
-
items = inventory(root, table)
|
|
740
|
+
items = inventory(root)
|
|
440
741
|
print("toolbox: %d item(s)" % len(items))
|
|
441
742
|
for item in items:
|
|
442
743
|
where = "%s:%d" % (item["path"], item["line"]) if item["line"] else item["path"]
|
|
443
744
|
name = " %s" % item["name"] if item["kind"] == "make" else ""
|
|
444
745
|
print(" %-8s %s%s — %s" % (item["kind"], where, name, clip(item["purpose"]) if item["purpose"] else UNKNOWN))
|
|
445
|
-
for guard, paths in guard_overlap(items
|
|
746
|
+
for guard, paths in guard_overlap(items).items():
|
|
446
747
|
print(" guard %s: %s" % (guard, ", ".join(paths) + " (same job, kept)" if paths else "no project hook"))
|
|
447
748
|
current = verify_value(root)
|
|
448
|
-
candidates = verify_candidates(root
|
|
749
|
+
candidates = verify_candidates(root)
|
|
449
750
|
if current:
|
|
450
|
-
print("verify_command: %s (
|
|
751
|
+
print("verify_command: %s (verification.md)" % current)
|
|
451
752
|
elif candidates:
|
|
452
753
|
print("verify_command: proposed `%s` (from %s) — write it with `setup.py verify --set` once the human "
|
|
453
754
|
"confirms" % candidates[0])
|
|
@@ -463,73 +764,69 @@ def cmd_check(root):
|
|
|
463
764
|
value = verify_value(root)
|
|
464
765
|
if not value or value.lower() == "none":
|
|
465
766
|
print("VERIFY_COMMAND_MISSING: %s names no verify_command — propose one with `setup.py survey`, "
|
|
466
|
-
"write it with `setup.py verify --set \"<command>\"` after the human confirms" %
|
|
767
|
+
"write it with `setup.py verify --set \"<command>\"` after the human confirms" % VERIFICATION)
|
|
467
768
|
return 1
|
|
468
769
|
print("verify_command: %s" % value)
|
|
469
770
|
return 0
|
|
470
771
|
|
|
471
772
|
|
|
472
|
-
def cmd_adopt(root, apply):
|
|
473
|
-
table = load_map()
|
|
474
|
-
router = os.path.join(root, ROUTER_FILE)
|
|
475
|
-
router_text = read_text(router)
|
|
476
|
-
if router_text is None:
|
|
477
|
-
die("%s not found — scaffold .ai/ first (/rr-setup)" % ROUTER_FILE)
|
|
478
|
-
items = inventory(root, table)
|
|
479
|
-
text = toolbox_text(items, guard_overlap(items, table))
|
|
480
|
-
toolbox = os.path.join(root, TOOLBOX)
|
|
481
|
-
existing = read_text(toolbox)
|
|
482
|
-
new_router, added = add_rows(router_text, router_rows(items))
|
|
483
|
-
if new_router is None:
|
|
484
|
-
die("%s has no table to add router rows to" % ROUTER_FILE)
|
|
485
|
-
if existing is not None and existing != text:
|
|
486
|
-
print("refused: %s exists and differs from the inventory — compare by hand, or delete it and run "
|
|
487
|
-
"again" % TOOLBOX)
|
|
488
|
-
return 3
|
|
489
|
-
print("%s %s (%d item(s))" % ("unchanged" if existing == text else "write", TOOLBOX, len(items)))
|
|
490
|
-
for row in added:
|
|
491
|
-
print("router %s" % row)
|
|
492
|
-
if not added:
|
|
493
|
-
print("router no new row")
|
|
494
|
-
if not apply:
|
|
495
|
-
print("dry run — --apply writes it")
|
|
496
|
-
return 0
|
|
497
|
-
if existing != text:
|
|
498
|
-
write(toolbox, text)
|
|
499
|
-
if added:
|
|
500
|
-
write(router, new_router)
|
|
501
|
-
print("applied")
|
|
502
|
-
return 0
|
|
503
|
-
|
|
504
|
-
|
|
505
773
|
def cmd_verify(root, command):
|
|
506
774
|
command = (command or "").strip()
|
|
507
775
|
if not command or "\n" in command or command.startswith("#"):
|
|
508
776
|
die("--set needs one command on one line")
|
|
509
|
-
path = os.path.join(root,
|
|
777
|
+
path = os.path.join(root, VERIFICATION)
|
|
510
778
|
text = read_text(path)
|
|
511
779
|
if text is None or not VERIFY_RE.search(text):
|
|
512
|
-
die("%s has no verify_command line — run /rr-setup --update first" %
|
|
780
|
+
die("%s has no verify_command line — run /rr-setup --update first" % VERIFICATION)
|
|
513
781
|
current = verify_value(root)
|
|
514
782
|
if current and current.lower() != "none":
|
|
515
|
-
print("refused: verify_command is already `%s` — the human edits %s" % (current,
|
|
783
|
+
print("refused: verify_command is already `%s` — the human edits %s" % (current, VERIFICATION))
|
|
516
784
|
return 3
|
|
517
785
|
match = VERIFY_RE.search(text)
|
|
518
786
|
write(path, text[:match.start()] + "verify_command: " + command + text[match.end():])
|
|
519
|
-
print("verify_command: %s (written to %s)" % (command,
|
|
787
|
+
print("verify_command: %s (written to %s)" % (command, VERIFICATION))
|
|
788
|
+
return 0
|
|
789
|
+
|
|
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)))
|
|
520
813
|
return 0
|
|
521
814
|
|
|
522
815
|
|
|
523
816
|
def main(argv=None):
|
|
524
817
|
parser = argparse.ArgumentParser(prog="setup.py", description=__doc__.split("\n\n", 1)[0])
|
|
525
818
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
526
|
-
for name in ("survey", "check", "
|
|
819
|
+
for name in ("survey", "check", "verify", "options"):
|
|
527
820
|
p = sub.add_parser(name)
|
|
528
821
|
p.add_argument("--root", default=".")
|
|
529
|
-
if name == "adopt":
|
|
530
|
-
p.add_argument("--apply", action="store_true")
|
|
531
822
|
if name == "verify":
|
|
532
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")
|
|
533
830
|
args = parser.parse_args(argv)
|
|
534
831
|
root = os.path.abspath(args.root)
|
|
535
832
|
if not os.path.isdir(root):
|
|
@@ -538,8 +835,9 @@ def main(argv=None):
|
|
|
538
835
|
return cmd_survey(root)
|
|
539
836
|
if args.cmd == "check":
|
|
540
837
|
return cmd_check(root)
|
|
541
|
-
if args.cmd == "
|
|
542
|
-
|
|
838
|
+
if args.cmd == "options":
|
|
839
|
+
run = cmd_options if args.json else cmd_options_open
|
|
840
|
+
return run(root, args.all, args.skip)
|
|
543
841
|
return cmd_verify(root, args.command)
|
|
544
842
|
|
|
545
843
|
|