geneseed 3.12.0 → 3.14.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 +160 -0
- package/README.md +2 -2
- package/SHIPPED.md +1 -1
- package/adapters/bob/README.md +1 -1
- package/adapters/claude-code/README.md +1 -1
- package/adapters/openclaude/README.md +1 -1
- package/adapters/opencode/README.md +3 -3
- package/adapters/opencode/plugins/geneseed-learn.js +10 -1
- package/bin/geneseed-cli.mjs +10 -14
- package/bin/geneseed-hook.mjs +22 -15
- package/docs/README.md +41 -22
- package/docs/concepts/agents.md +3 -1
- package/docs/concepts/collaboration.md +3 -1
- package/docs/concepts/exclusions.md +4 -2
- package/docs/concepts/footprint.md +3 -1
- package/docs/concepts/foreman-mode.md +3 -1
- package/docs/concepts/hook-gates.md +61 -0
- package/docs/concepts/hooks.md +8 -56
- package/docs/concepts/host-matrix.md +38 -0
- package/docs/concepts/hosts.md +6 -32
- package/docs/concepts/loop-branch.md +66 -0
- package/docs/concepts/loop-bricks.md +48 -0
- package/docs/concepts/loop-contracts.md +33 -0
- package/docs/concepts/loop-gates.md +30 -0
- package/docs/concepts/loop-notes.md +43 -0
- package/docs/concepts/loop-risk.md +36 -0
- package/docs/concepts/loop-templates.md +50 -0
- package/docs/concepts/loops.md +24 -116
- package/docs/concepts/lsp.md +2 -0
- package/docs/concepts/memory.md +3 -1
- package/docs/concepts/notebook.md +3 -1
- package/docs/concepts/rules.md +4 -2
- package/docs/concepts/skills.md +3 -1
- package/docs/concepts/themes.md +3 -1
- package/docs/extending.md +28 -13
- package/docs/guides/autostart.md +3 -1
- package/docs/guides/choose-your-setup.md +5 -80
- package/docs/guides/headless.md +5 -3
- package/docs/guides/install-agent-md.md +37 -0
- package/docs/guides/install-bob.md +20 -0
- package/docs/guides/install-claude-code.md +22 -0
- package/docs/guides/install-clone.md +34 -0
- package/docs/guides/install-openclaude.md +25 -0
- package/docs/guides/install-opencode.md +35 -0
- package/docs/guides/install-scripted.md +21 -0
- package/docs/guides/install.md +9 -118
- package/docs/guides/loop-finish.md +54 -0
- package/docs/guides/loop-running.md +66 -0
- package/docs/guides/loop-which-template.md +35 -0
- package/docs/guides/mcp-filesystem.md +41 -0
- package/docs/guides/mcp-gitlab.md +66 -0
- package/docs/guides/mcp-markitdown.md +36 -0
- package/docs/guides/mcp.md +8 -137
- package/docs/guides/migrate.md +3 -1
- package/docs/guides/project-context.md +4 -2
- package/docs/guides/read-non-markdown.md +17 -0
- package/docs/guides/run-a-loop.md +64 -0
- package/docs/guides/run-anywhere.md +2 -0
- package/docs/guides/setup-doctrines.md +55 -0
- package/docs/guides/setup-footprint.md +48 -0
- package/docs/guides/uninstall.md +3 -1
- package/docs/guides/upgrade.md +3 -1
- package/docs/guides/verify.md +3 -1
- package/docs/guides/wiki.md +3 -1
- package/docs/guides/worktree.md +3 -1
- package/docs/reference/about.md +3 -1
- package/docs/reference/cli.md +2 -0
- package/docs/reference/env-context.md +40 -0
- package/docs/reference/environment.md +10 -59
- package/docs/reference/glossary.md +3 -1
- package/docs/reference/loop-actions.md +20 -0
- package/docs/reference/opencode-plugins.md +14 -118
- package/docs/reference/plugin-activity.md +20 -0
- package/docs/reference/plugin-context.md +27 -0
- package/docs/reference/plugin-guard.md +27 -0
- package/docs/reference/plugin-learn.md +27 -0
- package/docs/reference/plugin-notify.md +20 -0
- package/docs/reference/plugin-ponytail.md +19 -0
- package/docs/reference/plugin-workflow.md +25 -0
- package/docs/reference/troubleshoot-doctor.md +50 -0
- package/docs/reference/troubleshoot-hooks.md +99 -0
- package/docs/reference/troubleshoot-install.md +108 -0
- package/docs/reference/troubleshoot-tools.md +56 -0
- package/docs/reference/troubleshoot-update.md +23 -0
- package/docs/reference/troubleshoot-web.md +66 -0
- package/docs/reference/troubleshooting.md +13 -337
- package/docs/understand/a-day.md +3 -1
- package/docs/understand/enforced-vs-asked.md +3 -1
- package/docs/understand/harness.md +2 -0
- package/docs/understand/machine-bob.md +17 -0
- package/docs/understand/machine-claude.md +35 -0
- package/docs/understand/machine-opencode.md +36 -0
- package/docs/understand/on-your-machine.md +12 -55
- package/docs/understand/take-it-out.md +13 -0
- package/docs/web-ui.md +35 -15
- package/harness.config.json +1 -1
- package/js/README.md +30 -13
- package/js/build/args.mjs +374 -0
- package/js/build/driver.mjs +137 -553
- package/js/build/emit-claude.mjs +2 -4
- package/js/build/generate.mjs +6 -3
- package/js/build/render.mjs +5 -9
- package/js/build/source.mjs +3 -11
- package/js/cli-table.json +27 -1
- package/js/hosts/hooks-context.mjs +431 -0
- package/js/hosts/hooks-learn.mjs +275 -0
- package/js/hosts/hooks-prims.mjs +108 -0
- package/js/hosts/hooks.mjs +50 -977
- package/js/hosts/hosts.mjs +12 -4
- package/js/hosts/installs.mjs +5 -6
- package/js/hosts/mcp.mjs +13 -49
- package/js/hosts/memory-files.mjs +181 -0
- package/js/hosts/native.mjs +43 -5
- package/js/hosts/settings.mjs +135 -414
- package/js/hosts/shim.mjs +373 -0
- package/js/inspect/checks-authoring.mjs +21 -828
- package/js/inspect/checks-build.mjs +32 -4
- package/js/inspect/checks-constitution.mjs +493 -0
- package/js/inspect/checks-counts.mjs +242 -0
- package/js/inspect/checks-repo.mjs +17 -8
- package/js/inspect/diff.mjs +4 -1
- package/js/inspect/doctor.mjs +2 -2
- package/js/inspect/inventory.mjs +4 -4
- package/js/inspect/scan.mjs +1 -1
- package/js/inspect/status.mjs +1 -1
- package/js/inspect/validate.mjs +48 -35
- package/js/loop/catalog.mjs +17 -4
- package/js/loop/cli.mjs +52 -16
- package/js/loop/graph.mjs +19 -1
- package/js/loop/registry.mjs +191 -0
- package/js/loop/score.mjs +46 -7
- package/js/loop/state.mjs +157 -19
- package/js/maintain/memory.mjs +1 -1
- package/js/maintain/migrate.mjs +18 -30
- package/js/maintain/setup.mjs +1 -1
- package/js/maintain/uninstall.mjs +47 -88
- package/js/ui/cli.mjs +12 -12
- package/js/web/actions.mjs +39 -4
- package/js/web/api.mjs +34 -21
- package/js/web/docs.mjs +87 -78
- package/js/web/routes.mjs +5 -1
- package/package.json +1 -1
- package/src/agents/explorer.md +2 -2
- package/src/agents/researcher.md +3 -3
- package/src/bricks/README.md +3 -1
- package/src/bricks/adr-challenge.md +20 -0
- package/src/bricks/adr-draft.md +15 -0
- package/src/bricks/characterize.md +17 -0
- package/src/bricks/ci-fix.md +2 -1
- package/src/bricks/ci-triage.md +20 -0
- package/src/bricks/compat-check.md +16 -0
- package/src/bricks/done-check.md +16 -0
- package/src/bricks/fitness-define.md +22 -0
- package/src/bricks/flake-check.md +17 -0
- package/src/bricks/flake-repro.md +18 -0
- package/src/bricks/identify.md +6 -3
- package/src/bricks/migration-plan.md +21 -0
- package/src/bricks/mutation-check.md +18 -0
- package/src/bricks/plan.md +6 -6
- package/src/bricks/red-test.md +16 -0
- package/src/bricks/review.md +5 -3
- package/src/bricks/spec.md +18 -0
- package/src/bricks/upgrade-scout.md +16 -0
- package/src/loops/api-endpoint.json +31 -0
- package/src/loops/architecture-decision.json +22 -0
- package/src/loops/bugfix.json +1 -0
- package/src/loops/ci-repair.json +19 -0
- package/src/loops/deps-upgrade.json +29 -0
- package/src/loops/enforce-architecture-rule.json +28 -0
- package/src/loops/feature.json +8 -5
- package/src/loops/fix-flaky-tests.json +30 -0
- package/src/loops/legacy-refactor.json +26 -0
- package/src/loops/legacy-tests.json +29 -0
- package/src/loops/refactor.json +5 -1
- package/src/loops/remove-dead-code.json +30 -0
- package/src/loops/spec-first-feature.json +29 -0
- package/src/loops/tdd.json +25 -0
- package/src/loops/update-contract.json +33 -0
- package/src/skills/brick-forge.md +3 -1
- package/src/skills/bruno-collection-generator.md +3 -3
- package/src/skills/bruno-test-writer.md +3 -3
- package/src/skills/ci-fix.md +3 -3
- package/src/skills/consolidate-memory.md +4 -4
- package/src/skills/council.md +1 -1
- package/src/skills/fresh-eyes.md +1 -1
- package/src/skills/git-archaeology.md +1 -1
- package/src/skills/git-rescue.md +4 -3
- package/src/skills/handoff.md +1 -1
- package/src/skills/herdr.md +1 -1
- package/src/skills/learn-mode.md +2 -2
- package/src/skills/loop.md +22 -10
- package/src/skills/opencode-theme.md +3 -3
- package/src/skills/ponytail.md +1 -1
- package/src/skills/profile.md +3 -4
- package/src/skills/prose.md +2 -3
- package/src/skills/quiz.md +1 -1
- package/src/skills/react-view-transitions/SKILL.md +1 -1
- package/src/skills/react-view-transitions/VENDOR.md +8 -3
- package/src/skills/research.md +3 -3
- package/src/skills/review-response.md +2 -2
- package/src/skills/rule.md +3 -3
- package/src/skills/security-audit.md +4 -4
- package/src/skills/teach.md +1 -1
- package/src/skills/wiki.md +3 -3
- package/src/skills/workflow.md +4 -4
- package/src/skills/worktree.md +2 -2
- package/web/dist/assets/Activity-CjRqZnr-.js +1 -0
- package/web/dist/assets/ActivityDetail-OQTxKBj2.js +1 -0
- package/web/dist/assets/Dashboard-J-xCe_Gk.js +2 -0
- package/web/dist/assets/Docs-CvOZ8akL.js +5 -0
- package/web/dist/assets/{GreenhouseView-C6ibc8IE.js → GreenhouseView-6KDjX8da.js} +1 -1
- package/web/dist/assets/{Installs-DRYjYnxs.js → Installs-MWuhNjSm.js} +2 -2
- package/web/dist/assets/JournalView-C8MrOz2c.js +1 -0
- package/web/dist/assets/Laws-Du0-Zyp2.js +3 -0
- package/web/dist/assets/Library-DOkO0TtB.js +1 -0
- package/web/dist/assets/Loops-Dv_aLByJ.js +2 -0
- package/web/dist/assets/{Markdown-qjM2_ofS.js → Markdown-X2a3qvOh.js} +14 -14
- package/web/dist/assets/{OperatorHudView-Bs7paWmh.js → OperatorHudView-BPP1dtLz.js} +1 -1
- package/web/dist/assets/{Personal-BY5n4gTw.js → Personal-26VPYkK0.js} +1 -1
- package/web/dist/assets/Tabs-BzKuFskD.js +1 -0
- package/web/dist/assets/{usePoll-O0sJEw1Y.js → activity-Dr7ZMBI3.js} +1 -1
- package/web/dist/assets/{api-CyMRbU2x.js → api-D_ixdyEY.js} +1 -1
- package/web/dist/assets/drawable-zVVDbsyw.js +1 -0
- package/web/dist/assets/index-Ctp-4poP.css +1 -0
- package/web/dist/assets/index-KHt3wE_K.js +2 -0
- package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
- package/web/dist/index.html +3 -3
- package/web/src/pages/Laws.jsx +0 -1
- package/web/dist/assets/Activity-GaaZWXTY.js +0 -1
- package/web/dist/assets/ActivityDetail-4t7d45Du.js +0 -1
- package/web/dist/assets/Dashboard-CYn7Tp16.js +0 -2
- package/web/dist/assets/Docs-BeFKLg2T.js +0 -5
- package/web/dist/assets/JournalView-1HbU1mfg.js +0 -1
- package/web/dist/assets/Laws-DFoG8JWB.js +0 -3
- package/web/dist/assets/Library-DDjgNwz_.js +0 -1
- package/web/dist/assets/Loops-D9VEoO0F.js +0 -1
- package/web/dist/assets/Tabs-ho-EYD8i.js +0 -1
- package/web/dist/assets/drawable-CLS9drMb.js +0 -1
- package/web/dist/assets/index-CX_hXMi5.css +0 -1
- package/web/dist/assets/index-Cjr7XCA3.js +0 -2
package/js/hosts/settings.mjs
CHANGED
|
@@ -2,16 +2,14 @@
|
|
|
2
2
|
* `_build_settings.py` in Node — the host-config WIRING layer.
|
|
3
3
|
*
|
|
4
4
|
* This is the half of the emit that edits files the USER co-owns: the JSONC reader, the
|
|
5
|
-
* `opencode.json` / `settings.json` merges, the
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* migration, `js/inspect/checks-repo.mjs` reads `shimDeadPaths` for the doctor, and
|
|
14
|
-
* `js/web/api.mjs` borrows the same readers for the console.
|
|
5
|
+
* `opencode.json` / `settings.json` merges, the managed-block machinery and the integrity
|
|
6
|
+
* check — and it is the unit the RUNTIME drives as well as the emit. Every emit and every
|
|
7
|
+
* lifecycle verb goes through this module: `js/build/emit-opencode.mjs` and
|
|
8
|
+
* `js/build/emit-claude.mjs` wire the host config, `js/maintain/uninstall.mjs` drives the
|
|
9
|
+
* unwire, `js/hosts/mcp.mjs` and `js/maintain/migrate.mjs` read through `loadJsonObject`, and
|
|
10
|
+
* `js/web/api.mjs` borrows the same readers for the console. The hook SHIM the merged hooks
|
|
11
|
+
* call lives in `./shim.mjs`, split out so the CLI's every-run dead-shim check need not load
|
|
12
|
+
* this module.
|
|
15
13
|
*
|
|
16
14
|
* THE STDOUT RULE BINDS HARDEST HERE. The hooks this module writes signal their verdict as
|
|
17
15
|
* a JSON object on stdout and return 0 on EVERY path (`|| exit 0` is not what it looks
|
|
@@ -36,18 +34,14 @@
|
|
|
36
34
|
* Fixing it means parsing into a Map and threading that through every accessor, which is a
|
|
37
35
|
* much larger change than the defect.
|
|
38
36
|
*/
|
|
39
|
-
import {
|
|
40
|
-
chmodSync, existsSync, mkdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync,
|
|
41
|
-
writeFileSync,
|
|
42
|
-
} from 'node:fs';
|
|
37
|
+
import { existsSync, mkdirSync, renameSync, rmSync, unlinkSync } from 'node:fs';
|
|
43
38
|
import path from 'node:path';
|
|
44
|
-
import os from 'node:os';
|
|
45
39
|
import { readText, writeText, isOsError } from '../lib/fs.mjs';
|
|
46
40
|
import {
|
|
47
41
|
jsonDumps, jsonDumpsCompact, jsonDumpsIndent, parseJson, deepEquals, formatRepr,
|
|
48
42
|
indexOfDeepEqual, get, has, isDict,
|
|
49
43
|
} from '../lib/json.mjs';
|
|
50
|
-
import {
|
|
44
|
+
import { SHIM_MARK, hookPrefix } from './shim.mjs';
|
|
51
45
|
import { stripWhitespace, stripWhitespaceEnd } from '../lib/text.mjs';
|
|
52
46
|
|
|
53
47
|
const OPENCODE_SCHEMA = 'https://opencode.ai/config.json';
|
|
@@ -94,9 +88,14 @@ const consentRuleOn = (d, excluded = []) =>
|
|
|
94
88
|
* `clean -f`, `branch -D`, `checkout --`) stay in EVERY build: they are Law IV's territory —
|
|
95
89
|
* an always-on invariant — not the process pack's. They mirror `DESTRUCTIVE_GIT_RE` in
|
|
96
90
|
* js/hosts/hooks.mjs, so Claude's git-gate and OpenCode's permission ask about the same acts;
|
|
97
|
-
* before, OpenCode only logged the last four.
|
|
91
|
+
* before, OpenCode only logged the last four. A glob is not a regex, so the mirror is the
|
|
92
|
+
* closest glob for each act rather than an exact copy: the `* ` forms catch a flag that comes
|
|
93
|
+
* after other words (`push origin -f`, `reset -q --hard`, `clean -d -f`) and `git push *+*`
|
|
94
|
+
* a `+refspec` force push. The regex stays the stricter reader — it also sees a flag cluster
|
|
95
|
+
* with `f` not first (`clean -d -xf`), which a glob cannot say without matching `--exclude=f`.
|
|
98
96
|
*/
|
|
99
|
-
const LAW_IV_BASH = ['git push --force*', 'git push -f*', 'git
|
|
97
|
+
const LAW_IV_BASH = ['git push --force*', 'git push -f*', 'git push *--force*', 'git push * -f*',
|
|
98
|
+
'git push *+*', 'git reset --hard*', 'git reset * --hard*', 'git clean -f*', 'git clean * -f*',
|
|
100
99
|
'git branch -D*', 'git checkout -- *'];
|
|
101
100
|
|
|
102
101
|
function defaultPermission(doctrines = null, excluded = []) {
|
|
@@ -267,6 +266,46 @@ export function readJsonc(text) {
|
|
|
267
266
|
}
|
|
268
267
|
}
|
|
269
268
|
|
|
269
|
+
/**
|
|
270
|
+
* A config file read as a JSON OBJECT, classified rather than decided:
|
|
271
|
+
* `{ state, data, hadComments }`, `state` one of
|
|
272
|
+
* - `'absent'` — no file at `p`
|
|
273
|
+
* - `'unreadable'` — it exists and the read threw (`error` carries why)
|
|
274
|
+
* - `'invalid'` — it does not parse (`data` null)
|
|
275
|
+
* - `'notObject'` — it parses, to something that is not an object (`[]`, `3`)
|
|
276
|
+
* - `'ok'` — `data` is the object.
|
|
277
|
+
*
|
|
278
|
+
* ONE READER, MANY POLICIES. Ten call sites had each restated read → `readJsonc` → `isDict`, and
|
|
279
|
+
* their refusal rules had drifted: some warned, some returned `false`, some swallowed a
|
|
280
|
+
* non-OS throw. The reading is the same everywhere and lives here; what a state MEANS stays at
|
|
281
|
+
* the caller, because it really does differ — a merge must refuse to rewrite a file it could
|
|
282
|
+
* not read, an unwire just declines, an integrity check reports. `hadComments` is reported in
|
|
283
|
+
* every state that got as far as the text, since `mcpCommented` asks it of files that do not
|
|
284
|
+
* parse.
|
|
285
|
+
*
|
|
286
|
+
* `strict` reads plain JSON (`parseJson`) — comments and trailing commas are then `'invalid'`.
|
|
287
|
+
* The Claude-style MCP configs are strict by contract (see `mcpLoad`).
|
|
288
|
+
*/
|
|
289
|
+
export function loadJsonObject(p, { strict = false } = {}) {
|
|
290
|
+
if (!existsSync(p)) return { state: 'absent', data: null, hadComments: false };
|
|
291
|
+
let text;
|
|
292
|
+
try {
|
|
293
|
+
text = readText(p);
|
|
294
|
+
} catch (e) {
|
|
295
|
+
if (!isOsError(e)) throw e;
|
|
296
|
+
return { state: 'unreadable', data: null, hadComments: false, error: e };
|
|
297
|
+
}
|
|
298
|
+
let data;
|
|
299
|
+
let hadComments = false;
|
|
300
|
+
if (strict) {
|
|
301
|
+
try { data = parseJson(text); } catch { data = null; }
|
|
302
|
+
} else {
|
|
303
|
+
[data, hadComments] = readJsonc(text);
|
|
304
|
+
}
|
|
305
|
+
if (data === null) return { state: 'invalid', data, hadComments };
|
|
306
|
+
return { state: isDict(data) ? 'ok' : 'notObject', data, hadComments };
|
|
307
|
+
}
|
|
308
|
+
|
|
270
309
|
/**
|
|
271
310
|
* `_build_settings._warn_commented_jsonc`. Prints to STDOUT — the Python's own choice.
|
|
272
311
|
*
|
|
@@ -297,8 +336,17 @@ function warnCommentedJsonc(target, agentPath, permission,
|
|
|
297
336
|
}
|
|
298
337
|
}
|
|
299
338
|
|
|
300
|
-
/**
|
|
339
|
+
/**
|
|
340
|
+
* `_build_settings._atomic_write_json` — the ONE atomic JSON writer: pretty JSON through a
|
|
341
|
+
* sibling temp file and a rename, creating the parent directory first. Throws on failure, as
|
|
342
|
+
* the Python does, and never leaves the temp file behind.
|
|
343
|
+
*
|
|
344
|
+
* Every config this codebase rewrites goes through it — settings, `opencode.json`, MCP
|
|
345
|
+
* configs, the install manifest — because a plain write that dies half-way through leaves a
|
|
346
|
+
* truncated file where the user's config was.
|
|
347
|
+
*/
|
|
301
348
|
export function atomicWriteJson(p, config) {
|
|
349
|
+
mkdirSync(path.dirname(p), { recursive: true });
|
|
302
350
|
const tmp = `${p}.geneseed-tmp`;
|
|
303
351
|
writeText(tmp, `${jsonDumpsIndent(config)}\n`);
|
|
304
352
|
try {
|
|
@@ -319,28 +367,20 @@ export function atomicWriteJson(p, config) {
|
|
|
319
367
|
export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = []) {
|
|
320
368
|
const target = opencodeTarget(p);
|
|
321
369
|
let config = { $schema: OPENCODE_SCHEMA, instructions: [] };
|
|
322
|
-
|
|
323
|
-
if (
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const [loaded, hc] = readJsonc(raw);
|
|
335
|
-
hadComments = hc;
|
|
336
|
-
if (loaded === null || !isDict(loaded)) {
|
|
337
|
-
process.stderr.write(`[geneseed] ${path.basename(target)} is not a JSON object — NOT `
|
|
338
|
-
+ `rewriting it (fix the file, then re-run). Add ${jsonDumps(agentPath)} to its `
|
|
339
|
-
+ '"instructions" once repaired.\n');
|
|
340
|
-
return target;
|
|
341
|
-
}
|
|
342
|
-
config = loaded;
|
|
370
|
+
const { state, data, hadComments, error } = loadJsonObject(target);
|
|
371
|
+
if (state === 'unreadable') {
|
|
372
|
+
process.stderr.write(`[geneseed] WARN: could not read ${target} (${error.message}) `
|
|
373
|
+
+ `— NOT touching it. Add ${jsonDumps(agentPath)} to its "instructions" array by `
|
|
374
|
+
+ "hand once it's readable again.\n");
|
|
375
|
+
return target;
|
|
376
|
+
}
|
|
377
|
+
if (state === 'invalid' || state === 'notObject') {
|
|
378
|
+
process.stderr.write(`[geneseed] ${path.basename(target)} is not a JSON object — NOT `
|
|
379
|
+
+ `rewriting it (fix the file, then re-run). Add ${jsonDumps(agentPath)} to its `
|
|
380
|
+
+ '"instructions" once repaired.\n');
|
|
381
|
+
return target;
|
|
343
382
|
}
|
|
383
|
+
if (state === 'ok') config = data;
|
|
344
384
|
if (!has(config, '$schema')) config.$schema = OPENCODE_SCHEMA;
|
|
345
385
|
let instr = get(config, 'instructions');
|
|
346
386
|
if (!Array.isArray(instr)) instr = [];
|
|
@@ -418,240 +458,6 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [])
|
|
|
418
458
|
// gate does not drive a cell through a real fs failure — it exercises the branch with an
|
|
419
459
|
// injectable failure instead and compares everything except the OS's own wording.
|
|
420
460
|
|
|
421
|
-
// ---- the hook shim ----------------------------------------------------------------
|
|
422
|
-
// `|| exit 0` on the emitted commands does NOT mean "ignore failures": git-gate and
|
|
423
|
-
// rule-gate return 0 on EVERY path and signal their verdict as a JSON object on stdout.
|
|
424
|
-
// The `|| exit 0` is there because a hook that fails to LAUNCH (a moved checkout, a dead
|
|
425
|
-
// interpreter) must not block the tool call. A stray byte on stdout — a cmd.exe command
|
|
426
|
-
// echo, a "created ~/.geneseed" notice — corrupts that JSON and the gate stops gating
|
|
427
|
-
// while still reporting success. Hence `@echo off`, and a shim that prints nothing.
|
|
428
|
-
//
|
|
429
|
-
// The marker lives in the FILENAME, not the directory: GENESEED_HOME relocates the dir,
|
|
430
|
-
// and a relocated install's hooks must stay recognisable to `GENESEED_HOOK_SNIFF`.
|
|
431
|
-
export const SHIM_MARK = 'geneseed-hook';
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* `_build_settings._SHIM_ARGV` — the tokens in a shim body that are NOT paths.
|
|
435
|
-
*
|
|
436
|
-
* `shimProblems` reads every quoted token back out of the body and requires each to name an
|
|
437
|
-
* existing file. On Windows that is exactly the two baked paths (`%*` is bare); on POSIX the
|
|
438
|
-
* body forwards argv as `"$@"`, quoted, so both implementations' `doctor` reported a healthy
|
|
439
|
-
* shim as dead on every Linux and macOS host. A defect the port faithfully inherited, and
|
|
440
|
-
* one no Windows cell could see.
|
|
441
|
-
*/
|
|
442
|
-
export const SHIM_ARGV = new Set(['$@', '%*']);
|
|
443
|
-
|
|
444
|
-
/**
|
|
445
|
-
* Every quoted token in a shim body that names a path which is not there — empty is healthy.
|
|
446
|
-
*
|
|
447
|
-
* ONE OWNER, because the rule has two consumers now and a second copy is a second place for the
|
|
448
|
-
* `"$@"` defect above to come back: `js/inspect/checks-repo.mjs` turns this into the `[shim] … does not
|
|
449
|
-
* exist` report, and `hookPrefix` reads it to decide whether the shim already on disk is worth
|
|
450
|
-
* protecting from a checkout that will not outlive the emit.
|
|
451
|
-
*/
|
|
452
|
-
export function shimDeadPaths(body) {
|
|
453
|
-
return [...body.matchAll(/"([^"]+)"/g)].map((m) => m[1])
|
|
454
|
-
.filter((q) => !SHIM_ARGV.has(q) && !existsSync(q));
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/** `_SHIM_REL`, with the platform as an argument.
|
|
458
|
-
*
|
|
459
|
-
* Python computes it at IMPORT time from `sys.platform`, which is what makes the other
|
|
460
|
-
* platform's branch unreachable on any one machine. Taking it as a parameter is what lets
|
|
461
|
-
* the parity gate compare both branches in one run (Python's side is reachable because
|
|
462
|
-
* `_hook_shim_body` reads `sys.platform` at CALL time). */
|
|
463
|
-
export function shimRel(platform = process.platform) {
|
|
464
|
-
return ['bin', SHIM_MARK + (platform === 'win32' ? '.cmd' : '')];
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
/**
|
|
468
|
-
* `_build_settings._shim_home`.
|
|
469
|
-
*
|
|
470
|
-
* `expanduser` is `js/hosts/hosts.mjs`'s — GENESEED_HOME is user-set, same as any config-dir
|
|
471
|
-
* env var there, so a `~user` value here has the identical failure mode (a hook shim written
|
|
472
|
-
* under a literal `~user` directory) and gets the identical refusal. This module used to carry
|
|
473
|
-
* a private "same guard" twin; the two had drifted (this one's refusal message dropped the
|
|
474
|
-
* parenthetical explaining WHY), which is exactly the kind of divergence one owner prevents.
|
|
475
|
-
*/
|
|
476
|
-
export function shimHome() {
|
|
477
|
-
const env = process.env.GENESEED_HOME;
|
|
478
|
-
return env ? expanduser(env) : path.join(os.homedir(), '.geneseed');
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
/** `_build_settings._hook_shim_path`. */
|
|
482
|
-
export function hookShimPath(platform = process.platform) {
|
|
483
|
-
return path.join(shimHome(), ...shimRel(platform));
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/**
|
|
487
|
-
* The paths the machine's hook shim names that are gone — `[]` when the shim is absent or live.
|
|
488
|
-
*
|
|
489
|
-
* Cheap on purpose: `status`, the doctor and EVERY CLI run call it (the run-time warning in
|
|
490
|
-
* `bin/geneseed-cli.mjs`), because a dead shim disables every hook of every Claude-shaped
|
|
491
|
-
* install and nothing else would say so. An absent shim is not dead — a checkout that has never
|
|
492
|
-
* emitted owns none — and an unreadable one is reported by the doctor, not here.
|
|
493
|
-
*/
|
|
494
|
-
export function shimDead() {
|
|
495
|
-
try { return shimDeadPaths(readText(hookShimPath())); } catch { return []; }
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
/**
|
|
499
|
-
* `_build_settings._hook_shim_body`, with the two volatile values INJECTED.
|
|
500
|
-
*
|
|
501
|
-
* This is the one function in the unit whose Python output is legitimately
|
|
502
|
-
* runtime-dependent: it bakes `sys.executable` and `<checkout>/rituals/harness.py`, and a
|
|
503
|
-
* Node twin at GA bakes `process.execPath` and a different entry point. Golden already
|
|
504
|
-
* refuses to compare the emitted shim for that reason (`_SHIM_GLOB`) and asserts
|
|
505
|
-
* `_shim_health` instead.
|
|
506
|
-
*
|
|
507
|
-
* So the answer here is NOT "skip it". Taking `runner`, `entry` and `platform` as arguments
|
|
508
|
-
* makes the BODY a pure function of three inputs, which is what lets
|
|
509
|
-
* `tests/unit/hook_form.test.mjs` assert BOTH platform shapes absolutely in one run on
|
|
510
|
-
* whichever host it happens to be on: the `@echo off`, the CRLF, `exit /b` vs `exec`, the
|
|
511
|
-
* quoting that keeps `--root "<cfg>"` intact. What it does not prove is WHICH values the
|
|
512
|
-
* driver passes — that is `bin/build-driver.mjs`'s `hookRunnerEntry()`, gated separately.
|
|
513
|
-
*/
|
|
514
|
-
export function hookShimBody(runner, entry, platform = process.platform) {
|
|
515
|
-
if (platform === 'win32') {
|
|
516
|
-
// Bare `exit /b` propagates the LIVE errorlevel; `%ERRORLEVEL%` would expand at parse
|
|
517
|
-
// time and return a stale one. Never plain `exit` — that kills the parent cmd.exe, so
|
|
518
|
-
// the emitted `|| exit 0` would never get to evaluate.
|
|
519
|
-
return '@echo off\r\n'
|
|
520
|
-
+ 'rem Generated by Geneseed - do not edit. Rewritten on every emit.\r\n'
|
|
521
|
-
+ 'setlocal\r\n'
|
|
522
|
-
+ `"${runner}" "${entry}" %*\r\n`
|
|
523
|
-
+ 'exit /b\r\n';
|
|
524
|
-
}
|
|
525
|
-
return '#!/bin/sh\n'
|
|
526
|
-
+ '# Generated by Geneseed - do not edit. Rewritten on every emit.\n'
|
|
527
|
-
+ `exec "${runner}" "${entry}" "$@"\n`;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
/**
|
|
531
|
-
* `_build_settings._write_hook_shim` — create or refresh the shim, or null on failure.
|
|
532
|
-
*
|
|
533
|
-
* The path and body are arguments for the same reason `hookShimBody`'s two values are: the
|
|
534
|
-
* routine itself has no runtime dependency at all once they are supplied, so all of it is
|
|
535
|
-
* comparable — the unchanged-content fast path (a Windows shim a hook is executing right
|
|
536
|
-
* now cannot be replaced), the newline-folded comparison that makes that fast path
|
|
537
|
-
* reachable, the pid-suffixed temp name that keeps concurrent emits from unlinking each
|
|
538
|
-
* other's file, and the chmod.
|
|
539
|
-
*/
|
|
540
|
-
export function writeHookShim(p, body, platform = process.platform) {
|
|
541
|
-
try {
|
|
542
|
-
// Newline-normalised: the body carries explicit CRLF on Windows but `read_text()`
|
|
543
|
-
// translates back to `\n`, so a raw `===` would never match and the "unchanged" fast
|
|
544
|
-
// path — the whole point of this branch — would never be taken.
|
|
545
|
-
if (existsSync(p) && statSync(p).isFile()
|
|
546
|
-
&& readText(p).replaceAll('\r\n', '\n') === body.replaceAll('\r\n', '\n')) {
|
|
547
|
-
return p;
|
|
548
|
-
}
|
|
549
|
-
mkdirSync(path.dirname(p), { recursive: true });
|
|
550
|
-
const tmp = `${p}.${process.pid}.tmp`;
|
|
551
|
-
// `newline=''` on the Python side: raw, so the CRLF in the body survives verbatim.
|
|
552
|
-
writeFileSync(tmp, body, 'utf8');
|
|
553
|
-
if (platform !== 'win32') chmodSync(tmp, 0o755);
|
|
554
|
-
renameSync(tmp, p);
|
|
555
|
-
return p;
|
|
556
|
-
} catch (e) {
|
|
557
|
-
if (!isOsError(e)) throw e;
|
|
558
|
-
return null;
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
/** The OS temp root, resolved once. `.native` because a Windows `TEMP` can be an 8.3 alias —
|
|
563
|
-
* the same trap `tests/helpers/sandbox.mjs` documents, reached from the other side. */
|
|
564
|
-
const TMP_REAL = (() => {
|
|
565
|
-
try { return realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
|
|
566
|
-
})();
|
|
567
|
-
|
|
568
|
-
/** Is `child` inside `parent`? `path.relative` compares case-insensitively on win32. */
|
|
569
|
-
function isUnder(child, parent) {
|
|
570
|
-
const rel = path.relative(parent, child);
|
|
571
|
-
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
/**
|
|
575
|
-
* Will the checkout holding `entry` outlive the emit that is baking it into the shim?
|
|
576
|
-
*
|
|
577
|
-
* TWO SHAPES, and both are in `docs/extending.md` §5.3 because both have really happened on this
|
|
578
|
-
* repo: a copied checkout under the OS temp root — the `copyCheckout` fixture, which builds its
|
|
579
|
-
* copy from `git ls-files` and therefore carries no `.git` at all, so only the temp root
|
|
580
|
-
* identifies it — and a git WORKTREE, whose `.git` is a FILE pointing at the main checkout
|
|
581
|
-
* rather than a directory. A plain checkout and an npm install are neither, and keep the
|
|
582
|
-
* ownership they have always had.
|
|
583
|
-
*
|
|
584
|
-
* `tmpRoot` IS A PARAMETER for the reason `shimRel`'s `platform` is: with the real temp root
|
|
585
|
-
* baked in, the second rule is unreachable in a test — anything a test can build under `mkdtemp`
|
|
586
|
-
* has already answered true on the first — so the worktree arm could only ever be exercised by
|
|
587
|
-
* whichever kind of checkout the run happened to sit in.
|
|
588
|
-
*/
|
|
589
|
-
export function ephemeralCheckout(entry, tmpRoot = TMP_REAL) {
|
|
590
|
-
let real = entry;
|
|
591
|
-
try { real = realpathSync.native(entry); } catch { /* not there yet — test the literal */ }
|
|
592
|
-
if (isUnder(real, tmpRoot)) return true;
|
|
593
|
-
// `<checkout>/bin/geneseed-hook.mjs` — the one shape `hookRunnerEntry` produces.
|
|
594
|
-
try { return statSync(path.join(path.dirname(path.dirname(real)), '.git')).isFile(); } catch {
|
|
595
|
-
return false;
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
/** Does the shim already on disk still name paths that all exist? */
|
|
600
|
-
function shimIsLive(p) {
|
|
601
|
-
try { return statSync(p).isFile() && shimDeadPaths(readText(p)).length === 0; } catch {
|
|
602
|
-
return false;
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
/**
|
|
607
|
-
* `_build_settings._hook_prefix` — the `<runner> <entrypoint>` every emitted hook starts
|
|
608
|
-
* with, falling back to the pre-shim direct form when the shim cannot be written.
|
|
609
|
-
*
|
|
610
|
-
* The fallback is strictly no worse than the old behaviour and far better than emitting
|
|
611
|
-
* commands naming a shim that does not exist — those fail on every hook (9009 under
|
|
612
|
-
* cmd.exe, 127 under sh) and take both gates down with them.
|
|
613
|
-
*/
|
|
614
|
-
export function hookPrefix({ runner, entry, platform = process.platform } = {}) {
|
|
615
|
-
// NOT a default. `runner`/`entry` have no computable fallback on this side — the child's
|
|
616
|
-
// own `process.execPath` is node and the hooks it wires are Python — and the failure mode
|
|
617
|
-
// of letting them through undefined is the worst kind this unit has: `hookShimBody` bakes
|
|
618
|
-
// `"undefined" "undefined" %*`, the shim is rewritten with it, every hook in the install
|
|
619
|
-
// dies, and because the hooks signal through stdout and return 0 on every path, nothing
|
|
620
|
-
// reports it. The shim is also excluded from golden's byte comparison by name, so no
|
|
621
|
-
// acceptance gate would catch it either. Throw where it is cheap to see.
|
|
622
|
-
if (!runner || !entry) {
|
|
623
|
-
throw new Error('hookPrefix: runner and entry are required — the emitted hook shim '
|
|
624
|
-
+ 'bakes them, and there is no correct value to guess from inside Node');
|
|
625
|
-
}
|
|
626
|
-
const p = hookShimPath(platform);
|
|
627
|
-
// ⚠ A DISPOSABLE CHECKOUT DOES NOT GET TO CLAIM THE MACHINE-WIDE SHIM. The shim has no
|
|
628
|
-
// per-install component, so the last checkout to emit owns EVERY install's hooks — and when
|
|
629
|
-
// that checkout is a test sandbox or a git worktree, deleting it kills hooks machine-wide with
|
|
630
|
-
// nothing to report it: hooks signal through stdout and return 0 on every path, and this file
|
|
631
|
-
// is excluded from the byte corpora by name. Measured twice in one session, from
|
|
632
|
-
// `tests/unit/harness.test.mjs`'s copied-checkout fixture; `docs/extending.md` §5.3 carries
|
|
633
|
-
// the worktree half.
|
|
634
|
-
//
|
|
635
|
-
// KEEP, NOT REFUSE, and that distinction is what makes this safe to add here rather than at
|
|
636
|
-
// each call site. Returning the existing shim path leaves the emitted hook command
|
|
637
|
-
// byte-identical to what a normal emit writes; taking the fallback below instead would move a
|
|
638
|
-
// hook command in every recorded bundle. The install being emitted then runs the DURABLE
|
|
639
|
-
// checkout's entry — which is exactly what last-writer-wins already handed every other install
|
|
640
|
-
// on the machine, so nothing is lost by it.
|
|
641
|
-
//
|
|
642
|
-
// ONLY A SHIM THAT STILL RESOLVES IS PROTECTED. An absent or already-dead one is no worse for
|
|
643
|
-
// being rewritten from here, and a sandboxed `GENESEED_HOME` (every emit test, every cell) has
|
|
644
|
-
// none — so the suite's own emits are unaffected and this cannot go green by silently
|
|
645
|
-
// skipping the write.
|
|
646
|
-
if (ephemeralCheckout(entry) && shimIsLive(p)) return `"${p}"`;
|
|
647
|
-
const shim = writeHookShim(p, hookShimBody(runner, entry, platform), platform);
|
|
648
|
-
if (shim !== null) return `"${shim}"`;
|
|
649
|
-
process.stderr.write(`[geneseed] WARN: could not write the hook shim at ${p} — emitting `
|
|
650
|
-
+ 'hooks that call the interpreter directly. They will break if this checkout moves; '
|
|
651
|
-
+ 're-run the build to repair them.\n');
|
|
652
|
-
return `"${runner}" "${entry}"`;
|
|
653
|
-
}
|
|
654
|
-
|
|
655
461
|
/**
|
|
656
462
|
* `_build_settings._claude_hook_groups` — Geneseed's Claude hooks, keyed by event.
|
|
657
463
|
*
|
|
@@ -749,6 +555,38 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
|
|
|
749
555
|
};
|
|
750
556
|
}
|
|
751
557
|
|
|
558
|
+
/**
|
|
559
|
+
* An existing settings file read for a read-modify-write: `[config, hadComments]`, or `null`
|
|
560
|
+
* after saying on stderr why the file must NOT be rewritten.
|
|
561
|
+
*
|
|
562
|
+
* Every refusal is the same refusal `mergeOpencodeJson` makes, for the same reason: the merge
|
|
563
|
+
* starts from `{}` when it has nothing to start from, and `atomicWriteJson` would then
|
|
564
|
+
* replace the user's file with Geneseed's keys alone. A syntax error, valid JSON that is not
|
|
565
|
+
* an object (`[]`), and a file that exists but cannot be read are all "nothing to start
|
|
566
|
+
* from", and none of them is the user's consent to be overwritten. An ABSENT file is the one
|
|
567
|
+
* real empty start: `[{}, false]`.
|
|
568
|
+
*/
|
|
569
|
+
function readSettingsForMerge(p, consequence) {
|
|
570
|
+
const { state, data, hadComments, error } = loadJsonObject(p);
|
|
571
|
+
if (state === 'absent') return [{}, false];
|
|
572
|
+
if (state === 'unreadable') {
|
|
573
|
+
process.stderr.write(`[geneseed] WARN: could not read ${p} (${error.message}) — NOT `
|
|
574
|
+
+ `touching it. ${consequence}\n`);
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
if (state === 'invalid') {
|
|
578
|
+
process.stderr.write(`[geneseed] ${path.basename(p)} is not valid JSON — NOT `
|
|
579
|
+
+ `rewriting it (fix the syntax, then re-run). ${consequence}\n`);
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
if (state === 'notObject') {
|
|
583
|
+
process.stderr.write(`[geneseed] ${path.basename(p)} is not a JSON object — NOT `
|
|
584
|
+
+ `rewriting it (fix the file, then re-run). ${consequence}\n`);
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
return [data, hadComments];
|
|
588
|
+
}
|
|
589
|
+
|
|
752
590
|
/**
|
|
753
591
|
* `_build_settings._merge_claude_settings` — returns `[target, managed]`.
|
|
754
592
|
*
|
|
@@ -758,33 +596,18 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
|
|
|
758
596
|
* that a re-emit stacks the new group beside the stale one and `learn` runs twice per Stop.
|
|
759
597
|
* `managed` is the complete current claim set, so unwire removes exactly those.
|
|
760
598
|
*
|
|
761
|
-
* `scope` is accepted and unused, exactly as in the Python.
|
|
762
|
-
*
|
|
763
599
|
* `cfgDir` is the install's own dir — what the hooks carry as `--root` and under which
|
|
764
600
|
* `memory/` lives. It is NOT always `dirname(p)`: Bob's global settings file is nested
|
|
765
601
|
* (`~/.bob/settings/settings.json`), and deriving the root from it pointed every global Bob
|
|
766
602
|
* hook at `~/.bob/settings` — memory learned where nothing reads it, excludes missed, and a
|
|
767
603
|
* global that never stood down for a project install.
|
|
768
604
|
*/
|
|
769
|
-
export function mergeClaudeSettings(p,
|
|
605
|
+
export function mergeClaudeSettings(p, priorHooks = null, hookOpts = {},
|
|
770
606
|
doctrines = null, excluded = [], host = 'claude', cfgDir = path.dirname(p)) {
|
|
771
607
|
const prior = (priorHooks || []).filter(isDict);
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
try {
|
|
776
|
-
const [loaded, hc] = readJsonc(readText(p));
|
|
777
|
-
hadComments = hc;
|
|
778
|
-
if (loaded === null) {
|
|
779
|
-
process.stderr.write(`[geneseed] ${path.basename(p)} is not valid JSON — NOT `
|
|
780
|
-
+ 'rewriting it (fix the syntax, then re-run). Hooks were not wired.\n');
|
|
781
|
-
return [p, prior];
|
|
782
|
-
}
|
|
783
|
-
if (isDict(loaded)) config = loaded;
|
|
784
|
-
} catch (e) {
|
|
785
|
-
if (!isOsError(e)) throw e;
|
|
786
|
-
}
|
|
787
|
-
}
|
|
608
|
+
const loaded = readSettingsForMerge(p, 'Hooks were not wired.');
|
|
609
|
+
if (!loaded) return [p, prior];
|
|
610
|
+
const [config, hadComments] = loaded;
|
|
788
611
|
let hooks = get(config, 'hooks');
|
|
789
612
|
if (!isDict(hooks)) hooks = {};
|
|
790
613
|
const canonical = claudeHookGroups(cfgDir, hookOpts, doctrines, excluded, host);
|
|
@@ -827,17 +650,9 @@ export function mergeClaudeSettings(p, _scope = 'global', priorHooks = null, hoo
|
|
|
827
650
|
+ 'adapters/claude-code/settings.json.\n');
|
|
828
651
|
return [p, prior];
|
|
829
652
|
}
|
|
830
|
-
//
|
|
831
|
-
//
|
|
832
|
-
|
|
833
|
-
// stays green, and it stays green because there is nothing to detect — recorded the way
|
|
834
|
-
// `themed_rel` is, so "the gate cannot see it" and "there is nothing to see" keep their
|
|
835
|
-
// distance. NOT the way `lstripNewlines` is: that one looked unobservable for two phases
|
|
836
|
-
// and turned out to be mutation M7, killed by `golden.mjs --idempotent` through
|
|
837
|
-
// `managedBlockWrite`'s `updated` branch, which only a RE-emit reaches.
|
|
838
|
-
if (Object.keys(hooks).length) config.hooks = hooks;
|
|
839
|
-
else delete config.hooks;
|
|
840
|
-
mkdirSync(path.dirname(p), { recursive: true });
|
|
653
|
+
// Never empty here: `canonical` always carries at least three events, and every one of them
|
|
654
|
+
// was just written into `hooks`.
|
|
655
|
+
config.hooks = hooks;
|
|
841
656
|
atomicWriteJson(p, config);
|
|
842
657
|
return [p, managedNow];
|
|
843
658
|
}
|
|
@@ -849,16 +664,11 @@ export function mergeClaudeSettings(p, _scope = 'global', priorHooks = null, hoo
|
|
|
849
664
|
* can report reality instead of assuming success.
|
|
850
665
|
*/
|
|
851
666
|
export function unwireClaudeSettings(p, added) {
|
|
852
|
-
if (!
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
} catch (e) {
|
|
858
|
-
if (!isOsError(e)) throw e;
|
|
859
|
-
return false;
|
|
860
|
-
}
|
|
861
|
-
if (hadComments || !isDict(loaded)) return false;
|
|
667
|
+
if (!added || !added.length) return false;
|
|
668
|
+
// Every state but a comment-free object declines: an unwire never rewrites what it could not
|
|
669
|
+
// read whole, and a commented file would lose its comments.
|
|
670
|
+
const { state, data: loaded, hadComments } = loadJsonObject(p);
|
|
671
|
+
if (state !== 'ok' || hadComments) return false;
|
|
862
672
|
const hooks = get(loaded, 'hooks');
|
|
863
673
|
if (!isDict(hooks)) return false;
|
|
864
674
|
for (const rec of added) {
|
|
@@ -889,22 +699,9 @@ export function unwireClaudeSettings(p, added) {
|
|
|
889
699
|
export function wireClaudeExcludes(p, excludes) {
|
|
890
700
|
const want = (excludes || []).filter(Boolean);
|
|
891
701
|
if (!want.length) return [];
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
try {
|
|
896
|
-
const [loaded, hc] = readJsonc(readText(p));
|
|
897
|
-
hadComments = hc;
|
|
898
|
-
if (loaded === null) {
|
|
899
|
-
process.stderr.write(`[geneseed] ${path.basename(p)} is not valid JSON — NOT `
|
|
900
|
-
+ 'rewriting it (fix the syntax, then re-run). Excludes were not wired.\n');
|
|
901
|
-
return [];
|
|
902
|
-
}
|
|
903
|
-
if (isDict(loaded)) config = loaded;
|
|
904
|
-
} catch (e) {
|
|
905
|
-
if (!isOsError(e)) throw e;
|
|
906
|
-
}
|
|
907
|
-
}
|
|
702
|
+
const loaded = readSettingsForMerge(p, 'Excludes were not wired.');
|
|
703
|
+
if (!loaded) return [];
|
|
704
|
+
const [config, hadComments] = loaded;
|
|
908
705
|
let cur = get(config, 'claudeMdExcludes');
|
|
909
706
|
if (!Array.isArray(cur)) cur = [];
|
|
910
707
|
const added = want.filter((e) => indexOfDeepEqual(cur, e) < 0);
|
|
@@ -917,7 +714,6 @@ export function wireClaudeExcludes(p, excludes) {
|
|
|
917
714
|
}
|
|
918
715
|
cur.push(...added);
|
|
919
716
|
config.claudeMdExcludes = cur;
|
|
920
|
-
mkdirSync(path.dirname(p), { recursive: true });
|
|
921
717
|
try {
|
|
922
718
|
atomicWriteJson(p, config);
|
|
923
719
|
} catch (e) {
|
|
@@ -932,16 +728,9 @@ export function wireClaudeExcludes(p, excludes) {
|
|
|
932
728
|
|
|
933
729
|
/** `_build_settings._unwire_claude_excludes`. */
|
|
934
730
|
export function unwireClaudeExcludes(p, excludes) {
|
|
935
|
-
if (!
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
try {
|
|
939
|
-
[loaded, hadComments] = readJsonc(readText(p));
|
|
940
|
-
} catch (e) {
|
|
941
|
-
if (!isOsError(e)) throw e;
|
|
942
|
-
return;
|
|
943
|
-
}
|
|
944
|
-
if (hadComments || !isDict(loaded)) return;
|
|
731
|
+
if (!excludes || !excludes.length) return;
|
|
732
|
+
const { state, data: loaded, hadComments } = loadJsonObject(p);
|
|
733
|
+
if (state !== 'ok' || hadComments) return;
|
|
945
734
|
const cur = get(loaded, 'claudeMdExcludes');
|
|
946
735
|
if (!Array.isArray(cur)) return;
|
|
947
736
|
for (const e of excludes) {
|
|
@@ -966,71 +755,6 @@ export function unwireClaudeExcludes(p, excludes) {
|
|
|
966
755
|
// silently leave live hooks behind in every config emitted before the shim landed.
|
|
967
756
|
export const GENESEED_HOOK_SNIFF = ['harness.py', SHIM_MARK];
|
|
968
757
|
|
|
969
|
-
// P10d. `GENESEED_HOOK_SNIFF` answers "is this hook Geneseed's?". A MIGRATION needs the other
|
|
970
|
-
// question — "is this Geneseed's OLD one?" — and the two are not the same sniff.
|
|
971
|
-
//
|
|
972
|
-
// THREE SHAPES ARE IN THE WILD, AND TWO OF THEM ARE IDENTICAL IN THE SETTINGS FILE:
|
|
973
|
-
// 1. legacy-direct the command itself names `harness.py` (pre-shim, P0 and earlier)
|
|
974
|
-
// 2. shim-python the command names the shim; the shim BODY runs python + harness.py
|
|
975
|
-
// 3. shim-node the command names the shim; the shim BODY runs node + the .mjs entry
|
|
976
|
-
//
|
|
977
|
-
// 2 and 3 differ ONLY inside the shim body. A classifier reading the host config alone would
|
|
978
|
-
// call a fully-unmigrated machine "already migrated" — silently — and `migrate` would no-op on
|
|
979
|
-
// exactly the installs it exists for. The shim body is a REQUIRED input, not a refinement.
|
|
980
|
-
const SHIM_ENTRY_MARK = 'geneseed-hook.mjs';
|
|
981
|
-
|
|
982
|
-
/**
|
|
983
|
-
* `_build_settings._migrate_shape` — 'legacy' | 'current' | 'none'.
|
|
984
|
-
*
|
|
985
|
-
* PURE — and currently reached only END TO END, by the `migrate/a-node-baked-shim-reads-as-current`
|
|
986
|
-
* cell. No unit corpus drives it directly, which is worth knowing before trusting it.
|
|
987
|
-
* 'none' is not a fault: a user's own settings.json may carry three hand-written hooks
|
|
988
|
-
* and no Geneseed entry. That must read as
|
|
989
|
-
* "nothing to migrate" rather than "unrecognised", or `migrate` refuses on the commonest
|
|
990
|
-
* config on any machine.
|
|
991
|
-
*/
|
|
992
|
-
export function migrateShape(commands, shimBody) {
|
|
993
|
-
if (commands.some((c) => c.includes('harness.py'))) return 'legacy';
|
|
994
|
-
if (commands.some((c) => c.includes(SHIM_MARK))) {
|
|
995
|
-
return shimBody.includes(SHIM_ENTRY_MARK) ? 'current' : 'legacy';
|
|
996
|
-
}
|
|
997
|
-
return 'none';
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
/**
|
|
1001
|
-
* `_build_settings._autostart_paths` — where a hand-written web-daemon autostart entry lives.
|
|
1002
|
-
*
|
|
1003
|
-
* NOTHING IN THIS REPOSITORY HAS EVER WRITTEN ONE — the install guide tells the user to create them
|
|
1004
|
-
* by hand, and no .py/.mjs/.sh/.cmd in the tree contains `vbs`, `LaunchAgents` or `plist`. So
|
|
1005
|
-
* `migrate` REPORTS a stale one and never rewrites it, on the rule `settingsIntegrityCheck`
|
|
1006
|
-
* already states: an entry the manifest does not claim is "possibly user-authored; left alone".
|
|
1007
|
-
*
|
|
1008
|
-
* Both platforms' paths on both platforms, deliberately — the scan is a read that misses
|
|
1009
|
-
* harmlessly, and returning only the host platform's would make the macOS arm unreachable
|
|
1010
|
-
* from the Windows machine this port is developed on: an ungated branch dressed as a fork.
|
|
1011
|
-
*/
|
|
1012
|
-
export function autostartPaths() {
|
|
1013
|
-
const home = os.homedir();
|
|
1014
|
-
return [
|
|
1015
|
-
path.join(home, 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu',
|
|
1016
|
-
'Programs', 'Startup', 'geneseed-web.vbs'),
|
|
1017
|
-
path.join(home, 'Library', 'LaunchAgents', 'dev.geneseed.web.plist'),
|
|
1018
|
-
];
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
/**
|
|
1022
|
-
* `_build_settings._autostart_stale` — the entry names a Geneseed launcher somewhere else.
|
|
1023
|
-
*
|
|
1024
|
-
* Weak in one direction and strict in the other on purpose: it fires only when the file
|
|
1025
|
-
* mentions geneseed at all (an unrelated Startup entry is never named), and clears only when
|
|
1026
|
-
* the CURRENT root appears verbatim. A false positive costs one printed line; a false negative
|
|
1027
|
-
* leaves a login task pointing at a checkout npm is about to make stale.
|
|
1028
|
-
*/
|
|
1029
|
-
export function autostartStale(text, root) {
|
|
1030
|
-
if (!text.toLowerCase().includes('geneseed')) return false;
|
|
1031
|
-
return !text.includes(root) && !text.includes(root.replaceAll('\\', '/'));
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
758
|
/** `_build_settings._settings_hook_groups` — flatten `hooks` to [event, group] pairs. */
|
|
1035
759
|
function settingsHookGroups(loaded) {
|
|
1036
760
|
const hooks = get(loaded, 'hooks');
|
|
@@ -1060,22 +784,19 @@ export function settingsIntegrityCheck(p, managed, expect = 'present') {
|
|
|
1060
784
|
return problems;
|
|
1061
785
|
};
|
|
1062
786
|
const m = isDict(managed) ? managed : {};
|
|
1063
|
-
|
|
787
|
+
const { state, data: loaded, error } = loadJsonObject(p);
|
|
788
|
+
if (state === 'absent') {
|
|
1064
789
|
if (expect === 'present') {
|
|
1065
790
|
problems.push(`${p}: file does not exist, but hooks/excludes were supposed to be `
|
|
1066
791
|
+ 'wired into it');
|
|
1067
792
|
}
|
|
1068
793
|
return flush();
|
|
1069
794
|
}
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
[loaded] = readJsonc(readText(p));
|
|
1073
|
-
} catch (e) {
|
|
1074
|
-
if (!isOsError(e)) throw e;
|
|
1075
|
-
problems.push(`${p}: could not read the file (${e.message})`);
|
|
795
|
+
if (state === 'unreadable') {
|
|
796
|
+
problems.push(`${p}: could not read the file (${error.message})`);
|
|
1076
797
|
return flush();
|
|
1077
798
|
}
|
|
1078
|
-
if (
|
|
799
|
+
if (state !== 'ok') {
|
|
1079
800
|
problems.push(`${p}: not a JSON object — cannot verify hooks/excludes`);
|
|
1080
801
|
return flush();
|
|
1081
802
|
}
|