owfadev 0.0.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/.agents/skills/asset-read/SKILL.md +39 -0
  2. package/.agents/skills/check/SKILL.md +130 -0
  3. package/.agents/skills/docs/SKILL.md +24 -0
  4. package/.agents/skills/map/SKILL.md +72 -0
  5. package/.agents/skills/plugin/SKILL.md +74 -0
  6. package/.agents/skills/profile/SKILL.md +75 -0
  7. package/.agents/skills/query/SKILL.md +48 -0
  8. package/.agents/skills/save/SKILL.md +94 -0
  9. package/.agents/skills/wiki_plugin_kernel/SKILL.md +104 -0
  10. package/.meta/VERSION +1 -0
  11. package/.meta/command/asset-read/SKILL.md +39 -0
  12. package/.meta/command/check/SKILL.md +130 -0
  13. package/.meta/command/docs/SKILL.md +24 -0
  14. package/.meta/command/map/SKILL.md +72 -0
  15. package/.meta/command/plugin/SKILL.md +74 -0
  16. package/.meta/command/profile/SKILL.md +75 -0
  17. package/.meta/command/query/SKILL.md +48 -0
  18. package/.meta/command/save/SKILL.md +94 -0
  19. package/.meta/command/wiki_plugin_kernel/SKILL.md +104 -0
  20. package/.meta/docs/agent-behavior.md +68 -0
  21. package/.meta/docs/agents-example.md +34 -0
  22. package/.meta/docs/conversion.md +24 -0
  23. package/.meta/docs/deployment.md +311 -0
  24. package/.meta/docs/developer.md +33 -0
  25. package/.meta/docs/distribution.md +132 -0
  26. package/.meta/docs/intro.md +56 -0
  27. package/.meta/docs/log-example.md +19 -0
  28. package/.meta/docs/map.md +23 -0
  29. package/.meta/docs/mechanics.md +180 -0
  30. package/.meta/docs/naming.md +113 -0
  31. package/.meta/docs/packages.md +140 -0
  32. package/.meta/docs/quickstart.md +79 -0
  33. package/.meta/docs/research-landscape.md +132 -0
  34. package/.meta/docs/research-user-profile.md +27 -0
  35. package/.meta/docs/sandbox.md +40 -0
  36. package/.meta/docs/usage.md +45 -0
  37. package/.meta/plugins/core/calendar/PLUGIN.md +35 -0
  38. package/.meta/plugins/core/calendar/PLUGIN.yaml +21 -0
  39. package/.meta/plugins/core/cron/PLUGIN.md +38 -0
  40. package/.meta/plugins/core/cron/PLUGIN.yaml +24 -0
  41. package/.meta/plugins/core/device/PLUGIN.md +38 -0
  42. package/.meta/plugins/core/device/PLUGIN.yaml +21 -0
  43. package/.meta/plugins/core/device/scripts/check.py +29 -0
  44. package/.meta/plugins/core/domain/PLUGIN.md +43 -0
  45. package/.meta/plugins/core/domain/PLUGIN.yaml +8 -0
  46. package/.meta/plugins/core/hot/PLUGIN.md +40 -0
  47. package/.meta/plugins/core/hot/PLUGIN.yaml +15 -0
  48. package/.meta/plugins/core/index/PLUGIN.md +39 -0
  49. package/.meta/plugins/core/index/PLUGIN.yaml +18 -0
  50. package/.meta/plugins/core/instance-git/PLUGIN.md +39 -0
  51. package/.meta/plugins/core/instance-git/PLUGIN.yaml +15 -0
  52. package/.meta/plugins/core/instance-git/scripts/check.py +90 -0
  53. package/.meta/plugins/core/language/PLUGIN.md +38 -0
  54. package/.meta/plugins/core/language/PLUGIN.yaml +23 -0
  55. package/.meta/plugins/core/language/scripts/check.py +23 -0
  56. package/.meta/plugins/core/link/PLUGIN.md +39 -0
  57. package/.meta/plugins/core/link/PLUGIN.yaml +16 -0
  58. package/.meta/plugins/core/link/scripts/check.py +122 -0
  59. package/.meta/plugins/core/log/PLUGIN.md +58 -0
  60. package/.meta/plugins/core/log/PLUGIN.yaml +18 -0
  61. package/.meta/plugins/core/log/scripts/check.py +21 -0
  62. package/.meta/plugins/core/mapping/PLUGIN.md +32 -0
  63. package/.meta/plugins/core/mapping/PLUGIN.yaml +24 -0
  64. package/.meta/plugins/core/mapping/scripts/check.py +90 -0
  65. package/.meta/plugins/core/notes/PLUGIN.md +42 -0
  66. package/.meta/plugins/core/notes/PLUGIN.yaml +19 -0
  67. package/.meta/plugins/core/sessions/PLUGIN.md +63 -0
  68. package/.meta/plugins/core/sessions/PLUGIN.yaml +20 -0
  69. package/.meta/plugins/core/sessions/scripts/check.py +33 -0
  70. package/.meta/plugins/core/structure/PLUGIN.md +29 -0
  71. package/.meta/plugins/core/structure/PLUGIN.yaml +18 -0
  72. package/.meta/plugins/core/structure/scripts/check.py +32 -0
  73. package/.meta/plugins/core/tag/PLUGIN.md +37 -0
  74. package/.meta/plugins/core/tag/PLUGIN.yaml +18 -0
  75. package/.meta/plugins/core/tag/scripts/check.py +28 -0
  76. package/.meta/plugins/core/tmp/PLUGIN.md +26 -0
  77. package/.meta/plugins/core/tmp/PLUGIN.yaml +16 -0
  78. package/.meta/plugins/core/todo/PLUGIN.md +33 -0
  79. package/.meta/plugins/core/todo/PLUGIN.yaml +22 -0
  80. package/.meta/plugins/core/trust/PLUGIN.md +70 -0
  81. package/.meta/plugins/core/trust/PLUGIN.yaml +23 -0
  82. package/.meta/plugins/core/trust/scripts/check.py +77 -0
  83. package/.meta/plugins/core/user-profile/PLUGIN.md +39 -0
  84. package/.meta/plugins/core/user-profile/PLUGIN.yaml +30 -0
  85. package/.meta/plugins/core/vault/PLUGIN.md +28 -0
  86. package/.meta/plugins/core/vault/PLUGIN.yaml +13 -0
  87. package/.meta/plugins/core/wiki/PLUGIN.md +37 -0
  88. package/.meta/plugins/core/wiki/PLUGIN.yaml +8 -0
  89. package/.meta/protocol/actions.md +42 -0
  90. package/.meta/protocol/experiments.md +40 -0
  91. package/.meta/protocol/registry.yaml +75 -0
  92. package/.meta/scripts/pipeline.py +428 -0
  93. package/.meta/scripts/wiki_plugin_kernel.py +1891 -0
  94. package/.meta/scripts/wikilib.py +79 -0
  95. package/.meta/tests/test_install_preflight.py +177 -0
  96. package/LICENSE +21 -21
  97. package/README.md +96 -15
  98. package/README.zh-CN.md +103 -0
  99. package/bin/owfa.js +594 -0
  100. package/package.json +36 -7
  101. package/templates/AGENTS.template.md +19 -0
@@ -0,0 +1,1891 @@
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(base=None):
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 if base is None else base):
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
+ for project in (do_inject, do_check_inject, do_cmd_inject, do_registry):
994
+ if not project(plugins):
995
+ print(f"[{label}] projection failed ({project.__name__}) — state may be partial")
996
+ return False
997
+ if not do_deploy():
998
+ print(f"[{label}] deployment failed — state may be partial")
999
+ return False
1000
+ return True
1001
+
1002
+
1003
+ def _preflight_install(src, ignore):
1004
+ """Validate the prospective manifest graph before writing installed package state.
1005
+
1006
+ Stage only masters using the same copy filter as install, including --without.
1007
+ Binding/bridge/package checks still run after commands and connectors land.
1008
+ This is a preflight, not a transaction or a replacement for convergence.
1009
+ """
1010
+ import tempfile
1011
+
1012
+ plugins, errors = load_plugins()
1013
+ warns = []
1014
+ try:
1015
+ with tempfile.TemporaryDirectory(prefix="vw-preflight-") as stage:
1016
+ shutil.copytree(src, os.path.join(stage, "package"), ignore=ignore)
1017
+ incoming, load_errors = load_plugins(stage)
1018
+ errors.extend(load_errors)
1019
+ for name, manifest in incoming.items():
1020
+ if name in plugins:
1021
+ errors.append(f"[error] {name}/: duplicate plugin id (already installed)")
1022
+ else:
1023
+ plugins[name] = manifest
1024
+ if not errors:
1025
+ validate(plugins, errors, warnings=warns)
1026
+ except (OSError, ValueError) as e:
1027
+ errors.append(f"[error] install preflight: {e}")
1028
+ for w in warns:
1029
+ print(f"[warning] {w}")
1030
+ for e in errors:
1031
+ print(e)
1032
+ if errors:
1033
+ print("[install] preflight failed — package files and projections were not changed")
1034
+ return False
1035
+ return True
1036
+
1037
+
1038
+ def do_install(spec, args, prev_version=None):
1039
+ """Install a package from the index (or --path <dir> for a local/unregistered one): masters + connectors + commands, then converge."""
1040
+ pkg, _, tag = spec.partition("@") if spec else ("", "", "")
1041
+ path = None
1042
+ if "--path" in args:
1043
+ i = args.index("--path")
1044
+ path = args[i + 1] if i + 1 < len(args) else None
1045
+ without = []
1046
+ if "--without" in args:
1047
+ i = args.index("--without")
1048
+ without = [x for x in args[i + 1].split(",") if x] if i + 1 < len(args) else []
1049
+ if not pkg and not path:
1050
+ print(__doc__)
1051
+ return 2
1052
+ direct = pkg.startswith(("https://", "git@")) or ("/" in pkg and not path)
1053
+ # framework repo guard: library posture — the sandbox (test-repo) is the install target
1054
+ if os.path.isdir(os.path.join(ROOT, "test-repo")) and not os.path.exists(AGENTS_MD):
1055
+ print("[error] framework repo is library posture — install into an instance (e.g. cd test-repo)")
1056
+ return 1
1057
+ errors = []
1058
+ if path:
1059
+ src = path
1060
+ if not os.path.exists(os.path.join(src, "PKG.yaml")):
1061
+ print(f"[error] {src}/PKG.yaml not found (not a package directory)")
1062
+ return 1
1063
+ elif direct:
1064
+ # unindexed repository: reachable by convention, trust unknown — the index PR is the review gate (#20 / #12)
1065
+ if "--yes" not in args:
1066
+ 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")
1067
+ return 1
1068
+ url = pkg if pkg.startswith(("https://", "git@")) else f"https://github.com/{pkg}.git"
1069
+ entry = {"repo": url}
1070
+ src = _clone_package(entry, pkg, tag or None, errors)
1071
+ for e in errors:
1072
+ print(e)
1073
+ if src is None:
1074
+ return 1
1075
+ print(f"[install] trust: unknown (unindexed direct install)")
1076
+ else:
1077
+ index, _src = load_index(errors)
1078
+ if pkg not in index:
1079
+ print(f"[error] package {pkg} not in the index ({_src}) — third-party packages register via an index PR (#20 生态四问)")
1080
+ for e in errors:
1081
+ print(e)
1082
+ return 1
1083
+ entry = index[pkg]
1084
+ if entry.get("trust", "") not in ("framework-bundled", "reviewed-3rd-party") and "--yes" not in args:
1085
+ print(f"[error] package {pkg} trust level {entry.get('trust')!r} is not auto-installable — explicit --yes required (#12 安全面)")
1086
+ return 1
1087
+ src = _clone_package(entry, pkg, tag, errors)
1088
+ for e in errors:
1089
+ print(e)
1090
+ if src is None:
1091
+ return 1
1092
+ try:
1093
+ pkgm = parse_pkg(os.path.join(src, "PKG.yaml"))
1094
+ except ValueError as e:
1095
+ print(f"[error] PKG.yaml: {e}")
1096
+ return 1
1097
+ pid = str(pkgm.get("id") or pkg or os.path.basename(path))
1098
+ if not path and not direct and pid != pkg:
1099
+ print(f"[error] index says {pkg}, PKG.yaml says {pid} (index ↔ package mismatch — refuse)")
1100
+ return 1
1101
+ fw = framework_version()
1102
+ if fw is not None:
1103
+ m = re.fullmatch(r">=\s*(\d+)\.(\d+)", str(pkgm.get("compat") or ""))
1104
+ if m and (int(m.group(1)), int(m.group(2))) > fw:
1105
+ print(f"[error] package compat {pkgm.get('compat')} exceeds framework {'.'.join(map(str, fw))}")
1106
+ return 1
1107
+ if not path and not direct:
1108
+ idx_members = set(str(entry.get("members", "")).split("/")) - {""}
1109
+ pkg_members = set(pkgm.get("members") or [])
1110
+ if idx_members and idx_members != pkg_members:
1111
+ print(f"[error] index members {sorted(idx_members)} != PKG.yaml members {sorted(pkg_members)} (index <-> package drift — refuse)")
1112
+ return 1
1113
+ idx_ver = str(entry.get("version") or "").strip()
1114
+ if idx_ver and idx_ver != str(pkgm.get("version")).strip():
1115
+ 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)")
1116
+ return 1
1117
+ members_all = [str(m) for m in (pkgm.get("members") or [])]
1118
+ pruned = set()
1119
+ if without:
1120
+ bad = [w for w in without if w not in members_all]
1121
+ if bad:
1122
+ print(f"[error] --without {', '.join(bad)}: not members of {pid}")
1123
+ return 1
1124
+ if pid in without:
1125
+ print(f"[error] --without {pid}: the family root is never prunable (D5)")
1126
+ return 1
1127
+ depends_in = {}
1128
+ for m in members_all:
1129
+ try:
1130
+ deps = parse_manifest(os.path.join(src, m, "PLUGIN.yaml")).get("depends") or []
1131
+ except ValueError:
1132
+ deps = []
1133
+ depends_in[m] = [d for d in deps if d in members_all]
1134
+ pruned = set(without)
1135
+ changed = True
1136
+ while changed:
1137
+ changed = False
1138
+ for m in members_all:
1139
+ if m not in pruned and m != pid and set(depends_in[m]) & pruned:
1140
+ pruned.add(m)
1141
+ changed = True
1142
+ if pid in pruned or not [m for m in members_all if m not in pruned]:
1143
+ print(f"[error] --without cascade prunes the whole package (root or all members)")
1144
+ return 1
1145
+ print(f"[install] subset: final members {', '.join(m for m in members_all if m not in pruned)}; pruned {', '.join(sorted(pruned))}")
1146
+ dst = os.path.join(PLUGINS_DIR, "domains", pid)
1147
+ if os.path.exists(dst):
1148
+ print(f"[error] {pid} already installed — uninstall first (kernel uninstall {pid} --yes)")
1149
+ return 1
1150
+ ignore = shutil.ignore_patterns(".git", ".meta", "connectors", "command", "README.md",
1151
+ "__pycache__", "migrations", *sorted(pruned))
1152
+ if not _preflight_install(src, ignore):
1153
+ if not path:
1154
+ shutil.rmtree(src, ignore_errors=True)
1155
+ return 1
1156
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
1157
+ shutil.copytree(src, dst, ignore=ignore)
1158
+ copied_conn, copied_cmd = [], []
1159
+ csrc = os.path.join(src, "connectors")
1160
+ if os.path.isdir(csrc):
1161
+ for d in sorted(os.listdir(csrc)):
1162
+ dd = os.path.join(CONNECTORS_DIR, d)
1163
+ if os.path.exists(dd):
1164
+ print(f"[warning] connector {d} already present — kept existing")
1165
+ continue
1166
+ os.makedirs(CONNECTORS_DIR, exist_ok=True)
1167
+ shutil.copytree(os.path.join(csrc, d), dd, ignore=shutil.ignore_patterns("__pycache__", ".venv", "*.egg-info"))
1168
+ copied_conn.append(d)
1169
+ msrc = os.path.join(src, "command")
1170
+ if os.path.isdir(msrc):
1171
+ import wikilib
1172
+ for d in sorted(os.listdir(msrc)):
1173
+ fm_path = os.path.join(msrc, d, "SKILL.md")
1174
+ if os.path.exists(fm_path):
1175
+ owners = _owner_list(wikilib.parse_frontmatter(open(fm_path, encoding="utf-8").read()).get("owner"))
1176
+ if owners and owners[0] in pruned:
1177
+ continue # command of a pruned member rides out with it
1178
+ dd = os.path.join(COMMANDS_DIR, d)
1179
+ if os.path.exists(dd):
1180
+ print(f"[warning] command {d} already present — kept existing")
1181
+ continue
1182
+ shutil.copytree(os.path.join(msrc, d), dd, ignore=shutil.ignore_patterns("__pycache__"))
1183
+ copied_cmd.append(d)
1184
+ led0 = _load_ledger()
1185
+ if pid in led0 and led0[pid].get("kind") == "teardown":
1186
+ del led0[pid] # reinstall: the teardown premise vanished with the package's return
1187
+ _save_ledger(led0)
1188
+ _banner_set(led0)
1189
+ _journal("sweep", f"{pid} teardown swept by reinstall")
1190
+ print(f"[sweep] {pid}: pending teardown swept (reinstall — premise vanished)")
1191
+ staged = _stage_migrations(os.path.join(src, "migrations"), pid, prev_version, pkgm.get("version"))
1192
+ os.makedirs(os.path.join(MIGRATIONS_DIR, pid), exist_ok=True)
1193
+ try:
1194
+ shutil.copyfile(os.path.join(src, "PKG.yaml"), os.path.join(MIGRATIONS_DIR, pid, "PKG.yaml")) # dormant roster copy — residue-reference for self-healing
1195
+ except OSError:
1196
+ pass
1197
+ tsrc = os.path.join(src, "migrations")
1198
+ if os.path.isdir(tsrc): # dormant teardown declaration rides along silently until an uninstall consumes it
1199
+ for f in ("teardown.md", "teardown.py"):
1200
+ if os.path.exists(os.path.join(tsrc, f)):
1201
+ shutil.copyfile(os.path.join(tsrc, f), os.path.join(MIGRATIONS_DIR, pid, f))
1202
+ if not path:
1203
+ shutil.rmtree(src, ignore_errors=True)
1204
+ print(f"[install] {pid}: masters -> .meta/plugins/domains/{pid}/"
1205
+ + (f"; connectors: {', '.join(copied_conn)}" if copied_conn else "")
1206
+ + (f"; commands: {', '.join(copied_cmd)}" if copied_cmd else ""))
1207
+ _journal("install", f"{pid} v{pkgm.get('version')}" + (f" subset(-{','.join(sorted(pruned))})" if pruned else "") + (f" staged={','.join(staged)}" if staged else ""))
1208
+ converged = converge("install")
1209
+ if staged:
1210
+ print(f"[pending] semantic migration staged for {pid}: {', '.join(staged)} — kernel migrate {pid}; validate stays red until cleared")
1211
+ if not converged:
1212
+ print("[error] install incomplete — files may remain; run kernel validate before retrying")
1213
+ return 1
1214
+ return 0
1215
+
1216
+
1217
+ def do_uninstall(pkg, args):
1218
+ """D6 uninstall: mechanical four-clear + convergence; data zones and credentials never touched."""
1219
+ if not pkg:
1220
+ print(__doc__)
1221
+ return 2
1222
+ if "--yes" not in args:
1223
+ print("[error] uninstall needs --yes (removes package masters + projections; data zones stay — #20 D6)")
1224
+ return 1
1225
+ pkg_dir = os.path.join(PLUGINS_DIR, "domains", pkg)
1226
+ if not os.path.exists(pkg_dir):
1227
+ print(f"[error] {pkg} not installed")
1228
+ return 1
1229
+ try:
1230
+ pkgm = parse_pkg(os.path.join(pkg_dir, "PKG.yaml"))
1231
+ except ValueError as e:
1232
+ print(f"[warning] PKG.yaml unreadable ({e}) — proceeding with mechanical removal")
1233
+ pkgm = {}
1234
+ members = set(pkgm.get("members") or [pkg])
1235
+ import wikilib
1236
+ removed_cmd = []
1237
+ if os.path.isdir(COMMANDS_DIR):
1238
+ for d in sorted(os.listdir(COMMANDS_DIR)):
1239
+ fm_path = os.path.join(COMMANDS_DIR, d, "SKILL.md")
1240
+ if not os.path.exists(fm_path):
1241
+ continue
1242
+ owners = _owner_list(wikilib.parse_frontmatter(open(fm_path, encoding="utf-8").read()).get("owner"))
1243
+ if owners and owners[0] in members:
1244
+ shutil.rmtree(os.path.join(COMMANDS_DIR, d))
1245
+ sk = os.path.join(SKILLS_DIR, d)
1246
+ if os.path.isdir(sk):
1247
+ shutil.rmtree(sk)
1248
+ removed_cmd.append(d)
1249
+ removed_conn = []
1250
+ for c in pkgm.get("connectors") or []:
1251
+ name = c.get("name")
1252
+ if not name:
1253
+ continue
1254
+ cd = os.path.join(CONNECTORS_DIR, name)
1255
+ if os.path.isdir(cd):
1256
+ shutil.rmtree(cd)
1257
+ removed_conn.append(name)
1258
+ sk = os.path.join(SKILLS_DIR, name)
1259
+ if os.path.isdir(sk) and not os.path.exists(os.path.join(COMMANDS_DIR, name)):
1260
+ shutil.rmtree(sk)
1261
+ # teardown (x->0): dormant declaration staged before removal — semantic cleanup debt gets its warning
1262
+ tmd = os.path.join(MIGRATIONS_DIR, pkg, "teardown.md")
1263
+ if os.path.exists(tmd):
1264
+ led = _load_ledger()
1265
+ led[pkg] = {"kind": "teardown", "from": str(pkgm.get("version", "?")), "to": "0", "items": ["teardown"]}
1266
+ _save_ledger(led)
1267
+ _banner_set(led)
1268
+ try:
1269
+ shutil.copyfile(os.path.join(pkg_dir, "PKG.yaml"), os.path.join(MIGRATIONS_DIR, pkg, "PKG.yaml"))
1270
+ except OSError:
1271
+ pass
1272
+ _journal("warn+", f"{pkg} teardown staged (x->0)")
1273
+ print(f"[pending] {pkg}: teardown staged (x->0) — semantic deletion commands outstanding; kernel migrate {pkg}")
1274
+ shutil.rmtree(pkg_dir)
1275
+ # migrate-kind state sweeps with the package; teardown-kind survives (it is ABOUT the absence)
1276
+ led = _load_ledger()
1277
+ if pkg in led and led[pkg].get("kind", "migrate") == "migrate":
1278
+ del led[pkg]
1279
+ _save_ledger(led)
1280
+ _banner_set(led)
1281
+ shutil.rmtree(os.path.join(MIGRATIONS_DIR, pkg), ignore_errors=True)
1282
+ _journal("sweep", f"{pkg} migration state cleared by uninstall")
1283
+ print(f"[uninstall] {pkg}: pending migration state swept (ledger/staging/banner)")
1284
+ print(f"[uninstall] {pkg}: masters removed"
1285
+ + (f"; commands: {', '.join(removed_cmd)}" if removed_cmd else "")
1286
+ + (f"; connectors: {', '.join(removed_conn)}" if removed_conn else ""))
1287
+ _journal("uninstall", f"{pkg} v{pkgm.get('version', '?')}")
1288
+ converge("uninstall")
1289
+ do_residual(pkg, {pkg: pkgm})
1290
+ un = pkgm.get("uninstall") or {}
1291
+ if un.get("retains"):
1292
+ print(f"[uninstall] retains (never touched): {', '.join(un['retains'])}")
1293
+ if un.get("credentials"):
1294
+ print(f"[uninstall] credentials stay on this machine — remove manually if desired: {', '.join(un['credentials'])}")
1295
+ if un.get("processes"):
1296
+ print(f"[uninstall] stop long-running executors if any: {', '.join(un['processes'])}")
1297
+ for d in (os.path.join(PLUGINS_DIR, "domains"), CONNECTORS_DIR):
1298
+ if os.path.isdir(d) and not os.listdir(d):
1299
+ os.rmdir(d) # empty containers leave no residue
1300
+ return 0
1301
+
1302
+
1303
+ def do_list_remote():
1304
+ """Index table with installed marks — GitHub is the marketplace page, this is the agent surface."""
1305
+ errors = []
1306
+ index, src = load_index(errors)
1307
+ installed = load_packages([])
1308
+ print(f"index: {src}")
1309
+ for name, e in sorted(index.items()):
1310
+ mark = "installed" if name in installed else "available"
1311
+ print(f" {name:<10} v{e.get('version', '?'):<5} compat {e.get('compat', '?'):<8} [{mark}] {e.get('summary', '')}")
1312
+ mem = str(e.get("members", "")).replace("/", ", ")
1313
+ print(f" {'':<12}members: {mem if mem else '?'}")
1314
+ print(f" {'':<12}{e.get('repo', '')} trust: {e.get('trust', '?')}")
1315
+ for e in errors:
1316
+ print(e)
1317
+ return 0 if index else 1
1318
+
1319
+
1320
+ def _upgrade_action(pkg, args):
1321
+ """Mechanical reinstall + update report + migration chain staging (issue #20 + migration design)."""
1322
+ import contextlib
1323
+ import io
1324
+ pkg_dir = os.path.join(PLUGINS_DIR, "domains", pkg)
1325
+ if not os.path.exists(pkg_dir):
1326
+ print(f"[error] {pkg} not installed")
1327
+ return 1
1328
+ old_pkgm = parse_pkg(os.path.join(pkg_dir, "PKG.yaml"))
1329
+ old_ver = str(old_pkgm.get("version", "0"))
1330
+ old_members = {}
1331
+ for m in old_pkgm.get("members") or []:
1332
+ mp = os.path.join(pkg_dir, str(m), "PLUGIN.yaml")
1333
+ if os.path.exists(mp):
1334
+ try:
1335
+ old_members[str(m)] = str(parse_manifest(mp).get("version", "?"))
1336
+ except ValueError:
1337
+ old_members[str(m)] = "?"
1338
+ path = None
1339
+ if "--path" in args:
1340
+ i = args.index("--path")
1341
+ path = args[i + 1] if i + 1 < len(args) else None
1342
+ errors = []
1343
+ if path:
1344
+ src = path
1345
+ if not os.path.exists(os.path.join(src, "PKG.yaml")):
1346
+ print(f"[error] {src}/PKG.yaml not found")
1347
+ return 1
1348
+ else:
1349
+ index, _src = load_index(errors)
1350
+ if pkg not in index:
1351
+ print(f"[error] {pkg} not in the index — use --path for local/dev upgrades")
1352
+ return 1
1353
+ entry = index[pkg]
1354
+ src = _clone_package(entry, pkg, None, errors)
1355
+ for e in errors:
1356
+ print(e)
1357
+ if src is None:
1358
+ return 1
1359
+ new_pkgm = parse_pkg(os.path.join(src, "PKG.yaml"))
1360
+ new_ver = str(new_pkgm.get("version", old_ver))
1361
+ if new_ver == old_ver:
1362
+ print(f"[upgrade] {pkg}: already at v{old_ver} (index/local agree) — nothing to do")
1363
+ if not path:
1364
+ shutil.rmtree(src, ignore_errors=True)
1365
+ return 0
1366
+ fw = framework_version()
1367
+ if fw is not None:
1368
+ m = re.fullmatch(r">=\s*(\d+)\.(\d+)", str(new_pkgm.get("compat") or ""))
1369
+ if m and (int(m.group(1)), int(m.group(2))) > fw:
1370
+ 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")
1371
+ return 1
1372
+ # quiet mechanical reinstall (uninstall -> install-from-src with prev_version)
1373
+ buf = io.StringIO()
1374
+ with contextlib.redirect_stdout(buf):
1375
+ ok1 = do_uninstall(pkg, ["--yes"])
1376
+ ok2 = do_install(pkg + ("" if path else ""), (["--path", path] if path else []), prev_version=old_ver) if ok1 == 0 else 1
1377
+ if ok2 != 0:
1378
+ print(buf.getvalue())
1379
+ print("[error] reinstall failed — state may be partial, rerun kernel validate")
1380
+ return 1
1381
+ # report: member version deltas + changelog excerpts
1382
+ new_members = {}
1383
+ for m in new_pkgm.get("members") or []:
1384
+ mp = os.path.join(PLUGINS_DIR, "domains", pkg, str(m), "PLUGIN.yaml")
1385
+ if os.path.exists(mp):
1386
+ try:
1387
+ new_members[str(m)] = str(parse_manifest(mp).get("version", "?"))
1388
+ except ValueError:
1389
+ new_members[str(m)] = "?"
1390
+ 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")}
1391
+ 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'}")
1392
+ for m, (a, b) in sorted(changed.items()):
1393
+ print(f"[upgrade] {m} {a} -> {b}")
1394
+ if a != "new":
1395
+ for line in _changelog_delta(os.path.join(PLUGINS_DIR, "domains", pkg, m, "PLUGIN.md"), a, b)[:4]:
1396
+ print(f"[upgrade] {line}")
1397
+ _journal("upgrade", f"{pkg} {old_ver}->{new_ver} changed={len(changed)}")
1398
+ if _load_ledger().get(pkg):
1399
+ print(f"[pending] {pkg}: semantic migration staged — kernel migrate {pkg}; validate stays red until cleared")
1400
+ return 0
1401
+
1402
+
1403
+ def do_upgrade(pkg, args=None):
1404
+ """Reconciliation report (packages vs index, framework vs template remote); `upgrade <pkg> --yes` performs the mechanical reinstall + stages migrations (per #20 逐包不级联)."""
1405
+ args = args or []
1406
+ if pkg and "--yes" in args:
1407
+ return _upgrade_action(pkg, args)
1408
+ errors = []
1409
+ index, src = load_index(errors)
1410
+ installed = load_packages([])
1411
+ fw = framework_version()
1412
+ fw_note = ""
1413
+ try:
1414
+ import urllib.request
1415
+ rfw = urllib.request.urlopen(FW_VERSION_REMOTE, timeout=10).read().decode("utf-8").strip()
1416
+ m = re.fullmatch(r"(\d+)\.(\d+)", rfw)
1417
+ if m:
1418
+ rt = (int(m.group(1)), int(m.group(2)))
1419
+ if fw and rt > fw:
1420
+ fw_note = f" — remote v{rfw} available (run `npx owfa upgrade`, or manual sync: template diff → selective copy → kernel all, see CONTRIBUTING)"
1421
+ elif fw:
1422
+ fw_note = " — up to date with the template"
1423
+ except Exception:
1424
+ fw_note = " — remote check unavailable"
1425
+ print(f"framework {'.'.join(map(str, fw)) if fw else '?'}{fw_note} — index: {src}")
1426
+ rows = 0
1427
+ for name, local in sorted(installed.items()):
1428
+ lv = str(local.get("version", "?"))
1429
+ e = index.get(name)
1430
+ if not e:
1431
+ print(f" {name:<10} local v{lv} — not in the index (instance-local package, channel C)")
1432
+ else:
1433
+ rv = e.get("version", "?")
1434
+ state = "up to date" if lv == rv else f"v{lv} -> v{rv}: kernel upgrade {name} --yes"
1435
+ print(f" {name:<10} local v{lv} remote v{rv} — {state}")
1436
+ rows += 1
1437
+ if pkg and pkg not in installed:
1438
+ print(f" {pkg:<10} not installed")
1439
+ if not rows:
1440
+ print(" (no packages installed)")
1441
+ for e in errors:
1442
+ print(e)
1443
+ return 0
1444
+
1445
+
1446
+ def _vt(x):
1447
+ return tuple(int(i) for i in str(x).split("."))
1448
+
1449
+
1450
+ def _journal(op, detail):
1451
+ import datetime
1452
+ line = f"{datetime.datetime.now().isoformat(timespec='seconds')} {op} {detail}"
1453
+ try:
1454
+ old = open(JOURNAL, encoding="utf-8").read().splitlines() if os.path.exists(JOURNAL) else []
1455
+ except OSError:
1456
+ old = []
1457
+ old.append(line)
1458
+ open(JOURNAL, "w", encoding="utf-8").write("\n".join(old[-500:]) + "\n")
1459
+
1460
+
1461
+ def _load_ledger():
1462
+ import json as _json
1463
+ if not os.path.exists(LEDGER):
1464
+ return {}
1465
+ try:
1466
+ return _json.load(open(LEDGER, encoding="utf-8"))
1467
+ except (OSError, ValueError):
1468
+ return {}
1469
+
1470
+
1471
+ def _save_ledger(led):
1472
+ import json as _json
1473
+ os.makedirs(MIGRATIONS_DIR, exist_ok=True)
1474
+ _json.dump(led, open(LEDGER, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
1475
+
1476
+
1477
+ def _banner_set(entries):
1478
+ """Tri-face visibility, agent side: a kernel-managed block inside the AGENTS injection region — every session sees it until cleared."""
1479
+ if not os.path.exists(AGENTS_MD):
1480
+ return
1481
+ text = open(AGENTS_MD, encoding="utf-8").read()
1482
+ text = re.sub(r"<!-- pending-migrations -->.*?<!-- /pending-migrations -->\n*", "", text, flags=re.S)
1483
+ if entries:
1484
+ lines = "; ".join(f"{k} {v.get('from', '?')}->{v.get('to', '?')}" for k, v in sorted(entries.items()))
1485
+ block = (f"<!-- pending-migrations -->\n> ⚠ PENDING SEMANTIC MIGRATIONS: {lines} — "
1486
+ f"territory may be mid-shape; complete via `kernel migrate <pkg> --clear`; validate stays red until cleared.\n<!-- /pending-migrations -->\n\n")
1487
+ m = re.search(re.escape(INJECT_START) + r"\n", text)
1488
+ if m:
1489
+ text = text[:m.end()] + "\n" + block + text[m.end():]
1490
+ open(AGENTS_MD, "w", encoding="utf-8").write(text)
1491
+
1492
+
1493
+ def _stage_migrations(migsrc, pid, prev_version, new_version):
1494
+ """Stage declarations into the kernel zone + register the ledger + AGENTS banner. Package dirs never carry migrations (use-and-delete)."""
1495
+ if not os.path.isdir(migsrc):
1496
+ return []
1497
+ os.makedirs(os.path.join(MIGRATIONS_DIR, pid), exist_ok=True)
1498
+ staged = []
1499
+ if prev_version is None:
1500
+ cands = [("init", "init.md")] if os.path.exists(os.path.join(migsrc, "init.md")) else []
1501
+ else:
1502
+ cands = []
1503
+ for f in sorted(os.listdir(migsrc)):
1504
+ m = re.fullmatch(r"(\d+\.\d+)-to-(\d+\.\d+)\.md", f)
1505
+ if m and _vt(m.group(1)) >= _vt(prev_version):
1506
+ cands.append((f[:-3], f))
1507
+ for label, f in cands:
1508
+ shutil.copyfile(os.path.join(migsrc, f), os.path.join(MIGRATIONS_DIR, pid, f))
1509
+ pyp = os.path.join(migsrc, f[:-3] + ".py")
1510
+ if os.path.exists(pyp):
1511
+ shutil.copyfile(pyp, os.path.join(MIGRATIONS_DIR, pid, f[:-3] + ".py"))
1512
+ staged.append(label)
1513
+ if staged:
1514
+ led = _load_ledger()
1515
+ pkgd = os.path.join(PLUGINS_DIR, "domains", pid)
1516
+ 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)))]
1517
+ led[pid] = {"from": str(prev_version or "0"), "to": str(new_version), "items": staged, "members": present}
1518
+ _save_ledger(led)
1519
+ _banner_set(led)
1520
+ _journal("warn+", f"{pid} {prev_version or '0'}->{new_version} items={','.join(staged)}")
1521
+ return staged
1522
+
1523
+
1524
+ def _ast_gate(path):
1525
+ """Verify-script gate: stdlib-adjacent, read-only, defines verify(ctx). Catches casual abuse; malice is the registry review's job."""
1526
+ try:
1527
+ tree = ast.parse(open(path, encoding="utf-8").read())
1528
+ except SyntaxError as e:
1529
+ return [f"syntax error: {e}"]
1530
+ issues = []
1531
+ banned = {"subprocess", "shutil", "socket", "urllib", "urllib.request", "requests", "http", "ftplib", "telnetlib", "pickle"}
1532
+ for n in ast.walk(tree):
1533
+ if isinstance(n, ast.Import):
1534
+ for a in n.names:
1535
+ if a.name.split(".")[0] in banned:
1536
+ issues.append(f"banned import: {a.name}")
1537
+ elif isinstance(n, ast.ImportFrom):
1538
+ if (n.module or "").split(".")[0] in banned:
1539
+ issues.append(f"banned import: {n.module}")
1540
+ elif isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == "open":
1541
+ mode = n.args[1].value if len(n.args) > 1 and isinstance(n.args[1], ast.Constant) else "r"
1542
+ if any(c in str(mode) for c in "wa+"):
1543
+ issues.append(f"open(..., {mode!r}) — verify is read-only")
1544
+ if not any(isinstance(n, ast.FunctionDef) and n.name == "verify" for n in tree.body):
1545
+ issues.append("verify(ctx) not defined")
1546
+ return issues
1547
+
1548
+
1549
+ def _run_verify(py_path):
1550
+ """Execute a staged verify(ctx) in-process; returns (issues, gate_problems)."""
1551
+ sys.dont_write_bytecode = True # staged code is use-and-delete; leave no __pycache__ behind
1552
+ gate = _ast_gate(py_path)
1553
+ if gate:
1554
+ return None, gate
1555
+ import wikilib
1556
+ spec = importlib.util.spec_from_file_location("vw_verify", py_path)
1557
+ mod = importlib.util.module_from_spec(spec)
1558
+ spec.loader.exec_module(mod)
1559
+ pkg = os.path.basename(os.path.dirname(os.path.abspath(py_path)))
1560
+ pkg_dir = os.path.join(PLUGINS_DIR, "domains", pkg)
1561
+ installed = []
1562
+ if os.path.isdir(pkg_dir):
1563
+ try:
1564
+ pm = parse_pkg(os.path.join(pkg_dir, "PKG.yaml"))
1565
+ installed = [str(m) for m in (pm.get("members") or []) if os.path.isdir(os.path.join(pkg_dir, str(m)))]
1566
+ except ValueError:
1567
+ installed = []
1568
+ ctx = types.SimpleNamespace(root=ROOT, pages=list(wikilib.walk_pages(ROOT)), installed=installed) # subset matching: verifies address the installed subset, never the full roster
1569
+ try:
1570
+ return mod.verify(ctx) or [], []
1571
+ except Exception as e:
1572
+ return None, [f"verify() raised: {e}"]
1573
+
1574
+
1575
+ def _changelog_delta(md_path, old_v, new_v):
1576
+ try:
1577
+ sec = open(md_path, encoding="utf-8").read().split("## Changelog", 1)[1]
1578
+ except (OSError, IndexError):
1579
+ return []
1580
+ out = []
1581
+ for line in sec.splitlines():
1582
+ m = re.match(r"-\s+(\d+(?:\.\d+)?)\s*[\s(]", line.strip())
1583
+ if m and _vt(old_v) < _vt(m.group(1)) <= _vt(new_v):
1584
+ out.append(line.strip()[:160])
1585
+ return out
1586
+
1587
+
1588
+ def do_migrate(pkg, args):
1589
+ """Migration status / declaration printing / gated clear. The exit is always open: migrate works while warned."""
1590
+ led = _load_ledger()
1591
+ if not pkg:
1592
+ if not led:
1593
+ print("[migrate] no pending semantic migrations")
1594
+ return 0
1595
+ for k, v in sorted(led.items()):
1596
+ print(f"[migrate] {k} {v.get('from')}->{v.get('to')} pending: {', '.join(v.get('items', []))}")
1597
+ return 0
1598
+ ent = led.get(pkg)
1599
+ if ent is None:
1600
+ print(f"[migrate] {pkg}: nothing pending")
1601
+ return 0
1602
+ pdir = os.path.join(MIGRATIONS_DIR, pkg)
1603
+ if "--clear" not in args:
1604
+ print(f"[migrate] {pkg} {ent.get('from')}->{ent.get('to')} pending: {', '.join(ent.get('items', []))}")
1605
+ for f in sorted(os.listdir(pdir)) if os.path.isdir(pdir) else []:
1606
+ if f.endswith(".md"):
1607
+ print(f"[migrate] --- {f} ---")
1608
+ print(open(os.path.join(pdir, f), encoding="utf-8").read().rstrip())
1609
+ pyf = os.path.join(pdir, f[:-3] + ".py")
1610
+ if os.path.exists(pyf):
1611
+ issues, gate = _run_verify(pyf)
1612
+ if gate:
1613
+ print(f"[migrate] verify gate: {gate}")
1614
+ elif issues:
1615
+ print(f"[migrate] verify: {len(issues)} issue(s): " + " | ".join(str(i)[:100] for i in issues[:5]))
1616
+ else:
1617
+ print("[migrate] verify: clean — ready to clear (--clear)")
1618
+ elif ent.get("kind") == "teardown":
1619
+ pk = os.path.join(pdir, "PKG.yaml") # default gate: show what the residual scan still sees
1620
+ pkgm2 = parse_pkg(pk) if os.path.exists(pk) else {"members": [pkg]}
1621
+ for h in _residual_hits(pkg, pkgm2)[:8]:
1622
+ print(f"[migrate] residual: {h}")
1623
+ if not _residual_hits(pkg, pkgm2):
1624
+ print("[migrate] residual gate: clean — ready to clear (--clear)")
1625
+ return 0
1626
+ # --clear: verify-gated (explicit assertion only via --force when no script)
1627
+ cleared_all = True
1628
+ for f in sorted(os.listdir(pdir)) if os.path.isdir(pdir) else []:
1629
+ if not f.endswith(".py"):
1630
+ continue
1631
+ issues, gate = _run_verify(os.path.join(pdir, f))
1632
+ if gate:
1633
+ print(f"[error] verify gate refused {f}: {gate}")
1634
+ return 1
1635
+ errs = [i for i in issues if not (isinstance(i, (tuple, list)) and i[0] == "warning")]
1636
+ if errs:
1637
+ print(f"[error] {f}: migration incomplete — {len(errs)} issue(s): " + " | ".join(str(i)[:100] for i in errs[:5]))
1638
+ return 1
1639
+ if issues:
1640
+ print(f"[warning] {f}: warnings (non-blocking): " + " | ".join(str(i)[:100] for i in issues[:5]))
1641
+ has_py = any(f.endswith(".py") for f in os.listdir(pdir)) if os.path.isdir(pdir) else False
1642
+ if not has_py:
1643
+ if ent.get("kind") == "teardown":
1644
+ pk = os.path.join(pdir, "PKG.yaml")
1645
+ pkgm2 = parse_pkg(pk) if os.path.exists(pk) else {"members": [pkg]}
1646
+ hits = _residual_hits(pkg, pkgm2)
1647
+ if hits:
1648
+ print(f"[error] teardown incomplete — residual references on anchor surfaces: {hits[:5]}")
1649
+ return 1
1650
+ elif "--force" not in args:
1651
+ print(f"[error] no verify script staged for {pkg} — clear needs --force (explicit assertion) or author-provided verify")
1652
+ return 1
1653
+ if ent.get("kind") == "teardown":
1654
+ shutil.rmtree(pdir, ignore_errors=True)
1655
+ else:
1656
+ for item in ent.get("items", []): # delete the pending items only — dormant teardown files survive for a future uninstall
1657
+ for ext in (".md", ".py"):
1658
+ try:
1659
+ os.remove(os.path.join(pdir, item + ext))
1660
+ except OSError:
1661
+ pass
1662
+ del led[pkg]
1663
+ _save_ledger(led)
1664
+ _banner_set(led)
1665
+ _journal("clear", f"{pkg} {ent.get('from')}->{ent.get('to')} verify-clean")
1666
+ print(f"[migrate] {pkg}: warning cleared, staged files deleted (use-and-delete); validate unblessed — permanent invariants belong in scripts/check.py (judgment split rule)")
1667
+ return 0
1668
+
1669
+
1670
+ def do_plan(spec, args):
1671
+ """One-shot disclosure plan (consent-wizard data source): trust, package disclosure, baseline/optional members, connectors, suggested command. Never interactive."""
1672
+ import json as _json
1673
+ pkg, _, tag = spec.partition("@") if spec else ("", "", "")
1674
+ if not pkg:
1675
+ print(__doc__)
1676
+ return 2
1677
+ errors = []
1678
+ index, _src = load_index(errors, quiet=True)
1679
+ entry = index.get(pkg)
1680
+ direct = entry is None
1681
+ if direct:
1682
+ if not ("/" in pkg or pkg.startswith(("https://", "git@"))):
1683
+ print(f"[error] package {pkg} not in the index (registry)")
1684
+ for e in errors:
1685
+ print(e)
1686
+ return 1
1687
+ url = pkg if pkg.startswith(("https://", "git@")) else f"https://github.com/{pkg}.git"
1688
+ entry = {"repo": url, "trust": "unknown (unindexed direct)", "version": ""}
1689
+ src = _clone_package(entry, pkg, tag or None, errors)
1690
+ for e in errors:
1691
+ print(e)
1692
+ if src is None:
1693
+ return 1
1694
+ try:
1695
+ pkgm = parse_pkg(os.path.join(src, "PKG.yaml"))
1696
+ except ValueError as e:
1697
+ print(f"[error] PKG.yaml: {e}")
1698
+ shutil.rmtree(src, ignore_errors=True)
1699
+ return 1
1700
+ pid = str(pkgm.get("id") or pkg)
1701
+ members = [str(m) for m in (pkgm.get("members") or [])]
1702
+ menu = pkgm.get("menu") or {}
1703
+ depends_in = {}
1704
+ for m in members:
1705
+ try:
1706
+ deps = parse_manifest(os.path.join(src, m, "PLUGIN.yaml")).get("depends") or []
1707
+ except ValueError:
1708
+ deps = []
1709
+ depends_in[m] = [d for d in deps if d in members]
1710
+
1711
+ def closure_of(m):
1712
+ out, stack = set(), [m]
1713
+ while stack:
1714
+ x = stack.pop()
1715
+ for d in depends_in.get(x, []):
1716
+ if d not in out:
1717
+ out.add(d)
1718
+ stack.append(d)
1719
+ return out
1720
+
1721
+ optional = [m for m in members if m != pid]
1722
+ plan = {
1723
+ "package": pid,
1724
+ "version": pkgm.get("version"),
1725
+ "compat": pkgm.get("compat"),
1726
+ "trust": entry.get("trust", "?"),
1727
+ "disclosure": pkgm.get("disclosure") or "(no package disclosure declared)",
1728
+ "baseline": [{"id": pid, "summary": menu.get(pid, "")}],
1729
+ "optional": [{"id": m, "summary": menu.get(m, ""), "pulls": sorted(closure_of(m) - {pid})} for m in optional],
1730
+ "connectors": [{"name": c.get("name"), "kind": ("external" if c.get("external") else "system" if c.get("system") else "in-repo")}
1731
+ for c in (pkgm.get("connectors") or [])],
1732
+ "suggested_command": f"kernel install {pid}" + (" --yes" if direct else ""),
1733
+ }
1734
+ # subset preview when --without given
1735
+ if "--without" in args:
1736
+ i = args.index("--without")
1737
+ wl = [x for x in args[i + 1].split(",") if x] if i + 1 < len(args) else []
1738
+ pruned = set(wl)
1739
+ changed = True
1740
+ while changed:
1741
+ changed = False
1742
+ for m in members:
1743
+ if m not in pruned and m != pid and set(depends_in[m]) & pruned:
1744
+ pruned.add(m)
1745
+ changed = True
1746
+ plan["subset_preview"] = {"final": [m for m in members if m not in pruned], "pruned": sorted(pruned)}
1747
+ plan["suggested_command"] += f" --without {','.join(wl)}"
1748
+ shutil.rmtree(src, ignore_errors=True)
1749
+ if "--json" in args:
1750
+ print(_json.dumps(plan, ensure_ascii=False, indent=2))
1751
+ return 0
1752
+ print(f"[plan] {pid} v{plan['version']} (compat {plan['compat']}) — trust: {plan['trust']}")
1753
+ print(f"[plan] disclosure: {plan['disclosure']}")
1754
+ print(f"[plan] baseline (always installed): {pid}" + (f" — {menu.get(pid, '')}" if menu.get(pid) else ""))
1755
+ for o in plan["optional"]:
1756
+ pulls = f" — pulls: {', '.join(o['pulls'])}" if o["pulls"] else ""
1757
+ print(f"[plan] optional: {o['id']}" + (f" — {o['summary']}" if o["summary"] else "") + pulls)
1758
+ if plan["connectors"]:
1759
+ print("[plan] connectors (ride the package): " + ", ".join(f"{c['name']} ({c['kind']})" for c in plan["connectors"]))
1760
+ if "subset_preview" in plan:
1761
+ sp = plan["subset_preview"]
1762
+ print(f"[plan] subset preview: final {', '.join(sp['final'])}; pruned {', '.join(sp['pruned'])}")
1763
+ print(f"[plan] suggested: {plan['suggested_command']}")
1764
+ return 0
1765
+
1766
+
1767
+ def main():
1768
+ cmd = sys.argv[1] if len(sys.argv) > 1 else ""
1769
+ if cmd == "plan":
1770
+ return do_plan(sys.argv[2] if len(sys.argv) > 2 else "", sys.argv[3:])
1771
+ if cmd == "install":
1772
+ return do_install(sys.argv[2] if len(sys.argv) > 2 else "", sys.argv[3:])
1773
+ if cmd == "uninstall":
1774
+ return do_uninstall(sys.argv[2] if len(sys.argv) > 2 else None, sys.argv[3:])
1775
+ if cmd == "list-remote":
1776
+ return do_list_remote()
1777
+ if cmd == "migrate":
1778
+ return do_migrate(sys.argv[2] if len(sys.argv) > 2 else None, sys.argv[3:])
1779
+ if cmd == "upgrade":
1780
+ return do_upgrade(sys.argv[2] if len(sys.argv) > 2 else None, sys.argv[3:])
1781
+ _led = _load_ledger()
1782
+ _orphans = []
1783
+ if os.path.isdir(MIGRATIONS_DIR):
1784
+ for d in sorted(os.listdir(MIGRATIONS_DIR)):
1785
+ if d == "pending.json" or not os.path.isdir(os.path.join(MIGRATIONS_DIR, d)):
1786
+ continue
1787
+ if not os.path.exists(os.path.join(PLUGINS_DIR, "domains", d)) and _led.get(d, {}).get("kind") != "teardown":
1788
+ _orphans.append(d) # staged dormant PKG marks presence; absent package + no teardown = orphan residue
1789
+ for k in _orphans: # self-healing: migrate-kind entries die with the package; residue gets mechanically swept too
1790
+ _led.pop(k, None)
1791
+ _pkg_dir = os.path.join(MIGRATIONS_DIR, k)
1792
+ try:
1793
+ _pm = parse_pkg(os.path.join(_pkg_dir, "PKG.yaml")) if os.path.isdir(_pkg_dir) else None
1794
+ except ValueError:
1795
+ _pm = None
1796
+ if _pm:
1797
+ import wikilib as _wl
1798
+ _members = set(str(m) for m in (_pm.get("members") or []))
1799
+ if os.path.isdir(COMMANDS_DIR):
1800
+ for d in sorted(os.listdir(COMMANDS_DIR)):
1801
+ _fm = os.path.join(COMMANDS_DIR, d, "SKILL.md")
1802
+ if os.path.exists(_fm):
1803
+ _own = _owner_list(_wl.parse_frontmatter(open(_fm, encoding="utf-8").read()).get("owner"))
1804
+ if _own and _own[0] in _members:
1805
+ shutil.rmtree(os.path.join(COMMANDS_DIR, d))
1806
+ _sk = os.path.join(SKILLS_DIR, d)
1807
+ os.path.isdir(_sk) and shutil.rmtree(_sk)
1808
+ for c in _pm.get("connectors") or []:
1809
+ _cd = os.path.join(CONNECTORS_DIR, str(c.get("name")))
1810
+ if os.path.isdir(_cd):
1811
+ shutil.rmtree(_cd)
1812
+ _sk = os.path.join(SKILLS_DIR, str(c.get("name")))
1813
+ if os.path.isdir(_sk):
1814
+ shutil.rmtree(_sk)
1815
+ shutil.rmtree(_pkg_dir, ignore_errors=True)
1816
+ _journal("sweep", f"{k} orphaned package state auto-swept (package absent, residue cleaned)")
1817
+ if _orphans:
1818
+ _save_ledger(_led)
1819
+ _banner_set(_led)
1820
+ print(f"[sweep] orphaned migration state removed: {', '.join(_orphans)} (package no longer installed)")
1821
+ if _led and cmd:
1822
+ _ps = "; ".join(f"{k} {v.get('from', '?')}->{v.get('to', '?')}" for k, v in sorted(_led.items()))
1823
+ print(f"[pending] SEMANTIC MIGRATIONS OUTSTANDING: {_ps} — kernel migrate <pkg>; validate stays red until cleared")
1824
+ plugins, errors = load_plugins()
1825
+ pkgs = load_packages(errors)
1826
+ if cmd == "pkg":
1827
+ if errors:
1828
+ for e in errors:
1829
+ print(e)
1830
+ return 1
1831
+ do_pkg_ls(pkgs, plugins)
1832
+ return 0
1833
+ if cmd == "residual":
1834
+ return do_residual(sys.argv[2] if len(sys.argv) > 2 else None, pkgs)
1835
+ if cmd == "ls":
1836
+ do_ls(plugins)
1837
+ if errors:
1838
+ print(f"[warning] {len(errors)} manifest load errors — run validate for details")
1839
+ return 0
1840
+ if cmd == "audit":
1841
+ for e in errors:
1842
+ print(e)
1843
+ return do_audit(plugins, sys.argv[2] if len(sys.argv) > 2 else None)
1844
+ if cmd not in ("validate", "inject", "registry", "deploy", "all"):
1845
+ print(__doc__)
1846
+ return 2
1847
+ scope = None
1848
+ if len(sys.argv) > 3 and sys.argv[2] == "--scope":
1849
+ scope = sys.argv[3]
1850
+ if scope not in pkgs:
1851
+ print(f"[error] --scope {scope}: no such package (see: kernel pkg)")
1852
+ return 1
1853
+ members = set(pkgs[scope].get("members") or [])
1854
+ plugins = {p: m for p, m in plugins.items() if p in members}
1855
+ warns = []
1856
+ validate(plugins, errors, scope=scope, warnings=warns)
1857
+ if scope:
1858
+ print(f"[scope] bindings/bridges/package checks skipped — single-package mode over {scope} (full-graph disciplines run where the full graph lives)")
1859
+ else:
1860
+ validate_bindings(plugins, errors)
1861
+ validate_bridges(plugins, errors)
1862
+ validate_packages(plugins, pkgs, errors, warns)
1863
+ for k, v in sorted(_led.items()):
1864
+ errors.append(f"[error] pending semantic migration {k} {v.get('from', '?')}->{v.get('to', '?')} — kernel migrate {k} --clear; mechanical blessing withheld (tri-face warning)")
1865
+ for w in warns:
1866
+ print(f"[warning] {w}")
1867
+ for e in errors:
1868
+ print(e)
1869
+ if errors:
1870
+ print(f"[result] validation failed ({len(errors)} errors) — mechanical actions blocked")
1871
+ return 1
1872
+ label = f"package {scope}" if scope else f"all {len(plugins)} plugins"
1873
+ print(f"[validate] {label} passed (fields / id match / deps exist / acyclic / command bindings / usage routes / bridges)")
1874
+ if cmd in ("inject", "all"):
1875
+ if not do_inject(plugins):
1876
+ return 1
1877
+ if not do_check_inject(plugins):
1878
+ return 1
1879
+ if not do_cmd_inject(plugins):
1880
+ return 1
1881
+ if cmd in ("registry", "all"):
1882
+ if not do_registry(plugins):
1883
+ return 1
1884
+ if cmd in ("deploy", "all"):
1885
+ if not do_deploy():
1886
+ return 1
1887
+ return 0
1888
+
1889
+
1890
+ if __name__ == "__main__":
1891
+ sys.exit(main())