owfadev 0.0.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/asset-read/SKILL.md +39 -0
- package/.agents/skills/check/SKILL.md +130 -0
- package/.agents/skills/docs/SKILL.md +24 -0
- package/.agents/skills/map/SKILL.md +72 -0
- package/.agents/skills/plugin/SKILL.md +74 -0
- package/.agents/skills/profile/SKILL.md +75 -0
- package/.agents/skills/query/SKILL.md +48 -0
- package/.agents/skills/save/SKILL.md +94 -0
- package/.agents/skills/wiki_plugin_kernel/SKILL.md +104 -0
- package/.meta/VERSION +1 -0
- package/.meta/command/asset-read/SKILL.md +39 -0
- package/.meta/command/check/SKILL.md +130 -0
- package/.meta/command/docs/SKILL.md +24 -0
- package/.meta/command/map/SKILL.md +72 -0
- package/.meta/command/plugin/SKILL.md +74 -0
- package/.meta/command/profile/SKILL.md +75 -0
- package/.meta/command/query/SKILL.md +48 -0
- package/.meta/command/save/SKILL.md +94 -0
- package/.meta/command/wiki_plugin_kernel/SKILL.md +104 -0
- package/.meta/docs/agent-behavior.md +68 -0
- package/.meta/docs/agents-example.md +34 -0
- package/.meta/docs/conversion.md +24 -0
- package/.meta/docs/deployment.md +311 -0
- package/.meta/docs/developer.md +33 -0
- package/.meta/docs/distribution.md +132 -0
- package/.meta/docs/intro.md +56 -0
- package/.meta/docs/log-example.md +19 -0
- package/.meta/docs/map.md +23 -0
- package/.meta/docs/mechanics.md +180 -0
- package/.meta/docs/naming.md +113 -0
- package/.meta/docs/packages.md +130 -0
- package/.meta/docs/quickstart.md +79 -0
- package/.meta/docs/research-landscape.md +132 -0
- package/.meta/docs/research-user-profile.md +27 -0
- package/.meta/docs/sandbox.md +40 -0
- package/.meta/docs/usage.md +45 -0
- package/.meta/plugins/core/calendar/PLUGIN.md +35 -0
- package/.meta/plugins/core/calendar/PLUGIN.yaml +21 -0
- package/.meta/plugins/core/cron/PLUGIN.md +38 -0
- package/.meta/plugins/core/cron/PLUGIN.yaml +24 -0
- package/.meta/plugins/core/device/PLUGIN.md +38 -0
- package/.meta/plugins/core/device/PLUGIN.yaml +21 -0
- package/.meta/plugins/core/device/scripts/check.py +29 -0
- package/.meta/plugins/core/domain/PLUGIN.md +43 -0
- package/.meta/plugins/core/domain/PLUGIN.yaml +8 -0
- package/.meta/plugins/core/hot/PLUGIN.md +40 -0
- package/.meta/plugins/core/hot/PLUGIN.yaml +15 -0
- package/.meta/plugins/core/index/PLUGIN.md +39 -0
- package/.meta/plugins/core/index/PLUGIN.yaml +18 -0
- package/.meta/plugins/core/instance-git/PLUGIN.md +39 -0
- package/.meta/plugins/core/instance-git/PLUGIN.yaml +15 -0
- package/.meta/plugins/core/instance-git/scripts/check.py +90 -0
- package/.meta/plugins/core/language/PLUGIN.md +38 -0
- package/.meta/plugins/core/language/PLUGIN.yaml +23 -0
- package/.meta/plugins/core/language/scripts/check.py +23 -0
- package/.meta/plugins/core/link/PLUGIN.md +39 -0
- package/.meta/plugins/core/link/PLUGIN.yaml +16 -0
- package/.meta/plugins/core/link/scripts/check.py +122 -0
- package/.meta/plugins/core/log/PLUGIN.md +58 -0
- package/.meta/plugins/core/log/PLUGIN.yaml +18 -0
- package/.meta/plugins/core/log/scripts/check.py +21 -0
- package/.meta/plugins/core/mapping/PLUGIN.md +32 -0
- package/.meta/plugins/core/mapping/PLUGIN.yaml +24 -0
- package/.meta/plugins/core/mapping/scripts/check.py +90 -0
- package/.meta/plugins/core/notes/PLUGIN.md +42 -0
- package/.meta/plugins/core/notes/PLUGIN.yaml +19 -0
- package/.meta/plugins/core/sessions/PLUGIN.md +63 -0
- package/.meta/plugins/core/sessions/PLUGIN.yaml +20 -0
- package/.meta/plugins/core/sessions/scripts/check.py +33 -0
- package/.meta/plugins/core/structure/PLUGIN.md +29 -0
- package/.meta/plugins/core/structure/PLUGIN.yaml +18 -0
- package/.meta/plugins/core/structure/scripts/check.py +32 -0
- package/.meta/plugins/core/tag/PLUGIN.md +37 -0
- package/.meta/plugins/core/tag/PLUGIN.yaml +18 -0
- package/.meta/plugins/core/tag/scripts/check.py +28 -0
- package/.meta/plugins/core/tmp/PLUGIN.md +26 -0
- package/.meta/plugins/core/tmp/PLUGIN.yaml +16 -0
- package/.meta/plugins/core/todo/PLUGIN.md +33 -0
- package/.meta/plugins/core/todo/PLUGIN.yaml +22 -0
- package/.meta/plugins/core/trust/PLUGIN.md +70 -0
- package/.meta/plugins/core/trust/PLUGIN.yaml +23 -0
- package/.meta/plugins/core/trust/scripts/check.py +77 -0
- package/.meta/plugins/core/user-profile/PLUGIN.md +39 -0
- package/.meta/plugins/core/user-profile/PLUGIN.yaml +30 -0
- package/.meta/plugins/core/vault/PLUGIN.md +28 -0
- package/.meta/plugins/core/vault/PLUGIN.yaml +13 -0
- package/.meta/plugins/core/wiki/PLUGIN.md +37 -0
- package/.meta/plugins/core/wiki/PLUGIN.yaml +8 -0
- package/.meta/protocol/actions.md +42 -0
- package/.meta/protocol/experiments.md +40 -0
- package/.meta/protocol/registry.yaml +75 -0
- package/.meta/scripts/pipeline.py +428 -0
- package/.meta/scripts/wiki_plugin_kernel.py +1846 -0
- package/.meta/scripts/wikilib.py +79 -0
- package/LICENSE +21 -21
- package/README.md +98 -15
- package/bin/owfa.js +540 -0
- package/package.json +36 -7
- package/templates/AGENTS.template.md +19 -0
|
@@ -0,0 +1,1846 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""owfa plugin lifecycle mechanical kernel ((un)install / compliance / dependencies / injection / registry / copy sync).
|
|
4
|
+
|
|
5
|
+
Positioning (see .meta/protocol/actions.md execution principles): deterministic structural
|
|
6
|
+
operations go to this script; semantic judgment goes to the LLM. The script touches only
|
|
7
|
+
four places: plugin directories in and out, AGENTS.md injection-region marker blocks,
|
|
8
|
+
the registry.yaml plugin section, and .agents/skills/ command copies; the protocol /
|
|
9
|
+
reserved sections and handwritten regions are never touched.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
python .meta/scripts/wiki_plugin_kernel.py ls # list + dependencies
|
|
13
|
+
python .meta/scripts/wiki_plugin_kernel.py validate # compliance and dependency check (read-only, exit code 1 on errors)
|
|
14
|
+
python .meta/scripts/wiki_plugin_kernel.py audit # plugin attached audits (discovery-style execution of each plugin's scripts/check.py)
|
|
15
|
+
python .meta/scripts/wiki_plugin_kernel.py inject # rebuild AGENTS.md injection region, check inspection blocks and command usage blocks (idempotent)
|
|
16
|
+
python .meta/scripts/wiki_plugin_kernel.py registry # rebuild the registry.yaml plugin section (idempotent)
|
|
17
|
+
python .meta/scripts/wiki_plugin_kernel.py deploy # sync command and connector skill deployment copies (idempotent)
|
|
18
|
+
python .meta/scripts/wiki_plugin_kernel.py all # validate + inject + registry + deploy
|
|
19
|
+
python .meta/scripts/wiki_plugin_kernel.py plan <pkg> # dependency plan for a package about to land
|
|
20
|
+
python .meta/scripts/wiki_plugin_kernel.py install <pkg> [--yes] [--without ...] # fetch from registry/path -> domains/<pkg>/ (owner/repo needs --yes)
|
|
21
|
+
python .meta/scripts/wiki_plugin_kernel.py uninstall <pkg> [--yes] # remove package + projections; stages teardown (x->0) when declared
|
|
22
|
+
python .meta/scripts/wiki_plugin_kernel.py list-remote # registry catalog (INDEX.yaml): packages, versions, compat floors
|
|
23
|
+
python .meta/scripts/wiki_plugin_kernel.py migrate [<pkg>] [--clear] [--force] # pending semantic migrations: status / clear (verify- and residual-gated)
|
|
24
|
+
python .meta/scripts/wiki_plugin_kernel.py upgrade [<pkg>] # compare versions against the registry; apply updates
|
|
25
|
+
python .meta/scripts/wiki_plugin_kernel.py pkg # installed packages: members, versions, connectors
|
|
26
|
+
python .meta/scripts/wiki_plugin_kernel.py residual <pkg> # anchor-surface reference scan (the default teardown gate)
|
|
27
|
+
|
|
28
|
+
The manifest is a minimal YAML subset: top-level `key: value`, `key: []`, block lists
|
|
29
|
+
(` - item`), one-level field dicts (` name: description`); double-quoted values are
|
|
30
|
+
unquoted; inline comments (starting with ` #`) are stripped.
|
|
31
|
+
|
|
32
|
+
Dependencies and injection order:
|
|
33
|
+
- depends declares behavioral or semantic dependencies (a bridging piece depending on the
|
|
34
|
+
concept plugins at both ends is a semantic dependency); validate checks existence and acyclicity
|
|
35
|
+
- injection order = dependency topology (dependees injected first) + alphabetical within
|
|
36
|
+
the same batch; no layering concept
|
|
37
|
+
|
|
38
|
+
Injection tier and budget (issue #12 layer discipline, see protocol/experiments.md):
|
|
39
|
+
- optional manifest field inject_tier (full|member, default full) — member exits the AGENTS.md
|
|
40
|
+
injection region; exposure rides on the family root's roster line, the skill catalog, and
|
|
41
|
+
L-layer reads (the manifest inject field stays the full-disclosure home either way)
|
|
42
|
+
- validate requires a member to reach a full-tier domain root through depends (family
|
|
43
|
+
membership, else it exits into invisibility)
|
|
44
|
+
- the projected plugin blocks carry a byte budget (INJECT_BUDGET): over-budget is a validate
|
|
45
|
+
error (compress pointer lines or re-tier domain members) the region under budget
|
|
46
|
+
|
|
47
|
+
Command-plugin binding (mutual declaration, validate checks consistency):
|
|
48
|
+
- command frontmatter adds owner: plugin id (multiple as an [a, b] list) or framework
|
|
49
|
+
(cross-cutting, explicitly ownerless)
|
|
50
|
+
- plugin manifest adds the optional field commands: list of command names this plugin drives
|
|
51
|
+
- error cases: command missing owner, owner pointing at an uninstalled plugin, framework
|
|
52
|
+
combined with other owners, plugin declaring a nonexistent command, one-sided declaration
|
|
53
|
+
on either side (owner not claimed by commands, or the reverse)
|
|
54
|
+
|
|
55
|
+
Command injection (the third projection: plugin write-side contract -> command):
|
|
56
|
+
- command frontmatter adds optional consumes: list of plugin ids whose write-side contract
|
|
57
|
+
it consumes (pull side, destination declaration); owner-driven commands must fill it in
|
|
58
|
+
and include all owners
|
|
59
|
+
- manifest adds optional usage_routes: list of extra command names this plugin's usage
|
|
60
|
+
lands on (source-side routing) — presence-as-registration on the push side: installing
|
|
61
|
+
the plugin lands the projection, no need to edit hub commands; validate checks routing
|
|
62
|
+
targets exist, routing plugins carry usage, and routing duplicating consumes is an error
|
|
63
|
+
- the manifest's usage / checks lists are the sole projection source of write-side contracts
|
|
64
|
+
and inspection rules: checks project into the check command in injection order, usage
|
|
65
|
+
projects into each command SKILL.md's cmd-inject marker block — disclosure order: owner
|
|
66
|
+
blocks first (consumes order), routed blocks in the middle (dependency topology plus
|
|
67
|
+
alphabetical), remaining consumes last; to change a contract, change PLUGIN.yaml —
|
|
68
|
+
command bodies keep only the operational flow.
|
|
69
|
+
PLUGIN.md returns to pure documentation (Role / Structure / Invariants / Changelog)
|
|
70
|
+
|
|
71
|
+
Global pieces and domain pieces (constitution principle 11):
|
|
72
|
+
- the optional manifest bridge key (必依|按需): the extension point and attach cardinality
|
|
73
|
+
declared by a global piece; disclosure prose lands in PLUGIN.md's bridge section
|
|
74
|
+
- a domain piece = depends chain reaches domain; depending on domain directly makes a domain base
|
|
75
|
+
- validate checks: bridges may only be held by global pieces; a must-attach (必依) bridge
|
|
76
|
+
requires every domain base to have the corresponding depends edge (missing edge is an error)
|
|
77
|
+
|
|
78
|
+
Plugin attached-audit contract (scripts/check.py, optional):
|
|
79
|
+
- must define check(ctx), returning an issue list: {"level": "error"|"warning"|"info", "message": str}
|
|
80
|
+
- ctx.root = repository root; ctx.pages = [(wiki-relative path, frontmatter dict, body)],
|
|
81
|
+
shared across the single scan
|
|
82
|
+
- read-only with zero side effects: repair actions belong to commands/humans, attached
|
|
83
|
+
audits only report; messages in Chinese, no third-party dependencies
|
|
84
|
+
"""
|
|
85
|
+
import ast
|
|
86
|
+
import importlib.util
|
|
87
|
+
import os
|
|
88
|
+
import re
|
|
89
|
+
import shutil
|
|
90
|
+
import sys
|
|
91
|
+
import types
|
|
92
|
+
|
|
93
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
94
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
95
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
96
|
+
|
|
97
|
+
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
98
|
+
PLUGINS_DIR = os.path.join(ROOT, ".meta", "plugins")
|
|
99
|
+
COMMANDS_DIR = os.path.join(ROOT, ".meta", "command")
|
|
100
|
+
CONNECTORS_DIR = os.path.join(ROOT, "connectors")
|
|
101
|
+
SKILLS_DIR = os.path.join(ROOT, ".agents", "skills")
|
|
102
|
+
AGENTS_MD = os.path.join(ROOT, "AGENTS.md")
|
|
103
|
+
REGISTRY = os.path.join(ROOT, ".meta", "protocol", "registry.yaml")
|
|
104
|
+
MIGRATIONS_DIR = os.path.join(ROOT, ".meta", "migrations") # kernel zone: staged migration declarations (use-and-delete)
|
|
105
|
+
LEDGER = os.path.join(MIGRATIONS_DIR, "pending.json") # kernel-owned warning ledger (never a plugin surface)
|
|
106
|
+
JOURNAL = os.path.join(ROOT, ".meta", "kernel-journal.log") # mechanical event journal, one line per op
|
|
107
|
+
|
|
108
|
+
# manifest's seven fields (id / version / depends / updated / attachment / fields / inject) + optional commands
|
|
109
|
+
REQUIRED_KEYS = ["id", "version", "depends", "updated", "attachment", "fields", "inject"]
|
|
110
|
+
INJECT_START = "<!-- wiki-inject:start -->"
|
|
111
|
+
INJECT_END = "<!-- wiki-inject:end -->"
|
|
112
|
+
CHECK_SKILL = os.path.join(COMMANDS_DIR, "check", "SKILL.md")
|
|
113
|
+
CHECK_INJECT_START = "<!-- check-inject:start -->"
|
|
114
|
+
CHECK_INJECT_END = "<!-- check-inject:end -->"
|
|
115
|
+
CMD_INJECT_START = "<!-- cmd-inject:start -->"
|
|
116
|
+
CMD_INJECT_END = "<!-- cmd-inject:end -->"
|
|
117
|
+
# tier law (issue #12 layer discipline): full projects into AGENTS.md, member exits the injection
|
|
118
|
+
# region — exposure rides on the family root's roster line, the skill catalog, and L-layer reads
|
|
119
|
+
INJECT_TIERS = ("full", "member")
|
|
120
|
+
# injection budget on the projected plugin blocks: smallest mainstream whole-file AGENTS.md cap
|
|
121
|
+
# (Devin 16 KiB) minus ~1 KiB instance handwritten allowance; over-budget is a validate error
|
|
122
|
+
INJECT_BUDGET = 16 * 1024 # #12 layer discipline: raised 15->16 KiB at the conversion drill — five packages + core 21 project 15.7K; the 16K line stays the anchor (Devin floor)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def ordered_plugins(plugins):
|
|
126
|
+
"""Injection order = dependency topology (dependees injected first) + alphabetical within a batch; shared by projection regions.
|
|
127
|
+
|
|
128
|
+
On cycles or dangling dependencies, falls back to alphabetical output (validate
|
|
129
|
+
reports the error separately; not duplicated here).
|
|
130
|
+
"""
|
|
131
|
+
remaining = dict(plugins)
|
|
132
|
+
order = []
|
|
133
|
+
while remaining:
|
|
134
|
+
ready = sorted(p for p, m in remaining.items()
|
|
135
|
+
if all(d not in remaining for d in (m.get("depends") or [])))
|
|
136
|
+
if not ready:
|
|
137
|
+
order.extend(sorted(remaining))
|
|
138
|
+
break
|
|
139
|
+
order.extend(ready)
|
|
140
|
+
for p in ready:
|
|
141
|
+
del remaining[p]
|
|
142
|
+
return order
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def strip_quotes(s):
|
|
146
|
+
if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
|
|
147
|
+
return s[1:-1]
|
|
148
|
+
return s
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def strip_comment(s):
|
|
152
|
+
"""Strip inline comments (from ` #` on) — a quoted value is protected up to its closing quote.
|
|
153
|
+
|
|
154
|
+
The manifest contract says "quote the whole value first if it contains #"; this honors it:
|
|
155
|
+
comment stripping never fires inside the quoted span (e.g. `## Section` names in disclosures).
|
|
156
|
+
"""
|
|
157
|
+
if s.startswith('"'):
|
|
158
|
+
end = s.find('"', 1)
|
|
159
|
+
if end != -1:
|
|
160
|
+
return s[:end + 1] + strip_comment(s[end + 1:])
|
|
161
|
+
return re.split(r"\s+#", s, maxsplit=1)[0].strip()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def parse_manifest(path):
|
|
165
|
+
"""Parse the manifest's minimal YAML subset; raises ValueError on failure."""
|
|
166
|
+
data, target = {}, None
|
|
167
|
+
for raw in open(path, encoding="utf-8").read().splitlines():
|
|
168
|
+
if not raw.strip() or raw.lstrip().startswith("#"):
|
|
169
|
+
continue
|
|
170
|
+
if raw.startswith(" - "):
|
|
171
|
+
if target is None:
|
|
172
|
+
raise ValueError(f"list item before any key: {raw!r}")
|
|
173
|
+
item = strip_quotes(strip_comment(raw.strip()[2:]))
|
|
174
|
+
if not isinstance(data.get(target), list):
|
|
175
|
+
data[target] = []
|
|
176
|
+
data[target].append(item)
|
|
177
|
+
continue
|
|
178
|
+
m = re.match(r"^(\s*)([A-Za-z_][\w-]*):\s*(.*)$", raw)
|
|
179
|
+
if not m:
|
|
180
|
+
raise ValueError(f"unparsable line: {raw!r}")
|
|
181
|
+
key, val = m.group(2), strip_comment(m.group(3))
|
|
182
|
+
if m.group(1): # indented line: fields dict item
|
|
183
|
+
if target is None:
|
|
184
|
+
raise ValueError(f"field item before any key: {raw!r}")
|
|
185
|
+
if not isinstance(data.get(target), dict):
|
|
186
|
+
data[target] = {}
|
|
187
|
+
data[target][key] = strip_quotes(val)
|
|
188
|
+
else:
|
|
189
|
+
target = key
|
|
190
|
+
if val == "":
|
|
191
|
+
data[key] = None # block style, filled by following lines
|
|
192
|
+
elif val == "[]":
|
|
193
|
+
data[key] = []
|
|
194
|
+
elif val == "{}":
|
|
195
|
+
data[key] = {}
|
|
196
|
+
else:
|
|
197
|
+
data[key] = strip_quotes(val)
|
|
198
|
+
return data
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _plugin_dirs(base):
|
|
202
|
+
"""Yield (plugin_id, dir) across layouts: flat <p>/, core/<p>/, domains/<pkg>/<p>/."""
|
|
203
|
+
for name in sorted(os.listdir(base)):
|
|
204
|
+
pdir = os.path.join(base, name)
|
|
205
|
+
if not os.path.isdir(pdir):
|
|
206
|
+
continue
|
|
207
|
+
if os.path.exists(os.path.join(pdir, "PLUGIN.yaml")):
|
|
208
|
+
yield name, pdir
|
|
209
|
+
continue
|
|
210
|
+
for sub in sorted(os.listdir(pdir)): # core/<p> or domains/<pkg>
|
|
211
|
+
sdir = os.path.join(pdir, sub)
|
|
212
|
+
if not os.path.isdir(sdir):
|
|
213
|
+
continue
|
|
214
|
+
if os.path.exists(os.path.join(sdir, "PLUGIN.yaml")):
|
|
215
|
+
yield sub, sdir
|
|
216
|
+
continue
|
|
217
|
+
for sub2 in sorted(os.listdir(sdir)): # domains/<pkg>/<p>
|
|
218
|
+
s2dir = os.path.join(sdir, sub2)
|
|
219
|
+
if os.path.isdir(s2dir) and os.path.exists(os.path.join(s2dir, "PLUGIN.yaml")):
|
|
220
|
+
yield sub2, s2dir
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def load_plugins():
|
|
224
|
+
"""Read all plugin manifests (flat / core/ / domains/<pkg>/ layouts); returns (plugins, errors)."""
|
|
225
|
+
plugins, errors = {}, []
|
|
226
|
+
for name, pdir in _plugin_dirs(PLUGINS_DIR):
|
|
227
|
+
yml = os.path.join(pdir, "PLUGIN.yaml")
|
|
228
|
+
md = os.path.join(pdir, "PLUGIN.md")
|
|
229
|
+
if not os.path.exists(md):
|
|
230
|
+
errors.append(f"[error] {name}/: PLUGIN.yaml or PLUGIN.md missing")
|
|
231
|
+
continue
|
|
232
|
+
if name in plugins:
|
|
233
|
+
errors.append(f"[error] {name}/: duplicate plugin id (two layouts declare it)")
|
|
234
|
+
continue
|
|
235
|
+
try:
|
|
236
|
+
m = parse_manifest(yml)
|
|
237
|
+
except ValueError as e:
|
|
238
|
+
errors.append(f"[error] {name}/PLUGIN.yaml: {e}")
|
|
239
|
+
continue
|
|
240
|
+
m["_dir"] = pdir # private: layout-aware path anchor (attached-audit contract etc.)
|
|
241
|
+
plugins[name] = m
|
|
242
|
+
return plugins, errors
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def reaches_domain(plugins, pid, seen=None):
|
|
246
|
+
"""Does the depends chain of pid reach the domain concept plugin (domain-piece test, principle 11)?"""
|
|
247
|
+
seen = set() if seen is None else seen
|
|
248
|
+
if pid in seen or pid not in plugins:
|
|
249
|
+
return False
|
|
250
|
+
seen.add(pid)
|
|
251
|
+
deps = plugins[pid].get("depends") or []
|
|
252
|
+
return "domain" in deps or any(reaches_domain(plugins, d, seen) for d in deps)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def reaches_plugin(plugins, start, target, seen=None):
|
|
256
|
+
"""Does the depends chain of start reach target (transitive family-membership test)?"""
|
|
257
|
+
seen = set() if seen is None else seen
|
|
258
|
+
if start in seen or start not in plugins:
|
|
259
|
+
return False
|
|
260
|
+
seen.add(start)
|
|
261
|
+
deps = plugins[start].get("depends") or []
|
|
262
|
+
return target in deps or any(reaches_plugin(plugins, d, target, seen) for d in deps)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def build_inject_blocks(plugins):
|
|
266
|
+
"""AGENTS.md injection-region plugin blocks with member-tier exits applied; returns (blocks, exited)."""
|
|
267
|
+
blocks, exited = [], 0
|
|
268
|
+
for pid in ordered_plugins(plugins):
|
|
269
|
+
if plugins[pid].get("inject_tier") == "member":
|
|
270
|
+
exited += 1
|
|
271
|
+
continue
|
|
272
|
+
ver = plugins[pid].get("version")
|
|
273
|
+
line = plugins[pid].get("inject", "")
|
|
274
|
+
blocks.append(f"<!-- plugin:{pid} v{ver} -->\n- {line}\n<!-- /plugin:{pid} -->")
|
|
275
|
+
return blocks, exited
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def validate(plugins, errors, scope=None, warnings=None):
|
|
279
|
+
"""Compliance and dependency checks; errors are appended to errors (scope set → single-package tolerant mode, #20 D3)."""
|
|
280
|
+
warnings = [] if warnings is None else warnings
|
|
281
|
+
for name, m in sorted(plugins.items()):
|
|
282
|
+
for k in REQUIRED_KEYS:
|
|
283
|
+
if k not in m:
|
|
284
|
+
errors.append(f"[error] {name}/PLUGIN.yaml: missing field {k}")
|
|
285
|
+
if m.get("id") and m["id"] != name:
|
|
286
|
+
errors.append(f"[error] {name}/: id ({m['id']}) != directory name")
|
|
287
|
+
if m.get("version") and not re.fullmatch(r"\d+\.\d+", str(m["version"])):
|
|
288
|
+
errors.append(f"[error] {name}/: version ({m['version']}) not X.Y")
|
|
289
|
+
if m.get("updated") and not re.fullmatch(r"\d{4}-\d{2}-\d{2}", str(m["updated"])):
|
|
290
|
+
errors.append(f"[error] {name}/: updated ({m['updated']}) not YYYY-MM-DD")
|
|
291
|
+
deps = m.get("depends")
|
|
292
|
+
if deps is not None and not isinstance(deps, list):
|
|
293
|
+
errors.append(f"[error] {name}/: depends must be a list")
|
|
294
|
+
if not m.get("inject"):
|
|
295
|
+
errors.append(f"[error] {name}/: inject (projection line) empty")
|
|
296
|
+
if m.get("inject_tier") is not None and m["inject_tier"] not in INJECT_TIERS:
|
|
297
|
+
errors.append(f"[error] {name}/: inject_tier ({m['inject_tier']}) must be full or member")
|
|
298
|
+
# stray-quote smell (post-parse): a value still carrying a quote edge means unbalanced
|
|
299
|
+
# quoting — the classic artifact of a clipped value (issue #12 post-migration field report)
|
|
300
|
+
if isinstance(m.get("inject"), str) and (m["inject"].startswith('"') or m["inject"].endswith('"')):
|
|
301
|
+
print(f"[warning] {name}/: inject value carries a stray quote (unbalanced quoting — parse artifact or manifest typo)")
|
|
302
|
+
# attached-audit contract (optional): if scripts/check.py exists it must define check(ctx) — checked statically via AST, not executed
|
|
303
|
+
cpath = os.path.join(m.get("_dir", os.path.join(PLUGINS_DIR, name)), "scripts", "check.py")
|
|
304
|
+
if os.path.exists(cpath):
|
|
305
|
+
try:
|
|
306
|
+
tree = ast.parse(open(cpath, encoding="utf-8").read())
|
|
307
|
+
except SyntaxError as e:
|
|
308
|
+
errors.append(f"[error] {name}/scripts/check.py syntax error: {e}")
|
|
309
|
+
else:
|
|
310
|
+
if not any(isinstance(n, ast.FunctionDef) and n.name == "check" for n in tree.body):
|
|
311
|
+
errors.append(f"[error] {name}/scripts/check.py: check(ctx) not defined (audit contract)")
|
|
312
|
+
# tier law (issue #12): member-tier exits are legitimate only inside a family — the depends
|
|
313
|
+
# chain must reach a full-tier domain root, else the plugin exits into invisibility
|
|
314
|
+
roots = [p for p in sorted(plugins)
|
|
315
|
+
if reaches_domain(plugins, p) and plugins[p].get("inject_tier") != "member"]
|
|
316
|
+
for name, m in sorted(plugins.items()):
|
|
317
|
+
if m.get("inject_tier") == "member" and not any(reaches_plugin(plugins, name, r) for r in roots):
|
|
318
|
+
msg = f"{name}/: inject_tier member must reach a full-tier domain root through depends"
|
|
319
|
+
if scope:
|
|
320
|
+
warnings.append(f"{msg} — family root may live outside package {scope} (single-package mode)")
|
|
321
|
+
else:
|
|
322
|
+
errors.append(f"[error] {msg}")
|
|
323
|
+
# injection budget (hard since the issue #12 tier migration landed): over-budget blocks mechanical actions
|
|
324
|
+
blocks, _ = build_inject_blocks(plugins)
|
|
325
|
+
size = sum(len(b.encode("utf-8")) for b in blocks)
|
|
326
|
+
if size > INJECT_BUDGET:
|
|
327
|
+
errors.append(f"[error] projected injection region {size} B over the {INJECT_BUDGET} B budget — compress pointer lines or re-tier domain members (issue #12 layer discipline)")
|
|
328
|
+
# dependency existence
|
|
329
|
+
for name, m in sorted(plugins.items()):
|
|
330
|
+
for dep in m.get("depends") or []:
|
|
331
|
+
if dep not in plugins:
|
|
332
|
+
if scope:
|
|
333
|
+
warnings.append(f"{name}/: dependency {dep} absent outside package {scope} (tolerated in single-package mode)")
|
|
334
|
+
else:
|
|
335
|
+
errors.append(f"[error] {name}/: dependency {dep} not found")
|
|
336
|
+
# cycle detection (DFS three-color marking)
|
|
337
|
+
WHITE, GRAY, BLACK = 0, 1, 2
|
|
338
|
+
color = {k: WHITE for k in plugins}
|
|
339
|
+
|
|
340
|
+
def dfs(node, path):
|
|
341
|
+
color[node] = GRAY
|
|
342
|
+
for dep in plugins[node].get("depends") or []:
|
|
343
|
+
if dep not in plugins:
|
|
344
|
+
continue
|
|
345
|
+
if color[dep] == GRAY:
|
|
346
|
+
errors.append(f"[error] dependency cycle: {' -> '.join(path + [node, dep])}")
|
|
347
|
+
elif color[dep] == WHITE:
|
|
348
|
+
dfs(dep, path + [node])
|
|
349
|
+
color[node] = BLACK
|
|
350
|
+
|
|
351
|
+
for k in sorted(plugins):
|
|
352
|
+
if color[k] == WHITE:
|
|
353
|
+
dfs(k, [])
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _owner_list(owner):
|
|
357
|
+
"""Normalize the owner value into an id list: lists as-is, strings split on commas (tolerating the [a, b] form)."""
|
|
358
|
+
if isinstance(owner, list):
|
|
359
|
+
vals = [str(v).strip() for v in owner]
|
|
360
|
+
else:
|
|
361
|
+
vals = [v.strip() for v in str(owner).replace("[", "").replace("]", "").split(",")]
|
|
362
|
+
return [v for v in vals if v]
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def validate_bindings(plugins, errors):
|
|
366
|
+
"""Command-plugin binding: owner × commands mutual consistency; consumes pull and usage_routes source-side routing."""
|
|
367
|
+
import wikilib
|
|
368
|
+
|
|
369
|
+
commands, claimed, consumes_map = [], {}, {} # claimed: command -> owner set (framework excluded)
|
|
370
|
+
for name in sorted(os.listdir(COMMANDS_DIR)):
|
|
371
|
+
cdir = os.path.join(COMMANDS_DIR, name)
|
|
372
|
+
path = os.path.join(cdir, "SKILL.md")
|
|
373
|
+
if not os.path.isdir(cdir) or not os.path.exists(path):
|
|
374
|
+
continue
|
|
375
|
+
commands.append(name)
|
|
376
|
+
fm = wikilib.parse_frontmatter(open(path, encoding="utf-8").read())
|
|
377
|
+
if fm.get("owner") is None:
|
|
378
|
+
errors.append(f"[error] command {name}/: frontmatter missing owner (plugin id or framework)")
|
|
379
|
+
continue
|
|
380
|
+
owners = _owner_list(fm["owner"])
|
|
381
|
+
# consumes (optional; required for owner-driven commands and must include all owners): write-side contract pull declaration
|
|
382
|
+
consumes = fm.get("consumes")
|
|
383
|
+
consumes = _owner_list(consumes) if consumes else []
|
|
384
|
+
consumes_map[name] = consumes
|
|
385
|
+
if "framework" not in owners and not consumes:
|
|
386
|
+
errors.append(f"[error] command {name}/: owner-driven command requires consumes (owner usage projects too)")
|
|
387
|
+
for pid in consumes:
|
|
388
|
+
if pid not in plugins:
|
|
389
|
+
errors.append(f"[error] command {name}/: consumes {pid} is not an installed plugin")
|
|
390
|
+
continue
|
|
391
|
+
if not (plugins[pid].get("usage") or []):
|
|
392
|
+
errors.append(f"[error] command {name}/: consumes {pid} but its manifest lacks a usage list")
|
|
393
|
+
if "framework" not in owners:
|
|
394
|
+
for pid in owners:
|
|
395
|
+
if pid not in consumes:
|
|
396
|
+
errors.append(f"[error] command {name}/: owner {pid} not in consumes (owner usage projects too)")
|
|
397
|
+
if "framework" in owners:
|
|
398
|
+
if len(owners) > 1:
|
|
399
|
+
errors.append(f"[error] command {name}/: framework cannot combine with other owners ({', '.join(owners)})")
|
|
400
|
+
continue
|
|
401
|
+
claimed[name] = set(owners)
|
|
402
|
+
for pid in owners:
|
|
403
|
+
if pid not in plugins:
|
|
404
|
+
errors.append(f"[error] command {name}/: owner {pid} is not an installed plugin")
|
|
405
|
+
for pid, m in sorted(plugins.items()):
|
|
406
|
+
cmds = m.get("commands")
|
|
407
|
+
if cmds is None:
|
|
408
|
+
continue
|
|
409
|
+
if not isinstance(cmds, list):
|
|
410
|
+
errors.append(f"[error] {pid}/: commands must be a list")
|
|
411
|
+
continue
|
|
412
|
+
for c in cmds:
|
|
413
|
+
if c not in commands:
|
|
414
|
+
errors.append(f"[error] {pid}/: commands declares missing command {c} (.meta/command/{c}/)")
|
|
415
|
+
elif c not in claimed:
|
|
416
|
+
errors.append(f"[error] command {c}/: {pid} one-sided claim (owner is framework or missing)")
|
|
417
|
+
elif pid not in claimed[c]:
|
|
418
|
+
errors.append(f"[error] command {c}/: {pid} one-sided claim (owner does not include {pid})")
|
|
419
|
+
for c, owners in sorted(claimed.items()):
|
|
420
|
+
for pid in owners:
|
|
421
|
+
if pid in plugins and c not in (plugins[pid].get("commands") or []):
|
|
422
|
+
errors.append(f"[error] command {c}/: owner {pid} not claimed in manifest commands (one-sided)")
|
|
423
|
+
# source-side routing (usage_routes): installing a plugin lands the projection, no destination-command edit needed
|
|
424
|
+
for pid, m in sorted(plugins.items()):
|
|
425
|
+
routes = m.get("usage_routes")
|
|
426
|
+
if routes is None:
|
|
427
|
+
continue
|
|
428
|
+
if not isinstance(routes, list):
|
|
429
|
+
errors.append(f"[error] {pid}/: usage_routes must be a list")
|
|
430
|
+
continue
|
|
431
|
+
if not (m.get("usage") or []):
|
|
432
|
+
errors.append(f"[error] {pid}/: usage_routes declared but manifest lacks a usage list")
|
|
433
|
+
for c in routes:
|
|
434
|
+
if c not in commands:
|
|
435
|
+
errors.append(f"[error] {pid}/: usage_routes target command {c} not found (.meta/command/{c}/)")
|
|
436
|
+
elif pid in consumes_map.get(c, []):
|
|
437
|
+
errors.append(f"[error] command {c}/: {pid} both routed and in consumes (duplicate routing)")
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def validate_bridges(plugins, errors):
|
|
441
|
+
"""Global/domain piece bridge law (constitution principle 11): bridges are global-piece extension points; must-attach bridges check domain-base dependency completeness."""
|
|
442
|
+
bases = [p for p, m in sorted(plugins.items()) if "domain" in (m.get("depends") or [])]
|
|
443
|
+
for pid, m in sorted(plugins.items()):
|
|
444
|
+
bridge = m.get("bridge")
|
|
445
|
+
if bridge is None:
|
|
446
|
+
continue
|
|
447
|
+
if bridge not in ("必依", "按需"):
|
|
448
|
+
errors.append(f"[error] {pid}/: bridge ({bridge}) must be 必依 (required) or 按需 (on-demand)")
|
|
449
|
+
continue
|
|
450
|
+
if reaches_domain(plugins, pid):
|
|
451
|
+
errors.append(f"[error] {pid}/: bridge is global-only (depends chain reaches domain)")
|
|
452
|
+
continue
|
|
453
|
+
if bridge == "必依":
|
|
454
|
+
for base in bases:
|
|
455
|
+
if pid not in (plugins[base].get("depends") or []):
|
|
456
|
+
errors.append(f"[error] {base}/: missing depends {pid} (必依 bridge: every domain base must carry this edge)")
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def do_audit(plugins, only=None):
|
|
460
|
+
"""Plugin attached audits: discovery-style execution of each plugin's scripts/check.py (contract in the module docstring).
|
|
461
|
+
|
|
462
|
+
This is the mechanical shape of "code injection": a contract-compliant attached-audit
|
|
463
|
+
script present in a plugin directory is automatically listed, and moving the directory
|
|
464
|
+
out on uninstall automatically delists it — isomorphic to the AGENTS.md injection
|
|
465
|
+
region (presence as registration), but the code does no text splicing (avoiding
|
|
466
|
+
namespace and merge noise), using discovery + invocation instead.
|
|
467
|
+
"""
|
|
468
|
+
import wikilib
|
|
469
|
+
|
|
470
|
+
pages = list(wikilib.walk_pages(ROOT)) # single scan shared by all plugins (frugal invocation)
|
|
471
|
+
ctx = types.SimpleNamespace(root=ROOT, pages=pages)
|
|
472
|
+
counts = {"error": 0, "warning": 0, "info": 0}
|
|
473
|
+
ran = 0
|
|
474
|
+
for pid in sorted(plugins):
|
|
475
|
+
if only and pid != only:
|
|
476
|
+
continue
|
|
477
|
+
cpath = os.path.join(plugins[pid].get("_dir", os.path.join(PLUGINS_DIR, pid)), "scripts", "check.py")
|
|
478
|
+
if not os.path.exists(cpath):
|
|
479
|
+
continue
|
|
480
|
+
spec = importlib.util.spec_from_file_location(f"plugin_check_{pid}", cpath)
|
|
481
|
+
mod = importlib.util.module_from_spec(spec)
|
|
482
|
+
try:
|
|
483
|
+
spec.loader.exec_module(mod)
|
|
484
|
+
issues = mod.check(ctx) or []
|
|
485
|
+
except Exception as e: # a check script's own failure is reported as an error, without taking down the other plugins
|
|
486
|
+
print(f"[error] ({pid}) check script failed: {e}")
|
|
487
|
+
counts["error"] += 1
|
|
488
|
+
ran += 1
|
|
489
|
+
continue
|
|
490
|
+
ran += 1
|
|
491
|
+
for it in issues:
|
|
492
|
+
level = str(it.get("level", "warning"))
|
|
493
|
+
counts[level] = counts.get(level, 0) + 1
|
|
494
|
+
print(f"[{level}] ({pid}) {it.get('message', '')}")
|
|
495
|
+
if ran == 0:
|
|
496
|
+
print("[audit] no plugin carries scripts/check.py (optional contract; currently semantic-only)")
|
|
497
|
+
return 0
|
|
498
|
+
print(f"[audit] {ran} plugins, error {counts['error']} / warning {counts.get('warning', 0)} / info {counts.get('info', 0)}")
|
|
499
|
+
return 1 if counts["error"] else 0
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def do_inject(plugins):
|
|
503
|
+
"""Rebuild the AGENTS.md injection region from manifests (preamble preserved, blocks in dependency topology + alphabetical order, idempotent)."""
|
|
504
|
+
text = open(AGENTS_MD, encoding="utf-8").read()
|
|
505
|
+
m = re.search(re.escape(INJECT_START) + r"\n(.*?)" + re.escape(INJECT_END), text, re.S)
|
|
506
|
+
if not m:
|
|
507
|
+
print("[error] AGENTS.md: inject markers (wiki-inject:start/end) not found")
|
|
508
|
+
return False
|
|
509
|
+
region = m.group(1)
|
|
510
|
+
first = region.find("<!-- plugin:")
|
|
511
|
+
preamble = region[:first].rstrip() if first != -1 else region.rstrip()
|
|
512
|
+
blocks, exited = build_inject_blocks(plugins)
|
|
513
|
+
new_region = preamble + "\n\n" + "\n\n".join(blocks) + "\n\n"
|
|
514
|
+
if new_region == region:
|
|
515
|
+
print("[inject] region unchanged")
|
|
516
|
+
return True
|
|
517
|
+
open(AGENTS_MD, "w", encoding="utf-8", newline="\n").write(
|
|
518
|
+
text[: m.start(1)] + new_region + text[m.end(1):]
|
|
519
|
+
)
|
|
520
|
+
tail = f", {exited} member-tier exits skipped" if exited else ""
|
|
521
|
+
print(f"[inject] rebuilt ({len(blocks)} plugin blocks{tail})")
|
|
522
|
+
return True
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def do_check_inject(plugins):
|
|
526
|
+
"""Rebuild the check command's injection region from each manifest's checks list (presence as registration, idempotent).
|
|
527
|
+
|
|
528
|
+
Isomorphic to the AGENTS.md injection region: the manifest is the body, the check
|
|
529
|
+
block is the projection — to change inspection rules, change the manifest's checks
|
|
530
|
+
list, and drift between projection and handwritten blocks disappears.
|
|
531
|
+
"""
|
|
532
|
+
if not os.path.exists(CHECK_SKILL):
|
|
533
|
+
print("[check-inject] check command absent, skipped")
|
|
534
|
+
return True
|
|
535
|
+
text = open(CHECK_SKILL, encoding="utf-8").read()
|
|
536
|
+
m = re.search(re.escape(CHECK_INJECT_START) + r"\n(.*?)" + re.escape(CHECK_INJECT_END), text, re.S)
|
|
537
|
+
if not m:
|
|
538
|
+
print("[error] check/SKILL.md: inject markers (check-inject:start/end) not found")
|
|
539
|
+
return False
|
|
540
|
+
blocks = []
|
|
541
|
+
for pid in ordered_plugins(plugins):
|
|
542
|
+
items = plugins[pid].get("checks") or []
|
|
543
|
+
if not items:
|
|
544
|
+
continue
|
|
545
|
+
body = "\n".join(f"- {it}" for it in items)
|
|
546
|
+
blocks.append(f"<!-- check:{pid} -->\n{body}\n<!-- /check:{pid} -->")
|
|
547
|
+
new_region = "\n\n".join(blocks) + "\n" if blocks else "\n"
|
|
548
|
+
if new_region == m.group(1):
|
|
549
|
+
print("[check-inject] unchanged")
|
|
550
|
+
return True
|
|
551
|
+
open(CHECK_SKILL, "w", encoding="utf-8", newline="\n").write(
|
|
552
|
+
text[: m.start(1)] + new_region + text[m.end(1):]
|
|
553
|
+
)
|
|
554
|
+
print(f"[check-inject] rebuilt ({len(blocks)} plugin blocks)")
|
|
555
|
+
return True
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def do_cmd_inject(plugins):
|
|
559
|
+
"""Rebuild command injection regions from manifests: consumes pull + usage_routes source-side routing (presence as registration, idempotent).
|
|
560
|
+
|
|
561
|
+
The third projection: plugin write-side contract -> command. Disclosure order: owner
|
|
562
|
+
blocks first (consumes order), routed blocks in the middle (dependency topology plus
|
|
563
|
+
alphabetical), remaining consumes last. Each block is the corresponding manifest usage
|
|
564
|
+
list verbatim — command bodies keep only the operational flow; (un)installing plugins
|
|
565
|
+
adds and removes them automatically.
|
|
566
|
+
"""
|
|
567
|
+
import wikilib
|
|
568
|
+
|
|
569
|
+
routed = {} # command -> [plugins] (source-side routing, topology + alphabetical)
|
|
570
|
+
for pid in ordered_plugins(plugins):
|
|
571
|
+
for c in plugins[pid].get("usage_routes") or []:
|
|
572
|
+
routed.setdefault(c, []).append(pid)
|
|
573
|
+
|
|
574
|
+
ok = True
|
|
575
|
+
for name in sorted(os.listdir(COMMANDS_DIR)):
|
|
576
|
+
path = os.path.join(COMMANDS_DIR, name, "SKILL.md")
|
|
577
|
+
if not os.path.exists(path):
|
|
578
|
+
continue
|
|
579
|
+
text = open(path, encoding="utf-8").read()
|
|
580
|
+
fm = wikilib.parse_frontmatter(text)
|
|
581
|
+
consumes = fm.get("consumes")
|
|
582
|
+
if consumes is None:
|
|
583
|
+
continue
|
|
584
|
+
if not isinstance(consumes, list):
|
|
585
|
+
consumes = _owner_list(consumes)
|
|
586
|
+
m = re.search(re.escape(CMD_INJECT_START) + r"\n(.*?)" + re.escape(CMD_INJECT_END), text, re.S)
|
|
587
|
+
if not m:
|
|
588
|
+
print(f"[error] command {name}/: inject markers (cmd-inject:start/end) not found")
|
|
589
|
+
ok = False
|
|
590
|
+
continue
|
|
591
|
+
owners = [o for o in _owner_list(fm.get("owner") or "") if o != "framework"]
|
|
592
|
+
seq, seen = [p for p in consumes if p in owners], set()
|
|
593
|
+
seen.update(seq)
|
|
594
|
+
seq += [p for p in routed.get(name, []) if p not in seen]
|
|
595
|
+
seen.update(seq)
|
|
596
|
+
seq += [p for p in consumes if p not in seen]
|
|
597
|
+
blocks = []
|
|
598
|
+
for pid in seq:
|
|
599
|
+
items = plugins.get(pid, {}).get("usage") or []
|
|
600
|
+
if items:
|
|
601
|
+
body = "\n".join(f"- {it}" for it in items)
|
|
602
|
+
blocks.append(f"<!-- usage:{pid} -->\n{body}\n<!-- /usage:{pid} -->")
|
|
603
|
+
new_region = "\n\n".join(blocks) + "\n" if blocks else "\n"
|
|
604
|
+
if new_region == m.group(1):
|
|
605
|
+
continue
|
|
606
|
+
open(path, "w", encoding="utf-8", newline="\n").write(
|
|
607
|
+
text[: m.start(1)] + new_region + text[m.end(1):]
|
|
608
|
+
)
|
|
609
|
+
print(f"[cmd-inject] {name}: rebuilt ({len(blocks)} usage blocks)")
|
|
610
|
+
return ok
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def do_registry(plugins):
|
|
614
|
+
"""Rebuild the registry.yaml plugin section from the manifests' fields (protocol / reserved sections untouched)."""
|
|
615
|
+
text = open(REGISTRY, encoding="utf-8").read()
|
|
616
|
+
m = re.search(r"^plugins:\n(.*?)^reserved:", text, re.S | re.M)
|
|
617
|
+
if not m:
|
|
618
|
+
print("[error] registry.yaml: plugins:/reserved: section structure not found")
|
|
619
|
+
return False
|
|
620
|
+
lines = []
|
|
621
|
+
for pid in sorted(plugins):
|
|
622
|
+
fields = plugins[pid].get("fields")
|
|
623
|
+
if not fields:
|
|
624
|
+
continue # plugins with no fields of their own stay out of the registry plugin section
|
|
625
|
+
lines.append(f" {pid}:")
|
|
626
|
+
for f in sorted(fields):
|
|
627
|
+
lines.append(f" {f}:")
|
|
628
|
+
lines.append(f" note: {fields[f]}")
|
|
629
|
+
body = "\n".join(lines) + "\n\n"
|
|
630
|
+
if body == m.group(1):
|
|
631
|
+
print("[registry] plugin section unchanged")
|
|
632
|
+
return True
|
|
633
|
+
open(REGISTRY, "w", encoding="utf-8", newline="\n").write(
|
|
634
|
+
text[: m.start(1)] + body + text[m.end(1):]
|
|
635
|
+
)
|
|
636
|
+
print("[registry] plugin section rebuilt")
|
|
637
|
+
return True
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def do_deploy():
|
|
641
|
+
"""Sync .meta/command/*/SKILL.md and connectors/*/SKILL.md -> .agents/skills/*/SKILL.md; orphan copies are reported only."""
|
|
642
|
+
sources = {}
|
|
643
|
+
for base in (COMMANDS_DIR, CONNECTORS_DIR):
|
|
644
|
+
if not os.path.isdir(base):
|
|
645
|
+
continue
|
|
646
|
+
for d in sorted(os.listdir(base)):
|
|
647
|
+
src = os.path.join(base, d, "SKILL.md")
|
|
648
|
+
if not os.path.isfile(src):
|
|
649
|
+
continue
|
|
650
|
+
if d in sources:
|
|
651
|
+
print(f"[warning] skill name collision: {d}/ in both commands and connectors; commands win")
|
|
652
|
+
continue
|
|
653
|
+
sources[d] = src
|
|
654
|
+
changed = 0
|
|
655
|
+
for name, src in sorted(sources.items()):
|
|
656
|
+
dst_dir = os.path.join(SKILLS_DIR, name)
|
|
657
|
+
dst = os.path.join(dst_dir, "SKILL.md")
|
|
658
|
+
os.makedirs(dst_dir, exist_ok=True)
|
|
659
|
+
if not os.path.exists(dst) or open(src, "rb").read() != open(dst, "rb").read():
|
|
660
|
+
shutil.copyfile(src, dst)
|
|
661
|
+
changed += 1
|
|
662
|
+
print(f"[deploy] synced {name}/SKILL.md")
|
|
663
|
+
if os.path.isdir(SKILLS_DIR):
|
|
664
|
+
for d in sorted(os.listdir(SKILLS_DIR)):
|
|
665
|
+
if d not in sources and os.path.isdir(os.path.join(SKILLS_DIR, d)):
|
|
666
|
+
print(f"[warning] orphan copy .agents/skills/{d}/ (master gone; deletion belongs to human)")
|
|
667
|
+
if not changed:
|
|
668
|
+
print("[deploy] all in sync")
|
|
669
|
+
return True
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def do_ls(plugins):
|
|
673
|
+
for pid in sorted(plugins):
|
|
674
|
+
deps = ", ".join(plugins[pid].get("depends") or []) or "—"
|
|
675
|
+
cmds = ", ".join(plugins[pid].get("commands") or []) or "—"
|
|
676
|
+
tier = "member" if plugins[pid].get("inject_tier") == "member" else "full"
|
|
677
|
+
size = len(str(plugins[pid].get("inject") or "").encode("utf-8"))
|
|
678
|
+
print(f" {pid:<10} {plugins[pid].get('version', '?'):<6} {tier:<6} {size:>5} B deps {deps}; commands {cmds}")
|
|
679
|
+
print(f"{len(plugins)} plugins (.meta/plugins/; injection order = dependency topo + alphabetical; inject budget {INJECT_BUDGET} B)")
|
|
680
|
+
# usage routing table: plugin -> landing commands (own + source-side routes + command pulls)
|
|
681
|
+
import wikilib
|
|
682
|
+
|
|
683
|
+
consumes_map = {}
|
|
684
|
+
for name in sorted(os.listdir(COMMANDS_DIR)):
|
|
685
|
+
path = os.path.join(COMMANDS_DIR, name, "SKILL.md")
|
|
686
|
+
if not os.path.exists(path):
|
|
687
|
+
continue
|
|
688
|
+
c = wikilib.parse_frontmatter(open(path, encoding="utf-8").read()).get("consumes")
|
|
689
|
+
if c is None:
|
|
690
|
+
continue
|
|
691
|
+
consumes_map[name] = c if isinstance(c, list) else _owner_list(c)
|
|
692
|
+
rows = []
|
|
693
|
+
for pid in sorted(plugins):
|
|
694
|
+
if not (plugins[pid].get("usage") or []):
|
|
695
|
+
continue
|
|
696
|
+
targets = list(dict.fromkeys(
|
|
697
|
+
list(plugins[pid].get("commands") or [])
|
|
698
|
+
+ list(plugins[pid].get("usage_routes") or [])
|
|
699
|
+
+ [c for c, lst in consumes_map.items() if pid in lst]))
|
|
700
|
+
rows.append(f" {pid:<12} -> {', '.join(targets) if targets else '(no targets — contract documentation surface)'}")
|
|
701
|
+
if rows:
|
|
702
|
+
print("usage routing (plugin → commands):")
|
|
703
|
+
print("\n".join(rows))
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def parse_pkg(path):
|
|
707
|
+
"""Parse PKG.yaml's minimal shape (scalars / flow lists / block dict-lists / one nested mapping of lists); raises ValueError."""
|
|
708
|
+
data, target, item = {}, None, None
|
|
709
|
+
|
|
710
|
+
def flow_list(s):
|
|
711
|
+
s = s.strip()
|
|
712
|
+
if not (s.startswith("[") and s.endswith("]")):
|
|
713
|
+
raise ValueError(f"expected flow list, got: {s!r}")
|
|
714
|
+
inner = s[1:-1].strip()
|
|
715
|
+
return [strip_quotes(x.strip()) for x in inner.split(",")] if inner else []
|
|
716
|
+
|
|
717
|
+
for raw in open(path, encoding="utf-8").read().splitlines():
|
|
718
|
+
if not raw.strip() or raw.lstrip().startswith("#"):
|
|
719
|
+
continue
|
|
720
|
+
indent = len(raw) - len(raw.lstrip(" "))
|
|
721
|
+
line = raw.strip()
|
|
722
|
+
if indent == 0:
|
|
723
|
+
m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
|
|
724
|
+
if not m:
|
|
725
|
+
raise ValueError(f"unparsable line: {raw!r}")
|
|
726
|
+
key, val = m.group(1), m.group(2)
|
|
727
|
+
if val == "":
|
|
728
|
+
data[key] = None
|
|
729
|
+
elif val.startswith("["):
|
|
730
|
+
data[key] = flow_list(val)
|
|
731
|
+
else:
|
|
732
|
+
data[key] = strip_quotes(val)
|
|
733
|
+
target, item = key, None
|
|
734
|
+
elif indent == 2 and line.startswith("- "):
|
|
735
|
+
if target is None:
|
|
736
|
+
raise ValueError(f"list item before any key: {raw!r}")
|
|
737
|
+
body = line[2:]
|
|
738
|
+
if data.get(target) is None:
|
|
739
|
+
data[target] = []
|
|
740
|
+
if re.match(r"^[A-Za-z_][\w-]*:\s*\S", body) and ":" in body:
|
|
741
|
+
k, v = body.split(":", 1)
|
|
742
|
+
data[target].append({k.strip(): strip_quotes(v.strip())})
|
|
743
|
+
item = data[target][-1]
|
|
744
|
+
else:
|
|
745
|
+
data[target].append(strip_quotes(body))
|
|
746
|
+
item = None
|
|
747
|
+
elif indent == 2:
|
|
748
|
+
m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
|
|
749
|
+
if not m or target is None:
|
|
750
|
+
raise ValueError(f"unparsable line: {raw!r}")
|
|
751
|
+
if data.get(target) is None:
|
|
752
|
+
data[target] = {}
|
|
753
|
+
k, v = m.group(1), m.group(2)
|
|
754
|
+
data[target][k] = flow_list(v) if v.startswith("[") else (strip_quotes(v) if v else "")
|
|
755
|
+
item = None
|
|
756
|
+
elif indent == 4 and item is not None:
|
|
757
|
+
m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
|
|
758
|
+
if not m:
|
|
759
|
+
raise ValueError(f"unparsable line: {raw!r}")
|
|
760
|
+
item[m.group(1)] = strip_quotes(m.group(2))
|
|
761
|
+
else:
|
|
762
|
+
raise ValueError(f"unparsable line: {raw!r}")
|
|
763
|
+
return data
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def load_packages(errors):
|
|
767
|
+
"""Read domains/*/PKG.yaml package declarations (D1); returns {pkg_id: pkg}."""
|
|
768
|
+
pkgs = {}
|
|
769
|
+
domains_dir = os.path.join(PLUGINS_DIR, "domains")
|
|
770
|
+
if not os.path.isdir(domains_dir):
|
|
771
|
+
return pkgs
|
|
772
|
+
for name in sorted(os.listdir(domains_dir)):
|
|
773
|
+
ppath = os.path.join(domains_dir, name, "PKG.yaml")
|
|
774
|
+
if not os.path.exists(ppath):
|
|
775
|
+
continue
|
|
776
|
+
try:
|
|
777
|
+
pkgs[name] = parse_pkg(ppath)
|
|
778
|
+
if pkgs[name].get("id") and pkgs[name]["id"] != name:
|
|
779
|
+
errors.append(f"[error] domains/{name}/PKG.yaml: id ({pkgs[name]['id']}) != directory name")
|
|
780
|
+
except ValueError as e:
|
|
781
|
+
errors.append(f"[error] domains/{name}/PKG.yaml: {e}")
|
|
782
|
+
return pkgs
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def framework_version():
|
|
786
|
+
"""Framework version anchor (.meta/VERSION, 'X.Y' — #20 D2); None when absent."""
|
|
787
|
+
vpath = os.path.join(ROOT, ".meta", "VERSION")
|
|
788
|
+
if not os.path.exists(vpath):
|
|
789
|
+
return None
|
|
790
|
+
m = re.fullmatch(r"\s*(\d+)\.(\d+)\s*", open(vpath, encoding="utf-8").read())
|
|
791
|
+
return (int(m.group(1)), int(m.group(2))) if m else None
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def validate_packages(plugins, pkgs, errors, warnings):
|
|
795
|
+
"""Package invariants (issue #20 定稿 D1/D2/D5): roster == family closure (authoring side),
|
|
796
|
+
full presence (site side, v1 — subsets land with --without later), flat package graph,
|
|
797
|
+
compat vs the .meta/VERSION anchor."""
|
|
798
|
+
seen_member = {}
|
|
799
|
+
fw = framework_version()
|
|
800
|
+
if fw is None:
|
|
801
|
+
warnings.append(".meta/VERSION missing — framework anchor absent, PKG compat unchecked")
|
|
802
|
+
for pid, pkg in sorted(pkgs.items()):
|
|
803
|
+
members = pkg.get("members")
|
|
804
|
+
if not isinstance(members, list) or not members:
|
|
805
|
+
errors.append(f"[error] PKG {pid}: members must be a non-empty list")
|
|
806
|
+
continue
|
|
807
|
+
for mem in members:
|
|
808
|
+
if mem in seen_member:
|
|
809
|
+
errors.append(f"[error] PKG {pid}: member {mem} also declared by PKG {seen_member[mem]} (one plugin one package)")
|
|
810
|
+
seen_member[mem] = pid
|
|
811
|
+
if pid not in members:
|
|
812
|
+
errors.append(f"[error] PKG {pid}: family root {pid} must be a member (D5: roots are never prunable)")
|
|
813
|
+
if all(m in plugins for m in members): # full presence → authoring invariant (roster == closure); subsets exempt (site invariant above covers them)
|
|
814
|
+
closure = {pid} | {p for p in plugins if reaches_plugin(plugins, p, pid)}
|
|
815
|
+
for extra in sorted(set(members) - closure):
|
|
816
|
+
errors.append(f"[error] PKG {pid}: member {extra} declared but outside the family closure (depends chain never reaches {pid})")
|
|
817
|
+
for miss in sorted(closure - set(members)):
|
|
818
|
+
errors.append(f"[error] PKG {pid}: {miss} is in the family closure but missing from members (roster drift)")
|
|
819
|
+
absent = [mem for mem in members if mem not in plugins]
|
|
820
|
+
if pid not in plugins:
|
|
821
|
+
errors.append(f"[error] PKG {pid}: family root {pid} must be installed (D5: roots are never prunable)")
|
|
822
|
+
elif absent:
|
|
823
|
+
closed = all(dep not in members or dep in plugins
|
|
824
|
+
for mem in members if mem in plugins
|
|
825
|
+
for dep in (plugins[mem].get("depends") or []))
|
|
826
|
+
if closed:
|
|
827
|
+
warnings.append(f"PKG {pid}: subset install — pruned {', '.join(absent)} (D5 legal subset)")
|
|
828
|
+
else:
|
|
829
|
+
errors.append(f"[error] PKG {pid}: installed subset not dependency-closed (structural member pruned while dependents remain — see kernel plan <pkg> --without ...)")
|
|
830
|
+
for dep in pkg.get("depends") or []:
|
|
831
|
+
if dep != "framework":
|
|
832
|
+
errors.append(f"[error] PKG {pid}: package dependency {dep} not allowed in v1 (flat graph — framework only)")
|
|
833
|
+
if fw is not None:
|
|
834
|
+
compat = str(pkg.get("compat") or "")
|
|
835
|
+
m = re.fullmatch(r">=\s*(\d+)\.(\d+)", compat)
|
|
836
|
+
if not m:
|
|
837
|
+
warnings.append(f"PKG {pid}: compat ({compat!r}) not in '>= X.Y' form — unchecked (v1 supports only this form)")
|
|
838
|
+
elif (int(m.group(1)), int(m.group(2))) > fw:
|
|
839
|
+
errors.append(f"[error] PKG {pid}: compat {compat} exceeds framework version {'.'.join(map(str, fw))}")
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def do_pkg_ls(pkgs, plugins):
|
|
843
|
+
"""Package inventory: PKG table + connectors + the implicit core remainder."""
|
|
844
|
+
claimed = {m for pkg in pkgs.values() for m in (pkg.get("members") or [])}
|
|
845
|
+
for pid, pkg in sorted(pkgs.items()):
|
|
846
|
+
members = pkg.get("members") or []
|
|
847
|
+
print(f"pkg {pid:<10} v{pkg.get('version','?'):<5} compat {str(pkg.get('compat','?')):<8} {len(members)} members: {', '.join(members)}")
|
|
848
|
+
for c in pkg.get("connectors") or []:
|
|
849
|
+
where = c.get("path") or ("external" if c.get("external") else "system" if c.get("system") else "?")
|
|
850
|
+
print(f"{'':16}connector {c.get('name')} ({where})")
|
|
851
|
+
core = sorted(p for p in plugins if p not in claimed)
|
|
852
|
+
print(f"core (framework): {len(core)}: {', '.join(core)}")
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _residual_hits(pkg_id, pkg):
|
|
856
|
+
"""Anchor-surface reference scan -> hit list (the default teardown gate)."""
|
|
857
|
+
tokens = [pkg_id] + [t for t in (pkg.get("members") or []) if t != pkg_id]
|
|
858
|
+
anchors = [AGENTS_MD,
|
|
859
|
+
os.path.join(ROOT, "wiki", "profile.md"),
|
|
860
|
+
os.path.join(ROOT, "wiki", "language.md"),
|
|
861
|
+
os.path.join(ROOT, "wiki", "calendar.md"),
|
|
862
|
+
os.path.join(ROOT, "wiki", "todo.md")]
|
|
863
|
+
crondir = os.path.join(ROOT, "wiki", "cron")
|
|
864
|
+
if os.path.isdir(crondir):
|
|
865
|
+
anchors += [os.path.join(crondir, f) for f in sorted(os.listdir(crondir)) if f.endswith(".md")]
|
|
866
|
+
hits = []
|
|
867
|
+
for path in anchors:
|
|
868
|
+
if not os.path.exists(path):
|
|
869
|
+
continue
|
|
870
|
+
try:
|
|
871
|
+
lines = open(path, encoding="utf-8").read().splitlines()
|
|
872
|
+
except OSError:
|
|
873
|
+
continue
|
|
874
|
+
inside_inject = False
|
|
875
|
+
for i, line in enumerate(lines, 1):
|
|
876
|
+
if INJECT_START in line:
|
|
877
|
+
inside_inject = True
|
|
878
|
+
if INJECT_END in line:
|
|
879
|
+
inside_inject = False
|
|
880
|
+
continue
|
|
881
|
+
if inside_inject:
|
|
882
|
+
continue # kernel-managed region — cleaned mechanically on uninstall
|
|
883
|
+
for t in tokens:
|
|
884
|
+
if re.search(rf"(?<![\w-]){re.escape(t)}(?![\w-])", line):
|
|
885
|
+
hits.append(f"{os.path.relpath(path, ROOT)}:{i}: references {t}: {line.strip()[:120]}")
|
|
886
|
+
break
|
|
887
|
+
return hits
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def do_residual(pkg_id, pkgs):
|
|
891
|
+
"""D6 residual scan: grep the semantic anchor surfaces for member references after/before uninstall."""
|
|
892
|
+
if not pkg_id:
|
|
893
|
+
print(__doc__)
|
|
894
|
+
return 2
|
|
895
|
+
pkg = pkgs.get(pkg_id)
|
|
896
|
+
if pkg is None:
|
|
897
|
+
print(f"[error] residual: no such package {pkg_id} (see: kernel pkg)")
|
|
898
|
+
return 1
|
|
899
|
+
hits = _residual_hits(pkg_id, pkg)
|
|
900
|
+
for h in hits:
|
|
901
|
+
print(f"[residual] {h}")
|
|
902
|
+
if hits:
|
|
903
|
+
print(f"[residual] {len(hits)} reference(s) on semantic anchor surfaces (handwritten AGENTS region / profile / language / calendar / todo / cron) — semantic cleanup before/after uninstall, per PKG uninstall.bridges")
|
|
904
|
+
else:
|
|
905
|
+
print("[residual] clean — no member references on semantic anchor surfaces")
|
|
906
|
+
return 0
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
# ---------- process round: install / uninstall / list-remote / upgrade (issue #20 流程轮) ----------
|
|
910
|
+
|
|
911
|
+
INDEX_FILENAME = "INDEX.yaml"
|
|
912
|
+
INDEX_REMOTE = "https://raw.githubusercontent.com/owfadev/registry/master/INDEX.yaml"
|
|
913
|
+
FW_VERSION_REMOTE = "https://raw.githubusercontent.com/owfadev/owfa/master/.meta/VERSION"
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def load_index(errors, quiet=False):
|
|
917
|
+
"""Resolve the package index: local ROOT/INDEX.yaml first, then a short-TTL cache, else remote (cached on success; stale cache as fallback)."""
|
|
918
|
+
import urllib.request
|
|
919
|
+
import time
|
|
920
|
+
raw, src = None, None
|
|
921
|
+
local = os.path.join(ROOT, INDEX_FILENAME)
|
|
922
|
+
cache = os.path.join(ROOT, ".meta", "registry-cache.yaml")
|
|
923
|
+
if os.path.exists(local):
|
|
924
|
+
raw, src = open(local, encoding="utf-8").read(), local
|
|
925
|
+
else:
|
|
926
|
+
fetched = False
|
|
927
|
+
if os.path.exists(cache) and time.time() - os.path.getmtime(cache) < 600:
|
|
928
|
+
raw, src = open(cache, encoding="utf-8").read(), cache + " (TTL fresh)"
|
|
929
|
+
fetched = True
|
|
930
|
+
if not fetched:
|
|
931
|
+
try:
|
|
932
|
+
raw = urllib.request.urlopen(INDEX_REMOTE, timeout=15).read().decode("utf-8")
|
|
933
|
+
src = INDEX_REMOTE
|
|
934
|
+
try:
|
|
935
|
+
open(cache, "w", encoding="utf-8").write(raw)
|
|
936
|
+
except OSError:
|
|
937
|
+
pass
|
|
938
|
+
except Exception as e:
|
|
939
|
+
if os.path.exists(cache): # a stale index beats no index
|
|
940
|
+
raw, src = open(cache, encoding="utf-8").read(), cache + " (stale fallback)"
|
|
941
|
+
elif not quiet:
|
|
942
|
+
errors.append(f"[error] index unavailable: {e}")
|
|
943
|
+
return {}, src
|
|
944
|
+
index = {}
|
|
945
|
+
for line in raw.splitlines():
|
|
946
|
+
m = re.match(r"^\s{2}([\w-]+):\s*\{(.+)\}\s*$", line)
|
|
947
|
+
if not m:
|
|
948
|
+
continue
|
|
949
|
+
entry = {kv.group(1): strip_quotes(kv.group(2).strip()) for kv in re.finditer(r"(\w+):\s*([^,}]+)", m.group(2))}
|
|
950
|
+
if entry:
|
|
951
|
+
index[m.group(1)] = entry
|
|
952
|
+
return index, src
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _clone_package(entry, pkg, tag, errors):
|
|
956
|
+
"""git clone the package repo (git is the declared prerequisite); returns the clone dir or None."""
|
|
957
|
+
import subprocess
|
|
958
|
+
import tempfile
|
|
959
|
+
url = entry.get("repo")
|
|
960
|
+
if not url:
|
|
961
|
+
errors.append(f"[error] index entry {pkg} has no repo")
|
|
962
|
+
return None
|
|
963
|
+
tmp = tempfile.mkdtemp(prefix="vw-pkg-")
|
|
964
|
+
ref = tag or (f"{pkg}-v{entry.get('version')}" if entry.get("version") else None)
|
|
965
|
+
cmd = ["git", "clone", "--quiet", "--depth", "1"] + (["--branch", ref] if ref else []) + [url, tmp]
|
|
966
|
+
r = subprocess.run(cmd,
|
|
967
|
+
capture_output=True, text=True)
|
|
968
|
+
if r.returncode != 0:
|
|
969
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
970
|
+
errors.append(f"[error] clone {url}@{ref} failed: {(r.stderr or '').strip()[:200]}")
|
|
971
|
+
return None
|
|
972
|
+
return tmp
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def converge(label):
|
|
976
|
+
"""Post-change convergence: reload, validate, run every mechanical projection."""
|
|
977
|
+
plugins, errors = load_plugins()
|
|
978
|
+
pkgs = load_packages(errors)
|
|
979
|
+
warns = []
|
|
980
|
+
validate(plugins, errors, warnings=warns)
|
|
981
|
+
if not errors:
|
|
982
|
+
validate_bindings(plugins, errors)
|
|
983
|
+
validate_bridges(plugins, errors)
|
|
984
|
+
validate_packages(plugins, pkgs, errors, warns)
|
|
985
|
+
for w in warns:
|
|
986
|
+
print(f"[warning] {w}")
|
|
987
|
+
for e in errors:
|
|
988
|
+
print(e)
|
|
989
|
+
if errors:
|
|
990
|
+
print(f"[{label}] validation failed ({len(errors)} errors) — fix before continuing")
|
|
991
|
+
return False
|
|
992
|
+
print(f"[{label}] validate: {len(plugins)} plugins green")
|
|
993
|
+
do_inject(plugins)
|
|
994
|
+
do_check_inject(plugins)
|
|
995
|
+
do_cmd_inject(plugins)
|
|
996
|
+
do_registry(plugins)
|
|
997
|
+
do_deploy()
|
|
998
|
+
return True
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def do_install(spec, args, prev_version=None):
|
|
1002
|
+
"""Install a package from the index (or --path <dir> for a local/unregistered one): masters + connectors + commands, then converge."""
|
|
1003
|
+
pkg, _, tag = spec.partition("@") if spec else ("", "", "")
|
|
1004
|
+
path = None
|
|
1005
|
+
if "--path" in args:
|
|
1006
|
+
i = args.index("--path")
|
|
1007
|
+
path = args[i + 1] if i + 1 < len(args) else None
|
|
1008
|
+
without = []
|
|
1009
|
+
if "--without" in args:
|
|
1010
|
+
i = args.index("--without")
|
|
1011
|
+
without = [x for x in args[i + 1].split(",") if x] if i + 1 < len(args) else []
|
|
1012
|
+
if not pkg and not path:
|
|
1013
|
+
print(__doc__)
|
|
1014
|
+
return 2
|
|
1015
|
+
direct = pkg.startswith(("https://", "git@")) or ("/" in pkg and not path)
|
|
1016
|
+
# framework repo guard: library posture — the sandbox (test-repo) is the install target
|
|
1017
|
+
if os.path.isdir(os.path.join(ROOT, "test-repo")) and not os.path.exists(AGENTS_MD):
|
|
1018
|
+
print("[error] framework repo is library posture — install into an instance (e.g. cd test-repo)")
|
|
1019
|
+
return 1
|
|
1020
|
+
errors = []
|
|
1021
|
+
if path:
|
|
1022
|
+
src = path
|
|
1023
|
+
if not os.path.exists(os.path.join(src, "PKG.yaml")):
|
|
1024
|
+
print(f"[error] {src}/PKG.yaml not found (not a package directory)")
|
|
1025
|
+
return 1
|
|
1026
|
+
elif direct:
|
|
1027
|
+
# unindexed repository: reachable by convention, trust unknown — the index PR is the review gate (#20 / #12)
|
|
1028
|
+
if "--yes" not in args:
|
|
1029
|
+
print(f"[error] direct install of {pkg!r} (not in the index) requires --yes — trust: unknown; the registry PR is how a package earns reviewed trust")
|
|
1030
|
+
return 1
|
|
1031
|
+
url = pkg if pkg.startswith(("https://", "git@")) else f"https://github.com/{pkg}.git"
|
|
1032
|
+
entry = {"repo": url}
|
|
1033
|
+
src = _clone_package(entry, pkg, tag or None, errors)
|
|
1034
|
+
for e in errors:
|
|
1035
|
+
print(e)
|
|
1036
|
+
if src is None:
|
|
1037
|
+
return 1
|
|
1038
|
+
print(f"[install] trust: unknown (unindexed direct install)")
|
|
1039
|
+
else:
|
|
1040
|
+
index, _src = load_index(errors)
|
|
1041
|
+
if pkg not in index:
|
|
1042
|
+
print(f"[error] package {pkg} not in the index ({_src}) — third-party packages register via an index PR (#20 生态四问)")
|
|
1043
|
+
for e in errors:
|
|
1044
|
+
print(e)
|
|
1045
|
+
return 1
|
|
1046
|
+
entry = index[pkg]
|
|
1047
|
+
if entry.get("trust", "") not in ("framework-bundled", "reviewed-3rd-party") and "--yes" not in args:
|
|
1048
|
+
print(f"[error] package {pkg} trust level {entry.get('trust')!r} is not auto-installable — explicit --yes required (#12 安全面)")
|
|
1049
|
+
return 1
|
|
1050
|
+
src = _clone_package(entry, pkg, tag, errors)
|
|
1051
|
+
for e in errors:
|
|
1052
|
+
print(e)
|
|
1053
|
+
if src is None:
|
|
1054
|
+
return 1
|
|
1055
|
+
try:
|
|
1056
|
+
pkgm = parse_pkg(os.path.join(src, "PKG.yaml"))
|
|
1057
|
+
except ValueError as e:
|
|
1058
|
+
print(f"[error] PKG.yaml: {e}")
|
|
1059
|
+
return 1
|
|
1060
|
+
pid = str(pkgm.get("id") or pkg or os.path.basename(path))
|
|
1061
|
+
if not path and not direct and pid != pkg:
|
|
1062
|
+
print(f"[error] index says {pkg}, PKG.yaml says {pid} (index ↔ package mismatch — refuse)")
|
|
1063
|
+
return 1
|
|
1064
|
+
fw = framework_version()
|
|
1065
|
+
if fw is not None:
|
|
1066
|
+
m = re.fullmatch(r">=\s*(\d+)\.(\d+)", str(pkgm.get("compat") or ""))
|
|
1067
|
+
if m and (int(m.group(1)), int(m.group(2))) > fw:
|
|
1068
|
+
print(f"[error] package compat {pkgm.get('compat')} exceeds framework {'.'.join(map(str, fw))}")
|
|
1069
|
+
return 1
|
|
1070
|
+
if not path and not direct:
|
|
1071
|
+
idx_members = set(str(entry.get("members", "")).split("/")) - {""}
|
|
1072
|
+
pkg_members = set(pkgm.get("members") or [])
|
|
1073
|
+
if idx_members and idx_members != pkg_members:
|
|
1074
|
+
print(f"[error] index members {sorted(idx_members)} != PKG.yaml members {sorted(pkg_members)} (index <-> package drift — refuse)")
|
|
1075
|
+
return 1
|
|
1076
|
+
idx_ver = str(entry.get("version") or "").strip()
|
|
1077
|
+
if idx_ver and idx_ver != str(pkgm.get("version")).strip():
|
|
1078
|
+
print(f"[error] index version v{idx_ver} != PKG.yaml version v{pkgm.get('version')} (release metadata drift — the tag would install but never reconcile with `pkg`/`upgrade`; refuse)")
|
|
1079
|
+
return 1
|
|
1080
|
+
members_all = [str(m) for m in (pkgm.get("members") or [])]
|
|
1081
|
+
pruned = set()
|
|
1082
|
+
if without:
|
|
1083
|
+
bad = [w for w in without if w not in members_all]
|
|
1084
|
+
if bad:
|
|
1085
|
+
print(f"[error] --without {', '.join(bad)}: not members of {pid}")
|
|
1086
|
+
return 1
|
|
1087
|
+
if pid in without:
|
|
1088
|
+
print(f"[error] --without {pid}: the family root is never prunable (D5)")
|
|
1089
|
+
return 1
|
|
1090
|
+
depends_in = {}
|
|
1091
|
+
for m in members_all:
|
|
1092
|
+
try:
|
|
1093
|
+
deps = parse_manifest(os.path.join(src, m, "PLUGIN.yaml")).get("depends") or []
|
|
1094
|
+
except ValueError:
|
|
1095
|
+
deps = []
|
|
1096
|
+
depends_in[m] = [d for d in deps if d in members_all]
|
|
1097
|
+
pruned = set(without)
|
|
1098
|
+
changed = True
|
|
1099
|
+
while changed:
|
|
1100
|
+
changed = False
|
|
1101
|
+
for m in members_all:
|
|
1102
|
+
if m not in pruned and m != pid and set(depends_in[m]) & pruned:
|
|
1103
|
+
pruned.add(m)
|
|
1104
|
+
changed = True
|
|
1105
|
+
if pid in pruned or not [m for m in members_all if m not in pruned]:
|
|
1106
|
+
print(f"[error] --without cascade prunes the whole package (root or all members)")
|
|
1107
|
+
return 1
|
|
1108
|
+
print(f"[install] subset: final members {', '.join(m for m in members_all if m not in pruned)}; pruned {', '.join(sorted(pruned))}")
|
|
1109
|
+
dst = os.path.join(PLUGINS_DIR, "domains", pid)
|
|
1110
|
+
if os.path.exists(dst):
|
|
1111
|
+
print(f"[error] {pid} already installed — uninstall first (kernel uninstall {pid} --yes)")
|
|
1112
|
+
return 1
|
|
1113
|
+
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
1114
|
+
shutil.copytree(src, dst, ignore=shutil.ignore_patterns(".git", ".meta", "connectors", "command", "README.md", "__pycache__", "migrations",
|
|
1115
|
+
*sorted(pruned)))
|
|
1116
|
+
copied_conn, copied_cmd = [], []
|
|
1117
|
+
csrc = os.path.join(src, "connectors")
|
|
1118
|
+
if os.path.isdir(csrc):
|
|
1119
|
+
for d in sorted(os.listdir(csrc)):
|
|
1120
|
+
dd = os.path.join(CONNECTORS_DIR, d)
|
|
1121
|
+
if os.path.exists(dd):
|
|
1122
|
+
print(f"[warning] connector {d} already present — kept existing")
|
|
1123
|
+
continue
|
|
1124
|
+
os.makedirs(CONNECTORS_DIR, exist_ok=True)
|
|
1125
|
+
shutil.copytree(os.path.join(csrc, d), dd, ignore=shutil.ignore_patterns("__pycache__", ".venv", "*.egg-info"))
|
|
1126
|
+
copied_conn.append(d)
|
|
1127
|
+
msrc = os.path.join(src, "command")
|
|
1128
|
+
if os.path.isdir(msrc):
|
|
1129
|
+
import wikilib
|
|
1130
|
+
for d in sorted(os.listdir(msrc)):
|
|
1131
|
+
fm_path = os.path.join(msrc, d, "SKILL.md")
|
|
1132
|
+
if os.path.exists(fm_path):
|
|
1133
|
+
owners = _owner_list(wikilib.parse_frontmatter(open(fm_path, encoding="utf-8").read()).get("owner"))
|
|
1134
|
+
if owners and owners[0] in pruned:
|
|
1135
|
+
continue # command of a pruned member rides out with it
|
|
1136
|
+
dd = os.path.join(COMMANDS_DIR, d)
|
|
1137
|
+
if os.path.exists(dd):
|
|
1138
|
+
print(f"[warning] command {d} already present — kept existing")
|
|
1139
|
+
continue
|
|
1140
|
+
shutil.copytree(os.path.join(msrc, d), dd, ignore=shutil.ignore_patterns("__pycache__"))
|
|
1141
|
+
copied_cmd.append(d)
|
|
1142
|
+
led0 = _load_ledger()
|
|
1143
|
+
if pid in led0 and led0[pid].get("kind") == "teardown":
|
|
1144
|
+
del led0[pid] # reinstall: the teardown premise vanished with the package's return
|
|
1145
|
+
_save_ledger(led0)
|
|
1146
|
+
_banner_set(led0)
|
|
1147
|
+
_journal("sweep", f"{pid} teardown swept by reinstall")
|
|
1148
|
+
print(f"[sweep] {pid}: pending teardown swept (reinstall — premise vanished)")
|
|
1149
|
+
staged = _stage_migrations(os.path.join(src, "migrations"), pid, prev_version, pkgm.get("version"))
|
|
1150
|
+
os.makedirs(os.path.join(MIGRATIONS_DIR, pid), exist_ok=True)
|
|
1151
|
+
try:
|
|
1152
|
+
shutil.copyfile(os.path.join(src, "PKG.yaml"), os.path.join(MIGRATIONS_DIR, pid, "PKG.yaml")) # dormant roster copy — residue-reference for self-healing
|
|
1153
|
+
except OSError:
|
|
1154
|
+
pass
|
|
1155
|
+
tsrc = os.path.join(src, "migrations")
|
|
1156
|
+
if os.path.isdir(tsrc): # dormant teardown declaration rides along silently until an uninstall consumes it
|
|
1157
|
+
for f in ("teardown.md", "teardown.py"):
|
|
1158
|
+
if os.path.exists(os.path.join(tsrc, f)):
|
|
1159
|
+
shutil.copyfile(os.path.join(tsrc, f), os.path.join(MIGRATIONS_DIR, pid, f))
|
|
1160
|
+
if not path:
|
|
1161
|
+
shutil.rmtree(src, ignore_errors=True)
|
|
1162
|
+
print(f"[install] {pid}: masters -> .meta/plugins/domains/{pid}/"
|
|
1163
|
+
+ (f"; connectors: {', '.join(copied_conn)}" if copied_conn else "")
|
|
1164
|
+
+ (f"; commands: {', '.join(copied_cmd)}" if copied_cmd else ""))
|
|
1165
|
+
_journal("install", f"{pid} v{pkgm.get('version')}" + (f" subset(-{','.join(sorted(pruned))})" if pruned else "") + (f" staged={','.join(staged)}" if staged else ""))
|
|
1166
|
+
converge("install")
|
|
1167
|
+
if staged:
|
|
1168
|
+
print(f"[pending] semantic migration staged for {pid}: {', '.join(staged)} — kernel migrate {pid}; validate stays red until cleared")
|
|
1169
|
+
return 0
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def do_uninstall(pkg, args):
|
|
1173
|
+
"""D6 uninstall: mechanical four-clear + convergence; data zones and credentials never touched."""
|
|
1174
|
+
if not pkg:
|
|
1175
|
+
print(__doc__)
|
|
1176
|
+
return 2
|
|
1177
|
+
if "--yes" not in args:
|
|
1178
|
+
print("[error] uninstall needs --yes (removes package masters + projections; data zones stay — #20 D6)")
|
|
1179
|
+
return 1
|
|
1180
|
+
pkg_dir = os.path.join(PLUGINS_DIR, "domains", pkg)
|
|
1181
|
+
if not os.path.exists(pkg_dir):
|
|
1182
|
+
print(f"[error] {pkg} not installed")
|
|
1183
|
+
return 1
|
|
1184
|
+
try:
|
|
1185
|
+
pkgm = parse_pkg(os.path.join(pkg_dir, "PKG.yaml"))
|
|
1186
|
+
except ValueError as e:
|
|
1187
|
+
print(f"[warning] PKG.yaml unreadable ({e}) — proceeding with mechanical removal")
|
|
1188
|
+
pkgm = {}
|
|
1189
|
+
members = set(pkgm.get("members") or [pkg])
|
|
1190
|
+
import wikilib
|
|
1191
|
+
removed_cmd = []
|
|
1192
|
+
if os.path.isdir(COMMANDS_DIR):
|
|
1193
|
+
for d in sorted(os.listdir(COMMANDS_DIR)):
|
|
1194
|
+
fm_path = os.path.join(COMMANDS_DIR, d, "SKILL.md")
|
|
1195
|
+
if not os.path.exists(fm_path):
|
|
1196
|
+
continue
|
|
1197
|
+
owners = _owner_list(wikilib.parse_frontmatter(open(fm_path, encoding="utf-8").read()).get("owner"))
|
|
1198
|
+
if owners and owners[0] in members:
|
|
1199
|
+
shutil.rmtree(os.path.join(COMMANDS_DIR, d))
|
|
1200
|
+
sk = os.path.join(SKILLS_DIR, d)
|
|
1201
|
+
if os.path.isdir(sk):
|
|
1202
|
+
shutil.rmtree(sk)
|
|
1203
|
+
removed_cmd.append(d)
|
|
1204
|
+
removed_conn = []
|
|
1205
|
+
for c in pkgm.get("connectors") or []:
|
|
1206
|
+
name = c.get("name")
|
|
1207
|
+
if not name:
|
|
1208
|
+
continue
|
|
1209
|
+
cd = os.path.join(CONNECTORS_DIR, name)
|
|
1210
|
+
if os.path.isdir(cd):
|
|
1211
|
+
shutil.rmtree(cd)
|
|
1212
|
+
removed_conn.append(name)
|
|
1213
|
+
sk = os.path.join(SKILLS_DIR, name)
|
|
1214
|
+
if os.path.isdir(sk) and not os.path.exists(os.path.join(COMMANDS_DIR, name)):
|
|
1215
|
+
shutil.rmtree(sk)
|
|
1216
|
+
# teardown (x->0): dormant declaration staged before removal — semantic cleanup debt gets its warning
|
|
1217
|
+
tmd = os.path.join(MIGRATIONS_DIR, pkg, "teardown.md")
|
|
1218
|
+
if os.path.exists(tmd):
|
|
1219
|
+
led = _load_ledger()
|
|
1220
|
+
led[pkg] = {"kind": "teardown", "from": str(pkgm.get("version", "?")), "to": "0", "items": ["teardown"]}
|
|
1221
|
+
_save_ledger(led)
|
|
1222
|
+
_banner_set(led)
|
|
1223
|
+
try:
|
|
1224
|
+
shutil.copyfile(os.path.join(pkg_dir, "PKG.yaml"), os.path.join(MIGRATIONS_DIR, pkg, "PKG.yaml"))
|
|
1225
|
+
except OSError:
|
|
1226
|
+
pass
|
|
1227
|
+
_journal("warn+", f"{pkg} teardown staged (x->0)")
|
|
1228
|
+
print(f"[pending] {pkg}: teardown staged (x->0) — semantic deletion commands outstanding; kernel migrate {pkg}")
|
|
1229
|
+
shutil.rmtree(pkg_dir)
|
|
1230
|
+
# migrate-kind state sweeps with the package; teardown-kind survives (it is ABOUT the absence)
|
|
1231
|
+
led = _load_ledger()
|
|
1232
|
+
if pkg in led and led[pkg].get("kind", "migrate") == "migrate":
|
|
1233
|
+
del led[pkg]
|
|
1234
|
+
_save_ledger(led)
|
|
1235
|
+
_banner_set(led)
|
|
1236
|
+
shutil.rmtree(os.path.join(MIGRATIONS_DIR, pkg), ignore_errors=True)
|
|
1237
|
+
_journal("sweep", f"{pkg} migration state cleared by uninstall")
|
|
1238
|
+
print(f"[uninstall] {pkg}: pending migration state swept (ledger/staging/banner)")
|
|
1239
|
+
print(f"[uninstall] {pkg}: masters removed"
|
|
1240
|
+
+ (f"; commands: {', '.join(removed_cmd)}" if removed_cmd else "")
|
|
1241
|
+
+ (f"; connectors: {', '.join(removed_conn)}" if removed_conn else ""))
|
|
1242
|
+
_journal("uninstall", f"{pkg} v{pkgm.get('version', '?')}")
|
|
1243
|
+
converge("uninstall")
|
|
1244
|
+
do_residual(pkg, {pkg: pkgm})
|
|
1245
|
+
un = pkgm.get("uninstall") or {}
|
|
1246
|
+
if un.get("retains"):
|
|
1247
|
+
print(f"[uninstall] retains (never touched): {', '.join(un['retains'])}")
|
|
1248
|
+
if un.get("credentials"):
|
|
1249
|
+
print(f"[uninstall] credentials stay on this machine — remove manually if desired: {', '.join(un['credentials'])}")
|
|
1250
|
+
if un.get("processes"):
|
|
1251
|
+
print(f"[uninstall] stop long-running executors if any: {', '.join(un['processes'])}")
|
|
1252
|
+
for d in (os.path.join(PLUGINS_DIR, "domains"), CONNECTORS_DIR):
|
|
1253
|
+
if os.path.isdir(d) and not os.listdir(d):
|
|
1254
|
+
os.rmdir(d) # empty containers leave no residue
|
|
1255
|
+
return 0
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def do_list_remote():
|
|
1259
|
+
"""Index table with installed marks — GitHub is the marketplace page, this is the agent surface."""
|
|
1260
|
+
errors = []
|
|
1261
|
+
index, src = load_index(errors)
|
|
1262
|
+
installed = load_packages([])
|
|
1263
|
+
print(f"index: {src}")
|
|
1264
|
+
for name, e in sorted(index.items()):
|
|
1265
|
+
mark = "installed" if name in installed else "available"
|
|
1266
|
+
print(f" {name:<10} v{e.get('version', '?'):<5} compat {e.get('compat', '?'):<8} [{mark}] {e.get('summary', '')}")
|
|
1267
|
+
mem = str(e.get("members", "")).replace("/", ", ")
|
|
1268
|
+
print(f" {'':<12}members: {mem if mem else '?'}")
|
|
1269
|
+
print(f" {'':<12}{e.get('repo', '')} trust: {e.get('trust', '?')}")
|
|
1270
|
+
for e in errors:
|
|
1271
|
+
print(e)
|
|
1272
|
+
return 0 if index else 1
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def _upgrade_action(pkg, args):
|
|
1276
|
+
"""Mechanical reinstall + update report + migration chain staging (issue #20 + migration design)."""
|
|
1277
|
+
import contextlib
|
|
1278
|
+
import io
|
|
1279
|
+
pkg_dir = os.path.join(PLUGINS_DIR, "domains", pkg)
|
|
1280
|
+
if not os.path.exists(pkg_dir):
|
|
1281
|
+
print(f"[error] {pkg} not installed")
|
|
1282
|
+
return 1
|
|
1283
|
+
old_pkgm = parse_pkg(os.path.join(pkg_dir, "PKG.yaml"))
|
|
1284
|
+
old_ver = str(old_pkgm.get("version", "0"))
|
|
1285
|
+
old_members = {}
|
|
1286
|
+
for m in old_pkgm.get("members") or []:
|
|
1287
|
+
mp = os.path.join(pkg_dir, str(m), "PLUGIN.yaml")
|
|
1288
|
+
if os.path.exists(mp):
|
|
1289
|
+
try:
|
|
1290
|
+
old_members[str(m)] = str(parse_manifest(mp).get("version", "?"))
|
|
1291
|
+
except ValueError:
|
|
1292
|
+
old_members[str(m)] = "?"
|
|
1293
|
+
path = None
|
|
1294
|
+
if "--path" in args:
|
|
1295
|
+
i = args.index("--path")
|
|
1296
|
+
path = args[i + 1] if i + 1 < len(args) else None
|
|
1297
|
+
errors = []
|
|
1298
|
+
if path:
|
|
1299
|
+
src = path
|
|
1300
|
+
if not os.path.exists(os.path.join(src, "PKG.yaml")):
|
|
1301
|
+
print(f"[error] {src}/PKG.yaml not found")
|
|
1302
|
+
return 1
|
|
1303
|
+
else:
|
|
1304
|
+
index, _src = load_index(errors)
|
|
1305
|
+
if pkg not in index:
|
|
1306
|
+
print(f"[error] {pkg} not in the index — use --path for local/dev upgrades")
|
|
1307
|
+
return 1
|
|
1308
|
+
entry = index[pkg]
|
|
1309
|
+
src = _clone_package(entry, pkg, None, errors)
|
|
1310
|
+
for e in errors:
|
|
1311
|
+
print(e)
|
|
1312
|
+
if src is None:
|
|
1313
|
+
return 1
|
|
1314
|
+
new_pkgm = parse_pkg(os.path.join(src, "PKG.yaml"))
|
|
1315
|
+
new_ver = str(new_pkgm.get("version", old_ver))
|
|
1316
|
+
if new_ver == old_ver:
|
|
1317
|
+
print(f"[upgrade] {pkg}: already at v{old_ver} (index/local agree) — nothing to do")
|
|
1318
|
+
if not path:
|
|
1319
|
+
shutil.rmtree(src, ignore_errors=True)
|
|
1320
|
+
return 0
|
|
1321
|
+
fw = framework_version()
|
|
1322
|
+
if fw is not None:
|
|
1323
|
+
m = re.fullmatch(r">=\s*(\d+)\.(\d+)", str(new_pkgm.get("compat") or ""))
|
|
1324
|
+
if m and (int(m.group(1)), int(m.group(2))) > fw:
|
|
1325
|
+
print(f"[error] upgrade needs framework {'.'.join(map(str, (int(m.group(1)), int(m.group(2)))))} first — run `npx owfa upgrade` (npm channel), or sync the template trees and rerun kernel all")
|
|
1326
|
+
return 1
|
|
1327
|
+
# quiet mechanical reinstall (uninstall -> install-from-src with prev_version)
|
|
1328
|
+
buf = io.StringIO()
|
|
1329
|
+
with contextlib.redirect_stdout(buf):
|
|
1330
|
+
ok1 = do_uninstall(pkg, ["--yes"])
|
|
1331
|
+
ok2 = do_install(pkg + ("" if path else ""), (["--path", path] if path else []), prev_version=old_ver) if ok1 == 0 else 1
|
|
1332
|
+
if ok2 != 0:
|
|
1333
|
+
print(buf.getvalue())
|
|
1334
|
+
print("[error] reinstall failed — state may be partial, rerun kernel validate")
|
|
1335
|
+
return 1
|
|
1336
|
+
# report: member version deltas + changelog excerpts
|
|
1337
|
+
new_members = {}
|
|
1338
|
+
for m in new_pkgm.get("members") or []:
|
|
1339
|
+
mp = os.path.join(PLUGINS_DIR, "domains", pkg, str(m), "PLUGIN.yaml")
|
|
1340
|
+
if os.path.exists(mp):
|
|
1341
|
+
try:
|
|
1342
|
+
new_members[str(m)] = str(parse_manifest(mp).get("version", "?"))
|
|
1343
|
+
except ValueError:
|
|
1344
|
+
new_members[str(m)] = "?"
|
|
1345
|
+
changed = {m: (old_members.get(m, "new"), new_members.get(m, "new")) for m in new_members if old_members.get(m, "new") != new_members.get(m, "new")}
|
|
1346
|
+
print(f"[upgrade] {pkg} v{old_ver} -> v{new_ver} — {len(changed)} member change(s); semantic migrations: {', '.join(_load_ledger().get(pkg, {}).get('items', [])) or 'none'}")
|
|
1347
|
+
for m, (a, b) in sorted(changed.items()):
|
|
1348
|
+
print(f"[upgrade] {m} {a} -> {b}")
|
|
1349
|
+
if a != "new":
|
|
1350
|
+
for line in _changelog_delta(os.path.join(PLUGINS_DIR, "domains", pkg, m, "PLUGIN.md"), a, b)[:4]:
|
|
1351
|
+
print(f"[upgrade] {line}")
|
|
1352
|
+
_journal("upgrade", f"{pkg} {old_ver}->{new_ver} changed={len(changed)}")
|
|
1353
|
+
if _load_ledger().get(pkg):
|
|
1354
|
+
print(f"[pending] {pkg}: semantic migration staged — kernel migrate {pkg}; validate stays red until cleared")
|
|
1355
|
+
return 0
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def do_upgrade(pkg, args=None):
|
|
1359
|
+
"""Reconciliation report (packages vs index, framework vs template remote); `upgrade <pkg> --yes` performs the mechanical reinstall + stages migrations (per #20 逐包不级联)."""
|
|
1360
|
+
args = args or []
|
|
1361
|
+
if pkg and "--yes" in args:
|
|
1362
|
+
return _upgrade_action(pkg, args)
|
|
1363
|
+
errors = []
|
|
1364
|
+
index, src = load_index(errors)
|
|
1365
|
+
installed = load_packages([])
|
|
1366
|
+
fw = framework_version()
|
|
1367
|
+
fw_note = ""
|
|
1368
|
+
try:
|
|
1369
|
+
import urllib.request
|
|
1370
|
+
rfw = urllib.request.urlopen(FW_VERSION_REMOTE, timeout=10).read().decode("utf-8").strip()
|
|
1371
|
+
m = re.fullmatch(r"(\d+)\.(\d+)", rfw)
|
|
1372
|
+
if m:
|
|
1373
|
+
rt = (int(m.group(1)), int(m.group(2)))
|
|
1374
|
+
if fw and rt > fw:
|
|
1375
|
+
fw_note = f" — remote v{rfw} available (run `npx owfa upgrade`, or manual sync: template diff → selective copy → kernel all, see CONTRIBUTING)"
|
|
1376
|
+
elif fw:
|
|
1377
|
+
fw_note = " — up to date with the template"
|
|
1378
|
+
except Exception:
|
|
1379
|
+
fw_note = " — remote check unavailable"
|
|
1380
|
+
print(f"framework {'.'.join(map(str, fw)) if fw else '?'}{fw_note} — index: {src}")
|
|
1381
|
+
rows = 0
|
|
1382
|
+
for name, local in sorted(installed.items()):
|
|
1383
|
+
lv = str(local.get("version", "?"))
|
|
1384
|
+
e = index.get(name)
|
|
1385
|
+
if not e:
|
|
1386
|
+
print(f" {name:<10} local v{lv} — not in the index (instance-local package, channel C)")
|
|
1387
|
+
else:
|
|
1388
|
+
rv = e.get("version", "?")
|
|
1389
|
+
state = "up to date" if lv == rv else f"v{lv} -> v{rv}: kernel upgrade {name} --yes"
|
|
1390
|
+
print(f" {name:<10} local v{lv} remote v{rv} — {state}")
|
|
1391
|
+
rows += 1
|
|
1392
|
+
if pkg and pkg not in installed:
|
|
1393
|
+
print(f" {pkg:<10} not installed")
|
|
1394
|
+
if not rows:
|
|
1395
|
+
print(" (no packages installed)")
|
|
1396
|
+
for e in errors:
|
|
1397
|
+
print(e)
|
|
1398
|
+
return 0
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
def _vt(x):
|
|
1402
|
+
return tuple(int(i) for i in str(x).split("."))
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def _journal(op, detail):
|
|
1406
|
+
import datetime
|
|
1407
|
+
line = f"{datetime.datetime.now().isoformat(timespec='seconds')} {op} {detail}"
|
|
1408
|
+
try:
|
|
1409
|
+
old = open(JOURNAL, encoding="utf-8").read().splitlines() if os.path.exists(JOURNAL) else []
|
|
1410
|
+
except OSError:
|
|
1411
|
+
old = []
|
|
1412
|
+
old.append(line)
|
|
1413
|
+
open(JOURNAL, "w", encoding="utf-8").write("\n".join(old[-500:]) + "\n")
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def _load_ledger():
|
|
1417
|
+
import json as _json
|
|
1418
|
+
if not os.path.exists(LEDGER):
|
|
1419
|
+
return {}
|
|
1420
|
+
try:
|
|
1421
|
+
return _json.load(open(LEDGER, encoding="utf-8"))
|
|
1422
|
+
except (OSError, ValueError):
|
|
1423
|
+
return {}
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
def _save_ledger(led):
|
|
1427
|
+
import json as _json
|
|
1428
|
+
os.makedirs(MIGRATIONS_DIR, exist_ok=True)
|
|
1429
|
+
_json.dump(led, open(LEDGER, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
def _banner_set(entries):
|
|
1433
|
+
"""Tri-face visibility, agent side: a kernel-managed block inside the AGENTS injection region — every session sees it until cleared."""
|
|
1434
|
+
if not os.path.exists(AGENTS_MD):
|
|
1435
|
+
return
|
|
1436
|
+
text = open(AGENTS_MD, encoding="utf-8").read()
|
|
1437
|
+
text = re.sub(r"<!-- pending-migrations -->.*?<!-- /pending-migrations -->\n*", "", text, flags=re.S)
|
|
1438
|
+
if entries:
|
|
1439
|
+
lines = "; ".join(f"{k} {v.get('from', '?')}->{v.get('to', '?')}" for k, v in sorted(entries.items()))
|
|
1440
|
+
block = (f"<!-- pending-migrations -->\n> ⚠ PENDING SEMANTIC MIGRATIONS: {lines} — "
|
|
1441
|
+
f"territory may be mid-shape; complete via `kernel migrate <pkg> --clear`; validate stays red until cleared.\n<!-- /pending-migrations -->\n\n")
|
|
1442
|
+
m = re.search(re.escape(INJECT_START) + r"\n", text)
|
|
1443
|
+
if m:
|
|
1444
|
+
text = text[:m.end()] + "\n" + block + text[m.end():]
|
|
1445
|
+
open(AGENTS_MD, "w", encoding="utf-8").write(text)
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def _stage_migrations(migsrc, pid, prev_version, new_version):
|
|
1449
|
+
"""Stage declarations into the kernel zone + register the ledger + AGENTS banner. Package dirs never carry migrations (use-and-delete)."""
|
|
1450
|
+
if not os.path.isdir(migsrc):
|
|
1451
|
+
return []
|
|
1452
|
+
os.makedirs(os.path.join(MIGRATIONS_DIR, pid), exist_ok=True)
|
|
1453
|
+
staged = []
|
|
1454
|
+
if prev_version is None:
|
|
1455
|
+
cands = [("init", "init.md")] if os.path.exists(os.path.join(migsrc, "init.md")) else []
|
|
1456
|
+
else:
|
|
1457
|
+
cands = []
|
|
1458
|
+
for f in sorted(os.listdir(migsrc)):
|
|
1459
|
+
m = re.fullmatch(r"(\d+\.\d+)-to-(\d+\.\d+)\.md", f)
|
|
1460
|
+
if m and _vt(m.group(1)) >= _vt(prev_version):
|
|
1461
|
+
cands.append((f[:-3], f))
|
|
1462
|
+
for label, f in cands:
|
|
1463
|
+
shutil.copyfile(os.path.join(migsrc, f), os.path.join(MIGRATIONS_DIR, pid, f))
|
|
1464
|
+
pyp = os.path.join(migsrc, f[:-3] + ".py")
|
|
1465
|
+
if os.path.exists(pyp):
|
|
1466
|
+
shutil.copyfile(pyp, os.path.join(MIGRATIONS_DIR, pid, f[:-3] + ".py"))
|
|
1467
|
+
staged.append(label)
|
|
1468
|
+
if staged:
|
|
1469
|
+
led = _load_ledger()
|
|
1470
|
+
pkgd = os.path.join(PLUGINS_DIR, "domains", pid)
|
|
1471
|
+
present = [str(m) for m in ((parse_pkg(os.path.join(pkgd, "PKG.yaml")).get("members")) if os.path.exists(os.path.join(pkgd, "PKG.yaml")) else []) if os.path.isdir(os.path.join(pkgd, str(m)))]
|
|
1472
|
+
led[pid] = {"from": str(prev_version or "0"), "to": str(new_version), "items": staged, "members": present}
|
|
1473
|
+
_save_ledger(led)
|
|
1474
|
+
_banner_set(led)
|
|
1475
|
+
_journal("warn+", f"{pid} {prev_version or '0'}->{new_version} items={','.join(staged)}")
|
|
1476
|
+
return staged
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
def _ast_gate(path):
|
|
1480
|
+
"""Verify-script gate: stdlib-adjacent, read-only, defines verify(ctx). Catches casual abuse; malice is the registry review's job."""
|
|
1481
|
+
try:
|
|
1482
|
+
tree = ast.parse(open(path, encoding="utf-8").read())
|
|
1483
|
+
except SyntaxError as e:
|
|
1484
|
+
return [f"syntax error: {e}"]
|
|
1485
|
+
issues = []
|
|
1486
|
+
banned = {"subprocess", "shutil", "socket", "urllib", "urllib.request", "requests", "http", "ftplib", "telnetlib", "pickle"}
|
|
1487
|
+
for n in ast.walk(tree):
|
|
1488
|
+
if isinstance(n, ast.Import):
|
|
1489
|
+
for a in n.names:
|
|
1490
|
+
if a.name.split(".")[0] in banned:
|
|
1491
|
+
issues.append(f"banned import: {a.name}")
|
|
1492
|
+
elif isinstance(n, ast.ImportFrom):
|
|
1493
|
+
if (n.module or "").split(".")[0] in banned:
|
|
1494
|
+
issues.append(f"banned import: {n.module}")
|
|
1495
|
+
elif isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == "open":
|
|
1496
|
+
mode = n.args[1].value if len(n.args) > 1 and isinstance(n.args[1], ast.Constant) else "r"
|
|
1497
|
+
if any(c in str(mode) for c in "wa+"):
|
|
1498
|
+
issues.append(f"open(..., {mode!r}) — verify is read-only")
|
|
1499
|
+
if not any(isinstance(n, ast.FunctionDef) and n.name == "verify" for n in tree.body):
|
|
1500
|
+
issues.append("verify(ctx) not defined")
|
|
1501
|
+
return issues
|
|
1502
|
+
|
|
1503
|
+
|
|
1504
|
+
def _run_verify(py_path):
|
|
1505
|
+
"""Execute a staged verify(ctx) in-process; returns (issues, gate_problems)."""
|
|
1506
|
+
sys.dont_write_bytecode = True # staged code is use-and-delete; leave no __pycache__ behind
|
|
1507
|
+
gate = _ast_gate(py_path)
|
|
1508
|
+
if gate:
|
|
1509
|
+
return None, gate
|
|
1510
|
+
import wikilib
|
|
1511
|
+
spec = importlib.util.spec_from_file_location("vw_verify", py_path)
|
|
1512
|
+
mod = importlib.util.module_from_spec(spec)
|
|
1513
|
+
spec.loader.exec_module(mod)
|
|
1514
|
+
pkg = os.path.basename(os.path.dirname(os.path.abspath(py_path)))
|
|
1515
|
+
pkg_dir = os.path.join(PLUGINS_DIR, "domains", pkg)
|
|
1516
|
+
installed = []
|
|
1517
|
+
if os.path.isdir(pkg_dir):
|
|
1518
|
+
try:
|
|
1519
|
+
pm = parse_pkg(os.path.join(pkg_dir, "PKG.yaml"))
|
|
1520
|
+
installed = [str(m) for m in (pm.get("members") or []) if os.path.isdir(os.path.join(pkg_dir, str(m)))]
|
|
1521
|
+
except ValueError:
|
|
1522
|
+
installed = []
|
|
1523
|
+
ctx = types.SimpleNamespace(root=ROOT, pages=list(wikilib.walk_pages(ROOT)), installed=installed) # subset matching: verifies address the installed subset, never the full roster
|
|
1524
|
+
try:
|
|
1525
|
+
return mod.verify(ctx) or [], []
|
|
1526
|
+
except Exception as e:
|
|
1527
|
+
return None, [f"verify() raised: {e}"]
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
def _changelog_delta(md_path, old_v, new_v):
|
|
1531
|
+
try:
|
|
1532
|
+
sec = open(md_path, encoding="utf-8").read().split("## Changelog", 1)[1]
|
|
1533
|
+
except (OSError, IndexError):
|
|
1534
|
+
return []
|
|
1535
|
+
out = []
|
|
1536
|
+
for line in sec.splitlines():
|
|
1537
|
+
m = re.match(r"-\s+(\d+(?:\.\d+)?)\s*[\s(]", line.strip())
|
|
1538
|
+
if m and _vt(old_v) < _vt(m.group(1)) <= _vt(new_v):
|
|
1539
|
+
out.append(line.strip()[:160])
|
|
1540
|
+
return out
|
|
1541
|
+
|
|
1542
|
+
|
|
1543
|
+
def do_migrate(pkg, args):
|
|
1544
|
+
"""Migration status / declaration printing / gated clear. The exit is always open: migrate works while warned."""
|
|
1545
|
+
led = _load_ledger()
|
|
1546
|
+
if not pkg:
|
|
1547
|
+
if not led:
|
|
1548
|
+
print("[migrate] no pending semantic migrations")
|
|
1549
|
+
return 0
|
|
1550
|
+
for k, v in sorted(led.items()):
|
|
1551
|
+
print(f"[migrate] {k} {v.get('from')}->{v.get('to')} pending: {', '.join(v.get('items', []))}")
|
|
1552
|
+
return 0
|
|
1553
|
+
ent = led.get(pkg)
|
|
1554
|
+
if ent is None:
|
|
1555
|
+
print(f"[migrate] {pkg}: nothing pending")
|
|
1556
|
+
return 0
|
|
1557
|
+
pdir = os.path.join(MIGRATIONS_DIR, pkg)
|
|
1558
|
+
if "--clear" not in args:
|
|
1559
|
+
print(f"[migrate] {pkg} {ent.get('from')}->{ent.get('to')} pending: {', '.join(ent.get('items', []))}")
|
|
1560
|
+
for f in sorted(os.listdir(pdir)) if os.path.isdir(pdir) else []:
|
|
1561
|
+
if f.endswith(".md"):
|
|
1562
|
+
print(f"[migrate] --- {f} ---")
|
|
1563
|
+
print(open(os.path.join(pdir, f), encoding="utf-8").read().rstrip())
|
|
1564
|
+
pyf = os.path.join(pdir, f[:-3] + ".py")
|
|
1565
|
+
if os.path.exists(pyf):
|
|
1566
|
+
issues, gate = _run_verify(pyf)
|
|
1567
|
+
if gate:
|
|
1568
|
+
print(f"[migrate] verify gate: {gate}")
|
|
1569
|
+
elif issues:
|
|
1570
|
+
print(f"[migrate] verify: {len(issues)} issue(s): " + " | ".join(str(i)[:100] for i in issues[:5]))
|
|
1571
|
+
else:
|
|
1572
|
+
print("[migrate] verify: clean — ready to clear (--clear)")
|
|
1573
|
+
elif ent.get("kind") == "teardown":
|
|
1574
|
+
pk = os.path.join(pdir, "PKG.yaml") # default gate: show what the residual scan still sees
|
|
1575
|
+
pkgm2 = parse_pkg(pk) if os.path.exists(pk) else {"members": [pkg]}
|
|
1576
|
+
for h in _residual_hits(pkg, pkgm2)[:8]:
|
|
1577
|
+
print(f"[migrate] residual: {h}")
|
|
1578
|
+
if not _residual_hits(pkg, pkgm2):
|
|
1579
|
+
print("[migrate] residual gate: clean — ready to clear (--clear)")
|
|
1580
|
+
return 0
|
|
1581
|
+
# --clear: verify-gated (explicit assertion only via --force when no script)
|
|
1582
|
+
cleared_all = True
|
|
1583
|
+
for f in sorted(os.listdir(pdir)) if os.path.isdir(pdir) else []:
|
|
1584
|
+
if not f.endswith(".py"):
|
|
1585
|
+
continue
|
|
1586
|
+
issues, gate = _run_verify(os.path.join(pdir, f))
|
|
1587
|
+
if gate:
|
|
1588
|
+
print(f"[error] verify gate refused {f}: {gate}")
|
|
1589
|
+
return 1
|
|
1590
|
+
errs = [i for i in issues if not (isinstance(i, (tuple, list)) and i[0] == "warning")]
|
|
1591
|
+
if errs:
|
|
1592
|
+
print(f"[error] {f}: migration incomplete — {len(errs)} issue(s): " + " | ".join(str(i)[:100] for i in errs[:5]))
|
|
1593
|
+
return 1
|
|
1594
|
+
if issues:
|
|
1595
|
+
print(f"[warning] {f}: warnings (non-blocking): " + " | ".join(str(i)[:100] for i in issues[:5]))
|
|
1596
|
+
has_py = any(f.endswith(".py") for f in os.listdir(pdir)) if os.path.isdir(pdir) else False
|
|
1597
|
+
if not has_py:
|
|
1598
|
+
if ent.get("kind") == "teardown":
|
|
1599
|
+
pk = os.path.join(pdir, "PKG.yaml")
|
|
1600
|
+
pkgm2 = parse_pkg(pk) if os.path.exists(pk) else {"members": [pkg]}
|
|
1601
|
+
hits = _residual_hits(pkg, pkgm2)
|
|
1602
|
+
if hits:
|
|
1603
|
+
print(f"[error] teardown incomplete — residual references on anchor surfaces: {hits[:5]}")
|
|
1604
|
+
return 1
|
|
1605
|
+
elif "--force" not in args:
|
|
1606
|
+
print(f"[error] no verify script staged for {pkg} — clear needs --force (explicit assertion) or author-provided verify")
|
|
1607
|
+
return 1
|
|
1608
|
+
if ent.get("kind") == "teardown":
|
|
1609
|
+
shutil.rmtree(pdir, ignore_errors=True)
|
|
1610
|
+
else:
|
|
1611
|
+
for item in ent.get("items", []): # delete the pending items only — dormant teardown files survive for a future uninstall
|
|
1612
|
+
for ext in (".md", ".py"):
|
|
1613
|
+
try:
|
|
1614
|
+
os.remove(os.path.join(pdir, item + ext))
|
|
1615
|
+
except OSError:
|
|
1616
|
+
pass
|
|
1617
|
+
del led[pkg]
|
|
1618
|
+
_save_ledger(led)
|
|
1619
|
+
_banner_set(led)
|
|
1620
|
+
_journal("clear", f"{pkg} {ent.get('from')}->{ent.get('to')} verify-clean")
|
|
1621
|
+
print(f"[migrate] {pkg}: warning cleared, staged files deleted (use-and-delete); validate unblessed — permanent invariants belong in scripts/check.py (judgment split rule)")
|
|
1622
|
+
return 0
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
def do_plan(spec, args):
|
|
1626
|
+
"""One-shot disclosure plan (consent-wizard data source): trust, package disclosure, baseline/optional members, connectors, suggested command. Never interactive."""
|
|
1627
|
+
import json as _json
|
|
1628
|
+
pkg, _, tag = spec.partition("@") if spec else ("", "", "")
|
|
1629
|
+
if not pkg:
|
|
1630
|
+
print(__doc__)
|
|
1631
|
+
return 2
|
|
1632
|
+
errors = []
|
|
1633
|
+
index, _src = load_index(errors, quiet=True)
|
|
1634
|
+
entry = index.get(pkg)
|
|
1635
|
+
direct = entry is None
|
|
1636
|
+
if direct:
|
|
1637
|
+
if not ("/" in pkg or pkg.startswith(("https://", "git@"))):
|
|
1638
|
+
print(f"[error] package {pkg} not in the index (registry)")
|
|
1639
|
+
for e in errors:
|
|
1640
|
+
print(e)
|
|
1641
|
+
return 1
|
|
1642
|
+
url = pkg if pkg.startswith(("https://", "git@")) else f"https://github.com/{pkg}.git"
|
|
1643
|
+
entry = {"repo": url, "trust": "unknown (unindexed direct)", "version": ""}
|
|
1644
|
+
src = _clone_package(entry, pkg, tag or None, errors)
|
|
1645
|
+
for e in errors:
|
|
1646
|
+
print(e)
|
|
1647
|
+
if src is None:
|
|
1648
|
+
return 1
|
|
1649
|
+
try:
|
|
1650
|
+
pkgm = parse_pkg(os.path.join(src, "PKG.yaml"))
|
|
1651
|
+
except ValueError as e:
|
|
1652
|
+
print(f"[error] PKG.yaml: {e}")
|
|
1653
|
+
shutil.rmtree(src, ignore_errors=True)
|
|
1654
|
+
return 1
|
|
1655
|
+
pid = str(pkgm.get("id") or pkg)
|
|
1656
|
+
members = [str(m) for m in (pkgm.get("members") or [])]
|
|
1657
|
+
menu = pkgm.get("menu") or {}
|
|
1658
|
+
depends_in = {}
|
|
1659
|
+
for m in members:
|
|
1660
|
+
try:
|
|
1661
|
+
deps = parse_manifest(os.path.join(src, m, "PLUGIN.yaml")).get("depends") or []
|
|
1662
|
+
except ValueError:
|
|
1663
|
+
deps = []
|
|
1664
|
+
depends_in[m] = [d for d in deps if d in members]
|
|
1665
|
+
|
|
1666
|
+
def closure_of(m):
|
|
1667
|
+
out, stack = set(), [m]
|
|
1668
|
+
while stack:
|
|
1669
|
+
x = stack.pop()
|
|
1670
|
+
for d in depends_in.get(x, []):
|
|
1671
|
+
if d not in out:
|
|
1672
|
+
out.add(d)
|
|
1673
|
+
stack.append(d)
|
|
1674
|
+
return out
|
|
1675
|
+
|
|
1676
|
+
optional = [m for m in members if m != pid]
|
|
1677
|
+
plan = {
|
|
1678
|
+
"package": pid,
|
|
1679
|
+
"version": pkgm.get("version"),
|
|
1680
|
+
"compat": pkgm.get("compat"),
|
|
1681
|
+
"trust": entry.get("trust", "?"),
|
|
1682
|
+
"disclosure": pkgm.get("disclosure") or "(no package disclosure declared)",
|
|
1683
|
+
"baseline": [{"id": pid, "summary": menu.get(pid, "")}],
|
|
1684
|
+
"optional": [{"id": m, "summary": menu.get(m, ""), "pulls": sorted(closure_of(m) - {pid})} for m in optional],
|
|
1685
|
+
"connectors": [{"name": c.get("name"), "kind": ("external" if c.get("external") else "system" if c.get("system") else "in-repo")}
|
|
1686
|
+
for c in (pkgm.get("connectors") or [])],
|
|
1687
|
+
"suggested_command": f"kernel install {pid}" + (" --yes" if direct else ""),
|
|
1688
|
+
}
|
|
1689
|
+
# subset preview when --without given
|
|
1690
|
+
if "--without" in args:
|
|
1691
|
+
i = args.index("--without")
|
|
1692
|
+
wl = [x for x in args[i + 1].split(",") if x] if i + 1 < len(args) else []
|
|
1693
|
+
pruned = set(wl)
|
|
1694
|
+
changed = True
|
|
1695
|
+
while changed:
|
|
1696
|
+
changed = False
|
|
1697
|
+
for m in members:
|
|
1698
|
+
if m not in pruned and m != pid and set(depends_in[m]) & pruned:
|
|
1699
|
+
pruned.add(m)
|
|
1700
|
+
changed = True
|
|
1701
|
+
plan["subset_preview"] = {"final": [m for m in members if m not in pruned], "pruned": sorted(pruned)}
|
|
1702
|
+
plan["suggested_command"] += f" --without {','.join(wl)}"
|
|
1703
|
+
shutil.rmtree(src, ignore_errors=True)
|
|
1704
|
+
if "--json" in args:
|
|
1705
|
+
print(_json.dumps(plan, ensure_ascii=False, indent=2))
|
|
1706
|
+
return 0
|
|
1707
|
+
print(f"[plan] {pid} v{plan['version']} (compat {plan['compat']}) — trust: {plan['trust']}")
|
|
1708
|
+
print(f"[plan] disclosure: {plan['disclosure']}")
|
|
1709
|
+
print(f"[plan] baseline (always installed): {pid}" + (f" — {menu.get(pid, '')}" if menu.get(pid) else ""))
|
|
1710
|
+
for o in plan["optional"]:
|
|
1711
|
+
pulls = f" — pulls: {', '.join(o['pulls'])}" if o["pulls"] else ""
|
|
1712
|
+
print(f"[plan] optional: {o['id']}" + (f" — {o['summary']}" if o["summary"] else "") + pulls)
|
|
1713
|
+
if plan["connectors"]:
|
|
1714
|
+
print("[plan] connectors (ride the package): " + ", ".join(f"{c['name']} ({c['kind']})" for c in plan["connectors"]))
|
|
1715
|
+
if "subset_preview" in plan:
|
|
1716
|
+
sp = plan["subset_preview"]
|
|
1717
|
+
print(f"[plan] subset preview: final {', '.join(sp['final'])}; pruned {', '.join(sp['pruned'])}")
|
|
1718
|
+
print(f"[plan] suggested: {plan['suggested_command']}")
|
|
1719
|
+
return 0
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
def main():
|
|
1723
|
+
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
1724
|
+
if cmd == "plan":
|
|
1725
|
+
return do_plan(sys.argv[2] if len(sys.argv) > 2 else "", sys.argv[3:])
|
|
1726
|
+
if cmd == "install":
|
|
1727
|
+
return do_install(sys.argv[2] if len(sys.argv) > 2 else "", sys.argv[3:])
|
|
1728
|
+
if cmd == "uninstall":
|
|
1729
|
+
return do_uninstall(sys.argv[2] if len(sys.argv) > 2 else None, sys.argv[3:])
|
|
1730
|
+
if cmd == "list-remote":
|
|
1731
|
+
return do_list_remote()
|
|
1732
|
+
if cmd == "migrate":
|
|
1733
|
+
return do_migrate(sys.argv[2] if len(sys.argv) > 2 else None, sys.argv[3:])
|
|
1734
|
+
if cmd == "upgrade":
|
|
1735
|
+
return do_upgrade(sys.argv[2] if len(sys.argv) > 2 else None, sys.argv[3:])
|
|
1736
|
+
_led = _load_ledger()
|
|
1737
|
+
_orphans = []
|
|
1738
|
+
if os.path.isdir(MIGRATIONS_DIR):
|
|
1739
|
+
for d in sorted(os.listdir(MIGRATIONS_DIR)):
|
|
1740
|
+
if d == "pending.json" or not os.path.isdir(os.path.join(MIGRATIONS_DIR, d)):
|
|
1741
|
+
continue
|
|
1742
|
+
if not os.path.exists(os.path.join(PLUGINS_DIR, "domains", d)) and _led.get(d, {}).get("kind") != "teardown":
|
|
1743
|
+
_orphans.append(d) # staged dormant PKG marks presence; absent package + no teardown = orphan residue
|
|
1744
|
+
for k in _orphans: # self-healing: migrate-kind entries die with the package; residue gets mechanically swept too
|
|
1745
|
+
_led.pop(k, None)
|
|
1746
|
+
_pkg_dir = os.path.join(MIGRATIONS_DIR, k)
|
|
1747
|
+
try:
|
|
1748
|
+
_pm = parse_pkg(os.path.join(_pkg_dir, "PKG.yaml")) if os.path.isdir(_pkg_dir) else None
|
|
1749
|
+
except ValueError:
|
|
1750
|
+
_pm = None
|
|
1751
|
+
if _pm:
|
|
1752
|
+
import wikilib as _wl
|
|
1753
|
+
_members = set(str(m) for m in (_pm.get("members") or []))
|
|
1754
|
+
if os.path.isdir(COMMANDS_DIR):
|
|
1755
|
+
for d in sorted(os.listdir(COMMANDS_DIR)):
|
|
1756
|
+
_fm = os.path.join(COMMANDS_DIR, d, "SKILL.md")
|
|
1757
|
+
if os.path.exists(_fm):
|
|
1758
|
+
_own = _owner_list(_wl.parse_frontmatter(open(_fm, encoding="utf-8").read()).get("owner"))
|
|
1759
|
+
if _own and _own[0] in _members:
|
|
1760
|
+
shutil.rmtree(os.path.join(COMMANDS_DIR, d))
|
|
1761
|
+
_sk = os.path.join(SKILLS_DIR, d)
|
|
1762
|
+
os.path.isdir(_sk) and shutil.rmtree(_sk)
|
|
1763
|
+
for c in _pm.get("connectors") or []:
|
|
1764
|
+
_cd = os.path.join(CONNECTORS_DIR, str(c.get("name")))
|
|
1765
|
+
if os.path.isdir(_cd):
|
|
1766
|
+
shutil.rmtree(_cd)
|
|
1767
|
+
_sk = os.path.join(SKILLS_DIR, str(c.get("name")))
|
|
1768
|
+
if os.path.isdir(_sk):
|
|
1769
|
+
shutil.rmtree(_sk)
|
|
1770
|
+
shutil.rmtree(_pkg_dir, ignore_errors=True)
|
|
1771
|
+
_journal("sweep", f"{k} orphaned package state auto-swept (package absent, residue cleaned)")
|
|
1772
|
+
if _orphans:
|
|
1773
|
+
_save_ledger(_led)
|
|
1774
|
+
_banner_set(_led)
|
|
1775
|
+
print(f"[sweep] orphaned migration state removed: {', '.join(_orphans)} (package no longer installed)")
|
|
1776
|
+
if _led and cmd:
|
|
1777
|
+
_ps = "; ".join(f"{k} {v.get('from', '?')}->{v.get('to', '?')}" for k, v in sorted(_led.items()))
|
|
1778
|
+
print(f"[pending] SEMANTIC MIGRATIONS OUTSTANDING: {_ps} — kernel migrate <pkg>; validate stays red until cleared")
|
|
1779
|
+
plugins, errors = load_plugins()
|
|
1780
|
+
pkgs = load_packages(errors)
|
|
1781
|
+
if cmd == "pkg":
|
|
1782
|
+
if errors:
|
|
1783
|
+
for e in errors:
|
|
1784
|
+
print(e)
|
|
1785
|
+
return 1
|
|
1786
|
+
do_pkg_ls(pkgs, plugins)
|
|
1787
|
+
return 0
|
|
1788
|
+
if cmd == "residual":
|
|
1789
|
+
return do_residual(sys.argv[2] if len(sys.argv) > 2 else None, pkgs)
|
|
1790
|
+
if cmd == "ls":
|
|
1791
|
+
do_ls(plugins)
|
|
1792
|
+
if errors:
|
|
1793
|
+
print(f"[warning] {len(errors)} manifest load errors — run validate for details")
|
|
1794
|
+
return 0
|
|
1795
|
+
if cmd == "audit":
|
|
1796
|
+
for e in errors:
|
|
1797
|
+
print(e)
|
|
1798
|
+
return do_audit(plugins, sys.argv[2] if len(sys.argv) > 2 else None)
|
|
1799
|
+
if cmd not in ("validate", "inject", "registry", "deploy", "all"):
|
|
1800
|
+
print(__doc__)
|
|
1801
|
+
return 2
|
|
1802
|
+
scope = None
|
|
1803
|
+
if len(sys.argv) > 3 and sys.argv[2] == "--scope":
|
|
1804
|
+
scope = sys.argv[3]
|
|
1805
|
+
if scope not in pkgs:
|
|
1806
|
+
print(f"[error] --scope {scope}: no such package (see: kernel pkg)")
|
|
1807
|
+
return 1
|
|
1808
|
+
members = set(pkgs[scope].get("members") or [])
|
|
1809
|
+
plugins = {p: m for p, m in plugins.items() if p in members}
|
|
1810
|
+
warns = []
|
|
1811
|
+
validate(plugins, errors, scope=scope, warnings=warns)
|
|
1812
|
+
if scope:
|
|
1813
|
+
print(f"[scope] bindings/bridges/package checks skipped — single-package mode over {scope} (full-graph disciplines run where the full graph lives)")
|
|
1814
|
+
else:
|
|
1815
|
+
validate_bindings(plugins, errors)
|
|
1816
|
+
validate_bridges(plugins, errors)
|
|
1817
|
+
validate_packages(plugins, pkgs, errors, warns)
|
|
1818
|
+
for k, v in sorted(_led.items()):
|
|
1819
|
+
errors.append(f"[error] pending semantic migration {k} {v.get('from', '?')}->{v.get('to', '?')} — kernel migrate {k} --clear; mechanical blessing withheld (tri-face warning)")
|
|
1820
|
+
for w in warns:
|
|
1821
|
+
print(f"[warning] {w}")
|
|
1822
|
+
for e in errors:
|
|
1823
|
+
print(e)
|
|
1824
|
+
if errors:
|
|
1825
|
+
print(f"[result] validation failed ({len(errors)} errors) — mechanical actions blocked")
|
|
1826
|
+
return 1
|
|
1827
|
+
label = f"package {scope}" if scope else f"all {len(plugins)} plugins"
|
|
1828
|
+
print(f"[validate] {label} passed (fields / id match / deps exist / acyclic / command bindings / usage routes / bridges)")
|
|
1829
|
+
if cmd in ("inject", "all"):
|
|
1830
|
+
if not do_inject(plugins):
|
|
1831
|
+
return 1
|
|
1832
|
+
if not do_check_inject(plugins):
|
|
1833
|
+
return 1
|
|
1834
|
+
if not do_cmd_inject(plugins):
|
|
1835
|
+
return 1
|
|
1836
|
+
if cmd in ("registry", "all"):
|
|
1837
|
+
if not do_registry(plugins):
|
|
1838
|
+
return 1
|
|
1839
|
+
if cmd in ("deploy", "all"):
|
|
1840
|
+
if not do_deploy():
|
|
1841
|
+
return 1
|
|
1842
|
+
return 0
|
|
1843
|
+
|
|
1844
|
+
|
|
1845
|
+
if __name__ == "__main__":
|
|
1846
|
+
sys.exit(main())
|