geneseed 3.19.0 → 4.0.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.
- package/CHANGELOG.md +41 -0
- package/README.md +9 -1
- package/SHIPPED.md +1 -1
- package/adapters/bob/README.md +31 -12
- package/adapters/claude-code/README.md +24 -17
- package/adapters/claude-code/settings.json +4 -6
- package/adapters/openclaude/README.md +13 -5
- package/adapters/opencode/plugins/geneseed-guard.js +291 -174
- package/bin/geneseed-hook.mjs +36 -5
- package/docs/README.md +3 -1
- package/docs/candidate-hosts.md +0 -8
- package/docs/concepts/enforcement-table.md +66 -0
- package/docs/concepts/hook-gates.md +2 -2
- package/docs/concepts/hooks.md +6 -6
- package/docs/concepts/host-matrix.md +4 -3
- package/docs/concepts/loop-branch.md +6 -1
- package/docs/concepts/loops.md +4 -3
- package/docs/concepts/rules.md +1 -1
- package/docs/design-history.md +59 -0
- package/docs/extending.md +11 -3
- package/docs/guides/install-opencode.md +1 -1
- package/docs/guides/loop-running.md +1 -1
- package/docs/reference/env-context.md +3 -2
- package/docs/reference/environment.md +2 -1
- package/docs/reference/plugin-guard.md +3 -3
- package/docs/reference/troubleshoot-hooks.md +34 -0
- package/docs/understand/design-choices-engineering.md +29 -0
- package/docs/understand/design-choices-gates.md +23 -0
- package/docs/understand/design-choices-hosts.md +21 -0
- package/docs/understand/design-choices-install.md +19 -0
- package/docs/understand/design-choices-shape.md +21 -0
- package/docs/understand/design-choices.md +34 -0
- package/docs/understand/enforced-vs-asked.md +5 -4
- package/docs/understand/machine-bob.md +1 -1
- package/docs/understand/machine-opencode.md +2 -2
- package/harness.config.json +1 -1
- package/js/build/driver.mjs +42 -16
- package/js/build/emit-claude.mjs +24 -8
- package/js/build/emit-opencode.mjs +33 -15
- package/js/build/generate.mjs +38 -9
- package/js/build/source.mjs +39 -0
- package/js/build/stubs.mjs +2 -1
- package/js/cli-table.json +6 -6
- package/js/hosts/gitref.mjs +3 -3
- package/js/hosts/hooks-context.mjs +13 -31
- package/js/hosts/hooks-learn.mjs +89 -8
- package/js/hosts/hooks.mjs +212 -18
- package/js/hosts/hosts.mjs +217 -13
- package/js/hosts/installs.mjs +259 -46
- package/js/hosts/native.mjs +48 -12
- package/js/hosts/opencode.mjs +47 -17
- package/js/hosts/settings.mjs +366 -42
- package/js/hosts/shim.mjs +37 -0
- package/js/inspect/checks-authoring.mjs +81 -1
- package/js/inspect/checks-constitution.mjs +2 -1
- package/js/inspect/checks-repo.mjs +68 -4
- package/js/inspect/doctor.mjs +40 -9
- package/js/inspect/scan.mjs +5 -4
- package/js/inspect/status.mjs +62 -12
- package/js/lib/text.mjs +23 -0
- package/js/loop/catalog.mjs +7 -2
- package/js/loop/state.mjs +2 -2
- package/js/maintain/migrate.mjs +42 -5
- package/js/maintain/uninstall.mjs +150 -38
- package/js/maintain/update.mjs +22 -2
- package/js/web/actions.mjs +28 -10
- package/js/web/api.mjs +5 -3
- package/js/web/catalog.mjs +1 -1
- package/js/web/state.mjs +28 -15
- package/package.json +1 -1
- package/src/agents/_template.md +1 -1
- package/src/bricks/_template.md +1 -1
- package/src/bricks/apply.md +1 -1
- package/src/bricks/test.md +1 -1
- package/src/doctrines/ops.md +9 -7
- package/src/doctrines/process.md +3 -3
- package/src/laws/universal.md +1 -1
- package/src/loops/api-endpoint.json +1 -1
- package/src/loops/bugfix.json +1 -1
- package/src/loops/ci-repair.json +1 -1
- package/src/loops/deps-upgrade.json +1 -1
- package/src/loops/enforce-architecture-rule.json +1 -1
- package/src/loops/feature.json +1 -1
- package/src/loops/fix-flaky-tests.json +1 -1
- package/src/loops/legacy-refactor.json +1 -1
- package/src/loops/legacy-tests.json +1 -1
- package/src/loops/refactor.json +1 -1
- package/src/loops/remove-dead-code.json +1 -1
- package/src/loops/spec-first-feature.json +1 -1
- package/src/loops/tdd.json +1 -1
- package/src/loops/update-contract.json +1 -1
- package/src/skills/_template.md +3 -3
- package/src/skills/herdr.md +1 -1
- package/web/dist/assets/{Activity-IbwdHHyx.js → Activity-BCtqxjfy.js} +1 -1
- package/web/dist/assets/{ActivityDetail-B5uivdWI.js → ActivityDetail-Ba3Oo4dE.js} +1 -1
- package/web/dist/assets/{Dashboard-v6WQ37bD.js → Dashboard-DpHza68-.js} +2 -2
- package/web/dist/assets/{Docs-CypIfDBX.js → Docs-Bw_-UK0O.js} +1 -1
- package/web/dist/assets/{GreenhouseView-ClxP0hQG.js → GreenhouseView-DnJ1Ggp1.js} +1 -1
- package/web/dist/assets/{Installs-CPkQeFjO.js → Installs-CWoNKFBk.js} +1 -1
- package/web/dist/assets/{JournalView-BeEInhUf.js → JournalView-g6-bw4c8.js} +1 -1
- package/web/dist/assets/{Laws-Ds6U5xBQ.js → Laws-DkVWR1tb.js} +1 -1
- package/web/dist/assets/{Library-BraUKPyQ.js → Library-CK9HZzjV.js} +1 -1
- package/web/dist/assets/{Loops-DWcy3mwK.js → Loops-BP98SMOH.js} +1 -1
- package/web/dist/assets/{OperatorHudView-CQyrJQ9m.js → OperatorHudView-DI_n12M9.js} +1 -1
- package/web/dist/assets/{Personal-YWqs6qFL.js → Personal-Bk89f13a.js} +1 -1
- package/web/dist/assets/{Tabs-EhzEC-Xh.js → Tabs-3WuYIE1b.js} +1 -1
- package/web/dist/assets/{drawable-CEKPNV7X.js → drawable-oCUeq9e5.js} +1 -1
- package/web/dist/assets/{index-CPi22b7y.js → index-CiXwChvf.js} +2 -2
- package/web/dist/index.html +1 -1
package/js/hosts/installs.mjs
CHANGED
|
@@ -36,11 +36,15 @@ import { existsSync, readdirSync } from 'node:fs';
|
|
|
36
36
|
import path from 'node:path';
|
|
37
37
|
|
|
38
38
|
import { CONFIG, PACK_ORDER, THEMES, discoverNames, resolveSkillNames } from '../build/source.mjs';
|
|
39
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
CLAUDE_STYLE, DISABLED_STASH, GLOBAL_MANIFEST, HOSTS, isHostGlobalDir, liveRecordedHooks,
|
|
41
|
+
resolvePath,
|
|
42
|
+
} from './hosts.mjs';
|
|
40
43
|
import { registryRoots } from '../inspect/registry.mjs';
|
|
41
44
|
import { printErr, readText, isFile, isDir } from '../lib/fs.mjs';
|
|
42
45
|
import { formatRepr, isDict } from '../lib/json.mjs';
|
|
43
46
|
import { comparePaths } from '../lib/paths.mjs';
|
|
47
|
+
import { claudeHookShell } from './shim.mjs';
|
|
44
48
|
|
|
45
49
|
/**
|
|
46
50
|
* `Path.read_text(encoding="utf-8")`, or null where Python raises OSError.
|
|
@@ -137,14 +141,54 @@ function themeFromAgent(agentMd) {
|
|
|
137
141
|
}
|
|
138
142
|
|
|
139
143
|
/**
|
|
140
|
-
* `_harness_setup.CARRIERS` — the four
|
|
144
|
+
* `_harness_setup.CARRIERS` — the Python's four root-level carriers, plus one JS-only addition.
|
|
141
145
|
*
|
|
142
146
|
* A shared table rather than three copies of the same list: `_theme_of_dir`,
|
|
143
147
|
* `_posture_of_dir` and `_mode_of_dir` each walk it, and the order is observable (the first
|
|
144
148
|
* carrier that answers wins). `rules/geneseed.md` is spelled with `path.join` at each use
|
|
145
149
|
* because the Python writes `d / "rules" / "geneseed.md"` for that one entry.
|
|
150
|
+
*
|
|
151
|
+
* `.openclaude/CLAUDE.md` is NOT in the Python — OpenClaude didn't exist yet — and it is half
|
|
152
|
+
* of the fix for host-compat verdict B1 (the other half is `carriersFor`, below).
|
|
153
|
+
* OpenClaude's own per-repo carrier is `carrierInLayer` (see `CLAUDE_SHAPED.openclaude` in
|
|
154
|
+
* `js/build/driver.mjs`, and the `HOSTS` column of the same name): it sits at
|
|
155
|
+
* `<repo>/.openclaude/CLAUDE.md`, and the root `CLAUDE.md` is deliberately left untouched so a
|
|
156
|
+
* Claude Code install can share the repo.
|
|
157
|
+
*
|
|
158
|
+
* ⚠ THIS LIST STAYS HOST-AGNOSTIC ON PURPOSE — it is only `firstCarrier`'s FALLBACK for a
|
|
159
|
+
* caller with no host to narrow by (none of this module's exported `*OfDir` readers is one;
|
|
160
|
+
* see `carriersFor`). A caller that DOES have a host must pass it, or in a repo sharing two
|
|
161
|
+
* carriers that answer the same probe — only root `CLAUDE.md` today, between `claude` and
|
|
162
|
+
* `openclaude`'s global scope — this host-agnostic order decides for both of them, which was
|
|
163
|
+
* exactly B1's failure mode reversed: reading `.openclaude/CLAUDE.md` unconditionally made
|
|
164
|
+
* `claude`'s OWN read-back answer OpenClaude's settings in a shared repo.
|
|
146
165
|
*/
|
|
147
|
-
const CARRIERS = [
|
|
166
|
+
const CARRIERS = [
|
|
167
|
+
'AGENT.md', path.join('.openclaude', 'CLAUDE.md'), 'CLAUDE.md',
|
|
168
|
+
path.join('rules', 'geneseed.md'), 'AGENTS.md',
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* `host`'s own carrier path(s), narrowed off the single `HOSTS` table — `carrierInLayer` and
|
|
173
|
+
* `projectMarker`/`agentFile` — rather than a second hand-rolled host→carrier map.
|
|
174
|
+
*
|
|
175
|
+
* ONE HOST, ONE ANSWER, EXCEPT BOB. `carrierInLayer` true (OpenClaude only) tries the nested
|
|
176
|
+
* per-repo path FIRST and the bare `agentFile` SECOND, because the same column name means two
|
|
177
|
+
* different locations depending on scope: `<repo>/.openclaude/CLAUDE.md` for a project
|
|
178
|
+
* install, `<cfgDir>/CLAUDE.md` for a global one (see `CLAUDE_SHAPED.openclaude-global`,
|
|
179
|
+
* which has no `carrierInLayer`). Bob is the one host with a second, differently-NAMED
|
|
180
|
+
* carrier — `rules/geneseed.md` at global scope, because Bob never auto-loads a global
|
|
181
|
+
* `AGENTS.md` (`CLAUDE_SHAPED['bob-global']`'s summary says so) — which `HOSTS` has no column
|
|
182
|
+
* for and this hard-codes, same as the un-narrowed `CARRIERS` above always did.
|
|
183
|
+
*/
|
|
184
|
+
function carriersFor(host) {
|
|
185
|
+
const row = HOSTS.find((h) => h.host === host);
|
|
186
|
+
if (!row) return CARRIERS;
|
|
187
|
+
if (host === 'bob') return [path.join('rules', 'geneseed.md'), row.agentFile];
|
|
188
|
+
return row.carrierInLayer
|
|
189
|
+
? [path.join(row.projectMarker, row.agentFile), row.agentFile]
|
|
190
|
+
: [row.agentFile];
|
|
191
|
+
}
|
|
148
192
|
|
|
149
193
|
/**
|
|
150
194
|
* The scan `themeOfDir`, `leadOfDir`, `doctrinesOfDir` and `excludedRulesOfDir` each wrote
|
|
@@ -153,11 +197,35 @@ const CARRIERS = ['AGENT.md', 'CLAUDE.md', path.join('rules', 'geneseed.md'), 'A
|
|
|
153
197
|
* legitimate stop values for the two register readers below — to mean "stop, this is the
|
|
154
198
|
* answer". `fallback` is what every caller's own trailing `return …;` supplied once the
|
|
155
199
|
* whole list was exhausted with no answer.
|
|
200
|
+
*
|
|
201
|
+
* `host`, when given, narrows the scan to THAT HOST'S OWN carrier(s) via `carriersFor` —
|
|
202
|
+
* `null` (the default) keeps today's full, host-agnostic `CARRIERS` walk, so every call site
|
|
203
|
+
* this task's fix round did not touch is unchanged. Every exported reader below takes the
|
|
204
|
+
* same optional `host` last, mirroring `trustOfDir`'s existing `(d, host = null)` shape one
|
|
205
|
+
* screen down — this is not a new convention, it is that one applied to the carrier scan too.
|
|
206
|
+
*
|
|
207
|
+
* ⚠ HOST-NARROWED: THE FIRST CANDIDATE THAT EXISTS IS AUTHORITATIVE, EVEN IF ITS PROBE COMES
|
|
208
|
+
* BACK `undefined`. `undefined` is overloaded on purpose for the host-agnostic walk above (it
|
|
209
|
+
* means BOTH "this carrier is absent" and "this carrier exists but says nothing"), and that
|
|
210
|
+
* is exactly right for `CARRIERS`, where only one entry can ever exist for a given host —
|
|
211
|
+
* but `carriersFor` can return TWO paths for one host (OpenClaude's nested-then-bare
|
|
212
|
+
* `CLAUDE.md`; Bob's `rules/geneseed.md`-then-`AGENTS.md`), and "exists but says nothing" is
|
|
213
|
+
* the COMMON case for `excludedRulesOfDir`/`excludedSkillsOfDir` — their marker line is
|
|
214
|
+
* written only when something is excluded. Falling through past an existing-but-silent own
|
|
215
|
+
* carrier to the SECOND candidate let it read a sibling host's file that happens to sit at
|
|
216
|
+
* that second path (root `CLAUDE.md`, for OpenClaude's second candidate, in a repo also
|
|
217
|
+
* carrying a Claude install) — the fix-round-1 bug, reversed again. So once a host is given,
|
|
218
|
+
* `isFile` on each candidate (checked AFTER the probe, so a non-existent file's `undefined`
|
|
219
|
+
* still falls through to try the host's OWN next candidate — the one legitimate reason the
|
|
220
|
+
* list has two entries) stops the walk at the first one that exists and returns `fallback`,
|
|
221
|
+
* never a later file's answer.
|
|
156
222
|
*/
|
|
157
|
-
function firstCarrier(d, probe, fallback = null) {
|
|
158
|
-
for (const carrier of CARRIERS) {
|
|
159
|
-
const
|
|
223
|
+
function firstCarrier(d, probe, fallback = null, host = null) {
|
|
224
|
+
for (const carrier of (host === null ? CARRIERS : carriersFor(host))) {
|
|
225
|
+
const carrierPath = path.join(d, carrier);
|
|
226
|
+
const result = probe(carrierPath);
|
|
160
227
|
if (result !== undefined) return result;
|
|
228
|
+
if (host !== null && isFile(carrierPath)) return fallback;
|
|
161
229
|
}
|
|
162
230
|
return fallback;
|
|
163
231
|
}
|
|
@@ -176,14 +244,35 @@ const THEME_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
|
176
244
|
* through to the sigil scan, as a missing marker does. A plain name that is not shipped is
|
|
177
245
|
* still RETURNED: `loadTheme` then refuses it loudly, which `status`/`rebuild-all`/`migrate`
|
|
178
246
|
* cells pin ("unknown theme 'nosuchtheme'"), rather than a typo going unnoticed.
|
|
247
|
+
*
|
|
248
|
+
* `host`, when given, narrows the sigil scan to that host's own carrier — see `firstCarrier`.
|
|
249
|
+
*
|
|
250
|
+
* ⚠ THE ROOT MARKER ITSELF IS HOST-NARROWED TOO (host-compat B1, round 3): `driver.mjs` writes
|
|
251
|
+
* it at `d` unconditionally for a GLOBAL emit (every host's `-global` arm, the `writeText`
|
|
252
|
+
* right after `writeMarkers`), but at PROJECT scope only for a host whose emit calls `build()`
|
|
253
|
+
* directly — today that is `opencode` alone; the CLAUDE-STYLE hosts (`claude`/`bob`/
|
|
254
|
+
* `openclaude`, `CLAUDE_STYLE` below) go through `claudeShaped` instead, which never calls it
|
|
255
|
+
* (`driver.mjs`'s own comment: "Deliberately not written for the claude/bob/openclaude
|
|
256
|
+
* PROJECT emits"). So a claude-style host must distrust this marker UNLESS `d` is provably
|
|
257
|
+
* that host's OWN global config dir — `isHostGlobalDir` (`hosts.mjs`), which is what makes this
|
|
258
|
+
* scope-correct without a `scope` parameter: at ANY other `d` (a project root, or another
|
|
259
|
+
* host's global dir entirely) a claude-style host's project emit never wrote this file, so a
|
|
260
|
+
* present one is a SIBLING's (`opencode`'s project marker, in a shared repo — the B1 failure,
|
|
261
|
+
* reached a third way) and `themeOfDir` falls through to `firstCarrier`'s sigil scan instead,
|
|
262
|
+
* exactly as it already must for a claude-style host's own project installs.
|
|
179
263
|
*/
|
|
180
|
-
export function themeOfDir(d) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
264
|
+
export function themeOfDir(d, host = null) {
|
|
265
|
+
let trust = host === null || !CLAUDE_STYLE.includes(host);
|
|
266
|
+
// A `configDir()` that throws cannot prove `d` is the global: distrust the marker.
|
|
267
|
+
try { trust ||= isHostGlobalDir(host, d); } catch { /* sigil scan below */ }
|
|
268
|
+
if (trust) {
|
|
269
|
+
const marker = path.join(d, '.geneseed-theme');
|
|
270
|
+
if (isFile(marker)) {
|
|
271
|
+
const name = (readMaybe(marker) ?? '').trim();
|
|
272
|
+
if (THEME_NAME_RE.test(name)) return name;
|
|
273
|
+
}
|
|
185
274
|
}
|
|
186
|
-
return firstCarrier(d, (carrierPath) => themeFromAgent(carrierPath) || undefined);
|
|
275
|
+
return firstCarrier(d, (carrierPath) => themeFromAgent(carrierPath) || undefined, null, host);
|
|
187
276
|
}
|
|
188
277
|
|
|
189
278
|
/** `_harness_setup.FOOTPRINTS`. */
|
|
@@ -230,7 +319,7 @@ export function capitalize(s) {
|
|
|
230
319
|
return s.length ? s[0].toUpperCase() + s.slice(1).toLowerCase() : s;
|
|
231
320
|
}
|
|
232
321
|
|
|
233
|
-
function leadOfDir(d, names) {
|
|
322
|
+
function leadOfDir(d, names, host = null) {
|
|
234
323
|
return firstCarrier(d, (carrierPath) => {
|
|
235
324
|
const text = readMaybe(carrierPath);
|
|
236
325
|
if (text === null) return undefined;
|
|
@@ -238,11 +327,11 @@ function leadOfDir(d, names) {
|
|
|
238
327
|
if (text.includes(`**${capitalize(name)}**`)) return name;
|
|
239
328
|
}
|
|
240
329
|
return undefined;
|
|
241
|
-
});
|
|
330
|
+
}, null, host);
|
|
242
331
|
}
|
|
243
332
|
|
|
244
|
-
export const postureOfDir = (d) => leadOfDir(d, discoverNames('postures', 'peer'));
|
|
245
|
-
export const modeOfDir = (d) => leadOfDir(d, discoverNames('modes', 'direct'));
|
|
333
|
+
export const postureOfDir = (d, host = null) => leadOfDir(d, discoverNames('postures', 'peer'), host);
|
|
334
|
+
export const modeOfDir = (d, host = null) => leadOfDir(d, discoverNames('modes', 'direct'), host);
|
|
246
335
|
|
|
247
336
|
/**
|
|
248
337
|
* The loop skill's default trust preset, read back off a deployed install — `null` when no
|
|
@@ -274,7 +363,8 @@ export function trustOfDir(d, host = null) {
|
|
|
274
363
|
const ACTIVE_PACKS_RE = /^Active packs:[ \t]*(.+?)[ \t]*$/m;
|
|
275
364
|
|
|
276
365
|
/**
|
|
277
|
-
* A carrier rendered BEFORE the comms pack existed — the address migration's one
|
|
366
|
+
* A carrier rendered BEFORE the comms pack existed — the pre-4.0.0 address migration's one
|
|
367
|
+
* detector, kept only to WARN now.
|
|
278
368
|
*
|
|
279
369
|
* The comms pack took `process 7` (reference codes) out of process, and `process 8` (one
|
|
280
370
|
* writer per file) was renumbered to `process 7`. An install built before that names
|
|
@@ -285,12 +375,67 @@ const ACTIVE_PACKS_RE = /^Active packs:[ \t]*(.+?)[ \t]*$/m;
|
|
|
285
375
|
* it. No current carrier can say `process 8` — the pack has seven rules and nothing cites
|
|
286
376
|
* an eighth.
|
|
287
377
|
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
378
|
+
* 4.0.0 RETIRED THE AUTO-MIGRATION THIS DROVE (see `warnLegacyProcessCarrier` below): it used
|
|
379
|
+
* to add `comms` and re-address `process 7`/`process 8` on sight, silently, which is exactly
|
|
380
|
+
* backward the day the process pack ever regains an eighth rule for an unrelated reason —
|
|
381
|
+
* this sniff would then fire on a CURRENT carrier and rewrite data nobody asked rewritten. A
|
|
382
|
+
* false positive that only prints a loud warning and reads the carrier as written is safe to
|
|
383
|
+
* keep; a false positive that mutates data was not. See CHANGELOG "Removed" (4.0.0) for the
|
|
384
|
+
* upgrade instruction this now points installs at.
|
|
291
385
|
*/
|
|
292
386
|
const legacyProcessCarrier = (text) => /\bprocess 8\b/.test(text);
|
|
293
387
|
|
|
388
|
+
/**
|
|
389
|
+
* The loud half of retiring the migration: one stderr line pointing a pre-comms install at
|
|
390
|
+
* `geneseed setup`, instead of the silent rewrite `doctrinesOfDir`/`excludedRulesOfDir` used
|
|
391
|
+
* to perform on the same detection. Never thrown — both are read-only introspection called by
|
|
392
|
+
* `uninstall`, `diff`, `validate` and the web console, none of which should become
|
|
393
|
+
* un-runnable against an install that merely needs a rebuild.
|
|
394
|
+
*
|
|
395
|
+
* NEVER `geneseed rebuild-all` — that is the exact command `rebuildAll` (`js/build/generate.mjs`)
|
|
396
|
+
* now REFUSES to run against a legacy carrier (see `legacyProcessCarrierOfDir` there), because
|
|
397
|
+
* running it is what caused the permanent data loss this fix round exists to stop. Pointing the
|
|
398
|
+
* warning at the one command that is guaranteed to do nothing here would be a second silent
|
|
399
|
+
* misread wearing a loud one's clothes.
|
|
400
|
+
*
|
|
401
|
+
* DEDUPED ONCE PER PROCESS — `doctor`/`validate`/`uninstall` read the same install's carrier
|
|
402
|
+
* more than once per run, and a line per read drowns the one thing the owner needs to act on.
|
|
403
|
+
* `resetLegacyProcessCarrierWarning` (below) exists only so tests can see the line fire more
|
|
404
|
+
* than once in one process; nothing else should call it.
|
|
405
|
+
*/
|
|
406
|
+
let warnedLegacyProcessCarrier = false;
|
|
407
|
+
function warnLegacyProcessCarrier() {
|
|
408
|
+
if (warnedLegacyProcessCarrier) return;
|
|
409
|
+
warnedLegacyProcessCarrier = true;
|
|
410
|
+
printErr('[geneseed] WARN: this install predates the comms pack (3.7.0, 2026-09-27) — its '
|
|
411
|
+
+ "carrier still names 'process 8'. The automatic migration (adding comms, re-addressing "
|
|
412
|
+
+ 'process 7/process 8) was removed in 4.0.0; the carrier is now read as written. Run '
|
|
413
|
+
+ '`geneseed setup` once to rebuild this install on the current rule addresses.\n');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Test-only: undo the dedup above so a test can see the warning fire again. */
|
|
417
|
+
export function resetLegacyProcessCarrierWarning() {
|
|
418
|
+
warnedLegacyProcessCarrier = false;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Whether ANY of `d`'s own carriers still shows the pre-comms signature — the one question
|
|
423
|
+
* `rebuildAll`, `upgrade` (which shells into it) and `migrate` must ask BEFORE calling
|
|
424
|
+
* `installProfile`/`driverMain` on an install, now that `doctrinesOfDir`/`excludedRulesOfDir`
|
|
425
|
+
* no longer rewrite it for them. Re-emitting a legacy carrier through the current template
|
|
426
|
+
* does not raise an error — it just writes a fresh `Active packs:` line that silently drops
|
|
427
|
+
* `comms` and leaves `process 7`/`process 8` unaddressed, permanently, with no further carrier
|
|
428
|
+
* text left afterward for THIS function to ever detect again. Checking before the write is the
|
|
429
|
+
* only point where the loss is still preventable.
|
|
430
|
+
*/
|
|
431
|
+
export function legacyProcessCarrierOfDir(d, host = null) {
|
|
432
|
+
return firstCarrier(d, (carrierPath) => {
|
|
433
|
+
const text = readMaybe(carrierPath);
|
|
434
|
+
if (text === null) return undefined;
|
|
435
|
+
return legacyProcessCarrier(text) || undefined;
|
|
436
|
+
}, false, host) === true;
|
|
437
|
+
}
|
|
438
|
+
|
|
294
439
|
/**
|
|
295
440
|
* The doctrine packs a DEPLOYED install carries — the third register read back off disk,
|
|
296
441
|
* beside `postureOfDir` and `modeOfDir`, and the first that is a SET.
|
|
@@ -322,7 +467,7 @@ const legacyProcessCarrier = (text) => /\bprocess 8\b/.test(text);
|
|
|
322
467
|
* checkout ships; one that is not condemns the whole line to `null`. A trailing comma is
|
|
323
468
|
* exactly the empty field a fold leaves behind, which is what makes this catch it.
|
|
324
469
|
*/
|
|
325
|
-
export function doctrinesOfDir(d) {
|
|
470
|
+
export function doctrinesOfDir(d, host = null) {
|
|
326
471
|
return firstCarrier(d, (carrierPath) => {
|
|
327
472
|
const text = readMaybe(carrierPath);
|
|
328
473
|
if (text === null) return undefined;
|
|
@@ -331,14 +476,14 @@ export function doctrinesOfDir(d) {
|
|
|
331
476
|
if (m[1] === 'none') return [];
|
|
332
477
|
const named = m[1].split(',').map((s) => s.trim());
|
|
333
478
|
if (!named.every((n) => PACK_ORDER.includes(n))) return null;
|
|
334
|
-
// MIGRATION: a pre-comms install
|
|
335
|
-
//
|
|
336
|
-
//
|
|
479
|
+
// RETIRED MIGRATION (4.0.0): a pre-comms install used to gain `comms` here so it kept the
|
|
480
|
+
// codes rule it carried inside `process`. That rewrite is gone — the carrier is read as
|
|
481
|
+
// written, and the owner is warned loudly instead, once per read.
|
|
337
482
|
if (named.includes('process') && !named.includes('comms') && legacyProcessCarrier(text)) {
|
|
338
|
-
|
|
483
|
+
warnLegacyProcessCarrier();
|
|
339
484
|
}
|
|
340
485
|
return PACK_ORDER.filter((pk) => named.includes(pk));
|
|
341
|
-
});
|
|
486
|
+
}, null, host);
|
|
342
487
|
}
|
|
343
488
|
|
|
344
489
|
/**
|
|
@@ -359,8 +504,8 @@ export function doctrinesOfDir(d) {
|
|
|
359
504
|
*
|
|
360
505
|
* `[]` still passes through untouched — a marker that reads `none` is an answer, not a silence.
|
|
361
506
|
*/
|
|
362
|
-
export function doctrinesForBuild(d) {
|
|
363
|
-
return doctrinesOfDir(d) ?? [...PACK_ORDER];
|
|
507
|
+
export function doctrinesForBuild(d, host = null) {
|
|
508
|
+
return doctrinesOfDir(d, host) ?? [...PACK_ORDER];
|
|
364
509
|
}
|
|
365
510
|
|
|
366
511
|
/** `Excluded rules: process 7, craft 3` — the marker's optional second line. */
|
|
@@ -384,7 +529,7 @@ const EXCLUDED_RULES_RE = /^Excluded rules:[ \t]*(.+?)[ \t]*$/m;
|
|
|
384
529
|
* same rule as the pack list, same reason, opposite default. Half an exclusion list would
|
|
385
530
|
* take away rules nobody named.
|
|
386
531
|
*/
|
|
387
|
-
export function excludedRulesOfDir(d) {
|
|
532
|
+
export function excludedRulesOfDir(d, host = null) {
|
|
388
533
|
return firstCarrier(d, (carrierPath) => {
|
|
389
534
|
const text = readMaybe(carrierPath);
|
|
390
535
|
if (text === null) return undefined;
|
|
@@ -400,12 +545,13 @@ export function excludedRulesOfDir(d) {
|
|
|
400
545
|
? `${parts[0]}.${Number(parts[1])}` : null;
|
|
401
546
|
});
|
|
402
547
|
if (!ids.every(Boolean)) return [];
|
|
403
|
-
// MIGRATION:
|
|
404
|
-
// `
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
548
|
+
// RETIRED MIGRATION (4.0.0): a pre-comms carrier used to have its old addresses
|
|
549
|
+
// (`process.7` -> `comms.1`, `process.8` -> `process.7`) remapped here, once, so
|
|
550
|
+
// `process 8` could not chain onward. That rewrite is gone — the addresses are returned
|
|
551
|
+
// exactly as named, and the owner is warned loudly instead of having them silently moved.
|
|
552
|
+
if (legacyProcessCarrier(text)) warnLegacyProcessCarrier();
|
|
553
|
+
return [...new Set(ids)].sort();
|
|
554
|
+
}, [], host);
|
|
409
555
|
}
|
|
410
556
|
|
|
411
557
|
/** `Excluded skills: bruno, daydream` — written only when something is excluded. */
|
|
@@ -421,9 +567,10 @@ const EXCLUDED_SKILLS_RE = /^Excluded skills:[ \t]*(.+?)[ \t]*$/m;
|
|
|
421
567
|
* name drops out (`resolveSkillNames`), so a skill merge does not quietly re-admit everything
|
|
422
568
|
* else the install left out. Silent unless the caller passes `notify` — status, diff and the
|
|
423
569
|
* console call this on every read; the replays that rebuild an install (`rebuild-all`,
|
|
424
|
-
* `upgrade`, `migrate`) pass it.
|
|
570
|
+
* `upgrade`, `migrate`) pass it. `host` is last, after `notify`, for the same reason
|
|
571
|
+
* `trustOfDir` puts it last: every existing positional call keeps working unchanged.
|
|
425
572
|
*/
|
|
426
|
-
export function excludedSkillsOfDir(d, notify = null) {
|
|
573
|
+
export function excludedSkillsOfDir(d, notify = null, host = null) {
|
|
427
574
|
return firstCarrier(d, (carrierPath) => {
|
|
428
575
|
const text = readMaybe(carrierPath);
|
|
429
576
|
if (text === null) return undefined;
|
|
@@ -433,7 +580,7 @@ export function excludedSkillsOfDir(d, notify = null) {
|
|
|
433
580
|
if (unknown.length) return [];
|
|
434
581
|
if (notify) for (const n of notices) notify(n);
|
|
435
582
|
return ids;
|
|
436
|
-
}, []);
|
|
583
|
+
}, [], host);
|
|
437
584
|
}
|
|
438
585
|
|
|
439
586
|
// ---- what host a deployed dir belongs to (`_harness_mcp`) ---------------------------------
|
|
@@ -468,8 +615,8 @@ export function emitHostScopeOf(root) {
|
|
|
468
615
|
return EMIT_HOST_SCOPE.get(emit.trim()) ?? null;
|
|
469
616
|
}
|
|
470
617
|
|
|
471
|
-
|
|
472
|
-
export
|
|
618
|
+
// `DISABLED_STASH` moved to `hosts.mjs`: the hook stand-down reads it and must not import this.
|
|
619
|
+
export { DISABLED_STASH };
|
|
473
620
|
|
|
474
621
|
/**
|
|
475
622
|
* `_harness_mcp._claude_cfg` — where a Claude-STYLE install keeps its manifest.
|
|
@@ -505,6 +652,33 @@ export function installState(root, host = 'opencode', scope = 'global') {
|
|
|
505
652
|
return installKind(root) !== null ? 'active' : 'absent';
|
|
506
653
|
}
|
|
507
654
|
|
|
655
|
+
/**
|
|
656
|
+
* Host-compat verdict I1: an OpenClaude PROJECT install is additive, never exclusive — it loads
|
|
657
|
+
* its own `<repo>/.openclaude/CLAUDE.md` **and** whatever root `CLAUDE.md`/`AGENTS.md` a claude
|
|
658
|
+
* or bob PROJECT install already wrote there (`B/src/utils/claudemd.ts` l.904-928; `CARRIERS`
|
|
659
|
+
* above deliberately leaves the root file untouched for OpenClaude so a Claude/Bob install can
|
|
660
|
+
* share the repo). There is no clean fix: excluding the root file would hide the user's OWN
|
|
661
|
+
* content in it too, so the only thing `status`/`doctor` can do is say the doubling exists.
|
|
662
|
+
*
|
|
663
|
+
* `installState` on the two hosts is enough — a claude/bob PROJECT install is 'active' only
|
|
664
|
+
* once it has actually written its own `.claude`/`.bob` manifest, which is exactly when it has
|
|
665
|
+
* also written the root carrier.
|
|
666
|
+
*/
|
|
667
|
+
export function openclaudeDualHarnessRoot(root) {
|
|
668
|
+
if (installState(root, 'openclaude', 'project') !== 'active') return false;
|
|
669
|
+
return installState(root, 'claude', 'project') === 'active'
|
|
670
|
+
|| installState(root, 'bob', 'project') === 'active';
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Every PROJECT root `installTargets()` reaches where `openclaudeDualHarnessRoot` is true. */
|
|
674
|
+
export function openclaudeDualHarnessRoots() {
|
|
675
|
+
const roots = new Set();
|
|
676
|
+
for (const [, scope, root] of installTargets()) {
|
|
677
|
+
if (scope === 'project') roots.add(resolvePath(root));
|
|
678
|
+
}
|
|
679
|
+
return [...roots].filter(openclaudeDualHarnessRoot).sort(comparePaths);
|
|
680
|
+
}
|
|
681
|
+
|
|
508
682
|
/** `_harness_mcp._registered_targets` — (host, scope, root) for every registered root. */
|
|
509
683
|
export function registeredTargets() {
|
|
510
684
|
const out = [];
|
|
@@ -531,15 +705,15 @@ export function installTargets() {
|
|
|
531
705
|
const seen = new Set();
|
|
532
706
|
|
|
533
707
|
const add = (host, scope, root) => {
|
|
534
|
-
// A "project" whose marker dir IS this host's global
|
|
535
|
-
//
|
|
536
|
-
//
|
|
708
|
+
// A "project" whose marker dir IS this host's global (`isHostGlobalDir`: the env-resolved
|
|
709
|
+
// dir, the default `~/<marker>`, or a `-global` emit) is the global install seen from its
|
|
710
|
+
// parent (the daemon's cwd is $HOME, where $HOME/.claude == ~/.claude) — not a separate
|
|
711
|
+
// project. Surfacing it would alias the global's files, and `rebuild-all` would re-emit it
|
|
712
|
+
// as a project rooted at $HOME (final review C2, under `$CLAUDE_CONFIG_DIR`).
|
|
537
713
|
if (scope !== 'global') {
|
|
538
714
|
try {
|
|
539
715
|
const spec = HOSTS.find((h) => h.host === host);
|
|
540
|
-
if (
|
|
541
|
-
return;
|
|
542
|
-
}
|
|
716
|
+
if (isHostGlobalDir(host, path.join(root, spec.projectMarker))) return;
|
|
543
717
|
} catch { /* as the Python's bare `except Exception: pass` */ }
|
|
544
718
|
}
|
|
545
719
|
const key = `${host}\0${resolvePath(root)}`;
|
|
@@ -557,6 +731,45 @@ export function installTargets() {
|
|
|
557
731
|
return out;
|
|
558
732
|
}
|
|
559
733
|
|
|
734
|
+
/**
|
|
735
|
+
* Each Claude install whose emitted hook FORM no longer matches this machine's hook shell
|
|
736
|
+
* (`claudeHookShell`; Task 15) — the "Git Bash removed after the emit" gap, which otherwise
|
|
737
|
+
* reports nothing: hooks signal through stdout and Claude treats a parse error as non-blocking.
|
|
738
|
+
*
|
|
739
|
+
* Bash form with Git Bash now gone is a PROBLEM: `context`/`learn` fail open under PowerShell
|
|
740
|
+
* (no `onFailure`, so a launch failure there is still non-blocking) and, since Task 1, the two
|
|
741
|
+
* gates fail CLOSED instead (`onFailure: "block"` blocks every Bash/PowerShell/Write/Edit/
|
|
742
|
+
* NotebookEdit call until `rebuild-all` re-emits the PowerShell form) — worse for the user
|
|
743
|
+
* either way, so the message below still reports it as drift needing a rebuild; it does not
|
|
744
|
+
* distinguish the two outcomes. PowerShell form with Git Bash now present is a `[note]`: the hooks work,
|
|
745
|
+
* they only pay PowerShell's startup. Read from the recorded claims the settings file still
|
|
746
|
+
* carries (`liveRecordedHooks`) — the form the emit wrote, not re-derived; Windows only, since
|
|
747
|
+
* nowhere else has two hook shells. `targets` and `platform` are parameters so a test can hand
|
|
748
|
+
* it a sandboxed install and `gateSummary` its own config dirs.
|
|
749
|
+
*/
|
|
750
|
+
export function hookShellProblems(targets = installTargets(), platform = process.platform) {
|
|
751
|
+
if (platform !== 'win32') return [];
|
|
752
|
+
const now = claudeHookShell(process.env, platform);
|
|
753
|
+
const out = [];
|
|
754
|
+
for (const [host, scope, root] of targets) {
|
|
755
|
+
// A disabled install's manifest still records the hooks it unwired: nothing runs, so
|
|
756
|
+
// nothing drifts.
|
|
757
|
+
if (host !== 'claude' || claudeState(root, scope) !== 'active') continue;
|
|
758
|
+
const cfg = claudeCfg(root, scope);
|
|
759
|
+
const handlers = liveRecordedHooks(cfg);
|
|
760
|
+
if (!handlers.length) continue;
|
|
761
|
+
const was = handlers.some(([, h]) => h.shell === 'powershell') ? 'powershell' : 'bash';
|
|
762
|
+
if (was === now) continue;
|
|
763
|
+
out.push(was === 'bash'
|
|
764
|
+
? `[hooks] ${cfg}: hooks were emitted for Git Bash, which is no longer found, so Claude `
|
|
765
|
+
+ 'Code runs them under PowerShell where context/learn fail open (no effect) and '
|
|
766
|
+
+ 'git-gate/rule-gate now BLOCK every call instead of gating it - run: geneseed rebuild-all'
|
|
767
|
+
: `[note] ${cfg}: hooks were emitted for PowerShell, but Git Bash is now found - they work, `
|
|
768
|
+
+ 'and pay PowerShell startup on every call; geneseed rebuild-all switches them back');
|
|
769
|
+
}
|
|
770
|
+
return out;
|
|
771
|
+
}
|
|
772
|
+
|
|
560
773
|
// ---- what this machine installed (`_harness_setup._installed_defaults`) --------------------
|
|
561
774
|
|
|
562
775
|
/**
|
package/js/hosts/native.mjs
CHANGED
|
@@ -150,7 +150,7 @@ function triggerOf(text) {
|
|
|
150
150
|
* failure, so the cure is a shorter first trigger sentence in the source, never a wider cap.
|
|
151
151
|
*/
|
|
152
152
|
const DESCRIPTION_CAP = 320;
|
|
153
|
-
function skillDescription(text) {
|
|
153
|
+
export function skillDescription(text) {
|
|
154
154
|
return withWhen(firstBlockquote(text), triggerOf(text));
|
|
155
155
|
}
|
|
156
156
|
|
|
@@ -245,7 +245,7 @@ function isReadonly(text) {
|
|
|
245
245
|
* themselves, and the ones gated on an environment the model cannot see (a herdr pane).
|
|
246
246
|
*/
|
|
247
247
|
const USER_INVOKED_RE = /<!--\s*invocation:\s*user\s*-->/;
|
|
248
|
-
function isUserInvokedOnly(text) {
|
|
248
|
+
export function isUserInvokedOnly(text) {
|
|
249
249
|
return USER_INVOKED_RE.test(text);
|
|
250
250
|
}
|
|
251
251
|
|
|
@@ -359,7 +359,11 @@ function claudeAgentFrontmatter(stem, text, overrides) {
|
|
|
359
359
|
if (isReadonly(text)) {
|
|
360
360
|
const list = ['Write', 'Edit', 'NotebookEdit'];
|
|
361
361
|
if (!text.includes(WEBFETCH_MARKER)) list.push('WebFetch');
|
|
362
|
-
|
|
362
|
+
// Bash AND PowerShell: docs `tools-reference.md` says denying Bash turns PowerShell off
|
|
363
|
+
// only for settings rules and `--disallowedTools` — agent frontmatter is not named there,
|
|
364
|
+
// so a "read-only" agent on Windows could still run shell commands via PowerShell
|
|
365
|
+
// (Claude verdict R1). The one opt-in marker lifts both together.
|
|
366
|
+
if (!text.includes(BASH_MARKER)) list.push('Bash', 'PowerShell');
|
|
363
367
|
fm.push(`disallowedTools: ${list.join(', ')}`);
|
|
364
368
|
}
|
|
365
369
|
return fm;
|
|
@@ -505,8 +509,10 @@ export function claimer(oldOwned, cfg, manifestExisted = true) {
|
|
|
505
509
|
* `opts.claim` — a `claimer` closure the caller shares with its other writers; built from
|
|
506
510
|
* `oldOwned`/`cfg`/`manifestExisted` when absent.
|
|
507
511
|
*
|
|
508
|
-
* Returns `{ nAgents, nSkills, written }` — Python's 3-tuple, with `written` as
|
|
509
|
-
* paths in write order
|
|
512
|
+
* Returns `{ nAgents, nSkills, written, userOnlySkills }` — Python's 3-tuple, with `written` as
|
|
513
|
+
* absolute paths in write order, plus `userOnlySkills` (the claimed stems carrying the
|
|
514
|
+
* user-only marker; Task 4/O-1's input to OpenCode's `permission.skill` wiring — see the
|
|
515
|
+
* comment above the skill-frontmatter branch).
|
|
510
516
|
*
|
|
511
517
|
* That order is NOT observable, and this comment used to claim it was ("it lands in
|
|
512
518
|
* `owned`, which the prune diffs against"). Measured when a mutation reversing it stayed
|
|
@@ -519,6 +525,15 @@ export function claimer(oldOwned, cfg, manifestExisted = true) {
|
|
|
519
525
|
export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null, {
|
|
520
526
|
host = 'opencode', oldOwned = null, cfg = null, manifestExisted = true,
|
|
521
527
|
theme = null, src = undefined, skillDirOf = null, claim = claimer(oldOwned, cfg, manifestExisted),
|
|
528
|
+
// Bob (host-compat R1): its skills page documents only `name`/`description` — no
|
|
529
|
+
// `disable-model-invocation` — and giving it the Claude-dialect alias SKILL.md (the
|
|
530
|
+
// `host === 'claude'` branch below) would be an unverified key PLUS a visible duplicate
|
|
531
|
+
// in the model's catalogue (exactly the OpenCode problem this file already dodges for
|
|
532
|
+
// aliases). `bobDialect` suppresses both for Bob only, while `host: 'claude'` keeps every
|
|
533
|
+
// OTHER Claude-dialect choice (agent frontmatter, `warnClaudeIgnored`) — Bob still renders
|
|
534
|
+
// those verbatim. The caller (`emit-claude.mjs`) routes the suppressed names through
|
|
535
|
+
// `writeAliasCommands(..., alsoUserOnly: true)` into `.bob/commands/*.md` instead (O2).
|
|
536
|
+
bobDialect = false,
|
|
522
537
|
} = {}) {
|
|
523
538
|
const ov = overrides || {};
|
|
524
539
|
if (host === 'claude') warnClaudeIgnored(ov);
|
|
@@ -527,6 +542,7 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
|
|
|
527
542
|
const written = [];
|
|
528
543
|
const sideFiles = []; // [stem, dest, text], written after the loop
|
|
529
544
|
const claimedSkills = new Set(); // stems whose own SKILL.md claim held
|
|
545
|
+
const userOnlySkills = []; // claimed stems carrying the user-only marker
|
|
530
546
|
|
|
531
547
|
const write = (dest, text) => {
|
|
532
548
|
mkdirSync(path.dirname(dest), { recursive: true });
|
|
@@ -590,6 +606,7 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
|
|
|
590
606
|
let fm;
|
|
591
607
|
let dest;
|
|
592
608
|
let kind;
|
|
609
|
+
let userOnly = false;
|
|
593
610
|
if (folder === 'agents') {
|
|
594
611
|
fm = host === 'claude' ? claudeAgentFrontmatter(stem, text, ov)
|
|
595
612
|
: opencodeAgentFrontmatter(stem, text, ov, theme);
|
|
@@ -597,26 +614,45 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
|
|
|
597
614
|
kind = 'agent';
|
|
598
615
|
} else {
|
|
599
616
|
// Skills are BYTE-IDENTICAL across hosts: name + description, body link-stripped.
|
|
600
|
-
// The user-only key is emitted for
|
|
601
|
-
// OpenCode
|
|
602
|
-
//
|
|
617
|
+
// The user-only key is emitted for Claude Code, which honours it natively
|
|
618
|
+
// (`disable-model-invocation`). OpenCode has no frontmatter equivalent, so the
|
|
619
|
+
// SAME marker also drives `userOnlySkills` below, which the OpenCode emit turns into a
|
|
620
|
+
// `permission.skill: {name: "deny"}` row in `opencode.json` (opencode-verdict.md O-1) —
|
|
621
|
+
// the model loses the skill from its catalogue, `/name` still runs it (O-1's second
|
|
622
|
+
// finding: the command path reads the UNFILTERED skill list, never the permission one).
|
|
623
|
+
// Bob's skills page documents no such key (host-compat R1) — see `bobDialect` above,
|
|
624
|
+
// which skips the SKILL.md for a user-only skill on Bob entirely.
|
|
625
|
+
// ALIASES are the one case every non-Claude dialect shares: no alias skill via
|
|
626
|
+
// frontmatter exists for them either, so an alias skill there would land in the
|
|
603
627
|
// model's catalogue as a duplicate; it gets a command instead (`writeAliasCommands`).
|
|
604
628
|
fm = [`name: ${stem}`, `description: ${jsonDumps(skillDescription(text))}`];
|
|
605
|
-
|
|
629
|
+
userOnly = isUserInvokedOnly(text);
|
|
630
|
+
if (userOnly && !bobDialect) fm.push('disable-model-invocation: true');
|
|
606
631
|
body = stripSkillBodyLinks(pointSideFiles(body, stem, skillDirOf ? skillDirOf(stem)
|
|
607
632
|
: SKILL_DIR_PLACEHOLDER));
|
|
608
633
|
dest = path.join(skillsDir, stem, 'SKILL.md');
|
|
609
634
|
kind = 'skill';
|
|
610
635
|
}
|
|
636
|
+
// Bob, user-only: no SKILL.md at all — the command written by the caller is the only
|
|
637
|
+
// copy, so the model's `use_skill` catalogue never carries it (see `bobDialect` above).
|
|
638
|
+
// The `continue` is BEFORE `claimedSkills.add(stem)` below, so a user-only skill's own
|
|
639
|
+
// side files (none exist today, but the layout allows them) are skipped too — there is
|
|
640
|
+
// no `skills/<stem>/` folder on Bob for one to sit beside.
|
|
641
|
+
if (kind === 'skill' && userOnly && bobDialect) continue;
|
|
611
642
|
if (!claim(dest)) continue;
|
|
612
643
|
write(dest, `---\n${fm.join('\n')}\n---\n\n${body}`);
|
|
613
|
-
if (kind === 'skill')
|
|
644
|
+
if (kind === 'skill') {
|
|
645
|
+
claimedSkills.add(stem);
|
|
646
|
+
// Only a CLAIMED user-only skill is Geneseed's to deny: a pre-existing file the claim
|
|
647
|
+
// declined belongs to the user, and `userOnly` was computed from OUR text, not theirs.
|
|
648
|
+
if (userOnly) userOnlySkills.push(stem);
|
|
649
|
+
}
|
|
614
650
|
// An old name keeps answering `/name`: user-only, so the model's catalogue lists the
|
|
615
651
|
// skill once. Read off the already-filtered items, so excluding the target drops its
|
|
616
652
|
// aliases; through `claim`, so manifest, prune and uninstall own them; not counted.
|
|
617
653
|
// Only once the target's own claim held: a user's file at the target gets no alias
|
|
618
654
|
// pointing at a skill this install never wrote.
|
|
619
|
-
if (kind === 'skill' && host === 'claude') {
|
|
655
|
+
if (kind === 'skill' && host === 'claude' && !bobDialect) {
|
|
620
656
|
// The host announces the ALIAS's folder as the base directory, so a side-file pointer
|
|
621
657
|
// climbs back to the target's.
|
|
622
658
|
const aliasBody = skillDirOf ? body : stripSkillBodyLinks(
|
|
@@ -634,7 +670,7 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
|
|
|
634
670
|
for (const [stem, dest, text] of sideFiles) {
|
|
635
671
|
if (claimedSkills.has(stem) && claim(dest)) write(dest, stripSkillBodyLinks(text));
|
|
636
672
|
}
|
|
637
|
-
return { nAgents, nSkills, written };
|
|
673
|
+
return { nAgents, nSkills, written, userOnlySkills };
|
|
638
674
|
}
|
|
639
675
|
|
|
640
676
|
/**
|