riskrails 3.2.1 → 4.0.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 +40 -83
- package/agents/ai-reviewer.md.tmpl +1 -1
- package/ai/using-riskrails.md +0 -3
- package/bin/riskrails.js +13 -59
- package/hooks/ai-path-guard-defaults.json +1 -1
- package/hooks/context-guard.py +2 -4
- 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/templates/.ai/AGENTS.md +6 -6
- package/skills/ai-init/templates/.ai/VERSION +1 -1
- 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 +1 -1
- package/skills/ai-task/rr_state/dialog.py +2 -4
- package/skills/ai-task/rr_state/fs.py +5 -13
- 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/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 +32 -13
- package/skills/project-update/render_instructions.py +12 -45
- package/skills/project-update/update.py +757 -1172
- package/skills/rr-setup/SKILL.md +42 -81
- package/skills/rr-setup/scaffold_ai.py +7 -7
- package/skills/rr-setup/setup.py +96 -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
|
@@ -1,69 +1,76 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Bring a project's RiskRails files up to date with the installed plugin.
|
|
3
3
|
|
|
4
|
-
update.py [project-dir] [--apply [--confirm-delete NAME]] [--check [--budget]]
|
|
4
|
+
update.py [project-dir] [--apply [--confirm-delete NAME] [--confirm-cleanup NAME]] [--check [--budget]]
|
|
5
5
|
|
|
6
6
|
Default is a dry run: print what would change, write nothing.
|
|
7
7
|
--apply write the changes
|
|
8
|
-
--check print one line and exit 1 when an
|
|
8
|
+
--check print one line and exit 1 when an update is pending
|
|
9
9
|
--check --budget print one line per root instruction file over its byte
|
|
10
10
|
budget (block over `project`, file over `skeleton`) and exit 1
|
|
11
|
-
--confirm-delete NAME a human confirms this run's
|
|
12
|
-
(exit 5) unless a terminal outside
|
|
13
|
-
|
|
11
|
+
--confirm-delete NAME a human confirms this run's gated rows (`regenerate?`,
|
|
12
|
+
`delete?`); refused (exit 5, CONFIRM_REFUSED) unless a terminal outside
|
|
13
|
+
Claude Code or Codex (no agent-session marker) or AI_UNATTENDED
|
|
14
|
+
--confirm-cleanup NAME the same for the [cleanup] rows
|
|
14
15
|
|
|
15
16
|
Exit codes:
|
|
16
17
|
0 done (or, with --check, the project is current)
|
|
17
18
|
1 --check: an update is pending; --check --budget: a file is over budget
|
|
18
|
-
2 usage error, missing templates,
|
|
19
|
+
2 usage error, missing templates, .ai/VERSION unreadable or newer than the plugin
|
|
19
20
|
3 an apply aborted part-way; nothing after the failing item was written
|
|
20
|
-
5 refused: --confirm
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
21
|
+
5 refused: --confirm-* without a human (CONFIRM_REFUSED on stdout line 1)
|
|
22
|
+
6 a file this update keeps cannot be read (KEPT_UNREADABLE on stdout line 1); nothing written
|
|
23
|
+
|
|
24
|
+
This version regenerates its own files and converts nothing, except one split:
|
|
25
|
+
a project that still has the mixed files gets .ai/policies/verification.md (the
|
|
26
|
+
command lines of testing.md, verbatim) and .ai/policies/weight.project.json
|
|
27
|
+
(what its weight.json changed against the plugin's table) once, when they are absent.
|
|
28
|
+
|
|
29
|
+
What it does, per file:
|
|
30
|
+
GENERATED .ai/AGENTS.md, .ai/VERSION, .ai/policies/{path-guard.json, coding.md,
|
|
31
|
+
safety.md, security.md, testing.md, weight.md}, .ai/rules/README.md,
|
|
32
|
+
.ai/state/README.md, <runtime>/memory/README.md, the managed block of each
|
|
33
|
+
instruction file: rewritten from the plugin. A copy that is a version the plugin
|
|
34
|
+
once shipped is replaced (regenerate); an edited one waits for --confirm-delete
|
|
35
|
+
(regenerate?) and its original is kept under .ai/reports/project-update-<date>/original/.
|
|
36
|
+
KEPT .ai/project, .ai/reports, .ai/memory, .ai/state, docs/sdlc, verification.md,
|
|
37
|
+
weight.project.json, dialog.json, memory.json, .ai/local/memory, edited shared
|
|
38
|
+
overrides, the text outside the managed blocks, .claude/settings.json,
|
|
39
|
+
.codex/config.toml, .gitignore (entries appended): never rewritten.
|
|
40
|
+
REMOVED .ai/rules/<slug>.md, .ai/local/plugin-update/, the old weight.json after its
|
|
41
|
+
split, an unedited copy of a shared file: delete?, gated, with a copy in original/.
|
|
42
|
+
[cleanup] rows are unchanged: proposals only, settled with --confirm-cleanup.
|
|
39
43
|
|
|
40
44
|
A project uses the runtime(s) it declares: CLAUDE.md or .claude/ means Claude
|
|
41
45
|
Code, AGENTS.md or .codex/ means Codex, both means both. A project that declares
|
|
42
|
-
neither gets the runtime this copy of the plugin was installed for
|
|
43
|
-
the scaffolds do.
|
|
44
|
-
|
|
45
|
-
Three-way update: a file whose content is a version this plugin once shipped was
|
|
46
|
-
never edited, so it is replaced. An edited file is merged against the shipped
|
|
47
|
-
version closest to it (git merge-file for text, per key for JSON), keeping every
|
|
48
|
-
edit. A real conflict leaves the project file alone and puts the plugin's version
|
|
49
|
-
in a git-ignored local directory for a human or /rr-setup --update to merge.
|
|
46
|
+
neither gets the runtime this copy of the plugin was installed for.
|
|
50
47
|
"""
|
|
51
|
-
import argparse, contextlib,
|
|
48
|
+
import argparse, contextlib, hashlib, importlib.util, io, json, os, re, shlex, subprocess, sys, tempfile
|
|
52
49
|
from collections import OrderedDict
|
|
53
|
-
from types import SimpleNamespace
|
|
54
50
|
from datetime import datetime, timedelta, timezone
|
|
55
51
|
|
|
52
|
+
try:
|
|
53
|
+
import tomllib
|
|
54
|
+
except ImportError: # Python before 3.11: .codex/config.toml is not checked
|
|
55
|
+
tomllib = None
|
|
56
|
+
|
|
56
57
|
sys.dont_write_bytecode = True # never leave __pycache__ in an installed plugin
|
|
57
58
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
58
59
|
sys.path.insert(0, HERE)
|
|
59
|
-
import migrations # noqa: E402 # lives next to this script
|
|
60
|
-
from migrations import SchemaError # noqa: E402
|
|
61
60
|
import render_instructions # noqa: E402 # lives next to this script
|
|
62
|
-
import adopt # noqa: E402 # lives next to this script
|
|
63
61
|
SKILLS = os.path.dirname(HERE)
|
|
62
|
+
LAST_MIGRATING_VERSION = "3.1.0" # the last riskrails that converts older trees; I3 names it
|
|
63
|
+
EXIT_KEPT_UNREADABLE = 6
|
|
64
|
+
REFUSED = "CONFIRM_REFUSED"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SchemaError(Exception):
|
|
68
|
+
"""The project's schema cannot be read or is newer than this plugin."""
|
|
69
|
+
|
|
70
|
+
|
|
64
71
|
def env(name, default=None):
|
|
65
|
-
"""RISKRAILS_<name
|
|
66
|
-
return os.environ.get("RISKRAILS_" + name,
|
|
72
|
+
"""RISKRAILS_<name>."""
|
|
73
|
+
return os.environ.get("RISKRAILS_" + name, default)
|
|
67
74
|
|
|
68
75
|
|
|
69
76
|
TEMPLATES = {
|
|
@@ -72,37 +79,58 @@ TEMPLATES = {
|
|
|
72
79
|
}
|
|
73
80
|
HISTORY = env("HISTORY", os.path.join(HERE, "history"))
|
|
74
81
|
# The shared tree every project reads unless it keeps its own copy: installed
|
|
75
|
-
# beside the skills as riskrails/ai
|
|
76
|
-
# the root of a checkout.
|
|
82
|
+
# beside the skills as riskrails/ai, or ai/ at the root of a checkout.
|
|
77
83
|
PLUGIN_HOME = os.path.dirname(SKILLS)
|
|
78
84
|
SHARED = env("SHARED") or next(
|
|
79
|
-
(p for p in (os.path.join(PLUGIN_HOME, "riskrails", "ai"), os.path.join(PLUGIN_HOME, "
|
|
80
|
-
os.path.join(PLUGIN_HOME, "ai"))
|
|
85
|
+
(p for p in (os.path.join(PLUGIN_HOME, "riskrails", "ai"), os.path.join(PLUGIN_HOME, "ai"))
|
|
81
86
|
if os.path.isdir(p)), os.path.join(PLUGIN_HOME, "riskrails", "ai"))
|
|
82
87
|
VERSION_FILE = ".ai/VERSION"
|
|
83
88
|
ROUTER_FILE = ".ai/AGENTS.md"
|
|
84
|
-
# The router must name the shared tree exactly once: a merge that drops the
|
|
85
|
-
# section, or doubles it, is a conflict for a human.
|
|
86
|
-
SHARED_SECTION_RE = re.compile(rb"(?m)^## Shared files[ \t]*\r?$")
|
|
87
|
-
SHARED_SCHEMA = 6 # the schema from which shared copies are overrides (0006)
|
|
88
89
|
STALE_DAYS = 7 # an unclosed task whose last event is older is proposed for cleanup
|
|
89
90
|
STATE_FILE = ".ai/state/state.json"
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
91
|
+
TESTING_MD = ".ai/policies/testing.md"
|
|
92
|
+
VERIFICATION_MD = ".ai/policies/verification.md"
|
|
93
|
+
WEIGHT_JSON, WEIGHT_MD = ".ai/policies/weight.json", ".ai/policies/weight.md"
|
|
94
|
+
WEIGHT_PROJECT = ".ai/policies/weight.project.json"
|
|
95
|
+
LOCAL_CACHE = ".ai/local/plugin-update"
|
|
96
|
+
|
|
97
|
+
# D2. GENERATED targets copied from a template as they are. weight.md is rendered,
|
|
98
|
+
# .ai/VERSION is written last, the managed blocks and the runtimes' memory READMEs
|
|
99
|
+
# come from the maps below.
|
|
100
|
+
GENERATED = (ROUTER_FILE, ".ai/policies/path-guard.json", ".ai/policies/coding.md", ".ai/policies/safety.md",
|
|
101
|
+
".ai/policies/security.md", TESTING_MD, ".ai/rules/README.md", ".ai/state/README.md")
|
|
102
|
+
# Template files created when missing and never touched again: the project's own from then on.
|
|
103
|
+
CREATE_ONLY = (VERIFICATION_MD, WEIGHT_PROJECT, ".ai/reports/.gitkeep")
|
|
104
|
+
CREATE_ONLY_DIRS = (".ai/project/",)
|
|
105
|
+
# Template files that are not copied: written last, the plugin's own table, the human's switch.
|
|
106
|
+
NOT_COPIED = (VERSION_FILE, WEIGHT_JSON, WEIGHT_MD, ".ai/policies/dialog.json")
|
|
107
|
+
KEPT = (".ai/project/", ".ai/reports/", ".ai/memory/", ".ai/state/", "docs/sdlc/", ".ai/local/memory/",
|
|
108
|
+
WEIGHT_PROJECT, VERIFICATION_MD, ".ai/policies/dialog.json", ".ai/policies/memory.json",
|
|
109
|
+
".claude/settings.json", ".codex/config.toml", ".gitignore")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def is_kept(path):
|
|
113
|
+
"""True for a path update.py never rewrites. A GENERATED path under a KEPT
|
|
114
|
+
directory (.ai/state/README.md) is generated: the two sets are disjoint."""
|
|
115
|
+
if path in GENERATED:
|
|
116
|
+
return False
|
|
117
|
+
return any(path == k or (k.endswith("/") and path.startswith(k)) for k in KEPT)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# The shared set, literally: the plugin's tree must equal it (tested). A project
|
|
121
|
+
# copy identical to a shipped version is offered for deletion; an edited one is an override.
|
|
122
|
+
SHARED_SET = frozenset((
|
|
123
|
+
".ai/policies/production.md", ".ai/policies/database.md", ".ai/policies/git.md", ".ai/policies/release.md",
|
|
124
|
+
".ai/policies/context-management.md", ".ai/policies/tooling.md", ".ai/policies/model-routing.md",
|
|
125
|
+
".ai/policies/review-economy.md",
|
|
126
|
+
".ai/agents/context.md", ".ai/agents/discovery.md", ".ai/agents/implementer.md",
|
|
127
|
+
".ai/agents/planner.md", ".ai/agents/release.md", ".ai/agents/reviewer.md", ".ai/agents/risk.md",
|
|
128
|
+
".ai/agents/security.md", ".ai/agents/tester.md",
|
|
129
|
+
".ai/templates/task-context.md", ".ai/templates/impact-report.md", ".ai/templates/review-report.md",
|
|
130
|
+
".ai/templates/release-report.md", ".ai/templates/architecture-decision.md",
|
|
131
|
+
".ai/templates/implementation-plan.md",
|
|
132
|
+
".ai/using-riskrails.md", ".ai/workflows.md",
|
|
133
|
+
))
|
|
106
134
|
RULES_DIR = ".ai/rules"
|
|
107
135
|
# Directories a stale rule block is never hunted in: a dependency's instruction
|
|
108
136
|
# file is not ours (the path guard refuses it too), and .git is not text.
|
|
@@ -114,38 +142,17 @@ RULES_SKIP = {".git", ".ai", "vendor", "node_modules", "bower_components",
|
|
|
114
142
|
"third_party", "Pods", "site-packages", ".venv", ".pnpm", ".yarn"}
|
|
115
143
|
# Compared case-insensitively: on a case-insensitive filesystem `Vendor/` is `vendor/`.
|
|
116
144
|
RULES_SKIP_FOLDED = frozenset(n.casefold() for n in RULES_SKIP)
|
|
117
|
-
START, END = "<!-- riskrails:start -->", "<!-- riskrails:end -->"
|
|
118
|
-
OLD_START, OLD_END = b"<!-- claude-agentic:start -->", b"<!-- claude-agentic:end -->" # R35, one version
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def renamed_markers(block):
|
|
122
|
-
"""A block the plugin shipped before 3.4b, as it reads under the current
|
|
123
|
-
markers: the version an unedited block was installed from still matches it."""
|
|
124
|
-
return block.replace(OLD_START, START.encode()).replace(OLD_END, END.encode())
|
|
125
|
-
# The marker lookup is shared with render_instructions (block_of) and adopt:
|
|
126
|
-
# one place knows the legacy Codex-agentic pair.
|
|
127
145
|
find_block = render_instructions.find_block
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
PROJECT_INIT_MAP = [ # template -> target, kind
|
|
132
|
-
("sdlc-README.md", "docs/sdlc/README.md", "update"),
|
|
133
|
-
# read in place by /rr-spec and /rr-plan; a project copy is an override: never created
|
|
134
|
-
("intent.md", "docs/sdlc/intent/TEMPLATE.md", "retire"),
|
|
135
|
-
("spec.md", "docs/sdlc/specs/TEMPLATE.md", "retire"),
|
|
136
|
-
("plan.md", "docs/sdlc/plans/TEMPLATE.md", "retire"),
|
|
137
|
-
("adr.md", "docs/sdlc/adr/TEMPLATE.md", "retire"),
|
|
138
|
-
# the project's own principles: created once, then it is the project's file
|
|
139
|
-
("constitution.md", "docs/sdlc/constitution.md", "create"),
|
|
140
|
-
]
|
|
141
|
-
RUNTIME_MAP = { # the per-runtime layer of the project-init scaffold
|
|
142
|
-
"claude": [("memory-README.md", ".claude/memory/README.md", "update"),
|
|
146
|
+
|
|
147
|
+
RUNTIME_MAP = { # the per-runtime layer of the project-init scaffold: template, target, kind
|
|
148
|
+
"claude": [("memory-README.md", ".claude/memory/README.md", "regenerate"),
|
|
143
149
|
("project-settings.json", ".claude/settings.json", "create")],
|
|
144
|
-
"codex": [("memory-README.md", ".codex/memory/README.md", "
|
|
150
|
+
"codex": [("memory-README.md", ".codex/memory/README.md", "regenerate"),
|
|
145
151
|
("project-config.toml", ".codex/config.toml", "create")],
|
|
146
|
-
"gemini": [("memory-README.md", ".gemini/memory/README.md", "
|
|
147
|
-
"junie": [("memory-README.md", ".junie/memory/README.md", "
|
|
152
|
+
"gemini": [("memory-README.md", ".gemini/memory/README.md", "regenerate")],
|
|
153
|
+
"junie": [("memory-README.md", ".junie/memory/README.md", "regenerate")],
|
|
148
154
|
}
|
|
155
|
+
SDLC_CREATE = (("sdlc-README.md", "docs/sdlc/README.md"), ("constitution.md", "docs/sdlc/constitution.md"))
|
|
149
156
|
INSTRUCTION_FILE = { # runtime -> (file, block template, minimal template)
|
|
150
157
|
"claude": ("CLAUDE.md", "CLAUDE.block.md", "CLAUDE.minimal.md"),
|
|
151
158
|
"codex": ("AGENTS.md", "AGENTS.block.md", "AGENTS.minimal.md"),
|
|
@@ -153,33 +160,6 @@ INSTRUCTION_FILE = { # runtime -> (file, block template, minimal template)
|
|
|
153
160
|
"junie": (".junie/guidelines.md", "junie-guidelines.block.md",
|
|
154
161
|
"junie-guidelines.minimal.md"),
|
|
155
162
|
}
|
|
156
|
-
SKELETON = { # runtime -> the project-init template that scaffolds its instruction file
|
|
157
|
-
"claude": "CLAUDE.md",
|
|
158
|
-
"codex": "AGENTS.md",
|
|
159
|
-
"gemini": "GEMINI.md",
|
|
160
|
-
"junie": "junie-guidelines.md",
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def instruction_history_keys():
|
|
165
|
-
"""{instruction file: [history keys]} — a root instruction file is shipped
|
|
166
|
-
twice, as the project-init skeleton that scaffolds it and as the ai-init
|
|
167
|
-
minimal template, so a migration that matches shipped text must see both."""
|
|
168
|
-
out = {}
|
|
169
|
-
for runtime, (name, _, minimal_tpl) in INSTRUCTION_FILE.items():
|
|
170
|
-
out[name] = ["project-init/" + SKELETON[runtime], "ai-init/" + minimal_tpl]
|
|
171
|
-
return out
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def template_targets():
|
|
175
|
-
"""{project target: history key} for the templates that land outside .ai/.
|
|
176
|
-
Every runtime's entries, not only this project's, so a rename resolves the
|
|
177
|
-
same way for a Claude, a Codex and a dual-runtime project."""
|
|
178
|
-
out = {target: "project-init/" + src for src, target, _ in PROJECT_INIT_MAP}
|
|
179
|
-
for entries in RUNTIME_MAP.values():
|
|
180
|
-
for src, target, _ in entries:
|
|
181
|
-
out[target] = "project-init/" + src
|
|
182
|
-
return out
|
|
183
163
|
|
|
184
164
|
|
|
185
165
|
def project_runtimes(root):
|
|
@@ -212,75 +192,41 @@ def read(path):
|
|
|
212
192
|
|
|
213
193
|
|
|
214
194
|
class History:
|
|
215
|
-
|
|
195
|
+
"""index.json answers "was this ever shipped" from hashes alone; the blobs
|
|
196
|
+
give the one-time split its base values."""
|
|
197
|
+
|
|
198
|
+
def __init__(self, root):
|
|
216
199
|
try:
|
|
217
200
|
with open(os.path.join(root, "index.json"), encoding="utf-8") as fh:
|
|
218
201
|
self.index = json.load(fh)
|
|
219
202
|
except (OSError, ValueError, RecursionError):
|
|
220
203
|
self.index = {}
|
|
221
204
|
self.root = root
|
|
222
|
-
# {new key: [old keys]} from the migrations' MOVES: a template that moved
|
|
223
|
-
# keeps the versions it was shipped under its old name.
|
|
224
|
-
self.renames = renames or {}
|
|
225
|
-
|
|
226
|
-
def chain(self, key):
|
|
227
|
-
"""A template's keys, the oldest name first, ending with this one. The
|
|
228
|
-
registry rules out cycles; the visited set makes the walk safe anyway."""
|
|
229
|
-
out, seen = [], set()
|
|
230
|
-
|
|
231
|
-
def walk(k):
|
|
232
|
-
if k in seen:
|
|
233
|
-
return
|
|
234
|
-
seen.add(k)
|
|
235
|
-
for old in self.renames.get(k, []):
|
|
236
|
-
walk(old)
|
|
237
|
-
out.append(k)
|
|
238
|
-
walk(key)
|
|
239
|
-
return out
|
|
240
205
|
|
|
241
206
|
def versions(self, key):
|
|
242
|
-
"""Every shipped version of a template, oldest first, as bytes.
|
|
243
|
-
the user edited before a rename is merged against the version it was
|
|
244
|
-
installed from, which was shipped under the old key."""
|
|
207
|
+
"""Every shipped version of a template, oldest first, as bytes."""
|
|
245
208
|
out = []
|
|
246
|
-
for
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
pass
|
|
209
|
+
for h in self.index.get(key, []):
|
|
210
|
+
try:
|
|
211
|
+
out.append(read(os.path.join(self.root, "blobs", h)))
|
|
212
|
+
except OSError:
|
|
213
|
+
pass
|
|
252
214
|
return out
|
|
253
215
|
|
|
254
216
|
def hashes(self, key):
|
|
255
|
-
"""The sha of every version shipped under this key
|
|
256
|
-
|
|
257
|
-
counts as shipped."""
|
|
258
|
-
return {h for k in self.chain(key) for h in self.index.get(k, [])}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
_DECLARED = None
|
|
217
|
+
"""The sha of every version shipped under this key; a version whose blob is gone still counts."""
|
|
218
|
+
return set(self.index.get(key, []))
|
|
262
219
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
points at. What the installed tree holds never widens it: a file planted
|
|
268
|
-
there must not turn a project's own file into a deletion candidate."""
|
|
269
|
-
global _DECLARED # pylint: disable=global-statement # loaded once per run
|
|
270
|
-
if _DECLARED is None:
|
|
271
|
-
path = os.path.join(HERE, "migrations", "0006_shared_files.py")
|
|
272
|
-
spec = importlib.util.spec_from_file_location("riskrails_shared_set", path)
|
|
273
|
-
mod = importlib.util.module_from_spec(spec)
|
|
274
|
-
spec.loader.exec_module(mod)
|
|
275
|
-
_DECLARED = frozenset(mod.SHARED)
|
|
276
|
-
return _DECLARED
|
|
220
|
+
def shipped(self, key, data):
|
|
221
|
+
"""True when these bytes are a version the plugin shipped, also after a checkout turned its line endings into CRLF."""
|
|
222
|
+
known = self.hashes(key)
|
|
223
|
+
return sha(data) in known or sha(data.replace(b"\r\n", b"\n")) in known
|
|
277
224
|
|
|
278
225
|
|
|
279
226
|
def shared_files():
|
|
280
|
-
"""{".ai/<rel>": bytes} for every
|
|
281
|
-
holds; empty when the tree is not installed."""
|
|
227
|
+
"""{".ai/<rel>": bytes} for every shared file the shared tree holds; empty when the tree is not installed."""
|
|
282
228
|
out = {}
|
|
283
|
-
for path in sorted(
|
|
229
|
+
for path in sorted(SHARED_SET):
|
|
284
230
|
full = os.path.join(SHARED, path[len(".ai/"):])
|
|
285
231
|
if os.path.isfile(full):
|
|
286
232
|
out[path] = read(full)
|
|
@@ -288,12 +234,12 @@ def shared_files():
|
|
|
288
234
|
|
|
289
235
|
|
|
290
236
|
def undeclared_shared():
|
|
291
|
-
"""Files in the shared tree outside the
|
|
237
|
+
"""Files in the shared tree outside the shared set, as .ai/ paths."""
|
|
292
238
|
out = []
|
|
293
239
|
for here, _dirs, files in os.walk(SHARED):
|
|
294
240
|
for name in files:
|
|
295
241
|
rel = ".ai/" + os.path.relpath(os.path.join(here, name), SHARED).replace(os.sep, "/")
|
|
296
|
-
if rel not in
|
|
242
|
+
if rel not in SHARED_SET:
|
|
297
243
|
out.append(rel)
|
|
298
244
|
return sorted(out)
|
|
299
245
|
|
|
@@ -357,98 +303,115 @@ def git_states(root, rel_dir):
|
|
|
357
303
|
return classify
|
|
358
304
|
|
|
359
305
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
for c in candidates: # oldest first; ties keep the older one
|
|
364
|
-
s = score(c, ours)
|
|
365
|
-
if s > best_score:
|
|
366
|
-
best, best_score = c, s
|
|
367
|
-
return best
|
|
306
|
+
def dump_json(value):
|
|
307
|
+
return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode()
|
|
308
|
+
|
|
368
309
|
|
|
310
|
+
def unsafe_path(root, path):
|
|
311
|
+
"""Why a project-relative path must not be deleted: a symlink on the way to it,
|
|
312
|
+
or a real path outside the project; None when it is safe. Run at plan time and
|
|
313
|
+
again right before the delete."""
|
|
314
|
+
real_root = os.path.realpath(root)
|
|
315
|
+
here = root
|
|
316
|
+
for part in path.split("/"):
|
|
317
|
+
here = os.path.join(here, part)
|
|
318
|
+
if os.path.islink(here):
|
|
319
|
+
return "%s is a symlink" % os.path.relpath(here, root).replace(os.sep, "/")
|
|
320
|
+
real = os.path.realpath(os.path.join(root, path))
|
|
321
|
+
if real != real_root and not real.startswith(real_root + os.sep):
|
|
322
|
+
return "%s resolves outside the project (%s)" % (path, real)
|
|
323
|
+
return None
|
|
369
324
|
|
|
370
|
-
|
|
371
|
-
|
|
325
|
+
# ---------------------------------------------------------------- the one gate
|
|
326
|
+
# Set in the environment of a shell an agent runs: Claude Code's CLAUDECODE,
|
|
327
|
+
# and the names Codex gives its tool calls. Present counts, an empty value too.
|
|
328
|
+
AGENT_MARKERS = ("CLAUDECODE", "CODEX_THREAD_ID", "CODEX_SESSION_ID", "CODEX_CI",
|
|
329
|
+
"CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", "CODEX_VERSION")
|
|
372
330
|
|
|
373
331
|
|
|
374
|
-
def
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
with open(p, "wb") as fh:
|
|
379
|
-
fh.write(c)
|
|
380
|
-
r = subprocess.run(["git", "merge-file", "-p", "--quiet", *paths], capture_output=True, check=False)
|
|
381
|
-
return r.stdout if r.returncode == 0 else None
|
|
332
|
+
def ai_unattended():
|
|
333
|
+
"""AI_UNATTENDED is set when it is non-empty and not 0, false or no (case
|
|
334
|
+
and blanks ignored): one rule in every reader (risk 0587)."""
|
|
335
|
+
return os.environ.get("AI_UNATTENDED", "").strip().lower() not in ("", "0", "false", "no")
|
|
382
336
|
|
|
383
337
|
|
|
384
|
-
def
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
return {prefix: value}
|
|
338
|
+
def human_gate():
|
|
339
|
+
"""(passed, via, why) for --confirm-delete. A terminal outside any agent
|
|
340
|
+
session is a human: via "terminal". Otherwise a launcher that declared the
|
|
341
|
+
run unattended (AI_UNATTENDED) passes: via "unattended", as state.py's
|
|
342
|
+
approve records it. Anything else is refused, and `why` says what was seen —
|
|
343
|
+
a terminal alone is not enough, since an agent can open a pty (F3).
|
|
391
344
|
|
|
345
|
+
Duplicated in spirit from skills/ai-task/state.py (human_present) on purpose:
|
|
346
|
+
the two skills are installed independently and neither imports the other."""
|
|
347
|
+
markers = [name for name in AGENT_MARKERS if name in os.environ]
|
|
348
|
+
if os.isatty(0) and not markers:
|
|
349
|
+
return True, "terminal", ""
|
|
350
|
+
if ai_unattended():
|
|
351
|
+
return True, "unattended", ""
|
|
352
|
+
if markers:
|
|
353
|
+
return False, None, ("this run is inside an agent session (%s %s set); run it in a terminal "
|
|
354
|
+
"outside Claude Code or Codex, not through the `!` prefix"
|
|
355
|
+
% (", ".join(markers), "is" if len(markers) == 1 else "are"))
|
|
356
|
+
return False, None, "this run has no terminal"
|
|
392
357
|
|
|
393
|
-
def json_score(base, ours):
|
|
394
|
-
fb, fo = flatten(base), flatten(ours)
|
|
395
|
-
return sum(1 for k, v in fo.items() if k in fb and fb[k] == v)
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
def merge_json(base, ours, theirs, path, conflicts):
|
|
399
|
-
if ours == theirs:
|
|
400
|
-
return ours
|
|
401
|
-
if ours == base:
|
|
402
|
-
return theirs
|
|
403
|
-
if theirs == base:
|
|
404
|
-
return ours
|
|
405
|
-
if isinstance(ours, dict) and isinstance(theirs, dict) and (base is MISSING or isinstance(base, dict)):
|
|
406
|
-
b = base if isinstance(base, dict) else {}
|
|
407
|
-
out = OrderedDict()
|
|
408
|
-
for k in list(ours) + [k for k in theirs if k not in ours]:
|
|
409
|
-
v = merge_json(b.get(k, MISSING), ours.get(k, MISSING), theirs.get(k, MISSING), path + [k], conflicts)
|
|
410
|
-
if v is not MISSING:
|
|
411
|
-
out[k] = v
|
|
412
|
-
return out
|
|
413
|
-
conflicts.append(".".join(map(str, path)) or "(root)")
|
|
414
|
-
return ours
|
|
415
358
|
|
|
359
|
+
# The name typed into --confirm-delete is the audit trail; the template's own
|
|
360
|
+
# "<your name>" copied verbatim names no one. The whole value, not a substring.
|
|
416
361
|
|
|
417
|
-
def dump_json(value):
|
|
418
|
-
return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode()
|
|
419
362
|
|
|
363
|
+
# The name typed into --confirm-delete is the audit trail; the template's own
|
|
364
|
+
# "<your name>" copied verbatim names no one. The whole value, not a substring.
|
|
365
|
+
PLACEHOLDER_NAME_RE = re.compile(r"\A\s*(?:<[^>]*>|your\s+name)\s*\Z", re.IGNORECASE)
|
|
420
366
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
367
|
+
|
|
368
|
+
def placeholder_name(name):
|
|
369
|
+
return bool(PLACEHOLDER_NAME_RE.match(name or ""))
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def refuse(reason, how):
|
|
373
|
+
"""Print the refusal the way every gate does — the marker on the first
|
|
374
|
+
stdout line, then what to do — and return exit code 5."""
|
|
375
|
+
print("%s: %s" % (REFUSED, reason))
|
|
376
|
+
print(" %s" % how)
|
|
377
|
+
return 5
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def budget_offenders(root, files, caps, block_of):
|
|
381
|
+
"""R21: one line per root instruction file over budget — its managed block
|
|
382
|
+
over `caps["project"]`, or the whole file over `caps["skeleton"]`. Files that
|
|
383
|
+
do not exist are skipped; a file under both budgets prints nothing."""
|
|
384
|
+
out = []
|
|
385
|
+
for name in files:
|
|
386
|
+
path = os.path.join(root, name)
|
|
387
|
+
if not os.path.isfile(path):
|
|
388
|
+
continue
|
|
389
|
+
with open(path, encoding="utf-8") as fh:
|
|
390
|
+
text = fh.read()
|
|
391
|
+
block = block_of(text)
|
|
392
|
+
size = len(text.encode("utf-8"))
|
|
393
|
+
if block is not None and caps.get("project") and len(block.encode("utf-8")) > caps["project"]:
|
|
394
|
+
out.append("%s block %d B over the project budget of %d B"
|
|
395
|
+
% (name, len(block.encode("utf-8")), caps["project"]))
|
|
396
|
+
if caps.get("skeleton") and size > caps["skeleton"]:
|
|
397
|
+
out.append("%s file %d B over the skeleton budget of %d B" % (name, size, caps["skeleton"]))
|
|
398
|
+
return out
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# ---------------------------------------------------------------- the kept files
|
|
404
|
+
def template_version():
|
|
405
|
+
"""The schema number this plugin ships (its .ai/VERSION template)."""
|
|
406
|
+
return int(read(os.path.join(TEMPLATES["ai-init"], VERSION_FILE)).decode().strip())
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def project_version(root):
|
|
410
|
+
"""The project's .ai/VERSION: 0 when there is none; SchemaError when it cannot
|
|
411
|
+
be read, is not a number, or is newer than this plugin's."""
|
|
412
|
+
path = os.path.join(root, VERSION_FILE)
|
|
450
413
|
try:
|
|
451
|
-
raw =
|
|
414
|
+
raw = read(path) if os.path.lexists(path) else None
|
|
452
415
|
except OSError as exc: # a directory, a mode-000 file, a dangling link
|
|
453
416
|
raise SchemaError("%s cannot be read: %s" % (VERSION_FILE, exc)) from exc
|
|
454
417
|
if raw is None:
|
|
@@ -459,363 +422,151 @@ def detect_version(plan):
|
|
|
459
422
|
text = ""
|
|
460
423
|
if not re.fullmatch(r"\d+", text):
|
|
461
424
|
raise SchemaError("%s is not a schema number: %r" % (VERSION_FILE, raw[:40]))
|
|
425
|
+
ours = template_version()
|
|
426
|
+
if int(text) > ours:
|
|
427
|
+
raise SchemaError("%s says schema %s, this plugin ships schema %d — update the plugin "
|
|
428
|
+
"(riskrails/install.sh)" % (VERSION_FILE, text, ours))
|
|
462
429
|
return int(text)
|
|
463
430
|
|
|
464
431
|
|
|
465
|
-
|
|
466
|
-
"""Why a project-relative path must not be deleted: a symlink on the way to it,
|
|
467
|
-
or a real path outside the project; None when it is safe. Run at plan time and
|
|
468
|
-
again right before the delete."""
|
|
469
|
-
real_root = os.path.realpath(root)
|
|
470
|
-
here = root
|
|
471
|
-
for part in path.split("/"):
|
|
472
|
-
here = os.path.join(here, part)
|
|
473
|
-
if os.path.islink(here):
|
|
474
|
-
return "%s is a symlink" % os.path.relpath(here, root).replace(os.sep, "/")
|
|
475
|
-
real = os.path.realpath(os.path.join(root, path))
|
|
476
|
-
if real != real_root and not real.startswith(real_root + os.sep):
|
|
477
|
-
return "%s resolves outside the project (%s)" % (path, real)
|
|
478
|
-
return None
|
|
432
|
+
_MODULES = {}
|
|
479
433
|
|
|
480
434
|
|
|
481
|
-
|
|
482
|
-
"""
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
self.state = state
|
|
489
|
-
self.history = history
|
|
490
|
-
self.root = plan.root
|
|
491
|
-
self.runtimes = project_runtimes(plan.root)
|
|
492
|
-
|
|
493
|
-
# ---- what the plugin shipped, so a migration can recognise its own text
|
|
494
|
-
# instead of matching a regular expression against the project's prose.
|
|
495
|
-
def instruction_files(self):
|
|
496
|
-
"""The root instruction files this project actually has, one per runtime
|
|
497
|
-
it declares."""
|
|
498
|
-
return [INSTRUCTION_FILE[rt][0] for rt in self.runtimes
|
|
499
|
-
if self.exists(INSTRUCTION_FILE[rt][0])]
|
|
500
|
-
|
|
501
|
-
def shipped(self, path):
|
|
502
|
-
"""Every version of every template the plugin ever shipped at this
|
|
503
|
-
project path, oldest first. Renamed templates come with it: History
|
|
504
|
-
follows the MOVES chain and RETIRED."""
|
|
505
|
-
keys = list(instruction_history_keys().get(path, []))
|
|
506
|
-
key = migrations.template_key(path, template_targets())
|
|
507
|
-
if key and key not in keys:
|
|
508
|
-
keys.append(key)
|
|
509
|
-
out = []
|
|
510
|
-
for k in keys:
|
|
511
|
-
for version in self.history.versions(k):
|
|
512
|
-
if version not in out:
|
|
513
|
-
out.append(version)
|
|
514
|
-
return out
|
|
435
|
+
def sibling(name, *parts):
|
|
436
|
+
"""A module of another skill, loaded by path once per run (skills are installed independently)."""
|
|
437
|
+
if name not in _MODULES:
|
|
438
|
+
spec = importlib.util.spec_from_file_location("riskrails_" + name, os.path.join(SKILLS, *parts))
|
|
439
|
+
_MODULES[name] = importlib.util.module_from_spec(spec)
|
|
440
|
+
spec.loader.exec_module(_MODULES[name])
|
|
441
|
+
return _MODULES[name]
|
|
515
442
|
|
|
516
|
-
def block_status(self, path):
|
|
517
|
-
""""none" when this file has no managed block, "shipped" when the block
|
|
518
|
-
is one the plugin installed, "edited" when a human changed it inside the
|
|
519
|
-
markers — the one case where the plugin never writes over it."""
|
|
520
|
-
text = self.read(path)
|
|
521
|
-
runtime = next((rt for rt, entry in INSTRUCTION_FILE.items() if entry[0] == path), None)
|
|
522
|
-
if text is None or runtime is None:
|
|
523
|
-
return "none"
|
|
524
|
-
found = find_block(text)
|
|
525
|
-
if found is None:
|
|
526
|
-
return "none"
|
|
527
|
-
start, end, start_marker, end_marker = found
|
|
528
|
-
ours = current_markers(text[start:end], start_marker, end_marker) + b"\n"
|
|
529
|
-
block_tpl = INSTRUCTION_FILE[runtime][1]
|
|
530
|
-
tpl = read(os.path.join(TEMPLATES["ai-init"], block_tpl))
|
|
531
|
-
if ours == tpl or ours in map(renamed_markers, self.history.versions("ai-init/" + block_tpl)):
|
|
532
|
-
return "shipped"
|
|
533
|
-
return "edited"
|
|
534
|
-
|
|
535
|
-
def template(self, path):
|
|
536
|
-
"""The bytes this plugin ships today for a project path: the scaffold
|
|
537
|
-
template, or the shared tree's copy once the template is retired."""
|
|
538
|
-
self._check_path(path)
|
|
539
|
-
if path.startswith(".ai/"):
|
|
540
|
-
rel = path[len(".ai/"):]
|
|
541
|
-
candidates = [os.path.join(TEMPLATES["ai-init"], ".ai", rel), os.path.join(SHARED, rel)]
|
|
542
|
-
else:
|
|
543
|
-
key = template_targets().get(path)
|
|
544
|
-
candidates = [os.path.join(TEMPLATES["project-init"], key.split("/", 1)[1])] if key else []
|
|
545
|
-
for full in candidates:
|
|
546
|
-
if os.path.isfile(full):
|
|
547
|
-
return read(full)
|
|
548
|
-
raise SchemaError("%04d: the plugin ships no template for %s" % (self.module.VERSION, path))
|
|
549
|
-
|
|
550
|
-
def is_shipped(self, path, data):
|
|
551
|
-
"""True when these bytes are a version the plugin shipped at this path,
|
|
552
|
-
also after a checkout turned its line endings into CRLF."""
|
|
553
|
-
key = migrations.template_key(path, template_targets())
|
|
554
|
-
if key is None or data is None:
|
|
555
|
-
return False
|
|
556
|
-
known = self.history.hashes(key)
|
|
557
|
-
return sha(data) in known or sha(data.replace(b"\r\n", b"\n")) in known
|
|
558
443
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
installed home of a runtime the project declares. A home this machine
|
|
563
|
-
does not have is not checked."""
|
|
564
|
-
undeclared = sorted(p for p in paths if (p if p.startswith(".ai/") else ".ai/" + p) not in declared_shared())
|
|
565
|
-
if undeclared:
|
|
566
|
-
raise SchemaError("%04d: not in the shared set, never deleted as a shared copy: %s"
|
|
567
|
-
% (self.module.VERSION, ", ".join(undeclared)))
|
|
568
|
-
rels = sorted(p[len(".ai/"):] if p.startswith(".ai/") else p for p in paths)
|
|
569
|
-
missing = [r for r in rels if not os.path.isfile(os.path.join(SHARED, r))]
|
|
570
|
-
if missing:
|
|
571
|
-
raise SchemaError("%04d: the plugin's shared tree %s lacks %d file(s) a project copy would "
|
|
572
|
-
"fall back to: %s — reinstall the plugin (install.sh), then run this again"
|
|
573
|
-
% (self.module.VERSION, SHARED, len(missing), ", ".join(missing)))
|
|
574
|
-
homes = {"claude": os.path.join(os.path.expanduser("~"), ".claude"),
|
|
575
|
-
"codex": os.environ.get("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex")}
|
|
576
|
-
mine = os.path.realpath(PLUGIN_HOME)
|
|
577
|
-
for runtime in self.runtimes:
|
|
578
|
-
home = homes.get(runtime)
|
|
579
|
-
if (not home or os.path.realpath(home) == mine
|
|
580
|
-
or not os.path.isdir(os.path.join(home, "skills", "project-update"))):
|
|
581
|
-
continue
|
|
582
|
-
tree = os.path.join(home, "riskrails", "ai")
|
|
583
|
-
if not os.path.isdir(tree): # installed before 3.4b (R35, one version)
|
|
584
|
-
tree = os.path.join(home, "claude-agentic", "ai")
|
|
585
|
-
missing = [r for r in rels if not os.path.isfile(os.path.join(tree, r))]
|
|
586
|
-
if missing:
|
|
587
|
-
raise SchemaError("%04d: %s has this plugin installed for %s, but %d of the %d shared "
|
|
588
|
-
"file(s) a project copy would fall back to are missing from %s — "
|
|
589
|
-
"run `install.sh --target %s` from this build, then run this again"
|
|
590
|
-
% (self.module.VERSION, home, runtime, len(missing), len(rels),
|
|
591
|
-
tree, runtime))
|
|
592
|
-
|
|
593
|
-
def unsafe_target(self, path):
|
|
594
|
-
"""Why a file must not be deleted through this path, or None: it is a
|
|
595
|
-
symlink, it is reached through one, or it resolves outside the project."""
|
|
596
|
-
self._check_path(path)
|
|
597
|
-
return unsafe_path(self.root, path)
|
|
598
|
-
|
|
599
|
-
def override_note(self, path):
|
|
600
|
-
"""What an edited copy of a shared file now means."""
|
|
601
|
-
rel = path[len(".ai/"):] if path.startswith(".ai/") else path
|
|
602
|
-
return ("%s overrides the plugin's shared copy and no longer receives its changes — "
|
|
603
|
-
"diff against %s; `ai-path %s` shows which applies; delete it when it adds nothing"
|
|
604
|
-
% (path, os.path.join(SHARED, rel), rel))
|
|
605
|
-
|
|
606
|
-
def hint(self, text):
|
|
607
|
-
"""Say something to the human that is not an operation on a file."""
|
|
608
|
-
if text not in self.plan.hints:
|
|
609
|
-
self.plan.hints.append(text)
|
|
610
|
-
|
|
611
|
-
def conflict(self, path, note):
|
|
612
|
-
"""Hold the schema version on a file this migration could not handle; `note` says why."""
|
|
613
|
-
self._add("conflict", path, note)
|
|
614
|
-
|
|
615
|
-
def exists(self, path):
|
|
616
|
-
return self.read(path) is not None
|
|
617
|
-
|
|
618
|
-
def read(self, path):
|
|
619
|
-
self._check_path(path)
|
|
620
|
-
try:
|
|
621
|
-
return self.plan.read(path)
|
|
622
|
-
except OSError as exc:
|
|
623
|
-
raise SchemaError("%04d: %s cannot be read: %s" % (self.module.VERSION, path, exc)) from exc
|
|
444
|
+
def weight_module():
|
|
445
|
+
"""skills/ai-task/weight.py, the renderer behind `state.py weight --mirror`."""
|
|
446
|
+
return sibling("weight", "ai-task", "weight.py")
|
|
624
447
|
|
|
625
|
-
def _check_path(self, path):
|
|
626
|
-
why = migrations.path_error(path)
|
|
627
|
-
if why:
|
|
628
|
-
raise SchemaError("%04d: %r is %s" % (self.module.VERSION, path, why))
|
|
629
448
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
449
|
+
def weight_overlay():
|
|
450
|
+
"""skills/ai-task/weight_overlay.py: the plugin's table with the project's laid over it."""
|
|
451
|
+
return sibling("weight_overlay", "ai-task", "weight_overlay.py")
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def shipped_weight():
|
|
455
|
+
return os.path.join(TEMPLATES["ai-init"], WEIGHT_JSON)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# Old shapes the update no longer converts: literal checks, nothing parsed (D4).
|
|
459
|
+
OLD_MARKERS = (b"<!-- claude-agentic:", b"<!-- Codex-agentic:")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def check_kept(root):
|
|
463
|
+
"""[(path, reason)] for every kept file its own reader cannot take and every old
|
|
464
|
+
shape this version no longer converts. Reads only: the caller refuses on a
|
|
465
|
+
non-empty answer before anything is written (R12)."""
|
|
466
|
+
bad = []
|
|
467
|
+
|
|
468
|
+
def raw_of(rel):
|
|
469
|
+
path = os.path.join(root, rel)
|
|
470
|
+
if not os.path.lexists(path):
|
|
633
471
|
return None
|
|
634
472
|
try:
|
|
635
|
-
return
|
|
636
|
-
except
|
|
637
|
-
|
|
638
|
-
except RecursionError:
|
|
639
|
-
# Not "absent": a file nested past the parser's limit is there. The migration goes on
|
|
640
|
-
# as if it were absent (0007 then creates a default weight.json beside it, overwriting
|
|
641
|
-
# nothing); the conflict holds the schema version, so the next run tries again.
|
|
642
|
-
self._add("conflict", path, "nested too deeply, so this migration could not read it")
|
|
473
|
+
return read(path)
|
|
474
|
+
except OSError as exc:
|
|
475
|
+
bad.append((rel, "cannot be read: %s" % (exc.strerror or exc)))
|
|
643
476
|
return None
|
|
644
477
|
|
|
645
|
-
|
|
646
|
-
# records nothing, so a re-run after an interrupted apply is a no-op.
|
|
647
|
-
def _add(self, action, target, note="", **kw):
|
|
648
|
-
self._check_path(target)
|
|
649
|
-
if not isinstance(kw.get("content", b""), bytes):
|
|
650
|
-
raise SchemaError("%04d: %s content must be bytes, not %s"
|
|
651
|
-
% (self.module.VERSION, target, type(kw["content"]).__name__))
|
|
652
|
-
if not isinstance(note, str):
|
|
653
|
-
raise SchemaError("%04d: %s note must be str, not %s"
|
|
654
|
-
% (self.module.VERSION, target, type(note).__name__))
|
|
655
|
-
deep = action == "conflict" and isinstance(note, str) and note.startswith("nested too deeply")
|
|
656
|
-
if action == "conflict" and any(i["action"] == "conflict" and i["target"] == target
|
|
657
|
-
and i["migration"] == self.module.VERSION
|
|
658
|
-
and (i["note"] == note or deep and isinstance(i["note"], str)
|
|
659
|
-
and i["note"].startswith("nested too deeply"))
|
|
660
|
-
for i in self.plan.items):
|
|
661
|
-
return # one deep file read twice, or read then edited: one row, one held schema
|
|
662
|
-
self.plan.add(action, target, note, migration=self.module.VERSION, **kw)
|
|
663
|
-
|
|
664
|
-
def create(self, path, content):
|
|
665
|
-
"""Add a file the project does not have yet."""
|
|
666
|
-
if not self.exists(path):
|
|
667
|
-
self._add("create", path, content=content)
|
|
668
|
-
|
|
669
|
-
def move(self, src, dst):
|
|
670
|
-
"""Move a file the project may have edited. The pair must be in MOVES, so
|
|
671
|
-
the rename is visible to the history lookup without running the migration."""
|
|
672
|
-
if (src, dst) not in self.module.MOVES:
|
|
673
|
-
raise SchemaError("%04d: move(%r, %r) is not in MOVES" % (self.module.VERSION, src, dst))
|
|
674
|
-
data = self.read(src)
|
|
675
|
-
if data is None: # already moved, or the project never had it
|
|
676
|
-
return
|
|
677
|
-
there = self.read(dst)
|
|
678
|
-
if there is not None and there != data:
|
|
679
|
-
self._add("conflict", dst, "%s was moved here, but this file already exists" % src,
|
|
680
|
-
conflict_copy=data, src=src)
|
|
681
|
-
return
|
|
682
|
-
# there == data means a previous apply wrote dst and stopped before
|
|
683
|
-
# removing src: the move simply finishes.
|
|
684
|
-
self._add("move", dst, content=data, src=src)
|
|
685
|
-
self.plan.remove(src)
|
|
686
|
-
|
|
687
|
-
def edit_text(self, path, fn, note="migrated"):
|
|
688
|
-
"""Rewrite a text file the project has; fn returns its input to do nothing."""
|
|
689
|
-
data = self.read(path)
|
|
690
|
-
if data is None:
|
|
691
|
-
return
|
|
692
|
-
new = fn(data)
|
|
693
|
-
if new != data:
|
|
694
|
-
self._add("update", path, note, content=new)
|
|
695
|
-
|
|
696
|
-
def edit_json(self, path, fn):
|
|
697
|
-
"""Rewrite a JSON file the project has. A policy file's leaf changes are
|
|
698
|
-
listed for confirmation, exactly as a merged policy change is. fn gets a
|
|
699
|
-
deep copy of the document and returns the new one. It must not walk the
|
|
700
|
-
document with Python recursion (a file that just passed copy.deepcopy can
|
|
701
|
-
be 470-490 levels deep at the default limit), or it guards RecursionError
|
|
702
|
-
itself: an unguarded fn's RecursionError is reported as its own, the run
|
|
703
|
-
fails and no file is blamed."""
|
|
704
|
-
raw = self.read(path)
|
|
705
|
-
if raw is None:
|
|
706
|
-
return
|
|
478
|
+
def json_object(rel, data):
|
|
707
479
|
try:
|
|
708
|
-
|
|
709
|
-
except ValueError as exc:
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
480
|
+
doc = json.loads(data.decode("utf-8"))
|
|
481
|
+
except (ValueError, RecursionError) as exc: # UnicodeDecodeError is a ValueError
|
|
482
|
+
bad.append((rel, "is not valid JSON (%s)" % (type(exc).__name__ if isinstance(exc, RecursionError) else exc)))
|
|
483
|
+
return None
|
|
484
|
+
if not isinstance(doc, dict):
|
|
485
|
+
bad.append((rel, "is not a JSON object"))
|
|
486
|
+
return None
|
|
487
|
+
return doc
|
|
488
|
+
|
|
489
|
+
for rel in (STATE_FILE, ".ai/policies/dialog.json", ".ai/policies/memory.json", ".claude/settings.json"):
|
|
490
|
+
data = raw_of(rel)
|
|
491
|
+
if data is not None:
|
|
492
|
+
json_object(rel, data)
|
|
493
|
+
data = raw_of(WEIGHT_PROJECT)
|
|
494
|
+
if data is not None:
|
|
495
|
+
doc = json_object(WEIGHT_PROJECT, data)
|
|
496
|
+
if doc is not None and doc.get("version") != 1:
|
|
497
|
+
bad.append((WEIGHT_PROJECT, "has no \"version\": 1"))
|
|
498
|
+
elif doc is not None:
|
|
499
|
+
try:
|
|
500
|
+
weight_overlay().load(shipped_weight(), os.path.join(root, WEIGHT_PROJECT))
|
|
501
|
+
except ValueError as exc:
|
|
502
|
+
bad.append((WEIGHT_PROJECT, str(exc).split(": ", 1)[-1]))
|
|
503
|
+
data = raw_of(VERIFICATION_MD)
|
|
504
|
+
if data is not None:
|
|
717
505
|
try:
|
|
718
|
-
|
|
719
|
-
except
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
after = fn(copied)
|
|
506
|
+
data.decode("utf-8")
|
|
507
|
+
except UnicodeDecodeError:
|
|
508
|
+
bad.append((VERIFICATION_MD, "is not UTF-8 text"))
|
|
509
|
+
data = raw_of(".codex/config.toml")
|
|
510
|
+
if data is not None and tomllib is not None:
|
|
724
511
|
try:
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
512
|
+
tomllib.loads(data.decode("utf-8"))
|
|
513
|
+
except ValueError as exc: # TOMLDecodeError and UnicodeDecodeError
|
|
514
|
+
bad.append((".codex/config.toml", "is not valid TOML (%s)" % exc))
|
|
515
|
+
# the old weight.json is read once, by the split, so it must parse then
|
|
516
|
+
if not os.path.lexists(os.path.join(root, WEIGHT_PROJECT)):
|
|
517
|
+
data = raw_of(WEIGHT_JSON)
|
|
518
|
+
if data is not None:
|
|
519
|
+
json_object(WEIGHT_JSON, data)
|
|
520
|
+
old_shapes(root, bad)
|
|
521
|
+
return bad
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def old_shapes(root, bad):
|
|
525
|
+
"""The four literal detectors of an older riskrails' files (D4)."""
|
|
526
|
+
exists = lambda rel: os.path.lexists(os.path.join(root, rel)) # noqa: E731
|
|
527
|
+
if exists(".ai/state/current.json") and not exists(STATE_FILE):
|
|
528
|
+
bad.append((".ai/state/current.json", "is the task state of a riskrails before 3.4b (there is no state.json)"))
|
|
529
|
+
if exists(".ai/policies/risk-tiers.json") and not exists(WEIGHT_JSON) and not exists(WEIGHT_PROJECT):
|
|
530
|
+
bad.append((".ai/policies/risk-tiers.json", "is the risk-tier policy of a riskrails before 2.2 (there is no weight table)"))
|
|
531
|
+
names = set(render_instructions.RULE_FILE.values()) | {e[0] for e in INSTRUCTION_FILE.values()}
|
|
532
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
533
|
+
dirnames[:] = sorted(d for d in dirnames if d not in RULES_SKIP
|
|
534
|
+
and not os.path.lexists(os.path.join(dirpath, d, ".git")))
|
|
535
|
+
for name in filenames:
|
|
536
|
+
rel = os.path.relpath(os.path.join(dirpath, name), root).replace(os.sep, "/")
|
|
537
|
+
if name not in names and rel not in names:
|
|
538
|
+
continue
|
|
539
|
+
try:
|
|
540
|
+
data = read(os.path.join(root, rel))
|
|
541
|
+
except OSError:
|
|
542
|
+
continue # an unreadable instruction file is the plan's to report, not an old shape
|
|
543
|
+
found = next((m for m in OLD_MARKERS if m in data), None)
|
|
544
|
+
if found:
|
|
545
|
+
bad.append((rel, "carries %s of a riskrails before 3.4b" % found.decode()))
|
|
733
546
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
if after == self.state:
|
|
745
|
-
return
|
|
746
|
-
after["updated_at"] = utc_now()
|
|
747
|
-
after.setdefault("history", []).append(
|
|
748
|
-
{"at": utc_now(), "event": "schema_migrated",
|
|
749
|
-
"detail": "%04d %s" % (self.module.VERSION, self.module.TITLE)})
|
|
750
|
-
self.state = after
|
|
751
|
-
self._add("update", state_file(self.plan), "task state migrated",
|
|
752
|
-
content=(json.dumps(after, indent=2, ensure_ascii=False) + "\n").encode())
|
|
753
|
-
|
|
754
|
-
def delete(self, path, reason):
|
|
755
|
-
"""Propose a deletion. It is only listed until a human passes
|
|
756
|
-
--apply --confirm-delete NAME. Three places remove a file, each behind a
|
|
757
|
-
human confirmation or a block the plugin owns: this one (a migration),
|
|
758
|
-
rules_update (a rendered rule block whose rule is gone) and
|
|
759
|
-
adopt.remove_confirmed (--adopt --cleanup, behind --confirm-delete)."""
|
|
760
|
-
if not self.exists(path):
|
|
761
|
-
return
|
|
762
|
-
if not self.plan.confirm_delete:
|
|
763
|
-
self._add("delete?", path, reason, reason=reason)
|
|
764
|
-
return
|
|
765
|
-
self._add("delete", path, reason, reason=reason)
|
|
766
|
-
# Out of the virtual tree, so this run's template walk does not put it
|
|
767
|
-
# back. A template the plugin still ships is re-created by the next run:
|
|
768
|
-
# a migration that deletes must retire the template in the same release.
|
|
769
|
-
self.plan.remove(path)
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
def run_migrations(plan, version, history):
|
|
773
|
-
"""Plan every migration this project is missing, oldest first, before the
|
|
774
|
-
three-way merge sees a single file. Returns the modules that ran."""
|
|
775
|
-
mods = [m for m in migrations.load() if version < m.VERSION <= migrations.CURRENT]
|
|
776
|
-
state = None
|
|
777
|
-
path = state_file(plan)
|
|
778
|
-
try:
|
|
779
|
-
raw = plan.read(path)
|
|
780
|
-
except OSError as exc:
|
|
781
|
-
raise SchemaError("%s cannot be read: %s — a migration must see the task in flight"
|
|
782
|
-
% (path, exc)) from exc
|
|
783
|
-
if raw is not None:
|
|
784
|
-
# state.py dies loudly on a corrupt state file because ai-scope-guard
|
|
785
|
-
# derives its boundaries from it; a migration must not quietly skip it.
|
|
786
|
-
try:
|
|
787
|
-
state = json.loads(raw, object_pairs_hook=OrderedDict)
|
|
788
|
-
except ValueError as exc:
|
|
789
|
-
raise SchemaError("%s is not valid JSON (%s) — inspect it by hand" % (path, exc)) from exc
|
|
790
|
-
except RecursionError as exc:
|
|
791
|
-
raise SchemaError("%s is nested too deeply to parse — inspect it by hand" % path) from exc
|
|
792
|
-
if not isinstance(state, dict):
|
|
793
|
-
raise SchemaError("%s is not a task state object" % path)
|
|
794
|
-
for mod in mods:
|
|
795
|
-
# A copy each, taken from the plan: a migration that mutates ctx.state in
|
|
796
|
-
# place without patch_state changes nothing for the next one.
|
|
797
|
-
planned = plan.final.get(state_file(plan))
|
|
798
|
-
if planned is not None:
|
|
799
|
-
state = json.loads(planned, object_pairs_hook=OrderedDict)
|
|
800
|
-
mod.plan(MigrationContext(plan, mod, version, copy.deepcopy(state), history))
|
|
801
|
-
if state is not None:
|
|
802
|
-
plan.hints.append("%s: a task is in flight while the schema changes — check that the files "
|
|
803
|
-
"its current step may touch still exist afterwards" % state_file(plan))
|
|
804
|
-
return mods
|
|
547
|
+
|
|
548
|
+
def kept_refusal(bad):
|
|
549
|
+
"""I3, verbatim: one line per failing file, then the rest."""
|
|
550
|
+
for rel, why in bad:
|
|
551
|
+
print("KEPT_UNREADABLE: %s: %s" % (rel, why))
|
|
552
|
+
print("project-update: nothing was deleted or written. This version regenerates its own files and no longer "
|
|
553
|
+
"converts older ones.")
|
|
554
|
+
print("Install riskrails@%s (npx riskrails@%s), run /rr-setup --update with it, then install this version "
|
|
555
|
+
"and run /rr-setup --update again." % (LAST_MIGRATING_VERSION, LAST_MIGRATING_VERSION))
|
|
556
|
+
return EXIT_KEPT_UNREADABLE
|
|
805
557
|
|
|
806
558
|
|
|
807
559
|
# ---------------------------------------------------------------- planning
|
|
808
560
|
class Plan:
|
|
809
561
|
def __init__(self, root):
|
|
810
562
|
self.root = root
|
|
811
|
-
self.items = [] # dicts: action, target, note, content,
|
|
563
|
+
self.items = [] # dicts: action, target, note, content, reason, keep, expect
|
|
812
564
|
self.hints = []
|
|
813
|
-
self.
|
|
814
|
-
self.confirm_delete = None # the human who confirmed this run's deletions
|
|
565
|
+
self.confirm_delete = None # the human who confirmed this run's gated rows
|
|
815
566
|
self.confirm_via = None # why that gate passed: "terminal" or "unattended"
|
|
816
567
|
self.confirm_cleanup = None # the human who confirmed this run's [cleanup] deletions
|
|
817
568
|
self.cleanup_via = None
|
|
818
|
-
self.
|
|
569
|
+
self.version_from = None # the .ai/VERSION this run started from
|
|
819
570
|
self.final = {} # target -> bytes after this run (for cross-file fixes)
|
|
820
571
|
self.removed = set() # targets this run takes away
|
|
821
572
|
self.seen = {} # target -> sha of what was on disk when it was first read,
|
|
@@ -846,229 +597,115 @@ class Plan:
|
|
|
846
597
|
def before(self, target):
|
|
847
598
|
"""The sha of the target as this run first read it, None when it was
|
|
848
599
|
absent. Recorded by the read that planned the change, so the precondition
|
|
849
|
-
and the
|
|
850
|
-
(a move and then its merge) is checked against what this run wrote."""
|
|
600
|
+
and the planning input are the same bytes."""
|
|
851
601
|
if target not in self.seen:
|
|
852
602
|
path = os.path.join(self.root, target)
|
|
853
603
|
self.seen[target] = sha(read(path)) if os.path.isfile(path) and not os.path.islink(path) else None
|
|
854
604
|
return self.seen[target]
|
|
855
605
|
|
|
856
|
-
def add(self, action, target, note="", content=None,
|
|
857
|
-
migration=None, src=None, reason=None, tool=None):
|
|
858
|
-
expect = self.before(target)
|
|
606
|
+
def add(self, action, target, note="", content=None, reason=None, keep=False):
|
|
859
607
|
self.items.append(dict(action=action, target=target, note=note, content=content,
|
|
860
|
-
|
|
861
|
-
src=src, reason=reason, tool=tool, expect=expect,
|
|
862
|
-
src_expect=self.before(src) if src else None))
|
|
608
|
+
reason=reason, keep=keep, expect=self.before(target)))
|
|
863
609
|
if content is not None:
|
|
864
610
|
self.final[target] = content
|
|
865
611
|
self.removed.discard(target) # planned content wins over an earlier removal
|
|
866
612
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
613
|
+
def original_of(self, target):
|
|
614
|
+
return "%s/original/%s" % (self.report_dir, target)
|
|
615
|
+
|
|
616
|
+
def gated(self, action, target, note, content=None, reason=None):
|
|
617
|
+
"""A change that waits for a human: `regenerate?` / `delete?` until
|
|
618
|
+
--apply --confirm-delete NAME, then `regenerate` / `delete` with the original kept
|
|
619
|
+
under original/ (R7)."""
|
|
620
|
+
if self.confirm_delete:
|
|
621
|
+
self.add(action, target, note, content=content, reason=reason, keep=True)
|
|
622
|
+
if action == "delete":
|
|
623
|
+
self.remove(target)
|
|
624
|
+
else:
|
|
625
|
+
self.add(action + "?", target, note, reason=reason)
|
|
872
626
|
|
|
873
627
|
|
|
874
|
-
|
|
875
|
-
|
|
628
|
+
# ---------------------------------------------------------------- regenerate
|
|
629
|
+
def lost_lines(ours, theirs):
|
|
630
|
+
"""The lines of the project's copy that the plugin's version does not have."""
|
|
631
|
+
kept = set(theirs.decode("utf-8", "replace").splitlines())
|
|
632
|
+
return [line.strip() for line in ours.decode("utf-8", "replace").splitlines()
|
|
633
|
+
if line.strip() and line not in kept]
|
|
876
634
|
|
|
877
635
|
|
|
878
|
-
def
|
|
879
|
-
"""
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
636
|
+
def patterns_of(data):
|
|
637
|
+
"""The count of non-empty deny and allow patterns in a path-guard.json, or None when it is not one."""
|
|
638
|
+
try:
|
|
639
|
+
doc = json.loads(data)
|
|
640
|
+
return sum(1 for key in ("deny_patterns", "allow_patterns") for p in doc.get(key) or []
|
|
641
|
+
if isinstance(p, str) and p.strip())
|
|
642
|
+
except (ValueError, AttributeError, TypeError, RecursionError):
|
|
643
|
+
return None
|
|
886
644
|
|
|
887
645
|
|
|
888
|
-
def
|
|
889
|
-
"""
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
646
|
+
def edited_note(plan, target, ours, theirs):
|
|
647
|
+
"""What a gated regenerate would lose, and where the original goes (R6)."""
|
|
648
|
+
lost = lost_lines(ours, theirs)
|
|
649
|
+
shown = "; ".join("`%s`" % (line[:48] + ("…" if len(line) > 48 else "")) for line in lost[:3])
|
|
650
|
+
note = "edited here: %d line(s) of yours are not in the plugin's version%s" % (len(lost), " (" + shown + ")" if shown else "")
|
|
651
|
+
if target.endswith("path-guard.json"):
|
|
652
|
+
count = patterns_of(ours)
|
|
653
|
+
note += "; drops %s deny/allow pattern(s)" % ("an unreadable number of" if count is None else count)
|
|
654
|
+
return "%s; original kept at %s" % (note, plan.original_of(target))
|
|
895
655
|
|
|
896
656
|
|
|
897
|
-
def
|
|
898
|
-
"""
|
|
899
|
-
the
|
|
900
|
-
merged as text with the template's, whose sha256 line names the template's JSON, it
|
|
901
|
-
was a conflict on every update. A mirror edited by hand, or stale, merges as before."""
|
|
902
|
-
ours = plan.read(WEIGHT_MD)
|
|
903
|
-
path = os.path.join(plan.root, WEIGHT_JSON)
|
|
904
|
-
disk = read(path) if os.path.isfile(path) and not os.path.islink(path) else None
|
|
905
|
-
after = plan.read(WEIGHT_JSON)
|
|
906
|
-
fresh = None
|
|
907
|
-
rendering_raised = False
|
|
908
|
-
if ours is not None and disk is not None and after is not None:
|
|
909
|
-
try:
|
|
910
|
-
if ours == render_weight(disk):
|
|
911
|
-
fresh = render_weight(after)
|
|
912
|
-
except Exception as exc: # pylint: disable=broad-except
|
|
913
|
-
# not a table the renderer reads (or too deep, or no temp dir, or no renderer):
|
|
914
|
-
# the text merge decides, and it is always safe
|
|
915
|
-
fresh = None
|
|
916
|
-
rendering_raised = True
|
|
917
|
-
# L3: say so, so a renderer bug is not read as a hand-edited mirror
|
|
918
|
-
plan.hints.append("%s: not rendered from weight.json (%s); merged as text"
|
|
919
|
-
% (WEIGHT_MD, type(exc).__name__))
|
|
920
|
-
if fresh is None:
|
|
921
|
-
three_way(plan, history, "ai-init/" + WEIGHT_MD, WEIGHT_MD, theirs)
|
|
922
|
-
# L4: not when the renderer raised: `state.py weight --mirror` would fail the same way
|
|
923
|
-
stale = None if rendering_raised else stale_mirror_hint(ours, disk)
|
|
924
|
-
if stale:
|
|
925
|
-
plan.hints.append(stale)
|
|
926
|
-
elif fresh != ours:
|
|
927
|
-
plan.add("update", WEIGHT_MD, "rendered from weight.json", content=fresh)
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
def stale_mirror_hint(ours, disk):
|
|
931
|
-
"""L3: the sha256 in weight.md's header names a weight.json other than the one on disk
|
|
932
|
-
(an --apply interrupted between the two writes, or a JSON edited without regenerating)."""
|
|
933
|
-
if ours is None or disk is None:
|
|
934
|
-
return None
|
|
935
|
-
head = ours[:2000]
|
|
936
|
-
found = re.search(r"^<!--.*\bsha256:\s*([0-9a-fA-F]{64})\b.*-->", head if isinstance(head, str) else head.decode("utf-8", "replace"), re.M)
|
|
937
|
-
if not found or found.group(1).lower() == hashlib.sha256(disk if isinstance(disk, bytes) else disk.encode("utf-8")).hexdigest():
|
|
938
|
-
return None
|
|
939
|
-
return ("%s: rendered from a different weight.json than the one on disk (stale: an interrupted "
|
|
940
|
-
"--apply, or the JSON edited without regenerating) — `state.py weight --mirror` "
|
|
941
|
-
"regenerates it from the JSON and REPLACES any hand edits in it, so keep or move "
|
|
942
|
-
"them first" % WEIGHT_MD)
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
def three_way(plan, history, key, target, theirs):
|
|
657
|
+
def regenerate(plan, history, key, target, theirs):
|
|
658
|
+
"""D5: absent -> create; equal -> nothing; a version the plugin once shipped -> regenerate;
|
|
659
|
+
anything else is the human's edit -> regenerate? behind the gate."""
|
|
946
660
|
ours = plan.read(target)
|
|
947
661
|
if ours is None:
|
|
948
662
|
plan.add("create", target, content=theirs)
|
|
663
|
+
elif ours == theirs:
|
|
949
664
|
return
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
policy = None
|
|
955
|
-
if target.endswith(".json") and "/policies/" in "/" + target:
|
|
956
|
-
try:
|
|
957
|
-
policy = leaf_changes(json.loads(ours, object_pairs_hook=OrderedDict),
|
|
958
|
-
json.loads(theirs, object_pairs_hook=OrderedDict))
|
|
959
|
-
except (ValueError, RecursionError): # not JSON, or too deep to read or flatten: no policy lines
|
|
960
|
-
policy = None
|
|
961
|
-
plan.add("update", target, "unchanged since it was installed",
|
|
962
|
-
content=theirs, policy=policy)
|
|
963
|
-
return
|
|
665
|
+
elif history.shipped(key, ours):
|
|
666
|
+
plan.add("regenerate", target, "unchanged since it was installed", content=theirs)
|
|
667
|
+
else:
|
|
668
|
+
plan.gated("regenerate", target, edited_note(plan, target, ours, theirs), content=theirs)
|
|
964
669
|
|
|
965
|
-
if target.endswith(".json"):
|
|
966
|
-
try:
|
|
967
|
-
o = json.loads(ours, object_pairs_hook=OrderedDict)
|
|
968
|
-
t = json.loads(theirs, object_pairs_hook=OrderedDict)
|
|
969
|
-
bases = [json.loads(v, object_pairs_hook=OrderedDict) for v in versions]
|
|
970
|
-
except ValueError:
|
|
971
|
-
plan.add("conflict", target, "not valid JSON; not merged", conflict_copy=theirs)
|
|
972
|
-
return
|
|
973
|
-
except RecursionError: # parsing, flatten or merge_json past the recursion limit
|
|
974
|
-
plan.add("conflict", target, "nested too deeply; not merged", conflict_copy=theirs)
|
|
975
|
-
return
|
|
976
|
-
try:
|
|
977
|
-
base = closest(bases, o, json_score) if bases else MISSING
|
|
978
|
-
conflicts = []
|
|
979
|
-
merged = merge_json(base, o, t, [], conflicts)
|
|
980
|
-
policy = leaf_changes(o, merged) if "/policies/" in "/" + target else None
|
|
981
|
-
content = dump_json(merged) if merged != o else None
|
|
982
|
-
except RecursionError:
|
|
983
|
-
plan.add("conflict", target, "nested too deeply; not merged", conflict_copy=theirs)
|
|
984
|
-
return
|
|
985
|
-
if merged == o and not conflicts:
|
|
986
|
-
return
|
|
987
|
-
if merged != o:
|
|
988
|
-
plan.add("merge", target, "your edits kept", content=content, policy=policy)
|
|
989
|
-
if conflicts:
|
|
990
|
-
plan.add("conflict", target, "kept your value for: " + ", ".join(conflicts), conflict_copy=theirs)
|
|
991
|
-
return
|
|
992
670
|
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
return
|
|
1002
|
-
if merged is None:
|
|
1003
|
-
plan.add("conflict", target, "edited here and changed in the plugin", conflict_copy=theirs)
|
|
1004
|
-
elif merged != ours:
|
|
1005
|
-
plan.add("merge", target, "your edits kept", content=merged)
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
def split_shared_section(text):
|
|
1009
|
-
"""(text without its `## Shared files` section, the section, the heading
|
|
1010
|
-
line that followed it) — the section runs from its heading to the next
|
|
1011
|
-
`## ` heading or the end; None when it is not there exactly once."""
|
|
1012
|
-
found = list(SHARED_SECTION_RE.finditer(text))
|
|
1013
|
-
if len(found) != 1:
|
|
1014
|
-
return None
|
|
1015
|
-
start = found[0].start()
|
|
1016
|
-
nxt = re.compile(rb"(?m)^## ").search(text, found[0].end())
|
|
1017
|
-
if nxt is None: # at the end: the blank line before it belongs to the section
|
|
1018
|
-
head = text[:start]
|
|
1019
|
-
return (head[:-1] if head.endswith(b"\n\n") else head), text[start:], b""
|
|
1020
|
-
end = nxt.start()
|
|
1021
|
-
eol = text.find(b"\n", end)
|
|
1022
|
-
follow = text[end:eol + 1] if eol >= 0 else text[end:]
|
|
1023
|
-
return text[:start] + text[end:], text[start:end], follow
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
def router_merge(plan, ours, versions, theirs):
|
|
1027
|
-
"""A router that already names the shared files once. 0006 put the section
|
|
1028
|
-
in by insertion, so no shipped version is a fair base for it: merge the
|
|
1029
|
-
rest of the router without the section, against the shipped version that
|
|
1030
|
-
rest is closest to, then put the project's section back before the heading
|
|
1031
|
-
it preceded (or where the plugin puts it, or at the end). Nothing to do
|
|
1032
|
-
only when the plugin changed nothing but the section since that base."""
|
|
1033
|
-
crlf = b"\r\n" in ours
|
|
1034
|
-
lf = (lambda b: b.replace(b"\r\n", b"\n")) if crlf else (lambda b: b)
|
|
1035
|
-
o_rest, o_sec, follow = split_shared_section(lf(ours))
|
|
1036
|
-
t_split = split_shared_section(theirs)
|
|
1037
|
-
t_rest = t_split[0] if t_split else theirs
|
|
1038
|
-
rests = [(split_shared_section(v) or (v,))[0] for v in versions]
|
|
1039
|
-
base = closest(rests, o_rest, text_ratio) if rests else None
|
|
1040
|
-
if o_rest == t_rest or (base is not None and base == t_rest):
|
|
1041
|
-
return
|
|
1042
|
-
merged = merge_text(o_rest, base, t_rest) if base is not None else None
|
|
1043
|
-
if merged is not None and not follow: # the project had it at the end: it stays there
|
|
1044
|
-
merged = merged + (b"" if merged.endswith(b"\n") else b"\n") + b"\n" + o_sec
|
|
1045
|
-
elif merged is not None:
|
|
1046
|
-
merged = put_section(merged, o_sec, [follow, t_split[2] if t_split else b""])
|
|
1047
|
-
if merged is None or len(SHARED_SECTION_RE.findall(merged)) != 1:
|
|
1048
|
-
plan.add("conflict", ROUTER_FILE, "edited here and changed in the plugin", conflict_copy=theirs)
|
|
1049
|
-
return
|
|
1050
|
-
if crlf:
|
|
1051
|
-
merged = merged.replace(b"\n", b"\r\n")
|
|
1052
|
-
if merged != ours:
|
|
1053
|
-
plan.add("merge", ROUTER_FILE, "your edits kept", content=merged)
|
|
671
|
+
def effective_weight(plan):
|
|
672
|
+
"""The weight table as this run leaves it: the plugin's with weight.project.json laid over it."""
|
|
673
|
+
own = plan.read(WEIGHT_PROJECT)
|
|
674
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
675
|
+
path = os.path.join(tmp, "weight.project.json")
|
|
676
|
+
if own is not None:
|
|
677
|
+
with open(path, "wb") as fh:
|
|
678
|
+
fh.write(own)
|
|
679
|
+
return weight_overlay().load(shipped_weight(), path)
|
|
1054
680
|
|
|
1055
681
|
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
682
|
+
GENERATED_HEADER = b"generated by `state.py weight --mirror`"
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def weight_md(plan, history):
|
|
686
|
+
"""weight.md is always rendered from the shipped table and the overlay (spec :161). A file that is
|
|
687
|
+
a rendering or a shipped version is replaced; one a human wrote is gated."""
|
|
688
|
+
try:
|
|
689
|
+
theirs = weight_module().mirror(effective_weight(plan)).encode("utf-8")
|
|
690
|
+
except (ValueError, OSError, KeyError, TypeError, AttributeError, RecursionError) as exc:
|
|
691
|
+
plan.hints.append("%s: not rendered (%s: %s); fix %s, then run this again"
|
|
692
|
+
% (WEIGHT_MD, type(exc).__name__, exc, WEIGHT_PROJECT))
|
|
693
|
+
return
|
|
694
|
+
ours = plan.read(WEIGHT_MD)
|
|
695
|
+
if ours is None:
|
|
696
|
+
plan.add("create", WEIGHT_MD, "rendered from the weight table", content=theirs)
|
|
697
|
+
elif ours == theirs:
|
|
698
|
+
return
|
|
699
|
+
elif GENERATED_HEADER in ours[:600] or history.shipped("ai-init/" + WEIGHT_MD, ours):
|
|
700
|
+
plan.add("regenerate", WEIGHT_MD, "rendered from the weight table", content=theirs)
|
|
701
|
+
else:
|
|
702
|
+
plan.gated("regenerate", WEIGHT_MD, edited_note(plan, WEIGHT_MD, ours, theirs), content=theirs)
|
|
1066
703
|
|
|
1067
704
|
|
|
1068
705
|
def block_update(plan, history, runtime):
|
|
706
|
+
"""R15, D5: the managed block of one instruction file. Rewritten between its markers; no block
|
|
707
|
+
-> appended; no file -> a minimal one with the block. Text outside the markers is never touched."""
|
|
1069
708
|
name, block_tpl, minimal_tpl = INSTRUCTION_FILE[runtime]
|
|
1070
|
-
if name in plan.removed: # a migration moved this instruction file away
|
|
1071
|
-
return
|
|
1072
709
|
tpl = read(os.path.join(TEMPLATES["ai-init"], block_tpl))
|
|
1073
710
|
text = plan.read(name)
|
|
1074
711
|
if text is None:
|
|
@@ -1078,116 +715,222 @@ def block_update(plan, history, runtime):
|
|
|
1078
715
|
return
|
|
1079
716
|
found = find_block(text)
|
|
1080
717
|
if found is None:
|
|
1081
|
-
plan.add("
|
|
1082
|
-
content=text.rstrip(b"\n") + b"\n\n" + tpl)
|
|
718
|
+
plan.add("regenerate", name, "managed block appended", content=text.rstrip(b"\n") + b"\n\n" + tpl)
|
|
1083
719
|
return
|
|
1084
|
-
s, e
|
|
1085
|
-
|
|
1086
|
-
renamed = " (legacy %s markers renamed)" % start_marker.decode()[len("<!-- "):-len(":start -->")] if legacy else ""
|
|
1087
|
-
ours = current_markers(text[s:e], start_marker, end_marker) + b"\n"
|
|
720
|
+
s, e = found[0], found[1]
|
|
721
|
+
ours = text[s:e] + b"\n"
|
|
1088
722
|
if ours == tpl:
|
|
1089
|
-
if legacy:
|
|
1090
|
-
plan.add("update", name, "managed block kept" + renamed,
|
|
1091
|
-
content=text[:s] + tpl.rstrip(b"\n") + text[e:])
|
|
1092
723
|
return
|
|
1093
|
-
|
|
1094
|
-
if
|
|
1095
|
-
|
|
1096
|
-
note = "managed block replaced (unchanged since it was installed)" + renamed
|
|
724
|
+
content = text[:s] + tpl.rstrip(b"\n") + text[e:]
|
|
725
|
+
if history.shipped("ai-init/" + block_tpl, ours):
|
|
726
|
+
plan.add("regenerate", name, "managed block replaced (unchanged since it was installed)", content=content)
|
|
1097
727
|
else:
|
|
1098
|
-
|
|
1099
|
-
new_block = merge_text(ours, base, tpl) if base is not None else None
|
|
1100
|
-
note = "managed block merged, your edits kept" + renamed
|
|
1101
|
-
if new_block is None and legacy:
|
|
1102
|
-
# The edit cannot be merged, but the markers can still be renamed: the
|
|
1103
|
-
# block stays as edited, and the next run treats it as an edited
|
|
1104
|
-
# current block — a conflict with the plugin's copy beside it.
|
|
1105
|
-
new_block = ours
|
|
1106
|
-
note = "managed block kept as edited" + renamed
|
|
1107
|
-
if new_block is None:
|
|
1108
|
-
plan.add("conflict", name, "the managed block was edited here and changed in the plugin",
|
|
1109
|
-
conflict_copy=tpl)
|
|
1110
|
-
return
|
|
1111
|
-
if new_block == ours and not legacy:
|
|
1112
|
-
# The merge already holds the plugin's changes: writing it back is no
|
|
1113
|
-
# change, and planning it would keep --check "behind" on every run.
|
|
1114
|
-
return
|
|
1115
|
-
plan.add("update", name, note, content=text[:s] + new_block.rstrip(b"\n") + text[e:])
|
|
728
|
+
plan.gated("regenerate", name, "managed block " + edited_note(plan, name, ours, tpl), content=content)
|
|
1116
729
|
|
|
1117
730
|
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
731
|
+
# ---------------------------------------------------------------- the one-time split
|
|
732
|
+
FIELD_LINE = re.compile(r"(?:%s):" % "|".join(
|
|
733
|
+
("verify_command", "step_test_command", "e2e_command", "lint_command", "typecheck_command",
|
|
734
|
+
"single_test", "doc_tests", "bite_codes", "healthy_output", "runtime")))
|
|
735
|
+
SPECIFICS_RE = re.compile(r"(?m)^## Project specifics[ \t]*$")
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def split_testing(testing, template):
|
|
739
|
+
"""R11: verification.md from testing.md. The field lines are copied verbatim with their continuation
|
|
740
|
+
lines (indented, after a field line), then `## Project specifics` to the end of the file."""
|
|
741
|
+
lines = testing.decode("utf-8").splitlines(keepends=True)
|
|
742
|
+
fields, taking = [], False
|
|
743
|
+
for line in lines:
|
|
744
|
+
if FIELD_LINE.match(line):
|
|
745
|
+
fields.append(line)
|
|
746
|
+
taking = True
|
|
747
|
+
elif taking and line[:1] in (" ", "\t") and line.strip():
|
|
748
|
+
fields.append(line)
|
|
749
|
+
else:
|
|
750
|
+
taking = False
|
|
751
|
+
text = template.decode("utf-8")
|
|
752
|
+
if not fields:
|
|
753
|
+
return template, 0
|
|
754
|
+
head = text[:re.search(r"(?m)^verify_command:", text).start()]
|
|
755
|
+
tail = SPECIFICS_RE.search(testing.decode("utf-8"))
|
|
756
|
+
specifics = testing.decode("utf-8")[tail.start():] if tail else text[SPECIFICS_RE.search(text).start():]
|
|
757
|
+
body = "".join(fields)
|
|
758
|
+
return (head + body + ("" if body.endswith("\n") else "\n") + "\n" + specifics).encode("utf-8"), len(fields)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def leaves(value, prefix=()):
|
|
762
|
+
"""{path tuple: leaf} — objects are walked, a list or scalar (or an empty object) is one leaf."""
|
|
763
|
+
if isinstance(value, dict) and value:
|
|
764
|
+
out = {}
|
|
765
|
+
for k, v in value.items():
|
|
766
|
+
out.update(leaves(v, prefix + (k,)))
|
|
767
|
+
return out
|
|
768
|
+
return {prefix: value}
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def split_weight(old, history, template):
|
|
772
|
+
"""R11: weight.project.json from the old weight.json: the leaves that differ from that leaf in every
|
|
773
|
+
version the plugin shipped (lists whole). -> (bytes, carried paths, uncarried paths that differ from today's table)."""
|
|
774
|
+
mine = json.loads(old.decode("utf-8"), object_pairs_hook=OrderedDict)
|
|
775
|
+
tables = []
|
|
776
|
+
for blob in history.versions("ai-init/" + WEIGHT_JSON) + [read(shipped_weight())]:
|
|
777
|
+
try:
|
|
778
|
+
tables.append(leaves(json.loads(blob.decode("utf-8"))))
|
|
779
|
+
except (ValueError, RecursionError):
|
|
780
|
+
continue
|
|
781
|
+
today = tables[-1] if tables else {}
|
|
782
|
+
carried, uncarried = OrderedDict(), []
|
|
783
|
+
for path, value in leaves(mine).items():
|
|
784
|
+
if path[:1] in (("version",), ("_comment",)) or not path:
|
|
785
|
+
continue
|
|
786
|
+
if any(path in t and t[path] == value for t in tables):
|
|
787
|
+
if today.get(path, ...) != value:
|
|
788
|
+
uncarried.append(".".join(map(str, path)))
|
|
789
|
+
continue
|
|
790
|
+
here = carried
|
|
791
|
+
for part in path[:-1]:
|
|
792
|
+
here = here.setdefault(part, OrderedDict())
|
|
793
|
+
here[path[-1]] = value
|
|
794
|
+
doc = json.loads(template.decode("utf-8"), object_pairs_hook=OrderedDict)
|
|
795
|
+
doc.update(carried)
|
|
796
|
+
paths = [".".join(map(str, p)) for p in leaves(carried) if p]
|
|
797
|
+
return dump_json(doc), paths, uncarried
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def listed(names, limit=6):
|
|
801
|
+
shown = ", ".join(names[:limit])
|
|
802
|
+
return shown + (" and %d more" % (len(names) - limit) if len(names) > limit else "")
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def unwritten(plan, history, target, template):
|
|
806
|
+
"""True when the new file is absent, or is still a shipped template (today's or any in the history index):
|
|
807
|
+
a scaffold run over an old tree can leave one, and it carries nothing of the project."""
|
|
808
|
+
if not plan.exists(target):
|
|
809
|
+
return True
|
|
810
|
+
data = plan.read(target)
|
|
811
|
+
return data is not None and (data.replace(b"\r\n", b"\n") == template.replace(b"\r\n", b"\n")
|
|
812
|
+
or history.shipped("ai-init/" + target, data))
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def split_once(plan, history):
|
|
816
|
+
"""R11: when verification.md / weight.project.json are absent (or only a shipped template), make them from
|
|
817
|
+
testing.md / weight.json (once); with a project's own file present the old file is not read again.
|
|
818
|
+
The old weight.json is then REMOVED, gated."""
|
|
819
|
+
tpl = TEMPLATES["ai-init"]
|
|
820
|
+
template = read(os.path.join(tpl, VERIFICATION_MD))
|
|
821
|
+
if unwritten(plan, history, VERIFICATION_MD, template):
|
|
822
|
+
absent = not plan.exists(VERIFICATION_MD)
|
|
823
|
+
testing = plan.read(TESTING_MD)
|
|
824
|
+
content, count = split_testing(testing, template) if testing is not None else (template, 0)
|
|
825
|
+
if count:
|
|
826
|
+
plan.add("split", VERIFICATION_MD, "%d command line(s) of testing.md copied verbatim, with its "
|
|
827
|
+
"Project specifics" % count, content=content)
|
|
828
|
+
elif absent:
|
|
829
|
+
plan.add("create", VERIFICATION_MD, content=content)
|
|
830
|
+
template = read(os.path.join(tpl, WEIGHT_PROJECT))
|
|
831
|
+
if unwritten(plan, history, WEIGHT_PROJECT, template):
|
|
832
|
+
absent = not plan.exists(WEIGHT_PROJECT)
|
|
833
|
+
old = plan.read(WEIGHT_JSON)
|
|
834
|
+
if old is None:
|
|
835
|
+
if absent:
|
|
836
|
+
plan.add("create", WEIGHT_PROJECT, content=template)
|
|
837
|
+
else:
|
|
838
|
+
content, carried, uncarried = split_weight(old, history, template)
|
|
839
|
+
if absent or content != plan.read(WEIGHT_PROJECT):
|
|
840
|
+
note = "%d leaf/leaves of weight.json that differ from the plugin's table carried (%s)" % (
|
|
841
|
+
len(carried), listed(carried) or "none")
|
|
842
|
+
if uncarried:
|
|
843
|
+
note += "; %d not carried, equal to an older shipped value: %s" % (len(uncarried), listed(uncarried))
|
|
844
|
+
plan.add("split", WEIGHT_PROJECT, note, content=content)
|
|
845
|
+
if plan.exists(WEIGHT_JSON):
|
|
846
|
+
why = "[split] superseded by %s; the plugin's table is read from the plugin" % WEIGHT_PROJECT
|
|
847
|
+
plan.gated("delete", WEIGHT_JSON, why, reason=why)
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
# ---------------------------------------------------------------- shared files, rules, caches
|
|
851
|
+
def require_shared(paths, runtimes):
|
|
852
|
+
"""Refuse, before anything is written, to drop a project copy whose shared fallback is missing:
|
|
853
|
+
in this plugin's tree, or in another installed home of a runtime the project declares. A home
|
|
854
|
+
this machine does not have is not checked."""
|
|
855
|
+
undeclared = sorted(p for p in paths if p not in SHARED_SET)
|
|
856
|
+
if undeclared:
|
|
857
|
+
raise SchemaError("not in the shared set, never deleted as a shared copy: %s" % ", ".join(undeclared))
|
|
858
|
+
rels = sorted(p[len(".ai/"):] for p in paths)
|
|
859
|
+
missing = [r for r in rels if not os.path.isfile(os.path.join(SHARED, r))]
|
|
860
|
+
if missing:
|
|
861
|
+
raise SchemaError("the plugin's shared tree %s lacks %d file(s) a project copy would fall back to: %s — "
|
|
862
|
+
"reinstall the plugin (install.sh), then run this again" % (SHARED, len(missing), ", ".join(missing)))
|
|
863
|
+
homes = {"claude": os.path.join(os.path.expanduser("~"), ".claude"),
|
|
864
|
+
"codex": os.environ.get("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex")}
|
|
865
|
+
mine = os.path.realpath(PLUGIN_HOME)
|
|
866
|
+
for runtime in runtimes:
|
|
867
|
+
home = homes.get(runtime)
|
|
868
|
+
if (not home or os.path.realpath(home) == mine
|
|
869
|
+
or not os.path.isdir(os.path.join(home, "skills", "project-update"))):
|
|
870
|
+
continue
|
|
871
|
+
tree = os.path.join(home, "riskrails", "ai")
|
|
872
|
+
missing = [r for r in rels if not os.path.isfile(os.path.join(tree, r))]
|
|
873
|
+
if missing:
|
|
874
|
+
raise SchemaError("%s has this plugin installed for %s, but %d of the %d shared file(s) a project copy "
|
|
875
|
+
"would fall back to are missing from %s — run `install.sh --target %s` from this "
|
|
876
|
+
"build, then run this again" % (home, runtime, len(missing), len(rels), tree, runtime))
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def shared_update(plan, history, runtimes):
|
|
880
|
+
"""R17: an unedited copy of a shared file is offered for deletion; an edited one is an
|
|
881
|
+
override, kept, and named once (OQ8)."""
|
|
1129
882
|
edited, candidates = [], []
|
|
1130
883
|
extra = undeclared_shared()
|
|
1131
884
|
if extra:
|
|
1132
|
-
|
|
1133
|
-
|
|
885
|
+
plan.hints.append("the shared tree %s holds %d file(s) outside the shared set, ignored — reinstall the "
|
|
886
|
+
"plugin (install.sh) if you did not put them there: %s" % (SHARED, len(extra), ", ".join(extra)))
|
|
1134
887
|
for path in sorted(shared_files()):
|
|
1135
888
|
data = plan.read(path)
|
|
1136
889
|
if data is None:
|
|
1137
890
|
continue
|
|
1138
|
-
why =
|
|
891
|
+
why = unsafe_path(plan.root, path)
|
|
1139
892
|
if why:
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
elif not
|
|
893
|
+
plan.hints.append("%s: %s, so this update never deletes it — check by hand whether it is still "
|
|
894
|
+
"needed" % (path, why))
|
|
895
|
+
elif not history.shipped("ai-init/" + path, data):
|
|
1143
896
|
edited.append(path)
|
|
1144
|
-
elif not named:
|
|
1145
|
-
ctx.hint("%s: unedited, but %s does not name the shared files (## Shared files), so the "
|
|
1146
|
-
"copy stays — restore that section, then run /rr-setup --update again"
|
|
1147
|
-
% (path, ROUTER_FILE))
|
|
1148
897
|
else:
|
|
1149
898
|
candidates.append(path)
|
|
1150
899
|
if candidates:
|
|
1151
900
|
try:
|
|
1152
|
-
|
|
901
|
+
require_shared(candidates, runtimes)
|
|
1153
902
|
except SchemaError as exc:
|
|
1154
|
-
|
|
903
|
+
plan.hints.append("%s — the copies stay until then" % exc)
|
|
1155
904
|
candidates = []
|
|
1156
905
|
for path in candidates:
|
|
1157
906
|
why = "[shared] identical to a shipped version"
|
|
1158
|
-
|
|
1159
|
-
plan.add("delete", path, why, reason=why)
|
|
1160
|
-
plan.remove(path)
|
|
1161
|
-
else:
|
|
1162
|
-
plan.add("delete?", path, why, reason=why)
|
|
907
|
+
plan.gated("delete", path, why, reason=why)
|
|
1163
908
|
if edited:
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
def
|
|
1170
|
-
"""
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
plan.
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
plan.hints.append("%d file(s) override the plugin's SDLC template and no longer receive its "
|
|
1190
|
-
"changes: %s" % (len(edited), ", ".join(edited)))
|
|
909
|
+
plan.hints.append("%d file(s) override the plugin's shared copy and no longer receive its changes: %s — "
|
|
910
|
+
"diff each against %s/<path>; `ai-path <path>` shows which applies; delete one when it "
|
|
911
|
+
"adds nothing" % (len(edited), ", ".join(edited), SHARED))
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def rule_sources(plan):
|
|
915
|
+
"""OQ1: a rule source is not kept. Each `.ai/rules/<slug>.md` is `delete?` behind the gate with its
|
|
916
|
+
original copied; until it is confirmed the rule stays rendered."""
|
|
917
|
+
rules = os.path.join(plan.root, RULES_DIR)
|
|
918
|
+
for name in sorted(os.listdir(rules)) if os.path.isdir(rules) else []:
|
|
919
|
+
rel = "%s/%s" % (RULES_DIR, name)
|
|
920
|
+
if name.endswith(".md") and name != "README.md" and os.path.isfile(os.path.join(rules, name)) \
|
|
921
|
+
and not unsafe_path(plan.root, rel):
|
|
922
|
+
why = "[rules] rule sources are not kept by an update: the rendered blocks go with it"
|
|
923
|
+
plan.gated("delete", rel, why, reason=why)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def local_cache(plan):
|
|
927
|
+
"""R18: .ai/local/plugin-update/ (the old merge's conflict copies) is removed; .ai/local/memory/ is never touched."""
|
|
928
|
+
for here, _dirs, names in os.walk(os.path.join(plan.root, LOCAL_CACHE)):
|
|
929
|
+
for name in sorted(names):
|
|
930
|
+
rel = os.path.relpath(os.path.join(here, name), plan.root).replace(os.sep, "/")
|
|
931
|
+
if not unsafe_path(plan.root, rel):
|
|
932
|
+
why = "[local] the old merge's cache, not written any more"
|
|
933
|
+
plan.gated("delete", rel, why, reason=why)
|
|
1191
934
|
|
|
1192
935
|
|
|
1193
936
|
def task_journal(root, task_id):
|
|
@@ -1242,14 +985,12 @@ def cleanup_update(plan, now=None):
|
|
|
1242
985
|
file=sys.stderr)
|
|
1243
986
|
raise SystemExit(2) from None
|
|
1244
987
|
flight = None
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
return # unreadable: a task may be in flight, so no task is proposed as stale
|
|
1252
|
-
break
|
|
988
|
+
raw = plan.read(STATE_FILE)
|
|
989
|
+
if raw is not None:
|
|
990
|
+
try:
|
|
991
|
+
flight = json.loads(raw).get("task_id")
|
|
992
|
+
except (ValueError, AttributeError, RecursionError):
|
|
993
|
+
return # unreadable: a task may be in flight, so no task is proposed as stale
|
|
1253
994
|
reports =os.path.join(plan.root, ".ai", "reports")
|
|
1254
995
|
logs = os.path.join(plan.root, ".ai", "state", "logs")
|
|
1255
996
|
|
|
@@ -1307,14 +1048,14 @@ def cleanup_update(plan, now=None):
|
|
|
1307
1048
|
|
|
1308
1049
|
|
|
1309
1050
|
def deletion_hint(plan):
|
|
1310
|
-
"""M4: git is the rollback of a
|
|
1051
|
+
"""M4: git is the rollback of a deletion or an overwrite; it must hold .ai/ first.
|
|
1311
1052
|
A [cleanup] deletion of git-ignored run state is not counted: git does not roll it back;
|
|
1312
1053
|
one under .ai/reports/ that git tracks, or should, is."""
|
|
1313
|
-
if any(i["action"] in ("delete?", "delete") and i["target"].startswith(".ai/")
|
|
1054
|
+
if any(i["action"] in ("delete?", "delete", "regenerate?") and i["target"].startswith(".ai/")
|
|
1314
1055
|
and (not (i.get("reason") or "").startswith("[cleanup]") or i["target"] in plan.git_kept)
|
|
1315
1056
|
for i in plan.items) \
|
|
1316
1057
|
and git_dirty_ai(plan.root):
|
|
1317
|
-
plan.hints.append("commit .ai/ first: git is this
|
|
1058
|
+
plan.hints.append("commit .ai/ first: git is this update's rollback "
|
|
1318
1059
|
"(`git status -- .ai` lists what it does not hold yet)")
|
|
1319
1060
|
|
|
1320
1061
|
|
|
@@ -1438,25 +1179,17 @@ def _frozen_block(plan, target, slug):
|
|
|
1438
1179
|
return held is not None and slug in render_instructions.rule_slugs_in(held.decode("utf-8", "replace"))
|
|
1439
1180
|
|
|
1440
1181
|
|
|
1441
|
-
def rules_update(plan, runtimes
|
|
1182
|
+
def rules_update(plan, runtimes):
|
|
1442
1183
|
"""Render `.ai/rules/<slug>.md` into the instruction file of every directory
|
|
1443
1184
|
it names, and take back what a rule no longer asks for. The text around a
|
|
1444
1185
|
block belongs to the project and is never read for meaning, only preserved.
|
|
1445
|
-
|
|
1446
|
-
`planned` ({".ai/rules/<slug>.md": text}) are rules this same run is about
|
|
1447
|
-
to write (an adopt): they replace or join the ones on disk by slug, so their
|
|
1448
|
-
blocks render in the same run. The plain update passes nothing."""
|
|
1186
|
+
A rule whose source this run deletes is no longer rendered."""
|
|
1449
1187
|
rules_path = os.path.join(plan.root, RULES_DIR)
|
|
1450
|
-
if not os.path.isdir(rules_path)
|
|
1188
|
+
if not os.path.isdir(rules_path):
|
|
1451
1189
|
return
|
|
1452
1190
|
try:
|
|
1453
|
-
rules = render_instructions.load_rules(rules_path)
|
|
1454
|
-
|
|
1455
|
-
by_slug = OrderedDict((r.slug, r) for r in rules)
|
|
1456
|
-
for rel, text in sorted(planned.items()):
|
|
1457
|
-
slug = os.path.basename(rel)[:-3]
|
|
1458
|
-
by_slug[slug] = render_instructions.parse_rule(slug, text, where=rel)
|
|
1459
|
-
rules = list(by_slug.values())
|
|
1191
|
+
rules = [r for r in render_instructions.load_rules(rules_path)
|
|
1192
|
+
if "%s/%s.md" % (RULES_DIR, r.slug) not in plan.removed]
|
|
1460
1193
|
except render_instructions.RenderError as exc:
|
|
1461
1194
|
plan.hints.append("%s — that rule is not rendered; the rest are" % exc)
|
|
1462
1195
|
return
|
|
@@ -1488,7 +1221,7 @@ def rules_update(plan, runtimes, planned=None):
|
|
|
1488
1221
|
if current is None:
|
|
1489
1222
|
plan.add("create", target, "rendered from " + source, content=new)
|
|
1490
1223
|
elif current != new:
|
|
1491
|
-
plan.add("
|
|
1224
|
+
plan.add("regenerate", target, "rendered from " + source, content=new)
|
|
1492
1225
|
continue
|
|
1493
1226
|
text = "" if current is None else current.decode("utf-8")
|
|
1494
1227
|
new = render_instructions.splice_rule(text, item["slug"], content).encode()
|
|
@@ -1496,7 +1229,7 @@ def rules_update(plan, runtimes, planned=None):
|
|
|
1496
1229
|
plan.add("create", target, "rule block %s from %s" % (item["slug"], source),
|
|
1497
1230
|
content=new)
|
|
1498
1231
|
elif current != new:
|
|
1499
|
-
plan.add("
|
|
1232
|
+
plan.add("regenerate", target, "rule block %s from %s" % (item["slug"], source),
|
|
1500
1233
|
content=new)
|
|
1501
1234
|
|
|
1502
1235
|
blocks, docs = rendered_rules(plan)
|
|
@@ -1505,7 +1238,7 @@ def rules_update(plan, runtimes, planned=None):
|
|
|
1505
1238
|
continue
|
|
1506
1239
|
text = plan.read(target).decode("utf-8")
|
|
1507
1240
|
# The block is ours; the file is not. Take the block, leave the file.
|
|
1508
|
-
plan.add("
|
|
1241
|
+
plan.add("regenerate", target, "rule block %s removed (%s/%s.md no longer renders here)"
|
|
1509
1242
|
% (slug, RULES_DIR, slug),
|
|
1510
1243
|
content=render_instructions.strip_rule(text, slug).encode())
|
|
1511
1244
|
for target in docs:
|
|
@@ -1536,8 +1269,8 @@ def gitignore_update(plan, snippet_path):
|
|
|
1536
1269
|
sep = b"\n" if current and not current.endswith(b"\n") else b""
|
|
1537
1270
|
new = current + sep + add.encode()
|
|
1538
1271
|
# Replace an earlier .gitignore item from this run instead of stacking two.
|
|
1539
|
-
plan.items = [i for i in plan.items if i["target"] != ".gitignore"
|
|
1540
|
-
plan.add("
|
|
1272
|
+
plan.items = [i for i in plan.items if i["target"] != ".gitignore"]
|
|
1273
|
+
plan.add("regenerate", ".gitignore", "entries appended: " + ", ".join(missing), content=new)
|
|
1541
1274
|
|
|
1542
1275
|
|
|
1543
1276
|
def budget_hints(plan, runtimes):
|
|
@@ -1586,7 +1319,30 @@ def constitution_hint(plan):
|
|
|
1586
1319
|
plan.hints.append(line.replace(path, rel))
|
|
1587
1320
|
|
|
1588
1321
|
|
|
1322
|
+
def verification_hints(plan):
|
|
1323
|
+
"""R10: update's empty-field hints read verification.md."""
|
|
1324
|
+
text = plan.read(VERIFICATION_MD)
|
|
1325
|
+
if text is None:
|
|
1326
|
+
return
|
|
1327
|
+
for field, why in (
|
|
1328
|
+
("verify_command", "the feedback loop needs it"),
|
|
1329
|
+
("step_test_command", "a step runs only its own tests"),
|
|
1330
|
+
("e2e_command", "e2e runs once at the end of a task; write 'none' if there is no suite"),
|
|
1331
|
+
("lint_command", "a sensor reads it; read the project's config files for it, and write 'none' if there is none"),
|
|
1332
|
+
("typecheck_command", "a sensor reads it; read the project's config files for it, and write 'none' if there is none"),
|
|
1333
|
+
):
|
|
1334
|
+
if re.search(rb"(?m)^" + field.encode() + rb":[ \t]*(#.*)?$", text):
|
|
1335
|
+
plan.hints.append("%s: %s is empty — %s" % (VERIFICATION_MD, field, why))
|
|
1336
|
+
elif not re.search(rb"(?m)^" + field.encode() + rb":", text):
|
|
1337
|
+
plan.hints.append("%s: %s is missing — %s" % (VERIFICATION_MD, field, why))
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
PHASE = {"split": 0, "delete": 2, "delete?": 2}
|
|
1341
|
+
|
|
1342
|
+
|
|
1589
1343
|
def build_plan(root, confirm_delete=None, confirm_via=None, confirm_cleanup=None, cleanup_via=None):
|
|
1344
|
+
"""D3: split_once -> regenerate -> shared -> block per runtime -> rules -> local -> gitignore, cleanup, hints.
|
|
1345
|
+
The caller has run check_kept: nothing here reads a kept file for meaning."""
|
|
1590
1346
|
plan = Plan(root)
|
|
1591
1347
|
plan.confirm_cleanup = confirm_cleanup
|
|
1592
1348
|
plan.cleanup_via = cleanup_via
|
|
@@ -1596,110 +1352,66 @@ def build_plan(root, confirm_delete=None, confirm_via=None, confirm_cleanup=None
|
|
|
1596
1352
|
has_sdlc = os.path.isdir(os.path.join(root, "docs", "sdlc"))
|
|
1597
1353
|
if not (has_ai or has_sdlc):
|
|
1598
1354
|
return None
|
|
1599
|
-
|
|
1600
|
-
# moved file — and a faulty registry must not break the projects that cannot.
|
|
1601
|
-
# renames() validates the registry, raising the same SchemaError as load().
|
|
1602
|
-
history = History(HISTORY, migrations.renames(template_targets()) if has_ai else {})
|
|
1603
|
-
|
|
1355
|
+
history = History(HISTORY)
|
|
1604
1356
|
runtimes = project_runtimes(root)
|
|
1605
|
-
|
|
1606
|
-
if has_ai:
|
|
1607
|
-
version = detect_version(plan)
|
|
1608
|
-
migrations.load() # CURRENT comes from the file names; this validates the registry
|
|
1609
|
-
if version > migrations.CURRENT:
|
|
1610
|
-
raise SchemaError("%s says schema %d, this plugin ships schema %d — update the plugin "
|
|
1611
|
-
"(riskrails/install.sh)" % (VERSION_FILE, version, migrations.CURRENT))
|
|
1612
|
-
if version < migrations.CURRENT:
|
|
1613
|
-
mods = run_migrations(plan, version, history)
|
|
1614
|
-
plan.schema = (version, migrations.CURRENT, mods)
|
|
1615
|
-
# The schema only advances when its migrations are done. Writing
|
|
1616
|
-
# VERSION over an unresolved conflict, or over a deletion nobody has
|
|
1617
|
-
# confirmed, would drop the operation: the next run would see a
|
|
1618
|
-
# current project and plan nothing.
|
|
1619
|
-
stuck = [i for i in plan.items if i["migration"] is not None
|
|
1620
|
-
and i["action"] in ("conflict", "delete?")]
|
|
1621
|
-
if stuck:
|
|
1622
|
-
plan.held = version
|
|
1623
|
-
plan.hints.append("the schema stays at %d until %s — resolve a conflict by hand, confirm a "
|
|
1624
|
-
"deletion with --apply --confirm-delete NAME, then run /rr-setup --update again"
|
|
1625
|
-
% (version, ", ".join("%s (%s)" % (i["target"], i["action"]) for i in stuck)))
|
|
1626
|
-
else:
|
|
1627
|
-
# Last of the migration items: an apply that stops short leaves the
|
|
1628
|
-
# version alone, and the next run plans the same migrations again.
|
|
1629
|
-
plan.add("update" if plan.exists(VERSION_FILE) else "create", VERSION_FILE,
|
|
1630
|
-
"schema %d" % migrations.CURRENT, content=b"%d\n" % migrations.CURRENT,
|
|
1631
|
-
migration=migrations.CURRENT)
|
|
1357
|
+
plan.version_from = project_version(root) if has_ai else None
|
|
1632
1358
|
|
|
1633
1359
|
if has_sdlc:
|
|
1634
1360
|
tpl = TEMPLATES["project-init"]
|
|
1635
|
-
|
|
1636
|
-
|
|
1361
|
+
for src, target in SDLC_CREATE: # the project's own from then on
|
|
1362
|
+
if not plan.exists(target):
|
|
1363
|
+
plan.add("create", target, content=read(os.path.join(tpl, src)).replace(
|
|
1364
|
+
b"{{PROJECT}}", os.path.basename(root).encode()))
|
|
1637
1365
|
for rt in runtimes:
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
elif kind == "create":
|
|
1646
|
-
if not plan.exists(target):
|
|
1647
|
-
plan.add("create", target, content=content.replace(
|
|
1648
|
-
b"{{PROJECT}}", os.path.basename(root).encode()))
|
|
1649
|
-
else:
|
|
1650
|
-
three_way(plan, history, "project-init/" + src, target, content)
|
|
1651
|
-
retire_templates(plan, history, retired)
|
|
1366
|
+
for src, target, kind in RUNTIME_MAP[rt]:
|
|
1367
|
+
content = read(os.path.join(tpl, src))
|
|
1368
|
+
if kind == "create":
|
|
1369
|
+
if not plan.exists(target):
|
|
1370
|
+
plan.add("create", target, content=content)
|
|
1371
|
+
else:
|
|
1372
|
+
regenerate(plan, history, "project-init/" + src, target, content)
|
|
1652
1373
|
gitignore_update(plan, os.path.join(tpl, "gitignore.snippet"))
|
|
1653
1374
|
|
|
1654
1375
|
if has_ai:
|
|
1655
1376
|
tpl = TEMPLATES["ai-init"]
|
|
1377
|
+
split_once(plan, history)
|
|
1656
1378
|
files = []
|
|
1657
1379
|
for dirpath, _, names in os.walk(os.path.join(tpl, ".ai")):
|
|
1658
1380
|
for n in names:
|
|
1659
|
-
files.append(os.path.relpath(os.path.join(dirpath, n), tpl))
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
if rel == VERSION_FILE: # owned by the migration step, never merged
|
|
1663
|
-
continue
|
|
1664
|
-
if rel in plan.removed: # a migration moved it away in this run
|
|
1381
|
+
files.append(os.path.relpath(os.path.join(dirpath, n), tpl).replace(os.sep, "/"))
|
|
1382
|
+
for rel in sorted(files):
|
|
1383
|
+
if rel in NOT_COPIED or rel in (VERIFICATION_MD, WEIGHT_PROJECT):
|
|
1665
1384
|
continue
|
|
1666
|
-
if rel in CREATE_ONCE and version >= CREATE_ONCE[rel] and not plan.exists(rel):
|
|
1667
|
-
continue # past the step that created it: its absence is the human's choice
|
|
1668
1385
|
content = read(os.path.join(tpl, rel))
|
|
1669
|
-
if rel
|
|
1386
|
+
if rel in CREATE_ONLY or rel.startswith(CREATE_ONLY_DIRS):
|
|
1670
1387
|
if not plan.exists(rel):
|
|
1671
1388
|
plan.add("create", rel, content=content)
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1389
|
+
else:
|
|
1390
|
+
regenerate(plan, history, "ai-init/" + rel, rel, content)
|
|
1391
|
+
weight_md(plan, history)
|
|
1392
|
+
shared_update(plan, history, runtimes)
|
|
1393
|
+
rule_sources(plan)
|
|
1394
|
+
local_cache(plan)
|
|
1678
1395
|
cleanup_update(plan)
|
|
1679
|
-
deletion_hint(plan)
|
|
1680
1396
|
for rt in runtimes:
|
|
1681
1397
|
block_update(plan, history, rt)
|
|
1682
1398
|
rules_update(plan, runtimes)
|
|
1399
|
+
deletion_hint(plan)
|
|
1683
1400
|
gitignore_update(plan, os.path.join(tpl, "gitignore.snippet"))
|
|
1684
1401
|
tracked_ignored_hint(plan)
|
|
1685
|
-
|
|
1686
|
-
testing = plan.read(".ai/policies/testing.md")
|
|
1687
|
-
if testing is not None:
|
|
1688
|
-
for field, why in (
|
|
1689
|
-
("verify_command", "the feedback loop needs it"),
|
|
1690
|
-
("step_test_command", "a step runs only its own tests"),
|
|
1691
|
-
("e2e_command", "e2e runs once at the end of a task; write 'none' if there is no suite"),
|
|
1692
|
-
("lint_command", "a sensor reads it; read the project's config files for it, and write 'none' if there is none"),
|
|
1693
|
-
("typecheck_command", "a sensor reads it; read the project's config files for it, and write 'none' if there is none"),
|
|
1694
|
-
):
|
|
1695
|
-
if re.search(rb"(?m)^" + field.encode() + rb":[ \t]*(#.*)?$", testing):
|
|
1696
|
-
plan.hints.append(".ai/policies/testing.md: %s is empty — %s" % (field, why))
|
|
1697
|
-
elif not re.search(rb"(?m)^" + field.encode() + rb":", testing):
|
|
1698
|
-
plan.hints.append(".ai/policies/testing.md: %s is missing — %s" % (field, why))
|
|
1402
|
+
verification_hints(plan)
|
|
1699
1403
|
budget_hints(plan, runtimes)
|
|
1700
|
-
|
|
1404
|
+
if plan.items and plan.exists(STATE_FILE):
|
|
1405
|
+
plan.hints.append("%s: a task is in flight while files are regenerated — check that the files "
|
|
1406
|
+
"its current step may touch still exist afterwards" % STATE_FILE)
|
|
1407
|
+
# Last of the items: an apply that stops short leaves the version alone, and the next run plans the rest again.
|
|
1408
|
+
ours = template_version()
|
|
1409
|
+
if plan.version_from != ours:
|
|
1410
|
+
plan.add("regenerate" if plan.exists(VERSION_FILE) else "create", VERSION_FILE,
|
|
1411
|
+
"schema %d" % ours, content=b"%d\n" % ours)
|
|
1701
1412
|
if has_sdlc:
|
|
1702
1413
|
constitution_hint(plan)
|
|
1414
|
+
plan.items.sort(key=lambda i: 3 if i["target"] == VERSION_FILE else PHASE.get(i["action"], 1))
|
|
1703
1415
|
return plan
|
|
1704
1416
|
|
|
1705
1417
|
|
|
@@ -1772,10 +1484,11 @@ def keep_original(plan, target):
|
|
|
1772
1484
|
read(path), mode=os.stat(path).st_mode & 0o7777)
|
|
1773
1485
|
|
|
1774
1486
|
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1487
|
+
|
|
1488
|
+
def record_run(plan, ops, deletions):
|
|
1489
|
+
"""The record of what was taken away or overwritten, rewritten after every such operation:
|
|
1490
|
+
an abort must not lose the note of a change a human approved. A second run on the same
|
|
1491
|
+
day appends to the record instead of replacing it."""
|
|
1779
1492
|
path = os.path.join(plan.report_dir, "migration.json")
|
|
1780
1493
|
existing = {}
|
|
1781
1494
|
full = os.path.join(plan.root, path)
|
|
@@ -1787,17 +1500,12 @@ def record_migration(plan, ops, deletions):
|
|
|
1787
1500
|
if not isinstance(existing, dict): # hand-edited into another shape
|
|
1788
1501
|
existing = {}
|
|
1789
1502
|
record = OrderedDict()
|
|
1790
|
-
record["from"] = existing.get("from", plan.
|
|
1791
|
-
# The version the project is actually at now, read from the disk
|
|
1792
|
-
#
|
|
1793
|
-
# never reaches CURRENT.
|
|
1503
|
+
record["from"] = existing.get("from", plan.version_from)
|
|
1504
|
+
# The version the project is actually at now, read from the disk: this record is
|
|
1505
|
+
# written before the files change, and an aborted run never reaches the template's.
|
|
1794
1506
|
version_path = os.path.join(plan.root, VERSION_FILE)
|
|
1795
1507
|
on_disk = read(version_path).decode().strip() if os.path.isfile(version_path) else ""
|
|
1796
1508
|
record["to"] = int(on_disk) if on_disk.isdigit() else record["from"]
|
|
1797
|
-
if plan.schema and record["to"] != plan.schema[1]:
|
|
1798
|
-
record["target"] = plan.schema[1]
|
|
1799
|
-
if plan.held is not None:
|
|
1800
|
-
record["held_at"] = plan.held
|
|
1801
1509
|
record["applied_at"] = utc_now()
|
|
1802
1510
|
# A recovery re-run replays the same operation: record it once.
|
|
1803
1511
|
record["ops"] = list(existing.get("ops", []))
|
|
@@ -1805,6 +1513,8 @@ def record_migration(plan, ops, deletions):
|
|
|
1805
1513
|
record["deletions"] = list(existing.get("deletions", []))
|
|
1806
1514
|
record["deletions"] += [d for d in deletions
|
|
1807
1515
|
if not any(e.get("path") == d["path"] for e in record["deletions"])]
|
|
1516
|
+
if existing and all(existing.get(k) == record[k] for k in ("from", "to", "ops", "deletions")):
|
|
1517
|
+
return # a run that changed nothing leaves the record as it was
|
|
1808
1518
|
write_file(plan.root, path, dump_json(record))
|
|
1809
1519
|
|
|
1810
1520
|
|
|
@@ -1820,74 +1530,58 @@ def apply(plan):
|
|
|
1820
1530
|
raise Abort(exc.item or item, exc.why) from exc
|
|
1821
1531
|
except OSError as exc:
|
|
1822
1532
|
raise Abort(item, str(exc)) from exc
|
|
1823
|
-
# The run finished: say which version it actually reached.
|
|
1824
|
-
# that replayed an aborted one and so recorded no operation of its own.
|
|
1533
|
+
# The run finished: say which version it actually reached.
|
|
1825
1534
|
try:
|
|
1826
1535
|
if recorded or os.path.isfile(os.path.join(plan.root, plan.report_dir, "migration.json")):
|
|
1827
|
-
|
|
1536
|
+
record_run(plan, [], [])
|
|
1828
1537
|
except OSError as exc:
|
|
1829
1538
|
raise Abort(None, str(exc)) from exc
|
|
1830
1539
|
|
|
1831
1540
|
|
|
1832
1541
|
def apply_item(plan, item, written, recorded):
|
|
1833
1542
|
target, action = item["target"], item["action"]
|
|
1834
|
-
|
|
1835
|
-
|
|
1543
|
+
reason = item.get("reason") or ""
|
|
1544
|
+
cleanup = reason.startswith("[cleanup]")
|
|
1545
|
+
if action in ("delete?", "regenerate?"): # listed only, until a human confirms it
|
|
1836
1546
|
return
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1547
|
+
by = dict(confirmed_by=plan.confirm_cleanup if cleanup else plan.confirm_delete,
|
|
1548
|
+
via=plan.cleanup_via if cleanup else plan.confirm_via, at=utc_now())
|
|
1549
|
+
if action == "delete":
|
|
1840
1550
|
# the plan-time symlink check is repeated: a path swapped since is refused
|
|
1841
|
-
why = unsafe_path(plan.root,
|
|
1551
|
+
why = unsafe_path(plan.root, target)
|
|
1842
1552
|
if why:
|
|
1843
1553
|
raise Abort(item, why)
|
|
1844
|
-
check(plan, item, source, item["src_expect"] if action == "move" else item["expect"], written)
|
|
1845
|
-
if action == "move":
|
|
1846
|
-
check(plan, item, target, item["expect"], written)
|
|
1847
|
-
mode = os.stat(os.path.join(plan.root, source)).st_mode & 0o7777
|
|
1848
|
-
# git-ignored task state: a move is byte-identical; a [cleanup] deletion of it is NOT
|
|
1849
|
-
# recoverable through git (the logs are git-ignored), and copying what it removes
|
|
1850
|
-
# would defeat the cleanup, so no original/ copy is kept. A report file git tracks,
|
|
1851
|
-
# or should, is kept like any other deletion.
|
|
1852
|
-
if not source.startswith(".ai/state/") and (not cleanup or source in plan.git_kept):
|
|
1853
|
-
keep_original(plan, source)
|
|
1854
|
-
recorded.append(target)
|
|
1855
|
-
# Recorded before the file is gone: an abort later must not lose the note
|
|
1856
|
-
# of what was taken away, or of who approved a deletion.
|
|
1857
|
-
record_migration(plan,
|
|
1858
|
-
[dict(action=action, migration=item["migration"],
|
|
1859
|
-
**(dict(src=item["src"], dst=target) if action == "move" else dict(path=target)))],
|
|
1860
|
-
[dict(path=target, confirmed_by=plan.confirm_cleanup if cleanup else plan.confirm_delete,
|
|
1861
|
-
via=plan.cleanup_via if cleanup else plan.confirm_via, at=utc_now())]
|
|
1862
|
-
if action == "delete" else [])
|
|
1863
|
-
elif item["content"] is not None:
|
|
1864
1554
|
check(plan, item, target, item["expect"], written)
|
|
1865
|
-
#
|
|
1866
|
-
#
|
|
1867
|
-
#
|
|
1868
|
-
if
|
|
1869
|
-
and item["expect"] is not None and target not in written \
|
|
1870
|
-
and not os.path.exists(os.path.join(plan.root, plan.report_dir, "original", target)):
|
|
1555
|
+
# git-ignored task state: a [cleanup] deletion of it is NOT recoverable through git, and
|
|
1556
|
+
# copying what it removes would defeat the cleanup, so no original/ copy is kept. A report
|
|
1557
|
+
# file git tracks, or should, is kept like any other deletion.
|
|
1558
|
+
if not target.startswith(".ai/state/") and (not cleanup or target in plan.git_kept):
|
|
1871
1559
|
keep_original(plan, target)
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
os.remove(os.path.join(plan.root, item["src"]))
|
|
1877
|
-
written[item["src"]] = None
|
|
1878
|
-
elif action == "delete":
|
|
1560
|
+
recorded.append(target)
|
|
1561
|
+
# Recorded before the file is gone: an abort later must not lose the note
|
|
1562
|
+
# of what was taken away, or of who approved it.
|
|
1563
|
+
record_run(plan, [dict(action=action, path=target)], [dict(path=target, **by)])
|
|
1879
1564
|
os.remove(os.path.join(plan.root, target))
|
|
1880
1565
|
written[target] = None
|
|
1881
|
-
if
|
|
1566
|
+
if reason.startswith(("[cleanup]", "[local]")):
|
|
1882
1567
|
prune_empty(plan.root, target)
|
|
1883
|
-
|
|
1884
|
-
|
|
1568
|
+
return
|
|
1569
|
+
check(plan, item, target, item["expect"], written)
|
|
1570
|
+
if item["keep"] and item["expect"] is not None and target not in written:
|
|
1571
|
+
# A change a human confirmed overwrites what they wrote: keep it, so it can be read back.
|
|
1572
|
+
if not os.path.exists(os.path.join(plan.root, plan.original_of(target))):
|
|
1573
|
+
keep_original(plan, target)
|
|
1574
|
+
recorded.append(target)
|
|
1575
|
+
record_run(plan, [dict(action=action, path=target, **by)], [])
|
|
1576
|
+
write_file(plan.root, target, item["content"])
|
|
1577
|
+
written[target] = sha(item["content"])
|
|
1885
1578
|
|
|
1886
1579
|
|
|
1887
1580
|
def prune_empty(root, target):
|
|
1888
|
-
"""The directories a cleanup deletion left empty, up to (not including)
|
|
1889
|
-
.ai/reports
|
|
1890
|
-
top = ".ai/state/logs" if target.startswith(".ai/state/logs/") else
|
|
1581
|
+
"""The directories a cleanup or cache deletion left empty, up to (not including)
|
|
1582
|
+
.ai/reports, .ai/state/logs and .ai/local; a directory with anything in it stays."""
|
|
1583
|
+
top = ".ai/state/logs" if target.startswith(".ai/state/logs/") else \
|
|
1584
|
+
".ai/local" if target.startswith(LOCAL_CACHE + "/") else ".ai/reports"
|
|
1891
1585
|
here = os.path.dirname(target)
|
|
1892
1586
|
while here.startswith(top + "/"):
|
|
1893
1587
|
try:
|
|
@@ -1897,56 +1591,13 @@ def prune_empty(root, target):
|
|
|
1897
1591
|
here = os.path.dirname(here)
|
|
1898
1592
|
|
|
1899
1593
|
|
|
1900
|
-
def plain_pending(root, ignore=()):
|
|
1901
|
-
"""True when the plain --check would exit 1: an automatic item or a schema
|
|
1902
|
-
step is pending. Targets in `ignore` (an adopt resuming its own writes) do
|
|
1903
|
-
not count. Same condition as main()'s --check branch."""
|
|
1904
|
-
plan = build_plan(root)
|
|
1905
|
-
if plan is None:
|
|
1906
|
-
return False
|
|
1907
|
-
pending = [i for i in plan.items if i["action"] not in ("conflict", "delete?") and i["target"] not in ignore]
|
|
1908
|
-
return bool(pending or plan.schema)
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
def shipped_ai_files():
|
|
1912
|
-
"""{project-relative path: current shipped bytes} for every file this
|
|
1913
|
-
plugin ships unedited into a scaffold: `.ai/**` from the ai-init templates,
|
|
1914
|
-
and the project-init docs (`docs/sdlc/README.md`, the `TEMPLATE.md`s). Used
|
|
1915
|
-
only to tell adopt's no-dangling check that an untouched plugin file
|
|
1916
|
-
mentioning a tool's name in passing (a generic example, a policy) is not a
|
|
1917
|
-
project-specific dependency on it — the same "unedited = ours, not the
|
|
1918
|
-
project's words" rule the three-way update already applies."""
|
|
1919
|
-
out = {}
|
|
1920
|
-
base = os.path.join(TEMPLATES["ai-init"], ".ai")
|
|
1921
|
-
for here, _dirs, files in os.walk(base):
|
|
1922
|
-
for name in files:
|
|
1923
|
-
full = os.path.join(here, name)
|
|
1924
|
-
rel = ".ai/" + os.path.relpath(full, base).replace(os.sep, "/")
|
|
1925
|
-
out[rel] = read(full)
|
|
1926
|
-
for rel, data in shared_files().items(): # read from the plugin, never scaffolded
|
|
1927
|
-
out.setdefault(rel, data)
|
|
1928
|
-
for src, target, _kind in PROJECT_INIT_MAP:
|
|
1929
|
-
path = os.path.join(TEMPLATES["project-init"], src)
|
|
1930
|
-
if os.path.isfile(path):
|
|
1931
|
-
out[target] = read(path)
|
|
1932
|
-
return out
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
def shipped_block(runtime):
|
|
1936
|
-
"""The managed block the installed plugin ships for a runtime, as text."""
|
|
1937
|
-
return read(os.path.join(TEMPLATES["ai-init"], INSTRUCTION_FILE[runtime][1])).decode("utf-8").rstrip("\n")
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
1594
|
def report(plan, applied):
|
|
1941
|
-
auto = [i for i in plan.items if i["action"] not in ("
|
|
1942
|
-
|
|
1943
|
-
deletions = [i for i in plan.items if i["action"] == "delete?"]
|
|
1595
|
+
auto = [i for i in plan.items if i["action"] not in ("delete?", "regenerate?")]
|
|
1596
|
+
gated = [i for i in plan.items if i["action"] in ("delete?", "regenerate?")]
|
|
1944
1597
|
head = "applied" if applied else "dry run, nothing written; --apply to write"
|
|
1945
1598
|
print("project-update: %s (%s)" % (plan.root, head))
|
|
1946
1599
|
if not plan.items:
|
|
1947
1600
|
print(" up to date with the installed plugin")
|
|
1948
|
-
def shown(i):
|
|
1949
|
-
return "%s -> %s" % (i["src"], i["target"]) if i["src"] else i["target"]
|
|
1950
1601
|
# a cleanup is one line per directory, however many files it holds
|
|
1951
1602
|
rows, groups = [], {}
|
|
1952
1603
|
for i in plan.items:
|
|
@@ -1957,49 +1608,34 @@ def report(plan, applied):
|
|
|
1957
1608
|
if key in groups:
|
|
1958
1609
|
groups[key]["count"] += 1
|
|
1959
1610
|
continue
|
|
1960
|
-
row = dict(i,
|
|
1611
|
+
row = dict(i, count=1)
|
|
1961
1612
|
groups[key] = row
|
|
1962
1613
|
row["target"] = where
|
|
1963
1614
|
i = row
|
|
1964
1615
|
rows.append(i)
|
|
1965
1616
|
for (where, _action, _note), row in groups.items():
|
|
1966
1617
|
row["target"] = "%s/ (%d file%s)" % (where, row["count"], "" if row["count"] == 1 else "s")
|
|
1967
|
-
width = max([len(
|
|
1968
|
-
if plan.schema:
|
|
1969
|
-
frm, to, mods = plan.schema
|
|
1970
|
-
print(" %-9s %-*s (%d migration%s: %s)" % (
|
|
1971
|
-
"schema", width, "%d -> %d" % (frm, to), len(mods), "" if len(mods) == 1 else "s",
|
|
1972
|
-
", ".join("%04d %s" % (m.VERSION, m.TITLE) for m in mods)))
|
|
1618
|
+
width = max([len(i["target"]) for i in rows] + [10])
|
|
1973
1619
|
for i in rows:
|
|
1974
1620
|
note = i["note"]
|
|
1975
|
-
if i["
|
|
1976
|
-
note = "[%04d]%s" % (i["migration"], " " + note if note else "")
|
|
1977
|
-
if i.get("tool") and not note.startswith("[%s]" % i["tool"]):
|
|
1978
|
-
note = "[%s] %s" % (i["tool"], note)
|
|
1979
|
-
if i["action"] == "delete?":
|
|
1621
|
+
if i["action"] in ("delete?", "regenerate?"):
|
|
1980
1622
|
if (i["reason"] or "").startswith("[cleanup]"):
|
|
1981
1623
|
note += "; needs --apply --confirm-cleanup NAME"
|
|
1982
1624
|
if i["target"].startswith(".ai/state/"):
|
|
1983
1625
|
note += " (git-ignored: not recoverable through git)"
|
|
1984
1626
|
else:
|
|
1985
1627
|
note += "; needs --apply --confirm-delete NAME"
|
|
1986
|
-
|
|
1987
|
-
note += "; plugin version %s %s/%s" % ("at" if applied else "will be at", plan.local_dir, i["target"])
|
|
1988
|
-
print(" %-9s %-*s %s" % (i["action"], width, shown(i), note))
|
|
1989
|
-
for change in i["policy"] or []:
|
|
1990
|
-
print(" %-9s %-*s %s" % ("policy", width, "", change))
|
|
1628
|
+
print(" %-9s %-*s %s" % (i["action"], width, i["target"], note))
|
|
1991
1629
|
for h in plan.hints:
|
|
1992
1630
|
print(" hint " + h)
|
|
1993
1631
|
tail = ""
|
|
1994
|
-
if any(i["policy"] for i in auto):
|
|
1995
|
-
tail += ", %d policy change(s) to confirm" % sum(len(i["policy"] or []) for i in auto)
|
|
1996
1632
|
# the summary names the flag each row names: [cleanup] rows settle with --confirm-cleanup only
|
|
1997
|
-
cleanups = [i for i in
|
|
1998
|
-
if len(
|
|
1999
|
-
tail += ", %d
|
|
1633
|
+
cleanups = [i for i in gated if (i["reason"] or "").startswith("[cleanup]")]
|
|
1634
|
+
if len(gated) > len(cleanups):
|
|
1635
|
+
tail += ", %d change(s) awaiting --confirm-delete" % (len(gated) - len(cleanups))
|
|
2000
1636
|
if cleanups:
|
|
2001
1637
|
tail += ", %d cleanup file(s) awaiting --confirm-cleanup" % len(cleanups)
|
|
2002
|
-
print("%d automatic
|
|
1638
|
+
print("%d automatic%s" % (len(auto), tail))
|
|
2003
1639
|
|
|
2004
1640
|
|
|
2005
1641
|
def main():
|
|
@@ -2007,24 +1643,10 @@ def main():
|
|
|
2007
1643
|
ap.add_argument("root", nargs="?", default=".")
|
|
2008
1644
|
ap.add_argument("--apply", action="store_true")
|
|
2009
1645
|
ap.add_argument("--check", action="store_true")
|
|
2010
|
-
ap.add_argument("--adopt", action="store_true",
|
|
2011
|
-
help="adopt a foreign AI-tool structure (Spec Kit, Kiro, Cursor, ...); a dry run unless --apply")
|
|
2012
|
-
ap.add_argument("--mode", choices=("migrate", "coexist"), default="migrate",
|
|
2013
|
-
help="with --adopt: migrate (default) moves the foreign files; coexist only routes to them")
|
|
2014
|
-
ap.add_argument("--tool", metavar="LIST", help="with --adopt: only these tools, comma-separated")
|
|
2015
|
-
ap.add_argument("--split-request", action="store_true",
|
|
2016
|
-
help="with --adopt: write split-request.json for each oversized instruction file, nothing else")
|
|
2017
|
-
ap.add_argument("--split", choices=("proposal", "fallback"),
|
|
2018
|
-
help="with --adopt: split by the session's proposal, or move every line outside the "
|
|
2019
|
-
"block verbatim to .ai/policies/adopted/ (default: the proposal if one matches)")
|
|
2020
|
-
ap.add_argument("--diff", action="store_true", help="with --adopt: print the unified diff of the dry run")
|
|
2021
|
-
ap.add_argument("--cleanup", action="store_true",
|
|
2022
|
-
help="with --adopt: list the adopted foreign files for deletion; they are deleted only "
|
|
2023
|
-
"with --apply --confirm-delete NAME, typed by a human")
|
|
2024
1646
|
ap.add_argument("--budget", action="store_true",
|
|
2025
1647
|
help="with --check: exit 1 when a root instruction file is over its byte budget")
|
|
2026
1648
|
ap.add_argument("--confirm-delete", metavar="NAME",
|
|
2027
|
-
help="a human confirms this run's
|
|
1649
|
+
help="a human confirms this run's gated rows (regenerate?, delete?), and is recorded in "
|
|
2028
1650
|
"the migration report; only with --apply")
|
|
2029
1651
|
ap.add_argument("--confirm-cleanup", metavar="NAME",
|
|
2030
1652
|
help="a human confirms this run's [cleanup] deletions (run logs and stale task files, "
|
|
@@ -2038,52 +1660,27 @@ def main():
|
|
|
2038
1660
|
if not args.apply:
|
|
2039
1661
|
ap.error("%s only makes sense with --apply" % flag)
|
|
2040
1662
|
if not name.strip():
|
|
2041
|
-
ap.error("%s needs the name of the human who confirmed the
|
|
2042
|
-
if
|
|
1663
|
+
ap.error("%s needs the name of the human who confirmed the change" % flag)
|
|
1664
|
+
if placeholder_name(name):
|
|
2043
1665
|
ap.error("%s got the placeholder %r; type your own name, it is the audit trail"
|
|
2044
1666
|
% (flag, name.strip()))
|
|
2045
|
-
passed, via, why =
|
|
1667
|
+
passed, via, why = human_gate()
|
|
2046
1668
|
if not passed:
|
|
2047
1669
|
# Under an agent-session marker the AI_UNATTENDED route is the launcher's
|
|
2048
1670
|
# to declare, not the agent's to type: the hint names it only otherwise.
|
|
2049
1671
|
hint = "run the same command yourself in a terminal outside Claude Code or Codex"
|
|
2050
1672
|
if "agent session" not in why:
|
|
2051
1673
|
hint += ", or set AI_UNATTENDED=1 if a launcher runs it unattended on your behalf"
|
|
2052
|
-
return
|
|
1674
|
+
return refuse("%s is typed by a human, and %s" % (flag, why), hint)
|
|
2053
1675
|
if flag == "--confirm-delete":
|
|
2054
1676
|
confirm_via = via
|
|
2055
1677
|
else:
|
|
2056
1678
|
cleanup_via = via
|
|
2057
1679
|
if args.budget and not args.check:
|
|
2058
1680
|
ap.error("--budget only makes sense with --check")
|
|
2059
|
-
if not args.adopt and (args.tool or args.mode != "migrate" or args.split_request or args.split or args.diff
|
|
2060
|
-
or args.cleanup):
|
|
2061
|
-
ap.error("--mode, --tool, --split, --split-request, --diff and --cleanup only make sense with --adopt")
|
|
2062
1681
|
root = os.path.abspath(args.root)
|
|
2063
|
-
if args.adopt and args.confirm_cleanup is not None:
|
|
2064
|
-
ap.error("--confirm-cleanup does not apply to --adopt")
|
|
2065
|
-
if args.adopt:
|
|
2066
|
-
if args.confirm_delete is not None and not args.cleanup:
|
|
2067
|
-
ap.error("--adopt --confirm-delete belongs to --cleanup")
|
|
2068
|
-
if args.cleanup and (args.check or args.split_request or args.split or args.diff or args.tool):
|
|
2069
|
-
ap.error("--cleanup takes only --apply --confirm-delete NAME")
|
|
2070
|
-
if args.cleanup and args.apply and args.confirm_delete is None:
|
|
2071
|
-
ap.error("--cleanup --apply needs --confirm-delete NAME, typed by the human who confirmed it")
|
|
2072
|
-
if sum((args.apply, args.check, args.split_request)) > 1:
|
|
2073
|
-
ap.error("--adopt takes one of --apply, --check and --split-request")
|
|
2074
|
-
if args.diff and (args.apply or args.check or args.split_request):
|
|
2075
|
-
ap.error("--diff is a dry run; it takes no --apply, --check or --split-request")
|
|
2076
|
-
caps = render_instructions.budgets(render_instructions.DEFAULT_SOURCE)
|
|
2077
|
-
instruction_files = frozenset(INSTRUCTION_FILE[rt][0] for rt in INSTRUCTION_FILE)
|
|
2078
|
-
if args.cleanup:
|
|
2079
|
-
return adopt.cleanup_run(Plan(root), args, sys.modules[__name__], caps.get("skeleton"),
|
|
2080
|
-
instruction_files, shipped_ai_files())
|
|
2081
|
-
if args.apply:
|
|
2082
|
-
return adopt.apply_run(Plan(root), args, sys.modules[__name__], caps.get("skeleton"),
|
|
2083
|
-
instruction_files, shipped_ai_files())
|
|
2084
|
-
return adopt.run(Plan(root), args, shipped_block, caps.get("skeleton"), instruction_files, shipped_ai_files())
|
|
2085
1682
|
if args.check and args.budget:
|
|
2086
|
-
offenders =
|
|
1683
|
+
offenders = budget_offenders(
|
|
2087
1684
|
root, [INSTRUCTION_FILE[rt][0] for rt in project_runtimes(root)],
|
|
2088
1685
|
render_instructions.budgets(render_instructions.DEFAULT_SOURCE), render_instructions.block_of)
|
|
2089
1686
|
for line in offenders:
|
|
@@ -2093,44 +1690,32 @@ def main():
|
|
|
2093
1690
|
if not os.path.isdir(tpl):
|
|
2094
1691
|
print("project-update: %s templates not found at %s (run riskrails/install.sh)" % (name, tpl), file=sys.stderr)
|
|
2095
1692
|
return 2
|
|
1693
|
+
if not (os.path.isdir(os.path.join(root, ".ai")) or os.path.isdir(os.path.join(root, "docs", "sdlc"))):
|
|
1694
|
+
print("project-update: %s has neither .ai/ nor docs/sdlc/ — run /rr-setup first" % root, file=sys.stderr)
|
|
1695
|
+
return 2
|
|
2096
1696
|
try:
|
|
2097
|
-
|
|
1697
|
+
if os.path.isdir(os.path.join(root, ".ai")):
|
|
1698
|
+
project_version(root)
|
|
1699
|
+
bad = check_kept(root)
|
|
1700
|
+
if bad:
|
|
1701
|
+
return kept_refusal(bad)
|
|
1702
|
+
plan = build_plan(root, confirm_delete=args.confirm_delete, confirm_via=confirm_via,
|
|
2098
1703
|
confirm_cleanup=args.confirm_cleanup, cleanup_via=cleanup_via)
|
|
2099
1704
|
except SchemaError as exc:
|
|
2100
|
-
# --check is read by /
|
|
1705
|
+
# --check is read by /rr-status, which reads stdout: it must see the reason.
|
|
2101
1706
|
print("project-update: %s" % exc, file=sys.stdout if args.check else sys.stderr)
|
|
2102
1707
|
return 2
|
|
2103
|
-
if plan is None:
|
|
2104
|
-
print("project-update: %s has neither .ai/ nor docs/sdlc/ — run /rr-setup first" % root,
|
|
2105
|
-
file=sys.stderr)
|
|
2106
|
-
return 2
|
|
2107
|
-
if not args.check:
|
|
2108
|
-
# D5: the plain dry run says when a foreign structure waits for --adopt
|
|
2109
|
-
# (never --check, whose one line /ai-status reads byte for byte).
|
|
2110
|
-
caps = render_instructions.budgets(render_instructions.DEFAULT_SOURCE)
|
|
2111
|
-
code, line = adopt.state_line(root, shipped_block, caps.get("skeleton"))
|
|
2112
|
-
if code:
|
|
2113
|
-
plan.hints.append(line)
|
|
2114
|
-
pending = [i for i in plan.items if i["action"] not in ("conflict", "delete?")]
|
|
2115
1708
|
if args.check:
|
|
2116
|
-
|
|
2117
|
-
if
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
if files:
|
|
2123
|
-
bits.append("%d file(s) to update" % files)
|
|
2124
|
-
if conflicts:
|
|
2125
|
-
bits.append("%d need a manual merge" % conflicts)
|
|
2126
|
-
deletions = sum(1 for i in plan.items if i["action"] == "delete?")
|
|
2127
|
-
if deletions:
|
|
2128
|
-
bits.append("%d deletion(s) a human must confirm" % deletions)
|
|
2129
|
-
if plan.held is not None:
|
|
2130
|
-
bits.append("the schema stays at %d until they are settled" % plan.held)
|
|
1709
|
+
pending = [i for i in plan.items if not i["action"].endswith("?")]
|
|
1710
|
+
waiting = [i for i in plan.items if i["action"].endswith("?") and not (i["reason"] or "").startswith("[cleanup]")]
|
|
1711
|
+
if pending:
|
|
1712
|
+
bits = ["%d file(s) to update" % len(pending)]
|
|
1713
|
+
if waiting:
|
|
1714
|
+
bits.append("%d change(s) a human must confirm" % len(waiting))
|
|
2131
1715
|
print("project is behind the installed plugin: %s — run /rr-setup --update" % ", ".join(bits))
|
|
2132
1716
|
return 1
|
|
2133
|
-
print("project matches the installed plugin"
|
|
1717
|
+
print("project matches the installed plugin"
|
|
1718
|
+
+ (" (%d change(s) wait for --confirm-delete)" % len(waiting) if waiting else ""))
|
|
2134
1719
|
return 0
|
|
2135
1720
|
if args.apply:
|
|
2136
1721
|
try:
|