owfadev 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +63 -0
  41. package/README.md +32 -24
  42. package/README.zh-CN.md +32 -24
  43. package/bin/owfa.js +525 -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
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """OWFA machine substrate: connector homes and workspace bindings (work order #56, B1-B3).
4
+
5
+ Layout (machine-global — one machine, many workspaces):
6
+
7
+ ~/.owfa/connectors/<name>/
8
+ ├── bindings.json # [{id, root, home, bot?, installed_at}] — the registry
9
+ └── homes/<id>/ # one state dir per binding (credentials, spool, logs)
10
+
11
+ Resolution order for "which home serves this workspace":
12
+
13
+ explicit --root/--home argument
14
+ > OWFA_ROOT environment variable
15
+ > cwd reverse-lookup in the bindings registry
16
+ > homes/default (single-workspace machines: zero configuration)
17
+
18
+ Invariants (enforced here so every connector inherits them):
19
+
20
+ - one home, one active binding — two workspaces draining one spool is a semantic
21
+ error, not a health issue; `exclusivity_violations` reports it
22
+ - an unbound workspace is degraded-but-healthy: `resolve_home` falls back to
23
+ homes/default only for machines with exactly one binding-less usage pattern;
24
+ a workspace wanting isolation must bind explicitly
25
+ - unbind detaches the binding; homes/<id>/ holds data — deletion is a human act
26
+ - stale bindings (root directory gone) are reported by `stale_bindings` and
27
+ cleaned by explicit confirmation, never silently
28
+
29
+ Migration from pre-0.4 scattered homes (~/.qq-cli and friends): `migrate_legacy_home`
30
+ MOVES the legacy directory into homes/<id> (move, never copy — credentials must not
31
+ duplicated) and registers the binding.
32
+
33
+ Pure stdlib. State writes are atomic (tmp + os.replace) with 0600 perms; the
34
+ registry is single-writer by convention (human or installer), so no file lock.
35
+ """
36
+ import json
37
+ import os
38
+ import re
39
+ import secrets
40
+ import sys
41
+ from datetime import datetime, timezone
42
+ from pathlib import Path
43
+
44
+ ENV_HOME = "OWFA_HOME" # machine substrate root (default ~/.owfa)
45
+ ENV_ROOT = "OWFA_ROOT" # workspace root (set by executors / nested sessions)
46
+ BINDINGS_VERSION = 1
47
+ _SLUG_RE = re.compile(r"[^a-z0-9]+")
48
+
49
+
50
+ def machine_root() -> Path:
51
+ raw = os.environ.get(ENV_HOME)
52
+ return Path(raw).expanduser() if raw else Path.home() / ".owfa"
53
+
54
+
55
+ def connectors_root() -> Path:
56
+ return machine_root() / "connectors"
57
+
58
+
59
+ def connector_dir(connector: str) -> Path:
60
+ return connectors_root() / connector
61
+
62
+
63
+ def bindings_path(connector: str) -> Path:
64
+ return connector_dir(connector) / "bindings.json"
65
+
66
+
67
+ def homes_root(connector: str) -> Path:
68
+ return connector_dir(connector) / "homes"
69
+
70
+
71
+ def home_dir(connector: str, binding_id: str) -> Path:
72
+ return homes_root(connector) / binding_id
73
+
74
+
75
+ def _now_iso() -> str:
76
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
77
+
78
+
79
+ def load_bindings(connector: str) -> list:
80
+ try:
81
+ with open(bindings_path(connector), encoding="utf-8") as f:
82
+ data = json.load(f)
83
+ except (OSError, json.JSONDecodeError):
84
+ return []
85
+ out = data.get("bindings") if isinstance(data, dict) else None
86
+ return out if isinstance(out, list) else []
87
+
88
+
89
+ def save_bindings(connector: str, bindings: list) -> None:
90
+ path = bindings_path(connector)
91
+ path.parent.mkdir(parents=True, exist_ok=True)
92
+ try:
93
+ os.chmod(path.parent, 0o700)
94
+ except OSError:
95
+ pass
96
+ tmp = str(path) + ".tmp"
97
+ with open(tmp, "w", encoding="utf-8") as f:
98
+ json.dump({"version": BINDINGS_VERSION, "bindings": bindings}, f, ensure_ascii=False, indent=2)
99
+ f.write("\n")
100
+ f.flush()
101
+ os.fsync(f.fileno())
102
+ try:
103
+ os.chmod(tmp, 0o600)
104
+ except OSError:
105
+ pass
106
+ os.replace(tmp, path)
107
+
108
+
109
+ def _slug(root: str) -> str:
110
+ base = os.path.basename(os.path.normpath(root)).lower()
111
+ slug = _SLUG_RE.sub("-", base).strip("-")[:24] or "ws"
112
+ return f"{slug}-{secrets.token_hex(2)}"
113
+
114
+
115
+ def resolve_root(connector: str, explicit: str = "") -> str:
116
+ """Explicit argument > OWFA_ROOT env > cwd reverse-lookup > '' (unresolved → default)."""
117
+ if explicit:
118
+ return str(Path(explicit).expanduser().resolve())
119
+ env = os.environ.get(ENV_ROOT)
120
+ if env:
121
+ return str(Path(env).expanduser().resolve())
122
+ cwd = os.getcwd()
123
+ for b in load_bindings(connector):
124
+ try:
125
+ root = str(Path(b.get("root", "")).resolve())
126
+ except OSError:
127
+ continue
128
+ if root and (cwd == root or cwd.startswith(root + os.sep)):
129
+ return root
130
+ return ""
131
+
132
+
133
+ def resolve_home(connector: str, explicit_root: str = "", explicit_home: str = "") -> Path:
134
+ """The home directory serving the resolved workspace (B1: homes/<id>, default = zero-config)."""
135
+ if explicit_home:
136
+ return Path(explicit_home).expanduser()
137
+ root = resolve_root(connector, explicit_root)
138
+ bindings = load_bindings(connector)
139
+ for b in bindings:
140
+ if root and os.path.normpath(b.get("root", "")) == os.path.normpath(root):
141
+ return Path(b["home"])
142
+ if not root and len(bindings) == 1:
143
+ return Path(b["home"]) # single binding, no better signal — it is the default
144
+ if len(bindings) >= 2:
145
+ # multi-binding machine, workspace matches nothing: default would be an
146
+ # unregistered home that two unmatched workspaces could silently share
147
+ # (review round 3) — degraded-but-healthy, but it must be loud
148
+ print(
149
+ f"[owfa-home] warning: workspace matches none of {len(bindings)} bindings — "
150
+ "serving homes/default; bind this root or set OWFA_ROOT to remove the ambiguity",
151
+ file=sys.stderr,
152
+ )
153
+ return home_dir(connector, "default")
154
+
155
+
156
+ def bind(connector: str, root: str, binding_id: str = "", bot: str = "") -> dict:
157
+ """Register a workspace binding (B2). Re-binding the same root replaces the entry."""
158
+ root = str(Path(root).expanduser().resolve())
159
+ bindings = load_bindings(connector)
160
+ entry = {
161
+ "id": binding_id or next((b["id"] for b in bindings if os.path.normpath(b.get("root", "")) == os.path.normpath(root)), ""),
162
+ "root": root,
163
+ "home": str(home_dir(connector, binding_id or "")) if binding_id else "",
164
+ "bot": bot,
165
+ "installed_at": _now_iso(),
166
+ }
167
+ if not entry["id"]:
168
+ entry["id"] = "default" if not bindings else _slug(root)
169
+ entry["home"] = str(home_dir(connector, entry["id"]))
170
+ bindings = [b for b in bindings if b.get("id") != entry["id"]]
171
+ bindings.append(entry)
172
+ save_bindings(connector, bindings)
173
+ Path(entry["home"]).mkdir(parents=True, exist_ok=True, mode=0o700) # docs/home.md: 0600/0700
174
+ return entry
175
+
176
+
177
+ def unbind(connector: str, binding_id: str) -> bool:
178
+ """Detach a binding. homes/<id>/ keeps its data — deletion belongs to the human (B3)."""
179
+ bindings = load_bindings(connector)
180
+ kept = [b for b in bindings if b.get("id") != binding_id]
181
+ if len(kept) == len(bindings):
182
+ return False
183
+ save_bindings(connector, kept)
184
+ return True
185
+
186
+
187
+ def exclusivity_violations(connector: str) -> list:
188
+ """Homes served by more than one active binding (B3: this is an error, not a warning)."""
189
+ seen = {}
190
+ for b in load_bindings(connector):
191
+ seen.setdefault(b.get("home", ""), []).append(b.get("id", ""))
192
+ return [{"home": h, "bindings": ids} for h, ids in sorted(seen.items()) if len(ids) > 1]
193
+
194
+
195
+ def stale_bindings(connector: str) -> list:
196
+ """Bindings whose workspace root disappeared — report and clean on confirmation (B3)."""
197
+ return [b for b in load_bindings(connector) if not os.path.isdir(b.get("root", ""))]
198
+
199
+
200
+ def migrate_legacy_home(connector: str, legacy_dir: str, binding_id: str = "", root: str = "") -> dict:
201
+ """Move a pre-0.4 scattered home (e.g. ~/.qq-cli) into homes/<id> and bind it.
202
+
203
+ MOVE, never copy: credentials must not exist in two places. The legacy
204
+ directory is left as a symlink to the new home so old absolute paths keep
205
+ resolving — the symlink is the 0.5-cleanup item.
206
+ """
207
+ src = Path(legacy_dir).expanduser()
208
+ if not src.exists() and not src.is_symlink():
209
+ print(f"[owfa-home] warning: legacy home {src} does not exist — registering the binding only")
210
+ entry = bind(connector, root or os.getcwd(), binding_id) # empty id → reuse the root's existing binding
211
+ dst = Path(entry["home"])
212
+ if src.is_symlink():
213
+ return entry # already migrated
214
+ dst.parent.mkdir(parents=True, exist_ok=True)
215
+ if src.exists():
216
+ # bind() pre-creates homes/<id>; POSIX rename replaces an empty existing
217
+ # directory, Windows raises WinError 183 — clear the empty pre-creation
218
+ # first so the move works on both (review P1, PR #57 round 2).
219
+ if dst.is_dir():
220
+ if any(dst.iterdir()):
221
+ raise SystemExit(
222
+ f"[owfa-home] refusing to migrate: target home {dst} already exists and is not empty"
223
+ )
224
+ dst.rmdir()
225
+ os.rename(src, dst) # same volume — atomic enough
226
+ try:
227
+ if os.name == "nt":
228
+ import _winapi
229
+
230
+ # Junctions need no admin rights / Developer Mode, unlike
231
+ # os.symlink on Windows. Argument order matches os.symlink:
232
+ # junction AT src pointing TO dst.
233
+ _winapi.CreateJunction(str(dst), str(src))
234
+ else:
235
+ os.symlink(dst, src, target_is_directory=True)
236
+ except OSError as exc:
237
+ print(f"[owfa-home] warning: could not link {src} -> {dst} ({exc}); old absolute paths will not resolve")
238
+ return entry
239
+
240
+
241
+ if __name__ == "__main__":
242
+ import argparse
243
+
244
+ ap = argparse.ArgumentParser(description="OWFA machine substrate: connector home bindings")
245
+ sub = ap.add_subparsers(dest="cmd", required=True)
246
+ for name in ("bind", "unbind", "resolve", "list", "stale", "exclusive", "migrate"):
247
+ s = sub.add_parser(name)
248
+ s.add_argument("connector")
249
+ if name in ("bind", "resolve", "migrate"):
250
+ s.add_argument("--root", default="")
251
+ if name in ("bind", "migrate"):
252
+ s.add_argument("--id", default="")
253
+ s.add_argument("--bot", default="")
254
+ if name == "migrate":
255
+ s.add_argument("--legacy", required=True)
256
+ if name == "unbind":
257
+ s.add_argument("binding_id")
258
+ args = ap.parse_args()
259
+ c = args.connector
260
+ if args.cmd == "bind":
261
+ print(json.dumps(bind(c, args.root or os.getcwd(), args.id, args.bot), ensure_ascii=False))
262
+ elif args.cmd == "unbind":
263
+ print("unbound" if unbind(c, args.binding_id) else "no such binding")
264
+ elif args.cmd == "resolve":
265
+ print(resolve_home(c, args.root))
266
+ elif args.cmd == "list":
267
+ print(json.dumps(load_bindings(c), ensure_ascii=False, indent=2))
268
+ elif args.cmd == "stale":
269
+ print(json.dumps(stale_bindings(c), ensure_ascii=False))
270
+ elif args.cmd == "exclusive":
271
+ print(json.dumps(exclusivity_violations(c), ensure_ascii=False))
272
+ elif args.cmd == "migrate":
273
+ print(json.dumps(migrate_legacy_home(c, args.legacy, args.id, args.root or os.getcwd()), ensure_ascii=False))