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.
Files changed (109) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +9 -1
  3. package/SHIPPED.md +1 -1
  4. package/adapters/bob/README.md +31 -12
  5. package/adapters/claude-code/README.md +24 -17
  6. package/adapters/claude-code/settings.json +4 -6
  7. package/adapters/openclaude/README.md +13 -5
  8. package/adapters/opencode/plugins/geneseed-guard.js +291 -174
  9. package/bin/geneseed-hook.mjs +36 -5
  10. package/docs/README.md +3 -1
  11. package/docs/candidate-hosts.md +0 -8
  12. package/docs/concepts/enforcement-table.md +66 -0
  13. package/docs/concepts/hook-gates.md +2 -2
  14. package/docs/concepts/hooks.md +6 -6
  15. package/docs/concepts/host-matrix.md +4 -3
  16. package/docs/concepts/loop-branch.md +6 -1
  17. package/docs/concepts/loops.md +4 -3
  18. package/docs/concepts/rules.md +1 -1
  19. package/docs/design-history.md +59 -0
  20. package/docs/extending.md +11 -3
  21. package/docs/guides/install-opencode.md +1 -1
  22. package/docs/guides/loop-running.md +1 -1
  23. package/docs/reference/env-context.md +3 -2
  24. package/docs/reference/environment.md +2 -1
  25. package/docs/reference/plugin-guard.md +3 -3
  26. package/docs/reference/troubleshoot-hooks.md +34 -0
  27. package/docs/understand/design-choices-engineering.md +29 -0
  28. package/docs/understand/design-choices-gates.md +23 -0
  29. package/docs/understand/design-choices-hosts.md +21 -0
  30. package/docs/understand/design-choices-install.md +19 -0
  31. package/docs/understand/design-choices-shape.md +21 -0
  32. package/docs/understand/design-choices.md +34 -0
  33. package/docs/understand/enforced-vs-asked.md +5 -4
  34. package/docs/understand/machine-bob.md +1 -1
  35. package/docs/understand/machine-opencode.md +2 -2
  36. package/harness.config.json +1 -1
  37. package/js/build/driver.mjs +42 -16
  38. package/js/build/emit-claude.mjs +24 -8
  39. package/js/build/emit-opencode.mjs +33 -15
  40. package/js/build/generate.mjs +38 -9
  41. package/js/build/source.mjs +39 -0
  42. package/js/build/stubs.mjs +2 -1
  43. package/js/cli-table.json +6 -6
  44. package/js/hosts/gitref.mjs +3 -3
  45. package/js/hosts/hooks-context.mjs +13 -31
  46. package/js/hosts/hooks-learn.mjs +89 -8
  47. package/js/hosts/hooks.mjs +212 -18
  48. package/js/hosts/hosts.mjs +217 -13
  49. package/js/hosts/installs.mjs +259 -46
  50. package/js/hosts/native.mjs +48 -12
  51. package/js/hosts/opencode.mjs +47 -17
  52. package/js/hosts/settings.mjs +366 -42
  53. package/js/hosts/shim.mjs +37 -0
  54. package/js/inspect/checks-authoring.mjs +81 -1
  55. package/js/inspect/checks-constitution.mjs +2 -1
  56. package/js/inspect/checks-repo.mjs +68 -4
  57. package/js/inspect/doctor.mjs +40 -9
  58. package/js/inspect/scan.mjs +5 -4
  59. package/js/inspect/status.mjs +62 -12
  60. package/js/lib/text.mjs +23 -0
  61. package/js/loop/catalog.mjs +7 -2
  62. package/js/loop/state.mjs +2 -2
  63. package/js/maintain/migrate.mjs +42 -5
  64. package/js/maintain/uninstall.mjs +150 -38
  65. package/js/maintain/update.mjs +22 -2
  66. package/js/web/actions.mjs +28 -10
  67. package/js/web/api.mjs +5 -3
  68. package/js/web/catalog.mjs +1 -1
  69. package/js/web/state.mjs +28 -15
  70. package/package.json +1 -1
  71. package/src/agents/_template.md +1 -1
  72. package/src/bricks/_template.md +1 -1
  73. package/src/bricks/apply.md +1 -1
  74. package/src/bricks/test.md +1 -1
  75. package/src/doctrines/ops.md +9 -7
  76. package/src/doctrines/process.md +3 -3
  77. package/src/laws/universal.md +1 -1
  78. package/src/loops/api-endpoint.json +1 -1
  79. package/src/loops/bugfix.json +1 -1
  80. package/src/loops/ci-repair.json +1 -1
  81. package/src/loops/deps-upgrade.json +1 -1
  82. package/src/loops/enforce-architecture-rule.json +1 -1
  83. package/src/loops/feature.json +1 -1
  84. package/src/loops/fix-flaky-tests.json +1 -1
  85. package/src/loops/legacy-refactor.json +1 -1
  86. package/src/loops/legacy-tests.json +1 -1
  87. package/src/loops/refactor.json +1 -1
  88. package/src/loops/remove-dead-code.json +1 -1
  89. package/src/loops/spec-first-feature.json +1 -1
  90. package/src/loops/tdd.json +1 -1
  91. package/src/loops/update-contract.json +1 -1
  92. package/src/skills/_template.md +3 -3
  93. package/src/skills/herdr.md +1 -1
  94. package/web/dist/assets/{Activity-IbwdHHyx.js → Activity-BCtqxjfy.js} +1 -1
  95. package/web/dist/assets/{ActivityDetail-B5uivdWI.js → ActivityDetail-Ba3Oo4dE.js} +1 -1
  96. package/web/dist/assets/{Dashboard-v6WQ37bD.js → Dashboard-DpHza68-.js} +2 -2
  97. package/web/dist/assets/{Docs-CypIfDBX.js → Docs-Bw_-UK0O.js} +1 -1
  98. package/web/dist/assets/{GreenhouseView-ClxP0hQG.js → GreenhouseView-DnJ1Ggp1.js} +1 -1
  99. package/web/dist/assets/{Installs-CPkQeFjO.js → Installs-CWoNKFBk.js} +1 -1
  100. package/web/dist/assets/{JournalView-BeEInhUf.js → JournalView-g6-bw4c8.js} +1 -1
  101. package/web/dist/assets/{Laws-Ds6U5xBQ.js → Laws-DkVWR1tb.js} +1 -1
  102. package/web/dist/assets/{Library-BraUKPyQ.js → Library-CK9HZzjV.js} +1 -1
  103. package/web/dist/assets/{Loops-DWcy3mwK.js → Loops-BP98SMOH.js} +1 -1
  104. package/web/dist/assets/{OperatorHudView-CQyrJQ9m.js → OperatorHudView-DI_n12M9.js} +1 -1
  105. package/web/dist/assets/{Personal-YWqs6qFL.js → Personal-Bk89f13a.js} +1 -1
  106. package/web/dist/assets/{Tabs-EhzEC-Xh.js → Tabs-3WuYIE1b.js} +1 -1
  107. package/web/dist/assets/{drawable-CEKPNV7X.js → drawable-oCUeq9e5.js} +1 -1
  108. package/web/dist/assets/{index-CPi22b7y.js → index-CiXwChvf.js} +2 -2
  109. package/web/dist/index.html +1 -1
@@ -50,18 +50,20 @@ import path from 'node:path';
50
50
  import { hookRunnerEntry } from '../hosts/shim.mjs';
51
51
  import { confirm } from '../lib/prompt.mjs';
52
52
  import {
53
- claudeCfg, claudeReadManifest, doctrinesOfDir, emitHostScopeOf, excludedRulesOfDir, installKind,
54
- installState,
53
+ claudeCfg, claudeReadManifest, doctrinesOfDir, EMIT_HOST_SCOPE, emitHostScopeOf,
54
+ excludedRulesOfDir, installKind, installState,
55
55
  registeredTargets, DISABLED_STASH,
56
56
  } from '../hosts/installs.mjs';
57
57
  import {
58
- CLAUDE_STYLE, GLOBAL_MANIFEST, HOSTS, VERSION_MARKER, expanduser, opencodeConfigDir, resolvePath,
58
+ CLAUDE_STYLE, GLOBAL_MANIFEST, HOSTS, VERSION_MARKER, expanduser, isHostGlobalDir, opencodeConfigDir,
59
+ resolvePath, settingsFile,
59
60
  } from '../hosts/hosts.mjs';
60
61
  import { mcpCommented, mcpLoad } from '../hosts/mcp.mjs';
61
62
  import {
62
63
  atomicWriteJson, managedBlockRead, managedBlockRemove, managedBlockWrite,
63
- loadJsonObject, mergeClaudeSettings, opencodeTarget,
64
- settingsIntegrityCheck, wireClaudeExcludes, unwireClaudeExcludes, unwireClaudeSettings,
64
+ loadJsonObject, mergeClaudeSettings, opencodeTarget, OPENCODE_SENTINEL, opencodeSentinelWrite,
65
+ settingsIntegrityCheck, wireClaudeExcludes, unwireClaudeExcludes,
66
+ unwireClaudeSettings,
65
67
  } from '../hosts/settings.mjs';
66
68
  import { printOut, printErr, readText, writeText, isFile, isDir, isOsError } from '../lib/fs.mjs';
67
69
  import { indexOfDeepEqual, isDict, jsonDumps, deepEquals } from '../lib/json.mjs';
@@ -212,27 +214,63 @@ function warnMarkersKept() {
212
214
  const REVERSAL_MARKERS = [GLOBAL_MANIFEST, '.geneseed-theme', '.geneseed-emit',
213
215
  '.geneseed-footprint', VERSION_MARKER];
214
216
 
217
+ /**
218
+ * The CURRENT manifest's `skill_denies` — the `permission.skill` denies Geneseed itself added
219
+ * (`opencodeLayer` in emit-opencode.mjs records them; a deny the user wrote, even on a Geneseed
220
+ * skill, is never in it). Task 4 (O-1) wires one per user-only skill, and unlike the Law IV
221
+ * `permission.bash` gates (permanent invariants, never taken back — see
222
+ * `reconcileOpencodePermission`), a skill-visibility deny is reversible: once uninstalled there
223
+ * is no skill left to hide, and the name is the user's to reuse. No record takes back nothing.
224
+ */
225
+ function skillPermissionNames(man) {
226
+ return Array.isArray(man.skill_denies) ? man.skill_denies.filter((n) => typeof n === 'string') : [];
227
+ }
228
+
215
229
  /**
216
230
  * `_harness_mcp._unmerge_opencode_json` — drop one `instructions` entry, leave every other
217
231
  * key intact.
218
232
  *
219
- * A COMMENTED `.jsonc` is not rewritten and the user is told to do it by hand: rewriting it
233
+ * `denyNames`, Task 4 (O-1): the denies this install's manifest records writing
234
+ * (`skill_denies`) — remove `permission.skill[name]` for each one, but ONLY where the value is
235
+ * still exactly `"deny"`, and drop the `skill` object once it is empty. Ownership comes from the
236
+ * record, not the value; the value check on top guards a user who re-scoped a recorded name to
237
+ * their own `"allow"`/`"ask"`. A
238
+ * caller passing `[]` (every non-uninstall unwire — `installDeactivate`, same as
239
+ * `permission.bash`) leaves `permission.skill` untouched, so a paused install keeps the model
240
+ * blind to its skills rather than re-exposing them until the next `geneseed build`.
241
+ *
242
+ * The sweep runs even when the `instructions` entry is already gone — an uninstall after a
243
+ * deactivate, which removed the entry but kept the denies (PR #241 review) — so either half
244
+ * alone is a change, and only "neither" declines.
245
+ *
246
+ * A COMMENTED `.jsonc` is not rewritten and the user is told to do it by hand — the entry AND
247
+ * the recorded denies still set to `"deny"`, each named: rewriting it
220
248
  * would drop the comments. That branch returns False, so the caller's `unmerged` reports
221
249
  * REALITY rather than intent.
222
250
  */
223
- export function unmergeOpencodeJson(p, entry) {
251
+ export function unmergeOpencodeJson(p, entry, denyNames = []) {
224
252
  const target = opencodeTarget(p);
225
253
  // Absent, unreadable (the Python's `except OSError: return False`) or not an object: decline.
226
254
  const { state, data: cfg, hadComments } = loadJsonObject(target);
227
255
  if (state !== 'ok') return false;
228
256
  const instr = cfg.instructions;
229
- if (!Array.isArray(instr) || !instr.includes(entry)) return false;
257
+ const hasEntry = Array.isArray(instr) && instr.includes(entry);
258
+ // The deny sweep does NOT hang off the entry: `installDeactivate` already dropped the entry
259
+ // (and kept the denies), so an uninstall after a deactivate finds no entry but still owns them.
260
+ const skill = isDict(cfg.permission) && isDict(cfg.permission.skill) ? cfg.permission.skill : null;
261
+ const denies = skill ? denyNames.filter((n) => skill[n] === 'deny') : [];
262
+ if (!hasEntry && !denies.length) return false;
230
263
  if (path.extname(target) === '.jsonc' && hadComments) {
231
- printOut(`[uninstall] ${path.basename(target)} has comments — not rewriting it. Remove `
232
- + `this from its "instructions" by hand: ${jsonDumps(entry)}\n`);
264
+ printOut(`[uninstall] ${path.basename(target)} has comments — not rewriting it.`
265
+ + (hasEntry ? ` Remove this from its "instructions" by hand: ${jsonDumps(entry)}` : '')
266
+ + (denies.length ? ` Remove these from its "permission.skill" by hand: `
267
+ + `${denies.map((n) => jsonDumps(n)).join(', ')}` : '')
268
+ + '\n');
233
269
  return false;
234
270
  }
235
- cfg.instructions = instr.filter((i) => i !== entry);
271
+ if (hasEntry) cfg.instructions = instr.filter((i) => i !== entry);
272
+ for (const name of denies) delete skill[name];
273
+ if (skill && denies.length && !Object.keys(skill).length) delete cfg.permission.skill;
236
274
  atomicWriteJson(target, cfg);
237
275
  return true;
238
276
  }
@@ -265,16 +303,9 @@ export function archiveStore(store) {
265
303
  return dest;
266
304
  }
267
305
 
268
- /**
269
- * `_harness_mcp._settings_file` — the file this install's hooks were actually wired into.
270
- *
271
- * `settings.local.json` for a Claude PROJECT install (personal, untracked), `settings.json`
272
- * everywhere else, and the manifest is the authority. Every lifecycle path must target the
273
- * file the EMIT wrote, or the hooks linger in one file while the claims chase another.
274
- */
275
- function settingsFile(cfg, managed) {
276
- return path.join(cfg, (managed && managed.settings_file) || 'settings.json');
277
- }
306
+ // `settingsFile` lives in `hosts.mjs` (the hook stand-down reads it, and must not import this
307
+ // module); re-exported here for `migrate`.
308
+ export { settingsFile };
278
309
 
279
310
  /** `_harness_mcp._claude_md_path` — where the manifest says the managed block lives. */
280
311
  function claudeMdPath(cfg, managed) {
@@ -343,7 +374,8 @@ export function uninstallGlobal(target, archiveMemory, host = 'opencode') {
343
374
  if (isDir(p) && isEmptyDir(p)) rmdirQuiet(p);
344
375
  }
345
376
  const unmerged = unmergeOpencodeJson(path.join(target, 'opencode.json'),
346
- path.join(target, 'AGENT.md').split(path.sep).join('/'));
377
+ path.join(target, 'AGENT.md').split(path.sep).join('/'), skillPermissionNames(man));
378
+ managedBlockRemove(path.join(target, OPENCODE_SENTINEL));
347
379
  if (failed.length) warnMarkersKept();
348
380
  else for (const m of REVERSAL_MARKERS) unlinkQuiet(path.join(target, m));
349
381
  let archived = null;
@@ -403,10 +435,11 @@ function opencodeProjectUninstall(root) {
403
435
  const entry = installAgentEntry(root, 'project');
404
436
  let removed = 0;
405
437
  let failed = [];
438
+ let man = null;
406
439
  const oc = path.join(root, '.opencode');
407
440
  const manifestPath = path.join(oc, GLOBAL_MANIFEST);
408
441
  if (isFile(manifestPath)) {
409
- const man = claudeReadManifest(oc);
442
+ man = claudeReadManifest(oc);
410
443
  [removed, failed] = unlinkOwned(oc, ownedOf(man), '.opencode/');
411
444
  if (failed.length) warnSurvivors(failed);
412
445
  // Survivors gate, mirroring the other two reversals: `_project_qualifies` keys off the
@@ -427,7 +460,8 @@ function opencodeProjectUninstall(root) {
427
460
  }
428
461
  const am = path.join(root, 'AGENT.md');
429
462
  if (isFile(am)) { unlinkQuiet(am); removed += 1; }
430
- const unmerged = unmergeOpencodeJson(path.join(root, 'opencode.json'), entry);
463
+ const unmerged = unmergeOpencodeJson(path.join(root, 'opencode.json'), entry,
464
+ man ? skillPermissionNames(man) : []);
431
465
  const result = { removed, unmerged, archived: null };
432
466
  if (failed.length) result.failed = failed;
433
467
  return result;
@@ -519,10 +553,75 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
519
553
  if (archived.length) out.archived = archived;
520
554
  return out;
521
555
  }
522
- for (const m of ['.geneseed-emit', '.geneseed-theme', VERSION_MARKER]) {
523
- unlinkQuiet(path.join(root, m));
556
+ // The root markers are ONE PER ROOT (`generate.mjs`'s "THE MARKER IS TRUSTED ONLY FOR ITS
557
+ // OWN HOST") and `registryRoots` keeps a row alive only while `.geneseed-emit` exists there
558
+ // — so in a repo sharing two project installs (`.claude/` + `.opencode/` at the same cwd,
559
+ // last deploy wins the marker), deleting it unconditionally because THIS host is being
560
+ // uninstalled would silently deregister the OTHER host's still-live install, regardless of
561
+ // which host's name the marker happened to carry.
562
+ //
563
+ // Delete every marker ONLY when nothing else is installed here. Otherwise:
564
+ // - `.geneseed-emit` carries HOST IDENTITY (`EMIT_HOST_SCOPE`'s key names a (host, scope)
565
+ // pair) and `installProfile`/`migrateSurvey` trust it only for the host it names
566
+ // (`generate.mjs`'s own docblock). If it currently names the host just removed, REWRITE
567
+ // it to the survivor's own emit name — the same string `writeMarkers` (`driver.mjs`)
568
+ // would have written for that survivor — rather than deleting it and leaving the
569
+ // registry row to self-prune a live install. If it already names a survivor, it is
570
+ // already correct; leave it untouched.
571
+ // - `.geneseed-theme` and `VERSION_MARKER` carry NO host identity — a bare theme name and
572
+ // a version/fingerprint string — so there is no survivor value to rewrite them TO; the
573
+ // only question is whether they are THIS host's own litter to clear. At PROJECT scope
574
+ // `.geneseed-theme` here is OpenCode's alone: `driver.mjs`'s own comment says the
575
+ // claude/bob/openclaude project emits never write it, and `themeOfDir`'s host-narrowing
576
+ // (host-compat B1 round 3, `js/hosts/installs.mjs`) means no Claude-style sibling ever
577
+ // reads it either — so delete it only when OpenCode is the host being removed, and
578
+ // leave it for a Claude-style uninstall (it belongs to a surviving or absent OpenCode
579
+ // install either way, never to the host that just left). `VERSION_MARKER` is written at
580
+ // `cfgDir`, never bare `root`, by every PROJECT emit (`js/build/version.mjs`'s callers),
581
+ // so a project root never actually carries this file regardless of host — deleting it
582
+ // here is always a no-op there. At GLOBAL scope neither file is ever shared (each host
583
+ // owns its own config dir), so both are always this host's own and always safe to drop.
584
+ // PROJECT scope uses `projectQualifies` (the same predicate `uninstallResolve` already
585
+ // trusts for exactly this question), never `installState`: `installState`'s OpenCode
586
+ // branch answers 'active' off a bare `.opencode/` DIRECTORY's existence alone, with no
587
+ // manifest or marker check, so an unrelated non-Geneseed `.opencode/` sitting next to a
588
+ // real Claude install would read as a "surviving" OpenCode install and keep the marker
589
+ // alive for a host that was never actually here.
590
+ const survivor = HOSTS.find(({ host: h }) => h !== host && (
591
+ scope === 'project'
592
+ ? projectQualifies(root, h)
593
+ // GLOBAL: each host owns a distinct config dir, so no two hosts' global installs ever
594
+ // share a root — this can only match THIS host's own global install, and by the time
595
+ // execution reaches here (step 3) step 1 has already unlinked THIS host's own
596
+ // manifest/markers, so the lookback never mistakes its own just-removed install for a
597
+ // surviving sibling.
598
+ : installState(root, h, scope) !== 'absent'
599
+ ));
600
+ if (!survivor) {
601
+ for (const m of ['.geneseed-emit', '.geneseed-theme', VERSION_MARKER]) {
602
+ unlinkQuiet(path.join(root, m));
603
+ }
604
+ } else {
605
+ const markerScope = emitHostScopeOf(root);
606
+ if (markerScope !== null && markerScope[0] === host) {
607
+ const survivorEmit = [...EMIT_HOST_SCOPE.entries()]
608
+ .find(([, hs]) => hs[0] === survivor.host && hs[1] === scope)?.[0];
609
+ if (survivorEmit) {
610
+ writeText(path.join(root, '.geneseed-emit'), `${survivorEmit}\n`);
611
+ } else {
612
+ // Defensive: every (host, scope) pair in `HOSTS` has an `EMIT_HOST_SCOPE` entry, so
613
+ // this should be unreachable — but silently leaving a marker naming the just-removed
614
+ // host would deregister the survivor exactly as before, so it is loud instead of quiet.
615
+ printErr(`[uninstall] WARN: could not resolve an emit name for the surviving `
616
+ + `${survivor.host}:${scope} install here — the shared .geneseed-emit marker still `
617
+ + `names ${host}, which was just removed. Re-run \`geneseed rebuild-all\` or `
618
+ + `re-emit ${survivor.host} to refresh it.\n`);
619
+ }
620
+ }
621
+ if (scope === 'global' || host === 'opencode') unlinkQuiet(path.join(root, '.geneseed-theme'));
622
+ unlinkQuiet(path.join(root, VERSION_MARKER));
524
623
  }
525
- // 4. Tidy an emptied marker dir (.claude/.bob) so no husk lingers in the repo.
624
+ // 4. Tidy an emptied marker dir (.claude/.bob/.openclaude) so no husk lingers in the repo.
526
625
  if (data !== root && isDir(data) && isEmptyDir(data)) rmdirQuiet(data);
527
626
  const out = { ok: true, removed: summary.removed ?? 0, memory };
528
627
  if (archived.length) out.archived = archived;
@@ -532,8 +631,9 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
532
631
  /**
533
632
  * `_harness_mcp._project_qualifies` — does `root` carry a REAL Geneseed project install?
534
633
  *
535
- * The marker dir exists, is not the host's global config dir seen from its parent (the
536
- * `installTargets` aliasing guard), and shows Geneseed's own tracks: the manifest, or the
634
+ * The marker dir exists, is not the host's global seen from its parent (`isHostGlobalDir`, the
635
+ * `installTargets` aliasing guard — the default `~/.claude` too, under `$CLAUDE_CONFIG_DIR`),
636
+ * and shows Geneseed's own tracks: the manifest, or the
537
637
  * root `.geneseed-emit` naming this host's project emit for a pre-manifest legacy install.
538
638
  * A bare non-Geneseed `.claude/` is very common and must never hijack the resolve.
539
639
  */
@@ -542,7 +642,7 @@ export function projectQualifies(root, host) {
542
642
  const cfg = path.join(root, spec.projectMarker);
543
643
  if (!isDir(cfg)) return false;
544
644
  try {
545
- if (resolvePath(cfg) === resolvePath(spec.configDir())) return false;
645
+ if (isHostGlobalDir(host, cfg)) return false;
546
646
  } catch { /* as the Python's bare `except Exception: pass` */ }
547
647
  if (isFile(path.join(cfg, GLOBAL_MANIFEST))) return true;
548
648
  const hs = emitHostScopeOf(root);
@@ -556,7 +656,8 @@ export function projectQualifies(root, host) {
556
656
  * is NAMED like a project marker and is the global install, never `claude:project` rooted at
557
657
  * `$HOME`, so the global-config-dir case is checked before the marker-name case.
558
658
  *
559
- * 1. `--target` IS a host's global config dir.
659
+ * 1. `--target` IS a host's global (`isHostGlobalDir`: its env-resolved dir, its default
660
+ * `~/<marker>`, or a dir whose emit marker names that host's `-global` emit).
560
661
  * 2. `--target` IS a project marker dir itself (…/.claude) — root is its parent.
561
662
  * 3. `--target` (as a root) carries a Geneseed project install.
562
663
  * 4. `--target` given and unrecognised — null, and the caller reports it.
@@ -566,7 +667,7 @@ export function uninstallResolve(targetArg) {
566
667
  const globalHit = (p) => {
567
668
  for (const spec of HOSTS) {
568
669
  try {
569
- if (p === resolvePath(spec.configDir())) return [spec.host, 'global', p];
670
+ if (isHostGlobalDir(spec.host, p)) return [spec.host, 'global', p];
570
671
  } catch { continue; }
571
672
  }
572
673
  return null;
@@ -673,12 +774,17 @@ export function cmdUninstall(args) {
673
774
  const stores = ['memory', 'notebook'].filter((n) => isDir(path.join(data, n)));
674
775
  printOut(`[uninstall] target: ${root} (${host}:${scope})\n`);
675
776
  if (CLAUDE_STYLE.includes(host)) {
777
+ // Not always settings.json: a Claude/OpenClaude PROJECT install wires settings.local.json
778
+ // (host-compat B4) — read back from the manifest `settingsFile` already resolves, rather
779
+ // than restate the rule here a second time.
780
+ const cfgForSf = claudeCfg(root, scope, host);
781
+ const sfName = path.basename(settingsFile(cfgForSf, managedOf(claudeReadManifest(cfgForSf))));
676
782
  printOut('[uninstall] removes: agents/, skills/, markers, the '
677
783
  + `${hostSpec(host).agentFile} managed block, and Geneseed's `
678
- + 'settings.json hooks/excludes (your own keys/hooks are kept).\n');
784
+ + `${sfName} hooks/excludes (your own keys/hooks are kept).\n`);
679
785
  } else if (scope === 'global') {
680
- printOut('[uninstall] removes: AGENT.md, agents/, skills/, plugins/, markers, and the '
681
- + 'opencode.json instructions entry.\n');
786
+ printOut('[uninstall] removes: AGENT.md, the AGENTS.md managed block, agents/, skills/, '
787
+ + 'plugins/, markers, and the opencode.json instructions entry.\n');
682
788
  } else {
683
789
  printOut('[uninstall] removes: AGENT.md, .opencode/, laws/, agents/, skills/, and the '
684
790
  + 'opencode.json instructions entry.\n');
@@ -949,6 +1055,8 @@ export function installDeactivate(root, host = 'opencode', scope = 'global') {
949
1055
  return { ok: false, failed, rolled_back: done.length };
950
1056
  }
951
1057
  unmergeOpencodeJson(path.join(root, 'opencode.json'), installAgentEntry(root, kind));
1058
+ // The sentinel goes too, or a disabled install keeps the user's ~/.claude/CLAUDE.md from loading.
1059
+ if (kind === 'global') managedBlockRemove(path.join(root, OPENCODE_SENTINEL));
952
1060
  for (const rel of done) pruneAncestors(path.dirname(path.join(root, rel)), root);
953
1061
  return { ok: true, kind, moved: done.length };
954
1062
  }
@@ -975,6 +1083,7 @@ export function installReactivate(root, host = 'opencode', scope = 'global') {
975
1083
  if (leftovers.length) return { ok: false, failed: leftovers, moved };
976
1084
  const kind = installKind(root) || 'global';
977
1085
  installReaddEntry(target, installAgentEntry(root, kind));
1086
+ if (kind === 'global') opencodeSentinelWrite(root);
978
1087
  rmtreeQuiet(stash);
979
1088
  return { ok: true, kind, moved };
980
1089
  }
@@ -1026,11 +1135,14 @@ function remergeClaudeHooks(cfg, root = cfg, host = 'claude') {
1026
1135
  // unconditionally (fail-closed, so not a hole — but wrong, and it made the toggle one-way
1027
1136
  // for project installs). `root` first, `cfg` second: on a global install the two are the
1028
1137
  // same directory anyway. Both silent ⇒ `null` ⇒ the gate stays.
1029
- const doctrines = doctrinesOfDir(root) ?? doctrinesOfDir(cfg);
1138
+ // Both reads are narrowed to `host`'s OWN carrier (host-compat B1): in a repo shared with
1139
+ // another Claude-style host, an un-narrowed scan can answer with a SIBLING host's carrier —
1140
+ // the same failure `installProfile` had, reached here because `root` is the shared repo.
1141
+ const doctrines = doctrinesOfDir(root, host) ?? doctrinesOfDir(cfg, host);
1030
1142
  // Same two-carrier read for the second axis, and its own default: an absent line means
1031
1143
  // NOTHING excluded, so a reactivate can only ever restore a gate, never remove one.
1032
- const excluded = excludedRulesOfDir(root).length ? excludedRulesOfDir(root)
1033
- : excludedRulesOfDir(cfg);
1144
+ const excluded = excludedRulesOfDir(root, host).length ? excludedRulesOfDir(root, host)
1145
+ : excludedRulesOfDir(cfg, host);
1034
1146
  // `host` reaches the group builder: a Bob reactivate must re-wire Gemini-named groups.
1035
1147
  const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed),
1036
1148
  managed.settings_hooks ?? null, hookRunnerEntry(), doctrines, excluded, host, cfg);
@@ -46,6 +46,7 @@ import path from 'node:path';
46
46
  import { ROOT } from '../build/source.mjs';
47
47
  import { exportImprovements, flushExportNotes } from '../inspect/diff.mjs';
48
48
  import { opencodeConfigDir } from '../hosts/hosts.mjs';
49
+ import { legacyProcessCarrierOfDir } from '../hosts/installs.mjs';
49
50
  import { appendText, copyFile, printOut, printErr, readText, writeText } from '../lib/fs.mjs';
50
51
  import { comparePaths, toPlatformPath, which } from '../lib/paths.mjs';
51
52
  import { WHITESPACE, parseIntStrict, stripWhitespace } from '../lib/text.mjs';
@@ -617,9 +618,18 @@ async function drainStdout() {
617
618
  * Only the TAIL of the argv is kept: theme and emit are `upgrade`'s to decide (a theme argument
618
619
  * may override the marker), and `setupBuildArgs` opens with exactly those four tokens when it
619
620
  * is given no out and no root.
621
+ *
622
+ * `null` — distinct from `[]`'s "never built, nothing to carry over" — means `dir` carries a
623
+ * pre-comms carrier (same guard as `rebuild-all`/`migrate`/the web console, reusing
624
+ * `legacyProcessCarrierOfDir`): `installProfile` would read it exactly as written, and feeding
625
+ * that into a rebuild would write the loss back PERMANENTLY. `[]` here would be WORSE, not
626
+ * neutral — it resets every axis to the generator's config default, the exact failure this
627
+ * function exists to prevent, on top of the comms/process-address loss. `upgrade` must skip the
628
+ * bundle rebuild entirely on `null`, never fall through to an empty axes list.
620
629
  */
621
630
  export async function installAxes(dir, { installProfile, setupBuildArgs }, notify = null) {
622
631
  if (!existsSync(path.join(String(dir), '.geneseed-emit'))) return [];
632
+ if (legacyProcessCarrierOfDir(String(dir), 'opencode')) return null;
623
633
  const p = installProfile('opencode', 'global', String(dir), notify);
624
634
  return setupBuildArgs('', 'files', null, null, p.footprint, p.posture, p.mode, p.doctrines,
625
635
  undefined, p.excludeRules, p.trust, p.excludeSkills).slice(4);
@@ -756,8 +766,18 @@ export async function upgrade(ref = null, themeArg = null) {
756
766
  } catch (e) {
757
767
  log(`[geneseed] ⚠️ could not migrate the old in-folder bundle (${e && e.message ? e.message : e}) — continuing.`);
758
768
  }
759
- let rc = await rebuildBundle(here, out, theme, emit, rootDir, log, axes,
760
- bounceDaemonIfRunning);
769
+ let rc = 0;
770
+ if (axes === null) {
771
+ // `installAxes` found a pre-comms carrier at this bundle's own target — skip the rebuild
772
+ // entirely rather than fall through with no axes (which would reset it to the generator's
773
+ // config default, worse than the loss this guard exists to stop). `rebuildInstalls` below
774
+ // reaches the same directory through `rebuild-all`'s own, identically-reasoned skip.
775
+ log(`[geneseed] SKIPPED bundle rebuild: pre-comms carrier at `
776
+ + `${emit === 'opencode-global' ? cfg : out} (names 'process 8') — run `
777
+ + '`geneseed setup` for it.');
778
+ } else {
779
+ rc = await rebuildBundle(here, out, theme, emit, rootDir, log, axes, bounceDaemonIfRunning);
780
+ }
761
781
  if (rc !== 0) {
762
782
  log(`[geneseed][E-BUILD] ✗ the bundle build FAILED (theme: ${theme || 'default'}, emit: ${emit}).`);
763
783
  return 1;
@@ -47,8 +47,7 @@ import {
47
47
  } from '../hosts/hosts.mjs';
48
48
  import {
49
49
  EMIT_HOST_SCOPE, doctrinesForBuild, excludedRulesOfDir, excludedSkillsOfDir, footprintOfDir,
50
- installState,
51
- installTargets, modeOfDir, postureOfDir, trustOfDir,
50
+ installState, installTargets, legacyProcessCarrierOfDir, modeOfDir, postureOfDir, trustOfDir,
52
51
  } from '../hosts/installs.mjs';
53
52
  import {
54
53
  MCP_PRESETS, isDict, mcpApply, mcpCommented, mcpInstallTargets, mcpKnownNames, mcpLoad,
@@ -605,26 +604,38 @@ export function apiInstallCmd(state, body) {
605
604
  if (installState(root, host, scope) === 'disabled') {
606
605
  return { error: 'install is disabled — reactivate it before (re)building' };
607
606
  }
607
+ // A LEGACY CARRIER IS REFUSED, NEVER REBUILT — same guard as `rebuild-all`/`migrate` (see
608
+ // `legacyProcessCarrierOfDir`'s docblock): `doctrinesForBuild`/`excludedRulesOfDir` below
609
+ // read a pre-comms carrier exactly as written, and this endpoint's `{cmd}` is EXECUTED by
610
+ // `startJob` (`js/web/handler.mjs`'s `install` action) — building that argv here would write
611
+ // the same permanent loss a console click away.
612
+ if (legacyProcessCarrierOfDir(root, host)) {
613
+ return { error: `${host}:${scope} (${root}) predates the comms pack (its carrier names `
614
+ + "'process 8') — run `geneseed setup` for it before rebuilding from the console" };
615
+ }
608
616
  const emit = EMIT_FOR.get(`${host} ${scope}`);
609
617
  if (emit === undefined) return { error: `no install mode for ${host}:${scope}` };
618
+ // Every read-back below is narrowed to `host`'s OWN carrier (host-compat B1) — see the
619
+ // comment at `installProfile` in `js/build/generate.mjs`.
610
620
  const { theme, footprint: fp, posture: pos, mode, trust } = bodyAxes(body, {
611
- theme: state.theme, footprint: footprintOfDir(root), posture: postureOfDir(root) || 'peer',
612
- mode: modeOfDir(root) || 'direct', trust: trustOfDir(root, host) || DEFAULT_PRESET });
621
+ theme: state.theme, footprint: footprintOfDir(root),
622
+ posture: postureOfDir(root, host) || 'peer',
623
+ mode: modeOfDir(root, host) || 'direct', trust: trustOfDir(root, host) || DEFAULT_PRESET });
613
624
  // Unspecified means "keep what this install already has", exactly as theme, footprint,
614
625
  // posture and mode above do — a rebuild through the console is not a place to silently
615
626
  // re-decide the constitution. ⚠ AND A CARRIER WITH NO `Active packs:` MARKER (a pre-2.3
616
627
  // install) MUST NOT LAND ON `harness.config.json`: `doctrinesOfDir` answers `null` there,
617
628
  // `null` elided the flag, and the generator's config fallback then narrowed the install and
618
629
  // took its consent gate with it. `doctrinesForBuild` resolves unknown to ALL packs.
619
- const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root);
630
+ const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root, host);
620
631
  // The per-rule axis needs NO `doctrinesForBuild`-style rescue: the marker it reads is only
621
632
  // written when something is excluded, so its absence is a statement ("nothing") rather than
622
633
  // the silence a pre-2.3 carrier gives on packs. Unspecified therefore keeps the install's
623
634
  // own answer, and a pre-marker install answers the empty list — which is what it has.
624
- const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root);
635
+ const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root, host);
625
636
  const out = scope === 'global' ? null : String(root);
626
637
  const argv = setupBuildArgs(theme || 'neutral', emit, out, out, fp, pos, mode, doctrines,
627
- PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root));
638
+ PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, null, host));
628
639
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
629
640
  }
630
641
 
@@ -704,6 +715,13 @@ export function apiDeployCmd(state, body) {
704
715
  if (cfgdirs.has(root)) {
705
716
  return { error: "that's a host global config dir — use its existing row to build a global install" };
706
717
  }
718
+ // Same guard as `apiInstallCmd` above: a landing folder that already holds a pre-comms
719
+ // install must not be rebuilt through `doctrinesForBuild`/`excludedRulesOfDir` below. A
720
+ // fresh, never-built folder has no carrier at all, so this is a no-op for the common case.
721
+ if (legacyProcessCarrierOfDir(root, host)) {
722
+ return { error: `${root} predates the comms pack (its carrier names 'process 8') — `
723
+ + 'run `geneseed setup` for it before deploying over it' };
724
+ }
707
725
  // A fresh deploy is full, at the default register, mode and trust.
708
726
  const { theme, footprint: fp, posture: pos, mode, trust } = bodyAxes(body, {
709
727
  theme: state.theme, footprint: 'full', posture: 'peer', mode: 'direct',
@@ -714,10 +732,10 @@ export function apiDeployCmd(state, body) {
714
732
  // the flag off, the generator fell back to `harness.config.json`, and deploying onto an
715
733
  // existing all-four Claude install dropped it to one pack — measured: 6 hook groups became 5
716
734
  // and `PreToolUse::Bash` went with them. `doctrinesForBuild` resolves unknown to ALL packs.
717
- const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root);
718
- const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root);
735
+ const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root, host);
736
+ const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root, host);
719
737
  // project-scope emit name == host name (opencode / claude / bob / openclaude)
720
738
  const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode, doctrines,
721
- PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root));
739
+ PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, null, host));
722
740
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
723
741
  }
package/js/web/api.mjs CHANGED
@@ -119,10 +119,12 @@ export function apiInstalls(state) {
119
119
  out.push({
120
120
  id: `${host}:${scope}`, host, scope, path: root,
121
121
  state: installState(root, host, scope),
122
- theme: themeOfDir(root),
122
+ // Narrowed to this row's own host (host-compat B1): a shared repo otherwise answers
123
+ // every row with whichever host's carrier the host-agnostic scan favours.
124
+ theme: themeOfDir(root, host),
123
125
  footprint: footprintOfDir(root),
124
- posture: postureOfDir(root),
125
- mode: modeOfDir(root),
126
+ posture: postureOfDir(root, host),
127
+ mode: modeOfDir(root, host),
126
128
  trust: trustOfDir(root, host),
127
129
  selected: samePath(viewCfg(host, scope, root), state.target),
128
130
  });
@@ -185,7 +185,7 @@ export function notebookItems(state) {
185
185
 
186
186
  /**
187
187
  * The wiki manifest's names, current first. `wiki.jsonc` is the name before the `geneseed-`
188
- * prefix: the build renames it on the next emit, and until then it is still read — one release.
188
+ * prefix: the build renames it on the next emit, and until then it is still read (no end date; see `LEGACY_WIKI_FILES`).
189
189
  */
190
190
  const WIKI_NAMES = ['geneseed-wiki.jsonc', 'wiki.jsonc'];
191
191
  const WIKI_META = ['Wiki manifest', 'your machine-wide knowledge base(s)'];
package/js/web/state.mjs CHANGED
@@ -10,7 +10,8 @@ import path from 'node:path';
10
10
 
11
11
  import { opencodeConfigDir } from '../hosts/hosts.mjs';
12
12
  import {
13
- footprintOfDir, installedDefaults, modeOfDir, postureOfDir, themeOfDir, trustOfDir,
13
+ EMIT_HOST_SCOPE, footprintOfDir, installedDefaults, modeOfDir, postureOfDir, themeOfDir,
14
+ trustOfDir,
14
15
  } from '../hosts/installs.mjs';
15
16
  import { doctorCollect } from '../inspect/doctor.mjs';
16
17
  import { DEFAULT_PRESET } from '../loop/score.mjs';
@@ -56,18 +57,25 @@ export function webState(theme = null, target = null) {
56
57
  _inv: null,
57
58
  _doctor: null,
58
59
  };
59
- // The INSTALL ROOT (== build --out). For globals it IS the data dir; a claude/bob
60
- // PROJECT install keeps its data under <repo>/.claude while the markers land at <repo>/.
60
+ // The INSTALL ROOT (== build --out). For globals it IS the data dir; a claude/bob/openclaude
61
+ // PROJECT install keeps its data under <repo>/.claude|.bob|.openclaude while the markers
62
+ // land at <repo>/.
61
63
  st.root = st.target;
62
64
  st.theme = theme || themeOfDir(st.target) || 'neutral';
63
65
  st.emit = installedDefaults().emit || 'opencode-global';
66
+ // `st.emit` to `host`, the same lookup `apiInstallCmd`/`diffCollect` already make off the
67
+ // same map — unknown or missing falls back to OpenCode, as those two do.
68
+ const hostOf = () => (EMIT_HOST_SCOPE.get(st.emit || '') ?? ['opencode', 'global'])[0];
64
69
  // The install's sigils, each defaulted when its marker is absent (footprint to 'full').
65
- // Read from the ROOT, where every emit writes them.
66
- const readMarkers = () => {
70
+ // Read from the ROOT, where every emit writes them. `host`, when given, narrows the carrier
71
+ // scan to its own (host-compat B1) — omitted here at construction, where `st.emit` is only
72
+ // `installedDefaults()`'s best guess and may not be THIS target's own host; `selectView`
73
+ // and `refresh` below pass it once `detectEmit()` has looked at this install directly.
74
+ const readMarkers = (host = null) => {
67
75
  st.footprint = footprintOfDir(st.root);
68
- st.posture = postureOfDir(st.root) || 'peer';
69
- st.mode = modeOfDir(st.root) || 'direct';
70
- st.trust = trustOfDir(st.root) || DEFAULT_PRESET;
76
+ st.posture = postureOfDir(st.root, host) || 'peer';
77
+ st.mode = modeOfDir(st.root, host) || 'direct';
78
+ st.trust = trustOfDir(st.root, host) || DEFAULT_PRESET;
71
79
  };
72
80
  readMarkers();
73
81
 
@@ -112,27 +120,32 @@ export function webState(theme = null, target = null) {
112
120
  /**
113
121
  * Re-point the console at another detected install's data dir.
114
122
  *
115
- * `root` is the install ROOT the markers and sigils live at. It defaults to `target` and
116
- * differs only for claude/bob/openclaude PROJECT installs, where the data sits under
123
+ * `root` is the install ROOT the markers live at. It defaults to `target` and differs only
124
+ * for claude/bob/openclaude PROJECT installs, where the data sits under
117
125
  * `<repo>/.claude|.bob|.openclaude` while `.geneseed-emit`/`-theme`/`-footprint` land at
118
126
  * `<repo>/` — reading them from the data dir mis-detects the install as opencode/neutral,
119
- * and a Diff or a Restore would then overwrite it in the wrong dialect.
127
+ * and a Diff or a Restore would then overwrite it in the wrong dialect. The theme/posture/mode
128
+ * SIGILS are different: for claude/bob they live in the ROOT carrier (`themeOfDir` scans it);
129
+ * for openclaude they live ONLY in the data dir's nested `.openclaude/CLAUDE.md`, since its
130
+ * root carrier is deliberately left untouched (host-compat B1/I1).
120
131
  */
121
132
  st.selectView = (target, root = null) => {
122
133
  st.target = target;
123
134
  st.root = root || target;
124
- st.theme = themeOfDir(st.root) || themeOfDir(st.target) || 'neutral';
125
135
  st.emit = st.detectEmit();
126
- readMarkers();
136
+ const host = hostOf();
137
+ st.theme = themeOfDir(st.root, host) || themeOfDir(st.target, host) || 'neutral';
138
+ readMarkers(host);
127
139
  st._inv = null;
128
140
  st._doctor = null;
129
141
  };
130
142
  st.refresh = () => {
131
143
  st._inv = null;
132
144
  st._doctor = null;
133
- st.theme = themeOfDir(st.root) || themeOfDir(st.target) || st.theme;
134
145
  st.emit = st.detectEmit() || st.emit;
135
- readMarkers();
146
+ const host = hostOf();
147
+ st.theme = themeOfDir(st.root, host) || themeOfDir(st.target, host) || st.theme;
148
+ readMarkers(host);
136
149
  };
137
150
  return st;
138
151
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "Every field here is argued and gated in tests/unit/package_manifest.test.mjs. `files`, `bin` and `engines` each have a test that fails when reality drifts from this file; `version` mirrors harness.config.json and is gated too. Read that docblock before editing this one.",
3
3
  "name": "geneseed",
4
- "version": "3.19.0",
4
+ "version": "4.0.0",
5
5
  "description": "A portable, theme-able harness you implant once and use everywhere to grow a disciplined AI coding agent.",
6
6
  "keywords": [
7
7
  "ai",
@@ -1,4 +1,4 @@
1
- <!--
1
+ <!-- note:
2
2
  Authoring a new agent:
3
3
  1. Reuse first — if an existing {{AGENT}}'s capability already covers the need,
4
4
  extend it instead of adding a file ({{DOCTRINE:automate-repetition}} applies to specialists too).
@@ -1,4 +1,4 @@
1
- <!--
1
+ <!-- note:
2
2
  A brick template — copy this file to `<name>.md` (kebab-case, matching the `name:` field
3
3
  below) under `bricks/` at whichever level you are authoring for: shipped (this package's
4
4
  `src/bricks/`), global ($XDG_CONFIG_HOME/geneseed/bricks/), or project (<repo>/.geneseed/bricks/).
@@ -10,7 +10,7 @@ carries an `amend`, that supersedes the original intent — follow the amendment
10
10
  replaced.
11
11
 
12
12
  When this is a repair after `test`, act on the latest `test` note only — it is the verdict on
13
- the code as it stands. Fix every finding it lists in this one pass: there is no second repair.
13
+ the code as it stands. Fix every finding it lists in this one pass: repairs are capped at two.
14
14
 
15
15
  Make no unrelated edits: no drive-by rename, no incidental cleanup, no touching a file the card
16
16
  did not declare. If the change genuinely needs a file outside the write set, stop and report that
@@ -11,7 +11,7 @@ brick only observes; a fix belongs in `apply`, not here.
11
11
  1. **Gate** — the build or compile step. If it fails, mark every check below `SKIPPED (gate
12
12
  failed)` and report.
13
13
  2. **Focused checks** — the linter and the tests the change can reach. Run every one even after a
14
- failure: they check independent properties, and the one repair must see every finding at once.
14
+ failure: they check independent properties, and each repair must see every finding at once.
15
15
  3. **Full suite** — only when everything above passed; otherwise `SKIPPED (an earlier check failed)`.
16
16
 
17
17
  List every check with `PASS`, `FAIL`, `ERROR` or `SKIPPED (reason)` — one that did not run is