@hybridlabor-api/aos 4.15.0 → 4.17.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 (104) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude/hooks/env-file-protection.mjs +19 -1
  3. package/.claude/hooks/go-gate.mjs +787 -68
  4. package/.claude/hooks/go-grant.mjs +88 -0
  5. package/.claude/settings.json +8 -0
  6. package/.codex-plugin/plugin.json +36 -6
  7. package/.opencode/commands/bdb-aos-brainstorm.md +5 -0
  8. package/.opencode/commands/bdb-aos-doctor.md +5 -0
  9. package/.opencode/commands/bdb-aos-graph.md +5 -0
  10. package/.opencode/commands/bdb-aos-init.md +5 -0
  11. package/.opencode/commands/bdb-aos-loop.md +5 -0
  12. package/.opencode/commands/bdb-aos-mastersession.md +5 -0
  13. package/.opencode/commands/bdb-aos-memb.md +5 -0
  14. package/.opencode/commands/bdb-aos-orchestrator.md +5 -0
  15. package/.opencode/commands/bdb-aos-plan.md +5 -0
  16. package/.opencode/commands/bdb-aos-playbooks.md +5 -0
  17. package/.opencode/commands/bdb-aos-setup.md +5 -0
  18. package/.opencode/commands/bdb-aos-shipping.md +5 -0
  19. package/.opencode/commands/bdb-aos-startproject.md +5 -0
  20. package/.opencode/commands/bdb-aos-store.md +5 -0
  21. package/.opencode/plugins/bdb-aos.js +54 -10
  22. package/README.md +4 -4
  23. package/agy-commands/loop.md +5 -0
  24. package/bin/aos-acp.mjs +7 -3
  25. package/bin/aos-doctor.mjs +26 -9
  26. package/bin/aos-uninstall.mjs +25 -1
  27. package/commands/brainstorm.md +5 -0
  28. package/commands/doctor.md +5 -0
  29. package/commands/graph.md +5 -0
  30. package/commands/init.md +5 -0
  31. package/commands/loop.md +5 -0
  32. package/commands/mastersession.md +5 -0
  33. package/commands/memb.md +5 -0
  34. package/commands/orchestrator.md +5 -0
  35. package/commands/plan.md +5 -0
  36. package/commands/playbooks.md +5 -0
  37. package/commands/setup.md +5 -0
  38. package/commands/shipping.md +5 -0
  39. package/commands/startproject.md +5 -0
  40. package/commands/store.md +5 -0
  41. package/docs/codex-agy-setup.md +16 -0
  42. package/docs/opencode-setup.md +36 -0
  43. package/docs/plugin-migration.md +61 -0
  44. package/installer.js +564 -215
  45. package/lib/plugin-migration.js +462 -0
  46. package/lib/store-ui/index.html +9 -1
  47. package/lib/store-ui/server.mjs +2 -0
  48. package/package.json +7 -2
  49. package/plugin-commands.json +144 -0
  50. package/plugin.json +302 -0
  51. package/plugins/bdb-aos-codex/.codex-plugin/plugin.json +38 -0
  52. package/plugins/bdb-aos-codex/skills/brainstorm/SKILL.md +6 -0
  53. package/plugins/bdb-aos-codex/skills/doctor/SKILL.md +6 -0
  54. package/plugins/bdb-aos-codex/skills/graph/SKILL.md +6 -0
  55. package/plugins/bdb-aos-codex/skills/init/SKILL.md +6 -0
  56. package/plugins/bdb-aos-codex/skills/loop/SKILL.md +6 -0
  57. package/plugins/bdb-aos-codex/skills/mastersession/SKILL.md +6 -0
  58. package/plugins/bdb-aos-codex/skills/memb/SKILL.md +6 -0
  59. package/plugins/bdb-aos-codex/skills/orchestrator/SKILL.md +6 -0
  60. package/plugins/bdb-aos-codex/skills/plan/SKILL.md +6 -0
  61. package/plugins/bdb-aos-codex/skills/playbooks/SKILL.md +6 -0
  62. package/plugins/bdb-aos-codex/skills/setup/SKILL.md +6 -0
  63. package/plugins/bdb-aos-codex/skills/shipping/SKILL.md +6 -0
  64. package/plugins/bdb-aos-codex/skills/startproject/SKILL.md +6 -0
  65. package/plugins/bdb-aos-codex/skills/store/SKILL.md +6 -0
  66. package/scripts/build-plugin-manifest.mjs +202 -3
  67. package/skills/basic/master-session/SKILL.md +1 -1
  68. package/skills/bdb-aos/scripts/list-playbooks.mjs +72 -0
  69. package/skills/global_config/gogate/SKILL.md +123 -0
  70. package/skills/global_config/loop-templates/SKILL.md +27 -0
  71. package/skills/global_config/loop-templates/references/ci-until-green.md +27 -0
  72. package/skills/global_config/loop-templates/references/daily-summary.md +27 -0
  73. package/skills/global_config/loop-templates/references/pr-to-merge.md +27 -0
  74. package/skills/global_config/loop-templates/references/review-rounds.md +27 -0
  75. package/skills/playbooks/pb-bug-fix/SKILL.md +48 -0
  76. package/skills/playbooks/pb-clip-from-moodboard/SKILL.md +55 -0
  77. package/skills/playbooks/pb-crew-call-sheet/SKILL.md +40 -0
  78. package/skills/playbooks/pb-deploy-saas/SKILL.md +44 -0
  79. package/skills/playbooks/pb-docs-site/SKILL.md +44 -0
  80. package/skills/playbooks/pb-focus-chunks/SKILL.md +40 -0
  81. package/skills/playbooks/pb-handover/SKILL.md +41 -0
  82. package/skills/playbooks/pb-harness-work/SKILL.md +64 -0
  83. package/skills/playbooks/pb-health-weekly/SKILL.md +43 -0
  84. package/skills/playbooks/pb-idea-to-launch/SKILL.md +51 -0
  85. package/skills/playbooks/pb-image-to-3d/SKILL.md +46 -0
  86. package/skills/playbooks/pb-inbox-zero/SKILL.md +39 -0
  87. package/skills/playbooks/pb-invoice-check/SKILL.md +41 -0
  88. package/skills/playbooks/pb-landing-page/SKILL.md +46 -0
  89. package/skills/playbooks/pb-launch-video/SKILL.md +49 -0
  90. package/skills/playbooks/pb-machine-setup/SKILL.md +44 -0
  91. package/skills/playbooks/pb-master/SKILL.md +53 -0
  92. package/skills/playbooks/pb-newsletter/SKILL.md +43 -0
  93. package/skills/playbooks/pb-offer/SKILL.md +39 -0
  94. package/skills/playbooks/pb-open-source/SKILL.md +50 -0
  95. package/skills/playbooks/pb-pcb-to-case/SKILL.md +46 -0
  96. package/skills/playbooks/pb-redesign-app/SKILL.md +47 -0
  97. package/skills/playbooks/pb-release-aos/SKILL.md +50 -0
  98. package/skills/playbooks/pb-security-sweep/SKILL.md +48 -0
  99. package/skills/playbooks/pb-ship/SKILL.md +49 -0
  100. package/skills/playbooks/pb-show-build/SKILL.md +48 -0
  101. package/skills/playbooks/pb-social-pack/SKILL.md +47 -0
  102. package/skills/playbooks/pb-todo/SKILL.md +41 -0
  103. package/skills/playbooks/pb-worktrees-land/SKILL.md +52 -0
  104. package/.codex-plugin/marketplace.json +0 -11
@@ -0,0 +1,462 @@
1
+ 'use strict';
2
+ // Plugin registration and loose-copy migration. Pure of installer state: every
3
+ // path derives from `home`, so tests and the uninstaller drive it with a temp HOME.
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+
8
+ const PLUGIN = 'bdb-aos';
9
+ const MARKETPLACE = 'bdb-marketplace';
10
+ const OWN_REPO = 'hybridlabor-api/aos';
11
+ const EXTERNAL_REPO = 'hybridlabor-api/bdb-marketplace';
12
+
13
+ // Harness key -> where its removable loose skill copies live. Only Claude's plugin bundles the
14
+ // skills. Codex bundles just the command wrappers and agy bundling is unverified, so their copies
15
+ // stay, like OpenCode's and the shared ~/.agents/skills.
16
+ const LOOSE_ROOTS = {
17
+ claudecode: (home) => path.join(home, '.claude', 'skills'),
18
+ };
19
+ const KEPT_COPIES = { codex: '~/.codex/skills', antigravity: '~/.gemini/config/skills', opencode: '~/.config/opencode/skills' };
20
+
21
+ const statePath = (home) => path.join(home, '.agents', '.bdb-plugin-migration.json');
22
+ const settingsPath = (home) => path.join(home, '.claude', 'settings.json');
23
+ const hashOf = (file) => {
24
+ try { return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); } catch { return null; }
25
+ };
26
+ const stamp = () => new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
27
+
28
+ function readState(home) {
29
+ try { return JSON.parse(fs.readFileSync(statePath(home), 'utf8')); } catch { return { registered: {}, backups: [] }; }
30
+ }
31
+ function writeState(home, state) {
32
+ fs.mkdirSync(path.dirname(statePath(home)), { recursive: true });
33
+ fs.writeFileSync(statePath(home), JSON.stringify(state, null, 2) + '\n');
34
+ }
35
+
36
+ function writeJsonAtomic(file, data, mode) {
37
+ const tmp = `${file}.${process.pid}.tmp`;
38
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode });
39
+ fs.renameSync(tmp, file);
40
+ }
41
+
42
+ const repoOf = (entry) => {
43
+ const s = entry && entry.source;
44
+ if (!s || typeof s !== 'object') return '';
45
+ return String(s.repo || s.url || '').replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '');
46
+ };
47
+
48
+ // A symlinked settings.json is written through to its real file so the link survives.
49
+ function settingsTarget(home) {
50
+ const link = settingsPath(home);
51
+ let st;
52
+ try { st = fs.lstatSync(link); } catch { return link; }
53
+ if (!st.isSymbolicLink()) return link;
54
+ return fs.realpathSync(link);
55
+ }
56
+
57
+ // Evidence that Claude Code itself installed the plugin, readable without network.
58
+ // installed_plugins.json ({version, plugins: {"<name>@<marketplace>": [{installPath, ...}]}}) is the
59
+ // shape seen locally for other plugins. A candidate directory counts only when it resolves (realpath)
60
+ // under ~/.claude/plugins, is not cached from another marketplace (an older external one), and holds
61
+ // skills/<name>/SKILL.md for a wanted skill (any skill when none is named). The cache fallback
62
+ // (cache/<marketplace>/<plugin>/<version>) is inferred from the installPath layout seen there.
63
+ function pluginInstalled(home, wanted = []) {
64
+ const dir = path.join(home, '.claude', 'plugins');
65
+ let realPlugins;
66
+ try { realPlugins = fs.realpathSync(dir) + path.sep; } catch { return false; }
67
+ const good = (candidate) => {
68
+ let real;
69
+ try { real = fs.realpathSync(candidate); } catch { return false; }
70
+ if (!real.startsWith(realPlugins)) return false;
71
+ const rel = real.slice(realPlugins.length).split(path.sep);
72
+ if (rel[0] === 'cache' && rel[1] !== MARKETPLACE) return false;
73
+ const skills = path.join(real, 'skills');
74
+ try {
75
+ const have = fs.readdirSync(skills).filter((n) => fs.existsSync(path.join(skills, n, 'SKILL.md')));
76
+ return wanted.length ? wanted.some((n) => have.includes(n)) : have.length > 0;
77
+ } catch { return false; }
78
+ };
79
+ try {
80
+ const doc = JSON.parse(fs.readFileSync(path.join(dir, 'installed_plugins.json'), 'utf8'));
81
+ const entries = doc && doc.plugins && doc.plugins[`${PLUGIN}@${MARKETPLACE}`];
82
+ if (Array.isArray(entries) && entries.some((e) => e && typeof e.installPath === 'string' && good(e.installPath))) return true;
83
+ } catch { /* no evidence */ }
84
+ try {
85
+ const base = path.join(dir, 'cache', MARKETPLACE, PLUGIN);
86
+ return fs.readdirSync(base).some((v) => good(path.join(base, v)));
87
+ } catch { return false; }
88
+ }
89
+
90
+ // Claude Code: marketplace entry for THIS repo + enabledPlugins, merged into settings.json.
91
+ // Never throws; ok=true only when a re-read shows both keys in place (or the human opted out).
92
+ function registerClaude({ home, repo = OWN_REPO }) {
93
+ let file;
94
+ try { file = settingsTarget(home); } catch (e) {
95
+ return { ok: false, error: `${settingsPath(home)} is a broken symlink (${e.message}); nothing changed` };
96
+ }
97
+ const pluginId = `${PLUGIN}@${MARKETPLACE}`;
98
+ let data = {};
99
+ let existed = false;
100
+ try {
101
+ if (fs.existsSync(file)) {
102
+ existed = true;
103
+ data = JSON.parse(fs.readFileSync(file, 'utf8'));
104
+ if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('not a JSON object');
105
+ }
106
+ } catch (e) {
107
+ return { ok: false, error: `${file} is not valid JSON (${e.message}); nothing changed` };
108
+ }
109
+ for (const key of ['extraKnownMarketplaces', 'enabledPlugins']) {
110
+ const v = data[key];
111
+ if (v !== undefined && (!v || typeof v !== 'object' || Array.isArray(v))) {
112
+ return { ok: false, error: `${key} in ${file} is not a plain JSON object; nothing changed` };
113
+ }
114
+ }
115
+ const optOut = { ok: true, optedOut: true, changed: false, record: null };
116
+ const record = { marketplace: MARKETPLACE, pluginId, addedMarketplace: false, addedEnabled: false, replaced: null, renames: [], keptForeign: null };
117
+ const prev = JSON.parse(JSON.stringify(data));
118
+ const mk = data.extraKnownMarketplaces && typeof data.extraKnownMarketplaces === 'object' ? data.extraKnownMarketplaces : {};
119
+ const plugins = data.enabledPlugins && typeof data.enabledPlugins === 'object' ? data.enabledPlugins : {};
120
+ if (plugins[pluginId] === false) return optOut;
121
+
122
+ for (const [key, entry] of Object.entries(mk)) {
123
+ if (repoOf(entry) !== EXTERNAL_REPO) continue;
124
+ const id = `${PLUGIN}@${key}`;
125
+ if (plugins[id] === false) return optOut;
126
+ const others = Object.keys(plugins).filter((i) => i.endsWith(`@${key}`) && i !== id);
127
+ if (key === MARKETPLACE && others.length) {
128
+ return { ok: false, error: `marketplace "${MARKETPLACE}" in ${file} is the external ${EXTERNAL_REPO} and ${others.join(', ')} use it; not replacing it, nothing changed` };
129
+ }
130
+ if (id in plugins && key !== MARKETPLACE) {
131
+ record.renames.push({ from: id, to: pluginId, value: plugins[id], merged: pluginId in plugins });
132
+ if (!(pluginId in plugins)) plugins[pluginId] = plugins[id];
133
+ delete plugins[id];
134
+ }
135
+ if (!others.length && !record.replaced) {
136
+ record.replaced = { key, entry };
137
+ delete mk[key];
138
+ } else if (!record.keptForeign && key !== MARKETPLACE) {
139
+ record.keptForeign = key;
140
+ }
141
+ }
142
+ if (mk[MARKETPLACE] && repoOf(mk[MARKETPLACE]) !== repo) {
143
+ return { ok: false, error: `marketplace "${MARKETPLACE}" in ${file} points at another source (${repoOf(mk[MARKETPLACE]) || 'unknown'}); not overwriting it, nothing changed` };
144
+ }
145
+ if (!mk[MARKETPLACE]) {
146
+ record.addedMarketplace = true;
147
+ mk[MARKETPLACE] = { source: { source: 'github', repo } };
148
+ }
149
+ if (plugins[pluginId] !== true) {
150
+ record.addedEnabled = !(pluginId in plugins);
151
+ plugins[pluginId] = true;
152
+ }
153
+ data.extraKnownMarketplaces = mk;
154
+ data.enabledPlugins = plugins;
155
+ const changed = JSON.stringify(prev) !== JSON.stringify(data);
156
+
157
+ try {
158
+ if (changed) {
159
+ fs.mkdirSync(path.dirname(file), { recursive: true });
160
+ let mode = 0o644;
161
+ if (existed) {
162
+ mode = fs.statSync(file).mode & 0o777;
163
+ fs.copyFileSync(file, `${file}.${process.hrtime.bigint()}.bak`);
164
+ }
165
+ writeJsonAtomic(file, data, mode);
166
+ }
167
+ const check = JSON.parse(fs.readFileSync(file, 'utf8'));
168
+ const ok = repoOf(check.extraKnownMarketplaces && check.extraKnownMarketplaces[MARKETPLACE]) === repo
169
+ && check.enabledPlugins && check.enabledPlugins[pluginId] === true;
170
+ return { ok: !!ok, changed, record, error: ok ? null : 'settings.json re-read did not show the registration' };
171
+ } catch (e) {
172
+ return { ok: false, error: `could not write ${file}: ${e.message}` };
173
+ }
174
+ }
175
+
176
+ // Reverses exactly what the record lists: added keys, the replaced marketplace, every rename.
177
+ function deregisterClaude({ home, record }) {
178
+ let file;
179
+ try { file = settingsTarget(home); } catch { return { changed: false, error: 'settings.json is a broken symlink' }; }
180
+ if (!record || !fs.existsSync(file)) return { changed: false };
181
+ let data;
182
+ try { data = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return { changed: false, error: 'settings.json not readable' }; }
183
+ let changed = false;
184
+ const plugins = data.enabledPlugins && typeof data.enabledPlugins === 'object' ? data.enabledPlugins : null;
185
+ if (plugins) {
186
+ const renamedIn = (record.renames || []).some((r) => !r.merged);
187
+ if ((record.addedEnabled || renamedIn) && record.pluginId in plugins) { delete plugins[record.pluginId]; changed = true; }
188
+ for (const r of record.renames || []) {
189
+ if (r.from in plugins) continue;
190
+ plugins[r.from] = r.value; changed = true;
191
+ }
192
+ } else if ((record.renames || []).length) {
193
+ data.enabledPlugins = Object.fromEntries(record.renames.map((r) => [r.from, r.value]));
194
+ changed = true;
195
+ }
196
+ if (data.enabledPlugins && !Object.keys(data.enabledPlugins).length) delete data.enabledPlugins;
197
+ const mk = data.extraKnownMarketplaces;
198
+ if (mk && (record.addedMarketplace || record.replaced) && repoOf(mk[record.marketplace]) === OWN_REPO) {
199
+ delete mk[record.marketplace]; changed = true;
200
+ if (record.replaced) mk[record.replaced.key] = record.replaced.entry;
201
+ if (!Object.keys(mk).length) delete data.extraKnownMarketplaces;
202
+ }
203
+ if (changed) {
204
+ fs.copyFileSync(file, `${file}.${process.hrtime.bigint()}.bak`);
205
+ writeJsonAtomic(file, data, fs.statSync(file).mode & 0o777);
206
+ }
207
+ return { changed };
208
+ }
209
+
210
+ // Scripts that installer-owned jobs (the OpenWiki daemon) may still point at never leave the root.
211
+ const PROTECTED = ['openwiki-skill/scripts/'];
212
+ const skillOf = (rel) => rel.split('/')[0];
213
+
214
+ // All-or-nothing: files the manifest recorded whose bytes still match the recorded hash are backed
215
+ // up (backupDir/<path relative to home>) and removed only when no edited or unsafe copy would
216
+ // remain; otherwise nothing is removed and the leftover skill names are returned so the caller
217
+ // does not treat Claude as covered. Every candidate must resolve (path.resolve and realpath) to a
218
+ // regular file under the real root; a symlinked root is refused outright.
219
+ function removeLooseCopies({ home, root, manifest, backupDir }) {
220
+ const res = { removed: [], keptEdited: [], skipped: [], leftover: [], refused: null };
221
+ let rootStat;
222
+ try { rootStat = fs.lstatSync(root); } catch { return res; }
223
+ if (rootStat.isSymbolicLink()) { res.refused = `${root} is a symlink`; return res; }
224
+ const prefix = path.resolve(root) + path.sep;
225
+ const realPrefix = fs.realpathSync(root) + path.sep;
226
+ const relOf = (abs) => abs.slice(prefix.length).split(path.sep).join('/');
227
+ const todo = [];
228
+ const leftover = new Set();
229
+ for (const [file, entry] of Object.entries(manifest)) {
230
+ if (typeof file !== 'string' || !path.isAbsolute(file)) continue;
231
+ if (file.split(/[\\/]/).includes('..')) {
232
+ if (file.startsWith(prefix)) { res.skipped.push(file); leftover.add(skillOf(relOf(file))); }
233
+ continue;
234
+ }
235
+ const abs = path.resolve(file);
236
+ if (!abs.startsWith(prefix)) continue;
237
+ const rel = relOf(abs);
238
+ if (PROTECTED.some((p) => rel.startsWith(p))) continue;
239
+ let st;
240
+ try { st = fs.lstatSync(abs); } catch { continue; }
241
+ let real = null;
242
+ try { real = fs.realpathSync(abs); } catch { /* skipped below */ }
243
+ if (!st.isFile() || !real || !real.startsWith(realPrefix)) { res.skipped.push(file); leftover.add(skillOf(rel)); continue; }
244
+ if (hashOf(abs) !== (entry && entry.sha256)) { res.keptEdited.push(file); leftover.add(skillOf(rel)); continue; }
245
+ todo.push({ file, abs });
246
+ }
247
+ if (leftover.size) { res.leftover = [...leftover].sort(); return res; }
248
+ for (const { file, abs } of todo) {
249
+ try {
250
+ const dest = path.join(backupDir, path.relative(home, abs));
251
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
252
+ fs.copyFileSync(abs, dest);
253
+ fs.unlinkSync(abs);
254
+ } catch (e) {
255
+ if (e.code === 'ENOENT') continue;
256
+ res.skipped.push(file);
257
+ continue;
258
+ }
259
+ delete manifest[file];
260
+ res.removed.push(file);
261
+ for (let d = path.dirname(abs); d.startsWith(prefix); d = path.dirname(d)) {
262
+ try { fs.rmdirSync(d); } catch { break; }
263
+ }
264
+ }
265
+ return res;
266
+ }
267
+
268
+ const lockPath = (home) => path.join(home, '.agents', '.bdb-plugin-migration.lock');
269
+ // One migration at a time. A lock is stale when its pid is gone or it is older than ten minutes.
270
+ function acquireLock(home) {
271
+ const file = lockPath(home);
272
+ fs.mkdirSync(path.dirname(file), { recursive: true });
273
+ for (let attempt = 0; attempt < 2; attempt++) {
274
+ try {
275
+ const fd = fs.openSync(file, 'wx');
276
+ fs.writeSync(fd, String(process.pid));
277
+ fs.closeSync(fd);
278
+ return () => { try { fs.unlinkSync(file); } catch { /* already gone */ } };
279
+ } catch (e) {
280
+ if (e.code !== 'EEXIST') return null;
281
+ let stale = false;
282
+ try {
283
+ const pid = Number(fs.readFileSync(file, 'utf8'));
284
+ let alive = true;
285
+ try { process.kill(pid, 0); } catch (k) { alive = k.code === 'EPERM'; }
286
+ stale = !alive || Date.now() - fs.statSync(file).mtimeMs > 10 * 60 * 1000;
287
+ } catch { stale = true; }
288
+ if (!stale) return null;
289
+ try { fs.unlinkSync(file); } catch { /* raced */ }
290
+ }
291
+ }
292
+ return null;
293
+ }
294
+
295
+ const installManifestPath = (home) => path.join(home, '.agents', '.bdb-install-manifest.json');
296
+
297
+ // Plugin-migration backup dirs, newest first: the state file's list plus a scan, so they are
298
+ // found even after uninstall removed or reset the state.
299
+ function listBackups(home) {
300
+ const found = new Set(readState(home).backups.filter((d) => fs.existsSync(d)));
301
+ const dir = path.join(home, '.agents', 'backups');
302
+ try {
303
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
304
+ if (e.isDirectory() && e.name.startsWith('plugin-migration-')) found.add(path.join(dir, e.name));
305
+ }
306
+ } catch { /* none */ }
307
+ return [...found].sort().reverse();
308
+ }
309
+
310
+ // Copies backed-up files back to their original place; never overwrites an existing file.
311
+ // Restored files are recorded in the install manifest so a later migration can retire them again.
312
+ function restoreBackup({ home, backupDir }) {
313
+ const restored = [];
314
+ const walk = (dir) => {
315
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
316
+ const full = path.join(dir, e.name);
317
+ if (e.isDirectory()) { walk(full); continue; }
318
+ const target = path.join(home, path.relative(backupDir, full));
319
+ if (fs.existsSync(target)) continue;
320
+ fs.mkdirSync(path.dirname(target), { recursive: true });
321
+ fs.copyFileSync(full, target);
322
+ restored.push(target);
323
+ }
324
+ };
325
+ if (fs.existsSync(backupDir)) walk(backupDir);
326
+ if (restored.length) {
327
+ let manifest = {};
328
+ try { manifest = JSON.parse(fs.readFileSync(installManifestPath(home), 'utf8')); } catch { /* new */ }
329
+ for (const f of restored) manifest[f] = { path: f, sha256: hashOf(f), restoredAt: new Date().toISOString() };
330
+ fs.mkdirSync(path.dirname(installManifestPath(home)), { recursive: true });
331
+ fs.writeFileSync(installManifestPath(home), JSON.stringify(manifest, null, 2) + '\n');
332
+ }
333
+ return restored;
334
+ }
335
+
336
+ // Persists the human's choice to keep loose copies: the explicit false registerClaude respects.
337
+ function optOutClaude(home) {
338
+ let file;
339
+ try { file = settingsTarget(home); } catch { return false; }
340
+ let data = {};
341
+ let existed = false;
342
+ try {
343
+ if (fs.existsSync(file)) { existed = true; data = JSON.parse(fs.readFileSync(file, 'utf8')); }
344
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return false;
345
+ if (data.enabledPlugins !== undefined && (!data.enabledPlugins || typeof data.enabledPlugins !== 'object' || Array.isArray(data.enabledPlugins))) return false;
346
+ data.enabledPlugins = data.enabledPlugins || {};
347
+ data.enabledPlugins[`${PLUGIN}@${MARKETPLACE}`] = false;
348
+ fs.mkdirSync(path.dirname(file), { recursive: true });
349
+ let mode = 0o644;
350
+ if (existed) { mode = fs.statSync(file).mode & 0o777; fs.copyFileSync(file, `${file}.${process.hrtime.bigint()}.bak`); }
351
+ writeJsonAtomic(file, data, mode);
352
+ return true;
353
+ } catch { return false; }
354
+ }
355
+
356
+ // Restores every backup, then deregisters the plugin it registered so Claude does not load the
357
+ // restored skills a second time, and sets the plugin to false so the next install does not retire them again. stillEnabled is true when the plugin remains enabled anyway.
358
+ function restorePluginBackups({ home }) {
359
+ const dirs = listBackups(home);
360
+ const files = [];
361
+ for (const d of dirs) files.push(...restoreBackup({ home, backupDir: d }));
362
+ const state = readState(home);
363
+ const record = state.registered.claudecode;
364
+ let deregistered = false;
365
+ if (files.length && record) {
366
+ deregistered = !!deregisterClaude({ home, record }).changed;
367
+ delete state.registered.claudecode;
368
+ writeState(home, state);
369
+ }
370
+ const optedOut = files.length > 0 && optOutClaude(home);
371
+ let stillEnabled = false;
372
+ try { stillEnabled = JSON.parse(fs.readFileSync(settingsTarget(home), 'utf8')).enabledPlugins[`${PLUGIN}@${MARKETPLACE}`] === true; } catch { /* not enabled */ }
373
+ return { dirs, files, deregistered, optedOut, stillEnabled };
374
+ }
375
+
376
+ // After uninstall: keep the backup index, drop the registration record.
377
+ function retireState(home) {
378
+ const backups = listBackups(home);
379
+ if (!backups.length) { try { fs.rmSync(statePath(home)); } catch { /* already gone */ } return; }
380
+ writeState(home, { registered: {}, backups });
381
+ }
382
+
383
+ const REGISTRARS = { claudecode: registerClaude };
384
+
385
+ // mode: 'on' | 'check' (report only) | 'off'. Returns { covered:Set, lines:[] }.
386
+ // covered = harnesses whose loose copies are retired: the plugin is registered AND Claude Code
387
+ // itself shows it installed. Registered but not yet installed keeps the copies for a later run.
388
+ function migrate({ home, manifest, detected, mode = 'on', registrars = REGISTRARS, repo = OWN_REPO }) {
389
+ const lines = [];
390
+ const covered = new Set();
391
+ if (mode === 'off') return { covered, lines: ['Plugin migration is off (AOS_PLUGIN_MIGRATION=off); loose copies untouched.'] };
392
+ const release = mode === 'on' ? acquireLock(home) : () => {};
393
+ if (!release) return { covered, lines: ['Another plugin migration is running (lock in ~/.agents); loose copies kept.'] };
394
+ try {
395
+ return migrateLocked({ home, manifest, detected, mode, registrars, repo, lines, covered });
396
+ } finally { release(); }
397
+ }
398
+
399
+ function migrateLocked({ home, manifest, detected, mode, registrars, repo, lines, covered }) {
400
+ const state = readState(home);
401
+ const before = JSON.stringify(state);
402
+ const backupDir = path.join(home, '.agents', 'backups', `plugin-migration-${stamp()}`);
403
+ let removedAny = false;
404
+
405
+ for (const [key, where] of Object.entries(KEPT_COPIES)) {
406
+ if (detected.includes(key)) lines.push(`${key}: loose copies in ${where} kept (the plugin does not bundle the skills there).`);
407
+ }
408
+ for (const key of Object.keys(LOOSE_ROOTS)) {
409
+ if (!detected.includes(key)) continue;
410
+ const root = LOOSE_ROOTS[key](home);
411
+ const register = registrars[key];
412
+ if (!register) { lines.push(`${key}: no verified plugin registration; loose copies kept.`); continue; }
413
+ if (mode === 'check') {
414
+ const n = Object.keys(manifest).filter((f) => f.startsWith(root + path.sep)).length;
415
+ lines.push(`[check] ${key}: would register the ${PLUGIN} plugin and, once Claude Code shows it installed, back up and remove up to ${n} unmodified own copies from ${root}.`);
416
+ continue;
417
+ }
418
+ const reg = register({ home, repo });
419
+ if (!reg.ok) { lines.push(`${key}: plugin registration failed (${reg.error}); loose copies kept.`); continue; }
420
+ if (reg.optedOut) { lines.push(`${key}: ${PLUGIN}@${MARKETPLACE} is set to false in settings.json (opt-out); nothing registered, loose copies untouched.`); continue; }
421
+ if (reg.record) {
422
+ const prior = state.registered[key];
423
+ state.registered[key] = prior ? {
424
+ ...reg.record,
425
+ addedMarketplace: prior.addedMarketplace || reg.record.addedMarketplace,
426
+ addedEnabled: prior.addedEnabled || reg.record.addedEnabled,
427
+ replaced: prior.replaced || reg.record.replaced,
428
+ renames: [...(prior.renames || []), ...(reg.record.renames || [])],
429
+ keptForeign: prior.keptForeign || reg.record.keptForeign,
430
+ } : reg.record;
431
+ if (reg.record.replaced) lines.push(`${key}: replaced external marketplace "${reg.record.replaced.key}" (${EXTERNAL_REPO}) with ${repo}. Nothing on GitHub was changed.`);
432
+ if (reg.record.keptForeign) lines.push(`${key}: external marketplace "${reg.record.keptForeign}" also lists other plugins and stays in place; only ${PLUGIN}@${reg.record.keptForeign} moved to ${MARKETPLACE}.`);
433
+ }
434
+ lines.push(`${key}: ${PLUGIN} plugin registered${reg.changed ? '' : ' (already in place)'}.`);
435
+ const wanted = [...new Set(Object.keys(manifest).filter((f) => f.startsWith(root + path.sep)).map((f) => skillOf(f.slice(root.length + 1).split(path.sep).join('/'))))];
436
+ if (!pluginInstalled(home, wanted)) {
437
+ lines.push(`${key}: Claude Code does not show the plugin as installed yet; loose copies kept: restart Claude Code, then run the installer again to retire the loose copies.`);
438
+ continue;
439
+ }
440
+ const r = removeLooseCopies({ home, root, manifest, backupDir });
441
+ if (r.refused) { lines.push(`${key}: ${r.refused}; loose copies kept.`); continue; }
442
+ if (r.leftover.length) {
443
+ lines.push(`${key}: ${r.leftover.length} skill(s) have edited or unsafe loose copies (${r.leftover.slice(0, 8).join(', ')}${r.leftover.length > 8 ? ', ...' : ''}); nothing removed, the installer keeps updating the copies it owns. Move your edits out and run the installer again to retire them.`);
444
+ continue;
445
+ }
446
+ covered.add(key);
447
+ if (r.removed.length) removedAny = true;
448
+ lines.push(`${key}: ${r.removed.length} own loose copies removed from ${root}${r.skipped.length ? `, ${r.skipped.length} could not be removed` : ''}; unknown files untouched.`);
449
+ }
450
+ if (removedAny) {
451
+ state.backups.push(backupDir);
452
+ lines.push(`Backup of removed files: ${backupDir} (restore with: aos-uninstall --restore-plugin-backup).`);
453
+ }
454
+ if (mode === 'on' && JSON.stringify(state) !== before) writeState(home, state);
455
+ return { covered, lines, removedAny };
456
+ }
457
+
458
+ module.exports = {
459
+ PLUGIN, MARKETPLACE, OWN_REPO, EXTERNAL_REPO, LOOSE_ROOTS, REGISTRARS,
460
+ statePath, readState, writeState, registerClaude, deregisterClaude, removeLooseCopies, restoreBackup, listBackups,
461
+ restorePluginBackups, retireState, pluginInstalled, optOutClaude, acquireLock, lockPath, migrate,
462
+ };
@@ -73,7 +73,7 @@ const uniq=f=>[...new Set(S.data.items.map(f))].sort();
73
73
  const count=(f,v)=>base().filter(i=>f(i)===v).length;
74
74
  const chip=(a,v,label,n)=>`<button class="chip" aria-pressed="${S[a]===v}" data-a="${a}" data-v="${esc(v)}">${esc(label)}<b>${n}</b></button>`;
75
75
  function strip(){const all=S.data.items;return `<div class="strip"><div><strong>${all.length}</strong><small>total</small></div>${uniq(i=>i.source).map(s=>`<div><strong>${all.filter(i=>i.source===s).length}</strong><small>${esc(s)}</small></div>`).join('')}</div>`}
76
- function tabs(){const n=base(),ic=n.filter(i=>i.installed).length,k=x=>S.data.items.filter(i=>i.kind===x).length;return `<div class="tabs">${chip('tab','skill','Skills',k('skill'))}${chip('tab','agent','Agents',k('agent'))}<span style="width:1rem"></span>${chip('sub','installed','Installed',ic)}${chip('sub','discover','Discover',n.length-ic)}</div>`}
76
+ function tabs(){const n=base(),ic=n.filter(i=>i.installed).length,k=x=>S.data.items.filter(i=>i.kind===x).length;return `<div class="tabs">${chip('tab','skill','Skills',k('skill'))}${chip('tab','agent','Agents',k('agent'))}${chip('tab','playbook','Playbooks',S.data.playbooks.length)}${S.tab==='playbook'?'':`<span style="width:1rem"></span>${chip('sub','installed','Installed',ic)}${chip('sub','discover','Discover',n.length-ic)}`}</div>`}
77
77
  const scopeToggle=()=>`<div class="scope"><span class="kicker" style="margin:0">Scope</span>${chip('scope','global','Global','')}${chip('scope','project','Project','')}</div>`;
78
78
  function act(i){
79
79
  if(i.core) return '<span class="state">AOS Core</span>';
@@ -88,7 +88,15 @@ function row(i){
88
88
  if(open){const d=i.core?i.description:S.detail[k];h+=`<div class="detail">${d===undefined?'Loading...':esc(d)}</div>`}
89
89
  return h+'</div>';
90
90
  }
91
+ function pbRow(p){return `<div class="rowwrap"><div class="row"><span class="dot g"></span><span class="n">${esc(p.name)}</span><span class="d" style="color:var(--muted)">${esc(p.description)}<br><small>Requires: ${esc(reqLabel(p.requires)||'nothing')}</small></span><span class="c cat">${esc(p.difficulty)}</span><span class="s src">${esc(p.est_time)}</span><span style="text-align:right"><span class="state">AOS Core</span></span></div></div>`}
92
+ const reqLabel=r=>[...r.skills,...r.agents.map(a=>'agent:'+a),...r.mcps.map(m=>'mcp:'+m),...r.store.map(x=>'store:'+x)].join(', ');
93
+ function renderPlaybooks(){
94
+ const a=document.activeElement,fq=a&&a.id==='q',pos=fq?a.selectionStart:0,q=S.q.toLowerCase(),l=S.data.playbooks.filter(p=>!q||(p.name+' '+p.description+' '+p.difficulty).toLowerCase().includes(q));
95
+ document.getElementById('app').innerHTML=`<div class="ledger"><aside class="rail"><h1>AOS<br>Store</h1><input class="search" id="q" type="search" placeholder="Search playbooks" value="${esc(S.q)}" aria-label="Search">${tabs()}<p class="cmdnote">Playbooks ship with AOS Core. Start one with /bdb-aos:playbooks.</p></aside><section><p class="kicker">${l.length} of ${S.data.playbooks.length} playbooks</p><div class="row head"><span></span><span>name</span><span class="d">description</span><span class="c">difficulty</span><span class="s">est. time</span><span></span></div>${l.length?l.map(pbRow).join(''):'<div class="empty">No matches.</div>'}</section></div>`;
96
+ if(fq){const e=document.getElementById('q');e.focus();e.setSelectionRange(pos,pos)}
97
+ }
91
98
  function render(){
99
+ if(S.tab==='playbook')return renderPlaybooks();
92
100
  const a=document.activeElement,fq=a&&a.id==='q',pos=fq?a.selectionStart:0,l=visible();
93
101
  document.getElementById('app').innerHTML=`<div class="ledger"><aside class="rail"><h1>AOS<br>Store</h1><p class="kicker" style="margin:0">Active scope: ${S.scope==='global'?'Global (harness roots in ~)':'Project ('+esc(S.data.cwd)+')'}</p>${scopeToggle()}<input class="search" id="q" type="search" placeholder="Search ${S.tab}s" value="${esc(S.q)}" aria-label="Search">${tabs()}<div><p class="kicker">Category</p><div class="chips">${chip('cat','','all',base().length)}${uniq(i=>i.category).map(c=>chip('cat',c,c,count(i=>i.category,c))).join('')}</div></div><div><p class="kicker">Source</p><div class="chips">${chip('src','','all',base().length)}${uniq(i=>i.source).map(s=>chip('src',s,s,count(i=>i.source,s))).join('')}</div></div></aside><section><p class="kicker">${l.length} of ${base().length} ${S.tab}s</p>${strip()}<div class="row head"><span></span><span>name</span><span class="d">description</span><span class="c">category</span><span class="s">source</span><span style="text-align:right">state</span></div>${l.length?l.map(row).join(''):`<div class="empty">${S.sub==='installed'?'Nothing installed yet.':'No matches.'}</div>`}</section></div>`;
94
102
  if(fq){const q=document.getElementById('q');q.focus();q.setSelectionRange(pos,pos)}
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs';
4
4
  import { dirname, join, resolve } from 'node:path';
5
5
  import { randomBytes, timingSafeEqual } from 'node:crypto';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { listPlaybooks } from '../../skills/bdb-aos/scripts/list-playbooks.mjs';
7
8
  import { findItem, installPlan, isInstalled, loadSources, localAgents, localSkills, planSummary } from '../store-shared.mjs';
8
9
 
9
10
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
@@ -52,6 +53,7 @@ function catalog(scope, cwd) {
52
53
  }
53
54
  return {
54
55
  scope, cwd, pinned_commit: sources[0].pinned_commit, items,
56
+ playbooks: listPlaybooks(join(ROOT, 'skills')),
55
57
  sources: sources.map((s) => ({ label: s.label, pinned_commit: s.pinned_commit, license: s.license, copyright: s.copyright, upstream: s.upstream })),
56
58
  };
57
59
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hybridlabor-api/aos",
3
- "version": "4.15.0",
3
+ "version": "4.17.0",
4
4
  "description": "AOS — A Curated AI AGENT OS. Optimized agent skills and add-ons like memB, OpenWiki, Heimdall Token Saver, and Godmode architectures.",
5
5
  "main": "installer.js",
6
6
  "engines": {
@@ -29,7 +29,7 @@
29
29
  "plugin:build": "node scripts/build-plugin-manifest.mjs",
30
30
  "plugin:check": "node scripts/build-plugin-manifest.mjs --check",
31
31
  "doctor": "node bin/aos-doctor.mjs",
32
- "test": "node scripts/validate-skills.mjs --selftest && node scripts/validate-skills.mjs && node scripts/build-plugin-manifest.mjs --check && node --test tests/opencode-graph-gate.test.js && node --test tests/cross-harness-hooks.test.js && node --test tests/plan-canvas-security.test.mjs && node --test tests/plan-canvas-modes.test.mjs && node --test tests/swarm-wiring.test.js && node tests/aos-store.test.mjs && node --test tests/aos-store-ui.test.mjs && node tests/aos-store-scenario.test.mjs && node tests/aos-doctor.test.mjs && node tests/build-plugin-manifest.test.mjs && node --test tests/agent-models.test.js && node --test tests/installer-p3.test.js && node --test tests/installer-binary.test.js && node --test tests/installer-version.test.js && node --test tests/mcsc-server.test.js && node --test tests/antigravity-hooks.test.js && node --test tests/windows-hooks.test.js && node --test tests/plan-builder.test.mjs && node --test tests/plan-builder-trail.test.mjs && node --test tests/plan-builder-showcase.test.mjs && node --test tests/plan-builder-templates.test.mjs && node --test tests/plan-canvas-home.test.mjs && node --test tests/launchpad-installer.test.js && node --test tests/launchpad-plan-canvas.test.js && node --test tests/go-token.test.js && node --test tests/master-session-skill.test.js && node --test tests/aos-acp.test.js && node --test tests/opencode-go-token.test.js && node --test tests/opencode-adapter.test.js && node --test tests/pr-recap.test.mjs && node --test tests/bdb-visual-edit.test.mjs && node --test tests/opencode-trail-autostart.test.mjs && node --test tests/trail-autostart-hook.test.mjs && node --test tests/agenttrail-ensure.test.mjs && node --test tests/aos-bus.test.js"
32
+ "test": "node scripts/validate-skills.mjs --selftest && node scripts/validate-skills.mjs && node scripts/build-plugin-manifest.mjs --check && node --test tests/opencode-graph-gate.test.js && node --test tests/cross-harness-hooks.test.js && node --test tests/plan-canvas-security.test.mjs && node --test tests/plan-canvas-modes.test.mjs && node --test tests/swarm-wiring.test.js && node tests/aos-store.test.mjs && node --test tests/aos-store-ui.test.mjs && node tests/aos-store-scenario.test.mjs && node tests/aos-doctor.test.mjs && node tests/build-plugin-manifest.test.mjs && node --test tests/plugin-commands.test.js && node --test tests/playbooks.test.mjs && node --test tests/agent-models.test.js && node --test tests/installer-p3.test.js && node --test tests/plugin-migration.test.js && node --test tests/installer-binary.test.js && node --test tests/installer-version.test.js && node --test tests/mcsc-server.test.js && node --test tests/antigravity-hooks.test.js && node --test tests/windows-hooks.test.js && node --test tests/plan-builder.test.mjs && node --test tests/plan-builder-trail.test.mjs && node --test tests/plan-builder-showcase.test.mjs && node --test tests/plan-builder-templates.test.mjs && node --test tests/plan-canvas-home.test.mjs && node --test tests/launchpad-installer.test.js && node --test tests/launchpad-plan-canvas.test.js && node --test tests/go-token.test.js && node --test tests/go-gate-modes.test.js && node --test tests/go-gate-review.test.js && node --test tests/go-gate-review2.test.js && node --test tests/opencode-gogate.test.js && node --test tests/master-session-skill.test.js && node --test tests/aos-acp.test.js && node --test tests/opencode-go-token.test.js && node --test tests/opencode-adapter.test.js && node --test tests/opencode-install.test.js && node --test tests/pr-recap.test.mjs && node --test tests/bdb-visual-edit.test.mjs && node --test tests/opencode-trail-autostart.test.mjs && node --test tests/trail-autostart-hook.test.mjs && node --test tests/agenttrail-ensure.test.mjs && node --test tests/aos-bus.test.js && node --test tests/installer-quick-update.test.js"
33
33
  },
34
34
  "publishConfig": {
35
35
  "access": "public"
@@ -54,6 +54,11 @@
54
54
  ".agents/",
55
55
  ".claude/",
56
56
  ".codex-plugin/",
57
+ "plugin.json",
58
+ "plugin-commands.json",
59
+ "commands/",
60
+ "agy-commands/",
61
+ "plugins/bdb-aos-codex/",
57
62
  ".opencode/",
58
63
  "docs/",
59
64
  "!docs/handover/**",