owfadev 0.3.2 → 0.4.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 (117) hide show
  1. package/.agents/skills/owfa-check/SKILL.md +49 -0
  2. package/.agents/skills/{docs → owfa-docs}/SKILL.md +5 -5
  3. package/.agents/skills/{wiki_plugin_kernel → owfa-kernel}/SKILL.md +13 -12
  4. package/{.meta/command/plugin → .agents/skills/owfa-plugin}/SKILL.md +10 -10
  5. package/.agents/skills/{query → owfa-query}/SKILL.md +5 -6
  6. package/.owfa/VERSION +1 -0
  7. package/.owfa/command/owfa-check/SKILL.md +49 -0
  8. package/{.meta/command/docs → .owfa/command/owfa-docs}/SKILL.md +5 -5
  9. package/{.meta/command/wiki_plugin_kernel → .owfa/command/owfa-kernel}/SKILL.md +13 -12
  10. package/{.agents/skills/plugin → .owfa/command/owfa-plugin}/SKILL.md +10 -10
  11. package/{.meta/command/query → .owfa/command/owfa-query}/SKILL.md +5 -6
  12. package/{.meta → .owfa}/docs/agent-behavior.md +3 -2
  13. package/{.meta → .owfa}/docs/agents-example.md +1 -1
  14. package/{.meta → .owfa}/docs/conversion.md +2 -2
  15. package/{.meta → .owfa}/docs/deployment.md +51 -46
  16. package/{.meta → .owfa}/docs/developer.md +4 -4
  17. package/{.meta → .owfa}/docs/distribution.md +21 -19
  18. package/.owfa/docs/home.md +61 -0
  19. package/{.meta → .owfa}/docs/intro.md +5 -5
  20. package/{.meta → .owfa}/docs/log-example.md +1 -1
  21. package/{.meta → .owfa}/docs/map.md +4 -3
  22. package/{.meta → .owfa}/docs/mechanics.md +11 -11
  23. package/{.meta → .owfa}/docs/naming.md +7 -7
  24. package/{.meta → .owfa}/docs/packages.md +80 -15
  25. package/.owfa/docs/plugin-lifecycle.md +70 -0
  26. package/{.meta → .owfa}/docs/quickstart.md +12 -12
  27. package/{.meta → .owfa}/docs/research-user-profile.md +1 -1
  28. package/{.meta → .owfa}/docs/sandbox.md +3 -3
  29. package/{.meta → .owfa}/docs/usage.md +1 -1
  30. package/{.meta → .owfa}/protocol/actions.md +6 -6
  31. package/.owfa/protocol/registry.yaml +34 -0
  32. package/.owfa/scripts/owfa_home.py +273 -0
  33. package/{.meta/scripts/wiki_plugin_kernel.py → .owfa/scripts/owfa_kernel.py} +521 -77
  34. package/{.meta → .owfa}/scripts/pipeline.py +9 -9
  35. package/.owfa/scripts/wiki_plugin_kernel.py +15 -0
  36. package/{.meta → .owfa}/scripts/wikilib.py +2 -2
  37. package/.owfa/tests/test_capabilities.py +311 -0
  38. package/{.meta → .owfa}/tests/test_install_preflight.py +11 -7
  39. package/.owfa/tests/test_owfa_home.py +127 -0
  40. package/.owfa/tests/test_version_scheme.py +68 -0
  41. package/README.md +32 -24
  42. package/README.zh-CN.md +32 -24
  43. package/bin/owfa.js +483 -65
  44. package/package.json +3 -2
  45. package/payloads/personal/PKG.yaml +15 -0
  46. package/payloads/personal/README.md +7 -0
  47. package/{.meta/plugins/core → payloads/personal}/calendar/PLUGIN.md +1 -0
  48. package/{.meta/plugins/core → payloads/personal}/calendar/PLUGIN.yaml +3 -3
  49. package/{.agents/skills/map → payloads/personal/command/owfa-map}/SKILL.md +9 -9
  50. package/{.meta/command/profile → payloads/personal/command/owfa-profile}/SKILL.md +11 -11
  51. package/{.agents/skills/save → payloads/personal/command/owfa-save}/SKILL.md +10 -10
  52. package/{.meta/plugins/core → payloads/personal}/cron/PLUGIN.md +2 -0
  53. package/{.meta/plugins/core → payloads/personal}/cron/PLUGIN.yaml +3 -3
  54. package/{.meta/plugins/core → payloads/personal}/device/PLUGIN.md +1 -0
  55. package/{.meta/plugins/core → payloads/personal}/device/PLUGIN.yaml +3 -3
  56. package/{.meta/plugins/core → payloads/personal}/domain/PLUGIN.md +2 -0
  57. package/payloads/personal/domain/PLUGIN.yaml +8 -0
  58. package/{.meta/plugins/core → payloads/personal}/hot/PLUGIN.yaml +1 -1
  59. package/{.meta/plugins/core → payloads/personal}/index/PLUGIN.md +1 -0
  60. package/{.meta/plugins/core → payloads/personal}/index/PLUGIN.yaml +4 -4
  61. package/{.meta/plugins/core → payloads/personal}/instance-git/PLUGIN.md +5 -0
  62. package/{.meta/plugins/core → payloads/personal}/instance-git/PLUGIN.yaml +3 -3
  63. package/{.meta/plugins/core → payloads/personal}/language/PLUGIN.md +1 -0
  64. package/{.meta/plugins/core → payloads/personal}/language/PLUGIN.yaml +4 -4
  65. package/{.meta/plugins/core → payloads/personal}/link/PLUGIN.md +1 -0
  66. package/{.meta/plugins/core → payloads/personal}/link/PLUGIN.yaml +3 -3
  67. package/{.meta/plugins/core → payloads/personal}/link/scripts/check.py +1 -1
  68. package/{.meta/plugins/core → payloads/personal}/log/PLUGIN.md +1 -0
  69. package/{.meta/plugins/core → payloads/personal}/log/PLUGIN.yaml +4 -4
  70. package/{.meta/plugins/core → payloads/personal}/mapping/PLUGIN.yaml +1 -1
  71. package/{.meta/plugins/core → payloads/personal}/notes/PLUGIN.md +1 -0
  72. package/{.meta/plugins/core → payloads/personal}/notes/PLUGIN.yaml +4 -4
  73. package/{.meta/plugins/core → payloads/personal}/sessions/PLUGIN.md +2 -1
  74. package/{.meta/plugins/core → payloads/personal}/sessions/PLUGIN.yaml +5 -5
  75. package/{.meta/plugins/core → payloads/personal}/structure/PLUGIN.md +2 -0
  76. package/{.meta/plugins/core → payloads/personal}/structure/PLUGIN.yaml +3 -3
  77. package/{.meta/plugins/core → payloads/personal}/structure/scripts/check.py +1 -1
  78. package/{.meta/plugins/core → payloads/personal}/tag/PLUGIN.md +2 -0
  79. package/{.meta/plugins/core → payloads/personal}/tag/PLUGIN.yaml +3 -3
  80. package/{.meta/plugins/core → payloads/personal}/tag/scripts/check.py +3 -1
  81. package/{.meta/plugins/core → payloads/personal}/tmp/PLUGIN.md +1 -0
  82. package/{.meta/plugins/core → payloads/personal}/tmp/PLUGIN.yaml +3 -3
  83. package/{.meta/plugins/core → payloads/personal}/todo/PLUGIN.md +2 -0
  84. package/{.meta/plugins/core → payloads/personal}/todo/PLUGIN.yaml +6 -5
  85. package/payloads/personal/todo/scripts/check.py +51 -0
  86. package/{.meta/plugins/core → payloads/personal}/trust/PLUGIN.md +1 -0
  87. package/{.meta/plugins/core → payloads/personal}/trust/PLUGIN.yaml +3 -3
  88. package/{.meta/plugins/core → payloads/personal}/user-profile/PLUGIN.md +2 -0
  89. package/{.meta/plugins/core → payloads/personal}/user-profile/PLUGIN.yaml +7 -6
  90. package/payloads/personal/user-profile/scripts/check.py +20 -0
  91. package/{.meta/plugins/core → payloads/personal}/vault/PLUGIN.md +1 -0
  92. package/payloads/personal/vault/PLUGIN.yaml +13 -0
  93. package/{.meta/plugins/core → payloads/personal}/wiki/PLUGIN.md +1 -0
  94. package/payloads/personal/wiki/PLUGIN.yaml +8 -0
  95. package/templates/AGENTS.template.md +2 -2
  96. package/.agents/skills/check/SKILL.md +0 -130
  97. package/.agents/skills/profile/SKILL.md +0 -75
  98. package/.meta/VERSION +0 -1
  99. package/.meta/command/check/SKILL.md +0 -130
  100. package/.meta/command/map/SKILL.md +0 -72
  101. package/.meta/command/save/SKILL.md +0 -94
  102. package/.meta/plugins/core/domain/PLUGIN.yaml +0 -8
  103. package/.meta/plugins/core/vault/PLUGIN.yaml +0 -13
  104. package/.meta/plugins/core/wiki/PLUGIN.yaml +0 -8
  105. package/.meta/protocol/registry.yaml +0 -75
  106. /package/{.meta → .owfa}/command/asset-read/SKILL.md +0 -0
  107. /package/{.meta → .owfa}/docs/research-landscape.md +0 -0
  108. /package/{.meta → .owfa}/protocol/experiments.md +0 -0
  109. /package/{.meta/plugins/core → payloads/personal}/device/scripts/check.py +0 -0
  110. /package/{.meta/plugins/core → payloads/personal}/hot/PLUGIN.md +0 -0
  111. /package/{.meta/plugins/core → payloads/personal}/instance-git/scripts/check.py +0 -0
  112. /package/{.meta/plugins/core → payloads/personal}/language/scripts/check.py +0 -0
  113. /package/{.meta/plugins/core → payloads/personal}/log/scripts/check.py +0 -0
  114. /package/{.meta/plugins/core → payloads/personal}/mapping/PLUGIN.md +0 -0
  115. /package/{.meta/plugins/core → payloads/personal}/mapping/scripts/check.py +0 -0
  116. /package/{.meta/plugins/core → payloads/personal}/sessions/scripts/check.py +0 -0
  117. /package/{.meta/plugins/core → payloads/personal}/trust/scripts/check.py +0 -0
@@ -2,33 +2,62 @@
2
2
  # -*- coding: utf-8 -*-
3
3
  """owfa plugin lifecycle mechanical kernel ((un)install / compliance / dependencies / injection / registry / copy sync).
4
4
 
5
- Positioning (see .meta/protocol/actions.md execution principles): deterministic structural
5
+ Positioning (see .owfa/protocol/actions.md execution principles): deterministic structural
6
6
  operations go to this script; semantic judgment goes to the LLM. The script touches only
7
7
  four places: plugin directories in and out, AGENTS.md injection-region marker blocks,
8
8
  the registry.yaml plugin section, and .agents/skills/ command copies; the protocol /
9
9
  reserved sections and handwritten regions are never touched.
10
10
 
11
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)
12
+ python .owfa/scripts/owfa_kernel.py ls # list + dependencies
13
+ python .owfa/scripts/owfa_kernel.py validate # compliance and dependency check (read-only, exit code 1 on errors)
14
+ python .owfa/scripts/owfa_kernel.py audit # plugin attached audits (discovery-style execution of each plugin's scripts/check.py)
15
+ python .owfa/scripts/owfa_kernel.py inject # rebuild AGENTS.md injection region, check inspection blocks and command usage blocks (idempotent)
16
+ python .owfa/scripts/owfa_kernel.py registry # rebuild the registry.yaml plugin section (idempotent)
17
+ python .owfa/scripts/owfa_kernel.py deploy # sync command and connector skill deployment copies (idempotent)
18
+ python .owfa/scripts/owfa_kernel.py all # validate + inject + registry + deploy
19
+ python .owfa/scripts/owfa_kernel.py plan <pkg> # dependency plan for a package about to land
20
+ python .owfa/scripts/owfa_kernel.py install <pkg> [--yes] [--without ...] # fetch from registry/path -> domains/<pkg>/ (owner/repo needs --yes)
21
+ python .owfa/scripts/owfa_kernel.py uninstall <pkg> [--yes] # remove package + projections; stages teardown (x->0) when declared
22
+ python .owfa/scripts/owfa_kernel.py list-remote # registry catalog (INDEX.yaml): packages, versions, compat floors
23
+ python .owfa/scripts/owfa_kernel.py migrate [<pkg>] [--clear] [--force] # pending semantic migrations: status / clear (verify- and residual-gated)
24
+ python .owfa/scripts/owfa_kernel.py upgrade [<pkg>] # compare versions against the registry; apply updates
25
+ python .owfa/scripts/owfa_kernel.py pkg # installed packages: members, versions, connectors
26
+ python .owfa/scripts/owfa_kernel.py provides # installed packages: capability provides + needs satisfaction (offline instance view)
27
+ python .owfa/scripts/owfa_kernel.py residual <pkg> # anchor-surface reference scan (the default teardown gate)
27
28
 
28
29
  The manifest is a minimal YAML subset: top-level `key: value`, `key: []`, block lists
29
30
  (` - item`), one-level field dicts (` name: description`); double-quoted values are
30
31
  unquoted; inline comments (starting with ` #`) are stripped.
31
32
 
33
+ PKG.yaml (v0.4 de-parasitization) may additionally declare:
34
+ - `skeleton: [vault/, wiki/notes/]` — instance dirs the package needs; install
35
+ mkdirs them mechanically (the kernel itself no longer hardcodes any layout)
36
+ - `uninstall: {anchors: [wiki/profile.md, wiki/cron/]}` — teardown anchor surfaces
37
+ added to the kernel floor (AGENTS.md handwritten region) for the residual scan;
38
+ additive only, a directory anchor expands to the .md files inside it, and a
39
+ PKG.yaml without the key contributes nothing (backward compatible)
40
+
41
+ PKG.yaml (v0.4 rootless suites + capabilities) may additionally declare:
42
+ - `kind: suite` — a rootless package: N parallel members, no family root plugin and
43
+ none required (the owfa-personal shape). Suite packages skip the family-root /
44
+ closure laws; presence (subset discipline), compat, flat graph and id == dir name
45
+ still apply. Absent kind = the classic domain-family behavior (default)
46
+ - `needs: [owfa.org.v1]` / `provides: [owfa.org.v1]` — package-scale capabilities
47
+ (plugin level deliberately unsupported). Grammar
48
+ ^[a-z][a-z0-9-]*(\\.[a-z0-9-]+)*\\.v\\d+$; a non-owfa.* capability's first name
49
+ segment must equal the package id (owfa.* is framework-owned, policed at the
50
+ registry review gate). Matching is exact-name; one provider per capability
51
+ (exclusive slot); needs count only trust >= reviewed provides (registry INDEX row
52
+ is the trust source — --path/direct installs are unknown and their provides stay
53
+ unverified; --yes overrides at install, journaled). Convergence points: install
54
+ preflight (prospective set — upgrade boards installed − old + new provides),
55
+ validate (full-graph error, --scope warning), upgrade (prospective). Uninstall is
56
+ deliberately NOT a point: removing the last provider leaves a red validate while
57
+ the uninstall itself succeeds (intentional asymmetry)
58
+ - INDEX rows (registry) may carry `needs:` / `provides:` ('/'-separated lists, the
59
+ members precedent) and `commit:` (SHA pin — a clone whose HEAD != pin is refused)
60
+
32
61
  Dependencies and injection order:
33
62
  - depends declares behavioral or semantic dependencies (a bridging piece depending on the
34
63
  concept plugins at both ends is a semantic dependency); validate checks existence and acyclicity
@@ -43,11 +72,28 @@ Injection tier and budget (issue #12 layer discipline, see protocol/experiments.
43
72
  membership, else it exits into invisibility)
44
73
  - the projected plugin blocks carry a byte budget (INJECT_BUDGET): over-budget is a validate
45
74
  error (compress pointer lines or re-tier domain members) the region under budget
75
+ - each full-tier inject line carries a pointer-density guideline (INJECT_LINE_GUIDE, issue #49):
76
+ a line over it is a validate warning — the line is a routing pointer (path + read/write trigger
77
+ + the one easy-to-break invariant + pointer to PLUGIN.md), detail lives in PLUGIN.md / skills
46
78
 
47
79
  Command-plugin binding (mutual declaration, validate checks consistency):
48
80
  - command frontmatter adds owner: plugin id (multiple as an [a, b] list) or framework
49
81
  (cross-cutting, explicitly ownerless)
50
82
  - plugin manifest adds the optional field commands: list of command names this plugin drives
83
+
84
+ Transport contracts (v0.4 work order #56 / owfa#27, connector-agnostic domains):
85
+ - optional manifest field transports: {contract, recommends, declaration, providers} — a
86
+ domain declaring a connector-agnostic capability (e.g. qq.transport.v1). contract is the
87
+ capability id; recommends names the default connector (installed under connectors/<id>);
88
+ declaration names a wiki page (relative to wiki/) whose frontmatter `transport:` field
89
+ marks an active third-party transport; providers is a comma-separated scalar of
90
+ connector ids (minimal-YAML: nested block lists inside a block mapping are
91
+ silently dropped by the parser — per-provider harness / guarantees detail lives
92
+ in the contract docs and the registry row) — routing data, never enforcement
93
+ (the kernel stays harness-agnostic)
94
+ - validate checks the block structurally and judges health mechanically: contract declared
95
+ but neither connectors/<recommends> installed nor the declaration page carrying a
96
+ transport field → warning (unhealthy-but-usable, never an error)
51
97
  - error cases: command missing owner, owner pointing at an uninstalled plugin, framework
52
98
  combined with other owners, plugin declaring a nonexistent command, one-sided declaration
53
99
  on either side (owner not claimed by commands, or the reverse)
@@ -95,21 +141,21 @@ if hasattr(sys.stdout, "reconfigure"):
95
141
  sys.stderr.reconfigure(encoding="utf-8")
96
142
 
97
143
  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")
144
+ PLUGINS_DIR = os.path.join(ROOT, ".owfa", "plugins")
145
+ COMMANDS_DIR = os.path.join(ROOT, ".owfa", "command")
100
146
  CONNECTORS_DIR = os.path.join(ROOT, "connectors")
101
147
  SKILLS_DIR = os.path.join(ROOT, ".agents", "skills")
102
148
  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)
149
+ REGISTRY = os.path.join(ROOT, ".owfa", "protocol", "registry.yaml")
150
+ MIGRATIONS_DIR = os.path.join(ROOT, ".owfa", "migrations") # kernel zone: staged migration declarations (use-and-delete)
105
151
  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
152
+ JOURNAL = os.path.join(ROOT, ".owfa", "kernel-journal.log") # mechanical event journal, one line per op
107
153
 
108
154
  # manifest's seven fields (id / version / depends / updated / attachment / fields / inject) + optional commands
109
155
  REQUIRED_KEYS = ["id", "version", "depends", "updated", "attachment", "fields", "inject"]
110
156
  INJECT_START = "<!-- wiki-inject:start -->"
111
157
  INJECT_END = "<!-- wiki-inject:end -->"
112
- CHECK_SKILL = os.path.join(COMMANDS_DIR, "check", "SKILL.md")
158
+ CHECK_SKILL = os.path.join(COMMANDS_DIR, "owfa-check", "SKILL.md") # v0.4 rename: the check command master is owfa-check/ (owner: framework) — the pre-rename constant silently skipped the check-inject projection
113
159
  CHECK_INJECT_START = "<!-- check-inject:start -->"
114
160
  CHECK_INJECT_END = "<!-- check-inject:end -->"
115
161
  CMD_INJECT_START = "<!-- cmd-inject:start -->"
@@ -120,6 +166,9 @@ INJECT_TIERS = ("full", "member")
120
166
  # injection budget on the projected plugin blocks: smallest mainstream whole-file AGENTS.md cap
121
167
  # (Devin 16 KiB) minus ~1 KiB instance handwritten allowance; over-budget is a validate error
122
168
  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)
169
+ # per-line pointer-density guideline for full-tier inject lines (issue #49): the budget is the pressure
170
+ # valve, this is the gauge — over-guideline lines warn, never block (third-party packages migrate at their pace)
171
+ INJECT_LINE_GUIDE = 256
123
172
 
124
173
 
125
174
  def ordered_plugins(plugins):
@@ -143,7 +192,9 @@ def ordered_plugins(plugins):
143
192
 
144
193
 
145
194
  def strip_quotes(s):
146
- if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
195
+ # both quote kinds: single-quoted values (e.g. INDEX rows) used to keep their
196
+ # quote edges and silently fail every downstream fullmatch — stripped uniformly
197
+ if len(s) >= 2 and s[0] == s[-1] and s[0] in "\"'":
147
198
  return s[1:-1]
148
199
  return s
149
200
 
@@ -200,6 +251,8 @@ def parse_manifest(path):
200
251
 
201
252
  def _plugin_dirs(base):
202
253
  """Yield (plugin_id, dir) across layouts: flat <p>/, core/<p>/, domains/<pkg>/<p>/."""
254
+ if not os.path.isdir(base):
255
+ return # v0.4: the channel ships no plugins/ at all (core moved to the suite package) — empty set is valid
203
256
  for name in sorted(os.listdir(base)):
204
257
  pdir = os.path.join(base, name)
205
258
  if not os.path.isdir(pdir):
@@ -262,6 +315,59 @@ def reaches_plugin(plugins, start, target, seen=None):
262
315
  return target in deps or any(reaches_plugin(plugins, d, target, seen) for d in deps)
263
316
 
264
317
 
318
+ def _check_transports(name, m, errors, warnings):
319
+ """Structural + health checks for the optional transports block (work order #56 / owfa#27).
320
+
321
+ Health is mechanical and fail-open: contract declared but neither the recommended
322
+ connector installed nor a third-party transport declared in the wiki → warning
323
+ (unhealthy-but-usable). providers is a comma-separated scalar of connector ids —
324
+ the manifest parser is a minimal YAML subset: nested block lists inside a block
325
+ mapping are silently DROPPED (empirically, review on this PR), so per-provider
326
+ harness/guarantees detail lives in the contract docs and the registry row; the
327
+ kernel never enforces them (harness-agnostic by charter).
328
+ """
329
+ t = m.get("transports")
330
+ if t is None:
331
+ return
332
+ tag = f"{name}/PLUGIN.yaml"
333
+ if not isinstance(t, dict):
334
+ errors.append(f"[error] {tag}: transports must be a block mapping")
335
+ return
336
+ contract = t.get("contract")
337
+ if not isinstance(contract, str) or not contract.strip():
338
+ errors.append(f"[error] {tag}: transports.contract missing — name the capability (e.g. qq.transport.v1)")
339
+ for key in ("recommends", "declaration"):
340
+ if t.get(key) is not None and not isinstance(t[key], str):
341
+ errors.append(f"[error] {tag}: transports.{key} must be a string")
342
+ providers = t.get("providers")
343
+ if providers is not None and not isinstance(providers, str):
344
+ errors.append(
345
+ f"[error] {tag}: transports.providers must be a comma-separated scalar string of connector ids "
346
+ "(minimal-YAML: block lists inside a block mapping are silently dropped by the parser — review on this PR)"
347
+ )
348
+ elif providers == "":
349
+ errors.append(f"[error] {tag}: transports.providers is empty — a nested block list is silently dropped by the parser; write it as a comma-separated scalar")
350
+ elif isinstance(providers, str) and providers.strip():
351
+ bad = [x for x in (i.strip() for i in providers.split(",")) if x and not re.fullmatch(r"[\w.-]+", x)]
352
+ if bad:
353
+ errors.append(f"[error] {tag}: transports.providers entries must be bare connector ids, got {bad!r} (flow mappings are not parseable in a manifest)")
354
+ # mechanical health: recommended connector installed, or a third-party transport declared
355
+ recommends = t.get("recommends")
356
+ declaration = t.get("declaration")
357
+ installed = bool(recommends) and os.path.isdir(os.path.join(CONNECTORS_DIR, str(recommends)))
358
+ declared = False
359
+ if isinstance(declaration, str) and declaration.strip():
360
+ page = os.path.join(ROOT, "wiki", declaration)
361
+ if os.path.exists(page):
362
+ body = open(page, encoding="utf-8").read()
363
+ declared = bool(re.search(r"(?m)^transport:\s*\S", body))
364
+ if contract and not installed and not declared:
365
+ warnings.append(
366
+ f"{name}/: transport contract '{contract}' has no provider — install connectors/{recommends} "
367
+ f"or declare an active transport in wiki/{declaration} (the domain degrades to guidance until then)"
368
+ )
369
+
370
+
265
371
  def build_inject_blocks(plugins):
266
372
  """AGENTS.md injection-region plugin blocks with member-tier exits applied; returns (blocks, exited)."""
267
373
  blocks, exited = [], 0
@@ -295,6 +401,7 @@ def validate(plugins, errors, scope=None, warnings=None):
295
401
  errors.append(f"[error] {name}/: inject (projection line) empty")
296
402
  if m.get("inject_tier") is not None and m["inject_tier"] not in INJECT_TIERS:
297
403
  errors.append(f"[error] {name}/: inject_tier ({m['inject_tier']}) must be full or member")
404
+ _check_transports(name, m, errors, warnings)
298
405
  # stray-quote smell (post-parse): a value still carrying a quote edge means unbalanced
299
406
  # quoting — the classic artifact of a clipped value (issue #12 post-migration field report)
300
407
  if isinstance(m.get("inject"), str) and (m["inject"].startswith('"') or m["inject"].endswith('"')):
@@ -325,6 +432,10 @@ def validate(plugins, errors, scope=None, warnings=None):
325
432
  size = sum(len(b.encode("utf-8")) for b in blocks)
326
433
  if size > INJECT_BUDGET:
327
434
  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)")
435
+ for name, m in sorted(plugins.items()):
436
+ n = len(str(m.get("inject") or "").encode("utf-8"))
437
+ if m.get("inject_tier") != "member" and n > INJECT_LINE_GUIDE:
438
+ warnings.append(f"{name}/: inject line {n} B over the {INJECT_LINE_GUIDE} B pointer guideline — keep path + read/write trigger + pointer to PLUGIN.md, move detail there (issue #49)")
328
439
  # dependency existence
329
440
  for name, m in sorted(plugins.items()):
330
441
  for dep in m.get("depends") or []:
@@ -411,7 +522,7 @@ def validate_bindings(plugins, errors):
411
522
  continue
412
523
  for c in cmds:
413
524
  if c not in commands:
414
- errors.append(f"[error] {pid}/: commands declares missing command {c} (.meta/command/{c}/)")
525
+ errors.append(f"[error] {pid}/: commands declares missing command {c} (.owfa/command/{c}/)")
415
526
  elif c not in claimed:
416
527
  errors.append(f"[error] command {c}/: {pid} one-sided claim (owner is framework or missing)")
417
528
  elif pid not in claimed[c]:
@@ -432,7 +543,7 @@ def validate_bindings(plugins, errors):
432
543
  errors.append(f"[error] {pid}/: usage_routes declared but manifest lacks a usage list")
433
544
  for c in routes:
434
545
  if c not in commands:
435
- errors.append(f"[error] {pid}/: usage_routes target command {c} not found (.meta/command/{c}/)")
546
+ errors.append(f"[error] {pid}/: usage_routes target command {c} not found (.owfa/command/{c}/)")
436
547
  elif pid in consumes_map.get(c, []):
437
548
  errors.append(f"[error] command {c}/: {pid} both routed and in consumes (duplicate routing)")
438
549
 
@@ -638,7 +749,7 @@ def do_registry(plugins):
638
749
 
639
750
 
640
751
  def do_deploy():
641
- """Sync .meta/command/*/SKILL.md and connectors/*/SKILL.md -> .agents/skills/*/SKILL.md; orphan copies are reported only."""
752
+ """Sync .owfa/command/*/SKILL.md and connectors/*/SKILL.md -> .agents/skills/*/SKILL.md; orphan copies are reported only."""
642
753
  sources = {}
643
754
  for base in (COMMANDS_DIR, CONNECTORS_DIR):
644
755
  if not os.path.isdir(base):
@@ -676,7 +787,7 @@ def do_ls(plugins):
676
787
  tier = "member" if plugins[pid].get("inject_tier") == "member" else "full"
677
788
  size = len(str(plugins[pid].get("inject") or "").encode("utf-8"))
678
789
  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)")
790
+ print(f"{len(plugins)} plugins (.owfa/plugins/; injection order = dependency topo + alphabetical; inject budget {INJECT_BUDGET} B)")
680
791
  # usage routing table: plugin -> landing commands (own + source-side routes + command pulls)
681
792
  import wikilib
682
793
 
@@ -783,23 +894,41 @@ def load_packages(errors):
783
894
 
784
895
 
785
896
  def framework_version():
786
- """Framework version anchor (.meta/VERSION, 'X.Y' — #20 D2); None when absent."""
787
- vpath = os.path.join(ROOT, ".meta", "VERSION")
897
+ """Framework release version (.owfa/VERSION, 'X.Y.Z' — legacy 'X.Y' tolerated); None when absent.
898
+
899
+ Returns the full tuple: compat floors ('>= X.Y') compare against it with plain
900
+ tuple-prefix semantics, which keeps architecture floors correct across patch
901
+ releases (versioning scheme, 0.4 — the anchor carries the full release)."""
902
+ vpath = os.path.join(ROOT, ".owfa", "VERSION")
788
903
  if not os.path.exists(vpath):
789
904
  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
905
+ m = re.fullmatch(r"\s*(\d+)\.(\d+)(?:\.(\d+))?\s*", open(vpath, encoding="utf-8").read())
906
+ if not m:
907
+ return None
908
+ return tuple(int(g) for g in m.groups() if g is not None)
792
909
 
793
910
 
794
911
  def validate_packages(plugins, pkgs, errors, warnings):
795
912
  """Package invariants (issue #20 定稿 D1/D2/D5): roster == family closure (authoring side),
796
913
  full presence (site side, v1 — subsets land with --without later), flat package graph,
797
- compat vs the .meta/VERSION anchor."""
914
+ compat vs the .owfa/VERSION anchor.
915
+
916
+ kind discriminant (v0.4 rootless suites): `kind: suite` marks a rootless package —
917
+ N parallel members, no family root plugin, none required (the owfa-personal shape).
918
+ Suite packages skip the family-root/closure laws entirely (D5 root prunability,
919
+ roster == closure); presence (subset discipline), compat, flat graph and the
920
+ id == directory-name consistency still apply. A missing kind defaults to the
921
+ classic domain-family behavior — fully backward compatible."""
798
922
  seen_member = {}
799
923
  fw = framework_version()
800
924
  if fw is None:
801
- warnings.append(".meta/VERSION missing — framework anchor absent, PKG compat unchecked")
925
+ warnings.append(".owfa/VERSION missing — framework anchor absent, PKG compat unchecked")
802
926
  for pid, pkg in sorted(pkgs.items()):
927
+ kind = str(pkg.get("kind") or "domain-family")
928
+ if kind not in ("domain-family", "suite"):
929
+ errors.append(f"[error] PKG {pid}: kind ({kind}) must be 'suite' or 'domain-family' (absent key = domain-family)")
930
+ continue
931
+ suite = kind == "suite"
803
932
  members = pkg.get("members")
804
933
  if not isinstance(members, list) or not members:
805
934
  errors.append(f"[error] PKG {pid}: members must be a non-empty list")
@@ -808,25 +937,36 @@ def validate_packages(plugins, pkgs, errors, warnings):
808
937
  if mem in seen_member:
809
938
  errors.append(f"[error] PKG {pid}: member {mem} also declared by PKG {seen_member[mem]} (one plugin one package)")
810
939
  seen_member[mem] = pid
811
- if pid not in members:
940
+ if not suite and pid not in members:
812
941
  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)
942
+ if not suite and all(m in plugins for m in members): # full presence → authoring invariant (roster == closure); subsets exempt (site invariant below covers them)
814
943
  closure = {pid} | {p for p in plugins if reaches_plugin(plugins, p, pid)}
815
944
  for extra in sorted(set(members) - closure):
816
945
  errors.append(f"[error] PKG {pid}: member {extra} declared but outside the family closure (depends chain never reaches {pid})")
817
946
  for miss in sorted(closure - set(members)):
818
947
  errors.append(f"[error] PKG {pid}: {miss} is in the family closure but missing from members (roster drift)")
819
948
  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)")
949
+ if not suite:
950
+ if pid not in plugins:
951
+ errors.append(f"[error] PKG {pid}: family root {pid} must be installed (D5: roots are never prunable)")
952
+ elif absent:
953
+ closed = all(dep not in members or dep in plugins
954
+ for mem in members if mem in plugins
955
+ for dep in (plugins[mem].get("depends") or []))
956
+ if closed:
957
+ warnings.append(f"PKG {pid}: subset install — pruned {', '.join(absent)} (D5 legal subset)")
958
+ else:
959
+ errors.append(f"[error] PKG {pid}: installed subset not dependency-closed (structural member pruned while dependents remain — see kernel plan <pkg> --without ...)")
822
960
  elif absent:
961
+ # rootless suite: no root to protect — the presence law is the subset
962
+ # discipline alone (installed members must not depend on pruned ones)
823
963
  closed = all(dep not in members or dep in plugins
824
964
  for mem in members if mem in plugins
825
965
  for dep in (plugins[mem].get("depends") or []))
826
966
  if closed:
827
- warnings.append(f"PKG {pid}: subset install — pruned {', '.join(absent)} (D5 legal subset)")
967
+ warnings.append(f"PKG {pid}: suite subset install — pruned {', '.join(absent)}")
828
968
  else:
829
- errors.append(f"[error] PKG {pid}: installed subset not dependency-closed (structural member pruned while dependents remain — see kernel plan <pkg> --without ...)")
969
+ errors.append(f"[error] PKG {pid}: installed suite subset not dependency-closed (member pruned while dependents remain)")
830
970
  for dep in pkg.get("depends") or []:
831
971
  if dep != "framework":
832
972
  errors.append(f"[error] PKG {pid}: package dependency {dep} not allowed in v1 (flat graph — framework only)")
@@ -839,30 +979,170 @@ def validate_packages(plugins, pkgs, errors, warnings):
839
979
  errors.append(f"[error] PKG {pid}: compat {compat} exceeds framework version {'.'.join(map(str, fw))}")
840
980
 
841
981
 
982
+ # ---------- capabilities (v0.4 needs/provides, work order #56 follow-up) ----------
983
+ # PKG.yaml top-level flow lists: `needs: [owfa.org.v1]` / `provides: [owfa.org.v1]`
984
+ # (plugin level deliberately unsupported — a capability is a package-scale contract).
985
+ # Grammar: ^[a-z][a-z0-9-]*(\.[a-z0-9-]+)*\.v\d+$ (e.g. owfa.org.v1, qq.transport.v1)
986
+ # Prefix rule: a non-`owfa.` capability's first name segment must equal the declaring
987
+ # package id (owfa.* is the framework-owned namespace; its definition right is enforced
988
+ # at the registry review gate, the kernel only checks grammar).
989
+ # Matching is exact-name equality: needs owfa.org.v1 is satisfied only by provides
990
+ # owfa.org.v1 — a v2 provider leaves a v1 need unsatisfied (reported distinctly).
991
+ CAPABILITY_RE = re.compile(r"^[a-z][a-z0-9-]*(?:\.[a-z0-9-]+)*\.v\d+$")
992
+ # trust levels whose provides may satisfy needs ("trust >= reviewed"); the registry
993
+ # INDEX row is the trust source — --path / owner-repo direct installs carry no row
994
+ # (trust unknown) and their provides stay unverified
995
+ TRUSTED_PROVIDERS = ("framework-bundled", "reviewed-3rd-party")
996
+
997
+
998
+ def _cap_list(value):
999
+ """Normalize a needs/provides declaration into a list: PKG.yaml flow lists as-is;
1000
+ INDEX-row strings split on '/' (aligning with the members precedent — a comma is a
1001
+ value terminator in the INDEX line grammar and would silently truncate)."""
1002
+ if value is None:
1003
+ return []
1004
+ if isinstance(value, list):
1005
+ vals = [str(x).strip() for x in value]
1006
+ else:
1007
+ vals = [x.strip() for x in str(value).replace("/", ",").split(",")]
1008
+ return [v for v in vals if v]
1009
+
1010
+
1011
+ def _cap_base(cap):
1012
+ """The name without the trailing .vN version segment (version-variant test)."""
1013
+ return cap.rsplit(".v", 1)[0]
1014
+
1015
+
1016
+ def capability_grammar_issues(pid, pkg):
1017
+ """Declaration-level checks (grammar + namespace prefix) for one package; list of messages."""
1018
+ out = []
1019
+ for key in ("needs", "provides"):
1020
+ for cap in _cap_list(pkg.get(key)):
1021
+ if not CAPABILITY_RE.fullmatch(cap):
1022
+ out.append(f"PKG {pid}: {key} entry {cap!r} fails the capability grammar ^[a-z][a-z0-9-]*(\\.[a-z0-9-]+)*\\.v\\d+$ (e.g. owfa.org.v1)")
1023
+ elif not cap.startswith("owfa.") and cap.split(".", 1)[0] != pid:
1024
+ out.append(f"PKG {pid}: {key} entry {cap!r} — the first name segment must equal the package id (non-owfa.* capabilities belong to their declarer; owfa.* is framework-owned)")
1025
+ return out
1026
+
1027
+
1028
+ def capability_providers(pkgs):
1029
+ """Provider map {capability: [package ids]} over the given package dict."""
1030
+ providers = {}
1031
+ for pid in sorted(pkgs):
1032
+ for cap in _cap_list(pkgs[pid].get("provides")):
1033
+ providers.setdefault(cap, []).append(pid)
1034
+ return providers
1035
+
1036
+
1037
+ def capability_issues(pkgs, trust_map=None, enforce_trust=True):
1038
+ """Graph checks over a package set: exclusive slots + needs satisfaction.
1039
+
1040
+ trust_map: {pkg id: trust level or None}. enforce_trust=False (index unavailable)
1041
+ degrades the trust gate to structural satisfaction only. Returns a list of
1042
+ (kind, message) with kind in {"exclusive", "missing", "version", "trust"}."""
1043
+ issues = []
1044
+ providers = capability_providers(pkgs)
1045
+ for cap, ps in sorted(providers.items()):
1046
+ if len(ps) > 1:
1047
+ issues.append(("exclusive", f"capability {cap} is provided by multiple installed packages ({', '.join(ps)}) — exclusive slot: at most one provider per capability"))
1048
+ provided = set(providers)
1049
+ for pid in sorted(pkgs):
1050
+ for need in _cap_list(pkgs[pid].get("needs")):
1051
+ ps = providers.get(need, [])
1052
+ trusted = [p for p in ps if not enforce_trust or trust_map.get(p) in TRUSTED_PROVIDERS]
1053
+ if trusted:
1054
+ continue
1055
+ if ps:
1056
+ # exact name present but unverified — trust gate (overridable with --yes at install)
1057
+ issues.append(("trust", f"PKG {pid}: needs capability {need} — provided by {', '.join(ps)} at unverified trust (only reviewed provides satisfy needs; the registry INDEX row is the trust source)"))
1058
+ continue
1059
+ variants = sorted(c for c in provided if c != need and _cap_base(c) == _cap_base(need))
1060
+ if variants:
1061
+ issues.append(("version", f"PKG {pid}: needs capability {need} — capability present at different version ({', '.join(variants)}); matching is exact-name, a v2 provider does not satisfy a v1 need"))
1062
+ else:
1063
+ issues.append(("missing", f"PKG {pid}: needs capability {need} — capability missing entirely (no installed package provides it)"))
1064
+ return issues
1065
+
1066
+
1067
+ def _trust_map_from_index(index):
1068
+ return {pid: str(e.get("trust") or "") for pid, e in (index or {}).items()}
1069
+
1070
+
1071
+ def validate_capabilities(pkgs, errors, warnings, scope=None):
1072
+ """Capability laws at validate time (full-graph: errors; --scope single-package
1073
+ mode: downgraded to warnings, mirroring the dependency-absence tolerance)."""
1074
+ for pid, pkg in sorted(pkgs.items()):
1075
+ for msg in capability_grammar_issues(pid, pkg):
1076
+ if scope:
1077
+ warnings.append(f"{msg} — tolerated in single-package mode (full-graph disciplines run where the full graph lives)")
1078
+ else:
1079
+ errors.append(f"[error] {msg}")
1080
+ if not any(_cap_list(p.get("needs")) or _cap_list(p.get("provides")) for p in pkgs.values()):
1081
+ return # nobody declares capabilities — nothing to check (pre-0.4 packages)
1082
+ index, _src = load_index([], quiet=True, local_only=True) # read-only view: local INDEX / cache, never a network fetch
1083
+ enforce_trust = bool(index)
1084
+ if not enforce_trust:
1085
+ warnings.append("registry index unavailable (offline, no cache) — capability trust gate degraded to structural satisfaction")
1086
+ issues = capability_issues(pkgs, _trust_map_from_index(index), enforce_trust)
1087
+ for kind, msg in issues:
1088
+ if scope:
1089
+ warnings.append(f"{msg} — tolerated in single-package mode")
1090
+ else:
1091
+ errors.append(f"[error] {msg}")
1092
+
1093
+
842
1094
  def do_pkg_ls(pkgs, plugins):
843
1095
  """Package inventory: PKG table + connectors + the implicit core remainder."""
844
1096
  claimed = {m for pkg in pkgs.values() for m in (pkg.get("members") or [])}
845
1097
  for pid, pkg in sorted(pkgs.items()):
846
1098
  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)}")
1099
+ kind = " (suite)" if str(pkg.get("kind") or "") == "suite" else ""
1100
+ print(f"pkg {pid:<10} v{pkg.get('version','?'):<5} compat {str(pkg.get('compat','?')):<8} {len(members)} members{kind}: {', '.join(members)}")
848
1101
  for c in pkg.get("connectors") or []:
849
1102
  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})")
1103
+ home = f", home {c['home']}" if c.get("home") else ""
1104
+ print(f"{'':16}connector {c.get('name')} ({where}{home})")
851
1105
  core = sorted(p for p in plugins if p not in claimed)
852
1106
  print(f"core (framework): {len(core)}: {', '.join(core)}")
853
1107
 
854
1108
 
855
- def _residual_hits(pkg_id, pkg):
856
- """Anchor-surface reference scan -> hit list (the default teardown gate)."""
1109
+ def _anchor_files(pkgs):
1110
+ """De-parasitized anchor surface (v0.4): the kernel floor (AGENTS.md, whose inject
1111
+ region is skipped — the handwritten region is never deleted) plus the union of
1112
+ `uninstall.anchors` declared by every loaded package. Declarations are additive
1113
+ only; a PKG.yaml without the key contributes nothing (backward compatible).
1114
+ An anchor path that is a directory (e.g. wiki/cron/) expands to the .md files
1115
+ inside it — the mechanical form of the old hardcoded cron special case.
1116
+ """
1117
+ files, seen = [], set()
1118
+
1119
+ def add(p):
1120
+ if p not in seen:
1121
+ seen.add(p)
1122
+ files.append(p)
1123
+
1124
+ add(AGENTS_MD)
1125
+ for pid in sorted(pkgs):
1126
+ un = pkgs[pid].get("uninstall")
1127
+ for a in ((un.get("anchors") if isinstance(un, dict) else None) or []):
1128
+ p = os.path.join(ROOT, str(a))
1129
+ if os.path.isdir(p):
1130
+ for f in sorted(os.listdir(p)):
1131
+ if f.endswith(".md"):
1132
+ add(os.path.join(p, f))
1133
+ else:
1134
+ add(p)
1135
+ return files
1136
+
1137
+
1138
+ def _residual_hits(pkg_id, pkg, pkgs=None):
1139
+ """Anchor-surface reference scan -> hit list (the default teardown gate).
1140
+
1141
+ pkgs (optional) widens the anchor surface to the union over all installed
1142
+ packages; the default {pkg_id: pkg} keeps single-package callers honest for
1143
+ a package that is no longer installed (teardown gate)."""
857
1144
  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")]
1145
+ anchors = _anchor_files(pkgs if pkgs is not None else {pkg_id: pkg})
866
1146
  hits = []
867
1147
  for path in anchors:
868
1148
  if not os.path.exists(path):
@@ -896,11 +1176,11 @@ def do_residual(pkg_id, pkgs):
896
1176
  if pkg is None:
897
1177
  print(f"[error] residual: no such package {pkg_id} (see: kernel pkg)")
898
1178
  return 1
899
- hits = _residual_hits(pkg_id, pkg)
1179
+ hits = _residual_hits(pkg_id, pkg, pkgs)
900
1180
  for h in hits:
901
1181
  print(f"[residual] {h}")
902
1182
  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")
1183
+ print(f"[residual] {len(hits)} reference(s) on anchor surfaces (AGENTS.md handwritten region ∪ uninstall.anchors declared by installed packages) — semantic cleanup before/after uninstall, per PKG uninstall.bridges")
904
1184
  else:
905
1185
  print("[residual] clean — no member references on semantic anchor surfaces")
906
1186
  return 0
@@ -909,19 +1189,31 @@ def do_residual(pkg_id, pkgs):
909
1189
  # ---------- process round: install / uninstall / list-remote / upgrade (issue #20 流程轮) ----------
910
1190
 
911
1191
  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"
1192
+ INDEX_REMOTE = "https://raw.githubusercontent.com/owfadev/owfa-registry/master/INDEX.yaml"
1193
+ FW_VERSION_REMOTE = "https://raw.githubusercontent.com/owfadev/owfa/master/.owfa/VERSION"
914
1194
 
915
1195
 
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)."""
1196
+ def load_index(errors, quiet=False, local_only=False):
1197
+ """Resolve the package index: local ROOT/INDEX.yaml first, then a short-TTL cache, else remote (cached on success; stale cache as fallback).
1198
+
1199
+ local_only=True never touches the network: local INDEX.yaml, else any cache file
1200
+ regardless of age (a read-only trust view for validate/`kernel provides`).
1201
+ Row fields (v0.4): needs / provides are '/'-separated capability lists (the
1202
+ members precedent — a comma terminates the value in the INDEX line grammar and
1203
+ would silently truncate a list); commit is an optional SHA pin (supply-chain
1204
+ content anchor, checked after clone). Absent fields are empty sets / no pin."""
918
1205
  import urllib.request
919
1206
  import time
920
1207
  raw, src = None, None
921
1208
  local = os.path.join(ROOT, INDEX_FILENAME)
922
- cache = os.path.join(ROOT, ".meta", "registry-cache.yaml")
1209
+ cache = os.path.join(ROOT, ".owfa", "registry-cache.yaml")
923
1210
  if os.path.exists(local):
924
1211
  raw, src = open(local, encoding="utf-8").read(), local
1212
+ elif local_only:
1213
+ if os.path.exists(cache):
1214
+ raw, src = open(cache, encoding="utf-8").read(), cache + " (local view)"
1215
+ else:
1216
+ return {}, None
925
1217
  else:
926
1218
  fetched = False
927
1219
  if os.path.exists(cache) and time.time() - os.path.getmtime(cache) < 600:
@@ -953,7 +1245,11 @@ def load_index(errors, quiet=False):
953
1245
 
954
1246
 
955
1247
  def _clone_package(entry, pkg, tag, errors):
956
- """git clone the package repo (git is the declared prerequisite); returns the clone dir or None."""
1248
+ """git clone the package repo (git is the declared prerequisite); returns the clone dir or None.
1249
+
1250
+ Supply-chain content anchor (v0.4): an INDEX row carrying `commit: <sha>` pins the
1251
+ expected HEAD — after the clone the actual HEAD SHA must equal the pin, else the
1252
+ clone is refused (tag/path drift between review and fetch)."""
957
1253
  import subprocess
958
1254
  import tempfile
959
1255
  url = entry.get("repo")
@@ -969,6 +1265,13 @@ def _clone_package(entry, pkg, tag, errors):
969
1265
  shutil.rmtree(tmp, ignore_errors=True)
970
1266
  errors.append(f"[error] clone {url}@{ref} failed: {(r.stderr or '').strip()[:200]}")
971
1267
  return None
1268
+ pin = str(entry.get("commit") or "").strip()
1269
+ if pin:
1270
+ head = subprocess.run(["git", "-C", tmp, "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip()
1271
+ if not head or head.lower() != pin.lower():
1272
+ shutil.rmtree(tmp, ignore_errors=True)
1273
+ errors.append(f"[error] commit pin mismatch for {pkg}: index pins {pin}, clone HEAD is {head or '?'} — supply-chain content anchor refused (registry row and fetched content disagree)")
1274
+ return None
972
1275
  return tmp
973
1276
 
974
1277
 
@@ -982,6 +1285,7 @@ def converge(label):
982
1285
  validate_bindings(plugins, errors)
983
1286
  validate_bridges(plugins, errors)
984
1287
  validate_packages(plugins, pkgs, errors, warns)
1288
+ validate_capabilities(pkgs, errors, warns)
985
1289
  for w in warns:
986
1290
  print(f"[warning] {w}")
987
1291
  for e in errors:
@@ -1035,8 +1339,12 @@ def _preflight_install(src, ignore):
1035
1339
  return True
1036
1340
 
1037
1341
 
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."""
1342
+ def do_install(spec, args, prev_version=None, prev_pkgm=None):
1343
+ """Install a package from the index (or --path <dir> for a local/unregistered one): masters + connectors + commands, then converge.
1344
+
1345
+ prev_pkgm (upgrade path): the ousted edition's PKG dict — its provides leave the
1346
+ capability preflight's prospective board (upgrade = uninstall→reinstall; the disk
1347
+ is mid-state when the reinstall preflights)."""
1040
1348
  pkg, _, tag = spec.partition("@") if spec else ("", "", "")
1041
1349
  path = None
1042
1350
  if "--path" in args:
@@ -1147,8 +1455,51 @@ def do_install(spec, args, prev_version=None):
1147
1455
  if os.path.exists(dst):
1148
1456
  print(f"[error] {pid} already installed — uninstall first (kernel uninstall {pid} --yes)")
1149
1457
  return 1
1150
- ignore = shutil.ignore_patterns(".git", ".meta", "connectors", "command", "README.md",
1458
+ ignore = shutil.ignore_patterns(".git", ".meta", ".owfa*", "connectors", "command", "README.md",
1151
1459
  "__pycache__", "migrations", *sorted(pruned))
1460
+ # ---- capability preflight (v0.4 needs/provides), prospective set ----
1461
+ # The board is the PROSPECTIVE graph: installed provides − the replaced edition's
1462
+ # old provides (upgrade runs uninstall→reinstall; at this point the disk is already
1463
+ # mid-state and reading it directly would false-refuse upgrading the sole provider
1464
+ # of a needed capability) + the incoming edition's provides. Grammar fails fast;
1465
+ # then exclusive slots and needs satisfaction. The trust gate is the only --yes-
1466
+ # overridable class (journaled); exclusive / missing / version refuse outright.
1467
+ _gram = capability_grammar_issues(pid, pkgm)
1468
+ for msg in _gram:
1469
+ print(f"[error] {msg}")
1470
+ if _gram:
1471
+ if not path:
1472
+ shutil.rmtree(src, ignore_errors=True)
1473
+ return 1
1474
+ if path or direct:
1475
+ caps_index, _ = load_index([], quiet=True, local_only=True) # trust view of installed providers only; the incoming channel itself is unknown-trust
1476
+ else:
1477
+ caps_index = index # registry channel: the row IS the trust source (reviewed-3rd-party / framework-bundled pass)
1478
+ _board = load_packages([])
1479
+ if prev_pkgm is not None:
1480
+ _board.pop(pid, None) # the ousted edition's provides leave the prospective board
1481
+ _board[pid] = pkgm
1482
+ _issues = capability_issues(_board, _trust_map_from_index(caps_index))
1483
+ _hard = [(kd, m) for kd, m in _issues if kd in ("exclusive", "missing", "version")]
1484
+ _soft = [(kd, m) for kd, m in _issues if kd == "trust"]
1485
+ if _hard:
1486
+ for _kd, m in _hard:
1487
+ print(f"[error] {m}")
1488
+ print("[install] capability preflight failed — package files and projections were not changed")
1489
+ if not path:
1490
+ shutil.rmtree(src, ignore_errors=True)
1491
+ return 1
1492
+ if _soft:
1493
+ if "--yes" not in args:
1494
+ for _kd, m in _soft:
1495
+ print(f"[error] {m}")
1496
+ print("[install] capability trust gate refused — only reviewed provides satisfy needs; --yes overrides (journaled)")
1497
+ if not path:
1498
+ shutil.rmtree(src, ignore_errors=True)
1499
+ return 1
1500
+ _caps = ", ".join(sorted({m.split("needs capability ")[1].split(" —")[0] for _kd, m in _soft if "needs capability " in m}))
1501
+ _journal("trust-override", f"{pid} — needs satisfied by unverified provides: {_caps or '?'} (--yes)")
1502
+ print(f"[warning] {pid}: capability trust gate overridden (--yes) — {_caps or 'unverified provides'} accepted unreviewed; journaled")
1152
1503
  if not _preflight_install(src, ignore):
1153
1504
  if not path:
1154
1505
  shutil.rmtree(src, ignore_errors=True)
@@ -1201,9 +1552,25 @@ def do_install(spec, args, prev_version=None):
1201
1552
  shutil.copyfile(os.path.join(tsrc, f), os.path.join(MIGRATIONS_DIR, pid, f))
1202
1553
  if not path:
1203
1554
  shutil.rmtree(src, ignore_errors=True)
1204
- print(f"[install] {pid}: masters -> .meta/plugins/domains/{pid}/"
1555
+ # de-parasitization (v0.4): the package declares its own skeleton dirs (flow list,
1556
+ # e.g. `skeleton: [vault/, wiki/notes/]`) — install mkdirs them mechanically; the
1557
+ # kernel no longer hardcodes any instance layout. Path-escape guard only: the
1558
+ # kernel does not judge the semantics of a declared skeleton.
1559
+ skeleton = [str(s).strip() for s in (pkgm.get("skeleton") or []) if str(s).strip()]
1560
+ for d in skeleton:
1561
+ if d.startswith(("/", "~")) or os.path.isabs(d) or ".." in d.split(os.sep) or ".." in d.split("/"):
1562
+ print(f"[error] {pid}: skeleton entry {d!r} escapes the instance — refused")
1563
+ return 1
1564
+ os.makedirs(os.path.join(ROOT, d), exist_ok=True)
1565
+ _un = pkgm.get("uninstall")
1566
+ _anchors = (_un.get("anchors") if isinstance(_un, dict) else None) or []
1567
+ print(f"[install] {pid}: masters -> .owfa/plugins/domains/{pid}/"
1568
+ + (f"; skeleton: {', '.join(skeleton)}" if skeleton else "")
1205
1569
  + (f"; connectors: {', '.join(copied_conn)}" if copied_conn else "")
1206
1570
  + (f"; commands: {', '.join(copied_cmd)}" if copied_cmd else ""))
1571
+ if _anchors:
1572
+ # teardown disclosure: declared anchor surfaces are additive and never deleted by the kernel
1573
+ print(f"[install] {pid} teardown anchors (declared, kernel never deletes them): {', '.join(str(a) for a in _anchors)}")
1207
1574
  _journal("install", f"{pid} v{pkgm.get('version')}" + (f" subset(-{','.join(sorted(pruned))})" if pruned else "") + (f" staged={','.join(staged)}" if staged else ""))
1208
1575
  converged = converge("install")
1209
1576
  if staged:
@@ -1285,8 +1652,15 @@ def do_uninstall(pkg, args):
1285
1652
  + (f"; commands: {', '.join(removed_cmd)}" if removed_cmd else "")
1286
1653
  + (f"; connectors: {', '.join(removed_conn)}" if removed_conn else ""))
1287
1654
  _journal("uninstall", f"{pkg} v{pkgm.get('version', '?')}")
1655
+ # capability note (v0.4, intentional asymmetry): uninstall is NOT a capability
1656
+ # convergence point — removing the last provider of a needed capability leaves a
1657
+ # red validate (the honest report) while the uninstall itself succeeds; the
1658
+ # needs/provides discipline is enforced at install/upgrade/validate, not teardown
1288
1659
  converge("uninstall")
1289
- do_residual(pkg, {pkg: pkgm})
1660
+ _errs = [] # anchor surface = kernel floor ∪ installed packages' declarations ∪ this package's own
1661
+ _all = load_packages(_errs)
1662
+ _all.setdefault(pkg, pkgm)
1663
+ do_residual(pkg, _all)
1290
1664
  un = pkgm.get("uninstall") or {}
1291
1665
  if un.get("retains"):
1292
1666
  print(f"[uninstall] retains (never touched): {', '.join(un['retains'])}")
@@ -1300,6 +1674,36 @@ def do_uninstall(pkg, args):
1300
1674
  return 0
1301
1675
 
1302
1676
 
1677
+ def do_provides(pkgs):
1678
+ """Instance view of capabilities (offline, disk-read only — no marketplace view):
1679
+ each installed package's provides, and the satisfaction status of every needs
1680
+ declaration (exact-name matching; trust annotated from the local index view)."""
1681
+ index, _src = load_index([], quiet=True, local_only=True)
1682
+ trust_map = _trust_map_from_index(index)
1683
+ providers = capability_providers(pkgs)
1684
+ if not pkgs:
1685
+ print("(no packages installed — kernel provides reads installed PKG.yaml files)")
1686
+ return 0
1687
+ for pid in sorted(pkgs):
1688
+ provs = _cap_list(pkgs[pid].get("provides"))
1689
+ trust = trust_map.get(pid, "")
1690
+ tmark = f" (trust: {trust})" if provs and trust else (" (trust: unknown — unverified provides)" if provs else "")
1691
+ print(f"pkg {pid}: provides {', '.join(provs) if provs else '—'}{tmark}")
1692
+ for need in _cap_list(pkgs[pid].get("needs")):
1693
+ ps = providers.get(need, [])
1694
+ if ps:
1695
+ tps = [p for p in ps if trust_map.get(p) in TRUSTED_PROVIDERS]
1696
+ state = "satisfied" if tps else "satisfied by UNVERIFIED provides (trust gate: only reviewed provides count)"
1697
+ print(f" needs {need} — {state}: {', '.join(ps)}")
1698
+ else:
1699
+ variants = sorted(c for c in providers if c != need and _cap_base(c) == _cap_base(need))
1700
+ if variants:
1701
+ print(f" needs {need} — NOT satisfied: capability present at different version ({', '.join(variants)}); matching is exact-name")
1702
+ else:
1703
+ print(f" needs {need} — NOT satisfied: capability missing entirely")
1704
+ return 0
1705
+
1706
+
1303
1707
  def do_list_remote():
1304
1708
  """Index table with installed marks — GitHub is the marketplace page, this is the agent surface."""
1305
1709
  errors = []
@@ -1369,11 +1773,14 @@ def _upgrade_action(pkg, args):
1369
1773
  if m and (int(m.group(1)), int(m.group(2))) > fw:
1370
1774
  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
1775
  return 1
1372
- # quiet mechanical reinstall (uninstall -> install-from-src with prev_version)
1776
+ # quiet mechanical reinstall (uninstall -> install-from-src with prev_version; the
1777
+ # old PKG rides along as prev_pkgm so the capability preflight boards the PROSPECTIVE
1778
+ # graph — mid-upgrade the disk lacks the package and its provides, and the sole
1779
+ # provider of a needed capability must be upgradable without a false refusal)
1373
1780
  buf = io.StringIO()
1374
1781
  with contextlib.redirect_stdout(buf):
1375
1782
  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
1783
+ ok2 = do_install(pkg + ("" if path else ""), (["--path", path] if path else []), prev_version=old_ver, prev_pkgm=old_pkgm) if ok1 == 0 else 1
1377
1784
  if ok2 != 0:
1378
1785
  print(buf.getvalue())
1379
1786
  print("[error] reinstall failed — state may be partial, rerun kernel validate")
@@ -1413,9 +1820,9 @@ def do_upgrade(pkg, args=None):
1413
1820
  try:
1414
1821
  import urllib.request
1415
1822
  rfw = urllib.request.urlopen(FW_VERSION_REMOTE, timeout=10).read().decode("utf-8").strip()
1416
- m = re.fullmatch(r"(\d+)\.(\d+)", rfw)
1823
+ m = re.fullmatch(r"(\d+)\.(\d+)(?:\.(\d+))?", rfw)
1417
1824
  if m:
1418
- rt = (int(m.group(1)), int(m.group(2)))
1825
+ rt = tuple(int(g) for g in m.groups() if g is not None)
1419
1826
  if fw and rt > fw:
1420
1827
  fw_note = f" — remote v{rfw} available (run `npx owfa upgrade`, or manual sync: template diff → selective copy → kernel all, see CONTRIBUTING)"
1421
1828
  elif fw:
@@ -1447,6 +1854,12 @@ def _vt(x):
1447
1854
  return tuple(int(i) for i in str(x).split("."))
1448
1855
 
1449
1856
 
1857
+ def _vline(x):
1858
+ """The B-line (major.minor) of a version — migrations and compat live on lines;
1859
+ releases carry the patch (versioning scheme, 0.4)."""
1860
+ return _vt(x)[:2]
1861
+
1862
+
1450
1863
  def _journal(op, detail):
1451
1864
  import datetime
1452
1865
  line = f"{datetime.datetime.now().isoformat(timespec='seconds')} {op} {detail}"
@@ -1500,9 +1913,12 @@ def _stage_migrations(migsrc, pid, prev_version, new_version):
1500
1913
  cands = [("init", "init.md")] if os.path.exists(os.path.join(migsrc, "init.md")) else []
1501
1914
  else:
1502
1915
  cands = []
1916
+ prev_line, new_line = _vline(prev_version), _vline(new_version)
1503
1917
  for f in sorted(os.listdir(migsrc)):
1504
1918
  m = re.fullmatch(r"(\d+\.\d+)-to-(\d+\.\d+)\.md", f)
1505
- if m and _vt(m.group(1)) >= _vt(prev_version):
1919
+ # stage every line-crossing this upgrade makes: [prev_line, new_line) —
1920
+ # same-line C updates stage nothing (versioning scheme, 0.4)
1921
+ if m and prev_line <= _vt(m.group(1)) < new_line:
1506
1922
  cands.append((f[:-3], f))
1507
1923
  for label, f in cands:
1508
1924
  shutil.copyfile(os.path.join(migsrc, f), os.path.join(MIGRATIONS_DIR, pid, f))
@@ -1618,9 +2034,12 @@ def do_migrate(pkg, args):
1618
2034
  elif ent.get("kind") == "teardown":
1619
2035
  pk = os.path.join(pdir, "PKG.yaml") # default gate: show what the residual scan still sees
1620
2036
  pkgm2 = parse_pkg(pk) if os.path.exists(pk) else {"members": [pkg]}
1621
- for h in _residual_hits(pkg, pkgm2)[:8]:
2037
+ _errs = []
2038
+ _all = load_packages(_errs) # anchor surface includes installed packages' declared anchors
2039
+ _all.setdefault(pkg, pkgm2)
2040
+ for h in _residual_hits(pkg, pkgm2, _all)[:8]:
1622
2041
  print(f"[migrate] residual: {h}")
1623
- if not _residual_hits(pkg, pkgm2):
2042
+ if not _residual_hits(pkg, pkgm2, _all):
1624
2043
  print("[migrate] residual gate: clean — ready to clear (--clear)")
1625
2044
  return 0
1626
2045
  # --clear: verify-gated (explicit assertion only via --force when no script)
@@ -1643,7 +2062,10 @@ def do_migrate(pkg, args):
1643
2062
  if ent.get("kind") == "teardown":
1644
2063
  pk = os.path.join(pdir, "PKG.yaml")
1645
2064
  pkgm2 = parse_pkg(pk) if os.path.exists(pk) else {"members": [pkg]}
1646
- hits = _residual_hits(pkg, pkgm2)
2065
+ _errs = []
2066
+ _all = load_packages(_errs)
2067
+ _all.setdefault(pkg, pkgm2)
2068
+ hits = _residual_hits(pkg, pkgm2, _all)
1647
2069
  if hits:
1648
2070
  print(f"[error] teardown incomplete — residual references on anchor surfaces: {hits[:5]}")
1649
2071
  return 1
@@ -1719,14 +2141,21 @@ def do_plan(spec, args):
1719
2141
  return out
1720
2142
 
1721
2143
  optional = [m for m in members if m != pid]
2144
+ kind = str(pkgm.get("kind") or "domain-family")
1722
2145
  plan = {
1723
2146
  "package": pid,
2147
+ "kind": kind,
1724
2148
  "version": pkgm.get("version"),
1725
2149
  "compat": pkgm.get("compat"),
1726
2150
  "trust": entry.get("trust", "?"),
2151
+ "needs": _cap_list(pkgm.get("needs")),
2152
+ "provides": _cap_list(pkgm.get("provides")),
1727
2153
  "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],
2154
+ # rootless suite: no family root — nothing is baseline-mandatory, every member is optional
2155
+ "baseline": ([] if kind == "suite" else [{"id": pid, "summary": menu.get(pid, "")}]),
2156
+ "optional": ([{"id": m, "summary": menu.get(m, ""), "pulls": sorted(closure_of(m) - {pid})} for m in optional]
2157
+ if kind != "suite" else
2158
+ [{"id": m, "summary": menu.get(m, ""), "pulls": sorted(closure_of(m))} for m in members]),
1730
2159
  "connectors": [{"name": c.get("name"), "kind": ("external" if c.get("external") else "system" if c.get("system") else "in-repo")}
1731
2160
  for c in (pkgm.get("connectors") or [])],
1732
2161
  "suggested_command": f"kernel install {pid}" + (" --yes" if direct else ""),
@@ -1749,9 +2178,16 @@ def do_plan(spec, args):
1749
2178
  if "--json" in args:
1750
2179
  print(_json.dumps(plan, ensure_ascii=False, indent=2))
1751
2180
  return 0
1752
- print(f"[plan] {pid} v{plan['version']} (compat {plan['compat']}) — trust: {plan['trust']}")
2181
+ print(f"[plan] {pid} v{plan['version']} (compat {plan['compat']}) — trust: {plan['trust']}"
2182
+ + ("; kind: suite (rootless — no family root member)" if kind == "suite" else ""))
1753
2183
  print(f"[plan] disclosure: {plan['disclosure']}")
1754
- print(f"[plan] baseline (always installed): {pid}" + (f" — {menu.get(pid, '')}" if menu.get(pid) else ""))
2184
+ if plan["needs"] or plan["provides"]:
2185
+ print(f"[plan] capabilities — needs: {', '.join(plan['needs']) or '—'}; provides: {', '.join(plan['provides']) or '—'}"
2186
+ + (" (provides count toward needs only at trust >= reviewed — this package's trust: {})".format(plan['trust']) if plan["provides"] else ""))
2187
+ if kind == "suite":
2188
+ print("[plan] baseline: none — rootless suite, every member is prunable via --without")
2189
+ else:
2190
+ print(f"[plan] baseline (always installed): {pid}" + (f" — {menu.get(pid, '')}" if menu.get(pid) else ""))
1755
2191
  for o in plan["optional"]:
1756
2192
  pulls = f" — pulls: {', '.join(o['pulls'])}" if o["pulls"] else ""
1757
2193
  print(f"[plan] optional: {o['id']}" + (f" — {o['summary']}" if o["summary"] else "") + pulls)
@@ -1832,6 +2268,12 @@ def main():
1832
2268
  return 0
1833
2269
  if cmd == "residual":
1834
2270
  return do_residual(sys.argv[2] if len(sys.argv) > 2 else None, pkgs)
2271
+ if cmd == "provides":
2272
+ if errors:
2273
+ for e in errors:
2274
+ print(e)
2275
+ return 1
2276
+ return do_provides(pkgs)
1835
2277
  if cmd == "ls":
1836
2278
  do_ls(plugins)
1837
2279
  if errors:
@@ -1855,11 +2297,13 @@ def main():
1855
2297
  warns = []
1856
2298
  validate(plugins, errors, scope=scope, warnings=warns)
1857
2299
  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)")
2300
+ print(f"[scope] bindings/bridges/package checks skipped; capability checks downgraded to warnings — single-package mode over {scope} (full-graph disciplines run where the full graph lives)")
2301
+ validate_capabilities(pkgs, [], warns, scope=scope)
1859
2302
  else:
1860
2303
  validate_bindings(plugins, errors)
1861
2304
  validate_bridges(plugins, errors)
1862
2305
  validate_packages(plugins, pkgs, errors, warns)
2306
+ validate_capabilities(pkgs, errors, warns)
1863
2307
  for k, v in sorted(_led.items()):
1864
2308
  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
2309
  for w in warns: