riskrails 4.0.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +22 -13
  5. package/bin/menu.js +116 -0
  6. package/bin/riskrails.js +119 -20
  7. package/hooks/AGENTS.md +0 -6
  8. package/hooks/CLAUDE.md +0 -6
  9. package/hooks/guard/dialog.py +14 -2
  10. package/hooks/guard/paths.py +48 -1406
  11. package/hooks/guard/paths_base.py +59 -0
  12. package/hooks/guard/paths_bash.py +176 -0
  13. package/hooks/guard/paths_classify.py +205 -0
  14. package/hooks/guard/paths_fs.py +137 -0
  15. package/hooks/guard/paths_human.py +105 -0
  16. package/hooks/guard/paths_interp.py +162 -0
  17. package/hooks/guard/paths_movers.py +393 -0
  18. package/hooks/guard/paths_shell.py +288 -0
  19. package/package.json +1 -1
  20. package/skills/AGENTS.md +0 -6
  21. package/skills/CLAUDE.md +0 -6
  22. package/skills/ai-init/scaffold-ai.sh +2 -1
  23. package/skills/ai-init/templates/.ai/policies/safety.md +4 -3
  24. package/skills/ai-init/templates/gitignore.snippet +2 -0
  25. package/skills/ai-task/memory_cleanup.py +213 -0
  26. package/skills/ai-task/memory_compact.py +25 -0
  27. package/skills/ai-task/memory_config.py +120 -0
  28. package/skills/ai-task/memory_export.py +246 -0
  29. package/skills/ai-task/memory_format.py +7 -3
  30. package/skills/ai-task/memory_hook.py +172 -0
  31. package/skills/ai-task/memory_io.py +122 -0
  32. package/skills/ai-task/memory_migrate.py +187 -0
  33. package/skills/ai-task/memory_packages.py +83 -0
  34. package/skills/ai-task/memory_risks.py +84 -0
  35. package/skills/ai-task/memory_status.py +76 -0
  36. package/skills/ai-task/memory_sync.py +20 -1481
  37. package/skills/ai-task/memory_verify.py +316 -0
  38. package/skills/ai-task/rr_state/cli.py +2 -1
  39. package/skills/ai-task/rr_state/close.py +2 -2
  40. package/skills/ai-task/rr_state/dialog_cmd.py +183 -6
  41. package/skills/ai-task/rr_state/labels.py +14 -2
  42. package/skills/ai-task/rr_state/memory_cmd.py +91 -68
  43. package/skills/project-update/history/blobs/8ec171f0fbdf38cc6996d496f01491fd1f21f04a435a118ad979b127a8be06f1 +85 -0
  44. package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
  45. package/skills/project-update/history/index.json +4 -2
  46. package/skills/project-update/rr_update/__init__.py +1 -0
  47. package/skills/project-update/rr_update/apply.py +188 -0
  48. package/skills/project-update/rr_update/blocks.py +38 -0
  49. package/skills/project-update/rr_update/build.py +96 -0
  50. package/skills/project-update/rr_update/cleanup.py +194 -0
  51. package/skills/project-update/rr_update/cli.py +102 -0
  52. package/skills/project-update/rr_update/files.py +106 -0
  53. package/skills/project-update/rr_update/gate.py +60 -0
  54. package/skills/project-update/rr_update/hints.py +128 -0
  55. package/skills/project-update/rr_update/history.py +117 -0
  56. package/skills/project-update/rr_update/kept.py +120 -0
  57. package/skills/project-update/rr_update/paths.py +108 -0
  58. package/skills/project-update/rr_update/plan.py +123 -0
  59. package/skills/project-update/rr_update/report.py +51 -0
  60. package/skills/project-update/rr_update/rules.py +178 -0
  61. package/skills/project-update/rr_update/shared.py +74 -0
  62. package/skills/project-update/rr_update/siblings.py +37 -0
  63. package/skills/project-update/rr_update/weights.py +171 -0
  64. package/skills/project-update/update.py +8 -1682
  65. package/skills/rr-setup/SKILL.md +79 -21
  66. package/skills/rr-setup/scaffold_ai.py +8 -2
  67. package/skills/rr-setup/setup.py +371 -4
@@ -0,0 +1,178 @@
1
+ """The project rules: where they come from, how they render, the nested tree, and the rules rows of the plan.
2
+
3
+ shell: rule_sources, rendered_rules, nested_tree, unwalked_dir, _frozen_block, rules_update
4
+ """
5
+
6
+ import os
7
+
8
+ import render_instructions # next to the entry, whose directory the entry puts on sys.path
9
+
10
+ from rr_update import files
11
+ from rr_update import paths
12
+
13
+
14
+ def rule_sources(plan):
15
+ """OQ1: a rule source is not kept. Each `.ai/rules/<slug>.md` is `delete?` behind the gate with its
16
+ original copied; until it is confirmed the rule stays rendered."""
17
+ rules = os.path.join(plan.root, paths.RULES_DIR)
18
+ for name in sorted(os.listdir(rules)) if os.path.isdir(rules) else []:
19
+ rel = "%s/%s" % (paths.RULES_DIR, name)
20
+ if name.endswith(".md") and name != "README.md" and os.path.isfile(os.path.join(rules, name)) \
21
+ and not files.unsafe_path(plan.root, rel):
22
+ why = "[rules] rule sources are not kept by an update: the rendered blocks go with it"
23
+ plan.gated("delete", rel, why, reason=why)
24
+
25
+
26
+ def rendered_rules(plan):
27
+ """Every (file, slug) in the project that carries a rule block today, plus
28
+ every path-scoped copy under .claude/rules/. Walked rather than remembered:
29
+ a manifest would be one more file to keep true, and the markers already say
30
+ who wrote them."""
31
+ blocks, docs = [], []
32
+ names = set(render_instructions.RULE_FILE.values())
33
+ for dirpath, dirnames, filenames in os.walk(plan.root):
34
+ dirnames[:] = sorted(d for d in dirnames if d not in paths.RULES_SKIP
35
+ and not os.path.lexists(os.path.join(dirpath, d, ".git")))
36
+ rel_dir = os.path.relpath(dirpath, plan.root)
37
+ for name in sorted(filenames):
38
+ rel = name if rel_dir == "." else os.path.join(rel_dir, name)
39
+ rel = rel.replace(os.sep, "/")
40
+ if name in names:
41
+ text = plan.read(rel)
42
+ if text is None:
43
+ continue
44
+ for slug in render_instructions.rule_slugs_in(text.decode("utf-8", "replace")):
45
+ blocks.append((rel, slug))
46
+ elif rel.startswith(render_instructions.CLAUDE_RULES_DIR + "/") and name.endswith(".md"):
47
+ text = plan.read(rel)
48
+ if text is not None and render_instructions.RULE_GENERATED % name[:-3] in \
49
+ text.decode("utf-8", "replace"):
50
+ docs.append(rel)
51
+ return blocks, docs
52
+
53
+
54
+ def nested_tree(root, rel_dir):
55
+ """The first directory below the root on the way to `rel_dir`, `rel_dir`
56
+ included, that holds a .git entry (a worktree, a nested repository, a
57
+ submodule), or None. The same predicate rendered_rules() prunes on."""
58
+ # "." and "" are the root itself (dirs: ["."] reaches here as ""), whose
59
+ # own .git is not a nested tree.
60
+ parts = [p for p in rel_dir.split("/") if p not in ("", ".")]
61
+ for i in range(1, len(parts) + 1):
62
+ candidate = "/".join(parts[:i])
63
+ if os.path.lexists(os.path.join(root, candidate, ".git")):
64
+ return candidate
65
+ return None
66
+
67
+
68
+ def unwalked_dir(root, rel_dir):
69
+ """Why the take-back walk (rendered_rules) never enters `rel_dir`, or None:
70
+ a RULES_SKIP name on the way, or a symlinked directory on the way."""
71
+ here = root
72
+ for part in (p for p in rel_dir.split("/") if p not in ("", ".")):
73
+ here = os.path.join(here, part)
74
+ if part.casefold() in paths.RULES_SKIP_FOLDED:
75
+ return "%s is under a directory the take-back never walks (%s)" % (rel_dir, part)
76
+ if os.path.islink(here):
77
+ return "%s is a symlink, which the take-back never follows" % os.path.relpath(here, root).replace(os.sep, "/")
78
+ return None
79
+
80
+
81
+ def _frozen_block(plan, target, slug):
82
+ """True when `target` is a readable regular file inside the project, holding a
83
+ block for `slug`, at a place the take-back walk (rendered_rules) never enters: a
84
+ RULES_SKIP name or a nested git tree on its real path. A symlink that leads inside
85
+ the walked part of the project is removed by the same run; one that leads outside
86
+ the project, a directory, a FIFO or an unreadable file is not read at all."""
87
+ full = os.path.join(plan.root, target)
88
+ try:
89
+ real = os.path.realpath(full)
90
+ base = os.path.realpath(plan.root)
91
+ if os.path.commonpath([real, base]) != base or not os.path.isfile(real):
92
+ return False
93
+ rel_dir = os.path.relpath(os.path.dirname(real), base).replace(os.sep, "/")
94
+ parts = [p for p in rel_dir.split("/") if p not in ("", ".")]
95
+ if not (any(p in paths.RULES_SKIP for p in parts) or nested_tree(base, rel_dir) is not None):
96
+ return False
97
+ held = plan.read(target)
98
+ except (OSError, ValueError):
99
+ return False
100
+ return held is not None and slug in render_instructions.rule_slugs_in(held.decode("utf-8", "replace"))
101
+
102
+
103
+ def rules_update(plan, runtimes):
104
+ """Render `.ai/rules/<slug>.md` into the instruction file of every directory
105
+ it names, and take back what a rule no longer asks for. The text around a
106
+ block belongs to the project and is never read for meaning, only preserved.
107
+ A rule whose source this run deletes is no longer rendered."""
108
+ rules_path = os.path.join(plan.root, paths.RULES_DIR)
109
+ if not os.path.isdir(rules_path):
110
+ return
111
+ try:
112
+ rules = [r for r in render_instructions.load_rules(rules_path)
113
+ if "%s/%s.md" % (paths.RULES_DIR, r.slug) not in plan.removed]
114
+ except render_instructions.RenderError as exc:
115
+ plan.hints.append("%s — that rule is not rendered; the rest are" % exc)
116
+ return
117
+ wanted = render_instructions.rule_targets(rules, runtimes)
118
+ managed = {(i["target"], i["slug"]) for i in wanted if i["kind"] == "block"}
119
+ kept_docs = {i["target"] for i in wanted if i["kind"] == "doc"}
120
+
121
+ for item in wanted:
122
+ source = "%s/%s.md" % (paths.RULES_DIR, item["slug"])
123
+ target, content = item["target"], item["content"]
124
+ tree = nested_tree(plan.root, os.path.dirname(target)) if item["kind"] == "block" else None
125
+ if tree is not None:
126
+ # Not this project's file, and the take-back never walks there: a
127
+ # block written there could never be taken back again.
128
+ plan.hints.append("%s: %s is a nested git tree (worktree, repository or submodule)"
129
+ " — rule %s is not rendered there" % (target, tree, item["slug"]))
130
+ continue
131
+ skip = unwalked_dir(plan.root, os.path.dirname(target)) if item["kind"] == "block" else None
132
+ if skip is not None:
133
+ hint = "%s: %s — rule %s is not rendered there" % (target, skip, item["slug"])
134
+ if _frozen_block(plan, target, item["slug"]):
135
+ hint += ("; an earlier rendering of it is still there, the take-back never walks there:"
136
+ " remove that block by hand")
137
+ plan.hints.append(hint)
138
+ continue
139
+ current = plan.read(target)
140
+ if item["kind"] == "doc":
141
+ new = content.encode()
142
+ if current is None:
143
+ plan.add("create", target, "rendered from " + source, content=new)
144
+ elif current != new:
145
+ plan.add("regenerate", target, "rendered from " + source, content=new)
146
+ continue
147
+ text = "" if current is None else current.decode("utf-8")
148
+ new = render_instructions.splice_rule(text, item["slug"], content).encode()
149
+ if current is None:
150
+ plan.add("create", target, "rule block %s from %s" % (item["slug"], source),
151
+ content=new)
152
+ elif current != new:
153
+ plan.add("regenerate", target, "rule block %s from %s" % (item["slug"], source),
154
+ content=new)
155
+
156
+ blocks, docs = rendered_rules(plan)
157
+ for target, slug in blocks:
158
+ if (target, slug) in managed:
159
+ continue
160
+ text = plan.read(target).decode("utf-8")
161
+ # The block is ours; the file is not. Take the block, leave the file.
162
+ plan.add("regenerate", target, "rule block %s removed (%s/%s.md no longer renders here)"
163
+ % (slug, paths.RULES_DIR, slug),
164
+ content=render_instructions.strip_rule(text, slug).encode())
165
+ for target in docs:
166
+ if target in kept_docs:
167
+ continue
168
+ # The whole file is a rendered copy, so taking it away is a deletion, and
169
+ # a deletion waits for a human exactly as a migration's does.
170
+ slug = os.path.basename(target)[:-3]
171
+ why = "[rules] %s" % ("%s/%s.md no longer sets paths:" % (paths.RULES_DIR, slug)
172
+ if slug in {r.slug for r in rules}
173
+ else "source %s/%s.md is gone" % (paths.RULES_DIR, slug))
174
+ if plan.confirm_delete:
175
+ plan.add("delete", target, why, reason=why)
176
+ plan.remove(target)
177
+ else:
178
+ plan.add("delete?", target, why, reason=why)
@@ -0,0 +1,74 @@
1
+ """The shared tree: refusing to drop a copy whose fallback is missing, and the shared-copy rows of the plan.
2
+
3
+ shell: require_shared, shared_update
4
+ """
5
+
6
+ import os
7
+
8
+ from rr_update import files
9
+ from rr_update import history as history_mod
10
+ from rr_update import paths as paths_mod
11
+
12
+
13
+ # ---------------------------------------------------------------- shared files, rules, caches
14
+ def require_shared(paths, runtimes):
15
+ """Refuse, before anything is written, to drop a project copy whose shared fallback is missing:
16
+ in this plugin's tree, or in another installed home of a runtime the project declares. A home
17
+ this machine does not have is not checked."""
18
+ undeclared = sorted(p for p in paths if p not in paths_mod.SHARED_SET)
19
+ if undeclared:
20
+ raise history_mod.SchemaError("not in the shared set, never deleted as a shared copy: %s" % ", ".join(undeclared))
21
+ rels = sorted(p[len(".ai/"):] for p in paths)
22
+ missing = [r for r in rels if not os.path.isfile(os.path.join(paths_mod.SHARED, r))]
23
+ if missing:
24
+ raise history_mod.SchemaError("the plugin's shared tree %s lacks %d file(s) a project copy would fall back to: %s — "
25
+ "reinstall the plugin (install.sh), then run this again" % (paths_mod.SHARED, len(missing), ", ".join(missing)))
26
+ homes = {"claude": os.path.join(os.path.expanduser("~"), ".claude"),
27
+ "codex": os.environ.get("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex")}
28
+ mine = os.path.realpath(paths_mod.PLUGIN_HOME)
29
+ for runtime in runtimes:
30
+ home = homes.get(runtime)
31
+ if (not home or os.path.realpath(home) == mine
32
+ or not os.path.isdir(os.path.join(home, "skills", "project-update"))):
33
+ continue
34
+ tree = os.path.join(home, "riskrails", "ai")
35
+ missing = [r for r in rels if not os.path.isfile(os.path.join(tree, r))]
36
+ if missing:
37
+ raise history_mod.SchemaError("%s has this plugin installed for %s, but %d of the %d shared file(s) a project copy "
38
+ "would fall back to are missing from %s — run `install.sh --target %s` from this "
39
+ "build, then run this again" % (home, runtime, len(missing), len(rels), tree, runtime))
40
+
41
+
42
+ def shared_update(plan, history, runtimes):
43
+ """R17: an unedited copy of a shared file is offered for deletion; an edited one is an
44
+ override, kept, and named once (OQ8)."""
45
+ edited, candidates = [], []
46
+ extra = history_mod.undeclared_shared()
47
+ if extra:
48
+ plan.hints.append("the shared tree %s holds %d file(s) outside the shared set, ignored — reinstall the "
49
+ "plugin (install.sh) if you did not put them there: %s" % (paths_mod.SHARED, len(extra), ", ".join(extra)))
50
+ for path in sorted(history_mod.shared_files()):
51
+ data = plan.read(path)
52
+ if data is None:
53
+ continue
54
+ why = files.unsafe_path(plan.root, path)
55
+ if why:
56
+ plan.hints.append("%s: %s, so this update never deletes it — check by hand whether it is still "
57
+ "needed" % (path, why))
58
+ elif not history.shipped("ai-init/" + path, data):
59
+ edited.append(path)
60
+ else:
61
+ candidates.append(path)
62
+ if candidates:
63
+ try:
64
+ require_shared(candidates, runtimes)
65
+ except history_mod.SchemaError as exc:
66
+ plan.hints.append("%s — the copies stay until then" % exc)
67
+ candidates = []
68
+ for path in candidates:
69
+ why = "[shared] identical to a shipped version"
70
+ plan.gated("delete", path, why, reason=why)
71
+ if edited:
72
+ plan.hints.append("%d file(s) override the plugin's shared copy and no longer receive its changes: %s — "
73
+ "diff each against %s/<path>; `ai-path <path>` shows which applies; delete one when it "
74
+ "adds nothing" % (len(edited), ", ".join(edited), paths_mod.SHARED))
@@ -0,0 +1,37 @@
1
+ """Modules of the sibling skills, loaded by path once per run, and the weight files they serve.
2
+
3
+ shell: _MODULES, sibling, weight_module, weight_overlay, shipped_weight
4
+ """
5
+
6
+ import importlib.util
7
+ import os
8
+
9
+ from rr_update import paths
10
+
11
+
12
+ _MODULES = {}
13
+
14
+
15
+ def sibling(name, *parts):
16
+ """A module of another skill, loaded by path once per run (skills are installed independently)."""
17
+ if name not in _MODULES:
18
+ spec = importlib.util.spec_from_file_location("riskrails_" + name, os.path.join(paths.SKILLS, *parts))
19
+ _MODULES[name] = importlib.util.module_from_spec(spec)
20
+ spec.loader.exec_module(_MODULES[name])
21
+ return _MODULES[name]
22
+
23
+
24
+ def weight_module():
25
+ """skills/ai-task/weight.py, the renderer behind `state.py weight --mirror`."""
26
+ return sibling("weight", "ai-task", "weight.py")
27
+
28
+
29
+ def weight_overlay():
30
+ """skills/ai-task/weight_overlay.py: the plugin's table with the project's laid over it."""
31
+ return sibling("weight_overlay", "ai-task", "weight_overlay.py")
32
+
33
+
34
+ def shipped_weight():
35
+ return os.path.join(paths.TEMPLATES["ai-init"], paths.WEIGHT_JSON)
36
+
37
+
@@ -0,0 +1,171 @@
1
+ """The weight files: the effective table, weight.md, and the one-time split of the mixed testing.md and weight.json.
2
+
3
+ shell: effective_weight, weight_md, split_weight, unwritten, split_once
4
+ core: GENERATED_HEADER, FIELD_LINE, SPECIFICS_RE, split_testing, leaves, listed
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import re
10
+ import tempfile
11
+ from collections import OrderedDict
12
+
13
+ from rr_update import files
14
+ from rr_update import paths
15
+ from rr_update import plan as plan_mod
16
+ from rr_update import siblings
17
+
18
+
19
+ def effective_weight(plan):
20
+ """The weight table as this run leaves it: the plugin's with weight.project.json laid over it."""
21
+ own = plan.read(paths.WEIGHT_PROJECT)
22
+ with tempfile.TemporaryDirectory() as tmp:
23
+ path = os.path.join(tmp, "weight.project.json")
24
+ if own is not None:
25
+ with open(path, "wb") as fh:
26
+ fh.write(own)
27
+ return siblings.weight_overlay().load(siblings.shipped_weight(), path)
28
+
29
+
30
+ GENERATED_HEADER = b"generated by `state.py weight --mirror`"
31
+
32
+
33
+ def weight_md(plan, history):
34
+ """weight.md is always rendered from the shipped table and the overlay (spec :161). A file that is
35
+ a rendering or a shipped version is replaced; one a human wrote is gated."""
36
+ try:
37
+ theirs = siblings.weight_module().mirror(effective_weight(plan)).encode("utf-8")
38
+ except (ValueError, OSError, KeyError, TypeError, AttributeError, RecursionError) as exc:
39
+ plan.hints.append("%s: not rendered (%s: %s); fix %s, then run this again"
40
+ % (paths.WEIGHT_MD, type(exc).__name__, exc, paths.WEIGHT_PROJECT))
41
+ return
42
+ ours = plan.read(paths.WEIGHT_MD)
43
+ if ours is None:
44
+ plan.add("create", paths.WEIGHT_MD, "rendered from the weight table", content=theirs)
45
+ elif ours == theirs:
46
+ return
47
+ elif GENERATED_HEADER in ours[:600] or history.shipped("ai-init/" + paths.WEIGHT_MD, ours):
48
+ plan.add("regenerate", paths.WEIGHT_MD, "rendered from the weight table", content=theirs)
49
+ else:
50
+ plan.gated("regenerate", paths.WEIGHT_MD, plan_mod.edited_note(plan, paths.WEIGHT_MD, ours, theirs), content=theirs)
51
+
52
+
53
+ # ---------------------------------------------------------------- the one-time split
54
+ FIELD_LINE = re.compile(r"(?:%s):" % "|".join(
55
+ ("verify_command", "step_test_command", "e2e_command", "lint_command", "typecheck_command",
56
+ "single_test", "doc_tests", "bite_codes", "healthy_output", "runtime")))
57
+
58
+
59
+ SPECIFICS_RE = re.compile(r"(?m)^## Project specifics[ \t]*$")
60
+
61
+
62
+ def split_testing(testing, template):
63
+ """R11: verification.md from testing.md. The field lines are copied verbatim with their continuation
64
+ lines (indented, after a field line), then `## Project specifics` to the end of the file."""
65
+ lines = testing.decode("utf-8").splitlines(keepends=True)
66
+ fields, taking = [], False
67
+ for line in lines:
68
+ if FIELD_LINE.match(line):
69
+ fields.append(line)
70
+ taking = True
71
+ elif taking and line[:1] in (" ", "\t") and line.strip():
72
+ fields.append(line)
73
+ else:
74
+ taking = False
75
+ text = template.decode("utf-8")
76
+ if not fields:
77
+ return template, 0
78
+ head = text[:re.search(r"(?m)^verify_command:", text).start()]
79
+ tail = SPECIFICS_RE.search(testing.decode("utf-8"))
80
+ specifics = testing.decode("utf-8")[tail.start():] if tail else text[SPECIFICS_RE.search(text).start():]
81
+ body = "".join(fields)
82
+ return (head + body + ("" if body.endswith("\n") else "\n") + "\n" + specifics).encode("utf-8"), len(fields)
83
+
84
+
85
+ def leaves(value, prefix=()):
86
+ """{path tuple: leaf} — objects are walked, a list or scalar (or an empty object) is one leaf."""
87
+ if isinstance(value, dict) and value:
88
+ out = {}
89
+ for k, v in value.items():
90
+ out.update(leaves(v, prefix + (k,)))
91
+ return out
92
+ return {prefix: value}
93
+
94
+
95
+ def split_weight(old, history, template):
96
+ """R11: weight.project.json from the old weight.json: the leaves that differ from that leaf in every
97
+ version the plugin shipped (lists whole). -> (bytes, carried paths, uncarried paths that differ from today's table)."""
98
+ mine = json.loads(old.decode("utf-8"), object_pairs_hook=OrderedDict)
99
+ tables = []
100
+ for blob in history.versions("ai-init/" + paths.WEIGHT_JSON) + [files.read(siblings.shipped_weight())]:
101
+ try:
102
+ tables.append(leaves(json.loads(blob.decode("utf-8"))))
103
+ except (ValueError, RecursionError):
104
+ continue
105
+ today = tables[-1] if tables else {}
106
+ carried, uncarried = OrderedDict(), []
107
+ for path, value in leaves(mine).items():
108
+ if path[:1] in (("version",), ("_comment",)) or not path:
109
+ continue
110
+ if any(path in t and t[path] == value for t in tables):
111
+ if today.get(path, ...) != value:
112
+ uncarried.append(".".join(map(str, path)))
113
+ continue
114
+ here = carried
115
+ for part in path[:-1]:
116
+ here = here.setdefault(part, OrderedDict())
117
+ here[path[-1]] = value
118
+ doc = json.loads(template.decode("utf-8"), object_pairs_hook=OrderedDict)
119
+ doc.update(carried)
120
+ leaf_paths = [".".join(map(str, p)) for p in leaves(carried) if p]
121
+ return files.dump_json(doc), leaf_paths, uncarried
122
+
123
+
124
+ def listed(names, limit=6):
125
+ shown = ", ".join(names[:limit])
126
+ return shown + (" and %d more" % (len(names) - limit) if len(names) > limit else "")
127
+
128
+
129
+ def unwritten(plan, history, target, template):
130
+ """True when the new file is absent, or is still a shipped template (today's or any in the history index):
131
+ a scaffold run over an old tree can leave one, and it carries nothing of the project."""
132
+ if not plan.exists(target):
133
+ return True
134
+ data = plan.read(target)
135
+ return data is not None and (data.replace(b"\r\n", b"\n") == template.replace(b"\r\n", b"\n")
136
+ or history.shipped("ai-init/" + target, data))
137
+
138
+
139
+ def split_once(plan, history):
140
+ """R11: when verification.md / weight.project.json are absent (or only a shipped template), make them from
141
+ testing.md / weight.json (once); with a project's own file present the old file is not read again.
142
+ The old weight.json is then REMOVED, gated."""
143
+ tpl = paths.TEMPLATES["ai-init"]
144
+ template = files.read(os.path.join(tpl, paths.VERIFICATION_MD))
145
+ if unwritten(plan, history, paths.VERIFICATION_MD, template):
146
+ absent = not plan.exists(paths.VERIFICATION_MD)
147
+ testing = plan.read(paths.TESTING_MD)
148
+ content, count = split_testing(testing, template) if testing is not None else (template, 0)
149
+ if count:
150
+ plan.add("split", paths.VERIFICATION_MD, "%d command line(s) of testing.md copied verbatim, with its "
151
+ "Project specifics" % count, content=content)
152
+ elif absent:
153
+ plan.add("create", paths.VERIFICATION_MD, content=content)
154
+ template = files.read(os.path.join(tpl, paths.WEIGHT_PROJECT))
155
+ if unwritten(plan, history, paths.WEIGHT_PROJECT, template):
156
+ absent = not plan.exists(paths.WEIGHT_PROJECT)
157
+ old = plan.read(paths.WEIGHT_JSON)
158
+ if old is None:
159
+ if absent:
160
+ plan.add("create", paths.WEIGHT_PROJECT, content=template)
161
+ else:
162
+ content, carried, uncarried = split_weight(old, history, template)
163
+ if absent or content != plan.read(paths.WEIGHT_PROJECT):
164
+ note = "%d leaf/leaves of weight.json that differ from the plugin's table carried (%s)" % (
165
+ len(carried), listed(carried) or "none")
166
+ if uncarried:
167
+ note += "; %d not carried, equal to an older shipped value: %s" % (len(uncarried), listed(uncarried))
168
+ plan.add("split", paths.WEIGHT_PROJECT, note, content=content)
169
+ if plan.exists(paths.WEIGHT_JSON):
170
+ why = "[split] superseded by %s; the plugin's table is read from the plugin" % paths.WEIGHT_PROJECT
171
+ plan.gated("delete", paths.WEIGHT_JSON, why, reason=why)