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.
- package/.agents/skills/owfa-check/SKILL.md +49 -0
- package/.agents/skills/{docs → owfa-docs}/SKILL.md +5 -5
- package/.agents/skills/{wiki_plugin_kernel → owfa-kernel}/SKILL.md +13 -12
- package/{.meta/command/plugin → .agents/skills/owfa-plugin}/SKILL.md +10 -10
- package/.agents/skills/{query → owfa-query}/SKILL.md +5 -6
- package/.owfa/VERSION +1 -0
- package/.owfa/command/owfa-check/SKILL.md +49 -0
- package/{.meta/command/docs → .owfa/command/owfa-docs}/SKILL.md +5 -5
- package/{.meta/command/wiki_plugin_kernel → .owfa/command/owfa-kernel}/SKILL.md +13 -12
- package/{.agents/skills/plugin → .owfa/command/owfa-plugin}/SKILL.md +10 -10
- package/{.meta/command/query → .owfa/command/owfa-query}/SKILL.md +5 -6
- package/{.meta → .owfa}/docs/agent-behavior.md +3 -2
- package/{.meta → .owfa}/docs/agents-example.md +1 -1
- package/{.meta → .owfa}/docs/conversion.md +2 -2
- package/{.meta → .owfa}/docs/deployment.md +51 -46
- package/{.meta → .owfa}/docs/developer.md +4 -4
- package/{.meta → .owfa}/docs/distribution.md +21 -19
- package/.owfa/docs/home.md +61 -0
- package/{.meta → .owfa}/docs/intro.md +5 -5
- package/{.meta → .owfa}/docs/log-example.md +1 -1
- package/{.meta → .owfa}/docs/map.md +4 -3
- package/{.meta → .owfa}/docs/mechanics.md +11 -11
- package/{.meta → .owfa}/docs/naming.md +7 -7
- package/{.meta → .owfa}/docs/packages.md +80 -15
- package/.owfa/docs/plugin-lifecycle.md +70 -0
- package/{.meta → .owfa}/docs/quickstart.md +12 -12
- package/{.meta → .owfa}/docs/research-user-profile.md +1 -1
- package/{.meta → .owfa}/docs/sandbox.md +3 -3
- package/{.meta → .owfa}/docs/usage.md +1 -1
- package/{.meta → .owfa}/protocol/actions.md +6 -6
- package/.owfa/protocol/registry.yaml +34 -0
- package/.owfa/scripts/owfa_home.py +273 -0
- package/{.meta/scripts/wiki_plugin_kernel.py → .owfa/scripts/owfa_kernel.py} +521 -77
- package/{.meta → .owfa}/scripts/pipeline.py +9 -9
- package/.owfa/scripts/wiki_plugin_kernel.py +15 -0
- package/{.meta → .owfa}/scripts/wikilib.py +2 -2
- package/.owfa/tests/test_capabilities.py +311 -0
- package/{.meta → .owfa}/tests/test_install_preflight.py +11 -7
- package/.owfa/tests/test_owfa_home.py +127 -0
- package/.owfa/tests/test_version_scheme.py +68 -0
- package/README.md +32 -24
- package/README.zh-CN.md +32 -24
- package/bin/owfa.js +483 -65
- package/package.json +3 -2
- package/payloads/personal/PKG.yaml +15 -0
- package/payloads/personal/README.md +7 -0
- package/{.meta/plugins/core → payloads/personal}/calendar/PLUGIN.md +1 -0
- package/{.meta/plugins/core → payloads/personal}/calendar/PLUGIN.yaml +3 -3
- package/{.agents/skills/map → payloads/personal/command/owfa-map}/SKILL.md +9 -9
- package/{.meta/command/profile → payloads/personal/command/owfa-profile}/SKILL.md +11 -11
- package/{.agents/skills/save → payloads/personal/command/owfa-save}/SKILL.md +10 -10
- package/{.meta/plugins/core → payloads/personal}/cron/PLUGIN.md +2 -0
- package/{.meta/plugins/core → payloads/personal}/cron/PLUGIN.yaml +3 -3
- package/{.meta/plugins/core → payloads/personal}/device/PLUGIN.md +1 -0
- package/{.meta/plugins/core → payloads/personal}/device/PLUGIN.yaml +3 -3
- package/{.meta/plugins/core → payloads/personal}/domain/PLUGIN.md +2 -0
- package/payloads/personal/domain/PLUGIN.yaml +8 -0
- package/{.meta/plugins/core → payloads/personal}/hot/PLUGIN.yaml +1 -1
- package/{.meta/plugins/core → payloads/personal}/index/PLUGIN.md +1 -0
- package/{.meta/plugins/core → payloads/personal}/index/PLUGIN.yaml +4 -4
- package/{.meta/plugins/core → payloads/personal}/instance-git/PLUGIN.md +5 -0
- package/{.meta/plugins/core → payloads/personal}/instance-git/PLUGIN.yaml +3 -3
- package/{.meta/plugins/core → payloads/personal}/language/PLUGIN.md +1 -0
- package/{.meta/plugins/core → payloads/personal}/language/PLUGIN.yaml +4 -4
- package/{.meta/plugins/core → payloads/personal}/link/PLUGIN.md +1 -0
- package/{.meta/plugins/core → payloads/personal}/link/PLUGIN.yaml +3 -3
- package/{.meta/plugins/core → payloads/personal}/link/scripts/check.py +1 -1
- package/{.meta/plugins/core → payloads/personal}/log/PLUGIN.md +1 -0
- package/{.meta/plugins/core → payloads/personal}/log/PLUGIN.yaml +4 -4
- package/{.meta/plugins/core → payloads/personal}/mapping/PLUGIN.yaml +1 -1
- package/{.meta/plugins/core → payloads/personal}/notes/PLUGIN.md +1 -0
- package/{.meta/plugins/core → payloads/personal}/notes/PLUGIN.yaml +4 -4
- package/{.meta/plugins/core → payloads/personal}/sessions/PLUGIN.md +2 -1
- package/{.meta/plugins/core → payloads/personal}/sessions/PLUGIN.yaml +5 -5
- package/{.meta/plugins/core → payloads/personal}/structure/PLUGIN.md +2 -0
- package/{.meta/plugins/core → payloads/personal}/structure/PLUGIN.yaml +3 -3
- package/{.meta/plugins/core → payloads/personal}/structure/scripts/check.py +1 -1
- package/{.meta/plugins/core → payloads/personal}/tag/PLUGIN.md +2 -0
- package/{.meta/plugins/core → payloads/personal}/tag/PLUGIN.yaml +3 -3
- package/{.meta/plugins/core → payloads/personal}/tag/scripts/check.py +3 -1
- package/{.meta/plugins/core → payloads/personal}/tmp/PLUGIN.md +1 -0
- package/{.meta/plugins/core → payloads/personal}/tmp/PLUGIN.yaml +3 -3
- package/{.meta/plugins/core → payloads/personal}/todo/PLUGIN.md +2 -0
- package/{.meta/plugins/core → payloads/personal}/todo/PLUGIN.yaml +6 -5
- package/payloads/personal/todo/scripts/check.py +51 -0
- package/{.meta/plugins/core → payloads/personal}/trust/PLUGIN.md +1 -0
- package/{.meta/plugins/core → payloads/personal}/trust/PLUGIN.yaml +3 -3
- package/{.meta/plugins/core → payloads/personal}/user-profile/PLUGIN.md +2 -0
- package/{.meta/plugins/core → payloads/personal}/user-profile/PLUGIN.yaml +7 -6
- package/payloads/personal/user-profile/scripts/check.py +20 -0
- package/{.meta/plugins/core → payloads/personal}/vault/PLUGIN.md +1 -0
- package/payloads/personal/vault/PLUGIN.yaml +13 -0
- package/{.meta/plugins/core → payloads/personal}/wiki/PLUGIN.md +1 -0
- package/payloads/personal/wiki/PLUGIN.yaml +8 -0
- package/templates/AGENTS.template.md +2 -2
- package/.agents/skills/check/SKILL.md +0 -130
- package/.agents/skills/profile/SKILL.md +0 -75
- package/.meta/VERSION +0 -1
- package/.meta/command/check/SKILL.md +0 -130
- package/.meta/command/map/SKILL.md +0 -72
- package/.meta/command/save/SKILL.md +0 -94
- package/.meta/plugins/core/domain/PLUGIN.yaml +0 -8
- package/.meta/plugins/core/vault/PLUGIN.yaml +0 -13
- package/.meta/plugins/core/wiki/PLUGIN.yaml +0 -8
- package/.meta/protocol/registry.yaml +0 -75
- /package/{.meta → .owfa}/command/asset-read/SKILL.md +0 -0
- /package/{.meta → .owfa}/docs/research-landscape.md +0 -0
- /package/{.meta → .owfa}/protocol/experiments.md +0 -0
- /package/{.meta/plugins/core → payloads/personal}/device/scripts/check.py +0 -0
- /package/{.meta/plugins/core → payloads/personal}/hot/PLUGIN.md +0 -0
- /package/{.meta/plugins/core → payloads/personal}/instance-git/scripts/check.py +0 -0
- /package/{.meta/plugins/core → payloads/personal}/language/scripts/check.py +0 -0
- /package/{.meta/plugins/core → payloads/personal}/log/scripts/check.py +0 -0
- /package/{.meta/plugins/core → payloads/personal}/mapping/PLUGIN.md +0 -0
- /package/{.meta/plugins/core → payloads/personal}/mapping/scripts/check.py +0 -0
- /package/{.meta/plugins/core → payloads/personal}/sessions/scripts/check.py +0 -0
- /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 .
|
|
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 .
|
|
13
|
-
python .
|
|
14
|
-
python .
|
|
15
|
-
python .
|
|
16
|
-
python .
|
|
17
|
-
python .
|
|
18
|
-
python .
|
|
19
|
-
python .
|
|
20
|
-
python .
|
|
21
|
-
python .
|
|
22
|
-
python .
|
|
23
|
-
python .
|
|
24
|
-
python .
|
|
25
|
-
python .
|
|
26
|
-
python .
|
|
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, ".
|
|
99
|
-
COMMANDS_DIR = os.path.join(ROOT, ".
|
|
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, ".
|
|
104
|
-
MIGRATIONS_DIR = os.path.join(ROOT, ".
|
|
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, ".
|
|
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
|
-
|
|
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} (.
|
|
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 (.
|
|
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 .
|
|
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 (.
|
|
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
|
|
787
|
-
|
|
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
|
-
|
|
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 .
|
|
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(".
|
|
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
|
|
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
|
|
821
|
-
|
|
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)}
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
856
|
-
"""
|
|
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 =
|
|
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
|
|
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/.
|
|
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, ".
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1729
|
-
"
|
|
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
|
-
|
|
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:
|